From d2dd506a94f3f6f49dfe0909306be78d30e420ba Mon Sep 17 00:00:00 2001 From: Beingpax Date: Sun, 28 Dec 2025 15:30:48 +0545 Subject: [PATCH 1/8] Move transcription history to separate window - Add HistoryWindowController for native macOS window management - Update MenuBarManager to support window opening - Modify sidebar to open history in separate window instead of inline - Update navigation handlers to use window controller - Settings remains inline as before --- VoiceInk/HistoryWindowController.swift | 76 ++++++++++++++++++++++++++ VoiceInk/MenuBarManager.swift | 26 +++++++-- VoiceInk/Views/ContentView.swift | 58 +++++++++++++++----- VoiceInk/Views/MenuBarView.swift | 2 +- VoiceInk/VoiceInk.swift | 5 +- 5 files changed, 145 insertions(+), 22 deletions(-) create mode 100644 VoiceInk/HistoryWindowController.swift diff --git a/VoiceInk/HistoryWindowController.swift b/VoiceInk/HistoryWindowController.swift new file mode 100644 index 0000000..845f385 --- /dev/null +++ b/VoiceInk/HistoryWindowController.swift @@ -0,0 +1,76 @@ +import SwiftUI +import SwiftData +import AppKit + +class HistoryWindowController: NSObject, NSWindowDelegate { + static let shared = HistoryWindowController() + + private var historyWindow: NSWindow? + private let windowIdentifier = NSUserInterfaceItemIdentifier("com.prakashjoshipax.voiceink.historyWindow") + private let windowAutosaveName = NSWindow.FrameAutosaveName("VoiceInkHistoryWindowFrame") + + private override init() { + super.init() + } + + func showHistoryWindow(modelContainer: ModelContainer) { + if let existingWindow = historyWindow, existingWindow.isVisible { + existingWindow.makeKeyAndOrderFront(nil) + NSApplication.shared.activate(ignoringOtherApps: true) + return + } + + let window = createHistoryWindow(modelContainer: modelContainer) + historyWindow = window + window.makeKeyAndOrderFront(nil) + NSApplication.shared.activate(ignoringOtherApps: true) + } + + private func createHistoryWindow(modelContainer: ModelContainer) -> NSWindow { + let historyView = TranscriptionHistoryView() + .modelContainer(modelContainer) + .frame(minWidth: 800, minHeight: 600) + + let hostingController = NSHostingController(rootView: historyView) + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 900, height: 700), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + + window.contentViewController = hostingController + window.title = "VoiceInk — Transcription History" + window.identifier = windowIdentifier + window.delegate = self + window.titlebarAppearsTransparent = true + window.titleVisibility = .visible + window.backgroundColor = NSColor.windowBackgroundColor + window.isReleasedWhenClosed = false + window.collectionBehavior = [.fullScreenPrimary] + window.minSize = NSSize(width: 700, height: 500) + + window.setFrameAutosaveName(windowAutosaveName) + if !window.setFrameUsingName(windowAutosaveName) { + window.center() + } + + return window + } + + // MARK: - NSWindowDelegate + + func windowWillClose(_ notification: Notification) { + guard let window = notification.object as? NSWindow, + window.identifier == windowIdentifier else { return } + + historyWindow = nil + } + + func windowDidBecomeKey(_ notification: Notification) { + guard let window = notification.object as? NSWindow, + window.identifier == windowIdentifier else { return } + NSApplication.shared.activate(ignoringOtherApps: true) + } +} diff --git a/VoiceInk/MenuBarManager.swift b/VoiceInk/MenuBarManager.swift index e9aede1..ce51475 100644 --- a/VoiceInk/MenuBarManager.swift +++ b/VoiceInk/MenuBarManager.swift @@ -1,4 +1,5 @@ import SwiftUI +import SwiftData import AppKit class MenuBarManager: ObservableObject { @@ -8,12 +9,17 @@ class MenuBarManager: ObservableObject { updateAppActivationPolicy() } } - - + + private var modelContainer: ModelContainer? + init() { self.isMenuBarOnly = UserDefaults.standard.bool(forKey: "IsMenuBarOnly") updateAppActivationPolicy() } + + func configure(modelContainer: ModelContainer) { + self.modelContainer = modelContainer + } func toggleMenuBarOnly() { isMenuBarOnly.toggle() @@ -54,17 +60,17 @@ class MenuBarManager: ObservableObject { func openMainWindowAndNavigate(to destination: String) { print("MenuBarManager: Navigating to \(destination)") - + DispatchQueue.main.async { [weak self] in guard let self = self else { return } - + self.applyActivationPolicy() - + guard WindowManager.shared.showMainWindow() != nil else { print("MenuBarManager: Unable to show main window for navigation") return } - + // Post a notification to navigate to the desired destination DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { NotificationCenter.default.post( @@ -76,4 +82,12 @@ class MenuBarManager: ObservableObject { } } } + + func openHistoryWindow() { + guard let modelContainer = modelContainer else { + print("MenuBarManager: ModelContainer not configured") + return + } + HistoryWindowController.shared.showHistoryWindow(modelContainer: modelContainer) + } } diff --git a/VoiceInk/Views/ContentView.swift b/VoiceInk/Views/ContentView.swift index 34265ee..ea3a3e7 100644 --- a/VoiceInk/Views/ContentView.swift +++ b/VoiceInk/Views/ContentView.swift @@ -106,22 +106,47 @@ struct ContentView: View { ForEach(visibleViewTypes) { viewType in Section { - NavigationLink(value: viewType) { - HStack(spacing: 12) { - Image(systemName: viewType.icon) - .font(.system(size: 18, weight: .medium)) - .frame(width: 24, height: 24) + if viewType == .history { + // History opens in separate window instead of inline + Button(action: { + HistoryWindowController.shared.showHistoryWindow( + modelContainer: modelContext.container + ) + }) { + HStack(spacing: 12) { + Image(systemName: viewType.icon) + .font(.system(size: 18, weight: .medium)) + .frame(width: 24, height: 24) - Text(viewType.rawValue) - .font(.system(size: 14, weight: .medium)) + Text(viewType.rawValue) + .font(.system(size: 14, weight: .medium)) - Spacer() + Spacer() + } + .padding(.vertical, 8) + .padding(.horizontal, 2) } - .padding(.vertical, 8) - .padding(.horizontal, 2) + .buttonStyle(.plain) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + .listRowSeparator(.hidden) + } else { + NavigationLink(value: viewType) { + HStack(spacing: 12) { + Image(systemName: viewType.icon) + .font(.system(size: 18, weight: .medium)) + .frame(width: 24, height: 24) + + Text(viewType.rawValue) + .font(.system(size: 14, weight: .medium)) + + Spacer() + } + .padding(.vertical, 8) + .padding(.horizontal, 2) + } + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + .listRowSeparator(.hidden) } - .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) - .listRowSeparator(.hidden) } } } @@ -150,7 +175,10 @@ struct ContentView: View { case "VoiceInk Pro": selectedView = .license case "History": - selectedView = .history + // Open History in separate window instead of inline + HistoryWindowController.shared.showHistoryWindow( + modelContainer: modelContext.container + ) case "Permissions": selectedView = .permissions case "Enhancement": @@ -178,7 +206,9 @@ struct ContentView: View { case .transcribeAudio: AudioTranscribeView() case .history: - TranscriptionHistoryView() + // History now opens in separate window, not shown inline + Text("History") + .foregroundColor(.secondary) case .audioInput: AudioInputSettingsView() case .dictionary: diff --git a/VoiceInk/Views/MenuBarView.swift b/VoiceInk/Views/MenuBarView.swift index 21b54e2..b755591 100644 --- a/VoiceInk/Views/MenuBarView.swift +++ b/VoiceInk/Views/MenuBarView.swift @@ -199,7 +199,7 @@ struct MenuBarView: View { .keyboardShortcut("c", modifiers: [.command, .shift]) Button("History") { - menuBarManager.openMainWindowAndNavigate(to: "History") + menuBarManager.openHistoryWindow() } .keyboardShortcut("h", modifiers: [.command, .shift]) diff --git a/VoiceInk/VoiceInk.swift b/VoiceInk/VoiceInk.swift index 77d8c19..71dacac 100644 --- a/VoiceInk/VoiceInk.swift +++ b/VoiceInk/VoiceInk.swift @@ -109,6 +109,9 @@ struct VoiceInkApp: App { let menuBarManager = MenuBarManager() _menuBarManager = StateObject(wrappedValue: menuBarManager) + // Configure MenuBarManager with ModelContainer for window management + menuBarManager.configure(modelContainer: container) + let activeWindowService = ActiveWindowService.shared activeWindowService.configure(with: enhancementService) activeWindowService.configureWhisperState(whisperState) @@ -277,7 +280,7 @@ struct VoiceInkApp: App { .windowStyle(.hiddenTitleBar) .commands { CommandGroup(replacing: .newItem) { } - + CommandGroup(after: .appInfo) { CheckForUpdatesView(updaterViewModel: updaterViewModel) } From ebad2c42d00bab692b1c1c2aefda85a1de213ef0 Mon Sep 17 00:00:00 2001 From: Beingpax Date: Sun, 28 Dec 2025 18:47:23 +0545 Subject: [PATCH 2/8] Fix retranscribe functionality in history window - Pass WhisperState to HistoryWindowController for transcription services - Update MenuBarManager to store and pass WhisperState reference - Ensure all transcription providers work in separate history window --- VoiceInk/HistoryWindowController.swift | 7 ++++--- VoiceInk/MenuBarManager.swift | 14 ++++++++++---- VoiceInk/Views/ContentView.swift | 9 ++++----- VoiceInk/VoiceInk.swift | 4 +--- 4 files changed, 19 insertions(+), 15 deletions(-) diff --git a/VoiceInk/HistoryWindowController.swift b/VoiceInk/HistoryWindowController.swift index 845f385..457155b 100644 --- a/VoiceInk/HistoryWindowController.swift +++ b/VoiceInk/HistoryWindowController.swift @@ -13,22 +13,23 @@ class HistoryWindowController: NSObject, NSWindowDelegate { super.init() } - func showHistoryWindow(modelContainer: ModelContainer) { + func showHistoryWindow(modelContainer: ModelContainer, whisperState: WhisperState) { if let existingWindow = historyWindow, existingWindow.isVisible { existingWindow.makeKeyAndOrderFront(nil) NSApplication.shared.activate(ignoringOtherApps: true) return } - let window = createHistoryWindow(modelContainer: modelContainer) + let window = createHistoryWindow(modelContainer: modelContainer, whisperState: whisperState) historyWindow = window window.makeKeyAndOrderFront(nil) NSApplication.shared.activate(ignoringOtherApps: true) } - private func createHistoryWindow(modelContainer: ModelContainer) -> NSWindow { + private func createHistoryWindow(modelContainer: ModelContainer, whisperState: WhisperState) -> NSWindow { let historyView = TranscriptionHistoryView() .modelContainer(modelContainer) + .environmentObject(whisperState) .frame(minWidth: 800, minHeight: 600) let hostingController = NSHostingController(rootView: historyView) diff --git a/VoiceInk/MenuBarManager.swift b/VoiceInk/MenuBarManager.swift index ce51475..56d9ea2 100644 --- a/VoiceInk/MenuBarManager.swift +++ b/VoiceInk/MenuBarManager.swift @@ -11,14 +11,16 @@ class MenuBarManager: ObservableObject { } private var modelContainer: ModelContainer? + private var whisperState: WhisperState? init() { self.isMenuBarOnly = UserDefaults.standard.bool(forKey: "IsMenuBarOnly") updateAppActivationPolicy() } - func configure(modelContainer: ModelContainer) { + func configure(modelContainer: ModelContainer, whisperState: WhisperState) { self.modelContainer = modelContainer + self.whisperState = whisperState } func toggleMenuBarOnly() { @@ -84,10 +86,14 @@ class MenuBarManager: ObservableObject { } func openHistoryWindow() { - guard let modelContainer = modelContainer else { - print("MenuBarManager: ModelContainer not configured") + guard let modelContainer = modelContainer, + let whisperState = whisperState else { + print("MenuBarManager: Dependencies not configured") return } - HistoryWindowController.shared.showHistoryWindow(modelContainer: modelContainer) + HistoryWindowController.shared.showHistoryWindow( + modelContainer: modelContainer, + whisperState: whisperState + ) } } diff --git a/VoiceInk/Views/ContentView.swift b/VoiceInk/Views/ContentView.swift index ea3a3e7..be1a8b8 100644 --- a/VoiceInk/Views/ContentView.swift +++ b/VoiceInk/Views/ContentView.swift @@ -107,10 +107,10 @@ struct ContentView: View { ForEach(visibleViewTypes) { viewType in Section { if viewType == .history { - // History opens in separate window instead of inline Button(action: { HistoryWindowController.shared.showHistoryWindow( - modelContainer: modelContext.container + modelContainer: modelContext.container, + whisperState: whisperState ) }) { HStack(spacing: 12) { @@ -175,9 +175,9 @@ struct ContentView: View { case "VoiceInk Pro": selectedView = .license case "History": - // Open History in separate window instead of inline HistoryWindowController.shared.showHistoryWindow( - modelContainer: modelContext.container + modelContainer: modelContext.container, + whisperState: whisperState ) case "Permissions": selectedView = .permissions @@ -206,7 +206,6 @@ struct ContentView: View { case .transcribeAudio: AudioTranscribeView() case .history: - // History now opens in separate window, not shown inline Text("History") .foregroundColor(.secondary) case .audioInput: diff --git a/VoiceInk/VoiceInk.swift b/VoiceInk/VoiceInk.swift index 71dacac..ff32b05 100644 --- a/VoiceInk/VoiceInk.swift +++ b/VoiceInk/VoiceInk.swift @@ -108,9 +108,7 @@ struct VoiceInkApp: App { let menuBarManager = MenuBarManager() _menuBarManager = StateObject(wrappedValue: menuBarManager) - - // Configure MenuBarManager with ModelContainer for window management - menuBarManager.configure(modelContainer: container) + menuBarManager.configure(modelContainer: container, whisperState: whisperState) let activeWindowService = ActiveWindowService.shared activeWindowService.configure(with: enhancementService) From 531da7b1725997ac977bdff6a2187240e4439676 Mon Sep 17 00:00:00 2001 From: Beingpax Date: Mon, 29 Dec 2025 11:44:50 +0545 Subject: [PATCH 3/8] Redesign transcription history with three-pane layout and compact audio player - Implement three-pane Xcode-style layout with toggleable sidebars - Refactor into modular components for better maintainability - Replace tabbed interface with iMessage-style message bubbles - Redesign audio player for 70% height reduction with horizontal layout - Update to native macOS colors and remove custom backgrounds - Increase minimum window size to 1000x700 for better usability --- VoiceInk/HistoryWindowController.swift | 6 +- VoiceInk/Views/AudioPlayerView.swift | 109 +++-- .../History/TranscriptionDetailView.swift | 100 ++++ .../History/TranscriptionHistoryView.swift | 421 +++++++++++++++++ .../Views/History/TranscriptionListItem.swift | 83 ++++ .../History/TranscriptionMetadataView.swift | 184 ++++++++ VoiceInk/Views/TranscriptionHistoryView.swift | 442 ------------------ 7 files changed, 843 insertions(+), 502 deletions(-) create mode 100644 VoiceInk/Views/History/TranscriptionDetailView.swift create mode 100644 VoiceInk/Views/History/TranscriptionHistoryView.swift create mode 100644 VoiceInk/Views/History/TranscriptionListItem.swift create mode 100644 VoiceInk/Views/History/TranscriptionMetadataView.swift delete mode 100644 VoiceInk/Views/TranscriptionHistoryView.swift diff --git a/VoiceInk/HistoryWindowController.swift b/VoiceInk/HistoryWindowController.swift index 457155b..8012bc8 100644 --- a/VoiceInk/HistoryWindowController.swift +++ b/VoiceInk/HistoryWindowController.swift @@ -30,12 +30,12 @@ class HistoryWindowController: NSObject, NSWindowDelegate { let historyView = TranscriptionHistoryView() .modelContainer(modelContainer) .environmentObject(whisperState) - .frame(minWidth: 800, minHeight: 600) + .frame(minWidth: 1000, minHeight: 700) let hostingController = NSHostingController(rootView: historyView) let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 900, height: 700), + contentRect: NSRect(x: 0, y: 0, width: 1100, height: 750), styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], backing: .buffered, defer: false @@ -50,7 +50,7 @@ class HistoryWindowController: NSObject, NSWindowDelegate { window.backgroundColor = NSColor.windowBackgroundColor window.isReleasedWhenClosed = false window.collectionBehavior = [.fullScreenPrimary] - window.minSize = NSSize(width: 700, height: 500) + window.minSize = NSSize(width: 1000, height: 700) window.setFrameAutosaveName(windowAutosaveName) if !window.setFrameUsingName(windowAutosaveName) { diff --git a/VoiceInk/Views/AudioPlayerView.swift b/VoiceInk/Views/AudioPlayerView.swift index cb9ecce..e403d19 100644 --- a/VoiceInk/Views/AudioPlayerView.swift +++ b/VoiceInk/Views/AudioPlayerView.swift @@ -118,16 +118,16 @@ struct WaveformView: View { GeometryReader { geometry in ZStack(alignment: .leading) { if isLoading { - VStack { + HStack { ProgressView() .controlSize(.small) - Text("Generating waveform...") - .font(.system(size: 12)) + Text("Loading...") + .font(.system(size: 10)) .foregroundColor(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) } else { - HStack(spacing: 1) { + HStack(spacing: 0.5) { ForEach(0.. String { @@ -223,10 +224,10 @@ struct WaveformBar: View { ) ) .frame( - width: max((geometryWidth / CGFloat(totalBars)) - 1, 1), - height: max(CGFloat(sample) * 40, 3) + width: max((geometryWidth / CGFloat(totalBars)) - 0.5, 1), + height: max(CGFloat(sample) * 24, 2) ) - .scaleEffect(y: isHovering && isNearHover ? 1.2 : 1.0) + .scaleEffect(y: isHovering && isNearHover ? 1.15 : 1.0) .animation(.interpolatingSpring(stiffness: 300, damping: 15), value: isHovering && isNearHover) } } @@ -247,47 +248,38 @@ struct AudioPlayerView: View { } var body: some View { - VStack(spacing: 16) { - HStack { - HStack(spacing: 6) { - Image(systemName: "waveform") - .foregroundStyle(Color.accentColor) - Text("Recording") - .font(.system(size: 14, weight: .medium)) - } - .foregroundColor(.secondary) - - Spacer() - - Text(formatTime(playerManager.duration)) - .font(.system(size: 14, weight: .medium)) + VStack(spacing: 8) { + WaveformView( + samples: playerManager.waveformSamples, + currentTime: playerManager.currentTime, + duration: playerManager.duration, + isLoading: playerManager.isLoadingWaveform, + onSeek: { playerManager.seek(to: $0) } + ) + .padding(.horizontal, 10) + + HStack(spacing: 8) { + Text(formatTime(playerManager.currentTime)) + .font(.system(size: 11, weight: .medium)) .monospacedDigit() .foregroundColor(.secondary) - } - - VStack(spacing: 16) { - WaveformView( - samples: playerManager.waveformSamples, - currentTime: playerManager.currentTime, - duration: playerManager.duration, - isLoading: playerManager.isLoadingWaveform, - onSeek: { playerManager.seek(to: $0) } - ) - - HStack(spacing: 20) { + + Spacer() + + HStack(spacing: 8) { Button(action: showInFinder) { Circle() .fill(Color.orange.opacity(0.1)) - .frame(width: 44, height: 44) + .frame(width: 32, height: 32) .overlay( Image(systemName: "folder") - .font(.system(size: 18, weight: .semibold)) + .font(.system(size: 14, weight: .semibold)) .foregroundStyle(Color.orange) ) } .buttonStyle(.plain) .help("Show in Finder") - + Button(action: { if playerManager.isPlaying { playerManager.pause() @@ -297,10 +289,10 @@ struct AudioPlayerView: View { }) { Circle() .fill(Color.accentColor.opacity(0.1)) - .frame(width: 44, height: 44) + .frame(width: 32, height: 32) .overlay( Image(systemName: playerManager.isPlaying ? "pause.fill" : "play.fill") - .font(.system(size: 18, weight: .semibold)) + .font(.system(size: 14, weight: .semibold)) .foregroundStyle(Color.accentColor) .contentTransition(.symbolEffect(.replace.downUp)) ) @@ -312,11 +304,11 @@ struct AudioPlayerView: View { isHovering = hovering } } - + Button(action: retranscribeAudio) { Circle() .fill(Color.green.opacity(0.1)) - .frame(width: 44, height: 44) + .frame(width: 32, height: 32) .overlay( Group { if isRetranscribing { @@ -324,11 +316,11 @@ struct AudioPlayerView: View { .controlSize(.small) } else if showRetranscribeSuccess { Image(systemName: "checkmark") - .font(.system(size: 18, weight: .semibold)) + .font(.system(size: 14, weight: .semibold)) .foregroundStyle(Color.green) } else { Image(systemName: "arrow.clockwise") - .font(.system(size: 18, weight: .semibold)) + .font(.system(size: 14, weight: .semibold)) .foregroundStyle(Color.green) } } @@ -337,16 +329,19 @@ struct AudioPlayerView: View { .buttonStyle(.plain) .disabled(isRetranscribing) .help("Retranscribe this audio") - - Text(formatTime(playerManager.currentTime)) - .font(.system(size: 14, weight: .medium)) - .monospacedDigit() - .foregroundColor(.secondary) } + + Spacer() + + Text(formatTime(playerManager.duration)) + .font(.system(size: 11, weight: .medium)) + .monospacedDigit() + .foregroundColor(.secondary) } + .padding(.horizontal, 10) } - .padding(.vertical, 12) - .padding(.horizontal, 16) + .padding(.top, 8) + .padding(.bottom, 6) .onAppear { playerManager.loadAudio(from: url) } diff --git a/VoiceInk/Views/History/TranscriptionDetailView.swift b/VoiceInk/Views/History/TranscriptionDetailView.swift new file mode 100644 index 0000000..b826db2 --- /dev/null +++ b/VoiceInk/Views/History/TranscriptionDetailView.swift @@ -0,0 +1,100 @@ +import SwiftUI + +struct TranscriptionDetailView: View { + let transcription: Transcription + + private var hasAudioFile: Bool { + if let urlString = transcription.audioFileURL, + let url = URL(string: urlString), + FileManager.default.fileExists(atPath: url.path) { + return true + } + return false + } + + var body: some View { + VStack(spacing: 12) { + ScrollView { + VStack(spacing: 16) { + MessageBubble( + label: "Original", + text: transcription.text, + isEnhanced: false + ) + + if let enhancedText = transcription.enhancedText { + MessageBubble( + label: "Enhanced", + text: enhancedText, + isEnhanced: true + ) + } + } + .padding(16) + } + + if hasAudioFile, let urlString = transcription.audioFileURL, + let url = URL(string: urlString) { + VStack(spacing: 0) { + Divider() + + AudioPlayerView(url: url) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color(NSColor.controlBackgroundColor).opacity(0.5)) + ) + .padding(.horizontal, 12) + .padding(.top, 6) + } + } + } + .padding(.vertical, 12) + .background(Color(NSColor.controlBackgroundColor)) + } +} + +private struct MessageBubble: View { + let label: String + let text: String + let isEnhanced: Bool + + var body: some View { + HStack(alignment: .bottom) { + if isEnhanced { Spacer(minLength: 60) } + + VStack(alignment: isEnhanced ? .leading : .trailing, spacing: 4) { + Text(label) + .font(.system(size: 9, weight: .medium)) + .foregroundColor(.secondary.opacity(0.7)) + .padding(.horizontal, 12) + + ScrollView { + Text(text) + .font(.system(size: 14, weight: .regular)) + .lineSpacing(2) + .textSelection(.enabled) + .padding(.horizontal, 12) + .padding(.vertical, 10) + } + .frame(maxHeight: 350) + .background { + if isEnhanced { + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(Color.accentColor.opacity(0.2)) + } else { + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(.thinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .strokeBorder(Color.primary.opacity(0.06), lineWidth: 0.5) + ) + } + } + } + + if !isEnhanced { Spacer(minLength: 60) } + } + } +} diff --git a/VoiceInk/Views/History/TranscriptionHistoryView.swift b/VoiceInk/Views/History/TranscriptionHistoryView.swift new file mode 100644 index 0000000..19e6d58 --- /dev/null +++ b/VoiceInk/Views/History/TranscriptionHistoryView.swift @@ -0,0 +1,421 @@ +import SwiftUI +import SwiftData + +struct TranscriptionHistoryView: View { + @Environment(\.modelContext) private var modelContext + @State private var searchText = "" + @State private var selectedTranscription: Transcription? + @State private var selectedTranscriptions: Set = [] + @State private var showDeleteConfirmation = false + @State private var isViewCurrentlyVisible = false + @State private var showAnalysisView = false + @State private var isLeftSidebarVisible = true + @State private var isRightSidebarVisible = true + @State private var leftSidebarWidth: CGFloat = 260 + @State private var rightSidebarWidth: CGFloat = 260 + @State private var displayedTranscriptions: [Transcription] = [] + @State private var isLoading = false + @State private var hasMoreContent = true + @State private var lastTimestamp: Date? + + private let exportService = VoiceInkCSVExportService() + private let minSidebarWidth: CGFloat = 200 + private let maxSidebarWidth: CGFloat = 350 + private let pageSize = 20 + + @Query(Self.createLatestTranscriptionIndicatorDescriptor()) private var latestTranscriptionIndicator: [Transcription] + + private static func createLatestTranscriptionIndicatorDescriptor() -> FetchDescriptor { + var descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.timestamp, order: .reverse)] + ) + descriptor.fetchLimit = 1 + return descriptor + } + + private func cursorQueryDescriptor(after timestamp: Date? = nil) -> FetchDescriptor { + var descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\Transcription.timestamp, order: .reverse)] + ) + + if let timestamp = timestamp { + if !searchText.isEmpty { + descriptor.predicate = #Predicate { transcription in + (transcription.text.localizedStandardContains(searchText) || + (transcription.enhancedText?.localizedStandardContains(searchText) ?? false)) && + transcription.timestamp < timestamp + } + } else { + descriptor.predicate = #Predicate { transcription in + transcription.timestamp < timestamp + } + } + } else if !searchText.isEmpty { + descriptor.predicate = #Predicate { transcription in + transcription.text.localizedStandardContains(searchText) || + (transcription.enhancedText?.localizedStandardContains(searchText) ?? false) + } + } + + descriptor.fetchLimit = pageSize + return descriptor + } + + var body: some View { + HStack(spacing: 0) { + if isLeftSidebarVisible { + leftSidebarView + .frame( + minWidth: minSidebarWidth, + idealWidth: leftSidebarWidth, + maxWidth: maxSidebarWidth + ) + .transition(.move(edge: .leading)) + + Divider() + } + + centerPaneView + .frame(maxWidth: .infinity) + + if isRightSidebarVisible { + Divider() + + rightSidebarView + .frame( + minWidth: minSidebarWidth, + idealWidth: rightSidebarWidth, + maxWidth: maxSidebarWidth + ) + .transition(.move(edge: .trailing)) + } + } + .toolbar { + ToolbarItemGroup(placement: .navigation) { + Button(action: { withAnimation { isLeftSidebarVisible.toggle() } }) { + Label("Toggle Sidebar", systemImage: "sidebar.left") + } + } + + ToolbarItemGroup(placement: .automatic) { + Button(action: { withAnimation { isRightSidebarVisible.toggle() } }) { + Label("Toggle Inspector", systemImage: "sidebar.right") + } + } + } + .alert("Delete Selected Items?", isPresented: $showDeleteConfirmation) { + Button("Delete", role: .destructive) { + deleteSelectedTranscriptions() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This action cannot be undone. Are you sure you want to delete \(selectedTranscriptions.count) item\(selectedTranscriptions.count == 1 ? "" : "s")?") + } + .sheet(isPresented: $showAnalysisView) { + if !selectedTranscriptions.isEmpty { + PerformanceAnalysisView(transcriptions: Array(selectedTranscriptions)) + } + } + .onAppear { + isViewCurrentlyVisible = true + Task { + await loadInitialContent() + } + } + .onDisappear { + isViewCurrentlyVisible = false + } + .onChange(of: searchText) { _, _ in + Task { + await resetPagination() + await loadInitialContent() + } + } + .onChange(of: latestTranscriptionIndicator.first?.id) { oldId, newId in + guard isViewCurrentlyVisible else { return } + if newId != oldId { + Task { + await resetPagination() + await loadInitialContent() + } + } + } + } + + private var leftSidebarView: some View { + VStack(spacing: 0) { + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.secondary) + .font(.system(size: 13)) + TextField("Search transcriptions", text: $searchText) + .textFieldStyle(PlainTextFieldStyle()) + .font(.system(size: 13)) + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(.thinMaterial) + ) + .padding(12) + + Divider() + + ZStack(alignment: .bottom) { + if displayedTranscriptions.isEmpty && !isLoading { + VStack(spacing: 12) { + Image(systemName: "doc.text.magnifyingglass") + .font(.system(size: 40)) + .foregroundColor(.secondary) + Text("No transcriptions") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + LazyVStack(spacing: 8) { + ForEach(displayedTranscriptions) { transcription in + TranscriptionListItem( + transcription: transcription, + isSelected: selectedTranscription == transcription, + isChecked: selectedTranscriptions.contains(transcription), + onSelect: { selectedTranscription = transcription }, + onToggleCheck: { toggleSelection(transcription) } + ) + } + + if hasMoreContent { + Button(action: { + Task { await loadMoreContent() } + }) { + HStack(spacing: 8) { + if isLoading { + ProgressView().controlSize(.small) + } + Text(isLoading ? "Loading..." : "Load More") + .font(.system(size: 13, weight: .medium)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + } + .buttonStyle(.plain) + .disabled(isLoading) + } + } + .padding(8) + .padding(.bottom, !selectedTranscriptions.isEmpty ? 50 : 0) + } + } + + if !selectedTranscriptions.isEmpty { + selectionToolbar + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + } + .background(Color(NSColor.controlBackgroundColor)) + } + + private var centerPaneView: some View { + Group { + if let transcription = selectedTranscription { + TranscriptionDetailView(transcription: transcription) + .id(transcription.id) + } else { + VStack(spacing: 12) { + Image(systemName: "doc.text") + .font(.system(size: 50)) + .foregroundColor(.secondary) + Text("No Selection") + .font(.system(size: 18, weight: .medium)) + .foregroundColor(.secondary) + Text("Select a transcription to view details") + .font(.system(size: 14)) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(NSColor.controlBackgroundColor)) + } + } + } + + private var rightSidebarView: some View { + Group { + if let transcription = selectedTranscription { + TranscriptionMetadataView(transcription: transcription) + .id(transcription.id) + } else { + VStack(spacing: 12) { + Image(systemName: "info.circle") + .font(.system(size: 40)) + .foregroundColor(.secondary) + Text("No Metadata") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(NSColor.controlBackgroundColor)) + } + } + } + + private var selectionToolbar: some View { + HStack(spacing: 12) { + Button(action: { showAnalysisView = true }) { + Image(systemName: "chart.bar.xaxis") + .font(.system(size: 14, weight: .regular)) + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + .help("Analyze") + + Button(action: { + exportService.exportTranscriptionsToCSV(transcriptions: Array(selectedTranscriptions)) + }) { + Image(systemName: "square.and.arrow.up") + .font(.system(size: 14, weight: .regular)) + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + .help("Export") + + Button(action: { showDeleteConfirmation = true }) { + Image(systemName: "trash") + .font(.system(size: 14, weight: .regular)) + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + .help("Delete") + + Spacer() + + Text("\(selectedTranscriptions.count) selected") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.secondary) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background( + Color(NSColor.windowBackgroundColor) + .shadow(color: Color.black.opacity(0.15), radius: 3, y: -2) + ) + } + + @MainActor + private func loadInitialContent() async { + isLoading = true + defer { isLoading = false } + + do { + lastTimestamp = nil + let items = try modelContext.fetch(cursorQueryDescriptor()) + displayedTranscriptions = items + lastTimestamp = items.last?.timestamp + hasMoreContent = items.count == pageSize + } catch { + print("Error loading transcriptions: \(error)") + } + } + + @MainActor + private func loadMoreContent() async { + guard !isLoading, hasMoreContent, let lastTimestamp = lastTimestamp else { return } + + isLoading = true + defer { isLoading = false } + + do { + let newItems = try modelContext.fetch(cursorQueryDescriptor(after: lastTimestamp)) + displayedTranscriptions.append(contentsOf: newItems) + self.lastTimestamp = newItems.last?.timestamp + hasMoreContent = newItems.count == pageSize + } catch { + print("Error loading more transcriptions: \(error)") + } + } + + @MainActor + private func resetPagination() { + displayedTranscriptions = [] + lastTimestamp = nil + hasMoreContent = true + isLoading = false + } + + private func deleteTranscription(_ transcription: Transcription) { + if let urlString = transcription.audioFileURL, + let url = URL(string: urlString) { + try? FileManager.default.removeItem(at: url) + } + + modelContext.delete(transcription) + if selectedTranscription == transcription { + selectedTranscription = nil + } + + selectedTranscriptions.remove(transcription) + + Task { + try? modelContext.save() + await loadInitialContent() + } + } + + private func deleteSelectedTranscriptions() { + for transcription in selectedTranscriptions { + if let urlString = transcription.audioFileURL, + let url = URL(string: urlString) { + try? FileManager.default.removeItem(at: url) + } + modelContext.delete(transcription) + if selectedTranscription == transcription { + selectedTranscription = nil + } + } + + selectedTranscriptions.removeAll() + + Task { + try? modelContext.save() + await loadInitialContent() + } + } + + private func toggleSelection(_ transcription: Transcription) { + if selectedTranscriptions.contains(transcription) { + selectedTranscriptions.remove(transcription) + } else { + selectedTranscriptions.insert(transcription) + } + } + + private func selectAllTranscriptions() async { + do { + var allDescriptor = FetchDescriptor() + + if !searchText.isEmpty { + allDescriptor.predicate = #Predicate { transcription in + transcription.text.localizedStandardContains(searchText) || + (transcription.enhancedText?.localizedStandardContains(searchText) ?? false) + } + } + + allDescriptor.propertiesToFetch = [\.id] + let allTranscriptions = try modelContext.fetch(allDescriptor) + let visibleIds = Set(displayedTranscriptions.map { $0.id }) + + await MainActor.run { + selectedTranscriptions = Set(displayedTranscriptions) + + for transcription in allTranscriptions { + if !visibleIds.contains(transcription.id) { + selectedTranscriptions.insert(transcription) + } + } + } + } catch { + print("Error selecting all transcriptions: \(error)") + } + } +} diff --git a/VoiceInk/Views/History/TranscriptionListItem.swift b/VoiceInk/Views/History/TranscriptionListItem.swift new file mode 100644 index 0000000..0e68ffd --- /dev/null +++ b/VoiceInk/Views/History/TranscriptionListItem.swift @@ -0,0 +1,83 @@ +import SwiftUI + +struct TranscriptionListItem: View { + let transcription: Transcription + let isSelected: Bool + let isChecked: Bool + let onSelect: () -> Void + let onToggleCheck: () -> Void + + var body: some View { + HStack(spacing: 8) { + Toggle("", isOn: Binding( + get: { isChecked }, + set: { _ in onToggleCheck() } + )) + .toggleStyle(CircularCheckboxStyle()) + .labelsHidden() + + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(transcription.timestamp, format: .dateTime.month(.abbreviated).day().hour().minute()) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.secondary) + Spacer() + if transcription.duration > 0 { + Text(formatTiming(transcription.duration)) + .font(.system(size: 10, weight: .medium)) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(Color.secondary.opacity(0.1)) + ) + .foregroundColor(.secondary) + } + } + + Text(transcription.enhancedText ?? transcription.text) + .font(.system(size: 12, weight: .regular)) + .lineLimit(2) + .foregroundColor(.primary) + } + } + .padding(10) + .background { + if isSelected { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color(NSColor.selectedContentBackgroundColor).opacity(0.3)) + } else { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(.thinMaterial) + } + } + .contentShape(Rectangle()) + .onTapGesture { onSelect() } + } + + private func formatTiming(_ duration: TimeInterval) -> String { + if duration < 1 { + return String(format: "%.0fms", duration * 1000) + } + if duration < 60 { + return String(format: "%.1fs", duration) + } + let minutes = Int(duration) / 60 + let seconds = duration.truncatingRemainder(dividingBy: 60) + return String(format: "%dm %.0fs", minutes, seconds) + } +} + +struct CircularCheckboxStyle: ToggleStyle { + func makeBody(configuration: Configuration) -> some View { + Button(action: { + configuration.isOn.toggle() + }) { + Image(systemName: configuration.isOn ? "checkmark.circle.fill" : "circle") + .symbolRenderingMode(.hierarchical) + .foregroundColor(configuration.isOn ? Color(NSColor.controlAccentColor) : .secondary) + .font(.system(size: 18)) + } + .buttonStyle(.plain) + } +} diff --git a/VoiceInk/Views/History/TranscriptionMetadataView.swift b/VoiceInk/Views/History/TranscriptionMetadataView.swift new file mode 100644 index 0000000..6c9b987 --- /dev/null +++ b/VoiceInk/Views/History/TranscriptionMetadataView.swift @@ -0,0 +1,184 @@ +import SwiftUI + +struct TranscriptionMetadataView: View { + let transcription: Transcription + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + Text("Details") + .font(.system(size: 14, weight: .semibold)) + + VStack(alignment: .leading, spacing: 8) { + metadataRow( + icon: "calendar", + label: "Date", + value: transcription.timestamp.formatted(date: .abbreviated, time: .shortened) + ) + + Divider() + + metadataRow( + icon: "hourglass", + label: "Duration", + value: formatTiming(transcription.duration) + ) + + if let modelName = transcription.transcriptionModelName { + Divider() + metadataRow( + icon: "cpu.fill", + label: "Transcription Model", + value: modelName + ) + + if let duration = transcription.transcriptionDuration { + Divider() + metadataRow( + icon: "clock.fill", + label: "Transcription Time", + value: formatTiming(duration) + ) + } + } + + if let aiModel = transcription.aiEnhancementModelName { + Divider() + metadataRow( + icon: "sparkles", + label: "Enhancement Model", + value: aiModel + ) + + if let duration = transcription.enhancementDuration { + Divider() + metadataRow( + icon: "clock.fill", + label: "Enhancement Time", + value: formatTiming(duration) + ) + } + } + + if let promptName = transcription.promptName { + Divider() + metadataRow( + icon: "text.bubble.fill", + label: "Prompt", + value: promptName + ) + } + + if let powerModeValue = powerModeDisplay( + name: transcription.powerModeName, + emoji: transcription.powerModeEmoji + ) { + Divider() + metadataRow( + icon: "bolt.fill", + label: "Power Mode", + value: powerModeValue + ) + } + } + .padding(14) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(.thinMaterial) + ) + + if transcription.aiRequestSystemMessage != nil || transcription.aiRequestUserMessage != nil { + VStack(alignment: .leading, spacing: 12) { + Text("AI Request") + .font(.system(size: 14, weight: .semibold)) + + ScrollView { + VStack(alignment: .leading, spacing: 12) { + if let systemMsg = transcription.aiRequestSystemMessage, !systemMsg.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text("System Prompt") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.secondary) + Text(systemMsg) + .font(.system(size: 11, weight: .regular, design: .monospaced)) + .lineSpacing(2) + .textSelection(.enabled) + .foregroundColor(.primary) + } + } + + if let userMsg = transcription.aiRequestUserMessage, !userMsg.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text("User Message") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.secondary) + Text(userMsg) + .font(.system(size: 11, weight: .regular, design: .monospaced)) + .lineSpacing(2) + .textSelection(.enabled) + .foregroundColor(.primary) + } + } + } + .padding(14) + } + .frame(minHeight: 250, maxHeight: 500) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(.thinMaterial) + ) + } + } + } + .padding(12) + } + .background(Color(NSColor.controlBackgroundColor)) + } + + private func metadataRow(icon: String, label: String, value: String) -> some View { + HStack(spacing: 8) { + Image(systemName: icon) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.secondary) + .frame(width: 20, height: 20) + + Text(label) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.secondary) + + Spacer(minLength: 0) + + Text(value) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.primary) + .lineLimit(1) + } + } + + private func formatTiming(_ duration: TimeInterval) -> String { + if duration < 1 { + return String(format: "%.0fms", duration * 1000) + } + if duration < 60 { + return String(format: "%.1fs", duration) + } + let minutes = Int(duration) / 60 + let seconds = duration.truncatingRemainder(dividingBy: 60) + return String(format: "%dm %.0fs", minutes, seconds) + } + + private func powerModeDisplay(name: String?, emoji: String?) -> String? { + guard name != nil || emoji != nil else { return nil } + + switch (emoji?.trimmingCharacters(in: .whitespacesAndNewlines), name?.trimmingCharacters(in: .whitespacesAndNewlines)) { + case let (.some(emojiValue), .some(nameValue)) where !emojiValue.isEmpty && !nameValue.isEmpty: + return "\(emojiValue) \(nameValue)" + case let (.some(emojiValue), _) where !emojiValue.isEmpty: + return emojiValue + case let (_, .some(nameValue)) where !nameValue.isEmpty: + return nameValue + default: + return nil + } + } +} diff --git a/VoiceInk/Views/TranscriptionHistoryView.swift b/VoiceInk/Views/TranscriptionHistoryView.swift deleted file mode 100644 index 1545401..0000000 --- a/VoiceInk/Views/TranscriptionHistoryView.swift +++ /dev/null @@ -1,442 +0,0 @@ -import SwiftUI -import SwiftData - -struct TranscriptionHistoryView: View { - @Environment(\.modelContext) private var modelContext - @State private var searchText = "" - @State private var expandedTranscription: Transcription? - @State private var selectedTranscriptions: Set = [] - @State private var showDeleteConfirmation = false - @State private var isViewCurrentlyVisible = false - @State private var showAnalysisView = false - - private let exportService = VoiceInkCSVExportService() - - // Pagination states - @State private var displayedTranscriptions: [Transcription] = [] - @State private var isLoading = false - @State private var hasMoreContent = true - - // Cursor-based pagination - track the last timestamp - @State private var lastTimestamp: Date? - private let pageSize = 20 - - @Query(Self.createLatestTranscriptionIndicatorDescriptor()) private var latestTranscriptionIndicator: [Transcription] - - // Static function to create the FetchDescriptor for the latest transcription indicator - private static func createLatestTranscriptionIndicatorDescriptor() -> FetchDescriptor { - var descriptor = FetchDescriptor( - sortBy: [SortDescriptor(\.timestamp, order: .reverse)] - ) - descriptor.fetchLimit = 1 - return descriptor - } - - // Cursor-based query descriptor - private func cursorQueryDescriptor(after timestamp: Date? = nil) -> FetchDescriptor { - var descriptor = FetchDescriptor( - sortBy: [SortDescriptor(\Transcription.timestamp, order: .reverse)] - ) - - // Build the predicate based on search text and timestamp cursor - if let timestamp = timestamp { - if !searchText.isEmpty { - descriptor.predicate = #Predicate { transcription in - (transcription.text.localizedStandardContains(searchText) || - (transcription.enhancedText?.localizedStandardContains(searchText) ?? false)) && - transcription.timestamp < timestamp - } - } else { - descriptor.predicate = #Predicate { transcription in - transcription.timestamp < timestamp - } - } - } else if !searchText.isEmpty { - descriptor.predicate = #Predicate { transcription in - transcription.text.localizedStandardContains(searchText) || - (transcription.enhancedText?.localizedStandardContains(searchText) ?? false) - } - } - - descriptor.fetchLimit = pageSize - return descriptor - } - - var body: some View { - ZStack(alignment: .bottom) { - VStack(spacing: 0) { - searchBar - - if displayedTranscriptions.isEmpty && !isLoading { - emptyStateView - } else { - ScrollViewReader { proxy in - ScrollView { - LazyVStack(spacing: 10) { - ForEach(displayedTranscriptions) { transcription in - TranscriptionCard( - transcription: transcription, - isExpanded: expandedTranscription == transcription, - isSelected: selectedTranscriptions.contains(transcription), - onDelete: { deleteTranscription(transcription) }, - onToggleSelection: { toggleSelection(transcription) } - ) - .id(transcription) // Using the object as its own ID - .onTapGesture { - withAnimation(.easeInOut(duration: 0.25)) { - if expandedTranscription == transcription { - expandedTranscription = nil - } else { - expandedTranscription = transcription - } - } - } - } - - if hasMoreContent { - Button(action: { - Task { - await loadMoreContent() - } - }) { - HStack(spacing: 8) { - if isLoading { - ProgressView() - .controlSize(.small) - } - Text(isLoading ? "Loading..." : "Load More") - .font(.system(size: 14, weight: .medium)) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(CardBackground(isSelected: false)) - } - .buttonStyle(.plain) - .disabled(isLoading) - .padding(.top, 12) - } - } - .animation(.easeInOut(duration: 0.3), value: expandedTranscription) - .padding(24) - // Add bottom padding to ensure content is not hidden by the toolbar when visible - .padding(.bottom, !selectedTranscriptions.isEmpty ? 60 : 0) - } - .padding(.vertical, 16) - .onChange(of: expandedTranscription) { old, new in - if let transcription = new { - proxy.scrollTo(transcription, anchor: nil) - } - } - } - } - } - .background(Color(NSColor.controlBackgroundColor)) - - // Selection toolbar as an overlay - if !selectedTranscriptions.isEmpty { - selectionToolbar - .transition(.move(edge: .bottom).combined(with: .opacity)) - .animation(.easeInOut(duration: 0.3), value: !selectedTranscriptions.isEmpty) - } - } - .alert("Delete Selected Items?", isPresented: $showDeleteConfirmation) { - Button("Delete", role: .destructive) { - deleteSelectedTranscriptions() - } - Button("Cancel", role: .cancel) {} - } message: { - Text("This action cannot be undone. Are you sure you want to delete \(selectedTranscriptions.count) item\(selectedTranscriptions.count == 1 ? "" : "s")?") - } - .sheet(isPresented: $showAnalysisView) { - if !selectedTranscriptions.isEmpty { - PerformanceAnalysisView(transcriptions: Array(selectedTranscriptions)) - } - } - .onAppear { - isViewCurrentlyVisible = true - Task { - await loadInitialContent() - } - } - .onDisappear { - isViewCurrentlyVisible = false - } - .onChange(of: searchText) { _, _ in - Task { - await resetPagination() - await loadInitialContent() - } - } - // Improved change detection for new transcriptions - .onChange(of: latestTranscriptionIndicator.first?.id) { oldId, newId in - guard isViewCurrentlyVisible else { return } // Only proceed if the view is visible - - // Check if a new transcription was added or the latest one changed - if newId != oldId { - // Only refresh if we're on the first page (no pagination cursor set) - // or if the view is active and new content is relevant. - if lastTimestamp == nil { - Task { - await resetPagination() - await loadInitialContent() - } - } else { - // Reset pagination to show the latest content - Task { - await resetPagination() - await loadInitialContent() - } - } - } - } - } - - private var searchBar: some View { - HStack { - Image(systemName: "magnifyingglass") - .foregroundColor(.secondary) - TextField("Search transcriptions", text: $searchText) - .font(.system(size: 16, weight: .regular, design: .default)) - .textFieldStyle(PlainTextFieldStyle()) - } - .padding(12) - .background(CardBackground(isSelected: false)) - .padding(.horizontal, 24) - .padding(.vertical, 16) - } - - private var emptyStateView: some View { - VStack(spacing: 20) { - Image(systemName: "doc.text.magnifyingglass") - .font(.system(size: 50)) - .foregroundColor(.secondary) - Text("No transcriptions found") - .font(.system(size: 24, weight: .semibold, design: .default)) - Text("Your history will appear here") - .font(.system(size: 18, weight: .regular, design: .default)) - .foregroundColor(.secondary) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(CardBackground(isSelected: false)) - .padding(24) - } - - private var selectionToolbar: some View { - HStack(spacing: 12) { - Text("\(selectedTranscriptions.count) selected") - .foregroundColor(.secondary) - .font(.system(size: 14)) - - Spacer() - - Button(action: { - showAnalysisView = true - }) { - HStack(spacing: 4) { - Image(systemName: "chart.bar.xaxis") - Text("Analyze") - } - } - .buttonStyle(.borderless) - - Button(action: { - exportService.exportTranscriptionsToCSV(transcriptions: Array(selectedTranscriptions)) - }) { - HStack(spacing: 4) { - Image(systemName: "square.and.arrow.up") - Text("Export") - } - } - .buttonStyle(.borderless) - - Button(action: { - showDeleteConfirmation = true - }) { - HStack(spacing: 4) { - Image(systemName: "trash") - Text("Delete") - } - } - .buttonStyle(.borderless) - - if selectedTranscriptions.count < displayedTranscriptions.count { - Button("Select All") { - Task { - await selectAllTranscriptions() - } - } - .buttonStyle(.borderless) - } else { - Button("Deselect All") { - selectedTranscriptions.removeAll() - } - .buttonStyle(.borderless) - } - } - .padding(16) - .frame(maxWidth: .infinity) - .background( - Color(.windowBackgroundColor) - .shadow(color: Color.black.opacity(0.1), radius: 3, y: -2) - ) - } - - @MainActor - private func loadInitialContent() async { - isLoading = true - defer { isLoading = false } - - do { - // Reset cursor - lastTimestamp = nil - - // Fetch initial page without a cursor - let items = try modelContext.fetch(cursorQueryDescriptor()) - - displayedTranscriptions = items - // Update cursor to the timestamp of the last item - lastTimestamp = items.last?.timestamp - // If we got fewer items than the page size, there are no more items - hasMoreContent = items.count == pageSize - } catch { - print("Error loading transcriptions: \(error)") - } - } - - @MainActor - private func loadMoreContent() async { - guard !isLoading, hasMoreContent, let lastTimestamp = lastTimestamp else { return } - - isLoading = true - defer { isLoading = false } - - do { - // Fetch next page using the cursor - let newItems = try modelContext.fetch(cursorQueryDescriptor(after: lastTimestamp)) - - // Append new items to the displayed list - displayedTranscriptions.append(contentsOf: newItems) - // Update cursor to the timestamp of the last new item - self.lastTimestamp = newItems.last?.timestamp - // If we got fewer items than the page size, there are no more items - hasMoreContent = newItems.count == pageSize - } catch { - print("Error loading more transcriptions: \(error)") - } - } - - @MainActor - private func resetPagination() { - displayedTranscriptions = [] - lastTimestamp = nil - hasMoreContent = true - isLoading = false - } - - private func deleteTranscription(_ transcription: Transcription) { - // First delete the audio file if it exists - if let urlString = transcription.audioFileURL, - let url = URL(string: urlString) { - try? FileManager.default.removeItem(at: url) - } - - modelContext.delete(transcription) - if expandedTranscription == transcription { - expandedTranscription = nil - } - - // Remove from selection if selected - selectedTranscriptions.remove(transcription) - - // Refresh the view - Task { - try? await modelContext.save() - await loadInitialContent() - } - } - - private func deleteSelectedTranscriptions() { - // Delete audio files and transcriptions - for transcription in selectedTranscriptions { - if let urlString = transcription.audioFileURL, - let url = URL(string: urlString) { - try? FileManager.default.removeItem(at: url) - } - modelContext.delete(transcription) - if expandedTranscription == transcription { - expandedTranscription = nil - } - } - - // Clear selection - selectedTranscriptions.removeAll() - - // Save changes and refresh - Task { - try? await modelContext.save() - await loadInitialContent() - } - } - - private func toggleSelection(_ transcription: Transcription) { - if selectedTranscriptions.contains(transcription) { - selectedTranscriptions.remove(transcription) - } else { - selectedTranscriptions.insert(transcription) - } - } - - // Modified function to select all transcriptions in the database - private func selectAllTranscriptions() async { - do { - // Create a descriptor without pagination limits to get all IDs - var allDescriptor = FetchDescriptor() - - // Apply search filter if needed - if !searchText.isEmpty { - allDescriptor.predicate = #Predicate { transcription in - transcription.text.localizedStandardContains(searchText) || - (transcription.enhancedText?.localizedStandardContains(searchText) ?? false) - } - } - - // For better performance, only fetch the IDs - allDescriptor.propertiesToFetch = [\.id] - - // Fetch all matching transcriptions - let allTranscriptions = try modelContext.fetch(allDescriptor) - - // Create a set of all visible transcriptions for quick lookup - let visibleIds = Set(displayedTranscriptions.map { $0.id }) - - // Add all transcriptions to the selection - await MainActor.run { - // First add all visible transcriptions directly - selectedTranscriptions = Set(displayedTranscriptions) - - // Then add any non-visible transcriptions by ID - for transcription in allTranscriptions { - if !visibleIds.contains(transcription.id) { - selectedTranscriptions.insert(transcription) - } - } - } - } catch { - print("Error selecting all transcriptions: \(error)") - } - } -} - -struct CircularCheckboxStyle: ToggleStyle { - func makeBody(configuration: Configuration) -> some View { - Button(action: { - configuration.isOn.toggle() - }) { - Image(systemName: configuration.isOn ? "checkmark.circle.fill" : "circle") - .symbolRenderingMode(.hierarchical) - .foregroundColor(configuration.isOn ? .blue : .gray) - .font(.system(size: 18)) - } - .buttonStyle(.plain) - } -} From 0b6c9bccc143d52f00cc44c5c0731761d155115d Mon Sep 17 00:00:00 2001 From: Beingpax Date: Mon, 29 Dec 2025 12:13:46 +0545 Subject: [PATCH 4/8] Fix errors --- VoiceInk/HistoryWindowController.swift | 5 +- VoiceInk/Views/ContentView.swift | 46 +++++++++---------- .../History/TranscriptionHistoryView.swift | 18 ++++++-- 3 files changed, 39 insertions(+), 30 deletions(-) diff --git a/VoiceInk/HistoryWindowController.swift b/VoiceInk/HistoryWindowController.swift index 8012bc8..55c7266 100644 --- a/VoiceInk/HistoryWindowController.swift +++ b/VoiceInk/HistoryWindowController.swift @@ -14,7 +14,10 @@ class HistoryWindowController: NSObject, NSWindowDelegate { } func showHistoryWindow(modelContainer: ModelContainer, whisperState: WhisperState) { - if let existingWindow = historyWindow, existingWindow.isVisible { + if let existingWindow = historyWindow { + if existingWindow.isMiniaturized { + existingWindow.deminiaturize(nil) + } existingWindow.makeKeyAndOrderFront(nil) NSApplication.shared.activate(ignoringOtherApps: true) return diff --git a/VoiceInk/Views/ContentView.swift b/VoiceInk/Views/ContentView.swift index be1a8b8..017502a 100644 --- a/VoiceInk/Views/ContentView.swift +++ b/VoiceInk/Views/ContentView.swift @@ -113,36 +113,14 @@ struct ContentView: View { whisperState: whisperState ) }) { - HStack(spacing: 12) { - Image(systemName: viewType.icon) - .font(.system(size: 18, weight: .medium)) - .frame(width: 24, height: 24) - - Text(viewType.rawValue) - .font(.system(size: 14, weight: .medium)) - - Spacer() - } - .padding(.vertical, 8) - .padding(.horizontal, 2) + SidebarItemView(viewType: viewType) } .buttonStyle(.plain) .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) .listRowSeparator(.hidden) } else { NavigationLink(value: viewType) { - HStack(spacing: 12) { - Image(systemName: viewType.icon) - .font(.system(size: 18, weight: .medium)) - .frame(width: 24, height: 24) - - Text(viewType.rawValue) - .font(.system(size: 14, weight: .medium)) - - Spacer() - } - .padding(.vertical, 8) - .padding(.horizontal, 2) + SidebarItemView(viewType: viewType) } .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) .listRowSeparator(.hidden) @@ -225,4 +203,22 @@ struct ContentView: View { } } - +private struct SidebarItemView: View { + let viewType: ViewType + + var body: some View { + HStack(spacing: 12) { + Image(systemName: viewType.icon) + .font(.system(size: 18, weight: .medium)) + .frame(width: 24, height: 24) + + Text(viewType.rawValue) + .font(.system(size: 14, weight: .medium)) + + Spacer() + } + .padding(.vertical, 8) + .padding(.horizontal, 2) + } +} + diff --git a/VoiceInk/Views/History/TranscriptionHistoryView.swift b/VoiceInk/Views/History/TranscriptionHistoryView.swift index 19e6d58..4c6c66c 100644 --- a/VoiceInk/Views/History/TranscriptionHistoryView.swift +++ b/VoiceInk/Views/History/TranscriptionHistoryView.swift @@ -357,8 +357,13 @@ struct TranscriptionHistoryView: View { selectedTranscriptions.remove(transcription) Task { - try? modelContext.save() - await loadInitialContent() + do { + try modelContext.save() + await loadInitialContent() + } catch { + print("Error saving deletion: \(error.localizedDescription)") + await loadInitialContent() + } } } @@ -377,8 +382,13 @@ struct TranscriptionHistoryView: View { selectedTranscriptions.removeAll() Task { - try? modelContext.save() - await loadInitialContent() + do { + try modelContext.save() + await loadInitialContent() + } catch { + print("Error saving deletion: \(error.localizedDescription)") + await loadInitialContent() + } } } From 61a0e82a6d0637e55af1c4e032331fd12107b67d Mon Sep 17 00:00:00 2001 From: Beingpax Date: Tue, 30 Dec 2025 10:20:12 +0545 Subject: [PATCH 5/8] Add global keyboard shortcut to open history window from anywhere --- VoiceInk/HotkeyManager.swift | 13 ++++- .../History/HistoryShortcutTipView.swift | 47 +++++++++++++++++++ .../History/TranscriptionHistoryView.swift | 35 ++++++++++---- 3 files changed, 84 insertions(+), 11 deletions(-) create mode 100644 VoiceInk/Views/History/HistoryShortcutTipView.swift diff --git a/VoiceInk/HotkeyManager.swift b/VoiceInk/HotkeyManager.swift index 2cc8309..2481942 100644 --- a/VoiceInk/HotkeyManager.swift +++ b/VoiceInk/HotkeyManager.swift @@ -9,6 +9,7 @@ extension KeyboardShortcuts.Name { static let pasteLastTranscription = Self("pasteLastTranscription") static let pasteLastEnhancement = Self("pasteLastEnhancement") static let retryLastTranscription = Self("retryLastTranscription") + static let openHistoryWindow = Self("openHistoryWindow") } @MainActor @@ -147,7 +148,17 @@ class HotkeyManager: ObservableObject { LastTranscriptionService.retryLastTranscription(from: self.whisperState.modelContext, whisperState: self.whisperState) } } - + + KeyboardShortcuts.onKeyUp(for: .openHistoryWindow) { [weak self] in + guard let self = self else { return } + Task { @MainActor in + HistoryWindowController.shared.showHistoryWindow( + modelContainer: self.whisperState.modelContext.container, + whisperState: self.whisperState + ) + } + } + Task { @MainActor in try? await Task.sleep(nanoseconds: 100_000_000) self.setupHotkeyMonitoring() diff --git a/VoiceInk/Views/History/HistoryShortcutTipView.swift b/VoiceInk/Views/History/HistoryShortcutTipView.swift new file mode 100644 index 0000000..d3df02e --- /dev/null +++ b/VoiceInk/Views/History/HistoryShortcutTipView.swift @@ -0,0 +1,47 @@ +import SwiftUI +import KeyboardShortcuts + +struct HistoryShortcutTipView: View { + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 12) { + Image(systemName: "command.circle") + .font(.system(size: 20)) + .foregroundColor(.accentColor) + .frame(width: 24, height: 24) + + VStack(alignment: .leading, spacing: 2) { + Text("Quick Access") + .font(.headline) + Text("Open history from anywhere with a global shortcut") + .font(.subheadline) + .foregroundColor(.secondary) + } + } + + Divider() + .padding(.vertical, 4) + + HStack(spacing: 12) { + Text("Open History Window") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.secondary) + + KeyboardShortcuts.Recorder(for: .openHistoryWindow) + .controlSize(.small) + + Spacer() + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(NSColor.controlBackgroundColor).opacity(0.5)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(Color(NSColor.separatorColor).opacity(0.3), lineWidth: 1) + ) + } +} diff --git a/VoiceInk/Views/History/TranscriptionHistoryView.swift b/VoiceInk/Views/History/TranscriptionHistoryView.swift index 4c6c66c..bd27746 100644 --- a/VoiceInk/Views/History/TranscriptionHistoryView.swift +++ b/VoiceInk/Views/History/TranscriptionHistoryView.swift @@ -223,16 +223,31 @@ struct TranscriptionHistoryView: View { TranscriptionDetailView(transcription: transcription) .id(transcription.id) } else { - VStack(spacing: 12) { - Image(systemName: "doc.text") - .font(.system(size: 50)) - .foregroundColor(.secondary) - Text("No Selection") - .font(.system(size: 18, weight: .medium)) - .foregroundColor(.secondary) - Text("Select a transcription to view details") - .font(.system(size: 14)) - .foregroundColor(.secondary) + ScrollView { + VStack(spacing: 32) { + Spacer() + .frame(minHeight: 40) + + VStack(spacing: 12) { + Image(systemName: "doc.text") + .font(.system(size: 50)) + .foregroundColor(.secondary) + Text("No Selection") + .font(.system(size: 18, weight: .medium)) + .foregroundColor(.secondary) + Text("Select a transcription to view details") + .font(.system(size: 14)) + .foregroundColor(.secondary) + } + + HistoryShortcutTipView() + .padding(.horizontal, 24) + + Spacer() + .frame(minHeight: 40) + } + .frame(maxWidth: .infinity) + .frame(minHeight: 600) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(NSColor.controlBackgroundColor)) From 27186837410fdad13dae713113eda754a33a6974 Mon Sep 17 00:00:00 2001 From: Beingpax Date: Tue, 30 Dec 2025 20:58:41 +0545 Subject: [PATCH 6/8] Add copy button --- .../History/TranscriptionDetailView.swift | 69 ++-- VoiceInk/Views/TranscriptionCard.swift | 324 ------------------ 2 files changed, 49 insertions(+), 344 deletions(-) delete mode 100644 VoiceInk/Views/TranscriptionCard.swift diff --git a/VoiceInk/Views/History/TranscriptionDetailView.swift b/VoiceInk/Views/History/TranscriptionDetailView.swift index b826db2..94092bd 100644 --- a/VoiceInk/Views/History/TranscriptionDetailView.swift +++ b/VoiceInk/Views/History/TranscriptionDetailView.swift @@ -59,6 +59,7 @@ private struct MessageBubble: View { let label: String let text: String let isEnhanced: Bool + @State private var justCopied = false var body: some View { HStack(alignment: .bottom) { @@ -70,31 +71,59 @@ private struct MessageBubble: View { .foregroundColor(.secondary.opacity(0.7)) .padding(.horizontal, 12) - ScrollView { - Text(text) - .font(.system(size: 14, weight: .regular)) - .lineSpacing(2) - .textSelection(.enabled) - .padding(.horizontal, 12) - .padding(.vertical, 10) - } - .frame(maxHeight: 350) - .background { - if isEnhanced { - RoundedRectangle(cornerRadius: 18, style: .continuous) - .fill(Color.accentColor.opacity(0.2)) - } else { - RoundedRectangle(cornerRadius: 18, style: .continuous) - .fill(.thinMaterial) - .overlay( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .strokeBorder(Color.primary.opacity(0.06), lineWidth: 0.5) - ) + HStack(alignment: .top, spacing: 8) { + ScrollView { + Text(text) + .font(.system(size: 14, weight: .regular)) + .lineSpacing(2) + .textSelection(.enabled) + .padding(.horizontal, 12) + .padding(.vertical, 10) } + .frame(maxHeight: 350) + .background { + if isEnhanced { + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(Color.accentColor.opacity(0.2)) + } else { + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(.thinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .strokeBorder(Color.primary.opacity(0.06), lineWidth: 0.5) + ) + } + } + + Button(action: { + copyToClipboard(text) + }) { + Image(systemName: justCopied ? "checkmark" : "doc.on.doc") + .font(.system(size: 12)) + .foregroundColor(justCopied ? .green : .secondary) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + .help("Copy to clipboard") + .padding(.top, 8) } } if !isEnhanced { Spacer(minLength: 60) } } } + + private func copyToClipboard(_ text: String) { + let _ = ClipboardManager.copyToClipboard(text) + + withAnimation { + justCopied = true + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + withAnimation { + justCopied = false + } + } + } } diff --git a/VoiceInk/Views/TranscriptionCard.swift b/VoiceInk/Views/TranscriptionCard.swift deleted file mode 100644 index 981a7f1..0000000 --- a/VoiceInk/Views/TranscriptionCard.swift +++ /dev/null @@ -1,324 +0,0 @@ -import SwiftUI -import SwiftData - -enum ContentTab: String, CaseIterable { - case original = "Original" - case enhanced = "Enhanced" - case aiRequest = "AI Request" -} - -struct TranscriptionCard: View { - let transcription: Transcription - let isExpanded: Bool - let isSelected: Bool - let onDelete: () -> Void - let onToggleSelection: () -> Void - - @State private var selectedTab: ContentTab = .original - - private var availableTabs: [ContentTab] { - var tabs: [ContentTab] = [] - if transcription.enhancedText != nil { - tabs.append(.enhanced) - } - tabs.append(.original) - if transcription.aiRequestSystemMessage != nil || transcription.aiRequestUserMessage != nil { - tabs.append(.aiRequest) - } - return tabs - } - - private var hasAudioFile: Bool { - if let urlString = transcription.audioFileURL, - let url = URL(string: urlString), - FileManager.default.fileExists(atPath: url.path) { - return true - } - return false - } - - private var copyTextForCurrentTab: String { - switch selectedTab { - case .original: - return transcription.text - case .enhanced: - return transcription.enhancedText ?? transcription.text - case .aiRequest: - var result = "" - if let systemMsg = transcription.aiRequestSystemMessage, !systemMsg.isEmpty { - result += systemMsg - } - if let userMsg = transcription.aiRequestUserMessage, !userMsg.isEmpty { - if !result.isEmpty { - result += "\n\n" - } - result += userMsg - } - return result.isEmpty ? transcription.text : result - } - } - - private var originalContentView: some View { - Text(transcription.text) - .font(.system(size: 15, weight: .regular, design: .default)) - .lineSpacing(2) - .textSelection(.enabled) - } - - private func enhancedContentView(_ enhancedText: String) -> some View { - Text(enhancedText) - .font(.system(size: 15, weight: .regular, design: .default)) - .lineSpacing(2) - .textSelection(.enabled) - } - - private var aiRequestContentView: some View { - VStack(alignment: .leading, spacing: 12) { - - if let systemMsg = transcription.aiRequestSystemMessage, !systemMsg.isEmpty { - VStack(alignment: .leading, spacing: 6) { - Text("System Prompt") - .font(.system(size: 13, weight: .semibold)) - .foregroundColor(.secondary) - Text(systemMsg) - .font(.system(size: 13, weight: .regular, design: .monospaced)) - .lineSpacing(2) - .textSelection(.enabled) - } - } - - if let userMsg = transcription.aiRequestUserMessage, !userMsg.isEmpty { - VStack(alignment: .leading, spacing: 6) { - Text("User Message") - .font(.system(size: 13, weight: .semibold)) - .foregroundColor(.secondary) - Text(userMsg) - .font(.system(size: 13, weight: .regular, design: .monospaced)) - .lineSpacing(2) - .textSelection(.enabled) - } - } - } - } - - private struct TabButton: View { - let title: String - let isSelected: Bool - let action: () -> Void - - var body: some View { - Button(action: action) { - Text(title) - .font(.system(size: 13, weight: isSelected ? .semibold : .medium)) - .foregroundColor(isSelected ? .white : .secondary) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background( - RoundedRectangle(cornerRadius: 16) - .fill(isSelected ? Color.accentColor.opacity(0.75) : Color.clear) - .overlay( - RoundedRectangle(cornerRadius: 16) - .stroke(isSelected ? Color.clear : Color.secondary.opacity(0.3), lineWidth: 1) - ) - .contentShape(.capsule) - ) - } - .buttonStyle(.plain) - .animation(.easeInOut(duration: 0.2), value: isSelected) - } - } - - var body: some View { - HStack(spacing: 12) { - Toggle("", isOn: Binding( - get: { isSelected }, - set: { _ in onToggleSelection() } - )) - .toggleStyle(CircularCheckboxStyle()) - .labelsHidden() - - VStack(alignment: .leading, spacing: 12) { - HStack { - Text(transcription.timestamp, format: .dateTime.month(.abbreviated).day().year().hour().minute()) - .font(.system(size: 14, weight: .medium, design: .default)) - .foregroundColor(.secondary) - Spacer() - - Text(formatTiming(transcription.duration)) - .font(.system(size: 14, weight: .medium, design: .default)) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(Color.blue.opacity(0.1)) - .foregroundColor(.blue) - .cornerRadius(6) - } - - if isExpanded { - HStack(spacing: 4) { - ForEach(availableTabs, id: \.self) { tab in - TabButton( - title: tab.rawValue, - isSelected: selectedTab == tab, - action: { selectedTab = tab } - ) - } - - Spacer() - - AnimatedCopyButton(textToCopy: copyTextForCurrentTab) - } - .padding(.vertical, 8) - .padding(.horizontal, 4) - - ScrollView { - VStack(alignment: .leading, spacing: 12) { - switch selectedTab { - case .original: - originalContentView - case .enhanced: - if let enhancedText = transcription.enhancedText { - enhancedContentView(enhancedText) - } - case .aiRequest: - aiRequestContentView - } - } - .padding(.vertical, 8) - } - .frame(maxHeight: 300) - .cornerRadius(8) - - if hasAudioFile, let urlString = transcription.audioFileURL, - let url = URL(string: urlString) { - Divider() - .padding(.vertical, 8) - AudioPlayerView(url: url) - } - - if hasMetadata { - Divider() - .padding(.vertical, 8) - - VStack(alignment: .leading, spacing: 10) { - if let powerModeValue = powerModeDisplay( - name: transcription.powerModeName, - emoji: transcription.powerModeEmoji - ) { - metadataRow( - icon: "bolt.fill", - label: "Power Mode", - value: powerModeValue - ) - } - metadataRow(icon: "hourglass", label: "Audio Duration", value: formatTiming(transcription.duration)) - if let modelName = transcription.transcriptionModelName { - metadataRow(icon: "cpu.fill", label: "Transcription Model", value: modelName) - } - if let aiModel = transcription.aiEnhancementModelName { - metadataRow(icon: "sparkles", label: "Enhancement Model", value: aiModel) - } - if let promptName = transcription.promptName { - metadataRow(icon: "text.bubble.fill", label: "Prompt Used", value: promptName) - } - if let duration = transcription.transcriptionDuration { - metadataRow(icon: "clock.fill", label: "Transcription Time", value: formatTiming(duration)) - } - if let duration = transcription.enhancementDuration { - metadataRow(icon: "clock.fill", label: "Enhancement Time", value: formatTiming(duration)) - } - } - } - } else { - Text(transcription.enhancedText ?? transcription.text) - .font(.system(size: 15, weight: .regular, design: .default)) - .lineLimit(2) - .lineSpacing(2) - } - } - } - .padding(16) - .background(CardBackground(isSelected: false)) - .cornerRadius(12) - .shadow(color: Color.black.opacity(0.05), radius: 3, x: 0, y: 2) - .contextMenu { - if let enhancedText = transcription.enhancedText { - Button { - let _ = ClipboardManager.copyToClipboard(enhancedText) - } label: { - Label("Copy Enhanced", systemImage: "doc.on.doc") - } - } - - Button { - let _ = ClipboardManager.copyToClipboard(transcription.text) - } label: { - Label("Copy Original", systemImage: "doc.on.doc") - } - - Button(role: .destructive) { - onDelete() - } label: { - Label("Delete", systemImage: "trash") - } - } - .onChange(of: isExpanded) { oldValue, newValue in - if newValue { - selectedTab = transcription.enhancedText != nil ? .enhanced : .original - } - } - } - - private var hasMetadata: Bool { - transcription.powerModeName != nil || - transcription.powerModeEmoji != nil || - transcription.transcriptionModelName != nil || - transcription.aiEnhancementModelName != nil || - transcription.promptName != nil || - transcription.transcriptionDuration != nil || - transcription.enhancementDuration != nil - } - - private func formatTiming(_ duration: TimeInterval) -> String { - if duration < 1 { - return String(format: "%.0fms", duration * 1000) - } - if duration < 60 { - return String(format: "%.1fs", duration) - } - let minutes = Int(duration) / 60 - let seconds = duration.truncatingRemainder(dividingBy: 60) - return String(format: "%dm %.0fs", minutes, seconds) - } - - private func metadataRow(icon: String, label: String, value: String) -> some View { - HStack(spacing: 12) { - Image(systemName: icon) - .font(.system(size: 13, weight: .medium)) - .foregroundColor(.secondary) - .frame(width: 20, alignment: .center) - - Text(label) - .font(.system(size: 13, weight: .medium)) - .foregroundColor(.primary) - Spacer() - Text(value) - .font(.system(size: 13, weight: .semibold)) - .foregroundColor(.secondary) - } - } - - private func powerModeDisplay(name: String?, emoji: String?) -> String? { - guard name != nil || emoji != nil else { return nil } - - switch (emoji?.trimmingCharacters(in: .whitespacesAndNewlines), name?.trimmingCharacters(in: .whitespacesAndNewlines)) { - case let (.some(emojiValue), .some(nameValue)) where !emojiValue.isEmpty && !nameValue.isEmpty: - return "\(emojiValue) \(nameValue)" - case let (.some(emojiValue), _) where !emojiValue.isEmpty: - return emojiValue - case let (_, .some(nameValue)) where !nameValue.isEmpty: - return nameValue - default: - return nil - } - } -} From 444d97112a0cce8b655c8fe1c2f63c918741541d Mon Sep 17 00:00:00 2001 From: Beingpax Date: Wed, 31 Dec 2025 14:13:01 +0545 Subject: [PATCH 7/8] Add inline copy buttons to detail view --- .../History/TranscriptionDetailView.swift | 51 ++++++++++--------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/VoiceInk/Views/History/TranscriptionDetailView.swift b/VoiceInk/Views/History/TranscriptionDetailView.swift index 94092bd..9606652 100644 --- a/VoiceInk/Views/History/TranscriptionDetailView.swift +++ b/VoiceInk/Views/History/TranscriptionDetailView.swift @@ -71,41 +71,42 @@ private struct MessageBubble: View { .foregroundColor(.secondary.opacity(0.7)) .padding(.horizontal, 12) - HStack(alignment: .top, spacing: 8) { - ScrollView { - Text(text) - .font(.system(size: 14, weight: .regular)) - .lineSpacing(2) - .textSelection(.enabled) - .padding(.horizontal, 12) - .padding(.vertical, 10) + ScrollView { + Text(text) + .font(.system(size: 14, weight: .regular)) + .lineSpacing(2) + .textSelection(.enabled) + .padding(.horizontal, 12) + .padding(.vertical, 10) + } + .frame(maxHeight: 350) + .background { + if isEnhanced { + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(Color.accentColor.opacity(0.2)) + } else { + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(.thinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .strokeBorder(Color.primary.opacity(0.06), lineWidth: 0.5) + ) } - .frame(maxHeight: 350) - .background { - if isEnhanced { - RoundedRectangle(cornerRadius: 18, style: .continuous) - .fill(Color.accentColor.opacity(0.2)) - } else { - RoundedRectangle(cornerRadius: 18, style: .continuous) - .fill(.thinMaterial) - .overlay( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .strokeBorder(Color.primary.opacity(0.06), lineWidth: 0.5) - ) - } - } - + } + .overlay(alignment: .bottomTrailing) { Button(action: { copyToClipboard(text) }) { Image(systemName: justCopied ? "checkmark" : "doc.on.doc") .font(.system(size: 12)) .foregroundColor(justCopied ? .green : .secondary) - .frame(width: 24, height: 24) + .frame(width: 28, height: 28) + .background(Color(NSColor.controlBackgroundColor).opacity(0.9)) + .clipShape(Circle()) } .buttonStyle(.plain) .help("Copy to clipboard") - .padding(.top, 8) + .padding(8) } } From 2a8ed6d08c27609071db1ec7aa05ba80ab9a39a3 Mon Sep 17 00:00:00 2001 From: Beingpax Date: Wed, 31 Dec 2025 20:51:03 +0545 Subject: [PATCH 8/8] Refactor: Extract formatTiming to extension, deduplicate file deletion logic, add AudioPlayerManager cleanup, and implement waveform caching --- VoiceInk/Views/AudioPlayerView.swift | 57 +++++++++++++++---- .../History/TranscriptionHistoryView.swift | 54 +++++++++--------- .../Views/History/TranscriptionListItem.swift | 14 +---- .../History/TranscriptionMetadataView.swift | 18 +----- 4 files changed, 76 insertions(+), 67 deletions(-) diff --git a/VoiceInk/Views/AudioPlayerView.swift b/VoiceInk/Views/AudioPlayerView.swift index e403d19..76209fe 100644 --- a/VoiceInk/Views/AudioPlayerView.swift +++ b/VoiceInk/Views/AudioPlayerView.swift @@ -1,37 +1,63 @@ import SwiftUI import AVFoundation +extension TimeInterval { + func formatTiming() -> String { + if self < 1 { + return String(format: "%.0fms", self * 1000) + } + if self < 60 { + return String(format: "%.1fs", self) + } + let minutes = Int(self) / 60 + let seconds = self.truncatingRemainder(dividingBy: 60) + return String(format: "%dm %.0fs", minutes, seconds) + } +} + class WaveformGenerator { + private static let cache = NSCache() + static func generateWaveformSamples(from url: URL, sampleCount: Int = 200) async -> [Float] { + let cacheKey = url.absoluteString as NSString + + if let cachedSamples = cache.object(forKey: cacheKey) as? [Float] { + return cachedSamples + } guard let audioFile = try? AVAudioFile(forReading: url) else { return [] } let format = audioFile.processingFormat let frameCount = UInt32(audioFile.length) let stride = max(1, Int(frameCount) / sampleCount) let bufferSize = min(UInt32(4096), frameCount) - + guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: bufferSize) else { return [] } - + do { var maxValues = [Float](repeating: 0.0, count: sampleCount) var sampleIndex = 0 var framePosition: AVAudioFramePosition = 0 - + while sampleIndex < sampleCount && framePosition < AVAudioFramePosition(frameCount) { audioFile.framePosition = framePosition try audioFile.read(into: buffer) - + if let channelData = buffer.floatChannelData?[0], buffer.frameLength > 0 { maxValues[sampleIndex] = abs(channelData[0]) sampleIndex += 1 } - + framePosition += AVAudioFramePosition(stride) } - + + let normalizedSamples: [Float] if let maxSample = maxValues.max(), maxSample > 0 { - return maxValues.map { $0 / maxSample } + normalizedSamples = maxValues.map { $0 / maxSample } + } else { + normalizedSamples = maxValues } - return maxValues + + cache.setObject(normalizedSamples as NSArray, forKey: cacheKey) + return normalizedSamples } catch { print("Error reading audio file: \(error)") return [] @@ -94,14 +120,20 @@ class AudioPlayerManager: ObservableObject { } } } - + private func stopTimer() { timer?.invalidate() timer = nil } - - deinit { + + func cleanup() { stopTimer() + audioPlayer?.stop() + audioPlayer = nil + } + + deinit { + cleanup() } } @@ -345,6 +377,9 @@ struct AudioPlayerView: View { .onAppear { playerManager.loadAudio(from: url) } + .onDisappear { + playerManager.cleanup() + } .overlay( VStack { if showRetranscribeSuccess { diff --git a/VoiceInk/Views/History/TranscriptionHistoryView.swift b/VoiceInk/Views/History/TranscriptionHistoryView.swift index bd27746..6c1294b 100644 --- a/VoiceInk/Views/History/TranscriptionHistoryView.swift +++ b/VoiceInk/Views/History/TranscriptionHistoryView.swift @@ -357,53 +357,51 @@ struct TranscriptionHistoryView: View { hasMoreContent = true isLoading = false } - - private func deleteTranscription(_ transcription: Transcription) { + + private func performDeletion(for transcription: Transcription) { if let urlString = transcription.audioFileURL, - let url = URL(string: urlString) { - try? FileManager.default.removeItem(at: url) + let url = URL(string: urlString), + FileManager.default.fileExists(atPath: url.path) { + do { + try FileManager.default.removeItem(at: url) + } catch { + print("Error deleting audio file: \(error.localizedDescription)") + } } - modelContext.delete(transcription) if selectedTranscription == transcription { selectedTranscription = nil } selectedTranscriptions.remove(transcription) + modelContext.delete(transcription) + } + private func saveAndReload() async { + do { + try modelContext.save() + await loadInitialContent() + } catch { + print("Error saving deletion: \(error.localizedDescription)") + await loadInitialContent() + } + } + + private func deleteTranscription(_ transcription: Transcription) { + performDeletion(for: transcription) Task { - do { - try modelContext.save() - await loadInitialContent() - } catch { - print("Error saving deletion: \(error.localizedDescription)") - await loadInitialContent() - } + await saveAndReload() } } private func deleteSelectedTranscriptions() { for transcription in selectedTranscriptions { - if let urlString = transcription.audioFileURL, - let url = URL(string: urlString) { - try? FileManager.default.removeItem(at: url) - } - modelContext.delete(transcription) - if selectedTranscription == transcription { - selectedTranscription = nil - } + performDeletion(for: transcription) } - selectedTranscriptions.removeAll() Task { - do { - try modelContext.save() - await loadInitialContent() - } catch { - print("Error saving deletion: \(error.localizedDescription)") - await loadInitialContent() - } + await saveAndReload() } } diff --git a/VoiceInk/Views/History/TranscriptionListItem.swift b/VoiceInk/Views/History/TranscriptionListItem.swift index 0e68ffd..b9e2411 100644 --- a/VoiceInk/Views/History/TranscriptionListItem.swift +++ b/VoiceInk/Views/History/TranscriptionListItem.swift @@ -23,7 +23,7 @@ struct TranscriptionListItem: View { .foregroundColor(.secondary) Spacer() if transcription.duration > 0 { - Text(formatTiming(transcription.duration)) + Text(transcription.duration.formatTiming()) .font(.system(size: 10, weight: .medium)) .padding(.horizontal, 6) .padding(.vertical, 3) @@ -54,18 +54,6 @@ struct TranscriptionListItem: View { .contentShape(Rectangle()) .onTapGesture { onSelect() } } - - private func formatTiming(_ duration: TimeInterval) -> String { - if duration < 1 { - return String(format: "%.0fms", duration * 1000) - } - if duration < 60 { - return String(format: "%.1fs", duration) - } - let minutes = Int(duration) / 60 - let seconds = duration.truncatingRemainder(dividingBy: 60) - return String(format: "%dm %.0fs", minutes, seconds) - } } struct CircularCheckboxStyle: ToggleStyle { diff --git a/VoiceInk/Views/History/TranscriptionMetadataView.swift b/VoiceInk/Views/History/TranscriptionMetadataView.swift index 6c9b987..3713bd8 100644 --- a/VoiceInk/Views/History/TranscriptionMetadataView.swift +++ b/VoiceInk/Views/History/TranscriptionMetadataView.swift @@ -21,7 +21,7 @@ struct TranscriptionMetadataView: View { metadataRow( icon: "hourglass", label: "Duration", - value: formatTiming(transcription.duration) + value: transcription.duration.formatTiming() ) if let modelName = transcription.transcriptionModelName { @@ -37,7 +37,7 @@ struct TranscriptionMetadataView: View { metadataRow( icon: "clock.fill", label: "Transcription Time", - value: formatTiming(duration) + value: duration.formatTiming() ) } } @@ -55,7 +55,7 @@ struct TranscriptionMetadataView: View { metadataRow( icon: "clock.fill", label: "Enhancement Time", - value: formatTiming(duration) + value: duration.formatTiming() ) } } @@ -155,18 +155,6 @@ struct TranscriptionMetadataView: View { } } - private func formatTiming(_ duration: TimeInterval) -> String { - if duration < 1 { - return String(format: "%.0fms", duration * 1000) - } - if duration < 60 { - return String(format: "%.1fs", duration) - } - let minutes = Int(duration) / 60 - let seconds = duration.truncatingRemainder(dividingBy: 60) - return String(format: "%dm %.0fs", minutes, seconds) - } - private func powerModeDisplay(name: String?, emoji: String?) -> String? { guard name != nil || emoji != nil else { return nil }