From 531da7b1725997ac977bdff6a2187240e4439676 Mon Sep 17 00:00:00 2001 From: Beingpax Date: Mon, 29 Dec 2025 11:44:50 +0545 Subject: [PATCH] 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) - } -}