Added proper notification system
This commit is contained in:
parent
bced13315d
commit
63ea51113f
125
VoiceInk/Notifications/AppNotificationView.swift
Normal file
125
VoiceInk/Notifications/AppNotificationView.swift
Normal file
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
107
VoiceInk/Notifications/NotificationManager.swift
Normal file
107
VoiceInk/Notifications/NotificationManager.swift
Normal file
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user