diff --git a/Murmur.xcodeproj/project.pbxproj b/Murmur.xcodeproj/project.pbxproj index 72f4642da..8f24bddd0 100644 --- a/Murmur.xcodeproj/project.pbxproj +++ b/Murmur.xcodeproj/project.pbxproj @@ -55,6 +55,8 @@ A100091A /* FloatingPanelController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A100090A /* FloatingPanelController.swift */; }; A100091B /* AuroraRecordingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A100090B /* AuroraRecordingView.swift */; }; A100091C /* AuroraIdleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A100090C /* AuroraIdleView.swift */; }; + A100091D /* AuroraProcessingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A100090D /* AuroraProcessingView.swift */; }; + A100091E /* AuroraSuccessView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A100090E /* AuroraSuccessView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -109,6 +111,8 @@ A100090A /* FloatingPanelController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingPanelController.swift; sourceTree = ""; }; A100090B /* AuroraRecordingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuroraRecordingView.swift; sourceTree = ""; }; A100090C /* AuroraIdleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuroraIdleView.swift; sourceTree = ""; }; + A100090D /* AuroraProcessingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuroraProcessingView.swift; sourceTree = ""; }; + A100090E /* AuroraSuccessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuroraSuccessView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -212,6 +216,8 @@ A1000908 /* ReviewTrayView.swift */, A100090B /* AuroraRecordingView.swift */, A100090C /* AuroraIdleView.swift */, + A100090D /* AuroraProcessingView.swift */, + A100090E /* AuroraSuccessView.swift */, ); path = Components; sourceTree = ""; @@ -399,6 +405,8 @@ A100091A /* FloatingPanelController.swift in Sources */, A100091B /* AuroraRecordingView.swift in Sources */, A100091C /* AuroraIdleView.swift in Sources */, + A100091D /* AuroraProcessingView.swift in Sources */, + A100091E /* AuroraSuccessView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Murmur/Core/TranscriptionTaskManager.swift b/Murmur/Core/TranscriptionTaskManager.swift index cce12231e..f734ce27c 100644 --- a/Murmur/Core/TranscriptionTaskManager.swift +++ b/Murmur/Core/TranscriptionTaskManager.swift @@ -3,32 +3,37 @@ import UserNotifications // MARK: - Display Status for UI (Goal-Gradient Effect) // Users are more motivated when they can see visible progress +// Simplified to 4 user-focused phases for cognitive clarity enum DisplayStatus: Equatable { case idle - case preparing // 0-10%: Loading audio files - case transcribing(progress: Double) // 10-70%: Active transcription - case extractingActionItems // 70-90%: AI processing - case saving // 90-95%: Writing to disk - case transcriptSaved // Complete but no action items - case pendingReview(itemCount: Int) // Waiting for user to review action items - case completed(taskCount: Int) // Complete with action items - case failed(message: String) // Error state + + // Processing phases (4 user-focused steps) + case gettingReady // 0-15%: Loading audio, initial setup + case transcribing(progress: Double) // 15-75%: Active transcription + case findingActionItems // 75-95%: AI analysis + case finishing // 95-100%: Saving, final steps + + // Completion states + case transcriptSaved // Complete but no action items + case pendingReview(itemCount: Int) // Waiting for user to review action items + case completed(taskCount: Int) // Complete with action items + case failed(message: String) // Error state /// Computed progress value (0.0 to 1.0) for UI progress bar var progress: Double { switch self { case .idle: return 0.0 - case .preparing: + case .gettingReady: return 0.10 case .transcribing(let p): - // Map transcription progress (0-1) to (0.10-0.70) - return 0.10 + (p * 0.60) - case .extractingActionItems: - return 0.80 - case .saving: - return 0.95 + // Map transcription progress (0-1) to (0.15-0.75) + return 0.15 + (p * 0.60) + case .findingActionItems: + return 0.85 + case .finishing: + return 0.97 case .transcriptSaved, .pendingReview, .completed: return 1.0 case .failed: @@ -36,24 +41,23 @@ enum DisplayStatus: Equatable { } } - /// User-friendly status text + /// User-friendly status text (outcome-oriented, not technical) var statusText: String { switch self { case .idle: return "Ready" - case .preparing: - return "Preparing..." - case .transcribing(let p): - let pct = Int(p * 100) - return "Transcribing (\(pct)%)" - case .extractingActionItems: - return "Extracting tasks..." - case .saving: - return "Saving..." + case .gettingReady: + return "Getting ready..." + case .transcribing: + return "Transcribing..." + case .findingActionItems: + return "Finding action items..." + case .finishing: + return "Almost done..." case .transcriptSaved: return "Saved" case .pendingReview(let count): - return "\(count) task\(count == 1 ? "" : "s") to review" + return "\(count) item\(count == 1 ? "" : "s") to review" case .completed(let count): return "\(count) task\(count == 1 ? "" : "s") added" case .failed(let message): @@ -66,7 +70,7 @@ enum DisplayStatus: Equatable { switch self { case .idle: return "circle" - case .preparing, .transcribing, .extractingActionItems, .saving: + case .gettingReady, .transcribing, .findingActionItems, .finishing: return "arrow.triangle.2.circlepath" case .transcriptSaved: return "checkmark.circle.fill" @@ -79,10 +83,10 @@ enum DisplayStatus: Equatable { } } - /// Whether this is a "processing" state (show progress bar) + /// Whether this is a "processing" state (show progress indicator) var isProcessing: Bool { switch self { - case .preparing, .transcribing, .extractingActionItems, .saving: + case .gettingReady, .transcribing, .findingActionItems, .finishing: return true default: return false @@ -147,10 +151,10 @@ class TranscriptionTaskManager: ObservableObject { func startTranscription(micURL: URL, systemURL: URL?, outputFolder: URL) { let task = TranscriptionTask(micURL: micURL, systemURL: systemURL, outputFolder: outputFolder) - // Increment active count and set preparing status immediately (Goal-Gradient Effect) + // Increment active count and set initial status immediately (Goal-Gradient Effect) DispatchQueue.main.async { self.activeCount += 1 - self.displayStatus = .preparing + self.displayStatus = .gettingReady } print("📝 Starting transcription task \(task.id) (active: \(activeCount))") @@ -268,7 +272,7 @@ class TranscriptionTaskManager: ObservableObject { // Phase 2: Save transcript based on provider await MainActor.run { - self.displayStatus = .saving + self.displayStatus = .finishing } let transcriptURL: URL? @@ -336,7 +340,7 @@ class TranscriptionTaskManager: ObservableObject { if !geminiApiKey.isEmpty && !transcriptionResult.allSpeakerIds.isEmpty { await MainActor.run { - self.displayStatus = .extractingActionItems // Reuse status for "AI processing" + self.displayStatus = .findingActionItems // AI processing phase } print("📋 Phase 2: Identifying \(transcriptionResult.allSpeakerIds.count) speakers with Gemini...") @@ -372,7 +376,7 @@ class TranscriptionTaskManager: ObservableObject { // Phase 3: Save transcript WITH speaker names await MainActor.run { - self.displayStatus = .saving + self.displayStatus = .finishing } guard let transcriptURL = TranscriptSaver.saveRichAssemblyAITranscript( @@ -415,7 +419,7 @@ class TranscriptionTaskManager: ObservableObject { // Increment retry count await MainActor.run { failedTranscriptionManager.incrementRetryCount(id: failedId) - self.displayStatus = .preparing + self.displayStatus = .gettingReady } // Get output folder (same as original) @@ -598,9 +602,9 @@ class TranscriptionTaskManager: ObservableObject { } } - // Update status to show we're extracting action items + // Update status to show we're finding action items await MainActor.run { - self.displayStatus = .extractingActionItems + self.displayStatus = .findingActionItems } do { diff --git a/Murmur/TranscriptedApp.swift b/Murmur/TranscriptedApp.swift index f6bfc2cdf..d172b0b2a 100644 --- a/Murmur/TranscriptedApp.swift +++ b/Murmur/TranscriptedApp.swift @@ -29,6 +29,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { var onboardingWindowController: OnboardingWindowController? func applicationDidFinishLaunching(_ notification: Notification) { + // Configure tooltip delay to 1 second + UserDefaults.standard.set(1000, forKey: "NSInitialToolTipDelay") + NSApp.setActivationPolicy(.accessory) // Check if onboarding is needed diff --git a/Murmur/UI/FloatingPanel/Components/AuroraIdleView.swift b/Murmur/UI/FloatingPanel/Components/AuroraIdleView.swift index 3160fee50..f17332101 100644 --- a/Murmur/UI/FloatingPanel/Components/AuroraIdleView.swift +++ b/Murmur/UI/FloatingPanel/Components/AuroraIdleView.swift @@ -97,6 +97,7 @@ struct AuroraIdleView: View { .scaleEffect(isRecordHovered ? 1.1 : 1.0) } .buttonStyle(PlainButtonStyle()) + .floatingTooltip("Record") .onHover { hovering in withAnimation(.spring(response: 0.15, dampingFraction: 0.8)) { isRecordHovered = hovering @@ -126,6 +127,7 @@ struct AuroraIdleView: View { .scaleEffect(isFilesHovered ? 1.1 : 1.0) } .buttonStyle(PlainButtonStyle()) + .floatingTooltip("Transcripts") .onHover { hovering in withAnimation(.spring(response: 0.15, dampingFraction: 0.8)) { isFilesHovered = hovering diff --git a/Murmur/UI/FloatingPanel/Components/AuroraProcessingView.swift b/Murmur/UI/FloatingPanel/Components/AuroraProcessingView.swift new file mode 100644 index 000000000..ed334d495 --- /dev/null +++ b/Murmur/UI/FloatingPanel/Components/AuroraProcessingView.swift @@ -0,0 +1,362 @@ +import SwiftUI + +// MARK: - Aurora Processing View +/// Enhanced processing view with progress-based aurora intensity +/// Always expanded (200x44) - no collapse for processing since users want to see status +/// Progress shown through brightness and animation speed, not color changes + +@available(macOS 26.0, *) +struct AuroraProcessingView: View { + let status: DisplayStatus + let recordingDuration: TimeInterval + + @Environment(\.accessibilityReduceMotion) var reduceMotion + + // Celebration bloom effect + @State private var showCelebrationBloom = true + + // Warning state tracking + @State private var stepStartTime: Date = Date() + @State private var stepElapsedTime: TimeInterval = 0 + @State private var timerCancellable: Timer? + + // Fixed expanded dimensions (no collapse for processing - users want to see status) + private let width: CGFloat = 200 + private let height: CGFloat = 44 + + init(status: DisplayStatus, recordingDuration: TimeInterval = 0) { + self.status = status + self.recordingDuration = recordingDuration + } + + var body: some View { + ZStack { + // Aurora background with progress-based intensity + auroraBackground + .clipShape(Capsule()) + .brightness(showCelebrationBloom ? 0.3 : 0) // Celebration flash + + // Always show expanded content (no collapse for processing) + expandedContent + } + .frame(width: width, height: height) + .onAppear { + // Celebration bloom animation + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { + withAnimation(.easeOut(duration: 0.2)) { + showCelebrationBloom = false + } + } + + // Start tracking step elapsed time + startStepTimer() + } + .onDisappear { + stopStepTimer() + } + .onChange(of: status) { _, _ in + // Reset step timer when status changes + stepStartTime = Date() + stepElapsedTime = 0 + } + .accessibilityElement(children: .combine) + .accessibilityLabel("Processing: \(status.statusText)") + } + + // MARK: - Progress-Based Aurora Properties + + /// Aurora opacity based on progress phase (dim → normal → bright) + private var auroraOpacity: Double { + let progress = status.progress + if progress < 0.15 { return 0.45 } // Getting ready: dim + else if progress < 0.75 { return 0.60 } // Transcribing: normal + else { return 0.75 } // Finding items/finishing: bright + } + + /// Aurora animation speed based on progress phase (slow → medium → fast) + private var auroraPulseSpeed: Double { + let progress = status.progress + if reduceMotion { return 0 } + if progress < 0.15 { return 0.03 } // 4s cycle (slow breathe) + else if progress < 0.75 { return 0.08 } // 2s cycle (steady pulse) + else { return 0.15 } // 1s cycle (active pulse) + } + + /// Warning text for long-running steps + private var warningText: String? { + if stepElapsedTime > 120 { + return "Taking longer than usual. Hang tight." + } else if stepElapsedTime > 90 { + return "Still working on this one..." + } + return nil + } + + // MARK: - Content + + private var expandedContent: some View { + HStack(spacing: 0) { + // Duration display (left) + Text(formatDuration(recordingDuration)) + .font(.system(size: 15, weight: .semibold, design: .monospaced)) + .foregroundColor(.panelTextPrimary) + .frame(width: 60, alignment: .center) + + Spacer() + + // Status text (center) + VStack(spacing: 2) { + Text(status.statusText) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.panelTextPrimary) + .lineLimit(1) + + // Warning text (if applicable) + if let warning = warningText { + Text(warning) + .font(.system(size: 10, weight: .regular)) + .foregroundColor(.statusWarningMuted) + .lineLimit(1) + } + } + + Spacer() + + // Estimated time (right) - optional + if let estimate = estimatedTimeRemaining { + Text(estimate) + .font(.system(size: 11, weight: .regular)) + .foregroundColor(.panelTextMuted) + .frame(width: 50, alignment: .center) + } else { + Spacer() + .frame(width: 50) + } + } + .padding(.horizontal, 12) + } + + // MARK: - Estimated Time + + private var estimatedTimeRemaining: String? { + guard recordingDuration > 0 else { return nil } + + // Rough estimate: processing takes ~10% of recording time + let estimatedTotal = recordingDuration * 0.1 + let currentProgress = status.progress + let elapsedFraction = currentProgress + let estimatedRemaining = estimatedTotal * (1 - elapsedFraction) + + if estimatedRemaining < 10 { return nil } // Don't show if almost done + if estimatedRemaining < 60 { return "~\(Int(estimatedRemaining))s" } + return "~\(Int(estimatedRemaining / 60)) min" + } + + // MARK: - Aurora Background + + private var auroraBackground: some View { + ZStack { + // Base dark background + Color.panelCharcoal + + // CORAL FOG: Warm tones, biased LEFT + fogLayer( + color: Color.auroraCoral, + secondaryColor: Color.auroraCoralLight, + blurRadius: 8, + opacity: auroraOpacity, + orbCount: 2, + phaseOffset: 0, + positionBias: -0.8 + ) + .blendMode(.plusLighter) + + // TEAL FOG: Cool tones, biased RIGHT + fogLayer( + color: Color.auroraTeal, + secondaryColor: Color.auroraTealLight, + blurRadius: 8, + opacity: auroraOpacity * 0.9, + orbCount: 2, + phaseOffset: .pi / 3, + positionBias: 0.8 + ) + .blendMode(.plusLighter) + } + .overlay( + // Subtle capsule border + Capsule() + .strokeBorder( + LinearGradient( + colors: [ + Color.auroraCoral.opacity(0.25), + Color.auroraTeal.opacity(0.25) + ], + startPoint: .leading, + endPoint: .trailing + ), + lineWidth: 1 + ) + ) + // Subtle outer glow + .shadow(color: Color.auroraCoral.opacity(0.15), radius: 12, x: -3, y: 0) + .shadow(color: Color.auroraTeal.opacity(0.12), radius: 12, x: 3, y: 0) + } + + // MARK: - Fog Layer + + private func fogLayer( + color: Color, + secondaryColor: Color, + blurRadius: CGFloat, + opacity: Double, + orbCount: Int, + phaseOffset: Double, + positionBias: CGFloat + ) -> some View { + TimelineView(.animation(minimumInterval: 1/30)) { timeline in + Canvas { context, size in + let time = timeline.date.timeIntervalSinceReferenceDate + + drawFogOrbs( + context: &context, + size: size, + time: time, + color: color, + secondaryColor: secondaryColor, + orbCount: orbCount, + phaseOffset: phaseOffset, + opacity: opacity, + positionBias: positionBias + ) + } + } + .blur(radius: blurRadius) + } + + // MARK: - Pseudo-noise Function + + private func pseudoNoise(time: Double, phase: Double, seed: Double) -> Double { + let t = time + seed + let p = phase + + let wave1 = sin(t * 1.0 + p) + let wave2 = sin(t * 1.7 + p * 2.3) * 0.5 + let wave3 = sin(t * 0.3 + p * 0.7) * 0.3 + let wave4 = sin(t * 2.3 + p * 1.1) * 0.2 + + return (wave1 + wave2 + wave3 + wave4) / 2.0 + } + + // MARK: - Draw Fog Orbs + + private func drawFogOrbs( + context: inout GraphicsContext, + size: CGSize, + time: Double, + color: Color, + secondaryColor: Color, + orbCount: Int, + phaseOffset: Double, + opacity: Double, + positionBias: CGFloat + ) { + let width = size.width + let height = size.height + let centerY = height / 2 + + // Progress-based animation speed + let slowTime = time * auroraPulseSpeed + + // Fixed intensity boost (no audio reactivity in processing) + let fixedBoost = 0.55 + + let biasOffset = positionBias * width * 0.15 + + for i in 0.. String { + let minutes = Int(duration) / 60 + let seconds = Int(duration) % 60 + return String(format: "%02d:%02d", minutes, seconds) + } +} + +// MARK: - Preview + +#if DEBUG +@available(macOS 26.0, *) +struct AuroraProcessingView_Previews: PreviewProvider { + static var previews: some View { + ZStack { + Color.black + VStack(spacing: 20) { + AuroraProcessingView(status: .gettingReady, recordingDuration: 125) + AuroraProcessingView(status: .transcribing(progress: 0.45), recordingDuration: 125) + AuroraProcessingView(status: .findingActionItems, recordingDuration: 125) + AuroraProcessingView(status: .finishing, recordingDuration: 125) + } + } + .frame(width: 400, height: 400) + } +} +#endif diff --git a/Murmur/UI/FloatingPanel/Components/AuroraRecordingView.swift b/Murmur/UI/FloatingPanel/Components/AuroraRecordingView.swift index 2f11af6ad..d9ea17407 100644 --- a/Murmur/UI/FloatingPanel/Components/AuroraRecordingView.swift +++ b/Murmur/UI/FloatingPanel/Components/AuroraRecordingView.swift @@ -14,6 +14,7 @@ struct AuroraRecordingView: View { @State private var isExpanded = false @State private var isStopHovered = false @State private var isTasksHovered = false + @State private var collapseTask: Task? @AppStorage("taskService") private var taskService: String = "reminders" // Smoothed audio levels (prevents jitter) @@ -47,7 +48,22 @@ struct AuroraRecordingView: View { ) .animation(.spring(response: 0.15, dampingFraction: 0.8), value: isExpanded) .onHover { hovering in - isExpanded = hovering + if hovering { + // Cancel any pending collapse and expand immediately + collapseTask?.cancel() + collapseTask = nil + isExpanded = true + } else { + // Delay collapse by 1 second + collapseTask = Task { + try? await Task.sleep(nanoseconds: 1_000_000_000) + if !Task.isCancelled { + await MainActor.run { + isExpanded = false + } + } + } + } } .accessibilityElement(children: .combine) .accessibilityLabel("Recording in progress, \(formatDurationAccessible(audio.recordingDuration))") @@ -270,6 +286,7 @@ struct AuroraRecordingView: View { .scaleEffect(isStopHovered ? 1.1 : 1.0) } .buttonStyle(PlainButtonStyle()) + .floatingTooltip("Stop") .onHover { hovering in withAnimation(.spring(response: 0.15, dampingFraction: 0.8)) { isStopHovered = hovering @@ -303,6 +320,7 @@ struct AuroraRecordingView: View { .scaleEffect(isTasksHovered ? 1.1 : 1.0) } .buttonStyle(PlainButtonStyle()) + .floatingTooltip("Tasks") .onHover { hovering in withAnimation(.spring(response: 0.15, dampingFraction: 0.8)) { isTasksHovered = hovering diff --git a/Murmur/UI/FloatingPanel/Components/AuroraSuccessView.swift b/Murmur/UI/FloatingPanel/Components/AuroraSuccessView.swift new file mode 100644 index 000000000..d3e226e57 --- /dev/null +++ b/Murmur/UI/FloatingPanel/Components/AuroraSuccessView.swift @@ -0,0 +1,174 @@ +import SwiftUI + +// MARK: - Aurora Success View +/// In-pill success feedback with animated checkmark and clear messaging +/// Expanded size (200x44) for visual continuity with processing view +/// Shows descriptive text so users know what happened + +@available(macOS 26.0, *) +struct AuroraSuccessView: View { + /// Type of success to display + enum SuccessType: Equatable { + case transcriptSaved + case tasksAdded(count: Int) + } + + let successType: SuccessType + + @State private var checkScale: CGFloat = 0.3 + @State private var checkOpacity: CGFloat = 0 + @State private var textOpacity: CGFloat = 0 + + // Expanded dimensions (matches processing view for smooth transition) + private let width: CGFloat = 200 + private let height: CGFloat = 44 + + var body: some View { + ZStack { + // Success background with green glow + successBackground + .clipShape(Capsule()) + + // Content: checkmark + text + HStack(spacing: 12) { + // Animated checkmark + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 22, weight: .medium)) + .foregroundColor(.statusSuccessMuted) + .scaleEffect(checkScale) + .opacity(checkOpacity) + + // Text content + VStack(alignment: .leading, spacing: 2) { + Text(primaryText) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.panelTextPrimary) + .opacity(textOpacity) + + if let secondary = secondaryText { + Text(secondary) + .font(.system(size: 11, weight: .regular)) + .foregroundColor(.panelTextMuted) + .opacity(textOpacity) + } + } + + Spacer() + } + .padding(.horizontal, 16) + } + .frame(width: width, height: height) + .onAppear { + animateSuccess() + } + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityText) + } + + // MARK: - Text Content + + private var primaryText: String { + switch successType { + case .transcriptSaved: + return "Transcription saved" + case .tasksAdded(let count): + return "\(count) task\(count == 1 ? "" : "s") added" + } + } + + private var secondaryText: String? { + switch successType { + case .transcriptSaved: + return "No action items found" + case .tasksAdded: + return nil + } + } + + // MARK: - Success Background + + private var successBackground: some View { + ZStack { + Color.panelCharcoal + + // Subtle green radial glow + RadialGradient( + colors: [ + Color.statusSuccessMuted.opacity(0.2), + Color.statusSuccessMuted.opacity(0.08), + Color.clear + ], + center: .leading, + startRadius: 0, + endRadius: 120 + ) + } + .overlay( + Capsule() + .strokeBorder(Color.statusSuccessMuted.opacity(0.5), lineWidth: 1.5) + ) + .shadow(color: Color.statusSuccessMuted.opacity(0.25), radius: 10, y: 0) + } + + // MARK: - Animation + + private func animateSuccess() { + // Phase 1: Scale + fade in checkmark (spring) + withAnimation(.spring(response: 0.4, dampingFraction: 0.6)) { + checkScale = 1.0 + checkOpacity = 1.0 + } + + // Phase 2: Bounce out slightly + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + withAnimation(.spring(response: 0.2, dampingFraction: 0.5)) { + checkScale = 1.15 + } + } + + // Phase 3: Settle back + DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { + withAnimation(.spring(response: 0.15, dampingFraction: 0.8)) { + checkScale = 1.0 + } + } + + // Phase 4: Fade in text + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + withAnimation(.easeOut(duration: 0.25)) { + textOpacity = 1.0 + } + } + } + + // MARK: - Accessibility + + private var accessibilityText: String { + switch successType { + case .transcriptSaved: + return "Transcript saved successfully. No action items found." + case .tasksAdded(let count): + return "\(count) task\(count == 1 ? "" : "s") added successfully" + } + } +} + +// MARK: - Preview + +#if DEBUG +@available(macOS 26.0, *) +struct AuroraSuccessView_Previews: PreviewProvider { + static var previews: some View { + ZStack { + Color.black + VStack(spacing: 20) { + AuroraSuccessView(successType: .transcriptSaved) + AuroraSuccessView(successType: .tasksAdded(count: 1)) + AuroraSuccessView(successType: .tasksAdded(count: 3)) + AuroraSuccessView(successType: .tasksAdded(count: 12)) + } + } + .frame(width: 300, height: 300) + } +} +#endif diff --git a/Murmur/UI/FloatingPanel/Components/PillViews.swift b/Murmur/UI/FloatingPanel/Components/PillViews.swift index e7afc36ac..c04c34438 100644 --- a/Murmur/UI/FloatingPanel/Components/PillViews.swift +++ b/Murmur/UI/FloatingPanel/Components/PillViews.swift @@ -332,11 +332,11 @@ struct PillProcessingView: View { private var statusColor: Color { switch status { - case .preparing, .transcribing: + case .gettingReady, .transcribing: return .statusProcessingMuted - case .extractingActionItems: + case .findingActionItems: return .processingPurple - case .saving: + case .finishing: return .statusProcessingMuted case .completed, .transcriptSaved: return .statusSuccessMuted diff --git a/Murmur/UI/FloatingPanel/FloatingPanelController.swift b/Murmur/UI/FloatingPanel/FloatingPanelController.swift index eeecc42b1..233aa7033 100644 --- a/Murmur/UI/FloatingPanel/FloatingPanelController.swift +++ b/Murmur/UI/FloatingPanel/FloatingPanelController.swift @@ -53,6 +53,7 @@ class FloatingPanelController: NSWindowController { window.titlebarAppearsTransparent = true window.titleVisibility = .hidden window.hidesOnDeactivate = false // Stay visible when app loses focus + window.acceptsMouseMovedEvents = true // Enable tooltips on hover // Create view with pill state manager and failed transcription manager let view = FloatingPanelView( @@ -120,7 +121,7 @@ class FloatingPanelController: NSWindowController { .sink { [weak self] status in guard let self = self else { return } switch status { - case .preparing, .transcribing, .extractingActionItems, .saving: + case .gettingReady, .transcribing, .findingActionItems, .finishing: if !self.audio.isRecording { self.pillStateManager.transition(to: .processing) } @@ -128,7 +129,15 @@ class FloatingPanelController: NSWindowController { // IMPORTANT: Transition BEFORE locking - lock() would block the transition self.pillStateManager.transition(to: .reviewing) self.pillStateManager.lock() - case .completed, .transcriptSaved, .idle: + case .completed, .transcriptSaved: + // Stay in processing state so success view can display + // The scheduleStatusReset() timer will set .idle after ~10s + self.pillStateManager.unlock(transitionToIdle: false) + if self.pillStateManager.state != .processing { + self.pillStateManager.transition(to: .processing) + } + case .idle: + // Now transition to idle (triggered by scheduleStatusReset timer) self.pillStateManager.unlock(transitionToIdle: !self.audio.isRecording) case .failed: // Play error sound and stay in current state diff --git a/Murmur/UI/FloatingPanel/FloatingPanelView.swift b/Murmur/UI/FloatingPanel/FloatingPanelView.swift index 9e3681f9b..19e539c1e 100644 --- a/Murmur/UI/FloatingPanel/FloatingPanelView.swift +++ b/Murmur/UI/FloatingPanel/FloatingPanelView.swift @@ -2,15 +2,6 @@ import SwiftUI // MARK: - SwiftUI View -// MARK: - Celebration State (consolidated) - -/// Single enum for all celebration overlays - prevents state explosion -enum CelebrationState: Equatable { - case none - case transcriptSaved - case actionItemsCreated(count: Int) -} - @available(macOS 26.0, *) struct FloatingPanelView: View { @ObservedObject var taskManager: TranscriptionTaskManager @@ -21,9 +12,6 @@ struct FloatingPanelView: View { // User preference for aurora recording indicator @AppStorage("useAuroraRecording") private var useAuroraRecording: Bool = false - // Consolidated celebration state (replaces 3 separate @State vars) - @State private var celebrationState: CelebrationState = .none - // Toast notification state @State private var showErrorToast = false @State private var currentError: ContextualError? @@ -47,17 +35,11 @@ struct FloatingPanelView: View { )) } - // MARK: - Celebration Overlays & Toast (float above pill) + // MARK: - Toast Notifications (float above pill) ZStack { Color.clear .frame(height: pillStateManager.state == .reviewing ? 0 : 60) - // Single celebration overlay based on consolidated state - if celebrationState != .none && pillStateManager.state != .reviewing { - celebrationOverlay - .transition(.scale.combined(with: .opacity)) - } - // Toast notification for errors (appears above pill, auto-dismisses) if showErrorToast, let error = currentError { ToastNotificationView(error: error, isVisible: $showErrorToast) @@ -87,49 +69,16 @@ struct FloatingPanelView: View { silencePromptDismissed = false } } - // Trigger celebrations and toasts based on displayStatus changes + // Trigger error toasts based on displayStatus changes + // (Success is now shown in-pill via AuroraSuccessView, not as overlay) .onChange(of: taskManager.displayStatus) { _, newStatus in - switch newStatus { - case .transcriptSaved: - triggerCelebration(.transcriptSaved, duration: 2.0) - case .completed(let count): - triggerCelebration(.actionItemsCreated(count: count), duration: 3.0) - case .failed(let message): + if case .failed(let message) = newStatus { triggerErrorToast(message: message) - default: - break } } - // Clear celebrations immediately when pill state changes - .onChange(of: pillStateManager.state) { _, _ in - celebrationState = .none - } - } - - // MARK: - Celebration - - /// Computed overlay view based on current celebration state - @ViewBuilder - private var celebrationOverlay: some View { - switch celebrationState { - case .none: - EmptyView() - case .transcriptSaved: - CelebrationOverlay(celebrationType: .transcriptSaved, isVisible: true) - case .actionItemsCreated(let count): - CelebrationOverlay(celebrationType: .actionItemsCreated(count: count), isVisible: true) - } } - /// Unified celebration trigger - shows overlay then auto-dismisses - private func triggerCelebration(_ state: CelebrationState, duration: TimeInterval) { - celebrationState = state - DispatchQueue.main.asyncAfter(deadline: .now() + duration) { - if celebrationState == state { // Only clear if still showing same celebration - celebrationState = .none - } - } - } + // MARK: - Error Toast /// Trigger error toast notification - parses message into contextual error private func triggerErrorToast(message: String) { @@ -163,7 +112,18 @@ struct FloatingPanelView: View { } } case .processing: - PillProcessingView(status: taskManager.displayStatus) + // Show success view for success states, processing aurora otherwise + switch taskManager.displayStatus { + case .transcriptSaved: + AuroraSuccessView(successType: .transcriptSaved) + case .completed(let count): + AuroraSuccessView(successType: .tasksAdded(count: count)) + default: + AuroraProcessingView( + status: taskManager.displayStatus, + recordingDuration: audio.recordingDuration + ) + } case .reviewing: PillReviewingView(itemCount: taskManager.pendingReview?.totalCount ?? 0) } diff --git a/Murmur/UI/FloatingPanel/Helpers/LawsComponents.swift b/Murmur/UI/FloatingPanel/Helpers/LawsComponents.swift index 1542c77cf..6a8bb9d8e 100644 --- a/Murmur/UI/FloatingPanel/Helpers/LawsComponents.swift +++ b/Murmur/UI/FloatingPanel/Helpers/LawsComponents.swift @@ -114,7 +114,7 @@ struct LawsStatusTextView: View { switch status { case .idle: return .accentBlue - case .preparing, .transcribing, .extractingActionItems, .saving: + case .gettingReady, .transcribing, .findingActionItems, .finishing: return .statusProcessingMuted case .transcriptSaved, .completed: return .statusSuccessMuted @@ -135,3 +135,76 @@ struct LawsStatusTextView: View { extension Color { static let retroGreen = Color(red: 0.2, green: 0.8, blue: 0.2) } + +// MARK: - Custom Floating Tooltip (for non-activating panels) + +/// Custom tooltip modifier that works with non-activating floating panels +/// Shows tooltip above the view after 1 second hover delay +struct FloatingTooltipModifier: ViewModifier { + let text: String + let delay: TimeInterval + + @State private var isHovered = false + @State private var showTooltip = false + @State private var hoverTask: Task? + + func body(content: Content) -> some View { + content + .overlay(alignment: .top) { + if showTooltip { + tooltipView + .offset(y: -32) + .transition(.opacity.combined(with: .scale(scale: 0.95))) + .zIndex(1000) + } + } + .onHover { hovering in + isHovered = hovering + + if hovering { + // Start delay timer + hoverTask = Task { + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + if !Task.isCancelled && isHovered { + withAnimation(.easeOut(duration: 0.15)) { + showTooltip = true + } + } + } + } else { + // Cancel timer and hide tooltip + hoverTask?.cancel() + hoverTask = nil + withAnimation(.easeOut(duration: 0.1)) { + showTooltip = false + } + } + } + } + + private var tooltipView: some View { + Text(text) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.white) + .fixedSize() + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color(white: 0.15)) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(Color.white.opacity(0.2), lineWidth: 0.5) + ) + .shadow(color: .black.opacity(0.4), radius: 8, y: 4) + } +} + +extension View { + /// Adds a custom floating tooltip that works with non-activating panels + /// - Parameters: + /// - text: The tooltip text to display + /// - delay: Delay in seconds before showing (default: 1.0) + func floatingTooltip(_ text: String, delay: TimeInterval = 1.0) -> some View { + modifier(FloatingTooltipModifier(text: text, delay: delay)) + } +}