Merge pull request #457 from Beingpax/seperate-history-view
Seperate history view
This commit is contained in:
commit
6ae8a7b5ff
80
VoiceInk/HistoryWindowController.swift
Normal file
80
VoiceInk/HistoryWindowController.swift
Normal file
@ -0,0 +1,80 @@
|
||||
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, whisperState: WhisperState) {
|
||||
if let existingWindow = historyWindow {
|
||||
if existingWindow.isMiniaturized {
|
||||
existingWindow.deminiaturize(nil)
|
||||
}
|
||||
existingWindow.makeKeyAndOrderFront(nil)
|
||||
NSApplication.shared.activate(ignoringOtherApps: true)
|
||||
return
|
||||
}
|
||||
|
||||
let window = createHistoryWindow(modelContainer: modelContainer, whisperState: whisperState)
|
||||
historyWindow = window
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
NSApplication.shared.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
private func createHistoryWindow(modelContainer: ModelContainer, whisperState: WhisperState) -> NSWindow {
|
||||
let historyView = TranscriptionHistoryView()
|
||||
.modelContainer(modelContainer)
|
||||
.environmentObject(whisperState)
|
||||
.frame(minWidth: 1000, minHeight: 700)
|
||||
|
||||
let hostingController = NSHostingController(rootView: historyView)
|
||||
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 1100, height: 750),
|
||||
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: 1000, height: 700)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import AppKit
|
||||
|
||||
class MenuBarManager: ObservableObject {
|
||||
@ -8,12 +9,19 @@ class MenuBarManager: ObservableObject {
|
||||
updateAppActivationPolicy()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private var modelContainer: ModelContainer?
|
||||
private var whisperState: WhisperState?
|
||||
|
||||
init() {
|
||||
self.isMenuBarOnly = UserDefaults.standard.bool(forKey: "IsMenuBarOnly")
|
||||
updateAppActivationPolicy()
|
||||
}
|
||||
|
||||
func configure(modelContainer: ModelContainer, whisperState: WhisperState) {
|
||||
self.modelContainer = modelContainer
|
||||
self.whisperState = whisperState
|
||||
}
|
||||
|
||||
func toggleMenuBarOnly() {
|
||||
isMenuBarOnly.toggle()
|
||||
@ -54,17 +62,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 +84,16 @@ class MenuBarManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func openHistoryWindow() {
|
||||
guard let modelContainer = modelContainer,
|
||||
let whisperState = whisperState else {
|
||||
print("MenuBarManager: Dependencies not configured")
|
||||
return
|
||||
}
|
||||
HistoryWindowController.shared.showHistoryWindow(
|
||||
modelContainer: modelContainer,
|
||||
whisperState: whisperState
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<NSString, NSArray>()
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -118,16 +150,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..<samples.count, id: \.self) { index in
|
||||
WaveformBar(
|
||||
sample: samples[index],
|
||||
@ -139,20 +171,21 @@ struct WaveformView: View {
|
||||
)
|
||||
}
|
||||
}
|
||||
.opacity(0.6)
|
||||
.frame(maxHeight: .infinity)
|
||||
.padding(.horizontal, 2)
|
||||
|
||||
|
||||
if isHovering {
|
||||
Text(formatTime(duration * Double(hoverLocation / geometry.size.width)))
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.monospacedDigit()
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(Capsule().fill(Color.accentColor))
|
||||
.offset(x: max(0, min(hoverLocation - 30, geometry.size.width - 60)))
|
||||
.offset(y: -30)
|
||||
|
||||
.offset(x: max(0, min(hoverLocation - 25, geometry.size.width - 50)))
|
||||
.offset(y: -26)
|
||||
|
||||
Rectangle()
|
||||
.fill(Color.accentColor)
|
||||
.frame(width: 2)
|
||||
@ -186,7 +219,7 @@ struct WaveformView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 56)
|
||||
.frame(height: 32)
|
||||
}
|
||||
|
||||
private func formatTime(_ time: TimeInterval) -> String {
|
||||
@ -223,10 +256,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 +280,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 +321,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 +336,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 +348,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,19 +361,25 @@ 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)
|
||||
}
|
||||
.onDisappear {
|
||||
playerManager.cleanup()
|
||||
}
|
||||
.overlay(
|
||||
VStack {
|
||||
if showRetranscribeSuccess {
|
||||
|
||||
@ -106,22 +106,25 @@ 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)
|
||||
|
||||
Text(viewType.rawValue)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
|
||||
Spacer()
|
||||
if viewType == .history {
|
||||
Button(action: {
|
||||
HistoryWindowController.shared.showHistoryWindow(
|
||||
modelContainer: modelContext.container,
|
||||
whisperState: whisperState
|
||||
)
|
||||
}) {
|
||||
SidebarItemView(viewType: viewType)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 2)
|
||||
.buttonStyle(.plain)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
.listRowSeparator(.hidden)
|
||||
} else {
|
||||
NavigationLink(value: viewType) {
|
||||
SidebarItemView(viewType: viewType)
|
||||
}
|
||||
.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 +153,10 @@ struct ContentView: View {
|
||||
case "VoiceInk Pro":
|
||||
selectedView = .license
|
||||
case "History":
|
||||
selectedView = .history
|
||||
HistoryWindowController.shared.showHistoryWindow(
|
||||
modelContainer: modelContext.container,
|
||||
whisperState: whisperState
|
||||
)
|
||||
case "Permissions":
|
||||
selectedView = .permissions
|
||||
case "Enhancement":
|
||||
@ -178,7 +184,8 @@ struct ContentView: View {
|
||||
case .transcribeAudio:
|
||||
AudioTranscribeView()
|
||||
case .history:
|
||||
TranscriptionHistoryView()
|
||||
Text("History")
|
||||
.foregroundColor(.secondary)
|
||||
case .audioInput:
|
||||
AudioInputSettingsView()
|
||||
case .dictionary:
|
||||
@ -196,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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
47
VoiceInk/Views/History/HistoryShortcutTipView.swift
Normal file
47
VoiceInk/Views/History/HistoryShortcutTipView.swift
Normal file
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
130
VoiceInk/Views/History/TranscriptionDetailView.swift
Normal file
130
VoiceInk/Views/History/TranscriptionDetailView.swift
Normal file
@ -0,0 +1,130 @@
|
||||
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
|
||||
@State private var justCopied = false
|
||||
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
Button(action: {
|
||||
copyToClipboard(text)
|
||||
}) {
|
||||
Image(systemName: justCopied ? "checkmark" : "doc.on.doc")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(justCopied ? .green : .secondary)
|
||||
.frame(width: 28, height: 28)
|
||||
.background(Color(NSColor.controlBackgroundColor).opacity(0.9))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Copy to clipboard")
|
||||
.padding(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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
444
VoiceInk/Views/History/TranscriptionHistoryView.swift
Normal file
444
VoiceInk/Views/History/TranscriptionHistoryView.swift
Normal file
@ -0,0 +1,444 @@
|
||||
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<Transcription> = []
|
||||
@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<Transcription> {
|
||||
var descriptor = FetchDescriptor<Transcription>(
|
||||
sortBy: [SortDescriptor(\.timestamp, order: .reverse)]
|
||||
)
|
||||
descriptor.fetchLimit = 1
|
||||
return descriptor
|
||||
}
|
||||
|
||||
private func cursorQueryDescriptor(after timestamp: Date? = nil) -> FetchDescriptor<Transcription> {
|
||||
var descriptor = FetchDescriptor<Transcription>(
|
||||
sortBy: [SortDescriptor(\Transcription.timestamp, order: .reverse)]
|
||||
)
|
||||
|
||||
if let timestamp = timestamp {
|
||||
if !searchText.isEmpty {
|
||||
descriptor.predicate = #Predicate<Transcription> { transcription in
|
||||
(transcription.text.localizedStandardContains(searchText) ||
|
||||
(transcription.enhancedText?.localizedStandardContains(searchText) ?? false)) &&
|
||||
transcription.timestamp < timestamp
|
||||
}
|
||||
} else {
|
||||
descriptor.predicate = #Predicate<Transcription> { transcription in
|
||||
transcription.timestamp < timestamp
|
||||
}
|
||||
}
|
||||
} else if !searchText.isEmpty {
|
||||
descriptor.predicate = #Predicate<Transcription> { 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 {
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 performDeletion(for transcription: Transcription) {
|
||||
if let urlString = transcription.audioFileURL,
|
||||
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)")
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
await saveAndReload()
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteSelectedTranscriptions() {
|
||||
for transcription in selectedTranscriptions {
|
||||
performDeletion(for: transcription)
|
||||
}
|
||||
selectedTranscriptions.removeAll()
|
||||
|
||||
Task {
|
||||
await saveAndReload()
|
||||
}
|
||||
}
|
||||
|
||||
private func toggleSelection(_ transcription: Transcription) {
|
||||
if selectedTranscriptions.contains(transcription) {
|
||||
selectedTranscriptions.remove(transcription)
|
||||
} else {
|
||||
selectedTranscriptions.insert(transcription)
|
||||
}
|
||||
}
|
||||
|
||||
private func selectAllTranscriptions() async {
|
||||
do {
|
||||
var allDescriptor = FetchDescriptor<Transcription>()
|
||||
|
||||
if !searchText.isEmpty {
|
||||
allDescriptor.predicate = #Predicate<Transcription> { 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
71
VoiceInk/Views/History/TranscriptionListItem.swift
Normal file
71
VoiceInk/Views/History/TranscriptionListItem.swift
Normal file
@ -0,0 +1,71 @@
|
||||
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(transcription.duration.formatTiming())
|
||||
.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() }
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
172
VoiceInk/Views/History/TranscriptionMetadataView.swift
Normal file
172
VoiceInk/Views/History/TranscriptionMetadataView.swift
Normal file
@ -0,0 +1,172 @@
|
||||
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: transcription.duration.formatTiming()
|
||||
)
|
||||
|
||||
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: duration.formatTiming()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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: duration.formatTiming()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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])
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<Transcription> = []
|
||||
@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<Transcription> {
|
||||
var descriptor = FetchDescriptor<Transcription>(
|
||||
sortBy: [SortDescriptor(\.timestamp, order: .reverse)]
|
||||
)
|
||||
descriptor.fetchLimit = 1
|
||||
return descriptor
|
||||
}
|
||||
|
||||
// Cursor-based query descriptor
|
||||
private func cursorQueryDescriptor(after timestamp: Date? = nil) -> FetchDescriptor<Transcription> {
|
||||
var descriptor = FetchDescriptor<Transcription>(
|
||||
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> { transcription in
|
||||
(transcription.text.localizedStandardContains(searchText) ||
|
||||
(transcription.enhancedText?.localizedStandardContains(searchText) ?? false)) &&
|
||||
transcription.timestamp < timestamp
|
||||
}
|
||||
} else {
|
||||
descriptor.predicate = #Predicate<Transcription> { transcription in
|
||||
transcription.timestamp < timestamp
|
||||
}
|
||||
}
|
||||
} else if !searchText.isEmpty {
|
||||
descriptor.predicate = #Predicate<Transcription> { 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<Transcription>()
|
||||
|
||||
// Apply search filter if needed
|
||||
if !searchText.isEmpty {
|
||||
allDescriptor.predicate = #Predicate<Transcription> { 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)
|
||||
}
|
||||
}
|
||||
@ -108,6 +108,7 @@ struct VoiceInkApp: App {
|
||||
|
||||
let menuBarManager = MenuBarManager()
|
||||
_menuBarManager = StateObject(wrappedValue: menuBarManager)
|
||||
menuBarManager.configure(modelContainer: container, whisperState: whisperState)
|
||||
|
||||
let activeWindowService = ActiveWindowService.shared
|
||||
activeWindowService.configure(with: enhancementService)
|
||||
@ -277,7 +278,7 @@ struct VoiceInkApp: App {
|
||||
.windowStyle(.hiddenTitleBar)
|
||||
.commands {
|
||||
CommandGroup(replacing: .newItem) { }
|
||||
|
||||
|
||||
CommandGroup(after: .appInfo) {
|
||||
CheckForUpdatesView(updaterViewModel: updaterViewModel)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user