From 63ea51113fd7f64bce6f91cbe4352912ba253731 Mon Sep 17 00:00:00 2001 From: Beingpax Date: Mon, 16 Jun 2025 11:36:40 +0545 Subject: [PATCH] Added proper notification system --- .../Notifications/AppNotificationView.swift | 125 ++++++++++++++++++ .../Notifications/NotificationManager.swift | 107 +++++++++++++++ VoiceInk/Recorder.swift | 16 +++ VoiceInk/Whisper/WhisperState.swift | 11 +- 4 files changed, 253 insertions(+), 6 deletions(-) create mode 100644 VoiceInk/Notifications/AppNotificationView.swift create mode 100644 VoiceInk/Notifications/NotificationManager.swift diff --git a/VoiceInk/Notifications/AppNotificationView.swift b/VoiceInk/Notifications/AppNotificationView.swift new file mode 100644 index 0000000..1e37846 --- /dev/null +++ b/VoiceInk/Notifications/AppNotificationView.swift @@ -0,0 +1,125 @@ +import SwiftUI + +struct AppNotificationView: View { + let title: String + let message: String + let type: NotificationType + let duration: TimeInterval + let onClose: () -> Void + + @State private var progress: Double = 1.0 + @State private var timer: Timer? + + enum NotificationType { + case error + case warning + case info + case success + + var iconName: String { + switch self { + case .error: return "xmark.octagon.fill" + case .warning: return "exclamationmark.triangle.fill" + case .info: return "info.circle.fill" + case .success: return "checkmark.circle.fill" + } + } + + var iconColor: Color { + switch self { + case .error: return .red + case .warning: return .yellow + case .info: return .blue + case .success: return .green + } + } + } + + var body: some View { + ZStack { + // Main content + HStack(alignment: .center, spacing: 12) { + // App icon on the left side + if let appIcon = NSApp.applicationIconImage { + Image(nsImage: appIcon) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 50, height: 50) + .cornerRadius(10) + } + + VStack(alignment: .leading, spacing: 3) { + Text(title) + .fontWeight(.bold) + .font(.system(size: 13)) + .lineLimit(1) + Text(message) + .font(.system(size: 11)) + .fontWeight(.semibold) + .opacity(0.9) + .lineLimit(2) + .multilineTextAlignment(.leading) + } + + Spacer() + } + .padding(12) + .frame(height: 60) // Fixed compact height + + // Close button overlaid on top-right + VStack { + HStack { + Spacer() + Button(action: onClose) { + Image(systemName: "xmark") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.secondary) + } + .buttonStyle(PlainButtonStyle()) + .frame(width: 16, height: 16) + } + Spacer() + } + .padding(8) + } + .frame(minWidth: 320, maxWidth: 420) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(nsColor: .controlBackgroundColor).opacity(0.95)) + ) + .overlay( + // Progress bar at the bottom + VStack { + Spacer() + GeometryReader { geometry in + Rectangle() + .fill(Color.accentColor.opacity(0.6)) + .frame(width: geometry.size.width * progress, height: 2) + .animation(.linear(duration: 0.1), value: progress) + } + .frame(height: 2) + } + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + ) + .onAppear { + startProgressTimer() + } + .onDisappear { + timer?.invalidate() + } + } + + private func startProgressTimer() { + let updateInterval: TimeInterval = 0.1 + let totalSteps = duration / updateInterval + let stepDecrement = 1.0 / totalSteps + + timer = Timer.scheduledTimer(withTimeInterval: updateInterval, repeats: true) { _ in + if progress > 0 { + progress -= stepDecrement + } else { + timer?.invalidate() + } + } + } +} diff --git a/VoiceInk/Notifications/NotificationManager.swift b/VoiceInk/Notifications/NotificationManager.swift new file mode 100644 index 0000000..afe5baf --- /dev/null +++ b/VoiceInk/Notifications/NotificationManager.swift @@ -0,0 +1,107 @@ +import SwiftUI +import AppKit + +class NotificationManager { + static let shared = NotificationManager() + + private var notificationWindow: NSPanel? + private var dismissTimer: Timer? + + private init() {} + + @MainActor + func showNotification( + title: String, + message: String, + type: AppNotificationView.NotificationType, + duration: TimeInterval = 8.0 + ) { + // If a notification is already showing, dismiss it before showing the new one. + if notificationWindow != nil { + dismissNotification() + } + + let notificationView = AppNotificationView( + title: title, + message: message, + type: type, + duration: duration, + onClose: { [weak self] in + Task { @MainActor in + self?.dismissNotification() + } + } + ) + let hostingController = NSHostingController(rootView: notificationView) + let size = hostingController.view.fittingSize + + let panel = NSPanel( + contentRect: NSRect(origin: .zero, size: size), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + panel.contentView = hostingController.view + panel.isFloatingPanel = true + panel.level = .mainMenu + panel.backgroundColor = .clear + panel.hasShadow = false + panel.isMovableByWindowBackground = false + + // Position at final location and start with fade animation + positionWindow(panel) + panel.alphaValue = 0 + panel.makeKeyAndOrderFront(nil) + + self.notificationWindow = panel + + // Simple fade-in animation + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.3 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + panel.animator().alphaValue = 1 + }) + + dismissTimer?.invalidate() + dismissTimer = Timer.scheduledTimer( + withTimeInterval: duration, + repeats: false + ) { [weak self] _ in + self?.dismissNotification() + } + } + + @MainActor + private func positionWindow(_ window: NSWindow) { + guard let screen = NSScreen.main else { return } + let screenRect = screen.visibleFrame + let windowRect = window.frame + + let x = screenRect.maxX - windowRect.width - 20 // 20px padding from the right + let y = screenRect.maxY - windowRect.height - 20 // 20px padding from the top + + window.setFrameOrigin(NSPoint(x: x, y: y)) + } + + + + + + @MainActor + func dismissNotification() { + guard let window = notificationWindow else { return } + + dismissTimer?.invalidate() + dismissTimer = nil + + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.2 + context.timingFunction = CAMediaTimingFunction(name: .easeIn) + window.animator().alphaValue = 0 + }, completionHandler: { + window.close() + self.notificationWindow = nil + }) + } +} \ No newline at end of file diff --git a/VoiceInk/Recorder.swift b/VoiceInk/Recorder.swift index 216b54e..30c1ad4 100644 --- a/VoiceInk/Recorder.swift +++ b/VoiceInk/Recorder.swift @@ -62,6 +62,22 @@ class Recorder: ObservableObject { func startRecording(toOutputFile url: URL) async throws { deviceManager.isRecordingActive = true + let currentDeviceID = deviceManager.getCurrentDevice() + let lastDeviceID = UserDefaults.standard.string(forKey: "lastUsedMicrophoneDeviceID") + + if String(currentDeviceID) != lastDeviceID { + if let deviceName = deviceManager.availableDevices.first(where: { $0.id == currentDeviceID })?.name { + await MainActor.run { + NotificationManager.shared.showNotification( + title: "Audio Input Source", + message: "Using: \(deviceName)", + type: .info + ) + } + } + } + UserDefaults.standard.set(String(currentDeviceID), forKey: "lastUsedMicrophoneDeviceID") + Task { await mediaController.muteSystemAudio() } diff --git a/VoiceInk/Whisper/WhisperState.swift b/VoiceInk/Whisper/WhisperState.swift index 9d7a853..0ea2646 100644 --- a/VoiceInk/Whisper/WhisperState.swift +++ b/VoiceInk/Whisper/WhisperState.swift @@ -148,12 +148,11 @@ class WhisperState: NSObject, ObservableObject, AVAudioRecorderDelegate { } else { guard currentTranscriptionModel != nil else { await MainActor.run { - let alert = NSAlert() - alert.messageText = "No AI Model Selected" - alert.informativeText = "Please select a default AI model in AI Models tab before recording." - alert.alertStyle = .warning - alert.addButton(withTitle: "OK") - alert.runModal() + NotificationManager.shared.showNotification( + title: "No AI Model Selected", + message: "Please select a default AI model before recording.", + type: .error + ) } return }