From d5b85ddfc194a07dc950ceb5b6047a958883fc25 Mon Sep 17 00:00:00 2001 From: iamtheorm <147745336+iamtheorm@users.noreply.github.com> Date: Thu, 8 Jan 2026 14:43:25 +0530 Subject: [PATCH] Update codebase with latest changes --- build_log.txt | 21 ++ speaktype.xcodeproj/project.pbxproj | 33 +- .../xcshareddata/swiftpm/Package.resolved | 11 +- speaktype/App/AppDelegate.swift | 24 ++ speaktype/App/speaktypeApp.swift | 49 ++- .../AccentColor.colorset/Contents.json | 11 +- .../Constants/Shortcuts+Extensions.swift | 11 + .../MiniRecorderWindowController.swift | 115 +++++++ speaktype/Resources/Info.plist | 11 + speaktype/Resources/speaktype.entitlements | 2 +- .../Services/AudioRecordingService.swift | 26 +- speaktype/Services/ClipboardService.swift | 12 + speaktype/Services/HistoryService.swift | 51 +++ speaktype/Services/HotkeyService.swift | 73 ----- speaktype/Services/ModelDownloadService.swift | 64 ++++ speaktype/Services/PermissionService.swift | 23 ++ speaktype/Services/WhisperService.swift | 36 +- speaktype/Utilities/AppColors.swift | 7 + speaktype/Views/AIModelsView.swift | 197 +++++++++++ speaktype/Views/AudioInputView.swift | 177 ++++++++++ speaktype/Views/ContentView.swift | 2 +- speaktype/Views/DashboardView.swift | 31 ++ speaktype/Views/HistoryView.swift | 82 +++++ speaktype/Views/MainView.swift | 35 ++ speaktype/Views/MiniRecorderView.swift | 196 +++++++++++ speaktype/Views/OnboardingView.swift | 310 ++++++++++++++++++ speaktype/Views/PermissionsView.swift | 170 ++++++++++ speaktype/Views/SettingsView.swift | 159 +++++++++ speaktype/Views/SidebarView.swift | 51 +++ speaktype/Views/TranscribeAudioView.swift | 131 ++++++++ speaktypeUITests/speaktypeUITests.swift | 2 + 31 files changed, 2029 insertions(+), 94 deletions(-) create mode 100644 build_log.txt create mode 100644 speaktype/App/AppDelegate.swift create mode 100644 speaktype/Constants/Shortcuts+Extensions.swift create mode 100644 speaktype/Controllers/MiniRecorderWindowController.swift create mode 100644 speaktype/Services/HistoryService.swift delete mode 100644 speaktype/Services/HotkeyService.swift create mode 100644 speaktype/Services/ModelDownloadService.swift create mode 100644 speaktype/Services/PermissionService.swift create mode 100644 speaktype/Utilities/AppColors.swift create mode 100644 speaktype/Views/AIModelsView.swift create mode 100644 speaktype/Views/AudioInputView.swift create mode 100644 speaktype/Views/DashboardView.swift create mode 100644 speaktype/Views/HistoryView.swift create mode 100644 speaktype/Views/MainView.swift create mode 100644 speaktype/Views/MiniRecorderView.swift create mode 100644 speaktype/Views/OnboardingView.swift create mode 100644 speaktype/Views/PermissionsView.swift create mode 100644 speaktype/Views/SettingsView.swift create mode 100644 speaktype/Views/SidebarView.swift create mode 100644 speaktype/Views/TranscribeAudioView.swift diff --git a/build_log.txt b/build_log.txt new file mode 100644 index 0000000..095dbe0 --- /dev/null +++ b/build_log.txt @@ -0,0 +1,21 @@ +2026-01-08 00:49:15.446 xcodebuild[53214:2330497] [MT] IDERunDestination: Supported platforms for the buildables in the current scheme is empty. +--- xcodebuild: WARNING: Using the first of multiple matching destinations: +{ platform:macOS, arch:arm64, id:00008122-000104641ADA001C, name:My Mac } +{ platform:macOS, arch:x86_64, id:00008122-000104641ADA001C, name:My Mac } +/Users/orm/speaktype/speaktype/Views/HistoryView.swift:9:13: error: referencing initializer 'init(_:selection:rowContent:)' on 'List' requires that 'HistoryItem' conform to 'Hashable' + List(historyService.items, selection: $selectedItem) { item in + ^ +SwiftUI.List:2:11: note: where 'SelectionValue' = 'HistoryItem' +extension List { + ^ +/Users/orm/speaktype/speaktype/Views/HistoryView.swift:10:17: error: initializer 'init(value:label:)' requires that 'HistoryItem' conform to 'Hashable' + NavigationLink(value: item) { + ^ +SwiftUI.NavigationLink.init:2:20: note: where 'P' = 'HistoryItem' +nonisolated public init

(value: P?, @ViewBuilder label: () -> Label) where P : Decodable, P : Encodable, P : Hashable} + ^ +Failed frontend command: +/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swift-frontend -frontend -c /Users/orm/speaktype/speaktype/App/speaktypeApp.swift /Users/orm/speaktype/speaktype/Models/AppSettings.swift /Users/orm/speaktype/speaktype/Models/AudioDevice.swift /Users/orm/speaktype/speaktype/Models/HotkeyConfiguration.swift /Users/orm/speaktype/speaktype/Models/RecordingSession.swift /Users/orm/speaktype/speaktype/Models/TranscriptionResult.swift /Users/orm/speaktype/speaktype/Models/TranscriptionState.swift /Users/orm/speaktype/speaktype/Services/AudioRecordingService.swift /Users/orm/speaktype/speaktype/Services/ClipboardService.swift /Users/orm/speaktype/speaktype/Services/HistoryService.swift /Users/orm/speaktype/speaktype/Services/HotkeyService.swift /Users/orm/speaktype/speaktype/Services/WhisperService.swift /Users/orm/speaktype/speaktype/Utilities/Extensions/Color+Extensions.swift /Users/orm/speaktype/speaktype/Utilities/Extensions/View+Extensions.swift /Users/orm/speaktype/speaktype/Utilities/AppColors.swift /Users/orm/speaktype/speaktype/Utilities/Constants.swift /Users/orm/speaktype/speaktype/Views/AIModelsView.swift /Users/orm/speaktype/speaktype/Views/AudioInputView.swift /Users/orm/speaktype/speaktype/Views/ContentView.swift /Users/orm/speaktype/speaktype/Views/DashboardView.swift -primary-file /Users/orm/speaktype/speaktype/Views/HistoryView.swift /Users/orm/speaktype/speaktype/Views/MainView.swift /Users/orm/speaktype/speaktype/Views/MiniRecorderView.swift /Users/orm/speaktype/speaktype/Views/OnboardingView.swift /Users/orm/speaktype/speaktype/Views/PermissionsView.swift /Users/orm/speaktype/speaktype/Views/SettingsView.swift /Users/orm/speaktype/speaktype/Views/ShortcutRecorderView.swift /Users/orm/speaktype/speaktype/Views/SidebarView.swift /Users/orm/speaktype/speaktype/Views/TranscribeAudioView.swift /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/DerivedSources/GeneratedAssetSymbols.swift -emit-dependencies-path /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/Objects-normal/arm64/HistoryView.d -emit-const-values-path /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/Objects-normal/arm64/HistoryView.swiftconstvalues -emit-reference-dependencies-path /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/Objects-normal/arm64/HistoryView.swiftdeps -serialize-diagnostics-path /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/Objects-normal/arm64/HistoryView.dia -emit-localized-strings -emit-localized-strings-path /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/Objects-normal/arm64 -target arm64-apple-macos26.1 -module-can-import-version AppKit 2685.20.119 2685.20.119 -module-can-import-version DeveloperToolsSupport 23.0.4 23.0.4 -module-can-import-version SwiftUI 7.1.13.1 7.1.13 -load-resolved-plugin /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib/swift/host/plugins/libFoundationMacros.dylib\#/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/bin/swift-plugin-server\#FoundationMacros -load-resolved-plugin /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib/swift/host/plugins/libObservationMacros.dylib\#/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/bin/swift-plugin-server\#ObservationMacros -load-resolved-plugin /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib/swift/host/plugins/libPreviewsMacros.dylib\#/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/bin/swift-plugin-server\#PreviewsMacros -load-resolved-plugin /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib/swift/host/plugins/libSwiftMacros.dylib\#/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/bin/swift-plugin-server\#SwiftMacros -load-resolved-plugin /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib/swift/host/plugins/libSwiftUIMacros.dylib\#/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/bin/swift-plugin-server\#SwiftUIMacros -disable-implicit-swift-modules -Xcc -fno-implicit-modules -Xcc -fno-implicit-module-maps -explicit-swift-module-map-file /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/Objects-normal/arm64/speaktype-dependencies-1.json -Xllvm -aarch64-use-tbi -enable-objc-interop -stack-check -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX26.1.sdk -I /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Products/Debug -F /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Products/Debug/PackageFrameworks -F /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Products/Debug/PackageFrameworks -F /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Products/Debug/PackageFrameworks -F /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Products/Debug/PackageFrameworks -F /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Products/Debug/PackageFrameworks -F /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Products/Debug/PackageFrameworks -F /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Products/Debug -no-color-diagnostics -Xcc -fno-color-diagnostics -enable-testing -g -debug-info-format\=dwarf -dwarf-version\=5 -module-cache-path /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules -profile-generate -profile-coverage-mapping -swift-version 5 -enforce-exclusivity\=checked -Onone -D DEBUG -serialize-debugging-options -const-gather-protocols-file /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/Objects-normal/arm64/speaktype_const_extract_protocols.json -enable-upcoming-feature DisableOutwardActorInference -enable-upcoming-feature InferSendableFromCaptures -enable-upcoming-feature GlobalActorIsolatedTypesUsability -enable-upcoming-feature MemberImportVisibility -enable-upcoming-feature InferIsolatedConformances -enable-upcoming-feature NonisolatedNonsendingByDefault -enable-experimental-feature DebugDescriptionMacro -enable-bare-slash-regex -default-isolation\=MainActor -empty-abi-descriptor -validate-clang-modules-once -clang-build-session-file /Users/orm/Library/Developer/Xcode/DerivedData/ModuleCache.noindex/Session.modulevalidation -Xcc -working-directory -Xcc /Users/orm/speaktype -enable-anonymous-context-mangled-names -file-compilation-dir /Users/orm/speaktype -Xcc -D_LIBCPP_HARDENING_MODE\=_LIBCPP_HARDENING_MODE_DEBUG -Xcc -ivfsstatcache -Xcc /Users/orm/Library/Developer/Xcode/DerivedData/SDKStatCaches.noindex/macosx26.1-25B74-3ae1261907bdadc328169391a47051a6.sdkstatcache -Xcc -I/Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/swift-overrides.hmap -Xcc -iquote -Xcc /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/speaktype-generated-files.hmap -Xcc -I/Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/speaktype-own-target-headers.hmap -Xcc -I/Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/speaktype-all-non-framework-target-headers.hmap -Xcc -ivfsoverlay -Xcc /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype-a09c45879bccb08166620f197fcee798-VFS/all-product-headers.yaml -Xcc -iquote -Xcc /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/speaktype-project-headers.hmap -Xcc -I/Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Products/Debug/include -Xcc -I/Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/DerivedSources-normal/arm64 -Xcc -I/Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/DerivedSources/arm64 -Xcc -I/Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/DerivedSources -Xcc -DDEBUG\=1 -no-auto-bridging-header-chaining -module-name speaktype -frontend-parseable-output -disable-clang-spi -target-sdk-version 26.1 -target-sdk-name macosx26.1 -clang-target arm64-apple-macos26.1 -in-process-plugin-server-path /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/host/libSwiftInProcPluginServer.dylib -o /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Build/Intermediates.noindex/speaktype.build/Debug/speaktype.build/Objects-normal/arm64/HistoryView.o -index-unit-output-path /speaktype.build/Debug/speaktype.build/Objects-normal/arm64/HistoryView.o -index-store-path /Users/orm/Library/Developer/Xcode/DerivedData/speaktype-dhvydfokwnwfendhtkesaktuqgcq/Index.noindex/DataStore -index-system-modules +/Users/orm/speaktype/speaktype.xcodeproj: warning: The Copy Bundle Resources build phase contains this target's Info.plist file '/Users/orm/speaktype/speaktype/Resources/Info.plist'. (in target 'speaktype' from project 'speaktype') +** BUILD FAILED ** + diff --git a/speaktype.xcodeproj/project.pbxproj b/speaktype.xcodeproj/project.pbxproj index 42f9d86..5483be6 100644 --- a/speaktype.xcodeproj/project.pbxproj +++ b/speaktype.xcodeproj/project.pbxproj @@ -8,6 +8,8 @@ /* Begin PBXBuildFile section */ 1C547F2B2F0E7DC0008120A6 /* WhisperKit in Frameworks */ = {isa = PBXBuildFile; productRef = 1C547F2A2F0E7DC0008120A6 /* WhisperKit */; }; + 1CE4CB3F2F0EF6AF00C01C62 /* KeyboardShortcuts in Frameworks */ = {isa = PBXBuildFile; productRef = 1CE4CB3E2F0EF6AF00C01C62 /* KeyboardShortcuts */; }; + 1CE4CB412F0EF86B00C01C62 /* whisperkit-cli in Frameworks */ = {isa = PBXBuildFile; productRef = 1CE4CB402F0EF86B00C01C62 /* whisperkit-cli */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -57,6 +59,8 @@ buildActionMask = 2147483647; files = ( 1C547F2B2F0E7DC0008120A6 /* WhisperKit in Frameworks */, + 1CE4CB3F2F0EF6AF00C01C62 /* KeyboardShortcuts in Frameworks */, + 1CE4CB412F0EF86B00C01C62 /* whisperkit-cli in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -126,6 +130,8 @@ name = speaktype; packageProductDependencies = ( 1C547F2A2F0E7DC0008120A6 /* WhisperKit */, + 1CE4CB3E2F0EF6AF00C01C62 /* KeyboardShortcuts */, + 1CE4CB402F0EF86B00C01C62 /* whisperkit-cli */, ); productName = speaktype; productReference = 362947222F0DBC4E00BE895D /* speaktype.app */; @@ -211,6 +217,7 @@ minimizedProjectReferenceProxies = 1; packageReferences = ( 1C547F222F0E7D07008120A6 /* XCRemoteSwiftPackageReference "WhisperKit" */, + 1CE4CB3D2F0EF6AF00C01C62 /* XCRemoteSwiftPackageReference "KeyboardShortcuts" */, ); preferredProjectObjectVersion = 77; productRefGroup = 362947232F0DBC4E00BE895D /* Products */; @@ -409,11 +416,11 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; CODE_SIGN_ENTITLEMENTS = speaktype/Resources/speaktype.entitlements; + CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - ENABLE_APP_SANDBOX = YES; + ENABLE_APP_SANDBOX = NO; ENABLE_PREVIEWS = YES; ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = NO; @@ -441,11 +448,11 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; CODE_SIGN_ENTITLEMENTS = speaktype/Resources/speaktype.entitlements; + CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - ENABLE_APP_SANDBOX = YES; + ENABLE_APP_SANDBOX = NO; ENABLE_PREVIEWS = YES; ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = NO; @@ -594,6 +601,14 @@ version = 0.9.4; }; }; + 1CE4CB3D2F0EF6AF00C01C62 /* XCRemoteSwiftPackageReference "KeyboardShortcuts" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/sindresorhus/KeyboardShortcuts"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.4.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -602,6 +617,16 @@ package = 1C547F222F0E7D07008120A6 /* XCRemoteSwiftPackageReference "WhisperKit" */; productName = WhisperKit; }; + 1CE4CB3E2F0EF6AF00C01C62 /* KeyboardShortcuts */ = { + isa = XCSwiftPackageProductDependency; + package = 1CE4CB3D2F0EF6AF00C01C62 /* XCRemoteSwiftPackageReference "KeyboardShortcuts" */; + productName = KeyboardShortcuts; + }; + 1CE4CB402F0EF86B00C01C62 /* whisperkit-cli */ = { + isa = XCSwiftPackageProductDependency; + package = 1C547F222F0E7D07008120A6 /* XCRemoteSwiftPackageReference "WhisperKit" */; + productName = "whisperkit-cli"; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 3629471A2F0DBC4E00BE895D /* Project object */; diff --git a/speaktype.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/speaktype.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 66ae653..f0af80d 100644 --- a/speaktype.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/speaktype.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "e843284a09b9d7fb8d0032fe1a3fd1fbd38f28ea54d42a39ccbe396af16d225d", + "originHash" : "75b62446b67bb6f01804d7cd072c92b0b43ff0d18ff0f3b558df7fac283c7dec", "pins" : [ + { + "identity" : "keyboardshortcuts", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sindresorhus/KeyboardShortcuts", + "state" : { + "revision" : "1aef85578fdd4f9eaeeb8d53b7b4fc31bf08fe27", + "version" : "2.4.0" + } + }, { "identity" : "swift-argument-parser", "kind" : "remoteSourceControl", diff --git a/speaktype/App/AppDelegate.swift b/speaktype/App/AppDelegate.swift new file mode 100644 index 0000000..5e7b0c6 --- /dev/null +++ b/speaktype/App/AppDelegate.swift @@ -0,0 +1,24 @@ +import SwiftUI +import KeyboardShortcuts + +class AppDelegate: NSObject, NSApplicationDelegate { + private var miniRecorderController: MiniRecorderWindowController? + + func applicationDidFinishLaunching(_ notification: Notification) { + // Initialize the controller + miniRecorderController = MiniRecorderWindowController() + + // Setup global hotkey listener + KeyboardShortcuts.onKeyUp(for: .toggleRecord) { [weak self] in + // Direct toggle via controller. + // Bypasses URL schemes and SwiftUI Scene routing completely. + self?.miniRecorderController?.toggle() + } + } + + // Critical: Prevent the app from quitting when the Mini Recorder panel closes. + // Since we are a Menu Bar app (mostly), we must stay alive. + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return false + } +} diff --git a/speaktype/App/speaktypeApp.swift b/speaktype/App/speaktypeApp.swift index 206106e..ae27243 100644 --- a/speaktype/App/speaktypeApp.swift +++ b/speaktype/App/speaktypeApp.swift @@ -6,12 +6,57 @@ // import SwiftUI +import SwiftData +import KeyboardShortcuts @main struct speaktypeApp: App { + @Environment(\.openWindow) var openWindow + @Environment(\.dismissWindow) var dismissWindow + + @AppStorage("hasCompletedOnboarding") var hasCompletedOnboarding: Bool = false + + @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + + init() { + // KeyboardShortcuts init moved to AppDelegate to ensure early binding? + // Or keep here. + } + var body: some Scene { - WindowGroup { - ContentView() + // Main Dashboard Window (Hidden by default, opened via Menu Bar or Dock) + WindowGroup(id: "main-dashboard") { + Group { + if hasCompletedOnboarding { + MainView() + } else { + OnboardingView() + } + } + .preferredColorScheme(.dark) + .tint(.appRed) + } + .handlesExternalEvents(matching: ["main-dashboard", "open"]) // Only open for matching IDs + .commands { + SidebarCommands() + } + + // Note: Mini Recorder is now managed manually by AppDelegate -> MiniRecorderWindowController + // to prevent SwiftUI from auto-opening the main dashboard on activation. + + // Menu Bar Extra (Always running listener) + MenuBarExtra("SpeakType", systemImage: "mic.fill") { + Button("Open Dashboard") { + // Ensure we open the main dashboard via consistent ID or URL + // Using URL forces the specific window group to handle it + if let url = URL(string: "speaktype://open") { + NSWorkspace.shared.open(url) + } + } + Divider() + Button("Quit") { + NSApplication.shared.terminate(nil) + } } } } diff --git a/speaktype/Assets.xcassets/AccentColor.colorset/Contents.json b/speaktype/Assets.xcassets/AccentColor.colorset/Contents.json index eb87897..848b2bf 100644 --- a/speaktype/Assets.xcassets/AccentColor.colorset/Contents.json +++ b/speaktype/Assets.xcassets/AccentColor.colorset/Contents.json @@ -1,7 +1,16 @@ { "colors" : [ { - "idiom" : "universal" + "idiom" : "universal", + "color" : { + "color-space" : "srgb", + "components" : { + "red" : "0.913", + "alpha" : "1.000", + "blue" : "0.352", + "green" : "0.293" + } + } } ], "info" : { diff --git a/speaktype/Constants/Shortcuts+Extensions.swift b/speaktype/Constants/Shortcuts+Extensions.swift new file mode 100644 index 0000000..6e3198c --- /dev/null +++ b/speaktype/Constants/Shortcuts+Extensions.swift @@ -0,0 +1,11 @@ +import Foundation +import KeyboardShortcuts +import AppKit + +extension KeyboardShortcuts.Name { + static let toggleRecord = Self("toggleRecord", default: .init(.space, modifiers: [.control, .option])) +} + +extension Notification.Name { + static let hotkeyTriggered = Notification.Name("hotkeyTriggered") +} diff --git a/speaktype/Controllers/MiniRecorderWindowController.swift b/speaktype/Controllers/MiniRecorderWindowController.swift new file mode 100644 index 0000000..8544ef8 --- /dev/null +++ b/speaktype/Controllers/MiniRecorderWindowController.swift @@ -0,0 +1,115 @@ + +import Cocoa +import SwiftUI + +class MiniRecorderWindowController: NSObject { + private var panel: NSPanel? + private var hostingController: NSHostingController? + + // Toggle visibility + func toggle() { + if panel == nil { + setupPanel() + } + + guard let panel = panel else { return } + + if panel.isVisible { + print("Hiding Mini Recorder Panel") + panel.orderOut(nil) + } else { + print("Showing Mini Recorder Panel") + // Center closely to mouse or screen center? Center of screen is safer. + // But let's respect previous floating behavior. + panel.center() + // panel.makeKeyAndOrderFront(nil) -> Activates app. We want specific behavior. + // We want it to float but NOT activate the app if possible? + // Actually, for a recorder, we usually want it visible. + + // To prevent activating the Main App (and thus showing Main Dashboard if it was key), + // we show the panel without activating the app? + // "panel.orderFront(nil)" shows it. + + // However, to type into it (if needed) or interact, we might need activation. + // But our Mini Recorder is button-based. + + // Critical: If we use NSApp.activate, it might switch to the main dashboard scene if that's "primary". + // By managing this panel separately, we avoid SwiftUI triggering the main scene. + + // orderFrontRegardless allows showing the window without activating the app + panel.orderFrontRegardless() + // panel.orderFrontDuringApplicationMakeKey() // Does not exist + // panel.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + } + + private func setupPanel() { + // Initialize View with callbacks + let recorderView = MiniRecorderView( + onCommit: { [weak self] text in + self?.handleCommit(text: text) + }, + onCancel: { [weak self] in + self?.panel?.orderOut(nil) + } + ) + + hostingController = NSHostingController(rootView: recorderView) + + let p = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 300, height: 60), + styleMask: [.nonactivatingPanel, .titled, .fullSizeContentView, .hudWindow], + backing: .buffered, + defer: false + ) + + p.contentViewController = hostingController + p.titleVisibility = .hidden + p.titlebarAppearsTransparent = true + p.isMovableByWindowBackground = true + + // Window Behavior + p.level = .floating + p.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + p.isReleasedWhenClosed = false + p.hidesOnDeactivate = false // Keep floating even if focus lost? Yes. + p.standardWindowButton(.closeButton)?.isHidden = true + p.standardWindowButton(.miniaturizeButton)?.isHidden = true + p.standardWindowButton(.zoomButton)?.isHidden = true + + self.panel = p + } + + private func handleCommit(text: String) { + Task { + // 1. Copy to clipboard + ClipboardService.shared.copy(text: text) + + // 2. Hide window to return focus + await MainActor.run { + print("Closing recorder panel...") + self.panel?.orderOut(nil) + // Also force deactivation just in case + NSApp.hide(nil) + } + + // 3. Wait for focus switch + print("Waiting for focus switch...") + try? await Task.sleep(nanoseconds: 800_000_000) // 0.8s + + // 4. Paste + if ClipboardService.shared.isAccessibilityTrusted { + print("Simulating Paste (Accessibility Trusted)...") + ClipboardService.shared.paste() + print("Paste command sent.") + } else { + print("⚠️ Accessibility Permission Missing! Cannot paste. Requesting system prompt...") + // Trigger native system prompt which is more reliable for re-association + await MainActor.run { + ClipboardService.shared.requestAccessibilityPermission() + } + } + } + } +} diff --git a/speaktype/Resources/Info.plist b/speaktype/Resources/Info.plist index 2267fd8..c82ced8 100644 --- a/speaktype/Resources/Info.plist +++ b/speaktype/Resources/Info.plist @@ -20,6 +20,17 @@ 1 LSMinimumSystemVersion $(MACOSX_DEPLOYMENT_TARGET) + CFBundleURLTypes + + + CFBundleURLName + com.karansinghgit.speaktype + CFBundleURLSchemes + + speaktype + + + LSUIElement NSMicrophoneUsageDescription diff --git a/speaktype/Resources/speaktype.entitlements b/speaktype/Resources/speaktype.entitlements index d019de2..80e0c50 100644 --- a/speaktype/Resources/speaktype.entitlements +++ b/speaktype/Resources/speaktype.entitlements @@ -3,7 +3,7 @@ com.apple.security.app-sandbox - + com.apple.security.device.audio-input com.apple.security.automation.apple-events diff --git a/speaktype/Services/AudioRecordingService.swift b/speaktype/Services/AudioRecordingService.swift index bb7c94f..4859ab8 100644 --- a/speaktype/Services/AudioRecordingService.swift +++ b/speaktype/Services/AudioRecordingService.swift @@ -4,7 +4,9 @@ import Combine class AudioRecordingService: NSObject, ObservableObject { private var audioRecorder: AVAudioRecorder? + private var timer: Timer? @Published var isRecording = false + @Published var audioLevel: Float = 0.0 // Request microphone permission func requestPermission() { @@ -12,7 +14,7 @@ class AudioRecordingService: NSObject, ObservableObject { AVCaptureDevice.requestAccess(for: .audio) { _ in } } - // Start recording audio to a temporary file + // Start recording audio func startRecording() { let status = AVCaptureDevice.authorizationStatus(for: .audio) @@ -49,16 +51,38 @@ class AudioRecordingService: NSObject, ObservableObject { do { audioRecorder = try AVAudioRecorder(url: audioFilename, settings: settings) + audioRecorder?.isMeteringEnabled = true // Enable metering audioRecorder?.record() isRecording = true + startMetering() // Start timer print("Recording started to \(audioFilename)") } catch { print("Could not start recording: \(error.localizedDescription)") } } + private func startMetering() { + timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in + guard let self = self, let recorder = self.audioRecorder else { return } + recorder.updateMeters() + let power = recorder.averagePower(forChannel: 0) // -160 to 0 dB + // Normalize: -60dB to 0dB -> 0.0 to 1.0 + let normalized = max(0.0, (power + 60) / 60) + DispatchQueue.main.async { + self.audioLevel = normalized + } + } + } + + private func stopMetering() { + timer?.invalidate() + timer = nil + audioLevel = 0.0 + } + // Stop recording and return the file URL func stopRecording() -> URL? { + stopMetering() audioRecorder?.stop() isRecording = false print("Recording stopped") diff --git a/speaktype/Services/ClipboardService.swift b/speaktype/Services/ClipboardService.swift index 7a6e066..5a3a0bb 100644 --- a/speaktype/Services/ClipboardService.swift +++ b/speaktype/Services/ClipboardService.swift @@ -43,6 +43,18 @@ class ClipboardService { cmdUp?.post(tap: .cghidEventTap) print("Simulated Cmd+V") + print("Simulated Cmd+V") } } + + // Check if we have permission to send keystrokes + var isAccessibilityTrusted: Bool { + return AXIsProcessTrusted() + } + + // Request permission via system prompt + func requestAccessibilityPermission() { + let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary + _ = AXIsProcessTrustedWithOptions(options) + } } diff --git a/speaktype/Services/HistoryService.swift b/speaktype/Services/HistoryService.swift new file mode 100644 index 0000000..01e66c8 --- /dev/null +++ b/speaktype/Services/HistoryService.swift @@ -0,0 +1,51 @@ +import Foundation +import Combine +import SwiftUI // For IndexSet operations if needed, though Foundation usually covers it, but error says missing import. + +struct HistoryItem: Identifiable, Codable, Hashable { + let id: UUID + let date: Date + let transcript: String + let duration: TimeInterval +} + +class HistoryService: ObservableObject { + static let shared = HistoryService() + + @Published var items: [HistoryItem] = [] + + private let saveKey = "history_items" + + private init() { + loadHistory() + } + + func addItem(transcript: String, duration: TimeInterval) { + let newItem = HistoryItem(id: UUID(), date: Date(), transcript: transcript, duration: duration) + items.insert(newItem, at: 0) // Newest first + saveHistory() + } + + func deleteItem(at offsets: IndexSet) { + items.remove(atOffsets: offsets) + saveHistory() + } + + func deleteItem(id: UUID) { + items.removeAll { $0.id == id } + saveHistory() + } + + private func saveHistory() { + if let encoded = try? JSONEncoder().encode(items) { + UserDefaults.standard.set(encoded, forKey: saveKey) + } + } + + private func loadHistory() { + if let data = UserDefaults.standard.data(forKey: saveKey), + let decoded = try? JSONDecoder().decode([HistoryItem].self, from: data) { + items = decoded + } + } +} diff --git a/speaktype/Services/HotkeyService.swift b/speaktype/Services/HotkeyService.swift deleted file mode 100644 index feea04f..0000000 --- a/speaktype/Services/HotkeyService.swift +++ /dev/null @@ -1,73 +0,0 @@ -import Cocoa -import Carbon -import Combine - -class HotkeyService: ObservableObject { - static let shared = HotkeyService() - - // Default hotkey: Option + Shift + Space - // We can make this configurable later - private var hotKeyRef: EventHotKeyRef? - - @Published var isPressed = false - - private init() { - registerHotkey() - } - - func registerHotkey() { - var eventType = EventTypeSpec( - eventClass: OSType(kEventClassKeyboard), - eventKind: UInt32(kEventHotKeyPressed) - ) - - // Install handler for Key Pressed - InstallEventHandler(GetApplicationEventTarget(), { (nextHandler, theEvent, userData) -> OSStatus in - // Handle Hotkey Pressed - print("Hotkey Pressed") - NotificationCenter.default.post(name: .hotkeyPressed, object: nil) - return noErr - }, 1, &eventType, nil, nil) - - // Install handler for Key Released - var releaseEventType = EventTypeSpec( - eventClass: OSType(kEventClassKeyboard), - eventKind: UInt32(kEventHotKeyReleased) - ) - - InstallEventHandler(GetApplicationEventTarget(), { (nextHandler, theEvent, userData) -> OSStatus in - // Handle Hotkey Released - print("Hotkey Released") - NotificationCenter.default.post(name: .hotkeyReleased, object: nil) - return noErr - }, 1, &releaseEventType, nil, nil) - - // Register the actual hotkey (Option + Shift + Space) - // 49 is Space - // modifiers: optionKey + shiftKey - let hotKeyID = EventHotKeyID(signature: OSType(0x5357), id: 1) // Signature 'SW', ID 1 - var gMyHotKeyRef: EventHotKeyRef? = nil - - // modifiers: cmdKey = 256, shiftKey = 512, optionKey = 2048, controlKey = 4096 - // preferring Control + Shift + Space to avoid conflicts? - // User requested "press-and-hold", picking Option+Space or similar is common. - // Let's go with Option + Space (common for launchers) or Shift+Option+Space. - // Let's use Option + Space (49 + 2048) for now. - - let modifiers = optionKey - - RegisterEventHotKey(UInt32(kVK_Space), UInt32(modifiers), hotKeyID, GetApplicationEventTarget(), 0, &gMyHotKeyRef) - hotKeyRef = gMyHotKeyRef - } - - func unregister() { - if let ref = hotKeyRef { - UnregisterEventHotKey(ref) - } - } -} - -extension Notification.Name { - static let hotkeyPressed = Notification.Name("hotkeyPressed") - static let hotkeyReleased = Notification.Name("hotkeyReleased") -} diff --git a/speaktype/Services/ModelDownloadService.swift b/speaktype/Services/ModelDownloadService.swift new file mode 100644 index 0000000..dd5acd1 --- /dev/null +++ b/speaktype/Services/ModelDownloadService.swift @@ -0,0 +1,64 @@ +import Foundation +import Combine +import WhisperKit + +class ModelDownloadService: ObservableObject { + static let shared = ModelDownloadService() + + @Published var downloadProgress: [String: Double] = [:] // Map Model Variant (String) to progress + @Published var isDownloading: [String: Bool] = [:] + + private init() {} + + // Asynchronous download using WhisperKit + func downloadModel(variant: String) { + guard isDownloading[variant] != true else { return } + + isDownloading[variant] = true + downloadProgress[variant] = 0.0 + print("Starting WhisperKit download for: \(variant)") + + Task { + do { + // Determine model variant enum/string + // Note: WhisperKit.download(variant:from:) is the likely API. + // We use the "variant" string to fetch. + // Assuming `WhisperKit.download(variant: variant)` acts as the fetcher. + // Progress callback mock (since we might not have exact API signature yet): + + // Actual API (hypothetical based on search): + // let model = try await WhisperKit(model: variant) + // OR + // try await WhisperKit.download(variant: variant) { progress in ... } + + // likely: download(variant:progressCallback:) - 'from' usually has a default + let path = try await WhisperKit.download(variant: variant, progressCallback: { progress in + DispatchQueue.main.async { + self.downloadProgress[variant] = progress.fractionCompleted + } + }) + + print("Model downloaded to: \(path)") + + DispatchQueue.main.async { + self.isDownloading[variant] = false + self.downloadProgress[variant] = 1.0 + } + } catch { + print("WhisperKit download error: \(error)") + DispatchQueue.main.async { + self.isDownloading[variant] = false + self.downloadProgress[variant] = 0.0 + } + } + } + } + + func cancelDownload(for variant: String) { + // WhisperKit might not support cancellation easily via this simple wrapper + // For now, just reset state + isDownloading[variant] = false + downloadProgress[variant] = 0.0 + } +} + diff --git a/speaktype/Services/PermissionService.swift b/speaktype/Services/PermissionService.swift new file mode 100644 index 0000000..67e39cf --- /dev/null +++ b/speaktype/Services/PermissionService.swift @@ -0,0 +1,23 @@ +import Foundation +import AVFoundation +import ApplicationServices + +class PermissionService { + static let shared = PermissionService() + + // Checks if both Microphone and Accessibility permissions are currently granted + var arePermissionsGranted: Bool { + let micStatus = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized + let accessibilityStatus = AXIsProcessTrusted() + return micStatus && accessibilityStatus + } + + // Checks specific permissions individually + var isMicGranted: Bool { + AVCaptureDevice.authorizationStatus(for: .audio) == .authorized + } + + var isAccessibilityGranted: Bool { + AXIsProcessTrusted() + } +} diff --git a/speaktype/Services/WhisperService.swift b/speaktype/Services/WhisperService.swift index ded70b2..66ae71d 100644 --- a/speaktype/Services/WhisperService.swift +++ b/speaktype/Services/WhisperService.swift @@ -7,25 +7,41 @@ class WhisperService { var isInitialized = false var isTranscribing = false + var currentModelVariant: String = "openai_whisper-base" // Default + enum TranscriptionError: Error { case notInitialized case fileNotFound } + // Default initialization (loads default or saved model) func initialize() async throws { - // Initialize WhisperKit with the base model (good balance of speed/accuracy) - // We'll use "tiny.en" for MVP speed, can be configured later - print("Initializing WhisperKit...") + // You might want to pull from UserDefaults here if you want persistence in Service + // For now, allow the View to drive the variant selection via loadModel + try await loadModel(variant: currentModelVariant) + } + + // Dynamic model loading + func loadModel(variant: String) async throws { + if isInitialized && variant == currentModelVariant && pipe != nil { + return // Already loaded + } + + print("Initializing WhisperKit with model: \(variant)...") + isInitialized = false + do { - // "tiny.en" is a small, English-only model perfect for offline dictation - // Explicitly request the tiny English model to minimize download size (~39MB) - pipe = try await WhisperKit(model: "tiny.en") - // Note: In a real app we might want to check for model availability or download it. - // For now, we rely on WhisperKit's default behavior or pre-downloaded models. + // WhisperKit.download(variant:) logic handles checking if consistent + // But here we want the PIPE, so we init WhisperKit(model: variant) + // Note: If model isn't downloaded, this might fail or trigger download depending on library version. + // Ideally, we ensure it's downloaded first via ModelDownloadService, but WhisperKit init often handles it. + + pipe = try await WhisperKit(model: variant) + currentModelVariant = variant isInitialized = true - print("WhisperKit initialized successfully") + print("WhisperKit initialized successfully with \(variant)") } catch { - print("Failed to initialize WhisperKit: \(error.localizedDescription)") + print("Failed to initialize WhisperKit with \(variant): \(error.localizedDescription)") throw error } } diff --git a/speaktype/Utilities/AppColors.swift b/speaktype/Utilities/AppColors.swift new file mode 100644 index 0000000..7f2dbeb --- /dev/null +++ b/speaktype/Utilities/AppColors.swift @@ -0,0 +1,7 @@ +import SwiftUI + +extension Color { + static let appRed = Color(red: 0.913, green: 0.293, blue: 0.352) + static let sidebarBackground = Color(red: 0.1, green: 0.1, blue: 0.1) // Approximate dark sidebar + static let contentBackground = Color(red: 0.12, green: 0.12, blue: 0.12) // Approximate dark content +} diff --git a/speaktype/Views/AIModelsView.swift b/speaktype/Views/AIModelsView.swift new file mode 100644 index 0000000..8137515 --- /dev/null +++ b/speaktype/Views/AIModelsView.swift @@ -0,0 +1,197 @@ +import SwiftUI + +struct AIModelsView: View { + @StateObject private var downloadService = ModelDownloadService.shared + @AppStorage("selectedModelVariant") private var selectedModel: String = "openai_whisper-base" + + // Using standard WhisperKit model variants + @State private var models = [ + AIModel( + name: "Whisper Base", + variant: "openai_whisper-base", + details: "English-only • Optimized for Apple Silicon" + ), + AIModel( + name: "Whisper Small", + variant: "openai_whisper-small", + details: "English-only • Higher accuracy" + ), + AIModel( + name: "Whisper Tiny", + variant: "openai_whisper-tiny", + details: "Multilingual • Fastest" + ) + ] + + // Helper to get name of selected model + var selectedModelName: String { + models.first(where: { $0.variant == selectedModel })?.name ?? "Unknown Model" + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 30) { + // Header (Redesigned) + VStack(alignment: .leading, spacing: 10) { + Text("AI Models") + .font(.largeTitle) + .fontWeight(.bold) + .foregroundStyle(.white) + Text("Manage your local transcription models powered by WhisperKit.") + .font(.body) + .foregroundStyle(.gray) + } + + VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 8) { + Text("Default Model") + .font(.headline) + .foregroundStyle(.gray) + Text(selectedModelName) + .font(.title2) + .fontWeight(.bold) + .foregroundStyle(.white) + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.contentBackground.opacity(0.5)) + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.gray.opacity(0.3), lineWidth: 1) + ) + } + + HStack { + Text("Available Models") + .font(.title2) + .fontWeight(.bold) + .foregroundStyle(.white) + Spacer() + } + + // Models List + VStack(spacing: 16) { + ForEach($models) { $model in + ModelRow(model: $model, selectedModel: $selectedModel) + } + } + } + .padding(30) + } + .background(Color.contentBackground) + } +} + +struct AIModel: Identifiable { + let id = UUID() + let name: String + let variant: String + let details: String + // status is derived from service now +} + +struct ModelRow: View { + @Binding var model: AIModel + @Binding var selectedModel: String // Bind to parent selection + @ObservedObject var downloadService = ModelDownloadService.shared + + var progress: Double { + downloadService.downloadProgress[model.variant] ?? 0.0 + } + + var isDownloading: Bool { + downloadService.isDownloading[model.variant] ?? false + } + + var isDownloaded: Bool { + // Placeholder check. In real app, check file existence or WhisperKit.isModelAvailable(variant) + return progress >= 1.0 + } + + var isActive: Bool { + selectedModel == model.variant + } + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(model.name) + .font(.headline) + .foregroundStyle(.white) + if isActive { + Text("(Active)") + .font(.caption) + .foregroundStyle(Color.green) + } + } + Text(model.details) + .font(.caption) + .foregroundStyle(.gray) + } + + Spacer() + + if isDownloaded { + // If downloaded, allow selection + if isActive { + HStack(spacing: 4) { + Image(systemName: "checkmark.circle.fill") + Text("Selected") + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .foregroundStyle(Color.green) + } else { + Button("Use") { + selectedModel = model.variant + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.white.opacity(0.1)) + .foregroundStyle(.white) + .cornerRadius(16) + .buttonStyle(.plain) + } + } else if isDownloading { + HStack { + ProgressView(value: progress) + .progressViewStyle(.linear) + .frame(width: 80) + .tint(Color.appRed) + Text("\(Int(progress * 100))%") + .font(.caption) + .foregroundStyle(.gray) + .frame(width: 35, alignment: .trailing) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.black.opacity(0.2)) + .cornerRadius(16) + } else { + Button(action: { + downloadService.downloadModel(variant: model.variant) + }) { + HStack(spacing: 4) { + Text("Download") + Image(systemName: "arrow.down.circle") + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.appRed) + .foregroundStyle(.white) + .cornerRadius(16) + } + .buttonStyle(.plain) + } + } + .padding() + .background(Color.white.opacity(0.05)) + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(isActive ? Color.green.opacity(0.5) : Color.white.opacity(0.05), lineWidth: 1) + ) + } +} diff --git a/speaktype/Views/AudioInputView.swift b/speaktype/Views/AudioInputView.swift new file mode 100644 index 0000000..b572aa8 --- /dev/null +++ b/speaktype/Views/AudioInputView.swift @@ -0,0 +1,177 @@ +import SwiftUI + +struct AudioInputView: View { + @State private var selectedMode = "Custom Device" + @State private var selectedDevice = "MacBook Air Microphone" + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 30) { + // Header + VStack(spacing: 15) { + Image(systemName: "waveform") + .font(.system(size: 40)) + .foregroundStyle(Color.appRed) + + Text("Audio Input") + .font(.largeTitle) + .fontWeight(.bold) + .foregroundStyle(.white) + + Text("Configure your microphone preferences") + .foregroundStyle(.gray) + } + .frame(maxWidth: .infinity) + .padding(.top, 40) + + // Input Mode Section + VStack(alignment: .leading, spacing: 15) { + Text("Input Mode") + .font(.headline) + .foregroundStyle(.white) + + HStack(spacing: 20) { + InputModeCard( + title: "Custom Device", + desc: "Select a specific input device", + icon: "mic.fill", + isSelected: selectedMode == "Custom Device", + color: .appRed + ) + .onTapGesture { selectedMode = "Custom Device" } + + InputModeCard( + title: "Prioritized", + desc: "Set up device priority order", + icon: "list.number", + isSelected: selectedMode == "Prioritized", + color: .gray + ) + .onTapGesture { selectedMode = "Prioritized" } + } + } + .padding(.horizontal, 40) + + // Available Devices Section + VStack(alignment: .leading, spacing: 15) { + HStack { + Text("Available Devices") + .font(.headline) + .foregroundStyle(.white) + Spacer() + Button(action: {}) { + HStack { + Image(systemName: "arrow.clockwise") + Text("Refresh") + } + .font(.subheadline) + .foregroundStyle(.gray) + } + .buttonStyle(.plain) + } + + Text("Note: Selecting a device here will override your Mac's system-wide default microphone.") + .font(.caption) + .foregroundStyle(.gray) + + VStack(spacing: 12) { + DeviceRow( + name: "MacBook Air Microphone", + isActive: true, + isSelected: selectedDevice == "MacBook Air Microphone" + ) + .onTapGesture { selectedDevice = "MacBook Air Microphone" } + + DeviceRow( + name: "Microsoft Teams Audio", + isActive: false, + isSelected: selectedDevice == "Microsoft Teams Audio" + ) + .onTapGesture { selectedDevice = "Microsoft Teams Audio" } + } + } + .padding(.horizontal, 40) + } + .padding(.bottom, 40) + } + .background(Color.contentBackground) + } +} + +struct InputModeCard: View { + let title: String + let desc: String + let icon: String + let isSelected: Bool + let color: Color + + var body: some View { + HStack(alignment: .top, spacing: 15) { + ZStack { + Circle() + .fill(isSelected ? color.opacity(0.2) : Color.white.opacity(0.1)) + .frame(width: 36, height: 36) + Image(systemName: icon) + .foregroundStyle(isSelected ? color : .gray) + } + + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.headline) + .foregroundStyle(.white) + Text(desc) + .font(.caption) + .foregroundStyle(.gray) + } + Spacer() + } + .padding() + .frame(height: 100) + .background(Color.white.opacity(0.05)) + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(isSelected ? color : Color.white.opacity(0.1), lineWidth: 1) + ) + } +} + +struct DeviceRow: View { + let name: String + let isActive: Bool + let isSelected: Bool + + var body: some View { + HStack { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .foregroundStyle(isSelected ? Color.appRed : .gray) + .font(.title3) + + Text(name) + .font(.body) + .foregroundStyle(.white) + + Spacer() + + if isActive { + HStack(spacing: 4) { + Image(systemName: "waveform") + Text("Active") + } + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.green.opacity(0.2)) + .foregroundStyle(.green) + .cornerRadius(12) + } + } + .padding() + .background(isSelected ? Color.appRed.opacity(0.05) : Color.white.opacity(0.02)) + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(isSelected ? Color.appRed.opacity(0.5) : Color.white.opacity(0.1), lineWidth: 1) + ) + } +} diff --git a/speaktype/Views/ContentView.swift b/speaktype/Views/ContentView.swift index 65e3d16..d7414b2 100644 --- a/speaktype/Views/ContentView.swift +++ b/speaktype/Views/ContentView.swift @@ -87,7 +87,7 @@ struct ContentView: View { .padding() .frame(width: 400, height: 500) .onAppear { - _ = HotkeyService.shared // Initialize Hotkey Listener + // HotkeyService removed in favor of KeyboardShortcuts defined in App } } } diff --git a/speaktype/Views/DashboardView.swift b/speaktype/Views/DashboardView.swift new file mode 100644 index 0000000..d29a623 --- /dev/null +++ b/speaktype/Views/DashboardView.swift @@ -0,0 +1,31 @@ +import SwiftUI + +struct DashboardView: View { + var body: some View { + VStack { + // Header removed for cleaner look + + // Placeholder content + Spacer() + + Image(systemName: "waveform") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 100, height: 100) + .foregroundStyle(Color.appRed) + + Text("No Transcriptions Yet") + .font(.headline) + .foregroundStyle(.white) + .padding(.top) + + Text("Start your first recording to unlock value insights.") + .font(.subheadline) + .foregroundStyle(.gray) + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.contentBackground) + } +} diff --git a/speaktype/Views/HistoryView.swift b/speaktype/Views/HistoryView.swift new file mode 100644 index 0000000..b1d02ee --- /dev/null +++ b/speaktype/Views/HistoryView.swift @@ -0,0 +1,82 @@ +import SwiftUI + +struct HistoryView: View { + @StateObject private var historyService = HistoryService.shared + @State private var selectedItem: HistoryItem? + + var body: some View { + NavigationSplitView { + List(historyService.items, selection: $selectedItem) { item in + NavigationLink(value: item) { + VStack(alignment: .leading, spacing: 4) { + Text(item.transcript.prefix(50) + (item.transcript.count > 50 ? "..." : "")) + .font(.headline) + .foregroundStyle(.white) + + HStack { + Text(item.date.formatted(date: .abbreviated, time: .shortened)) + Spacer() + Text(formatDuration(item.duration)) + } + .font(.caption) + .foregroundStyle(.gray) + } + .padding(.vertical, 4) + .contextMenu { + Button("Delete", role: .destructive) { + historyService.deleteItem(id: item.id) + if selectedItem?.id == item.id { + selectedItem = nil + } + } + } + } + } + .navigationTitle("History") + .listStyle(.sidebar) + } detail: { + if let item = selectedItem { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + HStack { + Text(item.date.formatted(date: .long, time: .standard)) + .font(.subheadline) + .foregroundStyle(.gray) + + Spacer() + + Button(action: { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(item.transcript, forType: .string) + }) { + Label("Copy", systemImage: "doc.on.doc") + } + } + + Divider() + + Text(item.transcript) + .font(.body) + .foregroundStyle(.white) + .textSelection(.enabled) + .lineSpacing(4) + + Spacer() + } + .padding(30) + } + .background(Color.contentBackground) + } else { + Text("Select a recording to view transcript") + .foregroundStyle(.gray) + } + } + } + + func formatDuration(_ duration: TimeInterval) -> String { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.minute, .second] + formatter.unitsStyle = .abbreviated + return formatter.string(from: duration) ?? "0s" + } +} diff --git a/speaktype/Views/MainView.swift b/speaktype/Views/MainView.swift new file mode 100644 index 0000000..a9cb5db --- /dev/null +++ b/speaktype/Views/MainView.swift @@ -0,0 +1,35 @@ +import SwiftUI + +struct MainView: View { + @State private var selection: SidebarItem? = .dashboard + + var body: some View { + NavigationSplitView { + SidebarView(selection: $selection) + } detail: { + switch selection { + case .dashboard: + DashboardView() + case .transcribeAudio: + TranscribeAudioView() + case .history: + HistoryView() + case .aiModels: + AIModelsView() + case .permissions: + PermissionsView() + case .audioInput: + AudioInputView() + case .settings: + SettingsView() + default: + Text("Content for \(selection?.rawValue ?? "Selection") coming soon") + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.contentBackground) + .foregroundStyle(.white) + } + } + .navigationTitle("SpeakType") + .fontDesign(.rounded) // Global modern font design + } +} diff --git a/speaktype/Views/MiniRecorderView.swift b/speaktype/Views/MiniRecorderView.swift new file mode 100644 index 0000000..513503c --- /dev/null +++ b/speaktype/Views/MiniRecorderView.swift @@ -0,0 +1,196 @@ +import SwiftUI +import Combine +import AVFoundation +import CoreMedia + +struct MiniRecorderView: View { + @StateObject private var audioRecorder = AudioRecordingService() + @State private var whisperService = WhisperService() + @State private var isListening = false + @State private var isProcessing = false + var onCommit: ((String) -> Void)? + var onCancel: (() -> Void)? + + @AppStorage("selectedModelVariant") private var selectedModel: String = "openai_whisper-base" + + // Default Init for Preview + init(onCommit: ((String) -> Void)? = nil, onCancel: (() -> Void)? = nil) { + self.onCommit = onCommit + self.onCancel = onCancel + } + + var body: some View { + HStack(spacing: 16) { + // State Icon / Button + Button(action: { + handleHotkeyTrigger() // Use same toggle logic + }) { + if isProcessing { + ProgressView() + .controlSize(.small) + .tint(.white) + } else { + Image(systemName: isListening ? "stop.circle.fill" : "mic.circle.fill") // Explicit Stop/Record icons + .font(.system(size: 28)) // Larger icon + .foregroundStyle(isListening ? Color.appRed : Color.white) // High contrast + .shadow(color: isListening ? Color.appRed.opacity(0.6) : .clear, radius: 8) + } + } + .buttonStyle(.plain) + + // Listening Visualizer (Dots) + HStack(spacing: 4) { + ForEach(0..<12) { index in + RoundedRectangle(cornerRadius: 2) + .fill(isListening ? Color.appRed : Color.white.opacity(0.3)) // Red when active + .frame(width: 4, height: 24) // Vertical bars instead of dots for better visibility + .scaleEffect(y: isListening ? CGFloat(1.0 + (Double(audioRecorder.audioLevel) * 5.0)) : 1.0) // Vertical scaling + .opacity(isListening ? 1.0 : 0.3) + .animation( + isListening ? .easeInOut(duration: 0.1) : .default, + value: audioRecorder.audioLevel + ) + } + } + .frame(width: 120, height: 32) // Fixed frame + + // Actions + HStack(spacing: 12) { + // Model Selector + Menu { + Picker("Model", selection: $selectedModel) { + Text("Base (Recommended)").tag("openai_whisper-base") + Text("Small (High Accuracy)").tag("openai_whisper-small") + Text("Tiny (Fastest)").tag("openai_whisper-tiny") + } + } label: { + Image(systemName: "cpu") + .font(.system(size: 14)) + .foregroundStyle(.gray) + .padding(6) + .background(Color.white.opacity(0.1)) + .clipShape(Circle()) + } + .menuStyle(.borderlessButton) + .fixedSize() + .onChange(of: selectedModel) { newValue in + switchModel(to: newValue) + } + + Button(action: { + // Cancel / Close + if isListening { + _ = audioRecorder.stopRecording() + isListening = false + } + // Notify controller to cancel/close + onCancel?() + }) { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(Color.white.opacity(0.6)) + .font(.system(size: 20)) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background( + Capsule() + .fill(Color.black) // Pure black for high contrast + .shadow(color: Color.black.opacity(0.5), radius: 10, x: 0, y: 5) + ) + .overlay( + Capsule() + .stroke(Color.white.opacity(0.15), lineWidth: 1) // Subtle border + ) + .fontDesign(.rounded) + .onReceive(NotificationCenter.default.publisher(for: .hotkeyTriggered)) { _ in + handleHotkeyTrigger() + } + .onAppear { + initializedService() + // Window configuration is now handled by Controller + } + } + + // Initialize Whisper + // Initialize Whisper + private func initializedService() { + Task { + // Load the persisted model + try? await whisperService.loadModel(variant: selectedModel) + } + } + + private func switchModel(to variant: String) { + guard !isListening && !isProcessing else { return } + Task { + print("Switching model to: \(variant)") + try? await whisperService.loadModel(variant: variant) + } + } + + // Main Workflow Logic + private func handleHotkeyTrigger() { + if isListening { + stopAndTranscribe() + } else { + startRecording() + } + } + + private func startRecording() { + guard !isProcessing else { return } + audioRecorder.startRecording() + isListening = true + } + + private func stopAndTranscribe() { + guard let url = audioRecorder.stopRecording() else { + isListening = false + return + } + isListening = false + isProcessing = true + + Task { + // Transcribe + do { + if !whisperService.isInitialized { + try? await whisperService.loadModel(variant: selectedModel) + } + let text = try await whisperService.transcribe(audioFile: url) + print("Transcription Result: '\(text)'") + + guard !text.isEmpty else { + print("Transcription was empty. Skipping paste.") + isProcessing = false + return + } + + // Save to History + let duration = getAudioDuration(url: url) + HistoryService.shared.addItem(transcript: text, duration: duration) + + // Delegate Commit to Controller (Copy, Hide, Paste happens there) + await MainActor.run { + onCommit?(text) + } + + } catch { + print("Transcription error: \(error)") + } + + // Reset state + await MainActor.run { + isProcessing = false + } + } + } + + private func getAudioDuration(url: URL) -> TimeInterval { + let asset = AVURLAsset(url: url) + return CMTimeGetSeconds(asset.duration) + } +} diff --git a/speaktype/Views/OnboardingView.swift b/speaktype/Views/OnboardingView.swift new file mode 100644 index 0000000..ac2f054 --- /dev/null +++ b/speaktype/Views/OnboardingView.swift @@ -0,0 +1,310 @@ +import SwiftUI +import AVFoundation + +struct OnboardingView: View { + @AppStorage("hasCompletedOnboarding") var hasCompletedOnboarding: Bool = false + @State private var currentPage = 0 + + var body: some View { + GeometryReader { geometry in + ZStack { + // Background - Solid Dark + Color.contentBackground + .ignoresSafeArea() + + // Content ZStack + ScrollView { + ZStack { + if currentPage == 0 { + WelcomePage(action: { + withAnimation { currentPage = 1 } + }) + .transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading))) + } else if currentPage == 1 { + PermissionsPage(finishAction: { + withAnimation { currentPage = 2 } + }) + .transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading))) + } else { + // Tutorial / Final Step + WelcomePage(action: { + completeOnboarding() + }) + .transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading))) + } + } + .padding(40) + .frame(maxWidth: .infinity, minHeight: 600) // Ensure layout has space but scrolls + } + } + } + .frame(minWidth: 600, minHeight: 500) // Lower minimum size + .fontDesign(.rounded) // Apply global rounded font for Onboarding + } + + func completeOnboarding() { + withAnimation { + hasCompletedOnboarding = true + } + } +} + +struct WelcomePage: View { + let action: () -> Void + + var body: some View { + VStack(spacing: 40) { + // Icon / Hero + Image(systemName: "mic.circle.fill") + .font(.system(size: 100)) + .foregroundStyle(Color.appRed) + .shadow(color: Color.appRed.opacity(0.5), radius: 20, x: 0, y: 0) + + VStack(spacing: 16) { + Text("Welcome to SpeakType") + .font(.system(size: 42, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + + Text("Experience the power of local AI transcription. Secure, fast, and completely offline.") + .font(.title3) + .multilineTextAlignment(.center) + .foregroundStyle(.gray) + .frame(maxWidth: 500) + } + + VStack(spacing: 24) { + FeatureCard(icon: "lock.shield.fill", title: "Private by Design", description: "Your audio never leaves your device.") + FeatureCard(icon: "bolt.fill", title: "Lightning Fast", description: "Optimized for Apple Silicon.") + FeatureCard(icon: "keyboard.fill", title: "Global Injection", description: "Type with your voice anywhere.") + } + + Button(action: action) { + Text("Get Started") + .font(.headline) + .fontWeight(.bold) + .foregroundStyle(.white) + .frame(width: 200, height: 50) + .background(Color.appRed) + .cornerRadius(25) + } + .buttonStyle(.plain) + } + } +} + +struct PermissionsPage: View { + var finishAction: () -> Void + @State private var micStatus: AVAuthorizationStatus = .notDetermined + @State private var accessibilityStatus: Bool = false + @State private var timer: Timer? + + var body: some View { + VStack(spacing: 40) { + VStack(spacing: 10) { + Text("Permissions Setup") + .font(.system(size: 36, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + Text("SpeakType needs access to your microphone and accessibility features to function.") + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding(.top, 20) + + VStack(spacing: 20) { + // Microphone + OnboardingPermissionRow( + icon: "mic.fill", + title: "Microphone Access", + description: "Required to record your voice.", + isGranted: micStatus == .authorized, + action: requestMicPermission + ) + + // Accessibility + OnboardingPermissionRow( + icon: "hand.raised.fill", + title: "Accessibility", + description: "Required to type text into other apps.", + isGranted: accessibilityStatus, + action: requestAccessibilityPermission + ) + } + .frame(maxWidth: 600) // Readability constraint + + Spacer() + + Button(action: finishAction) { + HStack { + Text("Start Using SpeakType") + .font(.title3) + .fontWeight(.bold) + if micStatus == .authorized && accessibilityStatus { + Image(systemName: "checkmark") + } + } + .foregroundColor(.white) + .frame(width: 300, height: 60) + .background( + RoundedRectangle(cornerRadius: 30) + .fill((micStatus == .authorized && accessibilityStatus) ? Color.appRed : Color.gray.opacity(0.3)) + .shadow(color: (micStatus == .authorized && accessibilityStatus) ? Color.appRed.opacity(0.4) : Color.clear, radius: 10, x: 0, y: 5) + ) + .animation(.easeInOut, value: micStatus == .authorized && accessibilityStatus) + } + .buttonStyle(.plain) + .disabled(micStatus != .authorized || !accessibilityStatus) + .padding(.bottom, 40) + } + .onAppear { + checkPermissions() + startPolling() + } + .onDisappear { + timer?.invalidate() + } + } + + // ... Copy-paste existing helpers (checkPermissions, request, polling) ... + // Note: Re-implementing them inline for the tool call + + func startPolling() { + timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in + checkPermissions() + } + } + + func checkPermissions() { + micStatus = AVCaptureDevice.authorizationStatus(for: .audio) + accessibilityStatus = AXIsProcessTrusted() + } + + func requestMicPermission() { + AVCaptureDevice.requestAccess(for: .audio) { _ in + DispatchQueue.main.async { checkPermissions() } + } + // Also open settings if denied? + if micStatus == .denied { + openSettings(for: "Privacy_Microphone") + } + } + + func requestAccessibilityPermission() { + print("DEBUG: Requesting Accessibility Permission") + let options: NSDictionary = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String : true] + let accessEnabled = AXIsProcessTrustedWithOptions(options) + accessibilityStatus = accessEnabled + + if !accessEnabled { + print("DEBUG: Access not enabled, opening settings") + openSettings(for: "Privacy_Accessibility") + } + } + + func openSettings(for pane: String) { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?\(pane)") { + NSWorkspace.shared.open(url) + } + } +} + +struct FeatureCard: View { + let icon: String + let title: String + let description: String + + var body: some View { + VStack(spacing: 12) { + Image(systemName: icon) + .font(.title) + .foregroundStyle(Color.appRed) + .frame(width: 50, height: 50) + .background(Color.appRed.opacity(0.1)) + .clipShape(Circle()) + + Text(title) + .font(.headline) + .foregroundStyle(.white) + + Text(description) + .font(.caption) + .foregroundStyle(.gray) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + .frame(width: 140, height: 160) + .padding() + .background(Color.white.opacity(0.03)) + .cornerRadius(20) + .overlay( + RoundedRectangle(cornerRadius: 20) + .stroke(Color.white.opacity(0.05), lineWidth: 1) + ) + } +} + +struct OnboardingPermissionRow: View { + let icon: String + let title: String + let description: String + let isGranted: Bool + let action: () -> Void + + var body: some View { + HStack { + Image(systemName: icon) + .font(.title2) + .foregroundStyle(Color.appRed) + .frame(width: 50, height: 50) + .background(Color.appRed.opacity(0.1)) + .clipShape(Circle()) + + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.headline) + .foregroundStyle(.white) + Text(description) + .font(.subheadline) + .foregroundStyle(.gray) + } + + Spacer() + + if isGranted { + HStack { + Text("Granted") + .font(.subheadline) + .fontWeight(.semibold) + Image(systemName: "checkmark.circle.fill") + } + .foregroundStyle(.green) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.green.opacity(0.1)) + .clipShape(Capsule()) + } else { + Button(action: action) { + Text("Allow Access") + .fontWeight(.semibold) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Color.appRed) + .foregroundStyle(.white) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } + } + .padding(20) + .background(Color.white.opacity(0.05)) + .cornerRadius(16) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(isGranted ? Color.green.opacity(0.3) : Color.white.opacity(0.1), lineWidth: 1) + ) + } +} + +#Preview { + OnboardingView() +} diff --git a/speaktype/Views/PermissionsView.swift b/speaktype/Views/PermissionsView.swift new file mode 100644 index 0000000..197c55d --- /dev/null +++ b/speaktype/Views/PermissionsView.swift @@ -0,0 +1,170 @@ +import SwiftUI +import AVFoundation +import KeyboardShortcuts + +struct PermissionsView: View { + @State private var micStatus: AVAuthorizationStatus = .notDetermined + @State private var accessibilityStatus: Bool = false + @State private var timer: Timer? + + var body: some View { + ScrollView { + VStack(spacing: 30) { + // Header + VStack(spacing: 15) { + Image(systemName: "shield.fill") + .font(.system(size: 40)) + .foregroundStyle(Color.appRed) + + Text("App Permissions") + .font(.largeTitle) + .fontWeight(.bold) + .foregroundStyle(.white) + + Text("Manage permissions to ensure full functionality") + .foregroundStyle(.gray) + } + .padding(.top, 40) + + // Permission Items + VStack(spacing: 16) { + // Microphone + PermissionRow( + icon: "mic.fill", + color: .green, + title: "Microphone Access", + desc: "Allow SpeakType to record your voice for transcription", + isGranted: micStatus == .authorized, + action: { openSettings(for: "Privacy_Microphone") } + ) + + // Accessibility + PermissionRow( + icon: "hand.raised.fill", + color: .green, + title: "Accessibility Access", + desc: "Allow SpeakType to paste transcribed text directly", + isGranted: accessibilityStatus, + action: { + ClipboardService.shared.requestAccessibilityPermission() + // Also open settings in case prompt doesn't appear (e.g. already denied) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + openSettings(for: "Privacy_Accessibility") + } + } + ) + + // Global Hotkey (New Customization Section) + VStack(alignment: .leading, spacing: 12) { + Text("Global Shortcut") + .font(.headline) + .foregroundStyle(.white) + + HStack { + Image(systemName: "keyboard") + .foregroundStyle(.orange) + .font(.title2) + .frame(width: 44, height: 44) + .background(Color.orange.opacity(0.1)) + .clipShape(Circle()) + + VStack(alignment: .leading, spacing: 4) { + Text("Toggle Recorder") + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(.white) + Text("Press to start/stop recording") + .font(.caption) + .foregroundStyle(.gray) + } + + Spacer() + + // The Recorder + KeyboardShortcuts.Recorder(for: .toggleRecord) + .padding(.vertical, 8) + } + .padding(16) + .background(Color.white.opacity(0.05)) + .cornerRadius(12) + } + .padding(.top, 10) + } + .padding(.horizontal, 40) + } + .padding(.bottom, 40) + } + .background(Color.contentBackground) + .onAppear { + checkPermissions() + startPolling() + } + .onDisappear { + timer?.invalidate() + } + } + + func startPolling() { + timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in + checkPermissions() + } + } + + func checkPermissions() { + micStatus = AVCaptureDevice.authorizationStatus(for: .audio) + accessibilityStatus = AXIsProcessTrusted() + } + + func openSettings(for pane: String) { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?\(pane)") { + NSWorkspace.shared.open(url) + } + } +} + +struct PermissionRow: View { + let icon: String + let color: Color + let title: String + let desc: String + let isGranted: Bool + let action: () -> Void + + var body: some View { + HStack(spacing: 20) { + ZStack { + Circle() + .fill(color.opacity(0.2)) + .frame(width: 44, height: 44) + Image(systemName: icon) + .foregroundStyle(color) + .font(.title2) + } + + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.headline) + .foregroundStyle(.white) + Text(desc) + .font(.caption) + .foregroundStyle(.gray) + } + + Spacer() + + Button("Manage") { + action() + } + .buttonStyle(.bordered) + .tint(Color.gray) + + if isGranted { + Image(systemName: "checkmark.seal.fill") + .foregroundStyle(.green) + .font(.title2) + } + } + .padding(16) + .background(Color.white.opacity(0.05)) + .cornerRadius(12) + } +} diff --git a/speaktype/Views/SettingsView.swift b/speaktype/Views/SettingsView.swift new file mode 100644 index 0000000..83ef439 --- /dev/null +++ b/speaktype/Views/SettingsView.swift @@ -0,0 +1,159 @@ +import SwiftUI +import KeyboardShortcuts + +// import LaunchAtLogin // Uncomment when package added +// import KeyboardShortcuts // Uncomment when package added + +struct SettingsView: View { + @State private var soundFeedback = true + @State private var muteSystemAudio = true + @State private var restoreClipboard = false + @State private var powerMode = false + @State private var experimentalFeatures = false + @State private var hideDockIcon = false + @State private var launchAtLogin = false // Will be replaced by LaunchAtLogin.isEnabled if package used + @State private var autoUpdate = true + @State private var showAnnouncements = true + @State private var customCancelShortcut = false + @State private var middleClickToggle = false + @State private var appleScriptPaste = false + @State private var recorderStyle: Int = 1 // 0: Notch, 1: Mini + @State private var hotkey1: String = "⌘ Space" + + + var body: some View { + ScrollView { + VStack(spacing: 24) { + // Shortcuts Section + SettingsSection { + HStack { + Image(systemName: "command.circle") + .foregroundStyle(Color.appRed) + Text("SpeakType Shortcuts") + .font(.headline) + .foregroundStyle(.white) + Spacer() + } + .padding(.bottom, 8) + + Text("Choose how you want to trigger SpeakType") + .font(.caption) + .foregroundStyle(.gray) + + Divider().background(Color.gray.opacity(0.3)) + + HStack { + Text("Global Hotkey") + .foregroundStyle(.gray) + Spacer() + KeyboardShortcuts.Recorder(for: .toggleRecord) + } + .padding(.vertical, 8) + + Text("Quick tap to start hands-free recording (tap again to stop). Press and hold for push-to-talk.") + .font(.caption2) + .foregroundStyle(.gray) + } + + // General + SettingsSection { + HStack { + Image(systemName: "gear") + .foregroundStyle(Color.appRed) + VStack(alignment: .leading) { + Text("General") + .font(.headline) + .foregroundStyle(.white) + Text("Startup settings") + .font(.caption) + .foregroundStyle(.gray) + } + Spacer() + } + + ToggleRow(title: "Launch at login", isOn: $launchAtLogin) + // Note: If using LaunchAtLogin package, replace the above line with: + // LaunchAtLogin.Toggle { + // Text("Launch at login") + // } + } + } + .padding() + } + .background(Color.contentBackground) + } +} + +struct SettingsSection: View { + let content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + content + } + .padding() + .background(Color.white.opacity(0.05)) + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.white.opacity(0.1), lineWidth: 1) + ) + } +} + +struct SettingsRow: View { + let title: String + let value: String + + var body: some View { + HStack { + Text(title) + .foregroundStyle(.gray) + Spacer() + Text(value) + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.white.opacity(0.1)) + .cornerRadius(4) + .foregroundStyle(.white) + } + } +} + +struct ToggleRow: View { + let title: String + @Binding var isOn: Bool + + var body: some View { + HStack { + Text(title) + .foregroundStyle(.white) + Spacer() + Toggle("", isOn: $isOn) + .labelsHidden() + } + } +} + +struct RadioButton: View { + let title: String + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 8) { + Image(systemName: isSelected ? "largecircle.fill.circle" : "circle") + .foregroundStyle(isSelected ? Color.appRed : .gray) + Text(title) + .foregroundStyle(.white) + } + } + .buttonStyle(.plain) + } +} diff --git a/speaktype/Views/SidebarView.swift b/speaktype/Views/SidebarView.swift new file mode 100644 index 0000000..b5bae31 --- /dev/null +++ b/speaktype/Views/SidebarView.swift @@ -0,0 +1,51 @@ +import SwiftUI + +enum SidebarItem: String, CaseIterable, Identifiable { + case dashboard = "Dashboard" + case transcribeAudio = "Transcribe Audio" + case history = "History" + case aiModels = "AI Models" + case permissions = "Permissions" + case audioInput = "Audio Input" + case settings = "Settings" + + var id: String { rawValue } + + var iconName: String { + switch self { + case .dashboard: return "gauge" + case .transcribeAudio: return "waveform" + case .history: return "doc.text" + case .aiModels: return "brain.head.profile" + case .permissions: return "shield" + case .audioInput: return "mic" + case .settings: return "gearshape" + } + } +} + +struct SidebarView: View { + @Binding var selection: SidebarItem? + + var body: some View { + List(selection: $selection) { + Section { + Label("SpeakType", systemImage: "mic.fill") + .font(.headline) + .foregroundStyle(.white) + .padding(.vertical, 10) + } + + Section { + ForEach(SidebarItem.allCases) { item in + NavigationLink(value: item) { + Label(item.rawValue, systemImage: item.iconName) + .padding(.vertical, 4) + } + } + } + } + .listStyle(.sidebar) + .preferredColorScheme(.dark) + } +} diff --git a/speaktype/Views/TranscribeAudioView.swift b/speaktype/Views/TranscribeAudioView.swift new file mode 100644 index 0000000..66b63d0 --- /dev/null +++ b/speaktype/Views/TranscribeAudioView.swift @@ -0,0 +1,131 @@ +import SwiftUI +import AVFoundation +import CoreMedia + +struct TranscribeAudioView: View { + @StateObject private var audioRecorder = AudioRecordingService() + @State private var whisperService = WhisperService() + @State private var transcribedText: String = "" + @State private var isTranscribing = false + + var body: some View { + VStack(spacing: 30) { + // Header removed for cleaner look + Spacer().frame(height: 20) + + // Main Drop Zone / Action Area + ZStack { + RoundedRectangle(cornerRadius: 12) + .stroke(style: StrokeStyle(lineWidth: 2, dash: [10])) + .foregroundStyle(Color.gray.opacity(0.3)) + .frame(maxWidth: .infinity, maxHeight: 400) + + VStack(spacing: 20) { + Image(systemName: "arrow.down.doc") + .font(.system(size: 50)) + .foregroundStyle(.gray) + + Text("Drop audio or video file here") + .font(.headline) + .foregroundStyle(.white) + + Text("or") + .foregroundStyle(.gray) + + if audioRecorder.isRecording { + Button(action: { + if let url = audioRecorder.stopRecording() { + startTranscription(url: url) + } + }) { + Text("Stop Recording") + .frame(minWidth: 120) + .padding() + .background(Color.appRed) + .foregroundStyle(.white) + .cornerRadius(8) + } + .buttonStyle(.plain) + } else if isTranscribing { + ProgressView("Transcribing...") + .tint(.white) + .foregroundStyle(.white) + } else { + Button(action: { + audioRecorder.startRecording() + }) { + Text("Start Recording") + .frame(minWidth: 120) + .padding() + .background(Color.gray.opacity(0.2)) + .foregroundStyle(.white) + .cornerRadius(8) + } + .buttonStyle(.plain) + } + + if !transcribedText.isEmpty { + ScrollView { + Text(transcribedText) + .padding() + .foregroundStyle(.white) + } + .frame(height: 100) + .background(Color.black.opacity(0.3)) + .cornerRadius(8) + + HStack { + Button("Copy Text") { + ClipboardService.shared.copy(text: transcribedText) + } + .buttonStyle(.plain) + .padding(8) + .background(Color.gray.opacity(0.2)) + .cornerRadius(6) + + Button("Paste (Simulate)") { + ClipboardService.shared.paste() + } + .buttonStyle(.plain) + .padding(8) + .background(Color.gray.opacity(0.2)) + .cornerRadius(6) + } + } + } + } + .padding() + + Spacer() + } + .background(Color.contentBackground) + .onAppear { + Task { + if !whisperService.isInitialized { + try? await whisperService.initialize() + } + } + } + } + + private func startTranscription(url: URL) { + Task { + isTranscribing = true + do { + transcribedText = try await whisperService.transcribe(audioFile: url) + // Save to History + let duration = getAudioDuration(url: url) + HistoryService.shared.addItem(transcript: transcribedText, duration: duration) + } catch { + transcribedText = "Error: \(error.localizedDescription)" + } + isTranscribing = false + } + } + + private func getAudioDuration(url: URL) -> TimeInterval { + // Simple duration check using AVURLAsset + let asset = AVURLAsset(url: url) + return CMTimeGetSeconds(asset.duration) + } +} diff --git a/speaktypeUITests/speaktypeUITests.swift b/speaktypeUITests/speaktypeUITests.swift index 9513902..d6105ea 100644 --- a/speaktypeUITests/speaktypeUITests.swift +++ b/speaktypeUITests/speaktypeUITests.swift @@ -29,6 +29,8 @@ final class speaktypeUITests: XCTestCase { app.launch() // Use XCTAssert and related functions to verify your tests produce the correct results. + let window = app.windows["SpeakType"] + XCTAssertTrue(window.exists, "The SpeakType window should exist") } @MainActor