diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index c083119..432ab99 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -2,9 +2,9 @@ name: Build and Lint on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] workflow_dispatch: jobs: @@ -12,123 +12,123 @@ jobs: name: SwiftLint runs-on: macos-15 if: github.event.pull_request.draft == false - + steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Install SwiftLint - run: brew install swiftlint - - - name: Run SwiftLint - run: | - cd Recap - swiftlint --strict --reporter github-actions-logging + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install SwiftLint + run: brew install swiftlint + + - name: Run SwiftLint + run: | + cd Recap + swiftlint --strict --reporter github-actions-logging build: name: Build runs-on: macos-15 if: github.event.pull_request.draft == false - + steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Xcode - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: latest-stable - - - name: Cache DerivedData - uses: actions/cache@v4 - with: - path: ~/Library/Developer/Xcode/DerivedData - key: ${{ runner.os }}-deriveddata-${{ hashFiles('Recap.xcodeproj/project.pbxproj') }} - restore-keys: | - ${{ runner.os }}-deriveddata- - - - name: Resolve Package Dependencies - run: | - xcodebuild -resolvePackageDependencies \ - -project Recap.xcodeproj \ - -scheme Recap - - - name: Build Project - run: | - xcodebuild build \ - -project Recap.xcodeproj \ - -scheme Recap \ - -configuration Debug \ - -destination 'platform=macOS' \ - -skipMacroValidation \ - CODE_SIGNING_ALLOWED=NO - - - name: Build Release - run: | - xcodebuild build \ - -project Recap.xcodeproj \ - -scheme Recap \ - -configuration Release \ - -destination 'platform=macOS' \ - -skipMacroValidation \ - CODE_SIGNING_ALLOWED=NO + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - name: Cache DerivedData + uses: actions/cache@v4 + with: + path: ~/Library/Developer/Xcode/DerivedData + key: ${{ runner.os }}-deriveddata-${{ hashFiles('Recap.xcodeproj/project.pbxproj') }} + restore-keys: | + ${{ runner.os }}-deriveddata- + + - name: Resolve Package Dependencies + run: | + xcodebuild -resolvePackageDependencies \ + -project Recap.xcodeproj \ + -scheme Recap + + - name: Build Project + run: | + xcodebuild build \ + -project Recap.xcodeproj \ + -scheme Recap \ + -configuration Debug \ + -destination 'platform=macOS' \ + -skipMacroValidation \ + CODE_SIGNING_ALLOWED=NO + + - name: Build Release + run: | + xcodebuild build \ + -project Recap.xcodeproj \ + -scheme Recap \ + -configuration Release \ + -destination 'platform=macOS' \ + -skipMacroValidation \ + CODE_SIGNING_ALLOWED=NO test: name: Test runs-on: macos-15 needs: build if: github.event.pull_request.draft == false - + steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Xcode - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: latest-stable - - - name: Cache DerivedData - uses: actions/cache@v4 - with: - path: ~/Library/Developer/Xcode/DerivedData - key: ${{ runner.os }}-deriveddata-${{ hashFiles('Recap.xcodeproj/project.pbxproj') }} - restore-keys: | - ${{ runner.os }}-deriveddata- - - - name: Resolve Package Dependencies - run: | - xcodebuild -resolvePackageDependencies \ - -project Recap.xcodeproj \ - -scheme Recap - - - name: Run Tests with Coverage - run: | - xcodebuild test \ - -project Recap.xcodeproj \ - -scheme Recap \ - -destination 'platform=macOS' \ - -resultBundlePath TestResults.xcresult \ - -enableCodeCoverage YES \ - -only-testing:RecapTests \ - -skipMacroValidation \ - CODE_SIGNING_ALLOWED=NO - - - name: Generate Coverage Report - run: | - xcrun xccov view --report --json TestResults.xcresult > coverage.json - - - name: Upload Test Results - uses: actions/upload-artifact@v4 - if: always() - with: - name: test-results - path: TestResults.xcresult - - - name: Upload Coverage Reports - uses: codecov/codecov-action@v5 - with: - file: coverage.json - flags: unittests - name: recap-coverage - fail_ci_if_error: false \ No newline at end of file + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - name: Cache DerivedData + uses: actions/cache@v4 + with: + path: ~/Library/Developer/Xcode/DerivedData + key: ${{ runner.os }}-deriveddata-${{ hashFiles('Recap.xcodeproj/project.pbxproj') }} + restore-keys: | + ${{ runner.os }}-deriveddata- + + - name: Resolve Package Dependencies + run: | + xcodebuild -resolvePackageDependencies \ + -project Recap.xcodeproj \ + -scheme Recap + + - name: Run Tests with Coverage + run: | + xcodebuild test \ + -project Recap.xcodeproj \ + -scheme Recap \ + -destination 'platform=macOS' \ + -resultBundlePath TestResults.xcresult \ + -enableCodeCoverage YES \ + -only-testing:RecapTests \ + -skipMacroValidation \ + CODE_SIGNING_ALLOWED=NO + + - name: Generate Coverage Report + run: | + xcrun xccov view --report --json TestResults.xcresult > coverage.json + + - name: Upload Test Results + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results + path: TestResults.xcresult + + - name: Upload Coverage Reports + uses: codecov/codecov-action@v5 + with: + files: coverage.json + flags: unittests + name: recap-coverage + fail_ci_if_error: false diff --git a/.gitignore b/.gitignore index ff20295..01066f2 100644 --- a/.gitignore +++ b/.gitignore @@ -90,4 +90,11 @@ fastlane/test_output iOSInjectionProject/ # Mac OS -.DS_Store \ No newline at end of file +.DS_Store + +# Archive outputs +Archives/ +*.xcarchive +Recap.app + +dist/ diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..efe5aa4 --- /dev/null +++ b/.swift-format @@ -0,0 +1,75 @@ +{ + "fileScopedDeclarationPrivacy" : { + "accessLevel" : "private" + }, + "indentConditionalCompilationBlocks" : true, + "indentSwitchCaseLabels" : false, + "indentation" : { + "spaces" : 2 + }, + "lineBreakAroundMultilineExpressionChainComponents" : false, + "lineBreakBeforeControlFlowKeywords" : false, + "lineBreakBeforeEachArgument" : false, + "lineBreakBeforeEachGenericRequirement" : false, + "lineBreakBetweenDeclarationAttributes" : false, + "lineLength" : 100, + "maximumBlankLines" : 1, + "multiElementCollectionTrailingCommas" : true, + "noAssignmentInExpressions" : { + "allowedFunctions" : [ + "XCTAssertNoThrow" + ] + }, + "prioritizeKeepingFunctionOutputTogether" : false, + "reflowMultilineStringLiterals" : "never", + "respectsExistingLineBreaks" : true, + "rules" : { + "AllPublicDeclarationsHaveDocumentation" : false, + "AlwaysUseLiteralForEmptyCollectionInit" : false, + "AlwaysUseLowerCamelCase" : true, + "AmbiguousTrailingClosureOverload" : true, + "AvoidRetroactiveConformances" : true, + "BeginDocumentationCommentWithOneLineSummary" : false, + "DoNotUseSemicolons" : true, + "DontRepeatTypeInStaticProperties" : true, + "FileScopedDeclarationPrivacy" : true, + "FullyIndirectEnum" : true, + "GroupNumericLiterals" : true, + "IdentifiersMustBeASCII" : true, + "NeverForceUnwrap" : false, + "NeverUseForceTry" : false, + "NeverUseImplicitlyUnwrappedOptionals" : false, + "NoAccessLevelOnExtensionDeclaration" : true, + "NoAssignmentInExpressions" : true, + "NoBlockComments" : true, + "NoCasesWithOnlyFallthrough" : true, + "NoEmptyLinesOpeningClosingBraces" : false, + "NoEmptyTrailingClosureParentheses" : true, + "NoLabelsInCasePatterns" : true, + "NoLeadingUnderscores" : false, + "NoParensAroundConditions" : true, + "NoPlaygroundLiterals" : true, + "NoVoidReturnOnFunctionSignature" : true, + "OmitExplicitReturns" : false, + "OneCasePerLine" : true, + "OneVariableDeclarationPerLine" : true, + "OnlyOneTrailingClosureArgument" : true, + "OrderedImports" : true, + "ReplaceForEachWithForLoop" : true, + "ReturnVoidInsteadOfEmptyTuple" : true, + "TypeNamesShouldBeCapitalized" : true, + "UseEarlyExits" : false, + "UseExplicitNilCheckInConditions" : true, + "UseLetInEveryBoundCaseVariable" : true, + "UseShorthandTypeNames" : true, + "UseSingleLinePropertyGetter" : true, + "UseSynthesizedInitializer" : true, + "UseTripleSlashForDocumentationComments" : true, + "UseWhereClausesInForLoops" : false, + "ValidateDocumentationComments" : false + }, + "spacesAroundRangeFormationOperators" : false, + "spacesBeforeEndOfLineComments" : 2, + "tabWidth" : 8, + "version" : 1 +} diff --git a/LICENSE b/LICENSE index 6736f84..f9a0f3d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 Recap AI & Rawand Ahmed Shaswar +Copyright (c) 2025 Recap AI & Rawand Ahmed Shaswar, Ivo Bellin Salarin Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Recap.xcodeproj/project.pbxproj b/Recap.xcodeproj/project.pbxproj index 873582b..59aca1d 100644 --- a/Recap.xcodeproj/project.pbxproj +++ b/Recap.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ A7BF55C92E38BF40003536FB /* Ollama in Frameworks */ = {isa = PBXBuildFile; productRef = A7BF55C82E38BF40003536FB /* Ollama */; }; A7C35B112E3DFD2700F9261F /* Mockable in Frameworks */ = {isa = PBXBuildFile; productRef = A7C35B102E3DFD2700F9261F /* Mockable */; }; A7C35B192E3DFDB500F9261F /* Mockable in Frameworks */ = {isa = PBXBuildFile; productRef = A7C35B182E3DFDB500F9261F /* Mockable */; }; + E72FA4FF2E8EC8A300BA8587 /* OpenAI in Frameworks */ = {isa = PBXBuildFile; productRef = E72FA4FE2E8EC8A300BA8587 /* OpenAI */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -37,6 +38,7 @@ Audio/Models/AudioProcess.swift, Audio/Models/AudioProcessGroup.swift, Audio/Processing/Detection/AudioProcessControllerType.swift, + Audio/Processing/FileManagement/RecordingFileManagerHelper.swift, DataModels/RecapDataModel.xcdatamodeld, Helpers/Availability/AvailabilityHelper.swift, "Helpers/Colors/Color+Extension.swift", @@ -71,6 +73,9 @@ Services/LLM/Providers/Ollama/OllamaAPIClient.swift, Services/LLM/Providers/Ollama/OllamaModel.swift, Services/LLM/Providers/Ollama/OllamaProvider.swift, + Services/LLM/Providers/OpenAI/OpenAIAPIClient.swift, + Services/LLM/Providers/OpenAI/OpenAIModel.swift, + Services/LLM/Providers/OpenAI/OpenAIProvider.swift, Services/LLM/Providers/OpenRouter/OpenRouterAPIClient.swift, Services/LLM/Providers/OpenRouter/OpenRouterModel.swift, Services/LLM/Providers/OpenRouter/OpenRouterProvider.swift, @@ -85,12 +90,19 @@ Services/Processing/Models/ProcessingState.swift, Services/Processing/Models/RecordingProcessingState.swift, Services/Processing/ProcessingCoordinator.swift, + "Services/Processing/ProcessingCoordinator+Completion.swift", + "Services/Processing/ProcessingCoordinator+Helpers.swift", + "Services/Processing/ProcessingCoordinator+Transcription.swift", Services/Processing/ProcessingCoordinatorType.swift, Services/Processing/SystemLifecycle/SystemLifecycleManager.swift, Services/Summarization/Models/SummarizationRequest.swift, Services/Summarization/Models/SummarizationResult.swift, Services/Summarization/SummarizationServiceType.swift, + Services/Transcription/Models/TranscriptionSegment.swift, Services/Transcription/TranscriptionServiceType.swift, + Services/Transcription/Utils/TranscriptionMarkdownExporter.swift, + Services/Transcription/Utils/TranscriptionMerger.swift, + Services/Transcription/Utils/TranscriptionTextCleaner.swift, Services/Utilities/Warnings/ProviderWarningCoordinator.swift, Services/Utilities/Warnings/WarningManager.swift, Services/Utilities/Warnings/WarningManagerType.swift, @@ -98,10 +110,17 @@ UIComponents/Cards/ActionableWarningCard.swift, UseCases/Onboarding/ViewModel/OnboardingViewModel.swift, UseCases/Onboarding/ViewModel/OnboardingViewModelType.swift, + UseCases/Settings/Components/FolderSettingsView.swift, UseCases/Settings/Components/MeetingDetection/MeetingDetectionView.swift, UseCases/Settings/Components/Reusable/CustomToggle.swift, UseCases/Settings/Components/SettingsCard.swift, + "UseCases/Settings/Components/TabViews/GeneralSettingsView+Preview.swift", + UseCases/Settings/ViewModels/FolderSettingsViewModel.swift, UseCases/Settings/ViewModels/General/GeneralSettingsViewModel.swift, + "UseCases/Settings/ViewModels/General/GeneralSettingsViewModel+APIKeys.swift", + "UseCases/Settings/ViewModels/General/GeneralSettingsViewModel+ModelManagement.swift", + "UseCases/Settings/ViewModels/General/GeneralSettingsViewModel+ProviderValidation.swift", + "UseCases/Settings/ViewModels/General/GeneralSettingsViewModel+Testing.swift", UseCases/Settings/ViewModels/General/GeneralSettingsViewModelType.swift, UseCases/Settings/ViewModels/MeetingDetection/MeetingDetectionSettingsViewModel.swift, UseCases/Settings/ViewModels/MeetingDetection/MeetingDetectionSettingsViewModelType.swift, @@ -112,12 +131,20 @@ ); target = A721065F2E30165B0073C515 /* RecapTests */; }; + E7A63B8F2E84794D00192B23 /* Exceptions for "Recap" folder in "Recap" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = A72106512E3016590073C515 /* Recap */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ A72106542E3016590073C515 /* Recap */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( + E7A63B8F2E84794D00192B23 /* Exceptions for "Recap" folder in "Recap" target */, A7C35B1B2E3DFE1D00F9261F /* Exceptions for "Recap" folder in "RecapTests" target */, ); path = Recap; @@ -139,6 +166,7 @@ A73F0CBD2E350D2700B07BB2 /* WhisperKit in Frameworks */, A73F0CBF2E350D2700B07BB2 /* whisperkit-cli in Frameworks */, A743B08B2E3D479600785BFF /* MarkdownUI in Frameworks */, + E72FA4FF2E8EC8A300BA8587 /* OpenAI in Frameworks */, A7C35B112E3DFD2700F9261F /* Mockable in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -197,6 +225,7 @@ A7BF55C82E38BF40003536FB /* Ollama */, A743B08A2E3D479600785BFF /* MarkdownUI */, A7C35B102E3DFD2700F9261F /* Mockable */, + E72FA4FE2E8EC8A300BA8587 /* OpenAI */, ); productName = Recap; productReference = A72106522E3016590073C515 /* Recap.app */; @@ -234,7 +263,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1640; - LastUpgradeCheck = 1640; + LastUpgradeCheck = 2600; TargetAttributes = { A72106512E3016590073C515 = { CreatedOnToolsVersion = 16.4; @@ -259,6 +288,7 @@ A7BF55C72E38BF40003536FB /* XCRemoteSwiftPackageReference "ollama-swift" */, A743B0892E3D479600785BFF /* XCRemoteSwiftPackageReference "swift-markdown-ui" */, A7C35B0F2E3DFD2700F9261F /* XCRemoteSwiftPackageReference "Mockable" */, + E72FA4FD2E8EC8A300BA8587 /* XCRemoteSwiftPackageReference "OpenAI" */, ); preferredProjectObjectVersion = 77; productRefGroup = A72106532E3016590073C515 /* Products */; @@ -348,6 +378,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = EY7EQX6JC5; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -373,6 +404,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited) MOCKING"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; @@ -412,6 +444,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = EY7EQX6JC5; ENABLE_NS_ASSERTIONS = NO; @@ -429,7 +462,9 @@ MACOSX_DEPLOYMENT_TARGET = 15.5; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; }; name = Release; @@ -443,9 +478,23 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = EY7EQX6JC5; + DEAD_CODE_STRIPPING = YES; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = 3KRL43SU3T; + ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; + ENABLE_INCOMING_NETWORK_CONNECTIONS = NO; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_PREVIEWS = YES; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = YES; + ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PRINTING = NO; + ENABLE_RESOURCE_ACCESS_USB = NO; + ENABLE_USER_SELECTED_FILES = readwrite; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Recap/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Recap; @@ -457,8 +506,8 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 0.0.2; - PRODUCT_BUNDLE_IDENTIFIER = dev.rawa.Recap; + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.nilleb.Recap; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited) MOCKING"; @@ -476,9 +525,23 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = EY7EQX6JC5; + DEAD_CODE_STRIPPING = YES; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = 3KRL43SU3T; + ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; + ENABLE_INCOMING_NETWORK_CONNECTIONS = NO; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_PREVIEWS = YES; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = YES; + ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PRINTING = NO; + ENABLE_RESOURCE_ACCESS_USB = NO; + ENABLE_USER_SELECTED_FILES = readwrite; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Recap/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Recap; @@ -490,8 +553,8 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 0.0.2; - PRODUCT_BUNDLE_IDENTIFIER = dev.rawa.Recap; + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.nilleb.Recap; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; SWIFT_EMIT_LOC_STRINGS = YES; @@ -505,11 +568,12 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = EY7EQX6JC5; + DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = 3KRL43SU3T; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = dev.rawa.RecapTests; + PRODUCT_BUNDLE_IDENTIFIER = co.nilleb.RecapTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited) MOCKING"; SWIFT_EMIT_LOC_STRINGS = NO; @@ -524,11 +588,12 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = EY7EQX6JC5; + DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = 3KRL43SU3T; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = dev.rawa.RecapTests; + PRODUCT_BUNDLE_IDENTIFIER = co.nilleb.RecapTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; @@ -573,8 +638,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/argmaxinc/WhisperKit.git"; requirement = { - branch = main; - kind = branch; + kind = upToNextMajorVersion; + minimumVersion = 0.9.0; }; }; A743B0892E3D479600785BFF /* XCRemoteSwiftPackageReference "swift-markdown-ui" */ = { @@ -601,6 +666,14 @@ minimumVersion = 0.4.0; }; }; + E72FA4FD2E8EC8A300BA8587 /* XCRemoteSwiftPackageReference "OpenAI" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/MacPaw/OpenAI.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.4.6; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -634,6 +707,11 @@ package = A7C35B0F2E3DFD2700F9261F /* XCRemoteSwiftPackageReference "Mockable" */; productName = Mockable; }; + E72FA4FE2E8EC8A300BA8587 /* OpenAI */ = { + isa = XCSwiftPackageProductDependency; + package = E72FA4FD2E8EC8A300BA8587 /* XCRemoteSwiftPackageReference "OpenAI" */; + productName = OpenAI; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = A721064A2E3016590073C515 /* Project object */; diff --git a/Recap.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Recap.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7cd11ac..e027bb8 100644 --- a/Recap.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Recap.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "22354261936fd8aee2d8d59cf96bf117f6576de93e6af7c22971e4ff62cecf2d", + "originHash" : "f67188f4de6ac4a4c2f88a4975d877b938f676c380b1104ca1e83ae31d5e359d", "pins" : [ { "identity" : "jinja", @@ -37,6 +37,15 @@ "version" : "1.8.0" } }, + { + "identity" : "openai", + "kind" : "remoteSourceControl", + "location" : "https://github.com/MacPaw/OpenAI.git", + "state" : { + "revision" : "80045fcda7ba727a327eb0a525e983fd7a796c70", + "version" : "0.4.6" + } + }, { "identity" : "swift-argument-parser", "kind" : "remoteSourceControl", @@ -64,6 +73,15 @@ "version" : "1.2.1" } }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types", + "state" : { + "revision" : "a0a57e949a8903563aba4615869310c0ebf14c03", + "version" : "1.4.0" + } + }, { "identity" : "swift-markdown-ui", "kind" : "remoteSourceControl", @@ -73,6 +91,15 @@ "version" : "2.4.1" } }, + { + "identity" : "swift-openapi-runtime", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-openapi-runtime", + "state" : { + "revision" : "7722cf8eac05c1f1b5b05895b04cfcc29576d9be", + "version" : "1.8.3" + } + }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", @@ -96,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/argmaxinc/WhisperKit.git", "state" : { - "branch" : "main", - "revision" : "3f13167641cf49a6023f509cda674e22f93b5220" + "revision" : "3f451e14fdd29276fbf548343e17a50b2bfd16f7", + "version" : "0.14.0" } }, { diff --git a/Recap.xcodeproj/xcshareddata/xcschemes/Recap.xcscheme b/Recap.xcodeproj/xcshareddata/xcschemes/Recap.xcscheme index 3a7fad6..418bc39 100644 --- a/Recap.xcodeproj/xcshareddata/xcschemes/Recap.xcscheme +++ b/Recap.xcodeproj/xcshareddata/xcschemes/Recap.xcscheme @@ -1,6 +1,6 @@ Mixer -> Tap all use same format") + + guard let outputURL = outputURL else { + throw AudioCaptureError.coreAudioError("No output URL specified") } - - func createAudioFile(at url: URL) throws { - let outputFormat = targetFormat ?? inputFormat - guard let finalFormat = outputFormat else { - throw AudioCaptureError.coreAudioError("No valid output format") - } - - let file = try AVAudioFile( - forWriting: url, - settings: finalFormat.settings, - commonFormat: .pcmFormatFloat32, - interleaved: finalFormat.isInterleaved - ) - - self.audioFile = file - - if let targetFormat = targetFormat { - logger.info("AVAudioFile created with target format: \(targetFormat.sampleRate)Hz, \(targetFormat.channelCount)ch") - } else { - logger.info("AVAudioFile created with input format: \(finalFormat.sampleRate)Hz, \(finalFormat.channelCount)ch") - } + + // Verify input node is available and has audio input + guard let inputNode = inputNode else { + throw AudioCaptureError.coreAudioError("Input node not available") } - - func stopAudioEngine() { - guard let audioEngine = audioEngine, isRecording else { return } - - converterNode?.removeTap(onBus: 0) - audioEngine.stop() - - isRecording = false - audioLevel = 0.0 + + let inputFormat = inputNode.inputFormat(forBus: 0) + // Update cached inputFormat to reflect current hardware state (may have changed since preparation) + self.inputFormat = inputFormat + logger.info( + "Starting audio engine with input format: \(inputFormat.sampleRate)Hz, \(inputFormat.channelCount)ch" + ) + + // Check if input node has audio input available + if inputFormat.channelCount == 0 { + logger.warning( + "Input node has no audio channels available - microphone may not be connected or permission denied" + ) + throw AudioCaptureError.coreAudioError( + "No audio input channels available - check microphone connection and permissions") } - - func closeAudioFile() { - audioFile = nil + + // Verify microphone permission before starting + let permissionStatus = AVCaptureDevice.authorizationStatus(for: .audio) + if permissionStatus != .authorized { + logger.error("Microphone permission not authorized: \(permissionStatus.rawValue)") + throw AudioCaptureError.microphonePermissionDenied } + + try createAudioFile(at: outputURL) + try installAudioTap() + + do { + try audioEngine.start() + logger.info("AVAudioEngine started successfully") + } catch { + logger.error("Failed to start AVAudioEngine: \(error)") + throw AudioCaptureError.coreAudioError( + "Failed to start audio engine: \(error.localizedDescription)") + } + + isRecording = true + } + + func installAudioTap() throws { + guard let converterNode = converterNode else { + throw AudioCaptureError.coreAudioError("Converter node not available") + } + + guard let inputFormat = inputFormat else { + throw AudioCaptureError.coreAudioError("Input format not available") + } + + let tapFormat = inputFormat + + converterNode.installTap(onBus: 0, bufferSize: 1024, format: tapFormat) { [weak self] buffer, time in + self?.processAudioBuffer(buffer, at: time) + } + + logger.info( + "Audio tap installed with input format: \(tapFormat.sampleRate)Hz, \(tapFormat.channelCount)ch" + ) + logger.info("Format consistency ensured: Hardware -> Mixer -> Tap all use same format") + } + + func createAudioFile(at url: URL) throws { + let outputFormat = targetFormat ?? inputFormat + guard let finalFormat = outputFormat else { + throw AudioCaptureError.coreAudioError("No valid output format") + } + + let file = try AVAudioFile( + forWriting: url, + settings: finalFormat.settings, + commonFormat: .pcmFormatFloat32, + interleaved: finalFormat.isInterleaved + ) + + self.audioFile = file + + if let targetFormat = targetFormat { + logger.info( + "AVAudioFile created with target format: \(targetFormat.sampleRate)Hz, \(targetFormat.channelCount)ch" + ) + } else { + logger.info( + "AVAudioFile created with input format: \(finalFormat.sampleRate)Hz, \(finalFormat.channelCount)ch" + ) + } + } + + func stopAudioEngine() { + guard let audioEngine = audioEngine, isRecording else { return } + + converterNode?.removeTap(onBus: 0) + audioEngine.stop() + + isRecording = false + audioLevel = 0.0 + } + + func closeAudioFile() { + audioFile = nil + } } diff --git a/Recap/Audio/Capture/MicrophoneCapture+AudioProcessing.swift b/Recap/Audio/Capture/MicrophoneCapture+AudioProcessing.swift index ad86457..c6a6d97 100644 --- a/Recap/Audio/Capture/MicrophoneCapture+AudioProcessing.swift +++ b/Recap/Audio/Capture/MicrophoneCapture+AudioProcessing.swift @@ -2,80 +2,101 @@ import AVFoundation import OSLog extension MicrophoneCapture { - - func processAudioBuffer(_ buffer: AVAudioPCMBuffer, at time: AVAudioTime) { - guard isRecording else { return } - - calculateAndUpdateAudioLevel(from: buffer) - - if let audioFile = audioFile { - do { - if let targetFormat = targetFormat, - buffer.format.sampleRate != targetFormat.sampleRate || - buffer.format.channelCount != targetFormat.channelCount { - - if let convertedBuffer = convertBuffer(buffer, to: targetFormat) { - try audioFile.write(from: convertedBuffer) - } else { - logger.warning("Failed to convert buffer, writing original") - try audioFile.write(from: buffer) - } - } else { - try audioFile.write(from: buffer) - } - } catch { - logger.error("Failed to write audio buffer: \(error)") - } - } + + func processAudioBuffer(_ buffer: AVAudioPCMBuffer, at time: AVAudioTime) { + guard isRecording else { return } + + // Log audio data reception for debugging + if buffer.frameLength > 0 { + logger.debug( + """ + Microphone received audio data: \(buffer.frameLength) frames, \ + \(buffer.format.sampleRate)Hz, \(buffer.format.channelCount)ch + """ + ) } - - func convertBuffer(_ inputBuffer: AVAudioPCMBuffer, to targetFormat: AVAudioFormat) -> AVAudioPCMBuffer? { - guard let converter = AVAudioConverter(from: inputBuffer.format, to: targetFormat) else { - return nil - } - - let frameCapacity = AVAudioFrameCount(Double(inputBuffer.frameLength) * (targetFormat.sampleRate / inputBuffer.format.sampleRate)) - - guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: frameCapacity) else { - return nil - } - - var error: NSError? - let status = converter.convert(to: outputBuffer, error: &error) { _, outStatus in - outStatus.pointee = .haveData - return inputBuffer - } - - if status == .error { - logger.error("Audio conversion failed: \(error?.localizedDescription ?? "Unknown error")") - return nil + + calculateAndUpdateAudioLevel(from: buffer) + + if let audioFile = audioFile { + do { + if let targetFormat = targetFormat, + buffer.format.sampleRate != targetFormat.sampleRate + || buffer.format.channelCount != targetFormat.channelCount { + if let convertedBuffer = convertBuffer(buffer, to: targetFormat) { + try audioFile.write(from: convertedBuffer) + logger.debug( + "Wrote converted audio buffer: \(convertedBuffer.frameLength) frames") + } else { + logger.warning("Failed to convert buffer, writing original") + try audioFile.write(from: buffer) + } + } else { + try audioFile.write(from: buffer) + logger.debug("Wrote audio buffer: \(buffer.frameLength) frames") } - - return outputBuffer + } catch { + logger.error("Failed to write audio buffer: \(error)") + } + } else { + logger.warning("No audio file available for writing") } + } - func calculateAndUpdateAudioLevel(from buffer: AVAudioPCMBuffer) { - guard let channelData = buffer.floatChannelData?[0] else { return } - - let frameCount = Int(buffer.frameLength) - guard frameCount > 0 else { return } - - var sum: Float = 0 - for i in 0.. AVAudioPCMBuffer? { + guard let converter = AVAudioConverter(from: inputBuffer.format, to: targetFormat) else { + return nil } - - func checkStatus(_ status: OSStatus, _ operation: String) throws { - guard status == noErr else { - throw AudioCaptureError.coreAudioError("\(operation) failed: \(status)") - } + + let frameCapacity = AVAudioFrameCount( + Double(inputBuffer.frameLength) + * (targetFormat.sampleRate / inputBuffer.format.sampleRate)) + + guard + let outputBuffer = AVAudioPCMBuffer( + pcmFormat: targetFormat, frameCapacity: frameCapacity) + else { + return nil + } + + var error: NSError? + let status = converter.convert(to: outputBuffer, error: &error) { _, outStatus in + outStatus.pointee = .haveData + return inputBuffer + } + + if status == .error { + logger.error( + "Audio conversion failed: \(error?.localizedDescription ?? "Unknown error")") + return nil + } + + return outputBuffer + } + + func calculateAndUpdateAudioLevel(from buffer: AVAudioPCMBuffer) { + guard let channelData = buffer.floatChannelData?[0] else { return } + + let frameCount = Int(buffer.frameLength) + guard frameCount > 0 else { return } + + var sum: Float = 0 + for frameIndex in 0..? - var isPreWarmed = false - - @Published var audioLevel: Float = 0.0 - - init() { - startBackgroundPreparation() - } - - deinit { - cleanup() - } - - func start(outputURL: URL, targetFormat: AudioStreamBasicDescription? = nil) throws { - self.outputURL = outputURL - - if let targetDesc = targetFormat { - var format = targetDesc - self.targetFormat = AVAudioFormat(streamDescription: &format) - - logger.info("Target format set from ProcessTap: \(targetDesc.mSampleRate)Hz, \(targetDesc.mChannelsPerFrame)ch, formatID: \(String(format: "0x%08x", targetDesc.mFormatID))") - } - - waitForPreWarmIfNeeded() - - try startAudioEngine() - logger.info("MicrophoneCapture started with AVAudioEngine") - } - - func stop() { - guard isRecording else { return } - stopAudioEngine() - closeAudioFile() - logger.info("MicrophoneCapture stopped") - } - - var recordingFormat: AVAudioFormat? { - return targetFormat ?? inputFormat + let logger = Logger( + subsystem: AppConstants.Logging.subsystem, + category: String(describing: MicrophoneCapture.self)) + + var audioEngine: AVAudioEngine? + var audioFile: AVAudioFile? + var isRecording = false + var outputURL: URL? + + var inputNode: AVAudioInputNode? + var converterNode: AVAudioMixerNode? + + var targetFormat: AVAudioFormat? + var inputFormat: AVAudioFormat? + + var preparationTask: Task? + var isPreWarmed = false + + @Published var audioLevel: Float = 0.0 + + init() { + startBackgroundPreparation() + } + + deinit { + cleanup() + } + + func start(outputURL: URL, targetFormat: AudioStreamBasicDescription? = nil) throws { + self.outputURL = outputURL + + if let targetDesc = targetFormat { + var format = targetDesc + self.targetFormat = AVAudioFormat(streamDescription: &format) + + logger.info( + """ + Target format set from ProcessTap: \(targetDesc.mSampleRate)Hz, \ + \(targetDesc.mChannelsPerFrame)ch, formatID: \(String(format: "0x%08x", targetDesc.mFormatID)) + """) } - + + waitForPreWarmIfNeeded() + + try startAudioEngine() + logger.info("MicrophoneCapture started with AVAudioEngine") + } + + func stop() { + guard isRecording else { return } + stopAudioEngine() + closeAudioFile() + logger.info("MicrophoneCapture stopped") + } + + var recordingFormat: AVAudioFormat? { + return targetFormat ?? inputFormat + } + } extension MicrophoneCapture { - - func startBackgroundPreparation() { - preparationTask = Task { - await performBackgroundPreparation() - } + + func startBackgroundPreparation() { + preparationTask = Task { + await performBackgroundPreparation() } - - private func waitForPreWarmIfNeeded() { - guard preparationTask != nil else { return } - - let startTime = CFAbsoluteTimeGetCurrent() - while !isPreWarmed && (CFAbsoluteTimeGetCurrent() - startTime) < 0.1 { - usleep(1000) - } + } + + private func waitForPreWarmIfNeeded() { + guard preparationTask != nil else { return } + + let startTime = CFAbsoluteTimeGetCurrent() + while !isPreWarmed && (CFAbsoluteTimeGetCurrent() - startTime) < 0.1 { + usleep(1000) } - - func cleanup() { - preparationTask?.cancel() - - if isRecording { - stop() - } - - if let audioEngine = audioEngine { - audioEngine.stop() - converterNode?.removeTap(onBus: 0) - } - - closeAudioFile() + } + + func cleanup() { + preparationTask?.cancel() + + if isRecording { + stop() } - + + if let audioEngine = audioEngine { + audioEngine.stop() + converterNode?.removeTap(onBus: 0) + } + + closeAudioFile() + } + } diff --git a/Recap/Audio/Capture/MicrophoneCaptureType.swift b/Recap/Audio/Capture/MicrophoneCaptureType.swift index 496b109..264e5f3 100644 --- a/Recap/Audio/Capture/MicrophoneCaptureType.swift +++ b/Recap/Audio/Capture/MicrophoneCaptureType.swift @@ -5,14 +5,13 @@ // Created by Rawand Ahmad on 01/08/2025. // - import AVFoundation import AudioToolbox protocol MicrophoneCaptureType: ObservableObject { - var audioLevel: Float { get } - var recordingFormat: AVAudioFormat? { get } + var audioLevel: Float { get } + var recordingFormat: AVAudioFormat? { get } - func start(outputURL: URL, targetFormat: AudioStreamBasicDescription?) throws - func stop() + func start(outputURL: URL, targetFormat: AudioStreamBasicDescription?) throws + func stop() } diff --git a/Recap/Audio/Capture/Tap/AudioTapType.swift b/Recap/Audio/Capture/Tap/AudioTapType.swift new file mode 100644 index 0000000..66fdd69 --- /dev/null +++ b/Recap/Audio/Capture/Tap/AudioTapType.swift @@ -0,0 +1,24 @@ +import AVFoundation +import AudioToolbox +import Foundation + +protocol AudioTapType: ObservableObject { + var activated: Bool { get } + var audioLevel: Float { get } + var errorMessage: String? { get } + var tapStreamDescription: AudioStreamBasicDescription? { get } + + @MainActor func activate() + func invalidate() + func run( + on queue: DispatchQueue, ioBlock: @escaping AudioDeviceIOBlock, + invalidationHandler: @escaping (Self) -> Void) throws +} + +protocol AudioTapRecorderType: ObservableObject { + var fileURL: URL { get } + var isRecording: Bool { get } + + @MainActor func start() throws + func stop() +} diff --git a/Recap/Audio/Capture/Tap/ProcessTap.swift b/Recap/Audio/Capture/Tap/ProcessTap.swift index c3df345..73e591c 100644 --- a/Recap/Audio/Capture/Tap/ProcessTap.swift +++ b/Recap/Audio/Capture/Tap/ProcessTap.swift @@ -1,305 +1,377 @@ -import SwiftUI +import AVFoundation import AudioToolbox import OSLog -import AVFoundation +import SwiftUI extension String: @retroactive LocalizedError { - public var errorDescription: String? { self } + public var errorDescription: String? { self } } -final class ProcessTap: ObservableObject { - typealias InvalidationHandler = (ProcessTap) -> Void - - let process: AudioProcess - let muteWhenRunning: Bool - private let logger: Logger - - private(set) var errorMessage: String? - @Published private(set) var audioLevel: Float = 0.0 - - fileprivate func setAudioLevel(_ level: Float) { - audioLevel = level - } - - init(process: AudioProcess, muteWhenRunning: Bool = false) { - self.process = process - self.muteWhenRunning = muteWhenRunning - self.logger = Logger(subsystem: AppConstants.Logging.subsystem, category: "\(String(describing: ProcessTap.self))(\(process.name))") - } - - @ObservationIgnored - private var processTapID: AudioObjectID = .unknown - @ObservationIgnored - private var aggregateDeviceID = AudioObjectID.unknown - @ObservationIgnored - private var deviceProcID: AudioDeviceIOProcID? - @ObservationIgnored - private(set) var tapStreamDescription: AudioStreamBasicDescription? - @ObservationIgnored - private var invalidationHandler: InvalidationHandler? - - @ObservationIgnored - private(set) var activated = false - - @MainActor - func activate() { - guard !activated else { return } - activated = true - - logger.debug(#function) - - self.errorMessage = nil - - do { - try prepare(for: process.objectID) - } catch { - logger.error("\(error, privacy: .public)") - self.errorMessage = error.localizedDescription - } +final class ProcessTap: ObservableObject, AudioTapType { + typealias InvalidationHandler = (ProcessTap) -> Void + + let process: AudioProcess + let muteWhenRunning: Bool + private let logger: Logger + + private(set) var errorMessage: String? + @Published private(set) var audioLevel: Float = 0.0 + + fileprivate func setAudioLevel(_ level: Float) { + audioLevel = level + } + + init(process: AudioProcess, muteWhenRunning: Bool = false) { + self.process = process + self.muteWhenRunning = muteWhenRunning + self.logger = Logger( + subsystem: AppConstants.Logging.subsystem, + category: "\(String(describing: ProcessTap.self))(\(process.name))") + } + + @ObservationIgnored + private var processTapID: AudioObjectID = .unknown + @ObservationIgnored + private var aggregateDeviceID = AudioObjectID.unknown + @ObservationIgnored + private var deviceProcID: AudioDeviceIOProcID? + @ObservationIgnored + private(set) var tapStreamDescription: AudioStreamBasicDescription? + @ObservationIgnored + private var invalidationHandler: InvalidationHandler? + + @ObservationIgnored + private(set) var activated = false + + @MainActor + func activate() { + guard !activated else { return } + activated = true + + logger.debug(#function) + + self.errorMessage = nil + + do { + try prepare(for: process.objectID) + } catch { + logger.error("\(error, privacy: .public)") + self.errorMessage = error.localizedDescription } - - func invalidate() { - guard activated else { return } - defer { activated = false } - - logger.debug(#function) - - invalidationHandler?(self) - self.invalidationHandler = nil - - if aggregateDeviceID.isValid { - var err = AudioDeviceStop(aggregateDeviceID, deviceProcID) - if err != noErr { logger.warning("Failed to stop aggregate device: \(err, privacy: .public)") } - - if let deviceProcID = deviceProcID { - err = AudioDeviceDestroyIOProcID(aggregateDeviceID, deviceProcID) - if err != noErr { logger.warning("Failed to destroy device I/O proc: \(err, privacy: .public)") } - self.deviceProcID = nil - } - - err = AudioHardwareDestroyAggregateDevice(aggregateDeviceID) - if err != noErr { - logger.warning("Failed to destroy aggregate device: \(err, privacy: .public)") - } - aggregateDeviceID = .unknown + } + + func invalidate() { + guard activated else { return } + defer { activated = false } + + logger.debug(#function) + + invalidationHandler?(self) + self.invalidationHandler = nil + + if aggregateDeviceID.isValid { + if let deviceProcID = deviceProcID { + var stopErr = AudioDeviceStop(aggregateDeviceID, deviceProcID) + if stopErr != noErr { + logger.warning("Failed to stop aggregate device: \(stopErr, privacy: .public)") } - - if processTapID.isValid { - let err = AudioHardwareDestroyProcessTap(processTapID) - if err != noErr { - logger.warning("Failed to destroy audio tap: \(err, privacy: .public)") - } - self.processTapID = .unknown + + stopErr = AudioDeviceDestroyIOProcID(aggregateDeviceID, deviceProcID) + if stopErr != noErr { + logger.warning( + "Failed to destroy device I/O proc: \(stopErr, privacy: .public)") } + self.deviceProcID = nil + } + + let destroyErr = AudioHardwareDestroyAggregateDevice(aggregateDeviceID) + if destroyErr != noErr { + logger.warning( + "Failed to destroy aggregate device: \(destroyErr, privacy: .public)") + } + aggregateDeviceID = .unknown } - - private func prepare(for objectID: AudioObjectID) throws { - errorMessage = nil - - let tapDescription = CATapDescription(stereoMixdownOfProcesses: [objectID]) - tapDescription.uuid = UUID() - tapDescription.muteBehavior = muteWhenRunning ? .mutedWhenTapped : .unmuted - - var tapID: AUAudioObjectID = .unknown - var err = AudioHardwareCreateProcessTap(tapDescription, &tapID) - - guard err == noErr else { - errorMessage = "Process tap creation failed with error \(err)" - return - } - - logger.debug("Created process tap #\(tapID, privacy: .public)") - - self.processTapID = tapID - - let systemOutputID = try AudioDeviceID.readDefaultSystemOutputDevice() - let outputUID = try systemOutputID.readDeviceUID() - let aggregateUID = UUID().uuidString - - let description: [String: Any] = [ - kAudioAggregateDeviceNameKey: "Tap-\(process.id)", - kAudioAggregateDeviceUIDKey: aggregateUID, - kAudioAggregateDeviceMainSubDeviceKey: outputUID, - kAudioAggregateDeviceIsPrivateKey: true, - kAudioAggregateDeviceIsStackedKey: false, - kAudioAggregateDeviceTapAutoStartKey: true, - kAudioAggregateDeviceSubDeviceListKey: [ - [ - kAudioSubDeviceUIDKey: outputUID - ] - ], - kAudioAggregateDeviceTapListKey: [ - [ - kAudioSubTapDriftCompensationKey: true, - kAudioSubTapUIDKey: tapDescription.uuid.uuidString - ] - ] + + if processTapID.isValid { + let err = AudioHardwareDestroyProcessTap(processTapID) + if err != noErr { + logger.warning("Failed to destroy audio tap: \(err, privacy: .public)") + } + self.processTapID = .unknown + } + } + + private func prepare(for objectID: AudioObjectID) throws { + errorMessage = nil + logger.info("Preparing process tap for objectID: \(objectID, privacy: .public)") + + let tapDescription = try createTapDescription(for: objectID) + let tapID = try createProcessTap(with: tapDescription) + self.processTapID = tapID + + self.tapStreamDescription = try tapID.readAudioTapStreamBasicDescription() + logger.info( + """ + Tap stream description: \(self.tapStreamDescription?.mSampleRate ?? 0)Hz, \ + \(self.tapStreamDescription?.mChannelsPerFrame ?? 0)ch + """) + + try createAggregateDevice(with: tapDescription) + } + + private func createTapDescription(for objectID: AudioObjectID) -> CATapDescription { + let tapDescription = CATapDescription(stereoMixdownOfProcesses: [objectID]) + tapDescription.uuid = UUID() + tapDescription.muteBehavior = muteWhenRunning ? .mutedWhenTapped : .unmuted + return tapDescription + } + + private func createProcessTap(with tapDescription: CATapDescription) throws -> AudioObjectID { + var tapID: AUAudioObjectID = .unknown + let err = AudioHardwareCreateProcessTap(tapDescription, &tapID) + + guard err == noErr else { + let errorMsg = + "Process tap creation failed with error \(err) (0x\(String(err, radix: 16, uppercase: true)))" + logger.error("\(errorMsg, privacy: .public)") + errorMessage = errorMsg + throw errorMsg + } + + logger.info("Created process tap #\(tapID, privacy: .public)") + return tapID + } + + private func createAggregateDevice(with tapDescription: CATapDescription) throws { + let systemOutputID = try AudioDeviceID.readDefaultSystemOutputDevice() + let outputUID = try systemOutputID.readDeviceUID() + let aggregateUID = UUID().uuidString + + let description: [String: Any] = [ + kAudioAggregateDeviceNameKey: "Tap-\(process.id)", + kAudioAggregateDeviceUIDKey: aggregateUID, + kAudioAggregateDeviceMainSubDeviceKey: outputUID, + kAudioAggregateDeviceIsPrivateKey: true, + kAudioAggregateDeviceIsStackedKey: false, + kAudioAggregateDeviceTapAutoStartKey: true, + kAudioAggregateDeviceSubDeviceListKey: [ + [ + kAudioSubDeviceUIDKey: outputUID ] - - self.tapStreamDescription = try tapID.readAudioTapStreamBasicDescription() - - aggregateDeviceID = AudioObjectID.unknown - err = AudioHardwareCreateAggregateDevice(description as CFDictionary, &aggregateDeviceID) - guard err == noErr else { - throw "Failed to create aggregate device: \(err)" - } - - logger.debug("Created aggregate device #\(self.aggregateDeviceID, privacy: .public)") + ], + kAudioAggregateDeviceTapListKey: [ + [ + kAudioSubTapDriftCompensationKey: true, + kAudioSubTapUIDKey: tapDescription.uuid.uuidString + ] + ] + ] + + aggregateDeviceID = AudioObjectID.unknown + let err = AudioHardwareCreateAggregateDevice(description as CFDictionary, &aggregateDeviceID) + guard err == noErr else { + let errorMsg = + "Failed to create aggregate device: \(err) (0x\(String(err, radix: 16, uppercase: true)))" + logger.error("\(errorMsg, privacy: .public)") + throw errorMsg + } + + logger.info("Created aggregate device #\(self.aggregateDeviceID, privacy: .public)") + } + + func run( + on queue: DispatchQueue, ioBlock: @escaping AudioDeviceIOBlock, + invalidationHandler: @escaping InvalidationHandler + ) throws { + assert(activated, "\(#function) called with inactive tap!") + assert(self.invalidationHandler == nil, "\(#function) called with tap already active!") + + errorMessage = nil + + logger.info( + "Starting audio device I/O proc for aggregate device #\(self.aggregateDeviceID, privacy: .public)" + ) + + self.invalidationHandler = invalidationHandler + + let createErr = AudioDeviceCreateIOProcIDWithBlock( + &deviceProcID, aggregateDeviceID, queue, ioBlock) + guard createErr == noErr else { + let errorMsg = + "Failed to create device I/O proc: \(createErr) (0x\(String(createErr, radix: 16, uppercase: true)))" + logger.error("\(errorMsg, privacy: .public)") + throw errorMsg } - - func run(on queue: DispatchQueue, ioBlock: @escaping AudioDeviceIOBlock, invalidationHandler: @escaping InvalidationHandler) throws { - assert(activated, "\(#function) called with inactive tap!") - assert(self.invalidationHandler == nil, "\(#function) called with tap already active!") - - errorMessage = nil - - logger.debug("Run tap!") - - self.invalidationHandler = invalidationHandler - - var err = AudioDeviceCreateIOProcIDWithBlock(&deviceProcID, aggregateDeviceID, queue, ioBlock) - guard err == noErr else { throw "Failed to create device I/O proc: \(err)" } - - err = AudioDeviceStart(aggregateDeviceID, deviceProcID) - guard err == noErr else { throw "Failed to start audio device: \(err)" } + + logger.info("Created device I/O proc ID successfully") + + guard let procID = deviceProcID else { + throw "Device I/O proc ID is nil" } - - deinit { - invalidate() + + let startErr = AudioDeviceStart(aggregateDeviceID, procID) + guard startErr == noErr else { + let errorMsg = + "Failed to start audio device: \(startErr) (0x\(String(startErr, radix: 16, uppercase: true)))" + logger.error("\(errorMsg, privacy: .public)") + throw errorMsg } + + logger.info("Audio device started successfully") + } + + deinit { + invalidate() + } } -final class ProcessTapRecorder: ObservableObject { - let fileURL: URL - let process: AudioProcess - private let queue = DispatchQueue(label: "ProcessTapRecorder", qos: .userInitiated) - private let logger: Logger - - @ObservationIgnored - private weak var _tap: ProcessTap? - - private(set) var isRecording = false - - init(fileURL: URL, tap: ProcessTap) { - self.process = tap.process - self.fileURL = fileURL - self._tap = tap - self.logger = Logger(subsystem: AppConstants.Logging.subsystem, category: "\(String(describing: ProcessTapRecorder.self))(\(fileURL.lastPathComponent))") +final class ProcessTapRecorder: ObservableObject, AudioTapRecorderType { + let fileURL: URL + let process: AudioProcess + private let queue = DispatchQueue(label: "ProcessTapRecorder", qos: .userInitiated) + private let logger: Logger + + @ObservationIgnored + private weak var processTapInstance: ProcessTap? + + private(set) var isRecording = false + + init(fileURL: URL, tap: ProcessTap) { + self.process = tap.process + self.fileURL = fileURL + self.processTapInstance = tap + self.logger = Logger( + subsystem: AppConstants.Logging.subsystem, + category: "\(String(describing: ProcessTapRecorder.self))(\(fileURL.lastPathComponent))" + ) + } + + private var tap: ProcessTap { + get throws { + guard let processTapInstance = processTapInstance else { + throw AudioCaptureError.coreAudioError("Process tap unavailable") + } + return processTapInstance } - - private var tap: ProcessTap { - get throws { - guard let _tap = _tap else { - throw AudioCaptureError.coreAudioError("Process tap unavailable") - } - return _tap - } + } + + @ObservationIgnored + private var currentFile: AVAudioFile? + + @MainActor + func start() throws { + logger.debug(#function) + + guard !isRecording else { + logger.warning("\(#function, privacy: .public) while already recording") + return } - - @ObservationIgnored - private var currentFile: AVAudioFile? - - @MainActor - func start() throws { - logger.debug(#function) - - guard !isRecording else { - logger.warning("\(#function, privacy: .public) while already recording") - return - } - - let tap = try tap - - if !tap.activated { - tap.activate() - } - - guard var streamDescription = tap.tapStreamDescription else { - throw AudioCaptureError.coreAudioError("Tap stream description not available") - } - - guard let format = AVAudioFormat(streamDescription: &streamDescription) else { - throw AudioCaptureError.coreAudioError("Failed to create AVAudioFormat") - } - - logger.info("Using audio format: \(format, privacy: .public)") - - let settings: [String: Any] = [ - AVFormatIDKey: streamDescription.mFormatID, - AVSampleRateKey: format.sampleRate, - AVNumberOfChannelsKey: format.channelCount, - ] - - let file = try AVAudioFile(forWriting: fileURL, settings: settings, commonFormat: .pcmFormatFloat32, interleaved: format.isInterleaved) - - self.currentFile = file - - try tap.run(on: queue) { [weak self] inNow, inInputData, inInputTime, outOutputData, inOutputTime in - guard let self, let currentFile = self.currentFile else { return } - do { - guard let buffer = AVAudioPCMBuffer(pcmFormat: format, bufferListNoCopy: inInputData, deallocator: nil) else { - throw "Failed to create PCM buffer" - } - - try currentFile.write(from: buffer) - - self.updateAudioLevel(from: buffer) - } catch { - logger.error("\(error, privacy: .public)") - } - } invalidationHandler: { [weak self] tap in - guard let self else { return } - handleInvalidation() - } - - isRecording = true + + let tap = try tap + + if !tap.activated { + tap.activate() } - - func stop() { - do { - logger.debug(#function) - - guard isRecording else { return } - - currentFile = nil - isRecording = false - - try tap.invalidate() - } catch { - logger.error("Stop failed: \(error, privacy: .public)") - } + + guard var streamDescription = tap.tapStreamDescription else { + throw AudioCaptureError.coreAudioError("Tap stream description not available") } - - private func handleInvalidation() { - guard isRecording else { return } - logger.debug(#function) + + guard let format = AVAudioFormat(streamDescription: &streamDescription) else { + throw AudioCaptureError.coreAudioError("Failed to create AVAudioFormat") } - - private func updateAudioLevel(from buffer: AVAudioPCMBuffer) { - guard let floatData = buffer.floatChannelData else { return } - - let channelCount = Int(buffer.format.channelCount) - let frameLength = Int(buffer.frameLength) - - var maxLevel: Float = 0.0 - - for channel in 0.. 0 { + logger.debug( + "Received audio data: \(buffer.frameLength) frames, \(buffer.format.sampleRate)Hz" + ) } + + try currentFile.write(from: buffer) + + self.updateAudioLevel(from: buffer) + } catch { + logger.error("Audio processing error: \(error, privacy: .public)") + } + } invalidationHandler: { [weak self] _ in + guard let self else { return } + logger.warning("Audio tap invalidated") + handleInvalidation() + } + + isRecording = true + } + + func stop() { + do { + logger.debug(#function) + + guard isRecording else { return } + + currentFile = nil + isRecording = false + + try tap.invalidate() + } catch { + logger.error("Stop failed: \(error, privacy: .public)") + } + } + + private func handleInvalidation() { + guard isRecording else { return } + logger.debug(#function) + } + + private func updateAudioLevel(from buffer: AVAudioPCMBuffer) { + guard let floatData = buffer.floatChannelData else { return } + + let channelCount = Int(buffer.format.channelCount) + let frameLength = Int(buffer.frameLength) + + var maxLevel: Float = 0.0 + + for channel in 0.. Void + + let muteWhenRunning: Bool + private let logger: Logger + + private(set) var errorMessage: String? + @Published private(set) var audioLevel: Float = 0.0 + + fileprivate func setAudioLevel(_ level: Float) { + audioLevel = level + } + + init(muteWhenRunning: Bool = false) { + self.muteWhenRunning = muteWhenRunning + self.logger = Logger( + subsystem: AppConstants.Logging.subsystem, + category: + "\(String(describing: SystemWideTap.self))") + } + + @ObservationIgnored + private var processTapID: AudioObjectID = .unknown + @ObservationIgnored + private var aggregateDeviceID = AudioObjectID.unknown + @ObservationIgnored + private var deviceProcID: AudioDeviceIOProcID? + @ObservationIgnored + private(set) var tapStreamDescription: AudioStreamBasicDescription? + @ObservationIgnored + private var invalidationHandler: InvalidationHandler? + + @ObservationIgnored + private(set) var activated = false + + @MainActor + func activate() { + guard !activated else { return } + activated = true + + logger.debug(#function) + + self.errorMessage = nil + + do { + try prepareSystemWideTap() + } catch { + logger.error("\(error, privacy: .public)") + self.errorMessage = error.localizedDescription + } + } + + func invalidate() { + guard activated else { return } + defer { activated = false } + + logger.debug(#function) + + invalidationHandler?(self) + self.invalidationHandler = nil + + if aggregateDeviceID.isValid { + var err = AudioDeviceStop(aggregateDeviceID, deviceProcID) + if err != noErr { + logger.warning("Failed to stop aggregate device: \(err, privacy: .public)") + } + + if let deviceProcID = deviceProcID { + err = AudioDeviceDestroyIOProcID(aggregateDeviceID, deviceProcID) + if err != noErr { + logger.warning("Failed to destroy device I/O proc: \(err, privacy: .public)") + } + self.deviceProcID = nil + } + + err = AudioHardwareDestroyAggregateDevice(aggregateDeviceID) + if err != noErr { + logger.warning("Failed to destroy aggregate device: \(err, privacy: .public)") + } + aggregateDeviceID = .unknown + } + + if processTapID.isValid { + let err = AudioHardwareDestroyProcessTap(processTapID) + if err != noErr { + logger.warning("Failed to destroy audio tap: \(err, privacy: .public)") + } + self.processTapID = .unknown + } + } + + private func prepareSystemWideTap() throws { + errorMessage = nil + + let tapDescription = CATapDescription(stereoGlobalTapButExcludeProcesses: []) + tapDescription.uuid = UUID() + tapDescription.muteBehavior = muteWhenRunning ? .mutedWhenTapped : .unmuted + tapDescription.name = "SystemWideAudioTap" + tapDescription.isPrivate = true + tapDescription.isExclusive = true + + var tapID: AUAudioObjectID = .unknown + var err = AudioHardwareCreateProcessTap(tapDescription, &tapID) + + guard err == noErr else { + errorMessage = "System-wide process tap creation failed with error \(err)" + return + } + + logger.debug("Created system-wide process tap #\(tapID, privacy: .public)") + + self.processTapID = tapID + + let systemOutputID = try AudioDeviceID.readDefaultSystemOutputDevice() + let outputUID = try systemOutputID.readDeviceUID() + let aggregateUID = UUID().uuidString + + let description: [String: Any] = [ + kAudioAggregateDeviceNameKey: "SystemWide-Tap", + kAudioAggregateDeviceUIDKey: aggregateUID, + kAudioAggregateDeviceMainSubDeviceKey: outputUID, + kAudioAggregateDeviceIsPrivateKey: true, + kAudioAggregateDeviceIsStackedKey: false, + kAudioAggregateDeviceTapAutoStartKey: true, + kAudioAggregateDeviceSubDeviceListKey: [ + [ + kAudioSubDeviceUIDKey: outputUID + ] + ], + kAudioAggregateDeviceTapListKey: [ + [ + kAudioSubTapDriftCompensationKey: true, + kAudioSubTapUIDKey: tapDescription.uuid.uuidString + ] + ] + ] + + self.tapStreamDescription = try tapID.readAudioTapStreamBasicDescription() + + aggregateDeviceID = AudioObjectID.unknown + err = AudioHardwareCreateAggregateDevice(description as CFDictionary, &aggregateDeviceID) + guard err == noErr else { + throw "Failed to create aggregate device: \(err)" + } + + logger.debug( + "Created system-wide aggregate device #\(self.aggregateDeviceID, privacy: .public)") + } + + func run( + on queue: DispatchQueue, ioBlock: @escaping AudioDeviceIOBlock, + invalidationHandler: @escaping InvalidationHandler + ) throws { + assert(activated, "\(#function) called with inactive tap!") + assert(self.invalidationHandler == nil, "\(#function) called with tap already active!") + + errorMessage = nil + + logger.debug("Run system-wide tap!") + + self.invalidationHandler = invalidationHandler + + var err = AudioDeviceCreateIOProcIDWithBlock(&deviceProcID, aggregateDeviceID, queue, ioBlock) + guard err == noErr else { throw "Failed to create device I/O proc: \(err)" } + + err = AudioDeviceStart(aggregateDeviceID, deviceProcID) + guard err == noErr else { throw "Failed to start audio device: \(err)" } + } + + deinit { + invalidate() + } +} + +final class SystemWideTapRecorder: ObservableObject, AudioTapRecorderType { + let fileURL: URL + private let queue = DispatchQueue(label: "SystemWideTapRecorder", qos: .userInitiated) + private let logger: Logger + + @ObservationIgnored + private weak var _tap: SystemWideTap? + + private(set) var isRecording = false + + init(fileURL: URL, tap: SystemWideTap) { + self.fileURL = fileURL + self._tap = tap + self.logger = Logger( + subsystem: AppConstants.Logging.subsystem, + category: "\(String(describing: SystemWideTapRecorder.self))(\(fileURL.lastPathComponent))" + ) + } + + private var tap: SystemWideTap { + get throws { + guard let tap = _tap else { + throw AudioCaptureError.coreAudioError("System-wide tap unavailable") + } + return tap + } + } + + @ObservationIgnored + private var currentFile: AVAudioFile? + + @MainActor + func start() throws { + logger.debug(#function) + + guard !isRecording else { + logger.warning("\(#function, privacy: .public) while already recording") + return + } + + let tap = try tap + + if !tap.activated { + tap.activate() + } + + guard var streamDescription = tap.tapStreamDescription else { + throw AudioCaptureError.coreAudioError("Tap stream description not available") + } + + guard let format = AVAudioFormat(streamDescription: &streamDescription) else { + throw AudioCaptureError.coreAudioError("Failed to create AVAudioFormat") + } + + logger.info("Using system-wide audio format: \(format, privacy: .public)") + + let settings: [String: Any] = [ + AVFormatIDKey: streamDescription.mFormatID, + AVSampleRateKey: format.sampleRate, + AVNumberOfChannelsKey: format.channelCount + ] + + let file = try AVAudioFile( + forWriting: fileURL, settings: settings, commonFormat: .pcmFormatFloat32, + interleaved: format.isInterleaved) + + self.currentFile = file + + try tap.run(on: queue) { [weak self] _, inInputData, _, _, _ in + guard let self, let currentFile = self.currentFile else { return } + do { + guard + let buffer = AVAudioPCMBuffer( + pcmFormat: format, bufferListNoCopy: inInputData, + deallocator: nil) + else { + throw "Failed to create PCM buffer" + } + + try currentFile.write(from: buffer) + + self.updateAudioLevel(from: buffer) + } catch { + logger.error("\(error, privacy: .public)") + } + } invalidationHandler: { [weak self] _ in + guard let self else { return } + handleInvalidation() + } + + isRecording = true + } + + func stop() { + do { + logger.debug(#function) + + guard isRecording else { return } + + currentFile = nil + isRecording = false + + try tap.invalidate() + } catch { + logger.error("Stop failed: \(error, privacy: .public)") + } + } + + private func handleInvalidation() { + guard isRecording else { return } + logger.debug(#function) + } + + private func updateAudioLevel(from buffer: AVAudioPCMBuffer) { + guard let floatData = buffer.floatChannelData else { return } + + let channelCount = Int(buffer.format.channelCount) + let frameLength = Int(buffer.frameLength) + + var maxLevel: Float = 0.0 + + for channel in 0.. AudioDeviceID { - try AudioDeviceID.system.readDefaultSystemOutputDevice() - } - - static func readProcessList() throws -> [AudioObjectID] { - try AudioObjectID.system.readProcessList() - } - - static func translatePIDToProcessObjectID(pid: pid_t) throws -> AudioObjectID { - try AudioDeviceID.system.translatePIDToProcessObjectID(pid: pid) - } - - func readProcessList() throws -> [AudioObjectID] { - try requireSystemObject() - - var address = AudioObjectPropertyAddress( - mSelector: kAudioHardwarePropertyProcessObjectList, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMain - ) - - var dataSize: UInt32 = 0 - var err = AudioObjectGetPropertyDataSize(self, &address, 0, nil, &dataSize) - guard err == noErr else { throw AudioCaptureError.coreAudioError("Error reading data size for \(address): \(err)") } - - var value = [AudioObjectID](repeating: .unknown, count: Int(dataSize) / MemoryLayout.size) - err = AudioObjectGetPropertyData(self, &address, 0, nil, &dataSize, &value) - guard err == noErr else { throw AudioCaptureError.coreAudioError("Error reading array for \(address): \(err)") } - - return value - } - - func translatePIDToProcessObjectID(pid: pid_t) throws -> AudioObjectID { - try requireSystemObject() - - let processObject = try read( - kAudioHardwarePropertyTranslatePIDToProcessObject, - defaultValue: AudioObjectID.unknown, - qualifier: pid - ) - - guard processObject.isValid else { - throw AudioCaptureError.invalidProcessID(pid) - } - - return processObject - } - - func readProcessBundleID() -> String? { - if let result = try? readString(kAudioProcessPropertyBundleID) { - result.isEmpty ? nil : result - } else { - nil - } - } - - func readProcessIsRunning() -> Bool { - (try? readBool(kAudioProcessPropertyIsRunning)) ?? false + static func readDefaultSystemOutputDevice() throws -> AudioDeviceID { + try AudioDeviceID.system.readDefaultSystemOutputDevice() + } + + static func readProcessList() throws -> [AudioObjectID] { + try AudioObjectID.system.readProcessList() + } + + static func translatePIDToProcessObjectID(pid: pid_t) throws -> AudioObjectID { + try AudioDeviceID.system.translatePIDToProcessObjectID(pid: pid) + } + + func readProcessList() throws -> [AudioObjectID] { + try requireSystemObject() + + var address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyProcessObjectList, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + + var dataSize: UInt32 = 0 + var err = AudioObjectGetPropertyDataSize(self, &address, 0, nil, &dataSize) + guard err == noErr else { + throw AudioCaptureError.coreAudioError("Error reading data size for \(address): \(err)") } - - func readDefaultSystemOutputDevice() throws -> AudioDeviceID { - try requireSystemObject() - return try read(kAudioHardwarePropertyDefaultSystemOutputDevice, defaultValue: AudioDeviceID.unknown) + + var value = [AudioObjectID]( + repeating: .unknown, count: Int(dataSize) / MemoryLayout.size) + err = AudioObjectGetPropertyData(self, &address, 0, nil, &dataSize, &value) + guard err == noErr else { + throw AudioCaptureError.coreAudioError("Error reading array for \(address): \(err)") } - - func readDeviceUID() throws -> String { - try readString(kAudioDevicePropertyDeviceUID) + + return value + } + + func translatePIDToProcessObjectID(pid: pid_t) throws -> AudioObjectID { + try requireSystemObject() + + let processObject = try read( + kAudioHardwarePropertyTranslatePIDToProcessObject, + defaultValue: AudioObjectID.unknown, + qualifier: pid + ) + + guard processObject.isValid else { + throw AudioCaptureError.invalidProcessID(pid) } - - func readAudioTapStreamBasicDescription() throws -> AudioStreamBasicDescription { - try read(kAudioTapPropertyFormat, defaultValue: AudioStreamBasicDescription()) + + return processObject + } + + func readProcessBundleID() -> String? { + if let result = try? readString(kAudioProcessPropertyBundleID) { + result.isEmpty ? nil : result + } else { + nil } - - private func requireSystemObject() throws { - if self != .system { - throw AudioCaptureError.invalidSystemObject - } + } + + func readProcessIsRunning() -> Bool { + (try? readBool(kAudioProcessPropertyIsRunning)) ?? false + } + + func readDefaultSystemOutputDevice() throws -> AudioDeviceID { + try requireSystemObject() + return try read( + kAudioHardwarePropertyDefaultSystemOutputDevice, defaultValue: AudioDeviceID.unknown) + } + + func readDeviceUID() throws -> String { + try readString(kAudioDevicePropertyDeviceUID) + } + + func readAudioTapStreamBasicDescription() throws -> AudioStreamBasicDescription { + try read(kAudioTapPropertyFormat, defaultValue: AudioStreamBasicDescription()) + } + + private func requireSystemObject() throws { + if self != .system { + throw AudioCaptureError.invalidSystemObject } + } } extension AudioObjectID { - func read(_ selector: AudioObjectPropertySelector, - scope: AudioObjectPropertyScope = kAudioObjectPropertyScopeGlobal, - element: AudioObjectPropertyElement = kAudioObjectPropertyElementMain, - defaultValue: T, - qualifier: Q) throws -> T { - try read(AudioObjectPropertyAddress(mSelector: selector, mScope: scope, mElement: element), - defaultValue: defaultValue, qualifier: qualifier) - } - - func read(_ selector: AudioObjectPropertySelector, - scope: AudioObjectPropertyScope = kAudioObjectPropertyScopeGlobal, - element: AudioObjectPropertyElement = kAudioObjectPropertyElementMain, - defaultValue: T) throws -> T { - try read(AudioObjectPropertyAddress(mSelector: selector, mScope: scope, mElement: element), - defaultValue: defaultValue) - } - - func read(_ address: AudioObjectPropertyAddress, defaultValue: T, qualifier: Q) throws -> T { - var inQualifier = qualifier - let qualifierSize = UInt32(MemoryLayout.size(ofValue: qualifier)) - return try withUnsafeMutablePointer(to: &inQualifier) { qualifierPtr in - try read(address, defaultValue: defaultValue, inQualifierSize: qualifierSize, inQualifierData: qualifierPtr) - } - } - - func read(_ address: AudioObjectPropertyAddress, defaultValue: T) throws -> T { - try read(address, defaultValue: defaultValue, inQualifierSize: 0, inQualifierData: nil) - } - - func readString(_ selector: AudioObjectPropertySelector, - scope: AudioObjectPropertyScope = kAudioObjectPropertyScopeGlobal, - element: AudioObjectPropertyElement = kAudioObjectPropertyElementMain) throws -> String { - try read(AudioObjectPropertyAddress(mSelector: selector, mScope: scope, mElement: element), - defaultValue: "" as CFString) as String + func read( + _ selector: AudioObjectPropertySelector, + scope: AudioObjectPropertyScope = kAudioObjectPropertyScopeGlobal, + element: AudioObjectPropertyElement = kAudioObjectPropertyElementMain, + defaultValue: T, + qualifier: Q + ) throws -> T { + try read( + AudioObjectPropertyAddress(mSelector: selector, mScope: scope, mElement: element), + defaultValue: defaultValue, + qualifier: qualifier + ) + } + + func read( + _ selector: AudioObjectPropertySelector, + scope: AudioObjectPropertyScope = kAudioObjectPropertyScopeGlobal, + element: AudioObjectPropertyElement = kAudioObjectPropertyElementMain, + defaultValue: T + ) throws -> T { + try read( + AudioObjectPropertyAddress(mSelector: selector, mScope: scope, mElement: element), + defaultValue: defaultValue + ) + } + + func read(_ address: AudioObjectPropertyAddress, defaultValue: T, qualifier: Q) throws + -> T { + var inQualifier = qualifier + let qualifierSize = UInt32(MemoryLayout.size(ofValue: qualifier)) + return try withUnsafeMutablePointer(to: &inQualifier) { qualifierPtr in + try read( + address, + defaultValue: defaultValue, + inQualifierSize: qualifierSize, + inQualifierData: qualifierPtr + ) + } + } + + func read(_ address: AudioObjectPropertyAddress, defaultValue: T) throws -> T { + try read( + address, + defaultValue: defaultValue, + inQualifierSize: 0, + inQualifierData: nil + ) + } + + func readString( + _ selector: AudioObjectPropertySelector, + scope: AudioObjectPropertyScope = kAudioObjectPropertyScopeGlobal, + element: AudioObjectPropertyElement = kAudioObjectPropertyElementMain + ) throws -> String { + try read( + AudioObjectPropertyAddress(mSelector: selector, mScope: scope, mElement: element), + defaultValue: "" as CFString) as String + } + + func readBool( + _ selector: AudioObjectPropertySelector, + scope: AudioObjectPropertyScope = kAudioObjectPropertyScopeGlobal, + element: AudioObjectPropertyElement = kAudioObjectPropertyElementMain + ) throws -> Bool { + let value: Int = try read( + AudioObjectPropertyAddress(mSelector: selector, mScope: scope, mElement: element), + defaultValue: 0) + return value == 1 + } + + private func read( + _ inAddress: AudioObjectPropertyAddress, + defaultValue: T, + inQualifierSize: UInt32 = 0, + inQualifierData: UnsafeRawPointer? = nil + ) throws -> T { + var address = inAddress + var dataSize: UInt32 = 0 + + var err = AudioObjectGetPropertyDataSize( + self, &address, inQualifierSize, inQualifierData, &dataSize) + guard err == noErr else { + throw AudioCaptureError.coreAudioError( + "Error reading data size for \(inAddress): \(err)") } - - func readBool(_ selector: AudioObjectPropertySelector, - scope: AudioObjectPropertyScope = kAudioObjectPropertyScopeGlobal, - element: AudioObjectPropertyElement = kAudioObjectPropertyElementMain) throws -> Bool { - let value: Int = try read(AudioObjectPropertyAddress(mSelector: selector, mScope: scope, mElement: element), - defaultValue: 0) - return value == 1 + + var value: T = defaultValue + err = withUnsafeMutablePointer(to: &value) { ptr in + AudioObjectGetPropertyData( + self, &address, inQualifierSize, inQualifierData, &dataSize, ptr) } - - private func read(_ inAddress: AudioObjectPropertyAddress, - defaultValue: T, - inQualifierSize: UInt32 = 0, - inQualifierData: UnsafeRawPointer? = nil) throws -> T { - var address = inAddress - var dataSize: UInt32 = 0 - - var err = AudioObjectGetPropertyDataSize(self, &address, inQualifierSize, inQualifierData, &dataSize) - guard err == noErr else { - throw AudioCaptureError.coreAudioError("Error reading data size for \(inAddress): \(err)") - } - - var value: T = defaultValue - err = withUnsafeMutablePointer(to: &value) { ptr in - AudioObjectGetPropertyData(self, &address, inQualifierSize, inQualifierData, &dataSize, ptr) - } - - guard err == noErr else { - throw AudioCaptureError.coreAudioError("Error reading data for \(inAddress): \(err)") - } - - return value + + guard err == noErr else { + throw AudioCaptureError.coreAudioError("Error reading data for \(inAddress): \(err)") } + + return value + } } -private extension UInt32 { - var fourCharString: String { - String(cString: [ - UInt8((self >> 24) & 0xFF), - UInt8((self >> 16) & 0xFF), - UInt8((self >> 8) & 0xFF), - UInt8(self & 0xFF), - 0 - ]) - } +extension UInt32 { + fileprivate var fourCharString: String { + String(cString: [ + UInt8((self >> 24) & 0xFF), + UInt8((self >> 16) & 0xFF), + UInt8((self >> 8) & 0xFF), + UInt8(self & 0xFF), + 0 + ]) + } } extension AudioObjectPropertyAddress { - public var description: String { - let elementDescription = mElement == kAudioObjectPropertyElementMain ? "main" : mElement.fourCharString - return "\(mSelector.fourCharString)/\(mScope.fourCharString)/\(elementDescription)" - } + public var description: String { + let elementDescription = + mElement == kAudioObjectPropertyElementMain ? "main" : mElement.fourCharString + return "\(mSelector.fourCharString)/\(mScope.fourCharString)/\(elementDescription)" + } } enum AudioCaptureError: LocalizedError { - case coreAudioError(String) - case invalidProcessID(pid_t) - case invalidSystemObject - case tapCreationFailed(OSStatus) - case deviceCreationFailed(OSStatus) - case microphonePermissionDenied - case unsupportedMacOSVersion - - var errorDescription: String? { - switch self { - case .coreAudioError(let message): - return "Core Audio Error: \(message)" - case .invalidProcessID(let pid): - return "Invalid process identifier: \(pid)" - case .invalidSystemObject: - return "Only supported for the system object" - case .tapCreationFailed(let status): - return "Process tap creation failed with error \(status)" - case .deviceCreationFailed(let status): - return "Audio device creation failed with error \(status)" - case .microphonePermissionDenied: - return "Microphone permission denied" - case .unsupportedMacOSVersion: - return "Core Audio Taps requires macOS 14.2 or later" - } - } -} \ No newline at end of file + case coreAudioError(String) + case invalidProcessID(pid_t) + case invalidSystemObject + case tapCreationFailed(OSStatus) + case deviceCreationFailed(OSStatus) + case microphonePermissionDenied + case unsupportedMacOSVersion + + var errorDescription: String? { + switch self { + case .coreAudioError(let message): + return "Core Audio Error: \(message)" + case .invalidProcessID(let pid): + return "Invalid process identifier: \(pid)" + case .invalidSystemObject: + return "Only supported for the system object" + case .tapCreationFailed(let status): + return "Process tap creation failed with error \(status)" + case .deviceCreationFailed(let status): + return "Audio device creation failed with error \(status)" + case .microphonePermissionDenied: + return "Microphone permission denied" + case .unsupportedMacOSVersion: + return "Core Audio Taps requires macOS 14.2 or later" + } + } +} diff --git a/Recap/Audio/Core/Utils/ProcessInfoHelper.swift b/Recap/Audio/Core/Utils/ProcessInfoHelper.swift index 5f25e1f..1d713a9 100644 --- a/Recap/Audio/Core/Utils/ProcessInfoHelper.swift +++ b/Recap/Audio/Core/Utils/ProcessInfoHelper.swift @@ -1,25 +1,25 @@ import Foundation struct ProcessInfoHelper { - static func processInfo(for pid: pid_t) -> (name: String, path: String)? { - let nameBuffer = UnsafeMutablePointer.allocate(capacity: Int(MAXPATHLEN)) - let pathBuffer = UnsafeMutablePointer.allocate(capacity: Int(MAXPATHLEN)) - - defer { - nameBuffer.deallocate() - pathBuffer.deallocate() - } - - let nameLength = proc_name(pid, nameBuffer, UInt32(MAXPATHLEN)) - let pathLength = proc_pidpath(pid, pathBuffer, UInt32(MAXPATHLEN)) - - guard nameLength > 0, pathLength > 0 else { - return nil - } - - let name = String(cString: nameBuffer) - let path = String(cString: pathBuffer) - - return (name, path) + static func processInfo(for pid: pid_t) -> (name: String, path: String)? { + let nameBuffer = UnsafeMutablePointer.allocate(capacity: Int(MAXPATHLEN)) + let pathBuffer = UnsafeMutablePointer.allocate(capacity: Int(MAXPATHLEN)) + + defer { + nameBuffer.deallocate() + pathBuffer.deallocate() + } + + let nameLength = proc_name(pid, nameBuffer, UInt32(MAXPATHLEN)) + let pathLength = proc_pidpath(pid, pathBuffer, UInt32(MAXPATHLEN)) + + guard nameLength > 0, pathLength > 0 else { + return nil } -} \ No newline at end of file + + let name = String(cString: nameBuffer) + let path = String(cString: pathBuffer) + + return (name, path) + } +} diff --git a/Recap/Audio/Models/AudioProcess.swift b/Recap/Audio/Models/AudioProcess.swift index 083f241..3c16d5e 100644 --- a/Recap/Audio/Models/AudioProcess.swift +++ b/Recap/Audio/Models/AudioProcess.swift @@ -1,63 +1,66 @@ -import Foundation import AppKit import AudioToolbox +import Foundation struct AudioProcess: Identifiable, Hashable, Sendable { - enum Kind: String, Sendable { - case process - case app - } - - var id: pid_t - var kind: Kind - var name: String - var audioActive: Bool - var bundleID: String? - var bundleURL: URL? - var objectID: AudioObjectID - - var isMeetingApp: Bool { - guard let bundleID = bundleID else { return false } - return Self.meetingAppBundleIDs.contains(bundleID) - } - - // to be used for auto meeting detection - static let meetingAppBundleIDs = [ - "us.zoom.xos", - "com.microsoft.teams", - "com.microsoft.teams2", - "com.tinyspeck.slackmacgap", - "com.google.Chrome", - "com.cisco.webex.meetings", - "com.gotomeeting.GoToMeeting", - "com.ringcentral.ringcentral", - "com.skype.skype", - "com.discord.discord", - "app.around.desktop" - ] + enum Kind: String, Sendable { + case process + case app + // case system + } + + var id: pid_t + var kind: Kind + var name: String + var audioActive: Bool + var bundleID: String? + var bundleURL: URL? + var objectID: AudioObjectID + + var isMeetingApp: Bool { + guard let bundleID = bundleID else { return false } + return Self.meetingAppBundleIDs.contains(bundleID) + } + + // to be used for auto meeting detection + static let meetingAppBundleIDs = [ + "us.zoom.xos", + "com.microsoft.teams", + "com.microsoft.teams2", + "com.tinyspeck.slackmacgap", + "com.google.Chrome", + "com.cisco.webex.meetings", + "com.gotomeeting.GoToMeeting", + "com.ringcentral.ringcentral", + "com.skype.skype", + "com.discord.discord", + "app.around.desktop" + ] } extension AudioProcess { - var icon: NSImage { - guard let bundleURL = bundleURL else { return kind.defaultIcon } - let image = NSWorkspace.shared.icon(forFile: bundleURL.path) - image.size = NSSize(width: 32, height: 32) - return image - } + var icon: NSImage { + guard let bundleURL = bundleURL else { return kind.defaultIcon } + let image = NSWorkspace.shared.icon(forFile: bundleURL.path) + image.size = NSSize(width: 32, height: 32) + return image + } } extension AudioProcess.Kind { - var defaultIcon: NSImage { - switch self { - case .process: NSWorkspace.shared.icon(for: .unixExecutable) - case .app: NSWorkspace.shared.icon(for: .applicationBundle) - } + var defaultIcon: NSImage { + switch self { + case .process: NSWorkspace.shared.icon(for: .unixExecutable) + case .app: NSWorkspace.shared.icon(for: .applicationBundle) + // case .system: NSWorkspace.shared.icon(for: .systemPreferencesPane) } - - var groupTitle: String { - switch self { - case .process: "Processes" - case .app: "Apps" - } + } + + var groupTitle: String { + switch self { + case .process: "Processes" + case .app: "Apps" + // case .system: "System" } + } } diff --git a/Recap/Audio/Models/AudioProcessGroup.swift b/Recap/Audio/Models/AudioProcessGroup.swift index 9a11501..c1bb139 100644 --- a/Recap/Audio/Models/AudioProcessGroup.swift +++ b/Recap/Audio/Models/AudioProcessGroup.swift @@ -1,23 +1,25 @@ import Foundation struct AudioProcessGroup: Identifiable, Hashable, Sendable { - var id: String - var title: String - var processes: [AudioProcess] + var id: String + var title: String + var processes: [AudioProcess] } extension AudioProcessGroup { - static func groups(with processes: [AudioProcess]) -> [AudioProcessGroup] { - var byKind = [AudioProcess.Kind: AudioProcessGroup]() - - for process in processes { - byKind[process.kind, default: .init(for: process.kind)].processes.append(process) - } - - return byKind.values.sorted(by: { $0.title.localizedStandardCompare($1.title) == .orderedAscending }) - } - - init(for kind: AudioProcess.Kind) { - self.init(id: kind.rawValue, title: kind.groupTitle, processes: []) + static func groups(with processes: [AudioProcess]) -> [AudioProcessGroup] { + var byKind = [AudioProcess.Kind: AudioProcessGroup]() + + for process in processes { + byKind[process.kind, default: .init(for: process.kind)].processes.append(process) } -} \ No newline at end of file + + return byKind.values.sorted(by: { + $0.title.localizedStandardCompare($1.title) == .orderedAscending + }) + } + + init(for kind: AudioProcess.Kind) { + self.init(id: kind.rawValue, title: kind.groupTitle, processes: []) + } +} diff --git a/Recap/Audio/Models/SelectableApp.swift b/Recap/Audio/Models/SelectableApp.swift index b5ca801..0318f60 100644 --- a/Recap/Audio/Models/SelectableApp.swift +++ b/Recap/Audio/Models/SelectableApp.swift @@ -1,62 +1,87 @@ -import Foundation import AppKit +import Foundation struct SelectableApp: Identifiable, Hashable { - let id: pid_t - let name: String - let icon: NSImage - let isMeetingApp: Bool - let isAudioActive: Bool - private let originalAudioProcess: AudioProcess - - init(from audioProcess: AudioProcess) { - self.id = audioProcess.id - self.name = audioProcess.name - self.icon = audioProcess.icon - self.isMeetingApp = audioProcess.isMeetingApp - self.isAudioActive = audioProcess.audioActive - self.originalAudioProcess = audioProcess - } - - var audioProcess: AudioProcess { - originalAudioProcess - } - - func hash(into hasher: inout Hasher) { - hasher.combine(id) - hasher.combine(name) - } - - static func == (lhs: SelectableApp, rhs: SelectableApp) -> Bool { - lhs.id == rhs.id && lhs.name == rhs.name + let id: pid_t + let name: String + let icon: NSImage + let isMeetingApp: Bool + let isAudioActive: Bool + let isSystemWide: Bool + private let originalAudioProcess: AudioProcess? + + init(from audioProcess: AudioProcess) { + self.id = audioProcess.id + self.name = audioProcess.name + self.icon = audioProcess.icon + self.isMeetingApp = audioProcess.isMeetingApp + self.isAudioActive = audioProcess.audioActive + self.isSystemWide = false + self.originalAudioProcess = audioProcess + } + + private init(systemWide: Bool) { + self.id = -1 + self.name = "All Apps" + self.icon = NSWorkspace.shared.icon(for: .wav) + self.isMeetingApp = false + self.isAudioActive = true + self.isSystemWide = true + self.originalAudioProcess = nil + } + + static let allApps = SelectableApp(systemWide: true) + + var audioProcess: AudioProcess { + guard let originalAudioProcess = originalAudioProcess else { + return AudioProcess( + id: -1, + kind: .app, + name: "All Apps", + audioActive: true, + bundleID: nil, + bundleURL: nil, + objectID: .unknown + ) } + return originalAudioProcess + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + hasher.combine(name) + } + + static func == (lhs: SelectableApp, rhs: SelectableApp) -> Bool { + lhs.id == rhs.id && lhs.name == rhs.name + } } enum AppSelectionState { - case noSelection - case selected(SelectableApp) - case showingDropdown + case noSelection + case selected(SelectableApp) + case showingDropdown } extension AppSelectionState { - var selectedApp: SelectableApp? { - if case .selected(let app) = self { - return app - } - return nil + var selectedApp: SelectableApp? { + if case .selected(let app) = self { + return app } - - var isShowingDropdown: Bool { - if case .showingDropdown = self { - return true - } - return false + return nil + } + + var isShowingDropdown: Bool { + if case .showingDropdown = self { + return true } - - var hasSelection: Bool { - if case .selected = self { - return true - } - return false + return false + } + + var hasSelection: Bool { + if case .selected = self { + return true } + return false + } } diff --git a/Recap/Audio/Processing/AudioRecordingCoordinator/AudioRecordingCoordinator.swift b/Recap/Audio/Processing/AudioRecordingCoordinator/AudioRecordingCoordinator.swift index c6fd8b3..26eba6a 100644 --- a/Recap/Audio/Processing/AudioRecordingCoordinator/AudioRecordingCoordinator.swift +++ b/Recap/Audio/Processing/AudioRecordingCoordinator/AudioRecordingCoordinator.swift @@ -3,84 +3,135 @@ import AudioToolbox import OSLog final class AudioRecordingCoordinator: AudioRecordingCoordinatorType { - private let logger = Logger(subsystem: AppConstants.Logging.subsystem, category: String(describing: AudioRecordingCoordinator.self)) - - private let configuration: RecordingConfiguration - private let microphoneCapture: MicrophoneCaptureType? - private let processTap: ProcessTap - - private var isRunning = false - private var tapRecorder: ProcessTapRecorder? - - init( - configuration: RecordingConfiguration, - microphoneCapture: MicrophoneCaptureType?, - processTap: ProcessTap - ) { - self.configuration = configuration - self.microphoneCapture = microphoneCapture - self.processTap = processTap - } - - func start() async throws { - guard !isRunning else { return } - - let expectedFiles = configuration.expectedFiles - - if let systemAudioURL = expectedFiles.systemAudioURL { - let recorder = ProcessTapRecorder(fileURL: systemAudioURL, tap: processTap) - self.tapRecorder = recorder - - try await MainActor.run { - try recorder.start() - } - logger.info("System audio recording started: \(systemAudioURL.lastPathComponent)") - } - - if let microphoneURL = expectedFiles.microphoneURL, - let microphoneCapture = microphoneCapture { - await MainActor.run { - processTap.activate() - } - - guard let tapStreamDescription = processTap.tapStreamDescription else { - throw AudioCaptureError.coreAudioError("Tap stream description not available") - } - - try microphoneCapture.start(outputURL: microphoneURL, targetFormat: tapStreamDescription) - logger.info("Microphone recording started: \(microphoneURL.lastPathComponent)") - } - - isRunning = true - logger.info("Recording started with configuration: \(self.configuration.id)") - } - - func stop() { - guard isRunning else { return } - - microphoneCapture?.stop() - tapRecorder?.stop() - processTap.invalidate() - - isRunning = false - tapRecorder = nil - - logger.info("Recording stopped for configuration: \(self.configuration.id)") - } - - var currentMicrophoneLevel: Float { - microphoneCapture?.audioLevel ?? 0.0 + private let logger = Logger( + subsystem: AppConstants.Logging.subsystem, + category: String(describing: AudioRecordingCoordinator.self)) + + private let configuration: RecordingConfiguration + private let microphoneCapture: (any MicrophoneCaptureType)? + private let processTap: ProcessTap? + private let systemWideTap: SystemWideTap? + + private var isRunning = false + private var tapRecorder: (any AudioTapRecorderType)? + + init( + configuration: RecordingConfiguration, + microphoneCapture: (any MicrophoneCaptureType)?, + processTap: ProcessTap? = nil, + systemWideTap: SystemWideTap? = nil + ) { + self.configuration = configuration + self.microphoneCapture = microphoneCapture + self.processTap = processTap + self.systemWideTap = systemWideTap + } + + func start() async throws { + guard !isRunning else { return } + + let expectedFiles = configuration.expectedFiles + + try await startSystemAudioRecording(expectedFiles) + try await startMicrophoneRecording(expectedFiles) + + isRunning = true + logger.info("Recording started with configuration: \(self.configuration.id)") + } + + private func startSystemAudioRecording(_ expectedFiles: RecordedFiles) async throws { + guard let systemAudioURL = expectedFiles.systemAudioURL else { return } + + if let systemWideTap = systemWideTap { + let recorder = SystemWideTapRecorder(fileURL: systemAudioURL, tap: systemWideTap) + self.tapRecorder = recorder + + try await MainActor.run { + try recorder.start() + } + logger.info("System-wide audio recording started: \(systemAudioURL.lastPathComponent)") + } else if let processTap = processTap { + let recorder = ProcessTapRecorder(fileURL: systemAudioURL, tap: processTap) + self.tapRecorder = recorder + + try await MainActor.run { + try recorder.start() + } + logger.info("Process-specific audio recording started: \(systemAudioURL.lastPathComponent)") } - - var currentSystemAudioLevel: Float { - processTap.audioLevel + } + + private func startMicrophoneRecording(_ expectedFiles: RecordedFiles) async throws { + guard let microphoneURL = expectedFiles.microphoneURL, + let microphoneCapture = microphoneCapture + else { return } + + let tapStreamDescription = try await getTapStreamDescription() + + try microphoneCapture.start( + outputURL: microphoneURL, targetFormat: tapStreamDescription) + logger.info("Microphone recording started: \(microphoneURL.lastPathComponent)") + } + + private func getTapStreamDescription() async throws -> AudioStreamBasicDescription { + if let systemWideTap = systemWideTap { + await MainActor.run { + systemWideTap.activate() + } + guard let streamDesc = systemWideTap.tapStreamDescription else { + throw AudioCaptureError.coreAudioError( + "System-wide tap stream description not available") + } + return streamDesc + } else if let processTap = processTap { + await MainActor.run { + processTap.activate() + } + guard let streamDesc = processTap.tapStreamDescription else { + throw AudioCaptureError.coreAudioError("Process tap stream description not available") + } + return streamDesc + } else { + throw AudioCaptureError.coreAudioError("No audio tap available") } - - var hasDualAudio: Bool { - configuration.enableMicrophone && microphoneCapture != nil + } + + func stop() { + guard isRunning else { return } + + microphoneCapture?.stop() + tapRecorder?.stop() + + if let systemWideTap = systemWideTap { + systemWideTap.invalidate() + } else if let processTap = processTap { + processTap.invalidate() } - - var recordedFiles: RecordedFiles { - configuration.expectedFiles + + isRunning = false + tapRecorder = nil + + logger.info("Recording stopped for configuration: \(self.configuration.id)") + } + + var currentMicrophoneLevel: Float { + microphoneCapture?.audioLevel ?? 0.0 + } + + var currentSystemAudioLevel: Float { + if let systemWideTap = systemWideTap { + return systemWideTap.audioLevel + } else if let processTap = processTap { + return processTap.audioLevel } + return 0.0 + } + + var hasDualAudio: Bool { + configuration.enableMicrophone && microphoneCapture != nil + } + + var recordedFiles: RecordedFiles { + configuration.expectedFiles + } } diff --git a/Recap/Audio/Processing/AudioRecordingCoordinator/AudioRecordingCoordinatorType.swift b/Recap/Audio/Processing/AudioRecordingCoordinator/AudioRecordingCoordinatorType.swift index 249cbeb..ec5e497 100644 --- a/Recap/Audio/Processing/AudioRecordingCoordinator/AudioRecordingCoordinatorType.swift +++ b/Recap/Audio/Processing/AudioRecordingCoordinator/AudioRecordingCoordinatorType.swift @@ -1,11 +1,11 @@ import Foundation protocol AudioRecordingCoordinatorType { - var currentMicrophoneLevel: Float { get } - var currentSystemAudioLevel: Float { get } - var hasDualAudio: Bool { get } - var recordedFiles: RecordedFiles { get } - - func start() async throws - func stop() -} \ No newline at end of file + var currentMicrophoneLevel: Float { get } + var currentSystemAudioLevel: Float { get } + var hasDualAudio: Bool { get } + var recordedFiles: RecordedFiles { get } + + func start() async throws + func stop() +} diff --git a/Recap/Audio/Processing/Detection/AudioProcessController.swift b/Recap/Audio/Processing/Detection/AudioProcessController.swift index a6d5211..fdd2e70 100644 --- a/Recap/Audio/Processing/Detection/AudioProcessController.swift +++ b/Recap/Audio/Processing/Detection/AudioProcessController.swift @@ -1,50 +1,55 @@ -import Foundation import AppKit -import SwiftUI -import OSLog import Combine +import Foundation +import OSLog +import SwiftUI @MainActor -final class AudioProcessController: AudioProcessControllerType { - private let logger = Logger(subsystem: AppConstants.Logging.subsystem, category: String(describing: AudioProcessController.self)) - - private let detectionService: AudioProcessDetectionServiceType - private var cancellables = Set() - - @Published private(set) var processes = [AudioProcess]() { - didSet { - guard processes != oldValue else { return } - processGroups = AudioProcessGroup.groups(with: processes) - meetingApps = processes.filter { $0.isMeetingApp && $0.audioActive } - } - } - - @Published private(set) var processGroups = [AudioProcessGroup]() - @Published private(set) var meetingApps = [AudioProcess]() - - init(detectionService: AudioProcessDetectionServiceType = AudioProcessDetectionService()) { - self.detectionService = detectionService - } - - func activate() { - logger.debug(#function) - - NSWorkspace.shared - .publisher(for: \.runningApplications, options: [.initial, .new]) - .map { $0.filter({ $0.processIdentifier != ProcessInfo.processInfo.processIdentifier }) } - .sink { [weak self] apps in - self?.reloadProcesses(from: apps) - } - .store(in: &cancellables) +final class AudioProcessController: @MainActor AudioProcessControllerType { + private let logger = Logger( + subsystem: AppConstants.Logging.subsystem, + category: String(describing: AudioProcessController.self) + ) + + private let detectionService: AudioProcessDetectionServiceType + private var cancellables = Set() + + @Published private(set) var processes = [AudioProcess]() { + didSet { + guard processes != oldValue else { return } + processGroups = AudioProcessGroup.groups(with: processes) + meetingApps = processes.filter { $0.isMeetingApp && $0.audioActive } } + } + + @Published private(set) var processGroups = [AudioProcessGroup]() + @Published private(set) var meetingApps = [AudioProcess]() + + init(detectionService: AudioProcessDetectionServiceType = AudioProcessDetectionService()) { + self.detectionService = detectionService + } + + func activate() { + logger.debug(#function) + + NSWorkspace.shared + .publisher(for: \.runningApplications, options: [.initial, .new]) + .map { + $0.filter({ $0.processIdentifier != ProcessInfo.processInfo.processIdentifier }) + } + .sink { [weak self] apps in + self?.reloadProcesses(from: apps) + } + .store(in: &cancellables) + } } -private extension AudioProcessController { - func reloadProcesses(from apps: [NSRunningApplication]) { - do { - processes = try detectionService.detectActiveProcesses(from: apps) - } catch { - logger.error("Error reading process list: \(error, privacy: .public)") - } +extension AudioProcessController { + fileprivate func reloadProcesses(from apps: [NSRunningApplication]) { + do { + processes = try detectionService.detectActiveProcesses(from: apps) + } catch { + logger.error("Error reading process list: \(error, privacy: .public)") } + } } diff --git a/Recap/Audio/Processing/Detection/AudioProcessControllerType.swift b/Recap/Audio/Processing/Detection/AudioProcessControllerType.swift index dca3eb8..e0be90d 100644 --- a/Recap/Audio/Processing/Detection/AudioProcessControllerType.swift +++ b/Recap/Audio/Processing/Detection/AudioProcessControllerType.swift @@ -1,16 +1,17 @@ -import Foundation import Combine +import Foundation + #if MOCKING -import Mockable + import Mockable #endif #if MOCKING -@Mockable + @Mockable #endif protocol AudioProcessControllerType: ObservableObject { - var processes: [AudioProcess] { get } - var processGroups: [AudioProcessGroup] { get } - var meetingApps: [AudioProcess] { get } - - func activate() -} \ No newline at end of file + var processes: [AudioProcess] { get } + var processGroups: [AudioProcessGroup] { get } + var meetingApps: [AudioProcess] { get } + + func activate() +} diff --git a/Recap/Audio/Processing/Detection/AudioProcessDetectionService.swift b/Recap/Audio/Processing/Detection/AudioProcessDetectionService.swift index 4ab83df..b4d26e0 100644 --- a/Recap/Audio/Processing/Detection/AudioProcessDetectionService.swift +++ b/Recap/Audio/Processing/Detection/AudioProcessDetectionService.swift @@ -1,38 +1,45 @@ -import Foundation import AppKit import AudioToolbox +import Foundation import OSLog protocol AudioProcessDetectionServiceType { - func detectActiveProcesses(from apps: [NSRunningApplication]) throws -> [AudioProcess] + func detectActiveProcesses(from apps: [NSRunningApplication]) throws -> [AudioProcess] } final class AudioProcessDetectionService: AudioProcessDetectionServiceType { - private let logger = Logger(subsystem: AppConstants.Logging.subsystem, category: String(describing: AudioProcessDetectionService.self)) - - func detectActiveProcesses(from apps: [NSRunningApplication]) throws -> [AudioProcess] { - let objectIdentifiers = try AudioObjectID.readProcessList() - - let processes: [AudioProcess] = objectIdentifiers.compactMap { objectID in - do { - let process = try AudioProcess(objectID: objectID, runningApplications: apps) - return process - } catch { - logger.warning("Failed to initialize process with object ID #\(objectID, privacy: .public): \(error, privacy: .public)") - return nil - } - } - - return processes.sorted { lhs, rhs in - if lhs.isMeetingApp != rhs.isMeetingApp { - return lhs.isMeetingApp - } - - if lhs.audioActive != rhs.audioActive { - return lhs.audioActive - } - - return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending - } + private let logger = Logger( + subsystem: AppConstants.Logging.subsystem, + category: String(describing: AudioProcessDetectionService.self)) + + func detectActiveProcesses(from apps: [NSRunningApplication]) throws -> [AudioProcess] { + let objectIdentifiers = try AudioObjectID.readProcessList() + + let processes: [AudioProcess] = objectIdentifiers.compactMap { objectID in + do { + let process = try AudioProcess(objectID: objectID, runningApplications: apps) + return process + } catch { + logger.warning( + """ + Failed to initialize process with object ID #\(objectID, privacy: .public): \ + \(error, privacy: .public) + """ + ) + return nil + } } -} \ No newline at end of file + + return processes.sorted { lhs, rhs in + if lhs.isMeetingApp != rhs.isMeetingApp { + return lhs.isMeetingApp + } + + if lhs.audioActive != rhs.audioActive { + return lhs.audioActive + } + + return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending + } + } +} diff --git a/Recap/Audio/Processing/Detection/MeetingAppDetectionService.swift b/Recap/Audio/Processing/Detection/MeetingAppDetectionService.swift index faf4468..5db6582 100644 --- a/Recap/Audio/Processing/Detection/MeetingAppDetectionService.swift +++ b/Recap/Audio/Processing/Detection/MeetingAppDetectionService.swift @@ -1,28 +1,28 @@ import Foundation protocol MeetingAppDetecting { - func detectMeetingApps() async -> [AudioProcess] - func getAllAudioProcesses() async -> [AudioProcess] + func detectMeetingApps() async -> [AudioProcess] + func getAllAudioProcesses() async -> [AudioProcess] } final class MeetingAppDetectionService: MeetingAppDetecting { - private var processController: (any AudioProcessControllerType)? - - init(processController: (any AudioProcessControllerType)?) { - self.processController = processController - } - - func setProcessController(_ controller: any AudioProcessControllerType) { - self.processController = controller - } - - func detectMeetingApps() async -> [AudioProcess] { - guard let processController = processController else { return [] } - return await MainActor.run { processController.meetingApps } - } - - func getAllAudioProcesses() async -> [AudioProcess] { - guard let processController = processController else { return [] } - return await MainActor.run { processController.processes } - } + private var processController: (any AudioProcessControllerType)? + + init(processController: (any AudioProcessControllerType)?) { + self.processController = processController + } + + func setProcessController(_ controller: any AudioProcessControllerType) { + self.processController = controller + } + + func detectMeetingApps() async -> [AudioProcess] { + guard let processController = processController else { return [] } + return await MainActor.run { processController.meetingApps } + } + + func getAllAudioProcesses() async -> [AudioProcess] { + guard let processController = processController else { return [] } + return await MainActor.run { processController.processes } + } } diff --git a/Recap/Audio/Processing/FileManagement/RecordingFileManager.swift b/Recap/Audio/Processing/FileManagement/RecordingFileManager.swift index 10d080e..337f1c9 100644 --- a/Recap/Audio/Processing/FileManagement/RecordingFileManager.swift +++ b/Recap/Audio/Processing/FileManagement/RecordingFileManager.swift @@ -1,40 +1,53 @@ import Foundation protocol RecordingFileManaging { - func createRecordingURL() -> URL - func createRecordingBaseURL(for recordingID: String) -> URL - func ensureRecordingsDirectoryExists() throws + func createRecordingURL() -> URL + func createRecordingBaseURL(for recordingID: String) -> URL + func ensureRecordingsDirectoryExists() throws } final class RecordingFileManager: RecordingFileManaging { - private let recordingsDirectoryName = "Recordings" - - func createRecordingURL() -> URL { - let timestamp = Date().timeIntervalSince1970 - let filename = "recap_recording_\(Int(timestamp))" - - return FileManager.default.temporaryDirectory - .appendingPathComponent(filename) - .appendingPathExtension("wav") - } - - func createRecordingBaseURL(for recordingID: String) -> URL { - let timestamp = Date().timeIntervalSince1970 - let filename = "\(recordingID)_\(Int(timestamp))" - - return recordingsDirectory - .appendingPathComponent(filename) - } - - func ensureRecordingsDirectoryExists() throws { - try FileManager.default.createDirectory( - at: recordingsDirectory, - withIntermediateDirectories: true - ) - } - - private var recordingsDirectory: URL { - FileManager.default.temporaryDirectory - .appendingPathComponent(recordingsDirectoryName) + private let recordingsDirectoryName = "Recordings" + private let fileManagerHelper: RecordingFileManagerHelperType? + + init(fileManagerHelper: RecordingFileManagerHelperType? = nil) { + self.fileManagerHelper = fileManagerHelper + } + + func createRecordingURL() -> URL { + let timestamp = Date().timeIntervalSince1970 + let filename = "recap_recording_\(Int(timestamp))" + + return FileManager.default.temporaryDirectory + .appendingPathComponent(filename) + .appendingPathExtension("wav") + } + + func createRecordingBaseURL(for recordingID: String) -> URL { + if let fileManagerHelper = fileManagerHelper { + do { + let recordingDirectory = try fileManagerHelper.createRecordingDirectory( + for: recordingID) + return recordingDirectory + } catch { + // Fallback to default system + return recordingsDirectory.appendingPathComponent(recordingID) + } + } else { + // Use default system + return recordingsDirectory.appendingPathComponent(recordingID) } -} \ No newline at end of file + } + + func ensureRecordingsDirectoryExists() throws { + try FileManager.default.createDirectory( + at: recordingsDirectory, + withIntermediateDirectories: true + ) + } + + private var recordingsDirectory: URL { + FileManager.default.temporaryDirectory + .appendingPathComponent(recordingsDirectoryName) + } +} diff --git a/Recap/Audio/Processing/FileManagement/RecordingFileManagerHelper.swift b/Recap/Audio/Processing/FileManagement/RecordingFileManagerHelper.swift new file mode 100644 index 0000000..35a20cb --- /dev/null +++ b/Recap/Audio/Processing/FileManagement/RecordingFileManagerHelper.swift @@ -0,0 +1,97 @@ +import Foundation +import OSLog + +protocol RecordingFileManagerHelperType { + func getBaseDirectory() -> URL + func setBaseDirectory(_ url: URL, bookmark: Data?) throws + func createRecordingDirectory(for recordingID: String) throws -> URL +} + +final class RecordingFileManagerHelper: RecordingFileManagerHelperType { + private let userPreferencesRepository: UserPreferencesRepositoryType + private let logger = Logger( + subsystem: AppConstants.Logging.subsystem, + category: String(describing: RecordingFileManagerHelper.self)) + + init(userPreferencesRepository: UserPreferencesRepositoryType) { + self.userPreferencesRepository = userPreferencesRepository + } + + func getBaseDirectory() -> URL { + // Try to get custom directory from preferences using security-scoped bookmark + let defaults = UserDefaults.standard + + // First try to resolve from bookmark data + if let bookmarkData = defaults.data(forKey: "customTmpDirectoryBookmark") { + var isStale = false + do { + let url = try URL( + resolvingBookmarkData: bookmarkData, + options: .withSecurityScope, + relativeTo: nil, + bookmarkDataIsStale: &isStale + ) + + logger.info( + "📂 Resolved bookmark to: \(url.path, privacy: .public), isStale: \(isStale, privacy: .public)" + ) + + // Start accessing the security-scoped resource + guard url.startAccessingSecurityScopedResource() else { + logger.error("❌ Failed to start accessing security-scoped resource") + // Fall through to default if we can't access + return defaultDirectory() + } + + logger.info("✅ Successfully started accessing security-scoped resource") + return url + } catch { + logger.error( + "❌ Bookmark resolution failed: \(error.localizedDescription, privacy: .public)") + // Fall through to default if bookmark resolution fails + } + } + + // Fallback: try the path string (won't work for sandboxed access but kept for backwards compatibility) + if let customPath = defaults.string(forKey: "customTmpDirectoryPath") { + logger.info("📂 Trying fallback path: \(customPath, privacy: .public)") + let url = URL(fileURLWithPath: customPath) + if FileManager.default.fileExists(atPath: url.path) { + return url + } + } + + logger.info("📂 Using default directory") + return defaultDirectory() + } + + private func defaultDirectory() -> URL { + return FileManager.default.temporaryDirectory + .appendingPathComponent("Recap", isDirectory: true) + } + + func setBaseDirectory(_ url: URL, bookmark: Data?) throws { + // This will be handled by UserPreferencesRepository + // Just validate the URL is accessible + guard FileManager.default.isWritableFile(atPath: url.path) else { + throw NSError( + domain: "RecordingFileManagerHelper", code: 1, + userInfo: [NSLocalizedDescriptionKey: "Directory is not writable"]) + } + } + + func createRecordingDirectory(for recordingID: String) throws -> URL { + let baseDir = getBaseDirectory() + let recordingDir = baseDir.appendingPathComponent(recordingID, isDirectory: true) + + if !FileManager.default.fileExists(atPath: recordingDir.path) { + try FileManager.default.createDirectory( + at: recordingDir, + withIntermediateDirectories: true, + attributes: nil + ) + } + + return recordingDir + } +} diff --git a/Recap/Audio/Processing/RecordingCoordinator.swift b/Recap/Audio/Processing/RecordingCoordinator.swift index 84d6436..15bcc42 100644 --- a/Recap/Audio/Processing/RecordingCoordinator.swift +++ b/Recap/Audio/Processing/RecordingCoordinator.swift @@ -1,130 +1,140 @@ -import Foundation import AVFoundation +import Foundation import OSLog final class RecordingCoordinator: ObservableObject { - private let logger = Logger(subsystem: AppConstants.Logging.subsystem, category: String(describing: RecordingCoordinator.self)) - - private(set) var state: RecordingState = .idle - private(set) var detectedMeetingApps: [AudioProcess] = [] - - private let appDetectionService: MeetingAppDetecting - private let sessionManager: RecordingSessionManaging - private let fileManager: RecordingFileManaging - private let microphoneCapture: any MicrophoneCaptureType - - private var currentRecordingURL: URL? - - init(appDetectionService: MeetingAppDetecting, - sessionManager: RecordingSessionManaging, - fileManager: RecordingFileManaging, - microphoneCapture: any MicrophoneCaptureType) { - - self.appDetectionService = appDetectionService - self.sessionManager = sessionManager - self.fileManager = fileManager - self.microphoneCapture = microphoneCapture - } - - func setupProcessController() { - Task { @MainActor in - let processController = AudioProcessController() - processController.activate() - (appDetectionService as? MeetingAppDetectionService)?.setProcessController(processController) - } - } + private let logger = Logger( + subsystem: AppConstants.Logging.subsystem, + category: String(describing: RecordingCoordinator.self) + ) - func detectMeetingApps() async -> [AudioProcess] { - let meetingApps = await appDetectionService.detectMeetingApps() - self.detectedMeetingApps = meetingApps - return meetingApps - } - - func getAllAudioProcesses() async -> [AudioProcess] { - await appDetectionService.getAllAudioProcesses() - } - - func startRecording(configuration: RecordingConfiguration) async throws -> RecordedFiles { - guard case .idle = state else { - throw AudioCaptureError.coreAudioError("Recording already in progress") - } - - state = .starting - - do { - let coordinator = try await sessionManager.startSession(configuration: configuration) - - state = .recording(coordinator) - currentRecordingURL = configuration.baseURL - - logger.info("Recording started successfully for \(configuration.audioProcess.name) with microphone: \(configuration.enableMicrophone)") - - return configuration.expectedFiles - - } catch { - state = .failed(error) - logger.error("Failed to start recording: \(error)") - throw error - } + private(set) var state: RecordingState = .idle + private(set) var detectedMeetingApps: [AudioProcess] = [] + + private let appDetectionService: MeetingAppDetecting + private let sessionManager: RecordingSessionManaging + private let fileManager: RecordingFileManaging + private let microphoneCapture: any MicrophoneCaptureType + + private var currentRecordingURL: URL? + + init( + appDetectionService: MeetingAppDetecting, + sessionManager: RecordingSessionManaging, + fileManager: RecordingFileManaging, + microphoneCapture: any MicrophoneCaptureType + ) { + + self.appDetectionService = appDetectionService + self.sessionManager = sessionManager + self.fileManager = fileManager + self.microphoneCapture = microphoneCapture + } + + func setupProcessController() { + Task { @MainActor in + let processController = AudioProcessController() + processController.activate() + (appDetectionService as? MeetingAppDetectionService)?.setProcessController( + processController) } - - func stopRecording() async -> RecordedFiles? { - guard case .recording(let coordinator) = state else { - logger.warning("No active recording to stop") - return nil - } - - state = .stopping - - coordinator.stop() - - let recordedFiles = coordinator.recordedFiles - currentRecordingURL = nil - state = .idle - - logger.info("Recording stopped successfully") - return recordedFiles + } + + func detectMeetingApps() async -> [AudioProcess] { + let meetingApps = await appDetectionService.detectMeetingApps() + self.detectedMeetingApps = meetingApps + return meetingApps + } + + func getAllAudioProcesses() async -> [AudioProcess] { + await appDetectionService.getAllAudioProcesses() + } + + func startRecording(configuration: RecordingConfiguration) async throws -> RecordedFiles { + guard case .idle = state else { + throw AudioCaptureError.coreAudioError("Recording already in progress") } - - var isRecording: Bool { - if case .recording = state { - return true - } - return false + + state = .starting + + do { + let coordinator = try await sessionManager.startSession(configuration: configuration) + + state = .recording(coordinator) + currentRecordingURL = configuration.baseURL + + logger.info( + """ + Recording started successfully for \(configuration.audioProcess.name) \ + with microphone: \(configuration.enableMicrophone) + """) + + return configuration.expectedFiles + + } catch { + state = .failed(error) + logger.error("Failed to start recording: \(error)") + throw error } - - var isIdle: Bool { - if case .idle = state { - return true - } - return false + } + + func stopRecording() async -> RecordedFiles? { + guard case .recording(let coordinator) = state else { + logger.warning("No active recording to stop") + return nil } - - var errorMessage: String? { - if case .failed(let error) = state { - return error.localizedDescription - } - return nil + + state = .stopping + + coordinator.stop() + + let recordedFiles = coordinator.recordedFiles + currentRecordingURL = nil + state = .idle + + logger.info("Recording stopped successfully") + return recordedFiles + } + + var isRecording: Bool { + if case .recording = state { + return true } - - var currentAudioLevel: Float { - microphoneCapture.audioLevel + return false + } + + var isIdle: Bool { + if case .idle = state { + return true } + return false + } - var hasDetectedMeetingApps: Bool { - !detectedMeetingApps.isEmpty + var errorMessage: String? { + if case .failed(let error) = state { + return error.localizedDescription } - - func getCurrentRecordingCoordinator() -> AudioRecordingCoordinatorType? { - if case .recording(let coordinator) = state { - return coordinator - } - return nil + return nil + } + + var currentAudioLevel: Float { + microphoneCapture.audioLevel + } + + var hasDetectedMeetingApps: Bool { + !detectedMeetingApps.isEmpty + } + + func getCurrentRecordingCoordinator() -> AudioRecordingCoordinatorType? { + if case .recording(let coordinator) = state { + return coordinator } - - deinit { - if case .recording(let coordinator) = state { - coordinator.stop() - } + return nil + } + + deinit { + if case .recording(let coordinator) = state { + coordinator.stop() } + } } diff --git a/Recap/Audio/Processing/Session/RecordingSessionManager.swift b/Recap/Audio/Processing/Session/RecordingSessionManager.swift index c133091..46e145d 100644 --- a/Recap/Audio/Processing/Session/RecordingSessionManager.swift +++ b/Recap/Audio/Processing/Session/RecordingSessionManager.swift @@ -2,48 +2,86 @@ import Foundation import OSLog protocol RecordingSessionManaging { - func startSession(configuration: RecordingConfiguration) async throws -> AudioRecordingCoordinatorType + func startSession(configuration: RecordingConfiguration) async throws + -> AudioRecordingCoordinatorType } final class RecordingSessionManager: RecordingSessionManaging { - private let logger = Logger(subsystem: AppConstants.Logging.subsystem, category: String(describing: RecordingSessionManager.self)) - private let microphoneCapture: MicrophoneCaptureType - private let permissionsHelper: PermissionsHelperType - - init(microphoneCapture: MicrophoneCaptureType, permissionsHelper: PermissionsHelperType) { - self.microphoneCapture = microphoneCapture - self.permissionsHelper = permissionsHelper + private let logger = Logger( + subsystem: AppConstants.Logging.subsystem, + category: String(describing: RecordingSessionManager.self) + ) + private let microphoneCapture: any MicrophoneCaptureType + private let permissionsHelper: PermissionsHelperType + + init( + microphoneCapture: any MicrophoneCaptureType, + permissionsHelper: PermissionsHelperType + ) { + self.microphoneCapture = microphoneCapture + self.permissionsHelper = permissionsHelper + } + + func startSession(configuration: RecordingConfiguration) async throws + -> AudioRecordingCoordinatorType { + let microphoneCaptureToUse = configuration.enableMicrophone ? microphoneCapture : nil + + if configuration.enableMicrophone { + let hasPermission = await permissionsHelper.checkMicrophonePermissionStatus() + guard hasPermission == .authorized else { + throw AudioCaptureError.microphonePermissionDenied + } } - - func startSession(configuration: RecordingConfiguration) async throws -> AudioRecordingCoordinatorType { - let processTap = ProcessTap(process: configuration.audioProcess) - await MainActor.run { - processTap.activate() - } - - if let errorMessage = processTap.errorMessage { - logger.error("Process tap failed: \(errorMessage)") - throw AudioCaptureError.coreAudioError("Failed to tap system audio: \(errorMessage)") - } - - let microphoneCaptureToUse = configuration.enableMicrophone ? microphoneCapture : nil - - if configuration.enableMicrophone { - let hasPermission = await permissionsHelper.checkMicrophonePermissionStatus() - guard hasPermission == .authorized else { - throw AudioCaptureError.microphonePermissionDenied - } - } - - let coordinator = AudioRecordingCoordinator( - configuration: configuration, - microphoneCapture: microphoneCaptureToUse, - processTap: processTap - ) - - try await coordinator.start() - - logger.info("Recording session started for \(configuration.audioProcess.name) with microphone: \(configuration.enableMicrophone)") - return coordinator + + let coordinator: AudioRecordingCoordinator + + if configuration.audioProcess.id == -1 { + let systemWideTap = SystemWideTap() + await MainActor.run { + systemWideTap.activate() + } + + if let errorMessage = systemWideTap.errorMessage { + logger.error("System-wide tap failed: \(errorMessage)") + throw AudioCaptureError.coreAudioError( + "Failed to tap system audio: \(errorMessage)") + } + + coordinator = AudioRecordingCoordinator( + configuration: configuration, + microphoneCapture: microphoneCaptureToUse, + systemWideTap: systemWideTap + ) + + logger.info( + "Recording session started for system-wide audio with microphone: \(configuration.enableMicrophone)" + ) + } else { + let processTap = ProcessTap(process: configuration.audioProcess) + await MainActor.run { + processTap.activate() + } + + if let errorMessage = processTap.errorMessage { + logger.error("Process tap failed: \(errorMessage)") + throw AudioCaptureError.coreAudioError( + "Failed to tap system audio: \(errorMessage)") + } + + coordinator = AudioRecordingCoordinator( + configuration: configuration, + microphoneCapture: microphoneCaptureToUse, + processTap: processTap + ) + + logger.info( + """ + Recording session started for \(configuration.audioProcess.name) + with microphone: \(configuration.enableMicrophone) + """) } + + try await coordinator.start() + return coordinator + } } diff --git a/Recap/Audio/Processing/Types/RecordedFiles.swift b/Recap/Audio/Processing/Types/RecordedFiles.swift index c4e4451..c7a72f9 100644 --- a/Recap/Audio/Processing/Types/RecordedFiles.swift +++ b/Recap/Audio/Processing/Types/RecordedFiles.swift @@ -1,13 +1,13 @@ import Foundation struct RecordedFiles { - let microphoneURL: URL? - let systemAudioURL: URL? - let applicationName: String? - - init(microphoneURL: URL?, systemAudioURL: URL?, applicationName: String? = nil) { - self.microphoneURL = microphoneURL - self.systemAudioURL = systemAudioURL - self.applicationName = applicationName - } -} \ No newline at end of file + let microphoneURL: URL? + let systemAudioURL: URL? + let applicationName: String? + + init(microphoneURL: URL?, systemAudioURL: URL?, applicationName: String? = nil) { + self.microphoneURL = microphoneURL + self.systemAudioURL = systemAudioURL + self.applicationName = applicationName + } +} diff --git a/Recap/Audio/Processing/Types/RecordingConfiguration.swift b/Recap/Audio/Processing/Types/RecordingConfiguration.swift index ded7326..f1ba871 100644 --- a/Recap/Audio/Processing/Types/RecordingConfiguration.swift +++ b/Recap/Audio/Processing/Types/RecordingConfiguration.swift @@ -1,24 +1,26 @@ import Foundation struct RecordingConfiguration { - let id: String - let audioProcess: AudioProcess - let enableMicrophone: Bool - let baseURL: URL - - var expectedFiles: RecordedFiles { - if enableMicrophone { - return RecordedFiles( - microphoneURL: baseURL.appendingPathExtension("microphone.wav"), - systemAudioURL: baseURL.appendingPathExtension("system.wav"), - applicationName: audioProcess.name - ) - } else { - return RecordedFiles( - microphoneURL: nil, - systemAudioURL: baseURL.appendingPathExtension("system.wav"), - applicationName: audioProcess.name - ) - } + let id: String + let audioProcess: AudioProcess + let enableMicrophone: Bool + let baseURL: URL + + var expectedFiles: RecordedFiles { + let applicationName = audioProcess.id == -1 ? "All Apps" : audioProcess.name + + if enableMicrophone { + return RecordedFiles( + microphoneURL: baseURL.appendingPathComponent("microphone_recording.wav"), + systemAudioURL: baseURL.appendingPathComponent("system_recording.wav"), + applicationName: applicationName + ) + } else { + return RecordedFiles( + microphoneURL: nil, + systemAudioURL: baseURL.appendingPathComponent("system_recording.wav"), + applicationName: applicationName + ) } -} \ No newline at end of file + } +} diff --git a/Recap/Audio/Processing/Types/RecordingState.swift b/Recap/Audio/Processing/Types/RecordingState.swift index 2bdf2ad..c0ab8c8 100644 --- a/Recap/Audio/Processing/Types/RecordingState.swift +++ b/Recap/Audio/Processing/Types/RecordingState.swift @@ -1,9 +1,9 @@ import Foundation enum RecordingState { - case idle - case starting - case recording(AudioRecordingCoordinatorType) - case stopping - case failed(Error) -} \ No newline at end of file + case idle + case starting + case recording(AudioRecordingCoordinatorType) + case stopping + case failed(Error) +} diff --git a/Recap/DataModels/RecapDataModel.xcdatamodeld/RecapDataModel.xcdatamodel/contents b/Recap/DataModels/RecapDataModel.xcdatamodeld/RecapDataModel.xcdatamodel/contents index aaf5ef8..100fa65 100644 --- a/Recap/DataModels/RecapDataModel.xcdatamodeld/RecapDataModel.xcdatamodel/contents +++ b/Recap/DataModels/RecapDataModel.xcdatamodeld/RecapDataModel.xcdatamodel/contents @@ -12,8 +12,16 @@ + + + + + + + + @@ -35,6 +43,8 @@ + + @@ -54,4 +64,4 @@ - \ No newline at end of file + diff --git a/Recap/DependencyContainer/DependencyContainer+Coordinators.swift b/Recap/DependencyContainer/DependencyContainer+Coordinators.swift index f125434..cdbbbcb 100644 --- a/Recap/DependencyContainer/DependencyContainer+Coordinators.swift +++ b/Recap/DependencyContainer/DependencyContainer+Coordinators.swift @@ -1,35 +1,35 @@ import Foundation extension DependencyContainer { - - func makeRecordingCoordinator() -> RecordingCoordinator { - let coordinator = RecordingCoordinator( - appDetectionService: meetingAppDetectionService, - sessionManager: recordingSessionManager, - fileManager: recordingFileManager, - microphoneCapture: microphoneCapture - ) - coordinator.setupProcessController() - return coordinator - } - - func makeProcessingCoordinator() -> ProcessingCoordinator { - ProcessingCoordinator( - recordingRepository: recordingRepository, - summarizationService: summarizationService, - transcriptionService: transcriptionService, - userPreferencesRepository: userPreferencesRepository - ) - } - - func makeProviderWarningCoordinator() -> ProviderWarningCoordinator { - ProviderWarningCoordinator( - warningManager: warningManager, - llmService: llmService - ) - } - - func makeAppSelectionCoordinator() -> AppSelectionCoordinatorType { - AppSelectionCoordinator(appSelectionViewModel: appSelectionViewModel) - } -} \ No newline at end of file + + func makeRecordingCoordinator() -> RecordingCoordinator { + let coordinator = RecordingCoordinator( + appDetectionService: meetingAppDetectionService, + sessionManager: recordingSessionManager, + fileManager: recordingFileManager, + microphoneCapture: microphoneCapture + ) + coordinator.setupProcessController() + return coordinator + } + + func makeProcessingCoordinator() -> ProcessingCoordinator { + ProcessingCoordinator( + recordingRepository: recordingRepository, + summarizationService: summarizationService, + transcriptionService: transcriptionService, + userPreferencesRepository: userPreferencesRepository + ) + } + + func makeProviderWarningCoordinator() -> ProviderWarningCoordinator { + ProviderWarningCoordinator( + warningManager: warningManager, + llmService: llmService + ) + } + + func makeAppSelectionCoordinator() -> AppSelectionCoordinatorType { + AppSelectionCoordinator(appSelectionViewModel: appSelectionViewModel) + } +} diff --git a/Recap/DependencyContainer/DependencyContainer+Helpers.swift b/Recap/DependencyContainer/DependencyContainer+Helpers.swift index fc6fcdf..58b4c00 100644 --- a/Recap/DependencyContainer/DependencyContainer+Helpers.swift +++ b/Recap/DependencyContainer/DependencyContainer+Helpers.swift @@ -1,8 +1,12 @@ import Foundation extension DependencyContainer { - - func makePermissionsHelper() -> PermissionsHelperType { - PermissionsHelper() - } + + func makePermissionsHelper() -> PermissionsHelperType { + PermissionsHelper() + } + + func makeRecordingFileManagerHelper() -> RecordingFileManagerHelperType { + RecordingFileManagerHelper(userPreferencesRepository: userPreferencesRepository) + } } diff --git a/Recap/DependencyContainer/DependencyContainer+Managers.swift b/Recap/DependencyContainer/DependencyContainer+Managers.swift index a2e3365..32768d6 100644 --- a/Recap/DependencyContainer/DependencyContainer+Managers.swift +++ b/Recap/DependencyContainer/DependencyContainer+Managers.swift @@ -1,24 +1,24 @@ import Foundation extension DependencyContainer { - - func makeCoreDataManager() -> CoreDataManagerType { - CoreDataManager(inMemory: inMemory) - } - - func makeStatusBarManager() -> StatusBarManagerType { - StatusBarManager() - } - - func makeAudioProcessController() -> AudioProcessController { - AudioProcessController() - } - - func makeRecordingFileManager() -> RecordingFileManaging { - RecordingFileManager() - } - - func makeWarningManager() -> any WarningManagerType { - WarningManager() - } + + func makeCoreDataManager() -> CoreDataManagerType { + CoreDataManager(inMemory: inMemory) + } + + func makeStatusBarManager() -> StatusBarManagerType { + StatusBarManager() + } + + func makeAudioProcessController() -> AudioProcessController { + AudioProcessController() + } + + func makeRecordingFileManager() -> RecordingFileManaging { + RecordingFileManager(fileManagerHelper: recordingFileManagerHelper) + } + + func makeWarningManager() -> any WarningManagerType { + WarningManager() + } } diff --git a/Recap/DependencyContainer/DependencyContainer+Repositories.swift b/Recap/DependencyContainer/DependencyContainer+Repositories.swift index f584108..f658b6b 100644 --- a/Recap/DependencyContainer/DependencyContainer+Repositories.swift +++ b/Recap/DependencyContainer/DependencyContainer+Repositories.swift @@ -1,20 +1,20 @@ import Foundation extension DependencyContainer { - - func makeWhisperModelRepository() -> WhisperModelRepositoryType { - WhisperModelRepository(coreDataManager: coreDataManager) - } - - func makeRecordingRepository() -> RecordingRepositoryType { - RecordingRepository(coreDataManager: coreDataManager) - } - - func makeLLMModelRepository() -> LLMModelRepositoryType { - LLMModelRepository(coreDataManager: coreDataManager) - } - - func makeUserPreferencesRepository() -> UserPreferencesRepositoryType { - UserPreferencesRepository(coreDataManager: coreDataManager) - } + + func makeWhisperModelRepository() -> WhisperModelRepositoryType { + WhisperModelRepository(coreDataManager: coreDataManager) + } + + func makeRecordingRepository() -> RecordingRepositoryType { + RecordingRepository(coreDataManager: coreDataManager) + } + + func makeLLMModelRepository() -> LLMModelRepositoryType { + LLMModelRepository(coreDataManager: coreDataManager) + } + + func makeUserPreferencesRepository() -> UserPreferencesRepositoryType { + UserPreferencesRepository(coreDataManager: coreDataManager) + } } diff --git a/Recap/DependencyContainer/DependencyContainer+Services.swift b/Recap/DependencyContainer/DependencyContainer+Services.swift index 44fc223..f1ee712 100644 --- a/Recap/DependencyContainer/DependencyContainer+Services.swift +++ b/Recap/DependencyContainer/DependencyContainer+Services.swift @@ -1,48 +1,52 @@ import Foundation extension DependencyContainer { - - func makeLLMService() -> LLMServiceType { - LLMService( - llmModelRepository: llmModelRepository, - userPreferencesRepository: userPreferencesRepository - ) - } - - func makeSummarizationService() -> SummarizationServiceType { - SummarizationService(llmService: llmService) - } - - func makeTranscriptionService() -> TranscriptionServiceType { - TranscriptionService(whisperModelRepository: whisperModelRepository) - } - - func makeMeetingDetectionService() -> any MeetingDetectionServiceType { - MeetingDetectionService(audioProcessController: audioProcessController, permissionsHelper: makePermissionsHelper()) - } - - func makeMeetingAppDetectionService() -> MeetingAppDetecting { - MeetingAppDetectionService(processController: audioProcessController) - } - - func makeRecordingSessionManager() -> RecordingSessionManaging { - RecordingSessionManager(microphoneCapture: microphoneCapture, - permissionsHelper: makePermissionsHelper()) - } - - func makeMicrophoneCapture() -> any MicrophoneCaptureType { - MicrophoneCapture() - } - - func makeNotificationService() -> NotificationServiceType { - NotificationService() - } - - func makeKeychainService() -> KeychainServiceType { - KeychainService() - } - - func makeKeychainAPIValidator() -> KeychainAPIValidatorType { - KeychainAPIValidator(keychainService: keychainService) - } + + func makeLLMService() -> LLMServiceType { + LLMService( + llmModelRepository: llmModelRepository, + userPreferencesRepository: userPreferencesRepository + ) + } + + func makeSummarizationService() -> SummarizationServiceType { + SummarizationService(llmService: llmService) + } + + func makeTranscriptionService() -> TranscriptionServiceType { + TranscriptionService(whisperModelRepository: whisperModelRepository) + } + + func makeMeetingDetectionService() -> any MeetingDetectionServiceType { + MeetingDetectionService( + audioProcessController: audioProcessController, + permissionsHelper: makePermissionsHelper()) + } + + func makeMeetingAppDetectionService() -> MeetingAppDetecting { + MeetingAppDetectionService(processController: audioProcessController) + } + + func makeRecordingSessionManager() -> RecordingSessionManaging { + RecordingSessionManager( + microphoneCapture: microphoneCapture, + permissionsHelper: makePermissionsHelper() + ) + } + + func makeMicrophoneCapture() -> any MicrophoneCaptureType { + MicrophoneCapture() + } + + func makeNotificationService() -> NotificationServiceType { + NotificationService() + } + + func makeKeychainService() -> KeychainServiceType { + KeychainService() + } + + func makeKeychainAPIValidator() -> KeychainAPIValidatorType { + KeychainAPIValidator(keychainService: keychainService) + } } diff --git a/Recap/DependencyContainer/DependencyContainer+ViewModels.swift b/Recap/DependencyContainer/DependencyContainer+ViewModels.swift index 2562622..d4797bf 100644 --- a/Recap/DependencyContainer/DependencyContainer+ViewModels.swift +++ b/Recap/DependencyContainer/DependencyContainer+ViewModels.swift @@ -1,41 +1,51 @@ import Foundation extension DependencyContainer { - - func makeWhisperModelsViewModel() -> WhisperModelsViewModel { - WhisperModelsViewModel(repository: whisperModelRepository) - } - - func makeAppSelectionViewModel() -> AppSelectionViewModel { - AppSelectionViewModel(audioProcessController: audioProcessController) - } - - func makePreviousRecapsViewModel() -> PreviousRecapsViewModel { - PreviousRecapsViewModel(recordingRepository: recordingRepository) - } - - func makeGeneralSettingsViewModel() -> GeneralSettingsViewModel { - GeneralSettingsViewModel( - llmService: llmService, - userPreferencesRepository: userPreferencesRepository, - keychainAPIValidator: keychainAPIValidator, - keychainService: keychainService, - warningManager: warningManager - ) - } - - func makeMeetingDetectionSettingsViewModel() -> MeetingDetectionSettingsViewModel { - MeetingDetectionSettingsViewModel( - detectionService: meetingDetectionService, - userPreferencesRepository: userPreferencesRepository, - permissionsHelper: makePermissionsHelper() - ) - } - - func makeOnboardingViewModel() -> OnboardingViewModel { - OnboardingViewModel( - permissionsHelper: PermissionsHelper(), - userPreferencesRepository: userPreferencesRepository - ) - } -} \ No newline at end of file + + func makeWhisperModelsViewModel() -> WhisperModelsViewModel { + WhisperModelsViewModel(repository: whisperModelRepository) + } + + func makeAppSelectionViewModel() -> AppSelectionViewModel { + AppSelectionViewModel(audioProcessController: audioProcessController) + } + + func makePreviousRecapsViewModel() -> PreviousRecapsViewModel { + PreviousRecapsViewModel(recordingRepository: recordingRepository) + } + + func makeGeneralSettingsViewModel() -> GeneralSettingsViewModel { + GeneralSettingsViewModel( + llmService: llmService, + userPreferencesRepository: userPreferencesRepository, + keychainAPIValidator: keychainAPIValidator, + keychainService: keychainService, + warningManager: warningManager, + fileManagerHelper: recordingFileManagerHelper + ) + } + + func makeMeetingDetectionSettingsViewModel() -> MeetingDetectionSettingsViewModel { + MeetingDetectionSettingsViewModel( + detectionService: meetingDetectionService, + userPreferencesRepository: userPreferencesRepository, + permissionsHelper: makePermissionsHelper() + ) + } + + func makeOnboardingViewModel() -> OnboardingViewModel { + OnboardingViewModel( + permissionsHelper: PermissionsHelper(), + userPreferencesRepository: userPreferencesRepository + ) + } + + func makeDragDropViewModel() -> DragDropViewModel { + DragDropViewModel( + transcriptionService: transcriptionService, + llmService: llmService, + userPreferencesRepository: userPreferencesRepository, + recordingFileManagerHelper: recordingFileManagerHelper + ) + } +} diff --git a/Recap/DependencyContainer/DependencyContainer.swift b/Recap/DependencyContainer/DependencyContainer.swift index bc5609b..28e8cf3 100644 --- a/Recap/DependencyContainer/DependencyContainer.swift +++ b/Recap/DependencyContainer/DependencyContainer.swift @@ -2,107 +2,111 @@ import Foundation @MainActor final class DependencyContainer { - let inMemory: Bool - - lazy var coreDataManager: CoreDataManagerType = makeCoreDataManager() - lazy var whisperModelRepository: WhisperModelRepositoryType = makeWhisperModelRepository() - lazy var whisperModelsViewModel: WhisperModelsViewModel = makeWhisperModelsViewModel() - lazy var statusBarManager: StatusBarManagerType = makeStatusBarManager() - lazy var audioProcessController: AudioProcessController = makeAudioProcessController() - lazy var appSelectionViewModel: AppSelectionViewModel = makeAppSelectionViewModel() - lazy var previousRecapsViewModel: PreviousRecapsViewModel = makePreviousRecapsViewModel() - lazy var recordingCoordinator: RecordingCoordinator = makeRecordingCoordinator() - lazy var recordingRepository: RecordingRepositoryType = makeRecordingRepository() - lazy var llmModelRepository: LLMModelRepositoryType = makeLLMModelRepository() - lazy var userPreferencesRepository: UserPreferencesRepositoryType = makeUserPreferencesRepository() - lazy var llmService: LLMServiceType = makeLLMService() - lazy var summarizationService: SummarizationServiceType = makeSummarizationService() - lazy var processingCoordinator: ProcessingCoordinator = makeProcessingCoordinator() - lazy var recordingFileManager: RecordingFileManaging = makeRecordingFileManager() - lazy var generalSettingsViewModel: GeneralSettingsViewModel = makeGeneralSettingsViewModel() - lazy var recapViewModel: RecapViewModel = createRecapViewModel() - lazy var onboardingViewModel: OnboardingViewModel = makeOnboardingViewModel() - lazy var summaryViewModel: SummaryViewModel = createSummaryViewModel() - lazy var transcriptionService: TranscriptionServiceType = makeTranscriptionService() - lazy var warningManager: any WarningManagerType = makeWarningManager() - lazy var providerWarningCoordinator: ProviderWarningCoordinator = makeProviderWarningCoordinator() - lazy var meetingDetectionService: MeetingDetectionServiceType = makeMeetingDetectionService() - lazy var meetingAppDetectionService: MeetingAppDetecting = makeMeetingAppDetectionService() - lazy var recordingSessionManager: RecordingSessionManaging = makeRecordingSessionManager() - lazy var microphoneCapture: MicrophoneCaptureType = makeMicrophoneCapture() - lazy var notificationService: NotificationServiceType = makeNotificationService() - lazy var appSelectionCoordinator: AppSelectionCoordinatorType = makeAppSelectionCoordinator() - lazy var keychainService: KeychainServiceType = makeKeychainService() - lazy var keychainAPIValidator: KeychainAPIValidatorType = makeKeychainAPIValidator() - - init(inMemory: Bool = false) { - self.inMemory = inMemory - } - - - // MARK: - Public Factory Methods - - func createMenuBarPanelManager() -> MenuBarPanelManager { - providerWarningCoordinator.startMonitoring() - return MenuBarPanelManager( - statusBarManager: statusBarManager, - whisperModelsViewModel: whisperModelsViewModel, - coreDataManager: coreDataManager, - audioProcessController: audioProcessController, - appSelectionViewModel: appSelectionViewModel, - previousRecapsViewModel: previousRecapsViewModel, - recapViewModel: recapViewModel, - onboardingViewModel: onboardingViewModel, - summaryViewModel: summaryViewModel, - generalSettingsViewModel: generalSettingsViewModel, - userPreferencesRepository: userPreferencesRepository, - meetingDetectionService: meetingDetectionService - ) - } - - func createRecapViewModel() -> RecapViewModel { - RecapViewModel( - recordingCoordinator: recordingCoordinator, - processingCoordinator: processingCoordinator, - recordingRepository: recordingRepository, - appSelectionViewModel: appSelectionViewModel, - fileManager: recordingFileManager, - warningManager: warningManager, - meetingDetectionService: meetingDetectionService, - userPreferencesRepository: userPreferencesRepository, - notificationService: notificationService, - appSelectionCoordinator: appSelectionCoordinator, - permissionsHelper: makePermissionsHelper() - ) - } - - - func createGeneralSettingsViewModel() -> GeneralSettingsViewModel { - generalSettingsViewModel - } - - func createSummaryViewModel() -> SummaryViewModel { - SummaryViewModel( - recordingRepository: recordingRepository, - processingCoordinator: processingCoordinator - ) - } + let inMemory: Bool + + lazy var coreDataManager: CoreDataManagerType = makeCoreDataManager() + lazy var whisperModelRepository: WhisperModelRepositoryType = makeWhisperModelRepository() + lazy var whisperModelsViewModel: WhisperModelsViewModel = makeWhisperModelsViewModel() + lazy var statusBarManager: StatusBarManagerType = makeStatusBarManager() + lazy var audioProcessController: AudioProcessController = makeAudioProcessController() + lazy var appSelectionViewModel: AppSelectionViewModel = makeAppSelectionViewModel() + lazy var previousRecapsViewModel: PreviousRecapsViewModel = makePreviousRecapsViewModel() + lazy var recordingCoordinator: RecordingCoordinator = makeRecordingCoordinator() + lazy var recordingRepository: RecordingRepositoryType = makeRecordingRepository() + lazy var llmModelRepository: LLMModelRepositoryType = makeLLMModelRepository() + lazy var userPreferencesRepository: UserPreferencesRepositoryType = + makeUserPreferencesRepository() + lazy var recordingFileManagerHelper: RecordingFileManagerHelperType = + makeRecordingFileManagerHelper() + lazy var llmService: LLMServiceType = makeLLMService() + lazy var summarizationService: SummarizationServiceType = makeSummarizationService() + lazy var processingCoordinator: ProcessingCoordinator = makeProcessingCoordinator() + lazy var recordingFileManager: RecordingFileManaging = makeRecordingFileManager() + lazy var generalSettingsViewModel: GeneralSettingsViewModel = makeGeneralSettingsViewModel() + lazy var recapViewModel: RecapViewModel = createRecapViewModel() + lazy var onboardingViewModel: OnboardingViewModel = makeOnboardingViewModel() + lazy var summaryViewModel: SummaryViewModel = createSummaryViewModel() + lazy var transcriptionService: TranscriptionServiceType = makeTranscriptionService() + lazy var warningManager: any WarningManagerType = makeWarningManager() + lazy var providerWarningCoordinator: ProviderWarningCoordinator = makeProviderWarningCoordinator() + lazy var meetingDetectionService: any MeetingDetectionServiceType = makeMeetingDetectionService() + lazy var meetingAppDetectionService: MeetingAppDetecting = makeMeetingAppDetectionService() + lazy var recordingSessionManager: RecordingSessionManaging = makeRecordingSessionManager() + lazy var microphoneCapture: any MicrophoneCaptureType = makeMicrophoneCapture() + lazy var notificationService: NotificationServiceType = makeNotificationService() + lazy var appSelectionCoordinator: AppSelectionCoordinatorType = makeAppSelectionCoordinator() + lazy var keychainService: KeychainServiceType = makeKeychainService() + lazy var keychainAPIValidator: KeychainAPIValidatorType = makeKeychainAPIValidator() + lazy var dragDropViewModel: DragDropViewModel = makeDragDropViewModel() + + init(inMemory: Bool = false) { + self.inMemory = inMemory + } + + // MARK: - Public Factory Methods + + func createMenuBarPanelManager() -> MenuBarPanelManager { + providerWarningCoordinator.startMonitoring() + return MenuBarPanelManager( + statusBarManager: statusBarManager, + whisperModelsViewModel: whisperModelsViewModel, + coreDataManager: coreDataManager, + audioProcessController: audioProcessController, + appSelectionViewModel: appSelectionViewModel, + previousRecapsViewModel: previousRecapsViewModel, + recapViewModel: recapViewModel, + onboardingViewModel: onboardingViewModel, + summaryViewModel: summaryViewModel, + generalSettingsViewModel: generalSettingsViewModel, + dragDropViewModel: dragDropViewModel, + userPreferencesRepository: userPreferencesRepository, + meetingDetectionService: meetingDetectionService + ) + } + + func createRecapViewModel() -> RecapViewModel { + RecapViewModel( + recordingCoordinator: recordingCoordinator, + processingCoordinator: processingCoordinator, + recordingRepository: recordingRepository, + appSelectionViewModel: appSelectionViewModel, + fileManager: recordingFileManager, + warningManager: warningManager, + meetingDetectionService: meetingDetectionService, + userPreferencesRepository: userPreferencesRepository, + notificationService: notificationService, + appSelectionCoordinator: appSelectionCoordinator, + permissionsHelper: makePermissionsHelper() + ) + } + + func createGeneralSettingsViewModel() -> GeneralSettingsViewModel { + generalSettingsViewModel + } + + func createSummaryViewModel() -> SummaryViewModel { + SummaryViewModel( + recordingRepository: recordingRepository, + processingCoordinator: processingCoordinator, + userPreferencesRepository: userPreferencesRepository + ) + } } extension DependencyContainer { - static func createForAppDelegate() async -> DependencyContainer { - await MainActor.run { - DependencyContainer() - } + static func createForAppDelegate() async -> DependencyContainer { + await MainActor.run { + DependencyContainer() } + } } extension DependencyContainer { - static func createForPreview() -> DependencyContainer { - DependencyContainer(inMemory: true) - } - - static func createForTesting(inMemory: Bool = true) -> DependencyContainer { - DependencyContainer(inMemory: inMemory) - } + static func createForPreview() -> DependencyContainer { + DependencyContainer(inMemory: true) + } + + static func createForTesting(inMemory: Bool = true) -> DependencyContainer { + DependencyContainer(inMemory: inMemory) + } } diff --git a/Recap/Frameworks/Toast/ActivityIndicator.swift b/Recap/Frameworks/Toast/ActivityIndicator.swift index 3877b1f..ff1f12c 100644 --- a/Recap/Frameworks/Toast/ActivityIndicator.swift +++ b/Recap/Frameworks/Toast/ActivityIndicator.swift @@ -8,40 +8,45 @@ import SwiftUI #if os(macOS) -@available(macOS 11, *) -struct ActivityIndicator: NSViewRepresentable { + @available(macOS 11, *) + struct ActivityIndicator: NSViewRepresentable { let color: Color func makeNSView(context: NSViewRepresentableContext) -> NSProgressIndicator { - let nsView = NSProgressIndicator() - - nsView.isIndeterminate = true - nsView.style = .spinning - nsView.startAnimation(context) - - return nsView + let nsView = NSProgressIndicator() + + nsView.isIndeterminate = true + nsView.style = .spinning + nsView.startAnimation(context) + + return nsView } - - func updateNSView(_ nsView: NSProgressIndicator, context: NSViewRepresentableContext) { + + func updateNSView( + _ nsView: NSProgressIndicator, context: NSViewRepresentableContext + ) { } -} + } #else -@available(iOS 14, *) -struct ActivityIndicator: UIViewRepresentable { + @available(iOS 14, *) + struct ActivityIndicator: UIViewRepresentable { let color: Color - func makeUIView(context: UIViewRepresentableContext) -> UIActivityIndicatorView { - - let progressView = UIActivityIndicatorView(style: .large) - progressView.startAnimating() - - return progressView + func makeUIView(context: UIViewRepresentableContext) + -> UIActivityIndicatorView { + + let progressView = UIActivityIndicatorView(style: .large) + progressView.startAnimating() + + return progressView } - func updateUIView(_ uiView: UIActivityIndicatorView, context: UIViewRepresentableContext) { - uiView.color = UIColor(color) + func updateUIView( + _ uiView: UIActivityIndicatorView, context: UIViewRepresentableContext + ) { + uiView.color = UIColor(color) } -} + } #endif diff --git a/Recap/Frameworks/Toast/AlertToast+Modifiers.swift b/Recap/Frameworks/Toast/AlertToast+Modifiers.swift new file mode 100644 index 0000000..ec625d1 --- /dev/null +++ b/Recap/Frameworks/Toast/AlertToast+Modifiers.swift @@ -0,0 +1,90 @@ +import Combine +import SwiftUI + +@available(iOS 14, macOS 11, *) +struct WithFrameModifier: ViewModifier { + var withFrame: Bool + var maxWidth: CGFloat = 175 + var maxHeight: CGFloat = 175 + + @ViewBuilder + func body(content: Content) -> some View { + if withFrame { + content + .frame(maxWidth: maxWidth, maxHeight: maxHeight, alignment: .center) + } else { + content + } + } +} + +@available(iOS 14, macOS 11, *) +struct BackgroundModifier: ViewModifier { + var color: Color? + + @ViewBuilder + func body(content: Content) -> some View { + if let color = color { + content + .background(color) + } else { + content + .background(BlurView()) + } + } +} + +@available(iOS 14, macOS 11, *) +struct TextForegroundModifier: ViewModifier { + var color: Color? + + @ViewBuilder + func body(content: Content) -> some View { + if let color = color { + content + .foregroundColor(color) + } else { + content + } + } +} + +@available(iOS 14, macOS 11, *) +extension View { + func withFrame(_ withFrame: Bool) -> some View { + modifier(WithFrameModifier(withFrame: withFrame)) + } + + func alertBackground(_ color: Color? = nil) -> some View { + modifier(BackgroundModifier(color: color)) + } + + func textColor(_ color: Color? = nil) -> some View { + modifier(TextForegroundModifier(color: color)) + } + + @ViewBuilder func valueChanged( + value: T, onChange: @escaping (T) -> Void + ) -> some View { + if #available(iOS 14.0, *) { + self.onChange(of: value) { _, newValue in + onChange(newValue) + } + } else { + self.onReceive(Just(value)) { (value) in + onChange(value) + } + } + } +} + +@available(iOS 14, macOS 11, *) +extension Image { + func hudModifier() -> some View { + self + .renderingMode(.template) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 20, maxHeight: 20, alignment: .center) + } +} diff --git a/Recap/Frameworks/Toast/AlertToast.swift b/Recap/Frameworks/Toast/AlertToast.swift index 17bf751..d7555e2 100644 --- a/Recap/Frameworks/Toast/AlertToast.swift +++ b/Recap/Frameworks/Toast/AlertToast.swift @@ -1,745 +1,227 @@ -//MIT License -// -//Copyright (c) 2021 Elai Zuberman -// -//Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -// -//The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -// -//THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -import SwiftUI import Combine +import SwiftUI @available(iOS 14, macOS 11, *) -fileprivate struct AnimatedCheckmark: View { - - ///Checkmark color - var color: Color = .black - - ///Checkmark color - var size: Int = 50 - - var height: CGFloat { - return CGFloat(size) - } - - var width: CGFloat { - return CGFloat(size) - } - - @State private var percentage: CGFloat = .zero - - var body: some View { - Path { path in - path.move(to: CGPoint(x: 0, y: height / 2)) - path.addLine(to: CGPoint(x: width / 2.5, y: height)) - path.addLine(to: CGPoint(x: width, y: 0)) - } - .trim(from: 0, to: percentage) - .stroke(color, style: StrokeStyle(lineWidth: CGFloat(size / 8), lineCap: .round, lineJoin: .round)) - .animation(Animation.spring().speed(0.75).delay(0.25), value: percentage) - .onAppear { - percentage = 1.0 - } - .frame(width: width, height: height, alignment: .center) - } -} +public struct AlertToast: View { + /// The display mode + /// - `alert` + /// - `hud` + /// - `banner` + public var displayMode: DisplayMode = .alert -@available(iOS 14, macOS 11, *) -fileprivate struct AnimatedXmark: View { - - ///xmark color - var color: Color = .black - - ///xmark size - var size: Int = 50 - - var height: CGFloat { - return CGFloat(size) - } - - var width: CGFloat { - return CGFloat(size) - } - - var rect: CGRect{ - return CGRect(x: 0, y: 0, width: size, height: size) - } - - @State private var percentage: CGFloat = .zero - - var body: some View { - Path { path in - path.move(to: CGPoint(x: rect.minX, y: rect.minY)) - path.addLine(to: CGPoint(x: rect.maxY, y: rect.maxY)) - path.move(to: CGPoint(x: rect.maxX, y: rect.minY)) - path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) - } - .trim(from: 0, to: percentage) - .stroke(color, style: StrokeStyle(lineWidth: CGFloat(size / 8), lineCap: .round, lineJoin: .round)) - .animation(Animation.spring().speed(0.75).delay(0.25), value: percentage) - .onAppear { - percentage = 1.0 - } - .frame(width: width, height: height, alignment: .center) - } -} + /// What the alert would show + /// `complete`, `error`, `systemImage`, `image`, `loading`, `regular` + public var type: AlertType -//MARK: - Main View + /// The title of the alert (`Optional(String)`) + public var title: String? -@available(iOS 14, macOS 11, *) -public struct AlertToast: View{ - - public enum BannerAnimation{ - case slide, pop - } - - /// Determine how the alert will be display - public enum DisplayMode: Equatable{ - - ///Present at the center of the screen - case alert - - ///Drop from the top of the screen - case hud - - ///Banner from the bottom of the view - case banner(_ transition: BannerAnimation) - } - - /// Determine what the alert will display - public enum AlertType: Equatable{ - - ///Animated checkmark - case complete(_ color: Color) - - ///Animated xmark - case error(_ color: Color) - - ///System image from `SFSymbols` - case systemImage(_ name: String, _ color: Color) - - ///Image from Assets - case image(_ name: String, _ color: Color) - - ///Loading indicator (Circular) - case loading - - ///Only text alert - case regular - } - - /// Customize Alert Appearance - public enum AlertStyle: Equatable{ - - case style(backgroundColor: Color? = nil, - titleColor: Color? = nil, - subTitleColor: Color? = nil, - titleFont: Font? = nil, - subTitleFont: Font? = nil, - activityIndicatorColor: Color? = nil) - - ///Get background color - var backgroundColor: Color? { - switch self{ - case .style(backgroundColor: let color, _, _, _, _, _): - return color - } - } - - /// Get title color - var titleColor: Color? { - switch self{ - case .style(_,let color, _,_,_,_): - return color - } - } - - /// Get subTitle color - var subtitleColor: Color? { - switch self{ - case .style(_,_, let color, _,_,_): - return color - } - } - - /// Get title font - var titleFont: Font? { - switch self { - case .style(_, _, _, titleFont: let font, _,_): - return font - } - } - - /// Get subTitle font - var subTitleFont: Font? { - switch self { - case .style(_, _, _, _, subTitleFont: let font,_): - return font - } - } + /// The subtitle of the alert (`Optional(String)`) + public var subTitle: String? - var activityIndicatorColor: Color? { - switch self { - case .style(_, _, _, _, _, let color): - return color - } - } - } - - ///The display mode - /// - `alert` - /// - `hud` - /// - `banner` - public var displayMode: DisplayMode = .alert - - ///What the alert would show - ///`complete`, `error`, `systemImage`, `image`, `loading`, `regular` - public var type: AlertType - - ///The title of the alert (`Optional(String)`) - public var title: String? = nil - - ///The subtitle of the alert (`Optional(String)`) - public var subTitle: String? = nil - - ///Customize your alert appearance - public var style: AlertStyle? = nil - - ///Full init - public init(displayMode: DisplayMode = .alert, - type: AlertType, - title: String? = nil, - subTitle: String? = nil, - style: AlertStyle? = nil){ - - self.displayMode = displayMode - self.type = type - self.title = title - self.subTitle = subTitle - self.style = style - } - - ///Short init with most used parameters - public init(displayMode: DisplayMode, - type: AlertType, - title: String? = nil){ - - self.displayMode = displayMode - self.type = type - self.title = title - } - - ///Banner from the bottom of the view - public var banner: some View{ - VStack{ - Spacer() - - //Banner view starts here - VStack(alignment: .leading, spacing: 10){ - HStack{ - switch type{ - case .complete(let color): - Image(systemName: "checkmark") - .foregroundColor(color) - case .error(let color): - Image(systemName: "xmark") - .foregroundColor(color) - case .systemImage(let name, let color): - Image(systemName: name) - .foregroundColor(color) - case .image(let name, let color): - Image(name) - .renderingMode(.template) - .foregroundColor(color) - case .loading: - ActivityIndicator(color: style?.activityIndicatorColor ?? .white) - case .regular: - EmptyView() - } - - Text(LocalizedStringKey(title ?? "")) - .font(style?.titleFont ?? Font.headline.bold()) - } - - if let subTitle = subTitle { - Text(LocalizedStringKey(subTitle)) - .font(style?.subTitleFont ?? Font.subheadline) - } - } - .multilineTextAlignment(.leading) - .textColor(style?.titleColor ?? nil) - .padding() - .frame(maxWidth: 400, alignment: .leading) - .alertBackground(style?.backgroundColor ?? nil) - .cornerRadius(10) - .padding([.horizontal, .bottom]) - } - } - - ///HUD View - public var hud: some View{ - Group{ - HStack(spacing: 16){ - switch type{ - case .complete(let color): - Image(systemName: "checkmark") - .hudModifier() - .foregroundColor(color) - case .error(let color): - Image(systemName: "xmark") - .hudModifier() - .foregroundColor(color) - case .systemImage(let name, let color): - Image(systemName: name) - .hudModifier() - .foregroundColor(color) - case .image(let name, let color): - Image(name) - .hudModifier() - .foregroundColor(color) - case .loading: - ActivityIndicator(color: style?.activityIndicatorColor ?? .white) - case .regular: - EmptyView() - } - - if title != nil || subTitle != nil{ - VStack(alignment: type == .regular ? .center : .leading, spacing: 2){ - if let title = title { - Text(LocalizedStringKey(title)) - .font(style?.titleFont ?? Font.body.bold()) - .multilineTextAlignment(.center) - .textColor(style?.titleColor ?? nil) - } - if let subTitle = subTitle { - Text(LocalizedStringKey(subTitle)) - .font(style?.subTitleFont ?? Font.footnote) - .opacity(0.7) - .multilineTextAlignment(.center) - .textColor(style?.subtitleColor ?? nil) - } - } - } - } - .padding(.horizontal, 24) - .padding(.vertical, 8) - .frame(minHeight: 50) - .alertBackground(style?.backgroundColor ?? nil) - .clipShape(Capsule()) - .overlay(Capsule().stroke(Color.gray.opacity(0.2), lineWidth: 1)) - .shadow(color: Color.black.opacity(0.1), radius: 5, x: 0, y: 6) - .compositingGroup() - } - .padding(.top) - } - - ///Alert View - public var alert: some View{ - VStack{ - switch type{ - case .complete(let color): - Spacer() - AnimatedCheckmark(color: color) - Spacer() - case .error(let color): - Spacer() - AnimatedXmark(color: color) - Spacer() - case .systemImage(let name, let color): - Spacer() - Image(systemName: name) - .renderingMode(.template) - .resizable() - .aspectRatio(contentMode: .fit) - .scaledToFit() - .foregroundColor(color) - .padding(.bottom) - Spacer() - case .image(let name, let color): - Spacer() - Image(name) - .resizable() - .aspectRatio(contentMode: .fit) - .scaledToFit() - .foregroundColor(color) - .padding(.bottom) - Spacer() - case .loading: - ActivityIndicator(color: style?.activityIndicatorColor ?? .white) - case .regular: - EmptyView() - } - - VStack(spacing: type == .regular ? 8 : 2){ - if let title = title { - Text(LocalizedStringKey(title)) - .font(style?.titleFont ?? Font.body.bold()) - .multilineTextAlignment(.center) - .textColor(style?.titleColor ?? nil) - } - if let subTitle = subTitle { - Text(LocalizedStringKey(subTitle)) - .font(style?.subTitleFont ?? Font.footnote) - .opacity(0.7) - .multilineTextAlignment(.center) - .textColor(style?.subtitleColor ?? nil) - } - } + /// Customize your alert appearance + public var style: AlertStyle? + + /// Full init + public init( + displayMode: DisplayMode = .alert, + type: AlertType, + title: String? = nil, + subTitle: String? = nil, + style: AlertStyle? = nil + ) { + + self.displayMode = displayMode + self.type = type + self.title = title + self.subTitle = subTitle + self.style = style + } + + /// Short init with most used parameters + public init( + displayMode: DisplayMode, + type: AlertType, + title: String? = nil + ) { + + self.displayMode = displayMode + self.type = type + self.title = title + } + + /// Banner from the bottom of the view + public var banner: some View { + VStack { + Spacer() + + // Banner view starts here + VStack(alignment: .leading, spacing: 10) { + HStack { + switch type { + case .complete(let color): + Image(systemName: "checkmark") + .foregroundColor(color) + case .error(let color): + Image(systemName: "xmark") + .foregroundColor(color) + case .systemImage(let name, let color): + Image(systemName: name) + .foregroundColor(color) + case .image(let name, let color): + Image(name) + .renderingMode(.template) + .foregroundColor(color) + case .loading: + ActivityIndicator(color: style?.activityIndicatorColor ?? .white) + case .regular: + EmptyView() + } + + Text(LocalizedStringKey(title ?? "")) + .font(style?.titleFont ?? Font.headline.bold()) } - .padding() - .withFrame(type != .regular && type != .loading) - .alertBackground(style?.backgroundColor ?? nil) - .cornerRadius(10) - } - - ///Body init determine by `displayMode` - public var body: some View{ - switch displayMode{ - case .alert: - alert - case .hud: - hud - case .banner: - banner + + if let subTitle = subTitle { + Text(LocalizedStringKey(subTitle)) + .font(style?.subTitleFont ?? Font.subheadline) + } + } + .multilineTextAlignment(.leading) + .textColor(style?.titleColor ?? nil) + .padding() + .frame(maxWidth: 400, alignment: .leading) + .alertBackground(style?.backgroundColor ?? nil) + .cornerRadius(10) + .padding([.horizontal, .bottom]) + } + } + + /// HUD View + public var hud: some View { + Group { + HStack(spacing: 16) { + switch type { + case .complete(let color): + Image(systemName: "checkmark") + .hudModifier() + .foregroundColor(color) + case .error(let color): + Image(systemName: "xmark") + .hudModifier() + .foregroundColor(color) + case .systemImage(let name, let color): + Image(systemName: name) + .hudModifier() + .foregroundColor(color) + case .image(let name, let color): + Image(name) + .hudModifier() + .foregroundColor(color) + case .loading: + ActivityIndicator(color: style?.activityIndicatorColor ?? .white) + case .regular: + EmptyView() } - } -} -@available(iOS 14, macOS 11, *) -public struct AlertToastModifier: ViewModifier{ - - ///Presentation `Binding` - @Binding var isPresenting: Bool - - ///Duration time to display the alert - @State var duration: TimeInterval = 2 - - ///Tap to dismiss alert - @State var tapToDismiss: Bool = true - - var offsetY: CGFloat = 0 - - ///Init `AlertToast` View - var alert: () -> AlertToast - - ///Completion block returns `true` after dismiss - var onTap: (() -> ())? = nil - var completion: (() -> ())? = nil - - @State private var workItem: DispatchWorkItem? - - @State private var hostRect: CGRect = .zero - @State private var alertRect: CGRect = .zero - - private var screen: CGRect { -#if os(iOS) - return UIScreen.main.bounds -#else - return NSScreen.main?.frame ?? .zero -#endif - } - - private var offset: CGFloat{ - return -hostRect.midY + alertRect.height - } - - @ViewBuilder - public func main() -> some View{ - if isPresenting{ - - switch alert().displayMode{ - case .alert: - alert() - .onTapGesture { - onTap?() - if tapToDismiss{ - withAnimation(Animation.spring()){ - self.workItem?.cancel() - isPresenting = false - self.workItem = nil - } - } - } - .onDisappear(perform: { - completion?() - }) - .transition(AnyTransition.scale(scale: 0.8).combined(with: .opacity)) - case .hud: - alert() - .overlay( - GeometryReader{ geo -> AnyView in - let rect = geo.frame(in: .global) - - if rect.integral != alertRect.integral{ - - DispatchQueue.main.async { - - self.alertRect = rect - } - } - return AnyView(EmptyView()) - } - ) - .onTapGesture { - onTap?() - if tapToDismiss{ - withAnimation(Animation.spring()){ - self.workItem?.cancel() - isPresenting = false - self.workItem = nil - } - } - } - .onDisappear(perform: { - completion?() - }) - .transition(AnyTransition.move(edge: .top).combined(with: .opacity)) - case .banner: - alert() - .onTapGesture { - onTap?() - if tapToDismiss{ - withAnimation(Animation.spring()){ - self.workItem?.cancel() - isPresenting = false - self.workItem = nil - } - } - } - .onDisappear(perform: { - completion?() - }) - .transition(alert().displayMode == .banner(.slide) ? AnyTransition.slide.combined(with: .opacity) : AnyTransition.move(edge: .bottom)) + if title != nil || subTitle != nil { + VStack(alignment: type == .regular ? .center : .leading, spacing: 2) { + if let title = title { + Text(LocalizedStringKey(title)) + .font(style?.titleFont ?? Font.body.bold()) + .multilineTextAlignment(.center) + .textColor(style?.titleColor ?? nil) } - - } - } - - @ViewBuilder - public func body(content: Content) -> some View { - switch alert().displayMode{ - case .banner: - content - .overlay(ZStack{ - main() - .offset(y: offsetY) - } - .animation(Animation.spring(), value: isPresenting) - ) - .valueChanged(value: isPresenting, onChange: { (presented) in - if presented{ - onAppearAction() - } - }) - case .hud: - content - .overlay( - GeometryReader{ geo -> AnyView in - let rect = geo.frame(in: .global) - - if rect.integral != hostRect.integral{ - DispatchQueue.main.async { - self.hostRect = rect - } - } - - return AnyView(EmptyView()) - } - .overlay(ZStack{ - main() - .offset(y: offsetY) - } - .frame(maxWidth: screen.width, maxHeight: screen.height) - .offset(y: offset) - .animation(Animation.spring(), value: isPresenting)) - ) - .valueChanged(value: isPresenting, onChange: { (presented) in - if presented{ - onAppearAction() - } - }) - case .alert: - content - .overlay(ZStack{ - main() - .offset(y: offsetY) - } - .frame(maxWidth: screen.width, maxHeight: screen.height, alignment: .center) - .edgesIgnoringSafeArea(.all) - .animation(Animation.spring(), value: isPresenting)) - .valueChanged(value: isPresenting, onChange: { (presented) in - if presented{ - onAppearAction() - } - }) - } - - } - - private func onAppearAction(){ - guard workItem == nil else { - return - } - - if alert().type == .loading{ - duration = 0 - tapToDismiss = false - } - - if duration > 0{ - workItem?.cancel() - - let task = DispatchWorkItem { - withAnimation(Animation.spring()){ - isPresenting = false - workItem = nil - } + if let subTitle = subTitle { + Text(LocalizedStringKey(subTitle)) + .font(style?.subTitleFont ?? Font.footnote) + .opacity(0.7) + .multilineTextAlignment(.center) + .textColor(style?.subtitleColor ?? nil) } - workItem = task - DispatchQueue.main.asyncAfter(deadline: .now() + duration, execute: task) - } - } -} + } + } + } + .padding(.horizontal, 24) + .padding(.vertical, 8) + .frame(minHeight: 50) + .alertBackground(style?.backgroundColor ?? nil) + .clipShape(Capsule()) + .overlay(Capsule().stroke(Color.gray.opacity(0.2), lineWidth: 1)) + .shadow(color: Color.black.opacity(0.1), radius: 5, x: 0, y: 6) + .compositingGroup() + } + .padding(.top) + } -///Fileprivate View Modifier for dynamic frame when alert type is `.regular` / `.loading` -@available(iOS 14, macOS 11, *) -fileprivate struct WithFrameModifier: ViewModifier{ - - var withFrame: Bool - - var maxWidth: CGFloat = 175 - var maxHeight: CGFloat = 175 - - @ViewBuilder - func body(content: Content) -> some View { - if withFrame{ - content - .frame(maxWidth: maxWidth, maxHeight: maxHeight, alignment: .center) - }else{ - content - } - } -} + /// Alert View + public var alert: some View { + VStack { + switch type { + case .complete(let color): + Spacer() + AnimatedCheckmark(color: color) + Spacer() + case .error(let color): + Spacer() + AnimatedXmark(color: color) + Spacer() + case .systemImage(let name, let color): + Spacer() + Image(systemName: name) + .renderingMode(.template) + .resizable() + .aspectRatio(contentMode: .fit) + .scaledToFit() + .foregroundColor(color) + .padding(.bottom) + Spacer() + case .image(let name, let color): + Spacer() + Image(name) + .resizable() + .aspectRatio(contentMode: .fit) + .scaledToFit() + .foregroundColor(color) + .padding(.bottom) + Spacer() + case .loading: + ActivityIndicator(color: style?.activityIndicatorColor ?? .white) + case .regular: + EmptyView() + } -///Fileprivate View Modifier to change the alert background -@available(iOS 14, macOS 11, *) -fileprivate struct BackgroundModifier: ViewModifier{ - - var color: Color? - - @ViewBuilder - func body(content: Content) -> some View { - if let color = color { - content - .background(color) - }else{ - content - .background(BlurView()) + VStack(spacing: type == .regular ? 8 : 2) { + if let title = title { + Text(LocalizedStringKey(title)) + .font(style?.titleFont ?? Font.body.bold()) + .multilineTextAlignment(.center) + .textColor(style?.titleColor ?? nil) } - } -} - -///Fileprivate View Modifier to change the text colors -@available(iOS 14, macOS 11, *) -fileprivate struct TextForegroundModifier: ViewModifier{ - - var color: Color? - - @ViewBuilder - func body(content: Content) -> some View { - if let color = color { - content - .foregroundColor(color) - }else{ - content + if let subTitle = subTitle { + Text(LocalizedStringKey(subTitle)) + .font(style?.subTitleFont ?? Font.footnote) + .opacity(0.7) + .multilineTextAlignment(.center) + .textColor(style?.subtitleColor ?? nil) } + } } -} + .padding() + .withFrame(type != .regular && type != .loading) + .alertBackground(style?.backgroundColor ?? nil) + .cornerRadius(10) + } -@available(iOS 14, macOS 11, *) -fileprivate extension Image{ - - func hudModifier() -> some View{ - self - .renderingMode(.template) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(maxWidth: 20, maxHeight: 20, alignment: .center) - } -} - -//@available(iOS 14, macOS 11, *) -public extension View{ - - /// Return some view w/o frame depends on the condition. - /// This view modifier function is set by default to: - /// - `maxWidth`: 175 - /// - `maxHeight`: 175 - fileprivate func withFrame(_ withFrame: Bool) -> some View{ - modifier(WithFrameModifier(withFrame: withFrame)) - } - - /// Present `AlertToast`. - /// - Parameters: - /// - show: Binding - /// - alert: () -> AlertToast - /// - Returns: `AlertToast` - func toast(isPresenting: Binding, duration: TimeInterval = 2, tapToDismiss: Bool = true, offsetY: CGFloat = 0, alert: @escaping () -> AlertToast, onTap: (() -> ())? = nil, completion: (() -> ())? = nil) -> some View{ - modifier(AlertToastModifier(isPresenting: isPresenting, duration: duration, tapToDismiss: tapToDismiss, offsetY: offsetY, alert: alert, onTap: onTap, completion: completion)) - } - - /// Present `AlertToast`. - /// - Parameters: - /// - item: Binding - /// - alert: (Item?) -> AlertToast - /// - Returns: `AlertToast` - func toast(item: Binding, duration: Double = 2, tapToDismiss: Bool = true, offsetY: CGFloat = 0, alert: @escaping (Item?) -> AlertToast, onTap: (() -> ())? = nil, completion: (() -> ())? = nil) -> some View where Item : Identifiable { - modifier( - AlertToastModifier( - isPresenting: Binding( - get: { - item.wrappedValue != nil - }, set: { select in - if !select { - item.wrappedValue = nil - } - } - ), - duration: duration, - tapToDismiss: tapToDismiss, - offsetY: offsetY, - alert: { - alert(item.wrappedValue) - }, - onTap: onTap, - completion: completion - ) - ) - } - - /// Choose the alert background - /// - Parameter color: Some Color, if `nil` return `VisualEffectBlur` - /// - Returns: some View - fileprivate func alertBackground(_ color: Color? = nil) -> some View{ - modifier(BackgroundModifier(color: color)) - } - - /// Choose the alert background - /// - Parameter color: Some Color, if `nil` return `.black`/`.white` depends on system theme - /// - Returns: some View - fileprivate func textColor(_ color: Color? = nil) -> some View{ - modifier(TextForegroundModifier(color: color)) - } - - @ViewBuilder fileprivate func valueChanged(value: T, onChange: @escaping (T) -> Void) -> some View { - if #available(iOS 14.0, *) { - self.onChange(of: value, perform: onChange) - } else { - self.onReceive(Just(value)) { (value) in - onChange(value) - } - } - } + /// Body init determine by `displayMode` + public var body: some View { + switch displayMode { + case .alert: + alert + case .hud: + hud + case .banner: + banner + } + } } diff --git a/Recap/Frameworks/Toast/AlertToastModifier.swift b/Recap/Frameworks/Toast/AlertToastModifier.swift new file mode 100644 index 0000000..7c30762 --- /dev/null +++ b/Recap/Frameworks/Toast/AlertToastModifier.swift @@ -0,0 +1,199 @@ +import Combine +import SwiftUI + +@available(iOS 14, macOS 11, *) +public struct AlertToastModifier: ViewModifier { + + /// Presentation `Binding` + @Binding var isPresenting: Bool + + /// Duration time to display the alert + @State var duration: TimeInterval = 2 + + /// Tap to dismiss alert + @State var tapToDismiss: Bool = true + + var offsetY: CGFloat = 0 + + /// Init `AlertToast` View + var alert: () -> AlertToast + + /// Completion block returns `true` after dismiss + var onTap: (() -> Void)? + var completion: (() -> Void)? + + @State private var workItem: DispatchWorkItem? + + @State private var hostRect: CGRect = .zero + @State private var alertRect: CGRect = .zero + + private var screen: CGRect { + #if os(iOS) + return UIScreen.main.bounds + #else + return NSScreen.main?.frame ?? .zero + #endif + } + + private var offset: CGFloat { + return -hostRect.midY + alertRect.height + } + + @ViewBuilder + public func main() -> some View { + if isPresenting { + switch alert().displayMode { + case .alert: + alertModeView + case .hud: + hudModeView + case .banner: + bannerModeView + } + } + } + + private var alertModeView: some View { + alert() + .onTapGesture { handleTapGesture() } + .onDisappear(perform: { completion?() }) + .transition(AnyTransition.scale(scale: 0.8).combined(with: .opacity)) + } + + private var hudModeView: some View { + alert() + .overlay( + GeometryReader { geo -> AnyView in + let rect = geo.frame(in: .global) + if rect.integral != alertRect.integral { + DispatchQueue.main.async { + self.alertRect = rect + } + } + return AnyView(EmptyView()) + } + ) + .onTapGesture { handleTapGesture() } + .onDisappear(perform: { completion?() }) + .transition(AnyTransition.move(edge: .top).combined(with: .opacity)) + } + + private var bannerModeView: some View { + alert() + .onTapGesture { handleTapGesture() } + .onDisappear(perform: { completion?() }) + .transition( + alert().displayMode == .banner(.slide) + ? AnyTransition.slide.combined(with: .opacity) + : AnyTransition.move(edge: .bottom)) + } + + private func handleTapGesture() { + onTap?() + if tapToDismiss { + withAnimation(Animation.spring()) { + self.workItem?.cancel() + isPresenting = false + self.workItem = nil + } + } + } + + @ViewBuilder + public func body(content: Content) -> some View { + switch alert().displayMode { + case .banner: + bannerBodyView(content) + case .hud: + hudBodyView(content) + case .alert: + alertBodyView(content) + } + } + + private func bannerBodyView(_ content: Content) -> some View { + content + .overlay( + ZStack { + main() + .offset(y: offsetY) + } + .animation(Animation.spring(), value: isPresenting) + ) + .valueChanged(value: isPresenting) { presented in + if presented { + onAppearAction() + } + } + } + + private func hudBodyView(_ content: Content) -> some View { + content + .overlay( + GeometryReader { geo -> AnyView in + let rect = geo.frame(in: .global) + if rect.integral != hostRect.integral { + DispatchQueue.main.async { + self.hostRect = rect + } + } + return AnyView(EmptyView()) + } + .overlay( + ZStack { + main() + .offset(y: offsetY) + } + .frame(maxWidth: screen.width, maxHeight: screen.height) + .offset(y: offset) + .animation(Animation.spring(), value: isPresenting)) + ) + .valueChanged(value: isPresenting) { presented in + if presented { + onAppearAction() + } + } + } + + private func alertBodyView(_ content: Content) -> some View { + content + .overlay( + ZStack { + main() + .offset(y: offsetY) + } + .frame(maxWidth: screen.width, maxHeight: screen.height, alignment: .center) + .edgesIgnoringSafeArea(.all) + .animation(Animation.spring(), value: isPresenting) + ) + .valueChanged(value: isPresenting) { presented in + if presented { + onAppearAction() + } + } + } + + private func onAppearAction() { + guard workItem == nil else { + return + } + + if alert().type == .loading { + duration = 0 + tapToDismiss = false + } + + if duration > 0 { + workItem?.cancel() + + let task = DispatchWorkItem { + withAnimation(Animation.spring()) { + isPresenting = false + workItem = nil + } + } + workItem = task + DispatchQueue.main.asyncAfter(deadline: .now() + duration, execute: task) + } + } +} diff --git a/Recap/Frameworks/Toast/AlertToastTypes.swift b/Recap/Frameworks/Toast/AlertToastTypes.swift new file mode 100644 index 0000000..7e63563 --- /dev/null +++ b/Recap/Frameworks/Toast/AlertToastTypes.swift @@ -0,0 +1,75 @@ +import SwiftUI + +@available(iOS 14, macOS 11, *) +extension AlertToast { + public enum BannerAnimation { + case slide, pop + } + + public enum DisplayMode: Equatable { + case alert + case hud + case banner(_ transition: BannerAnimation) + } + + public enum AlertType: Equatable { + case complete(_ color: Color) + case error(_ color: Color) + case systemImage(_ name: String, _ color: Color) + case image(_ name: String, _ color: Color) + case loading + case regular + } + + public enum AlertStyle: Equatable { + case style( + backgroundColor: Color? = nil, + titleColor: Color? = nil, + subTitleColor: Color? = nil, + titleFont: Font? = nil, + subTitleFont: Font? = nil, + activityIndicatorColor: Color? = nil) + + var backgroundColor: Color? { + switch self { + case .style(backgroundColor: let color, _, _, _, _, _): + return color + } + } + + var titleColor: Color? { + switch self { + case .style(_, let color, _, _, _, _): + return color + } + } + + var subtitleColor: Color? { + switch self { + case .style(_, _, let color, _, _, _): + return color + } + } + + var titleFont: Font? { + switch self { + case .style(_, _, _, titleFont: let font, _, _): + return font + } + } + + var subTitleFont: Font? { + switch self { + case .style(_, _, _, _, subTitleFont: let font, _): + return font + } + } + + var activityIndicatorColor: Color? { + switch self { + case .style(_, _, _, _, _, let color): + return color + } + } + } +} diff --git a/Recap/Frameworks/Toast/AnimatedCheckmark.swift b/Recap/Frameworks/Toast/AnimatedCheckmark.swift new file mode 100644 index 0000000..33991ae --- /dev/null +++ b/Recap/Frameworks/Toast/AnimatedCheckmark.swift @@ -0,0 +1,74 @@ +import SwiftUI + +@available(iOS 14, macOS 11, *) +struct AnimatedCheckmark: View { + var color: Color = .black + var size: Int = 50 + + var height: CGFloat { + return CGFloat(size) + } + + var width: CGFloat { + return CGFloat(size) + } + + @State private var percentage: CGFloat = .zero + + var body: some View { + Path { path in + path.move(to: CGPoint(x: 0, y: height / 2)) + path.addLine(to: CGPoint(x: width / 2.5, y: height)) + path.addLine(to: CGPoint(x: width, y: 0)) + } + .trim(from: 0, to: percentage) + .stroke( + color, + style: StrokeStyle(lineWidth: CGFloat(size / 8), lineCap: .round, lineJoin: .round) + ) + .animation(Animation.spring().speed(0.75).delay(0.25), value: percentage) + .onAppear { + percentage = 1.0 + } + .frame(width: width, height: height, alignment: .center) + } +} + +@available(iOS 14, macOS 11, *) +struct AnimatedXmark: View { + var color: Color = .black + var size: Int = 50 + + var height: CGFloat { + return CGFloat(size) + } + + var width: CGFloat { + return CGFloat(size) + } + + var rect: CGRect { + return CGRect(x: 0, y: 0, width: size, height: size) + } + + @State private var percentage: CGFloat = .zero + + var body: some View { + Path { path in + path.move(to: CGPoint(x: rect.minX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxY, y: rect.maxY)) + path.move(to: CGPoint(x: rect.maxX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) + } + .trim(from: 0, to: percentage) + .stroke( + color, + style: StrokeStyle(lineWidth: CGFloat(size / 8), lineCap: .round, lineJoin: .round) + ) + .animation(Animation.spring().speed(0.75).delay(0.25), value: percentage) + .onAppear { + percentage = 1.0 + } + .frame(width: width, height: height, alignment: .center) + } +} diff --git a/Recap/Frameworks/Toast/BlurView.swift b/Recap/Frameworks/Toast/BlurView.swift index 6aa6b9a..3ebca66 100644 --- a/Recap/Frameworks/Toast/BlurView.swift +++ b/Recap/Frameworks/Toast/BlurView.swift @@ -10,37 +10,37 @@ import SwiftUI #if os(macOS) -@available(macOS 11, *) -public struct BlurView: NSViewRepresentable { + @available(macOS 11, *) + public struct BlurView: NSViewRepresentable { public typealias NSViewType = NSVisualEffectView - + public func makeNSView(context: Context) -> NSVisualEffectView { - let effectView = NSVisualEffectView() - effectView.material = .hudWindow - effectView.blendingMode = .withinWindow - effectView.state = NSVisualEffectView.State.active - return effectView + let effectView = NSVisualEffectView() + effectView.material = .hudWindow + effectView.blendingMode = .withinWindow + effectView.state = NSVisualEffectView.State.active + return effectView } - + public func updateNSView(_ nsView: NSVisualEffectView, context: Context) { - nsView.material = .hudWindow - nsView.blendingMode = .withinWindow + nsView.material = .hudWindow + nsView.blendingMode = .withinWindow } -} + } #else -@available(iOS 14, *) -public struct BlurView: UIViewRepresentable { + @available(iOS 14, *) + public struct BlurView: UIViewRepresentable { public typealias UIViewType = UIVisualEffectView - + public func makeUIView(context: Context) -> UIVisualEffectView { - return UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial)) + return UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial)) } - + public func updateUIView(_ uiView: UIVisualEffectView, context: Context) { - uiView.effect = UIBlurEffect(style: .systemMaterial) + uiView.effect = UIBlurEffect(style: .systemMaterial) } -} + } #endif diff --git a/Recap/Frameworks/Toast/View+Toast.swift b/Recap/Frameworks/Toast/View+Toast.swift new file mode 100644 index 0000000..51a4a8a --- /dev/null +++ b/Recap/Frameworks/Toast/View+Toast.swift @@ -0,0 +1,44 @@ +import SwiftUI + +@available(iOS 14, macOS 11, *) +extension View { + public func toast( + isPresenting: Binding, duration: TimeInterval = 2, tapToDismiss: Bool = true, + offsetY: CGFloat = 0, alert: @escaping () -> AlertToast, onTap: (() -> Void)? = nil, + completion: (() -> Void)? = nil + ) -> some View { + modifier( + AlertToastModifier( + isPresenting: isPresenting, duration: duration, tapToDismiss: tapToDismiss, + offsetY: offsetY, alert: alert, onTap: onTap, completion: completion)) + } + + public func toast( + item: Binding, duration: Double = 2, tapToDismiss: Bool = true, offsetY: CGFloat = 0, + alert: @escaping (Item?) -> AlertToast, onTap: (() -> Void)? = nil, + completion: (() -> Void)? = nil + ) -> some View where Item: Identifiable { + modifier( + AlertToastModifier( + isPresenting: Binding( + get: { + item.wrappedValue != nil + }, + set: { select in + if !select { + item.wrappedValue = nil + } + } + ), + duration: duration, + tapToDismiss: tapToDismiss, + offsetY: offsetY, + alert: { + alert(item.wrappedValue) + }, + onTap: onTap, + completion: completion + ) + ) + } +} diff --git a/Recap/Helpers/Availability/AvailabilityHelper.swift b/Recap/Helpers/Availability/AvailabilityHelper.swift index c6b658b..929879c 100644 --- a/Recap/Helpers/Availability/AvailabilityHelper.swift +++ b/Recap/Helpers/Availability/AvailabilityHelper.swift @@ -1,64 +1,63 @@ -import Foundation import Combine +import Foundation @MainActor protocol AvailabilityHelperType: AnyObject { - var isAvailable: Bool { get } - var availabilityPublisher: AnyPublisher { get } - - func startMonitoring() - func stopMonitoring() - func checkAvailabilityNow() async -> Bool -} + var isAvailable: Bool { get } + var availabilityPublisher: AnyPublisher { get } + func startMonitoring() + func stopMonitoring() + func checkAvailabilityNow() async -> Bool +} @MainActor final class AvailabilityHelper: AvailabilityHelperType { - @Published private(set) var isAvailable: Bool = false - var availabilityPublisher: AnyPublisher { - $isAvailable.eraseToAnyPublisher() - } - - private let checkInterval: TimeInterval - private let availabilityCheck: () async -> Bool - private var monitoringTimer: Timer? - - init( - checkInterval: TimeInterval = 30.0, - availabilityCheck: @escaping () async -> Bool - ) { - self.checkInterval = checkInterval - self.availabilityCheck = availabilityCheck - } - - deinit { - monitoringTimer?.invalidate() - monitoringTimer = nil - } - - func startMonitoring() { - Task { - await checkAvailabilityNow() - } - - monitoringTimer = Timer.scheduledTimer( - withTimeInterval: checkInterval, - repeats: true - ) { [weak self] _ in - Task { @MainActor in - await self?.checkAvailabilityNow() - } - } - } - - func stopMonitoring() { - monitoringTimer?.invalidate() - monitoringTimer = nil + @Published private(set) var isAvailable: Bool = false + var availabilityPublisher: AnyPublisher { + $isAvailable.eraseToAnyPublisher() + } + + private let checkInterval: TimeInterval + private let availabilityCheck: () async -> Bool + private var monitoringTimer: Timer? + + init( + checkInterval: TimeInterval = 30.0, + availabilityCheck: @escaping () async -> Bool + ) { + self.checkInterval = checkInterval + self.availabilityCheck = availabilityCheck + } + + deinit { + monitoringTimer?.invalidate() + monitoringTimer = nil + } + + func startMonitoring() { + Task { + await checkAvailabilityNow() } - - func checkAvailabilityNow() async -> Bool { - let available = await availabilityCheck() - isAvailable = available - return available + + monitoringTimer = Timer.scheduledTimer( + withTimeInterval: checkInterval, + repeats: true + ) { [weak self] _ in + Task { @MainActor in + await self?.checkAvailabilityNow() + } } + } + + func stopMonitoring() { + monitoringTimer?.invalidate() + monitoringTimer = nil + } + + func checkAvailabilityNow() async -> Bool { + let available = await availabilityCheck() + isAvailable = available + return available + } } diff --git a/Recap/Helpers/Colors/Color+Extension.swift b/Recap/Helpers/Colors/Color+Extension.swift index 2f4b332..46734b7 100644 --- a/Recap/Helpers/Colors/Color+Extension.swift +++ b/Recap/Helpers/Colors/Color+Extension.swift @@ -1,27 +1,32 @@ import SwiftUI extension Color { - init(hex: String) { - let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) - var int: UInt64 = 0 - Scanner(string: hex).scanHexInt64(&int) - let a, r, g, b: UInt64 - switch hex.count { - case 3: - (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) - case 6: - (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) - case 8: - (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) - default: - (a, r, g, b) = (1, 1, 1, 0) - } - self.init( - .sRGB, - red: Double(r) / 255, - green: Double(g) / 255, - blue: Double(b) / 255, - opacity: Double(a) / 255 - ) + init(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + let alpha: UInt64 + let red: UInt64 + let green: UInt64 + let blue: UInt64 + switch hex.count { + case 3: + (alpha, red, green, blue) = ( + 255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17 + ) + case 6: + (alpha, red, green, blue) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: + (alpha, red, green, blue) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: + (alpha, red, green, blue) = (1, 1, 1, 0) } -} \ No newline at end of file + self.init( + .sRGB, + red: Double(red) / 255, + green: Double(green) / 255, + blue: Double(blue) / 255, + opacity: Double(alpha) / 255 + ) + } +} diff --git a/Recap/Helpers/Constants/AppConstants.swift b/Recap/Helpers/Constants/AppConstants.swift index 74b7b61..5c99eb5 100644 --- a/Recap/Helpers/Constants/AppConstants.swift +++ b/Recap/Helpers/Constants/AppConstants.swift @@ -1,7 +1,7 @@ import Foundation struct AppConstants { - struct Logging { - static let subsystem = "com.recap.audio" - } -} \ No newline at end of file + struct Logging { + static let subsystem = "com.recap.audio" + } +} diff --git a/Recap/Helpers/Constants/UIConstants.swift b/Recap/Helpers/Constants/UIConstants.swift index e8c2f8a..1652d23 100644 --- a/Recap/Helpers/Constants/UIConstants.swift +++ b/Recap/Helpers/Constants/UIConstants.swift @@ -8,158 +8,158 @@ import SwiftUI struct UIConstants { - - struct Colors { - static let backgroundGradientStart = Color(hex: "050507") - static let backgroundGradientMiddle = Color(hex: "020202").opacity(0.45) - static let backgroundGradientLightMiddle = Color(hex: "0A0A0A") - static let backgroundGradientEnd = Color(hex: "020202") - - static let cardBackground1 = Color(hex: "474747").opacity(0.1) - static let cardBackground2 = Color(hex: "0F0F0F").opacity(0.18) - static let cardBackground3 = Color(hex: "050505").opacity(0.5) - static let cardSecondaryBackground = Color(hex: "242323").opacity(0.4) - - static let borderStart = Color(hex: "979797").opacity(0.06) - static let borderEnd = Color(hex: "C4C4C4").opacity(0.12) - static let borderMid = Color(hex: "979797").opacity(0.08) - - static let audioActive = Color(hex: "9EFF36").opacity(0.6) - static let audioInactive = Color(hex: "252525") - static let audioGreen = Color(hex: "9EFF36") - - static let selectionStroke = Color(hex: "979797").opacity(0.5) - - static let textPrimary = Color.white - static let textSecondary = Color.white.opacity(0.7) - static let textTertiary = Color.white.opacity(0.5) - } - - struct Gradients { - static let backgroundGradient = LinearGradient( - gradient: Gradient(stops: [ - .init(color: Colors.backgroundGradientStart, location: 0), - .init(color: Colors.backgroundGradientMiddle, location: 0.4), - .init(color: Colors.backgroundGradientEnd, location: 1) - ]), - startPoint: .bottomLeading, - endPoint: .topTrailing - ) - - static let standardBorder = LinearGradient( - gradient: Gradient(stops: [ - .init(color: Colors.borderStart, location: 0), - .init(color: Colors.borderEnd, location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ) - - static let reflectionBorder = LinearGradient( - gradient: Gradient(stops: [ - .init(color: Colors.audioGreen.opacity(0.15), location: 0), - .init(color: Colors.borderMid, location: 0.3), - .init(color: Colors.borderEnd, location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ) - - static let reflectionBorderRecording = LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color.red.opacity(0.4), location: 0), - .init(color: Colors.borderMid, location: 0.3), - .init(color: Colors.borderEnd, location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ) - - static let iconGradient = LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color(hex: "979797").opacity(0.01), location: 0), - .init(color: Color.white.opacity(0.50), location: 0.5), - .init(color: Color.white, location: 1) - ]), - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - - static let dropdownBackground = LinearGradient( - gradient: Gradient(stops: [ - .init(color: Colors.backgroundGradientStart, location: 0), - .init(color: Colors.backgroundGradientLightMiddle, location: 0.4), - .init(color: Colors.backgroundGradientEnd, location: 1) - ]), - startPoint: .bottomLeading, - endPoint: .topTrailing - ) - - static let summarySeparator = LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color.clear, location: 0), - .init(color: Colors.borderMid.opacity(0.3), location: 0.3), - .init(color: Colors.borderMid.opacity(0.6), location: 0.5), - .init(color: Colors.borderMid.opacity(0.3), location: 0.7), - .init(color: Color.clear, location: 1) - ]), - startPoint: .leading, - endPoint: .trailing - ) - - static let summaryButtonBackground = LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color.clear, location: 0), - .init(color: Colors.backgroundGradientStart.opacity(0.08), location: 0.4), - .init(color: Colors.backgroundGradientStart.opacity(0.05), location: 0.7), - .init(color: Colors.backgroundGradientStart.opacity(0.10), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ) - } - - struct Spacing { - static let cardSpacing: CGFloat = 16 - static let sectionSpacing: CGFloat = 20 - static let contentPadding: CGFloat = 30 - static let cardPadding: CGFloat = 10 - static let cardInternalSpacing: CGFloat = 6 - static let gridSpacing: CGFloat = 2 - static let gridCellSpacing: CGFloat = 4 - } - - struct Sizing { - static let cornerRadius: CGFloat = 20 - static let smallCornerRadius: CGFloat = 1.5 - static let borderWidth: CGFloat = 2 - static let strokeWidth: CGFloat = 1 - static let heatmapCellSize: CGFloat = 6 - static let selectionCircleSize: CGFloat = 16 - static let iconSize: CGFloat = 8 + + struct Colors { + static let backgroundGradientStart = Color(hex: "050507") + static let backgroundGradientMiddle = Color(hex: "020202").opacity(0.45) + static let backgroundGradientLightMiddle = Color(hex: "0A0A0A") + static let backgroundGradientEnd = Color(hex: "020202") + + static let cardBackground1 = Color(hex: "474747").opacity(0.1) + static let cardBackground2 = Color(hex: "0F0F0F").opacity(0.18) + static let cardBackground3 = Color(hex: "050505").opacity(0.5) + static let cardSecondaryBackground = Color(hex: "242323").opacity(0.4) + + static let borderStart = Color(hex: "979797").opacity(0.06) + static let borderEnd = Color(hex: "C4C4C4").opacity(0.12) + static let borderMid = Color(hex: "979797").opacity(0.08) + + static let audioActive = Color(hex: "9EFF36").opacity(0.6) + static let audioInactive = Color(hex: "252525") + static let audioGreen = Color(hex: "9EFF36") + + static let selectionStroke = Color(hex: "979797").opacity(0.5) + + static let textPrimary = Color.white + static let textSecondary = Color.white.opacity(0.7) + static let textTertiary = Color.white.opacity(0.5) + } + + struct Gradients { + static let backgroundGradient = LinearGradient( + gradient: Gradient(stops: [ + .init(color: Colors.backgroundGradientStart, location: 0), + .init(color: Colors.backgroundGradientMiddle, location: 0.4), + .init(color: Colors.backgroundGradientEnd, location: 1) + ]), + startPoint: .bottomLeading, + endPoint: .topTrailing + ) + + static let standardBorder = LinearGradient( + gradient: Gradient(stops: [ + .init(color: Colors.borderStart, location: 0), + .init(color: Colors.borderEnd, location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ) + + static let reflectionBorder = LinearGradient( + gradient: Gradient(stops: [ + .init(color: Colors.audioGreen.opacity(0.15), location: 0), + .init(color: Colors.borderMid, location: 0.3), + .init(color: Colors.borderEnd, location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ) + + static let reflectionBorderRecording = LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color.red.opacity(0.4), location: 0), + .init(color: Colors.borderMid, location: 0.3), + .init(color: Colors.borderEnd, location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ) + + static let iconGradient = LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color(hex: "979797").opacity(0.01), location: 0), + .init(color: Color.white.opacity(0.50), location: 0.5), + .init(color: Color.white, location: 1) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + + static let dropdownBackground = LinearGradient( + gradient: Gradient(stops: [ + .init(color: Colors.backgroundGradientStart, location: 0), + .init(color: Colors.backgroundGradientLightMiddle, location: 0.4), + .init(color: Colors.backgroundGradientEnd, location: 1) + ]), + startPoint: .bottomLeading, + endPoint: .topTrailing + ) + + static let summarySeparator = LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color.clear, location: 0), + .init(color: Colors.borderMid.opacity(0.3), location: 0.3), + .init(color: Colors.borderMid.opacity(0.6), location: 0.5), + .init(color: Colors.borderMid.opacity(0.3), location: 0.7), + .init(color: Color.clear, location: 1) + ]), + startPoint: .leading, + endPoint: .trailing + ) + + static let summaryButtonBackground = LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color.clear, location: 0), + .init(color: Colors.backgroundGradientStart.opacity(0.08), location: 0.4), + .init(color: Colors.backgroundGradientStart.opacity(0.05), location: 0.7), + .init(color: Colors.backgroundGradientStart.opacity(0.10), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ) + } + + struct Spacing { + static let cardSpacing: CGFloat = 16 + static let sectionSpacing: CGFloat = 20 + static let contentPadding: CGFloat = 30 + static let cardPadding: CGFloat = 10 + static let cardInternalSpacing: CGFloat = 6 + static let gridSpacing: CGFloat = 2 + static let gridCellSpacing: CGFloat = 4 + } + + struct Sizing { + static let cornerRadius: CGFloat = 20 + static let smallCornerRadius: CGFloat = 1.5 + static let borderWidth: CGFloat = 2 + static let strokeWidth: CGFloat = 1 + static let heatmapCellSize: CGFloat = 6 + static let selectionCircleSize: CGFloat = 16 + static let iconSize: CGFloat = 8 + } + + struct Typography { + static let appTitle = Font.system(size: 24, weight: .bold) + static let cardTitle = Font.system(size: 12, weight: .bold) + static let infoCardTitle = Font.system(size: 16, weight: .bold) + static let transcriptionTitle = Font.system(size: 12, weight: .bold) + static let bodyText = Font.system(size: 10, weight: .regular) + static let iconFont = Font.system(size: 8, weight: .bold) + static let infoIconFont = Font.system(size: 24, weight: .bold) + } + + struct Layout { + static func cardWidth(containerWidth: CGFloat) -> CGFloat { + max((containerWidth - 82) / 2, 50) } - - struct Typography { - static let appTitle = Font.system(size: 24, weight: .bold) - static let cardTitle = Font.system(size: 12, weight: .bold) - static let infoCardTitle = Font.system(size: 16, weight: .bold) - static let transcriptionTitle = Font.system(size: 12, weight: .bold) - static let bodyText = Font.system(size: 10, weight: .regular) - static let iconFont = Font.system(size: 8, weight: .bold) - static let infoIconFont = Font.system(size: 24, weight: .bold) + + static func infoCardWidth(containerWidth: CGFloat) -> CGFloat { + max((containerWidth - 75) / 2, 50) } - - struct Layout { - static func cardWidth(containerWidth: CGFloat) -> CGFloat { - max((containerWidth - 82) / 2, 50) - } - - static func infoCardWidth(containerWidth: CGFloat) -> CGFloat { - max((containerWidth - 75) / 2, 50) - } - - static func fullCardWidth(containerWidth: CGFloat) -> CGFloat { - max(containerWidth - 60, 100) - } + + static func fullCardWidth(containerWidth: CGFloat) -> CGFloat { + max(containerWidth - 60, 100) } + } } diff --git a/Recap/Helpers/Extensions/String+Extensions.swift b/Recap/Helpers/Extensions/String+Extensions.swift index c24803b..0ea7bcc 100644 --- a/Recap/Helpers/Extensions/String+Extensions.swift +++ b/Recap/Helpers/Extensions/String+Extensions.swift @@ -1,7 +1,7 @@ import Foundation extension String { - var lastReverseDNSComponent: String? { - components(separatedBy: ".").last.flatMap { $0.isEmpty ? nil : $0 } - } -} \ No newline at end of file + var lastReverseDNSComponent: String? { + components(separatedBy: ".").last.flatMap { $0.isEmpty ? nil : $0 } + } +} diff --git a/Recap/Helpers/Extensions/URL+Extensions.swift b/Recap/Helpers/Extensions/URL+Extensions.swift index de62086..9d95f2e 100644 --- a/Recap/Helpers/Extensions/URL+Extensions.swift +++ b/Recap/Helpers/Extensions/URL+Extensions.swift @@ -2,21 +2,23 @@ import Foundation import UniformTypeIdentifiers extension URL { - func parentBundleURL(maxDepth: Int = 8) -> URL? { - var depth = 0 - var url = deletingLastPathComponent() - while depth < maxDepth, !url.isBundle { - url = url.deletingLastPathComponent() - depth += 1 - } - return url.isBundle ? url : nil + func parentBundleURL(maxDepth: Int = 8) -> URL? { + var depth = 0 + var url = deletingLastPathComponent() + while depth < maxDepth, !url.isBundle { + url = url.deletingLastPathComponent() + depth += 1 } - - var isBundle: Bool { - (try? resourceValues(forKeys: [.contentTypeKey]))?.contentType?.conforms(to: .bundle) == true - } - - var isApp: Bool { - (try? resourceValues(forKeys: [.contentTypeKey]))?.contentType?.conforms(to: .application) == true - } -} \ No newline at end of file + return url.isBundle ? url : nil + } + + var isBundle: Bool { + (try? resourceValues(forKeys: [.contentTypeKey]))?.contentType?.conforms(to: .bundle) + == true + } + + var isApp: Bool { + (try? resourceValues(forKeys: [.contentTypeKey]))?.contentType?.conforms(to: .application) + == true + } +} diff --git a/Recap/Helpers/GlobalShortcut/GlobalShortcutManager.swift b/Recap/Helpers/GlobalShortcut/GlobalShortcutManager.swift new file mode 100644 index 0000000..e4d236e --- /dev/null +++ b/Recap/Helpers/GlobalShortcut/GlobalShortcutManager.swift @@ -0,0 +1,156 @@ +import Carbon +import Cocoa +import OSLog + +@MainActor +protocol GlobalShortcutDelegate: AnyObject { + func globalShortcutActivated() +} + +@MainActor +final class GlobalShortcutManager { + private var hotKeyRef: EventHotKeyRef? + private var eventHandler: EventHandlerRef? + private weak var delegate: GlobalShortcutDelegate? + + // Default shortcut: Cmd+R + private var currentShortcut: (keyCode: UInt32, modifiers: UInt32) = ( + keyCode: 15, + modifiers: UInt32(cmdKey) + ) // 'R' key with Cmd + private let logger = Logger( + subsystem: AppConstants.Logging.subsystem, + category: String(describing: GlobalShortcutManager.self) + ) + + init() { + setupEventHandling() + } + + deinit { + // Note: We can't use Task here as it would capture self in deinit + // The shortcut will be cleaned up when the app terminates + } + + func setDelegate(_ delegate: GlobalShortcutDelegate) { + self.delegate = delegate + } + + func registerShortcut(keyCode: UInt32, modifiers: UInt32) { + unregisterShortcut() + currentShortcut = (keyCode: keyCode, modifiers: modifiers) + registerShortcut() + } + + func registerDefaultShortcut() { + registerShortcut(keyCode: 15, modifiers: UInt32(cmdKey)) // Cmd+R + } + + private func registerShortcut() { + let eventType = EventTypeSpec( + eventClass: OSType(kEventClassKeyboard), eventKind: OSType(kEventHotKeyPressed)) + + let status = InstallEventHandler( + GetApplicationEventTarget(), + { (_, theEvent, userData) -> OSStatus in + guard let userData = userData, let theEvent = theEvent else { + return OSStatus(eventNotHandledErr) + } + let manager = Unmanaged.fromOpaque(userData) + .takeUnretainedValue() + return manager.handleHotKeyEvent(theEvent) + }, + 1, + [eventType], + Unmanaged.passUnretained(self).toOpaque(), + &eventHandler + ) + + guard status == noErr else { + logger.error("Failed to install event handler: \(status, privacy: .public)") + return + } + + let hotKeyID = EventHotKeyID(signature: OSType(0x4D4B_4D4B), id: 1) + let status2 = RegisterEventHotKey( + currentShortcut.keyCode, + currentShortcut.modifiers, + hotKeyID, + GetApplicationEventTarget(), + 0, + &hotKeyRef + ) + + guard status2 == noErr else { + logger.error("Failed to register hot key: \(status2, privacy: .public)") + return + } + + logger.info("Global shortcut registered: Cmd+R") + } + + private func unregisterShortcut() { + if let hotKeyRef = hotKeyRef { + UnregisterEventHotKey(hotKeyRef) + self.hotKeyRef = nil + } + + if let eventHandler = eventHandler { + RemoveEventHandler(eventHandler) + self.eventHandler = nil + } + } + + private func setupEventHandling() { + // This is handled in registerShortcut + } + + private func handleHotKeyEvent(_ event: EventRef) -> OSStatus { + DispatchQueue.main.async { [weak self] in + self?.delegate?.globalShortcutActivated() + } + return noErr + } + + func getCurrentShortcut() -> (keyCode: UInt32, modifiers: UInt32) { + return currentShortcut + } + + func getShortcutString() -> String { + let keyString = getKeyString(for: currentShortcut.keyCode) + let modifierString = getModifierString(for: currentShortcut.modifiers) + return "\(modifierString)\(keyString)" + } + + private static let keyCodeMap: [UInt32: String] = [ + 0: "A", 1: "S", 2: "D", 3: "F", 4: "H", 5: "G", 6: "Z", 7: "X", + 8: "C", 9: "V", 11: "B", 12: "Q", 13: "W", 14: "E", 15: "R", 16: "Y", + 17: "T", 18: "1", 19: "2", 20: "3", 21: "4", 22: "6", 23: "5", 24: "=", + 25: "9", 26: "7", 27: "-", 28: "8", 29: "0", 30: "]", 31: "O", 32: "U", + 33: "[", 34: "I", 35: "P", 36: "Return", 37: "L", 38: "J", 39: "'", 40: "K", + 41: ";", 42: "\\", 43: ",", 44: "/", 45: "N", 46: "M", 47: ".", 48: "Tab", + 49: "Space", 50: "`", 51: "Delete", 53: "Escape", 123: "Left", 124: "Right", + 125: "Down", 126: "Up" + ] + + private func getKeyString(for keyCode: UInt32) -> String { + return Self.keyCodeMap[keyCode] ?? "Key\(keyCode)" + } + + private func getModifierString(for modifiers: UInt32) -> String { + var result = "" + if (modifiers & UInt32(cmdKey)) != 0 { + result += "⌘" + } + if (modifiers & UInt32(optionKey)) != 0 { + result += "⌥" + } + if (modifiers & UInt32(controlKey)) != 0 { + result += "⌃" + } + if (modifiers & UInt32(shiftKey)) != 0 { + result += "⇧" + } + return result + } +} diff --git a/Recap/Helpers/MeetingDetection/MeetingPatternMatcher.swift b/Recap/Helpers/MeetingDetection/MeetingPatternMatcher.swift index 09aadf5..e9bc113 100644 --- a/Recap/Helpers/MeetingDetection/MeetingPatternMatcher.swift +++ b/Recap/Helpers/MeetingDetection/MeetingPatternMatcher.swift @@ -1,95 +1,96 @@ import Foundation struct MeetingPattern { - let keyword: String - let confidence: MeetingDetectionResult.MeetingConfidence - let caseSensitive: Bool - let excludePatterns: [String] - - init( - keyword: String, - confidence: MeetingDetectionResult.MeetingConfidence, - caseSensitive: Bool = false, - excludePatterns: [String] = [] - ) { - self.keyword = keyword - self.confidence = confidence - self.caseSensitive = caseSensitive - self.excludePatterns = excludePatterns - } + let keyword: String + let confidence: MeetingDetectionResult.MeetingConfidence + let caseSensitive: Bool + let excludePatterns: [String] + + init( + keyword: String, + confidence: MeetingDetectionResult.MeetingConfidence, + caseSensitive: Bool = false, + excludePatterns: [String] = [] + ) { + self.keyword = keyword + self.confidence = confidence + self.caseSensitive = caseSensitive + self.excludePatterns = excludePatterns + } } final class MeetingPatternMatcher { - private let patterns: [MeetingPattern] - - init(patterns: [MeetingPattern]) { - self.patterns = patterns.sorted { $0.confidence.rawValue > $1.confidence.rawValue } - } - - func findBestMatch(in title: String) -> MeetingDetectionResult.MeetingConfidence? { - let processedTitle = title.lowercased() - - for pattern in patterns { - let searchText = pattern.caseSensitive ? title : processedTitle - let searchKeyword = pattern.caseSensitive ? pattern.keyword : pattern.keyword.lowercased() - - if searchText.contains(searchKeyword) { - let shouldExclude = pattern.excludePatterns.contains { excludePattern in - processedTitle.contains(excludePattern.lowercased()) - } - - if !shouldExclude { - return pattern.confidence - } - } + private let patterns: [MeetingPattern] + + init(patterns: [MeetingPattern]) { + self.patterns = patterns.sorted { $0.confidence.rawValue > $1.confidence.rawValue } + } + + func findBestMatch(in title: String) -> MeetingDetectionResult.MeetingConfidence? { + let processedTitle = title.lowercased() + + for pattern in patterns { + let searchText = pattern.caseSensitive ? title : processedTitle + let searchKeyword = + pattern.caseSensitive ? pattern.keyword : pattern.keyword.lowercased() + + if searchText.contains(searchKeyword) { + let shouldExclude = pattern.excludePatterns.contains { excludePattern in + processedTitle.contains(excludePattern.lowercased()) + } + + if !shouldExclude { + return pattern.confidence } - - return nil + } } + + return nil + } } extension MeetingPatternMatcher { - private static var commonMeetingPatterns: [MeetingPattern] { - return [ - MeetingPattern(keyword: "refinement", confidence: .high), - MeetingPattern(keyword: "daily", confidence: .high), - MeetingPattern(keyword: "sync", confidence: .high), - MeetingPattern(keyword: "retro", confidence: .high), - MeetingPattern(keyword: "retrospective", confidence: .high), - MeetingPattern(keyword: "meeting", confidence: .medium), - MeetingPattern(keyword: "call", confidence: .medium) - ] - } - - static var teamsPatterns: [MeetingPattern] { - return [ - MeetingPattern(keyword: "microsoft teams meeting", confidence: .high), - MeetingPattern(keyword: "teams meeting", confidence: .high), - MeetingPattern(keyword: "meeting in \"", confidence: .high), - MeetingPattern(keyword: "call with", confidence: .high), - MeetingPattern( - keyword: "| Microsoft Teams", - confidence: .high, - caseSensitive: true, - excludePatterns: ["chat", "activity", "microsoft teams"] - ), - MeetingPattern(keyword: "screen sharing", confidence: .medium) - ] + commonMeetingPatterns - } - - static var zoomPatterns: [MeetingPattern] { - return [ - MeetingPattern(keyword: "zoom meeting", confidence: .high), - MeetingPattern(keyword: "zoom webinar", confidence: .high), - MeetingPattern(keyword: "screen share", confidence: .medium) - ] + commonMeetingPatterns - } - - static var googleMeetPatterns: [MeetingPattern] { - return [ - MeetingPattern(keyword: "meet.google.com", confidence: .high), - MeetingPattern(keyword: "google meet", confidence: .high), - MeetingPattern(keyword: "meet -", confidence: .medium) - ] + commonMeetingPatterns - } -} \ No newline at end of file + private static var commonMeetingPatterns: [MeetingPattern] { + return [ + MeetingPattern(keyword: "refinement", confidence: .high), + MeetingPattern(keyword: "daily", confidence: .high), + MeetingPattern(keyword: "sync", confidence: .high), + MeetingPattern(keyword: "retro", confidence: .high), + MeetingPattern(keyword: "retrospective", confidence: .high), + MeetingPattern(keyword: "meeting", confidence: .medium), + MeetingPattern(keyword: "call", confidence: .medium) + ] + } + + static var teamsPatterns: [MeetingPattern] { + return [ + MeetingPattern(keyword: "microsoft teams meeting", confidence: .high), + MeetingPattern(keyword: "teams meeting", confidence: .high), + MeetingPattern(keyword: "meeting in \"", confidence: .high), + MeetingPattern(keyword: "call with", confidence: .high), + MeetingPattern( + keyword: "| Microsoft Teams", + confidence: .high, + caseSensitive: true, + excludePatterns: ["chat", "activity", "microsoft teams"] + ), + MeetingPattern(keyword: "screen sharing", confidence: .medium) + ] + commonMeetingPatterns + } + + static var zoomPatterns: [MeetingPattern] { + return [ + MeetingPattern(keyword: "zoom meeting", confidence: .high), + MeetingPattern(keyword: "zoom webinar", confidence: .high), + MeetingPattern(keyword: "screen share", confidence: .medium) + ] + commonMeetingPatterns + } + + static var googleMeetPatterns: [MeetingPattern] { + return [ + MeetingPattern(keyword: "meet.google.com", confidence: .high), + MeetingPattern(keyword: "google meet", confidence: .high), + MeetingPattern(keyword: "meet -", confidence: .medium) + ] + commonMeetingPatterns + } +} diff --git a/Recap/Helpers/Permissions/PermissionsHelper.swift b/Recap/Helpers/Permissions/PermissionsHelper.swift index f8346ee..b60d19b 100644 --- a/Recap/Helpers/Permissions/PermissionsHelper.swift +++ b/Recap/Helpers/Permissions/PermissionsHelper.swift @@ -1,63 +1,63 @@ -import Foundation import AVFoundation -import UserNotifications +import Foundation import ScreenCaptureKit +import UserNotifications @MainActor final class PermissionsHelper: PermissionsHelperType { - func requestMicrophonePermission() async -> Bool { - await withCheckedContinuation { continuation in - AVCaptureDevice.requestAccess(for: .audio) { granted in - continuation.resume(returning: granted) - } - } - } - - func requestScreenRecordingPermission() async -> Bool { - do { - let _ = try await SCShareableContent.current - return true - } catch { - return false - } + func requestMicrophonePermission() async -> Bool { + await withCheckedContinuation { continuation in + AVCaptureDevice.requestAccess(for: .audio) { granted in + continuation.resume(returning: granted) + } } - - func requestNotificationPermission() async -> Bool { - do { - let center = UNUserNotificationCenter.current() - let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge]) - return granted - } catch { - return false - } + } + + func requestScreenRecordingPermission() async -> Bool { + do { + _ = try await SCShareableContent.current + return true + } catch { + return false } - - func checkMicrophonePermissionStatus() -> AVAuthorizationStatus { - AVCaptureDevice.authorizationStatus(for: .audio) + } + + func requestNotificationPermission() async -> Bool { + do { + let center = UNUserNotificationCenter.current() + let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge]) + return granted + } catch { + return false } - - func checkNotificationPermissionStatus() async -> Bool { - await withCheckedContinuation { continuation in - UNUserNotificationCenter.current().getNotificationSettings { settings in - continuation.resume(returning: settings.authorizationStatus == .authorized) - } - } + } + + func checkMicrophonePermissionStatus() -> AVAuthorizationStatus { + AVCaptureDevice.authorizationStatus(for: .audio) + } + + func checkNotificationPermissionStatus() async -> Bool { + await withCheckedContinuation { continuation in + UNUserNotificationCenter.current().getNotificationSettings { settings in + continuation.resume(returning: settings.authorizationStatus == .authorized) + } } - - func checkScreenRecordingPermission() -> Bool { - if #available(macOS 11.0, *) { - return CGPreflightScreenCaptureAccess() - } else { - return true - } + } + + func checkScreenRecordingPermission() -> Bool { + if #available(macOS 11.0, *) { + return CGPreflightScreenCaptureAccess() + } else { + return true } - - func checkScreenCapturePermission() async -> Bool { - do { - let _ = try await SCShareableContent.current - return true - } catch { - return false - } + } + + func checkScreenCapturePermission() async -> Bool { + do { + _ = try await SCShareableContent.current + return true + } catch { + return false } + } } diff --git a/Recap/Helpers/Permissions/PermissionsHelperType.swift b/Recap/Helpers/Permissions/PermissionsHelperType.swift index 2702347..e2dca00 100644 --- a/Recap/Helpers/Permissions/PermissionsHelperType.swift +++ b/Recap/Helpers/Permissions/PermissionsHelperType.swift @@ -1,19 +1,20 @@ -import Foundation import AVFoundation +import Foundation + #if MOCKING -import Mockable + import Mockable #endif #if MOCKING -@Mockable + @Mockable #endif @MainActor protocol PermissionsHelperType: AnyObject { - func requestMicrophonePermission() async -> Bool - func requestScreenRecordingPermission() async -> Bool - func requestNotificationPermission() async -> Bool - func checkMicrophonePermissionStatus() -> AVAuthorizationStatus - func checkNotificationPermissionStatus() async -> Bool - func checkScreenRecordingPermission() -> Bool - func checkScreenCapturePermission() async -> Bool + func requestMicrophonePermission() async -> Bool + func requestScreenRecordingPermission() async -> Bool + func requestNotificationPermission() async -> Bool + func checkMicrophonePermissionStatus() -> AVAuthorizationStatus + func checkNotificationPermissionStatus() async -> Bool + func checkScreenRecordingPermission() -> Bool + func checkScreenCapturePermission() async -> Bool } diff --git a/Recap/Helpers/ViewGeometry.swift b/Recap/Helpers/ViewGeometry.swift index 731b004..932a686 100644 --- a/Recap/Helpers/ViewGeometry.swift +++ b/Recap/Helpers/ViewGeometry.swift @@ -1,18 +1,18 @@ -import SwiftUI import AppKit +import SwiftUI struct ViewGeometryReader: NSViewRepresentable { - let onViewCreated: (NSView) -> Void - - func makeNSView(context: Context) -> NSView { - let view = NSView() - view.wantsLayer = true - DispatchQueue.main.async { - onViewCreated(view) - } - return view - } - - func updateNSView(_ nsView: NSView, context: Context) { + let onViewCreated: (NSView) -> Void + + func makeNSView(context: Context) -> NSView { + let view = NSView() + view.wantsLayer = true + DispatchQueue.main.async { + onViewCreated(view) } + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + } } diff --git a/Recap/Helpers/WhisperKit/WhisperKit+ProgressTracking.swift b/Recap/Helpers/WhisperKit/WhisperKit+ProgressTracking.swift index 2de4f50..b3be41b 100644 --- a/Recap/Helpers/WhisperKit/WhisperKit+ProgressTracking.swift +++ b/Recap/Helpers/WhisperKit/WhisperKit+ProgressTracking.swift @@ -1,110 +1,113 @@ import Foundation -import WhisperKit import Hub +import WhisperKit struct ModelSizeInfo { - let modelName: String - let totalSizeMB: Double - let fileCount: Int - let isEstimate: Bool + let modelName: String + let totalSizeMB: Double + let fileCount: Int + let isEstimate: Bool } // whisperkit has builtin progress tracking, yet the source code does not expose callback, workaround extension WhisperKit { - - static func getModelSizeInfo(for modelName: String) async -> ModelSizeInfo { - do { - let hubApi = HubApi() - let repo = Hub.Repo(id: "argmaxinc/whisperkit-coreml", type: .models) - let modelSearchPath = "*\(modelName)*/*" - - let fileMetadata = try await hubApi.getFileMetadata(from: repo, matching: [modelSearchPath]) - - let totalBytes = fileMetadata.reduce(0) { total, metadata in - total + (metadata.size ?? 0) - } - let totalSizeMB = Double(totalBytes) / Constants.bytesToMBDivisor - - return ModelSizeInfo( - modelName: modelName, - totalSizeMB: totalSizeMB, - fileCount: fileMetadata.count, - isEstimate: false - ) - } catch { - let size = Constants.fallbackModelSizes[modelName] ?? Constants.defaultModelSizeMB - return ModelSizeInfo( - modelName: modelName, - totalSizeMB: size, - fileCount: Constants.defaultFileCount, - isEstimate: true - ) - } + + static func getModelSizeInfo(for modelName: String) async -> ModelSizeInfo { + do { + let hubApi = HubApi() + let repo = Hub.Repo(id: "argmaxinc/whisperkit-coreml", type: .models) + let modelSearchPath = "*\(modelName)*/*" + + let fileMetadata = try await hubApi.getFileMetadata( + from: repo, matching: [modelSearchPath]) + + let totalBytes = fileMetadata.reduce(0) { total, metadata in + total + (metadata.size ?? 0) + } + let totalSizeMB = Double(totalBytes) / Constants.bytesToMBDivisor + + return ModelSizeInfo( + modelName: modelName, + totalSizeMB: totalSizeMB, + fileCount: fileMetadata.count, + isEstimate: false + ) + } catch { + let size = Constants.fallbackModelSizes[modelName] ?? Constants.defaultModelSizeMB + return ModelSizeInfo( + modelName: modelName, + totalSizeMB: size, + fileCount: Constants.defaultFileCount, + isEstimate: true + ) } - - static func createWithProgress( - model: String?, - downloadBase: URL? = nil, - modelRepo: String? = nil, - modelToken: String? = nil, - modelFolder: String? = nil, - download: Bool = true, - progressCallback: @escaping (Progress) -> Void - ) async throws -> WhisperKit { - - var actualModelFolder = modelFolder - - if actualModelFolder == nil && download { - let repo = modelRepo ?? "argmaxinc/whisperkit-coreml" - let modelSupport = await WhisperKit.recommendedRemoteModels(from: repo, downloadBase: downloadBase) - let modelVariant = model ?? modelSupport.default + } + + static func createWithProgress( + model: String?, + downloadBase: URL? = nil, + modelRepo: String? = nil, + modelToken: String? = nil, + modelFolder: String? = nil, + download: Bool = true, + progressCallback: @escaping (Progress) -> Void + ) async throws -> WhisperKit { - do { - let downloadedFolder = try await WhisperKit.download( - variant: modelVariant, - downloadBase: downloadBase, - useBackgroundSession: false, - from: repo, - token: modelToken, - progressCallback: progressCallback - ) - actualModelFolder = downloadedFolder.path - } catch { - throw WhisperError.modelsUnavailable(""" - Model not found. Please check the model or repo name and try again. - Error: \(error) - """) - } - } - - let config = WhisperKitConfig( - model: model, - downloadBase: downloadBase, - modelRepo: modelRepo, - modelToken: modelToken, - modelFolder: actualModelFolder, - download: false + var actualModelFolder = modelFolder + + if actualModelFolder == nil && download { + let repo = modelRepo ?? "argmaxinc/whisperkit-coreml" + let modelSupport = await WhisperKit.recommendedRemoteModels( + from: repo, downloadBase: downloadBase) + let modelVariant = model ?? modelSupport.default + + do { + let downloadedFolder = try await WhisperKit.download( + variant: modelVariant, + downloadBase: downloadBase, + useBackgroundSession: false, + from: repo, + token: modelToken, + progressCallback: progressCallback ) - - return try await WhisperKit(config) + actualModelFolder = downloadedFolder.path + } catch { + throw WhisperError.modelsUnavailable( + """ + Model not found. Please check the model or repo name and try again. + Error: \(error) + """) + } } + + let config = WhisperKitConfig( + model: model, + downloadBase: downloadBase, + modelRepo: modelRepo, + modelToken: modelToken, + modelFolder: actualModelFolder, + download: false + ) + + return try await WhisperKit(config) + } } -private extension WhisperKit { - enum Constants { - // estimates from official repo - static let fallbackModelSizes: [String: Double] = [ - "tiny": 218, - "base": 279, - "small": 1342, - "medium": 2917, - "large-v2": 7812, - "large-v3": 16793, - "distil-whisper_distil-large-v3_turbo": 2035 - ] - - static let defaultModelSizeMB: Double = 500.0 - static let defaultFileCount: Int = 6 - static let bytesToMBDivisor: Double = 1024 * 1024 - } +extension WhisperKit { + fileprivate enum Constants { + // estimates from official repo + static let fallbackModelSizes: [String: Double] = [ + "tiny": 218, + "base": 279, + "small": 1342, + "medium": 2917, + "large-v2": 7812, + "large-v3": 16793, + "distil-whisper_distil-large-v3_turbo": 2035 + ] + + static let defaultModelSizeMB: Double = 500.0 + static let defaultFileCount: Int = 6 + static let bytesToMBDivisor: Double = 1024 * 1024 + } } diff --git a/Recap/MenuBar/Dropdowns/DropdownWindowManager.swift b/Recap/MenuBar/Dropdowns/DropdownWindowManager.swift index b6987bb..5f86c82 100644 --- a/Recap/MenuBar/Dropdowns/DropdownWindowManager.swift +++ b/Recap/MenuBar/Dropdowns/DropdownWindowManager.swift @@ -1,163 +1,166 @@ -import SwiftUI import AppKit +import SwiftUI @MainActor final class DropdownWindowManager: ObservableObject { - private var dropdownWindow: NSWindow? - private let dropdownWidth: CGFloat = 280 - private let maxDropdownHeight: CGFloat = 400 - - func showDropdown( - relativeTo button: NSView, - viewModel: AppSelectionViewModel, - onAppSelected: @escaping (SelectableApp) -> Void, - onClearSelection: @escaping () -> Void, - onDismiss: @escaping () -> Void - ) { - hideDropdown() - - let contentView = AppSelectionDropdown( - viewModel: viewModel, - onAppSelected: { app in - onAppSelected(app) - self.hideDropdown() - }, - onClearSelection: { - onClearSelection() - self.hideDropdown() - } - ) - - let actualHeight = calculateDropdownHeight( - meetingApps: viewModel.meetingApps, - otherApps: viewModel.otherApps - ) - - let hostingController = NSHostingController(rootView: contentView) - hostingController.view.wantsLayer = true - - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: dropdownWidth, height: actualHeight), - styleMask: [.borderless], - backing: .buffered, - defer: false - ) - - hostingController.view.frame = NSRect(x: 0, y: 0, width: dropdownWidth, height: actualHeight) - - window.contentViewController = hostingController - window.backgroundColor = .clear - window.isOpaque = false - window.hasShadow = true - window.level = .floating - window.isReleasedWhenClosed = false - - positionDropdown(window: window, relativeTo: button) - - window.orderFront(nil) - dropdownWindow = window - - animateDropdownIn(window: window) - setupOutsideClickDetection(onDismiss: onDismiss) + private var dropdownWindow: NSWindow? + private let dropdownWidth: CGFloat = 280 + private let maxDropdownHeight: CGFloat = 400 + + func showDropdown( + relativeTo button: NSView, + viewModel: AppSelectionViewModel, + onAppSelected: @escaping (SelectableApp) -> Void, + onClearSelection: @escaping () -> Void, + onDismiss: @escaping () -> Void + ) { + hideDropdown() + + let contentView = AppSelectionDropdown( + viewModel: viewModel, + onAppSelected: { app in + onAppSelected(app) + self.hideDropdown() + }, + onClearSelection: { + onClearSelection() + self.hideDropdown() + } + ) + + let actualHeight = calculateDropdownHeight( + meetingApps: viewModel.meetingApps, + otherApps: viewModel.otherApps + ) + + let hostingController = NSHostingController(rootView: contentView) + hostingController.view.wantsLayer = true + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: dropdownWidth, height: actualHeight), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + + hostingController.view.frame = NSRect(x: 0, y: 0, width: dropdownWidth, height: actualHeight) + + window.contentViewController = hostingController + window.backgroundColor = .clear + window.isOpaque = false + window.hasShadow = true + window.level = .floating + window.isReleasedWhenClosed = false + + positionDropdown(window: window, relativeTo: button) + + window.orderFront(nil) + dropdownWindow = window + + animateDropdownIn(window: window) + setupOutsideClickDetection(onDismiss: onDismiss) + } + + func hideDropdown() { + guard let window = dropdownWindow else { return } + + animateDropdownOut(window: window) { + Task { @MainActor in + window.orderOut(nil) + self.dropdownWindow = nil + } + } + + if let monitor = globalMonitor { + NSEvent.removeMonitor(monitor) + globalMonitor = nil } - - func hideDropdown() { - guard let window = dropdownWindow else { return } - - animateDropdownOut(window: window) { - window.orderOut(nil) - self.dropdownWindow = nil - } - - if let monitor = globalMonitor { - NSEvent.removeMonitor(monitor) - globalMonitor = nil - } + } + + private var globalMonitor: Any? + + private func animateDropdownIn(window: NSWindow) { + window.alphaValue = 0 + window.setFrame( + window.frame.offsetBy(dx: -20, dy: 0), + display: false + ) + + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.25 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + window.animator().alphaValue = 1 + window.animator().setFrame( + window.frame.offsetBy(dx: 20, dy: 0), + display: true + ) } - - private var globalMonitor: Any? - - private func animateDropdownIn(window: NSWindow) { - window.alphaValue = 0 - window.setFrame( - window.frame.offsetBy(dx: -20, dy: 0), - display: false + } + + private func animateDropdownOut(window: NSWindow, completion: @Sendable @escaping () -> Void) { + NSAnimationContext.runAnimationGroup( + { context in + context.duration = 0.2 + context.timingFunction = CAMediaTimingFunction(name: .easeIn) + window.animator().alphaValue = 0 + window.animator().setFrame( + window.frame.offsetBy(dx: -15, dy: 0), + display: true ) - - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.25 - context.timingFunction = CAMediaTimingFunction(name: .easeOut) - window.animator().alphaValue = 1 - window.animator().setFrame( - window.frame.offsetBy(dx: 20, dy: 0), - display: true - ) - } - } - - private func animateDropdownOut(window: NSWindow, completion: @escaping () -> Void) { - NSAnimationContext.runAnimationGroup({ context in - context.duration = 0.2 - context.timingFunction = CAMediaTimingFunction(name: .easeIn) - window.animator().alphaValue = 0 - window.animator().setFrame( - window.frame.offsetBy(dx: -15, dy: 0), - display: true - ) - }, completionHandler: completion) + }, completionHandler: completion) + } + + private func setupOutsideClickDetection(onDismiss: @escaping () -> Void) { + globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { _ in + onDismiss() + self.hideDropdown() } - - private func setupOutsideClickDetection(onDismiss: @escaping () -> Void) { - globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { _ in - onDismiss() - self.hideDropdown() - } + } + + private func positionDropdown(window: NSWindow, relativeTo button: NSView) { + guard let buttonWindow = button.window else { return } + + let buttonFrame = button.convert(button.bounds, to: nil) + let buttonScreenFrame = buttonWindow.convertToScreen(buttonFrame) + + let spacing: CGFloat = 50 + let dropdownX = buttonScreenFrame.minX - dropdownWidth - spacing + let dropdownY = buttonScreenFrame.minY + + window.setFrameOrigin(NSPoint(x: dropdownX, y: dropdownY)) + } + + private func calculateDropdownHeight( + meetingApps: [SelectableApp], + otherApps: [SelectableApp] + ) -> CGFloat { + let rowHeight: CGFloat = 32 + let sectionHeaderHeight: CGFloat = 28 + let dividerHeight: CGFloat = 17 + let clearSelectionRowHeight: CGFloat = 32 + let verticalPadding: CGFloat = 24 + + var totalHeight = verticalPadding + + if !meetingApps.isEmpty { + totalHeight += sectionHeaderHeight + totalHeight += CGFloat(meetingApps.count) * rowHeight + + if !otherApps.isEmpty { + totalHeight += dividerHeight + } } - - private func positionDropdown(window: NSWindow, relativeTo button: NSView) { - guard let buttonWindow = button.window else { return } - - let buttonFrame = button.convert(button.bounds, to: nil) - let buttonScreenFrame = buttonWindow.convertToScreen(buttonFrame) - - let spacing: CGFloat = 50 - let dropdownX = buttonScreenFrame.minX - dropdownWidth - spacing - let dropdownY = buttonScreenFrame.minY - - window.setFrameOrigin(NSPoint(x: dropdownX, y: dropdownY)) + + if !otherApps.isEmpty { + totalHeight += sectionHeaderHeight + totalHeight += CGFloat(otherApps.count) * rowHeight } - - private func calculateDropdownHeight( - meetingApps: [SelectableApp], - otherApps: [SelectableApp] - ) -> CGFloat { - let rowHeight: CGFloat = 32 - let sectionHeaderHeight: CGFloat = 28 - let dividerHeight: CGFloat = 17 - let clearSelectionRowHeight: CGFloat = 32 - let verticalPadding: CGFloat = 24 - - var totalHeight = verticalPadding - - if !meetingApps.isEmpty { - totalHeight += sectionHeaderHeight - totalHeight += CGFloat(meetingApps.count) * rowHeight - - if !otherApps.isEmpty { - totalHeight += dividerHeight - } - } - - if !otherApps.isEmpty { - totalHeight += sectionHeaderHeight - totalHeight += CGFloat(otherApps.count) * rowHeight - } - - if !meetingApps.isEmpty || !otherApps.isEmpty { - totalHeight += dividerHeight - totalHeight += clearSelectionRowHeight - } - - return min(totalHeight, maxDropdownHeight) + + if !meetingApps.isEmpty || !otherApps.isEmpty { + totalHeight += dividerHeight + totalHeight += clearSelectionRowHeight } + + return min(totalHeight, maxDropdownHeight) + } } diff --git a/Recap/MenuBar/Dropdowns/RecapsWindowManager.swift b/Recap/MenuBar/Dropdowns/RecapsWindowManager.swift index 91ae220..de1f24f 100644 --- a/Recap/MenuBar/Dropdowns/RecapsWindowManager.swift +++ b/Recap/MenuBar/Dropdowns/RecapsWindowManager.swift @@ -1,95 +1,96 @@ -import SwiftUI import AppKit +import SwiftUI @MainActor final class RecapsWindowManager: ObservableObject { - private var recapsWindow: NSPanel? - private let windowWidth: CGFloat = 380 - private let windowHeight: CGFloat = 500 - - func showRecapsWindow( - relativeTo button: NSView, - viewModel: PreviousRecapsViewModel, - onRecordingSelected: @escaping (RecordingInfo) -> Void, - onDismiss: @escaping () -> Void - ) { - hideRecapsWindow() - - let contentView = PreviousRecapsDropdown( - viewModel: viewModel, - onRecordingSelected: { recording in - onRecordingSelected(recording) - self.hideRecapsWindow() - }, - onClose: { [weak self] in - onDismiss() - self?.hideRecapsWindow() - } - ) - - let hostingController = NSHostingController(rootView: contentView) - hostingController.view.wantsLayer = true - - let window = NSPanel( - contentRect: NSRect(x: 0, y: 0, width: windowWidth, height: windowHeight), - styleMask: [.borderless, .nonactivatingPanel], - backing: .buffered, - defer: false - ) - - hostingController.view.frame = NSRect(x: 0, y: 0, width: windowWidth, height: windowHeight) - - window.contentViewController = hostingController - window.backgroundColor = .clear - window.isOpaque = false - window.hasShadow = true - window.level = .floating - window.isReleasedWhenClosed = false - - positionRecapsWindow(window: window, relativeTo: button) - - recapsWindow = window - - PanelAnimator.slideIn(panel: window) - setupOutsideClickDetection(onDismiss: onDismiss) - } - - func hideRecapsWindow() { - guard let window = recapsWindow else { return } - - PanelAnimator.slideOut(panel: window) { [weak self] in - self?.recapsWindow = nil - } - - if let monitor = globalMonitor { - NSEvent.removeMonitor(monitor) - globalMonitor = nil - } + private var recapsWindow: NSPanel? + private let windowWidth: CGFloat = 380 + private let windowHeight: CGFloat = 500 + + func showRecapsWindow( + relativeTo button: NSView, + viewModel: PreviousRecapsViewModel, + onRecordingSelected: @escaping (RecordingInfo) -> Void, + onDismiss: @escaping () -> Void + ) { + hideRecapsWindow() + + let contentView = PreviousRecapsDropdown( + viewModel: viewModel, + onRecordingSelected: { recording in + onRecordingSelected(recording) + self.hideRecapsWindow() + }, + onClose: { [weak self] in + onDismiss() + self?.hideRecapsWindow() + } + ) + + let hostingController = NSHostingController(rootView: contentView) + hostingController.view.wantsLayer = true + + let window = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: windowWidth, height: windowHeight), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + hostingController.view.frame = NSRect(x: 0, y: 0, width: windowWidth, height: windowHeight) + + window.contentViewController = hostingController + window.backgroundColor = .clear + window.isOpaque = false + window.hasShadow = true + window.level = .floating + window.isReleasedWhenClosed = false + + positionRecapsWindow(window: window, relativeTo: button) + + recapsWindow = window + + PanelAnimator.slideIn(panel: window) + setupOutsideClickDetection(onDismiss: onDismiss) + } + + func hideRecapsWindow() { + guard let window = recapsWindow else { return } + + PanelAnimator.slideOut(panel: window) { [weak self] in + self?.recapsWindow = nil } - - private var globalMonitor: Any? - - private func setupOutsideClickDetection(onDismiss: @escaping () -> Void) { - globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { _ in - onDismiss() - self.hideRecapsWindow() - } + + if let monitor = globalMonitor { + NSEvent.removeMonitor(monitor) + globalMonitor = nil } - - private func positionRecapsWindow(window: NSPanel, relativeTo button: NSView) { - guard let buttonWindow = button.window, - let screen = buttonWindow.screen else { return } - - let screenFrame = screen.frame - - let menuBarHeight: CGFloat = 24 - let panelOffset: CGFloat = 12 - let panelSpacing: CGFloat = 8 - let mainPanelWidth: CGFloat = 485 - - let recapsX = screenFrame.maxX - mainPanelWidth - windowWidth - (panelOffset * 2) - panelSpacing - let recapsY = screenFrame.maxY - menuBarHeight - windowHeight - panelSpacing - - window.setFrameOrigin(NSPoint(x: recapsX, y: recapsY)) + } + + private var globalMonitor: Any? + + private func setupOutsideClickDetection(onDismiss: @escaping () -> Void) { + globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { _ in + onDismiss() + self.hideRecapsWindow() } -} \ No newline at end of file + } + + private func positionRecapsWindow(window: NSPanel, relativeTo button: NSView) { + guard let buttonWindow = button.window, + let screen = buttonWindow.screen + else { return } + + let screenFrame = screen.frame + + let menuBarHeight: CGFloat = 24 + let panelOffset: CGFloat = 12 + let panelSpacing: CGFloat = 8 + let mainPanelWidth: CGFloat = 485 + + let recapsX = screenFrame.maxX - mainPanelWidth - windowWidth - (panelOffset * 2) - panelSpacing + let recapsY = screenFrame.maxY - menuBarHeight - windowHeight - panelSpacing + + window.setFrameOrigin(NSPoint(x: recapsX, y: recapsY)) + } +} diff --git a/Recap/MenuBar/Manager/MenuBarPanelManager+Delegates.swift b/Recap/MenuBar/Manager/MenuBarPanelManager+Delegates.swift index bd52227..bb7b91a 100644 --- a/Recap/MenuBar/Manager/MenuBarPanelManager+Delegates.swift +++ b/Recap/MenuBar/Manager/MenuBarPanelManager+Delegates.swift @@ -1,41 +1,41 @@ -import SwiftUI import AppKit +import SwiftUI extension MenuBarPanelManager: OnboardingDelegate { - func onboardingDidComplete() { - Task { - await transitionFromOnboardingToMain() - } - } - - private func transitionFromOnboardingToMain() async { - guard let currentPanel = panel else { return } - - await slideOutCurrentPanel(currentPanel) - await createAndShowMainPanel() + func onboardingDidComplete() { + Task { + await transitionFromOnboardingToMain() } - - private func slideOutCurrentPanel(_ currentPanel: SlidingPanel) async { - await withCheckedContinuation { continuation in - PanelAnimator.slideOut(panel: currentPanel) { [weak self] in - self?.panel = nil - self?.isVisible = false - continuation.resume() - } - } + } + + private func transitionFromOnboardingToMain() async { + guard let currentPanel = panel else { return } + + await slideOutCurrentPanel(currentPanel) + await createAndShowMainPanel() + } + + private func slideOutCurrentPanel(_ currentPanel: SlidingPanel) async { + await withCheckedContinuation { continuation in + PanelAnimator.slideOut(panel: currentPanel) { [weak self] in + self?.panel = nil + self?.isVisible = false + continuation.resume() + } } - - private func createAndShowMainPanel() async { - panel = createMainPanel() - guard let newPanel = panel else { return } - - positionPanel(newPanel) - - await withCheckedContinuation { continuation in - PanelAnimator.slideIn(panel: newPanel) { [weak self] in - self?.isVisible = true - continuation.resume() - } - } + } + + private func createAndShowMainPanel() async { + panel = createMainPanel() + guard let newPanel = panel else { return } + + positionPanel(newPanel) + + await withCheckedContinuation { continuation in + PanelAnimator.slideIn(panel: newPanel) { [weak self] in + self?.isVisible = true + continuation.resume() + } } + } } diff --git a/Recap/MenuBar/Manager/MenuBarPanelManager+DragDrop.swift b/Recap/MenuBar/Manager/MenuBarPanelManager+DragDrop.swift new file mode 100644 index 0000000..dff52d7 --- /dev/null +++ b/Recap/MenuBar/Manager/MenuBarPanelManager+DragDrop.swift @@ -0,0 +1,61 @@ +import AppKit +import SwiftUI + +extension MenuBarPanelManager { + func createDragDropPanel() -> SlidingPanel? { + let contentView = DragDropView( + viewModel: dragDropViewModel + ) { [weak self] in + self?.hideDragDropPanel() + } + let hostingController = NSHostingController(rootView: contentView) + hostingController.view.wantsLayer = true + hostingController.view.layer?.cornerRadius = 12 + + let newPanel = SlidingPanel( + contentViewController: hostingController, + shouldCloseOnOutsideClick: false + ) + newPanel.panelDelegate = self + return newPanel + } + + func positionDragDropPanel(_ panel: NSPanel) { + guard let statusButton = statusBarManager.statusButton, + let statusWindow = statusButton.window, + let screen = statusWindow.screen + else { return } + + let screenFrame = screen.frame + let dragDropX = screenFrame.maxX - (initialSize.width * 2) - (panelOffset * 2) - panelSpacing + let panelY = screenFrame.maxY - menuBarHeight - initialSize.height - panelSpacing + + panel.setFrame( + NSRect(x: dragDropX, y: panelY, width: initialSize.width, height: initialSize.height), + display: false + ) + } + + func showDragDropPanel() { + if dragDropPanel == nil { + dragDropPanel = createDragDropPanel() + } + + guard let dragDropPanel = dragDropPanel else { return } + + positionDragDropPanel(dragDropPanel) + dragDropPanel.contentView?.wantsLayer = true + + PanelAnimator.slideIn(panel: dragDropPanel) { [weak self] in + self?.isDragDropVisible = true + } + } + + func hideDragDropPanel() { + guard let dragDropPanel = dragDropPanel else { return } + + PanelAnimator.slideOut(panel: dragDropPanel) { [weak self] in + self?.isDragDropVisible = false + } + } +} diff --git a/Recap/MenuBar/Manager/MenuBarPanelManager+Onboarding.swift b/Recap/MenuBar/Manager/MenuBarPanelManager+Onboarding.swift index 4bb27a6..2b0ceb2 100644 --- a/Recap/MenuBar/Manager/MenuBarPanelManager+Onboarding.swift +++ b/Recap/MenuBar/Manager/MenuBarPanelManager+Onboarding.swift @@ -1,17 +1,17 @@ -import SwiftUI import AppKit +import SwiftUI extension MenuBarPanelManager { - @MainActor - func createOnboardingPanel() -> SlidingPanel { - onboardingViewModel.delegate = self - let contentView = OnboardingView(viewModel: onboardingViewModel) - let hostingController = NSHostingController(rootView: contentView) - hostingController.view.wantsLayer = true - hostingController.view.layer?.cornerRadius = 12 - - let newPanel = SlidingPanel(contentViewController: hostingController) - newPanel.panelDelegate = self - return newPanel - } -} \ No newline at end of file + @MainActor + func createOnboardingPanel() -> SlidingPanel { + onboardingViewModel.delegate = self + let contentView = OnboardingView(viewModel: onboardingViewModel) + let hostingController = NSHostingController(rootView: contentView) + hostingController.view.wantsLayer = true + hostingController.view.layer?.cornerRadius = 12 + + let newPanel = SlidingPanel(contentViewController: hostingController) + newPanel.panelDelegate = self + return newPanel + } +} diff --git a/Recap/MenuBar/Manager/MenuBarPanelManager+PreviousRecaps.swift b/Recap/MenuBar/Manager/MenuBarPanelManager+PreviousRecaps.swift index e7eed5b..9df800a 100644 --- a/Recap/MenuBar/Manager/MenuBarPanelManager+PreviousRecaps.swift +++ b/Recap/MenuBar/Manager/MenuBarPanelManager+PreviousRecaps.swift @@ -1,51 +1,52 @@ -import SwiftUI import AppKit +import SwiftUI extension MenuBarPanelManager { - func showPreviousRecapsWindow() { - if previousRecapsWindowManager == nil { - previousRecapsWindowManager = RecapsWindowManager() - } - - guard let statusButton = statusBarManager.statusButton, - let windowManager = previousRecapsWindowManager else { return } - - windowManager.showRecapsWindow( - relativeTo: statusButton, - viewModel: previousRecapsViewModel, - onRecordingSelected: { [weak self] recording in - self?.handleRecordingSelection(recording) - }, - onDismiss: { [weak self] in - self?.isPreviousRecapsVisible = false - } - ) - - isPreviousRecapsVisible = true - } - - func hidePreviousRecapsWindow() { - previousRecapsWindowManager?.hideRecapsWindow() - isPreviousRecapsVisible = false - } - - private func handleRecordingSelection(_ recording: RecordingInfo) { - hidePreviousRecapsWindow() - - summaryPanel?.close() - summaryPanel = nil - - showSummaryPanel(recordingID: recording.id) + func showPreviousRecapsWindow() { + if previousRecapsWindowManager == nil { + previousRecapsWindowManager = RecapsWindowManager() } + + guard let statusButton = statusBarManager.statusButton, + let windowManager = previousRecapsWindowManager + else { return } + + windowManager.showRecapsWindow( + relativeTo: statusButton, + viewModel: previousRecapsViewModel, + onRecordingSelected: { [weak self] recording in + self?.handleRecordingSelection(recording) + }, + onDismiss: { [weak self] in + self?.isPreviousRecapsVisible = false + } + ) + + isPreviousRecapsVisible = true + } + + func hidePreviousRecapsWindow() { + previousRecapsWindowManager?.hideRecapsWindow() + isPreviousRecapsVisible = false + } + + private func handleRecordingSelection(_ recording: RecordingInfo) { + hidePreviousRecapsWindow() + + summaryPanel?.close() + summaryPanel = nil + + showSummaryPanel(recordingID: recording.id) + } } extension MenuBarPanelManager { - func hideOtherPanels() { - if isSettingsVisible { - hideSettingsPanel() - } - if isSummaryVisible { - hideSummaryPanel() - } + func hideOtherPanels() { + if isSettingsVisible { + hideSettingsPanel() + } + if isSummaryVisible { + hideSummaryPanel() } -} \ No newline at end of file + } +} diff --git a/Recap/MenuBar/Manager/MenuBarPanelManager+Recaps.swift b/Recap/MenuBar/Manager/MenuBarPanelManager+Recaps.swift new file mode 100644 index 0000000..d586686 --- /dev/null +++ b/Recap/MenuBar/Manager/MenuBarPanelManager+Recaps.swift @@ -0,0 +1,71 @@ +import AppKit +import SwiftUI + +extension MenuBarPanelManager { + func createRecapsPanel() -> SlidingPanel? { + let contentView = PreviousRecapsDropdown( + viewModel: previousRecapsViewModel, + onRecordingSelected: { [weak self] recording in + self?.handleRecordingSelection(recording) + }, + onClose: { [weak self] in + self?.hideRecapsPanel() + } + ) + let hostingController = NSHostingController(rootView: contentView) + hostingController.view.wantsLayer = true + hostingController.view.layer?.cornerRadius = 12 + + let newPanel = SlidingPanel(contentViewController: hostingController) + newPanel.panelDelegate = self + return newPanel + } + + func positionRecapsPanel(_ panel: NSPanel) { + guard let statusButton = statusBarManager.statusButton, + let statusWindow = statusButton.window, + let screen = statusWindow.screen + else { return } + + let screenFrame = screen.frame + let recapsX = screenFrame.maxX - initialSize.width - panelOffset + let panelY = screenFrame.maxY - menuBarHeight - initialSize.height - panelSpacing + + panel.setFrame( + NSRect(x: recapsX, y: panelY, width: initialSize.width, height: initialSize.height), + display: false + ) + } + + func showRecapsPanel() { + if recapsPanel == nil { + recapsPanel = createRecapsPanel() + } + + guard let recapsPanel = recapsPanel else { return } + + positionRecapsPanel(recapsPanel) + recapsPanel.contentView?.wantsLayer = true + + PanelAnimator.slideIn(panel: recapsPanel) { [weak self] in + self?.isRecapsVisible = true + } + } + + func hideRecapsPanel() { + guard let recapsPanel = recapsPanel else { return } + + PanelAnimator.slideOut(panel: recapsPanel) { [weak self] in + self?.isRecapsVisible = false + } + } + + private func handleRecordingSelection(_ recording: RecordingInfo) { + hideRecapsPanel() + + summaryPanel?.close() + summaryPanel = nil + + showSummaryPanel(recordingID: recording.id) + } +} diff --git a/Recap/MenuBar/Manager/MenuBarPanelManager+Settings.swift b/Recap/MenuBar/Manager/MenuBarPanelManager+Settings.swift index 4117701..f99ad75 100644 --- a/Recap/MenuBar/Manager/MenuBarPanelManager+Settings.swift +++ b/Recap/MenuBar/Manager/MenuBarPanelManager+Settings.swift @@ -1,86 +1,96 @@ -import SwiftUI import AppKit +import SwiftUI extension MenuBarPanelManager { - func createSettingsPanel() -> SlidingPanel? { - let contentView = SettingsView( - whisperModelsViewModel: whisperModelsViewModel, - generalSettingsViewModel: generalSettingsViewModel, - meetingDetectionService: meetingDetectionService, - userPreferencesRepository: userPreferencesRepository - ) { [weak self] in - self?.hideSettingsPanel() - } - let hostingController = NSHostingController(rootView: contentView) - hostingController.view.wantsLayer = true - hostingController.view.layer?.cornerRadius = 12 - - let newPanel = SlidingPanel(contentViewController: hostingController) - newPanel.panelDelegate = self - return newPanel + func createSettingsPanel() -> SlidingPanel? { + let contentView = SettingsView( + whisperModelsViewModel: whisperModelsViewModel, + generalSettingsViewModel: generalSettingsViewModel, + meetingDetectionService: meetingDetectionService, + userPreferencesRepository: userPreferencesRepository, + recapViewModel: recapViewModel + ) { [weak self] in + self?.hideSettingsPanel() } - - func positionSettingsPanel(_ panel: NSPanel) { - guard let statusButton = statusBarManager.statusButton, - let statusWindow = statusButton.window, - let screen = statusWindow.screen else { return } - - let screenFrame = screen.frame - let settingsX = screenFrame.maxX - (initialSize.width * 2) - (panelOffset * 2) - panelSpacing - let panelY = screenFrame.maxY - menuBarHeight - initialSize.height - panelSpacing - - panel.setFrame( - NSRect(x: settingsX, y: panelY, width: initialSize.width, height: initialSize.height), - display: false - ) + let hostingController = NSHostingController(rootView: contentView) + hostingController.view.wantsLayer = true + hostingController.view.layer?.cornerRadius = 12 + + let newPanel = SlidingPanel(contentViewController: hostingController) + newPanel.panelDelegate = self + return newPanel + } + + func positionSettingsPanel(_ panel: NSPanel) { + guard let statusButton = statusBarManager.statusButton, + let statusWindow = statusButton.window, + let screen = statusWindow.screen + else { return } + + let screenFrame = screen.frame + let settingsX = screenFrame.maxX - (initialSize.width * 2) - (panelOffset * 2) - panelSpacing + let panelY = screenFrame.maxY - menuBarHeight - initialSize.height - panelSpacing + + panel.setFrame( + NSRect(x: settingsX, y: panelY, width: initialSize.width, height: initialSize.height), + display: false + ) + } + + func showSettingsPanel() { + if settingsPanel == nil { + settingsPanel = createSettingsPanel() } - - func showSettingsPanel() { - if settingsPanel == nil { - settingsPanel = createSettingsPanel() - } - - guard let settingsPanel = settingsPanel else { return } - - positionSettingsPanel(settingsPanel) - settingsPanel.contentView?.wantsLayer = true - - PanelAnimator.slideIn(panel: settingsPanel) { [weak self] in - self?.isSettingsVisible = true - } + + guard let settingsPanel = settingsPanel else { return } + + positionSettingsPanel(settingsPanel) + settingsPanel.contentView?.wantsLayer = true + + PanelAnimator.slideIn(panel: settingsPanel) { [weak self] in + self?.isSettingsVisible = true } - - func hideSettingsPanel() { - guard let settingsPanel = settingsPanel else { return } - - PanelAnimator.slideOut(panel: settingsPanel) { [weak self] in - self?.isSettingsVisible = false - } + } + + func hideSettingsPanel() { + guard let settingsPanel = settingsPanel else { return } + + PanelAnimator.slideOut(panel: settingsPanel) { [weak self] in + self?.isSettingsVisible = false } + } } extension MenuBarPanelManager: RecapViewModelDelegate { - func didRequestSettingsOpen() { - toggleSidePanel( - isVisible: isSettingsVisible, - show: showSettingsPanel, - hide: hideSettingsPanel - ) - } - - func didRequestViewOpen() { - toggleSidePanel( - isVisible: isSummaryVisible, - show: { showSummaryPanel() }, - hide: hideSummaryPanel - ) - } - - func didRequestPreviousRecapsOpen() { - toggleSidePanel( - isVisible: isPreviousRecapsVisible, - show: showPreviousRecapsWindow, - hide: hidePreviousRecapsWindow - ) + func didRequestSettingsOpen() { + // Hide main panel and show only settings panel + if isVisible { + hidePanel() } + toggleSidePanel( + isVisible: isSettingsVisible, + show: showSettingsPanel, + hide: hideSettingsPanel + ) + } + + func didRequestViewOpen() { + toggleSidePanel( + isVisible: isSummaryVisible, + show: { showSummaryPanel() }, + hide: hideSummaryPanel + ) + } + + func didRequestPreviousRecapsOpen() { + toggleSidePanel( + isVisible: isPreviousRecapsVisible, + show: showPreviousRecapsWindow, + hide: hidePreviousRecapsWindow + ) + } + + func didRequestPanelClose() { + hideMainPanel() + } } diff --git a/Recap/MenuBar/Manager/MenuBarPanelManager+Summary.swift b/Recap/MenuBar/Manager/MenuBarPanelManager+Summary.swift index 284546a..88ec51e 100644 --- a/Recap/MenuBar/Manager/MenuBarPanelManager+Summary.swift +++ b/Recap/MenuBar/Manager/MenuBarPanelManager+Summary.swift @@ -1,60 +1,62 @@ -import SwiftUI import AppKit +import SwiftUI extension MenuBarPanelManager { - func createSummaryPanel(recordingID: String? = nil) -> SlidingPanel? { - let contentView = SummaryView( - onClose: { [weak self] in - self?.hideSummaryPanel() - }, - viewModel: summaryViewModel, - recordingID: recordingID - ) - let hostingController = NSHostingController(rootView: contentView) - hostingController.view.wantsLayer = true - hostingController.view.layer?.cornerRadius = 12 - - let newPanel = SlidingPanel(contentViewController: hostingController) - newPanel.panelDelegate = self - return newPanel - } - - func positionSummaryPanel(_ panel: NSPanel) { - guard let statusButton = statusBarManager.statusButton, - let statusWindow = statusButton.window, - let screen = statusWindow.screen else { return } - - let screenFrame = screen.frame - let summaryWidth: CGFloat = 600 - let summaryX = screenFrame.maxX - initialSize.width - summaryWidth - (panelOffset * 2) - panelSpacing - let panelY = screenFrame.maxY - menuBarHeight - initialSize.height - panelSpacing - - panel.setFrame( - NSRect(x: summaryX, y: panelY, width: summaryWidth, height: initialSize.height), - display: false - ) + func createSummaryPanel(recordingID: String? = nil) -> SlidingPanel? { + let contentView = SummaryView( + onClose: { [weak self] in + self?.hideSummaryPanel() + }, + viewModel: summaryViewModel, + recordingID: recordingID + ) + let hostingController = NSHostingController(rootView: contentView) + hostingController.view.wantsLayer = true + hostingController.view.layer?.cornerRadius = 12 + + let newPanel = SlidingPanel(contentViewController: hostingController) + newPanel.panelDelegate = self + return newPanel + } + + func positionSummaryPanel(_ panel: NSPanel) { + guard let statusButton = statusBarManager.statusButton, + let statusWindow = statusButton.window, + let screen = statusWindow.screen + else { return } + + let screenFrame = screen.frame + let summaryWidth: CGFloat = 600 + let summaryX = + screenFrame.maxX - initialSize.width - summaryWidth - (panelOffset * 2) - panelSpacing + let panelY = screenFrame.maxY - menuBarHeight - initialSize.height - panelSpacing + + panel.setFrame( + NSRect(x: summaryX, y: panelY, width: summaryWidth, height: initialSize.height), + display: false + ) + } + + func showSummaryPanel(recordingID: String? = nil) { + if summaryPanel == nil { + summaryPanel = createSummaryPanel(recordingID: recordingID) } - - func showSummaryPanel(recordingID: String? = nil) { - if summaryPanel == nil { - summaryPanel = createSummaryPanel(recordingID: recordingID) - } - - guard let summaryPanel = summaryPanel else { return } - - positionSummaryPanel(summaryPanel) - summaryPanel.contentView?.wantsLayer = true - - PanelAnimator.slideIn(panel: summaryPanel) { [weak self] in - self?.isSummaryVisible = true - } + + guard let summaryPanel = summaryPanel else { return } + + positionSummaryPanel(summaryPanel) + summaryPanel.contentView?.wantsLayer = true + + PanelAnimator.slideIn(panel: summaryPanel) { [weak self] in + self?.isSummaryVisible = true } - - func hideSummaryPanel() { - guard let summaryPanel = summaryPanel else { return } - - PanelAnimator.slideOut(panel: summaryPanel) { [weak self] in - self?.isSummaryVisible = false - } + } + + func hideSummaryPanel() { + guard let summaryPanel = summaryPanel else { return } + + PanelAnimator.slideOut(panel: summaryPanel) { [weak self] in + self?.isSummaryVisible = false } -} \ No newline at end of file + } +} diff --git a/Recap/MenuBar/Manager/MenuBarPanelManager.swift b/Recap/MenuBar/Manager/MenuBarPanelManager.swift index 2e1b7d3..b32ca76 100644 --- a/Recap/MenuBar/Manager/MenuBarPanelManager.swift +++ b/Recap/MenuBar/Manager/MenuBarPanelManager.swift @@ -1,214 +1,302 @@ -import SwiftUI import AppKit +import Combine +import OSLog +import SwiftUI @MainActor final class MenuBarPanelManager: MenuBarPanelManagerType, ObservableObject { - var statusBarManager: StatusBarManagerType - var panel: SlidingPanel? - - var settingsPanel: SlidingPanel? - var summaryPanel: SlidingPanel? - var previousRecapsWindowManager: RecapsWindowManager? - - var isVisible = false - var isSettingsVisible = false - var isSummaryVisible = false - var isPreviousRecapsVisible = false - - let initialSize = CGSize(width: 485, height: 500) - let menuBarHeight: CGFloat = 24 - let panelOffset: CGFloat = 12 - let panelSpacing: CGFloat = 8 - - let audioProcessController: AudioProcessController - let appSelectionViewModel: AppSelectionViewModel - let previousRecapsViewModel: PreviousRecapsViewModel - let whisperModelsViewModel: WhisperModelsViewModel - let recapViewModel: RecapViewModel - let onboardingViewModel: OnboardingViewModel - let summaryViewModel: SummaryViewModel - let generalSettingsViewModel: GeneralSettingsViewModel - let userPreferencesRepository: UserPreferencesRepositoryType - let meetingDetectionService: any MeetingDetectionServiceType - - init( - statusBarManager: StatusBarManagerType, - whisperModelsViewModel: WhisperModelsViewModel, - coreDataManager: CoreDataManagerType, - audioProcessController: AudioProcessController, - appSelectionViewModel: AppSelectionViewModel, - previousRecapsViewModel: PreviousRecapsViewModel, - recapViewModel: RecapViewModel, - onboardingViewModel: OnboardingViewModel, - summaryViewModel: SummaryViewModel, - generalSettingsViewModel: GeneralSettingsViewModel, - userPreferencesRepository: UserPreferencesRepositoryType, - meetingDetectionService: any MeetingDetectionServiceType - ) { - self.statusBarManager = statusBarManager - self.audioProcessController = audioProcessController - self.appSelectionViewModel = appSelectionViewModel - self.whisperModelsViewModel = whisperModelsViewModel - self.recapViewModel = recapViewModel - self.onboardingViewModel = onboardingViewModel - self.summaryViewModel = summaryViewModel - self.generalSettingsViewModel = generalSettingsViewModel - self.userPreferencesRepository = userPreferencesRepository - self.meetingDetectionService = meetingDetectionService - self.previousRecapsViewModel = previousRecapsViewModel - setupDelegates() - } - - private func setupDelegates() { - statusBarManager.delegate = self - } - - func createMainPanel() -> SlidingPanel { - recapViewModel.delegate = self - let contentView = RecapHomeView(viewModel: recapViewModel) - let hostingController = NSHostingController(rootView: contentView) - hostingController.view.wantsLayer = true - hostingController.view.layer?.cornerRadius = 12 - - let newPanel = SlidingPanel(contentViewController: hostingController) - newPanel.panelDelegate = self - return newPanel - } - - func positionPanel(_ panel: NSPanel, size: CGSize? = nil) { - guard let statusButton = statusBarManager.statusButton, - let statusWindow = statusButton.window, - let screen = statusWindow.screen else { return } - - let panelSize = size ?? initialSize - let screenFrame = screen.frame - let finalX = screenFrame.maxX - panelSize.width - panelOffset - let panelY = screenFrame.maxY - menuBarHeight - panelSize.height - panelSpacing - - panel.setFrame( - NSRect(x: finalX, y: panelY, width: panelSize.width, height: panelSize.height), - display: false - ) - } - - private func showPanel() { - if panel == nil { - createAndShowNewPanel() - } else { - showExistingPanel() - } + var statusBarManager: StatusBarManagerType + var panel: SlidingPanel? + + var settingsPanel: SlidingPanel? + var summaryPanel: SlidingPanel? + var recapsPanel: SlidingPanel? + var dragDropPanel: SlidingPanel? + var previousRecapsWindowManager: RecapsWindowManager? + + var isVisible = false + var isSettingsVisible = false + var isSummaryVisible = false + var isRecapsVisible = false + var isDragDropVisible = false + var isPreviousRecapsVisible = false + + let initialSize = CGSize(width: 485, height: 500) + let menuBarHeight: CGFloat = 24 + let panelOffset: CGFloat = 12 + let panelSpacing: CGFloat = 8 + + private var cancellables = Set() + + let audioProcessController: AudioProcessController + let appSelectionViewModel: AppSelectionViewModel + let previousRecapsViewModel: PreviousRecapsViewModel + let whisperModelsViewModel: WhisperModelsViewModel + let recapViewModel: RecapViewModel + let onboardingViewModel: OnboardingViewModel + let summaryViewModel: SummaryViewModel + let generalSettingsViewModel: GeneralSettingsViewModel + let dragDropViewModel: DragDropViewModel + let userPreferencesRepository: UserPreferencesRepositoryType + let meetingDetectionService: any MeetingDetectionServiceType + private let logger = Logger( + subsystem: AppConstants.Logging.subsystem, + category: String(describing: MenuBarPanelManager.self)) + + init( + statusBarManager: StatusBarManagerType, + whisperModelsViewModel: WhisperModelsViewModel, + coreDataManager: CoreDataManagerType, + audioProcessController: AudioProcessController, + appSelectionViewModel: AppSelectionViewModel, + previousRecapsViewModel: PreviousRecapsViewModel, + recapViewModel: RecapViewModel, + onboardingViewModel: OnboardingViewModel, + summaryViewModel: SummaryViewModel, + generalSettingsViewModel: GeneralSettingsViewModel, + dragDropViewModel: DragDropViewModel, + userPreferencesRepository: UserPreferencesRepositoryType, + meetingDetectionService: any MeetingDetectionServiceType + ) { + self.statusBarManager = statusBarManager + self.audioProcessController = audioProcessController + self.appSelectionViewModel = appSelectionViewModel + self.whisperModelsViewModel = whisperModelsViewModel + self.recapViewModel = recapViewModel + self.onboardingViewModel = onboardingViewModel + self.summaryViewModel = summaryViewModel + self.generalSettingsViewModel = generalSettingsViewModel + self.dragDropViewModel = dragDropViewModel + self.userPreferencesRepository = userPreferencesRepository + self.meetingDetectionService = meetingDetectionService + self.previousRecapsViewModel = previousRecapsViewModel + setupDelegates() + } + + private func setupDelegates() { + statusBarManager.delegate = self + + // Observe recording state changes to update status bar icon + recapViewModel.$isRecording + .receive(on: DispatchQueue.main) + .sink { [weak self] isRecording in + self?.logger.info("🔴 Recording state changed to: \(isRecording, privacy: .public)") + self?.statusBarManager.setRecordingState(isRecording) + } + .store(in: &cancellables) + } + + func createMainPanel() -> SlidingPanel { + recapViewModel.delegate = self + let contentView = RecapHomeView(viewModel: recapViewModel) + let hostingController = NSHostingController(rootView: contentView) + hostingController.view.wantsLayer = true + hostingController.view.layer?.cornerRadius = 12 + + let newPanel = SlidingPanel(contentViewController: hostingController) + newPanel.panelDelegate = self + return newPanel + } + + func positionPanel(_ panel: NSPanel, size: CGSize? = nil) { + guard let statusButton = statusBarManager.statusButton, + let statusWindow = statusButton.window, + let screen = statusWindow.screen + else { return } + + let panelSize = size ?? initialSize + let screenFrame = screen.frame + let finalX = screenFrame.maxX - panelSize.width - panelOffset + let panelY = screenFrame.maxY - menuBarHeight - panelSize.height - panelSpacing + + panel.setFrame( + NSRect(x: finalX, y: panelY, width: panelSize.width, height: panelSize.height), + display: false + ) + } + + private func showPanel() { + if panel == nil { + createAndShowNewPanel() + } else { + showExistingPanel() } - - private func createAndShowNewPanel() { - Task { - do { - let preferences = try await userPreferencesRepository.getOrCreatePreferences() - await createPanelBasedOnOnboardingStatus(isOnboarded: preferences.onboarded) - } catch { - await createMainPanelAndPosition() - } - - await animateAndShowPanel() - } + } + + private func createAndShowNewPanel() { + Task { + do { + let preferences = try await userPreferencesRepository.getOrCreatePreferences() + await createPanelBasedOnOnboardingStatus(isOnboarded: preferences.onboarded) + } catch { + await createMainPanelAndPosition() + } + + await animateAndShowPanel() } - - private func createPanelBasedOnOnboardingStatus(isOnboarded: Bool) async { - if !isOnboarded { - panel = createOnboardingPanel() - } else { - panel = createMainPanel() - } - - if let panel = panel { - positionPanel(panel) - } + } + + private func createPanelBasedOnOnboardingStatus(isOnboarded: Bool) async { + if !isOnboarded { + panel = createOnboardingPanel() + } else { + panel = createMainPanel() } - - private func createMainPanelAndPosition() async { - panel = createMainPanel() - if let panel = panel { - positionPanel(panel) - } + + if let panel = panel { + positionPanel(panel) } - - private func animateAndShowPanel() async { - guard let panel = panel else { return } - panel.contentView?.wantsLayer = true - - await withCheckedContinuation { continuation in - PanelAnimator.slideIn(panel: panel) { [weak self] in - self?.isVisible = true - continuation.resume() - } - } + } + + private func createMainPanelAndPosition() async { + panel = createMainPanel() + if let panel = panel { + positionPanel(panel) } - - private func showExistingPanel() { - guard let panel = panel else { return } - - positionPanel(panel) - panel.contentView?.wantsLayer = true - - PanelAnimator.slideIn(panel: panel) { [weak self] in - self?.isVisible = true - } + } + + private func animateAndShowPanel() async { + guard let panel = panel else { return } + panel.contentView?.wantsLayer = true + + await withCheckedContinuation { continuation in + PanelAnimator.slideIn(panel: panel) { [weak self] in + self?.isVisible = true + continuation.resume() + } } - - func showMainPanel() { - showPanel() + } + + private func showExistingPanel() { + guard let panel = panel else { return } + + positionPanel(panel) + panel.contentView?.wantsLayer = true + + PanelAnimator.slideIn(panel: panel) { [weak self] in + self?.isVisible = true } - - func hideMainPanel() { - hidePanel() + } + + func showMainPanel() { + showPanel() + } + + func hideMainPanel() { + hidePanel() + } + + func hidePanel() { + guard let panel = panel else { return } + + PanelAnimator.slideOut(panel: panel) { [weak self] in + self?.isVisible = false } - - private func hidePanel() { - guard let panel = panel else { return } - - PanelAnimator.slideOut(panel: panel) { [weak self] in - self?.isVisible = false - } + } + + private func hideAllSidePanels() { + if isSettingsVisible { hideSettingsPanel() } + if isSummaryVisible { hideSummaryPanel() } + if isRecapsVisible { hideRecapsPanel() } + if isDragDropVisible { hideDragDropPanel() } + if isPreviousRecapsVisible { hidePreviousRecapsWindow() } + } + + func toggleSidePanel( + isVisible: Bool, + show: () -> Void, + hide: () -> Void + ) { + guard !isVisible else { return hide() } + hideAllSidePanels() + show() + } + + deinit { + panel = nil + settingsPanel = nil + recapsPanel = nil + dragDropPanel = nil + } +} + +extension MenuBarPanelManager: StatusBarDelegate { + func statusItemClicked() { + if isVisible { + hidePanel() + } else { + showPanel() } - - private func hideAllSidePanels() { - if isSettingsVisible { hideSettingsPanel() } - if isSummaryVisible { hideSummaryPanel() } - if isPreviousRecapsVisible { hidePreviousRecapsWindow() } + } + + func startRecordingRequested() { + Task { + await startRecordingForAllApplications() } - - func toggleSidePanel( - isVisible: Bool, - show: () -> Void, - hide: () -> Void - ) { - guard !isVisible else { return hide() } - hideAllSidePanels() - show() + } + + func stopRecordingRequested() { + Task { + await recapViewModel.stopRecording() + statusBarManager.setRecordingState(false) } - - deinit { - panel = nil - settingsPanel = nil + } + + func settingsRequested() { + // Hide main panel and show only settings panel + if isVisible { + hidePanel() } -} + toggleSidePanel( + isVisible: isSettingsVisible, + show: showSettingsPanel, + hide: hideSettingsPanel + ) + } -extension MenuBarPanelManager: StatusBarDelegate { - func statusItemClicked() { - if isVisible { - hidePanel() - } else { - showPanel() - } + func recapsRequested() { + // Hide main panel and show only recaps panel + if isVisible { + hidePanel() } - - func quitRequested() { - NSApplication.shared.terminate(nil) + toggleSidePanel( + isVisible: isRecapsVisible, + show: showRecapsPanel, + hide: hideRecapsPanel + ) + } + + func dragDropRequested() { + // Hide main panel and show only drag & drop panel + if isVisible { + hidePanel() } + toggleSidePanel( + isVisible: isDragDropVisible, + show: showDragDropPanel, + hide: hideDragDropPanel + ) + } + + func quitRequested() { + NSApplication.shared.terminate(nil) + } + + func startRecordingForAllApplications() async { + // Set the selected app to "All Apps" for system-wide recording + recapViewModel.selectApp(SelectableApp.allApps.audioProcess) + + // Start the recording (respects user's microphone setting) + await recapViewModel.startRecording() + + // Update the status bar icon to show recording state + statusBarManager.setRecordingState(recapViewModel.isRecording) + } } extension MenuBarPanelManager: SlidingPanelDelegate { - func panelDidReceiveClickOutside() { - hidePanel() - hideAllSidePanels() - } + func panelDidReceiveClickOutside() { + hidePanel() + hideAllSidePanels() + } } diff --git a/Recap/MenuBar/Manager/MenuBarPanelManagerType.swift b/Recap/MenuBar/Manager/MenuBarPanelManagerType.swift index 274cada..ef7534e 100644 --- a/Recap/MenuBar/Manager/MenuBarPanelManagerType.swift +++ b/Recap/MenuBar/Manager/MenuBarPanelManagerType.swift @@ -2,13 +2,13 @@ import Foundation @MainActor protocol MenuBarPanelManagerType: ObservableObject { - var isVisible: Bool { get } - var isSettingsVisible: Bool { get } - var isSummaryVisible: Bool { get } - - func toggleSidePanel( - isVisible: Bool, - show: () -> Void, - hide: () -> Void - ) -} \ No newline at end of file + var isVisible: Bool { get } + var isSettingsVisible: Bool { get } + var isSummaryVisible: Bool { get } + + func toggleSidePanel( + isVisible: Bool, + show: () -> Void, + hide: () -> Void + ) +} diff --git a/Recap/MenuBar/Manager/StatusBar/StatusBarManager.swift b/Recap/MenuBar/Manager/StatusBar/StatusBarManager.swift index ede2289..92ade08 100644 --- a/Recap/MenuBar/Manager/StatusBar/StatusBarManager.swift +++ b/Recap/MenuBar/Manager/StatusBar/StatusBarManager.swift @@ -1,65 +1,225 @@ import AppKit +import OSLog @MainActor protocol StatusBarDelegate: AnyObject { - func statusItemClicked() - func quitRequested() + func statusItemClicked() + func quitRequested() + func startRecordingRequested() + func stopRecordingRequested() + func settingsRequested() + func recapsRequested() + func dragDropRequested() } final class StatusBarManager: StatusBarManagerType { - private var statusItem: NSStatusItem? - weak var delegate: StatusBarDelegate? - - init() { - setupStatusItem() - } - - var statusButton: NSStatusBarButton? { - statusItem?.button + private var statusItem: NSStatusItem? + weak var delegate: StatusBarDelegate? + private var themeObserver: NSObjectProtocol? + private var isRecording = false + private let logger = Logger( + subsystem: AppConstants.Logging.subsystem, + category: String(describing: StatusBarManager.self)) + + init() { + setupStatusItem() + setupThemeObserver() + } + + var statusButton: NSStatusBarButton? { + statusItem?.button + } + + private func setupStatusItem() { + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + + if let button = statusItem?.button { + updateIconForCurrentTheme() + button.target = self + button.action = #selector(handleButtonClick(_:)) + button.sendAction(on: [.leftMouseUp, .rightMouseUp]) } - - private func setupStatusItem() { - statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - - if let button = statusItem?.button { - button.image = NSImage(named: "barIcon") - button.target = self - button.action = #selector(handleButtonClick(_:)) - button.sendAction(on: [.leftMouseUp, .rightMouseUp]) + } + + private func setupThemeObserver() { + themeObserver = nil + } + + private func updateIconForCurrentTheme() { + guard let button = statusItem?.button else { return } + + logger.debug( + "🎨 updateIconForCurrentTheme called, isRecording: \(self.isRecording, privacy: .public)" + ) + + // Always use the black icon, regardless of theme + if let image = NSImage(named: "barIcon-dark") { + if isRecording { + // Create red-tinted version + let tintedImage = createTintedImage(from: image, tint: .systemRed) + tintedImage.isTemplate = false + button.image = tintedImage + button.contentTintColor = nil + logger.debug("🎨 Applied red tinted image") + } else { + // Use original image + if let workingImage = image.copy() as? NSImage { + workingImage.isTemplate = true + button.image = workingImage + button.contentTintColor = nil + logger.debug("🎨 Applied normal image") } - } - - @objc private func handleButtonClick(_ sender: NSStatusBarButton) { - let event = NSApp.currentEvent - if event?.type == .rightMouseUp { - showContextMenu() - } else { - DispatchQueue.main.async { [weak self] in - self?.delegate?.statusItemClicked() - } + } + } else if let fallback = NSImage(named: "barIcon") { + if isRecording { + // Create red-tinted version + let tintedImage = createTintedImage(from: fallback, tint: .systemRed) + button.image = tintedImage + button.contentTintColor = nil + logger.debug("🎨 Applied red tinted fallback image") + } else { + // Use original image + if let workingImage = fallback.copy() as? NSImage { + workingImage.isTemplate = true + button.image = workingImage + button.contentTintColor = nil + logger.debug("🎨 Applied normal fallback image") } + } } - - private func showContextMenu() { - let contextMenu = NSMenu() - - let quitItem = NSMenuItem(title: "Quit Recap", action: #selector(quitMenuItemClicked), keyEquivalent: "q") - quitItem.target = self - - contextMenu.addItem(quitItem) - - if let button = statusItem?.button { - contextMenu.popUp(positioning: nil, at: NSPoint(x: 0, y: button.bounds.maxY), in: button) - } + } + + private func createTintedImage(from originalImage: NSImage, tint: NSColor) -> NSImage { + let size = originalImage.size + let tintedImage = NSImage(size: size) + + tintedImage.lockFocus() + + // Draw the original image + originalImage.draw(in: NSRect(origin: .zero, size: size)) + + // Apply the tint color with multiply blend mode + tint.set() + NSRect(origin: .zero, size: size).fill(using: .sourceAtop) + + tintedImage.unlockFocus() + + return tintedImage + } + + func setRecordingState(_ recording: Bool) { + logger.info( + "🎯 StatusBarManager.setRecordingState called with: \(recording, privacy: .public)") + isRecording = recording + updateIconForCurrentTheme() + logger.info("🎯 Icon updated, isRecording = \(self.isRecording, privacy: .public)") + } + + @objc private func handleButtonClick(_ sender: NSStatusBarButton) { + let event = NSApp.currentEvent + if event?.type == .rightMouseUp { + showContextMenu() + } else { + showMainMenu() } - - @objc private func quitMenuItemClicked() { - DispatchQueue.main.async { [weak self] in - self?.delegate?.quitRequested() - } + } + + private func showMainMenu() { + let mainMenu = NSMenu() + + // Recording menu item (toggles between Start/Stop) + let recordingTitle = isRecording ? "Stop recording" : "Start recording" + let recordingItem = NSMenuItem( + title: recordingTitle, action: #selector(recordingMenuItemClicked), keyEquivalent: "r") + recordingItem.keyEquivalentModifierMask = .command + recordingItem.target = self + + // Recaps menu item + let recapsItem = NSMenuItem( + title: "Recaps", action: #selector(recapsMenuItemClicked), keyEquivalent: "") + recapsItem.target = self + + // Drag & Drop menu item + let dragDropItem = NSMenuItem( + title: "Drag & Drop", action: #selector(dragDropMenuItemClicked), keyEquivalent: "") + dragDropItem.target = self + + // Settings menu item + let settingsItem = NSMenuItem( + title: "Settings", action: #selector(settingsMenuItemClicked), keyEquivalent: "") + settingsItem.target = self + + // Quit menu item + let quitItem = NSMenuItem( + title: "Quit Recap", action: #selector(quitMenuItemClicked), keyEquivalent: "q") + quitItem.target = self + + mainMenu.addItem(recordingItem) + mainMenu.addItem(recapsItem) + mainMenu.addItem(dragDropItem) + mainMenu.addItem(settingsItem) + mainMenu.addItem(NSMenuItem.separator()) + mainMenu.addItem(quitItem) + + if let button = statusItem?.button { + mainMenu.popUp(positioning: nil, at: NSPoint(x: 0, y: button.bounds.maxY), in: button) + } + } + + private func showContextMenu() { + let contextMenu = NSMenu() + + let quitItem = NSMenuItem( + title: "Quit Recap", action: #selector(quitMenuItemClicked), keyEquivalent: "q") + quitItem.target = self + + contextMenu.addItem(quitItem) + + if let button = statusItem?.button { + contextMenu.popUp( + positioning: nil, at: NSPoint(x: 0, y: button.bounds.maxY), in: button) } - - deinit { - statusItem = nil + } + + @objc private func recordingMenuItemClicked() { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + if self.isRecording { + self.delegate?.stopRecordingRequested() + } else { + self.delegate?.startRecordingRequested() + } + } + } + + @objc private func settingsMenuItemClicked() { + DispatchQueue.main.async { [weak self] in + self?.delegate?.settingsRequested() + } + } + + @objc private func recapsMenuItemClicked() { + DispatchQueue.main.async { [weak self] in + self?.delegate?.recapsRequested() + } + } + + @objc private func dragDropMenuItemClicked() { + DispatchQueue.main.async { [weak self] in + self?.delegate?.dragDropRequested() + } + } + + @objc private func quitMenuItemClicked() { + DispatchQueue.main.async { [weak self] in + self?.delegate?.quitRequested() + } + } + + deinit { + if let observer = themeObserver { + DistributedNotificationCenter.default.removeObserver(observer) } + statusItem = nil + } } diff --git a/Recap/MenuBar/Manager/StatusBar/StatusBarManagerType.swift b/Recap/MenuBar/Manager/StatusBar/StatusBarManagerType.swift index 783917a..5c4e6a5 100644 --- a/Recap/MenuBar/Manager/StatusBar/StatusBarManagerType.swift +++ b/Recap/MenuBar/Manager/StatusBar/StatusBarManagerType.swift @@ -2,6 +2,7 @@ import AppKit @MainActor protocol StatusBarManagerType { - var statusButton: NSStatusBarButton? { get } - var delegate: StatusBarDelegate? { get set } -} \ No newline at end of file + var statusButton: NSStatusBarButton? { get } + var delegate: StatusBarDelegate? { get set } + func setRecordingState(_ recording: Bool) +} diff --git a/Recap/MenuBar/PanelAnimator.swift b/Recap/MenuBar/PanelAnimator.swift index 45341d9..f702994 100644 --- a/Recap/MenuBar/PanelAnimator.swift +++ b/Recap/MenuBar/PanelAnimator.swift @@ -2,69 +2,69 @@ import AppKit import QuartzCore struct PanelAnimator { - private static let slideInDuration: CFTimeInterval = 0.3 - private static let slideOutDuration: CFTimeInterval = 0.2 - private static let translateOffset: CGFloat = 50 - - static func slideIn(panel: NSPanel, completion: (() -> Void)? = nil) { - guard let layer = panel.contentView?.layer else { - completion?() - return - } - - let panelWidth = panel.frame.width - let translateDistance = panelWidth + translateOffset - - layer.transform = CATransform3DMakeTranslation(translateDistance, 0, 0) - panel.alphaValue = 1.0 - panel.makeKeyAndOrderFront(nil) - - let slideAnimation = CABasicAnimation(keyPath: "transform.translation.x") - slideAnimation.fromValue = translateDistance - slideAnimation.toValue = 0 - slideAnimation.duration = slideInDuration - slideAnimation.timingFunction = CAMediaTimingFunction(controlPoints: 0.25, 0.46, 0.45, 0.94) - slideAnimation.fillMode = .forwards - slideAnimation.isRemovedOnCompletion = false - - CATransaction.begin() - CATransaction.setCompletionBlock { - completion?() - } - - layer.add(slideAnimation, forKey: "slideIn") - layer.transform = CATransform3DIdentity - - CATransaction.commit() + private static let slideInDuration: CFTimeInterval = 0.3 + private static let slideOutDuration: CFTimeInterval = 0.2 + private static let translateOffset: CGFloat = 50 + + static func slideIn(panel: NSPanel, completion: (() -> Void)? = nil) { + guard let layer = panel.contentView?.layer else { + completion?() + return } - - static func slideOut(panel: NSPanel, completion: (() -> Void)? = nil) { - guard let layer = panel.contentView?.layer else { - panel.orderOut(nil) - completion?() - return - } - - let panelWidth = panel.frame.width - let translateDistance = panelWidth + translateOffset - - let slideOutAnimation = CABasicAnimation(keyPath: "transform.translation.x") - slideOutAnimation.fromValue = 0 - slideOutAnimation.toValue = translateDistance - slideOutAnimation.duration = slideOutDuration - slideOutAnimation.timingFunction = CAMediaTimingFunction(controlPoints: 0.55, 0.06, 0.68, 0.19) - slideOutAnimation.fillMode = .forwards - slideOutAnimation.isRemovedOnCompletion = false - - CATransaction.begin() - CATransaction.setCompletionBlock { - panel.orderOut(nil) - completion?() - } - - layer.add(slideOutAnimation, forKey: "slideOut") - layer.transform = CATransform3DMakeTranslation(translateDistance, 0, 0) - - CATransaction.commit() + + let panelWidth = panel.frame.width + let translateDistance = panelWidth + translateOffset + + layer.transform = CATransform3DMakeTranslation(translateDistance, 0, 0) + panel.alphaValue = 1.0 + panel.makeKeyAndOrderFront(nil) + + let slideAnimation = CABasicAnimation(keyPath: "transform.translation.x") + slideAnimation.fromValue = translateDistance + slideAnimation.toValue = 0 + slideAnimation.duration = slideInDuration + slideAnimation.timingFunction = CAMediaTimingFunction(controlPoints: 0.25, 0.46, 0.45, 0.94) + slideAnimation.fillMode = .forwards + slideAnimation.isRemovedOnCompletion = false + + CATransaction.begin() + CATransaction.setCompletionBlock { + completion?() } + + layer.add(slideAnimation, forKey: "slideIn") + layer.transform = CATransform3DIdentity + + CATransaction.commit() + } + + static func slideOut(panel: NSPanel, completion: (() -> Void)? = nil) { + guard let layer = panel.contentView?.layer else { + panel.orderOut(nil) + completion?() + return + } + + let panelWidth = panel.frame.width + let translateDistance = panelWidth + translateOffset + + let slideOutAnimation = CABasicAnimation(keyPath: "transform.translation.x") + slideOutAnimation.fromValue = 0 + slideOutAnimation.toValue = translateDistance + slideOutAnimation.duration = slideOutDuration + slideOutAnimation.timingFunction = CAMediaTimingFunction(controlPoints: 0.55, 0.06, 0.68, 0.19) + slideOutAnimation.fillMode = .forwards + slideOutAnimation.isRemovedOnCompletion = false + + CATransaction.begin() + CATransaction.setCompletionBlock { + panel.orderOut(nil) + completion?() + } + + layer.add(slideOutAnimation, forKey: "slideOut") + layer.transform = CATransform3DMakeTranslation(translateDistance, 0, 0) + + CATransaction.commit() + } } diff --git a/Recap/MenuBar/SlidingPanel.swift b/Recap/MenuBar/SlidingPanel.swift index 2294418..2c6ddef 100644 --- a/Recap/MenuBar/SlidingPanel.swift +++ b/Recap/MenuBar/SlidingPanel.swift @@ -2,114 +2,121 @@ import AppKit @MainActor protocol SlidingPanelDelegate: AnyObject { - func panelDidReceiveClickOutside() + func panelDidReceiveClickOutside() } final class SlidingPanel: NSPanel, SlidingPanelType { - weak var panelDelegate: SlidingPanelDelegate? - private var eventMonitor: Any? - - init(contentViewController: NSViewController) { - super.init( - contentRect: .zero, - styleMask: [.borderless, .nonactivatingPanel], - backing: .buffered, - defer: false - ) - - setupPanel(with: contentViewController) - setupEventMonitoring() - } - - override var canBecomeKey: Bool { true } - override var canBecomeMain: Bool { false } - - private func setupPanel(with contentViewController: NSViewController) { - self.contentViewController = contentViewController - self.level = .popUpMenu - self.isOpaque = false - self.backgroundColor = .clear - self.hasShadow = true - self.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle] - self.animationBehavior = .none - self.alphaValue = 0.0 - - let containerView = createContainerView(with: contentViewController) - self.contentView = containerView - - containerView.wantsLayer = true - containerView.layer?.backgroundColor = NSColor.clear.cgColor - } - - private func createContainerView(with contentViewController: NSViewController) -> NSView { - let visualEffect = createVisualEffectView() - let containerView = NSView() - - containerView.wantsLayer = true - containerView.layer?.backgroundColor = NSColor.clear.cgColor - - containerView.addSubview(visualEffect) - containerView.addSubview(contentViewController.view) - - setupVisualEffectConstraints(visualEffect, in: containerView) - setupContentViewConstraints(contentViewController.view, in: containerView) - - return containerView - } - - private func createVisualEffectView() -> NSVisualEffectView { - let visualEffect = NSVisualEffectView() - visualEffect.material = .popover - visualEffect.blendingMode = .behindWindow - visualEffect.state = .active - visualEffect.wantsLayer = true - visualEffect.layer?.cornerRadius = 12 - visualEffect.layer?.shouldRasterize = true - visualEffect.layer?.rasterizationScale = NSScreen.main?.backingScaleFactor ?? 2.0 - return visualEffect - } - - private func setupEventMonitoring() { - eventMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] event in - self?.handleGlobalClick(event) - } + weak var panelDelegate: SlidingPanelDelegate? + private var eventMonitor: Any? + var shouldCloseOnOutsideClick: Bool = true + + init(contentViewController: NSViewController, shouldCloseOnOutsideClick: Bool = true) { + self.shouldCloseOnOutsideClick = shouldCloseOnOutsideClick + super.init( + contentRect: .zero, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + setupPanel(with: contentViewController) + setupEventMonitoring() + } + + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { false } + + private func setupPanel(with contentViewController: NSViewController) { + self.contentViewController = contentViewController + self.level = .popUpMenu + self.isOpaque = false + self.backgroundColor = .clear + self.hasShadow = true + self.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle] + self.animationBehavior = .none + self.alphaValue = 0.0 + + let containerView = createContainerView(with: contentViewController) + self.contentView = containerView + + containerView.wantsLayer = true + containerView.layer?.backgroundColor = NSColor.clear.cgColor + } + + private func createContainerView(with contentViewController: NSViewController) -> NSView { + let visualEffect = createVisualEffectView() + let containerView = NSView() + + containerView.wantsLayer = true + containerView.layer?.backgroundColor = NSColor.clear.cgColor + + containerView.addSubview(visualEffect) + containerView.addSubview(contentViewController.view) + + setupVisualEffectConstraints(visualEffect, in: containerView) + setupContentViewConstraints(contentViewController.view, in: containerView) + + return containerView + } + + private func createVisualEffectView() -> NSVisualEffectView { + let visualEffect = NSVisualEffectView() + visualEffect.material = .popover + visualEffect.blendingMode = .behindWindow + visualEffect.state = .active + visualEffect.wantsLayer = true + visualEffect.layer?.cornerRadius = 12 + visualEffect.layer?.shouldRasterize = true + visualEffect.layer?.rasterizationScale = NSScreen.main?.backingScaleFactor ?? 2.0 + return visualEffect + } + + private func setupEventMonitoring() { + eventMonitor = NSEvent.addGlobalMonitorForEvents(matching: [ + .leftMouseDown, .rightMouseDown + ]) { [weak self] event in + self?.handleGlobalClick(event) } - - private func handleGlobalClick(_ event: NSEvent) { - let globalLocation = NSEvent.mouseLocation - if !self.frame.contains(globalLocation) { - panelDelegate?.panelDidReceiveClickOutside() - } + } + + private func handleGlobalClick(_ event: NSEvent) { + guard shouldCloseOnOutsideClick else { return } + let globalLocation = NSEvent.mouseLocation + if !self.frame.contains(globalLocation) { + panelDelegate?.panelDidReceiveClickOutside() } - - deinit { - if let eventMonitor = eventMonitor { - NSEvent.removeMonitor(eventMonitor) - } + } + + deinit { + if let eventMonitor = eventMonitor { + NSEvent.removeMonitor(eventMonitor) } + } } extension SlidingPanel { - private func setupVisualEffectConstraints(_ visualEffect: NSVisualEffectView, in container: NSView) { - visualEffect.translatesAutoresizingMaskIntoConstraints = false - - NSLayoutConstraint.activate([ - visualEffect.topAnchor.constraint(equalTo: container.topAnchor), - visualEffect.bottomAnchor.constraint(equalTo: container.bottomAnchor), - visualEffect.leadingAnchor.constraint(equalTo: container.leadingAnchor), - visualEffect.trailingAnchor.constraint(equalTo: container.trailingAnchor) - ]) - } - - private func setupContentViewConstraints(_ contentView: NSView, in container: NSView) { - contentView.translatesAutoresizingMaskIntoConstraints = false - contentView.wantsLayer = true - - NSLayoutConstraint.activate([ - contentView.topAnchor.constraint(equalTo: container.topAnchor), - contentView.bottomAnchor.constraint(equalTo: container.bottomAnchor), - contentView.leadingAnchor.constraint(equalTo: container.leadingAnchor), - contentView.trailingAnchor.constraint(equalTo: container.trailingAnchor) - ]) - } + private func setupVisualEffectConstraints( + _ visualEffect: NSVisualEffectView, in container: NSView + ) { + visualEffect.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + visualEffect.topAnchor.constraint(equalTo: container.topAnchor), + visualEffect.bottomAnchor.constraint(equalTo: container.bottomAnchor), + visualEffect.leadingAnchor.constraint(equalTo: container.leadingAnchor), + visualEffect.trailingAnchor.constraint(equalTo: container.trailingAnchor) + ]) + } + + private func setupContentViewConstraints(_ contentView: NSView, in container: NSView) { + contentView.translatesAutoresizingMaskIntoConstraints = false + contentView.wantsLayer = true + + NSLayoutConstraint.activate([ + contentView.topAnchor.constraint(equalTo: container.topAnchor), + contentView.bottomAnchor.constraint(equalTo: container.bottomAnchor), + contentView.leadingAnchor.constraint(equalTo: container.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: container.trailingAnchor) + ]) + } } diff --git a/Recap/MenuBar/SlidingPanelType.swift b/Recap/MenuBar/SlidingPanelType.swift index d2d3b31..ea27b40 100644 --- a/Recap/MenuBar/SlidingPanelType.swift +++ b/Recap/MenuBar/SlidingPanelType.swift @@ -2,8 +2,8 @@ import AppKit @MainActor protocol SlidingPanelType: AnyObject { - var panelDelegate: SlidingPanelDelegate? { get set } - var contentView: NSView? { get } - - func setFrame(_ frameRect: NSRect, display flag: Bool) -} \ No newline at end of file + var panelDelegate: SlidingPanelDelegate? { get set } + var contentView: NSView? { get } + + func setFrame(_ frameRect: NSRect, display flag: Bool) +} diff --git a/Recap/Recap.entitlements b/Recap/Recap.entitlements index 2b6edc3..fe867bb 100644 --- a/Recap/Recap.entitlements +++ b/Recap/Recap.entitlements @@ -2,13 +2,7 @@ - com.apple.security.app-sandbox - - com.apple.security.device.audio-input - - com.apple.security.files.user-selected.read-only - - com.apple.security.network.client + com.apple.security.temporary-exception.audio-unit-host diff --git a/Recap/RecapApp.swift b/Recap/RecapApp.swift index 159e685..628a1fa 100644 --- a/Recap/RecapApp.swift +++ b/Recap/RecapApp.swift @@ -5,47 +5,93 @@ // Created by Rawand Ahmad on 22/07/2025. // -import SwiftUI import AppKit +import SwiftUI import UserNotifications @main struct RecapApp: App { - @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate - - var body: some Scene { - // We don't need any scenes since we're using NSStatusItem - Settings { - EmptyView() - } + @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + + var body: some Scene { + // We don't need any scenes since we're using NSStatusItem + Settings { + EmptyView() } + } } class AppDelegate: NSObject, NSApplicationDelegate { - private var panelManager: MenuBarPanelManager? - private var dependencyContainer: DependencyContainer? - - func applicationDidFinishLaunching(_ notification: Notification) { - Task { @MainActor in - dependencyContainer = DependencyContainer() - panelManager = dependencyContainer?.createMenuBarPanelManager() - - UNUserNotificationCenter.current().delegate = self - } + private var panelManager: MenuBarPanelManager? + private var dependencyContainer: DependencyContainer? + private var globalShortcutManager: GlobalShortcutManager? + + func applicationDidFinishLaunching(_ notification: Notification) { + Task { @MainActor in + dependencyContainer = DependencyContainer() + panelManager = dependencyContainer?.createMenuBarPanelManager() + + // Setup global shortcut manager + globalShortcutManager = GlobalShortcutManager() + globalShortcutManager?.setDelegate(self) + + // Load global shortcut from user preferences + await loadGlobalShortcutFromPreferences() + + UNUserNotificationCenter.current().delegate = self } + } + + private func loadGlobalShortcutFromPreferences() async { + guard let dependencyContainer = dependencyContainer else { return } + + do { + let preferences = try await dependencyContainer.userPreferencesRepository + .getOrCreatePreferences() + await globalShortcutManager?.registerShortcut( + keyCode: UInt32(preferences.globalShortcutKeyCode), + modifiers: UInt32(preferences.globalShortcutModifiers) + ) + } catch { + // Fallback to default shortcut if loading preferences fails + await globalShortcutManager?.registerDefaultShortcut() + } + } } extension AppDelegate: UNUserNotificationCenterDelegate { - func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { - Task { @MainActor in - if response.notification.request.content.userInfo["action"] as? String == "open_app" { - panelManager?.showMainPanel() - } - } - completionHandler() + func userNotificationCenter( + _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + Task { @MainActor in + if response.notification.request.content.userInfo["action"] as? String == "open_app" { + panelManager?.showMainPanel() + } } - - func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { - completionHandler([.banner, .sound]) + completionHandler() + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, willPresent notification: UNNotification, + withCompletionHandler completionHandler: + @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner, .sound]) + } +} + +extension AppDelegate: GlobalShortcutDelegate { + func globalShortcutActivated() { + Task { @MainActor in + // Toggle recording state when global shortcut is pressed + if let panelManager = panelManager { + if panelManager.recapViewModel.isRecording { + await panelManager.recapViewModel.stopRecording() + } else { + await panelManager.startRecordingForAllApplications() + } + } } + } } diff --git a/Recap/Repositories/LLMModels/LLMModelRepository.swift b/Recap/Repositories/LLMModels/LLMModelRepository.swift index 533ac94..58fa0a1 100644 --- a/Recap/Repositories/LLMModels/LLMModelRepository.swift +++ b/Recap/Repositories/LLMModels/LLMModelRepository.swift @@ -1,68 +1,68 @@ -import Foundation import CoreData +import Foundation @MainActor final class LLMModelRepository: LLMModelRepositoryType { - private let coreDataManager: CoreDataManagerType - - init(coreDataManager: CoreDataManagerType) { - self.coreDataManager = coreDataManager + private let coreDataManager: CoreDataManagerType + + init(coreDataManager: CoreDataManagerType) { + self.coreDataManager = coreDataManager + } + + func getAllModels() async throws -> [LLMModelInfo] { + let context = coreDataManager.viewContext + let request: NSFetchRequest = LLMModel.fetchRequest() + request.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)] + + do { + let models = try context.fetch(request) + return models.map { LLMModelInfo(from: $0) } + } catch { + throw LLMError.dataAccessError(error.localizedDescription) } - - func getAllModels() async throws -> [LLMModelInfo] { - let context = coreDataManager.viewContext - let request: NSFetchRequest = LLMModel.fetchRequest() - request.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)] - - do { - let models = try context.fetch(request) - return models.map { LLMModelInfo(from: $0) } - } catch { - throw LLMError.dataAccessError(error.localizedDescription) - } + } + + func getModel(byId id: String) async throws -> LLMModelInfo? { + let context = coreDataManager.viewContext + let request: NSFetchRequest = LLMModel.fetchRequest() + request.predicate = NSPredicate(format: "id == %@", id) + request.fetchLimit = 1 + + do { + let models = try context.fetch(request) + return models.first.map { LLMModelInfo(from: $0) } + } catch { + throw LLMError.dataAccessError(error.localizedDescription) } - - func getModel(byId id: String) async throws -> LLMModelInfo? { - let context = coreDataManager.viewContext - let request: NSFetchRequest = LLMModel.fetchRequest() - request.predicate = NSPredicate(format: "id == %@", id) - request.fetchLimit = 1 - - do { - let models = try context.fetch(request) - return models.first.map { LLMModelInfo(from: $0) } - } catch { - throw LLMError.dataAccessError(error.localizedDescription) - } + } + + func saveModels(_ models: [LLMModelInfo]) async throws { + let context = coreDataManager.viewContext + + for modelInfo in models { + let request: NSFetchRequest = LLMModel.fetchRequest() + request.predicate = NSPredicate(format: "id == %@", modelInfo.id) + request.fetchLimit = 1 + + do { + let existingModels = try context.fetch(request) + let model = existingModels.first ?? LLMModel(context: context) + + model.id = modelInfo.id + model.name = modelInfo.name + model.provider = modelInfo.provider + model.keepAliveMinutes = modelInfo.keepAliveMinutes ?? 0 + model.temperature = modelInfo.temperature ?? 0.7 + model.maxTokens = modelInfo.maxTokens + } catch { + throw LLMError.dataAccessError(error.localizedDescription) + } } - - func saveModels(_ models: [LLMModelInfo]) async throws { - let context = coreDataManager.viewContext - - for modelInfo in models { - let request: NSFetchRequest = LLMModel.fetchRequest() - request.predicate = NSPredicate(format: "id == %@", modelInfo.id) - request.fetchLimit = 1 - - do { - let existingModels = try context.fetch(request) - let model = existingModels.first ?? LLMModel(context: context) - - model.id = modelInfo.id - model.name = modelInfo.name - model.provider = modelInfo.provider - model.keepAliveMinutes = modelInfo.keepAliveMinutes ?? 0 - model.temperature = modelInfo.temperature ?? 0.7 - model.maxTokens = modelInfo.maxTokens - } catch { - throw LLMError.dataAccessError(error.localizedDescription) - } - } - - do { - try context.save() - } catch { - throw LLMError.dataAccessError(error.localizedDescription) - } + + do { + try context.save() + } catch { + throw LLMError.dataAccessError(error.localizedDescription) } + } } diff --git a/Recap/Repositories/LLMModels/LLMModelRepositoryType.swift b/Recap/Repositories/LLMModels/LLMModelRepositoryType.swift index d4d1a9c..4b72e80 100644 --- a/Recap/Repositories/LLMModels/LLMModelRepositoryType.swift +++ b/Recap/Repositories/LLMModels/LLMModelRepositoryType.swift @@ -2,7 +2,7 @@ import Foundation @MainActor protocol LLMModelRepositoryType { - func getAllModels() async throws -> [LLMModelInfo] - func getModel(byId id: String) async throws -> LLMModelInfo? - func saveModels(_ models: [LLMModelInfo]) async throws -} \ No newline at end of file + func getAllModels() async throws -> [LLMModelInfo] + func getModel(byId id: String) async throws -> LLMModelInfo? + func saveModels(_ models: [LLMModelInfo]) async throws +} diff --git a/Recap/Repositories/Models/LLMModelInfo.swift b/Recap/Repositories/Models/LLMModelInfo.swift index 90ac152..74f6c35 100644 --- a/Recap/Repositories/Models/LLMModelInfo.swift +++ b/Recap/Repositories/Models/LLMModelInfo.swift @@ -1,36 +1,36 @@ -import Foundation import CoreData +import Foundation struct LLMModelInfo: Identifiable, Hashable { - let id: String - let name: String - let provider: String - var keepAliveMinutes: Int32? - var temperature: Double? - var maxTokens: Int32 - - init(from managedObject: LLMModel) { - self.id = managedObject.id ?? UUID().uuidString - self.name = managedObject.name ?? "" - self.provider = managedObject.provider ?? "ollama" - self.keepAliveMinutes = managedObject.keepAliveMinutes - self.temperature = managedObject.temperature - self.maxTokens = managedObject.maxTokens - } - - init( - id: String = UUID().uuidString, - name: String, - provider: String = "ollama", - keepAliveMinutes: Int32? = nil, - temperature: Double? = nil, - maxTokens: Int32 = 8192 - ) { - self.id = id - self.name = name - self.provider = provider - self.keepAliveMinutes = keepAliveMinutes - self.temperature = temperature - self.maxTokens = maxTokens - } + let id: String + let name: String + let provider: String + var keepAliveMinutes: Int32? + var temperature: Double? + var maxTokens: Int32 + + init(from managedObject: LLMModel) { + self.id = managedObject.id ?? UUID().uuidString + self.name = managedObject.name ?? "" + self.provider = managedObject.provider ?? "ollama" + self.keepAliveMinutes = managedObject.keepAliveMinutes + self.temperature = managedObject.temperature + self.maxTokens = managedObject.maxTokens + } + + init( + id: String = UUID().uuidString, + name: String, + provider: String = "ollama", + keepAliveMinutes: Int32? = nil, + temperature: Double? = nil, + maxTokens: Int32 = 8192 + ) { + self.id = id + self.name = name + self.provider = provider + self.keepAliveMinutes = keepAliveMinutes + self.temperature = temperature + self.maxTokens = maxTokens + } } diff --git a/Recap/Repositories/Models/LLMProvider.swift b/Recap/Repositories/Models/LLMProvider.swift index aeabf11..cbff501 100644 --- a/Recap/Repositories/Models/LLMProvider.swift +++ b/Recap/Repositories/Models/LLMProvider.swift @@ -1,21 +1,24 @@ import Foundation enum LLMProvider: String, CaseIterable, Identifiable { - case ollama = "ollama" - case openRouter = "openrouter" - - var id: String { rawValue } - - var providerName: String { - switch self { - case .ollama: - return "Ollama" - case .openRouter: - return "OpenRouter" - } - } - - static var `default`: LLMProvider { - .ollama + case ollama = "ollama" + case openRouter = "openrouter" + case openAI = "openai" + + var id: String { rawValue } + + var providerName: String { + switch self { + case .ollama: + return "Ollama" + case .openRouter: + return "OpenRouter" + case .openAI: + return "OpenAI" } + } + + static var `default`: LLMProvider { + .ollama + } } diff --git a/Recap/Repositories/Models/RecordingInfo.swift b/Recap/Repositories/Models/RecordingInfo.swift index 3edefce..df409e4 100644 --- a/Recap/Repositories/Models/RecordingInfo.swift +++ b/Recap/Repositories/Models/RecordingInfo.swift @@ -1,56 +1,66 @@ import Foundation struct RecordingInfo: Identifiable, Equatable { - let id: String - let startDate: Date - let endDate: Date? - let state: RecordingProcessingState - let errorMessage: String? - let recordingURL: URL - let microphoneURL: URL? - let hasMicrophoneAudio: Bool - let applicationName: String? - let transcriptionText: String? - let summaryText: String? - let createdAt: Date - let modifiedAt: Date - - var duration: TimeInterval? { - guard let endDate = endDate else { return nil } - return endDate.timeIntervalSince(startDate) - } - - var isComplete: Bool { - state == .completed - } - - var isProcessing: Bool { - state.isProcessing - } - - var hasFailed: Bool { - state.isFailed - } - - var canRetry: Bool { - state.canRetry - } + let id: String + let startDate: Date + let endDate: Date? + let state: RecordingProcessingState + let errorMessage: String? + let recordingURL: URL + let microphoneURL: URL? + let hasMicrophoneAudio: Bool + let applicationName: String? + let transcriptionText: String? + let summaryText: String? + let timestampedTranscription: TimestampedTranscription? + let createdAt: Date + let modifiedAt: Date + + var duration: TimeInterval? { + guard let endDate = endDate else { return nil } + return endDate.timeIntervalSince(startDate) + } + + var isComplete: Bool { + state == .completed + } + + var isProcessing: Bool { + state.isProcessing + } + + var hasFailed: Bool { + state.isFailed + } + + var canRetry: Bool { + state.canRetry + } } extension RecordingInfo { - init(from entity: UserRecording) { - self.id = entity.id ?? UUID().uuidString - self.startDate = entity.startDate ?? Date() - self.endDate = entity.endDate - self.state = RecordingProcessingState(rawValue: entity.state) ?? .recording - self.errorMessage = entity.errorMessage - self.recordingURL = URL(fileURLWithPath: entity.recordingURL ?? "") - self.microphoneURL = entity.microphoneURL.map { URL(fileURLWithPath: $0) } - self.hasMicrophoneAudio = entity.hasMicrophoneAudio - self.applicationName = entity.applicationName - self.transcriptionText = entity.transcriptionText - self.summaryText = entity.summaryText - self.createdAt = entity.createdAt ?? Date() - self.modifiedAt = entity.modifiedAt ?? Date() + init(from entity: UserRecording) { + self.id = entity.id ?? UUID().uuidString + self.startDate = entity.startDate ?? Date() + self.endDate = entity.endDate + self.state = RecordingProcessingState(rawValue: entity.state) ?? .recording + self.errorMessage = entity.errorMessage + self.recordingURL = URL(fileURLWithPath: entity.recordingURL ?? "") + self.microphoneURL = entity.microphoneURL.map { URL(fileURLWithPath: $0) } + self.hasMicrophoneAudio = entity.hasMicrophoneAudio + self.applicationName = entity.applicationName + self.transcriptionText = entity.transcriptionText + self.summaryText = entity.summaryText + + // Decode timestamped transcription data if available + if let data = entity.timestampedTranscriptionData { + self.timestampedTranscription = try? JSONDecoder().decode( + TimestampedTranscription.self, from: data) + } else { + self.timestampedTranscription = nil } -} \ No newline at end of file + + self.createdAt = entity.createdAt ?? Date() + self.modifiedAt = entity.modifiedAt ?? Date() + } +} diff --git a/Recap/Repositories/Models/UserPreferencesInfo.swift b/Recap/Repositories/Models/UserPreferencesInfo.swift index 8fb7fe9..b5c9a64 100644 --- a/Recap/Repositories/Models/UserPreferencesInfo.swift +++ b/Recap/Repositories/Models/UserPreferencesInfo.swift @@ -1,61 +1,87 @@ -import Foundation import CoreData +import Foundation struct UserPreferencesInfo: Identifiable { - let id: String - let selectedLLMModelID: String? - let selectedProvider: LLMProvider - let autoSummarizeEnabled: Bool - let autoDetectMeetings: Bool - let autoStopRecording: Bool - let onboarded: Bool - let summaryPromptTemplate: String? - let createdAt: Date - let modifiedAt: Date + let id: String + let selectedLLMModelID: String? + let selectedProvider: LLMProvider + let autoSummarizeEnabled: Bool + let autoTranscribeEnabled: Bool + let autoDetectMeetings: Bool + let autoStopRecording: Bool + let onboarded: Bool + let summaryPromptTemplate: String? + let microphoneEnabled: Bool + let globalShortcutKeyCode: Int32 + let globalShortcutModifiers: Int32 + let customTmpDirectoryPath: String? + let customTmpDirectoryBookmark: Data? + let createdAt: Date + let modifiedAt: Date + + init(from managedObject: UserPreferences) { + self.id = managedObject.id ?? UUID().uuidString + self.selectedLLMModelID = managedObject.selectedLLMModelID + self.selectedProvider = + LLMProvider( + rawValue: managedObject.selectedProvider ?? LLMProvider.default.rawValue + ) ?? LLMProvider.default + self.autoSummarizeEnabled = managedObject.autoSummarizeEnabled + self.autoTranscribeEnabled = managedObject.autoTranscribeEnabled + self.autoDetectMeetings = managedObject.autoDetectMeetings + self.autoStopRecording = managedObject.autoStopRecording + self.onboarded = managedObject.onboarded + self.summaryPromptTemplate = managedObject.summaryPromptTemplate + self.microphoneEnabled = managedObject.microphoneEnabled + self.globalShortcutKeyCode = managedObject.globalShortcutKeyCode + self.globalShortcutModifiers = managedObject.globalShortcutModifiers + self.customTmpDirectoryPath = managedObject.customTmpDirectoryPath + self.customTmpDirectoryBookmark = managedObject.customTmpDirectoryBookmark + self.createdAt = managedObject.createdAt ?? Date() + self.modifiedAt = managedObject.modifiedAt ?? Date() + } - init(from managedObject: UserPreferences) { - self.id = managedObject.id ?? UUID().uuidString - self.selectedLLMModelID = managedObject.selectedLLMModelID - self.selectedProvider = LLMProvider(rawValue: managedObject.selectedProvider ?? LLMProvider.default.rawValue) ?? LLMProvider.default - self.autoSummarizeEnabled = managedObject.autoSummarizeEnabled - self.autoDetectMeetings = managedObject.autoDetectMeetings - self.autoStopRecording = managedObject.autoStopRecording - self.onboarded = managedObject.onboarded - self.summaryPromptTemplate = managedObject.summaryPromptTemplate - self.createdAt = managedObject.createdAt ?? Date() - self.modifiedAt = managedObject.modifiedAt ?? Date() - } + init( + id: String = UUID().uuidString, + selectedLLMModelID: String? = nil, + selectedProvider: LLMProvider = .default, + autoSummarizeEnabled: Bool = true, + autoTranscribeEnabled: Bool = true, + autoDetectMeetings: Bool = false, + autoStopRecording: Bool = false, + onboarded: Bool = false, + summaryPromptTemplate: String? = nil, + microphoneEnabled: Bool = false, + globalShortcutKeyCode: Int32 = 15, // 'R' key + globalShortcutModifiers: Int32 = 1_048_840, // Cmd key + customTmpDirectoryPath: String? = nil, + customTmpDirectoryBookmark: Data? = nil, + createdAt: Date = Date(), + modifiedAt: Date = Date() + ) { + self.id = id + self.selectedLLMModelID = selectedLLMModelID + self.selectedProvider = selectedProvider + self.autoSummarizeEnabled = autoSummarizeEnabled + self.autoTranscribeEnabled = autoTranscribeEnabled + self.autoDetectMeetings = autoDetectMeetings + self.autoStopRecording = autoStopRecording + self.onboarded = onboarded + self.summaryPromptTemplate = summaryPromptTemplate + self.microphoneEnabled = microphoneEnabled + self.globalShortcutKeyCode = globalShortcutKeyCode + self.globalShortcutModifiers = globalShortcutModifiers + self.customTmpDirectoryPath = customTmpDirectoryPath + self.customTmpDirectoryBookmark = customTmpDirectoryBookmark + self.createdAt = createdAt + self.modifiedAt = modifiedAt + } - - init( - id: String = UUID().uuidString, - selectedLLMModelID: String? = nil, - selectedProvider: LLMProvider = .default, - autoSummarizeEnabled: Bool = true, - autoDetectMeetings: Bool = false, - autoStopRecording: Bool = false, - onboarded: Bool = false, - summaryPromptTemplate: String? = nil, - createdAt: Date = Date(), - modifiedAt: Date = Date() - ) { - self.id = id - self.selectedLLMModelID = selectedLLMModelID - self.selectedProvider = selectedProvider - self.autoSummarizeEnabled = autoSummarizeEnabled - self.autoDetectMeetings = autoDetectMeetings - self.autoStopRecording = autoStopRecording - self.onboarded = onboarded - self.summaryPromptTemplate = summaryPromptTemplate - self.createdAt = createdAt - self.modifiedAt = modifiedAt - } - - static var defaultPromptTemplate: String { - """ - Please provide a concise summary of the following meeting transcript. \ - Focus on key points, decisions made, and action items. \ - Format the summary with clear sections for Main Topics, Decisions, and Action Items. - """ - } + static var defaultPromptTemplate: String { + """ + Please provide a concise summary of the following meeting transcript. \ + Focus on key points, decisions made, and action items. \ + Format the summary with clear sections for Main Topics, Decisions, and Action Items. + """ + } } diff --git a/Recap/Repositories/Recordings/RecordingRepository.swift b/Recap/Repositories/Recordings/RecordingRepository.swift index 8ef0869..13caf7a 100644 --- a/Recap/Repositories/Recordings/RecordingRepository.swift +++ b/Recap/Repositories/Recordings/RecordingRepository.swift @@ -1,236 +1,260 @@ -import Foundation import CoreData +import Foundation final class RecordingRepository: RecordingRepositoryType { - private let coreDataManager: CoreDataManagerType - - init(coreDataManager: CoreDataManagerType) { - self.coreDataManager = coreDataManager - } - - func createRecording(id: String, startDate: Date, recordingURL: URL, microphoneURL: URL?, hasMicrophoneAudio: Bool, applicationName: String?) async throws -> RecordingInfo { - try await withCheckedThrowingContinuation { continuation in - coreDataManager.performBackgroundTask { context in - do { - let recording = UserRecording(context: context) - recording.id = id - recording.startDate = startDate - recording.recordingURL = recordingURL.path - recording.microphoneURL = microphoneURL?.path - recording.hasMicrophoneAudio = hasMicrophoneAudio - recording.applicationName = applicationName - recording.state = RecordingProcessingState.recording.rawValue - recording.createdAt = Date() - recording.modifiedAt = Date() - - try context.save() - - let info = RecordingInfo(from: recording) - continuation.resume(returning: info) - } catch { - continuation.resume(throwing: error) - } - } + private let coreDataManager: CoreDataManagerType + + init(coreDataManager: CoreDataManagerType) { + self.coreDataManager = coreDataManager + } + + func createRecording(_ parameters: RecordingCreationParameters) async throws -> RecordingInfo { + try await withCheckedThrowingContinuation { continuation in + coreDataManager.performBackgroundTask { context in + do { + let recording = UserRecording(context: context) + recording.id = parameters.id + recording.startDate = parameters.startDate + recording.recordingURL = parameters.recordingURL.path + recording.microphoneURL = parameters.microphoneURL?.path + recording.hasMicrophoneAudio = parameters.hasMicrophoneAudio + recording.applicationName = parameters.applicationName + recording.state = RecordingProcessingState.recording.rawValue + recording.createdAt = Date() + recording.modifiedAt = Date() + + try context.save() + + let info = RecordingInfo(from: recording) + continuation.resume(returning: info) + } catch { + continuation.resume(throwing: error) } + } } - - func fetchRecording(id: String) async throws -> RecordingInfo? { - try await withCheckedThrowingContinuation { continuation in - coreDataManager.performBackgroundTask { context in - let request = UserRecording.fetchRequest() - request.predicate = NSPredicate(format: "id == %@", id) - request.fetchLimit = 1 - - do { - let recordings = try context.fetch(request) - let info = recordings.first.map { RecordingInfo(from: $0) } - continuation.resume(returning: info) - } catch { - continuation.resume(throwing: error) - } - } + } + + func fetchRecording(id: String) async throws -> RecordingInfo? { + try await withCheckedThrowingContinuation { continuation in + coreDataManager.performBackgroundTask { context in + let request = UserRecording.fetchRequest() + request.predicate = NSPredicate(format: "id == %@", id) + request.fetchLimit = 1 + + do { + let recordings = try context.fetch(request) + let info = recordings.first.map { RecordingInfo(from: $0) } + continuation.resume(returning: info) + } catch { + continuation.resume(throwing: error) } + } } - - func fetchAllRecordings() async throws -> [RecordingInfo] { - try await withCheckedThrowingContinuation { continuation in - coreDataManager.performBackgroundTask { context in - let request = UserRecording.fetchRequest() - request.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)] - - do { - let recordings = try context.fetch(request) - let infos = recordings.map { RecordingInfo(from: $0) } - continuation.resume(returning: infos) - } catch { - continuation.resume(throwing: error) - } - } + } + + func fetchAllRecordings() async throws -> [RecordingInfo] { + try await withCheckedThrowingContinuation { continuation in + coreDataManager.performBackgroundTask { context in + let request = UserRecording.fetchRequest() + request.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)] + + do { + let recordings = try context.fetch(request) + let infos = recordings.map { RecordingInfo(from: $0) } + continuation.resume(returning: infos) + } catch { + continuation.resume(throwing: error) } + } } - - func fetchRecordings(withState state: RecordingProcessingState) async throws -> [RecordingInfo] { - try await withCheckedThrowingContinuation { continuation in - coreDataManager.performBackgroundTask { context in - let request = UserRecording.fetchRequest() - request.predicate = NSPredicate(format: "state == %d", state.rawValue) - request.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)] - - do { - let recordings = try context.fetch(request) - let infos = recordings.map { RecordingInfo(from: $0) } - continuation.resume(returning: infos) - } catch { - continuation.resume(throwing: error) - } - } + } + + func fetchRecordings(withState state: RecordingProcessingState) async throws -> [RecordingInfo] { + try await withCheckedThrowingContinuation { continuation in + coreDataManager.performBackgroundTask { context in + let request = UserRecording.fetchRequest() + request.predicate = NSPredicate(format: "state == %d", state.rawValue) + request.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)] + + do { + let recordings = try context.fetch(request) + let infos = recordings.map { RecordingInfo(from: $0) } + continuation.resume(returning: infos) + } catch { + continuation.resume(throwing: error) } + } } - - func updateRecordingState(id: String, state: RecordingProcessingState, errorMessage: String?) async throws { - try await withCheckedThrowingContinuation { continuation in - coreDataManager.performBackgroundTask { context in - do { - let recording = try self.fetchRecordingEntity(id: id, context: context) - recording.state = state.rawValue - recording.errorMessage = errorMessage - recording.modifiedAt = Date() - - try context.save() - continuation.resume() - } catch { - continuation.resume(throwing: error) - } - } + } + + func updateRecordingState(id: String, state: RecordingProcessingState, errorMessage: String?) + async throws { + try await withCheckedThrowingContinuation { continuation in + coreDataManager.performBackgroundTask { context in + do { + let recording = try self.fetchRecordingEntity(id: id, context: context) + recording.state = state.rawValue + recording.errorMessage = errorMessage + recording.modifiedAt = Date() + + try context.save() + continuation.resume() + } catch { + continuation.resume(throwing: error) } + } } - - func updateRecordingEndDate(id: String, endDate: Date) async throws { - try await withCheckedThrowingContinuation { continuation in - coreDataManager.performBackgroundTask { context in - do { - let recording = try self.fetchRecordingEntity(id: id, context: context) - recording.endDate = endDate - recording.modifiedAt = Date() - - try context.save() - continuation.resume() - } catch { - continuation.resume(throwing: error) - } - } + } + + func updateRecordingEndDate(id: String, endDate: Date) async throws { + try await withCheckedThrowingContinuation { continuation in + coreDataManager.performBackgroundTask { context in + do { + let recording = try self.fetchRecordingEntity(id: id, context: context) + recording.endDate = endDate + recording.modifiedAt = Date() + + try context.save() + continuation.resume() + } catch { + continuation.resume(throwing: error) } + } } - - func updateRecordingTranscription(id: String, transcriptionText: String) async throws { - try await withCheckedThrowingContinuation { continuation in - coreDataManager.performBackgroundTask { context in - do { - let recording = try self.fetchRecordingEntity(id: id, context: context) - recording.transcriptionText = transcriptionText - recording.modifiedAt = Date() - - try context.save() - continuation.resume() - } catch { - continuation.resume(throwing: error) - } - } + } + + func updateRecordingTranscription(id: String, transcriptionText: String) async throws { + try await withCheckedThrowingContinuation { continuation in + coreDataManager.performBackgroundTask { context in + do { + let recording = try self.fetchRecordingEntity(id: id, context: context) + recording.transcriptionText = transcriptionText + recording.modifiedAt = Date() + + try context.save() + continuation.resume() + } catch { + continuation.resume(throwing: error) } + } } - - func updateRecordingSummary(id: String, summaryText: String) async throws { - try await withCheckedThrowingContinuation { continuation in - coreDataManager.performBackgroundTask { context in - do { - let recording = try self.fetchRecordingEntity(id: id, context: context) - recording.summaryText = summaryText - recording.modifiedAt = Date() - - try context.save() - continuation.resume() - } catch { - continuation.resume(throwing: error) - } - } + } + + func updateRecordingTimestampedTranscription( + id: String, timestampedTranscription: TimestampedTranscription + ) async throws { + try await withCheckedThrowingContinuation { continuation in + coreDataManager.performBackgroundTask { context in + do { + let recording = try self.fetchRecordingEntity(id: id, context: context) + + // Encode the timestamped transcription to binary data + let data = try JSONEncoder().encode(timestampedTranscription) + recording.timestampedTranscriptionData = data + recording.modifiedAt = Date() + + try context.save() + continuation.resume() + } catch { + continuation.resume(throwing: error) } + } } - - func updateRecordingURLs(id: String, recordingURL: URL?, microphoneURL: URL?) async throws { - try await withCheckedThrowingContinuation { continuation in - coreDataManager.performBackgroundTask { context in - do { - let recording = try self.fetchRecordingEntity(id: id, context: context) - if let recordingURL = recordingURL { - recording.recordingURL = recordingURL.path - } - if let microphoneURL = microphoneURL { - recording.microphoneURL = microphoneURL.path - } - recording.modifiedAt = Date() - - try context.save() - continuation.resume() - } catch { - continuation.resume(throwing: error) - } - } + } + + func updateRecordingSummary(id: String, summaryText: String) async throws { + try await withCheckedThrowingContinuation { continuation in + coreDataManager.performBackgroundTask { context in + do { + let recording = try self.fetchRecordingEntity(id: id, context: context) + recording.summaryText = summaryText + recording.modifiedAt = Date() + + try context.save() + continuation.resume() + } catch { + continuation.resume(throwing: error) } + } } - - func deleteRecording(id: String) async throws { - try await withCheckedThrowingContinuation { continuation in - coreDataManager.performBackgroundTask { context in - do { - let recording = try self.fetchRecordingEntity(id: id, context: context) - context.delete(recording) - - try context.save() - continuation.resume() - } catch { - continuation.resume(throwing: error) - } - } + } + + func updateRecordingURLs(id: String, recordingURL: URL?, microphoneURL: URL?) async throws { + try await withCheckedThrowingContinuation { continuation in + coreDataManager.performBackgroundTask { context in + do { + let recording = try self.fetchRecordingEntity(id: id, context: context) + if let recordingURL = recordingURL { + recording.recordingURL = recordingURL.path + } + if let microphoneURL = microphoneURL { + recording.microphoneURL = microphoneURL.path + } + recording.modifiedAt = Date() + + try context.save() + continuation.resume() + } catch { + continuation.resume(throwing: error) } + } } - - func deleteAllRecordings() async throws { - try await withCheckedThrowingContinuation { continuation in - coreDataManager.performBackgroundTask { context in - let request = NSFetchRequest(entityName: "UserRecording") - let deleteRequest = NSBatchDeleteRequest(fetchRequest: request) - - do { - try context.execute(deleteRequest) - try context.save() - continuation.resume() - } catch { - continuation.resume(throwing: error) - } - } + } + + func deleteRecording(id: String) async throws { + try await withCheckedThrowingContinuation { continuation in + coreDataManager.performBackgroundTask { context in + do { + let recording = try self.fetchRecordingEntity(id: id, context: context) + context.delete(recording) + + try context.save() + continuation.resume() + } catch { + continuation.resume(throwing: error) } + } } - - private func fetchRecordingEntity(id: String, context: NSManagedObjectContext) throws -> UserRecording { - let request = UserRecording.fetchRequest() - request.predicate = NSPredicate(format: "id == %@", id) - request.fetchLimit = 1 - - guard let recording = try context.fetch(request).first else { - throw RecordingRepositoryError.recordingNotFound(id: id) + } + + func deleteAllRecordings() async throws { + try await withCheckedThrowingContinuation { continuation in + coreDataManager.performBackgroundTask { context in + let request = NSFetchRequest(entityName: "UserRecording") + let deleteRequest = NSBatchDeleteRequest(fetchRequest: request) + + do { + try context.execute(deleteRequest) + try context.save() + continuation.resume() + } catch { + continuation.resume(throwing: error) } - - return recording + } } + } + + private func fetchRecordingEntity(id: String, context: NSManagedObjectContext) throws + -> UserRecording { + let request = UserRecording.fetchRequest() + request.predicate = NSPredicate(format: "id == %@", id) + request.fetchLimit = 1 + + guard let recording = try context.fetch(request).first else { + throw RecordingRepositoryError.recordingNotFound(id: id) + } + + return recording + } } enum RecordingRepositoryError: LocalizedError { - case recordingNotFound(id: String) - - var errorDescription: String? { - switch self { - case .recordingNotFound(let id): - return "Recording with ID '\(id)' not found" - } + case recordingNotFound(id: String) + + var errorDescription: String? { + switch self { + case .recordingNotFound(let id): + return "Recording with ID '\(id)' not found" } -} \ No newline at end of file + } +} diff --git a/Recap/Repositories/Recordings/RecordingRepositoryType.swift b/Recap/Repositories/Recordings/RecordingRepositoryType.swift index 5713da4..cde3003 100644 --- a/Recap/Repositories/Recordings/RecordingRepositoryType.swift +++ b/Recap/Repositories/Recordings/RecordingRepositoryType.swift @@ -1,21 +1,34 @@ import Foundation + +struct RecordingCreationParameters { + let id: String + let startDate: Date + let recordingURL: URL + let microphoneURL: URL? + let hasMicrophoneAudio: Bool + let applicationName: String? +} + #if MOCKING -import Mockable + import Mockable #endif #if MOCKING -@Mockable + @Mockable #endif protocol RecordingRepositoryType { - func createRecording(id: String, startDate: Date, recordingURL: URL, microphoneURL: URL?, hasMicrophoneAudio: Bool, applicationName: String?) async throws -> RecordingInfo - func fetchRecording(id: String) async throws -> RecordingInfo? - func fetchAllRecordings() async throws -> [RecordingInfo] - func fetchRecordings(withState state: RecordingProcessingState) async throws -> [RecordingInfo] - func updateRecordingState(id: String, state: RecordingProcessingState, errorMessage: String?) async throws - func updateRecordingEndDate(id: String, endDate: Date) async throws - func updateRecordingTranscription(id: String, transcriptionText: String) async throws - func updateRecordingSummary(id: String, summaryText: String) async throws - func updateRecordingURLs(id: String, recordingURL: URL?, microphoneURL: URL?) async throws - func deleteRecording(id: String) async throws - func deleteAllRecordings() async throws -} \ No newline at end of file + func createRecording(_ parameters: RecordingCreationParameters) async throws -> RecordingInfo + func fetchRecording(id: String) async throws -> RecordingInfo? + func fetchAllRecordings() async throws -> [RecordingInfo] + func fetchRecordings(withState state: RecordingProcessingState) async throws -> [RecordingInfo] + func updateRecordingState(id: String, state: RecordingProcessingState, errorMessage: String?) + async throws + func updateRecordingEndDate(id: String, endDate: Date) async throws + func updateRecordingTranscription(id: String, transcriptionText: String) async throws + func updateRecordingTimestampedTranscription( + id: String, timestampedTranscription: TimestampedTranscription) async throws + func updateRecordingSummary(id: String, summaryText: String) async throws + func updateRecordingURLs(id: String, recordingURL: URL?, microphoneURL: URL?) async throws + func deleteRecording(id: String) async throws + func deleteAllRecordings() async throws +} diff --git a/Recap/Repositories/UserPreferences/UserPreferencesRepository.swift b/Recap/Repositories/UserPreferences/UserPreferencesRepository.swift index 0c1990f..a2b452a 100644 --- a/Recap/Repositories/UserPreferences/UserPreferencesRepository.swift +++ b/Recap/Repositories/UserPreferences/UserPreferencesRepository.swift @@ -1,240 +1,176 @@ -import Foundation import CoreData +import Foundation @MainActor final class UserPreferencesRepository: UserPreferencesRepositoryType { - private let coreDataManager: CoreDataManagerType - private let defaultPreferencesId = "default-preferences" - - init(coreDataManager: CoreDataManagerType) { - self.coreDataManager = coreDataManager - } - - func getOrCreatePreferences() async throws -> UserPreferencesInfo { - let context = coreDataManager.viewContext - let request: NSFetchRequest = UserPreferences.fetchRequest() - request.predicate = NSPredicate(format: "id == %@", defaultPreferencesId) - request.fetchLimit = 1 - - do { - let preferences = try context.fetch(request).first - - if let existingPreferences = preferences { - return UserPreferencesInfo(from: existingPreferences) - } else { - let newPreferences = UserPreferences(context: context) - newPreferences.id = defaultPreferencesId - newPreferences.createdAt = Date() - newPreferences.modifiedAt = Date() - newPreferences.autoSummarizeEnabled = true - newPreferences.selectedProvider = LLMProvider.default.rawValue - newPreferences.autoDetectMeetings = false - newPreferences.autoStopRecording = false - - try context.save() - return UserPreferencesInfo(from: newPreferences) - } - } catch { - throw LLMError.dataAccessError(error.localizedDescription) - } - } - - func updateSelectedLLMModel(id: String?) async throws { - let context = coreDataManager.viewContext - let request: NSFetchRequest = UserPreferences.fetchRequest() - request.predicate = NSPredicate(format: "id == %@", defaultPreferencesId) - request.fetchLimit = 1 - - do { - guard let preferences = try context.fetch(request).first else { - let newPreferences = UserPreferences(context: context) - newPreferences.id = defaultPreferencesId - newPreferences.selectedLLMModelID = id - newPreferences.selectedProvider = LLMProvider.default.rawValue - newPreferences.autoDetectMeetings = false - newPreferences.autoStopRecording = false - newPreferences.createdAt = Date() - newPreferences.modifiedAt = Date() - newPreferences.autoSummarizeEnabled = true - try context.save() - return - } - - preferences.selectedLLMModelID = id - preferences.modifiedAt = Date() - try context.save() - } catch { - throw LLMError.dataAccessError(error.localizedDescription) - } - } - - func updateSelectedProvider(_ provider: LLMProvider) async throws { - let context = coreDataManager.viewContext - let request: NSFetchRequest = UserPreferences.fetchRequest() - request.predicate = NSPredicate(format: "id == %@", defaultPreferencesId) - request.fetchLimit = 1 - - do { - guard let preferences = try context.fetch(request).first else { - let newPreferences = UserPreferences(context: context) - newPreferences.id = defaultPreferencesId - newPreferences.selectedProvider = provider.rawValue - newPreferences.autoDetectMeetings = false - newPreferences.autoStopRecording = false - newPreferences.createdAt = Date() - newPreferences.modifiedAt = Date() - newPreferences.autoSummarizeEnabled = true - try context.save() - return - } - - preferences.selectedProvider = provider.rawValue - preferences.modifiedAt = Date() - try context.save() - } catch { - throw LLMError.dataAccessError(error.localizedDescription) - } - } - - func updateAutoDetectMeetings(_ enabled: Bool) async throws { - let context = coreDataManager.viewContext - let request: NSFetchRequest = UserPreferences.fetchRequest() - request.predicate = NSPredicate(format: "id == %@", defaultPreferencesId) - request.fetchLimit = 1 - - do { - guard let preferences = try context.fetch(request).first else { - let newPreferences = UserPreferences(context: context) - newPreferences.id = defaultPreferencesId - newPreferences.autoDetectMeetings = enabled - newPreferences.autoStopRecording = false - newPreferences.selectedProvider = LLMProvider.default.rawValue - newPreferences.createdAt = Date() - newPreferences.modifiedAt = Date() - newPreferences.autoSummarizeEnabled = true - try context.save() - return - } - - preferences.autoDetectMeetings = enabled - preferences.modifiedAt = Date() - try context.save() - } catch { - throw LLMError.dataAccessError(error.localizedDescription) - } - } - - func updateAutoStopRecording(_ enabled: Bool) async throws { - let context = coreDataManager.viewContext - let request: NSFetchRequest = UserPreferences.fetchRequest() - request.predicate = NSPredicate(format: "id == %@", defaultPreferencesId) - request.fetchLimit = 1 - - do { - guard let preferences = try context.fetch(request).first else { - let newPreferences = UserPreferences(context: context) - newPreferences.id = defaultPreferencesId - newPreferences.autoDetectMeetings = false - newPreferences.autoStopRecording = enabled - newPreferences.selectedProvider = LLMProvider.default.rawValue - newPreferences.createdAt = Date() - newPreferences.modifiedAt = Date() - newPreferences.autoSummarizeEnabled = true - try context.save() - return - } - - preferences.autoStopRecording = enabled - preferences.modifiedAt = Date() - try context.save() - } catch { - throw LLMError.dataAccessError(error.localizedDescription) - } - } - - func updateSummaryPromptTemplate(_ template: String?) async throws { - let context = coreDataManager.viewContext - let request: NSFetchRequest = UserPreferences.fetchRequest() - request.predicate = NSPredicate(format: "id == %@", defaultPreferencesId) - request.fetchLimit = 1 - - do { - guard let preferences = try context.fetch(request).first else { - let newPreferences = UserPreferences(context: context) - newPreferences.id = defaultPreferencesId - newPreferences.summaryPromptTemplate = template - newPreferences.selectedProvider = LLMProvider.default.rawValue - newPreferences.autoDetectMeetings = false - newPreferences.autoStopRecording = false - newPreferences.createdAt = Date() - newPreferences.modifiedAt = Date() - newPreferences.autoSummarizeEnabled = true - try context.save() - return - } - - preferences.summaryPromptTemplate = template - preferences.modifiedAt = Date() - try context.save() - } catch { - throw LLMError.dataAccessError(error.localizedDescription) - } - } - - func updateAutoSummarize(_ enabled: Bool) async throws { - let context = coreDataManager.viewContext - let request: NSFetchRequest = UserPreferences.fetchRequest() - request.predicate = NSPredicate(format: "id == %@", defaultPreferencesId) - request.fetchLimit = 1 - - do { - guard let preferences = try context.fetch(request).first else { - let newPreferences = UserPreferences(context: context) - newPreferences.id = defaultPreferencesId - newPreferences.autoSummarizeEnabled = enabled - newPreferences.selectedProvider = LLMProvider.default.rawValue - newPreferences.autoDetectMeetings = false - newPreferences.autoStopRecording = false - newPreferences.createdAt = Date() - newPreferences.modifiedAt = Date() - try context.save() - return - } - - preferences.autoSummarizeEnabled = enabled - preferences.modifiedAt = Date() - try context.save() - } catch { - throw LLMError.dataAccessError(error.localizedDescription) - } - } - - func updateOnboardingStatus(_ completed: Bool) async throws { - let context = coreDataManager.viewContext - let request: NSFetchRequest = UserPreferences.fetchRequest() - request.predicate = NSPredicate(format: "id == %@", defaultPreferencesId) - request.fetchLimit = 1 - - do { - guard let preferences = try context.fetch(request).first else { - let newPreferences = UserPreferences(context: context) - newPreferences.id = defaultPreferencesId - newPreferences.onboarded = completed - newPreferences.selectedProvider = LLMProvider.default.rawValue - newPreferences.autoDetectMeetings = false - newPreferences.autoStopRecording = false - newPreferences.autoSummarizeEnabled = true - newPreferences.createdAt = Date() - newPreferences.modifiedAt = Date() - try context.save() - return - } - - preferences.onboarded = completed - preferences.modifiedAt = Date() - try context.save() - } catch { - throw LLMError.dataAccessError(error.localizedDescription) - } + private let coreDataManager: CoreDataManagerType + private let defaultPreferencesId = "default-preferences" + + init(coreDataManager: CoreDataManagerType) { + self.coreDataManager = coreDataManager + } + + func getOrCreatePreferences() async throws -> UserPreferencesInfo { + let context = coreDataManager.viewContext + let request: NSFetchRequest = UserPreferences.fetchRequest() + request.predicate = NSPredicate(format: "id == %@", defaultPreferencesId) + request.fetchLimit = 1 + + do { + let preferences = try context.fetch(request).first + + if let existingPreferences = preferences { + syncToUserDefaults(existingPreferences) + return UserPreferencesInfo(from: existingPreferences) + } else { + return try createDefaultPreferences(in: context) + } + } catch { + throw LLMError.dataAccessError(error.localizedDescription) + } + } + + private func syncToUserDefaults(_ preferences: UserPreferences) { + if let customPath = preferences.customTmpDirectoryPath { + UserDefaults.standard.set(customPath, forKey: "customTmpDirectoryPath") + if let bookmark = preferences.customTmpDirectoryBookmark { + UserDefaults.standard.set(bookmark, forKey: "customTmpDirectoryBookmark") + } + } + } + + private func createDefaultPreferences(in context: NSManagedObjectContext) throws + -> UserPreferencesInfo { + let newPreferences = UserPreferences(context: context) + newPreferences.id = defaultPreferencesId + newPreferences.createdAt = Date() + newPreferences.modifiedAt = Date() + newPreferences.autoSummarizeEnabled = true + newPreferences.autoSummarizeDuringRecording = true + newPreferences.autoSummarizeAfterRecording = true + newPreferences.autoTranscribeEnabled = true + newPreferences.selectedProvider = LLMProvider.default.rawValue + newPreferences.autoDetectMeetings = false + newPreferences.autoStopRecording = false + + try context.save() + return UserPreferencesInfo(from: newPreferences) + } + + func fetchOrCreatePreferences( + in context: NSManagedObjectContext + ) throws -> UserPreferences { + let request: NSFetchRequest = UserPreferences.fetchRequest() + request.predicate = NSPredicate(format: "id == %@", defaultPreferencesId) + request.fetchLimit = 1 + + if let existing = try context.fetch(request).first { + return existing + } + + let newPreferences = UserPreferences(context: context) + newPreferences.id = defaultPreferencesId + newPreferences.createdAt = Date() + newPreferences.modifiedAt = Date() + newPreferences.autoSummarizeEnabled = true + newPreferences.selectedProvider = LLMProvider.default.rawValue + newPreferences.autoDetectMeetings = false + newPreferences.autoStopRecording = false + newPreferences.onboarded = false + + return newPreferences + } + + private func performUpdate( + _ updateBlock: (UserPreferences) throws -> Void + ) async throws { + let context = coreDataManager.viewContext + do { + let preferences = try fetchOrCreatePreferences(in: context) + try updateBlock(preferences) + preferences.modifiedAt = Date() + try context.save() + } catch { + throw LLMError.dataAccessError(error.localizedDescription) + } + } + + func updateSelectedLLMModel(id: String?) async throws { + try await performUpdate { preferences in + preferences.selectedLLMModelID = id + } + } + + func updateSelectedProvider(_ provider: LLMProvider) async throws { + try await performUpdate { preferences in + preferences.selectedProvider = provider.rawValue + } + } + + func updateAutoDetectMeetings(_ enabled: Bool) async throws { + try await performUpdate { preferences in + preferences.autoDetectMeetings = enabled + } + } + + func updateAutoStopRecording(_ enabled: Bool) async throws { + try await performUpdate { preferences in + preferences.autoStopRecording = enabled + } + } + + func updateSummaryPromptTemplate(_ template: String?) async throws { + try await performUpdate { preferences in + preferences.summaryPromptTemplate = template + } + } + + func updateAutoSummarize(_ enabled: Bool) async throws { + try await performUpdate { preferences in + preferences.autoSummarizeEnabled = enabled + } + } + + func updateAutoTranscribe(_ enabled: Bool) async throws { + try await performUpdate { preferences in + preferences.autoTranscribeEnabled = enabled + } + } + + func updateOnboardingStatus(_ completed: Bool) async throws { + try await performUpdate { preferences in + preferences.onboarded = completed + } + } + + func updateMicrophoneEnabled(_ enabled: Bool) async throws { + try await performUpdate { preferences in + preferences.microphoneEnabled = enabled + } + } + + func updateGlobalShortcut(keyCode: Int32, modifiers: Int32) async throws { + try await performUpdate { preferences in + preferences.globalShortcutKeyCode = keyCode + preferences.globalShortcutModifiers = modifiers + } + } + + func updateCustomTmpDirectory(path: String?, bookmark: Data?) async throws { + try await performUpdate { preferences in + preferences.customTmpDirectoryPath = path + preferences.customTmpDirectoryBookmark = bookmark + } + + // Also save to UserDefaults for synchronous access + if let path = path { + UserDefaults.standard.set(path, forKey: "customTmpDirectoryPath") + if let bookmark = bookmark { + UserDefaults.standard.set(bookmark, forKey: "customTmpDirectoryBookmark") + } + } else { + UserDefaults.standard.removeObject(forKey: "customTmpDirectoryPath") + UserDefaults.standard.removeObject(forKey: "customTmpDirectoryBookmark") } + } } diff --git a/Recap/Repositories/UserPreferences/UserPreferencesRepositoryType.swift b/Recap/Repositories/UserPreferences/UserPreferencesRepositoryType.swift index e87ef01..ecaf3d5 100644 --- a/Recap/Repositories/UserPreferences/UserPreferencesRepositoryType.swift +++ b/Recap/Repositories/UserPreferences/UserPreferencesRepositoryType.swift @@ -1,19 +1,24 @@ import Foundation + #if MOCKING -import Mockable + import Mockable #endif #if MOCKING -@Mockable + @Mockable #endif @MainActor protocol UserPreferencesRepositoryType { - func getOrCreatePreferences() async throws -> UserPreferencesInfo - func updateSelectedLLMModel(id: String?) async throws - func updateSelectedProvider(_ provider: LLMProvider) async throws - func updateAutoDetectMeetings(_ enabled: Bool) async throws - func updateAutoStopRecording(_ enabled: Bool) async throws - func updateAutoSummarize(_ enabled: Bool) async throws - func updateSummaryPromptTemplate(_ template: String?) async throws - func updateOnboardingStatus(_ completed: Bool) async throws + func getOrCreatePreferences() async throws -> UserPreferencesInfo + func updateSelectedLLMModel(id: String?) async throws + func updateSelectedProvider(_ provider: LLMProvider) async throws + func updateAutoDetectMeetings(_ enabled: Bool) async throws + func updateAutoStopRecording(_ enabled: Bool) async throws + func updateAutoSummarize(_ enabled: Bool) async throws + func updateAutoTranscribe(_ enabled: Bool) async throws + func updateSummaryPromptTemplate(_ template: String?) async throws + func updateOnboardingStatus(_ completed: Bool) async throws + func updateMicrophoneEnabled(_ enabled: Bool) async throws + func updateGlobalShortcut(keyCode: Int32, modifiers: Int32) async throws + func updateCustomTmpDirectory(path: String?, bookmark: Data?) async throws } diff --git a/Recap/Repositories/WhisperModels/WhisperModelRepository.swift b/Recap/Repositories/WhisperModels/WhisperModelRepository.swift index f26a1b8..2ca1531 100644 --- a/Recap/Repositories/WhisperModels/WhisperModelRepository.swift +++ b/Recap/Repositories/WhisperModels/WhisperModelRepository.swift @@ -1,153 +1,154 @@ -import Foundation import CoreData +import Foundation @MainActor final class WhisperModelRepository: WhisperModelRepositoryType { - private let coreDataManager: CoreDataManagerType - - init(coreDataManager: CoreDataManagerType) { - self.coreDataManager = coreDataManager - } - - func getAllModels() async throws -> [WhisperModelData] { - let context = coreDataManager.viewContext - let request = WhisperModel.fetchRequest() - request.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)] - - let models = try context.fetch(request) - return models.map { mapToData($0) } - } - - func getDownloadedModels() async throws -> [WhisperModelData] { - let context = coreDataManager.viewContext - let request = WhisperModel.fetchRequest() - request.predicate = NSPredicate(format: "isDownloaded == YES") - request.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)] - - let models = try context.fetch(request) - return models.map { mapToData($0) } - } - - func getSelectedModel() async throws -> WhisperModelData? { - let context = coreDataManager.viewContext - let request = WhisperModel.fetchRequest() - request.predicate = NSPredicate(format: "isSelected == YES") - request.fetchLimit = 1 - - let models = try context.fetch(request) - return models.first.map { mapToData($0) } - } - - func saveModel(_ model: WhisperModelData) async throws { - let context = coreDataManager.viewContext - - let whisperModel = WhisperModel(context: context) - whisperModel.name = model.name - whisperModel.isDownloaded = model.isDownloaded - whisperModel.isSelected = model.isSelected - whisperModel.downloadedAt = Int64(model.downloadedAt?.timeIntervalSince1970 ?? 0) - whisperModel.fileSizeInMB = model.fileSizeInMB ?? 0 - whisperModel.variant = model.variant - - try coreDataManager.save() - } - - func updateModel(_ model: WhisperModelData) async throws { - let context = coreDataManager.viewContext - let request = WhisperModel.fetchRequest() - request.predicate = NSPredicate(format: "name == %@", model.name) - request.fetchLimit = 1 - - guard let existingModel = try context.fetch(request).first else { - throw WhisperModelRepositoryError.modelNotFound(model.name) - } - - existingModel.isDownloaded = model.isDownloaded - existingModel.isSelected = model.isSelected - existingModel.downloadedAt = Int64(model.downloadedAt?.timeIntervalSince1970 ?? 0) - existingModel.fileSizeInMB = model.fileSizeInMB ?? 0 - existingModel.variant = model.variant - - try coreDataManager.save() - } - - func deleteModel(name: String) async throws { - let context = coreDataManager.viewContext - let request = WhisperModel.fetchRequest() - request.predicate = NSPredicate(format: "name == %@", name) - - let models = try context.fetch(request) - models.forEach { context.delete($0) } - - try coreDataManager.save() - } - - func setSelectedModel(name: String) async throws { - let context = coreDataManager.viewContext - - let deselectRequest = WhisperModel.fetchRequest() - deselectRequest.predicate = NSPredicate(format: "isSelected == YES") - let selectedModels = try context.fetch(deselectRequest) - selectedModels.forEach { $0.isSelected = false } - - let selectRequest = WhisperModel.fetchRequest() - selectRequest.predicate = NSPredicate(format: "name == %@ AND isDownloaded == YES", name) - selectRequest.fetchLimit = 1 - - guard let modelToSelect = try context.fetch(selectRequest).first else { - throw WhisperModelRepositoryError.modelNotDownloaded(name) - } - - modelToSelect.isSelected = true - try coreDataManager.save() + private let coreDataManager: CoreDataManagerType + + init(coreDataManager: CoreDataManagerType) { + self.coreDataManager = coreDataManager + } + + func getAllModels() async throws -> [WhisperModelData] { + let context = coreDataManager.viewContext + let request = WhisperModel.fetchRequest() + request.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)] + + let models = try context.fetch(request) + return models.map { mapToData($0) } + } + + func getDownloadedModels() async throws -> [WhisperModelData] { + let context = coreDataManager.viewContext + let request = WhisperModel.fetchRequest() + request.predicate = NSPredicate(format: "isDownloaded == YES") + request.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)] + + let models = try context.fetch(request) + return models.map { mapToData($0) } + } + + func getSelectedModel() async throws -> WhisperModelData? { + let context = coreDataManager.viewContext + let request = WhisperModel.fetchRequest() + request.predicate = NSPredicate(format: "isSelected == YES") + request.fetchLimit = 1 + + let models = try context.fetch(request) + return models.first.map { mapToData($0) } + } + + func saveModel(_ model: WhisperModelData) async throws { + let context = coreDataManager.viewContext + + let whisperModel = WhisperModel(context: context) + whisperModel.name = model.name + whisperModel.isDownloaded = model.isDownloaded + whisperModel.isSelected = model.isSelected + whisperModel.downloadedAt = Int64(model.downloadedAt?.timeIntervalSince1970 ?? 0) + whisperModel.fileSizeInMB = model.fileSizeInMB ?? 0 + whisperModel.variant = model.variant + + try coreDataManager.save() + } + + func updateModel(_ model: WhisperModelData) async throws { + let context = coreDataManager.viewContext + let request = WhisperModel.fetchRequest() + request.predicate = NSPredicate(format: "name == %@", model.name) + request.fetchLimit = 1 + + guard let existingModel = try context.fetch(request).first else { + throw WhisperModelRepositoryError.modelNotFound(model.name) } - - func markAsDownloaded(name: String, sizeInMB: Int64?) async throws { - let context = coreDataManager.viewContext - let request = WhisperModel.fetchRequest() - request.predicate = NSPredicate(format: "name == %@", name) - request.fetchLimit = 1 - - if let existingModel = try context.fetch(request).first { - existingModel.isDownloaded = true - existingModel.downloadedAt = Int64(Date().timeIntervalSince1970) - if let size = sizeInMB { - existingModel.fileSizeInMB = size - } - } else { - let newModel = WhisperModel(context: context) - newModel.name = name - newModel.isDownloaded = true - newModel.downloadedAt = Int64(Date().timeIntervalSince1970) - newModel.fileSizeInMB = sizeInMB ?? 0 - newModel.isSelected = false - } - - try coreDataManager.save() + + existingModel.isDownloaded = model.isDownloaded + existingModel.isSelected = model.isSelected + existingModel.downloadedAt = Int64(model.downloadedAt?.timeIntervalSince1970 ?? 0) + existingModel.fileSizeInMB = model.fileSizeInMB ?? 0 + existingModel.variant = model.variant + + try coreDataManager.save() + } + + func deleteModel(name: String) async throws { + let context = coreDataManager.viewContext + let request = WhisperModel.fetchRequest() + request.predicate = NSPredicate(format: "name == %@", name) + + let models = try context.fetch(request) + models.forEach { context.delete($0) } + + try coreDataManager.save() + } + + func setSelectedModel(name: String) async throws { + let context = coreDataManager.viewContext + + let deselectRequest = WhisperModel.fetchRequest() + deselectRequest.predicate = NSPredicate(format: "isSelected == YES") + let selectedModels = try context.fetch(deselectRequest) + selectedModels.forEach { $0.isSelected = false } + + let selectRequest = WhisperModel.fetchRequest() + selectRequest.predicate = NSPredicate(format: "name == %@ AND isDownloaded == YES", name) + selectRequest.fetchLimit = 1 + + guard let modelToSelect = try context.fetch(selectRequest).first else { + throw WhisperModelRepositoryError.modelNotDownloaded(name) } - - private func mapToData(_ model: WhisperModel) -> WhisperModelData { - WhisperModelData( - name: model.name ?? "", - isDownloaded: model.isDownloaded, - isSelected: model.isSelected, - downloadedAt: model.downloadedAt > 0 ? Date(timeIntervalSince1970: TimeInterval(model.downloadedAt)) : nil, - fileSizeInMB: model.fileSizeInMB > 0 ? model.fileSizeInMB : nil, - variant: model.variant - ) + + modelToSelect.isSelected = true + try coreDataManager.save() + } + + func markAsDownloaded(name: String, sizeInMB: Int64?) async throws { + let context = coreDataManager.viewContext + let request = WhisperModel.fetchRequest() + request.predicate = NSPredicate(format: "name == %@", name) + request.fetchLimit = 1 + + if let existingModel = try context.fetch(request).first { + existingModel.isDownloaded = true + existingModel.downloadedAt = Int64(Date().timeIntervalSince1970) + if let size = sizeInMB { + existingModel.fileSizeInMB = size + } + } else { + let newModel = WhisperModel(context: context) + newModel.name = name + newModel.isDownloaded = true + newModel.downloadedAt = Int64(Date().timeIntervalSince1970) + newModel.fileSizeInMB = sizeInMB ?? 0 + newModel.isSelected = false } + + try coreDataManager.save() + } + + private func mapToData(_ model: WhisperModel) -> WhisperModelData { + WhisperModelData( + name: model.name ?? "", + isDownloaded: model.isDownloaded, + isSelected: model.isSelected, + downloadedAt: model.downloadedAt > 0 + ? Date(timeIntervalSince1970: TimeInterval(model.downloadedAt)) : nil, + fileSizeInMB: model.fileSizeInMB > 0 ? model.fileSizeInMB : nil, + variant: model.variant + ) + } } enum WhisperModelRepositoryError: LocalizedError { - case modelNotFound(String) - case modelNotDownloaded(String) - - var errorDescription: String? { - switch self { - case .modelNotFound(let name): - return "Model '\(name)' not found" - case .modelNotDownloaded(let name): - return "Model '\(name)' is not downloaded" - } + case modelNotFound(String) + case modelNotDownloaded(String) + + var errorDescription: String? { + switch self { + case .modelNotFound(let name): + return "Model '\(name)' not found" + case .modelNotDownloaded(let name): + return "Model '\(name)' is not downloaded" } + } } diff --git a/Recap/Repositories/WhisperModels/WhisperModelRepositoryType.swift b/Recap/Repositories/WhisperModels/WhisperModelRepositoryType.swift index 287b31c..1d34084 100644 --- a/Recap/Repositories/WhisperModels/WhisperModelRepositoryType.swift +++ b/Recap/Repositories/WhisperModels/WhisperModelRepositoryType.swift @@ -1,28 +1,29 @@ import Foundation + #if MOCKING -import Mockable + import Mockable #endif #if MOCKING -@Mockable + @Mockable #endif @MainActor protocol WhisperModelRepositoryType { - func getAllModels() async throws -> [WhisperModelData] - func getDownloadedModels() async throws -> [WhisperModelData] - func getSelectedModel() async throws -> WhisperModelData? - func saveModel(_ model: WhisperModelData) async throws - func updateModel(_ model: WhisperModelData) async throws - func deleteModel(name: String) async throws - func setSelectedModel(name: String) async throws - func markAsDownloaded(name: String, sizeInMB: Int64?) async throws + func getAllModels() async throws -> [WhisperModelData] + func getDownloadedModels() async throws -> [WhisperModelData] + func getSelectedModel() async throws -> WhisperModelData? + func saveModel(_ model: WhisperModelData) async throws + func updateModel(_ model: WhisperModelData) async throws + func deleteModel(name: String) async throws + func setSelectedModel(name: String) async throws + func markAsDownloaded(name: String, sizeInMB: Int64?) async throws } struct WhisperModelData: Equatable { - let name: String - var isDownloaded: Bool - var isSelected: Bool - var downloadedAt: Date? - var fileSizeInMB: Int64? - var variant: String? -} \ No newline at end of file + let name: String + var isDownloaded: Bool + var isSelected: Bool + var downloadedAt: Date? + var fileSizeInMB: Int64? + var variant: String? +} diff --git a/Recap/Services/CoreData/CoreDataManager.swift b/Recap/Services/CoreData/CoreDataManager.swift index d6c2ccd..5e7f2fa 100644 --- a/Recap/Services/CoreData/CoreDataManager.swift +++ b/Recap/Services/CoreData/CoreDataManager.swift @@ -1,38 +1,38 @@ import CoreData final class CoreDataManager: CoreDataManagerType { - private let persistentContainer: NSPersistentContainer - - var viewContext: NSManagedObjectContext { - persistentContainer.viewContext - } - - init(modelName: String = "RecapDataModel", inMemory: Bool = false) { - persistentContainer = NSPersistentContainer(name: modelName) - - if inMemory { - persistentContainer.persistentStoreDescriptions.first?.url = URL(fileURLWithPath: "/dev/null") - } - - persistentContainer.loadPersistentStores { _, error in - if let error = error { - fatalError("Failed to load Core Data stack: \(error)") - } - } - - viewContext.automaticallyMergesChangesFromParent = true - } - - func save() throws { - guard viewContext.hasChanges else { return } - try viewContext.save() - } - - func performBackgroundTask(_ block: @escaping (NSManagedObjectContext) -> Void) { - persistentContainer.performBackgroundTask(block) + private let persistentContainer: NSPersistentContainer + + var viewContext: NSManagedObjectContext { + persistentContainer.viewContext + } + + init(modelName: String = "RecapDataModel", inMemory: Bool = false) { + persistentContainer = NSPersistentContainer(name: modelName) + + if inMemory { + persistentContainer.persistentStoreDescriptions.first?.url = URL(fileURLWithPath: "/dev/null") } - - func newBackgroundContext() -> NSManagedObjectContext { - persistentContainer.newBackgroundContext() + + persistentContainer.loadPersistentStores { _, error in + if let error = error { + fatalError("Failed to load Core Data stack: \(error)") + } } -} \ No newline at end of file + + viewContext.automaticallyMergesChangesFromParent = true + } + + func save() throws { + guard viewContext.hasChanges else { return } + try viewContext.save() + } + + func performBackgroundTask(_ block: @escaping (NSManagedObjectContext) -> Void) { + persistentContainer.performBackgroundTask(block) + } + + func newBackgroundContext() -> NSManagedObjectContext { + persistentContainer.newBackgroundContext() + } +} diff --git a/Recap/Services/CoreData/CoreDataManagerType.swift b/Recap/Services/CoreData/CoreDataManagerType.swift index 1c80871..e8af73c 100644 --- a/Recap/Services/CoreData/CoreDataManagerType.swift +++ b/Recap/Services/CoreData/CoreDataManagerType.swift @@ -1,8 +1,8 @@ import CoreData protocol CoreDataManagerType { - var viewContext: NSManagedObjectContext { get } - func save() throws - func performBackgroundTask(_ block: @escaping (NSManagedObjectContext) -> Void) - func newBackgroundContext() -> NSManagedObjectContext -} \ No newline at end of file + var viewContext: NSManagedObjectContext { get } + func save() throws + func performBackgroundTask(_ block: @escaping (NSManagedObjectContext) -> Void) + func newBackgroundContext() -> NSManagedObjectContext +} diff --git a/Recap/Services/Keychain/KeychainAPIValidator.swift b/Recap/Services/Keychain/KeychainAPIValidator.swift index ceea460..0e90104 100644 --- a/Recap/Services/Keychain/KeychainAPIValidator.swift +++ b/Recap/Services/Keychain/KeychainAPIValidator.swift @@ -1,31 +1,52 @@ import Foundation final class KeychainAPIValidator: KeychainAPIValidatorType { - private let keychainService: KeychainServiceType - - init(keychainService: KeychainServiceType = KeychainService()) { - self.keychainService = keychainService - } - - func validateOpenRouterAPI() -> APIValidationResult { - do { - guard let apiKey = try keychainService.retrieve(key: KeychainKey.openRouterApiKey.key), - !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - return .missingApiKey - } - - guard isValidOpenRouterAPIKeyFormat(apiKey) else { - return .invalidApiKey - } - - return .valid - } catch { - return .missingApiKey - } + private let keychainService: KeychainServiceType + + init(keychainService: KeychainServiceType = KeychainService()) { + self.keychainService = keychainService + } + + func validateOpenRouterAPI() -> APIValidationResult { + do { + guard let apiKey = try keychainService.retrieve(key: KeychainKey.openRouterApiKey.key), + !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + return .missingApiKey + } + + guard isValidOpenRouterAPIKeyFormat(apiKey) else { + return .invalidApiKey + } + + return .valid + } catch { + return .missingApiKey } - - private func isValidOpenRouterAPIKeyFormat(_ apiKey: String) -> Bool { - let trimmedKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmedKey.hasPrefix("sk-or-") && trimmedKey.count > 10 + } + + func validateOpenAIAPI() -> APIValidationResult { + do { + guard let apiKey = try keychainService.retrieve(key: KeychainKey.openAIApiKey.key), + !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + return .missingApiKey + } + + guard let endpoint = try keychainService.retrieve(key: KeychainKey.openAIEndpoint.key), + !endpoint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + return .missingApiKey + } + + return .valid + } catch { + return .missingApiKey } + } + + private func isValidOpenRouterAPIKeyFormat(_ apiKey: String) -> Bool { + let trimmedKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmedKey.hasPrefix("sk-or-") && trimmedKey.count > 10 + } } diff --git a/Recap/Services/Keychain/KeychainAPIValidatorType.swift b/Recap/Services/Keychain/KeychainAPIValidatorType.swift index ec2e574..27c9f93 100644 --- a/Recap/Services/Keychain/KeychainAPIValidatorType.swift +++ b/Recap/Services/Keychain/KeychainAPIValidatorType.swift @@ -1,37 +1,39 @@ import Foundation + #if MOCKING -import Mockable + import Mockable #endif #if MOCKING -@Mockable + @Mockable #endif protocol KeychainAPIValidatorType { - func validateOpenRouterAPI() -> APIValidationResult + func validateOpenRouterAPI() -> APIValidationResult + func validateOpenAIAPI() -> APIValidationResult } enum APIValidationResult { - case valid - case missingApiKey - case invalidApiKey - - var isValid: Bool { - switch self { - case .valid: - return true - case .missingApiKey, .invalidApiKey: - return false - } + case valid + case missingApiKey + case invalidApiKey + + var isValid: Bool { + switch self { + case .valid: + return true + case .missingApiKey, .invalidApiKey: + return false } - - var errorMessage: String? { - switch self { - case .valid: - return nil - case .missingApiKey: - return "API key not found. Please add your OpenRouter API key in settings." - case .invalidApiKey: - return "Invalid API key format. Please check your OpenRouter API key." - } + } + + var errorMessage: String? { + switch self { + case .valid: + return nil + case .missingApiKey: + return "API key not found. Please add your OpenRouter API key in settings." + case .invalidApiKey: + return "Invalid API key format. Please check your OpenRouter API key." } + } } diff --git a/Recap/Services/Keychain/KeychainService+Extensions.swift b/Recap/Services/Keychain/KeychainService+Extensions.swift index d96dd66..8a76731 100644 --- a/Recap/Services/Keychain/KeychainService+Extensions.swift +++ b/Recap/Services/Keychain/KeychainService+Extensions.swift @@ -1,19 +1,51 @@ import Foundation extension KeychainServiceType { - func storeOpenRouterAPIKey(_ apiKey: String) throws { - try store(key: KeychainKey.openRouterApiKey.key, value: apiKey) - } - - func retrieveOpenRouterAPIKey() throws -> String? { - try retrieve(key: KeychainKey.openRouterApiKey.key) - } - - func deleteOpenRouterAPIKey() throws { - try delete(key: KeychainKey.openRouterApiKey.key) - } - - func hasOpenRouterAPIKey() -> Bool { - exists(key: KeychainKey.openRouterApiKey.key) - } + func storeOpenRouterAPIKey(_ apiKey: String) throws { + try store(key: KeychainKey.openRouterApiKey.key, value: apiKey) + } + + func retrieveOpenRouterAPIKey() throws -> String? { + try retrieve(key: KeychainKey.openRouterApiKey.key) + } + + func deleteOpenRouterAPIKey() throws { + try delete(key: KeychainKey.openRouterApiKey.key) + } + + func hasOpenRouterAPIKey() -> Bool { + exists(key: KeychainKey.openRouterApiKey.key) + } + + func storeOpenAIAPIKey(_ apiKey: String) throws { + try store(key: KeychainKey.openAIApiKey.key, value: apiKey) + } + + func retrieveOpenAIAPIKey() throws -> String? { + try retrieve(key: KeychainKey.openAIApiKey.key) + } + + func deleteOpenAIAPIKey() throws { + try delete(key: KeychainKey.openAIApiKey.key) + } + + func hasOpenAIAPIKey() -> Bool { + exists(key: KeychainKey.openAIApiKey.key) + } + + func storeOpenAIEndpoint(_ endpoint: String) throws { + try store(key: KeychainKey.openAIEndpoint.key, value: endpoint) + } + + func retrieveOpenAIEndpoint() throws -> String? { + try retrieve(key: KeychainKey.openAIEndpoint.key) + } + + func deleteOpenAIEndpoint() throws { + try delete(key: KeychainKey.openAIEndpoint.key) + } + + func hasOpenAIEndpoint() -> Bool { + exists(key: KeychainKey.openAIEndpoint.key) + } } diff --git a/Recap/Services/Keychain/KeychainService.swift b/Recap/Services/Keychain/KeychainService.swift index 20fbfd6..ca0e6b6 100644 --- a/Recap/Services/Keychain/KeychainService.swift +++ b/Recap/Services/Keychain/KeychainService.swift @@ -2,114 +2,115 @@ import Foundation import Security final class KeychainService: KeychainServiceType { - private let service: String - - init(service: String = Bundle.main.bundleIdentifier ?? "com.recap.app") { - self.service = service + private let service: String + + init(service: String = Bundle.main.bundleIdentifier ?? "com.recap.app") { + self.service = service + } + + func store(key: String, value: String) throws { + guard let data = value.data(using: .utf8) else { + throw KeychainError.invalidData } - - func store(key: String, value: String) throws { - guard let data = value.data(using: .utf8) else { - throw KeychainError.invalidData - } - - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: key, - kSecValueData as String: data - ] - - let status = SecItemAdd(query as CFDictionary, nil) - - switch status { - case errSecSuccess: - break - case errSecDuplicateItem: - try update(key: key, value: value) - default: - throw KeychainError.unexpectedStatus(status) - } + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecValueData as String: data + ] + + let status = SecItemAdd(query as CFDictionary, nil) + + switch status { + case errSecSuccess: + break + case errSecDuplicateItem: + try update(key: key, value: value) + default: + throw KeychainError.unexpectedStatus(status) } - - func retrieve(key: String) throws -> String? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: key, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne - ] - - var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) - - switch status { - case errSecSuccess: - guard let data = result as? Data, - let string = String(data: data, encoding: .utf8) else { - throw KeychainError.invalidData - } - return string - case errSecItemNotFound: - return nil - default: - throw KeychainError.unexpectedStatus(status) - } + } + + func retrieve(key: String) throws -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + switch status { + case errSecSuccess: + guard let data = result as? Data, + let string = String(data: data, encoding: .utf8) + else { + throw KeychainError.invalidData + } + return string + case errSecItemNotFound: + return nil + default: + throw KeychainError.unexpectedStatus(status) } - - func delete(key: String) throws { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: key - ] - - let status = SecItemDelete(query as CFDictionary) - - switch status { - case errSecSuccess, errSecItemNotFound: - break - default: - throw KeychainError.unexpectedStatus(status) - } + } + + func delete(key: String) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key + ] + + let status = SecItemDelete(query as CFDictionary) + + switch status { + case errSecSuccess, errSecItemNotFound: + break + default: + throw KeychainError.unexpectedStatus(status) } - - func exists(key: String) -> Bool { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: key, - kSecReturnData as String: false, - kSecMatchLimit as String: kSecMatchLimitOne - ] - - let status = SecItemCopyMatching(query as CFDictionary, nil) - return status == errSecSuccess + } + + func exists(key: String) -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecReturnData as String: false, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + let status = SecItemCopyMatching(query as CFDictionary, nil) + return status == errSecSuccess + } + + private func update(key: String, value: String) throws { + guard let data = value.data(using: .utf8) else { + throw KeychainError.invalidData } - - private func update(key: String, value: String) throws { - guard let data = value.data(using: .utf8) else { - throw KeychainError.invalidData - } - - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: key - ] - - let attributes: [String: Any] = [ - kSecValueData as String: data - ] - - let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) - - switch status { - case errSecSuccess: - break - default: - throw KeychainError.unexpectedStatus(status) - } + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key + ] + + let attributes: [String: Any] = [ + kSecValueData as String: data + ] + + let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + + switch status { + case errSecSuccess: + break + default: + throw KeychainError.unexpectedStatus(status) } + } } diff --git a/Recap/Services/Keychain/KeychainServiceType.swift b/Recap/Services/Keychain/KeychainServiceType.swift index d6cab91..9ce4018 100644 --- a/Recap/Services/Keychain/KeychainServiceType.swift +++ b/Recap/Services/Keychain/KeychainServiceType.swift @@ -1,42 +1,45 @@ import Foundation + #if MOCKING -import Mockable + import Mockable #endif #if MOCKING -@Mockable + @Mockable #endif protocol KeychainServiceType { - func store(key: String, value: String) throws - func retrieve(key: String) throws -> String? - func delete(key: String) throws - func exists(key: String) -> Bool + func store(key: String, value: String) throws + func retrieve(key: String) throws -> String? + func delete(key: String) throws + func exists(key: String) -> Bool } enum KeychainError: Error, LocalizedError { - case invalidData - case itemNotFound - case duplicateItem - case unexpectedStatus(OSStatus) - - var errorDescription: String? { - switch self { - case .invalidData: - return "Invalid data provided for keychain operation" - case .itemNotFound: - return "Item not found in keychain" - case .duplicateItem: - return "Item already exists in keychain" - case .unexpectedStatus(let status): - return "Keychain operation failed with status: \(status)" - } + case invalidData + case itemNotFound + case duplicateItem + case unexpectedStatus(OSStatus) + + var errorDescription: String? { + switch self { + case .invalidData: + return "Invalid data provided for keychain operation" + case .itemNotFound: + return "Item not found in keychain" + case .duplicateItem: + return "Item already exists in keychain" + case .unexpectedStatus(let status): + return "Keychain operation failed with status: \(status)" } + } } enum KeychainKey: String, CaseIterable { - case openRouterApiKey = "openrouter_api_key" - - var key: String { - return "com.recap.\(rawValue)" - } + case openRouterApiKey = "openrouter_api_key" + case openAIApiKey = "openai_api_key" + case openAIEndpoint = "openai_endpoint" + + var key: String { + return "com.recap.\(rawValue)" + } } diff --git a/Recap/Services/LLM/Core/LLMError.swift b/Recap/Services/LLM/Core/LLMError.swift index ba30deb..98a3db8 100644 --- a/Recap/Services/LLM/Core/LLMError.swift +++ b/Recap/Services/LLM/Core/LLMError.swift @@ -1,51 +1,51 @@ import Foundation enum LLMError: Error, LocalizedError { - case providerNotAvailable - case modelNotFound(String) - case modelNotDownloaded(String) - case invalidResponse - case networkError(Error) - case configurationError(String) - case taskCancelled - case invalidPrompt - case tokenLimitExceeded - case rateLimitExceeded - case insufficientMemory - case unsupportedModel(String) - case dataAccessError(String) - case apiError(String) - - var errorDescription: String? { - switch self { - case .providerNotAvailable: - return "LLM provider is not available. Please ensure it is installed and running." - case .modelNotFound(let modelName): - return "Model '\(modelName)' not found." - case .modelNotDownloaded(let modelName): - return "Model '\(modelName)' is not downloaded locally." - case .invalidResponse: - return "Received invalid response from LLM provider." - case .networkError(let error): - return "Network error: \(error.localizedDescription)" - case .configurationError(let message): - return "Configuration error: \(message)" - case .taskCancelled: - return "Task was cancelled." - case .invalidPrompt: - return "Invalid prompt provided." - case .tokenLimitExceeded: - return "Token limit exceeded for this request." - case .rateLimitExceeded: - return "Rate limit exceeded. Please try again later." - case .insufficientMemory: - return "Insufficient memory to load model." - case .unsupportedModel(let modelName): - return "Model '\(modelName)' is not supported by this provider." - case .dataAccessError(let message): - return "Data access error: \(message)" - case .apiError(let message): - return "API error: \(message)" - } + case providerNotAvailable + case modelNotFound(String) + case modelNotDownloaded(String) + case invalidResponse + case networkError(Error) + case configurationError(String) + case taskCancelled + case invalidPrompt + case tokenLimitExceeded + case rateLimitExceeded + case insufficientMemory + case unsupportedModel(String) + case dataAccessError(String) + case apiError(String) + + var errorDescription: String? { + switch self { + case .providerNotAvailable: + return "LLM provider is not available. Please ensure it is installed and running." + case .modelNotFound(let modelName): + return "Model '\(modelName)' not found." + case .modelNotDownloaded(let modelName): + return "Model '\(modelName)' is not downloaded locally." + case .invalidResponse: + return "Received invalid response from LLM provider." + case .networkError(let error): + return "Network error: \(error.localizedDescription)" + case .configurationError(let message): + return "Configuration error: \(message)" + case .taskCancelled: + return "Task was cancelled." + case .invalidPrompt: + return "Invalid prompt provided." + case .tokenLimitExceeded: + return "Token limit exceeded for this request." + case .rateLimitExceeded: + return "Rate limit exceeded. Please try again later." + case .insufficientMemory: + return "Insufficient memory to load model." + case .unsupportedModel(let modelName): + return "Model '\(modelName)' is not supported by this provider." + case .dataAccessError(let message): + return "Data access error: \(message)" + case .apiError(let message): + return "API error: \(message)" } -} \ No newline at end of file + } +} diff --git a/Recap/Services/LLM/Core/LLMModelType.swift b/Recap/Services/LLM/Core/LLMModelType.swift index a56d99c..97dac33 100644 --- a/Recap/Services/LLM/Core/LLMModelType.swift +++ b/Recap/Services/LLM/Core/LLMModelType.swift @@ -1,9 +1,8 @@ import Foundation protocol LLMModelType: Identifiable, Hashable { - var id: String { get } - var name: String { get } - var provider: String { get } - var contextLength: Int32? { get } + var id: String { get } + var name: String { get } + var provider: String { get } + var contextLength: Int32? { get } } - diff --git a/Recap/Services/LLM/Core/LLMOptions.swift b/Recap/Services/LLM/Core/LLMOptions.swift index 00f59aa..ee19adc 100644 --- a/Recap/Services/LLM/Core/LLMOptions.swift +++ b/Recap/Services/LLM/Core/LLMOptions.swift @@ -1,40 +1,40 @@ import Foundation struct LLMOptions { - let temperature: Double - let maxTokens: Int? - let topP: Double? - let topK: Int? - let repeatPenalty: Double? - let keepAliveMinutes: Int? - let seed: Int? - let stopSequences: [String]? - - init( - temperature: Double = 0.7, - maxTokens: Int? = 8192, - topP: Double? = nil, - topK: Int? = nil, - repeatPenalty: Double? = nil, - keepAliveMinutes: Int? = nil, - seed: Int? = nil, - stopSequences: [String]? = nil - ) { - self.temperature = temperature - self.maxTokens = maxTokens - self.topP = topP - self.topK = topK - self.repeatPenalty = repeatPenalty - self.keepAliveMinutes = keepAliveMinutes - self.seed = seed - self.stopSequences = stopSequences - } - - static var defaultSummarization: LLMOptions { - LLMOptions( - temperature: 0.3, - maxTokens: 8192, - keepAliveMinutes: 5 - ) - } + let temperature: Double + let maxTokens: Int? + let topP: Double? + let topK: Int? + let repeatPenalty: Double? + let keepAliveMinutes: Int? + let seed: Int? + let stopSequences: [String]? + + init( + temperature: Double = 0.7, + maxTokens: Int? = 8192, + topP: Double? = nil, + topK: Int? = nil, + repeatPenalty: Double? = nil, + keepAliveMinutes: Int? = nil, + seed: Int? = nil, + stopSequences: [String]? = nil + ) { + self.temperature = temperature + self.maxTokens = maxTokens + self.topP = topP + self.topK = topK + self.repeatPenalty = repeatPenalty + self.keepAliveMinutes = keepAliveMinutes + self.seed = seed + self.stopSequences = stopSequences + } + + static var defaultSummarization: LLMOptions { + LLMOptions( + temperature: 0.3, + maxTokens: 8192, + keepAliveMinutes: 5 + ) + } } diff --git a/Recap/Services/LLM/Core/LLMProviderType.swift b/Recap/Services/LLM/Core/LLMProviderType.swift index 91682c6..95830ea 100644 --- a/Recap/Services/LLM/Core/LLMProviderType.swift +++ b/Recap/Services/LLM/Core/LLMProviderType.swift @@ -1,37 +1,31 @@ -import Foundation import Combine +import Foundation @MainActor protocol LLMProviderType: AnyObject { - associatedtype Model: LLMModelType - - var name: String { get } - var isAvailable: Bool { get } - var availabilityPublisher: AnyPublisher { get } - - func checkAvailability() async -> Bool - func listModels() async throws -> [Model] - func generateChatCompletion( - modelName: String, - messages: [LLMMessage], - options: LLMOptions - ) async throws -> String - func cancelCurrentTask() + associatedtype Model: LLMModelType + + var name: String { get } + var isAvailable: Bool { get } + var availabilityPublisher: AnyPublisher { get } + + func checkAvailability() async -> Bool + func listModels() async throws -> [Model] + func generateChatCompletion( + modelName: String, + messages: [LLMMessage], + options: LLMOptions + ) async throws -> String + func cancelCurrentTask() } struct LLMMessage { - enum Role: String { - case system - case user - case assistant - } - - let role: Role - let content: String - - init(role: Role, content: String) { - self.role = role - self.content = content - } -} + enum Role: String { + case system + case user + case assistant + } + let role: Role + let content: String +} diff --git a/Recap/Services/LLM/Core/LLMTaskManageable.swift b/Recap/Services/LLM/Core/LLMTaskManageable.swift index 0a44129..b59287f 100644 --- a/Recap/Services/LLM/Core/LLMTaskManageable.swift +++ b/Recap/Services/LLM/Core/LLMTaskManageable.swift @@ -2,27 +2,27 @@ import Foundation @MainActor protocol LLMTaskManageable: AnyObject { - var currentTask: Task? { get set } - func cancelCurrentTask() + var currentTask: Task? { get set } + func cancelCurrentTask() } extension LLMTaskManageable { - func cancelCurrentTask() { - currentTask?.cancel() - currentTask = nil - } - - func executeWithTaskManagement( - operation: @escaping () async throws -> T - ) async throws -> T { - cancelCurrentTask() - - return try await withTaskCancellationHandler { - try await operation() - } onCancel: { - Task { [weak self] in - await self?.cancelCurrentTask() - } - } + func cancelCurrentTask() { + currentTask?.cancel() + currentTask = nil + } + + func executeWithTaskManagement( + operation: @escaping () async throws -> T + ) async throws -> T { + cancelCurrentTask() + + return try await withTaskCancellationHandler { + try await operation() + } onCancel: { + Task { [weak self] in + await self?.cancelCurrentTask() + } } + } } diff --git a/Recap/Services/LLM/LLMService.swift b/Recap/Services/LLM/LLMService.swift index 03fb2b1..019f3e4 100644 --- a/Recap/Services/LLM/LLMService.swift +++ b/Recap/Services/LLM/LLMService.swift @@ -1,178 +1,246 @@ -import Foundation import Combine +import Foundation @MainActor final class LLMService: LLMServiceType { - @Published private(set) var isProviderAvailable: Bool = false - var providerAvailabilityPublisher: AnyPublisher { - $isProviderAvailable.eraseToAnyPublisher() - } - - private(set) var currentProvider: (any LLMProviderType)? - private(set) var availableProviders: [any LLMProviderType] = [] - - private let llmModelRepository: LLMModelRepositoryType - private let userPreferencesRepository: UserPreferencesRepositoryType - private var cancellables = Set() - private var modelRefreshTimer: Timer? - - init( - llmModelRepository: LLMModelRepositoryType, - userPreferencesRepository: UserPreferencesRepositoryType - ) { - self.llmModelRepository = llmModelRepository - self.userPreferencesRepository = userPreferencesRepository - initializeProviders() - startModelRefreshTimer() - } - - deinit { - modelRefreshTimer?.invalidate() - } - - func initializeProviders() { - let ollamaProvider = OllamaProvider() - let openRouterProvider = OpenRouterProvider() - availableProviders = [ollamaProvider, openRouterProvider] - - Task { - do { - let preferences = try await userPreferencesRepository.getOrCreatePreferences() - setCurrentProvider(preferences.selectedProvider) - } catch { - setCurrentProvider(.default) - } - } - - Publishers.CombineLatest( - ollamaProvider.availabilityPublisher, - openRouterProvider.availabilityPublisher - ) - .map { ollamaAvailable, openRouterAvailable in - ollamaAvailable || openRouterAvailable - } - .sink { [weak self] isAnyProviderAvailable in - self?.isProviderAvailable = isAnyProviderAvailable - } - .store(in: &cancellables) - - Task { - try? await Task.sleep(nanoseconds: 2_000_000_000) - try? await refreshModelsFromProviders() - } + @Published private(set) var isProviderAvailable: Bool = false + var providerAvailabilityPublisher: AnyPublisher { + $isProviderAvailable.eraseToAnyPublisher() + } + + private(set) var currentProvider: (any LLMProviderType)? + private(set) var availableProviders: [any LLMProviderType] = [] + + private let llmModelRepository: LLMModelRepositoryType + private let userPreferencesRepository: UserPreferencesRepositoryType + private var cancellables = Set() + private var modelRefreshTimer: Timer? + + init( + llmModelRepository: LLMModelRepositoryType, + userPreferencesRepository: UserPreferencesRepositoryType + ) { + self.llmModelRepository = llmModelRepository + self.userPreferencesRepository = userPreferencesRepository + initializeProviders() + startModelRefreshTimer() + } + + deinit { + modelRefreshTimer?.invalidate() + } + + func initializeProviders() { + let ollamaProvider = OllamaProvider() + + // Get credentials from keychain + let keychainService = KeychainService() + let openRouterApiKey = try? keychainService.retrieveOpenRouterAPIKey() + let openAIApiKey = try? keychainService.retrieveOpenAIAPIKey() + let openAIEndpoint = try? keychainService.retrieveOpenAIEndpoint() + + let openRouterProvider = OpenRouterProvider(apiKey: openRouterApiKey) + let openAIProvider = OpenAIProvider( + apiKey: openAIApiKey, + endpoint: openAIEndpoint ?? "https://api.openai.com/v1" + ) + + availableProviders = [ollamaProvider, openRouterProvider, openAIProvider] + + Task { + do { + let preferences = try await userPreferencesRepository.getOrCreatePreferences() + setCurrentProvider(preferences.selectedProvider) + } catch { + setCurrentProvider(.default) + } } - - func refreshModelsFromProviders() async throws { - var allModelInfos: [LLMModelInfo] = [] - - for provider in availableProviders { - guard provider.isAvailable else { continue } - - do { - let providerModels = try await provider.listModels() - let modelInfos = providerModels.map { model in - LLMModelInfo( - id: model.id, - name: model.name, - provider: model.provider, - maxTokens: model.contextLength ?? 8192 - ) - } - allModelInfos.append(contentsOf: modelInfos) - } catch { - continue - } - } - - try await llmModelRepository.saveModels(allModelInfos) + + Publishers.CombineLatest3( + ollamaProvider.availabilityPublisher, + openRouterProvider.availabilityPublisher, + openAIProvider.availabilityPublisher + ) + .map { ollamaAvailable, openRouterAvailable, openAIAvailable in + ollamaAvailable || openRouterAvailable || openAIAvailable } - - func getAvailableModels() async throws -> [LLMModelInfo] { - let allModels = try await llmModelRepository.getAllModels() - let preferences = try await userPreferencesRepository.getOrCreatePreferences() - return allModels.filter { $0.provider.lowercased() == preferences.selectedProvider.providerName.lowercased() } + .sink { [weak self] isAnyProviderAvailable in + self?.isProviderAvailable = isAnyProviderAvailable + } + .store(in: &cancellables) + + Task { + try? await Task.sleep(nanoseconds: 2_000_000_000) + try? await refreshModelsFromProviders() } - - func getSelectedModel() async throws -> LLMModelInfo? { + } + + func reinitializeProviders() { + // Cancel any existing subscriptions + cancellables.removeAll() + + // Get fresh credentials from keychain + let keychainService = KeychainService() + let openRouterApiKey = try? keychainService.retrieveOpenRouterAPIKey() + let openAIApiKey = try? keychainService.retrieveOpenAIAPIKey() + let openAIEndpoint = try? keychainService.retrieveOpenAIEndpoint() + + // Create new provider instances with updated credentials + let ollamaProvider = OllamaProvider() + let openRouterProvider = OpenRouterProvider(apiKey: openRouterApiKey) + let openAIProvider = OpenAIProvider( + apiKey: openAIApiKey, + endpoint: openAIEndpoint ?? "https://api.openai.com/v1" + ) + + availableProviders = [ollamaProvider, openRouterProvider, openAIProvider] + + // Update current provider + Task { + do { let preferences = try await userPreferencesRepository.getOrCreatePreferences() - guard let modelId = preferences.selectedLLMModelID else { return nil } - return try await llmModelRepository.getModel(byId: modelId) + setCurrentProvider(preferences.selectedProvider) + } catch { + setCurrentProvider(.default) + } } - - func selectModel(id: String) async throws { - guard (try await llmModelRepository.getModel(byId: id)) != nil else { - throw LLMError.modelNotFound(id) - } - try await userPreferencesRepository.updateSelectedLLMModel(id: id) + // Re-setup availability monitoring + Publishers.CombineLatest3( + ollamaProvider.availabilityPublisher, + openRouterProvider.availabilityPublisher, + openAIProvider.availabilityPublisher + ) + .map { ollamaAvailable, openRouterAvailable, openAIAvailable in + ollamaAvailable || openRouterAvailable || openAIAvailable } - - func getUserPreferences() async throws -> UserPreferencesInfo { - try await userPreferencesRepository.getOrCreatePreferences() + .sink { [weak self] isAnyProviderAvailable in + self?.isProviderAvailable = isAnyProviderAvailable } - - func generateSummarization( - text: String, - options: LLMOptions? = nil - ) async throws -> String { - guard let selectedModel = try await getSelectedModel() else { - throw LLMError.configurationError("No model selected") - } - - guard let provider = findProvider(for: selectedModel.provider) else { - throw LLMError.providerNotAvailable - } - - guard provider.isAvailable else { - throw LLMError.providerNotAvailable - } - - let preferences = try await userPreferencesRepository.getOrCreatePreferences() - let promptTemplate = preferences.summaryPromptTemplate ?? UserPreferencesInfo.defaultPromptTemplate - - let effectiveOptions = options ?? LLMOptions( - temperature: selectedModel.temperature ?? 0.7, - maxTokens: Int(selectedModel.maxTokens), - keepAliveMinutes: selectedModel.keepAliveMinutes.map(Int.init) - ) - - let messages = [ - LLMMessage(role: .system, content: promptTemplate), - LLMMessage(role: .user, content: text) - ] - - return try await provider.generateChatCompletion( - modelName: selectedModel.name, - messages: messages, - options: effectiveOptions - ) - } - - private func findProvider(for providerName: String) -> (any LLMProviderType)? { - availableProviders.first { provider in - provider.name.lowercased() == providerName.lowercased() - } + .store(in: &cancellables) + + // Refresh models from providers + Task { + try? await refreshModelsFromProviders() } - - func cancelCurrentTask() { - availableProviders.forEach { $0.cancelCurrentTask() } - } - - func setCurrentProvider(_ provider: LLMProvider) { - currentProvider = findProvider(for: provider.providerName) - } - - func selectProvider(_ provider: LLMProvider) async throws { - try await userPreferencesRepository.updateSelectedProvider(provider) - setCurrentProvider(provider) - } - - private func startModelRefreshTimer() { - modelRefreshTimer?.invalidate() - modelRefreshTimer = Timer.scheduledTimer(withTimeInterval: 3600.0, repeats: true) { [weak self] _ in - Task { @MainActor [weak self] in - try? await self?.refreshModelsFromProviders() - } + } + + func refreshModelsFromProviders() async throws { + var allModelInfos: [LLMModelInfo] = [] + + for provider in availableProviders { + guard provider.isAvailable else { continue } + + do { + let providerModels = try await provider.listModels() + let modelInfos = providerModels.map { model in + LLMModelInfo( + id: model.id, + name: model.name, + provider: model.provider, + maxTokens: model.contextLength ?? 8192 + ) } + allModelInfos.append(contentsOf: modelInfos) + } catch { + continue + } + } + + try await llmModelRepository.saveModels(allModelInfos) + } + + func getAvailableModels() async throws -> [LLMModelInfo] { + let allModels = try await llmModelRepository.getAllModels() + let preferences = try await userPreferencesRepository.getOrCreatePreferences() + return allModels.filter { + $0.provider.lowercased() == preferences.selectedProvider.providerName.lowercased() + } + } + + func getSelectedModel() async throws -> LLMModelInfo? { + let preferences = try await userPreferencesRepository.getOrCreatePreferences() + guard let modelId = preferences.selectedLLMModelID else { return nil } + return try await llmModelRepository.getModel(byId: modelId) + } + + func selectModel(id: String) async throws { + guard (try await llmModelRepository.getModel(byId: id)) != nil else { + throw LLMError.modelNotFound(id) + } + + try await userPreferencesRepository.updateSelectedLLMModel(id: id) + } + + func getUserPreferences() async throws -> UserPreferencesInfo { + try await userPreferencesRepository.getOrCreatePreferences() + } + + func generateSummarization( + text: String, + options: LLMOptions? = nil + ) async throws -> String { + guard let selectedModel = try await getSelectedModel() else { + throw LLMError.configurationError("No model selected") + } + + guard let provider = findProvider(for: selectedModel.provider) else { + throw LLMError.providerNotAvailable + } + + guard provider.isAvailable else { + throw LLMError.providerNotAvailable + } + + let preferences = try await userPreferencesRepository.getOrCreatePreferences() + let promptTemplate = + preferences.summaryPromptTemplate ?? UserPreferencesInfo.defaultPromptTemplate + + let effectiveOptions = + options + ?? LLMOptions( + temperature: selectedModel.temperature ?? 0.7, + maxTokens: Int(selectedModel.maxTokens), + keepAliveMinutes: selectedModel.keepAliveMinutes.map(Int.init) + ) + + let messages = [ + LLMMessage(role: .system, content: promptTemplate), + LLMMessage(role: .user, content: text) + ] + + return try await provider.generateChatCompletion( + modelName: selectedModel.name, + messages: messages, + options: effectiveOptions + ) + } + + private func findProvider(for providerName: String) -> (any LLMProviderType)? { + availableProviders.first { provider in + provider.name.lowercased() == providerName.lowercased() + } + } + + func cancelCurrentTask() { + availableProviders.forEach { $0.cancelCurrentTask() } + } + + func setCurrentProvider(_ provider: LLMProvider) { + currentProvider = findProvider(for: provider.providerName) + } + + func selectProvider(_ provider: LLMProvider) async throws { + try await userPreferencesRepository.updateSelectedProvider(provider) + setCurrentProvider(provider) + } + + private func startModelRefreshTimer() { + modelRefreshTimer?.invalidate() + modelRefreshTimer = Timer.scheduledTimer(withTimeInterval: 3600.0, repeats: true) { [weak self] _ in + Task { @MainActor [weak self] in + try? await self?.refreshModelsFromProviders() + } } + } } diff --git a/Recap/Services/LLM/LLMServiceType.swift b/Recap/Services/LLM/LLMServiceType.swift index f8a4604..ac49fd7 100644 --- a/Recap/Services/LLM/LLMServiceType.swift +++ b/Recap/Services/LLM/LLMServiceType.swift @@ -1,29 +1,31 @@ -import Foundation import Combine +import Foundation + #if MOCKING -import Mockable + import Mockable #endif @MainActor #if MOCKING -@Mockable + @Mockable #endif protocol LLMServiceType: AnyObject { - var currentProvider: (any LLMProviderType)? { get } - var availableProviders: [any LLMProviderType] { get } - var isProviderAvailable: Bool { get } - var providerAvailabilityPublisher: AnyPublisher { get } - - func initializeProviders() - func refreshModelsFromProviders() async throws - func getAvailableModels() async throws -> [LLMModelInfo] - func getSelectedModel() async throws -> LLMModelInfo? - func selectModel(id: String) async throws - func selectProvider(_ provider: LLMProvider) async throws - func getUserPreferences() async throws -> UserPreferencesInfo - func generateSummarization( - text: String, - options: LLMOptions? - ) async throws -> String - func cancelCurrentTask() -} \ No newline at end of file + var currentProvider: (any LLMProviderType)? { get } + var availableProviders: [any LLMProviderType] { get } + var isProviderAvailable: Bool { get } + var providerAvailabilityPublisher: AnyPublisher { get } + + func initializeProviders() + func reinitializeProviders() + func refreshModelsFromProviders() async throws + func getAvailableModels() async throws -> [LLMModelInfo] + func getSelectedModel() async throws -> LLMModelInfo? + func selectModel(id: String) async throws + func selectProvider(_ provider: LLMProvider) async throws + func getUserPreferences() async throws -> UserPreferencesInfo + func generateSummarization( + text: String, + options: LLMOptions? + ) async throws -> String + func cancelCurrentTask() +} diff --git a/Recap/Services/LLM/Providers/Ollama/OllamaAPIClient.swift b/Recap/Services/LLM/Providers/Ollama/OllamaAPIClient.swift index 083f5c7..247d514 100644 --- a/Recap/Services/LLM/Providers/Ollama/OllamaAPIClient.swift +++ b/Recap/Services/LLM/Providers/Ollama/OllamaAPIClient.swift @@ -3,140 +3,140 @@ import Ollama @MainActor final class OllamaAPIClient { - private let client: Client - - init(baseURL: String = "http://localhost", port: Int = 11434) { - let url = URL(string: "\(baseURL):\(port)")! - let configuration = URLSessionConfiguration.default - configuration.timeoutIntervalForRequest = 3600 - configuration.timeoutIntervalForResource = 3600 - let session = URLSession(configuration: configuration) - self.client = Client(session: session, host: url) + private let client: Client + + init(baseURL: String = "http://localhost", port: Int = 11434) { + let url = URL(string: "\(baseURL):\(port)")! + let configuration = URLSessionConfiguration.default + configuration.timeoutIntervalForRequest = 3600 + configuration.timeoutIntervalForResource = 3600 + let session = URLSession(configuration: configuration) + self.client = Client(session: session, host: url) + } + + func checkAvailability() async -> Bool { + do { + _ = try await client.listModels() + return true + } catch { + return false } - - func checkAvailability() async -> Bool { - do { - _ = try await client.listModels() - return true - } catch { - return false - } + } + + func listModels() async throws -> [OllamaAPIModel] { + let response = try await client.listModels() + return response.models.map { model in + OllamaAPIModel( + name: model.name, + size: model.size, + digest: model.digest, + modifiedAt: nil, + details: OllamaModelDetails( + format: model.details.format, + family: model.details.family, + families: model.details.families, + parameterSize: model.details.parameterSize, + quantizationLevel: model.details.quantizationLevel + ) + ) } - - func listModels() async throws -> [OllamaAPIModel] { - let response = try await client.listModels() - return response.models.map { model in - OllamaAPIModel( - name: model.name, - size: model.size, - digest: model.digest, - modifiedAt: nil, - details: OllamaModelDetails( - format: model.details.format, - family: model.details.family, - families: model.details.families, - parameterSize: model.details.parameterSize, - quantizationLevel: model.details.quantizationLevel - ) - ) - } + } + + func generateChatCompletion( + modelName: String, + messages: [LLMMessage], + options: LLMOptions + ) async throws -> String { + guard let modelId = createModelID(from: modelName) else { + throw LLMError.modelNotFound("Model \(modelName) not found") } - - func generateChatCompletion( - modelName: String, - messages: [LLMMessage], - options: LLMOptions - ) async throws -> String { - guard let modelId = createModelID(from: modelName) else { - throw LLMError.modelNotFound("Model \(modelName) not found") - } - - let response = try await client.chat( - model: modelId, - messages: mapMessagesToClient(messages), - options: mapOptionsToClient(options), - keepAlive: createKeepAlive(from: options) - ) - return response.message.content + + let response = try await client.chat( + model: modelId, + messages: mapMessagesToClient(messages), + options: mapOptionsToClient(options), + keepAlive: createKeepAlive(from: options) + ) + return response.message.content + } + + private func createModelID(from modelName: String) -> Model.ID? { + Model.ID(rawValue: modelName) + } + + private func createKeepAlive(from options: LLMOptions) -> KeepAlive { + options.keepAliveMinutes.map { KeepAlive.minutes($0) } ?? .default + } + + private func mapOptionsToClient(_ options: LLMOptions) -> [String: Value] { + var clientOptions: [String: Value] = [:] + clientOptions["temperature"] = .double(options.temperature) + + if let maxTokens = options.maxTokens { + clientOptions["num_predict"] = .double(Double(maxTokens)) + } + + if let topP = options.topP { + clientOptions["top_p"] = .double(topP) } - - private func createModelID(from modelName: String) -> Model.ID? { - Model.ID(rawValue: modelName) + if let topK = options.topK { + clientOptions["top_k"] = .double(Double(topK)) } - - private func createKeepAlive(from options: LLMOptions) -> KeepAlive { - options.keepAliveMinutes.map { KeepAlive.minutes($0) } ?? .default + if let repeatPenalty = options.repeatPenalty { + clientOptions["repeat_penalty"] = .double(repeatPenalty) } - - private func mapOptionsToClient(_ options: LLMOptions) -> [String: Value] { - var clientOptions: [String: Value] = [:] - clientOptions["temperature"] = .double(options.temperature) - - if let maxTokens = options.maxTokens { - clientOptions["num_predict"] = .double(Double(maxTokens)) - } - - if let topP = options.topP { - clientOptions["top_p"] = .double(topP) - } - if let topK = options.topK { - clientOptions["top_k"] = .double(Double(topK)) - } - if let repeatPenalty = options.repeatPenalty { - clientOptions["repeat_penalty"] = .double(repeatPenalty) - } - if let seed = options.seed { - clientOptions["seed"] = .double(Double(seed)) - } - if let stopSequences = options.stopSequences { - clientOptions["stop"] = .array(stopSequences.map { .string($0) }) - } - - return clientOptions + if let seed = options.seed { + clientOptions["seed"] = .double(Double(seed)) } - - private func mapMessagesToClient(_ messages: [LLMMessage]) -> [Chat.Message] { - messages.map { message in - switch message.role { - case .system: - return Chat.Message.system(message.content) - case .user: - return Chat.Message.user(message.content) - case .assistant: - return Chat.Message.assistant(message.content) - } - } + if let stopSequences = options.stopSequences { + clientOptions["stop"] = .array(stopSequences.map { .string($0) }) + } + + return clientOptions + } + + private func mapMessagesToClient(_ messages: [LLMMessage]) -> [Chat.Message] { + messages.map { message in + switch message.role { + case .system: + return Chat.Message.system(message.content) + case .user: + return Chat.Message.user(message.content) + case .assistant: + return Chat.Message.assistant(message.content) + } } + } } struct OllamaAPIModel: Codable { - let name: String - let size: Int64 - let digest: String - let modifiedAt: Date? - let details: OllamaModelDetails? - - private enum CodingKeys: String, CodingKey { - case name - case size - case digest - case modifiedAt = "modified_at" - case details - } + let name: String + let size: Int64 + let digest: String + let modifiedAt: Date? + let details: OllamaModelDetails? + + private enum CodingKeys: String, CodingKey { + case name + case size + case digest + case modifiedAt = "modified_at" + case details + } } struct OllamaModelDetails: Codable { - let format: String? - let family: String? - let families: [String]? - let parameterSize: String? - let quantizationLevel: String? - - private enum CodingKeys: String, CodingKey { - case format - case family - case families - case parameterSize = "parameter_size" - case quantizationLevel = "quantization_level" - } + let format: String? + let family: String? + let families: [String]? + let parameterSize: String? + let quantizationLevel: String? + + private enum CodingKeys: String, CodingKey { + case format + case family + case families + case parameterSize = "parameter_size" + case quantizationLevel = "quantization_level" + } } diff --git a/Recap/Services/LLM/Providers/Ollama/OllamaModel.swift b/Recap/Services/LLM/Providers/Ollama/OllamaModel.swift index 057b57a..9d911ef 100644 --- a/Recap/Services/LLM/Providers/Ollama/OllamaModel.swift +++ b/Recap/Services/LLM/Providers/Ollama/OllamaModel.swift @@ -1,19 +1,19 @@ import Foundation struct OllamaModel: LLMModelType { - let id: String - let name: String - let provider: String = "ollama" - let contextLength: Int32? = nil - - init(name: String) { - self.id = "ollama-\(name)" - self.name = name - } + let id: String + let name: String + let provider: String = "ollama" + let contextLength: Int32? = nil + + init(name: String) { + self.id = "ollama-\(name)" + self.name = name + } } extension OllamaModel { - init(from apiModel: OllamaAPIModel) { - self.init(name: apiModel.name) - } + init(from apiModel: OllamaAPIModel) { + self.init(name: apiModel.name) + } } diff --git a/Recap/Services/LLM/Providers/Ollama/OllamaProvider.swift b/Recap/Services/LLM/Providers/Ollama/OllamaProvider.swift index eb8f488..f46878b 100644 --- a/Recap/Services/LLM/Providers/Ollama/OllamaProvider.swift +++ b/Recap/Services/LLM/Providers/Ollama/OllamaProvider.swift @@ -1,85 +1,85 @@ -import Foundation import Combine +import Foundation @MainActor final class OllamaProvider: LLMProviderType, LLMTaskManageable { - typealias Model = OllamaModel - - let name = "Ollama" - - var isAvailable: Bool { - availabilityHelper.isAvailable - } - - var availabilityPublisher: AnyPublisher { - availabilityHelper.availabilityPublisher - } - - var currentTask: Task? - - private let apiClient: OllamaAPIClient - private let availabilityHelper: AvailabilityHelper - - init(baseURL: String = "http://localhost", port: Int = 11434) { - self.apiClient = OllamaAPIClient(baseURL: baseURL, port: port) - - self.availabilityHelper = AvailabilityHelper( - checkInterval: 30.0, - availabilityCheck: { [weak apiClient] in - await apiClient?.checkAvailability() ?? false - } - ) - availabilityHelper.startMonitoring() - } - - deinit { - Task { [weak self] in - await self?.cancelCurrentTask() - } + typealias Model = OllamaModel + + let name = "Ollama" + + var isAvailable: Bool { + availabilityHelper.isAvailable + } + + var availabilityPublisher: AnyPublisher { + availabilityHelper.availabilityPublisher + } + + var currentTask: Task? + + private let apiClient: OllamaAPIClient + private let availabilityHelper: AvailabilityHelper + + init(baseURL: String = "http://localhost", port: Int = 11434) { + self.apiClient = OllamaAPIClient(baseURL: baseURL, port: port) + + self.availabilityHelper = AvailabilityHelper( + checkInterval: 30.0, + availabilityCheck: { [weak apiClient] in + await apiClient?.checkAvailability() ?? false + } + ) + availabilityHelper.startMonitoring() + } + + deinit { + Task { [weak self] in + await self?.cancelCurrentTask() } - - func checkAvailability() async -> Bool { - await availabilityHelper.checkAvailabilityNow() + } + + func checkAvailability() async -> Bool { + await availabilityHelper.checkAvailabilityNow() + } + + func listModels() async throws -> [OllamaModel] { + guard isAvailable else { + throw LLMError.providerNotAvailable } - - func listModels() async throws -> [OllamaModel] { - guard isAvailable else { - throw LLMError.providerNotAvailable - } - - return try await executeWithTaskManagement { - let apiModels = try await self.apiClient.listModels() - return apiModels.map { OllamaModel(from: $0) } - } + + return try await executeWithTaskManagement { + let apiModels = try await self.apiClient.listModels() + return apiModels.map { OllamaModel(from: $0) } } - - func generateChatCompletion( - modelName: String, - messages: [LLMMessage], - options: LLMOptions - ) async throws -> String { - try validateProviderAvailable() - try validateMessages(messages) - - return try await executeWithTaskManagement { - try await self.apiClient.generateChatCompletion( - modelName: modelName, - messages: messages, - options: options - ) - } + } + + func generateChatCompletion( + modelName: String, + messages: [LLMMessage], + options: LLMOptions + ) async throws -> String { + try validateProviderAvailable() + try validateMessages(messages) + + return try await executeWithTaskManagement { + try await self.apiClient.generateChatCompletion( + modelName: modelName, + messages: messages, + options: options + ) } - - private func validateProviderAvailable() throws { - guard isAvailable else { - throw LLMError.providerNotAvailable - } + } + + private func validateProviderAvailable() throws { + guard isAvailable else { + throw LLMError.providerNotAvailable } - - private func validateMessages(_ messages: [LLMMessage]) throws { - guard !messages.isEmpty else { - throw LLMError.invalidPrompt - } + } + + private func validateMessages(_ messages: [LLMMessage]) throws { + guard !messages.isEmpty else { + throw LLMError.invalidPrompt } - + } + } diff --git a/Recap/Services/LLM/Providers/OpenAI/OpenAIAPIClient.swift b/Recap/Services/LLM/Providers/OpenAI/OpenAIAPIClient.swift new file mode 100644 index 0000000..80a1331 --- /dev/null +++ b/Recap/Services/LLM/Providers/OpenAI/OpenAIAPIClient.swift @@ -0,0 +1,113 @@ +import Foundation +import OpenAI + +@MainActor +final class OpenAIAPIClient { + private let openAI: OpenAI + private let apiKey: String? + private let endpoint: String + + init(apiKey: String? = nil, endpoint: String = "https://api.openai.com/v1") { + self.apiKey = apiKey + self.endpoint = endpoint + + let configuration = OpenAI.Configuration( + token: apiKey ?? "", + host: endpoint + ) + self.openAI = OpenAI(configuration: configuration) + } + + func checkAvailability() async -> Bool { + guard apiKey != nil && !apiKey!.isEmpty else { + return false + } + + do { + _ = try await listModels() + return true + } catch { + return false + } + } + + func listModels() async throws -> [OpenAIAPIModel] { + guard let apiKey = apiKey, !apiKey.isEmpty else { + throw LLMError.configurationError("API key is required") + } + + let modelsResult = try await openAI.models() + + // Filter for GPT models and map to our model type + return modelsResult.data.compactMap { model in + // Only include chat models (GPT models) + guard model.id.contains("gpt") else { return nil } + + return OpenAIAPIModel( + id: model.id, + contextWindow: getContextWindow(for: model.id) + ) + } + } + + func generateChatCompletion( + modelName: String, + messages: [LLMMessage], + options: LLMOptions + ) async throws -> String { + guard let apiKey = apiKey, !apiKey.isEmpty else { + throw LLMError.configurationError("API key is required") + } + + let chatMessages: [ChatQuery.ChatCompletionMessageParam] = messages.map { message in + switch message.role { + case .system: + return .system(.init(content: .textContent(message.content))) + case .user: + return .user(.init(content: .string(message.content))) + case .assistant: + return .assistant(.init(content: .textContent(message.content))) + } + } + + let query = ChatQuery( + messages: chatMessages, + model: .init(modelName), + stop: options.stopSequences?.isEmpty == false ? .stringList(options.stopSequences!) : nil, + temperature: options.temperature, + topP: options.topP + ) + + let result = try await openAI.chats(query: query) + + guard let choice = result.choices.first, + let content = choice.message.content + else { + throw LLMError.invalidResponse + } + + return content + } + + private func getContextWindow(for modelId: String) -> Int? { + // Common OpenAI model context windows + if modelId.contains("gpt-4-turbo") || modelId.contains("gpt-4-1106") + || modelId.contains("gpt-4-0125") { + return 128000 + } else if modelId.contains("gpt-4-32k") { + return 32768 + } else if modelId.contains("gpt-4") { + return 8192 + } else if modelId.contains("gpt-3.5-turbo-16k") { + return 16384 + } else if modelId.contains("gpt-3.5-turbo") { + return 4096 + } + return nil + } +} + +struct OpenAIAPIModel: Codable { + let id: String + let contextWindow: Int? +} diff --git a/Recap/Services/LLM/Providers/OpenAI/OpenAIModel.swift b/Recap/Services/LLM/Providers/OpenAI/OpenAIModel.swift new file mode 100644 index 0000000..fd3351c --- /dev/null +++ b/Recap/Services/LLM/Providers/OpenAI/OpenAIModel.swift @@ -0,0 +1,24 @@ +import Foundation + +struct OpenAIModel: LLMModelType { + let id: String + let name: String + let provider: String = "openai" + let contextLength: Int32? + + init(id: String, name: String, contextLength: Int? = nil) { + self.id = "openai-\(id)" + self.name = name + self.contextLength = contextLength.map(Int32.init) + } +} + +extension OpenAIModel { + init(from apiModel: OpenAIAPIModel) { + self.init( + id: apiModel.id, + name: apiModel.id, + contextLength: apiModel.contextWindow + ) + } +} diff --git a/Recap/Services/LLM/Providers/OpenAI/OpenAIProvider.swift b/Recap/Services/LLM/Providers/OpenAI/OpenAIProvider.swift new file mode 100644 index 0000000..a2367a0 --- /dev/null +++ b/Recap/Services/LLM/Providers/OpenAI/OpenAIProvider.swift @@ -0,0 +1,84 @@ +import Combine +import Foundation + +@MainActor +final class OpenAIProvider: LLMProviderType, LLMTaskManageable { + typealias Model = OpenAIModel + + let name = "OpenAI" + + var isAvailable: Bool { + availabilityHelper.isAvailable + } + + var availabilityPublisher: AnyPublisher { + availabilityHelper.availabilityPublisher + } + + var currentTask: Task? + + private let apiClient: OpenAIAPIClient + private let availabilityHelper: AvailabilityHelper + + init(apiKey: String? = nil, endpoint: String = "https://api.openai.com/v1") { + let resolvedApiKey = apiKey ?? ProcessInfo.processInfo.environment["OPENAI_API_KEY"] + self.apiClient = OpenAIAPIClient(apiKey: resolvedApiKey, endpoint: endpoint) + self.availabilityHelper = AvailabilityHelper( + checkInterval: 60.0, + availabilityCheck: { [weak apiClient] in + await apiClient?.checkAvailability() ?? false + } + ) + availabilityHelper.startMonitoring() + } + + deinit { + Task { [weak self] in + await self?.cancelCurrentTask() + } + } + + func checkAvailability() async -> Bool { + await availabilityHelper.checkAvailabilityNow() + } + + func listModels() async throws -> [OpenAIModel] { + guard isAvailable else { + throw LLMError.providerNotAvailable + } + + return try await executeWithTaskManagement { + let apiModels = try await self.apiClient.listModels() + return apiModels.map { OpenAIModel.init(from: $0) } + } + } + + func generateChatCompletion( + modelName: String, + messages: [LLMMessage], + options: LLMOptions + ) async throws -> String { + try validateProviderAvailable() + try validateMessages(messages) + + return try await executeWithTaskManagement { + try await self.apiClient.generateChatCompletion( + modelName: modelName, + messages: messages, + options: options + ) + } + } + + private func validateProviderAvailable() throws { + guard isAvailable else { + throw LLMError.providerNotAvailable + } + } + + private func validateMessages(_ messages: [LLMMessage]) throws { + guard !messages.isEmpty else { + throw LLMError.invalidPrompt + } + } +} diff --git a/Recap/Services/LLM/Providers/OpenRouter/OpenRouterAPIClient.swift b/Recap/Services/LLM/Providers/OpenRouter/OpenRouterAPIClient.swift index 629d25c..146d122 100644 --- a/Recap/Services/LLM/Providers/OpenRouter/OpenRouterAPIClient.swift +++ b/Recap/Services/LLM/Providers/OpenRouter/OpenRouterAPIClient.swift @@ -2,223 +2,223 @@ import Foundation @MainActor final class OpenRouterAPIClient { - private let baseURL: String - private let apiKey: String? - private let session: URLSession - - init(baseURL: String = "https://openrouter.ai/api/v1", apiKey: String? = nil) { - self.baseURL = baseURL - self.apiKey = apiKey - let configuration = URLSessionConfiguration.default - configuration.timeoutIntervalForRequest = 60.0 - configuration.timeoutIntervalForResource = 300.0 - self.session = URLSession(configuration: configuration) + private let baseURL: String + private let apiKey: String? + private let session: URLSession + + init(baseURL: String = "https://openrouter.ai/api/v1", apiKey: String? = nil) { + self.baseURL = baseURL + self.apiKey = apiKey + let configuration = URLSessionConfiguration.default + configuration.timeoutIntervalForRequest = 60.0 + configuration.timeoutIntervalForResource = 300.0 + self.session = URLSession(configuration: configuration) + } + + func checkAvailability() async -> Bool { + do { + _ = try await listModels() + return true + } catch { + return false + } + } + + func listModels() async throws -> [OpenRouterAPIModel] { + guard let url = URL(string: "\(baseURL)/models") else { + throw LLMError.configurationError("Invalid base URL") + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + addHeaders(&request) + + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw LLMError.apiError("Invalid response type") + } + + guard httpResponse.statusCode == 200 else { + throw LLMError.apiError("HTTP \(httpResponse.statusCode)") + } + + let modelsResponse = try JSONDecoder().decode(OpenRouterModelsResponse.self, from: data) + return modelsResponse.data + } + + func generateChatCompletion( + modelName: String, + messages: [LLMMessage], + options: LLMOptions + ) async throws -> String { + guard let url = URL(string: "\(baseURL)/chat/completions") else { + throw LLMError.configurationError("Invalid base URL") + } + + let requestBody = OpenRouterChatRequest( + model: modelName, + messages: messages.map { OpenRouterMessage(role: $0.role.rawValue, content: $0.content) }, + temperature: options.temperature, + maxTokens: options.maxTokens, + topP: options.topP, + stop: options.stopSequences + ) + + var request = URLRequest(url: url) + request.httpMethod = "POST" + addHeaders(&request) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + request.httpBody = try encoder.encode(requestBody) + + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw LLMError.apiError("Invalid response type") } - - func checkAvailability() async -> Bool { - do { - _ = try await listModels() - return true - } catch { - return false - } + + guard httpResponse.statusCode == 200 else { + if let errorData = try? JSONDecoder().decode(OpenRouterErrorResponse.self, from: data) { + throw LLMError.apiError(errorData.error.message) + } + throw LLMError.apiError("HTTP \(httpResponse.statusCode)") } - - func listModels() async throws -> [OpenRouterAPIModel] { - guard let url = URL(string: "\(baseURL)/models") else { - throw LLMError.configurationError("Invalid base URL") - } - - var request = URLRequest(url: url) - request.httpMethod = "GET" - addHeaders(&request) - - let (data, response) = try await session.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse else { - throw LLMError.apiError("Invalid response type") - } - - guard httpResponse.statusCode == 200 else { - throw LLMError.apiError("HTTP \(httpResponse.statusCode)") - } - - let modelsResponse = try JSONDecoder().decode(OpenRouterModelsResponse.self, from: data) - return modelsResponse.data + + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + let chatResponse = try decoder.decode(OpenRouterChatResponse.self, from: data) + + guard let choice = chatResponse.choices.first else { + throw LLMError.invalidResponse } - - func generateChatCompletion( - modelName: String, - messages: [LLMMessage], - options: LLMOptions - ) async throws -> String { - guard let url = URL(string: "\(baseURL)/chat/completions") else { - throw LLMError.configurationError("Invalid base URL") - } - - let requestBody = OpenRouterChatRequest( - model: modelName, - messages: messages.map { OpenRouterMessage(role: $0.role.rawValue, content: $0.content) }, - temperature: options.temperature, - maxTokens: options.maxTokens, - topP: options.topP, - stop: options.stopSequences - ) - - var request = URLRequest(url: url) - request.httpMethod = "POST" - addHeaders(&request) - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - let encoder = JSONEncoder() - encoder.keyEncodingStrategy = .convertToSnakeCase - request.httpBody = try encoder.encode(requestBody) - - let (data, response) = try await session.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse else { - throw LLMError.apiError("Invalid response type") - } - - guard httpResponse.statusCode == 200 else { - if let errorData = try? JSONDecoder().decode(OpenRouterErrorResponse.self, from: data) { - throw LLMError.apiError(errorData.error.message) - } - throw LLMError.apiError("HTTP \(httpResponse.statusCode)") - } - - let decoder = JSONDecoder() - decoder.keyDecodingStrategy = .convertFromSnakeCase - let chatResponse = try decoder.decode(OpenRouterChatResponse.self, from: data) - - guard let choice = chatResponse.choices.first else { - throw LLMError.invalidResponse - } - - let content = choice.message.content - guard !content.isEmpty else { - throw LLMError.invalidResponse - } - - return content + + let content = choice.message.content + guard !content.isEmpty else { + throw LLMError.invalidResponse } - - private func addHeaders(_ request: inout URLRequest) { - if let apiKey = apiKey { - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - } - request.setValue("Recap/1.0", forHTTPHeaderField: "HTTP-Referer") - request.setValue("Recap iOS App", forHTTPHeaderField: "X-Title") + + return content + } + + private func addHeaders(_ request: inout URLRequest) { + if let apiKey = apiKey { + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") } + request.setValue("Recap/1.0", forHTTPHeaderField: "HTTP-Referer") + request.setValue("Recap iOS App", forHTTPHeaderField: "X-Title") + } } struct OpenRouterModelsResponse: Codable { - let data: [OpenRouterAPIModel] + let data: [OpenRouterAPIModel] } struct OpenRouterAPIModel: Codable { - let id: String - let name: String - let description: String? - let pricing: OpenRouterPricing? - let contextLength: Int? - let architecture: OpenRouterArchitecture? - let topProvider: OpenRouterTopProvider? - - private enum CodingKeys: String, CodingKey { - case id - case name - case description - case pricing - case contextLength = "context_length" - case architecture - case topProvider = "top_provider" - } + let id: String + let name: String + let description: String? + let pricing: OpenRouterPricing? + let contextLength: Int? + let architecture: OpenRouterArchitecture? + let topProvider: OpenRouterTopProvider? + + private enum CodingKeys: String, CodingKey { + case id + case name + case description + case pricing + case contextLength = "context_length" + case architecture + case topProvider = "top_provider" + } } struct OpenRouterPricing: Codable { - let prompt: String? - let completion: String? + let prompt: String? + let completion: String? } struct OpenRouterArchitecture: Codable { - let modality: String? - let tokenizer: String? - let instructType: String? - - private enum CodingKeys: String, CodingKey { - case modality - case tokenizer - case instructType = "instruct_type" - } + let modality: String? + let tokenizer: String? + let instructType: String? + + private enum CodingKeys: String, CodingKey { + case modality + case tokenizer + case instructType = "instruct_type" + } } struct OpenRouterTopProvider: Codable { - let maxCompletionTokens: Int? - let isModerated: Bool? - - private enum CodingKeys: String, CodingKey { - case maxCompletionTokens = "max_completion_tokens" - case isModerated = "is_moderated" - } + let maxCompletionTokens: Int? + let isModerated: Bool? + + private enum CodingKeys: String, CodingKey { + case maxCompletionTokens = "max_completion_tokens" + case isModerated = "is_moderated" + } } struct OpenRouterChatRequest: Codable { - let model: String - let messages: [OpenRouterMessage] - let temperature: Double? - let maxTokens: Int? - let topP: Double? - let stop: [String]? - - private enum CodingKeys: String, CodingKey { - case model - case messages - case temperature - case maxTokens = "max_tokens" - case topP = "top_p" - case stop - } + let model: String + let messages: [OpenRouterMessage] + let temperature: Double? + let maxTokens: Int? + let topP: Double? + let stop: [String]? + + private enum CodingKeys: String, CodingKey { + case model + case messages + case temperature + case maxTokens = "max_tokens" + case topP = "top_p" + case stop + } } struct OpenRouterMessage: Codable { - let role: String - let content: String + let role: String + let content: String } struct OpenRouterChatResponse: Codable { - let choices: [OpenRouterChoice] - let usage: OpenRouterUsage? + let choices: [OpenRouterChoice] + let usage: OpenRouterUsage? } struct OpenRouterChoice: Codable { - let message: OpenRouterMessage - let finishReason: String? - - private enum CodingKeys: String, CodingKey { - case message - case finishReason = "finish_reason" - } + let message: OpenRouterMessage + let finishReason: String? + + private enum CodingKeys: String, CodingKey { + case message + case finishReason = "finish_reason" + } } struct OpenRouterUsage: Codable { - let promptTokens: Int? - let completionTokens: Int? - let totalTokens: Int? - - private enum CodingKeys: String, CodingKey { - case promptTokens = "prompt_tokens" - case completionTokens = "completion_tokens" - case totalTokens = "total_tokens" - } + let promptTokens: Int? + let completionTokens: Int? + let totalTokens: Int? + + private enum CodingKeys: String, CodingKey { + case promptTokens = "prompt_tokens" + case completionTokens = "completion_tokens" + case totalTokens = "total_tokens" + } } struct OpenRouterErrorResponse: Codable { - let error: OpenRouterError + let error: OpenRouterError } struct OpenRouterError: Codable { - let message: String - let type: String? - let code: String? -} \ No newline at end of file + let message: String + let type: String? + let code: String? +} diff --git a/Recap/Services/LLM/Providers/OpenRouter/OpenRouterModel.swift b/Recap/Services/LLM/Providers/OpenRouter/OpenRouterModel.swift index 2875e23..fed3455 100644 --- a/Recap/Services/LLM/Providers/OpenRouter/OpenRouterModel.swift +++ b/Recap/Services/LLM/Providers/OpenRouter/OpenRouterModel.swift @@ -1,27 +1,27 @@ import Foundation struct OpenRouterModel: LLMModelType { - let id: String - let name: String - let provider: String = "openrouter" - let contextLength: Int32? - let maxCompletionTokens: Int32? - - init(apiModelId: String, displayName: String, contextLength: Int?, maxCompletionTokens: Int?) { - self.id = "openrouter-\(apiModelId)" - self.name = apiModelId - self.contextLength = contextLength.map(Int32.init) - self.maxCompletionTokens = maxCompletionTokens.map(Int32.init) - } + let id: String + let name: String + let provider: String = "openrouter" + let contextLength: Int32? + let maxCompletionTokens: Int32? + + init(apiModelId: String, displayName: String, contextLength: Int?, maxCompletionTokens: Int?) { + self.id = "openrouter-\(apiModelId)" + self.name = apiModelId + self.contextLength = contextLength.map(Int32.init) + self.maxCompletionTokens = maxCompletionTokens.map(Int32.init) + } } extension OpenRouterModel { - init(from apiModel: OpenRouterAPIModel) { - self.init( - apiModelId: apiModel.id, - displayName: apiModel.name, - contextLength: apiModel.contextLength, - maxCompletionTokens: apiModel.topProvider?.maxCompletionTokens - ) - } -} \ No newline at end of file + init(from apiModel: OpenRouterAPIModel) { + self.init( + apiModelId: apiModel.id, + displayName: apiModel.name, + contextLength: apiModel.contextLength, + maxCompletionTokens: apiModel.topProvider?.maxCompletionTokens + ) + } +} diff --git a/Recap/Services/LLM/Providers/OpenRouter/OpenRouterProvider.swift b/Recap/Services/LLM/Providers/OpenRouter/OpenRouterProvider.swift index 11f63ba..f8879c5 100644 --- a/Recap/Services/LLM/Providers/OpenRouter/OpenRouterProvider.swift +++ b/Recap/Services/LLM/Providers/OpenRouter/OpenRouterProvider.swift @@ -1,84 +1,84 @@ -import Foundation import Combine +import Foundation @MainActor final class OpenRouterProvider: LLMProviderType, LLMTaskManageable { - typealias Model = OpenRouterModel - - let name = "OpenRouter" - - var isAvailable: Bool { - availabilityHelper.isAvailable - } - - var availabilityPublisher: AnyPublisher { - availabilityHelper.availabilityPublisher - } - - var currentTask: Task? - - private let apiClient: OpenRouterAPIClient - private let availabilityHelper: AvailabilityHelper - - init(apiKey: String? = nil) { - let resolvedApiKey = apiKey ?? ProcessInfo.processInfo.environment["OPENROUTER_API_KEY"] - self.apiClient = OpenRouterAPIClient(apiKey: resolvedApiKey) - self.availabilityHelper = AvailabilityHelper( - checkInterval: 60.0, - availabilityCheck: { [weak apiClient] in - await apiClient?.checkAvailability() ?? false - } - ) - availabilityHelper.startMonitoring() - } - - deinit { - Task { [weak self] in - await self?.cancelCurrentTask() - } + typealias Model = OpenRouterModel + + let name = "OpenRouter" + + var isAvailable: Bool { + availabilityHelper.isAvailable + } + + var availabilityPublisher: AnyPublisher { + availabilityHelper.availabilityPublisher + } + + var currentTask: Task? + + private let apiClient: OpenRouterAPIClient + private let availabilityHelper: AvailabilityHelper + + init(apiKey: String? = nil) { + let resolvedApiKey = apiKey ?? ProcessInfo.processInfo.environment["OPENROUTER_API_KEY"] + self.apiClient = OpenRouterAPIClient(apiKey: resolvedApiKey) + self.availabilityHelper = AvailabilityHelper( + checkInterval: 60.0, + availabilityCheck: { [weak apiClient] in + await apiClient?.checkAvailability() ?? false + } + ) + availabilityHelper.startMonitoring() + } + + deinit { + Task { [weak self] in + await self?.cancelCurrentTask() } - - func checkAvailability() async -> Bool { - await availabilityHelper.checkAvailabilityNow() + } + + func checkAvailability() async -> Bool { + await availabilityHelper.checkAvailabilityNow() + } + + func listModels() async throws -> [OpenRouterModel] { + guard isAvailable else { + throw LLMError.providerNotAvailable } - - func listModels() async throws -> [OpenRouterModel] { - guard isAvailable else { - throw LLMError.providerNotAvailable - } - - return try await executeWithTaskManagement { - let apiModels = try await self.apiClient.listModels() - return apiModels.map { OpenRouterModel.init(from: $0) } - } + + return try await executeWithTaskManagement { + let apiModels = try await self.apiClient.listModels() + return apiModels.map { OpenRouterModel.init(from: $0) } } - - func generateChatCompletion( - modelName: String, - messages: [LLMMessage], - options: LLMOptions - ) async throws -> String { - try validateProviderAvailable() - try validateMessages(messages) - - return try await executeWithTaskManagement { - try await self.apiClient.generateChatCompletion( - modelName: modelName, - messages: messages, - options: options - ) - } + } + + func generateChatCompletion( + modelName: String, + messages: [LLMMessage], + options: LLMOptions + ) async throws -> String { + try validateProviderAvailable() + try validateMessages(messages) + + return try await executeWithTaskManagement { + try await self.apiClient.generateChatCompletion( + modelName: modelName, + messages: messages, + options: options + ) } - - private func validateProviderAvailable() throws { - guard isAvailable else { - throw LLMError.providerNotAvailable - } + } + + private func validateProviderAvailable() throws { + guard isAvailable else { + throw LLMError.providerNotAvailable } - - private func validateMessages(_ messages: [LLMMessage]) throws { - guard !messages.isEmpty else { - throw LLMError.invalidPrompt - } + } + + private func validateMessages(_ messages: [LLMMessage]) throws { + guard !messages.isEmpty else { + throw LLMError.invalidPrompt } + } } diff --git a/Recap/Services/MeetingDetection/Core/MeetingDetectionService.swift b/Recap/Services/MeetingDetection/Core/MeetingDetectionService.swift index abc1ad4..e0371e2 100644 --- a/Recap/Services/MeetingDetection/Core/MeetingDetectionService.swift +++ b/Recap/Services/MeetingDetection/Core/MeetingDetectionService.swift @@ -1,148 +1,155 @@ -import Foundation -import ScreenCaptureKit import Combine +import Foundation import OSLog +import ScreenCaptureKit private struct DetectorResult { - let detector: any MeetingDetectorType - let result: MeetingDetectionResult + let detector: any MeetingDetectorType + let result: MeetingDetectionResult } @MainActor final class MeetingDetectionService: MeetingDetectionServiceType { - @Published private(set) var isMeetingActive = false - @Published private(set) var activeMeetingInfo: ActiveMeetingInfo? - @Published private(set) var detectedMeetingApp: AudioProcess? - @Published private(set) var hasPermission = false - @Published private(set) var isMonitoring = false - - var meetingStatePublisher: AnyPublisher { - Publishers.CombineLatest3($isMeetingActive, $activeMeetingInfo, $detectedMeetingApp) - .map { isMeeting, meetingInfo, detectedApp in - if isMeeting, let info = meetingInfo { - return .active(info: info, detectedApp: detectedApp) - } else { - return .inactive - } - } - .removeDuplicates() - .eraseToAnyPublisher() - } - - private var monitoringTask: Task? - private var detectors: [any MeetingDetectorType] = [] - private let checkInterval: TimeInterval = 1.0 - private let logger = Logger(subsystem: AppConstants.Logging.subsystem, category: "MeetingDetectionService") - private let audioProcessController: any AudioProcessControllerType - private let permissionsHelper: any PermissionsHelperType - - init(audioProcessController: any AudioProcessControllerType, permissionsHelper: any PermissionsHelperType) { - self.audioProcessController = audioProcessController - self.permissionsHelper = permissionsHelper - setupDetectors() - } - - private func setupDetectors() { - detectors = [ - TeamsMeetingDetector(), - ZoomMeetingDetector(), - GoogleMeetDetector() - ] - } - - func startMonitoring() { - guard !isMonitoring else { return } - - isMonitoring = true - monitoringTask?.cancel() - monitoringTask = Task { - while !Task.isCancelled { - if Task.isCancelled { break } - await checkForMeetings() - try? await Task.sleep(nanoseconds: UInt64(checkInterval * 1_000_000_000)) - } + @Published private(set) var isMeetingActive = false + @Published private(set) var activeMeetingInfo: ActiveMeetingInfo? + @Published private(set) var detectedMeetingApp: AudioProcess? + @Published private(set) var hasPermission = false + @Published private(set) var isMonitoring = false + + var meetingStatePublisher: AnyPublisher { + Publishers.CombineLatest3($isMeetingActive, $activeMeetingInfo, $detectedMeetingApp) + .map { isMeeting, meetingInfo, detectedApp in + if isMeeting, let info = meetingInfo { + return .active(info: info, detectedApp: detectedApp) + } else { + return .inactive } + } + .removeDuplicates() + .eraseToAnyPublisher() + } + + private var monitoringTask: Task? + private var detectors: [any MeetingDetectorType] = [] + private let checkInterval: TimeInterval = 1.0 + private let logger = Logger( + subsystem: AppConstants.Logging.subsystem, category: "MeetingDetectionService") + private let audioProcessController: any AudioProcessControllerType + private let permissionsHelper: any PermissionsHelperType + + init( + audioProcessController: any AudioProcessControllerType, + permissionsHelper: any PermissionsHelperType + ) { + self.audioProcessController = audioProcessController + self.permissionsHelper = permissionsHelper + setupDetectors() + } + + private func setupDetectors() { + detectors = [ + TeamsMeetingDetector(), + ZoomMeetingDetector(), + GoogleMeetDetector() + ] + } + + func startMonitoring() { + guard !isMonitoring else { return } + + isMonitoring = true + monitoringTask?.cancel() + monitoringTask = Task { + while !Task.isCancelled { + if Task.isCancelled { break } + await checkForMeetings() + try? await Task.sleep(nanoseconds: UInt64(checkInterval * 1_000_000_000)) + } } - - func stopMonitoring() { - monitoringTask?.cancel() - isMonitoring = false - monitoringTask = nil - isMeetingActive = false - activeMeetingInfo = nil - } - - private func checkForMeetings() async { - do { - let content = try await SCShareableContent.current - hasPermission = true - - var highestConfidenceResult: DetectorResult? - - for detector in detectors { - let relevantWindows = content.windows.filter { window in - guard let app = window.owningApplication else { return false } - let bundleID = app.bundleIdentifier - return detector.supportedBundleIdentifiers.contains(bundleID) - } - - if !relevantWindows.isEmpty { - let result = await detector.checkForMeeting(in: relevantWindows) - - if result.isActive { - if highestConfidenceResult == nil { - highestConfidenceResult = DetectorResult(detector: detector, result: result) - } else if let currentResult = highestConfidenceResult { - if result.confidence.rawValue > currentResult.result.confidence.rawValue { - highestConfidenceResult = DetectorResult(detector: detector, result: result) - } - } - } - } - } - - if let detectorResult = highestConfidenceResult { - let meetingInfo = ActiveMeetingInfo( - appName: detectorResult.detector.meetingAppName, - title: detectorResult.result.title ?? "Meeting in progress", - confidence: detectorResult.result.confidence - ) - let matchedApp = findMatchingAudioProcess(bundleIdentifiers: detectorResult.detector.supportedBundleIdentifiers) - - activeMeetingInfo = meetingInfo - detectedMeetingApp = matchedApp - isMeetingActive = true - } else { - activeMeetingInfo = nil - detectedMeetingApp = nil - isMeetingActive = false + } + + func stopMonitoring() { + monitoringTask?.cancel() + isMonitoring = false + monitoringTask = nil + isMeetingActive = false + activeMeetingInfo = nil + } + + private func checkForMeetings() async { + do { + let content = try await SCShareableContent.current + hasPermission = true + + var highestConfidenceResult: DetectorResult? + + for detector in detectors { + let relevantWindows = content.windows.filter { window in + guard let app = window.owningApplication else { return false } + let bundleID = app.bundleIdentifier + return detector.supportedBundleIdentifiers.contains(bundleID) + } + + if !relevantWindows.isEmpty { + let result = await detector.checkForMeeting(in: relevantWindows) + + if result.isActive { + if highestConfidenceResult == nil { + highestConfidenceResult = DetectorResult( + detector: detector, result: result) + } else if let currentResult = highestConfidenceResult { + if result.confidence.rawValue > currentResult.result.confidence.rawValue { + highestConfidenceResult = DetectorResult( + detector: detector, result: result) + } } - - } catch { - logger.error("Failed to check for meetings: \(error.localizedDescription)") - hasPermission = false + } } + } + + if let detectorResult = highestConfidenceResult { + let meetingInfo = ActiveMeetingInfo( + appName: detectorResult.detector.meetingAppName, + title: detectorResult.result.title ?? "Meeting in progress", + confidence: detectorResult.result.confidence + ) + let matchedApp = findMatchingAudioProcess( + bundleIdentifiers: detectorResult.detector.supportedBundleIdentifiers + ) + + activeMeetingInfo = meetingInfo + detectedMeetingApp = matchedApp + isMeetingActive = true + } else { + activeMeetingInfo = nil + detectedMeetingApp = nil + isMeetingActive = false + } + + } catch { + logger.error("Failed to check for meetings: \(error.localizedDescription)") + hasPermission = false } - - - private func findMatchingAudioProcess(bundleIdentifiers: Set) -> AudioProcess? { - audioProcessController.processes.first { process in - guard let processBundleID = process.bundleID else { return false } - return bundleIdentifiers.contains(processBundleID) - } + } + + private func findMatchingAudioProcess(bundleIdentifiers: Set) -> AudioProcess? { + audioProcessController.processes.first { process in + guard let processBundleID = process.bundleID else { return false } + return bundleIdentifiers.contains(processBundleID) } + } } extension MeetingDetectionResult.MeetingConfidence: Comparable { - var rawValue: Int { - switch self { - case .low: return 1 - case .medium: return 2 - case .high: return 3 - } - } - - static func < (lhs: Self, rhs: Self) -> Bool { - lhs.rawValue < rhs.rawValue + var rawValue: Int { + switch self { + case .low: return 1 + case .medium: return 2 + case .high: return 3 } + } + + static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } } diff --git a/Recap/Services/MeetingDetection/Core/MeetingDetectionServiceType.swift b/Recap/Services/MeetingDetection/Core/MeetingDetectionServiceType.swift index 4a79c61..1511d9b 100644 --- a/Recap/Services/MeetingDetection/Core/MeetingDetectionServiceType.swift +++ b/Recap/Services/MeetingDetection/Core/MeetingDetectionServiceType.swift @@ -1,44 +1,45 @@ -import Foundation import Combine +import Foundation + #if MOCKING -import Mockable + import Mockable #endif @MainActor #if MOCKING -@Mockable + @Mockable #endif protocol MeetingDetectionServiceType: ObservableObject { - var isMeetingActive: Bool { get } - var activeMeetingInfo: ActiveMeetingInfo? { get } - var detectedMeetingApp: AudioProcess? { get } - var hasPermission: Bool { get } - var isMonitoring: Bool { get } - - var meetingStatePublisher: AnyPublisher { get } - - func startMonitoring() - func stopMonitoring() + var isMeetingActive: Bool { get } + var activeMeetingInfo: ActiveMeetingInfo? { get } + var detectedMeetingApp: AudioProcess? { get } + var hasPermission: Bool { get } + var isMonitoring: Bool { get } + + var meetingStatePublisher: AnyPublisher { get } + + func startMonitoring() + func stopMonitoring() } struct ActiveMeetingInfo { - let appName: String - let title: String - let confidence: MeetingDetectionResult.MeetingConfidence + let appName: String + let title: String + let confidence: MeetingDetectionResult.MeetingConfidence } enum MeetingState: Equatable { - case inactive - case active(info: ActiveMeetingInfo, detectedApp: AudioProcess?) - - static func == (lhs: MeetingState, rhs: MeetingState) -> Bool { - switch (lhs, rhs) { - case (.inactive, .inactive): - return true - case (.active(let lhsInfo, _), .active(let rhsInfo, _)): - return lhsInfo.title == rhsInfo.title && lhsInfo.appName == rhsInfo.appName - default: - return false - } + case inactive + case active(info: ActiveMeetingInfo, detectedApp: AudioProcess?) + + static func == (lhs: MeetingState, rhs: MeetingState) -> Bool { + switch (lhs, rhs) { + case (.inactive, .inactive): + return true + case (.active(let lhsInfo, _), .active(let rhsInfo, _)): + return lhsInfo.title == rhsInfo.title && lhsInfo.appName == rhsInfo.appName + default: + return false } + } } diff --git a/Recap/Services/MeetingDetection/Detectors/GoogleMeetDetector.swift b/Recap/Services/MeetingDetection/Detectors/GoogleMeetDetector.swift index eaafa5d..e7aaf59 100644 --- a/Recap/Services/MeetingDetection/Detectors/GoogleMeetDetector.swift +++ b/Recap/Services/MeetingDetection/Detectors/GoogleMeetDetector.swift @@ -3,40 +3,40 @@ import ScreenCaptureKit @MainActor final class GoogleMeetDetector: MeetingDetectorType { - @Published private(set) var isMeetingActive = false - @Published private(set) var meetingTitle: String? - - let meetingAppName = "Google Meet" - let supportedBundleIdentifiers: Set = [ - "com.google.Chrome", - "com.apple.Safari", - "org.mozilla.firefox", - "com.microsoft.edgemac" - ] - - private let patternMatcher: MeetingPatternMatcher - - init() { - self.patternMatcher = MeetingPatternMatcher(patterns: MeetingPatternMatcher.googleMeetPatterns) - } - - func checkForMeeting(in windows: [any WindowTitleProviding]) async -> MeetingDetectionResult { - for window in windows { - guard let title = window.title, !title.isEmpty else { continue } - - if let confidence = patternMatcher.findBestMatch(in: title) { - return MeetingDetectionResult( - isActive: true, - title: title, - confidence: confidence - ) - } - } - + @Published private(set) var isMeetingActive = false + @Published private(set) var meetingTitle: String? + + let meetingAppName = "Google Meet" + let supportedBundleIdentifiers: Set = [ + "com.google.Chrome", + "com.apple.Safari", + "org.mozilla.firefox", + "com.microsoft.edgemac" + ] + + private let patternMatcher: MeetingPatternMatcher + + init() { + self.patternMatcher = MeetingPatternMatcher(patterns: MeetingPatternMatcher.googleMeetPatterns) + } + + func checkForMeeting(in windows: [any WindowTitleProviding]) async -> MeetingDetectionResult { + for window in windows { + guard let title = window.title, !title.isEmpty else { continue } + + if let confidence = patternMatcher.findBestMatch(in: title) { return MeetingDetectionResult( - isActive: false, - title: nil, - confidence: .low + isActive: true, + title: title, + confidence: confidence ) + } } -} \ No newline at end of file + + return MeetingDetectionResult( + isActive: false, + title: nil, + confidence: .low + ) + } +} diff --git a/Recap/Services/MeetingDetection/Detectors/MeetingDetectorType.swift b/Recap/Services/MeetingDetection/Detectors/MeetingDetectorType.swift index e075ec1..96230b9 100644 --- a/Recap/Services/MeetingDetection/Detectors/MeetingDetectorType.swift +++ b/Recap/Services/MeetingDetection/Detectors/MeetingDetectorType.swift @@ -1,38 +1,39 @@ import Foundation import ScreenCaptureKit + #if MOCKING -import Mockable + import Mockable #endif // MARK: - Window Protocol for Testing protocol WindowTitleProviding { - var title: String? { get } + var title: String? { get } } extension SCWindow: WindowTitleProviding {} @MainActor #if MOCKING -@Mockable + @Mockable #endif protocol MeetingDetectorType: ObservableObject { - var isMeetingActive: Bool { get } - var meetingTitle: String? { get } - var meetingAppName: String { get } - var supportedBundleIdentifiers: Set { get } - - func checkForMeeting(in windows: [any WindowTitleProviding]) async -> MeetingDetectionResult + var isMeetingActive: Bool { get } + var meetingTitle: String? { get } + var meetingAppName: String { get } + var supportedBundleIdentifiers: Set { get } + + func checkForMeeting(in windows: [any WindowTitleProviding]) async -> MeetingDetectionResult } struct MeetingDetectionResult { - let isActive: Bool - let title: String? - let confidence: MeetingConfidence - - enum MeetingConfidence { - case high - case medium - case low - } -} \ No newline at end of file + let isActive: Bool + let title: String? + let confidence: MeetingConfidence + + enum MeetingConfidence { + case high + case medium + case low + } +} diff --git a/Recap/Services/MeetingDetection/Detectors/TeamsMeetingDetector.swift b/Recap/Services/MeetingDetection/Detectors/TeamsMeetingDetector.swift index 62d38d1..065e0a7 100644 --- a/Recap/Services/MeetingDetection/Detectors/TeamsMeetingDetector.swift +++ b/Recap/Services/MeetingDetection/Detectors/TeamsMeetingDetector.swift @@ -3,38 +3,38 @@ import ScreenCaptureKit @MainActor final class TeamsMeetingDetector: MeetingDetectorType { - @Published private(set) var isMeetingActive = false - @Published private(set) var meetingTitle: String? - - let meetingAppName = "Microsoft Teams" - let supportedBundleIdentifiers: Set = [ - "com.microsoft.teams", - "com.microsoft.teams2" - ] - - private let patternMatcher: MeetingPatternMatcher - - init() { - self.patternMatcher = MeetingPatternMatcher(patterns: MeetingPatternMatcher.teamsPatterns) - } - - func checkForMeeting(in windows: [any WindowTitleProviding]) async -> MeetingDetectionResult { - for window in windows { - guard let title = window.title, !title.isEmpty else { continue } - - if let confidence = patternMatcher.findBestMatch(in: title) { - return MeetingDetectionResult( - isActive: true, - title: title, - confidence: confidence - ) - } - } - + @Published private(set) var isMeetingActive = false + @Published private(set) var meetingTitle: String? + + let meetingAppName = "Microsoft Teams" + let supportedBundleIdentifiers: Set = [ + "com.microsoft.teams", + "com.microsoft.teams2" + ] + + private let patternMatcher: MeetingPatternMatcher + + init() { + self.patternMatcher = MeetingPatternMatcher(patterns: MeetingPatternMatcher.teamsPatterns) + } + + func checkForMeeting(in windows: [any WindowTitleProviding]) async -> MeetingDetectionResult { + for window in windows { + guard let title = window.title, !title.isEmpty else { continue } + + if let confidence = patternMatcher.findBestMatch(in: title) { return MeetingDetectionResult( - isActive: false, - title: nil, - confidence: .low + isActive: true, + title: title, + confidence: confidence ) + } } -} \ No newline at end of file + + return MeetingDetectionResult( + isActive: false, + title: nil, + confidence: .low + ) + } +} diff --git a/Recap/Services/MeetingDetection/Detectors/ZoomMeetingDetector.swift b/Recap/Services/MeetingDetection/Detectors/ZoomMeetingDetector.swift index 1d7fa86..9fa4a14 100644 --- a/Recap/Services/MeetingDetection/Detectors/ZoomMeetingDetector.swift +++ b/Recap/Services/MeetingDetection/Detectors/ZoomMeetingDetector.swift @@ -3,35 +3,35 @@ import ScreenCaptureKit @MainActor final class ZoomMeetingDetector: MeetingDetectorType { - @Published private(set) var isMeetingActive = false - @Published private(set) var meetingTitle: String? - - let meetingAppName = "Zoom" - let supportedBundleIdentifiers: Set = ["us.zoom.xos"] - - private let patternMatcher: MeetingPatternMatcher - - init() { - self.patternMatcher = MeetingPatternMatcher(patterns: MeetingPatternMatcher.zoomPatterns) - } - - func checkForMeeting(in windows: [any WindowTitleProviding]) async -> MeetingDetectionResult { - for window in windows { - guard let title = window.title, !title.isEmpty else { continue } - - if let confidence = patternMatcher.findBestMatch(in: title) { - return MeetingDetectionResult( - isActive: true, - title: title, - confidence: confidence - ) - } - } - + @Published private(set) var isMeetingActive = false + @Published private(set) var meetingTitle: String? + + let meetingAppName = "Zoom" + let supportedBundleIdentifiers: Set = ["us.zoom.xos"] + + private let patternMatcher: MeetingPatternMatcher + + init() { + self.patternMatcher = MeetingPatternMatcher(patterns: MeetingPatternMatcher.zoomPatterns) + } + + func checkForMeeting(in windows: [any WindowTitleProviding]) async -> MeetingDetectionResult { + for window in windows { + guard let title = window.title, !title.isEmpty else { continue } + + if let confidence = patternMatcher.findBestMatch(in: title) { return MeetingDetectionResult( - isActive: false, - title: nil, - confidence: .low + isActive: true, + title: title, + confidence: confidence ) + } } -} \ No newline at end of file + + return MeetingDetectionResult( + isActive: false, + title: nil, + confidence: .low + ) + } +} diff --git a/Recap/Services/Processing/Models/ProcessingError.swift b/Recap/Services/Processing/Models/ProcessingError.swift index 794bdea..fc6e542 100644 --- a/Recap/Services/Processing/Models/ProcessingError.swift +++ b/Recap/Services/Processing/Models/ProcessingError.swift @@ -1,36 +1,36 @@ import Foundation enum ProcessingError: LocalizedError { - case transcriptionFailed(String) - case summarizationFailed(String) - case fileNotFound(String) - case coreDataError(String) - case networkError(String) - case cancelled - - var errorDescription: String? { - switch self { - case .transcriptionFailed(let message): - return "Transcription failed: \(message)" - case .summarizationFailed(let message): - return "Summarization failed: \(message)" - case .fileNotFound(let path): - return "Recording file not found at: \(path)" - case .coreDataError(let message): - return "Database error: \(message)" - case .networkError(let message): - return "Network error: \(message)" - case .cancelled: - return "Processing was cancelled" - } + case transcriptionFailed(String) + case summarizationFailed(String) + case fileNotFound(String) + case coreDataError(String) + case networkError(String) + case cancelled + + var errorDescription: String? { + switch self { + case .transcriptionFailed(let message): + return "Transcription failed: \(message)" + case .summarizationFailed(let message): + return "Summarization failed: \(message)" + case .fileNotFound(let path): + return "Recording file not found at: \(path)" + case .coreDataError(let message): + return "Database error: \(message)" + case .networkError(let message): + return "Network error: \(message)" + case .cancelled: + return "Processing was cancelled" } - - var isRetryable: Bool { - switch self { - case .fileNotFound, .cancelled: - return false - default: - return true - } + } + + var isRetryable: Bool { + switch self { + case .fileNotFound, .cancelled: + return false + default: + return true } -} \ No newline at end of file + } +} diff --git a/Recap/Services/Processing/Models/ProcessingResult.swift b/Recap/Services/Processing/Models/ProcessingResult.swift index 8e25da4..9925f82 100644 --- a/Recap/Services/Processing/Models/ProcessingResult.swift +++ b/Recap/Services/Processing/Models/ProcessingResult.swift @@ -1,8 +1,8 @@ import Foundation struct ProcessingResult { - let recordingID: String - let transcriptionText: String - let summaryText: String - let processingDuration: TimeInterval -} \ No newline at end of file + let recordingID: String + let transcriptionText: String + let summaryText: String + let processingDuration: TimeInterval +} diff --git a/Recap/Services/Processing/Models/ProcessingState.swift b/Recap/Services/Processing/Models/ProcessingState.swift index 553e7eb..03abe7a 100644 --- a/Recap/Services/Processing/Models/ProcessingState.swift +++ b/Recap/Services/Processing/Models/ProcessingState.swift @@ -1,25 +1,25 @@ import Foundation enum ProcessingState: Equatable { - case idle - case processing(recordingID: String) - case paused(recordingID: String) - - var isProcessing: Bool { - switch self { - case .processing: - return true - default: - return false - } + case idle + case processing(recordingID: String) + case paused(recordingID: String) + + var isProcessing: Bool { + switch self { + case .processing: + return true + default: + return false } - - var recordingID: String? { - switch self { - case .idle: - return nil - case .processing(let id), .paused(let id): - return id - } + } + + var recordingID: String? { + switch self { + case .idle: + return nil + case .processing(let id), .paused(let id): + return id } -} \ No newline at end of file + } +} diff --git a/Recap/Services/Processing/Models/RecordingError.swift b/Recap/Services/Processing/Models/RecordingError.swift index 03c4b88..5a32531 100644 --- a/Recap/Services/Processing/Models/RecordingError.swift +++ b/Recap/Services/Processing/Models/RecordingError.swift @@ -1,12 +1,12 @@ import Foundation enum RecordingError: LocalizedError { - case failedToStop - - var errorDescription: String? { - switch self { - case .failedToStop: - return "Failed to stop recording properly" - } + case failedToStop + + var errorDescription: String? { + switch self { + case .failedToStop: + return "Failed to stop recording properly" } -} \ No newline at end of file + } +} diff --git a/Recap/Services/Processing/Models/RecordingProcessingState.swift b/Recap/Services/Processing/Models/RecordingProcessingState.swift index 1f83861..e684325 100644 --- a/Recap/Services/Processing/Models/RecordingProcessingState.swift +++ b/Recap/Services/Processing/Models/RecordingProcessingState.swift @@ -1,57 +1,57 @@ import Foundation enum RecordingProcessingState: Int16, CaseIterable { - case recording = 0 - case recorded = 1 - case transcribing = 2 - case transcribed = 3 - case summarizing = 4 - case completed = 5 - case transcriptionFailed = 6 - case summarizationFailed = 7 + case recording = 0 + case recorded = 1 + case transcribing = 2 + case transcribed = 3 + case summarizing = 4 + case completed = 5 + case transcriptionFailed = 6 + case summarizationFailed = 7 } extension RecordingProcessingState { - var isProcessing: Bool { - switch self { - case .transcribing, .summarizing: - return true - default: - return false - } + var isProcessing: Bool { + switch self { + case .transcribing, .summarizing: + return true + default: + return false } - - var isFailed: Bool { - switch self { - case .transcriptionFailed, .summarizationFailed: - return true - default: - return false - } - } - - var canRetry: Bool { - isFailed + } + + var isFailed: Bool { + switch self { + case .transcriptionFailed, .summarizationFailed: + return true + default: + return false } - - var displayName: String { - switch self { - case .recording: - return "Recording" - case .recorded: - return "Recorded" - case .transcribing: - return "Transcribing" - case .transcribed: - return "Transcribed" - case .summarizing: - return "Summarizing" - case .completed: - return "Completed" - case .transcriptionFailed: - return "Transcription Failed" - case .summarizationFailed: - return "Summarization Failed" - } + } + + var canRetry: Bool { + isFailed + } + + var displayName: String { + switch self { + case .recording: + return "Recording" + case .recorded: + return "Recorded" + case .transcribing: + return "Transcribing" + case .transcribed: + return "Transcribed" + case .summarizing: + return "Summarizing" + case .completed: + return "Completed" + case .transcriptionFailed: + return "Transcription Failed" + case .summarizationFailed: + return "Summarization Failed" } -} \ No newline at end of file + } +} diff --git a/Recap/Services/Processing/ProcessingCoordinator+Completion.swift b/Recap/Services/Processing/ProcessingCoordinator+Completion.swift new file mode 100644 index 0000000..deb6248 --- /dev/null +++ b/Recap/Services/Processing/ProcessingCoordinator+Completion.swift @@ -0,0 +1,70 @@ +import Foundation + +@MainActor +extension ProcessingCoordinator { + func completeProcessing( + recording: RecordingInfo, + transcriptionText: String, + summaryText: String, + startTime: Date + ) async { + do { + try await updateRecordingState(recording.id, state: .completed) + + let result = ProcessingResult( + recordingID: recording.id, + transcriptionText: transcriptionText, + summaryText: summaryText, + processingDuration: Date().timeIntervalSince(startTime) + ) + + delegate?.processingDidComplete(recordingID: recording.id, result: result) + } catch { + await handleProcessingError( + ProcessingError.coreDataError(error.localizedDescription), for: recording) + } + } + + func completeProcessingWithoutSummary( + recording: RecordingInfo, + transcriptionText: String, + startTime: Date + ) async { + do { + try await updateRecordingState(recording.id, state: .completed) + + let result = ProcessingResult( + recordingID: recording.id, + transcriptionText: transcriptionText, + summaryText: "", + processingDuration: Date().timeIntervalSince(startTime) + ) + + delegate?.processingDidComplete(recordingID: recording.id, result: result) + } catch { + await handleProcessingError( + ProcessingError.coreDataError(error.localizedDescription), for: recording) + } + } + + func completeProcessingWithoutTranscription( + recording: RecordingInfo, + startTime: Date + ) async { + do { + try await updateRecordingState(recording.id, state: .completed) + + let result = ProcessingResult( + recordingID: recording.id, + transcriptionText: "", + summaryText: "", + processingDuration: Date().timeIntervalSince(startTime) + ) + + delegate?.processingDidComplete(recordingID: recording.id, result: result) + } catch { + await handleProcessingError( + ProcessingError.coreDataError(error.localizedDescription), for: recording) + } + } +} diff --git a/Recap/Services/Processing/ProcessingCoordinator+Helpers.swift b/Recap/Services/Processing/ProcessingCoordinator+Helpers.swift new file mode 100644 index 0000000..1d0864e --- /dev/null +++ b/Recap/Services/Processing/ProcessingCoordinator+Helpers.swift @@ -0,0 +1,49 @@ +import Foundation + +@MainActor +extension ProcessingCoordinator { + func checkAutoSummarizeEnabled() async -> Bool { + do { + let preferences = try await userPreferencesRepository.getOrCreatePreferences() + return preferences.autoSummarizeEnabled + } catch { + return true + } + } + + func checkAutoTranscribeEnabled() async -> Bool { + do { + let preferences = try await userPreferencesRepository.getOrCreatePreferences() + return preferences.autoTranscribeEnabled + } catch { + return true + } + } + + func buildSummarizationRequest(recording: RecordingInfo, transcriptionText: String) + -> SummarizationRequest { + let metadata = TranscriptMetadata( + duration: recording.duration ?? 0, + participants: recording.hasMicrophoneAudio + ? ["User", "System Audio"] : ["System Audio"], + recordingDate: recording.startDate, + applicationName: recording.applicationName + ) + + return SummarizationRequest( + transcriptText: transcriptionText, + metadata: metadata, + options: .default + ) + } + + func updateRecordingState(_ recordingID: String, state: RecordingProcessingState) + async throws { + try await recordingRepository.updateRecordingState( + id: recordingID, + state: state, + errorMessage: nil + ) + delegate?.processingStateDidChange(recordingID: recordingID, newState: state) + } +} diff --git a/Recap/Services/Processing/ProcessingCoordinator+Transcription.swift b/Recap/Services/Processing/ProcessingCoordinator+Transcription.swift new file mode 100644 index 0000000..655d00b --- /dev/null +++ b/Recap/Services/Processing/ProcessingCoordinator+Transcription.swift @@ -0,0 +1,85 @@ +import Foundation +import OSLog + +@MainActor +extension ProcessingCoordinator { + func performTranscriptionPhase(_ recording: RecordingInfo) async throws -> String { + try await updateRecordingState(recording.id, state: .transcribing) + + let transcriptionResult = try await performTranscription(recording) + + try await saveTranscriptionResults(recording: recording, result: transcriptionResult) + + try await updateRecordingState(recording.id, state: .transcribed) + + return transcriptionResult.combinedText + } + + func saveTranscriptionResults( + recording: RecordingInfo, + result: TranscriptionResult + ) async throws { + try await recordingRepository.updateRecordingTranscription( + id: recording.id, + transcriptionText: result.combinedText + ) + + if let timestampedTranscription = result.timestampedTranscription { + try await recordingRepository.updateRecordingTimestampedTranscription( + id: recording.id, + timestampedTranscription: timestampedTranscription + ) + + await exportTranscriptionToMarkdown( + recording: recording, + timestampedTranscription: timestampedTranscription + ) + } + } + + func performTranscription(_ recording: RecordingInfo) async throws + -> TranscriptionResult { + do { + let microphoneURL = recording.hasMicrophoneAudio ? recording.microphoneURL : nil + return try await transcriptionService.transcribe( + audioURL: recording.recordingURL, + microphoneURL: microphoneURL + ) + } catch let error as TranscriptionError { + throw ProcessingError.transcriptionFailed(error.localizedDescription) + } catch { + throw ProcessingError.transcriptionFailed(error.localizedDescription) + } + } + + /// Export transcription to markdown file in the same directory as the recording + func exportTranscriptionToMarkdown( + recording: RecordingInfo, + timestampedTranscription: TimestampedTranscription + ) async { + do { + // Get the directory containing the recording files + let recordingDirectory = recording.recordingURL.deletingLastPathComponent() + + // Fetch the updated recording with timestamped transcription + guard + let updatedRecording = try? await recordingRepository.fetchRecording( + id: recording.id) + else { + logger.warning("Could not fetch updated recording for markdown export") + return + } + + // Export to markdown + let markdownURL = try TranscriptionMarkdownExporter.exportToMarkdown( + recording: updatedRecording, + destinationDirectory: recordingDirectory + ) + + logger.info("Exported transcription to markdown: \(markdownURL.path)") + } catch { + logger.error( + "Failed to export transcription to markdown: \(error.localizedDescription)") + } + } +} diff --git a/Recap/Services/Processing/ProcessingCoordinator.swift b/Recap/Services/Processing/ProcessingCoordinator.swift index 4ad5461..78fa70b 100644 --- a/Recap/Services/Processing/ProcessingCoordinator.swift +++ b/Recap/Services/Processing/ProcessingCoordinator.swift @@ -1,290 +1,215 @@ -import Foundation import Combine +import Foundation +import OSLog @MainActor final class ProcessingCoordinator: ProcessingCoordinatorType { - weak var delegate: ProcessingCoordinatorDelegate? - - @Published private(set) var currentProcessingState: ProcessingState = .idle - - private let recordingRepository: RecordingRepositoryType - private let summarizationService: SummarizationServiceType - private let transcriptionService: TranscriptionServiceType - private let userPreferencesRepository: UserPreferencesRepositoryType - private var systemLifecycleManager: SystemLifecycleManager? - - private var processingTask: Task? - private let processingQueue = AsyncStream.makeStream() - private var queueTask: Task? - - init( - recordingRepository: RecordingRepositoryType, - summarizationService: SummarizationServiceType, - transcriptionService: TranscriptionServiceType, - userPreferencesRepository: UserPreferencesRepositoryType - ) { - self.recordingRepository = recordingRepository - self.summarizationService = summarizationService - self.transcriptionService = transcriptionService - self.userPreferencesRepository = userPreferencesRepository - - startQueueProcessing() - } - - func setSystemLifecycleManager(_ manager: SystemLifecycleManager) { - self.systemLifecycleManager = manager - manager.delegate = self - } - - func startProcessing(recordingInfo: RecordingInfo) async { - processingQueue.continuation.yield(recordingInfo) - } - - func cancelProcessing(recordingID: String) async { - guard case .processing(let currentID) = currentProcessingState, - currentID == recordingID else { return } - - processingTask?.cancel() - currentProcessingState = .idle + let logger = Logger( + subsystem: AppConstants.Logging.subsystem, + category: String(describing: ProcessingCoordinator.self)) + weak var delegate: ProcessingCoordinatorDelegate? - try? await recordingRepository.updateRecordingState( - id: recordingID, - state: .recorded, - errorMessage: "Processing cancelled" - ) - - delegate?.processingDidFail(recordingID: recordingID, error: .cancelled) - } - - func retryProcessing(recordingID: String) async { - guard let recording = try? await recordingRepository.fetchRecording(id: recordingID), - recording.canRetry else { return } - - await startProcessing(recordingInfo: recording) - } - - private func startQueueProcessing() { - queueTask = Task { - for await recording in processingQueue.stream { - guard !Task.isCancelled else { break } - - currentProcessingState = .processing(recordingID: recording.id) - delegate?.processingDidStart(recordingID: recording.id) - - processingTask = Task { - await processRecording(recording) - } - - await processingTask?.value - currentProcessingState = .idle - } - } - } - - private func processRecording(_ recording: RecordingInfo) async { - let startTime = Date() - - do { - let transcriptionText = try await performTranscriptionPhase(recording) - guard !Task.isCancelled else { throw ProcessingError.cancelled } - - let autoSummarizeEnabled = await checkAutoSummarizeEnabled() - - if autoSummarizeEnabled { - let summaryText = try await performSummarizationPhase(recording, transcriptionText: transcriptionText) - guard !Task.isCancelled else { throw ProcessingError.cancelled } - - await completeProcessing( - recording: recording, - transcriptionText: transcriptionText, - summaryText: summaryText, - startTime: startTime - ) - } else { - await completeProcessingWithoutSummary( - recording: recording, - transcriptionText: transcriptionText, - startTime: startTime - ) - } - - } catch let error as ProcessingError { - await handleProcessingError(error, for: recording) - } catch { - let processingError = ProcessingError.coreDataError(error.localizedDescription) - await handleProcessingError(processingError, for: recording) - } - } - - private func performTranscriptionPhase(_ recording: RecordingInfo) async throws -> String { - try await updateRecordingState(recording.id, state: .transcribing) - - let transcriptionResult = try await performTranscription(recording) - - try await recordingRepository.updateRecordingTranscription( - id: recording.id, - transcriptionText: transcriptionResult.combinedText - ) - - try await updateRecordingState(recording.id, state: .transcribed) - - return transcriptionResult.combinedText - } - - private func performSummarizationPhase(_ recording: RecordingInfo, transcriptionText: String) async throws -> String { - try await updateRecordingState(recording.id, state: .summarizing) - - let summaryRequest = buildSummarizationRequest( - recording: recording, - transcriptionText: transcriptionText - ) - - let summaryResult = try await summarizationService.summarize(summaryRequest) - - try await recordingRepository.updateRecordingSummary( - id: recording.id, - summaryText: summaryResult.summary - ) - - return summaryResult.summary - } - - private func buildSummarizationRequest(recording: RecordingInfo, transcriptionText: String) -> SummarizationRequest { - let metadata = SummarizationRequest.TranscriptMetadata( - duration: recording.duration ?? 0, - participants: recording.hasMicrophoneAudio ? ["User", "System Audio"] : ["System Audio"], - recordingDate: recording.startDate, - applicationName: recording.applicationName - ) - - return SummarizationRequest( - transcriptText: transcriptionText, - metadata: metadata, - options: .default - ) - } - - private func updateRecordingState(_ recordingID: String, state: RecordingProcessingState) async throws { - try await recordingRepository.updateRecordingState( - id: recordingID, - state: state, - errorMessage: nil - ) - delegate?.processingStateDidChange(recordingID: recordingID, newState: state) - } - - private func completeProcessing( - recording: RecordingInfo, - transcriptionText: String, - summaryText: String, - startTime: Date - ) async { - do { - try await updateRecordingState(recording.id, state: .completed) - - let result = ProcessingResult( - recordingID: recording.id, - transcriptionText: transcriptionText, - summaryText: summaryText, - processingDuration: Date().timeIntervalSince(startTime) - ) - - delegate?.processingDidComplete(recordingID: recording.id, result: result) - } catch { - await handleProcessingError(ProcessingError.coreDataError(error.localizedDescription), for: recording) - } - } - - private func completeProcessingWithoutSummary( - recording: RecordingInfo, - transcriptionText: String, - startTime: Date - ) async { - do { - try await updateRecordingState(recording.id, state: .completed) - - let result = ProcessingResult( - recordingID: recording.id, - transcriptionText: transcriptionText, - summaryText: "", - processingDuration: Date().timeIntervalSince(startTime) - ) - - delegate?.processingDidComplete(recordingID: recording.id, result: result) - } catch { - await handleProcessingError(ProcessingError.coreDataError(error.localizedDescription), for: recording) - } - } - - private func performTranscription(_ recording: RecordingInfo) async throws -> TranscriptionResult { - do { - let microphoneURL = recording.hasMicrophoneAudio ? recording.microphoneURL : nil - return try await transcriptionService.transcribe( - audioURL: recording.recordingURL, - microphoneURL: microphoneURL - ) - } catch let error as TranscriptionError { - throw ProcessingError.transcriptionFailed(error.localizedDescription) - } catch { - throw ProcessingError.transcriptionFailed(error.localizedDescription) + @Published private(set) var currentProcessingState: ProcessingState = .idle + + let recordingRepository: RecordingRepositoryType + private let summarizationService: SummarizationServiceType + let transcriptionService: TranscriptionServiceType + let userPreferencesRepository: UserPreferencesRepositoryType + private var systemLifecycleManager: SystemLifecycleManager? + + private var processingTask: Task? + private let processingQueue = AsyncStream.makeStream() + private var queueTask: Task? + + init( + recordingRepository: RecordingRepositoryType, + summarizationService: SummarizationServiceType, + transcriptionService: TranscriptionServiceType, + userPreferencesRepository: UserPreferencesRepositoryType + ) { + self.recordingRepository = recordingRepository + self.summarizationService = summarizationService + self.transcriptionService = transcriptionService + self.userPreferencesRepository = userPreferencesRepository + + startQueueProcessing() + } + + func setSystemLifecycleManager(_ manager: SystemLifecycleManager) { + self.systemLifecycleManager = manager + manager.delegate = self + } + + func startProcessing(recordingInfo: RecordingInfo) async { + processingQueue.continuation.yield(recordingInfo) + } + + func cancelProcessing(recordingID: String) async { + guard case .processing(let currentID) = currentProcessingState, + currentID == recordingID + else { return } + + processingTask?.cancel() + currentProcessingState = .idle + + try? await recordingRepository.updateRecordingState( + id: recordingID, + state: .recorded, + errorMessage: "Processing cancelled" + ) + + delegate?.processingDidFail(recordingID: recordingID, error: .cancelled) + } + + func retryProcessing(recordingID: String) async { + guard let recording = try? await recordingRepository.fetchRecording(id: recordingID), + recording.canRetry + else { return } + + await startProcessing(recordingInfo: recording) + } + + private func startQueueProcessing() { + queueTask = Task { + for await recording in processingQueue.stream { + guard !Task.isCancelled else { break } + + currentProcessingState = .processing(recordingID: recording.id) + delegate?.processingDidStart(recordingID: recording.id) + + processingTask = Task { + await processRecording(recording) } + + await processingTask?.value + currentProcessingState = .idle + } } - - private func handleProcessingError(_ error: ProcessingError, for recording: RecordingInfo) async { - let failureState: RecordingProcessingState - - switch error { - case .transcriptionFailed: - failureState = .transcriptionFailed - case .summarizationFailed: - failureState = .summarizationFailed - default: - failureState = recording.state == .transcribing ? .transcriptionFailed : .summarizationFailed - } - - do { - try await recordingRepository.updateRecordingState( - id: recording.id, - state: failureState, - errorMessage: error.localizedDescription - ) - delegate?.processingStateDidChange(recordingID: recording.id, newState: failureState) - } catch { - print("Failed to update recording state after error: \(error)") - } - - delegate?.processingDidFail(recordingID: recording.id, error: error) + } + + private func processRecording(_ recording: RecordingInfo) async { + let startTime = Date() + + do { + let autoTranscribeEnabled = await checkAutoTranscribeEnabled() + + if !autoTranscribeEnabled { + await completeProcessingWithoutTranscription(recording: recording, startTime: startTime) + return + } + + let transcriptionText = try await performTranscriptionPhase(recording) + guard !Task.isCancelled else { throw ProcessingError.cancelled } + + try await processSummarizationIfEnabled( + recording: recording, + transcriptionText: transcriptionText, + startTime: startTime + ) + + } catch let error as ProcessingError { + await handleProcessingError(error, for: recording) + } catch { + await handleProcessingError( + ProcessingError.coreDataError(error.localizedDescription), for: recording) } - - private func checkAutoSummarizeEnabled() async -> Bool { - do { - let preferences = try await userPreferencesRepository.getOrCreatePreferences() - return preferences.autoSummarizeEnabled - } catch { - return true - } + } + + private func processSummarizationIfEnabled( + recording: RecordingInfo, + transcriptionText: String, + startTime: Date + ) async throws { + let autoSummarizeEnabled = await checkAutoSummarizeEnabled() + + if autoSummarizeEnabled { + let summaryText = try await performSummarizationPhase( + recording, transcriptionText: transcriptionText) + guard !Task.isCancelled else { throw ProcessingError.cancelled } + + await completeProcessing( + recording: recording, + transcriptionText: transcriptionText, + summaryText: summaryText, + startTime: startTime + ) + } else { + await completeProcessingWithoutSummary( + recording: recording, + transcriptionText: transcriptionText, + startTime: startTime + ) + } + } + + private func performSummarizationPhase(_ recording: RecordingInfo, transcriptionText: String) + async throws -> String { + try await updateRecordingState(recording.id, state: .summarizing) + + let summaryRequest = buildSummarizationRequest( + recording: recording, + transcriptionText: transcriptionText + ) + + let summaryResult = try await summarizationService.summarize(summaryRequest) + + try await recordingRepository.updateRecordingSummary( + id: recording.id, + summaryText: summaryResult.summary + ) + + return summaryResult.summary + } + + func handleProcessingError(_ error: ProcessingError, for recording: RecordingInfo) async { + let failureState: RecordingProcessingState + + switch error { + case .transcriptionFailed: + failureState = .transcriptionFailed + case .summarizationFailed: + failureState = .summarizationFailed + default: + failureState = + recording.state == .transcribing ? .transcriptionFailed : .summarizationFailed } - - deinit { - queueTask?.cancel() - processingTask?.cancel() + + do { + try await recordingRepository.updateRecordingState( + id: recording.id, + state: failureState, + errorMessage: error.localizedDescription + ) + delegate?.processingStateDidChange(recordingID: recording.id, newState: failureState) + } catch { + logger.error( + "Failed to update recording state after error: \(error.localizedDescription, privacy: .public)" + ) } + + delegate?.processingDidFail(recordingID: recording.id, error: error) + } + + deinit { + queueTask?.cancel() + processingTask?.cancel() + } } extension ProcessingCoordinator: SystemLifecycleDelegate { - func systemWillSleep() { - guard case .processing(let recordingID) = currentProcessingState else { return } - currentProcessingState = .paused(recordingID: recordingID) - processingTask?.cancel() - } - - func systemDidWake() { - guard case .paused(let recordingID) = currentProcessingState else { return } - - Task { - if let recording = try? await recordingRepository.fetchRecording(id: recordingID) { - await startProcessing(recordingInfo: recording) - } - } + func systemWillSleep() { + guard case .processing(let recordingID) = currentProcessingState else { return } + currentProcessingState = .paused(recordingID: recordingID) + processingTask?.cancel() + } + + func systemDidWake() { + guard case .paused(let recordingID) = currentProcessingState else { return } + + Task { + if let recording = try? await recordingRepository.fetchRecording(id: recordingID) { + await startProcessing(recordingInfo: recording) + } } + } } diff --git a/Recap/Services/Processing/ProcessingCoordinatorType.swift b/Recap/Services/Processing/ProcessingCoordinatorType.swift index ba93d02..9c8fefe 100644 --- a/Recap/Services/Processing/ProcessingCoordinatorType.swift +++ b/Recap/Services/Processing/ProcessingCoordinatorType.swift @@ -1,25 +1,26 @@ import Foundation + #if MOCKING -import Mockable + import Mockable #endif @MainActor #if MOCKING -@Mockable + @Mockable #endif protocol ProcessingCoordinatorType { - var delegate: ProcessingCoordinatorDelegate? { get set } - var currentProcessingState: ProcessingState { get } - - func startProcessing(recordingInfo: RecordingInfo) async - func cancelProcessing(recordingID: String) async - func retryProcessing(recordingID: String) async + var delegate: ProcessingCoordinatorDelegate? { get set } + var currentProcessingState: ProcessingState { get } + + func startProcessing(recordingInfo: RecordingInfo) async + func cancelProcessing(recordingID: String) async + func retryProcessing(recordingID: String) async } @MainActor protocol ProcessingCoordinatorDelegate: AnyObject { - func processingDidStart(recordingID: String) - func processingDidComplete(recordingID: String, result: ProcessingResult) - func processingDidFail(recordingID: String, error: ProcessingError) - func processingStateDidChange(recordingID: String, newState: RecordingProcessingState) -} \ No newline at end of file + func processingDidStart(recordingID: String) + func processingDidComplete(recordingID: String, result: ProcessingResult) + func processingDidFail(recordingID: String, error: ProcessingError) + func processingStateDidChange(recordingID: String, newState: RecordingProcessingState) +} diff --git a/Recap/Services/Processing/SystemLifecycle/SystemLifecycleManager.swift b/Recap/Services/Processing/SystemLifecycle/SystemLifecycleManager.swift index c7ff029..9c10f72 100644 --- a/Recap/Services/Processing/SystemLifecycle/SystemLifecycleManager.swift +++ b/Recap/Services/Processing/SystemLifecycle/SystemLifecycleManager.swift @@ -1,49 +1,54 @@ -import Foundation import AppKit +import Foundation @MainActor protocol SystemLifecycleDelegate: AnyObject { - func systemWillSleep() - func systemDidWake() + func systemWillSleep() + func systemDidWake() } +@MainActor final class SystemLifecycleManager { - weak var delegate: SystemLifecycleDelegate? - - private var sleepObserver: NSObjectProtocol? - private var wakeObserver: NSObjectProtocol? - - init() { - setupNotifications() + weak var delegate: SystemLifecycleDelegate? + + private var sleepObserver: NSObjectProtocol? + private var wakeObserver: NSObjectProtocol? + + init() { + setupNotifications() + } + + private func setupNotifications() { + let workspace = NSWorkspace.shared + let notificationCenter = workspace.notificationCenter + + sleepObserver = notificationCenter.addObserver( + forName: NSWorkspace.willSleepNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor in + self?.delegate?.systemWillSleep() + } } - - private func setupNotifications() { - let workspace = NSWorkspace.shared - let notificationCenter = workspace.notificationCenter - - sleepObserver = notificationCenter.addObserver( - forName: NSWorkspace.willSleepNotification, - object: nil, - queue: .main - ) { [weak self] _ in - self?.delegate?.systemWillSleep() - } - - wakeObserver = notificationCenter.addObserver( - forName: NSWorkspace.didWakeNotification, - object: nil, - queue: .main - ) { [weak self] _ in - self?.delegate?.systemDidWake() - } + + wakeObserver = notificationCenter.addObserver( + forName: NSWorkspace.didWakeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor in + self?.delegate?.systemDidWake() + } + } + } + + deinit { + if let observer = sleepObserver { + NSWorkspace.shared.notificationCenter.removeObserver(observer) } - - deinit { - if let observer = sleepObserver { - NSWorkspace.shared.notificationCenter.removeObserver(observer) - } - if let observer = wakeObserver { - NSWorkspace.shared.notificationCenter.removeObserver(observer) - } + if let observer = wakeObserver { + NSWorkspace.shared.notificationCenter.removeObserver(observer) } + } } diff --git a/Recap/Services/Summarization/Models/SummarizationRequest.swift b/Recap/Services/Summarization/Models/SummarizationRequest.swift index 4691791..aeee188 100644 --- a/Recap/Services/Summarization/Models/SummarizationRequest.swift +++ b/Recap/Services/Summarization/Models/SummarizationRequest.swift @@ -1,40 +1,39 @@ import Foundation -// TODO: Clean up +enum SummarizationStyle: String, CaseIterable { + case concise + case detailed + case bulletPoints + case executive +} + +struct TranscriptMetadata { + let duration: TimeInterval + let participants: [String]? + let recordingDate: Date + let applicationName: String? +} + +struct SummarizationOptions { + let style: SummarizationStyle + let includeActionItems: Bool + let includeKeyPoints: Bool + let maxLength: Int? + let customPrompt: String? + + static var `default`: SummarizationOptions { + SummarizationOptions( + style: .concise, + includeActionItems: true, + includeKeyPoints: true, + maxLength: nil, + customPrompt: nil + ) + } +} + struct SummarizationRequest { - let transcriptText: String - let metadata: TranscriptMetadata? - let options: SummarizationOptions - - struct TranscriptMetadata { - let duration: TimeInterval - let participants: [String]? - let recordingDate: Date - let applicationName: String? - } - - struct SummarizationOptions { - let style: SummarizationStyle - let includeActionItems: Bool - let includeKeyPoints: Bool - let maxLength: Int? - let customPrompt: String? - - enum SummarizationStyle: String, CaseIterable { - case concise - case detailed - case bulletPoints - case executive - } - - static var `default`: SummarizationOptions { - SummarizationOptions( - style: .concise, - includeActionItems: true, - includeKeyPoints: true, - maxLength: nil, - customPrompt: nil - ) - } - } + let transcriptText: String + let metadata: TranscriptMetadata? + let options: SummarizationOptions } diff --git a/Recap/Services/Summarization/Models/SummarizationResult.swift b/Recap/Services/Summarization/Models/SummarizationResult.swift index 424c369..09aee9a 100644 --- a/Recap/Services/Summarization/Models/SummarizationResult.swift +++ b/Recap/Services/Summarization/Models/SummarizationResult.swift @@ -1,41 +1,41 @@ import Foundation +enum ActionItemPriority: String, CaseIterable { + case high + case medium + case low +} + +struct ActionItem { + let description: String + let assignee: String? + let priority: ActionItemPriority +} + struct SummarizationResult { - let id: String - let summary: String - let keyPoints: [String] - let actionItems: [ActionItem] - let generatedAt: Date - let modelUsed: String - let processingTime: TimeInterval - - struct ActionItem { - let description: String - let assignee: String? - let priority: Priority - - enum Priority: String, CaseIterable { - case high - case medium - case low - } - } - - init( - id: String = UUID().uuidString, - summary: String, - keyPoints: [String] = [], - actionItems: [ActionItem] = [], - generatedAt: Date = Date(), - modelUsed: String, - processingTime: TimeInterval = 0 - ) { - self.id = id - self.summary = summary - self.keyPoints = keyPoints - self.actionItems = actionItems - self.generatedAt = generatedAt - self.modelUsed = modelUsed - self.processingTime = processingTime - } -} \ No newline at end of file + let id: String + let summary: String + let keyPoints: [String] + let actionItems: [ActionItem] + let generatedAt: Date + let modelUsed: String + let processingTime: TimeInterval + + init( + id: String = UUID().uuidString, + summary: String, + keyPoints: [String] = [], + actionItems: [ActionItem] = [], + generatedAt: Date = Date(), + modelUsed: String, + processingTime: TimeInterval = 0 + ) { + self.id = id + self.summary = summary + self.keyPoints = keyPoints + self.actionItems = actionItems + self.generatedAt = generatedAt + self.modelUsed = modelUsed + self.processingTime = processingTime + } +} diff --git a/Recap/Services/Summarization/SummarizationService.swift b/Recap/Services/Summarization/SummarizationService.swift index 9cf9ade..d386f7f 100644 --- a/Recap/Services/Summarization/SummarizationService.swift +++ b/Recap/Services/Summarization/SummarizationService.swift @@ -1,108 +1,106 @@ -import Foundation import Combine +import Foundation @MainActor final class SummarizationService: SummarizationServiceType { - var isAvailable: Bool { - llmService.isProviderAvailable && currentModel != nil - } - - var currentModelName: String? { - currentModel?.name - } - - private let llmService: LLMServiceType - private var currentModel: LLMModelInfo? - private var cancellables = Set() - - init(llmService: LLMServiceType) { - self.llmService = llmService - setupModelMonitoring() - } - - private func setupModelMonitoring() { - Task { - currentModel = try? await llmService.getSelectedModel() - } - } - - func checkAvailability() async -> Bool { - currentModel = try? await llmService.getSelectedModel() - return isAvailable - } - - func summarize(_ request: SummarizationRequest) async throws -> SummarizationResult { - guard isAvailable else { - throw LLMError.providerNotAvailable - } - - guard let model = currentModel else { - throw LLMError.configurationError("No model selected for summarization") - } - - let startTime = Date() - - let prompt = await buildPrompt(from: request) - let options = buildLLMOptions(from: request.options) - - let summary = try await llmService.generateSummarization( - text: prompt, - options: options - ) - - let processingTime = Date().timeIntervalSince(startTime) - - return SummarizationResult( - summary: summary, - keyPoints: [], - actionItems: [], - modelUsed: model.name, - processingTime: processingTime - ) - } - - func cancelCurrentSummarization() { - llmService.cancelCurrentTask() + var isAvailable: Bool { + llmService.isProviderAvailable && currentModel != nil + } + + var currentModelName: String? { + currentModel?.name + } + + private let llmService: LLMServiceType + private var currentModel: LLMModelInfo? + private var cancellables = Set() + + init(llmService: LLMServiceType) { + self.llmService = llmService + setupModelMonitoring() + } + + private func setupModelMonitoring() { + Task { + currentModel = try? await llmService.getSelectedModel() } - - private func buildPrompt(from request: SummarizationRequest) async -> String { - var prompt = "" - - if let metadata = request.metadata { - prompt += "Context:\n" - if let appName = metadata.applicationName { - prompt += "- Application: \(appName)\n" - } - prompt += "- Duration: \(formatDuration(metadata.duration))\n" - if let participants = metadata.participants, !participants.isEmpty { - prompt += "- Participants: \(participants.joined(separator: ", "))\n" - } - prompt += "\n" - } - - prompt += "Transcript:\n\(request.transcriptText)" - - return prompt + } + + func checkAvailability() async -> Bool { + currentModel = try? await llmService.getSelectedModel() + return isAvailable + } + + func summarize(_ request: SummarizationRequest) async throws -> SummarizationResult { + guard isAvailable else { + throw LLMError.providerNotAvailable } - - - private func buildLLMOptions( - from options: SummarizationRequest.SummarizationOptions - ) -> LLMOptions { - let maxTokens = options.maxLength.map { $0 * 2 } - - return LLMOptions( - temperature: 0.7, - maxTokens: maxTokens, - keepAliveMinutes: 5 - ) + + guard let model = currentModel else { + throw LLMError.configurationError("No model selected for summarization") } - - - private func formatDuration(_ duration: TimeInterval) -> String { - let formatter = DateComponentsFormatter() - formatter.allowedUnits = [.hour, .minute, .second] - formatter.unitsStyle = .abbreviated - return formatter.string(from: duration) ?? "Unknown" + + let startTime = Date() + + let prompt = await buildPrompt(from: request) + let options = buildLLMOptions(from: request.options) + + let summary = try await llmService.generateSummarization( + text: prompt, + options: options + ) + + let processingTime = Date().timeIntervalSince(startTime) + + return SummarizationResult( + summary: summary, + keyPoints: [], + actionItems: [], + modelUsed: model.name, + processingTime: processingTime + ) + } + + func cancelCurrentSummarization() { + llmService.cancelCurrentTask() + } + + private func buildPrompt(from request: SummarizationRequest) async -> String { + var prompt = "" + + if let metadata = request.metadata { + prompt += "Context:\n" + if let appName = metadata.applicationName { + prompt += "- Application: \(appName)\n" + } + prompt += "- Duration: \(formatDuration(metadata.duration))\n" + if let participants = metadata.participants, !participants.isEmpty { + prompt += "- Participants: \(participants.joined(separator: ", "))\n" + } + prompt += "\n" } + + prompt += "Transcript:\n\(request.transcriptText)" + + return prompt + } + + private func buildLLMOptions( + from options: SummarizationOptions + ) -> LLMOptions { + let maxTokens = options.maxLength.map { $0 * 2 } + + return LLMOptions( + temperature: 0.7, + maxTokens: maxTokens, + keepAliveMinutes: 5 + ) + } + + private func formatDuration(_ duration: TimeInterval) -> String { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.hour, .minute, .second] + formatter.unitsStyle = .abbreviated + return formatter.string(from: duration) ?? "Unknown" + } } diff --git a/Recap/Services/Summarization/SummarizationServiceType.swift b/Recap/Services/Summarization/SummarizationServiceType.swift index 95ae89a..c755295 100644 --- a/Recap/Services/Summarization/SummarizationServiceType.swift +++ b/Recap/Services/Summarization/SummarizationServiceType.swift @@ -2,10 +2,10 @@ import Foundation @MainActor protocol SummarizationServiceType: AnyObject { - var isAvailable: Bool { get } - var currentModelName: String? { get } - - func checkAvailability() async -> Bool - func summarize(_ request: SummarizationRequest) async throws -> SummarizationResult - func cancelCurrentSummarization() -} \ No newline at end of file + var isAvailable: Bool { get } + var currentModelName: String? { get } + + func checkAvailability() async -> Bool + func summarize(_ request: SummarizationRequest) async throws -> SummarizationResult + func cancelCurrentSummarization() +} diff --git a/Recap/Services/Transcription/Models/TranscriptionSegment.swift b/Recap/Services/Transcription/Models/TranscriptionSegment.swift new file mode 100644 index 0000000..42a4f47 --- /dev/null +++ b/Recap/Services/Transcription/Models/TranscriptionSegment.swift @@ -0,0 +1,88 @@ +import Foundation + +/// Represents a single segment of transcribed text with timing information +struct TranscriptionSegment: Equatable, Codable { + let text: String + let startTime: TimeInterval + let endTime: TimeInterval + let source: AudioSource + + /// The audio source this segment came from + enum AudioSource: String, CaseIterable, Codable { + case systemAudio = "system_audio" + case microphone = "microphone" + } + + /// Duration of this segment + var duration: TimeInterval { + endTime - startTime + } + + /// Check if this segment overlaps with another segment + func overlaps(with other: TranscriptionSegment) -> Bool { + return startTime < other.endTime && endTime > other.startTime + } + + /// Check if this segment occurs before another segment + func isBefore(_ other: TranscriptionSegment) -> Bool { + return endTime <= other.startTime + } + + /// Check if this segment occurs after another segment + func isAfter(_ other: TranscriptionSegment) -> Bool { + return startTime >= other.endTime + } +} + +/// Collection of transcription segments with utility methods for merging and sorting +struct TimestampedTranscription: Equatable, Codable { + let segments: [TranscriptionSegment] + let totalDuration: TimeInterval + + init(segments: [TranscriptionSegment]) { + self.segments = segments.sorted { $0.startTime < $1.startTime } + self.totalDuration = segments.map { $0.endTime }.max() ?? 0 + } + + /// Get all segments from a specific audio source + func segments(from source: TranscriptionSegment.AudioSource) -> [TranscriptionSegment] { + return segments.filter { $0.source == source } + } + + /// Get segments within a specific time range + func segments(in timeRange: ClosedRange) -> [TranscriptionSegment] { + return segments.filter { segment in + segment.startTime <= timeRange.upperBound && segment.endTime >= timeRange.lowerBound + } + } + + /// Merge with another timestamped transcription, interleaving by time + func merged(with other: TimestampedTranscription) -> TimestampedTranscription { + let allSegments = segments + other.segments + return TimestampedTranscription(segments: allSegments) + } + + /// Get a simple text representation (current behavior) + var combinedText: String { + return segments.map { $0.text }.joined(separator: " ") + } + + /// Get a formatted text representation with timestamps + var formattedText: String { + return segments.map { segment in + let startMinutes = Int(segment.startTime) / 60 + let startSeconds = Int(segment.startTime) % 60 + let endMinutes = Int(segment.endTime) / 60 + let endSeconds = Int(segment.endTime) % 60 + + return "[\(String(format: "%02d:%02d", startMinutes, startSeconds))-" + + "\(String(format: "%02d:%02d", endMinutes, endSeconds))] " + + "[\(segment.source.rawValue)] \(segment.text)" + }.joined(separator: "\n") + } + + /// Get segments grouped by source + var segmentsBySource: [TranscriptionSegment.AudioSource: [TranscriptionSegment]] { + return Dictionary(grouping: segments) { $0.source } + } +} diff --git a/Recap/Services/Transcription/TranscriptionService.swift b/Recap/Services/Transcription/TranscriptionService.swift index b03a499..bf8eb96 100644 --- a/Recap/Services/Transcription/TranscriptionService.swift +++ b/Recap/Services/Transcription/TranscriptionService.swift @@ -1,120 +1,221 @@ import Foundation +import OSLog import WhisperKit @MainActor final class TranscriptionService: TranscriptionServiceType { - private let whisperModelRepository: WhisperModelRepositoryType - private var whisperKit: WhisperKit? - private var loadedModelName: String? - - init(whisperModelRepository: WhisperModelRepositoryType) { - self.whisperModelRepository = whisperModelRepository + private let whisperModelRepository: WhisperModelRepositoryType + private var whisperKit: WhisperKit? + private var loadedModelName: String? + private let logger = Logger( + subsystem: AppConstants.Logging.subsystem, + category: String(describing: TranscriptionService.self)) + + init(whisperModelRepository: WhisperModelRepositoryType) { + self.whisperModelRepository = whisperModelRepository + } + + func transcribe(audioURL: URL, microphoneURL: URL?) async throws -> TranscriptionResult { + let startTime = Date() + + guard FileManager.default.fileExists(atPath: audioURL.path) else { + throw TranscriptionError.audioFileNotFound } - - func transcribe(audioURL: URL, microphoneURL: URL?) async throws -> TranscriptionResult { - let startTime = Date() - - guard FileManager.default.fileExists(atPath: audioURL.path) else { - throw TranscriptionError.audioFileNotFound - } - - try await ensureModelLoaded() - - guard let whisperKit = self.whisperKit, - let modelName = self.loadedModelName else { - throw TranscriptionError.modelNotAvailable - } - - let systemAudioText = try await transcribeAudioFile(audioURL, with: whisperKit) - - var microphoneText: String? - if let microphoneURL = microphoneURL, - FileManager.default.fileExists(atPath: microphoneURL.path) { - microphoneText = try await transcribeAudioFile(microphoneURL, with: whisperKit) - } - - let combinedText = buildCombinedText( - systemAudioText: systemAudioText, - microphoneText: microphoneText - ) - - let duration = Date().timeIntervalSince(startTime) - - return TranscriptionResult( - systemAudioText: systemAudioText, - microphoneText: microphoneText, - combinedText: combinedText, - transcriptionDuration: duration, - modelUsed: modelName - ) + + try await ensureModelLoaded() + + guard let whisperKit = self.whisperKit, + let modelName = self.loadedModelName + else { + throw TranscriptionError.modelNotAvailable } - - func ensureModelLoaded() async throws { - let selectedModel = try await whisperModelRepository.getSelectedModel() - - guard let model = selectedModel else { - throw TranscriptionError.modelNotAvailable - } - - if loadedModelName != model.name || whisperKit == nil { - try await loadModel(model.name, isDownloaded: model.isDownloaded) - } + + // Get both text and timestamped segments + let systemAudioText = try await transcribeAudioFile(audioURL, with: whisperKit) + let systemAudioSegments = try await transcribeAudioFileWithTimestamps( + audioURL, with: whisperKit, source: .systemAudio) + + var microphoneText: String? + var microphoneSegments: [TranscriptionSegment] = [] + + if let microphoneURL = microphoneURL, + FileManager.default.fileExists(atPath: microphoneURL.path) { + microphoneText = try await transcribeAudioFile(microphoneURL, with: whisperKit) + microphoneSegments = try await transcribeAudioFileWithTimestamps( + microphoneURL, with: whisperKit, source: .microphone) } - - func getCurrentModel() async -> String? { - loadedModelName + + let combinedText = buildCombinedText( + systemAudioText: systemAudioText, + microphoneText: microphoneText + ) + + // Create timestamped transcription by merging segments + let allSegments = systemAudioSegments + microphoneSegments + let timestampedTranscription = TimestampedTranscription(segments: allSegments) + + let duration = Date().timeIntervalSince(startTime) + + return TranscriptionResult( + systemAudioText: systemAudioText, + microphoneText: microphoneText, + combinedText: combinedText, + transcriptionDuration: duration, + modelUsed: modelName, + timestampedTranscription: timestampedTranscription + ) + } + + func ensureModelLoaded() async throws { + let selectedModel = try await whisperModelRepository.getSelectedModel() + + guard let model = selectedModel else { + throw TranscriptionError.modelNotAvailable } - - private func loadModel(_ modelName: String, isDownloaded: Bool) async throws { - do { - let newWhisperKit = try await WhisperKit.createWithProgress( - model: modelName, - modelRepo: "argmaxinc/whisperkit-coreml", - modelFolder: nil, - download: true, - progressCallback: { progress in - // todo: notify UI? - print("WhisperKit download progress: \(progress.fractionCompleted)") - } - ) - - self.whisperKit = newWhisperKit - self.loadedModelName = modelName - - if !isDownloaded { - try await whisperModelRepository.markAsDownloaded(name: modelName, sizeInMB: nil) - } - - } catch { - throw TranscriptionError.modelLoadingFailed(error.localizedDescription) - } + + if loadedModelName != model.name || whisperKit == nil { + try await loadModel(model.name, isDownloaded: model.isDownloaded) } - - private func transcribeAudioFile(_ url: URL, with whisperKit: WhisperKit) async throws -> String { - do { - let transcriptionResults = try await whisperKit.transcribe(audioPath: url.path) - - let text = transcriptionResults - .map { $0.text.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - .joined(separator: " ") - - return text - - } catch { - throw TranscriptionError.transcriptionFailed(error.localizedDescription) + } + + func getCurrentModel() async -> String? { + loadedModelName + } + + private func loadModel(_ modelName: String, isDownloaded: Bool) async throws { + do { + logger.info( + """ + Loading WhisperKit model: \(modelName, privacy: .public), \ + isDownloaded: \(isDownloaded, privacy: .public) + """ + ) + + // Always try to download/load the model, as WhisperKit will handle caching + // The isDownloaded flag is just for UI purposes, but WhisperKit manages its own cache + let newWhisperKit = try await WhisperKit.createWithProgress( + model: modelName, + modelRepo: "argmaxinc/whisperkit-coreml", + modelFolder: nil, + download: true, // Always allow download, WhisperKit will use cache if available + progressCallback: { [weak self] progress in + self?.logger.info( + "WhisperKit download progress: \(progress.fractionCompleted, privacy: .public)" + ) } + ) + + logger.info("WhisperKit model loaded successfully: \(modelName, privacy: .public)") + self.whisperKit = newWhisperKit + self.loadedModelName = modelName + + // Mark as downloaded in our repository if not already marked + if !isDownloaded { + let modelInfo = await WhisperKit.getModelSizeInfo(for: modelName) + try await whisperModelRepository.markAsDownloaded( + name: modelName, sizeInMB: Int64(modelInfo.totalSizeMB)) + logger.info( + """ + Model marked as downloaded: \(modelName, privacy: .public), \ + size: \(modelInfo.totalSizeMB, privacy: .public) MB + """ + ) + } + + } catch { + logger.error( + """ + Failed to load WhisperKit model \(modelName, privacy: .public): \ + \(error.localizedDescription, privacy: .public) + """ + ) + throw TranscriptionError.modelLoadingFailed( + "Failed to load model \(modelName): \(error.localizedDescription)") } - - private func buildCombinedText(systemAudioText: String, microphoneText: String?) -> String { - var combinedText = systemAudioText - - if let microphoneText = microphoneText, !microphoneText.isEmpty { - combinedText += "\n\n[User Audio Note: The following was spoken by the user during this recording. Please incorporate this context when creating the meeting summary:]\n\n" - combinedText += microphoneText - combinedText += "\n\n[End of User Audio Note. Please align the above user input with the meeting content for a comprehensive summary.]" - } - - return combinedText + } + + private func transcribeAudioFile(_ url: URL, with whisperKit: WhisperKit) async throws -> String { + do { + let options = DecodingOptions( + task: .transcribe, + language: nil, // Auto-detect language + withoutTimestamps: false, // We want timestamps + wordTimestamps: false // We don't need word-level timestamps for basic transcription + ) + + let results = try await whisperKit.transcribe( + audioPath: url.path, decodeOptions: options) + let result = results.first + + guard let segments = result?.segments else { + return "" + } + + let text = + segments + .map { $0.text.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .joined(separator: " ") + + return text + + } catch { + throw TranscriptionError.transcriptionFailed(error.localizedDescription) } + } + + private func transcribeAudioFileWithTimestamps( + _ url: URL, with whisperKit: WhisperKit, source: TranscriptionSegment.AudioSource + ) async throws -> [TranscriptionSegment] { + do { + let options = DecodingOptions( + task: .transcribe, + language: nil, // Auto-detect language + withoutTimestamps: false, // We want timestamps + wordTimestamps: true // Enable word timestamps for precise timing + ) + + let results = try await whisperKit.transcribe( + audioPath: url.path, decodeOptions: options) + let result = results.first + + guard let segments = result?.segments else { + return [] + } + + // Convert WhisperKit segments to our TranscriptionSegment format + let transcriptionSegments = segments.compactMap { segment -> TranscriptionSegment? in + let text = segment.text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return nil } + + return TranscriptionSegment( + text: text, + startTime: TimeInterval(segment.start), + endTime: TimeInterval(segment.end), + source: source + ) + } + + return transcriptionSegments + + } catch { + throw TranscriptionError.transcriptionFailed(error.localizedDescription) + } + } + + private func buildCombinedText(systemAudioText: String, microphoneText: String?) -> String { + var combinedText = systemAudioText + + if let microphoneText = microphoneText, !microphoneText.isEmpty { + combinedText += + "\n\n[User Audio Note: The following was spoken by the user during this recording." + + " Please incorporate this context when creating the meeting summary:]\n\n" + combinedText += microphoneText + combinedText += + "\n\n[End of User Audio Note. Please align the above user input with the meeting " + + "content for a comprehensive summary.]" + } + + return combinedText + } } diff --git a/Recap/Services/Transcription/TranscriptionServiceType.swift b/Recap/Services/Transcription/TranscriptionServiceType.swift index 3525377..faf07e1 100644 --- a/Recap/Services/Transcription/TranscriptionServiceType.swift +++ b/Recap/Services/Transcription/TranscriptionServiceType.swift @@ -2,38 +2,57 @@ import Foundation @MainActor protocol TranscriptionServiceType { - func transcribe(audioURL: URL, microphoneURL: URL?) async throws -> TranscriptionResult - func ensureModelLoaded() async throws - func getCurrentModel() async -> String? + func transcribe(audioURL: URL, microphoneURL: URL?) async throws -> TranscriptionResult + func ensureModelLoaded() async throws + func getCurrentModel() async -> String? } struct TranscriptionResult: Equatable { - let systemAudioText: String - let microphoneText: String? - let combinedText: String - let transcriptionDuration: TimeInterval - let modelUsed: String + let systemAudioText: String + let microphoneText: String? + let combinedText: String + let transcriptionDuration: TimeInterval + let modelUsed: String + + // New timestamped transcription data + let timestampedTranscription: TimestampedTranscription? + + init( + systemAudioText: String, + microphoneText: String?, + combinedText: String, + transcriptionDuration: TimeInterval, + modelUsed: String, + timestampedTranscription: TimestampedTranscription? = nil + ) { + self.systemAudioText = systemAudioText + self.microphoneText = microphoneText + self.combinedText = combinedText + self.transcriptionDuration = transcriptionDuration + self.modelUsed = modelUsed + self.timestampedTranscription = timestampedTranscription + } } enum TranscriptionError: LocalizedError { - case modelNotAvailable - case modelLoadingFailed(String) - case audioFileNotFound - case transcriptionFailed(String) - case invalidAudioFormat - - var errorDescription: String? { - switch self { - case .modelNotAvailable: - return "No Whisper model is selected or available" - case .modelLoadingFailed(let reason): - return "Failed to load Whisper model: \(reason)" - case .audioFileNotFound: - return "Audio file not found at specified path" - case .transcriptionFailed(let reason): - return "Transcription failed: \(reason)" - case .invalidAudioFormat: - return "Invalid audio format for transcription" - } + case modelNotAvailable + case modelLoadingFailed(String) + case audioFileNotFound + case transcriptionFailed(String) + case invalidAudioFormat + + var errorDescription: String? { + switch self { + case .modelNotAvailable: + return "No Whisper model is selected or available" + case .modelLoadingFailed(let reason): + return "Failed to load Whisper model: \(reason)" + case .audioFileNotFound: + return "Audio file not found at specified path" + case .transcriptionFailed(let reason): + return "Transcription failed: \(reason)" + case .invalidAudioFormat: + return "Invalid audio format for transcription" } -} \ No newline at end of file + } +} diff --git a/Recap/Services/Transcription/Utils/TranscriptionMarkdownExporter.swift b/Recap/Services/Transcription/Utils/TranscriptionMarkdownExporter.swift new file mode 100644 index 0000000..1818ef8 --- /dev/null +++ b/Recap/Services/Transcription/Utils/TranscriptionMarkdownExporter.swift @@ -0,0 +1,168 @@ +import Foundation + +/// Service for exporting transcriptions to markdown format +final class TranscriptionMarkdownExporter { + + /// Export a recording's transcription to a markdown file + /// - Parameters: + /// - recording: The recording information + /// - destinationDirectory: The directory where the markdown file should be saved + /// - Returns: The URL of the created markdown file + /// - Throws: Error if file creation fails + static func exportToMarkdown( + recording: RecordingInfo, + destinationDirectory: URL + ) throws -> URL { + guard let timestampedTranscription = recording.timestampedTranscription else { + throw TranscriptionMarkdownError.noTimestampedTranscription + } + + let markdown = generateMarkdown( + recording: recording, + timestampedTranscription: timestampedTranscription + ) + + let fileURL = makeMarkdownURL( + recording: recording, + destinationDirectory: destinationDirectory + ) + + try markdown.write(to: fileURL, atomically: true, encoding: .utf8) + + return fileURL + } + + /// Compute the markdown file URL for a recording. + static func makeMarkdownURL( + recording: RecordingInfo, + destinationDirectory: URL + ) -> URL { + let filename = generateFilename(from: recording) + return destinationDirectory.appendingPathComponent(filename) + } + + /// Create a placeholder markdown file if one does not already exist. + /// - Parameters: + /// - recording: The recording information used to name the file. + /// - destinationDirectory: The directory where the markdown file should live. + /// - Returns: The URL of the placeholder (existing or newly created). + static func preparePlaceholder( + recording: RecordingInfo, + destinationDirectory: URL + ) throws -> URL { + try FileManager.default.createDirectory( + at: destinationDirectory, + withIntermediateDirectories: true + ) + + let fileURL = makeMarkdownURL( + recording: recording, + destinationDirectory: destinationDirectory + ) + + guard !FileManager.default.fileExists(atPath: fileURL.path) else { + return fileURL + } + + let placeholder = placeholderMarkdown(for: recording) + try placeholder.write(to: fileURL, atomically: true, encoding: .utf8) + + return fileURL + } + + /// Generate the markdown content + private static func generateMarkdown( + recording: RecordingInfo, + timestampedTranscription: TimestampedTranscription + ) -> String { + var markdown = "" + + // Title + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd_HH-mm-ss-SSS" + let dateString = dateFormatter.string(from: recording.startDate) + markdown += "# Transcription - \(dateString)\n\n" + + // Metadata + let generatedFormatter = ISO8601DateFormatter() + generatedFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + markdown += "**Generated:** \(generatedFormatter.string(from: Date()))\n" + + if let duration = recording.duration { + markdown += "**Duration:** \(String(format: "%.2f", duration))s\n" + } + + // Model (we'll use a placeholder for now since it's not stored in RecordingInfo) + markdown += "**Model:** whisperkit\n" + + // Sources + var sources: [String] = [] + if timestampedTranscription.segments.contains(where: { $0.source == .systemAudio }) { + sources.append("System Audio") + } + if timestampedTranscription.segments.contains(where: { $0.source == .microphone }) { + sources.append("Microphone") + } + markdown += "**Sources:** \(sources.joined(separator: ", "))\n" + + // Transcript section + markdown += "## Transcript\n\n" + + // Format transcript using the updated formatter + let formattedTranscript = TranscriptionMerger.getFormattedTranscript(timestampedTranscription) + markdown += formattedTranscript + + markdown += "\n" + + return markdown + } + + /// Generate a filename for the markdown file + private static func generateFilename(from recording: RecordingInfo) -> String { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd_HH-mm-ss-SSS" + let dateString = dateFormatter.string(from: recording.startDate) + return "transcription_\(dateString).md" + } + + /// Generate placeholder markdown content while transcription is in progress. + private static func placeholderMarkdown(for recording: RecordingInfo) -> String { + var markdown = "" + + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd_HH-mm-ss-SSS" + let dateString = dateFormatter.string(from: recording.startDate) + markdown += "# Transcription - \(dateString)\n\n" + + let generatedFormatter = ISO8601DateFormatter() + generatedFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + markdown += "**Generated:** \(generatedFormatter.string(from: Date()))\n" + + if let duration = recording.duration { + markdown += "**Duration:** \(String(format: "%.2f", duration))s\n" + } + + if let applicationName = recording.applicationName { + markdown += "**Source Application:** \(applicationName)\n" + } + + markdown += "\n_Transcription in progress..._\n" + + return markdown + } +} + +/// Errors that can occur during markdown export +enum TranscriptionMarkdownError: LocalizedError { + case noTimestampedTranscription + case fileWriteFailed(String) + + var errorDescription: String? { + switch self { + case .noTimestampedTranscription: + return "No timestamped transcription data available" + case .fileWriteFailed(let reason): + return "Failed to write markdown file: \(reason)" + } + } +} diff --git a/Recap/Services/Transcription/Utils/TranscriptionMerger.swift b/Recap/Services/Transcription/Utils/TranscriptionMerger.swift new file mode 100644 index 0000000..370bb88 --- /dev/null +++ b/Recap/Services/Transcription/Utils/TranscriptionMerger.swift @@ -0,0 +1,137 @@ +import Foundation + +/// Utility class for merging and working with timestamped transcriptions +struct TranscriptionMerger { + + /// Merge timestamped transcriptions from microphone and system audio + /// - Parameters: + /// - systemAudioSegments: Segments from system audio + /// - microphoneSegments: Segments from microphone audio + /// - Returns: Merged timestamped transcription with segments sorted by time + static func mergeTranscriptions( + systemAudioSegments: [TranscriptionSegment], + microphoneSegments: [TranscriptionSegment] + ) -> TimestampedTranscription { + let allSegments = systemAudioSegments + microphoneSegments + return TimestampedTranscription(segments: allSegments) + } + + /// Get a chronological view of the transcription with speaker identification + /// - Parameter transcription: The timestamped transcription + /// - Returns: Array of segments with speaker labels, sorted by time + static func getChronologicalView(_ transcription: TimestampedTranscription) + -> [ChronologicalSegment] { + return transcription.segments.map { segment in + ChronologicalSegment( + text: segment.text, + startTime: segment.startTime, + endTime: segment.endTime, + speaker: segment.source == .microphone ? "User" : "System Audio", + source: segment.source + ) + }.sorted { $0.startTime < $1.startTime } + } + + /// Get segments within a specific time range + /// - Parameters: + /// - transcription: The timestamped transcription + /// - startTime: Start time in seconds + /// - endTime: End time in seconds + /// - Returns: Segments within the specified time range + static func getSegmentsInTimeRange( + _ transcription: TimestampedTranscription, + startTime: TimeInterval, + endTime: TimeInterval + ) -> [TranscriptionSegment] { + return transcription.segments.filter { segment in + segment.startTime <= endTime && segment.endTime >= startTime + } + } + + /// Get a formatted transcript with timestamps and speaker labels + /// - Parameter transcription: The timestamped transcription + /// - Returns: Formatted transcript string + static func getFormattedTranscript(_ transcription: TimestampedTranscription) -> String { + let chronologicalSegments = getChronologicalView(transcription) + + return chronologicalSegments.map { segment in + let duration = segment.endTime - segment.startTime + let source = segment.source == .microphone ? "Microphone" : "System Audio" + let cleanedText = TranscriptionTextCleaner.cleanWhisperKitText(segment.text) + + return + "\(String(format: "%.2f", segment.startTime)) + " + + "\(String(format: "%.2f", duration)), [\(source)]: \(cleanedText)" + }.joined(separator: "\n") + } + + /// Get segments by source (microphone or system audio) + /// - Parameters: + /// - transcription: The timestamped transcription + /// - source: The audio source to filter by + /// - Returns: Segments from the specified source + static func getSegmentsBySource( + _ transcription: TimestampedTranscription, + source: TranscriptionSegment.AudioSource + ) -> [TranscriptionSegment] { + return transcription.segments.filter { $0.source == source } + } + + /// Find overlapping segments between different sources + /// - Parameter transcription: The timestamped transcription + /// - Returns: Array of overlapping segment pairs + static func findOverlappingSegments(_ transcription: TimestampedTranscription) + -> [OverlappingSegments] { + let systemSegments = getSegmentsBySource(transcription, source: .systemAudio) + let microphoneSegments = getSegmentsBySource(transcription, source: .microphone) + + var overlappingPairs: [OverlappingSegments] = [] + + for systemSegment in systemSegments { + for microphoneSegment in microphoneSegments + where systemSegment.overlaps(with: microphoneSegment) { + overlappingPairs.append( + OverlappingSegments( + systemAudio: systemSegment, + microphone: microphoneSegment + )) + } + } + + return overlappingPairs + } +} + +/// Represents a segment in chronological order with speaker information +struct ChronologicalSegment { + let text: String + let startTime: TimeInterval + let endTime: TimeInterval + let speaker: String + let source: TranscriptionSegment.AudioSource +} + +/// Represents overlapping segments from different sources +struct OverlappingSegments { + let systemAudio: TranscriptionSegment + let microphone: TranscriptionSegment + + /// Calculate the overlap duration + var overlapDuration: TimeInterval { + let overlapStart = max(systemAudio.startTime, microphone.startTime) + let overlapEnd = min(systemAudio.endTime, microphone.endTime) + return max(0, overlapEnd - overlapStart) + } + + /// Get the overlap percentage for the system audio segment + var systemAudioOverlapPercentage: Double { + guard systemAudio.duration > 0 else { return 0 } + return overlapDuration / systemAudio.duration + } + + /// Get the overlap percentage for the microphone segment + var microphoneOverlapPercentage: Double { + guard microphone.duration > 0 else { return 0 } + return overlapDuration / microphone.duration + } +} diff --git a/Recap/Services/Transcription/Utils/TranscriptionTextCleaner.swift b/Recap/Services/Transcription/Utils/TranscriptionTextCleaner.swift new file mode 100644 index 0000000..c9e449c --- /dev/null +++ b/Recap/Services/Transcription/Utils/TranscriptionTextCleaner.swift @@ -0,0 +1,77 @@ +import Foundation + +/// Utility class for cleaning and formatting transcription text +final class TranscriptionTextCleaner { + + /// Clean WhisperKit text by removing structured tags and formatting it nicely + static func cleanWhisperKitText(_ text: String) -> String { + var cleanedText = text + + // Remove WhisperKit structured tags + cleanedText = cleanedText.replacingOccurrences(of: "<|startoftranscript|>", with: "") + cleanedText = cleanedText.replacingOccurrences(of: "<|endoftext|>", with: "") + cleanedText = cleanedText.replacingOccurrences(of: "<|en|>", with: "") + cleanedText = cleanedText.replacingOccurrences(of: "<|transcribe|>", with: "") + + // Remove timestamp patterns like <|0.00|> and <|2.00|> + cleanedText = cleanedText.replacingOccurrences( + of: "<|\\d+\\.\\d+\\|>", with: "", options: .regularExpression) + + // Remove pipe characters at the beginning and end of text + cleanedText = cleanedText.replacingOccurrences( + of: "^\\s*\\|\\s*", with: "", options: .regularExpression) + cleanedText = cleanedText.replacingOccurrences( + of: "\\s*\\|\\s*$", with: "", options: .regularExpression) + + // Clean up extra whitespace and normalize line breaks + cleanedText = cleanedText.trimmingCharacters(in: .whitespacesAndNewlines) + cleanedText = cleanedText.replacingOccurrences( + of: "\\s+", with: " ", options: .regularExpression) + + return cleanedText + } + + /// Clean and prettify transcription text with enhanced formatting + static func prettifyTranscriptionText(_ text: String) -> String { + // First clean the WhisperKit tags + var cleanedText = cleanWhisperKitText(text) + + // Handle special sections like [User Audio Note: ...] + cleanedText = formatUserAudioNotes(cleanedText) + + // Clean up [ Silence ] markers + cleanedText = cleanedText.replacingOccurrences( + of: "\\[ Silence \\]", with: "", options: .regularExpression) + + // Normalize whitespace and ensure proper paragraph formatting + cleanedText = cleanedText.replacingOccurrences( + of: "\\n\\s*\\n", with: "\n\n", options: .regularExpression) + cleanedText = cleanedText.trimmingCharacters(in: .whitespacesAndNewlines) + + return cleanedText + } + + /// Format user audio note sections nicely + private static func formatUserAudioNotes(_ text: String) -> String { + var formattedText = text + + // Replace user audio note markers with cleaner formatting + formattedText = formattedText.replacingOccurrences( + of: + "\\[User Audio Note: The following was spoken by the user during this recording\\." + + " Please incorporate this context when creating the meeting summary:\\]", + with: "\n**User Input:**", + options: .regularExpression + ) + + formattedText = formattedText.replacingOccurrences( + of: + "\\[End of User Audio Note\\. Please align the above user input with " + + "the meeting content for a comprehensive summary\\.\\]", + with: "\n**System Audio:**", + options: .regularExpression + ) + + return formattedText + } +} diff --git a/Recap/Services/Transcription/Utils/WhisperKitTimestampExtractor.swift b/Recap/Services/Transcription/Utils/WhisperKitTimestampExtractor.swift new file mode 100644 index 0000000..c0b7ea7 --- /dev/null +++ b/Recap/Services/Transcription/Utils/WhisperKitTimestampExtractor.swift @@ -0,0 +1,211 @@ +import Foundation +import WhisperKit + +/// Utility class for extracting timestamps from WhisperKit transcription results +/// This provides enhanced functionality for working with timestamped transcriptions +struct WhisperKitTimestampExtractor { + + /// Extract timestamped segments from WhisperKit transcription results + /// - Parameters: + /// - segments: WhisperKit segments from transcribe result + /// - source: Audio source (microphone or system audio) + /// - Returns: Array of timestamped transcription segments + static func extractSegments( + from segments: [Any], + source: TranscriptionSegment.AudioSource + ) -> [TranscriptionSegment] { + return segments.compactMap { segment in + // Use Mirror to access properties dynamically + let mirror = Mirror(reflecting: segment) + guard let text = mirror.children.first(where: { $0.label == "text" })?.value as? String, + let start = mirror.children.first(where: { $0.label == "start" })?.value as? Float, + let end = mirror.children.first(where: { $0.label == "end" })?.value as? Float + else { + return nil + } + + let trimmedText = text.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + guard !trimmedText.isEmpty else { return nil } + + return TranscriptionSegment( + text: trimmedText, + startTime: TimeInterval(start), + endTime: TimeInterval(end), + source: source + ) + } + } + + /// Extract word-level segments from WhisperKit transcription results + /// - Parameters: + /// - segments: WhisperKit segments from transcribe result + /// - source: Audio source (microphone or system audio) + /// - Returns: Array of word-level timestamped segments + static func extractWordSegments( + from segments: [Any], + source: TranscriptionSegment.AudioSource + ) -> [TranscriptionSegment] { + var wordSegments: [TranscriptionSegment] = [] + + for segment in segments { + let segmentMirror = Mirror(reflecting: segment) + + // Extract word-level timestamps if available + if let words = segmentMirror.children.first(where: { $0.label == "words" })?.value + as? [Any] { + for word in words { + let wordMirror = Mirror(reflecting: word) + guard + let wordText = wordMirror.children.first(where: { $0.label == "word" })? + .value as? String, + let wordStart = wordMirror.children.first(where: { $0.label == "start" })? + .value as? Float, + let wordEnd = wordMirror.children.first(where: { $0.label == "end" })?.value + as? Float + else { continue } + + let text = wordText.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + guard !text.isEmpty else { continue } + + wordSegments.append( + TranscriptionSegment( + text: text, + startTime: TimeInterval(wordStart), + endTime: TimeInterval(wordEnd), + source: source + )) + } + } else { + // Fallback to segment-level timing + guard + let text = segmentMirror.children.first(where: { $0.label == "text" })?.value + as? String, + let start = segmentMirror.children.first(where: { $0.label == "start" })?.value + as? Float, + let end = segmentMirror.children.first(where: { $0.label == "end" })?.value + as? Float + else { continue } + + let trimmedText = text.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + guard !trimmedText.isEmpty else { continue } + + wordSegments.append( + TranscriptionSegment( + text: trimmedText, + startTime: TimeInterval(start), + endTime: TimeInterval(end), + source: source + )) + } + } + + return wordSegments + } + + /// Create a more granular transcription by splitting segments into smaller chunks + /// - Parameters: + /// - segments: WhisperKit segments + /// - source: Audio source + /// - maxSegmentDuration: Maximum duration for each segment in seconds + /// - Returns: Array of refined timestamped segments + static func createRefinedSegments( + from segments: [Any], + source: TranscriptionSegment.AudioSource, + maxSegmentDuration: TimeInterval = 5.0 + ) -> [TranscriptionSegment] { + var refinedSegments: [TranscriptionSegment] = [] + + for segment in segments { + let mirror = Mirror(reflecting: segment) + guard let text = mirror.children.first(where: { $0.label == "text" })?.value as? String, + let start = mirror.children.first(where: { $0.label == "start" })?.value as? Float, + let end = mirror.children.first(where: { $0.label == "end" })?.value as? Float + else { continue } + + let duration = end - start + + if duration <= Float(maxSegmentDuration) { + // Segment is already small enough + refinedSegments.append( + TranscriptionSegment( + text: text, + startTime: TimeInterval(start), + endTime: TimeInterval(end), + source: source + )) + } else { + // Split the segment into smaller chunks + let words = text.components(separatedBy: CharacterSet.whitespaces) + let wordsPerChunk = max( + 1, Int(Double(words.count) * maxSegmentDuration / Double(duration))) + + for wordIndex in stride(from: 0, to: words.count, by: wordsPerChunk) { + let endIndex = min(wordIndex + wordsPerChunk, words.count) + let chunkWords = Array(words[wordIndex.. TimeInterval { + let trimmedText = text.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + let wordCount = trimmedText.components(separatedBy: CharacterSet.whitespaces).count + + // Estimate based on average speaking rate (150 words per minute) + let wordsPerSecond = 150.0 / 60.0 + let estimatedDuration = Double(wordCount) / wordsPerSecond + + // Ensure minimum duration and add some padding for natural speech + return max(1.0, estimatedDuration * 1.2) + } + + /// Check if WhisperKit segments contain word-level timestamp information + /// - Parameter segments: WhisperKit segments + /// - Returns: True if word timestamps are available, false otherwise + static func hasWordTimestamps(_ segments: [Any]) -> Bool { + return segments.contains { segment in + let mirror = Mirror(reflecting: segment) + guard let words = mirror.children.first(where: { $0.label == "words" })?.value as? [Any] + else { return false } + return !words.isEmpty + } + } + + /// Get the total duration of all segments + /// - Parameter segments: Array of transcription segments + /// - Returns: Total duration in seconds + static func totalDuration(_ segments: [Any]) -> TimeInterval { + return segments.compactMap { segment in + let mirror = Mirror(reflecting: segment) + guard let end = mirror.children.first(where: { $0.label == "end" })?.value as? Float + else { return nil } + return TimeInterval(end) + }.max() ?? 0 + } +} diff --git a/Recap/Services/Utilities/Notifications/NotificationService.swift b/Recap/Services/Utilities/Notifications/NotificationService.swift index e337bfe..b0f4ed8 100644 --- a/Recap/Services/Utilities/Notifications/NotificationService.swift +++ b/Recap/Services/Utilities/Notifications/NotificationService.swift @@ -1,45 +1,46 @@ import Foundation -import UserNotifications import OSLog +import UserNotifications @MainActor final class NotificationService: NotificationServiceType { - private let logger = Logger(subsystem: AppConstants.Logging.subsystem, category: "NotificationService") - private let notificationCenter = UNUserNotificationCenter.current() - - func sendMeetingStartedNotification(appName: String, title: String) async { - let content = UNMutableNotificationContent() - content.title = "\(appName): Meeting Detected" - content.body = "Want to start recording it?" - content.sound = .default - content.categoryIdentifier = "MEETING_ACTIONS" - content.userInfo = ["action": "open_app"] - - await sendNotification(identifier: "meeting-started", content: content) - } - - func sendMeetingEndedNotification() async { - let content = UNMutableNotificationContent() - content.title = "Meeting Ended" - content.body = "The meeting has ended" - content.sound = .default - - await sendNotification(identifier: "meeting-ended", content: content) - } + private let logger = Logger( + subsystem: AppConstants.Logging.subsystem, category: "NotificationService") + private let notificationCenter = UNUserNotificationCenter.current() + + func sendMeetingStartedNotification(appName: String, title: String) async { + let content = UNMutableNotificationContent() + content.title = "\(appName): Meeting Detected" + content.body = "Want to start recording it?" + content.sound = .default + content.categoryIdentifier = "MEETING_ACTIONS" + content.userInfo = ["action": "open_app"] + + await sendNotification(identifier: "meeting-started", content: content) + } + + func sendMeetingEndedNotification() async { + let content = UNMutableNotificationContent() + content.title = "Meeting Ended" + content.body = "The meeting has ended" + content.sound = .default + + await sendNotification(identifier: "meeting-ended", content: content) + } } -private extension NotificationService { - func sendNotification(identifier: String, content: UNMutableNotificationContent) async { - let request = UNNotificationRequest( - identifier: identifier, - content: content, - trigger: nil - ) - - do { - try await notificationCenter.add(request) - } catch { - logger.error("Failed to send notification \(identifier): \(error)") - } +extension NotificationService { + fileprivate func sendNotification(identifier: String, content: UNMutableNotificationContent) async { + let request = UNNotificationRequest( + identifier: identifier, + content: content, + trigger: nil + ) + + do { + try await notificationCenter.add(request) + } catch { + logger.error("Failed to send notification \(identifier): \(error)") } + } } diff --git a/Recap/Services/Utilities/Notifications/NotificationServiceType.swift b/Recap/Services/Utilities/Notifications/NotificationServiceType.swift index 3a1f739..c4d41d5 100644 --- a/Recap/Services/Utilities/Notifications/NotificationServiceType.swift +++ b/Recap/Services/Utilities/Notifications/NotificationServiceType.swift @@ -2,6 +2,6 @@ import Foundation @MainActor protocol NotificationServiceType { - func sendMeetingStartedNotification(appName: String, title: String) async - func sendMeetingEndedNotification() async + func sendMeetingStartedNotification(appName: String, title: String) async + func sendMeetingEndedNotification() async } diff --git a/Recap/Services/Utilities/Warnings/ProviderWarningCoordinator.swift b/Recap/Services/Utilities/Warnings/ProviderWarningCoordinator.swift index 34e5d69..a180bdd 100644 --- a/Recap/Services/Utilities/Warnings/ProviderWarningCoordinator.swift +++ b/Recap/Services/Utilities/Warnings/ProviderWarningCoordinator.swift @@ -1,102 +1,110 @@ -import Foundation import Combine +import Foundation final class ProviderWarningCoordinator { - private let warningManager: any WarningManagerType - private let llmService: LLMServiceType - private var cancellables = Set() - - private let ollamaWarningId = "ollama_connectivity" - private let openRouterWarningId = "openrouter_connectivity" - - init(warningManager: any WarningManagerType, llmService: LLMServiceType) { - self.warningManager = warningManager - self.llmService = llmService + private let warningManager: any WarningManagerType + private let llmService: LLMServiceType + private var cancellables = Set() + + private let ollamaWarningId = "ollama_connectivity" + private let openRouterWarningId = "openrouter_connectivity" + + init(warningManager: any WarningManagerType, llmService: LLMServiceType) { + self.warningManager = warningManager + self.llmService = llmService + } + + func startMonitoring() { + Task { @MainActor in + try? await Task.sleep(nanoseconds: 1_000_000_000) + setupProviderMonitoring() } - - func startMonitoring() { - Task { @MainActor in - try? await Task.sleep(nanoseconds: 1_000_000_000) - setupProviderMonitoring() - } + } + + @MainActor + private func setupProviderMonitoring() { + guard let ollamaProvider = llmService.availableProviders.first(where: { $0.name == "Ollama" }), + let openRouterProvider = llmService.availableProviders.first(where: { + $0.name == "OpenRouter" + }) + else { + Task { + try? await Task.sleep(nanoseconds: 1_000_000_000) + setupProviderMonitoring() + } + return } - - @MainActor - private func setupProviderMonitoring() { - guard let ollamaProvider = llmService.availableProviders.first(where: { $0.name == "Ollama" }), - let openRouterProvider = llmService.availableProviders.first(where: { $0.name == "OpenRouter" }) else { - Task { - try? await Task.sleep(nanoseconds: 1_000_000_000) - setupProviderMonitoring() - } - return - } - - Publishers.CombineLatest( - ollamaProvider.availabilityPublisher, - openRouterProvider.availabilityPublisher + + Publishers.CombineLatest( + ollamaProvider.availabilityPublisher, + openRouterProvider.availabilityPublisher + ) + .sink { [weak self] ollamaAvailable, openRouterAvailable in + Task { @MainActor in + await self?.updateProviderWarnings( + ollamaAvailable: ollamaAvailable, + openRouterAvailable: openRouterAvailable ) - .sink { [weak self] ollamaAvailable, openRouterAvailable in - Task { @MainActor in - await self?.updateProviderWarnings( - ollamaAvailable: ollamaAvailable, - openRouterAvailable: openRouterAvailable - ) - } - } - .store(in: &cancellables) + } } - - @MainActor - private func updateProviderWarnings(ollamaAvailable: Bool, openRouterAvailable: Bool) async { - do { - let preferences = try await llmService.getUserPreferences() - let selectedProvider = preferences.selectedProvider - - switch selectedProvider { - case .ollama: - handleOllamaWarning(isAvailable: ollamaAvailable) - warningManager.removeWarning(withId: openRouterWarningId) - - case .openRouter: - handleOpenRouterWarning(isAvailable: openRouterAvailable) - warningManager.removeWarning(withId: ollamaWarningId) - } - } catch { - warningManager.removeWarning(withId: ollamaWarningId) - warningManager.removeWarning(withId: openRouterWarningId) - } + .store(in: &cancellables) + } + + @MainActor + private func updateProviderWarnings(ollamaAvailable: Bool, openRouterAvailable: Bool) async { + do { + let preferences = try await llmService.getUserPreferences() + let selectedProvider = preferences.selectedProvider + + switch selectedProvider { + case .ollama: + handleOllamaWarning(isAvailable: ollamaAvailable) + warningManager.removeWarning(withId: openRouterWarningId) + + case .openRouter: + handleOpenRouterWarning(isAvailable: openRouterAvailable) + warningManager.removeWarning(withId: ollamaWarningId) + + case .openAI: + // OpenAI warnings would be handled here if needed + warningManager.removeWarning(withId: ollamaWarningId) + warningManager.removeWarning(withId: openRouterWarningId) + } + } catch { + warningManager.removeWarning(withId: ollamaWarningId) + warningManager.removeWarning(withId: openRouterWarningId) } - - @MainActor - private func handleOllamaWarning(isAvailable: Bool) { - if isAvailable { - warningManager.removeWarning(withId: ollamaWarningId) - } else { - let warning = WarningItem( - id: ollamaWarningId, - title: "Ollama Not Running", - message: "Please start Ollama to use local AI models for summarization.", - icon: "server.rack", - severity: .error - ) - warningManager.updateWarning(warning) - } + } + + @MainActor + private func handleOllamaWarning(isAvailable: Bool) { + if isAvailable { + warningManager.removeWarning(withId: ollamaWarningId) + } else { + let warning = WarningItem( + id: ollamaWarningId, + title: "Ollama Not Running", + message: "Please start Ollama to use local AI models for summarization.", + icon: "server.rack", + severity: .error + ) + warningManager.updateWarning(warning) } - - @MainActor - private func handleOpenRouterWarning(isAvailable: Bool) { - if isAvailable { - warningManager.removeWarning(withId: openRouterWarningId) - } else { - let warning = WarningItem( - id: openRouterWarningId, - title: "OpenRouter Unavailable", - message: "Cannot connect to OpenRouter. Check your internet connection and API key.", - icon: "network.slash", - severity: .warning - ) - warningManager.updateWarning(warning) - } + } + + @MainActor + private func handleOpenRouterWarning(isAvailable: Bool) { + if isAvailable { + warningManager.removeWarning(withId: openRouterWarningId) + } else { + let warning = WarningItem( + id: openRouterWarningId, + title: "OpenRouter Unavailable", + message: "Cannot connect to OpenRouter. Check your internet connection and API key.", + icon: "network.slash", + severity: .warning + ) + warningManager.updateWarning(warning) } + } } diff --git a/Recap/Services/Utilities/Warnings/WarningManager.swift b/Recap/Services/Utilities/Warnings/WarningManager.swift index 400a824..b2f1cc2 100644 --- a/Recap/Services/Utilities/Warnings/WarningManager.swift +++ b/Recap/Services/Utilities/Warnings/WarningManager.swift @@ -1,32 +1,32 @@ -import Foundation import Combine +import Foundation final class WarningManager: WarningManagerType { - @Published private(set) var activeWarnings: [WarningItem] = [] - - var activeWarningsPublisher: AnyPublisher<[WarningItem], Never> { - $activeWarnings.eraseToAnyPublisher() - } - - func addWarning(_ warning: WarningItem) { - if !activeWarnings.contains(where: { $0.id == warning.id }) { - activeWarnings.append(warning) - } - } - - func removeWarning(withId id: String) { - activeWarnings.removeAll { $0.id == id } - } - - func clearAllWarnings() { - activeWarnings.removeAll() + @Published private(set) var activeWarnings: [WarningItem] = [] + + var activeWarningsPublisher: AnyPublisher<[WarningItem], Never> { + $activeWarnings.eraseToAnyPublisher() + } + + func addWarning(_ warning: WarningItem) { + if !activeWarnings.contains(where: { $0.id == warning.id }) { + activeWarnings.append(warning) } - - func updateWarning(_ warning: WarningItem) { - if let index = activeWarnings.firstIndex(where: { $0.id == warning.id }) { - activeWarnings[index] = warning - } else { - addWarning(warning) - } + } + + func removeWarning(withId id: String) { + activeWarnings.removeAll { $0.id == id } + } + + func clearAllWarnings() { + activeWarnings.removeAll() + } + + func updateWarning(_ warning: WarningItem) { + if let index = activeWarnings.firstIndex(where: { $0.id == warning.id }) { + activeWarnings[index] = warning + } else { + addWarning(warning) } + } } diff --git a/Recap/Services/Utilities/Warnings/WarningManagerType.swift b/Recap/Services/Utilities/Warnings/WarningManagerType.swift index 38ff820..6f0dd9b 100644 --- a/Recap/Services/Utilities/Warnings/WarningManagerType.swift +++ b/Recap/Services/Utilities/Warnings/WarningManagerType.swift @@ -1,58 +1,59 @@ -import Foundation import Combine +import Foundation + #if MOCKING -import Mockable + import Mockable #endif @MainActor #if MOCKING -@Mockable + @Mockable #endif protocol WarningManagerType: ObservableObject { - var activeWarnings: [WarningItem] { get } - var activeWarningsPublisher: AnyPublisher<[WarningItem], Never> { get } - - func addWarning(_ warning: WarningItem) - func removeWarning(withId id: String) - func clearAllWarnings() - func updateWarning(_ warning: WarningItem) + var activeWarnings: [WarningItem] { get } + var activeWarningsPublisher: AnyPublisher<[WarningItem], Never> { get } + + func addWarning(_ warning: WarningItem) + func removeWarning(withId id: String) + func clearAllWarnings() + func updateWarning(_ warning: WarningItem) } struct WarningItem: Identifiable, Equatable { - let id: String - let title: String - let message: String - let icon: String - let severity: WarningSeverity - - init( - id: String, - title: String, - message: String, - icon: String = "exclamationmark.triangle.fill", - severity: WarningSeverity = .warning - ) { - self.id = id - self.title = title - self.message = message - self.icon = icon - self.severity = severity - } + let id: String + let title: String + let message: String + let icon: String + let severity: WarningSeverity + + init( + id: String, + title: String, + message: String, + icon: String = "exclamationmark.triangle.fill", + severity: WarningSeverity = .warning + ) { + self.id = id + self.title = title + self.message = message + self.icon = icon + self.severity = severity + } } enum WarningSeverity { - case info - case warning - case error - - var color: String { - switch self { - case .info: - return "0084FF" - case .warning: - return "FFA500" - case .error: - return "FF3B30" - } + case info + case warning + case error + + var color: String { + switch self { + case .info: + return "0084FF" + case .warning: + return "FFA500" + case .error: + return "FF3B30" } + } } diff --git a/Recap/UIComponents/Alerts/CenteredAlert.swift b/Recap/UIComponents/Alerts/CenteredAlert.swift index d41517c..456d229 100644 --- a/Recap/UIComponents/Alerts/CenteredAlert.swift +++ b/Recap/UIComponents/Alerts/CenteredAlert.swift @@ -1,89 +1,92 @@ import SwiftUI struct CenteredAlert: View { - @Binding var isPresented: Bool - let title: String - let onDismiss: () -> Void - @ViewBuilder let content: Content - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - headerSection - - Divider() - .background(Color.white.opacity(0.1)) - - VStack(alignment: .leading, spacing: 20) { - content - } - .padding(.horizontal, 24) - .padding(.vertical, 20) - } - .frame(width: 400) - .background( - RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius) - .fill(.thinMaterial) - .overlay( - RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius) - .fill(UIConstants.Gradients.backgroundGradient.opacity(0.8)) - ) - .overlay( - RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius) - .stroke(UIConstants.Gradients.standardBorder, lineWidth: UIConstants.Sizing.strokeWidth) - ) - ) + @Binding var isPresented: Bool + let title: String + let onDismiss: () -> Void + @ViewBuilder let content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + headerSection + + Divider() + .background(Color.white.opacity(0.1)) + + VStack(alignment: .leading, spacing: 20) { + content + } + .padding(.horizontal, 24) + .padding(.vertical, 20) } - - private var headerSection: some View { - HStack(alignment: .center) { - VStack(alignment: .leading, spacing: 0) { - Text(title) - .font(.system(size: 16, weight: .bold)) - .foregroundColor(UIConstants.Colors.textPrimary) - .multilineTextAlignment(.leading) - } - - Spacer() - - PillButton( - text: "Close", - icon: "xmark" - ) { - isPresented = false - onDismiss() - } - } - .padding(.horizontal, 24) - .padding(.vertical, 20) + .frame(width: 400) + .background( + RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius) + .fill(.thinMaterial) + .overlay( + RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius) + .fill(UIConstants.Gradients.backgroundGradient.opacity(0.8)) + ) + .overlay( + RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius) + .stroke( + UIConstants.Gradients.standardBorder, + lineWidth: UIConstants.Sizing.strokeWidth) + ) + ) + } + + private var headerSection: some View { + HStack(alignment: .center) { + VStack(alignment: .leading, spacing: 0) { + Text(title) + .font(.system(size: 16, weight: .bold)) + .foregroundColor(UIConstants.Colors.textPrimary) + .multilineTextAlignment(.leading) + } + + Spacer() + + PillButton( + text: "Close", + icon: "xmark" + ) { + isPresented = false + onDismiss() + } } + .padding(.horizontal, 24) + .padding(.vertical, 20) + } } #Preview { - ZStack { - Rectangle() - .fill(Color.gray.opacity(0.3)) - .overlay( - Text("Background Content") - .foregroundColor(.white) - ) - - Color.black.opacity(0.3) - .ignoresSafeArea() - - CenteredAlert( - isPresented: .constant(true), - title: "Example Alert", - onDismiss: {} - ) { - VStack(alignment: .leading, spacing: 20) { - Text("This is centered alert content") - .foregroundColor(.white) - - Button("Example Button") {} - .foregroundColor(.blue) - } + ZStack { + Rectangle() + .fill(Color.gray.opacity(0.3)) + .overlay( + Text("Background Content") + .foregroundColor(.white) + ) + + Color.black.opacity(0.3) + .ignoresSafeArea() + + CenteredAlert( + isPresented: .constant(true), + title: "Example Alert", + onDismiss: {}, + content: { + VStack(alignment: .leading, spacing: 20) { + Text("This is centered alert content") + .foregroundColor(.white) + + Button("Example Button") {} + .foregroundColor(.blue) } - } - .frame(width: 600, height: 400) - .background(Color.black) + } + ) + } + .frame(width: 600, height: 400) + .background(Color.black) } diff --git a/Recap/UIComponents/Buttons/AppSelectionButton.swift b/Recap/UIComponents/Buttons/AppSelectionButton.swift index b4b9c5e..16044ce 100644 --- a/Recap/UIComponents/Buttons/AppSelectionButton.swift +++ b/Recap/UIComponents/Buttons/AppSelectionButton.swift @@ -8,137 +8,137 @@ import SwiftUI struct AppSelectionButton: View { - @ObservedObject private var viewModel: AppSelectionViewModel - @StateObject private var dropdownManager = DropdownWindowManager() - @State private var buttonView: NSView? - - init(viewModel: AppSelectionViewModel) { - self.viewModel = viewModel + @ObservedObject private var viewModel: AppSelectionViewModel + @StateObject private var dropdownManager = DropdownWindowManager() + @State private var buttonView: NSView? + + init(viewModel: AppSelectionViewModel) { + self.viewModel = viewModel + } + + var body: some View { + Button { + if viewModel.state.isShowingDropdown { + dropdownManager.hideDropdown() + viewModel.toggleDropdown() + } else { + viewModel.toggleDropdown() + showDropdownWindow() + } + } label: { + buttonContent + } + .buttonStyle(PlainButtonStyle()) + .background( + ViewGeometryReader { view in + buttonView = view + } + ) + .onReceive(viewModel.$state) { state in + if !state.isShowingDropdown { + dropdownManager.hideDropdown() + } } - - var body: some View { - Button { - if viewModel.state.isShowingDropdown { - dropdownManager.hideDropdown() - viewModel.toggleDropdown() - } else { - viewModel.toggleDropdown() - showDropdownWindow() - } - } label: { - buttonContent + } + + private func showDropdownWindow() { + guard let buttonView = buttonView else { return } + + dropdownManager.showDropdown( + relativeTo: buttonView, + viewModel: viewModel, + onAppSelected: { app in + withAnimation(.easeInOut(duration: 0.2)) { + viewModel.selectApp(app) } - .buttonStyle(PlainButtonStyle()) - .background( - ViewGeometryReader { view in - buttonView = view - } - ) - .onReceive(viewModel.$state) { state in - if !state.isShowingDropdown { - dropdownManager.hideDropdown() - } + }, + onClearSelection: { + withAnimation(.easeInOut(duration: 0.2)) { + viewModel.clearSelection() } - } - - private func showDropdownWindow() { - guard let buttonView = buttonView else { return } - - dropdownManager.showDropdown( - relativeTo: buttonView, - viewModel: viewModel, - onAppSelected: { app in - withAnimation(.easeInOut(duration: 0.2)) { - viewModel.selectApp(app) - } - }, - onClearSelection: { - withAnimation(.easeInOut(duration: 0.2)) { - viewModel.clearSelection() - } - }, - onDismiss: { - withAnimation(.easeInOut(duration: 0.2)) { - viewModel.toggleDropdown() - } - } - ) - } - - private var buttonContent: some View { - HStack(spacing: UIConstants.Spacing.gridCellSpacing * 2) { - Image(systemName: viewModel.state.isShowingDropdown ? "chevron.up" : "chevron.down") - .font(UIConstants.Typography.iconFont) - .foregroundColor(UIConstants.Colors.textPrimary) - - if let selectedApp = viewModel.state.selectedApp { - selectedAppIcon(selectedApp) - selectedAppText(selectedApp) - } else { - defaultIcon - defaultText - } + }, + onDismiss: { + withAnimation(.easeInOut(duration: 0.2)) { + viewModel.toggleDropdown() } - .padding(.horizontal, UIConstants.Spacing.cardPadding) - .padding(.vertical, UIConstants.Spacing.cardPadding) - .background( - RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius) - .stroke( - LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color(hex: "979797").opacity(0.3), location: 0), - .init(color: Color(hex: "979797").opacity(0.2), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ), - lineWidth: UIConstants.Sizing.strokeWidth - ) - ) - } - - private func selectedAppIcon(_ app: SelectableApp) -> some View { - RoundedRectangle(cornerRadius: UIConstants.Sizing.smallCornerRadius * 2) - .fill(Color.white) - .frame(width: 15, height: 15) - .overlay( - Image(nsImage: app.icon) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 12, height: 12) - ) - } - - private func selectedAppText(_ app: SelectableApp) -> some View { - Text(app.name) - .font(UIConstants.Typography.cardTitle) - .foregroundColor(UIConstants.Colors.textPrimary) - .lineLimit(1) - } - - private var defaultIcon: some View { - RoundedRectangle(cornerRadius: UIConstants.Sizing.smallCornerRadius * 2) - .fill(UIConstants.Colors.textTertiary.opacity(0.3)) - .frame(width: 15, height: 15) - .overlay( - Image(systemName: "app") - .font(UIConstants.Typography.iconFont) - .foregroundColor(UIConstants.Colors.textTertiary) - ) - } - - private var defaultText: some View { - Text("Select App") - .font(UIConstants.Typography.cardTitle) - .foregroundColor(UIConstants.Colors.textSecondary) + } + ) + } + + private var buttonContent: some View { + HStack(spacing: UIConstants.Spacing.gridCellSpacing * 2) { + Image(systemName: viewModel.state.isShowingDropdown ? "chevron.up" : "chevron.down") + .font(UIConstants.Typography.iconFont) + .foregroundColor(UIConstants.Colors.textPrimary) + + if let selectedApp = viewModel.state.selectedApp { + selectedAppIcon(selectedApp) + selectedAppText(selectedApp) + } else { + defaultIcon + defaultText + } } + .padding(.horizontal, UIConstants.Spacing.cardPadding) + .padding(.vertical, UIConstants.Spacing.cardPadding) + .background( + RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius) + .stroke( + LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color(hex: "979797").opacity(0.3), location: 0), + .init(color: Color(hex: "979797").opacity(0.2), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ), + lineWidth: UIConstants.Sizing.strokeWidth + ) + ) + } + + private func selectedAppIcon(_ app: SelectableApp) -> some View { + RoundedRectangle(cornerRadius: UIConstants.Sizing.smallCornerRadius * 2) + .fill(Color.white) + .frame(width: 15, height: 15) + .overlay( + Image(nsImage: app.icon) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 12, height: 12) + ) + } + + private func selectedAppText(_ app: SelectableApp) -> some View { + Text(app.name) + .font(UIConstants.Typography.cardTitle) + .foregroundColor(UIConstants.Colors.textPrimary) + .lineLimit(1) + } + + private var defaultIcon: some View { + RoundedRectangle(cornerRadius: UIConstants.Sizing.smallCornerRadius * 2) + .fill(UIConstants.Colors.textTertiary.opacity(0.3)) + .frame(width: 15, height: 15) + .overlay( + Image(systemName: "app") + .font(UIConstants.Typography.iconFont) + .foregroundColor(UIConstants.Colors.textTertiary) + ) + } + + private var defaultText: some View { + Text("Select App") + .font(UIConstants.Typography.cardTitle) + .foregroundColor(UIConstants.Colors.textSecondary) + } } #Preview { - let controller = AudioProcessController() - let viewModel = AppSelectionViewModel(audioProcessController: controller) - - return AppSelectionButton(viewModel: viewModel) - .padding() - .background(Color.black) -} \ No newline at end of file + let controller = AudioProcessController() + let viewModel = AppSelectionViewModel(audioProcessController: controller) + + return AppSelectionButton(viewModel: viewModel) + .padding() + .background(Color.black) +} diff --git a/Recap/UIComponents/Buttons/DownloadPillButton.swift b/Recap/UIComponents/Buttons/DownloadPillButton.swift index 87edb4d..752f029 100644 --- a/Recap/UIComponents/Buttons/DownloadPillButton.swift +++ b/Recap/UIComponents/Buttons/DownloadPillButton.swift @@ -1,110 +1,123 @@ +import OSLog import SwiftUI +private let downloadPillButtonPreviewLogger = Logger( + subsystem: AppConstants.Logging.subsystem, + category: "DownloadPillButtonPreview" +) + struct DownloadPillButton: View { - let text: String - let isDownloading: Bool - let downloadProgress: Double - let action: () -> Void - - @State private var iconOffset: CGFloat = 0 - - var body: some View { - Button(action: isDownloading ? {} : action) { - HStack(spacing: 4) { - Image(systemName: isDownloading ? "arrow.down" : "square.and.arrow.down") - .font(.system(size: 10, weight: .medium)) - .foregroundColor(.white) - .offset(y: isDownloading ? iconOffset : 0) - .animation(isDownloading ? .easeInOut(duration: 0.6).repeatForever(autoreverses: true) : .default, value: iconOffset) - - Text(text) - .font(.system(size: 10, weight: .medium)) - .foregroundColor(.white) + let text: String + let isDownloading: Bool + let downloadProgress: Double + let action: () -> Void + + @State private var iconOffset: CGFloat = 0 + + var body: some View { + Button(action: isDownloading ? {} : action) { + HStack(spacing: 4) { + Image(systemName: isDownloading ? "arrow.down" : "square.and.arrow.down") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(.white) + .offset(y: isDownloading ? iconOffset : 0) + .animation( + isDownloading + ? .easeInOut(duration: 0.6).repeatForever(autoreverses: true) + : .default, + value: iconOffset + ) + + Text(text) + .font(.system(size: 10, weight: .medium)) + .foregroundColor(.white) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + ZStack { + RoundedRectangle(cornerRadius: 16) + .fill(Color(hex: "242323")) + + if isDownloading && downloadProgress > 0 { + GeometryReader { geometry in + Rectangle() + .fill(Color.white.opacity(0.2)) + .frame( + width: geometry.size.width * min(max(downloadProgress, 0), 1) + ) + .animation(.easeInOut(duration: 0.3), value: downloadProgress) } - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background( - ZStack { - RoundedRectangle(cornerRadius: 16) - .fill(Color(hex: "242323")) - - if isDownloading && downloadProgress > 0 { - GeometryReader { geometry in - Rectangle() - .fill(Color.white.opacity(0.2)) - .frame(width: geometry.size.width * min(max(downloadProgress, 0), 1)) - .animation(.easeInOut(duration: 0.3), value: downloadProgress) - } - .mask(RoundedRectangle(cornerRadius: 16)) - } - - RoundedRectangle(cornerRadius: 16) - .stroke( - LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color(hex: "979797"), location: 0), - .init(color: Color(hex: "979797").opacity(0.8), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ), - lineWidth: 1 - ) - } - .clipped() + .mask(RoundedRectangle(cornerRadius: 16)) + } + + RoundedRectangle(cornerRadius: 16) + .stroke( + LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color(hex: "979797"), location: 0), + .init(color: Color(hex: "979797").opacity(0.8), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 1 ) } - .buttonStyle(PlainButtonStyle()) - .disabled(isDownloading) - .onAppear { - if isDownloading { - iconOffset = 3 - } - } - .onChange(of: isDownloading) { newValue in - if newValue { - iconOffset = 3 - } else { - iconOffset = 0 - } - } + .clipped() + ) + } + .buttonStyle(PlainButtonStyle()) + .disabled(isDownloading) + .onAppear { + if isDownloading { + iconOffset = 3 + } + } + .onChange(of: isDownloading) { _, newValue in + if newValue { + iconOffset = 3 + } else { + iconOffset = 0 + } } + } } #Preview { - VStack(spacing: 20) { - DownloadPillButton( - text: "Download", - isDownloading: false, - downloadProgress: 0.0 - ) { - print("Download started") - } - - DownloadPillButton( - text: "Downloading", - isDownloading: true, - downloadProgress: 0.3 - ) { - print("Download in progress") - } - - DownloadPillButton( - text: "Downloading", - isDownloading: true, - downloadProgress: 0.7 - ) { - print("Download in progress") - } - - DownloadPillButton( - text: "Downloaded", - isDownloading: false, - downloadProgress: 1.0 - ) { - print("Download complete") - } + VStack(spacing: 20) { + DownloadPillButton( + text: "Download", + isDownloading: false, + downloadProgress: 0.0 + ) { + downloadPillButtonPreviewLogger.info("Download started") + } + + DownloadPillButton( + text: "Downloading", + isDownloading: true, + downloadProgress: 0.3 + ) { + downloadPillButtonPreviewLogger.info("Download in progress (0.3)") } - .padding() - .background(Color.black) -} \ No newline at end of file + + DownloadPillButton( + text: "Downloading", + isDownloading: true, + downloadProgress: 0.7 + ) { + downloadPillButtonPreviewLogger.info("Download in progress (0.7)") + } + + DownloadPillButton( + text: "Downloaded", + isDownloading: false, + downloadProgress: 1.0 + ) { + downloadPillButtonPreviewLogger.info("Download complete") + } + } + .padding() + .background(Color.black) +} diff --git a/Recap/UIComponents/Buttons/PillButton.swift b/Recap/UIComponents/Buttons/PillButton.swift index f1c0da4..10b3d8b 100644 --- a/Recap/UIComponents/Buttons/PillButton.swift +++ b/Recap/UIComponents/Buttons/PillButton.swift @@ -1,66 +1,74 @@ +import OSLog import SwiftUI +private let pillButtonPreviewLogger = Logger( + subsystem: AppConstants.Logging.subsystem, category: "PillButtonPreview") + struct PillButton: View { - let text: String - let icon: String? - let action: () -> Void - let borderGradient: LinearGradient? - - init(text: String, icon: String? = nil, borderGradient: LinearGradient? = nil, action: @escaping () -> Void) { - self.text = text - self.icon = icon - self.borderGradient = borderGradient - self.action = action - } - - var body: some View { - Button(action: action) { - HStack(spacing: 6) { - if let icon = icon { - Image(systemName: icon) - .font(.system(size: 12, weight: .medium)) - .foregroundColor(.white) - } - - Text(text) - .font(.system(size: 12, weight: .medium)) - .foregroundColor(.white) - } - .padding(.horizontal, 12) - .padding(.vertical, 10) - .background( - RoundedRectangle(cornerRadius: 20) - .fill(Color(hex: "242323")) - .overlay( - RoundedRectangle(cornerRadius: 20) - .stroke( - borderGradient ?? LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color(hex: "979797").opacity(0.6), location: 0), - .init(color: Color(hex: "979797").opacity(0.4), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ), - lineWidth: 1 - ) - ) - ) + let text: String + let icon: String? + let action: () -> Void + let borderGradient: LinearGradient? + + init( + text: String, icon: String? = nil, borderGradient: LinearGradient? = nil, + action: @escaping () -> Void + ) { + self.text = text + self.icon = icon + self.borderGradient = borderGradient + self.action = action + } + + var body: some View { + Button(action: action) { + HStack(spacing: 6) { + if let icon = icon { + Image(systemName: icon) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.white) } - .buttonStyle(PlainButtonStyle()) + + Text(text) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.white) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 20) + .fill(Color(hex: "242323")) + .overlay( + RoundedRectangle(cornerRadius: 20) + .stroke( + borderGradient + ?? LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color(hex: "979797").opacity(0.6), location: 0), + .init(color: Color(hex: "979797").opacity(0.4), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 1 + ) + ) + ) } + .buttonStyle(PlainButtonStyle()) + } } #Preview { - VStack(spacing: 20) { - PillButton(text: "Start Recording", icon: "mic.fill") { - print("Recording started") - } - - PillButton(text: "Button", icon: nil) { - print("Button tapped") - } + VStack(spacing: 20) { + PillButton(text: "Start Recording", icon: "mic.fill") { + pillButtonPreviewLogger.info("Recording started") + } + + PillButton(text: "Button", icon: nil) { + pillButtonPreviewLogger.info("Button tapped") } - .padding() - .background(Color.black) + } + .padding() + .background(Color.black) } diff --git a/Recap/UIComponents/Buttons/RecordingButton.swift b/Recap/UIComponents/Buttons/RecordingButton.swift index 56e2506..be0faac 100644 --- a/Recap/UIComponents/Buttons/RecordingButton.swift +++ b/Recap/UIComponents/Buttons/RecordingButton.swift @@ -5,80 +5,83 @@ // Created by Rawand Ahmad on 25/07/2025. // -import SwiftUI import Combine +import SwiftUI struct RecordingButton: View { - let isRecording: Bool - let recordingDuration: TimeInterval - let isEnabled: Bool - let onToggleRecording: () -> Void - - init( - isRecording: Bool, - recordingDuration: TimeInterval, - isEnabled: Bool = true, - onToggleRecording: @escaping () -> Void - ) { - self.isRecording = isRecording - self.recordingDuration = recordingDuration - self.isEnabled = isEnabled - self.onToggleRecording = onToggleRecording - } - - private var formattedTime: String { - let hours = Int(recordingDuration) / 3600 - let minutes = Int(recordingDuration) / 60 % 60 - let seconds = Int(recordingDuration) % 60 - return String(format: "%02d:%02d:%02d", hours, minutes, seconds) - } - - var body: some View { - Button(action: isEnabled ? onToggleRecording : {}) { - HStack(spacing: 6) { - Image(systemName: isRecording ? "stop.fill" : "mic.fill") - .font(.system(size: 12, weight: .medium)) - .foregroundColor(isEnabled ? .white : .gray) - - Text(isRecording ? "Recording \(formattedTime)" : "Start Recording") - .font(.system(size: 12, weight: .medium)) - .foregroundColor(isEnabled ? .white : .gray) - } - .padding(.horizontal, 12) - .padding(.vertical, 10) - .background( - RoundedRectangle(cornerRadius: 20) - .fill(Color(hex: "242323")) - .overlay( - RoundedRectangle(cornerRadius: 20) - .stroke( - LinearGradient( - gradient: Gradient(stops: isRecording ? [ - .init(color: Color.red.opacity(0.4), location: 0), - .init(color: Color.red.opacity(0.2), location: 1) - ] : [ - .init(color: Color(hex: "979797").opacity(0.6), location: 0), - .init(color: Color(hex: "979797").opacity(0.4), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ), - lineWidth: 1 - ) - ) - ) - } - .buttonStyle(PlainButtonStyle()) - .animation(.easeInOut(duration: 0.3), value: isRecording) + let isRecording: Bool + let recordingDuration: TimeInterval + let isEnabled: Bool + let onToggleRecording: () -> Void + + init( + isRecording: Bool, + recordingDuration: TimeInterval, + isEnabled: Bool = true, + onToggleRecording: @escaping () -> Void + ) { + self.isRecording = isRecording + self.recordingDuration = recordingDuration + self.isEnabled = isEnabled + self.onToggleRecording = onToggleRecording + } + + private var formattedTime: String { + let hours = Int(recordingDuration) / 3600 + let minutes = Int(recordingDuration) / 60 % 60 + let seconds = Int(recordingDuration) % 60 + return String(format: "%02d:%02d:%02d", hours, minutes, seconds) + } + + var body: some View { + Button(action: isEnabled ? onToggleRecording : {}) { + HStack(spacing: 6) { + Image(systemName: isRecording ? "stop.fill" : "mic.fill") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(isEnabled ? .white : .gray) + + Text(isRecording ? "Recording \(formattedTime)" : "Start Recording") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(isEnabled ? .white : .gray) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 20) + .fill(Color(hex: "242323")) + .overlay( + RoundedRectangle(cornerRadius: 20) + .stroke( + LinearGradient( + gradient: Gradient( + stops: isRecording + ? [ + .init(color: Color.red.opacity(0.4), location: 0), + .init(color: Color.red.opacity(0.2), location: 1) + ] + : [ + .init(color: Color(hex: "979797").opacity(0.6), location: 0), + .init(color: Color(hex: "979797").opacity(0.4), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 1 + ) + ) + ) } + .buttonStyle(PlainButtonStyle()) + .animation(.easeInOut(duration: 0.3), value: isRecording) + } } #Preview { - RecordingButton( - isRecording: false, - recordingDuration: 0, - onToggleRecording: {} - ) - .padding() - .background(Color.black) + RecordingButton( + isRecording: false, + recordingDuration: 0, + onToggleRecording: {} + ) + .padding() + .background(Color.black) } diff --git a/Recap/UIComponents/Buttons/SummaryActionButton.swift b/Recap/UIComponents/Buttons/SummaryActionButton.swift index bf89156..5c775bf 100644 --- a/Recap/UIComponents/Buttons/SummaryActionButton.swift +++ b/Recap/UIComponents/Buttons/SummaryActionButton.swift @@ -1,116 +1,122 @@ +import OSLog import SwiftUI +private let summaryActionButtonPreviewLogger = Logger( + subsystem: AppConstants.Logging.subsystem, + category: "SummaryActionButtonPreview" +) + struct SummaryActionButton: View { - let text: String - let icon: String - let action: () -> Void - let isSecondary: Bool - - init( - text: String, - icon: String, - isSecondary: Bool = false, - action: @escaping () -> Void - ) { - self.text = text - self.icon = icon - self.isSecondary = isSecondary - self.action = action - } - - var body: some View { - Button(action: action) { - HStack(spacing: 8) { - Image(systemName: icon) - .font(.system(size: 13, weight: .medium)) - .foregroundColor(textColor) - - Text(text) - .font(.system(size: 13, weight: .medium)) - .foregroundColor(textColor) - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .frame(minWidth: 120) - .background(backgroundGradient) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(borderGradient, lineWidth: 0.8) - ) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } - .buttonStyle(PlainButtonStyle()) - } - - private var textColor: Color { - isSecondary ? UIConstants.Colors.textSecondary : UIConstants.Colors.textPrimary + let text: String + let icon: String + let action: () -> Void + let isSecondary: Bool + + init( + text: String, + icon: String, + isSecondary: Bool = false, + action: @escaping () -> Void + ) { + self.text = text + self.icon = icon + self.isSecondary = isSecondary + self.action = action + } + + var body: some View { + Button(action: action) { + HStack(spacing: 8) { + Image(systemName: icon) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(textColor) + + Text(text) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(textColor) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .frame(minWidth: 120) + .background(backgroundGradient) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(borderGradient, lineWidth: 0.8) + ) + .clipShape(RoundedRectangle(cornerRadius: 8)) } - - private var backgroundGradient: LinearGradient { - if isSecondary { - return LinearGradient( - gradient: Gradient(colors: [Color.clear]), - startPoint: .top, - endPoint: .bottom - ) - } else { - return LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color(hex: "4A4A4A").opacity(0.3), location: 0), - .init(color: Color(hex: "2A2A2A").opacity(0.5), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ) - } + .buttonStyle(PlainButtonStyle()) + } + + private var textColor: Color { + isSecondary ? UIConstants.Colors.textSecondary : UIConstants.Colors.textPrimary + } + + private var backgroundGradient: LinearGradient { + if isSecondary { + return LinearGradient( + gradient: Gradient(colors: [Color.clear]), + startPoint: .top, + endPoint: .bottom + ) + } else { + return LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color(hex: "4A4A4A").opacity(0.3), location: 0), + .init(color: Color(hex: "2A2A2A").opacity(0.5), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ) } - - private var borderGradient: LinearGradient { - if isSecondary { - return LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color(hex: "979797").opacity(0.25), location: 0), - .init(color: Color(hex: "979797").opacity(0.15), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ) - } else { - return LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color(hex: "979797").opacity(0.4), location: 0), - .init(color: Color(hex: "979797").opacity(0.25), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ) - } + } + + private var borderGradient: LinearGradient { + if isSecondary { + return LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color(hex: "979797").opacity(0.25), location: 0), + .init(color: Color(hex: "979797").opacity(0.15), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ) + } else { + return LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color(hex: "979797").opacity(0.4), location: 0), + .init(color: Color(hex: "979797").opacity(0.25), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ) } + } } #Preview { - VStack(spacing: 16) { - HStack(spacing: 12) { - SummaryActionButton( - text: "Copy", - icon: "doc.on.doc" - ) { - print("Copy tapped") - } - - SummaryActionButton( - text: "Retry", - icon: "arrow.clockwise", - isSecondary: true - ) { - print("Retry tapped") - } - } - - Text("Example in summary view context") - .foregroundColor(.white.opacity(0.7)) - .font(.caption) + VStack(spacing: 16) { + HStack(spacing: 12) { + SummaryActionButton( + text: "Copy", + icon: "doc.on.doc" + ) { + summaryActionButtonPreviewLogger.info("Copy tapped") + } + + SummaryActionButton( + text: "Retry", + icon: "arrow.clockwise", + isSecondary: true + ) { + summaryActionButtonPreviewLogger.info("Retry tapped") + } } - .padding(40) - .background(Color.black) -} \ No newline at end of file + + Text("Example in summary view context") + .foregroundColor(.white.opacity(0.7)) + .font(.caption) + } + .padding(40) + .background(Color.black) +} diff --git a/Recap/UIComponents/Buttons/TabButton.swift b/Recap/UIComponents/Buttons/TabButton.swift index ce51687..59c0b74 100644 --- a/Recap/UIComponents/Buttons/TabButton.swift +++ b/Recap/UIComponents/Buttons/TabButton.swift @@ -1,53 +1,57 @@ +import OSLog import SwiftUI +private let tabButtonPreviewLogger = Logger( + subsystem: AppConstants.Logging.subsystem, category: "TabButtonPreview") + struct TabButton: View { - let text: String - let isSelected: Bool - let action: () -> Void - - var body: some View { - Button(action: action) { - Text(text) - .font(.system(size: 12, weight: .medium)) - .foregroundColor(.white) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: 20) - .fill(isSelected ? Color(hex: "2E2E2E") : Color.clear) - .animation(.easeInOut(duration: 0.2), value: isSelected) - .overlay( - RoundedRectangle(cornerRadius: 20) - .stroke( - LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color(hex: "C8C8C8").opacity(0.2), location: 0), - .init(color: Color(hex: "0D0D0D"), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ), - lineWidth: 1.5 - ) - ) + let text: String + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(text) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.white) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 20) + .fill(isSelected ? Color(hex: "2E2E2E") : Color.clear) + .animation(.easeInOut(duration: 0.2), value: isSelected) + .overlay( + RoundedRectangle(cornerRadius: 20) + .stroke( + LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color(hex: "C8C8C8").opacity(0.2), location: 0), + .init(color: Color(hex: "0D0D0D"), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 1.5 ) - .scaleEffect(isSelected ? 1.0 : 0.98) - .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isSelected) - } - .buttonStyle(PlainButtonStyle()) + ) + ) + .scaleEffect(isSelected ? 1.0 : 0.98) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isSelected) } + .buttonStyle(PlainButtonStyle()) + } } #Preview { - HStack(spacing: 8) { - TabButton(text: "General", isSelected: true) { - print("General selected") - } - - TabButton(text: "Whisper Models", isSelected: false) { - print("Whisper Models selected") - } + HStack(spacing: 8) { + TabButton(text: "General", isSelected: true) { + tabButtonPreviewLogger.info("General selected") } - .padding() - .background(Color.black) -} \ No newline at end of file + + TabButton(text: "Whisper Models", isSelected: false) { + tabButtonPreviewLogger.info("Whisper Models selected") + } + } + .padding() + .background(Color.black) +} diff --git a/Recap/UIComponents/Buttons/TranscriptDropdownButton.swift b/Recap/UIComponents/Buttons/TranscriptDropdownButton.swift new file mode 100644 index 0000000..553df2d --- /dev/null +++ b/Recap/UIComponents/Buttons/TranscriptDropdownButton.swift @@ -0,0 +1,74 @@ +import Foundation +import SwiftUI + +struct TranscriptDropdownButton: View { + let transcriptText: String + + @State private var isCollapsed: Bool = true + + init(transcriptText: String) { + self.transcriptText = transcriptText + } + + private var displayText: String { + return transcriptText + } + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: isCollapsed ? "chevron.down" : "chevron.up") + .font(.system(size: 16, weight: .bold)) + + VStack(alignment: .leading) { + Text("Transcript") + .font(UIConstants.Typography.cardTitle) + .foregroundColor(UIConstants.Colors.textPrimary) + + VStack { + + if !isCollapsed { + Text(displayText) + .font(.system(size: 12)) + .foregroundColor(UIConstants.Colors.textSecondary) + .textSelection(.enabled) + } + } + } + + Spacer() + + } + .frame(alignment: .topLeading) + .padding(.horizontal, UIConstants.Spacing.cardPadding + 4) + .padding(.vertical, UIConstants.Spacing.cardPadding) + .background( + RoundedRectangle(cornerRadius: 20) + .fill(UIConstants.Colors.cardSecondaryBackground) + .overlay( + RoundedRectangle(cornerRadius: 20) + .stroke( + UIConstants.Gradients.standardBorder, + lineWidth: 1 + ) + ) + ) + .onTapGesture { + withAnimation(.easeInOut(duration: 0.25)) { + isCollapsed.toggle() + } + } + } +} + +#Preview { + GeometryReader { _ in + VStack(spacing: 16) { + TranscriptDropdownButton( + transcriptText: "Lorem ipsum dolor sit amet" + ) + } + .padding(20) + } + .frame(width: 500, height: 300) + .background(UIConstants.Gradients.backgroundGradient) +} diff --git a/Recap/UIComponents/Cards/ActionableWarningCard.swift b/Recap/UIComponents/Cards/ActionableWarningCard.swift index bc38dee..833a159 100644 --- a/Recap/UIComponents/Cards/ActionableWarningCard.swift +++ b/Recap/UIComponents/Cards/ActionableWarningCard.swift @@ -1,133 +1,143 @@ +import OSLog import SwiftUI +private let actionableWarningCardPreviewLogger = Logger( + subsystem: AppConstants.Logging.subsystem, + category: "ActionableWarningCardPreview" +) + struct ActionableWarningCard: View { - let warning: WarningItem - let containerWidth: CGFloat - let buttonText: String? - let buttonAction: (() -> Void)? - let footerText: String? - - init( - warning: WarningItem, - containerWidth: CGFloat, - buttonText: String? = nil, - buttonAction: (() -> Void)? = nil, - footerText: String? = nil - ) { - self.warning = warning - self.containerWidth = containerWidth - self.buttonText = buttonText - self.buttonAction = buttonAction - self.footerText = footerText - } - - var body: some View { - let severityColor = Color(hex: warning.severity.color) - - let cardBackground = LinearGradient( - gradient: Gradient(stops: [ - .init(color: severityColor.opacity(0.1), location: 0), - .init(color: severityColor.opacity(0.05), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ) - - let cardBorder = LinearGradient( - gradient: Gradient(stops: [ - .init(color: severityColor.opacity(0.3), location: 0), - .init(color: severityColor.opacity(0.2), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ) - - VStack(alignment: .leading, spacing: 12) { - HStack(spacing: 12) { - Image(systemName: warning.icon) - .font(.system(size: 16, weight: .bold)) - .foregroundColor(severityColor) - - Text(warning.title) - .font(UIConstants.Typography.cardTitle) - .foregroundColor(UIConstants.Colors.textPrimary) - - Spacer() - } - - VStack(alignment: .leading, spacing: 8) { - Text(warning.message) - .font(.system(size: 10, weight: .regular)) - .foregroundColor(UIConstants.Colors.textSecondary) - .multilineTextAlignment(.leading) - - if let footerText = footerText { - Text(footerText) - .font(.system(size: 9)) - .foregroundColor(UIConstants.Colors.textSecondary) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - } - } - - if let buttonText = buttonText, let buttonAction = buttonAction { - HStack { - PillButton( - text: buttonText, - icon: "gear" - ) { - buttonAction() - } - Spacer() - } - } + let warning: WarningItem + let containerWidth: CGFloat + let buttonText: String? + let buttonAction: (() -> Void)? + let footerText: String? + + init( + warning: WarningItem, + containerWidth: CGFloat, + buttonText: String? = nil, + buttonAction: (() -> Void)? = nil, + footerText: String? = nil + ) { + self.warning = warning + self.containerWidth = containerWidth + self.buttonText = buttonText + self.buttonAction = buttonAction + self.footerText = footerText + } + + var body: some View { + let severityColor = Color(hex: warning.severity.color) + + let cardBackground = LinearGradient( + gradient: Gradient(stops: [ + .init(color: severityColor.opacity(0.1), location: 0), + .init(color: severityColor.opacity(0.05), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ) + + let cardBorder = LinearGradient( + gradient: Gradient(stops: [ + .init(color: severityColor.opacity(0.3), location: 0), + .init(color: severityColor.opacity(0.2), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ) + + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 12) { + Image(systemName: warning.icon) + .font(.system(size: 16, weight: .bold)) + .foregroundColor(severityColor) + + Text(warning.title) + .font(UIConstants.Typography.cardTitle) + .foregroundColor(UIConstants.Colors.textPrimary) + + Spacer() + } + + VStack(alignment: .leading, spacing: 8) { + Text(warning.message) + .font(.system(size: 10, weight: .regular)) + .foregroundColor(UIConstants.Colors.textSecondary) + .multilineTextAlignment(.leading) + + if let footerText = footerText { + Text(footerText) + .font(.system(size: 9)) + .foregroundColor(UIConstants.Colors.textSecondary) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) } - .padding(.horizontal, UIConstants.Spacing.cardPadding + 4) - .padding(.vertical, UIConstants.Spacing.cardPadding) - .frame(width: UIConstants.Layout.fullCardWidth(containerWidth: containerWidth)) - .background( - RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius) - .fill(cardBackground) - .overlay( - RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius) - .stroke(cardBorder, lineWidth: UIConstants.Sizing.borderWidth) - ) - ) + } + + if let buttonText = buttonText, let buttonAction = buttonAction { + HStack { + PillButton( + text: buttonText, + icon: "gear" + ) { + buttonAction() + } + Spacer() + } + } } + .padding(.horizontal, UIConstants.Spacing.cardPadding + 4) + .padding(.vertical, UIConstants.Spacing.cardPadding) + .frame(width: UIConstants.Layout.fullCardWidth(containerWidth: containerWidth)) + .background( + RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius) + .fill(cardBackground) + .overlay( + RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius) + .stroke(cardBorder, lineWidth: UIConstants.Sizing.borderWidth) + ) + ) + } } #Preview { - GeometryReader { geometry in - VStack(spacing: 16) { - ActionableWarningCard( - warning: WarningItem( - id: "screen-recording", - title: "Permission Required", - message: "Screen Recording permission needed to detect meeting windows", - icon: "exclamationmark.shield", - severity: .warning - ), - containerWidth: geometry.size.width, - buttonText: "Open System Settings", - buttonAction: { - print("Button tapped") - }, - footerText: "This permission allows Recap to read window titles only. No screen content is captured or recorded." - ) - - ActionableWarningCard( - warning: WarningItem( - id: "network", - title: "Connection Issue", - message: "Unable to connect to the service. Check your network connection and try again.", - icon: "network.slash", - severity: .error - ), - containerWidth: geometry.size.width - ) - } - .padding(20) + GeometryReader { geometry in + VStack(spacing: 16) { + ActionableWarningCard( + warning: WarningItem( + id: "screen-recording", + title: "Permission Required", + message: "Screen Recording permission needed to detect meeting windows", + icon: "exclamationmark.shield", + severity: .warning + ), + containerWidth: geometry.size.width, + buttonText: "Open System Settings", + buttonAction: { + actionableWarningCardPreviewLogger.info("Button tapped") + }, + footerText: """ + This permission allows Recap to read window titles only. \ + No screen content is captured or recorded. + """ + ) + + ActionableWarningCard( + warning: WarningItem( + id: "network", + title: "Connection Issue", + message: + "Unable to connect to the service. Check your network connection and try again.", + icon: "network.slash", + severity: .error + ), + containerWidth: geometry.size.width + ) } - .frame(width: 500, height: 400) - .background(UIConstants.Gradients.backgroundGradient) + .padding(20) + } + .frame(width: 500, height: 400) + .background(UIConstants.Gradients.backgroundGradient) } diff --git a/Recap/UIComponents/Cards/WarningCard.swift b/Recap/UIComponents/Cards/WarningCard.swift index da1564c..690fb77 100644 --- a/Recap/UIComponents/Cards/WarningCard.swift +++ b/Recap/UIComponents/Cards/WarningCard.swift @@ -1,95 +1,95 @@ import SwiftUI struct WarningCard: View { - let warning: WarningItem - let containerWidth: CGFloat - - init(warning: WarningItem, containerWidth: CGFloat) { - self.warning = warning - self.containerWidth = containerWidth + let warning: WarningItem + let containerWidth: CGFloat + + init(warning: WarningItem, containerWidth: CGFloat) { + self.warning = warning + self.containerWidth = containerWidth + } + + var body: some View { + let severityColor = Color(hex: warning.severity.color) + + let cardBackground = LinearGradient( + gradient: Gradient(stops: [ + .init(color: severityColor.opacity(0.1), location: 0), + .init(color: severityColor.opacity(0.05), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ) + + let cardBorder = LinearGradient( + gradient: Gradient(stops: [ + .init(color: severityColor.opacity(0.3), location: 0), + .init(color: severityColor.opacity(0.2), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ) + + HStack(spacing: 12) { + Image(systemName: warning.icon) + .font(.system(size: 16, weight: .bold)) + .foregroundColor(severityColor) + + VStack(alignment: .leading, spacing: 4) { + Text(warning.title) + .font(UIConstants.Typography.cardTitle) + .foregroundColor(UIConstants.Colors.textPrimary) + + Text(warning.message) + .font(.system(size: 10, weight: .regular)) + .foregroundColor(UIConstants.Colors.textSecondary) + .lineLimit(2) + .multilineTextAlignment(.leading) + } + + Spacer() } - - var body: some View { - let severityColor = Color(hex: warning.severity.color) - - let cardBackground = LinearGradient( - gradient: Gradient(stops: [ - .init(color: severityColor.opacity(0.1), location: 0), - .init(color: severityColor.opacity(0.05), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ) - - let cardBorder = LinearGradient( - gradient: Gradient(stops: [ - .init(color: severityColor.opacity(0.3), location: 0), - .init(color: severityColor.opacity(0.2), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ) - - HStack(spacing: 12) { - Image(systemName: warning.icon) - .font(.system(size: 16, weight: .bold)) - .foregroundColor(severityColor) - - VStack(alignment: .leading, spacing: 4) { - Text(warning.title) - .font(UIConstants.Typography.cardTitle) - .foregroundColor(UIConstants.Colors.textPrimary) - - Text(warning.message) - .font(.system(size: 10, weight: .regular)) - .foregroundColor(UIConstants.Colors.textSecondary) - .lineLimit(2) - .multilineTextAlignment(.leading) - } - - Spacer() - } - .padding(.horizontal, UIConstants.Spacing.cardPadding + 4) - .padding(.vertical, UIConstants.Spacing.cardPadding) - .frame(width: UIConstants.Layout.fullCardWidth(containerWidth: containerWidth)) - .background( - RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius) - .fill(cardBackground) - .overlay( - RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius) - .stroke(cardBorder, lineWidth: UIConstants.Sizing.borderWidth) - ) + .padding(.horizontal, UIConstants.Spacing.cardPadding + 4) + .padding(.vertical, UIConstants.Spacing.cardPadding) + .frame(width: UIConstants.Layout.fullCardWidth(containerWidth: containerWidth)) + .background( + RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius) + .fill(cardBackground) + .overlay( + RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius) + .stroke(cardBorder, lineWidth: UIConstants.Sizing.borderWidth) ) - } + ) + } } #Preview { - GeometryReader { geometry in - VStack(spacing: 16) { - WarningCard( - warning: WarningItem( - id: "ollama", - title: "Ollama Not Running", - message: "Please start Ollama to use local AI models for summarization.", - icon: "server.rack", - severity: .warning - ), - containerWidth: geometry.size.width - ) - - WarningCard( - warning: WarningItem( - id: "network", - title: "Connection Issue", - message: "Unable to connect to the service. Check your network connection and try again.", - icon: "network.slash", - severity: .error - ), - containerWidth: geometry.size.width - ) - } - .padding(20) + GeometryReader { geometry in + VStack(spacing: 16) { + WarningCard( + warning: WarningItem( + id: "ollama", + title: "Ollama Not Running", + message: "Please start Ollama to use local AI models for summarization.", + icon: "server.rack", + severity: .warning + ), + containerWidth: geometry.size.width + ) + + WarningCard( + warning: WarningItem( + id: "network", + title: "Connection Issue", + message: "Unable to connect to the service. Check your network connection and try again.", + icon: "network.slash", + severity: .error + ), + containerWidth: geometry.size.width + ) } - .frame(width: 500, height: 300) - .background(UIConstants.Gradients.backgroundGradient) -} \ No newline at end of file + .padding(20) + } + .frame(width: 500, height: 300) + .background(UIConstants.Gradients.backgroundGradient) +} diff --git a/Recap/UseCases/AppSelection/Coordinator/AppSelectionCoordinator.swift b/Recap/UseCases/AppSelection/Coordinator/AppSelectionCoordinator.swift index b0ee229..6305c43 100644 --- a/Recap/UseCases/AppSelection/Coordinator/AppSelectionCoordinator.swift +++ b/Recap/UseCases/AppSelection/Coordinator/AppSelectionCoordinator.swift @@ -2,26 +2,26 @@ import Foundation @MainActor final class AppSelectionCoordinator: AppSelectionCoordinatorType { - private let appSelectionViewModel: AppSelectionViewModel - weak var delegate: AppSelectionCoordinatorDelegate? - - init(appSelectionViewModel: AppSelectionViewModel) { - self.appSelectionViewModel = appSelectionViewModel - self.appSelectionViewModel.delegate = self - } - - func autoSelectApp(_ app: AudioProcess) { - let selectableApp = SelectableApp(from: app) - appSelectionViewModel.selectApp(selectableApp) - } + private let appSelectionViewModel: AppSelectionViewModel + weak var delegate: AppSelectionCoordinatorDelegate? + + init(appSelectionViewModel: AppSelectionViewModel) { + self.appSelectionViewModel = appSelectionViewModel + self.appSelectionViewModel.delegate = self + } + + func autoSelectApp(_ app: AudioProcess) { + let selectableApp = SelectableApp(from: app) + appSelectionViewModel.selectApp(selectableApp) + } } extension AppSelectionCoordinator: AppSelectionDelegate { - func didSelectApp(_ app: AudioProcess) { - delegate?.didSelectApp(app) - } - - func didClearAppSelection() { - delegate?.didClearAppSelection() - } -} \ No newline at end of file + func didSelectApp(_ app: AudioProcess) { + delegate?.didSelectApp(app) + } + + func didClearAppSelection() { + delegate?.didClearAppSelection() + } +} diff --git a/Recap/UseCases/AppSelection/Coordinator/AppSelectionCoordinatorType.swift b/Recap/UseCases/AppSelection/Coordinator/AppSelectionCoordinatorType.swift index 20f7a80..0bdef5b 100644 --- a/Recap/UseCases/AppSelection/Coordinator/AppSelectionCoordinatorType.swift +++ b/Recap/UseCases/AppSelection/Coordinator/AppSelectionCoordinatorType.swift @@ -2,12 +2,12 @@ import Foundation @MainActor protocol AppSelectionCoordinatorType { - var delegate: AppSelectionCoordinatorDelegate? { get set } - func autoSelectApp(_ app: AudioProcess) + var delegate: AppSelectionCoordinatorDelegate? { get set } + func autoSelectApp(_ app: AudioProcess) } @MainActor protocol AppSelectionCoordinatorDelegate: AnyObject { - func didSelectApp(_ app: AudioProcess) - func didClearAppSelection() -} \ No newline at end of file + func didSelectApp(_ app: AudioProcess) + func didClearAppSelection() +} diff --git a/Recap/UseCases/AppSelection/View/AppSelectionDropdown.swift b/Recap/UseCases/AppSelection/View/AppSelectionDropdown.swift index 40a5d19..b17b9b2 100644 --- a/Recap/UseCases/AppSelection/View/AppSelectionDropdown.swift +++ b/Recap/UseCases/AppSelection/View/AppSelectionDropdown.swift @@ -1,187 +1,237 @@ import SwiftUI struct AppSelectionDropdown: View { - @ObservedObject private var viewModel: AppSelectionViewModel - let onAppSelected: (SelectableApp) -> Void - let onClearSelection: () -> Void - - init( - viewModel: AppSelectionViewModel, - onAppSelected: @escaping (SelectableApp) -> Void, - onClearSelection: @escaping () -> Void - ) { - self.viewModel = viewModel - self.onAppSelected = onAppSelected - self.onClearSelection = onClearSelection + @ObservedObject private var viewModel: AppSelectionViewModel + let onAppSelected: (SelectableApp) -> Void + let onClearSelection: () -> Void + + init( + viewModel: AppSelectionViewModel, + onAppSelected: @escaping (SelectableApp) -> Void, + onClearSelection: @escaping () -> Void + ) { + self.viewModel = viewModel + self.onAppSelected = onAppSelected + self.onClearSelection = onClearSelection + } + + var body: some View { + ScrollView(.vertical, showsIndicators: false) { + contentView } - - var body: some View { - ScrollView(.vertical, showsIndicators: false) { - contentView + .frame(width: 280, height: 400) + .clipped() + .background( + RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius * 0.6) + .fill(UIConstants.Gradients.dropdownBackground) + ) + .overlay( + RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius * 0.6) + .stroke(UIConstants.Gradients.standardBorder, lineWidth: UIConstants.Sizing.strokeWidth) + ) + } + + private var contentView: some View { + VStack(alignment: .leading, spacing: 0) { + dropdownHeader + + systemWideRow + + if !viewModel.meetingApps.isEmpty || !viewModel.otherApps.isEmpty { + sectionDivider + } + + if !viewModel.meetingApps.isEmpty { + sectionHeader("Meeting Apps") + ForEach(viewModel.meetingApps) { app in + appRow(app) } - .frame(width: 280, height: 400) - .clipped() - .background( - RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius * 0.6) - .fill(UIConstants.Gradients.dropdownBackground) - ) - .overlay( - RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius * 0.6) - .stroke(UIConstants.Gradients.standardBorder, lineWidth: UIConstants.Sizing.strokeWidth) - ) - } - - private var contentView: some View { - VStack(alignment: .leading, spacing: 0) { - dropdownHeader - - if !viewModel.meetingApps.isEmpty { - sectionHeader("Meeting Apps") - ForEach(viewModel.meetingApps) { app in - appRow(app) - } - - if !viewModel.otherApps.isEmpty { - sectionDivider - } - } - - if !viewModel.otherApps.isEmpty { - sectionHeader("Other Apps") - ForEach(viewModel.otherApps) { app in - appRow(app) - } - } - - if !viewModel.meetingApps.isEmpty || !viewModel.otherApps.isEmpty { - sectionDivider - clearSelectionRow - } + + if !viewModel.otherApps.isEmpty { + sectionDivider } - .padding(.vertical, UIConstants.Spacing.cardInternalSpacing) - } - - private var dropdownHeader: some View { - HStack { - Text("Select App") - .font(UIConstants.Typography.cardTitle) - .foregroundColor(UIConstants.Colors.textPrimary) - - Spacer() - - Button { - viewModel.toggleAudioFilter() - } label: { - Image(systemName: "waveform") - .font(.system(size: 14, weight: .medium)) - .foregroundColor(viewModel.isAudioFilterEnabled ? .white : UIConstants.Colors.textTertiary) - .frame(width: 24, height: 24) - .contentShape(Rectangle()) - } - .buttonStyle(PlainButtonStyle()) - .padding(8) - .background( - RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius * 0.4) - .fill(viewModel.isAudioFilterEnabled ? UIConstants.Colors.textTertiary.opacity(0.2) : Color.clear) - .contentShape(Rectangle()) - ) - .onHover { isHovered in - if isHovered { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() - } - } + } + + if !viewModel.otherApps.isEmpty { + sectionHeader("Other Apps") + ForEach(viewModel.otherApps) { app in + appRow(app) } - .padding(.horizontal, UIConstants.Spacing.cardPadding) - .padding(.top, UIConstants.Spacing.cardInternalSpacing) + } + + if !viewModel.meetingApps.isEmpty || !viewModel.otherApps.isEmpty { + sectionDivider + clearSelectionRow + } } - - private func sectionHeader(_ title: String) -> some View { - Text(title) - .font(UIConstants.Typography.bodyText) - .foregroundColor(UIConstants.Colors.textTertiary) - .padding(.horizontal, UIConstants.Spacing.cardPadding) - .padding(.vertical, UIConstants.Spacing.cardInternalSpacing) + .padding(.vertical, UIConstants.Spacing.cardInternalSpacing) + } + + private var dropdownHeader: some View { + HStack { + Text("Select App") + .font(UIConstants.Typography.cardTitle) + .foregroundColor(UIConstants.Colors.textPrimary) + + Spacer() + + Button { + viewModel.toggleAudioFilter() + } label: { + Image(systemName: "waveform") + .font(.system(size: 14, weight: .medium)) + .foregroundColor( + viewModel.isAudioFilterEnabled ? .white : UIConstants.Colors.textTertiary + ) + .frame(width: 24, height: 24) + .contentShape(Rectangle()) + } + .buttonStyle(PlainButtonStyle()) + .padding(8) + .background( + RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius * 0.4) + .fill( + viewModel.isAudioFilterEnabled + ? UIConstants.Colors.textTertiary.opacity(0.2) : Color.clear + ) + .contentShape(Rectangle()) + ) + .onHover { isHovered in + if isHovered { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } } - - private func appRow(_ app: SelectableApp) -> some View { - Button { - onAppSelected(app) - } label: { - HStack(spacing: 8) { - Image(nsImage: app.icon) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 14, height: 14) - - Text(app.name) - .font(UIConstants.Typography.bodyText) - .foregroundColor(UIConstants.Colors.textPrimary) - .lineLimit(1) - - Spacer(minLength: 0) - - if app.isAudioActive { - Circle() - .fill(UIConstants.Colors.audioGreen) - .frame(width: 5, height: 5) - } - } - .padding(.horizontal, UIConstants.Spacing.cardPadding) - .padding(.vertical, UIConstants.Spacing.gridCellSpacing * 2) - .contentShape(Rectangle()) + .padding(.horizontal, UIConstants.Spacing.cardPadding) + .padding(.top, UIConstants.Spacing.cardInternalSpacing) + } + + private func sectionHeader(_ title: String) -> some View { + Text(title) + .font(UIConstants.Typography.bodyText) + .foregroundColor(UIConstants.Colors.textTertiary) + .padding(.horizontal, UIConstants.Spacing.cardPadding) + .padding(.vertical, UIConstants.Spacing.cardInternalSpacing) + } + + private func appRow(_ app: SelectableApp) -> some View { + Button { + onAppSelected(app) + } label: { + HStack(spacing: 8) { + Image(nsImage: app.icon) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 14, height: 14) + + Text(app.name) + .font(UIConstants.Typography.bodyText) + .foregroundColor(UIConstants.Colors.textPrimary) + .lineLimit(1) + + Spacer(minLength: 0) + + if app.isAudioActive { + Circle() + .fill(UIConstants.Colors.audioGreen) + .frame(width: 5, height: 5) } - .buttonStyle(PlainButtonStyle()) - .background( - RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius * 0.3) - .fill(Color.clear) - .onHover { isHovered in - if isHovered { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() - } - } - ) + } + .padding(.horizontal, UIConstants.Spacing.cardPadding) + .padding(.vertical, UIConstants.Spacing.gridCellSpacing * 2) + .contentShape(Rectangle()) } - - private var sectionDivider: some View { - Rectangle() - .fill(UIConstants.Colors.textTertiary.opacity(0.1)) - .frame(height: 1) - .padding(.horizontal, UIConstants.Spacing.cardPadding) - .padding(.vertical, UIConstants.Spacing.gridSpacing) + .buttonStyle(PlainButtonStyle()) + .background( + RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius * 0.3) + .fill(Color.clear) + .onHover { isHovered in + if isHovered { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + ) + } + + private var sectionDivider: some View { + Rectangle() + .fill(UIConstants.Colors.textTertiary.opacity(0.1)) + .frame(height: 1) + .padding(.horizontal, UIConstants.Spacing.cardPadding) + .padding(.vertical, UIConstants.Spacing.gridSpacing) + } + + private var systemWideRow: some View { + Button { + onAppSelected(SelectableApp.allApps) + } label: { + HStack(spacing: 8) { + Image(nsImage: SelectableApp.allApps.icon) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 14, height: 14) + + Text("All Apps") + .font(UIConstants.Typography.bodyText) + .foregroundColor(UIConstants.Colors.textPrimary) + .lineLimit(1) + + Spacer(minLength: 0) + + Circle() + .fill(UIConstants.Colors.audioGreen) + .frame(width: 5, height: 5) + } + .padding(.horizontal, UIConstants.Spacing.cardPadding) + .padding(.vertical, UIConstants.Spacing.gridCellSpacing * 2) + .contentShape(Rectangle()) } - - private var clearSelectionRow: some View { - Button { - onClearSelection() - } label: { - HStack(spacing: 8) { - Image(systemName: "xmark.circle") - .font(UIConstants.Typography.iconFont) - .foregroundColor(UIConstants.Colors.textSecondary) - - Text("Clear Selection") - .font(UIConstants.Typography.bodyText) - .foregroundColor(UIConstants.Colors.textSecondary) - - Spacer(minLength: 0) - } - .padding(.horizontal, UIConstants.Spacing.cardPadding) - .padding(.vertical, UIConstants.Spacing.gridCellSpacing * 2) - .contentShape(Rectangle()) + .buttonStyle(PlainButtonStyle()) + .background( + RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius * 0.3) + .fill(Color.clear) + .onHover { isHovered in + if isHovered { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } } - .buttonStyle(PlainButtonStyle()) - .background( - RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius * 0.3) - .fill(Color.clear) - ) + ) + } + + private var clearSelectionRow: some View { + Button { + onClearSelection() + } label: { + HStack(spacing: 8) { + Image(systemName: "xmark.circle") + .font(UIConstants.Typography.iconFont) + .foregroundColor(UIConstants.Colors.textSecondary) + + Text("Clear Selection") + .font(UIConstants.Typography.bodyText) + .foregroundColor(UIConstants.Colors.textSecondary) + + Spacer(minLength: 0) + } + .padding(.horizontal, UIConstants.Spacing.cardPadding) + .padding(.vertical, UIConstants.Spacing.gridCellSpacing * 2) + .contentShape(Rectangle()) } + .buttonStyle(PlainButtonStyle()) + .background( + RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius * 0.3) + .fill(Color.clear) + ) + } } -//#Preview { +// #Preview { // AppSelectionDropdown( // meetingApps: [ // SelectableApp( @@ -254,4 +304,4 @@ struct AppSelectionDropdown: View { // onClearSelection: { } // ) // .frame(width: 300, height: 450) -//} +// } diff --git a/Recap/UseCases/AppSelection/ViewModel/AppSelectionViewModel.swift b/Recap/UseCases/AppSelection/ViewModel/AppSelectionViewModel.swift index d3a4872..51f9d66 100644 --- a/Recap/UseCases/AppSelection/ViewModel/AppSelectionViewModel.swift +++ b/Recap/UseCases/AppSelection/ViewModel/AppSelectionViewModel.swift @@ -2,87 +2,89 @@ import Foundation @MainActor final class AppSelectionViewModel: AppSelectionViewModelType { - @Published private(set) var state: AppSelectionState = .noSelection - @Published private(set) var availableApps: [SelectableApp] = [] - @Published private(set) var meetingApps: [SelectableApp] = [] - @Published private(set) var otherApps: [SelectableApp] = [] - @Published var isAudioFilterEnabled = true - - private(set) var audioProcessController: any AudioProcessControllerType - weak var delegate: AppSelectionDelegate? - weak var autoSelectionDelegate: AppAutoSelectionDelegate? - private var selectedApp: SelectableApp? - - init(audioProcessController: any AudioProcessControllerType) { - self.audioProcessController = audioProcessController + @Published private(set) var state: AppSelectionState = .noSelection + @Published private(set) var availableApps: [SelectableApp] = [] + @Published private(set) var meetingApps: [SelectableApp] = [] + @Published private(set) var otherApps: [SelectableApp] = [] + @Published var isAudioFilterEnabled = true - setupBindings() - audioProcessController.activate() - } - - func toggleDropdown() { - switch state { - case .noSelection: - state = .showingDropdown - case .selected(let app): - selectedApp = app - state = .showingDropdown - case .showingDropdown: - if let app = selectedApp { - state = .selected(app) - } else { - state = .noSelection - } - } - } - - func selectApp(_ app: SelectableApp) { - selectedApp = app + private(set) var audioProcessController: any AudioProcessControllerType + weak var delegate: AppSelectionDelegate? + weak var autoSelectionDelegate: AppAutoSelectionDelegate? + private var selectedApp: SelectableApp? + + init(audioProcessController: any AudioProcessControllerType) { + self.audioProcessController = audioProcessController + + setupBindings() + audioProcessController.activate() + } + + func toggleDropdown() { + switch state { + case .noSelection: + state = .showingDropdown + case .selected(let app): + selectedApp = app + state = .showingDropdown + case .showingDropdown: + if let app = selectedApp { state = .selected(app) - delegate?.didSelectApp(app.audioProcess) - } - - func clearSelection() { - selectedApp = nil + } else { state = .noSelection - delegate?.didClearAppSelection() - } - - func closeDropdown() { - if case .showingDropdown = state { - state = .noSelection - } - } - - func toggleAudioFilter() { - isAudioFilterEnabled.toggle() - updateAvailableApps() - } - - private func setupBindings() { - updateAvailableApps() + } } - - func refreshAvailableApps() { - updateAvailableApps() - } - - private func updateAvailableApps() { - let filteredProcesses = isAudioFilterEnabled - ? audioProcessController.processes.filter(\.audioActive) - : audioProcessController.processes - - let sortedApps = filteredProcesses - .map(SelectableApp.init) - .sorted { lhs, rhs in - if lhs.isMeetingApp != rhs.isMeetingApp { - return lhs.isMeetingApp - } - return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending - } - - availableApps = sortedApps - meetingApps = sortedApps.filter(\.isMeetingApp) - otherApps = sortedApps.filter { !$0.isMeetingApp } + } + + func selectApp(_ app: SelectableApp) { + selectedApp = app + state = .selected(app) + delegate?.didSelectApp(app.audioProcess) + } + + func clearSelection() { + selectedApp = nil + state = .noSelection + delegate?.didClearAppSelection() + } + + func closeDropdown() { + if case .showingDropdown = state { + state = .noSelection } + } + + func toggleAudioFilter() { + isAudioFilterEnabled.toggle() + updateAvailableApps() + } + + private func setupBindings() { + updateAvailableApps() + } + + func refreshAvailableApps() { + updateAvailableApps() + } + + private func updateAvailableApps() { + let filteredProcesses = + isAudioFilterEnabled + ? audioProcessController.processes.filter(\.audioActive) + : audioProcessController.processes + + let sortedApps = + filteredProcesses + .map(SelectableApp.init) + .sorted { lhs, rhs in + if lhs.isMeetingApp != rhs.isMeetingApp { + return lhs.isMeetingApp + } + return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending + } + + availableApps = [SelectableApp.allApps] + sortedApps + meetingApps = sortedApps.filter(\.isMeetingApp) + otherApps = sortedApps.filter { !$0.isMeetingApp } + } } diff --git a/Recap/UseCases/AppSelection/ViewModel/AppSelectionViewModelType.swift b/Recap/UseCases/AppSelection/ViewModel/AppSelectionViewModelType.swift index 0941427..8fef4ba 100644 --- a/Recap/UseCases/AppSelection/ViewModel/AppSelectionViewModelType.swift +++ b/Recap/UseCases/AppSelection/ViewModel/AppSelectionViewModelType.swift @@ -2,29 +2,29 @@ import Foundation @MainActor protocol AppSelectionDelegate: AnyObject { - func didSelectApp(_ app: AudioProcess) - func didClearAppSelection() + func didSelectApp(_ app: AudioProcess) + func didClearAppSelection() } @MainActor protocol AppAutoSelectionDelegate: AnyObject { - func autoSelectApp(_ app: AudioProcess) + func autoSelectApp(_ app: AudioProcess) } @MainActor protocol AppSelectionViewModelType: ObservableObject { - var state: AppSelectionState { get } - var availableApps: [SelectableApp] { get } - var meetingApps: [SelectableApp] { get } - var otherApps: [SelectableApp] { get } - var isAudioFilterEnabled: Bool { get set } - var audioProcessController: any AudioProcessControllerType { get } - - func toggleDropdown() - func selectApp(_ app: SelectableApp) - func clearSelection() - func toggleAudioFilter() - func refreshAvailableApps() - - var delegate: AppSelectionDelegate? { get set } -} \ No newline at end of file + var state: AppSelectionState { get } + var availableApps: [SelectableApp] { get } + var meetingApps: [SelectableApp] { get } + var otherApps: [SelectableApp] { get } + var isAudioFilterEnabled: Bool { get set } + var audioProcessController: any AudioProcessControllerType { get } + + func toggleDropdown() + func selectApp(_ app: SelectableApp) + func clearSelection() + func toggleAudioFilter() + func refreshAvailableApps() + + var delegate: AppSelectionDelegate? { get set } +} diff --git a/Recap/UseCases/DragDrop/View/DragDropView.swift b/Recap/UseCases/DragDrop/View/DragDropView.swift new file mode 100644 index 0000000..4bf2e94 --- /dev/null +++ b/Recap/UseCases/DragDrop/View/DragDropView.swift @@ -0,0 +1,172 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct DragDropView: View { + @ObservedObject var viewModel: ViewModel + let onClose: () -> Void + + @State private var isDragging = false + + var body: some View { + GeometryReader { _ in + ZStack { + UIConstants.Gradients.backgroundGradient + .ignoresSafeArea() + + VStack(spacing: UIConstants.Spacing.sectionSpacing) { + // Header with close button + HStack { + Text("Drag & Drop") + .foregroundColor(UIConstants.Colors.textPrimary) + .font(UIConstants.Typography.appTitle) + .padding(.leading, UIConstants.Spacing.contentPadding) + .padding(.top, UIConstants.Spacing.sectionSpacing) + + Spacer() + + Text("Close") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(.white) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 20) + .fill(Color(hex: "242323")) + .overlay( + RoundedRectangle(cornerRadius: 20) + .stroke( + LinearGradient( + gradient: Gradient(stops: [ + .init( + color: Color(hex: "979797").opacity( + 0.6), location: 0), + .init( + color: Color(hex: "979797").opacity( + 0.4), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 0.8 + ) + ) + .opacity(0.6) + ) + .onTapGesture { + onClose() + } + .padding(.trailing, UIConstants.Spacing.contentPadding) + .padding(.top, UIConstants.Spacing.sectionSpacing) + } + + // Checkboxes + HStack(spacing: 16) { + Toggle(isOn: $viewModel.transcriptEnabled) { + Text("Transcript") + .foregroundColor(UIConstants.Colors.textPrimary) + .font(.system(size: 14, weight: .medium)) + } + .toggleStyle(.checkbox) + + Toggle(isOn: $viewModel.summarizeEnabled) { + Text("Summarize") + .foregroundColor(UIConstants.Colors.textPrimary) + .font(.system(size: 14, weight: .medium)) + } + .toggleStyle(.checkbox) + + Spacer() + } + .padding(.horizontal, UIConstants.Spacing.contentPadding) + .disabled(viewModel.isProcessing) + + // Drop zone + ZStack { + RoundedRectangle(cornerRadius: 12) + .stroke( + isDragging ? Color.blue : Color.gray.opacity(0.5), + style: StrokeStyle(lineWidth: 2, dash: [10, 5]) + ) + .background( + RoundedRectangle(cornerRadius: 12) + .fill( + isDragging + ? Color.blue.opacity(0.1) + : Color.black.opacity(0.2) + ) + ) + + VStack(spacing: 16) { + Image(systemName: isDragging ? "arrow.down.circle.fill" : "waveform.circle") + .font(.system(size: 48)) + .foregroundColor(isDragging ? .blue : .gray) + + if viewModel.isProcessing { + ProgressView() + .scaleEffect(1.2) + .padding(.bottom, 8) + + Text("Processing...") + .foregroundColor(UIConstants.Colors.textSecondary) + .font(.system(size: 14, weight: .medium)) + } else { + Text(isDragging ? "Drop here" : "Drop audio file here") + .foregroundColor(UIConstants.Colors.textPrimary) + .font(.system(size: 16, weight: .semibold)) + + Text("Supported formats: wav, mp3, m4a, flac") + .foregroundColor(UIConstants.Colors.textSecondary) + .font(.system(size: 12)) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.horizontal, UIConstants.Spacing.contentPadding) + .padding(.bottom, UIConstants.Spacing.sectionSpacing) + .onDrop( + of: [.fileURL], + isTargeted: $isDragging + ) { providers in + handleDrop(providers: providers) + } + + // Messages + if let error = viewModel.errorMessage { + Text(error) + .foregroundColor(.red) + .font(.system(size: 12)) + .padding(.horizontal, UIConstants.Spacing.contentPadding) + .padding(.bottom, 8) + .multilineTextAlignment(.center) + } + + if let success = viewModel.successMessage { + Text(success) + .foregroundColor(.green) + .font(.system(size: 12)) + .padding(.horizontal, UIConstants.Spacing.contentPadding) + .padding(.bottom, 8) + .multilineTextAlignment(.center) + .lineLimit(3) + } + } + } + } + } + + private func handleDrop(providers: [NSItemProvider]) -> Bool { + guard let provider = providers.first else { return false } + + provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { item, _ in + guard let data = item as? Data, + let url = URL(dataRepresentation: data, relativeTo: nil) + else { return } + + Task { @MainActor in + await viewModel.handleDroppedFile(url: url) + } + } + + return true + } +} diff --git a/Recap/UseCases/DragDrop/ViewModel/DragDropViewModel.swift b/Recap/UseCases/DragDrop/ViewModel/DragDropViewModel.swift new file mode 100644 index 0000000..f00b3af --- /dev/null +++ b/Recap/UseCases/DragDrop/ViewModel/DragDropViewModel.swift @@ -0,0 +1,184 @@ +import Foundation +import OSLog + +@MainActor +final class DragDropViewModel: DragDropViewModelType { + @Published var transcriptEnabled: Bool + @Published var summarizeEnabled: Bool + @Published var isProcessing = false + @Published var errorMessage: String? + @Published var successMessage: String? + + private let transcriptionService: TranscriptionServiceType + private let llmService: LLMServiceType + private let userPreferencesRepository: UserPreferencesRepositoryType + private let recordingFileManagerHelper: RecordingFileManagerHelperType + private let logger = Logger( + subsystem: AppConstants.Logging.subsystem, + category: String(describing: DragDropViewModel.self)) + + init( + transcriptionService: TranscriptionServiceType, + llmService: LLMServiceType, + userPreferencesRepository: UserPreferencesRepositoryType, + recordingFileManagerHelper: RecordingFileManagerHelperType + ) { + self.transcriptionService = transcriptionService + self.llmService = llmService + self.userPreferencesRepository = userPreferencesRepository + self.recordingFileManagerHelper = recordingFileManagerHelper + + // Initialize with defaults, will be loaded async + self.transcriptEnabled = true + self.summarizeEnabled = true + + // Load user preferences asynchronously + Task { + if let prefs = try? await userPreferencesRepository.getOrCreatePreferences() { + await MainActor.run { + self.transcriptEnabled = prefs.autoTranscribeEnabled + self.summarizeEnabled = prefs.autoSummarizeEnabled + } + } + } + } + + func handleDroppedFile(url: URL) async { + errorMessage = nil + successMessage = nil + isProcessing = true + + do { + try validateFileFormat(url: url) + let recordingDirectory = try await prepareRecordingDirectory(url: url) + let transcriptionText = try await transcribeIfEnabled(recordingDirectory: recordingDirectory) + try await summarizeIfEnabled(text: transcriptionText, recordingDirectory: recordingDirectory) + + successMessage = "File processed successfully! Saved to: \(recordingDirectory.path)" + logger.info("✅ Drag & drop processing complete") + + } catch let error as DragDropError { + errorMessage = error.localizedDescription + logger.error("❌ Drag & drop error: \(error.localizedDescription)") + } catch { + errorMessage = "Failed to process file: \(error.localizedDescription)" + logger.error("❌ Unexpected error in drag & drop: \(error.localizedDescription)") + } + + isProcessing = false + } + + private func validateFileFormat(url: URL) throws { + let fileExtension = url.pathExtension.lowercased() + let supportedFormats = ["wav", "mp3", "m4a", "flac"] + guard supportedFormats.contains(fileExtension) else { + throw DragDropError.unsupportedFormat(fileExtension) + } + } + + private func prepareRecordingDirectory(url: URL) throws -> URL { + let timestamp = ISO8601DateFormatter().string(from: Date()) + .replacingOccurrences(of: ":", with: "-") + .replacingOccurrences(of: ".", with: "-") + let recordingID = "drag_drop_\(timestamp)" + + let recordingDirectory = try recordingFileManagerHelper.createRecordingDirectory(for: recordingID) + let destinationURL = recordingDirectory.appendingPathComponent("system_recording.wav") + try FileManager.default.copyItem(at: url, to: destinationURL) + + logger.info("Copied audio file to: \(destinationURL.path)") + return recordingDirectory + } + + private func transcribeIfEnabled(recordingDirectory: URL) async throws -> String? { + guard transcriptEnabled else { return nil } + + logger.info("Starting transcription for drag & drop file") + let audioURL = recordingDirectory.appendingPathComponent("system_recording.wav") + let result = try await transcriptionService.transcribe(audioURL: audioURL, microphoneURL: nil) + + let transcriptURL = try saveFormattedTranscript( + result: result, + recordingDirectory: recordingDirectory, + audioURL: audioURL, + startDate: Date() + ) + logger.info("Saved transcript to: \(transcriptURL.path)") + return result.combinedText + } + + private func summarizeIfEnabled(text: String?, recordingDirectory: URL) async throws { + guard summarizeEnabled, let text = text else { return } + + logger.info("Starting summarization for drag & drop file") + let summary = try await llmService.generateSummarization( + text: text, + options: .defaultSummarization + ) + + let summaryURL = recordingDirectory.appendingPathComponent("summary.md") + try summary.write(to: summaryURL, atomically: true, encoding: String.Encoding.utf8) + logger.info("Saved summary to: \(summaryURL.path)") + } + + private func saveFormattedTranscript( + result: TranscriptionResult, + recordingDirectory: URL, + audioURL: URL, + startDate: Date + ) throws -> URL { + var markdown = "" + + // Title + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd_HH-mm-ss-SSS" + let dateString = dateFormatter.string(from: startDate) + markdown += "# Transcription - \(dateString)\n\n" + + // Metadata + let generatedFormatter = ISO8601DateFormatter() + generatedFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + markdown += "**Generated:** \(generatedFormatter.string(from: Date()))\n" + + // Duration from transcription result + markdown += "**Duration:** \(String(format: "%.2f", result.transcriptionDuration))s\n" + + // Model used + markdown += "**Model:** \(result.modelUsed)\n" + + // Sources (for drag & drop, it's always system audio only) + markdown += "**Sources:** System Audio\n" + + // Transcript section + markdown += "## Transcript\n\n" + + // Format transcript using timestamped data if available, otherwise use combined text + if let timestampedTranscription = result.timestampedTranscription { + let formattedTranscript = TranscriptionMerger.getFormattedTranscript(timestampedTranscription) + markdown += formattedTranscript + } else { + // Fallback to combined text if no timestamped data + markdown += result.combinedText + } + + markdown += "\n" + + // Save to file + let filename = "transcription_\(dateString).md" + let fileURL = recordingDirectory.appendingPathComponent(filename) + try markdown.write(to: fileURL, atomically: true, encoding: .utf8) + + return fileURL + } +} + +enum DragDropError: LocalizedError { + case unsupportedFormat(String) + + var errorDescription: String? { + switch self { + case .unsupportedFormat(let format): + return "Unsupported audio format: .\(format). Supported formats: wav, mp3, m4a, flac" + } + } +} diff --git a/Recap/UseCases/DragDrop/ViewModel/DragDropViewModelType.swift b/Recap/UseCases/DragDrop/ViewModel/DragDropViewModelType.swift new file mode 100644 index 0000000..8ca9541 --- /dev/null +++ b/Recap/UseCases/DragDrop/ViewModel/DragDropViewModelType.swift @@ -0,0 +1,12 @@ +import Foundation + +@MainActor +protocol DragDropViewModelType: ObservableObject { + var transcriptEnabled: Bool { get set } + var summarizeEnabled: Bool { get set } + var isProcessing: Bool { get } + var errorMessage: String? { get } + var successMessage: String? { get } + + func handleDroppedFile(url: URL) async +} diff --git a/Recap/UseCases/Home/Components/CardBackground.swift b/Recap/UseCases/Home/Components/CardBackground.swift index c81a031..b5ced97 100644 --- a/Recap/UseCases/Home/Components/CardBackground.swift +++ b/Recap/UseCases/Home/Components/CardBackground.swift @@ -8,26 +8,26 @@ import SwiftUI struct CardBackground: View { - let width: CGFloat - let height: CGFloat - let backgroundColor: Color - let borderGradient: LinearGradient - - private var safeWidth: CGFloat { - max(width, 50) - } - - private var safeHeight: CGFloat { - max(height, 50) - } - - var body: some View { + let width: CGFloat + let height: CGFloat + let backgroundColor: Color + let borderGradient: LinearGradient + + private var safeWidth: CGFloat { + max(width, 50) + } + + private var safeHeight: CGFloat { + max(height, 50) + } + + var body: some View { + RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius) + .fill(backgroundColor) + .frame(width: safeWidth, height: safeHeight) + .overlay( RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius) - .fill(backgroundColor) - .frame(width: safeWidth, height: safeHeight) - .overlay( - RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius) - .stroke(borderGradient, lineWidth: UIConstants.Sizing.borderWidth) - ) - } -} \ No newline at end of file + .stroke(borderGradient, lineWidth: UIConstants.Sizing.borderWidth) + ) + } +} diff --git a/Recap/UseCases/Home/Components/CustomReflectionCard.swift b/Recap/UseCases/Home/Components/CustomReflectionCard.swift index 7ec79e2..473765a 100644 --- a/Recap/UseCases/Home/Components/CustomReflectionCard.swift +++ b/Recap/UseCases/Home/Components/CustomReflectionCard.swift @@ -8,55 +8,57 @@ import SwiftUI struct CustomReflectionCard: View { - let containerWidth: CGFloat - @ObservedObject private var appSelectionViewModel: AppSelectionViewModel - let isRecording: Bool - let recordingDuration: TimeInterval - let canStartRecording: Bool - let onToggleRecording: () -> Void - - init( - containerWidth: CGFloat, - appSelectionViewModel: AppSelectionViewModel, - isRecording: Bool, - recordingDuration: TimeInterval, - canStartRecording: Bool, - onToggleRecording: @escaping () -> Void - ) { - self.containerWidth = containerWidth - self.appSelectionViewModel = appSelectionViewModel - self.isRecording = isRecording - self.recordingDuration = recordingDuration - self.canStartRecording = canStartRecording - self.onToggleRecording = onToggleRecording - } + let containerWidth: CGFloat + @ObservedObject private var appSelectionViewModel: AppSelectionViewModel + let isRecording: Bool + let recordingDuration: TimeInterval + let canStartRecording: Bool + let onToggleRecording: () -> Void - var body: some View { - CardBackground( - width: UIConstants.Layout.fullCardWidth(containerWidth: containerWidth), - height: 60, - backgroundColor: UIConstants.Colors.cardBackground2, - borderGradient: isRecording ? UIConstants.Gradients.reflectionBorderRecording : UIConstants.Gradients.reflectionBorder - ) - .overlay( - HStack { - AppSelectionButton(viewModel: appSelectionViewModel) - .padding(.leading, UIConstants.Spacing.cardSpacing) - - Spacer() - - RecordingButton( - isRecording: isRecording, - recordingDuration: recordingDuration, - isEnabled: canStartRecording, - onToggleRecording: onToggleRecording - ) - .padding(.trailing, UIConstants.Spacing.cardSpacing) - } + init( + containerWidth: CGFloat, + appSelectionViewModel: AppSelectionViewModel, + isRecording: Bool, + recordingDuration: TimeInterval, + canStartRecording: Bool, + onToggleRecording: @escaping () -> Void + ) { + self.containerWidth = containerWidth + self.appSelectionViewModel = appSelectionViewModel + self.isRecording = isRecording + self.recordingDuration = recordingDuration + self.canStartRecording = canStartRecording + self.onToggleRecording = onToggleRecording + } + + var body: some View { + CardBackground( + width: UIConstants.Layout.fullCardWidth(containerWidth: containerWidth), + height: 60, + backgroundColor: UIConstants.Colors.cardBackground2, + borderGradient: isRecording + ? UIConstants.Gradients.reflectionBorderRecording + : UIConstants.Gradients.reflectionBorder + ) + .overlay( + HStack { + AppSelectionButton(viewModel: appSelectionViewModel) + .padding(.leading, UIConstants.Spacing.cardSpacing) + + Spacer() + + RecordingButton( + isRecording: isRecording, + recordingDuration: recordingDuration, + isEnabled: canStartRecording, + onToggleRecording: onToggleRecording ) - .animation(.easeInOut(duration: 0.3), value: isRecording) - .onAppear { - appSelectionViewModel.refreshAvailableApps() - } + .padding(.trailing, UIConstants.Spacing.cardSpacing) + } + ) + .animation(.easeInOut(duration: 0.3), value: isRecording) + .onAppear { + appSelectionViewModel.refreshAvailableApps() } + } } diff --git a/Recap/UseCases/Home/Components/HeatmapCard.swift b/Recap/UseCases/Home/Components/HeatmapCard.swift index 14dc606..423061d 100644 --- a/Recap/UseCases/Home/Components/HeatmapCard.swift +++ b/Recap/UseCases/Home/Components/HeatmapCard.swift @@ -8,117 +8,128 @@ import SwiftUI struct HeatmapCard: View { - let title: String - let containerWidth: CGFloat - let isSelected: Bool - let audioLevel: Float - let isInteractionEnabled: Bool - let onToggle: () -> Void - - var body: some View { - CardBackground( - width: UIConstants.Layout.cardWidth(containerWidth: containerWidth), - height: 90, - backgroundColor: UIConstants.Colors.cardBackground1, - borderGradient: UIConstants.Gradients.standardBorder - ) - .overlay( - VStack(spacing: 2) { - HeatmapGrid(audioLevel: audioLevel) - .padding(.top, 14) - - Spacer() - - Rectangle() - .fill(UIConstants.Colors.cardSecondaryBackground) - .frame(height: 35) - .overlay( - HStack { - Text(title) - .foregroundColor(UIConstants.Colors.textPrimary) - .font(UIConstants.Typography.cardTitle) - - Spacer() - - Circle() - .stroke(UIConstants.Colors.selectionStroke, lineWidth: UIConstants.Sizing.strokeWidth) - .frame(width: UIConstants.Sizing.selectionCircleSize, height: UIConstants.Sizing.selectionCircleSize) - .overlay { - if isSelected { - Image(systemName: "checkmark") - .font(UIConstants.Typography.iconFont) - .foregroundColor(UIConstants.Colors.textPrimary) - } - } - } - .padding(.horizontal, UIConstants.Spacing.cardPadding) - ) - } - .clipShape(RoundedRectangle(cornerRadius: 18)) - ) - .contentShape(RoundedRectangle(cornerRadius: 18)) - .onTapGesture { - if isInteractionEnabled { - onToggle() - } - } - .opacity(isInteractionEnabled ? (isSelected ? 1.0 : 0.8) : 0.6) - .animation(.easeInOut(duration: 0.2), value: isSelected) - .animation(.easeInOut(duration: 0.2), value: isInteractionEnabled) - .clipped() + let title: String + let containerWidth: CGFloat + let isSelected: Bool + let audioLevel: Float + let isInteractionEnabled: Bool + let onToggle: () -> Void - } -} + var body: some View { + CardBackground( + width: UIConstants.Layout.cardWidth(containerWidth: containerWidth), + height: 90, + backgroundColor: UIConstants.Colors.cardBackground1, + borderGradient: UIConstants.Gradients.standardBorder + ) + .overlay( + VStack(spacing: 2) { + HeatmapGrid(audioLevel: audioLevel) + .padding(.top, 14) + Spacer() -struct HeatmapGrid: View { - let cols = 18 - let rows = 4 - let audioLevel: Float - - func cellOpacity(row: Int, col: Int) -> Double { - let clampedLevel = min(max(audioLevel, 0), 1) - guard clampedLevel > 0 else { return 0 } - - let rowFromBottom = rows - 1 - row - let centerCol = Double(cols) / 2.0 - let distanceFromCenter = abs(Double(col) - centerCol + 0.5) / centerCol - - let baseWidthFactors = [1.0, 0.85, 0.65, 0.4] - let baseWidthFactor = baseWidthFactors[min(rowFromBottom, baseWidthFactors.count - 1)] - - let rowThreshold = Double(rowFromBottom) / Double(rows) - let levelProgress = Double(clampedLevel) - - guard levelProgress > rowThreshold else { return 0 } - - let rowIntensity = min((levelProgress - rowThreshold) * Double(rows), 1.0) - - let centerIntensity = 1.0 - pow(distanceFromCenter, 2.0) - let widthThreshold = baseWidthFactor * rowIntensity - - guard distanceFromCenter < widthThreshold else { return 0 } - - let edgeFade = 1.0 - pow(distanceFromCenter / widthThreshold, 3.0) - let intensity = rowIntensity * centerIntensity * edgeFade - - return intensity * 0.9 - } - - var body: some View { - VStack(spacing: UIConstants.Spacing.gridCellSpacing) { - ForEach(0.. Double { + let clampedLevel = min(max(audioLevel, 0), 1) + guard clampedLevel > 0 else { return 0 } + + let rowFromBottom = rows - 1 - row + let centerCol = Double(cols) / 2.0 + let distanceFromCenter = abs(Double(col) - centerCol + 0.5) / centerCol + + let baseWidthFactors = [1.0, 0.85, 0.65, 0.4] + let baseWidthFactor = baseWidthFactors[min(rowFromBottom, baseWidthFactors.count - 1)] + + let rowThreshold = Double(rowFromBottom) / Double(rows) + let levelProgress = Double(clampedLevel) + + guard levelProgress > rowThreshold else { return 0 } + + let rowIntensity = min((levelProgress - rowThreshold) * Double(rows), 1.0) + + let centerIntensity = 1.0 - pow(distanceFromCenter, 2.0) + let widthThreshold = baseWidthFactor * rowIntensity + + guard distanceFromCenter < widthThreshold else { return 0 } + + let edgeFade = 1.0 - pow(distanceFromCenter / widthThreshold, 3.0) + let intensity = rowIntensity * centerIntensity * edgeFade + + return intensity * 0.9 + } + + var body: some View { + VStack(spacing: UIConstants.Spacing.gridCellSpacing) { + ForEach(0.. Void - - var body: some View { - CardBackground( - width: UIConstants.Layout.fullCardWidth(containerWidth: containerWidth), - height: 80, - backgroundColor: UIConstants.Colors.cardBackground2, - borderGradient: UIConstants.Gradients.standardBorder - ) - .overlay( - VStack(spacing: 12) { - HStack { - VStack(alignment: .leading, spacing: UIConstants.Spacing.cardInternalSpacing) { - Text("Latest Meeting Summary") - .font(UIConstants.Typography.transcriptionTitle) - .foregroundColor(UIConstants.Colors.textPrimary) - Text("View your latest meeting summary!") - .font(UIConstants.Typography.bodyText) - .foregroundColor(UIConstants.Colors.textTertiary) - } - Spacer() - - PillButton(text: "View", icon: "square.arrowtriangle.4.outward") { - onViewTap() - } - } - } - .padding(.horizontal, 20) - .padding(.vertical, 16) - ) - } + let containerWidth: CGFloat + let onViewTap: () -> Void + + var body: some View { + CardBackground( + width: UIConstants.Layout.fullCardWidth(containerWidth: containerWidth), + height: 80, + backgroundColor: UIConstants.Colors.cardBackground2, + borderGradient: UIConstants.Gradients.standardBorder + ) + .overlay( + VStack(spacing: 12) { + HStack { + VStack(alignment: .leading, spacing: UIConstants.Spacing.cardInternalSpacing) { + Text("Latest Meeting Summary") + .font(UIConstants.Typography.transcriptionTitle) + .foregroundColor(UIConstants.Colors.textPrimary) + Text("View your latest meeting summary!") + .font(UIConstants.Typography.bodyText) + .foregroundColor(UIConstants.Colors.textTertiary) + } + Spacer() + + PillButton(text: "View", icon: "square.arrowtriangle.4.outward") { + onViewTap() + } + } + } + .padding(.horizontal, 20) + .padding(.vertical, 16) + ) + } } diff --git a/Recap/UseCases/Home/View/RecapView.swift b/Recap/UseCases/Home/View/RecapView.swift index a07fd8f..44a7673 100644 --- a/Recap/UseCases/Home/View/RecapView.swift +++ b/Recap/UseCases/Home/View/RecapView.swift @@ -8,120 +8,93 @@ import SwiftUI struct RecapHomeView: View { - @ObservedObject private var viewModel: RecapViewModel - - init(viewModel: RecapViewModel) { - self.viewModel = viewModel - } - - var body: some View { - GeometryReader { geometry in - ZStack { - UIConstants.Gradients.backgroundGradient - .ignoresSafeArea() - - ScrollView(.vertical, showsIndicators: false) { - VStack(spacing: UIConstants.Spacing.sectionSpacing) { - HStack { - Text("Recap") - .foregroundColor(UIConstants.Colors.textPrimary) - .font(UIConstants.Typography.appTitle) - .padding(.leading, UIConstants.Spacing.contentPadding) - .padding(.top, UIConstants.Spacing.sectionSpacing) - - Spacer() - } - - ForEach(viewModel.activeWarnings, id: \.id) { warning in - WarningCard(warning: warning, containerWidth: geometry.size.width) - .padding(.horizontal, UIConstants.Spacing.contentPadding) - } - - HStack(spacing: UIConstants.Spacing.cardSpacing) { - HeatmapCard( - title: "System Audio", - containerWidth: geometry.size.width, - isSelected: true, - audioLevel: viewModel.systemAudioHeatmapLevel, - isInteractionEnabled: !viewModel.isRecording, - onToggle: { } - ) - HeatmapCard( - title: "Microphone", - containerWidth: geometry.size.width, - isSelected: viewModel.isMicrophoneEnabled, - audioLevel: viewModel.microphoneHeatmapLevel, - isInteractionEnabled: !viewModel.isRecording, - onToggle: { - viewModel.toggleMicrophone() - } - ) - } - - VStack(spacing: UIConstants.Spacing.cardSpacing) { - CustomReflectionCard( - containerWidth: geometry.size.width, - appSelectionViewModel: viewModel.appSelectionViewModel, - isRecording: viewModel.isRecording, - recordingDuration: viewModel.recordingDuration, - canStartRecording: viewModel.canStartRecording, - onToggleRecording: { - Task { - if viewModel.isRecording { - await viewModel.stopRecording() - } else { - await viewModel.startRecording() - } - } - } - ) + @ObservedObject private var viewModel: RecapViewModel + + init(viewModel: RecapViewModel) { + self.viewModel = viewModel + } + + var body: some View { + GeometryReader { geometry in + ZStack { + UIConstants.Gradients.backgroundGradient + .ignoresSafeArea() + + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: UIConstants.Spacing.sectionSpacing) { + HStack { + Text("Recap") + .foregroundColor(UIConstants.Colors.textPrimary) + .font(UIConstants.Typography.appTitle) + .padding(.leading, UIConstants.Spacing.contentPadding) + .padding(.top, UIConstants.Spacing.sectionSpacing) + + Spacer() + + Button { + viewModel.closePanel() + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundColor(UIConstants.Colors.textSecondary) + .font(.title2) + } + .buttonStyle(PlainButtonStyle()) + .padding(.trailing, UIConstants.Spacing.contentPadding) + .padding(.top, UIConstants.Spacing.sectionSpacing) + } + + ForEach(viewModel.activeWarnings, id: \.id) { warning in + WarningCard(warning: warning, containerWidth: geometry.size.width) + .padding(.horizontal, UIConstants.Spacing.contentPadding) + } + + VStack(spacing: UIConstants.Spacing.cardSpacing) { + TranscriptionCard(containerWidth: geometry.size.width) { + viewModel.openView() + } + + HStack(spacing: UIConstants.Spacing.cardSpacing) { + InformationCard( + icon: "list.bullet.indent", + title: "Previous Recaps", + description: "View past recordings", + containerWidth: geometry.size.width + ) + .onTapGesture { + viewModel.openPreviousRecaps() + } - TranscriptionCard(containerWidth: geometry.size.width) { - viewModel.openView() - } - - HStack(spacing: UIConstants.Spacing.cardSpacing) { - InformationCard( - icon: "list.bullet.indent", - title: "Previous Recaps", - description: "View past recordings", - containerWidth: geometry.size.width - ) - .onTapGesture { - viewModel.openPreviousRecaps() - } - - InformationCard( - icon: "gear", - title: "Settings", - description: "App preferences", - containerWidth: geometry.size.width - ) - .onTapGesture { - viewModel.openSettings() - } - } - } - - Spacer(minLength: UIConstants.Spacing.sectionSpacing) - } + InformationCard( + icon: "gear", + title: "Settings", + description: "App preferences", + containerWidth: geometry.size.width + ) + .onTapGesture { + viewModel.openSettings() } + } } + + Spacer(minLength: UIConstants.Spacing.sectionSpacing) + } } - .toast(isPresenting: $viewModel.showErrorToast) { - AlertToast( - displayMode: .banner(.slide), - type: .error(.red), - title: "Recording Error", - subTitle: viewModel.errorMessage - ) - } + } } + .toast(isPresenting: $viewModel.showErrorToast) { + AlertToast( + displayMode: .banner(.slide), + type: .error(.red), + title: "Recording Error", + subTitle: viewModel.errorMessage + ) + } + } } #Preview { - let viewModel = RecapViewModel.createForPreview() - - return RecapHomeView(viewModel: viewModel) - .frame(width: 500, height: 500) + let viewModel = RecapViewModel.createForPreview() + + return RecapHomeView(viewModel: viewModel) + .frame(width: 500, height: 500) } diff --git a/Recap/UseCases/Home/ViewModel/RecapViewModel+MeetingDetection.swift b/Recap/UseCases/Home/ViewModel/RecapViewModel+MeetingDetection.swift index 4def952..7135abc 100644 --- a/Recap/UseCases/Home/ViewModel/RecapViewModel+MeetingDetection.swift +++ b/Recap/UseCases/Home/ViewModel/RecapViewModel+MeetingDetection.swift @@ -1,101 +1,101 @@ -import Foundation import Combine +import Foundation import SwiftUI // MARK: - Meeting Detection Setup extension RecapViewModel { - func setupMeetingDetection() { - Task { - guard await shouldEnableMeetingDetection() else { return } - - setupMeetingStateObserver() - await startMonitoringIfPermissionGranted() - } + func setupMeetingDetection() { + Task { + guard await shouldEnableMeetingDetection() else { return } + + setupMeetingStateObserver() + await startMonitoringIfPermissionGranted() } + } } // MARK: - Private Setup Helpers -private extension RecapViewModel { - func shouldEnableMeetingDetection() async -> Bool { - do { - let preferences = try await userPreferencesRepository.getOrCreatePreferences() - return preferences.autoDetectMeetings - } catch { - logger.error("Failed to load meeting detection preferences: \(error)") - return false - } - } - - func setupMeetingStateObserver() { - meetingDetectionService.meetingStatePublisher - .sink { [weak self] meetingState in - guard let self = self else { return } - self.handleMeetingStateChange(meetingState) - } - .store(in: &cancellables) +extension RecapViewModel { + fileprivate func shouldEnableMeetingDetection() async -> Bool { + do { + let preferences = try await userPreferencesRepository.getOrCreatePreferences() + return preferences.autoDetectMeetings + } catch { + logger.error("Failed to load meeting detection preferences: \(error)") + return false } - - func startMonitoringIfPermissionGranted() async { - if await permissionsHelper.checkScreenCapturePermission() { - meetingDetectionService.startMonitoring() - } else { - logger.warning("Meeting detection permission denied") - } + } + + fileprivate func setupMeetingStateObserver() { + meetingDetectionService.meetingStatePublisher + .sink { [weak self] meetingState in + guard let self = self else { return } + self.handleMeetingStateChange(meetingState) + } + .store(in: &cancellables) + } + + fileprivate func startMonitoringIfPermissionGranted() async { + if await permissionsHelper.checkScreenCapturePermission() { + meetingDetectionService.startMonitoring() + } else { + logger.warning("Meeting detection permission denied") } + } } // MARK: - Meeting State Handling -private extension RecapViewModel { - func handleMeetingStateChange(_ meetingState: MeetingState) { - switch meetingState { - case .active(let info, let detectedApp): - handleMeetingDetected(info: info, detectedApp: detectedApp) - case .inactive: - handleMeetingEnded() - } - } - - func handleMeetingDetected(info: ActiveMeetingInfo, detectedApp: AudioProcess?) { - autoSelectAppIfAvailable(detectedApp) - - let currentMeetingKey = "\(info.appName)-\(info.title)" - if lastNotifiedMeetingKey != currentMeetingKey { - lastNotifiedMeetingKey = currentMeetingKey - sendMeetingStartedNotification(appName: info.appName, title: info.title) - } +extension RecapViewModel { + fileprivate func handleMeetingStateChange(_ meetingState: MeetingState) { + switch meetingState { + case .active(let info, let detectedApp): + handleMeetingDetected(info: info, detectedApp: detectedApp) + case .inactive: + handleMeetingEnded() } - - func handleMeetingEnded() { - lastNotifiedMeetingKey = nil - sendMeetingEndedNotification() + } + + fileprivate func handleMeetingDetected(info: ActiveMeetingInfo, detectedApp: AudioProcess?) { + autoSelectAppIfAvailable(detectedApp) + + let currentMeetingKey = "\(info.appName)-\(info.title)" + if lastNotifiedMeetingKey != currentMeetingKey { + lastNotifiedMeetingKey = currentMeetingKey + sendMeetingStartedNotification(appName: info.appName, title: info.title) } + } + + fileprivate func handleMeetingEnded() { + lastNotifiedMeetingKey = nil + sendMeetingEndedNotification() + } } // MARK: - App Auto-Selection -private extension RecapViewModel { - func autoSelectAppIfAvailable(_ detectedApp: AudioProcess?) { - guard let detectedApp else { - return - } - - appSelectionCoordinator.autoSelectApp(detectedApp) +extension RecapViewModel { + fileprivate func autoSelectAppIfAvailable(_ detectedApp: AudioProcess?) { + guard let detectedApp else { + return } + + appSelectionCoordinator.autoSelectApp(detectedApp) + } } // MARK: - Notification Helpers -private extension RecapViewModel { - func sendMeetingStartedNotification(appName: String, title: String) { - Task { - await notificationService.sendMeetingStartedNotification(appName: appName, title: title) - } - } - - func sendMeetingEndedNotification() { - // TODO: Later we will analyze audio levels, and if silence is detected, send a notification here. +extension RecapViewModel { + fileprivate func sendMeetingStartedNotification(appName: String, title: String) { + Task { + await notificationService.sendMeetingStartedNotification(appName: appName, title: title) } + } + + fileprivate func sendMeetingEndedNotification() { + // Future enhancement: Analyze audio levels, and if silence is detected, send a notification here. + } } // MARK: - Supporting Types private enum MeetingDetectionConstants { - static let autoSelectionAnimationDuration: Double = 0.3 + static let autoSelectionAnimationDuration: Double = 0.3 } diff --git a/Recap/UseCases/Home/ViewModel/RecapViewModel+Processing.swift b/Recap/UseCases/Home/ViewModel/RecapViewModel+Processing.swift index ef64b37..ace3f54 100644 --- a/Recap/UseCases/Home/ViewModel/RecapViewModel+Processing.swift +++ b/Recap/UseCases/Home/ViewModel/RecapViewModel+Processing.swift @@ -1,54 +1,56 @@ import Foundation extension RecapViewModel: ProcessingCoordinatorDelegate { - func processingDidStart(recordingID: String) { - Task { @MainActor in - logger.info("Processing started for recording: \(recordingID)") - updateRecordingsFromRepository() - } + func processingDidStart(recordingID: String) { + Task { @MainActor in + logger.info("Processing started for recording: \(recordingID)") + updateRecordingsFromRepository() } - - func processingDidComplete(recordingID: String, result: ProcessingResult) { - Task { @MainActor in - logger.info("Processing completed for recording: \(recordingID)") - updateRecordingsFromRepository() - - showProcessingCompleteNotification(for: result) - } - } - - func processingDidFail(recordingID: String, error: ProcessingError) { - Task { @MainActor in - logger.error("Processing failed for recording \(recordingID): \(error.localizedDescription)") - updateRecordingsFromRepository() - - if error.isRetryable { - errorMessage = "\(error.localizedDescription). You can retry from the recordings list." - } else { - errorMessage = error.localizedDescription - } - } + } + + func processingDidComplete(recordingID: String, result: ProcessingResult) { + Task { @MainActor in + logger.info("Processing completed for recording: \(recordingID)") + updateRecordingsFromRepository() + + showProcessingCompleteNotification(for: result) } - - func processingStateDidChange(recordingID: String, newState: RecordingProcessingState) { - Task { @MainActor in - logger.info("Processing state changed for \(recordingID): \(newState.displayName)") - updateRecordingsFromRepository() - } + } + + func processingDidFail(recordingID: String, error: ProcessingError) { + Task { @MainActor in + logger.error( + "Processing failed for recording \(recordingID): \(error.localizedDescription)") + updateRecordingsFromRepository() + + if error.isRetryable { + errorMessage = + "\(error.localizedDescription). You can retry from the recordings list." + } else { + errorMessage = error.localizedDescription + } } - - private func updateRecordingsFromRepository() { - Task { - do { - currentRecordings = try await recordingRepository.fetchAllRecordings() - } catch { - logger.error("Failed to fetch recordings: \(error)") - } - } + } + + func processingStateDidChange(recordingID: String, newState: RecordingProcessingState) { + Task { @MainActor in + logger.info("Processing state changed for \(recordingID): \(newState.displayName)") + updateRecordingsFromRepository() } - - private func showProcessingCompleteNotification(for result: ProcessingResult) { - // TODO: Implement rich notification when Notification Center integration is added - logger.info("Summary ready for recording \(result.recordingID)") + } + + private func updateRecordingsFromRepository() { + Task { + do { + currentRecordings = try await recordingRepository.fetchAllRecordings() + } catch { + logger.error("Failed to fetch recordings: \(error)") + } } -} \ No newline at end of file + } + + private func showProcessingCompleteNotification(for result: ProcessingResult) { + // Future enhancement: Implement rich notification when Notification Center integration is added + logger.info("Summary ready for recording \(result.recordingID)") + } +} diff --git a/Recap/UseCases/Home/ViewModel/RecapViewModel+RecordingFailure.swift b/Recap/UseCases/Home/ViewModel/RecapViewModel+RecordingFailure.swift index 3017062..fb80c93 100644 --- a/Recap/UseCases/Home/ViewModel/RecapViewModel+RecordingFailure.swift +++ b/Recap/UseCases/Home/ViewModel/RecapViewModel+RecordingFailure.swift @@ -2,14 +2,14 @@ import Foundation import OSLog extension RecapViewModel { - func handleRecordingFailure(recordingID: String, error: Error) async { - do { - try await recordingRepository.deleteRecording(id: recordingID) - currentRecordings.removeAll { $0.id == recordingID } - - logger.error("Recording failed and cleaned up: \(error)") - } catch { - logger.error("Failed to clean up failed recording: \(error)") - } + func handleRecordingFailure(recordingID: String, error: Error) async { + do { + try await recordingRepository.deleteRecording(id: recordingID) + currentRecordings.removeAll { $0.id == recordingID } + + logger.error("Recording failed and cleaned up: \(error)") + } catch { + logger.error("Failed to clean up failed recording: \(error)") } -} \ No newline at end of file + } +} diff --git a/Recap/UseCases/Home/ViewModel/RecapViewModel+StartRecording.swift b/Recap/UseCases/Home/ViewModel/RecapViewModel+StartRecording.swift index a7c912e..43ac7de 100644 --- a/Recap/UseCases/Home/ViewModel/RecapViewModel+StartRecording.swift +++ b/Recap/UseCases/Home/ViewModel/RecapViewModel+StartRecording.swift @@ -2,77 +2,131 @@ import Foundation import OSLog extension RecapViewModel { - func startRecording() async { - syncRecordingStateWithCoordinator() - guard !isRecording else { return } - guard let selectedApp = selectedApp else { return } - - do { - errorMessage = nil - - let recordingID = generateRecordingID() - currentRecordingID = recordingID - - let configuration = try await createRecordingConfiguration( - recordingID: recordingID, - audioProcess: selectedApp - ) - - let recordedFiles = try await recordingCoordinator.startRecording(configuration: configuration) - - try await createRecordingEntity( - recordingID: recordingID, - recordedFiles: recordedFiles - ) - - updateRecordingUIState(started: true) - - logger.info("Recording started successfully - System: \(recordedFiles.systemAudioURL?.path ?? "none"), Microphone: \(recordedFiles.microphoneURL?.path ?? "none")") - } catch { - handleRecordingStartError(error) - } - } - - private func generateRecordingID() -> String { - UUID().uuidString + func startRecording() async { + syncRecordingStateWithCoordinator() + guard !isRecording else { return } + guard let selectedApp = selectedApp else { return } + + do { + errorMessage = nil + + let recordingID = generateRecordingID() + currentRecordingID = recordingID + + let configuration = try await createRecordingConfiguration( + recordingID: recordingID, + audioProcess: selectedApp + ) + + let recordedFiles = try await recordingCoordinator.startRecording( + configuration: configuration) + + let recordingInfo = try await createRecordingEntity( + recordingID: recordingID, + recordedFiles: recordedFiles + ) + + await prepareTranscriptionPlaceholderIfNeeded( + recording: recordingInfo, + recordedFiles: recordedFiles + ) + + updateRecordingUIState(started: true) + + logger.info( + """ + Recording started successfully - System: \(recordedFiles.systemAudioURL?.path ?? "none"), \ + Microphone: \(recordedFiles.microphoneURL?.path ?? "none") + """ + ) + } catch { + handleRecordingStartError(error) } - - private func createRecordingConfiguration( - recordingID: String, - audioProcess: AudioProcess - ) async throws -> RecordingConfiguration { - try fileManager.ensureRecordingsDirectoryExists() - - let baseURL = fileManager.createRecordingBaseURL(for: recordingID) - - return RecordingConfiguration( - id: recordingID, - audioProcess: audioProcess, - enableMicrophone: isMicrophoneEnabled, - baseURL: baseURL - ) + } + + private func generateRecordingID() -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd_HH-mm-ss-SSS" + formatter.timeZone = TimeZone.current + return formatter.string(from: Date()) + } + + private func createRecordingConfiguration( + recordingID: String, + audioProcess: AudioProcess + ) async throws -> RecordingConfiguration { + try fileManager.ensureRecordingsDirectoryExists() + + let baseURL = fileManager.createRecordingBaseURL(for: recordingID) + + return RecordingConfiguration( + id: recordingID, + audioProcess: audioProcess, + enableMicrophone: isMicrophoneEnabled, + baseURL: baseURL + ) + } + + private func createRecordingEntity( + recordingID: String, + recordedFiles: RecordedFiles + ) async throws -> RecordingInfo { + let parameters = RecordingCreationParameters( + id: recordingID, + startDate: Date(), + recordingURL: recordedFiles.systemAudioURL + ?? fileManager.createRecordingBaseURL(for: recordingID), + microphoneURL: recordedFiles.microphoneURL, + hasMicrophoneAudio: isMicrophoneEnabled, + applicationName: recordedFiles.applicationName ?? selectedApp?.name + ) + let recordingInfo = try await recordingRepository.createRecording(parameters) + currentRecordings.insert(recordingInfo, at: 0) + return recordingInfo + } + + private func handleRecordingStartError(_ error: Error) { + errorMessage = error.localizedDescription + logger.error("Failed to start recording: \(error)") + currentRecordingID = nil + updateRecordingUIState(started: false) + showErrorToast = true + } + + private func prepareTranscriptionPlaceholderIfNeeded( + recording: RecordingInfo, + recordedFiles: RecordedFiles + ) async { + let autoTranscribeEnabled = await isAutoTranscribeEnabled() + guard autoTranscribeEnabled else { return } + + let recordingDirectory: URL + if let systemAudioURL = recordedFiles.systemAudioURL { + recordingDirectory = systemAudioURL.deletingLastPathComponent() + } else { + recordingDirectory = fileManager.createRecordingBaseURL(for: recording.id) } - - private func createRecordingEntity( - recordingID: String, - recordedFiles: RecordedFiles - ) async throws { - let recordingInfo = try await recordingRepository.createRecording( - id: recordingID, - startDate: Date(), - recordingURL: recordedFiles.systemAudioURL ?? fileManager.createRecordingBaseURL(for: recordingID), - microphoneURL: recordedFiles.microphoneURL, - hasMicrophoneAudio: isMicrophoneEnabled, - applicationName: recordedFiles.applicationName ?? selectedApp?.name - ) - currentRecordings.insert(recordingInfo, at: 0) + + do { + let placeholderURL = try TranscriptionMarkdownExporter.preparePlaceholder( + recording: recording, + destinationDirectory: recordingDirectory + ) + + logger.info("Prepared transcription placeholder at \(placeholderURL.path)") + } catch { + logger.error( + "Failed to prepare transcription placeholder: \(error.localizedDescription)") } - - private func handleRecordingStartError(_ error: Error) { - errorMessage = error.localizedDescription - logger.error("Failed to start recording: \(error)") - currentRecordingID = nil - updateRecordingUIState(started: false) - showErrorToast = true + } + + private func isAutoTranscribeEnabled() async -> Bool { + do { + let preferences = try await userPreferencesRepository.getOrCreatePreferences() + return preferences.autoTranscribeEnabled + } catch { + logger.error("Failed to fetch transcription preference: \(error.localizedDescription)") + return true } -} \ No newline at end of file + } +} diff --git a/Recap/UseCases/Home/ViewModel/RecapViewModel+StopRecording.swift b/Recap/UseCases/Home/ViewModel/RecapViewModel+StopRecording.swift index 4636b64..a010232 100644 --- a/Recap/UseCases/Home/ViewModel/RecapViewModel+StopRecording.swift +++ b/Recap/UseCases/Home/ViewModel/RecapViewModel+StopRecording.swift @@ -2,79 +2,79 @@ import Foundation import OSLog extension RecapViewModel { - func stopRecording() async { - guard isRecording else { return } - guard let recordingID = currentRecordingID else { return } - - stopTimers() - - if let recordedFiles = await recordingCoordinator.stopRecording() { - await handleSuccessfulRecordingStop( - recordingID: recordingID, - recordedFiles: recordedFiles - ) - } else { - await handleRecordingFailure( - recordingID: recordingID, - error: RecordingError.failedToStop - ) - } - - updateRecordingUIState(started: false) - currentRecordingID = nil + func stopRecording() async { + guard isRecording else { return } + guard let recordingID = currentRecordingID else { return } + + stopTimers() + + if let recordedFiles = await recordingCoordinator.stopRecording() { + await handleSuccessfulRecordingStop( + recordingID: recordingID, + recordedFiles: recordedFiles + ) + } else { + await handleRecordingFailure( + recordingID: recordingID, + error: RecordingError.failedToStop + ) } - - private func handleSuccessfulRecordingStop( - recordingID: String, - recordedFiles: RecordedFiles - ) async { - logRecordedFiles(recordedFiles) - - do { - try await updateRecordingInRepository( - recordingID: recordingID, - recordedFiles: recordedFiles - ) - - if let updatedRecording = try await recordingRepository.fetchRecording(id: recordingID) { - await processingCoordinator.startProcessing(recordingInfo: updatedRecording) - } - } catch { - logger.error("Failed to update recording after stop: \(error)") - await handleRecordingFailure(recordingID: recordingID, error: error) - } + + updateRecordingUIState(started: false) + currentRecordingID = nil + } + + private func handleSuccessfulRecordingStop( + recordingID: String, + recordedFiles: RecordedFiles + ) async { + logRecordedFiles(recordedFiles) + + do { + try await updateRecordingInRepository( + recordingID: recordingID, + recordedFiles: recordedFiles + ) + + if let updatedRecording = try await recordingRepository.fetchRecording(id: recordingID) { + await processingCoordinator.startProcessing(recordingInfo: updatedRecording) + } + } catch { + logger.error("Failed to update recording after stop: \(error)") + await handleRecordingFailure(recordingID: recordingID, error: error) } - - private func updateRecordingInRepository( - recordingID: String, - recordedFiles: RecordedFiles - ) async throws { - if let systemAudioURL = recordedFiles.systemAudioURL { - try await recordingRepository.updateRecordingURLs( - id: recordingID, - recordingURL: systemAudioURL, - microphoneURL: recordedFiles.microphoneURL - ) - } - - try await recordingRepository.updateRecordingEndDate( - id: recordingID, - endDate: Date() - ) - - try await recordingRepository.updateRecordingState( - id: recordingID, - state: .recorded, - errorMessage: nil - ) + } + + private func updateRecordingInRepository( + recordingID: String, + recordedFiles: RecordedFiles + ) async throws { + if let systemAudioURL = recordedFiles.systemAudioURL { + try await recordingRepository.updateRecordingURLs( + id: recordingID, + recordingURL: systemAudioURL, + microphoneURL: recordedFiles.microphoneURL + ) + } + + try await recordingRepository.updateRecordingEndDate( + id: recordingID, + endDate: Date() + ) + + try await recordingRepository.updateRecordingState( + id: recordingID, + state: .recorded, + errorMessage: nil + ) + } + + private func logRecordedFiles(_ recordedFiles: RecordedFiles) { + if let systemAudioURL = recordedFiles.systemAudioURL { + logger.info("Recording stopped successfully - System audio: \(systemAudioURL.path)") } - - private func logRecordedFiles(_ recordedFiles: RecordedFiles) { - if let systemAudioURL = recordedFiles.systemAudioURL { - logger.info("Recording stopped successfully - System audio: \(systemAudioURL.path)") - } - if let microphoneURL = recordedFiles.microphoneURL { - logger.info("Recording stopped successfully - Microphone: \(microphoneURL.path)") - } + if let microphoneURL = recordedFiles.microphoneURL { + logger.info("Recording stopped successfully - Microphone: \(microphoneURL.path)") } -} \ No newline at end of file + } +} diff --git a/Recap/UseCases/Home/ViewModel/RecapViewModel+Timers.swift b/Recap/UseCases/Home/ViewModel/RecapViewModel+Timers.swift index e632c44..44058ee 100644 --- a/Recap/UseCases/Home/ViewModel/RecapViewModel+Timers.swift +++ b/Recap/UseCases/Home/ViewModel/RecapViewModel+Timers.swift @@ -1,32 +1,32 @@ import Foundation extension RecapViewModel { - func startTimers() { - timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in - Task { @MainActor in - self?.recordingDuration += 1 - } - } - - levelTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in - Task { @MainActor in - self?.updateAudioLevels() - } - } + func startTimers() { + timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + Task { @MainActor in + self?.recordingDuration += 1 + } } - - func stopTimers() { - timer?.invalidate() - timer = nil - levelTimer?.invalidate() - levelTimer = nil + + levelTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in + Task { @MainActor in + self?.updateAudioLevels() + } } - - func updateAudioLevels() { - microphoneLevel = recordingCoordinator.currentAudioLevel - - if let currentCoordinator = recordingCoordinator.getCurrentRecordingCoordinator() { - systemAudioLevel = currentCoordinator.currentSystemAudioLevel - } + } + + func stopTimers() { + timer?.invalidate() + timer = nil + levelTimer?.invalidate() + levelTimer = nil + } + + func updateAudioLevels() { + microphoneLevel = recordingCoordinator.currentAudioLevel + + if let currentCoordinator = recordingCoordinator.getCurrentRecordingCoordinator() { + systemAudioLevel = currentCoordinator.currentSystemAudioLevel } -} \ No newline at end of file + } +} diff --git a/Recap/UseCases/Home/ViewModel/RecapViewModel.swift b/Recap/UseCases/Home/ViewModel/RecapViewModel.swift index bb13528..a9af690 100644 --- a/Recap/UseCases/Home/ViewModel/RecapViewModel.swift +++ b/Recap/UseCases/Home/ViewModel/RecapViewModel.swift @@ -1,209 +1,236 @@ +import Combine import Foundation -import SwiftUI import OSLog -import Combine +import SwiftUI @MainActor protocol RecapViewModelDelegate: AnyObject { - func didRequestSettingsOpen() - func didRequestViewOpen() - func didRequestPreviousRecapsOpen() + func didRequestSettingsOpen() + func didRequestViewOpen() + func didRequestPreviousRecapsOpen() + func didRequestPanelClose() } @MainActor final class RecapViewModel: ObservableObject { - @Published var isRecording = false - @Published var recordingDuration: TimeInterval = 0 - @Published var microphoneLevel: Float = 0.0 - @Published var systemAudioLevel: Float = 0.0 - @Published var errorMessage: String? - @Published var isMicrophoneEnabled = false - @Published var currentRecordings: [RecordingInfo] = [] - @Published var showErrorToast = false - - @Published private(set) var processingState: ProcessingState = .idle - @Published private(set) var activeWarnings: [WarningItem] = [] - @Published private(set) var selectedApp: AudioProcess? - - let recordingCoordinator: RecordingCoordinator - let processingCoordinator: ProcessingCoordinator - let recordingRepository: RecordingRepositoryType - let appSelectionViewModel: AppSelectionViewModel - let fileManager: RecordingFileManaging - let warningManager: any WarningManagerType - let meetingDetectionService: any MeetingDetectionServiceType - let userPreferencesRepository: UserPreferencesRepositoryType - let notificationService: any NotificationServiceType - var appSelectionCoordinator: any AppSelectionCoordinatorType - let permissionsHelper: any PermissionsHelperType - - var timer: Timer? - var levelTimer: Timer? - let logger = Logger(subsystem: AppConstants.Logging.subsystem, category: String(describing: RecapViewModel.self)) - - weak var delegate: RecapViewModelDelegate? - - var currentRecordingID: String? - var lastNotifiedMeetingKey: String? - - var cancellables = Set() - init( - recordingCoordinator: RecordingCoordinator, - processingCoordinator: ProcessingCoordinator, - recordingRepository: RecordingRepositoryType, - appSelectionViewModel: AppSelectionViewModel, - fileManager: RecordingFileManaging, - warningManager: any WarningManagerType, - meetingDetectionService: any MeetingDetectionServiceType, - userPreferencesRepository: UserPreferencesRepositoryType, - notificationService: any NotificationServiceType, - appSelectionCoordinator: any AppSelectionCoordinatorType, - permissionsHelper: any PermissionsHelperType - ) { - self.recordingCoordinator = recordingCoordinator - self.processingCoordinator = processingCoordinator - self.recordingRepository = recordingRepository - self.appSelectionViewModel = appSelectionViewModel - self.fileManager = fileManager - self.warningManager = warningManager - self.meetingDetectionService = meetingDetectionService - self.userPreferencesRepository = userPreferencesRepository - self.notificationService = notificationService - self.appSelectionCoordinator = appSelectionCoordinator - self.permissionsHelper = permissionsHelper - - setupBindings() - setupWarningObserver() - setupMeetingDetection() - setupDelegates() - - Task { - await loadRecordings() - } - } - - func selectApp(_ app: AudioProcess) { - selectedApp = app - } - - func clearError() { - errorMessage = nil - } - - func refreshApps() { - appSelectionViewModel.refreshAvailableApps() - } - - private func setupDelegates() { - appSelectionCoordinator.delegate = self - processingCoordinator.delegate = self - } - - var currentRecordingLevel: Float { - recordingCoordinator.currentAudioLevel - } - - var hasAvailableApps: Bool { - !appSelectionViewModel.availableApps.isEmpty - } - - var canStartRecording: Bool { - selectedApp != nil - } - - func toggleMicrophone() { - isMicrophoneEnabled.toggle() - } - - var systemAudioHeatmapLevel: Float { - guard isRecording else { return 0 } - return systemAudioLevel - } - - var microphoneHeatmapLevel: Float { - guard isRecording && isMicrophoneEnabled else { return 0 } - return microphoneLevel - } - - private func setupBindings() { - appSelectionViewModel.refreshAvailableApps() - } - - private func setupWarningObserver() { - warningManager.activeWarningsPublisher - .assign(to: \.activeWarnings, on: self) - .store(in: &cancellables) - } - - private func loadRecordings() async { - do { - currentRecordings = try await recordingRepository.fetchAllRecordings() - } catch { - logger.error("Failed to load recordings: \(error)") - } - } - - func retryProcessing(for recordingID: String) async { - await processingCoordinator.retryProcessing(recordingID: recordingID) - } - - func updateRecordingUIState(started: Bool) { - isRecording = started - if started { - recordingDuration = 0 - startTimers() - } else { - stopTimers() - recordingDuration = 0 - microphoneLevel = 0.0 - systemAudioLevel = 0.0 - } + @Published var isRecording = false + @Published var recordingDuration: TimeInterval = 0 + @Published var microphoneLevel: Float = 0.0 + @Published var systemAudioLevel: Float = 0.0 + @Published var errorMessage: String? + @Published var isMicrophoneEnabled = false + @Published var currentRecordings: [RecordingInfo] = [] + @Published var showErrorToast = false + + @Published private(set) var processingState: ProcessingState = .idle + @Published private(set) var activeWarnings: [WarningItem] = [] + @Published private(set) var selectedApp: AudioProcess? + + let recordingCoordinator: RecordingCoordinator + let processingCoordinator: ProcessingCoordinator + let recordingRepository: RecordingRepositoryType + let appSelectionViewModel: AppSelectionViewModel + let fileManager: RecordingFileManaging + let warningManager: any WarningManagerType + let meetingDetectionService: any MeetingDetectionServiceType + let userPreferencesRepository: UserPreferencesRepositoryType + let notificationService: any NotificationServiceType + var appSelectionCoordinator: any AppSelectionCoordinatorType + let permissionsHelper: any PermissionsHelperType + + var timer: Timer? + var levelTimer: Timer? + let logger = Logger( + subsystem: AppConstants.Logging.subsystem, category: String(describing: RecapViewModel.self)) + + weak var delegate: RecapViewModelDelegate? + + var currentRecordingID: String? + var lastNotifiedMeetingKey: String? + + var cancellables = Set() + init( + recordingCoordinator: RecordingCoordinator, + processingCoordinator: ProcessingCoordinator, + recordingRepository: RecordingRepositoryType, + appSelectionViewModel: AppSelectionViewModel, + fileManager: RecordingFileManaging, + warningManager: any WarningManagerType, + meetingDetectionService: any MeetingDetectionServiceType, + userPreferencesRepository: UserPreferencesRepositoryType, + notificationService: any NotificationServiceType, + appSelectionCoordinator: any AppSelectionCoordinatorType, + permissionsHelper: any PermissionsHelperType + ) { + self.recordingCoordinator = recordingCoordinator + self.processingCoordinator = processingCoordinator + self.recordingRepository = recordingRepository + self.appSelectionViewModel = appSelectionViewModel + self.fileManager = fileManager + self.warningManager = warningManager + self.meetingDetectionService = meetingDetectionService + self.userPreferencesRepository = userPreferencesRepository + self.notificationService = notificationService + self.appSelectionCoordinator = appSelectionCoordinator + self.permissionsHelper = permissionsHelper + + setupBindings() + setupWarningObserver() + setupMeetingDetection() + setupDelegates() + + Task { + await loadRecordings() + await loadMicrophonePreference() } - - func syncRecordingStateWithCoordinator() { - let coordinatorIsRecording = recordingCoordinator.isRecording - if isRecording != coordinatorIsRecording { - updateRecordingUIState(started: coordinatorIsRecording) - if !coordinatorIsRecording { - currentRecordingID = nil - } - } + } + + func selectApp(_ app: AudioProcess) { + selectedApp = app + } + + func clearError() { + errorMessage = nil + } + + func refreshApps() { + appSelectionViewModel.refreshAvailableApps() + } + + private func setupDelegates() { + appSelectionCoordinator.delegate = self + processingCoordinator.delegate = self + } + + var currentRecordingLevel: Float { + recordingCoordinator.currentAudioLevel + } + + var hasAvailableApps: Bool { + !appSelectionViewModel.availableApps.isEmpty + } + + var canStartRecording: Bool { + selectedApp != nil + } + + func toggleMicrophone() { + isMicrophoneEnabled.toggle() + + // Save the preference + Task { + do { + try await userPreferencesRepository.updateMicrophoneEnabled(isMicrophoneEnabled) + } catch { + logger.error("Failed to save microphone preference: \(error)") + } + } + } + + var systemAudioHeatmapLevel: Float { + guard isRecording else { return 0 } + return systemAudioLevel + } + + var microphoneHeatmapLevel: Float { + guard isRecording && isMicrophoneEnabled else { return 0 } + return microphoneLevel + } + + private func setupBindings() { + appSelectionViewModel.refreshAvailableApps() + } + + private func setupWarningObserver() { + warningManager.activeWarningsPublisher + .assign(to: \.activeWarnings, on: self) + .store(in: &cancellables) + } + + private func loadRecordings() async { + do { + currentRecordings = try await recordingRepository.fetchAllRecordings() + } catch { + logger.error("Failed to load recordings: \(error)") } - - deinit { - Task { [weak self] in - await self?.stopTimers() - } + } + + private func loadMicrophonePreference() async { + do { + let preferences = try await userPreferencesRepository.getOrCreatePreferences() + await MainActor.run { + isMicrophoneEnabled = preferences.microphoneEnabled + } + } catch { + logger.error("Failed to load microphone preference: \(error)") + } + } + + func retryProcessing(for recordingID: String) async { + await processingCoordinator.retryProcessing(recordingID: recordingID) + } + + func updateRecordingUIState(started: Bool) { + isRecording = started + if started { + recordingDuration = 0 + startTimers() + } else { + stopTimers() + recordingDuration = 0 + microphoneLevel = 0.0 + systemAudioLevel = 0.0 + } + } + + func syncRecordingStateWithCoordinator() { + let coordinatorIsRecording = recordingCoordinator.isRecording + if isRecording != coordinatorIsRecording { + updateRecordingUIState(started: coordinatorIsRecording) + if !coordinatorIsRecording { + currentRecordingID = nil + } + } + } + + deinit { + Task { [weak self] in + await self?.stopTimers() } + } } extension RecapViewModel: AppSelectionCoordinatorDelegate { - func didSelectApp(_ app: AudioProcess) { - selectApp(app) - } - - func didClearAppSelection() { - selectedApp = nil - } + func didSelectApp(_ app: AudioProcess) { + selectApp(app) + } + + func didClearAppSelection() { + selectedApp = nil + } } extension RecapViewModel { - func openSettings() { - delegate?.didRequestSettingsOpen() - } - - func openView() { - delegate?.didRequestViewOpen() - } - - func openPreviousRecaps() { - delegate?.didRequestPreviousRecapsOpen() - } + func openSettings() { + delegate?.didRequestSettingsOpen() + } + + func openView() { + delegate?.didRequestViewOpen() + } + + func openPreviousRecaps() { + delegate?.didRequestPreviousRecapsOpen() + } + + func closePanel() { + delegate?.didRequestPanelClose() + } } extension RecapViewModel { - static func createForPreview() -> RecapViewModel { - let container = DependencyContainer.createForPreview() - return container.createRecapViewModel() - } + static func createForPreview() -> RecapViewModel { + let container = DependencyContainer.createForPreview() + return container.createRecapViewModel() + } } diff --git a/Recap/UseCases/Onboarding/Components/PermissionCard.swift b/Recap/UseCases/Onboarding/Components/PermissionCard.swift index 0f78d75..f2646f0 100644 --- a/Recap/UseCases/Onboarding/Components/PermissionCard.swift +++ b/Recap/UseCases/Onboarding/Components/PermissionCard.swift @@ -1,138 +1,141 @@ import SwiftUI struct PermissionCard: View { - let title: String - let description: String - @Binding var isEnabled: Bool - var isExpandable: Bool = false - var expandedContent: (() -> AnyView)? = nil - var isDisabled: Bool = false - let onToggle: (Bool) async -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - HStack(alignment: .center, spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - Text(title) - .font(.system(size: 13, weight: .semibold)) - .foregroundColor(UIConstants.Colors.textPrimary) - - Text(description) - .font(.system(size: 11, weight: .regular)) - .foregroundColor(UIConstants.Colors.textSecondary) - .lineLimit(2) + let title: String + let description: String + @Binding var isEnabled: Bool + var isExpandable: Bool = false + var expandedContent: (() -> AnyView)? + var isDisabled: Bool = false + let onToggle: (Bool) async -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(UIConstants.Colors.textPrimary) + + Text(description) + .font(.system(size: 11, weight: .regular)) + .foregroundColor(UIConstants.Colors.textSecondary) + .lineLimit(2) + } + + Spacer() + + Toggle( + "", + isOn: Binding( + get: { isEnabled }, + set: { newValue in + if !isDisabled { + Task { + await onToggle(newValue) } - - Spacer() - - Toggle("", isOn: Binding( - get: { isEnabled }, - set: { newValue in - if !isDisabled { - Task { - await onToggle(newValue) - } - } - } - )) - .toggleStyle(CustomToggleStyle()) - .labelsHidden() - .disabled(isDisabled) - .opacity(isDisabled ? 0.5 : 1.0) + } } - .padding(16) - - if isExpandable, let expandedContent = expandedContent { - Divider() - .background(Color.white.opacity(0.1)) - .padding(.horizontal, 16) - - expandedContent() - .padding(16) - .transition(.opacity.combined(with: .move(edge: .top))) - } - } - .background( - RoundedRectangle(cornerRadius: 10) - .fill( - LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color(hex: "2A2A2A").opacity(0.3), location: 0), - .init(color: Color(hex: "1A1A1A").opacity(0.4), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ) - ) - .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke( - LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color(hex: "979797").opacity(0.15), location: 0), - .init(color: Color(hex: "C4C4C4").opacity(0.2), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ), - lineWidth: 0.5 - ) - ) + ) ) + .toggleStyle(CustomToggleStyle()) + .labelsHidden() + .disabled(isDisabled) + .opacity(isDisabled ? 0.5 : 1.0) + } + .padding(16) + + if isExpandable, let expandedContent = expandedContent { + Divider() + .background(Color.white.opacity(0.1)) + .padding(.horizontal, 16) + + expandedContent() + .padding(16) + .transition(.opacity.combined(with: .move(edge: .top))) + } } + .background( + RoundedRectangle(cornerRadius: 10) + .fill( + LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color(hex: "2A2A2A").opacity(0.3), location: 0), + .init(color: Color(hex: "1A1A1A").opacity(0.4), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke( + LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color(hex: "979797").opacity(0.15), location: 0), + .init(color: Color(hex: "C4C4C4").opacity(0.2), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 0.5 + ) + ) + ) + } } struct PermissionRequirement: View { - let icon: String - let text: String - - var body: some View { - HStack(spacing: 8) { - Image(systemName: icon) - Text(text) - - Spacer() - } - .font(.system(size: 10, weight: .regular)) - .foregroundColor(UIConstants.Colors.textSecondary) + let icon: String + let text: String + + var body: some View { + HStack(spacing: 8) { + Image(systemName: icon) + Text(text) + + Spacer() } + .font(.system(size: 10, weight: .regular)) + .foregroundColor(UIConstants.Colors.textSecondary) + } } #Preview { - VStack(spacing: 16) { - PermissionCard( - title: "Microphone Access", - description: "Required for recording audio", - isEnabled: .constant(true), - onToggle: { _ in } - ) - - PermissionCard( - title: "Auto Detect Meetings", - description: "Automatically start recording when a meeting begins", - isEnabled: .constant(false), - isExpandable: true, - expandedContent: { - AnyView( - VStack(alignment: .leading, spacing: 8) { - Text("Required Permissions:") - .font(.system(size: 11, weight: .medium)) - .foregroundColor(UIConstants.Colors.textPrimary) - - PermissionRequirement( - icon: "rectangle.on.rectangle", - text: "Screen Recording Access" - ) - PermissionRequirement( - icon: "bell", - text: "Notification Access" - ) - } - ) - }, - onToggle: { _ in } + VStack(spacing: 16) { + PermissionCard( + title: "Microphone Access", + description: "Required for recording audio", + isEnabled: .constant(true), + onToggle: { _ in } + ) + + PermissionCard( + title: "Auto Detect Meetings", + description: "Automatically start recording when a meeting begins", + isEnabled: .constant(false), + isExpandable: true, + expandedContent: { + AnyView( + VStack(alignment: .leading, spacing: 8) { + Text("Required Permissions:") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(UIConstants.Colors.textPrimary) + + PermissionRequirement( + icon: "rectangle.on.rectangle", + text: "Screen Recording Access" + ) + PermissionRequirement( + icon: "bell", + text: "Notification Access" + ) + } ) - } - .padding(75) - .background(Color.black) + }, + onToggle: { _ in } + ) + } + .padding(75) + .background(Color.black) } diff --git a/Recap/UseCases/Onboarding/View/OnboardingView.swift b/Recap/UseCases/Onboarding/View/OnboardingView.swift index d3685b7..50811e1 100644 --- a/Recap/UseCases/Onboarding/View/OnboardingView.swift +++ b/Recap/UseCases/Onboarding/View/OnboardingView.swift @@ -1,272 +1,277 @@ import SwiftUI struct OnboardingView: View { - @ObservedObject private var viewModel: ViewModel - - init(viewModel: ViewModel) { - self.viewModel = viewModel + @ObservedObject private var viewModel: ViewModel + + init(viewModel: ViewModel) { + self.viewModel = viewModel + } + + var body: some View { + VStack(spacing: 0) { + headerSection + + ScrollView { + VStack(spacing: 20) { + permissionsSection + featuresSection + } + .padding(.vertical, 20) + } + + continueButton + } + .background( + LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color(hex: "0F0F0F"), location: 0), + .init(color: Color(hex: "1A1A1A"), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ) + ) + .toast(isPresenting: $viewModel.showErrorToast) { + AlertToast( + displayMode: .banner(.slide), + type: .error(.red), + title: "Error", + subTitle: viewModel.errorMessage + ) + } + } + + private var headerSection: some View { + VStack(spacing: 6) { + Text("Welcome to Recap") + .font(.system(size: 18, weight: .bold)) + .foregroundColor(UIConstants.Colors.textPrimary) + + Text("Let's set up a few things to get you started") + .font(.system(size: 12, weight: .regular)) + .foregroundColor(UIConstants.Colors.textSecondary) } - - var body: some View { - VStack(spacing: 0) { - headerSection - - ScrollView { - VStack(spacing: 20) { - permissionsSection - featuresSection + .padding(.vertical, 20) + .padding(.horizontal, 24) + .frame(maxWidth: .infinity) + .background( + LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color(hex: "2A2A2A").opacity(0.2), location: 0), + .init(color: Color(hex: "1A1A1A").opacity(0.3), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ) + ) + } + + private var permissionsSection: some View { + VStack(alignment: .leading, spacing: 16) { + Text("PERMISSIONS") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(UIConstants.Colors.textSecondary) + .padding(.horizontal, 24) + + VStack(spacing: 12) { + PermissionCard( + title: "Microphone Access", + description: "Required for recording and transcribing audio", + isEnabled: Binding( + get: { viewModel.isMicrophoneEnabled }, + set: { _ in } + ), + onToggle: { enabled in + await viewModel.requestMicrophonePermission(enabled) + } + ) + + PermissionCard( + title: "Auto Detect Meetings", + description: "Automatically start recording when a meeting begins", + isEnabled: Binding( + get: { viewModel.isAutoDetectMeetingsEnabled }, + set: { _ in } + ), + isExpandable: true, + expandedContent: { + AnyView( + VStack(alignment: .leading, spacing: 12) { + Text("This feature requires:") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(UIConstants.Colors.textPrimary) + + VStack(spacing: 8) { + HStack { + PermissionRequirement( + icon: "rectangle.on.rectangle", + text: "Screen Recording" + ) + Text("Window titles only") + .italic() + } + HStack { + PermissionRequirement( + icon: "bell", + text: " Notifications" // extra space needed :( + ) + Text("Meeting alerts") + .italic() + } } - .padding(.vertical, 20) - } - - continueButton - } - .background( - LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color(hex: "0F0F0F"), location: 0), - .init(color: Color(hex: "1A1A1A"), location: 1) - ]), - startPoint: .top, - endPoint: .bottom + .foregroundColor(UIConstants.Colors.textSecondary.opacity(0.5)) + .font(.system(size: 10, weight: .regular)) + + if !viewModel.hasRequiredPermissions { + Text("App restart required after granting permissions") + .font(.system(size: 10, weight: .regular)) + .foregroundColor(Color.orange.opacity(0.6)) + .padding(.top, 4) + } + } ) + }, + onToggle: { enabled in + await viewModel.toggleAutoDetectMeetings(enabled) + } ) - .toast(isPresenting: $viewModel.showErrorToast) { - AlertToast( - displayMode: .banner(.slide), - type: .error(.red), - title: "Error", - subTitle: viewModel.errorMessage - ) - } + } + .padding(.horizontal, 24) } - - private var headerSection: some View { - VStack(spacing: 6) { - Text("Welcome to Recap") - .font(.system(size: 18, weight: .bold)) - .foregroundColor(UIConstants.Colors.textPrimary) - - Text("Let's set up a few things to get you started") - .font(.system(size: 12, weight: .regular)) - .foregroundColor(UIConstants.Colors.textSecondary) - } - .padding(.vertical, 20) + } + + private var featuresSection: some View { + VStack(alignment: .leading, spacing: 16) { + Text("FEATURES") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(UIConstants.Colors.textSecondary) .padding(.horizontal, 24) - .frame(maxWidth: .infinity) - .background( - LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color(hex: "2A2A2A").opacity(0.2), location: 0), - .init(color: Color(hex: "1A1A1A").opacity(0.3), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ) + + VStack(spacing: 12) { + PermissionCard( + title: "Auto Summarize", + description: "Generate summaries after each recording - Coming Soon!", + isEnabled: Binding( + get: { false }, + set: { _ in } + ), + isDisabled: true, + onToggle: { _ in + + } ) - } - - private var permissionsSection: some View { - VStack(alignment: .leading, spacing: 16) { - Text("PERMISSIONS") - .font(.system(size: 11, weight: .semibold)) - .foregroundColor(UIConstants.Colors.textSecondary) - .padding(.horizontal, 24) - - VStack(spacing: 12) { - PermissionCard( - title: "Microphone Access", - description: "Required for recording and transcribing audio", - isEnabled: Binding( - get: { viewModel.isMicrophoneEnabled }, - set: { _ in } - ), - onToggle: { enabled in - await viewModel.requestMicrophonePermission(enabled) - } - ) - - PermissionCard( - title: "Auto Detect Meetings", - description: "Automatically start recording when a meeting begins", - isEnabled: Binding( - get: { viewModel.isAutoDetectMeetingsEnabled }, - set: { _ in } - ), - isExpandable: true, - expandedContent: { - AnyView( - VStack(alignment: .leading, spacing: 12) { - Text("This feature requires:") - .font(.system(size: 11, weight: .medium)) - .foregroundColor(UIConstants.Colors.textPrimary) - - VStack(spacing: 8) { - HStack { - PermissionRequirement( - icon: "rectangle.on.rectangle", - text: "Screen Recording" - ) - Text("Window titles only") - .italic() - } - HStack { - PermissionRequirement( - icon: "bell", - text: " Notifications" // extra space needed :( - ) - Text("Meeting alerts") - .italic() - } - } - .foregroundColor(UIConstants.Colors.textSecondary.opacity(0.5)) - .font(.system(size: 10, weight: .regular)) - - if !viewModel.hasRequiredPermissions { - Text("App restart required after granting permissions") - .font(.system(size: 10, weight: .regular)) - .foregroundColor(Color.orange.opacity(0.6)) - .padding(.top, 4) - } - } - ) - }, - onToggle: { enabled in - await viewModel.toggleAutoDetectMeetings(enabled) - } - ) - } - .padding(.horizontal, 24) - } + PermissionCard( + title: "Live Transcription", + description: "Show real-time transcription during recording", + isEnabled: Binding( + get: { viewModel.isLiveTranscriptionEnabled }, + set: { _ in } + ), + onToggle: { enabled in + viewModel.toggleLiveTranscription(enabled) + } + ) + } + .padding(.horizontal, 24) } - - private var featuresSection: some View { - VStack(alignment: .leading, spacing: 16) { - Text("FEATURES") - .font(.system(size: 11, weight: .semibold)) - .foregroundColor(UIConstants.Colors.textSecondary) - .padding(.horizontal, 24) - - VStack(spacing: 12) { - PermissionCard( - title: "Auto Summarize", - description: "Generate summaries after each recording - Coming Soon!", - isEnabled: Binding( - get: { false }, - set: { _ in } - ), - isDisabled: true, - onToggle: { enabled in - - } + } + + private var continueButton: some View { + GeometryReader { geometry in + HStack { + Spacer() + + Button { + viewModel.completeOnboarding() + } label: { + HStack(spacing: 6) { + Image(systemName: "arrow.right.circle.fill") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.white) + + Text("Continue") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.white) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(width: geometry.size.width * 0.6) + .background( + RoundedRectangle(cornerRadius: 20) + .fill( + LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color(hex: "4A4A4A").opacity(0.4), location: 0), + .init(color: Color(hex: "3A3A3A").opacity(0.6), location: 1) + ]), + startPoint: .top, + endPoint: .bottom ) - - PermissionCard( - title: "Live Transcription", - description: "Show real-time transcription during recording", - isEnabled: Binding( - get: { viewModel.isLiveTranscriptionEnabled }, - set: { _ in } + ) + .overlay( + RoundedRectangle(cornerRadius: 20) + .stroke( + LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color(hex: "979797").opacity(0.6), location: 0), + .init(color: Color(hex: "979797").opacity(0.4), location: 1) + ]), + startPoint: .top, + endPoint: .bottom ), - onToggle: { enabled in - viewModel.toggleLiveTranscription(enabled) - } - ) - } - .padding(.horizontal, 24) + lineWidth: 1 + ) + ) + ) } + .buttonStyle(PlainButtonStyle()) + .padding(.all, 6) + + Spacer() + } } - - private var continueButton: some View { - GeometryReader { geometry in - HStack { - Spacer() - - Button(action: { - viewModel.completeOnboarding() - }) { - HStack(spacing: 6) { - Image(systemName: "arrow.right.circle.fill") - .font(.system(size: 12, weight: .medium)) - .foregroundColor(.white) - - Text("Continue") - .font(.system(size: 12, weight: .medium)) - .foregroundColor(.white) - } - .padding(.horizontal, 12) - .padding(.vertical, 10) - .frame(width: geometry.size.width * 0.6) - .background( - RoundedRectangle(cornerRadius: 20) - .fill( - LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color(hex: "4A4A4A").opacity(0.4), location: 0), - .init(color: Color(hex: "3A3A3A").opacity(0.6), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ) - ) - .overlay( - RoundedRectangle(cornerRadius: 20) - .stroke( - LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color(hex: "979797").opacity(0.6), location: 0), - .init(color: Color(hex: "979797").opacity(0.4), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ), - lineWidth: 1 - ) - ) - ) - } - .buttonStyle(PlainButtonStyle()) - .padding(.all, 6) - - Spacer() - } - } - .frame(height: 60) - .padding(.horizontal, 16) - .background( - LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color(hex: "1A1A1A").opacity(0.5), location: 0), - .init(color: Color(hex: "0F0F0F").opacity(0.8), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ) - ) - } + .frame(height: 60) + .padding(.horizontal, 16) + .background( + LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color(hex: "1A1A1A").opacity(0.5), location: 0), + .init(color: Color(hex: "0F0F0F").opacity(0.8), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ) + ) + } } #Preview { - OnboardingView( - viewModel: OnboardingViewModel( - permissionsHelper: PermissionsHelper(), - userPreferencesRepository: PreviewUserPreferencesRepository() - ) + OnboardingView( + viewModel: OnboardingViewModel( + permissionsHelper: PermissionsHelper(), + userPreferencesRepository: PreviewUserPreferencesRepository() ) - .frame(width: 600, height: 500) + ) + .frame(width: 600, height: 500) } private class PreviewUserPreferencesRepository: UserPreferencesRepositoryType { - func getOrCreatePreferences() async throws -> UserPreferencesInfo { - UserPreferencesInfo() - } - - func updateSelectedLLMModel(id: String?) async throws {} - func updateSelectedProvider(_ provider: LLMProvider) async throws {} - func updateAutoSummarize(_ enabled: Bool) async throws {} - func updateSummaryPromptTemplate(_ template: String?) async throws {} - func updateAutoDetectMeetings(_ enabled: Bool) async throws {} - func updateAutoStopRecording(_ enabled: Bool) async throws {} - func updateOnboardingStatus(_ completed: Bool) async throws {} + func getOrCreatePreferences() async throws -> UserPreferencesInfo { + UserPreferencesInfo() + } + + func updateSelectedLLMModel(id: String?) async throws {} + func updateSelectedProvider(_ provider: LLMProvider) async throws {} + func updateAutoSummarize(_ enabled: Bool) async throws {} + func updateAutoSummarizeDuringRecording(_ enabled: Bool) async throws {} + func updateAutoSummarizeAfterRecording(_ enabled: Bool) async throws {} + func updateAutoTranscribe(_ enabled: Bool) async throws {} + func updateSummaryPromptTemplate(_ template: String?) async throws {} + func updateAutoDetectMeetings(_ enabled: Bool) async throws {} + func updateAutoStopRecording(_ enabled: Bool) async throws {} + func updateOnboardingStatus(_ completed: Bool) async throws {} + func updateMicrophoneEnabled(_ enabled: Bool) async throws {} + func updateGlobalShortcut(keyCode: Int32, modifiers: Int32) async throws {} + func updateCustomTmpDirectory(path: String?, bookmark: Data?) async throws {} } diff --git a/Recap/UseCases/Onboarding/ViewModel/OnboardingViewModel.swift b/Recap/UseCases/Onboarding/ViewModel/OnboardingViewModel.swift index c86fc07..a917e16 100644 --- a/Recap/UseCases/Onboarding/ViewModel/OnboardingViewModel.swift +++ b/Recap/UseCases/Onboarding/ViewModel/OnboardingViewModel.swift @@ -1,98 +1,98 @@ -import Foundation import AVFoundation +import Foundation @MainActor final class OnboardingViewModel: OnboardingViewModelType, ObservableObject { - @Published var isMicrophoneEnabled: Bool = false - @Published var isAutoDetectMeetingsEnabled: Bool = false - @Published var isAutoSummarizeEnabled: Bool = true - @Published var isLiveTranscriptionEnabled: Bool = true - @Published var hasRequiredPermissions: Bool = false - @Published var showErrorToast: Bool = false - @Published var errorMessage: String = "" - - weak var delegate: OnboardingDelegate? - - private let permissionsHelper: PermissionsHelperType - private let userPreferencesRepository: UserPreferencesRepositoryType - - var canContinue: Bool { - true // no enforced permissions yet - } - - init( - permissionsHelper: PermissionsHelperType, - userPreferencesRepository: UserPreferencesRepositoryType - ) { - self.permissionsHelper = permissionsHelper - self.userPreferencesRepository = userPreferencesRepository - checkExistingPermissions() - } - - func requestMicrophonePermission(_ enabled: Bool) async { - if enabled { - let granted = await permissionsHelper.requestMicrophonePermission() - isMicrophoneEnabled = granted - } else { - isMicrophoneEnabled = false - } - } - - func toggleAutoDetectMeetings(_ enabled: Bool) async { - if enabled { - let screenGranted = await permissionsHelper.requestScreenRecordingPermission() - let notificationGranted = await permissionsHelper.requestNotificationPermission() - - if screenGranted && notificationGranted { - isAutoDetectMeetingsEnabled = true - hasRequiredPermissions = true - } else { - isAutoDetectMeetingsEnabled = false - hasRequiredPermissions = false - } - } else { - isAutoDetectMeetingsEnabled = false - } - } - - func toggleAutoSummarize(_ enabled: Bool) { - isAutoSummarizeEnabled = enabled + @Published var isMicrophoneEnabled: Bool = false + @Published var isAutoDetectMeetingsEnabled: Bool = false + @Published var isAutoSummarizeEnabled: Bool = true + @Published var isLiveTranscriptionEnabled: Bool = true + @Published var hasRequiredPermissions: Bool = false + @Published var showErrorToast: Bool = false + @Published var errorMessage: String = "" + + weak var delegate: OnboardingDelegate? + + private let permissionsHelper: PermissionsHelperType + private let userPreferencesRepository: UserPreferencesRepositoryType + + var canContinue: Bool { + true // no enforced permissions yet + } + + init( + permissionsHelper: PermissionsHelperType, + userPreferencesRepository: UserPreferencesRepositoryType + ) { + self.permissionsHelper = permissionsHelper + self.userPreferencesRepository = userPreferencesRepository + checkExistingPermissions() + } + + func requestMicrophonePermission(_ enabled: Bool) async { + if enabled { + let granted = await permissionsHelper.requestMicrophonePermission() + isMicrophoneEnabled = granted + } else { + isMicrophoneEnabled = false } - - func toggleLiveTranscription(_ enabled: Bool) { - isLiveTranscriptionEnabled = enabled + } + + func toggleAutoDetectMeetings(_ enabled: Bool) async { + if enabled { + let screenGranted = await permissionsHelper.requestScreenRecordingPermission() + let notificationGranted = await permissionsHelper.requestNotificationPermission() + + if screenGranted && notificationGranted { + isAutoDetectMeetingsEnabled = true + hasRequiredPermissions = true + } else { + isAutoDetectMeetingsEnabled = false + hasRequiredPermissions = false + } + } else { + isAutoDetectMeetingsEnabled = false } - - func completeOnboarding() { + } + + func toggleAutoSummarize(_ enabled: Bool) { + isAutoSummarizeEnabled = enabled + } + + func toggleLiveTranscription(_ enabled: Bool) { + isLiveTranscriptionEnabled = enabled + } + + func completeOnboarding() { + Task { + do { + try await userPreferencesRepository.updateOnboardingStatus(true) + try await userPreferencesRepository.updateAutoDetectMeetings(isAutoDetectMeetingsEnabled) + try await userPreferencesRepository.updateAutoSummarize(isAutoSummarizeEnabled) + + delegate?.onboardingDidComplete() + } catch { + errorMessage = "Failed to save preferences. Please try again." + showErrorToast = true + Task { - do { - try await userPreferencesRepository.updateOnboardingStatus(true) - try await userPreferencesRepository.updateAutoDetectMeetings(isAutoDetectMeetingsEnabled) - try await userPreferencesRepository.updateAutoSummarize(isAutoSummarizeEnabled) - - delegate?.onboardingDidComplete() - } catch { - errorMessage = "Failed to save preferences. Please try again." - showErrorToast = true - - Task { - try? await Task.sleep(nanoseconds: 3_000_000_000) - showErrorToast = false - } - } + try? await Task.sleep(nanoseconds: 3_000_000_000) + showErrorToast = false } + } } - - private func checkExistingPermissions() { - let microphoneStatus = permissionsHelper.checkMicrophonePermissionStatus() - isMicrophoneEnabled = microphoneStatus == .authorized - - Task { - let notificationStatus = await permissionsHelper.checkNotificationPermissionStatus() - let screenStatus = permissionsHelper.checkScreenRecordingPermission() - hasRequiredPermissions = notificationStatus && screenStatus - - isAutoDetectMeetingsEnabled = false - } + } + + private func checkExistingPermissions() { + let microphoneStatus = permissionsHelper.checkMicrophonePermissionStatus() + isMicrophoneEnabled = microphoneStatus == .authorized + + Task { + let notificationStatus = await permissionsHelper.checkNotificationPermissionStatus() + let screenStatus = permissionsHelper.checkScreenRecordingPermission() + hasRequiredPermissions = notificationStatus && screenStatus + + isAutoDetectMeetingsEnabled = false } + } } diff --git a/Recap/UseCases/Onboarding/ViewModel/OnboardingViewModelType.swift b/Recap/UseCases/Onboarding/ViewModel/OnboardingViewModelType.swift index 7a8ac1a..a08033b 100644 --- a/Recap/UseCases/Onboarding/ViewModel/OnboardingViewModelType.swift +++ b/Recap/UseCases/Onboarding/ViewModel/OnboardingViewModelType.swift @@ -2,24 +2,24 @@ import Foundation @MainActor protocol OnboardingDelegate: AnyObject { - func onboardingDidComplete() + func onboardingDidComplete() } @MainActor protocol OnboardingViewModelType: ObservableObject { - var isMicrophoneEnabled: Bool { get } - var isAutoDetectMeetingsEnabled: Bool { get } - var isAutoSummarizeEnabled: Bool { get } - var isLiveTranscriptionEnabled: Bool { get } - var hasRequiredPermissions: Bool { get } - var showErrorToast: Bool { get set } - var errorMessage: String { get } - var canContinue: Bool { get } - var delegate: OnboardingDelegate? { get set } - - func requestMicrophonePermission(_ enabled: Bool) async - func toggleAutoDetectMeetings(_ enabled: Bool) async - func toggleAutoSummarize(_ enabled: Bool) - func toggleLiveTranscription(_ enabled: Bool) - func completeOnboarding() -} \ No newline at end of file + var isMicrophoneEnabled: Bool { get } + var isAutoDetectMeetingsEnabled: Bool { get } + var isAutoSummarizeEnabled: Bool { get } + var isLiveTranscriptionEnabled: Bool { get } + var hasRequiredPermissions: Bool { get } + var showErrorToast: Bool { get set } + var errorMessage: String { get } + var canContinue: Bool { get } + var delegate: OnboardingDelegate? { get set } + + func requestMicrophonePermission(_ enabled: Bool) async + func toggleAutoDetectMeetings(_ enabled: Bool) async + func toggleAutoSummarize(_ enabled: Bool) + func toggleLiveTranscription(_ enabled: Bool) + func completeOnboarding() +} diff --git a/Recap/UseCases/PreviousRecaps/View/Components/RecordingCard.swift b/Recap/UseCases/PreviousRecaps/View/Components/RecordingCard.swift index d5ae60d..edaa098 100644 --- a/Recap/UseCases/PreviousRecaps/View/Components/RecordingCard.swift +++ b/Recap/UseCases/PreviousRecaps/View/Components/RecordingCard.swift @@ -1,113 +1,115 @@ import SwiftUI struct RecordingCard: View { - let recording: RecordingInfo - let containerWidth: CGFloat - let onViewTap: () -> Void - - var body: some View { - CardBackground( - width: containerWidth - (UIConstants.Spacing.contentPadding * 2), - height: 80, - backgroundColor: Color(hex: "242323").opacity(0.25), + let recording: RecordingInfo + let containerWidth: CGFloat + let onViewTap: () -> Void + + var body: some View { + CardBackground( + width: containerWidth - (UIConstants.Spacing.contentPadding * 2), + height: 80, + backgroundColor: Color(hex: "242323").opacity(0.25), + borderGradient: LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color(hex: "979797").opacity(0.10), location: 0), + .init(color: Color(hex: "979797").opacity(0.02), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ) + ) + .overlay( + VStack(spacing: 12) { + HStack { + VStack( + alignment: .leading, + spacing: UIConstants.Spacing.cardInternalSpacing + ) { + Text(formattedStartTime) + .font(UIConstants.Typography.transcriptionTitle) + .foregroundColor(UIConstants.Colors.textPrimary) + .lineLimit(1) + + HStack(spacing: 8) { + stateView + + if let duration = recording.duration { + Text("•") + .font(UIConstants.Typography.bodyText) + .foregroundColor(UIConstants.Colors.textTertiary) + + Text(formattedDuration(duration)) + .font(UIConstants.Typography.bodyText) + .foregroundColor(UIConstants.Colors.textSecondary) + .lineLimit(1) + } + } + } + Spacer() + + PillButton( + text: "View", + icon: "square.arrowtriangle.4.outward", borderGradient: LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color(hex: "979797").opacity(0.10), location: 0), - .init(color: Color(hex: "979797").opacity(0.02), location: 1) - ]), - startPoint: .top, - endPoint: .bottom + gradient: Gradient(stops: [ + .init(color: Color(hex: "979797").opacity(0.2), location: 0), + .init(color: Color(hex: "979797").opacity(0.15), location: 1) + ]), + startPoint: .top, + endPoint: .bottom ) - ) - .overlay( - VStack(spacing: 12) { - HStack { - VStack(alignment: .leading, - spacing: UIConstants.Spacing.cardInternalSpacing) { - Text(formattedStartTime) - .font(UIConstants.Typography.transcriptionTitle) - .foregroundColor(UIConstants.Colors.textPrimary) - .lineLimit(1) - - HStack(spacing: 8) { - stateView - - if let duration = recording.duration { - Text("•") - .font(UIConstants.Typography.bodyText) - .foregroundColor(UIConstants.Colors.textTertiary) - - Text(formattedDuration(duration)) - .font(UIConstants.Typography.bodyText) - .foregroundColor(UIConstants.Colors.textSecondary) - .lineLimit(1) - } - } - } - Spacer() - - PillButton( - text: "View", - icon: "square.arrowtriangle.4.outward", - borderGradient: LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color(hex: "979797").opacity(0.2), location: 0), - .init(color: Color(hex: "979797").opacity(0.15), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ) - ) { - onViewTap() - } - } - } - .padding(.horizontal, 20) - .padding(.vertical, 16) - ) - } - - private var formattedStartTime: String { - let formatter = RelativeDateTimeFormatter() - formatter.dateTimeStyle = .named - return formatter.localizedString(for: recording.startDate, relativeTo: Date()) - } - - private var stateView: some View { - HStack(spacing: 6) { - Circle() - .fill(stateColor) - .frame(width: 6, height: 6) - - Text(recording.state.displayName) - .font(UIConstants.Typography.bodyText) - .foregroundColor(stateColor) - .lineLimit(1) + ) { + onViewTap() + } } + } + .padding(.horizontal, 20) + .padding(.vertical, 16) + ) + } + + private var formattedStartTime: String { + let formatter = RelativeDateTimeFormatter() + formatter.dateTimeStyle = .named + return formatter.localizedString(for: recording.startDate, relativeTo: Date()) + } + + private var stateView: some View { + HStack(spacing: 6) { + Circle() + .fill(stateColor) + .frame(width: 6, height: 6) + + Text(recording.state.displayName) + .font(UIConstants.Typography.bodyText) + .foregroundColor(stateColor) + .lineLimit(1) } - - private var stateColor: Color { - switch recording.state { - case .completed: - return UIConstants.Colors.audioGreen - case .transcriptionFailed, .summarizationFailed: - return .red - case .transcribing, .summarizing: - return .orange - default: - return UIConstants.Colors.textTertiary - } + } + + private var stateColor: Color { + switch recording.state { + case .completed: + return UIConstants.Colors.audioGreen + case .transcriptionFailed, .summarizationFailed: + return .red + case .transcribing, .summarizing: + return .orange + default: + return UIConstants.Colors.textTertiary } - - private func formattedDuration(_ duration: TimeInterval) -> String { - let hours = Int(duration) / 3600 - let minutes = Int(duration) % 3600 / 60 - let seconds = Int(duration) % 60 - - if hours > 0 { - return String(format: "%d:%02d:%02d", hours, minutes, seconds) - } else { - return String(format: "%d:%02d", minutes, seconds) - } + } + + private func formattedDuration(_ duration: TimeInterval) -> String { + let hours = Int(duration) / 3600 + let minutes = Int(duration) % 3600 / 60 + let seconds = Int(duration) % 60 + + if hours > 0 { + return String(format: "%d:%02d:%02d", hours, minutes, seconds) + } else { + return String(format: "%d:%02d", minutes, seconds) } + } } diff --git a/Recap/UseCases/PreviousRecaps/View/Components/RecordingRow.swift b/Recap/UseCases/PreviousRecaps/View/Components/RecordingRow.swift index 66eaaeb..8b4b336 100644 --- a/Recap/UseCases/PreviousRecaps/View/Components/RecordingRow.swift +++ b/Recap/UseCases/PreviousRecaps/View/Components/RecordingRow.swift @@ -1,118 +1,118 @@ -import SwiftUI import Foundation +import SwiftUI struct RecordingRow: View { - let recording: RecordingInfo - let onSelected: (RecordingInfo) -> Void - - var body: some View { - Button { - onSelected(recording) - } label: { - HStack(spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 8) { - Text(formattedStartTime) - .font(UIConstants.Typography.bodyText) - .foregroundColor(UIConstants.Colors.textPrimary) - .lineLimit(1) - - if let duration = recording.duration { - Text("•") - .font(UIConstants.Typography.bodyText) - .foregroundColor(UIConstants.Colors.textTertiary) - - Text(formattedDuration(duration)) - .font(UIConstants.Typography.bodyText) - .foregroundColor(UIConstants.Colors.textSecondary) - .lineLimit(1) - } - } - - HStack(spacing: 8) { - processingStateIndicator - - Text(recording.state.displayName) - .font(.caption) - .foregroundColor(stateColor) - .lineLimit(1) - - Spacer() - - contentIndicators - } - } - - Spacer(minLength: 0) + let recording: RecordingInfo + let onSelected: (RecordingInfo) -> Void + + var body: some View { + Button { + onSelected(recording) + } label: { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(formattedStartTime) + .font(UIConstants.Typography.bodyText) + .foregroundColor(UIConstants.Colors.textPrimary) + .lineLimit(1) + + if let duration = recording.duration { + Text("•") + .font(UIConstants.Typography.bodyText) + .foregroundColor(UIConstants.Colors.textTertiary) + + Text(formattedDuration(duration)) + .font(UIConstants.Typography.bodyText) + .foregroundColor(UIConstants.Colors.textSecondary) + .lineLimit(1) } - .padding(.horizontal, UIConstants.Spacing.cardPadding) - .padding(.vertical, UIConstants.Spacing.gridCellSpacing * 2) - .contentShape(Rectangle()) + } + + HStack(spacing: 8) { + processingStateIndicator + + Text(recording.state.displayName) + .font(.caption) + .foregroundColor(stateColor) + .lineLimit(1) + + Spacer() + + contentIndicators + } } - .buttonStyle(PlainButtonStyle()) - .background( - RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius * 0.3) - .fill(Color.clear) - .onHover { isHovered in - if isHovered { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() - } - } - ) - } - - private var formattedStartTime: String { - let formatter = RelativeDateTimeFormatter() - formatter.dateTimeStyle = .named - return formatter.localizedString(for: recording.startDate, relativeTo: Date()) + + Spacer(minLength: 0) + } + .padding(.horizontal, UIConstants.Spacing.cardPadding) + .padding(.vertical, UIConstants.Spacing.gridCellSpacing * 2) + .contentShape(Rectangle()) } - - private func formattedDuration(_ duration: TimeInterval) -> String { - let hours = Int(duration) / 3600 - let minutes = Int(duration) % 3600 / 60 - let seconds = Int(duration) % 60 - - if hours > 0 { - return String(format: "%d:%02d:%02d", hours, minutes, seconds) - } else { - return String(format: "%d:%02d", minutes, seconds) + .buttonStyle(PlainButtonStyle()) + .background( + RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius * 0.3) + .fill(Color.clear) + .onHover { isHovered in + if isHovered { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } } + ) + } + + private var formattedStartTime: String { + let formatter = RelativeDateTimeFormatter() + formatter.dateTimeStyle = .named + return formatter.localizedString(for: recording.startDate, relativeTo: Date()) + } + + private func formattedDuration(_ duration: TimeInterval) -> String { + let hours = Int(duration) / 3600 + let minutes = Int(duration) % 3600 / 60 + let seconds = Int(duration) % 60 + + if hours > 0 { + return String(format: "%d:%02d:%02d", hours, minutes, seconds) + } else { + return String(format: "%d:%02d", minutes, seconds) } - - private var processingStateIndicator: some View { - Circle() - .fill(stateColor) - .frame(width: 6, height: 6) - } - - private var stateColor: Color { - switch recording.state { - case .completed: - return UIConstants.Colors.audioGreen - case .transcriptionFailed, .summarizationFailed: - return .red - case .transcribing, .summarizing: - return .orange - default: - return UIConstants.Colors.textTertiary - } + } + + private var processingStateIndicator: some View { + Circle() + .fill(stateColor) + .frame(width: 6, height: 6) + } + + private var stateColor: Color { + switch recording.state { + case .completed: + return UIConstants.Colors.audioGreen + case .transcriptionFailed, .summarizationFailed: + return .red + case .transcribing, .summarizing: + return .orange + default: + return UIConstants.Colors.textTertiary } - - private var contentIndicators: some View { - HStack(spacing: 4) { - if recording.transcriptionText != nil { - Image(systemName: "doc.text") - .font(.caption2) - .foregroundColor(UIConstants.Colors.textSecondary) - } - - if recording.summaryText != nil { - Image(systemName: "doc.plaintext") - .font(.caption2) - .foregroundColor(UIConstants.Colors.textSecondary) - } - } + } + + private var contentIndicators: some View { + HStack(spacing: 4) { + if recording.transcriptionText != nil { + Image(systemName: "doc.text") + .font(.caption2) + .foregroundColor(UIConstants.Colors.textSecondary) + } + + if recording.summaryText != nil { + Image(systemName: "doc.plaintext") + .font(.caption2) + .foregroundColor(UIConstants.Colors.textSecondary) + } } + } } diff --git a/Recap/UseCases/PreviousRecaps/View/PreviousRecapsDropdown.swift b/Recap/UseCases/PreviousRecaps/View/PreviousRecapsDropdown.swift index c5c6e1c..4dbca00 100644 --- a/Recap/UseCases/PreviousRecaps/View/PreviousRecapsDropdown.swift +++ b/Recap/UseCases/PreviousRecaps/View/PreviousRecapsDropdown.swift @@ -1,268 +1,285 @@ import SwiftUI struct PreviousRecapsDropdown: View { - @ObservedObject private var viewModel: ViewModel - let onRecordingSelected: (RecordingInfo) -> Void - let onClose: () -> Void - - init( - viewModel: ViewModel, - onRecordingSelected: @escaping (RecordingInfo) -> Void, - onClose: @escaping () -> Void - ) { - self.viewModel = viewModel - self.onRecordingSelected = onRecordingSelected - self.onClose = onClose + @ObservedObject private var viewModel: ViewModel + let onRecordingSelected: (RecordingInfo) -> Void + let onClose: () -> Void + + init( + viewModel: ViewModel, + onRecordingSelected: @escaping (RecordingInfo) -> Void, + onClose: @escaping () -> Void + ) { + self.viewModel = viewModel + self.onRecordingSelected = onRecordingSelected + self.onClose = onClose + } + + var body: some View { + ScrollView(.vertical, showsIndicators: false) { + contentView } - - var body: some View { - ScrollView(.vertical, showsIndicators: false) { - contentView - } - .frame(width: 380, height: 500) - .clipped() + .frame(width: 380, height: 500) + .clipped() + .background( + RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius * 0.6) + .fill(UIConstants.Gradients.backgroundGradient) .background( - RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius * 0.6) - .fill(UIConstants.Gradients.backgroundGradient) - .background( - RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius * 0.6) - .fill(.ultraThinMaterial) - ) + RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius * 0.6) + .fill(.ultraThinMaterial) ) - .task { - await viewModel.loadRecordings() - viewModel.startAutoRefresh() - } - .onDisappear { - viewModel.stopAutoRefresh() - } + ) + .task { + await viewModel.loadRecordings() + viewModel.startAutoRefresh() } - - private var contentView: some View { - VStack(alignment: .leading, spacing: 0) { - dropdownHeader - - if viewModel.isLoading { - loadingView - } else if let errorMessage = viewModel.errorMessage { - errorView(errorMessage) - } else if viewModel.groupedRecordings.isEmpty { - emptyStateView - } else { - recordingsContent - .animation(.easeInOut(duration: 0.3), value: viewModel.groupedRecordings.todayRecordings.count) - .animation(.easeInOut(duration: 0.3), value: viewModel.groupedRecordings.thisWeekRecordings.count) - .animation(.easeInOut(duration: 0.3), value: viewModel.groupedRecordings.allRecordings.count) - } - } - .padding(.top, UIConstants.Spacing.contentPadding) - .padding(.bottom, UIConstants.Spacing.cardPadding) + .onDisappear { + viewModel.stopAutoRefresh() } - - private var dropdownHeader: some View { - HStack { - Text("Previous Recaps") - .foregroundColor(UIConstants.Colors.textPrimary) - .font(UIConstants.Typography.appTitle) - - Spacer() - - PillButton(text: "Close", icon: "xmark") { - onClose() - } - } - .padding(.horizontal, UIConstants.Spacing.contentPadding) - .padding(.bottom, UIConstants.Spacing.sectionSpacing) + } + + private var contentView: some View { + VStack(alignment: .leading, spacing: 0) { + dropdownHeader + + if viewModel.isLoading { + loadingView + } else if let errorMessage = viewModel.errorMessage { + errorView(errorMessage) + } else if viewModel.groupedRecordings.isEmpty { + emptyStateView + } else { + recordingsContent + .animation( + .easeInOut(duration: 0.3), + value: viewModel.groupedRecordings.todayRecordings.count + ) + .animation( + .easeInOut(duration: 0.3), + value: viewModel.groupedRecordings.thisWeekRecordings.count + ) + .animation( + .easeInOut(duration: 0.3), + value: viewModel.groupedRecordings.allRecordings.count) + } + } + .padding(.top, UIConstants.Spacing.contentPadding) + .padding(.bottom, UIConstants.Spacing.cardPadding) + } + + private var dropdownHeader: some View { + HStack { + Text("Previous Recaps") + .foregroundColor(UIConstants.Colors.textPrimary) + .font(UIConstants.Typography.appTitle) + + Spacer() + + PillButton(text: "Close", icon: "xmark") { + onClose() + } } - - private var recordingsContent: some View { - VStack(alignment: .leading, spacing: 4) { - if !viewModel.groupedRecordings.todayRecordings.isEmpty { - sectionHeader("Today") - ForEach(viewModel.groupedRecordings.todayRecordings) { recording in - RecordingCard( - recording: recording, - containerWidth: 380, - onViewTap: { - onRecordingSelected(recording) - } - ) - .padding(.horizontal, UIConstants.Spacing.contentPadding) - .padding(.bottom, UIConstants.Spacing.cardSpacing) - .transition(.asymmetric( - insertion: .move(edge: .top).combined(with: .opacity), - removal: .move(edge: .leading).combined(with: .opacity) - )) - } - - if !viewModel.groupedRecordings.thisWeekRecordings.isEmpty || !viewModel.groupedRecordings.allRecordings.isEmpty { - sectionDivider - } + .padding(.horizontal, UIConstants.Spacing.contentPadding) + .padding(.bottom, UIConstants.Spacing.sectionSpacing) + } + + private var recordingsContent: some View { + VStack(alignment: .leading, spacing: 4) { + if !viewModel.groupedRecordings.todayRecordings.isEmpty { + sectionHeader("Today") + ForEach(viewModel.groupedRecordings.todayRecordings) { recording in + RecordingCard( + recording: recording, + containerWidth: 380, + onViewTap: { + onRecordingSelected(recording) } - - if !viewModel.groupedRecordings.thisWeekRecordings.isEmpty { - sectionHeader("This Week") - ForEach(viewModel.groupedRecordings.thisWeekRecordings) { recording in - RecordingCard( - recording: recording, - containerWidth: 380, - onViewTap: { - onRecordingSelected(recording) - } - ) - .padding(.horizontal, UIConstants.Spacing.contentPadding) - .padding(.bottom, UIConstants.Spacing.cardSpacing) - .transition(.asymmetric( - insertion: .move(edge: .top).combined(with: .opacity), - removal: .move(edge: .leading).combined(with: .opacity) - )) - } - - if !viewModel.groupedRecordings.allRecordings.isEmpty { - sectionDivider - } + ) + .padding(.horizontal, UIConstants.Spacing.contentPadding) + .padding(.bottom, UIConstants.Spacing.cardSpacing) + .transition( + .asymmetric( + insertion: .move(edge: .top).combined(with: .opacity), + removal: .move(edge: .leading).combined(with: .opacity) + )) + } + + if !viewModel.groupedRecordings.thisWeekRecordings.isEmpty + || !viewModel.groupedRecordings.allRecordings.isEmpty { + sectionDivider + } + } + + if !viewModel.groupedRecordings.thisWeekRecordings.isEmpty { + sectionHeader("This Week") + ForEach(viewModel.groupedRecordings.thisWeekRecordings) { recording in + RecordingCard( + recording: recording, + containerWidth: 380, + onViewTap: { + onRecordingSelected(recording) } - - if !viewModel.groupedRecordings.allRecordings.isEmpty { - sectionHeader("All Recaps") - ForEach(viewModel.groupedRecordings.allRecordings) { recording in - RecordingCard( - recording: recording, - containerWidth: 380, - onViewTap: { - onRecordingSelected(recording) - } - ) - .padding(.horizontal, UIConstants.Spacing.contentPadding) - .padding(.bottom, UIConstants.Spacing.cardSpacing) - .transition(.asymmetric( - insertion: .move(edge: .top).combined(with: .opacity), - removal: .move(edge: .leading).combined(with: .opacity) - )) - } + ) + .padding(.horizontal, UIConstants.Spacing.contentPadding) + .padding(.bottom, UIConstants.Spacing.cardSpacing) + .transition( + .asymmetric( + insertion: .move(edge: .top).combined(with: .opacity), + removal: .move(edge: .leading).combined(with: .opacity) + )) + } + + if !viewModel.groupedRecordings.allRecordings.isEmpty { + sectionDivider + } + } + + if !viewModel.groupedRecordings.allRecordings.isEmpty { + sectionHeader("All Recaps") + ForEach(viewModel.groupedRecordings.allRecordings) { recording in + RecordingCard( + recording: recording, + containerWidth: 380, + onViewTap: { + onRecordingSelected(recording) } + ) + .padding(.horizontal, UIConstants.Spacing.contentPadding) + .padding(.bottom, UIConstants.Spacing.cardSpacing) + .transition( + .asymmetric( + insertion: .move(edge: .top).combined(with: .opacity), + removal: .move(edge: .leading).combined(with: .opacity) + )) } + } } - - private func sectionHeader(_ title: String) -> some View { - Text(title) - .font(.system(size: 14, weight: .semibold)) - .foregroundColor(UIConstants.Colors.textTertiary) - .padding(.horizontal, UIConstants.Spacing.contentPadding) - .padding(.bottom, UIConstants.Spacing.gridCellSpacing) - .padding(.all, 6) - } - - private var sectionDivider: some View { - Rectangle() - .fill(UIConstants.Colors.textTertiary.opacity(0.1)) - .frame(height: 1) - .padding(.horizontal, UIConstants.Spacing.cardPadding) - .padding(.vertical, UIConstants.Spacing.gridSpacing) - } - - private var loadingView: some View { - VStack(spacing: 16) { - ProgressView() - .progressViewStyle(CircularProgressViewStyle()) - .scaleEffect(0.8) - - Text("Loading recordings...") - .font(UIConstants.Typography.bodyText) - .foregroundColor(UIConstants.Colors.textSecondary) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 40) + } + + private func sectionHeader(_ title: String) -> some View { + Text(title) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(UIConstants.Colors.textTertiary) + .padding(.horizontal, UIConstants.Spacing.contentPadding) + .padding(.bottom, UIConstants.Spacing.gridCellSpacing) + .padding(.all, 6) + } + + private var sectionDivider: some View { + Rectangle() + .fill(UIConstants.Colors.textTertiary.opacity(0.1)) + .frame(height: 1) + .padding(.horizontal, UIConstants.Spacing.cardPadding) + .padding(.vertical, UIConstants.Spacing.gridSpacing) + } + + private var loadingView: some View { + VStack(spacing: 16) { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + .scaleEffect(0.8) + + Text("Loading recordings...") + .font(UIConstants.Typography.bodyText) + .foregroundColor(UIConstants.Colors.textSecondary) } - - private func errorView(_ message: String) -> some View { - VStack(spacing: 12) { - Image(systemName: "exclamationmark.triangle") - .font(.title2) - .foregroundColor(.orange) - - Text("Error Loading Recordings") - .font(UIConstants.Typography.bodyText) - .foregroundColor(UIConstants.Colors.textPrimary) - - Text(message) - .font(.caption) - .foregroundColor(UIConstants.Colors.textSecondary) - .multilineTextAlignment(.center) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 40) - .padding(.horizontal, UIConstants.Spacing.cardPadding) + .frame(maxWidth: .infinity) + .padding(.vertical, 40) + } + + private func errorView(_ message: String) -> some View { + VStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle") + .font(.title2) + .foregroundColor(.orange) + + Text("Error Loading Recordings") + .font(UIConstants.Typography.bodyText) + .foregroundColor(UIConstants.Colors.textPrimary) + + Text(message) + .font(.caption) + .foregroundColor(UIConstants.Colors.textSecondary) + .multilineTextAlignment(.center) } - - private var emptyStateView: some View { - VStack(spacing: 16) { - Image(systemName: "doc.text") - .font(.title) - .foregroundColor(UIConstants.Colors.textTertiary) - - Text("No Recordings Yet") - .font(UIConstants.Typography.bodyText) - .foregroundColor(UIConstants.Colors.textPrimary) - - Text("Start recording to see your previous recaps here") - .font(.caption) - .foregroundColor(UIConstants.Colors.textSecondary) - .multilineTextAlignment(.center) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 40) - .padding(.horizontal, UIConstants.Spacing.cardPadding) + .frame(maxWidth: .infinity) + .padding(.vertical, 40) + .padding(.horizontal, UIConstants.Spacing.cardPadding) + } + + private var emptyStateView: some View { + VStack(spacing: 16) { + Image(systemName: "doc.text") + .font(.title) + .foregroundColor(UIConstants.Colors.textTertiary) + + Text("No Recordings Yet") + .font(UIConstants.Typography.bodyText) + .foregroundColor(UIConstants.Colors.textPrimary) + + Text("Start recording to see your previous recaps here") + .font(.caption) + .foregroundColor(UIConstants.Colors.textSecondary) + .multilineTextAlignment(.center) } + .frame(maxWidth: .infinity) + .padding(.vertical, 40) + .padding(.horizontal, UIConstants.Spacing.cardPadding) + } } #Preview { - PreviousRecapsDropdown(viewModel: MockPreviousRecapsViewModel(), onRecordingSelected: { _ in }, onClose: {}) + PreviousRecapsDropdown( + viewModel: MockPreviousRecapsViewModel(), onRecordingSelected: { _ in }, onClose: {}) } private class MockPreviousRecapsViewModel: ObservableObject, PreviousRecapsViewModelType { - @Published var groupedRecordings = GroupedRecordings( - todayRecordings: [ - RecordingInfo( - id: "today", - startDate: Date(), - endDate: Calendar.current.date(byAdding: .minute, value: 30, to: Date()), - state: .completed, - errorMessage: nil, - recordingURL: URL(fileURLWithPath: "/tmp/today.m4a"), - microphoneURL: nil, - hasMicrophoneAudio: false, - applicationName: "Teams", - transcriptionText: "Meeting about project updates", - summaryText: "Discussed progress and next steps", - createdAt: Date(), - modifiedAt: Date() - ) - ], - thisWeekRecordings: [ - RecordingInfo( - id: "week", - startDate: Calendar.current.date(byAdding: .day, value: -3, to: Date()) ?? Date(), - endDate: Calendar.current.date(byAdding: .day, value: -3, to: Calendar.current.date(byAdding: .minute, value: 45, to: Date()) ?? Date()), - state: .completed, - errorMessage: nil, - recordingURL: URL(fileURLWithPath: "/tmp/week.m4a"), - microphoneURL: nil, - hasMicrophoneAudio: false, - applicationName: "Teams", - transcriptionText: "Team standup discussion", - summaryText: "Daily standup with team updates", - createdAt: Calendar.current.date(byAdding: .day, value: -3, to: Date()) ?? Date(), - modifiedAt: Calendar.current.date(byAdding: .day, value: -3, to: Date()) ?? Date() - ) - ], - allRecordings: [] - ) - - @Published var isLoading = false - @Published var errorMessage: String? - - func loadRecordings() async {} - func startAutoRefresh() {} - func stopAutoRefresh() {} + @Published var groupedRecordings = GroupedRecordings( + todayRecordings: [ + RecordingInfo( + id: "today", + startDate: Date(), + endDate: Calendar.current.date(byAdding: .minute, value: 30, to: Date()), + state: .completed, + errorMessage: nil, + recordingURL: URL(fileURLWithPath: "/tmp/today.m4a"), + microphoneURL: nil, + hasMicrophoneAudio: false, + applicationName: "Teams", + transcriptionText: "Meeting about project updates", + summaryText: "Discussed progress and next steps", + timestampedTranscription: nil, + createdAt: Date(), + modifiedAt: Date() + ) + ], + thisWeekRecordings: [ + RecordingInfo( + id: "week", + startDate: Calendar.current.date(byAdding: .day, value: -3, to: Date()) ?? Date(), + endDate: Calendar.current.date( + byAdding: .day, value: -3, + to: Calendar.current.date(byAdding: .minute, value: 45, to: Date()) ?? Date()), + state: .completed, + errorMessage: nil, + recordingURL: URL(fileURLWithPath: "/tmp/week.m4a"), + microphoneURL: nil, + hasMicrophoneAudio: false, + applicationName: "Teams", + transcriptionText: "Team standup discussion", + summaryText: "Daily standup with team updates", + timestampedTranscription: nil, + createdAt: Calendar.current.date(byAdding: .day, value: -3, to: Date()) ?? Date(), + modifiedAt: Calendar.current.date(byAdding: .day, value: -3, to: Date()) ?? Date() + ) + ], + allRecordings: [] + ) + + @Published var isLoading = false + @Published var errorMessage: String? + + func loadRecordings() async {} + func startAutoRefresh() {} + func stopAutoRefresh() {} } diff --git a/Recap/UseCases/PreviousRecaps/ViewModel/PreviousRecapsViewModel.swift b/Recap/UseCases/PreviousRecaps/ViewModel/PreviousRecapsViewModel.swift index 062c453..91e438f 100644 --- a/Recap/UseCases/PreviousRecaps/ViewModel/PreviousRecapsViewModel.swift +++ b/Recap/UseCases/PreviousRecaps/ViewModel/PreviousRecapsViewModel.swift @@ -2,93 +2,93 @@ import Foundation import SwiftUI struct GroupedRecordings { - let todayRecordings: [RecordingInfo] - let thisWeekRecordings: [RecordingInfo] - let allRecordings: [RecordingInfo] - - var isEmpty: Bool { - todayRecordings.isEmpty && thisWeekRecordings.isEmpty && allRecordings.isEmpty - } + let todayRecordings: [RecordingInfo] + let thisWeekRecordings: [RecordingInfo] + let allRecordings: [RecordingInfo] + + var isEmpty: Bool { + todayRecordings.isEmpty && thisWeekRecordings.isEmpty && allRecordings.isEmpty + } } @MainActor final class PreviousRecapsViewModel: PreviousRecapsViewModelType { - @Published private(set) var groupedRecordings = GroupedRecordings( - todayRecordings: [], - thisWeekRecordings: [], - allRecordings: [] - ) - @Published private(set) var isLoading = false - @Published private(set) var errorMessage: String? - - private let recordingRepository: RecordingRepositoryType - private var refreshTimer: Timer? - - init(recordingRepository: RecordingRepositoryType) { - self.recordingRepository = recordingRepository - } - - deinit { - Task { @MainActor [weak self] in - self?.stopAutoRefresh() - } - } - - func loadRecordings() async { - do { - let allRecordings = try await recordingRepository.fetchAllRecordings() - withAnimation(.easeInOut(duration: 0.3)) { - groupedRecordings = groupRecordingsByTimePeriod(allRecordings) - } - } catch { - withAnimation(.easeInOut(duration: 0.3)) { - errorMessage = "Failed to load recordings: \(error.localizedDescription)" - } - } + @Published private(set) var groupedRecordings = GroupedRecordings( + todayRecordings: [], + thisWeekRecordings: [], + allRecordings: [] + ) + @Published private(set) var isLoading = false + @Published private(set) var errorMessage: String? + + private let recordingRepository: RecordingRepositoryType + private var refreshTimer: Timer? + + init(recordingRepository: RecordingRepositoryType) { + self.recordingRepository = recordingRepository + } + + deinit { + Task { @MainActor [weak self] in + self?.stopAutoRefresh() } - - private func groupRecordingsByTimePeriod(_ recordings: [RecordingInfo]) -> GroupedRecordings { - let calendar = Calendar.current - let now = Date() - - let todayStart = calendar.startOfDay(for: now) - let weekStart = calendar.dateInterval(of: .weekOfYear, for: now)?.start ?? todayStart - - var todayRecordings: [RecordingInfo] = [] - var thisWeekRecordings: [RecordingInfo] = [] - var allRecordings: [RecordingInfo] = [] - - for recording in recordings { - let recordingDate = recording.createdAt - - if calendar.isDate(recordingDate, inSameDayAs: now) { - todayRecordings.append(recording) - } else if recordingDate >= weekStart && recordingDate < todayStart { - thisWeekRecordings.append(recording) - } else { - allRecordings.append(recording) - } - } - - return GroupedRecordings( - todayRecordings: todayRecordings.sorted { $0.createdAt > $1.createdAt }, - thisWeekRecordings: thisWeekRecordings.sorted { $0.createdAt > $1.createdAt }, - allRecordings: allRecordings.sorted { $0.createdAt > $1.createdAt } - ) + } + + func loadRecordings() async { + do { + let allRecordings = try await recordingRepository.fetchAllRecordings() + withAnimation(.easeInOut(duration: 0.3)) { + groupedRecordings = groupRecordingsByTimePeriod(allRecordings) + } + } catch { + withAnimation(.easeInOut(duration: 0.3)) { + errorMessage = "Failed to load recordings: \(error.localizedDescription)" + } } - - func startAutoRefresh() { - stopAutoRefresh() - - refreshTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in - Task { @MainActor in - await self?.loadRecordings() - } - } + } + + private func groupRecordingsByTimePeriod(_ recordings: [RecordingInfo]) -> GroupedRecordings { + let calendar = Calendar.current + let now = Date() + + let todayStart = calendar.startOfDay(for: now) + let weekStart = calendar.dateInterval(of: .weekOfYear, for: now)?.start ?? todayStart + + var todayRecordings: [RecordingInfo] = [] + var thisWeekRecordings: [RecordingInfo] = [] + var allRecordings: [RecordingInfo] = [] + + for recording in recordings { + let recordingDate = recording.createdAt + + if calendar.isDate(recordingDate, inSameDayAs: now) { + todayRecordings.append(recording) + } else if recordingDate >= weekStart && recordingDate < todayStart { + thisWeekRecordings.append(recording) + } else { + allRecordings.append(recording) + } } - - func stopAutoRefresh() { - refreshTimer?.invalidate() - refreshTimer = nil + + return GroupedRecordings( + todayRecordings: todayRecordings.sorted { $0.createdAt > $1.createdAt }, + thisWeekRecordings: thisWeekRecordings.sorted { $0.createdAt > $1.createdAt }, + allRecordings: allRecordings.sorted { $0.createdAt > $1.createdAt } + ) + } + + func startAutoRefresh() { + stopAutoRefresh() + + refreshTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in + Task { @MainActor in + await self?.loadRecordings() + } } + } + + func stopAutoRefresh() { + refreshTimer?.invalidate() + refreshTimer = nil + } } diff --git a/Recap/UseCases/PreviousRecaps/ViewModel/PreviousRecapsViewModelType.swift b/Recap/UseCases/PreviousRecaps/ViewModel/PreviousRecapsViewModelType.swift index 5cbafdc..4077078 100644 --- a/Recap/UseCases/PreviousRecaps/ViewModel/PreviousRecapsViewModelType.swift +++ b/Recap/UseCases/PreviousRecaps/ViewModel/PreviousRecapsViewModelType.swift @@ -2,11 +2,11 @@ import Foundation @MainActor protocol PreviousRecapsViewModelType: ObservableObject { - var groupedRecordings: GroupedRecordings { get } - var isLoading: Bool { get } - var errorMessage: String? { get } - - func loadRecordings() async - func startAutoRefresh() - func stopAutoRefresh() -} \ No newline at end of file + var groupedRecordings: GroupedRecordings { get } + var isLoading: Bool { get } + var errorMessage: String? { get } + + func loadRecordings() async + func startAutoRefresh() + func stopAutoRefresh() +} diff --git a/Recap/UseCases/Settings/Components/FolderSettingsView.swift b/Recap/UseCases/Settings/Components/FolderSettingsView.swift new file mode 100644 index 0000000..666504e --- /dev/null +++ b/Recap/UseCases/Settings/Components/FolderSettingsView.swift @@ -0,0 +1,150 @@ +import Combine +import SwiftUI + +#if os(macOS) + import AppKit +#endif + +struct FolderSettingsView: View { + @ObservedObject private var viewModel: ViewModel + + init(viewModel: ViewModel) { + self.viewModel = viewModel + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + settingsRow(label: "Storage Location") { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(viewModel.currentFolderPath) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(UIConstants.Colors.textPrimary) + .lineLimit(1) + .truncationMode(.middle) + + Spacer() + + PillButton(text: "Choose Folder") { + openFolderPicker() + } + } + + Text("Recordings and transcriptions will be organized in event-based folders") + .font(.system(size: 10, weight: .regular)) + .foregroundColor(UIConstants.Colors.textSecondary) + } + } + + if let errorMessage = viewModel.errorMessage { + Text(errorMessage) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.red) + .padding(.top, 4) + } + } + } + + private func settingsRow( + label: String, + @ViewBuilder control: () -> Content + ) -> some View { + HStack { + Text(label) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(UIConstants.Colors.textPrimary) + + Spacer() + + control() + } + } + + private func openFolderPicker() { + #if os(macOS) + NSApp.activate(ignoringOtherApps: true) + + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.canCreateDirectories = true + if !viewModel.currentFolderPath.isEmpty { + panel.directoryURL = URL( + fileURLWithPath: viewModel.currentFolderPath, isDirectory: true) + } + panel.prompt = "Choose" + panel.message = "Select a folder where Recap will store recordings and segments." + + if let window = NSApp.keyWindow { + panel.beginSheetModal(for: window) { response in + guard response == .OK, let url = panel.url else { return } + Task { + await viewModel.updateFolderPath(url) + } + } + } else { + panel.begin { response in + guard response == .OK, let url = panel.url else { return } + Task { + await viewModel.updateFolderPath(url) + } + } + } + #endif + } +} + +// MARK: - ViewModel Protocol + +@MainActor +protocol FolderSettingsViewModelType: ObservableObject { + var currentFolderPath: String { get } + var errorMessage: String? { get } + + func updateFolderPath(_ url: URL) async + func setErrorMessage(_ message: String?) +} + +// MARK: - Type Erased Wrapper + +@MainActor +final class AnyFolderSettingsViewModel: FolderSettingsViewModelType { + let objectWillChange = ObservableObjectPublisher() + private let _currentFolderPath: () -> String + private let _errorMessage: () -> String? + private let _updateFolderPath: (URL) async -> Void + private let _setErrorMessage: (String?) -> Void + private var cancellable: AnyCancellable? + + init(_ viewModel: ViewModel) { + self._currentFolderPath = { viewModel.currentFolderPath } + self._errorMessage = { viewModel.errorMessage } + self._updateFolderPath = { await viewModel.updateFolderPath($0) } + self._setErrorMessage = { viewModel.setErrorMessage($0) } + cancellable = viewModel.objectWillChange.sink { [weak self] _ in + self?.objectWillChange.send() + } + } + + var currentFolderPath: String { _currentFolderPath() } + var errorMessage: String? { _errorMessage() } + + func updateFolderPath(_ url: URL) async { + await _updateFolderPath(url) + } + + func setErrorMessage(_ message: String?) { + _setErrorMessage(message) + } +} + +// MARK: - Preview + +#if DEBUG + #Preview { + FolderSettingsView(viewModel: PreviewFolderSettingsViewModel()) + .frame(width: 550, height: 200) + .background(Color.black) + } +#endif diff --git a/Recap/UseCases/Settings/Components/GlobalShortcutSettingsView.swift b/Recap/UseCases/Settings/Components/GlobalShortcutSettingsView.swift new file mode 100644 index 0000000..fdb5639 --- /dev/null +++ b/Recap/UseCases/Settings/Components/GlobalShortcutSettingsView.swift @@ -0,0 +1,165 @@ +import Combine +import SwiftUI + +private let keyCodeMap: [Int32: String] = [ + 0: "A", 1: "S", 2: "D", 3: "F", 4: "H", 5: "G", 6: "Z", 7: "X", + 8: "C", 9: "V", 11: "B", 12: "Q", 13: "W", 14: "E", 15: "R", 16: "Y", + 17: "T", 18: "1", 19: "2", 20: "3", 21: "4", 22: "6", 23: "5", 24: "=", + 25: "9", 26: "7", 27: "-", 28: "8", 29: "0", 30: "]", 31: "O", 32: "U", + 33: "[", 34: "I", 35: "P", 36: "Return", 37: "L", 38: "J", 39: "'", 40: "K", + 41: ";", 42: "\\", 43: ",", 44: "/", 45: "N", 46: "M", 47: ".", 48: "Tab", + 49: "Space", 50: "`", 51: "Delete", 53: "Escape", 123: "Left", 124: "Right", + 125: "Down", 126: "Up" +] + +private let keyEquivalentMap: [Character: Int32] = [ + "a": 0, "b": 11, "c": 8, "d": 2, "e": 14, "f": 3, "g": 5, "h": 4, + "i": 34, "j": 38, "k": 40, "l": 37, "m": 46, "n": 45, "o": 31, "p": 35, + "q": 12, "r": 15, "s": 1, "t": 17, "u": 32, "v": 9, "w": 13, "x": 7, + "y": 16, "z": 6 +] + +struct GlobalShortcutSettingsView: View { + @ObservedObject private var viewModel: ViewModel + @State private var isRecordingShortcut = false + @State private var currentKeyCode: Int32 = 15 + @State private var currentModifiers: Int32 = 1_048_840 + + init(viewModel: ViewModel) { + self.viewModel = viewModel + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Global Shortcut") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(UIConstants.Colors.textPrimary) + + VStack(alignment: .leading, spacing: 8) { + Text("Press the key combination you want to use for starting/stopping recording:") + .font(.system(size: 12)) + .foregroundColor(UIConstants.Colors.textSecondary) + + HStack { + Button { + isRecordingShortcut = true + } label: { + HStack { + Text(shortcutDisplayString) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(UIConstants.Colors.textPrimary) + + Spacer() + + Image(systemName: "keyboard") + .font(.system(size: 12)) + .foregroundColor(UIConstants.Colors.textSecondary) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 6) + .fill( + isRecordingShortcut + ? Color.blue.opacity(0.2) : Color.gray.opacity(0.1) + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke( + isRecordingShortcut ? Color.blue : Color.gray.opacity(0.3), + lineWidth: 1 + ) + ) + } + .buttonStyle(PlainButtonStyle()) + .frame(width: 200) + + if isRecordingShortcut { + Button("Cancel") { + isRecordingShortcut = false + } + .font(.system(size: 12)) + .foregroundColor(UIConstants.Colors.textSecondary) + } + } + + if isRecordingShortcut { + Text("Press any key combination...") + .font(.system(size: 11)) + .foregroundColor(.blue) + } + } + } + .onAppear { + currentKeyCode = viewModel.globalShortcutKeyCode + currentModifiers = viewModel.globalShortcutModifiers + } + .onChange(of: viewModel.globalShortcutKeyCode) { _, newValue in + currentKeyCode = newValue + } + .onChange(of: viewModel.globalShortcutModifiers) { _, newValue in + currentModifiers = newValue + } + .onKeyPress { keyPress in + if isRecordingShortcut { + // Convert KeyEquivalent to key code (simplified mapping) + let keyCode = getKeyCodeFromKeyEquivalent(keyPress.key) + let modifiers = Int32(keyPress.modifiers.rawValue) + + Task { + await viewModel.updateGlobalShortcut(keyCode: keyCode, modifiers: modifiers) + } + + isRecordingShortcut = false + return .handled + } + return .ignored + } + } + + private var shortcutDisplayString: String { + let keyString = getKeyString(for: currentKeyCode) + let modifierString = getModifierString(for: currentModifiers) + return "\(modifierString)\(keyString)" + } + + private func getKeyString(for keyCode: Int32) -> String { + return keyCodeMap[keyCode] ?? "Key\(keyCode)" + } + + private func getKeyCodeFromKeyEquivalent(_ key: KeyEquivalent) -> Int32 { + switch key { + case .space: return 49 + case .tab: return 48 + case .return: return 36 + case .escape: return 53 + case .delete: return 51 + default: + if let char = key.character.lowercased().first, + let keyCode = keyEquivalentMap[char] { + return keyCode + } + return 15 // Default to 'R' + } + } + + private func getModifierString(for modifiers: Int32) -> String { + var result = "" + if (modifiers & Int32(NSEvent.ModifierFlags.command.rawValue)) != 0 { + result += "⌘" + } + if (modifiers & Int32(NSEvent.ModifierFlags.option.rawValue)) != 0 { + result += "⌥" + } + if (modifiers & Int32(NSEvent.ModifierFlags.control.rawValue)) != 0 { + result += "⌃" + } + if (modifiers & Int32(NSEvent.ModifierFlags.shift.rawValue)) != 0 { + result += "⇧" + } + return result + } +} + +// Note: Preview removed due to complex mock requirements diff --git a/Recap/UseCases/Settings/Components/MeetingDetection/MeetingDetectionView.swift b/Recap/UseCases/Settings/Components/MeetingDetection/MeetingDetectionView.swift index 2ffa4d8..34edd14 100644 --- a/Recap/UseCases/Settings/Components/MeetingDetection/MeetingDetectionView.swift +++ b/Recap/UseCases/Settings/Components/MeetingDetection/MeetingDetectionView.swift @@ -1,113 +1,127 @@ import SwiftUI struct MeetingDetectionView: View { - @ObservedObject private var viewModel: ViewModel - - init(viewModel: ViewModel) { - self.viewModel = viewModel - } - - var body: some View { - GeometryReader { geometry in - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 16) { - if viewModel.autoDetectMeetings && !viewModel.hasScreenRecordingPermission { - ActionableWarningCard( - warning: WarningItem( - id: "screen-recording", - title: "Permission Required", - message: "Screen Recording permission needed to detect meeting windows", - icon: "exclamationmark.shield", - severity: .warning - ), - containerWidth: geometry.size.width, - buttonText: "Open System Settings", - buttonAction: { - viewModel.openScreenRecordingPreferences() - }, - footerText: "This permission allows Recap to read window titles only. No screen content is captured or recorded." - ) - .transition(.opacity.combined(with: .move(edge: .top))) + @ObservedObject private var viewModel: ViewModel + + init(viewModel: ViewModel) { + self.viewModel = viewModel + } + + var body: some View { + GeometryReader { geometry in + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 16) { + if viewModel.autoDetectMeetings && !viewModel.hasScreenRecordingPermission { + ActionableWarningCard( + warning: WarningItem( + id: "screen-recording", + title: "Permission Required", + message: + "Screen Recording permission needed to detect meeting windows", + icon: "exclamationmark.shield", + severity: .warning + ), + containerWidth: geometry.size.width, + buttonText: "Open System Settings", + buttonAction: { + viewModel.openScreenRecordingPreferences() + }, + footerText: + "This permission allows Recap to read window titles only. " + + "No screen content is captured or recorded." + ) + .transition(.opacity.combined(with: .move(edge: .top))) + } + + SettingsCard(title: "Meeting Detection") { + VStack(spacing: 16) { + settingsRow( + label: "Auto-detect meetings", + description: + "Get notified in console when Teams, Zoom, or Meet meetings begin" + ) { + Toggle( + "", + isOn: Binding( + get: { viewModel.autoDetectMeetings }, + set: { newValue in + Task { + await viewModel.handleAutoDetectToggle(newValue) + } } - - SettingsCard(title: "Meeting Detection") { - VStack(spacing: 16) { - settingsRow( - label: "Auto-detect meetings", - description: "Get notified in console when Teams, Zoom, or Meet meetings begin" - ) { - Toggle("", isOn: Binding( - get: { viewModel.autoDetectMeetings }, - set: { newValue in - Task { - await viewModel.handleAutoDetectToggle(newValue) - } - } - )) - .toggleStyle(CustomToggleStyle()) - .labelsHidden() - } - - if viewModel.autoDetectMeetings { - VStack(spacing: 12) { - if !viewModel.hasScreenRecordingPermission { - HStack { - Text("Please enable Screen Recording permission above to continue.") - .font(.system(size: 10)) - .foregroundColor(.secondary) - .multilineTextAlignment(.leading) - Spacer() - } - } - } - } - } + ) + ) + .toggleStyle(CustomToggleStyle()) + .labelsHidden() + } + + if viewModel.autoDetectMeetings { + VStack(spacing: 12) { + if !viewModel.hasScreenRecordingPermission { + HStack { + Text( + "Please enable Screen Recording permission above to continue." + ) + .font(.system(size: 10)) + .foregroundColor(.secondary) + .multilineTextAlignment(.leading) + Spacer() } - + } } - .padding(.horizontal, 20) - .padding(.vertical, 20) - .animation(.spring(response: 0.4, dampingFraction: 0.8), value: viewModel.autoDetectMeetings) - .animation(.spring(response: 0.4, dampingFraction: 0.8), value: viewModel.hasScreenRecordingPermission) - } - } - .onAppear { - Task { - await viewModel.checkPermissionStatus() + } } + } + } - .onChange(of: viewModel.autoDetectMeetings) { enabled in - if enabled { - Task { - await viewModel.checkPermissionStatus() - } - } + .padding(.horizontal, 20) + .padding(.vertical, 20) + .animation( + .spring(response: 0.4, dampingFraction: 0.8), + value: viewModel.autoDetectMeetings + ) + .animation( + .spring(response: 0.4, dampingFraction: 0.8), + value: viewModel.hasScreenRecordingPermission) + } + } + .onAppear { + Task { + await viewModel.checkPermissionStatus() + } + } + .onChange(of: viewModel.autoDetectMeetings) { _, enabled in + if enabled { + Task { + await viewModel.checkPermissionStatus() } + } } - - private func settingsRow( - label: String, - description: String? = nil, - @ViewBuilder control: () -> Content - ) -> some View { - HStack(alignment: .center) { - VStack(alignment: .leading, spacing: 2) { - Text(label) - .font(.system(size: 12, weight: .medium)) - .foregroundColor(UIConstants.Colors.textPrimary) - - if let description = description { - Text(description) - .font(.system(size: 10)) - .foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - - Spacer() - - control() + } + + private func settingsRow( + label: String, + description: String? = nil, + @ViewBuilder control: () -> Content + ) -> some View { + HStack(alignment: .center) { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(UIConstants.Colors.textPrimary) + + if let description = description { + Text(description) + .font(.system(size: 10)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) } + } + + Spacer() + + control() } - + } + } diff --git a/Recap/UseCases/Settings/Components/OpenAIAPIKeyAlert.swift b/Recap/UseCases/Settings/Components/OpenAIAPIKeyAlert.swift new file mode 100644 index 0000000..3a3f540 --- /dev/null +++ b/Recap/UseCases/Settings/Components/OpenAIAPIKeyAlert.swift @@ -0,0 +1,174 @@ +import SwiftUI + +struct OpenAIAPIKeyAlert: View { + @Binding var isPresented: Bool + @State private var apiKey: String = "" + @State private var endpoint: String = "https://api.openai.com/v1" + @State private var isLoading: Bool = false + @State private var errorMessage: String? + + let existingKey: String? + let existingEndpoint: String? + let onSave: (String, String) async throws -> Void + + private var isUpdateMode: Bool { + existingKey != nil + } + + private var title: String { + isUpdateMode ? "Update OpenAI Configuration" : "Add OpenAI Configuration" + } + + private var buttonTitle: String { + isUpdateMode ? "Update" : "Save" + } + + var body: some View { + CenteredAlert( + isPresented: $isPresented, + title: title, + onDismiss: {}, + content: { + VStack(alignment: .leading, spacing: 20) { + inputSection + + if let errorMessage = errorMessage { + errorSection(errorMessage) + } + + HStack { + Spacer() + + PillButton( + text: isLoading ? "Saving..." : buttonTitle, + icon: isLoading ? nil : "checkmark" + ) { + Task { + await saveConfiguration() + } + } + } + } + } + ) + .onAppear { + if let existingKey = existingKey { + apiKey = existingKey + } + if let existingEndpoint = existingEndpoint { + endpoint = existingEndpoint + } + } + } + + private var inputSection: some View { + VStack(alignment: .leading, spacing: 12) { + CustomTextField( + label: "API Endpoint", + placeholder: "https://api.openai.com/v1", + text: $endpoint + ) + + Text( + "For Azure OpenAI, use: https://YOUR-RESOURCE.openai.azure.com/openai/deployments/YOUR-DEPLOYMENT" + ) + .font(.system(size: 10, weight: .regular)) + .foregroundColor(UIConstants.Colors.textSecondary) + .multilineTextAlignment(.leading) + .lineLimit(3) + + CustomPasswordField( + label: "API Key", + placeholder: "sk-...", + text: $apiKey + ) + + HStack { + Text( + "Your credentials are stored securely in the system keychain and never leave your device." + ) + .font(.system(size: 11, weight: .regular)) + .foregroundColor(UIConstants.Colors.textSecondary) + .multilineTextAlignment(.leading) + .lineLimit(2) + Spacer() + } + } + } + + private func errorSection(_ message: String) -> some View { + HStack { + Text(message) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.red) + .multilineTextAlignment(.leading) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color.red.opacity(0.1)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color.red.opacity(0.3), lineWidth: 0.5) + ) + ) + } + + private func saveConfiguration() async { + let trimmedKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedEndpoint = endpoint.trimmingCharacters(in: .whitespacesAndNewlines) + + guard !trimmedKey.isEmpty else { + errorMessage = "Please enter an API key" + return + } + + guard !trimmedEndpoint.isEmpty else { + errorMessage = "Please enter an API endpoint" + return + } + + guard let url = URL(string: trimmedEndpoint), url.scheme != nil else { + errorMessage = "Invalid endpoint URL format" + return + } + + isLoading = true + errorMessage = nil + + do { + try await onSave(trimmedKey, trimmedEndpoint) + isPresented = false + } catch { + errorMessage = error.localizedDescription + } + + isLoading = false + } +} + +#Preview { + VStack { + Rectangle() + .fill(Color.gray.opacity(0.3)) + .overlay( + Text("Background Content") + .foregroundColor(.white) + ) + } + .frame(height: 400) + .overlay( + OpenAIAPIKeyAlert( + isPresented: .constant(true), + existingKey: nil, + existingEndpoint: nil, + onSave: { _, _ in + try await Task.sleep(nanoseconds: 1_000_000_000) + } + ) + .frame(height: 400) + ) + .background(Color.black) +} diff --git a/Recap/UseCases/Settings/Components/OpenRouterAPIKeyAlert.swift b/Recap/UseCases/Settings/Components/OpenRouterAPIKeyAlert.swift index 1aa9101..6d3faee 100644 --- a/Recap/UseCases/Settings/Components/OpenRouterAPIKeyAlert.swift +++ b/Recap/UseCases/Settings/Components/OpenRouterAPIKeyAlert.swift @@ -5,56 +5,57 @@ struct OpenRouterAPIKeyAlert: View { @State private var apiKey: String = "" @State private var isLoading: Bool = false @State private var errorMessage: String? - + let existingKey: String? let onSave: (String) async throws -> Void - + private var isUpdateMode: Bool { existingKey != nil } - + private var title: String { isUpdateMode ? "Update OpenRouter API Key" : "Add OpenRouter API Key" } - + private var buttonTitle: String { isUpdateMode ? "Update Key" : "Save Key" } - + var body: some View { CenteredAlert( isPresented: $isPresented, title: title, - onDismiss: {} - ) { - VStack(alignment: .leading, spacing: 20) { - inputSection - - if let errorMessage = errorMessage { - errorSection(errorMessage) - } - - HStack { - Spacer() - - PillButton( - text: isLoading ? "Saving..." : buttonTitle, - icon: isLoading ? nil : "checkmark" - ) { - Task { - await saveAPIKey() + onDismiss: {}, + content: { + VStack(alignment: .leading, spacing: 20) { + inputSection + + if let errorMessage = errorMessage { + errorSection(errorMessage) + } + + HStack { + Spacer() + + PillButton( + text: isLoading ? "Saving..." : buttonTitle, + icon: isLoading ? nil : "checkmark" + ) { + Task { + await saveAPIKey() + } } } } } - } + ) .onAppear { if let existingKey = existingKey { apiKey = existingKey } } } - + private var inputSection: some View { VStack(alignment: .leading, spacing: 12) { CustomPasswordField( @@ -62,18 +63,20 @@ struct OpenRouterAPIKeyAlert: View { placeholder: "sk-or-v1-...", text: $apiKey ) - + HStack { - Text("Your API key is stored securely in the system keychain and never leaves your device.") - .font(.system(size: 11, weight: .regular)) - .foregroundColor(UIConstants.Colors.textSecondary) - .multilineTextAlignment(.leading) - .lineLimit(2) + Text( + "Your API key is stored securely in the system keychain and never leaves your device." + ) + .font(.system(size: 11, weight: .regular)) + .foregroundColor(UIConstants.Colors.textSecondary) + .multilineTextAlignment(.leading) + .lineLimit(2) Spacer() } } } - + private func errorSection(_ message: String) -> some View { HStack { Text(message) @@ -93,31 +96,30 @@ struct OpenRouterAPIKeyAlert: View { ) ) } - - + private func saveAPIKey() async { let trimmedKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) - + guard !trimmedKey.isEmpty else { errorMessage = "Please enter an API key" return } - + guard trimmedKey.hasPrefix("sk-or-") else { errorMessage = "Invalid OpenRouter API key format. Key should start with 'sk-or-'" return } - + isLoading = true errorMessage = nil - + do { try await onSave(trimmedKey) isPresented = false } catch { errorMessage = error.localizedDescription } - + isLoading = false } } @@ -136,11 +138,11 @@ struct OpenRouterAPIKeyAlert: View { OpenRouterAPIKeyAlert( isPresented: .constant(true), existingKey: nil, - onSave: { key in + onSave: { _ in try await Task.sleep(nanoseconds: 1_000_000_000) } ) .frame(height: 300) ) .background(Color.black) -} \ No newline at end of file +} diff --git a/Recap/UseCases/Settings/Components/Reusable/CustomDropdown.swift b/Recap/UseCases/Settings/Components/Reusable/CustomDropdown.swift index ecf3f64..1e4bea0 100644 --- a/Recap/UseCases/Settings/Components/Reusable/CustomDropdown.swift +++ b/Recap/UseCases/Settings/Components/Reusable/CustomDropdown.swift @@ -1,252 +1,259 @@ import SwiftUI struct CustomDropdown: View { - let title: String - let options: [T] - @Binding var selection: T - let displayName: (T) -> String - let showSearch: Bool - - @State private var isExpanded = false - @State private var hoveredOption: T? - @State private var searchText = "" - - private var filteredOptions: [T] { - guard showSearch && !searchText.isEmpty else { return options } - return options.filter { option in - displayName(option).localizedCaseInsensitiveContains(searchText) - } + let title: String + let options: [T] + @Binding var selection: T + let displayName: (T) -> String + let showSearch: Bool + + @State private var isExpanded = false + @State private var hoveredOption: T? + @State private var searchText = "" + + private var filteredOptions: [T] { + guard showSearch && !searchText.isEmpty else { return options } + return options.filter { option in + displayName(option).localizedCaseInsensitiveContains(searchText) } - - init( - title: String, - options: [T], - selection: Binding, - displayName: @escaping (T) -> String, - showSearch: Bool = false - ) { - self.title = title - self.options = options - self._selection = selection - self.displayName = displayName - self.showSearch = showSearch + } + + init( + title: String, + options: [T], + selection: Binding, + displayName: @escaping (T) -> String, + showSearch: Bool = false + ) { + self.title = title + self.options = options + self._selection = selection + self.displayName = displayName + self.showSearch = showSearch + } + + var body: some View { + dropdownButton + .popover(isPresented: $isExpanded, arrowEdge: .bottom) { + dropdownList + .frame(width: 285) + .frame(maxHeight: showSearch ? 350 : 300) + } + .onChange(of: isExpanded) { _, expanded in + if !expanded { + searchText = "" + } + } + } + + private var dropdownButton: some View { + Button { + isExpanded.toggle() + } label: { + HStack { + Text(displayName(selection)) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(UIConstants.Colors.textPrimary) + .lineLimit(1) + + Spacer() + + Image(systemName: "chevron.down") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(UIConstants.Colors.textSecondary) + .rotationEffect(.degrees(isExpanded ? 180 : 0)) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isExpanded) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(hex: "2A2A2A").opacity(0.3)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke( + LinearGradient( + gradient: Gradient(stops: [ + .init( + color: Color(hex: "979797").opacity(0.2), location: 0), + .init( + color: Color(hex: "979797").opacity(0.1), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 0.8 + ) + ) + ) } - - var body: some View { - dropdownButton - .popover(isPresented: $isExpanded, arrowEdge: .bottom) { - dropdownList - .frame(width: 285) - .frame(maxHeight: showSearch ? 350 : 300) - } - .onChange(of: isExpanded) { _, expanded in - if !expanded { - searchText = "" - } - } + .buttonStyle(PlainButtonStyle()) + } + + private var searchField: some View { + HStack { + Image(systemName: "magnifyingglass") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(UIConstants.Colors.textSecondary) + + TextField("Search...", text: $searchText) + .textFieldStyle(PlainTextFieldStyle()) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(UIConstants.Colors.textPrimary) } - - private var dropdownButton: some View { - Button(action: { - isExpanded.toggle() - }) { - HStack { - Text(displayName(selection)) - .font(.system(size: 12, weight: .medium)) - .foregroundColor(UIConstants.Colors.textPrimary) - .lineLimit(1) - - Spacer() - - Image(systemName: "chevron.down") - .font(.system(size: 10, weight: .medium)) - .foregroundColor(UIConstants.Colors.textSecondary) - .rotationEffect(.degrees(isExpanded ? 180 : 0)) - .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isExpanded) - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: 8) - .fill(Color(hex: "2A2A2A").opacity(0.3)) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke( - LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color(hex: "979797").opacity(0.2), location: 0), - .init(color: Color(hex: "979797").opacity(0.1), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ), - lineWidth: 0.8 - ) - ) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(Color(hex: "2A2A2A").opacity(0.5)) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color(hex: "979797").opacity(0.2), lineWidth: 0.5) + ) + ) + } + + private var dropdownList: some View { + ZStack { + RoundedRectangle(cornerRadius: 8) + .fill(Color(hex: "1A1A1A")) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke( + LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color(hex: "979797").opacity(0.3), location: 0), + .init(color: Color(hex: "979797").opacity(0.2), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 0.8 ) - } - .buttonStyle(PlainButtonStyle()) - } - - private var searchField: some View { - HStack { - Image(systemName: "magnifyingglass") - .font(.system(size: 10, weight: .medium)) - .foregroundColor(UIConstants.Colors.textSecondary) - - TextField("Search...", text: $searchText) - .textFieldStyle(PlainTextFieldStyle()) - .font(.system(size: 11, weight: .medium)) - .foregroundColor(UIConstants.Colors.textPrimary) - } - .padding(.horizontal, 8) - .padding(.vertical, 6) - .background( - RoundedRectangle(cornerRadius: 4) - .fill(Color(hex: "2A2A2A").opacity(0.5)) - .overlay( - RoundedRectangle(cornerRadius: 4) - .stroke(Color(hex: "979797").opacity(0.2), lineWidth: 0.5) - ) ) - } - - private var dropdownList: some View { - ZStack { - RoundedRectangle(cornerRadius: 8) - .fill(Color(hex: "1A1A1A")) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke( - LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color(hex: "979797").opacity(0.3), location: 0), - .init(color: Color(hex: "979797").opacity(0.2), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ), - lineWidth: 0.8 - ) - ) - - VStack(spacing: 0) { - if showSearch { - searchField - .padding(.horizontal, 8) - .padding(.top, 16) + + VStack(spacing: 0) { + if showSearch { + searchField + .padding(.horizontal, 8) + .padding(.top, 16) + } + + ScrollView(.vertical, showsIndicators: true) { + VStack(spacing: 0) { + ForEach(filteredOptions, id: \.self) { option in + Button { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + selection = option } - - ScrollView(.vertical, showsIndicators: true) { - VStack(spacing: 0) { - ForEach(filteredOptions, id: \.self) { option in - Button(action: { - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { - selection = option - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - isExpanded = false - } - }) { - HStack { - Text(displayName(option)) - .font(.system(size: 11, weight: .medium)) - .foregroundColor(selection == option ? UIConstants.Colors.textPrimary : UIConstants.Colors.textSecondary) - .lineLimit(1) - - Spacer() - - if selection == option { - Image(systemName: "checkmark") - .font(.system(size: 9, weight: .bold)) - .foregroundColor(UIConstants.Colors.textPrimary) - .transition(.scale.combined(with: .opacity)) - } - } - .padding(.horizontal, 12) - .padding(.vertical, 10) - .frame(maxWidth: .infinity) - .background( - selection == option - ? Color.white.opacity(0.09) - : (hoveredOption == option ? Color.white.opacity(0.01) : Color.clear) - ) - } - .buttonStyle(PlainButtonStyle()) - .onHover { isHovered in - hoveredOption = isHovered ? option : nil - } - - if option != filteredOptions.last { - Divider() - .background(Color(hex: "979797").opacity(0.1)) - } - } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + isExpanded = false } - .padding(.vertical, 8) - .cornerRadius(8) - } - } - - // Gradient overlays - VStack(spacing: 0) { - // Top gradient - LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color(hex: "1A1A1A"), location: 0), - .init(color: Color(hex: "1A1A1A").opacity(0.8), location: 0.3), - .init(color: Color(hex: "1A1A1A").opacity(0), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ) - .frame(height: 20) - .allowsHitTesting(false) - - Spacer() - - // Bottom gradient - LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color(hex: "1A1A1A").opacity(0), location: 0), - .init(color: Color(hex: "1A1A1A").opacity(0.8), location: 0.7), - .init(color: Color(hex: "1A1A1A"), location: 1) - ]), - startPoint: .top, - endPoint: .bottom + } label: { + HStack { + Text(displayName(option)) + .font(.system(size: 11, weight: .medium)) + .foregroundColor( + selection == option + ? UIConstants.Colors.textPrimary + : UIConstants.Colors.textSecondary + ) + .lineLimit(1) + + Spacer() + + if selection == option { + Image(systemName: "checkmark") + .font(.system(size: 9, weight: .bold)) + .foregroundColor(UIConstants.Colors.textPrimary) + .transition(.scale.combined(with: .opacity)) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: .infinity) + .background( + selection == option + ? Color.white.opacity(0.09) + : (hoveredOption == option + ? Color.white.opacity(0.01) : Color.clear) ) - .frame(height: 20) - .allowsHitTesting(false) + } + .buttonStyle(PlainButtonStyle()) + .onHover { isHovered in + hoveredOption = isHovered ? option : nil + } + + if option != filteredOptions.last { + Divider() + .background(Color(hex: "979797").opacity(0.1)) + } } - .cornerRadius(8) + } + .padding(.vertical, 8) + .cornerRadius(8) } - .padding(8) - } -} + } -#Preview { - VStack(spacing: 40) { - CustomDropdown( - title: "Language", - options: ["English", "Spanish", "French", "German"], - selection: .constant("English"), - displayName: { $0 } + // Gradient overlays + VStack(spacing: 0) { + // Top gradient + LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color(hex: "1A1A1A"), location: 0), + .init(color: Color(hex: "1A1A1A").opacity(0.8), location: 0.3), + .init(color: Color(hex: "1A1A1A").opacity(0), location: 1) + ]), + startPoint: .top, + endPoint: .bottom ) - .frame(width: 285) - - CustomDropdown( - title: "Numbers", - options: Array(1...20).map { "Option \($0)" }, - selection: .constant("Option 1"), - displayName: { $0 }, - showSearch: true + .frame(height: 20) + .allowsHitTesting(false) + + Spacer() + + // Bottom gradient + LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color(hex: "1A1A1A").opacity(0), location: 0), + .init(color: Color(hex: "1A1A1A").opacity(0.8), location: 0.7), + .init(color: Color(hex: "1A1A1A"), location: 1) + ]), + startPoint: .top, + endPoint: .bottom ) - .frame(width: 285) - - Text("This text should not move") - .foregroundColor(.white) + .frame(height: 20) + .allowsHitTesting(false) + } + .cornerRadius(8) } - .frame(width: 400, height: 500) - .padding(40) - .background(Color.black) + .padding(8) + } +} + +#Preview { + VStack(spacing: 40) { + CustomDropdown( + title: "Language", + options: ["English", "Spanish", "French", "German"], + selection: .constant("English"), + displayName: { $0 } + ) + .frame(width: 285) + + CustomDropdown( + title: "Numbers", + options: Array(1...20).map { "Option \($0)" }, + selection: .constant("Option 1"), + displayName: { $0 }, + showSearch: true + ) + .frame(width: 285) + + Text("This text should not move") + .foregroundColor(.white) + } + .frame(width: 400, height: 500) + .padding(40) + .background(Color.black) } diff --git a/Recap/UseCases/Settings/Components/Reusable/CustomPasswordField.swift b/Recap/UseCases/Settings/Components/Reusable/CustomPasswordField.swift index 9cb7039..0d945eb 100644 --- a/Recap/UseCases/Settings/Components/Reusable/CustomPasswordField.swift +++ b/Recap/UseCases/Settings/Components/Reusable/CustomPasswordField.swift @@ -1,101 +1,109 @@ import SwiftUI struct CustomPasswordField: View { - let label: String - let placeholder: String - @Binding var text: String - @State private var isSecure: Bool = true - @FocusState private var isFocused: Bool - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text(label) - .font(.system(size: 12, weight: .medium)) - .foregroundColor(UIConstants.Colors.textPrimary) - .multilineTextAlignment(.leading) - Spacer() - } - - HStack(spacing: 12) { - Group { - if isSecure { - SecureField(placeholder, text: $text) - .focused($isFocused) - } else { - TextField(placeholder, text: $text) - .focused($isFocused) - } - } - .font(.system(size: 12, weight: .regular)) - .foregroundColor(UIConstants.Colors.textPrimary) - .textFieldStyle(PlainTextFieldStyle()) - .multilineTextAlignment(.leading) - .padding(.horizontal, 12) - .padding(.vertical, 10) - .background( - RoundedRectangle(cornerRadius: 8) - .fill( - LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color(hex: "2A2A2A").opacity(0.3), location: 0), - .init(color: Color(hex: "1A1A1A").opacity(0.5), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ) - ) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke( - isFocused - ? LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color(hex: "979797").opacity(0.4), location: 0), - .init(color: Color(hex: "C4C4C4").opacity(0.3), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ) - : LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color(hex: "979797").opacity(0.2), location: 0), - .init(color: Color(hex: "C4C4C4").opacity(0.15), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ), - lineWidth: 1 - ) - ) + let label: String + let placeholder: String + @Binding var text: String + @State private var isSecure: Bool = true + @FocusState private var isFocused: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(label) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(UIConstants.Colors.textPrimary) + .multilineTextAlignment(.leading) + Spacer() + } + + HStack(spacing: 12) { + Group { + if isSecure { + SecureField(placeholder, text: $text) + .focused($isFocused) + } else { + TextField(placeholder, text: $text) + .focused($isFocused) + } + } + .font(.system(size: 12, weight: .regular)) + .foregroundColor(UIConstants.Colors.textPrimary) + .textFieldStyle(PlainTextFieldStyle()) + .multilineTextAlignment(.leading) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 8) + .fill( + LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color(hex: "2A2A2A").opacity(0.3), location: 0), + .init(color: Color(hex: "1A1A1A").opacity(0.5), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke( + isFocused + ? LinearGradient( + gradient: Gradient(stops: [ + .init( + color: Color(hex: "979797").opacity(0.4), + location: 0), + .init( + color: Color(hex: "C4C4C4").opacity(0.3), + location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ) + : LinearGradient( + gradient: Gradient(stops: [ + .init( + color: Color(hex: "979797").opacity(0.2), + location: 0), + .init( + color: Color(hex: "C4C4C4").opacity(0.15), + location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 1 ) - - PillButton( - text: isSecure ? "Show" : "Hide", - icon: isSecure ? "eye.slash" : "eye" - ) { - isSecure.toggle() - } - .padding(.trailing, 4) - } + ) + ) + + PillButton( + text: isSecure ? "Show" : "Hide", + icon: isSecure ? "eye.slash" : "eye" + ) { + isSecure.toggle() } + .padding(.trailing, 4) + } } + } } #Preview { - VStack(spacing: 20) { - CustomPasswordField( - label: "API Key", - placeholder: "Enter your API key", - text: .constant("sk-or-v1-abcdef123456789") - ) - - CustomPasswordField( - label: "Empty Field", - placeholder: "Enter password", - text: .constant("") - ) - } - .padding(40) - .background(Color.black) -} \ No newline at end of file + VStack(spacing: 20) { + CustomPasswordField( + label: "API Key", + placeholder: "Enter your API key", + text: .constant("sk-or-v1-abcdef123456789") + ) + + CustomPasswordField( + label: "Empty Field", + placeholder: "Enter password", + text: .constant("") + ) + } + .padding(40) + .background(Color.black) +} diff --git a/Recap/UseCases/Settings/Components/Reusable/CustomSegmentedControl.swift b/Recap/UseCases/Settings/Components/Reusable/CustomSegmentedControl.swift index 4a5bb45..223d547 100644 --- a/Recap/UseCases/Settings/Components/Reusable/CustomSegmentedControl.swift +++ b/Recap/UseCases/Settings/Components/Reusable/CustomSegmentedControl.swift @@ -1,123 +1,129 @@ import SwiftUI struct CustomSegmentedControl: View { - let options: [T] - @Binding var selection: T - let displayName: (T) -> String - let onSelectionChange: ((T) -> Void)? - - init( - options: [T], - selection: Binding, - displayName: @escaping (T) -> String, - onSelectionChange: ((T) -> Void)? = nil - ) { - self.options = options - self._selection = selection - self.displayName = displayName - self.onSelectionChange = onSelectionChange - } - - var body: some View { - HStack(spacing: 0) { - ForEach(Array(options.enumerated()), id: \.element) { index, option in - Button(action: { - withAnimation(.spring(response: 0.4, dampingFraction: 0.75)) { - selection = option - } - onSelectionChange?(option) - }) { - Text(displayName(option)) - .font(.system(size: 12, weight: .medium)) - .foregroundColor( - selection == option - ? UIConstants.Colors.textPrimary - : UIConstants.Colors.textSecondary - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(.vertical, 6) - .padding(.horizontal, 12) - .contentShape(Rectangle()) - .background( - selection == option - ? LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color(hex: "4A4A4A").opacity(0.4), location: 0), - .init(color: Color(hex: "2A2A2A").opacity(0.6), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ) - : LinearGradient( - gradient: Gradient(colors: [Color.clear]), - startPoint: .top, - endPoint: .bottom - ) - ) - .overlay( - selection == option - ? RoundedRectangle(cornerRadius: 6) - .stroke( - LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color(hex: "979797").opacity(0.3), location: 0), - .init(color: Color(hex: "979797").opacity(0.2), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ), - lineWidth: 0.8 - ) - : nil - ) - .clipShape(RoundedRectangle(cornerRadius: 6)) - .animation(.spring(response: 0.4, dampingFraction: 0.75), value: selection) - } - .buttonStyle(PlainButtonStyle()) - } - } - .padding(4) - .background( - RoundedRectangle(cornerRadius: 8) - .fill(Color(hex: "1A1A1A").opacity(0.6)) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke( - LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color(hex: "979797").opacity(0.2), location: 0), - .init(color: Color(hex: "979797").opacity(0.1), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ), - lineWidth: 0.8 - ) + let options: [T] + @Binding var selection: T + let displayName: (T) -> String + let onSelectionChange: ((T) -> Void)? + + init( + options: [T], + selection: Binding, + displayName: @escaping (T) -> String, + onSelectionChange: ((T) -> Void)? = nil + ) { + self.options = options + self._selection = selection + self.displayName = displayName + self.onSelectionChange = onSelectionChange + } + + var body: some View { + HStack(spacing: 0) { + ForEach(Array(options.enumerated()), id: \.element) { _, option in + Button { + withAnimation(.spring(response: 0.4, dampingFraction: 0.75)) { + selection = option + } + onSelectionChange?(option) + } label: { + Text(displayName(option)) + .font(.system(size: 12, weight: .medium)) + .foregroundColor( + selection == option + ? UIConstants.Colors.textPrimary + : UIConstants.Colors.textSecondary + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.vertical, 6) + .padding(.horizontal, 12) + .contentShape(Rectangle()) + .background( + selection == option + ? LinearGradient( + gradient: Gradient(stops: [ + .init( + color: Color(hex: "4A4A4A").opacity(0.4), location: 0), + .init( + color: Color(hex: "2A2A2A").opacity(0.6), location: 1) + ]), + startPoint: .top, + endPoint: .bottom ) - ) + : LinearGradient( + gradient: Gradient(colors: [Color.clear]), + startPoint: .top, + endPoint: .bottom + ) + ) + .overlay( + selection == option + ? RoundedRectangle(cornerRadius: 6) + .stroke( + LinearGradient( + gradient: Gradient(stops: [ + .init( + color: Color(hex: "979797").opacity(0.3), + location: 0), + .init( + color: Color(hex: "979797").opacity(0.2), + location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 0.8 + ) + : nil + ) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .animation(.spring(response: 0.4, dampingFraction: 0.75), value: selection) + } + .buttonStyle(PlainButtonStyle()) + } } + .padding(4) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(hex: "1A1A1A").opacity(0.6)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke( + LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color(hex: "979797").opacity(0.2), location: 0), + .init(color: Color(hex: "979797").opacity(0.1), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 0.8 + ) + ) + ) + } } #Preview { - VStack(spacing: 30) { - CustomSegmentedControl( - options: ["Local", "Cloud"], - selection: .constant("Local"), - displayName: { $0 } - ) - .frame(width: 285) - - CustomSegmentedControl( - options: ["Option A", "Option B"], - selection: .constant("Option B"), - displayName: { $0 } - ) - .frame(width: 260) - - Text("This text should not move") - .foregroundColor(.white) - } - .frame(width: 400, height: 300) - .padding(40) - .background(Color.black) -} \ No newline at end of file + VStack(spacing: 30) { + CustomSegmentedControl( + options: ["Local", "Cloud"], + selection: .constant("Local"), + displayName: { $0 } + ) + .frame(width: 285) + + CustomSegmentedControl( + options: ["Option A", "Option B"], + selection: .constant("Option B"), + displayName: { $0 } + ) + .frame(width: 260) + + Text("This text should not move") + .foregroundColor(.white) + } + .frame(width: 400, height: 300) + .padding(40) + .background(Color.black) +} diff --git a/Recap/UseCases/Settings/Components/Reusable/CustomTextEditor.swift b/Recap/UseCases/Settings/Components/Reusable/CustomTextEditor.swift index 388152c..a7f3022 100644 --- a/Recap/UseCases/Settings/Components/Reusable/CustomTextEditor.swift +++ b/Recap/UseCases/Settings/Components/Reusable/CustomTextEditor.swift @@ -1,97 +1,101 @@ import SwiftUI struct CustomTextEditor: View { - let title: String - let textBinding: Binding - let placeholder: String - let height: CGFloat - - @State private var isEditing = false - @FocusState private var isFocused: Bool - - init( - title: String, - text: Binding, - placeholder: String = "", - height: CGFloat = 100 - ) { - self.title = title - self.textBinding = text - self.placeholder = placeholder - self.height = height - } - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - Text(title) - .font(.system(size: 11, weight: .medium)) - .foregroundColor(UIConstants.Colors.textSecondary) - - ZStack(alignment: .topLeading) { - RoundedRectangle(cornerRadius: 8) - .fill(Color(hex: "2A2A2A").opacity(0.3)) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke( - LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color(hex: "979797").opacity(isFocused ? 0.4 : 0.2), location: 0), - .init(color: Color(hex: "979797").opacity(isFocused ? 0.3 : 0.1), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ), - lineWidth: 0.8 - ) - ) - .frame(height: height) - - if textBinding.wrappedValue.isEmpty && !isFocused { - Text(placeholder) - .font(.system(size: 12, weight: .medium)) - .foregroundColor(UIConstants.Colors.textSecondary.opacity(0.6)) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .allowsHitTesting(false) - } - - TextEditor(text: textBinding) - .font(.system(size: 12, weight: .medium)) - .foregroundColor(UIConstants.Colors.textPrimary) - .background(Color.clear) - .scrollContentBackground(.hidden) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .focused($isFocused) - .lineLimit(nil) - .textSelection(.enabled) - .onChange(of: isFocused) { _, focused in - withAnimation(.easeInOut(duration: 0.2)) { - isEditing = focused - } - } - } + let title: String + let textBinding: Binding + let placeholder: String + let height: CGFloat + + @State private var isEditing = false + @FocusState private var isFocused: Bool + + init( + title: String, + text: Binding, + placeholder: String = "", + height: CGFloat = 100 + ) { + self.title = title + self.textBinding = text + self.placeholder = placeholder + self.height = height + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(UIConstants.Colors.textSecondary) + + ZStack(alignment: .topLeading) { + RoundedRectangle(cornerRadius: 8) + .fill(Color(hex: "2A2A2A").opacity(0.3)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke( + LinearGradient( + gradient: Gradient(stops: [ + .init( + color: Color(hex: "979797").opacity( + isFocused ? 0.4 : 0.2), location: 0), + .init( + color: Color(hex: "979797").opacity( + isFocused ? 0.3 : 0.1), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 0.8 + ) + ) + .frame(height: height) + + if textBinding.wrappedValue.isEmpty && !isFocused { + Text(placeholder) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(UIConstants.Colors.textSecondary.opacity(0.6)) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .allowsHitTesting(false) } + + TextEditor(text: textBinding) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(UIConstants.Colors.textPrimary) + .background(Color.clear) + .scrollContentBackground(.hidden) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .focused($isFocused) + .lineLimit(nil) + .textSelection(.enabled) + .onChange(of: isFocused) { _, focused in + withAnimation(.easeInOut(duration: 0.2)) { + isEditing = focused + } + } + } } + } } #Preview { - VStack(spacing: 20) { - CustomTextEditor( - title: "Custom Prompt", - text: .constant(""), - placeholder: "Enter your custom prompt template here...", - height: 120 - ) - - CustomTextEditor( - title: "With Content", - text: .constant(UserPreferencesInfo.defaultPromptTemplate), - placeholder: "Enter text...", - height: 80 - ) - } - .frame(width: 400, height: 300) - .padding(20) - .background(Color.black) + VStack(spacing: 20) { + CustomTextEditor( + title: "Custom Prompt", + text: .constant(""), + placeholder: "Enter your custom prompt template here...", + height: 120 + ) + + CustomTextEditor( + title: "With Content", + text: .constant(UserPreferencesInfo.defaultPromptTemplate), + placeholder: "Enter text...", + height: 80 + ) + } + .frame(width: 400, height: 300) + .padding(20) + .background(Color.black) } diff --git a/Recap/UseCases/Settings/Components/Reusable/CustomTextField.swift b/Recap/UseCases/Settings/Components/Reusable/CustomTextField.swift new file mode 100644 index 0000000..98c3c64 --- /dev/null +++ b/Recap/UseCases/Settings/Components/Reusable/CustomTextField.swift @@ -0,0 +1,91 @@ +import SwiftUI + +struct CustomTextField: View { + let label: String + let placeholder: String + @Binding var text: String + @FocusState private var isFocused: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(label) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(UIConstants.Colors.textPrimary) + .multilineTextAlignment(.leading) + Spacer() + } + + TextField(placeholder, text: $text) + .focused($isFocused) + .font(.system(size: 12, weight: .regular)) + .foregroundColor(UIConstants.Colors.textPrimary) + .textFieldStyle(PlainTextFieldStyle()) + .multilineTextAlignment(.leading) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 8) + .fill( + LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color(hex: "2A2A2A").opacity(0.3), location: 0), + .init(color: Color(hex: "1A1A1A").opacity(0.5), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke( + isFocused + ? LinearGradient( + gradient: Gradient(stops: [ + .init( + color: Color(hex: "979797").opacity(0.4), + location: 0), + .init( + color: Color(hex: "C4C4C4").opacity(0.3), + location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ) + : LinearGradient( + gradient: Gradient(stops: [ + .init( + color: Color(hex: "979797").opacity(0.2), + location: 0), + .init( + color: Color(hex: "C4C4C4").opacity(0.15), + location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 1 + ) + ) + ) + } + } +} + +#Preview { + VStack(spacing: 20) { + CustomTextField( + label: "API Endpoint", + placeholder: "https://api.openai.com/v1", + text: .constant("https://api.openai.com/v1") + ) + + CustomTextField( + label: "Empty Field", + placeholder: "Enter value", + text: .constant("") + ) + } + .padding(40) + .background(Color.black) +} diff --git a/Recap/UseCases/Settings/Components/Reusable/CustomToggle.swift b/Recap/UseCases/Settings/Components/Reusable/CustomToggle.swift index 8b7132b..143f113 100644 --- a/Recap/UseCases/Settings/Components/Reusable/CustomToggle.swift +++ b/Recap/UseCases/Settings/Components/Reusable/CustomToggle.swift @@ -1,85 +1,85 @@ import SwiftUI struct CustomToggle: View { - @Binding var isOn: Bool - let label: String - - var body: some View { - HStack { - Text(label) - .font(.system(size: 12, weight: .medium)) - .foregroundColor(UIConstants.Colors.textPrimary) - - Spacer() - - Toggle("", isOn: $isOn) - .toggleStyle(CustomToggleStyle()) - .labelsHidden() - } + @Binding var isOn: Bool + let label: String + + var body: some View { + HStack { + Text(label) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(UIConstants.Colors.textPrimary) + + Spacer() + + Toggle("", isOn: $isOn) + .toggleStyle(CustomToggleStyle()) + .labelsHidden() } + } } struct CustomToggleStyle: ToggleStyle { - func makeBody(configuration: Configuration) -> some View { - Button(action: { - withAnimation(.easeInOut(duration: 0.2)) { - configuration.isOn.toggle() - } - }) { - RoundedRectangle(cornerRadius: 16) - .fill( - configuration.isOn - ? LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color(hex: "4A4A4A").opacity(0.4), location: 0), - .init(color: Color(hex: "2A2A2A").opacity(0.6), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ) - : LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color(hex: "3A3A3A"), location: 0), - .init(color: Color(hex: "2A2A2A"), location: 1) - ]), - startPoint: .leading, - endPoint: .trailing - ) - ) - .overlay( - RoundedRectangle(cornerRadius: 16) - .stroke( - LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color(hex: "979797").opacity(0.3), location: 0), - .init(color: Color(hex: "979797").opacity(0.2), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ), - lineWidth: 0.5 - ) - ) - .frame(width: 48, height: 28) - .overlay( - Circle() - .fill(Color.white) - .frame(width: 24, height: 24) - .shadow(color: .black.opacity(0.2), radius: 2, x: 0, y: 1) - .offset(x: configuration.isOn ? 10 : -10) - .animation(.easeInOut(duration: 0.2), value: configuration.isOn) - ) - } - .buttonStyle(PlainButtonStyle()) + func makeBody(configuration: Configuration) -> some View { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + configuration.isOn.toggle() + } + } label: { + RoundedRectangle(cornerRadius: 16) + .fill( + configuration.isOn + ? LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color(hex: "4A4A4A").opacity(0.4), location: 0), + .init(color: Color(hex: "2A2A2A").opacity(0.6), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ) + : LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color(hex: "3A3A3A"), location: 0), + .init(color: Color(hex: "2A2A2A"), location: 1) + ]), + startPoint: .leading, + endPoint: .trailing + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke( + LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color(hex: "979797").opacity(0.3), location: 0), + .init(color: Color(hex: "979797").opacity(0.2), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 0.5 + ) + ) + .frame(width: 48, height: 28) + .overlay( + Circle() + .fill(Color.white) + .frame(width: 24, height: 24) + .shadow(color: .black.opacity(0.2), radius: 2, x: 0, y: 1) + .offset(x: configuration.isOn ? 10 : -10) + .animation(.easeInOut(duration: 0.2), value: configuration.isOn) + ) } + .buttonStyle(PlainButtonStyle()) + } } #Preview { - VStack(spacing: 20) { - CustomToggle(isOn: .constant(true), label: "Enable Notifications") - CustomToggle(isOn: .constant(false), label: "Auto-start on login") - CustomToggle(isOn: .constant(true), label: "Show in menu bar") - } - .padding(40) - .background(Color.black) -} \ No newline at end of file + VStack(spacing: 20) { + CustomToggle(isOn: .constant(true), label: "Enable Notifications") + CustomToggle(isOn: .constant(false), label: "Auto-start on login") + CustomToggle(isOn: .constant(true), label: "Show in menu bar") + } + .padding(40) + .background(Color.black) +} diff --git a/Recap/UseCases/Settings/Components/SettingsCard.swift b/Recap/UseCases/Settings/Components/SettingsCard.swift index 1303a7a..ac48e9a 100644 --- a/Recap/UseCases/Settings/Components/SettingsCard.swift +++ b/Recap/UseCases/Settings/Components/SettingsCard.swift @@ -1,70 +1,70 @@ import SwiftUI struct SettingsCard: View { - let title: String - @ViewBuilder let content: Content - - var body: some View { - let cardBackground = LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color(hex: "232222").opacity(0.2), location: 0), - .init(color: Color(hex: "0F0F0F").opacity(0.3), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ) - - let cardBorder = LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color(hex: "979797").opacity(0.05), location: 0), - .init(color: Color(hex: "C4C4C4").opacity(0.1), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ) - - VStack(alignment: .leading, spacing: 12) { - Text(title) - .font(.system(size: 14, weight: .bold)) - .foregroundColor(UIConstants.Colors.textPrimary) - - content - } - .padding(20) - .background( - RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius) - .fill(cardBackground) - .overlay( - RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius) - .stroke(cardBorder, lineWidth: UIConstants.Sizing.borderWidth) - ) - ) + let title: String + @ViewBuilder let content: Content + + var body: some View { + let cardBackground = LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color(hex: "232222").opacity(0.2), location: 0), + .init(color: Color(hex: "0F0F0F").opacity(0.3), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ) + + let cardBorder = LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color(hex: "979797").opacity(0.05), location: 0), + .init(color: Color(hex: "C4C4C4").opacity(0.1), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ) + + VStack(alignment: .leading, spacing: 12) { + Text(title) + .font(.system(size: 14, weight: .bold)) + .foregroundColor(UIConstants.Colors.textPrimary) + + content } + .padding(20) + .background( + RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius) + .fill(cardBackground) + .overlay( + RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius) + .stroke(cardBorder, lineWidth: UIConstants.Sizing.borderWidth) + ) + ) + } } #Preview { - VStack(spacing: 16) { - SettingsCard(title: "Model Selection") { - VStack(spacing: 16) { - HStack { - Text("Provider") - .font(.system(size: 12, weight: .medium)) - .foregroundColor(UIConstants.Colors.textPrimary) - Spacer() - Text("Local") - .font(.system(size: 12, weight: .medium)) - .foregroundColor(UIConstants.Colors.textSecondary) - } - } - } - - SettingsCard(title: "Recording Settings") { - VStack(spacing: 16) { - CustomToggle(isOn: .constant(true), label: "Auto Detect Meetings") - CustomToggle(isOn: .constant(false), label: "Auto Stop Recording") - } + VStack(spacing: 16) { + SettingsCard(title: "Model Selection") { + VStack(spacing: 16) { + HStack { + Text("Provider") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(UIConstants.Colors.textPrimary) + Spacer() + Text("Local") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(UIConstants.Colors.textSecondary) } + } } - .padding(20) - .background(Color.black) + + SettingsCard(title: "Recording Settings") { + VStack(spacing: 16) { + CustomToggle(isOn: .constant(true), label: "Auto Detect Meetings") + CustomToggle(isOn: .constant(false), label: "Auto Stop Recording") + } + } + } + .padding(20) + .background(Color.black) } diff --git a/Recap/UseCases/Settings/Components/TabViews/GeneralSettingsView+Helpers.swift b/Recap/UseCases/Settings/Components/TabViews/GeneralSettingsView+Helpers.swift new file mode 100644 index 0000000..e3a2c5a --- /dev/null +++ b/Recap/UseCases/Settings/Components/TabViews/GeneralSettingsView+Helpers.swift @@ -0,0 +1,169 @@ +import SwiftUI + +extension GeneralSettingsView { + func settingsRow( + label: String, + @ViewBuilder control: () -> Content + ) -> some View { + HStack { + Text(label) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(UIConstants.Colors.textPrimary) + + Spacer() + + control() + } + } + + @ViewBuilder + func modelSelectionContent() -> some View { + if viewModel.isLoading { + loadingModelsView + } else if viewModel.hasModels { + modelDropdownView + } else { + manualModelInputView + } + } + + var loadingModelsView: some View { + HStack { + ProgressView() + .scaleEffect(0.5) + Text("Loading models...") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(UIConstants.Colors.textSecondary) + } + } + + @ViewBuilder + var modelDropdownView: some View { + settingsRow(label: "Summarizer Model") { + if let currentSelection = viewModel.currentSelection { + CustomDropdown( + title: "Model", + options: viewModel.availableModels, + selection: Binding( + get: { currentSelection }, + set: { newModel in + Task { + await viewModel.selectModel(newModel) + } + } + ), + displayName: { $0.name }, + showSearch: true + ) + .frame(width: 285) + } else { + HStack { + ProgressView() + .scaleEffect(0.5) + Text("Setting up...") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(UIConstants.Colors.textSecondary) + } + } + } + } + + var manualModelInputView: some View { + settingsRow(label: "Model Name") { + TextField("gpt-4o", text: viewModel.manualModelName) + .font(.system(size: 12, weight: .regular)) + .foregroundColor(UIConstants.Colors.textPrimary) + .textFieldStyle(PlainTextFieldStyle()) + .frame(width: 285) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill( + LinearGradient( + gradient: Gradient(stops: [ + .init( + color: Color(hex: "2A2A2A").opacity( + 0.3), location: 0), + .init( + color: Color(hex: "1A1A1A").opacity( + 0.5), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke( + LinearGradient( + gradient: Gradient(stops: [ + .init( + color: Color(hex: "979797") + .opacity(0.2), + location: 0), + .init( + color: Color(hex: "C4C4C4") + .opacity(0.15), + location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 1 + ) + ) + ) + } + } + + @ViewBuilder + func apiKeyAlertOverlay() -> some View { + Group { + if viewModel.showAPIKeyAlert { + ZStack { + Color.black.opacity(0.3) + .ignoresSafeArea() + .transition(.opacity) + + OpenRouterAPIKeyAlert( + isPresented: Binding( + get: { viewModel.showAPIKeyAlert }, + set: { _ in viewModel.dismissAPIKeyAlert() } + ), + existingKey: viewModel.existingAPIKey, + onSave: { apiKey in + try await viewModel.saveAPIKey(apiKey) + } + ) + .transition(.scale(scale: 0.8).combined(with: .opacity)) + } + } + + if viewModel.showOpenAIAlert { + ZStack { + Color.black.opacity(0.3) + .ignoresSafeArea() + .transition(.opacity) + + OpenAIAPIKeyAlert( + isPresented: Binding( + get: { viewModel.showOpenAIAlert }, + set: { _ in viewModel.dismissOpenAIAlert() } + ), + existingKey: viewModel.existingOpenAIKey, + existingEndpoint: viewModel.existingOpenAIEndpoint, + onSave: { apiKey, endpoint in + try await viewModel.saveOpenAIConfiguration( + apiKey: apiKey, endpoint: endpoint) + } + ) + .transition(.scale(scale: 0.8).combined(with: .opacity)) + } + } + } + .animation( + .spring(response: 0.4, dampingFraction: 0.8), + value: viewModel.showAPIKeyAlert || viewModel.showOpenAIAlert) + } +} diff --git a/Recap/UseCases/Settings/Components/TabViews/GeneralSettingsView+Preview.swift b/Recap/UseCases/Settings/Components/TabViews/GeneralSettingsView+Preview.swift new file mode 100644 index 0000000..fa17b98 --- /dev/null +++ b/Recap/UseCases/Settings/Components/TabViews/GeneralSettingsView+Preview.swift @@ -0,0 +1,124 @@ +import Combine +import SwiftUI + +#if DEBUG + final class PreviewGeneralSettingsViewModel: GeneralSettingsViewModelType { + init() { + // Preview initializer - no setup needed + } + + func updateCustomPromptTemplate(_ template: String) async {} + + func resetToDefaultPrompt() async {} + + var customPromptTemplate: Binding { + .constant(UserPreferencesInfo.defaultPromptTemplate) + } + + @Published var availableModels: [LLMModelInfo] = [ + LLMModelInfo(name: "llama3.2", provider: "ollama"), + LLMModelInfo(name: "codellama", provider: "ollama") + ] + @Published var selectedModel: LLMModelInfo? + @Published var selectedProvider: LLMProvider = .ollama + @Published var autoDetectMeetings: Bool = true + @Published var isAutoStopRecording: Bool = false + @Published var isAutoSummarizeEnabled: Bool = true + @Published var isAutoTranscribeEnabled: Bool = true + @Published var isLoading = false + @Published var errorMessage: String? + @Published var showToast = false + @Published var toastMessage = "" + @Published var showAPIKeyAlert = false + @Published var existingAPIKey: String? + @Published var showOpenAIAlert = false + @Published var existingOpenAIKey: String? + @Published var existingOpenAIEndpoint: String? + @Published var globalShortcutKeyCode: Int32 = 15 + @Published var globalShortcutModifiers: Int32 = 1_048_840 + @Published var isTestingProvider = false + @Published var testResult: String? + @Published var activeWarnings: [WarningItem] = [ + WarningItem( + id: "ollama", + title: "Ollama Not Running", + message: "Please start Ollama to use local AI models for summarization.", + icon: "server.rack", + severity: .warning + ) + ] + + var hasModels: Bool { + !availableModels.isEmpty + } + + var currentSelection: LLMModelInfo? { + selectedModel + } + + var manualModelName: Binding { + .constant("") + } + + var folderSettingsViewModel: FolderSettingsViewModelType { + PreviewFolderSettingsViewModel() + } + + func loadModels() async {} + func selectModel(_ model: LLMModelInfo) async { + selectedModel = model + } + func selectManualModel(_ modelName: String) async {} + func selectProvider(_ provider: LLMProvider) async { + selectedProvider = provider + } + func toggleAutoDetectMeetings(_ enabled: Bool) async { + autoDetectMeetings = enabled + } + func toggleAutoStopRecording(_ enabled: Bool) async { + isAutoStopRecording = enabled + } + func toggleAutoSummarize(_ enabled: Bool) async { + isAutoSummarizeEnabled = enabled + } + func toggleAutoTranscribe(_ enabled: Bool) async { + isAutoTranscribeEnabled = enabled + } + func saveAPIKey(_ apiKey: String) async throws {} + func dismissAPIKeyAlert() { + showAPIKeyAlert = false + } + func saveOpenAIConfiguration(apiKey: String, endpoint: String) async throws {} + func dismissOpenAIAlert() { + showOpenAIAlert = false + } + func updateGlobalShortcut(keyCode: Int32, modifiers: Int32) async { + globalShortcutKeyCode = keyCode + globalShortcutModifiers = modifiers + } + func testLLMProvider() async { + isTestingProvider = true + try? await Task.sleep(nanoseconds: 1_000_000_000) + testResult = "✓ Test successful!\n\nSummary:\nPreview mode - test functionality works!" + isTestingProvider = false + } + } + + final class PreviewFolderSettingsViewModel: FolderSettingsViewModelType { + @Published var currentFolderPath: String = + "/Users/nilleb/Library/Containers/co.nilleb.Recap/Data/tmp/" + @Published var errorMessage: String? + + init() { + // Preview initializer - no setup needed + } + + func updateFolderPath(_ url: URL) async { + currentFolderPath = url.path + } + + func setErrorMessage(_ message: String?) { + errorMessage = message + } + } +#endif diff --git a/Recap/UseCases/Settings/Components/TabViews/GeneralSettingsView.swift b/Recap/UseCases/Settings/Components/TabViews/GeneralSettingsView.swift index f942887..98cb13e 100644 --- a/Recap/UseCases/Settings/Components/TabViews/GeneralSettingsView.swift +++ b/Recap/UseCases/Settings/Components/TabViews/GeneralSettingsView.swift @@ -1,238 +1,217 @@ -import SwiftUI import Combine +import SwiftUI struct GeneralSettingsView: View { - @ObservedObject private var viewModel: ViewModel - - init(viewModel: ViewModel) { - self.viewModel = viewModel - } - - var body: some View { - GeometryReader { geometry in - ScrollView() { - VStack(alignment: .leading, spacing: 16) { - ForEach(viewModel.activeWarnings, id: \.id) { warning in - WarningCard(warning: warning, containerWidth: geometry.size.width) - } - SettingsCard(title: "Model Selection") { - VStack(spacing: 16) { - settingsRow(label: "Provider") { - CustomSegmentedControl( - options: LLMProvider.allCases, - selection: Binding( - get: { viewModel.selectedProvider }, - set: { newProvider in - Task { - await viewModel.selectProvider(newProvider) - } - } - ), - displayName: { $0.providerName } - ) - .frame(width: 285) - } - - if viewModel.isLoading { - HStack { - ProgressView() - .scaleEffect(0.5) - Text("Loading models...") - .font(.system(size: 12, weight: .medium)) - .foregroundColor(UIConstants.Colors.textSecondary) - } - } else if viewModel.hasModels { - settingsRow(label: "Summarizer Model") { - if let currentSelection = viewModel.currentSelection { - CustomDropdown( - title: "Model", - options: viewModel.availableModels, - selection: Binding( - get: { currentSelection }, - set: { newModel in - Task { - await viewModel.selectModel(newModel) - } - } - ), - displayName: { $0.name }, - showSearch: true - ) - .frame(width: 285) - } else { - HStack { - ProgressView() - .scaleEffect(0.5) - Text("Setting up...") - .font(.system(size: 12, weight: .medium)) - .foregroundColor(UIConstants.Colors.textSecondary) - } - } - } - } else { - settingsRow(label: "Selected Model") { - Text("No models available") - .font(.system(size: 12, weight: .medium)) - .foregroundColor(UIConstants.Colors.textSecondary) - } - } - - if let errorMessage = viewModel.errorMessage { - Text(errorMessage) - .font(.system(size: 11, weight: .medium)) - .foregroundColor(.red) - .padding(.top, 4) - } - } - } - - SettingsCard(title: "Custom Prompt") { - VStack(alignment: .leading, spacing: 12) { - CustomTextEditor( - title: "Prompt Template", - text: viewModel.customPromptTemplate, - placeholder: "Enter your custom prompt template here...", - height: 120 - ) - - HStack { - Text("Customize how AI summarizes your meeting transcripts") - .font(.system(size: 11, weight: .regular)) - .foregroundColor(UIConstants.Colors.textSecondary) - - Spacer() - - PillButton(text: "Reset to Default") { - Task { - await viewModel.resetToDefaultPrompt() - } - } - } - } + @ObservedObject var viewModel: ViewModel + var recapViewModel: RecapViewModel? + + init(viewModel: ViewModel, recapViewModel: RecapViewModel? = nil) { + self.viewModel = viewModel + self.recapViewModel = recapViewModel + } + + var body: some View { + GeometryReader { geometry in + ScrollView { + VStack(alignment: .leading, spacing: 16) { + // Audio Sources Section (moved from LeftPaneView) + if let recapViewModel = recapViewModel { + SettingsCard(title: "Audio Sources") { + HStack(spacing: UIConstants.Spacing.cardSpacing) { + HeatmapCard( + title: "System Audio", + containerWidth: geometry.size.width, + isSelected: true, + audioLevel: recapViewModel.systemAudioHeatmapLevel, + isInteractionEnabled: !recapViewModel.isRecording, + onToggle: {} + ) + HeatmapCard( + title: "Microphone", + containerWidth: geometry.size.width, + isSelected: recapViewModel.isMicrophoneEnabled, + audioLevel: recapViewModel.microphoneHeatmapLevel, + isInteractionEnabled: !recapViewModel.isRecording, + onToggle: { + recapViewModel.toggleMicrophone() + } + ) + } + } + } + + ForEach(viewModel.activeWarnings, id: \.id) { warning in + WarningCard(warning: warning, containerWidth: geometry.size.width) + } + SettingsCard(title: "Model Selection") { + VStack(spacing: 16) { + settingsRow(label: "Provider") { + CustomSegmentedControl( + options: LLMProvider.allCases, + selection: Binding( + get: { viewModel.selectedProvider }, + set: { newProvider in + Task { + await viewModel.selectProvider(newProvider) + } } - + ), + displayName: { $0.providerName } + ) + .frame(width: 285) + } + + modelSelectionContent() + + HStack { + Spacer() + + PillButton( + text: viewModel.isTestingProvider ? "Testing..." : "Test LLM Provider", + icon: viewModel.isTestingProvider ? nil : "checkmark.circle" + ) { + Task { + await viewModel.testLLMProvider() + } } - .padding(.horizontal, 20) - .padding(.vertical, 20) + .disabled(viewModel.isTestingProvider) + } + + if let testResult = viewModel.testResult { + Text(testResult) + .font(.system(size: 11, weight: .regular)) + .foregroundColor(UIConstants.Colors.textSecondary) + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(hex: "1A1A1A")) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color(hex: "2A2A2A"), lineWidth: 1) + ) + ) + } + + if let errorMessage = viewModel.errorMessage { + Text(errorMessage) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.red) + .padding(.top, 4) + } } - } - .toast(isPresenting: Binding( - get: { viewModel.showToast }, - set: { _ in } - )) { - AlertToast( - displayMode: .hud, - type: .error(.red), - title: viewModel.toastMessage - ) - } - .blur(radius: viewModel.showAPIKeyAlert ? 2 : 0) - .animation(.easeInOut(duration: 0.3), value: viewModel.showAPIKeyAlert) - .overlay( - Group { - if viewModel.showAPIKeyAlert { - ZStack { - Color.black.opacity(0.3) - .ignoresSafeArea() - .transition(.opacity) - - OpenRouterAPIKeyAlert( - isPresented: Binding( - get: { viewModel.showAPIKeyAlert }, - set: { _ in viewModel.dismissAPIKeyAlert() } - ), - existingKey: viewModel.existingAPIKey, - onSave: { apiKey in - try await viewModel.saveAPIKey(apiKey) - } - ) - .transition(.scale(scale: 0.8).combined(with: .opacity)) - } + } + + SettingsCard(title: "Custom Prompt") { + VStack(alignment: .leading, spacing: 12) { + CustomTextEditor( + title: "Prompt Template", + text: viewModel.customPromptTemplate, + placeholder: "Enter your custom prompt template here...", + height: 120 + ) + + HStack { + Text("Customize how AI summarizes your meeting transcripts") + .font(.system(size: 11, weight: .regular)) + .foregroundColor(UIConstants.Colors.textSecondary) + + Spacer() + + PillButton(text: "Reset to Default") { + Task { + await viewModel.resetToDefaultPrompt() + } } + } } - .animation(.spring(response: 0.4, dampingFraction: 0.8), value: viewModel.showAPIKeyAlert) - ) - } - - - private func settingsRow( - label: String, - @ViewBuilder control: () -> Content - ) -> some View { - HStack { - Text(label) - .font(.system(size: 12, weight: .medium)) - .foregroundColor(UIConstants.Colors.textPrimary) - - Spacer() - - control() - } - } -} + } -#Preview { - GeneralSettingsView(viewModel: PreviewGeneralSettingsViewModel()) - .frame(width: 550, height: 500) - .background(Color.black) -} + SettingsCard(title: "Processing Options") { + VStack(spacing: 16) { + settingsRow(label: "Enable Transcription") { + Toggle( + "", + isOn: Binding( + get: { viewModel.isAutoTranscribeEnabled }, + set: { newValue in + Task { + await viewModel.toggleAutoTranscribe(newValue) + } + } + ) + ) + .toggleStyle(SwitchToggleStyle(tint: UIConstants.Colors.audioGreen)) + } -private final class PreviewGeneralSettingsViewModel: GeneralSettingsViewModelType { - func updateCustomPromptTemplate(_ template: String) async {} - - func resetToDefaultPrompt() async {} - - var customPromptTemplate: Binding { - .constant(UserPreferencesInfo.defaultPromptTemplate) - } + Text("When disabled, transcription will be skipped") + .font(.system(size: 11, weight: .regular)) + .foregroundColor(UIConstants.Colors.textSecondary) + .frame(maxWidth: .infinity, alignment: .leading) - @Published var availableModels: [LLMModelInfo] = [ - LLMModelInfo(name: "llama3.2", provider: "ollama"), - LLMModelInfo(name: "codellama", provider: "ollama") - ] - @Published var selectedModel: LLMModelInfo? - @Published var selectedProvider: LLMProvider = .ollama - @Published var autoDetectMeetings: Bool = true - @Published var isAutoStopRecording: Bool = false - @Published var isLoading = false - @Published var errorMessage: String? - @Published var showToast = false - @Published var toastMessage = "" - @Published var showAPIKeyAlert = false - @Published var existingAPIKey: String? - @Published var activeWarnings: [WarningItem] = [ - WarningItem( - id: "ollama", - title: "Ollama Not Running", - message: "Please start Ollama to use local AI models for summarization.", - icon: "server.rack", - severity: .warning - ) - ] - - var hasModels: Bool { - !availableModels.isEmpty - } - - var currentSelection: LLMModelInfo? { - selectedModel - } - - func loadModels() async {} - func selectModel(_ model: LLMModelInfo) async { - selectedModel = model - } - func selectProvider(_ provider: LLMProvider) async { - selectedProvider = provider - } - func toggleAutoDetectMeetings(_ enabled: Bool) async { - autoDetectMeetings = enabled - } - func toggleAutoStopRecording(_ enabled: Bool) async { - isAutoStopRecording = enabled + settingsRow(label: "Enable Summarization") { + Toggle( + "", + isOn: Binding( + get: { viewModel.isAutoSummarizeEnabled }, + set: { newValue in + Task { + await viewModel.toggleAutoSummarize(newValue) + } + } + ) + ) + .toggleStyle(SwitchToggleStyle(tint: UIConstants.Colors.audioGreen)) + } + + Text( + "When disabled, recordings will only be transcribed without summarization" + ) + .font(.system(size: 11, weight: .regular)) + .foregroundColor(UIConstants.Colors.textSecondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + SettingsCard(title: "Global Shortcut") { + GlobalShortcutSettingsView(viewModel: viewModel) + } + + SettingsCard(title: "File Storage") { + FolderSettingsView( + viewModel: AnyFolderSettingsViewModel(viewModel.folderSettingsViewModel) + ) + } + + } + .padding(.horizontal, 20) + .padding(.vertical, 20) + } } - func saveAPIKey(_ apiKey: String) async throws {} - func dismissAPIKeyAlert() { - showAPIKeyAlert = false + .toast( + isPresenting: Binding( + get: { viewModel.showToast }, + set: { _ in } + ) + ) { + AlertToast( + displayMode: .hud, + type: .error(.red), + title: viewModel.toastMessage + ) } + .blur(radius: viewModel.showAPIKeyAlert || viewModel.showOpenAIAlert ? 2 : 0) + .animation( + .easeInOut(duration: 0.3), value: viewModel.showAPIKeyAlert || viewModel.showOpenAIAlert + ) + .overlay(apiKeyAlertOverlay()) + } } + +#if DEBUG + #Preview { + GeneralSettingsView(viewModel: PreviewGeneralSettingsViewModel()) + .frame(width: 550, height: 500) + .background(Color.black) + } +#endif diff --git a/Recap/UseCases/Settings/Components/TabViews/WhisperModelsView.swift b/Recap/UseCases/Settings/Components/TabViews/WhisperModelsView.swift index 3a64ced..745fc93 100644 --- a/Recap/UseCases/Settings/Components/TabViews/WhisperModelsView.swift +++ b/Recap/UseCases/Settings/Components/TabViews/WhisperModelsView.swift @@ -1,214 +1,222 @@ import SwiftUI struct WhisperModelsView: View { - @ObservedObject var viewModel: WhisperModelsViewModel - - var body: some View { - GeometryReader { geometry in - let mainCardBackground = LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color(hex: "232222").opacity(0.2), location: 0), - .init(color: Color(hex: "0F0F0F").opacity(0.3), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ) - - let mainCardBorder = LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color(hex: "979797").opacity(0.1), location: 0), - .init(color: Color(hex: "C4C4C4").opacity(0.2), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ) + @ObservedObject var viewModel: WhisperModelsViewModel - RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius) - .fill(mainCardBackground) - .frame(width: geometry.size.width - 40) - .overlay( - RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius) - .stroke(mainCardBorder, lineWidth: UIConstants.Sizing.borderWidth) - ) - .overlay( - VStack(alignment: .leading, spacing: UIConstants.Spacing.sectionSpacing) { - HStack { - Text("Models") - .font(.system(size: 14, weight: .bold)) - .foregroundColor(UIConstants.Colors.textPrimary) - Spacer() - } - .padding(.top, 14) - .padding(.horizontal, 14) - - ScrollView { - VStack(alignment: .leading, spacing: 16) { - modelSection( - title: "Recommended Models", - models: viewModel.recommendedModels - ) - - modelSection( - title: "Other Models", - models: viewModel.otherModels - ) - } - .padding(.horizontal, 20) - } - .padding(.bottom, 8) - } + var body: some View { + GeometryReader { geometry in + let mainCardBackground = LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color(hex: "232222").opacity(0.2), location: 0), + .init(color: Color(hex: "0F0F0F").opacity(0.3), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ) + + let mainCardBorder = LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color(hex: "979797").opacity(0.1), location: 0), + .init(color: Color(hex: "C4C4C4").opacity(0.2), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ) + + RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius) + .fill(mainCardBackground) + .frame(width: geometry.size.width - 40) + .overlay( + RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius) + .stroke(mainCardBorder, lineWidth: UIConstants.Sizing.borderWidth) + ) + .overlay( + VStack(alignment: .leading, spacing: UIConstants.Spacing.sectionSpacing) { + HStack { + Text("Models") + .font(.system(size: 14, weight: .bold)) + .foregroundColor(UIConstants.Colors.textPrimary) + Spacer() + } + .padding(.top, 14) + .padding(.horizontal, 14) + + ScrollView { + VStack(alignment: .leading, spacing: 16) { + modelSection( + title: "Recommended Models", + models: viewModel.recommendedModels ) - .position(x: geometry.size.width / 2, y: geometry.size.height / 2) - .overlay( - Group { - if let tooltipModel = viewModel.showingTooltipForModel, - let modelInfo = viewModel.getModelInfo(tooltipModel) { - VStack(alignment: .leading, spacing: 2) { - Text(modelInfo.displayName) - .font(.system(size: 10, weight: .semibold)) - .foregroundColor(.white) - Text("Size: \(modelInfo.parameters) parameters") - .font(.system(size: 9)) - .foregroundColor(.white) - Text("VRAM: \(modelInfo.vram)") - .font(.system(size: 9)) - .foregroundColor(.white) - Text("Speed: \(modelInfo.relativeSpeed)") - .font(.system(size: 9)) - .foregroundColor(.white) - } - .padding(8) - .background( - RoundedRectangle(cornerRadius: 6) - .fill(Color.black.opacity(0.95)) - .shadow(radius: 4) - ) - .position(x: viewModel.tooltipPosition.x + 60, y: viewModel.tooltipPosition.y - 40) - } - } + + modelSection( + title: "Other Models", + models: viewModel.otherModels ) - } + } + .padding(.horizontal, 20) + } + .padding(.bottom, 8) + } + ) + .position(x: geometry.size.width / 2, y: geometry.size.height / 2) + .overlay( + Group { + if let tooltipModel = viewModel.showingTooltipForModel, + let modelInfo = viewModel.getModelInfo(tooltipModel) { + VStack(alignment: .leading, spacing: 2) { + Text(modelInfo.displayName) + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(.white) + Text("Size: \(modelInfo.parameters) parameters") + .font(.system(size: 9)) + .foregroundColor(.white) + Text("VRAM: \(modelInfo.vram)") + .font(.system(size: 9)) + .foregroundColor(.white) + Text("Speed: \(modelInfo.relativeSpeed)") + .font(.system(size: 9)) + .foregroundColor(.white) + } + .padding(8) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color.black.opacity(0.95)) + .shadow(radius: 4) + ) + .position( + x: viewModel.tooltipPosition.x + 60, + y: viewModel.tooltipPosition.y - 40) + } + } + ) } - - private func modelSection(title: String, models: [String]) -> some View { - VStack(alignment: .leading, spacing: 8) { - Text(title) - .font(.system(size: 10, weight: .semibold)) - .foregroundColor(UIConstants.Colors.textSecondary) - - VStack(spacing: 4) { - ForEach(models, id: \.self) { model in - ModelRowView( - modelName: model, - displayName: viewModel.modelDisplayName(model), - isSelected: viewModel.selectedModel == model, - isDownloaded: viewModel.downloadedModels.contains(model), - isDownloading: viewModel.downloadingModels.contains(model), - downloadProgress: viewModel.downloadProgress[model] ?? 0.0, - showingTooltip: viewModel.showingTooltipForModel == model, - onSelect: { - withAnimation(.easeInOut(duration: 0.2)) { - viewModel.selectModel(model) - } - }, - onDownload: { - viewModel.downloadModel(model) - }, - onTooltipToggle: { position in - withAnimation { - viewModel.toggleTooltip(for: model, at: position) - } - } - ) - } + } + + private func modelSection(title: String, models: [String]) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(UIConstants.Colors.textSecondary) + + VStack(spacing: 4) { + ForEach(models, id: \.self) { model in + ModelRowView( + modelName: model, + displayName: viewModel.modelDisplayName(model), + isSelected: viewModel.selectedModel == model, + isDownloaded: viewModel.downloadedModels.contains(model), + isDownloading: viewModel.downloadingModels.contains(model), + downloadProgress: viewModel.downloadProgress[model] ?? 0.0, + showingTooltip: viewModel.showingTooltipForModel == model, + onSelect: { + withAnimation(.easeInOut(duration: 0.2)) { + viewModel.selectModel(model) + } + }, + onDownload: { + viewModel.downloadModel(model) + }, + onTooltipToggle: { position in + withAnimation { + viewModel.toggleTooltip(for: model, at: position) + } } + ) } + } } + } } struct ModelRowView: View { - let modelName: String - let displayName: String - let isSelected: Bool - let isDownloaded: Bool - let isDownloading: Bool - let downloadProgress: Double - let showingTooltip: Bool - let onSelect: () -> Void - let onDownload: () -> Void - let onTooltipToggle: (CGPoint) -> Void - - var body: some View { - RoundedRectangle(cornerRadius: 8) - .fill(Color(hex: "2A2A2A").opacity(0.2)) - .frame(height: 30) - .frame(maxHeight: 40) + let modelName: String + let displayName: String + let isSelected: Bool + let isDownloaded: Bool + let isDownloading: Bool + let downloadProgress: Double + let showingTooltip: Bool + let onSelect: () -> Void + let onDownload: () -> Void + let onTooltipToggle: (CGPoint) -> Void + + var body: some View { + RoundedRectangle(cornerRadius: 8) + .fill(Color(hex: "2A2A2A").opacity(0.2)) + .frame(height: 30) + .frame(maxHeight: 40) + .overlay( + HStack(spacing: 12) { + RoundedRectangle(cornerRadius: 4) + .fill(Color(hex: "2A2A2A")) + .frame(width: 16, height: 16) .overlay( - HStack(spacing: 12) { - RoundedRectangle(cornerRadius: 4) - .fill(Color(hex: "2A2A2A")) - .frame(width: 16, height: 16) - .overlay( - Image(systemName: "cpu") - .font(.system(size: 8, weight: .bold)) - .foregroundColor(UIConstants.Colors.textPrimary) - ) - - HStack(spacing: 6) { - Text(displayName) - .font(.system(size: 10, weight: .semibold)) - .foregroundColor(UIConstants.Colors.textPrimary) - - GeometryReader { geometry in - Button(action: { - let frame = geometry.frame(in: .global) - let buttonCenter = CGPoint( - x: frame.midX + 25, - y: frame.midY - 75 - ) - onTooltipToggle(buttonCenter) - }) { - Image(systemName: "questionmark.circle") - .font(.system(size: 12, weight: .medium)) - .foregroundColor(UIConstants.Colors.textSecondary) - } - .buttonStyle(PlainButtonStyle()) - } - .frame(width: 12, height: 12) - } - - Spacer() - - if !isDownloaded { - DownloadPillButton( - text: isDownloading ? "Downloading" : "Download", - isDownloading: isDownloading, - downloadProgress: downloadProgress, - action: onDownload - ) - } - - if isDownloaded { - Circle() - .stroke(UIConstants.Colors.selectionStroke, lineWidth: UIConstants.Sizing.strokeWidth) - .frame(width: UIConstants.Sizing.selectionCircleSize, height: UIConstants.Sizing.selectionCircleSize) - .overlay { - if isSelected { - Image(systemName: "checkmark") - .font(UIConstants.Typography.iconFont) - .foregroundColor(UIConstants.Colors.textPrimary) - } - } - } - } - .padding(.horizontal, 12) + Image(systemName: "cpu") + .font(.system(size: 8, weight: .bold)) + .foregroundColor(UIConstants.Colors.textPrimary) ) - .contentShape(Rectangle()) - .onTapGesture { - if isDownloaded { - onSelect() - } + + HStack(spacing: 6) { + Text(displayName) + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(UIConstants.Colors.textPrimary) + + GeometryReader { geometry in + Button { + let frame = geometry.frame(in: .global) + let buttonCenter = CGPoint( + x: frame.midX + 25, + y: frame.midY - 75 + ) + onTooltipToggle(buttonCenter) + } label: { + Image(systemName: "questionmark.circle") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(UIConstants.Colors.textSecondary) + } + .buttonStyle(PlainButtonStyle()) } - } - + .frame(width: 12, height: 12) + } + + Spacer() + + if !isDownloaded { + DownloadPillButton( + text: isDownloading ? "Downloading" : "Download", + isDownloading: isDownloading, + downloadProgress: downloadProgress, + action: onDownload + ) + } + + if isDownloaded { + Circle() + .stroke( + UIConstants.Colors.selectionStroke, + lineWidth: UIConstants.Sizing.strokeWidth + ) + .frame( + width: UIConstants.Sizing.selectionCircleSize, + height: UIConstants.Sizing.selectionCircleSize + ) + .overlay { + if isSelected { + Image(systemName: "checkmark") + .font(UIConstants.Typography.iconFont) + .foregroundColor(UIConstants.Colors.textPrimary) + } + } + } + } + .padding(.horizontal, 12) + ) + .contentShape(Rectangle()) + .onTapGesture { + if isDownloaded { + onSelect() + } + } + } + } diff --git a/Recap/UseCases/Settings/Models/ModelInfo.swift b/Recap/UseCases/Settings/Models/ModelInfo.swift index 22bd378..06b2452 100644 --- a/Recap/UseCases/Settings/Models/ModelInfo.swift +++ b/Recap/UseCases/Settings/Models/ModelInfo.swift @@ -7,60 +7,59 @@ import Foundation - struct ModelInfo { - let displayName: String - let parameters: String - let vram: String - let relativeSpeed: String - - var helpText: String { - return """ - \(displayName) - Size: \(parameters) parameters - Required VRAM: \(vram) - Relative Speed: \(relativeSpeed) - """ - } + let displayName: String + let parameters: String + let vram: String + let relativeSpeed: String + + var helpText: String { + return """ + \(displayName) + Size: \(parameters) parameters + Required VRAM: \(vram) + Relative Speed: \(relativeSpeed) + """ + } } extension String { - static let modelInfoData: [String: ModelInfo] = [ - "tiny": ModelInfo( - displayName: "Tiny Model", - parameters: "39M", - vram: "~1 GB", - relativeSpeed: "~10x" - ), - "base": ModelInfo( - displayName: "Base Model", - parameters: "74M", - vram: "~1 GB", - relativeSpeed: "~7x" - ), - "small": ModelInfo( - displayName: "Small Model", - parameters: "244M", - vram: "~2 GB", - relativeSpeed: "~4x" - ), - "medium": ModelInfo( - displayName: "Medium Model", - parameters: "769M", - vram: "~5 GB", - relativeSpeed: "~2x" - ), - "large": ModelInfo( - displayName: "Large Model", - parameters: "1550M", - vram: "~10 GB", - relativeSpeed: "1x (baseline)" - ), - "distil-whisper_distil-large-v3_turbo": ModelInfo( - displayName: "Turbo Model", - parameters: "809M", - vram: "~6 GB", - relativeSpeed: "~8x" - ) - ] + static let modelInfoData: [String: ModelInfo] = [ + "tiny": ModelInfo( + displayName: "Tiny Model", + parameters: "39M", + vram: "~1 GB", + relativeSpeed: "~10x" + ), + "base": ModelInfo( + displayName: "Base Model", + parameters: "74M", + vram: "~1 GB", + relativeSpeed: "~7x" + ), + "small": ModelInfo( + displayName: "Small Model", + parameters: "244M", + vram: "~2 GB", + relativeSpeed: "~4x" + ), + "medium": ModelInfo( + displayName: "Medium Model", + parameters: "769M", + vram: "~5 GB", + relativeSpeed: "~2x" + ), + "large": ModelInfo( + displayName: "Large Model", + parameters: "1550M", + vram: "~10 GB", + relativeSpeed: "1x (baseline)" + ), + "distil-whisper_distil-large-v3_turbo": ModelInfo( + displayName: "Turbo Model", + parameters: "809M", + vram: "~6 GB", + relativeSpeed: "~8x" + ) + ] } diff --git a/Recap/UseCases/Settings/Models/ProviderStatus.swift b/Recap/UseCases/Settings/Models/ProviderStatus.swift index 3fe965a..b9257af 100644 --- a/Recap/UseCases/Settings/Models/ProviderStatus.swift +++ b/Recap/UseCases/Settings/Models/ProviderStatus.swift @@ -1,17 +1,17 @@ import Foundation struct ProviderStatus { - let name: String - let isAvailable: Bool - let statusMessage: String - - static func ollama(isAvailable: Bool) -> ProviderStatus { - ProviderStatus( - name: "Ollama", - isAvailable: isAvailable, - statusMessage: isAvailable - ? "Connected to Ollama at localhost:11434" - : "Ollama not detected. Please install and run Ollama from https://ollama.ai" - ) - } -} \ No newline at end of file + let name: String + let isAvailable: Bool + let statusMessage: String + + static func ollama(isAvailable: Bool) -> ProviderStatus { + ProviderStatus( + name: "Ollama", + isAvailable: isAvailable, + statusMessage: isAvailable + ? "Connected to Ollama at localhost:11434" + : "Ollama not detected. Please install and run Ollama from https://ollama.ai" + ) + } +} diff --git a/Recap/UseCases/Settings/SettingsView.swift b/Recap/UseCases/Settings/SettingsView.swift index 0d6a763..1fe19c2 100644 --- a/Recap/UseCases/Settings/SettingsView.swift +++ b/Recap/UseCases/Settings/SettingsView.swift @@ -1,213 +1,167 @@ import SwiftUI enum SettingsTab: CaseIterable { - case general - case meetingDetection - case whisperModels - - var title: String { - switch self { - case .general: - return "General" - case .meetingDetection: - return "Meeting Detection" - case .whisperModels: - return "Whisper Models" - } + case general + case meetingDetection + case whisperModels + + var title: String { + switch self { + case .general: + return "General" + case .meetingDetection: + return "Meeting Detection" + case .whisperModels: + return "Whisper Models" } + } } struct SettingsView: View { - @State private var selectedTab: SettingsTab = .general - @ObservedObject var whisperModelsViewModel: WhisperModelsViewModel - @ObservedObject var generalSettingsViewModel: GeneralViewModel - @StateObject private var meetingDetectionViewModel: MeetingDetectionSettingsViewModel - let onClose: () -> Void - - init( - whisperModelsViewModel: WhisperModelsViewModel, - generalSettingsViewModel: GeneralViewModel, - meetingDetectionService: any MeetingDetectionServiceType, - userPreferencesRepository: UserPreferencesRepositoryType, - onClose: @escaping () -> Void - ) { - self.whisperModelsViewModel = whisperModelsViewModel - self.generalSettingsViewModel = generalSettingsViewModel - self._meetingDetectionViewModel = StateObject(wrappedValue: MeetingDetectionSettingsViewModel( - detectionService: meetingDetectionService, - userPreferencesRepository: userPreferencesRepository, - permissionsHelper: PermissionsHelper() - )) - self.onClose = onClose - } - - var body: some View { - GeometryReader { geometry in - ZStack { - UIConstants.Gradients.backgroundGradient - .ignoresSafeArea() - - VStack(spacing: UIConstants.Spacing.sectionSpacing) { - HStack { - Text("Settings") - .foregroundColor(UIConstants.Colors.textPrimary) - .font(UIConstants.Typography.appTitle) - .padding(.leading, UIConstants.Spacing.contentPadding) - .padding(.top, UIConstants.Spacing.sectionSpacing) - - Spacer() - - Text("Close") - .font(.system(size: 10, weight: .medium)) - .foregroundColor(.white) - .padding(.horizontal, 12) - .padding(.vertical, 10) - .background( - RoundedRectangle(cornerRadius: 20) - .fill(Color(hex: "242323")) - .overlay( - RoundedRectangle(cornerRadius: 20) - .stroke( - LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color(hex: "979797").opacity(0.6), location: 0), - .init(color: Color(hex: "979797").opacity(0.4), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ), - lineWidth: 0.8 - ) - ) - .opacity(0.6) + @State private var selectedTab: SettingsTab = .general + @ObservedObject var whisperModelsViewModel: WhisperModelsViewModel + @ObservedObject var generalSettingsViewModel: GeneralViewModel + @StateObject private var meetingDetectionViewModel: MeetingDetectionSettingsViewModel + var recapViewModel: RecapViewModel? + let onClose: () -> Void + + init( + whisperModelsViewModel: WhisperModelsViewModel, + generalSettingsViewModel: GeneralViewModel, + meetingDetectionService: any MeetingDetectionServiceType, + userPreferencesRepository: UserPreferencesRepositoryType, + recapViewModel: RecapViewModel? = nil, + onClose: @escaping () -> Void + ) { + self.whisperModelsViewModel = whisperModelsViewModel + self.generalSettingsViewModel = generalSettingsViewModel + self._meetingDetectionViewModel = StateObject( + wrappedValue: MeetingDetectionSettingsViewModel( + detectionService: meetingDetectionService, + userPreferencesRepository: userPreferencesRepository, + permissionsHelper: PermissionsHelper() + )) + self.recapViewModel = recapViewModel + self.onClose = onClose + } + + var body: some View { + GeometryReader { _ in + ZStack { + UIConstants.Gradients.backgroundGradient + .ignoresSafeArea() + + VStack(spacing: UIConstants.Spacing.sectionSpacing) { + HStack { + Text("Settings") + .foregroundColor(UIConstants.Colors.textPrimary) + .font(UIConstants.Typography.appTitle) + .padding(.leading, UIConstants.Spacing.contentPadding) + .padding(.top, UIConstants.Spacing.sectionSpacing) - ) - .onTapGesture { - onClose() - } - .padding(.trailing, UIConstants.Spacing.contentPadding) - .padding(.top, UIConstants.Spacing.sectionSpacing) - } - - HStack(spacing: 8) { - ForEach(SettingsTab.allCases, id: \.self) { tab in - TabButton( - text: tab.title, - isSelected: selectedTab == tab - ) { - withAnimation(.easeInOut(duration: 0.3)) { - selectedTab = tab - } - } - } - Spacer() - } - .padding(.horizontal, UIConstants.Spacing.contentPadding) - - Group { - switch selectedTab { - case .general: - GeneralSettingsView( - viewModel: generalSettingsViewModel - ) - case .meetingDetection: - MeetingDetectionView(viewModel: meetingDetectionViewModel) - case .whisperModels: - WhisperModelsView(viewModel: whisperModelsViewModel) - } - } - .transition(.asymmetric( - insertion: .opacity.combined(with: .move(edge: .trailing)), - removal: .opacity.combined(with: .move(edge: .leading)) - )) - .id(selectedTab) + Spacer() + + Text("Close") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(.white) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 20) + .fill(Color(hex: "242323")) + .overlay( + RoundedRectangle(cornerRadius: 20) + .stroke( + LinearGradient( + gradient: Gradient(stops: [ + .init( + color: Color(hex: "979797").opacity( + 0.6), location: 0), + .init( + color: Color(hex: "979797").opacity( + 0.4), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 0.8 + ) + ) + .opacity(0.6) + + ) + .onTapGesture { + onClose() + } + .padding(.trailing, UIConstants.Spacing.contentPadding) + .padding(.top, UIConstants.Spacing.sectionSpacing) + } + + HStack(spacing: 8) { + ForEach(SettingsTab.allCases, id: \.self) { tab in + TabButton( + text: tab.title, + isSelected: selectedTab == tab + ) { + withAnimation(.easeInOut(duration: 0.3)) { + selectedTab = tab } + } } - } - .toast(isPresenting: $whisperModelsViewModel.showingError) { - AlertToast( - displayMode: .banner(.slide), - type: .error(.red), - title: "Error", - subTitle: whisperModelsViewModel.errorMessage + Spacer() + } + .padding(.horizontal, UIConstants.Spacing.contentPadding) + + Group { + switch selectedTab { + case .general: + GeneralSettingsView( + viewModel: generalSettingsViewModel, + recapViewModel: recapViewModel + ) + case .meetingDetection: + MeetingDetectionView(viewModel: meetingDetectionViewModel) + case .whisperModels: + WhisperModelsView(viewModel: whisperModelsViewModel) + } + } + .transition( + .asymmetric( + insertion: .opacity.combined(with: .move(edge: .trailing)), + removal: .opacity.combined(with: .move(edge: .leading)) ) + ) + .id(selectedTab) } + } } + .toast(isPresenting: $whisperModelsViewModel.showingError) { + AlertToast( + displayMode: .banner(.slide), + type: .error(.red), + title: "Error", + subTitle: whisperModelsViewModel.errorMessage + ) + } + } } -#Preview { +#if DEBUG + #Preview { let coreDataManager = CoreDataManager(inMemory: true) let repository = WhisperModelRepository(coreDataManager: coreDataManager) let whisperModelsViewModel = WhisperModelsViewModel(repository: repository) let generalSettingsViewModel = PreviewGeneralSettingsViewModel() - + SettingsView( - whisperModelsViewModel: whisperModelsViewModel, - generalSettingsViewModel: generalSettingsViewModel, - meetingDetectionService: MeetingDetectionService(audioProcessController: AudioProcessController(), permissionsHelper: PermissionsHelper()), - userPreferencesRepository: UserPreferencesRepository(coreDataManager: coreDataManager), - onClose: {} + whisperModelsViewModel: whisperModelsViewModel, + generalSettingsViewModel: generalSettingsViewModel, + meetingDetectionService: MeetingDetectionService( + audioProcessController: AudioProcessController(), permissionsHelper: PermissionsHelper() + ), + userPreferencesRepository: UserPreferencesRepository(coreDataManager: coreDataManager), + onClose: {} ) .frame(width: 550, height: 500) -} - -// Just used for previews only! -private final class PreviewGeneralSettingsViewModel: GeneralSettingsViewModelType { - var customPromptTemplate: Binding = .constant("Hello") - - var showAPIKeyAlert: Bool = false - - var existingAPIKey: String? = nil - - func saveAPIKey(_ apiKey: String) async throws {} - - func dismissAPIKeyAlert() {} - - @Published var availableModels: [LLMModelInfo] = [ - LLMModelInfo(name: "llama3.2", provider: "ollama"), - LLMModelInfo(name: "codellama", provider: "ollama") - ] - @Published var selectedModel: LLMModelInfo? - @Published var selectedProvider: LLMProvider = .ollama - @Published var autoDetectMeetings: Bool = true - @Published var isAutoStopRecording: Bool = false - @Published var isLoading = false - @Published var errorMessage: String? - @Published var showToast = false - @Published var toastMessage = "" - @Published var activeWarnings: [WarningItem] = [ - WarningItem( - id: "ollama", - title: "Ollama Not Running", - message: "Please start Ollama to use local AI models for summarization.", - icon: "server.rack", - severity: .warning - ) - ] - - var hasModels: Bool { - !availableModels.isEmpty - } - - var currentSelection: LLMModelInfo? { - selectedModel - } - - func loadModels() async {} - func selectModel(_ model: LLMModelInfo) async { - selectedModel = model - } - func selectProvider(_ provider: LLMProvider) async { - selectedProvider = provider - } - func toggleAutoDetectMeetings(_ enabled: Bool) async { - autoDetectMeetings = enabled - } - func toggleAutoStopRecording(_ enabled: Bool) async { - isAutoStopRecording = enabled - } - - func updateCustomPromptTemplate(_ template: String) async {} - - func resetToDefaultPrompt() async {} -} + } +#endif diff --git a/Recap/UseCases/Settings/ViewModels/FolderSettingsViewModel.swift b/Recap/UseCases/Settings/ViewModels/FolderSettingsViewModel.swift new file mode 100644 index 0000000..035f17b --- /dev/null +++ b/Recap/UseCases/Settings/ViewModels/FolderSettingsViewModel.swift @@ -0,0 +1,125 @@ +import Foundation +import SwiftUI + +@MainActor +final class FolderSettingsViewModel: FolderSettingsViewModelType { + @Published private(set) var currentFolderPath: String = "" + @Published private(set) var errorMessage: String? + + private let userPreferencesRepository: UserPreferencesRepositoryType + private let fileManagerHelper: RecordingFileManagerHelperType + + init( + userPreferencesRepository: UserPreferencesRepositoryType, + fileManagerHelper: RecordingFileManagerHelperType + ) { + self.userPreferencesRepository = userPreferencesRepository + self.fileManagerHelper = fileManagerHelper + + loadCurrentFolderPath() + } + + private func loadCurrentFolderPath() { + Task { + do { + let preferences = try await userPreferencesRepository.getOrCreatePreferences() + if let customPath = preferences.customTmpDirectoryPath { + currentFolderPath = customPath + } else { + currentFolderPath = fileManagerHelper.getBaseDirectory().path + } + } catch { + currentFolderPath = fileManagerHelper.getBaseDirectory().path + errorMessage = "Failed to load folder settings: \(error.localizedDescription)" + } + } + } + + func updateFolderPath(_ url: URL) async { + errorMessage = nil + + do { + #if os(macOS) + var resolvedURL = url + var bookmarkData: Data + + do { + bookmarkData = try url.bookmarkData( + options: [.withSecurityScope], + includingResourceValuesForKeys: nil, + relativeTo: nil + ) + + var isStale = false + resolvedURL = try URL( + resolvingBookmarkData: bookmarkData, + options: [.withSecurityScope], + relativeTo: nil, + bookmarkDataIsStale: &isStale + ) + + if isStale { + bookmarkData = try resolvedURL.bookmarkData( + options: [.withSecurityScope], + includingResourceValuesForKeys: nil, + relativeTo: nil + ) + } + } catch { + errorMessage = "Failed to prepare folder access: \(error.localizedDescription)" + return + } + + let hasSecurityScope = resolvedURL.startAccessingSecurityScopedResource() + defer { + if hasSecurityScope { + resolvedURL.stopAccessingSecurityScopedResource() + } + } + + try await validateAndPersistSelection( + resolvedURL: resolvedURL, bookmark: bookmarkData) + #else + try await validateAndPersistSelection(resolvedURL: url, bookmark: nil) + #endif + } catch { + errorMessage = "Failed to update folder path: \(error.localizedDescription)" + } + } + + private func validateAndPersistSelection(resolvedURL: URL, bookmark: Data?) async throws { + // Check if the directory exists and is writable + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: resolvedURL.path, isDirectory: &isDirectory), + isDirectory.boolValue + else { + errorMessage = "Selected path does not exist or is not a directory" + return + } + + // Test write permissions + let testFile = resolvedURL.appendingPathComponent(".recap_test") + do { + try Data("test".utf8).write(to: testFile) + try FileManager.default.removeItem(at: testFile) + } catch { + errorMessage = "Selected directory is not writable: \(error.localizedDescription)" + return + } + + // Update the file manager helper + try fileManagerHelper.setBaseDirectory(resolvedURL, bookmark: bookmark) + + // Save to preferences + try await userPreferencesRepository.updateCustomTmpDirectory( + path: resolvedURL.path, + bookmark: bookmark + ) + + currentFolderPath = resolvedURL.path + } + + func setErrorMessage(_ message: String?) { + errorMessage = message + } +} diff --git a/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModel+APIKeys.swift b/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModel+APIKeys.swift new file mode 100644 index 0000000..1fc32b8 --- /dev/null +++ b/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModel+APIKeys.swift @@ -0,0 +1,41 @@ +import Foundation + +@MainActor +extension GeneralSettingsViewModel { + func saveAPIKey(_ apiKey: String) async throws { + try keychainService.storeOpenRouterAPIKey(apiKey) + + existingAPIKey = apiKey + showAPIKeyAlert = false + + // Reinitialize providers with new credentials + llmService.reinitializeProviders() + + await selectProvider(.openRouter) + } + + func dismissAPIKeyAlert() { + showAPIKeyAlert = false + existingAPIKey = nil + } + + func saveOpenAIConfiguration(apiKey: String, endpoint: String) async throws { + try keychainService.storeOpenAIAPIKey(apiKey) + try keychainService.storeOpenAIEndpoint(endpoint) + + existingOpenAIKey = apiKey + existingOpenAIEndpoint = endpoint + showOpenAIAlert = false + + // Reinitialize providers with new credentials + llmService.reinitializeProviders() + + await selectProvider(.openAI) + } + + func dismissOpenAIAlert() { + showOpenAIAlert = false + existingOpenAIKey = nil + existingOpenAIEndpoint = nil + } +} diff --git a/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModel+ModelManagement.swift b/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModel+ModelManagement.swift new file mode 100644 index 0000000..3bc0e7e --- /dev/null +++ b/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModel+ModelManagement.swift @@ -0,0 +1,71 @@ +import Foundation + +@MainActor +extension GeneralSettingsViewModel { + func loadModels() async { + isLoading = true + errorMessage = nil + + do { + availableModels = try await llmService.getAvailableModels() + selectedModel = try await llmService.getSelectedModel() + + if selectedModel == nil, let firstModel = availableModels.first { + await selectModel(firstModel) + } + } catch { + errorMessage = error.localizedDescription + } + + isLoading = false + } + + func selectModel(_ model: LLMModelInfo) async { + errorMessage = nil + selectedModel = model + + do { + try await llmService.selectModel(id: model.id) + } catch { + errorMessage = error.localizedDescription + selectedModel = nil + } + } + + func selectManualModel(_ modelName: String) async { + guard !modelName.isEmpty else { + return + } + + errorMessage = nil + manualModelNameValue = modelName + + let manualModel = LLMModelInfo(name: modelName, provider: selectedProvider.rawValue) + selectedModel = manualModel + + do { + try await llmService.selectModel(id: manualModel.id) + } catch { + errorMessage = error.localizedDescription + selectedModel = nil + } + } + + func updateModelsForNewProvider() async { + do { + let newModels = try await llmService.getAvailableModels() + availableModels = newModels + + let currentSelection = try await llmService.getSelectedModel() + let isCurrentModelAvailable = newModels.contains { $0.id == currentSelection?.id } + + if !isCurrentModelAvailable, let firstModel = newModels.first { + await selectModel(firstModel) + } else { + selectedModel = currentSelection + } + } catch { + errorMessage = error.localizedDescription + } + } +} diff --git a/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModel+ProviderValidation.swift b/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModel+ProviderValidation.swift new file mode 100644 index 0000000..20cae05 --- /dev/null +++ b/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModel+ProviderValidation.swift @@ -0,0 +1,47 @@ +import Foundation + +@MainActor +extension GeneralSettingsViewModel { + func validateProviderCredentials(_ provider: LLMProvider) async -> Bool { + switch provider { + case .openRouter: + return validateOpenRouterCredentials() + case .openAI: + return validateOpenAICredentials() + default: + return true + } + } + + func validateOpenRouterCredentials() -> Bool { + let validation = keychainAPIValidator.validateOpenRouterAPI() + + if !validation.isValid { + do { + existingAPIKey = try keychainService.retrieveOpenRouterAPIKey() + } catch { + existingAPIKey = nil + } + showAPIKeyAlert = true + return false + } + return true + } + + func validateOpenAICredentials() -> Bool { + let validation = keychainAPIValidator.validateOpenAIAPI() + + if !validation.isValid { + do { + existingOpenAIKey = try keychainService.retrieveOpenAIAPIKey() + existingOpenAIEndpoint = try keychainService.retrieveOpenAIEndpoint() + } catch { + existingOpenAIKey = nil + existingOpenAIEndpoint = nil + } + showOpenAIAlert = true + return false + } + return true + } +} diff --git a/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModel+Testing.swift b/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModel+Testing.swift new file mode 100644 index 0000000..802c37b --- /dev/null +++ b/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModel+Testing.swift @@ -0,0 +1,83 @@ +import Foundation + +@MainActor +extension GeneralSettingsViewModel { + func testLLMProvider() async { + errorMessage = nil + testResult = nil + isTestingProvider = true + + defer { + isTestingProvider = false + } + + let request = createTestRequest() + + do { + let result = try await llmService.generateSummarization( + text: await buildTestPrompt(from: request), + options: LLMOptions(temperature: 0.7, maxTokens: 500, keepAliveMinutes: 5) + ) + + testResult = "✓ Test successful!\n\nSummary:\n\(result)" + } catch { + errorMessage = "Test failed: \(error.localizedDescription)" + } + } + + private func createTestRequest() -> SummarizationRequest { + let boilerplateTranscript = """ + Speaker 1: Good morning everyone, thank you for joining today's meeting. + Speaker 2: Thanks for having us. I wanted to discuss our Q4 roadmap. + Speaker 1: Absolutely. Let's start with the main priorities. + Speaker 2: We need to focus on three key areas: product launch, marketing campaign, \ + and customer feedback integration. + Speaker 1: Agreed. For the product launch, we're targeting mid-November. + Speaker 2: That timeline works well with our marketing plans. + Speaker 1: Great. Any concerns or questions? + Speaker 2: No, I think we're aligned. Let's schedule a follow-up next week. + Speaker 1: Perfect, I'll send out calendar invites. Thanks everyone! + """ + + let metadata = TranscriptMetadata( + duration: 180, + participants: ["Speaker 1", "Speaker 2"], + recordingDate: Date(), + applicationName: "Test" + ) + + let options = SummarizationOptions( + style: .concise, + includeActionItems: true, + includeKeyPoints: true, + maxLength: nil, + customPrompt: customPromptTemplateValue.isEmpty ? nil : customPromptTemplateValue + ) + + return SummarizationRequest( + transcriptText: boilerplateTranscript, + metadata: metadata, + options: options + ) + } + + private func buildTestPrompt(from request: SummarizationRequest) async -> String { + var prompt = "" + + if let metadata = request.metadata { + prompt += "Context:\n" + if let appName = metadata.applicationName { + prompt += "- Application: \(appName)\n" + } + prompt += "- Duration: 3 minutes\n" + if let participants = metadata.participants, !participants.isEmpty { + prompt += "- Participants: \(participants.joined(separator: ", "))\n" + } + prompt += "\n" + } + + prompt += "Transcript:\n\(request.transcriptText)" + + return prompt + } +} diff --git a/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModel.swift b/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModel.swift index 8211017..da13540 100644 --- a/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModel.swift +++ b/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModel.swift @@ -1,220 +1,237 @@ -import Foundation import Combine +import Foundation import SwiftUI @MainActor final class GeneralSettingsViewModel: GeneralSettingsViewModelType { - @Published private(set) var availableModels: [LLMModelInfo] = [] - @Published private(set) var selectedModel: LLMModelInfo? - @Published private(set) var selectedProvider: LLMProvider = .default - @Published private(set) var autoDetectMeetings: Bool = false - @Published private(set) var isAutoStopRecording: Bool = false - @Published private var customPromptTemplateValue: String = "" - - var customPromptTemplate: Binding { - Binding( - get: { self.customPromptTemplateValue }, - set: { newValue in - Task { - await self.updateCustomPromptTemplate(newValue) - } - } - ) - } + @Published var availableModels: [LLMModelInfo] = [] + @Published var selectedModel: LLMModelInfo? + @Published private(set) var selectedProvider: LLMProvider = .default + @Published private(set) var autoDetectMeetings: Bool = false + @Published private(set) var isAutoStopRecording: Bool = false + @Published private(set) var isAutoSummarizeEnabled: Bool = true + @Published private(set) var isAutoTranscribeEnabled: Bool = true + @Published var customPromptTemplateValue: String = "" + @Published var manualModelNameValue: String = "" + @Published private(set) var globalShortcutKeyCode: Int32 = 15 // 'R' key + @Published private(set) var globalShortcutModifiers: Int32 = 1_048_840 // Cmd key - @Published private(set) var isLoading = false - @Published private(set) var errorMessage: String? - @Published private(set) var showToast = false - @Published private(set) var toastMessage = "" - @Published private(set) var activeWarnings: [WarningItem] = [] - @Published private(set) var showAPIKeyAlert = false - @Published private(set) var existingAPIKey: String? - - var hasModels: Bool { - !availableModels.isEmpty - } - - var currentSelection: LLMModelInfo? { - selectedModel - } - - private let llmService: LLMServiceType - private let userPreferencesRepository: UserPreferencesRepositoryType - private let keychainAPIValidator: KeychainAPIValidatorType - private let keychainService: KeychainServiceType - private let warningManager: any WarningManagerType - private var cancellables = Set() - - init( - llmService: LLMServiceType, - userPreferencesRepository: UserPreferencesRepositoryType, - keychainAPIValidator: KeychainAPIValidatorType, - keychainService: KeychainServiceType, - warningManager: any WarningManagerType - ) { - self.llmService = llmService - self.userPreferencesRepository = userPreferencesRepository - self.keychainAPIValidator = keychainAPIValidator - self.keychainService = keychainService - self.warningManager = warningManager - - setupWarningObserver() - + var customPromptTemplate: Binding { + Binding( + get: { self.customPromptTemplateValue }, + set: { newValue in Task { - await loadInitialState() + await self.updateCustomPromptTemplate(newValue) } - } - - private func setupWarningObserver() { - warningManager.activeWarningsPublisher - .assign(to: \.activeWarnings, on: self) - .store(in: &cancellables) - } - - private func loadInitialState() async { - do { - let preferences = try await llmService.getUserPreferences() - selectedProvider = preferences.selectedProvider - autoDetectMeetings = preferences.autoDetectMeetings - isAutoStopRecording = preferences.autoStopRecording - customPromptTemplateValue = preferences.summaryPromptTemplate ?? UserPreferencesInfo.defaultPromptTemplate - } catch { - selectedProvider = .default - autoDetectMeetings = false - isAutoStopRecording = false - customPromptTemplateValue = UserPreferencesInfo.defaultPromptTemplate - } - await loadModels() - } - - func loadModels() async { - isLoading = true - errorMessage = nil - - do { - availableModels = try await llmService.getAvailableModels() - selectedModel = try await llmService.getSelectedModel() - - if selectedModel == nil, let firstModel = availableModels.first { - await selectModel(firstModel) - } - } catch { - errorMessage = error.localizedDescription - } - - isLoading = false - } - - func selectModel(_ model: LLMModelInfo) async { - errorMessage = nil - selectedModel = model - - do { - try await llmService.selectModel(id: model.id) - } catch { - errorMessage = error.localizedDescription - selectedModel = nil + } + ) + } + + var manualModelName: Binding { + Binding( + get: { self.manualModelNameValue }, + set: { newValue in + Task { + await self.selectManualModel(newValue) } + } + ) + } + + @Published var isLoading = false + @Published var errorMessage: String? + @Published private(set) var showToast = false + @Published private(set) var toastMessage = "" + @Published private(set) var activeWarnings: [WarningItem] = [] + @Published var showAPIKeyAlert = false + @Published var existingAPIKey: String? + @Published var showOpenAIAlert = false + @Published var existingOpenAIKey: String? + @Published var existingOpenAIEndpoint: String? + @Published var isTestingProvider = false + @Published var testResult: String? + + var hasModels: Bool { + !availableModels.isEmpty + } + + var currentSelection: LLMModelInfo? { + selectedModel + } + + let llmService: LLMServiceType + let userPreferencesRepository: UserPreferencesRepositoryType + let keychainAPIValidator: KeychainAPIValidatorType + let keychainService: KeychainServiceType + private let warningManager: any WarningManagerType + private let fileManagerHelper: RecordingFileManagerHelperType + private var cancellables = Set() + + lazy var folderSettingsViewModel: FolderSettingsViewModelType = { + FolderSettingsViewModel( + userPreferencesRepository: userPreferencesRepository, + fileManagerHelper: fileManagerHelper + ) + }() + + init( + llmService: LLMServiceType, + userPreferencesRepository: UserPreferencesRepositoryType, + keychainAPIValidator: KeychainAPIValidatorType, + keychainService: KeychainServiceType, + warningManager: any WarningManagerType, + fileManagerHelper: RecordingFileManagerHelperType + ) { + self.llmService = llmService + self.userPreferencesRepository = userPreferencesRepository + self.keychainAPIValidator = keychainAPIValidator + self.keychainService = keychainService + self.warningManager = warningManager + self.fileManagerHelper = fileManagerHelper + + setupWarningObserver() + + Task { + await loadInitialState() } - - func selectProvider(_ provider: LLMProvider) async { - errorMessage = nil - - if provider == .openRouter { - let validation = keychainAPIValidator.validateOpenRouterAPI() - - if !validation.isValid { - do { - existingAPIKey = try keychainService.retrieveOpenRouterAPIKey() - } catch { - existingAPIKey = nil - } - showAPIKeyAlert = true - return - } - } - - selectedProvider = provider - - do { - try await llmService.selectProvider(provider) - - let newModels = try await llmService.getAvailableModels() - availableModels = newModels - - let currentSelection = try await llmService.getSelectedModel() - let isCurrentModelAvailable = newModels.contains { $0.id == currentSelection?.id } - - if !isCurrentModelAvailable, let firstModel = newModels.first { - await selectModel(firstModel) - } else { - selectedModel = currentSelection - } - } catch { - errorMessage = error.localizedDescription - } + } + + private func setupWarningObserver() { + warningManager.activeWarningsPublisher + .assign(to: \.activeWarnings, on: self) + .store(in: &cancellables) + } + + private func loadInitialState() async { + do { + let preferences = try await llmService.getUserPreferences() + selectedProvider = preferences.selectedProvider + autoDetectMeetings = preferences.autoDetectMeetings + isAutoStopRecording = preferences.autoStopRecording + isAutoSummarizeEnabled = preferences.autoSummarizeEnabled + isAutoTranscribeEnabled = preferences.autoTranscribeEnabled + customPromptTemplateValue = + preferences.summaryPromptTemplate ?? UserPreferencesInfo.defaultPromptTemplate + globalShortcutKeyCode = preferences.globalShortcutKeyCode + globalShortcutModifiers = preferences.globalShortcutModifiers + } catch { + selectedProvider = .default + autoDetectMeetings = false + isAutoStopRecording = false + isAutoSummarizeEnabled = true + isAutoTranscribeEnabled = true + customPromptTemplateValue = UserPreferencesInfo.defaultPromptTemplate + globalShortcutKeyCode = 15 // 'R' key + globalShortcutModifiers = 1_048_840 // Cmd key + } + await loadModels() + } + + func selectProvider(_ provider: LLMProvider) async { + errorMessage = nil + + guard await validateProviderCredentials(provider) else { + return } - - private func showValidationToast(_ message: String) { - toastMessage = message - showToast = true - - Task { - try? await Task.sleep(nanoseconds: 3_000_000_000) - showToast = false - } + + selectedProvider = provider + + do { + try await llmService.selectProvider(provider) + await updateModelsForNewProvider() + } catch { + errorMessage = error.localizedDescription } - - func toggleAutoDetectMeetings(_ enabled: Bool) async { - errorMessage = nil - autoDetectMeetings = enabled - - do { - try await userPreferencesRepository.updateAutoDetectMeetings(enabled) - } catch { - errorMessage = error.localizedDescription - autoDetectMeetings = !enabled - } + } + + private func showValidationToast(_ message: String) { + toastMessage = message + showToast = true + + Task { + try? await Task.sleep(nanoseconds: 3_000_000_000) + showToast = false } - - func updateCustomPromptTemplate(_ template: String) async { - customPromptTemplateValue = template - - do { - let templateToSave = template.isEmpty ? nil : template - try await userPreferencesRepository.updateSummaryPromptTemplate(templateToSave) - } catch { - errorMessage = error.localizedDescription - } + } + + func toggleAutoDetectMeetings(_ enabled: Bool) async { + errorMessage = nil + autoDetectMeetings = enabled + + do { + try await userPreferencesRepository.updateAutoDetectMeetings(enabled) + } catch { + errorMessage = error.localizedDescription + autoDetectMeetings = !enabled } + } - func resetToDefaultPrompt() async { - await updateCustomPromptTemplate(UserPreferencesInfo.defaultPromptTemplate) + func updateCustomPromptTemplate(_ template: String) async { + customPromptTemplateValue = template + + do { + let templateToSave = template.isEmpty ? nil : template + try await userPreferencesRepository.updateSummaryPromptTemplate(templateToSave) + } catch { + errorMessage = error.localizedDescription } - - func toggleAutoStopRecording(_ enabled: Bool) async { - errorMessage = nil - isAutoStopRecording = enabled - - do { - try await userPreferencesRepository.updateAutoStopRecording(enabled) - } catch { - errorMessage = error.localizedDescription - isAutoStopRecording = !enabled - } + } + + func resetToDefaultPrompt() async { + await updateCustomPromptTemplate(UserPreferencesInfo.defaultPromptTemplate) + } + + func toggleAutoStopRecording(_ enabled: Bool) async { + errorMessage = nil + isAutoStopRecording = enabled + + do { + try await userPreferencesRepository.updateAutoStopRecording(enabled) + } catch { + errorMessage = error.localizedDescription + isAutoStopRecording = !enabled } - - func saveAPIKey(_ apiKey: String) async throws { - try keychainService.storeOpenRouterAPIKey(apiKey) - - existingAPIKey = apiKey - showAPIKeyAlert = false - - await selectProvider(.openRouter) + } + + func toggleAutoSummarize(_ enabled: Bool) async { + errorMessage = nil + isAutoSummarizeEnabled = enabled + + do { + try await userPreferencesRepository.updateAutoSummarize(enabled) + } catch { + errorMessage = error.localizedDescription + isAutoSummarizeEnabled = !enabled } - - func dismissAPIKeyAlert() { - showAPIKeyAlert = false - existingAPIKey = nil + } + + func toggleAutoTranscribe(_ enabled: Bool) async { + errorMessage = nil + isAutoTranscribeEnabled = enabled + + do { + try await userPreferencesRepository.updateAutoTranscribe(enabled) + } catch { + errorMessage = error.localizedDescription + isAutoTranscribeEnabled = !enabled } + } + + func updateGlobalShortcut(keyCode: Int32, modifiers: Int32) async { + errorMessage = nil + globalShortcutKeyCode = keyCode + globalShortcutModifiers = modifiers + + do { + try await userPreferencesRepository.updateGlobalShortcut( + keyCode: keyCode, modifiers: modifiers) + } catch { + errorMessage = error.localizedDescription + // Revert on error - we'd need to reload from preferences + let preferences = try? await userPreferencesRepository.getOrCreatePreferences() + globalShortcutKeyCode = preferences?.globalShortcutKeyCode ?? 15 + globalShortcutModifiers = preferences?.globalShortcutModifiers ?? 1_048_840 + } + } + } diff --git a/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelType.swift b/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelType.swift index cfa3dc8..4185c1f 100644 --- a/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelType.swift +++ b/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelType.swift @@ -1,32 +1,50 @@ -import Foundation import Combine +import Foundation import SwiftUI @MainActor protocol GeneralSettingsViewModelType: ObservableObject { - var availableModels: [LLMModelInfo] { get } - var selectedModel: LLMModelInfo? { get } - var selectedProvider: LLMProvider { get } - var autoDetectMeetings: Bool { get } - var isAutoStopRecording: Bool { get } - var isLoading: Bool { get } - var errorMessage: String? { get } - var hasModels: Bool { get } - var currentSelection: LLMModelInfo? { get } - var showToast: Bool { get } - var toastMessage: String { get } - var activeWarnings: [WarningItem] { get } - var customPromptTemplate: Binding { get } - var showAPIKeyAlert: Bool { get } - var existingAPIKey: String? { get } - - func loadModels() async - func selectModel(_ model: LLMModelInfo) async - func selectProvider(_ provider: LLMProvider) async - func toggleAutoDetectMeetings(_ enabled: Bool) async - func toggleAutoStopRecording(_ enabled: Bool) async - func updateCustomPromptTemplate(_ template: String) async - func resetToDefaultPrompt() async - func saveAPIKey(_ apiKey: String) async throws - func dismissAPIKeyAlert() + var availableModels: [LLMModelInfo] { get } + var selectedModel: LLMModelInfo? { get } + var selectedProvider: LLMProvider { get } + var autoDetectMeetings: Bool { get } + var isAutoStopRecording: Bool { get } + var isAutoSummarizeEnabled: Bool { get } + var isAutoTranscribeEnabled: Bool { get } + var isLoading: Bool { get } + var errorMessage: String? { get } + var hasModels: Bool { get } + var currentSelection: LLMModelInfo? { get } + var showToast: Bool { get } + var toastMessage: String { get } + var activeWarnings: [WarningItem] { get } + var customPromptTemplate: Binding { get } + var showAPIKeyAlert: Bool { get } + var existingAPIKey: String? { get } + var showOpenAIAlert: Bool { get } + var existingOpenAIKey: String? { get } + var existingOpenAIEndpoint: String? { get } + var globalShortcutKeyCode: Int32 { get } + var globalShortcutModifiers: Int32 { get } + var folderSettingsViewModel: FolderSettingsViewModelType { get } + var manualModelName: Binding { get } + var isTestingProvider: Bool { get } + var testResult: String? { get } + + func loadModels() async + func selectModel(_ model: LLMModelInfo) async + func selectManualModel(_ modelName: String) async + func selectProvider(_ provider: LLMProvider) async + func toggleAutoDetectMeetings(_ enabled: Bool) async + func toggleAutoStopRecording(_ enabled: Bool) async + func toggleAutoSummarize(_ enabled: Bool) async + func toggleAutoTranscribe(_ enabled: Bool) async + func updateCustomPromptTemplate(_ template: String) async + func resetToDefaultPrompt() async + func saveAPIKey(_ apiKey: String) async throws + func dismissAPIKeyAlert() + func saveOpenAIConfiguration(apiKey: String, endpoint: String) async throws + func dismissOpenAIAlert() + func updateGlobalShortcut(keyCode: Int32, modifiers: Int32) async + func testLLMProvider() async } diff --git a/Recap/UseCases/Settings/ViewModels/LLM/LLMModelsViewModel.swift b/Recap/UseCases/Settings/ViewModels/LLM/LLMModelsViewModel.swift index d1b8a13..c1b6f4d 100644 --- a/Recap/UseCases/Settings/ViewModels/LLM/LLMModelsViewModel.swift +++ b/Recap/UseCases/Settings/ViewModels/LLM/LLMModelsViewModel.swift @@ -1,90 +1,90 @@ -import Foundation import Combine +import Foundation @MainActor final class LLMModelsViewModel: ObservableObject, LLMModelsViewModelType { - @Published private(set) var availableModels: [LLMModelInfo] = [] - @Published private(set) var selectedModelId: String? - @Published private(set) var isLoading = false - @Published private(set) var errorMessage: String? - @Published private(set) var providerStatus: ProviderStatus - @Published private(set) var isProviderAvailable = false - - private let llmService: LLMServiceType - private let llmModelRepository: LLMModelRepositoryType - private let userPreferencesRepository: UserPreferencesRepositoryType - private var cancellables = Set() - - init( - llmService: LLMServiceType, - llmModelRepository: LLMModelRepositoryType, - userPreferencesRepository: UserPreferencesRepositoryType - ) { - self.llmService = llmService - self.llmModelRepository = llmModelRepository - self.userPreferencesRepository = userPreferencesRepository - self.providerStatus = .ollama(isAvailable: false) - - setupBindings() - Task { - await loadInitialData() - } - } - - func refreshModels() async { - isLoading = true - errorMessage = nil - - do { - availableModels = try await llmService.getAvailableModels() - - let preferences = try await userPreferencesRepository.getOrCreatePreferences() - selectedModelId = preferences.selectedLLMModelID - } catch { - errorMessage = error.localizedDescription - } - - isLoading = false + @Published private(set) var availableModels: [LLMModelInfo] = [] + @Published private(set) var selectedModelId: String? + @Published private(set) var isLoading = false + @Published private(set) var errorMessage: String? + @Published private(set) var providerStatus: ProviderStatus + @Published private(set) var isProviderAvailable = false + + private let llmService: LLMServiceType + private let llmModelRepository: LLMModelRepositoryType + private let userPreferencesRepository: UserPreferencesRepositoryType + private var cancellables = Set() + + init( + llmService: LLMServiceType, + llmModelRepository: LLMModelRepositoryType, + userPreferencesRepository: UserPreferencesRepositoryType + ) { + self.llmService = llmService + self.llmModelRepository = llmModelRepository + self.userPreferencesRepository = userPreferencesRepository + self.providerStatus = .ollama(isAvailable: false) + + setupBindings() + Task { + await loadInitialData() } - - func selectModel(_ model: LLMModelInfo) async { - errorMessage = nil - - do { - try await llmService.selectModel(id: model.id) - selectedModelId = model.id - } catch { - errorMessage = error.localizedDescription - } + } + + func refreshModels() async { + isLoading = true + errorMessage = nil + + do { + availableModels = try await llmService.getAvailableModels() + + let preferences = try await userPreferencesRepository.getOrCreatePreferences() + selectedModelId = preferences.selectedLLMModelID + } catch { + errorMessage = error.localizedDescription } - - private func setupBindings() { - llmService.providerAvailabilityPublisher - .sink { [weak self] isAvailable in - self?.isProviderAvailable = isAvailable - self?.providerStatus = .ollama(isAvailable: isAvailable) - - if isAvailable { - Task { - await self?.refreshModels() - } - } - } - .store(in: &cancellables) + + isLoading = false + } + + func selectModel(_ model: LLMModelInfo) async { + errorMessage = nil + + do { + try await llmService.selectModel(id: model.id) + selectedModelId = model.id + } catch { + errorMessage = error.localizedDescription } - - private func loadInitialData() async { - isLoading = true - - do { - availableModels = try await llmService.getAvailableModels() - - let preferences = try await userPreferencesRepository.getOrCreatePreferences() - selectedModelId = preferences.selectedLLMModelID - } catch { - errorMessage = error.localizedDescription + } + + private func setupBindings() { + llmService.providerAvailabilityPublisher + .sink { [weak self] isAvailable in + self?.isProviderAvailable = isAvailable + self?.providerStatus = .ollama(isAvailable: isAvailable) + + if isAvailable { + Task { + await self?.refreshModels() + } } - - isLoading = false + } + .store(in: &cancellables) + } + + private func loadInitialData() async { + isLoading = true + + do { + availableModels = try await llmService.getAvailableModels() + + let preferences = try await userPreferencesRepository.getOrCreatePreferences() + selectedModelId = preferences.selectedLLMModelID + } catch { + errorMessage = error.localizedDescription } -} \ No newline at end of file + + isLoading = false + } +} diff --git a/Recap/UseCases/Settings/ViewModels/LLM/LLMModelsViewModelType.swift b/Recap/UseCases/Settings/ViewModels/LLM/LLMModelsViewModelType.swift index 69c2048..f3c9787 100644 --- a/Recap/UseCases/Settings/ViewModels/LLM/LLMModelsViewModelType.swift +++ b/Recap/UseCases/Settings/ViewModels/LLM/LLMModelsViewModelType.swift @@ -1,15 +1,15 @@ -import Foundation import Combine +import Foundation @MainActor protocol LLMModelsViewModelType: ObservableObject { - var availableModels: [LLMModelInfo] { get } - var selectedModelId: String? { get } - var isLoading: Bool { get } - var errorMessage: String? { get } - var providerStatus: ProviderStatus { get } - var isProviderAvailable: Bool { get } - - func refreshModels() async - func selectModel(_ model: LLMModelInfo) async -} \ No newline at end of file + var availableModels: [LLMModelInfo] { get } + var selectedModelId: String? { get } + var isLoading: Bool { get } + var errorMessage: String? { get } + var providerStatus: ProviderStatus { get } + var isProviderAvailable: Bool { get } + + func refreshModels() async + func selectModel(_ model: LLMModelInfo) async +} diff --git a/Recap/UseCases/Settings/ViewModels/MeetingDetection/MeetingDetectionSettingsViewModel.swift b/Recap/UseCases/Settings/ViewModels/MeetingDetection/MeetingDetectionSettingsViewModel.swift index de34410..e49031a 100644 --- a/Recap/UseCases/Settings/ViewModels/MeetingDetection/MeetingDetectionSettingsViewModel.swift +++ b/Recap/UseCases/Settings/ViewModels/MeetingDetection/MeetingDetectionSettingsViewModel.swift @@ -3,68 +3,71 @@ import SwiftUI @MainActor final class MeetingDetectionSettingsViewModel: MeetingDetectionSettingsViewModelType { - @Published var hasScreenRecordingPermission = false - @Published var autoDetectMeetings = false - - private let detectionService: any MeetingDetectionServiceType - private let userPreferencesRepository: UserPreferencesRepositoryType - private let permissionsHelper: any PermissionsHelperType - - init(detectionService: any MeetingDetectionServiceType, - userPreferencesRepository: UserPreferencesRepositoryType, - permissionsHelper: any PermissionsHelperType) { - self.detectionService = detectionService - self.userPreferencesRepository = userPreferencesRepository - self.permissionsHelper = permissionsHelper - - Task { - await loadCurrentSettings() - } + @Published var hasScreenRecordingPermission = false + @Published var autoDetectMeetings = false + + private let detectionService: any MeetingDetectionServiceType + private let userPreferencesRepository: UserPreferencesRepositoryType + private let permissionsHelper: any PermissionsHelperType + + init( + detectionService: any MeetingDetectionServiceType, + userPreferencesRepository: UserPreferencesRepositoryType, + permissionsHelper: any PermissionsHelperType + ) { + self.detectionService = detectionService + self.userPreferencesRepository = userPreferencesRepository + self.permissionsHelper = permissionsHelper + + Task { + await loadCurrentSettings() + } + } + + private func loadCurrentSettings() async { + guard let preferences = try? await userPreferencesRepository.getOrCreatePreferences() else { + return + } + + withAnimation(.easeInOut(duration: 0.2)) { + autoDetectMeetings = preferences.autoDetectMeetings } - - private func loadCurrentSettings() async { - guard let preferences = try? await userPreferencesRepository.getOrCreatePreferences() else { - return - } + } - withAnimation(.easeInOut(duration: 0.2)) { - autoDetectMeetings = preferences.autoDetectMeetings - } + func handleAutoDetectToggle(_ enabled: Bool) async { + try? await userPreferencesRepository.updateAutoDetectMeetings(enabled) + + withAnimation(.easeInOut(duration: 0.2)) { + autoDetectMeetings = enabled } - - func handleAutoDetectToggle(_ enabled: Bool) async { - try? await userPreferencesRepository.updateAutoDetectMeetings(enabled) - - withAnimation(.easeInOut(duration: 0.2)) { - autoDetectMeetings = enabled - } - - if enabled { - let hasPermission = await permissionsHelper.checkScreenCapturePermission() - hasScreenRecordingPermission = hasPermission - - if hasPermission { - detectionService.startMonitoring() - } else { - openScreenRecordingPreferences() - } - } else { - detectionService.stopMonitoring() - } - + + if enabled { + let hasPermission = await permissionsHelper.checkScreenCapturePermission() + hasScreenRecordingPermission = hasPermission + + if hasPermission { + detectionService.startMonitoring() + } else { + openScreenRecordingPreferences() + } + } else { + detectionService.stopMonitoring() } - - func checkPermissionStatus() async { - hasScreenRecordingPermission = await permissionsHelper.checkScreenCapturePermission() - - if autoDetectMeetings && hasScreenRecordingPermission { - detectionService.startMonitoring() - } + + } + + func checkPermissionStatus() async { + hasScreenRecordingPermission = await permissionsHelper.checkScreenCapturePermission() + + if autoDetectMeetings && hasScreenRecordingPermission { + detectionService.startMonitoring() } - - func openScreenRecordingPreferences() { - if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") { - NSWorkspace.shared.open(url) - } + } + + func openScreenRecordingPreferences() { + if let url = URL( + string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") { + NSWorkspace.shared.open(url) } + } } diff --git a/Recap/UseCases/Settings/ViewModels/MeetingDetection/MeetingDetectionSettingsViewModelType.swift b/Recap/UseCases/Settings/ViewModels/MeetingDetection/MeetingDetectionSettingsViewModelType.swift index b2e6c3e..b7170d1 100644 --- a/Recap/UseCases/Settings/ViewModels/MeetingDetection/MeetingDetectionSettingsViewModelType.swift +++ b/Recap/UseCases/Settings/ViewModels/MeetingDetection/MeetingDetectionSettingsViewModelType.swift @@ -2,10 +2,10 @@ import Foundation @MainActor protocol MeetingDetectionSettingsViewModelType: ObservableObject { - var hasScreenRecordingPermission: Bool { get } - var autoDetectMeetings: Bool { get } - - func handleAutoDetectToggle(_ enabled: Bool) async - func checkPermissionStatus() async - func openScreenRecordingPreferences() -} \ No newline at end of file + var hasScreenRecordingPermission: Bool { get } + var autoDetectMeetings: Bool { get } + + func handleAutoDetectToggle(_ enabled: Bool) async + func checkPermissionStatus() async + func openScreenRecordingPreferences() +} diff --git a/Recap/UseCases/Settings/ViewModels/Whisper/WhisperModelsViewModel.swift b/Recap/UseCases/Settings/ViewModels/Whisper/WhisperModelsViewModel.swift index 5dc8a6b..61c92d6 100644 --- a/Recap/UseCases/Settings/ViewModels/Whisper/WhisperModelsViewModel.swift +++ b/Recap/UseCases/Settings/ViewModels/Whisper/WhisperModelsViewModel.swift @@ -3,153 +3,156 @@ import WhisperKit @MainActor final class WhisperModelsViewModel: WhisperModelsViewModelType { - @Published var selectedModel: String? - @Published var downloadedModels: Set = [] - @Published var downloadingModels: Set = [] - @Published var downloadProgress: [String: Double] = [:] - @Published var showingTooltipForModel: String? - @Published var tooltipPosition: CGPoint = .zero - @Published var errorMessage: String? - @Published var showingError = false - - private let repository: WhisperModelRepositoryType - - init(repository: WhisperModelRepositoryType) { - self.repository = repository - Task { - await loadModelsFromRepository() - } - } - - var recommendedModels: [String] { - ModelVariant.multilingualCases - .filter { $0.isRecommended } - .map { $0.description } - } - - var otherModels: [String] { - ModelVariant.multilingualCases - .filter { !$0.isRecommended } - .map { $0.description } - } - - func selectModel(_ modelName: String) { - guard downloadedModels.contains(modelName) else { return } - - Task { - do { - if selectedModel == modelName { - selectedModel = nil - let models = try await repository.getAllModels() - for model in models where model.isSelected { - var updatedModel = model - updatedModel.isSelected = false - try await repository.updateModel(updatedModel) - } - } else { - try await repository.setSelectedModel(name: modelName) - selectedModel = modelName - } - } catch { - showError("Failed to select model: \(error.localizedDescription)") - } - } - } - - func downloadModel(_ modelName: String) { - Task { - do { - downloadingModels.insert(modelName) - downloadProgress[modelName] = 0.0 - - _ = try await WhisperKit.createWithProgress( - model: modelName, - modelRepo: "argmaxinc/whisperkit-coreml", - modelFolder: nil, - download: true, - progressCallback: { [weak self] progress in - Task { @MainActor in - guard let self = self, self.downloadingModels.contains(modelName) else { return } - self.downloadProgress[modelName] = progress.fractionCompleted - } - } - ) - - let modelInfo = await WhisperKit.getModelSizeInfo(for: modelName) - try await repository.markAsDownloaded( - name: modelName, - sizeInMB: Int64(modelInfo.totalSizeMB) - ) - - downloadedModels.insert(modelName) - downloadingModels.remove(modelName) - downloadProgress[modelName] = 1.0 - } catch { - downloadingModels.remove(modelName) - downloadProgress.removeValue(forKey: modelName) - showError("Failed to download model: \(error.localizedDescription)") - } - } + @Published var selectedModel: String? + @Published var downloadedModels: Set = [] + @Published var downloadingModels: Set = [] + @Published var downloadProgress: [String: Double] = [:] + @Published var showingTooltipForModel: String? + @Published var tooltipPosition: CGPoint = .zero + @Published var errorMessage: String? + @Published var showingError = false + + private let repository: WhisperModelRepositoryType + + init(repository: WhisperModelRepositoryType) { + self.repository = repository + Task { + await loadModelsFromRepository() } - - func toggleTooltip(for modelName: String, at position: CGPoint) { - if showingTooltipForModel == modelName { - showingTooltipForModel = nil + } + + var recommendedModels: [String] { + ModelVariant.multilingualCases + .filter { $0.isRecommended } + .map { $0.description } + } + + var otherModels: [String] { + ModelVariant.multilingualCases + .filter { !$0.isRecommended } + .map { $0.description } + } + + func selectModel(_ modelName: String) { + guard downloadedModels.contains(modelName) else { return } + + Task { + do { + if selectedModel == modelName { + selectedModel = nil + let models = try await repository.getAllModels() + for model in models where model.isSelected { + var updatedModel = model + updatedModel.isSelected = false + try await repository.updateModel(updatedModel) + } } else { - showingTooltipForModel = modelName - tooltipPosition = position + try await repository.setSelectedModel(name: modelName) + selectedModel = modelName } + } catch { + showError("Failed to select model: \(error.localizedDescription)") + } } - - func getModelInfo(_ name: String) -> ModelInfo? { - let baseModelName = name.replacingOccurrences(of: "-v2", with: "").replacingOccurrences(of: "-v3", with: "") - return String.modelInfoData[baseModelName] + } + + func downloadModel(_ modelName: String) { + Task { + do { + downloadingModels.insert(modelName) + downloadProgress[modelName] = 0.0 + + _ = try await WhisperKit.createWithProgress( + model: modelName, + modelRepo: "argmaxinc/whisperkit-coreml", + modelFolder: nil, + download: true, + progressCallback: { [weak self] progress in + Task { @MainActor in + guard let self = self, self.downloadingModels.contains(modelName) else { + return + } + self.downloadProgress[modelName] = progress.fractionCompleted + } + } + ) + + let modelInfo = await WhisperKit.getModelSizeInfo(for: modelName) + try await repository.markAsDownloaded( + name: modelName, + sizeInMB: Int64(modelInfo.totalSizeMB) + ) + + downloadedModels.insert(modelName) + downloadingModels.remove(modelName) + downloadProgress[modelName] = 1.0 + } catch { + downloadingModels.remove(modelName) + downloadProgress.removeValue(forKey: modelName) + showError("Failed to download model: \(error.localizedDescription)") + } } - - func modelDisplayName(_ name: String) -> String { - switch name { - case "large-v2": - return "Large v2" - case "large-v3": - return "Large v3" - case "distil-whisper_distil-large-v3_turbo": - return "Distil Large v3 Turbo" - default: - return name.capitalized - } + } + + func toggleTooltip(for modelName: String, at position: CGPoint) { + if showingTooltipForModel == modelName { + showingTooltipForModel = nil + } else { + showingTooltipForModel = modelName + tooltipPosition = position } - - private func showError(_ message: String) { - errorMessage = message - showingError = true + } + + func getModelInfo(_ name: String) -> ModelInfo? { + let baseModelName = name.replacingOccurrences(of: "-v2", with: "").replacingOccurrences( + of: "-v3", with: "") + return String.modelInfoData[baseModelName] + } + + func modelDisplayName(_ name: String) -> String { + switch name { + case "large-v2": + return "Large v2" + case "large-v3": + return "Large v3" + case "distil-whisper_distil-large-v3_turbo": + return "Distil Large v3 Turbo" + default: + return name.capitalized } - - private func loadModelsFromRepository() async { - do { - let models = try await repository.getAllModels() - let downloaded = models.filter { $0.isDownloaded } - downloadedModels = Set(downloaded.map { $0.name }) - - if let selected = models.first(where: { $0.isSelected }) { - selectedModel = selected.name - } - } catch { - showError("Failed to load models: \(error.localizedDescription)") - } + } + + private func showError(_ message: String) { + errorMessage = message + showingError = true + } + + private func loadModelsFromRepository() async { + do { + let models = try await repository.getAllModels() + let downloaded = models.filter { $0.isDownloaded } + downloadedModels = Set(downloaded.map { $0.name }) + + if let selected = models.first(where: { $0.isSelected }) { + selectedModel = selected.name + } + } catch { + showError("Failed to load models: \(error.localizedDescription)") } + } } extension ModelVariant { - static var multilingualCases: [ModelVariant] { - return allCases.filter { $0.isMultilingual } - } - - var isRecommended: Bool { - switch self { - case .largev3, .medium, .small: - return true - default: - return false - } + static var multilingualCases: [ModelVariant] { + return allCases.filter { $0.isMultilingual } + } + + var isRecommended: Bool { + switch self { + case .largev3, .medium, .small: + return true + default: + return false } + } } diff --git a/Recap/UseCases/Settings/ViewModels/Whisper/WhisperModelsViewModelType.swift b/Recap/UseCases/Settings/ViewModels/Whisper/WhisperModelsViewModelType.swift index 7efaaf0..67140ba 100644 --- a/Recap/UseCases/Settings/ViewModels/Whisper/WhisperModelsViewModelType.swift +++ b/Recap/UseCases/Settings/ViewModels/Whisper/WhisperModelsViewModelType.swift @@ -2,20 +2,20 @@ import SwiftUI @MainActor protocol WhisperModelsViewModelType: ObservableObject { - var selectedModel: String? { get } - var downloadedModels: Set { get } - var downloadingModels: Set { get } - var downloadProgress: [String: Double] { get } - var showingTooltipForModel: String? { get } - var tooltipPosition: CGPoint { get } - var errorMessage: String? { get } - var showingError: Bool { get } - var recommendedModels: [String] { get } - var otherModels: [String] { get } - - func selectModel(_ modelName: String) - func downloadModel(_ modelName: String) - func toggleTooltip(for modelName: String, at position: CGPoint) - func getModelInfo(_ name: String) -> ModelInfo? - func modelDisplayName(_ name: String) -> String + var selectedModel: String? { get } + var downloadedModels: Set { get } + var downloadingModels: Set { get } + var downloadProgress: [String: Double] { get } + var showingTooltipForModel: String? { get } + var tooltipPosition: CGPoint { get } + var errorMessage: String? { get } + var showingError: Bool { get } + var recommendedModels: [String] { get } + var otherModels: [String] { get } + + func selectModel(_ modelName: String) + func downloadModel(_ modelName: String) + func toggleTooltip(for modelName: String, at position: CGPoint) + func getModelInfo(_ name: String) -> ModelInfo? + func modelDisplayName(_ name: String) -> String } diff --git a/Recap/UseCases/Summary/Components/ProcessingProgressBar.swift b/Recap/UseCases/Summary/Components/ProcessingProgressBar.swift index a91fa5e..1674dbe 100644 --- a/Recap/UseCases/Summary/Components/ProcessingProgressBar.swift +++ b/Recap/UseCases/Summary/Components/ProcessingProgressBar.swift @@ -1,210 +1,210 @@ import SwiftUI struct ProcessingProgressBar: View { - let state: ProgressState - - enum ProgressState { - case pending - case current - case completed - } - - var body: some View { - GeometryReader { geometry in - ZStack(alignment: .leading) { - backgroundBar - - if state == .completed { - completedBar(width: geometry.size.width) - } else if state == .current { - currentBar(width: geometry.size.width) - } else { - pendingSlashes(width: geometry.size.width) - } - } + let state: ProgressState + + enum ProgressState { + case pending + case current + case completed + } + + var body: some View { + GeometryReader { geometry in + ZStack(alignment: .leading) { + backgroundBar + + if state == .completed { + completedBar(width: geometry.size.width) + } else if state == .current { + currentBar(width: geometry.size.width) + } else { + pendingSlashes(width: geometry.size.width) } - .frame(height: 6) - } - - private var backgroundBar: some View { - RoundedRectangle(cornerRadius: 3) - .fill(Color(hex: "1A1A1A").opacity(0.4)) - .overlay( - RoundedRectangle(cornerRadius: 3) - .stroke( - LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color(hex: "979797").opacity(0.1), location: 0), - .init(color: Color(hex: "979797").opacity(0.05), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ), - lineWidth: 0.5 - ) - ) - } - - private func completedBar(width: CGFloat) -> some View { - RoundedRectangle(cornerRadius: 3) - .fill( - LinearGradient( - gradient: Gradient(stops: [ - .init(color: UIConstants.Colors.audioGreen.opacity(0.4), location: 0), - .init(color: UIConstants.Colors.audioGreen.opacity(0.3), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ) - ) - .frame(width: width) + } } - - private func currentBar(width: CGFloat) -> some View { + .frame(height: 6) + } + + private var backgroundBar: some View { + RoundedRectangle(cornerRadius: 3) + .fill(Color(hex: "1A1A1A").opacity(0.4)) + .overlay( RoundedRectangle(cornerRadius: 3) - .fill( - LinearGradient( - gradient: Gradient(stops: [ - .init(color: UIConstants.Colors.audioGreen.opacity(0.7), location: 0), - .init(color: UIConstants.Colors.audioGreen.opacity(0.5), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ) - ) - .frame(width: width * 0.6) - } - - private func pendingSlashes(width: CGFloat) -> some View { - ZStack { - RoundedRectangle(cornerRadius: 3) - .fill(Color.clear) - .frame(width: width, height: 6) - .overlay( - HStack(spacing: 4) { - ForEach(0.. some View { + RoundedRectangle(cornerRadius: 3) + .fill( + LinearGradient( + gradient: Gradient(stops: [ + .init(color: UIConstants.Colors.audioGreen.opacity(0.4), location: 0), + .init(color: UIConstants.Colors.audioGreen.opacity(0.3), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ) + ) + .frame(width: width) + } + + private func currentBar(width: CGFloat) -> some View { + RoundedRectangle(cornerRadius: 3) + .fill( + LinearGradient( + gradient: Gradient(stops: [ + .init(color: UIConstants.Colors.audioGreen.opacity(0.7), location: 0), + .init(color: UIConstants.Colors.audioGreen.opacity(0.5), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ) + ) + .frame(width: width * 0.6) + } + + private func pendingSlashes(width: CGFloat) -> some View { + ZStack { + RoundedRectangle(cornerRadius: 3) + .fill(Color.clear) + .frame(width: width, height: 6) + .overlay( + HStack(spacing: 4) { + ForEach(0.. ProcessingProgressBar.ProgressState { - if stage.rawValue < currentStage.rawValue { - return .completed - } else if stage == currentStage { - return .current - } else { - return .pending - } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func progressState(for stage: ProcessingStage) -> ProcessingProgressBar.ProgressState { + if stage.rawValue < currentStage.rawValue { + return .completed + } else if stage == currentStage { + return .current + } else { + return .pending } + } } diff --git a/Recap/UseCases/Summary/SummaryView+MarkdownStyles.swift b/Recap/UseCases/Summary/SummaryView+MarkdownStyles.swift new file mode 100644 index 0000000..ab751b9 --- /dev/null +++ b/Recap/UseCases/Summary/SummaryView+MarkdownStyles.swift @@ -0,0 +1,47 @@ +import MarkdownUI +import SwiftUI + +extension SummaryView { + func markdownContent(_ summaryText: String) -> some View { + Markdown(summaryText) + .markdownTheme(.docC) + .markdownTextStyle { + ForegroundColor(UIConstants.Colors.textSecondary) + FontSize(12) + } + .markdownBlockStyle(\.heading1) { configuration in + configuration.label + .markdownTextStyle { + FontWeight(.bold) + FontSize(18) + ForegroundColor(UIConstants.Colors.textPrimary) + } + .padding(.vertical, 8) + } + .markdownBlockStyle(\.heading2) { configuration in + configuration.label + .markdownTextStyle { + FontWeight(.semibold) + FontSize(16) + ForegroundColor(UIConstants.Colors.textPrimary) + } + .padding(.vertical, 6) + } + .markdownBlockStyle(\.heading3) { configuration in + configuration.label + .markdownTextStyle { + FontWeight(.medium) + FontSize(14) + ForegroundColor(UIConstants.Colors.textPrimary) + } + .padding(.vertical, 4) + } + .markdownBlockStyle(\.listItem) { configuration in + configuration.label + .markdownTextStyle { + FontSize(12) + } + } + .textSelection(.enabled) + } +} diff --git a/Recap/UseCases/Summary/SummaryView+RecordingState.swift b/Recap/UseCases/Summary/SummaryView+RecordingState.swift new file mode 100644 index 0000000..38f6ce6 --- /dev/null +++ b/Recap/UseCases/Summary/SummaryView+RecordingState.swift @@ -0,0 +1,114 @@ +import SwiftUI + +extension SummaryView { + func recordingStateInfo(_ recording: RecordingInfo) -> some View { + VStack(alignment: .leading, spacing: 12) { + stateHeader(recording) + + if needsActionButtons(for: recording) { + actionSection(recording) + } + } + .padding(12) + .background(Color(hex: "242323").opacity(0.3)) + .cornerRadius(8) + } + + func stateHeader(_ recording: RecordingInfo) -> some View { + HStack { + Text("Recording State:") + .font(UIConstants.Typography.bodyText) + .foregroundColor(UIConstants.Colors.textSecondary) + + Text(recording.state.displayName) + .font(UIConstants.Typography.bodyText.weight(.semibold)) + .foregroundColor(stateColor(for: recording.state)) + } + } + + func needsActionButtons(for recording: RecordingInfo) -> Bool { + recording.state == .recording || recording.state == .recorded || recording.state.isFailed + } + + func actionSection(_ recording: RecordingInfo) -> some View { + VStack(alignment: .leading, spacing: 8) { + stateWarningMessage(recording) + actionButtons + } + } + + @ViewBuilder + func stateWarningMessage(_ recording: RecordingInfo) -> some View { + if recording.state == .recording { + Text("This recording is stuck in 'Recording' state.") + .font(.caption) + .foregroundColor(.orange) + } else if recording.state.isFailed { + Text("This recording has failed processing.") + .font(.caption) + .foregroundColor(.red) + } + } + + var actionButtons: some View { + HStack(spacing: 8) { + fixAndProcessButton + markCompletedButton + } + } + + var fixAndProcessButton: some View { + Button { + Task { + await viewModel.fixStuckRecording() + } + } label: { + HStack(spacing: 6) { + Image(systemName: "wrench.and.screwdriver") + Text("Fix & Process") + } + .font(.caption.weight(.medium)) + .foregroundColor(.white) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.orange) + .cornerRadius(6) + } + .buttonStyle(.plain) + } + + var markCompletedButton: some View { + Button { + Task { + await viewModel.markAsCompleted() + } + } label: { + HStack(spacing: 6) { + Image(systemName: "checkmark.circle") + Text("Mark Completed") + } + .font(.caption.weight(.medium)) + .foregroundColor(.white) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.green.opacity(0.8)) + .cornerRadius(6) + } + .buttonStyle(.plain) + } + + func stateColor(for state: RecordingProcessingState) -> Color { + switch state { + case .completed: + return UIConstants.Colors.audioGreen + case .transcriptionFailed, .summarizationFailed: + return .red + case .transcribing, .summarizing: + return .orange + case .recording: + return .yellow + default: + return UIConstants.Colors.textTertiary + } + } +} diff --git a/Recap/UseCases/Summary/SummaryView.swift b/Recap/UseCases/Summary/SummaryView.swift index 41f790d..fed431d 100644 --- a/Recap/UseCases/Summary/SummaryView.swift +++ b/Recap/UseCases/Summary/SummaryView.swift @@ -1,247 +1,259 @@ -import SwiftUI import MarkdownUI +import SwiftUI struct SummaryView: View { - let onClose: () -> Void - @ObservedObject var viewModel: ViewModel - let recordingID: String? - - init( - onClose: @escaping () -> Void, - viewModel: ViewModel, - recordingID: String? = nil + let onClose: () -> Void + @ObservedObject var viewModel: ViewModel + let recordingID: String? + + init( + onClose: @escaping () -> Void, + viewModel: ViewModel, + recordingID: String? = nil + ) { + self.onClose = onClose + self.viewModel = viewModel + self.recordingID = recordingID + } + + var body: some View { + GeometryReader { geometry in + ZStack { + UIConstants.Gradients.backgroundGradient + .ignoresSafeArea() + + VStack(spacing: UIConstants.Spacing.sectionSpacing) { + headerView + + if viewModel.isLoadingRecording { + loadingView + } else if let errorMessage = viewModel.errorMessage { + errorView(errorMessage) + } else if viewModel.currentRecording == nil { + noRecordingView + } else if viewModel.isProcessing { + processingView(geometry: geometry) + } else if viewModel.isRecordingReady { + summaryView + } else if let recording = viewModel.currentRecording { + stuckRecordingView(recording) + } else { + errorView("Recording is in an unexpected state") + } + + Spacer() + } + } + } + .onAppear { + if let recordingID = recordingID { + viewModel.loadRecording(withID: recordingID) + } else { + viewModel.loadLatestRecording() + } + viewModel.startAutoRefresh() + } + .onDisappear { + viewModel.stopAutoRefresh() + } + .toast( + isPresenting: .init( + get: { viewModel.showingCopiedToast }, + set: { _ in } + ) ) { - self.onClose = onClose - self.viewModel = viewModel - self.recordingID = recordingID + AlertToast( + displayMode: .banner(.pop), + type: .complete(UIConstants.Colors.audioGreen), + title: "Copied to clipboard" + ) } - - var body: some View { - GeometryReader { geometry in - ZStack { - UIConstants.Gradients.backgroundGradient - .ignoresSafeArea() - - VStack(spacing: UIConstants.Spacing.sectionSpacing) { - headerView - - if viewModel.isLoadingRecording { - loadingView - } else if let errorMessage = viewModel.errorMessage { - errorView(errorMessage) - } else if viewModel.currentRecording == nil { - noRecordingView - } else if viewModel.isProcessing { - processingView(geometry: geometry) - } else if viewModel.hasSummary { - summaryView - } else { - errorView(viewModel.currentRecording?.errorMessage ?? "Recording is in an unexpected state") - } - - Spacer() - } - } - } - .onAppear { - if let recordingID = recordingID { - viewModel.loadRecording(withID: recordingID) - } else { - viewModel.loadLatestRecording() - } - viewModel.startAutoRefresh() - } - .onDisappear { - viewModel.stopAutoRefresh() - } - .toast(isPresenting: .init( - get: { viewModel.showingCopiedToast }, - set: { _ in } - )) { - AlertToast( - displayMode: .banner(.pop), - type: .complete(UIConstants.Colors.audioGreen), - title: "Copied to clipboard" - ) - } + } + + private var headerView: some View { + HStack { + Text("Summary") + .foregroundColor(UIConstants.Colors.textPrimary) + .font(UIConstants.Typography.appTitle) + .padding(.leading, UIConstants.Spacing.contentPadding) + .padding(.top, UIConstants.Spacing.sectionSpacing) + + Spacer() + + closeButton + .padding(.trailing, UIConstants.Spacing.contentPadding) + .padding(.top, UIConstants.Spacing.sectionSpacing) } - - private var headerView: some View { - HStack { - Text("Summary") - .foregroundColor(UIConstants.Colors.textPrimary) - .font(UIConstants.Typography.appTitle) - .padding(.leading, UIConstants.Spacing.contentPadding) - .padding(.top, UIConstants.Spacing.sectionSpacing) - - Spacer() - - closeButton - .padding(.trailing, UIConstants.Spacing.contentPadding) - .padding(.top, UIConstants.Spacing.sectionSpacing) - } + } + + private var closeButton: some View { + PillButton(text: "Close", icon: "xmark") { + onClose() } - - private var closeButton: some View { - PillButton(text: "Close", icon: "xmark") { - onClose() - } + } + + private var loadingView: some View { + VStack(spacing: 16) { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + .scaleEffect(1.5) + + Text("Loading recording...") + .font(UIConstants.Typography.bodyText) + .foregroundColor(UIConstants.Colors.textSecondary) } - - private var loadingView: some View { - VStack(spacing: 16) { - ProgressView() - .progressViewStyle(CircularProgressViewStyle()) - .scaleEffect(1.5) - - Text("Loading recording...") - .font(UIConstants.Typography.bodyText) - .foregroundColor(UIConstants.Colors.textSecondary) - } - .frame(maxHeight: .infinity) + .frame(maxHeight: .infinity) + } + + private func errorView(_ message: String) -> some View { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 48)) + .foregroundColor(.red.opacity(0.8)) + + Text(message) + .font(.system(size: 14)) + .foregroundColor(UIConstants.Colors.textSecondary) + .multilineTextAlignment(.center) + .padding(.horizontal, UIConstants.Spacing.contentPadding) } - - private func errorView(_ message: String) -> some View { - VStack(spacing: 16) { - Image(systemName: "exclamationmark.triangle") - .font(.system(size: 48)) - .foregroundColor(.red.opacity(0.8)) - - Text(message) - .font(.system(size: 14)) - .foregroundColor(UIConstants.Colors.textSecondary) - .multilineTextAlignment(.center) - .padding(.horizontal, UIConstants.Spacing.contentPadding) + .frame(maxHeight: .infinity) + } + + private func stuckRecordingView(_ recording: RecordingInfo) -> some View { + VStack(spacing: 20) { + recordingStateInfo(recording) + .padding(.horizontal, UIConstants.Spacing.contentPadding) + + if let errorMessage = recording.errorMessage { + VStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 48)) + .foregroundColor(.red.opacity(0.8)) + + Text(errorMessage) + .font(.system(size: 14)) + .foregroundColor(UIConstants.Colors.textSecondary) + .multilineTextAlignment(.center) + .padding(.horizontal, UIConstants.Spacing.contentPadding) } - .frame(maxHeight: .infinity) + } } - - private var noRecordingView: some View { - VStack(spacing: 16) { - Image(systemName: "mic.slash") - .font(.system(size: 48)) - .foregroundColor(UIConstants.Colors.textTertiary) - - Text("No recordings found") - .font(.system(size: 14)) - .foregroundColor(UIConstants.Colors.textSecondary) - } - .frame(maxHeight: .infinity) + .frame(maxHeight: .infinity, alignment: .top) + .padding(.top, 20) + } + + private var noRecordingView: some View { + VStack(spacing: 16) { + Image(systemName: "mic.slash") + .font(.system(size: 48)) + .foregroundColor(UIConstants.Colors.textTertiary) + + Text("No recordings found") + .font(.system(size: 14)) + .foregroundColor(UIConstants.Colors.textSecondary) } - - private func processingView(geometry: GeometryProxy) -> some View { - VStack(spacing: UIConstants.Spacing.sectionSpacing) { - if let stage = viewModel.processingStage { - ProcessingStatesCard( - containerWidth: geometry.size.width, - currentStage: stage + .frame(maxHeight: .infinity) + } + + private func processingView(geometry: GeometryProxy) -> some View { + VStack(spacing: UIConstants.Spacing.sectionSpacing) { + if let stage = viewModel.processingStage { + ProcessingStatesCard( + containerWidth: geometry.size.width, + currentStage: stage + ) + .padding(.horizontal, UIConstants.Spacing.contentPadding) + } + + Spacer() + } + } + + private var summaryView: some View { + VStack(spacing: 0) { + ScrollView { + VStack(alignment: .leading, spacing: UIConstants.Spacing.cardSpacing) { + if let recording = viewModel.currentRecording { + + VStack(alignment: .leading, spacing: UIConstants.Spacing.cardInternalSpacing) { + recordingStateInfo(recording) + + if let transcriptionText = recording.transcriptionText, !transcriptionText.isEmpty { + TranscriptDropdownButton( + transcriptText: transcriptionText ) - .padding(.horizontal, UIConstants.Spacing.contentPadding) + } + + if let summaryText = recording.summaryText, !summaryText.isEmpty { + Text("Summary") + .font(UIConstants.Typography.infoCardTitle) + .foregroundColor(UIConstants.Colors.textPrimary) + + markdownContent(summaryText) + } + + if recording.summaryText == nil && recording.transcriptionText == nil { + Text("Recording completed without transcription or summary") + .font(UIConstants.Typography.bodyText) + .foregroundColor(UIConstants.Colors.textSecondary) + .padding(.vertical, 20) + } } - - Spacer() + .padding(.horizontal, UIConstants.Spacing.contentPadding) + .padding(.vertical, UIConstants.Spacing.cardSpacing) + .padding(.bottom, 80) + } } + } + + summaryActionButtons } - - private var summaryView: some View { - VStack(spacing: 0) { - ScrollView { - VStack(alignment: .leading, spacing: UIConstants.Spacing.cardSpacing) { - if let recording = viewModel.currentRecording, - let summaryText = recording.summaryText { - - VStack(alignment: .leading, spacing: UIConstants.Spacing.cardInternalSpacing) { - Text("Summary") - .font(UIConstants.Typography.infoCardTitle) - .foregroundColor(UIConstants.Colors.textPrimary) - - Markdown(summaryText) - .markdownTheme(.docC) - .markdownTextStyle { - ForegroundColor(UIConstants.Colors.textSecondary) - FontSize(12) - } - .markdownBlockStyle(\.heading1) { configuration in - configuration.label - .markdownTextStyle { - FontWeight(.bold) - FontSize(18) - ForegroundColor(UIConstants.Colors.textPrimary) - } - .padding(.vertical, 8) - } - .markdownBlockStyle(\.heading2) { configuration in - configuration.label - .markdownTextStyle { - FontWeight(.semibold) - FontSize(16) - ForegroundColor(UIConstants.Colors.textPrimary) - } - .padding(.vertical, 6) - } - .markdownBlockStyle(\.heading3) { configuration in - configuration.label - .markdownTextStyle { - FontWeight(.medium) - FontSize(14) - ForegroundColor(UIConstants.Colors.textPrimary) - } - .padding(.vertical, 4) - } - .markdownBlockStyle(\.listItem) { configuration in - configuration.label - .markdownTextStyle { - FontSize(12) - } - } - .textSelection(.enabled) - } - .padding(.horizontal, UIConstants.Spacing.contentPadding) - .padding(.vertical, UIConstants.Spacing.cardSpacing) - .padding(.bottom, 80) - } - } - } - - summaryActionButtons + } + + private var summaryActionButtons: some View { + VStack(spacing: 0) { + HStack(spacing: 12) { + SummaryActionButton( + text: "Copy Summary", + icon: "doc.on.doc" + ) { + viewModel.copySummary() } - } - - private var summaryActionButtons: some View { - VStack(spacing: 0) { - HStack(spacing: 12) { - SummaryActionButton( - text: "Copy", - icon: "doc.on.doc" - ) { - viewModel.copySummary() - } - - SummaryActionButton( - text: retryButtonText, - icon: "arrow.clockwise" - ) { - Task { - await viewModel.retryProcessing() - } - } - } - .padding(.horizontal, UIConstants.Spacing.cardPadding) - .padding(.top, UIConstants.Spacing.cardPadding) - .padding(.bottom, UIConstants.Spacing.cardInternalSpacing) + + SummaryActionButton( + text: "Copy Transcription", + icon: "doc.text" + ) { + viewModel.copyTranscription() } - .background(UIConstants.Gradients.summaryButtonBackground) - .cornerRadius(UIConstants.Sizing.cornerRadius) - } - - private var retryButtonText: String { - guard let recording = viewModel.currentRecording else { return "Retry Summarization" } - - switch recording.state { - case .transcriptionFailed: - return "Retry" - default: - return "Retry Summarization" + + SummaryActionButton( + text: retryButtonText, + icon: "arrow.clockwise" + ) { + Task { + await viewModel.retryProcessing() + } } + } + .padding(.horizontal, UIConstants.Spacing.cardPadding) + .padding(.top, UIConstants.Spacing.cardPadding) + .padding(.bottom, UIConstants.Spacing.cardInternalSpacing) } + .background(UIConstants.Gradients.summaryButtonBackground) + .cornerRadius(UIConstants.Sizing.cornerRadius) + } + + private var retryButtonText: String { + guard let recording = viewModel.currentRecording else { return "Retry Summarization" } + + switch recording.state { + case .transcriptionFailed: + return "Retry" + default: + return "Retry Summarization" + } + } + } diff --git a/Recap/UseCases/Summary/ViewModel/SummaryViewModel.swift b/Recap/UseCases/Summary/ViewModel/SummaryViewModel.swift index 72ed8ae..4e1988b 100644 --- a/Recap/UseCases/Summary/ViewModel/SummaryViewModel.swift +++ b/Recap/UseCases/Summary/ViewModel/SummaryViewModel.swift @@ -1,145 +1,231 @@ -import SwiftUI import Combine +import SwiftUI @MainActor final class SummaryViewModel: SummaryViewModelType { - @Published var currentRecording: RecordingInfo? - @Published private(set) var isLoadingRecording = false - @Published private(set) var errorMessage: String? - @Published var showingCopiedToast = false - - private let recordingRepository: RecordingRepositoryType - private let processingCoordinator: ProcessingCoordinatorType - private var cancellables = Set() - private var refreshTimer: Timer? - - init( - recordingRepository: RecordingRepositoryType, - processingCoordinator: ProcessingCoordinatorType - ) { - self.recordingRepository = recordingRepository - self.processingCoordinator = processingCoordinator + @Published var currentRecording: RecordingInfo? + @Published private(set) var isLoadingRecording = false + @Published private(set) var errorMessage: String? + @Published var showingCopiedToast = false + @Published private(set) var userPreferences: UserPreferencesInfo? + + private let recordingRepository: RecordingRepositoryType + private let processingCoordinator: ProcessingCoordinatorType + private let userPreferencesRepository: UserPreferencesRepositoryType + private var cancellables = Set() + private var refreshTimer: Timer? + + init( + recordingRepository: RecordingRepositoryType, + processingCoordinator: ProcessingCoordinatorType, + userPreferencesRepository: UserPreferencesRepositoryType + ) { + self.recordingRepository = recordingRepository + self.processingCoordinator = processingCoordinator + self.userPreferencesRepository = userPreferencesRepository + + Task { + await loadUserPreferences() } - - func loadRecording(withID recordingID: String) { - isLoadingRecording = true - errorMessage = nil - - Task { - do { - let recording = try await recordingRepository.fetchRecording(id: recordingID) - currentRecording = recording - } catch { - errorMessage = "Failed to load recording: \(error.localizedDescription)" - } - isLoadingRecording = false - } + } + + func loadUserPreferences() async { + do { + userPreferences = try await userPreferencesRepository.getOrCreatePreferences() + } catch { + // If we can't load preferences, assume defaults (auto-summarize enabled) + userPreferences = nil } - - func loadLatestRecording() { - isLoadingRecording = true - errorMessage = nil - - Task { - do { - let recordings = try await recordingRepository.fetchAllRecordings() - currentRecording = recordings.first - } catch { - errorMessage = "Failed to load recordings: \(error.localizedDescription)" - } - isLoadingRecording = false - } + } + + func loadRecording(withID recordingID: String) { + isLoadingRecording = true + errorMessage = nil + + Task { + do { + let recording = try await recordingRepository.fetchRecording(id: recordingID) + currentRecording = recording + } catch { + errorMessage = "Failed to load recording: \(error.localizedDescription)" + } + isLoadingRecording = false } - - var processingStage: ProcessingStatesCard.ProcessingStage? { - guard let recording = currentRecording else { return nil } - - switch recording.state { - case .recorded: - return .recorded - case .transcribing, .transcribed: - return .transcribing - case .summarizing: - return .summarizing - default: - return nil - } + } + + func loadLatestRecording() { + isLoadingRecording = true + errorMessage = nil + + Task { + do { + let recordings = try await recordingRepository.fetchAllRecordings() + currentRecording = recordings.first + } catch { + errorMessage = "Failed to load recordings: \(error.localizedDescription)" + } + isLoadingRecording = false } - - var isProcessing: Bool { - guard let recording = currentRecording else { return false } - return recording.state.isProcessing + } + + var processingStage: ProcessingStatesCard.ProcessingStage? { + guard let recording = currentRecording else { return nil } + + switch recording.state { + case .recorded: + return .recorded + case .transcribing, .transcribed: + return .transcribing + case .summarizing: + return .summarizing + default: + return nil } - - var hasSummary: Bool { - guard let recording = currentRecording else { return false } - return recording.state == .completed && recording.summaryText != nil + } + + var isProcessing: Bool { + guard let recording = currentRecording else { return false } + return recording.state.isProcessing + } + + var hasSummary: Bool { + guard let recording = currentRecording else { return false } + return recording.state == .completed && recording.summaryText != nil + } + + var isRecordingReady: Bool { + guard let recording = currentRecording else { return false } + guard recording.state == .completed else { return false } + + // If auto-summarize is enabled, we need summary text + if userPreferences?.autoSummarizeEnabled == true { + return recording.summaryText != nil } - - func retryProcessing() async { - guard let recording = currentRecording else { return } - - if recording.state == .transcriptionFailed { - await processingCoordinator.retryProcessing(recordingID: recording.id) - } else { - do { - try await recordingRepository.updateRecordingState( - id: recording.id, - state: .summarizing, - errorMessage: nil - ) - await processingCoordinator.startProcessing(recordingInfo: recording) - } catch { - errorMessage = "Failed to retry summarization: \(error.localizedDescription)" - } - } - - loadRecording(withID: recording.id) + + // If auto-summarize is disabled, the recording is valid when completed + return true + } + + func retryProcessing() async { + guard let recording = currentRecording else { return } + + if recording.state == .transcriptionFailed { + await processingCoordinator.retryProcessing(recordingID: recording.id) + } else { + do { + try await recordingRepository.updateRecordingState( + id: recording.id, + state: .summarizing, + errorMessage: nil + ) + await processingCoordinator.startProcessing(recordingInfo: recording) + } catch { + errorMessage = "Failed to retry summarization: \(error.localizedDescription)" + } } - - func startAutoRefresh() { - stopAutoRefresh() - - refreshTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in - Task { @MainActor in - await self?.refreshCurrentRecording() - } - } + + loadRecording(withID: recording.id) + } + + func fixStuckRecording() async { + guard let recording = currentRecording else { return } + + do { + // Update to transcribing state to show processing feedback + try await recordingRepository.updateRecordingState( + id: recording.id, + state: .transcribing, + errorMessage: nil + ) + + // Reload the recording to reflect the change + loadRecording(withID: recording.id) + + // Fetch the updated recording and trigger processing + if let updatedRecording = try await recordingRepository.fetchRecording(id: recording.id) { + await processingCoordinator.startProcessing(recordingInfo: updatedRecording) + } + } catch { + errorMessage = "Failed to fix recording state: \(error.localizedDescription)" } - - func stopAutoRefresh() { - refreshTimer?.invalidate() - refreshTimer = nil + } + + func markAsCompleted() async { + guard let recording = currentRecording else { return } + + do { + // Mark recording as completed without processing + try await recordingRepository.updateRecordingState( + id: recording.id, + state: .completed, + errorMessage: nil + ) + + // Reload the recording to reflect the change + loadRecording(withID: recording.id) + } catch { + errorMessage = "Failed to mark recording as completed: \(error.localizedDescription)" } - - private func refreshCurrentRecording() async { - guard let recordingID = currentRecording?.id else { return } - - do { - let recording = try await recordingRepository.fetchRecording(id: recordingID) - currentRecording = recording - } catch { - errorMessage = "Failed to refresh recording: \(error.localizedDescription)" - } + } + + func startAutoRefresh() { + stopAutoRefresh() + + refreshTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in + Task { @MainActor in + await self?.refreshCurrentRecording() + } } - - func copySummary() { - guard let summaryText = currentRecording?.summaryText else { return } - - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(summaryText, forType: .string) - - showingCopiedToast = true - - Task { - try? await Task.sleep(nanoseconds: 2_000_000_000) - showingCopiedToast = false - } + } + + func stopAutoRefresh() { + refreshTimer?.invalidate() + refreshTimer = nil + } + + private func refreshCurrentRecording() async { + guard let recordingID = currentRecording?.id else { return } + + do { + let recording = try await recordingRepository.fetchRecording(id: recordingID) + currentRecording = recording + } catch { + errorMessage = "Failed to refresh recording: \(error.localizedDescription)" } - - deinit { - Task { @MainActor [weak self] in - self?.stopAutoRefresh() - } + } + + func copySummary() { + guard let summaryText = currentRecording?.summaryText else { return } + + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(summaryText, forType: .string) + + showingCopiedToast = true + + Task { + try? await Task.sleep(nanoseconds: 2_000_000_000) + showingCopiedToast = false + } + } + + func copyTranscription() { + guard let recording = currentRecording else { return } + guard let transcriptionText = recording.transcriptionText else { return } + + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(transcriptionText, forType: .string) + + showingCopiedToast = true + + Task { + try? await Task.sleep(nanoseconds: 2_000_000_000) + showingCopiedToast = false + } + } + + deinit { + Task { @MainActor [weak self] in + self?.stopAutoRefresh() } + } } diff --git a/Recap/UseCases/Summary/ViewModel/SummaryViewModelType.swift b/Recap/UseCases/Summary/ViewModel/SummaryViewModelType.swift index 42cd840..b747910 100644 --- a/Recap/UseCases/Summary/ViewModel/SummaryViewModelType.swift +++ b/Recap/UseCases/Summary/ViewModel/SummaryViewModelType.swift @@ -2,18 +2,22 @@ import Foundation @MainActor protocol SummaryViewModelType: ObservableObject { - var currentRecording: RecordingInfo? { get } - var isLoadingRecording: Bool { get } - var errorMessage: String? { get } - var processingStage: ProcessingStatesCard.ProcessingStage? { get } - var isProcessing: Bool { get } - var hasSummary: Bool { get } - var showingCopiedToast: Bool { get } - - func loadRecording(withID recordingID: String) - func loadLatestRecording() - func retryProcessing() async - func startAutoRefresh() - func stopAutoRefresh() - func copySummary() -} \ No newline at end of file + var currentRecording: RecordingInfo? { get } + var isLoadingRecording: Bool { get } + var errorMessage: String? { get } + var processingStage: ProcessingStatesCard.ProcessingStage? { get } + var isProcessing: Bool { get } + var hasSummary: Bool { get } + var isRecordingReady: Bool { get } + var showingCopiedToast: Bool { get } + + func loadRecording(withID recordingID: String) + func loadLatestRecording() + func retryProcessing() async + func fixStuckRecording() async + func markAsCompleted() async + func startAutoRefresh() + func stopAutoRefresh() + func copySummary() + func copyTranscription() +} diff --git a/RecapTests/Helpers/UserPreferencesInfo+TestHelpers.swift b/RecapTests/Helpers/UserPreferencesInfo+TestHelpers.swift index b38ce1a..caddfaa 100644 --- a/RecapTests/Helpers/UserPreferencesInfo+TestHelpers.swift +++ b/RecapTests/Helpers/UserPreferencesInfo+TestHelpers.swift @@ -1,30 +1,31 @@ import Foundation + @testable import Recap extension UserPreferencesInfo { - static func createForTesting( - id: String = "test-id", - selectedLLMModelID: String? = nil, - selectedProvider: LLMProvider = .ollama, - autoSummarizeEnabled: Bool = false, - autoDetectMeetings: Bool = false, - autoStopRecording: Bool = false, - onboarded: Bool = true, - summaryPromptTemplate: String? = nil, - createdAt: Date = Date(), - modifiedAt: Date = Date() - ) -> UserPreferencesInfo { - return UserPreferencesInfo( - id: id, - selectedLLMModelID: selectedLLMModelID, - selectedProvider: selectedProvider, - autoSummarizeEnabled: autoSummarizeEnabled, - autoDetectMeetings: autoDetectMeetings, - autoStopRecording: autoStopRecording, - onboarded: onboarded, - summaryPromptTemplate: summaryPromptTemplate, - createdAt: createdAt, - modifiedAt: modifiedAt - ) - } + static func createForTesting( + id: String = "test-id", + selectedLLMModelID: String? = nil, + selectedProvider: LLMProvider = .ollama, + autoSummarizeEnabled: Bool = false, + autoDetectMeetings: Bool = false, + autoStopRecording: Bool = false, + onboarded: Bool = true, + summaryPromptTemplate: String? = nil, + createdAt: Date = Date(), + modifiedAt: Date = Date() + ) -> UserPreferencesInfo { + return UserPreferencesInfo( + id: id, + selectedLLMModelID: selectedLLMModelID, + selectedProvider: selectedProvider, + autoSummarizeEnabled: autoSummarizeEnabled, + autoDetectMeetings: autoDetectMeetings, + autoStopRecording: autoStopRecording, + onboarded: onboarded, + summaryPromptTemplate: summaryPromptTemplate, + createdAt: createdAt, + modifiedAt: modifiedAt + ) + } } diff --git a/RecapTests/Helpers/XCTestCase+Async.swift b/RecapTests/Helpers/XCTestCase+Async.swift index 8d31434..2507fc6 100644 --- a/RecapTests/Helpers/XCTestCase+Async.swift +++ b/RecapTests/Helpers/XCTestCase+Async.swift @@ -1,14 +1,14 @@ import XCTest extension XCTestCase { - func fulfillment( - of expectations: [XCTestExpectation], - timeout: TimeInterval, - enforceOrder: Bool = false - ) async { - await withCheckedContinuation { continuation in - wait(for: expectations, timeout: timeout, enforceOrder: enforceOrder) - continuation.resume() - } + func fulfillment( + of expectations: [XCTestExpectation], + timeout: TimeInterval, + enforceOrder: Bool = false + ) async { + await withCheckedContinuation { continuation in + wait(for: expectations, timeout: timeout, enforceOrder: enforceOrder) + continuation.resume() } -} \ No newline at end of file + } +} diff --git a/RecapTests/Services/MeetingDetection/Detectors/GoogleMeetDetectorSpec.swift b/RecapTests/Services/MeetingDetection/Detectors/GoogleMeetDetectorSpec.swift index 1163570..7e762cb 100644 --- a/RecapTests/Services/MeetingDetection/Detectors/GoogleMeetDetectorSpec.swift +++ b/RecapTests/Services/MeetingDetection/Detectors/GoogleMeetDetectorSpec.swift @@ -1,125 +1,126 @@ -import XCTest -import ScreenCaptureKit import Mockable +import ScreenCaptureKit +import XCTest + @testable import Recap @MainActor final class GoogleMeetDetectorSpec: XCTestCase { - private var sut: GoogleMeetDetector! - - override func setUp() async throws { - try await super.setUp() - sut = GoogleMeetDetector() - } - - override func tearDown() async throws { - sut = nil - try await super.tearDown() - } - - func testMeetingAppName() { - XCTAssertEqual(sut.meetingAppName, "Google Meet") - } - - func testSupportedBundleIdentifiers() { - let expected: Set = [ - "com.google.Chrome", - "com.apple.Safari", - "org.mozilla.firefox", - "com.microsoft.edgemac" - ] - XCTAssertEqual(sut.supportedBundleIdentifiers, expected) - } - - func testInitialState() { - XCTAssertFalse(sut.isMeetingActive) - XCTAssertNil(sut.meetingTitle) - } - - func testCheckForMeetingWithEmptyWindows() async { - let result = await sut.checkForMeeting(in: []) - - XCTAssertFalse(result.isActive) - XCTAssertNil(result.title) - XCTAssertEqual(result.confidence, .low) - } - - func testCheckForMeetingWithNoMatchingWindows() async { - let mockWindow = MockWindow(title: "Random Window Title") - let result = await sut.checkForMeeting(in: [mockWindow]) - - XCTAssertFalse(result.isActive) - XCTAssertNil(result.title) - XCTAssertEqual(result.confidence, .low) - } - - func testCheckForMeetingWithGoogleMeetWindow() async { - let meetingTitle = "Google Meet - Team Meeting" - let mockWindow = MockWindow(title: meetingTitle) - let result = await sut.checkForMeeting(in: [mockWindow]) - - XCTAssertTrue(result.isActive) - XCTAssertEqual(result.title, meetingTitle) - XCTAssertEqual(result.confidence, .high) - } - - func testCheckForMeetingWithGoogleMeetURL() async { - let meetingTitle = "meet.google.com/abc-def-ghi - Chrome" - let mockWindow = MockWindow(title: meetingTitle) - let result = await sut.checkForMeeting(in: [mockWindow]) - - XCTAssertTrue(result.isActive) - XCTAssertEqual(result.title, meetingTitle) - XCTAssertEqual(result.confidence, .high) - } - - func testCheckForMeetingWithMeetDash() async { - let meetingTitle = "Meet - Team Standup" - let mockWindow = MockWindow(title: meetingTitle) - let result = await sut.checkForMeeting(in: [mockWindow]) - - XCTAssertTrue(result.isActive) - XCTAssertEqual(result.title, meetingTitle) - XCTAssertEqual(result.confidence, .medium) - } - - func testCheckForMeetingWithMeetKeyword() async { - let meetingTitle = "Team meeting with John" - let mockWindow = MockWindow(title: meetingTitle) - let result = await sut.checkForMeeting(in: [mockWindow]) - - XCTAssertTrue(result.isActive) - XCTAssertEqual(result.title, meetingTitle) - XCTAssertEqual(result.confidence, .medium) - } - - func testCheckForMeetingWithEmptyTitle() async { - let mockWindow = MockWindow(title: "") - let result = await sut.checkForMeeting(in: [mockWindow]) - - XCTAssertFalse(result.isActive) - XCTAssertNil(result.title) - XCTAssertEqual(result.confidence, .low) - } - - func testCheckForMeetingWithNilTitle() async { - let mockWindow = MockWindow(title: nil) - let result = await sut.checkForMeeting(in: [mockWindow]) - - XCTAssertFalse(result.isActive) - XCTAssertNil(result.title) - XCTAssertEqual(result.confidence, .low) - } - - func testCheckForMeetingReturnsFirstMatch() async { - let meetingTitle1 = "Google Meet - Team Meeting" - let meetingTitle2 = "Another Meet Window" - let mockWindow1 = MockWindow(title: meetingTitle1) - let mockWindow2 = MockWindow(title: meetingTitle2) - - let result = await sut.checkForMeeting(in: [mockWindow1, mockWindow2]) - - XCTAssertTrue(result.isActive) - XCTAssertEqual(result.title, meetingTitle1) - } -} \ No newline at end of file + private var sut: GoogleMeetDetector! + + override func setUp() async throws { + try await super.setUp() + sut = GoogleMeetDetector() + } + + override func tearDown() async throws { + sut = nil + try await super.tearDown() + } + + func testMeetingAppName() { + XCTAssertEqual(sut.meetingAppName, "Google Meet") + } + + func testSupportedBundleIdentifiers() { + let expected: Set = [ + "com.google.Chrome", + "com.apple.Safari", + "org.mozilla.firefox", + "com.microsoft.edgemac" + ] + XCTAssertEqual(sut.supportedBundleIdentifiers, expected) + } + + func testInitialState() { + XCTAssertFalse(sut.isMeetingActive) + XCTAssertNil(sut.meetingTitle) + } + + func testCheckForMeetingWithEmptyWindows() async { + let result = await sut.checkForMeeting(in: []) + + XCTAssertFalse(result.isActive) + XCTAssertNil(result.title) + XCTAssertEqual(result.confidence, .low) + } + + func testCheckForMeetingWithNoMatchingWindows() async { + let mockWindow = MockWindow(title: "Random Window Title") + let result = await sut.checkForMeeting(in: [mockWindow]) + + XCTAssertFalse(result.isActive) + XCTAssertNil(result.title) + XCTAssertEqual(result.confidence, .low) + } + + func testCheckForMeetingWithGoogleMeetWindow() async { + let meetingTitle = "Google Meet - Team Meeting" + let mockWindow = MockWindow(title: meetingTitle) + let result = await sut.checkForMeeting(in: [mockWindow]) + + XCTAssertTrue(result.isActive) + XCTAssertEqual(result.title, meetingTitle) + XCTAssertEqual(result.confidence, .high) + } + + func testCheckForMeetingWithGoogleMeetURL() async { + let meetingTitle = "meet.google.com/abc-def-ghi - Chrome" + let mockWindow = MockWindow(title: meetingTitle) + let result = await sut.checkForMeeting(in: [mockWindow]) + + XCTAssertTrue(result.isActive) + XCTAssertEqual(result.title, meetingTitle) + XCTAssertEqual(result.confidence, .high) + } + + func testCheckForMeetingWithMeetDash() async { + let meetingTitle = "Meet - Team Standup" + let mockWindow = MockWindow(title: meetingTitle) + let result = await sut.checkForMeeting(in: [mockWindow]) + + XCTAssertTrue(result.isActive) + XCTAssertEqual(result.title, meetingTitle) + XCTAssertEqual(result.confidence, .medium) + } + + func testCheckForMeetingWithMeetKeyword() async { + let meetingTitle = "Team meeting with John" + let mockWindow = MockWindow(title: meetingTitle) + let result = await sut.checkForMeeting(in: [mockWindow]) + + XCTAssertTrue(result.isActive) + XCTAssertEqual(result.title, meetingTitle) + XCTAssertEqual(result.confidence, .medium) + } + + func testCheckForMeetingWithEmptyTitle() async { + let mockWindow = MockWindow(title: "") + let result = await sut.checkForMeeting(in: [mockWindow]) + + XCTAssertFalse(result.isActive) + XCTAssertNil(result.title) + XCTAssertEqual(result.confidence, .low) + } + + func testCheckForMeetingWithNilTitle() async { + let mockWindow = MockWindow(title: nil) + let result = await sut.checkForMeeting(in: [mockWindow]) + + XCTAssertFalse(result.isActive) + XCTAssertNil(result.title) + XCTAssertEqual(result.confidence, .low) + } + + func testCheckForMeetingReturnsFirstMatch() async { + let meetingTitle1 = "Google Meet - Team Meeting" + let meetingTitle2 = "Another Meet Window" + let mockWindow1 = MockWindow(title: meetingTitle1) + let mockWindow2 = MockWindow(title: meetingTitle2) + + let result = await sut.checkForMeeting(in: [mockWindow1, mockWindow2]) + + XCTAssertTrue(result.isActive) + XCTAssertEqual(result.title, meetingTitle1) + } +} diff --git a/RecapTests/Services/MeetingDetection/Detectors/MockSCWindow.swift b/RecapTests/Services/MeetingDetection/Detectors/MockSCWindow.swift index 8abdd79..2068dae 100644 --- a/RecapTests/Services/MeetingDetection/Detectors/MockSCWindow.swift +++ b/RecapTests/Services/MeetingDetection/Detectors/MockSCWindow.swift @@ -1,12 +1,9 @@ import Foundation + @testable import Recap // MARK: - Test Mock Implementation struct MockWindow: WindowTitleProviding { - let title: String? - - init(title: String?) { - self.title = title - } -} \ No newline at end of file + let title: String? +} diff --git a/RecapTests/Services/MeetingDetection/Detectors/TeamsMeetingDetectorSpec.swift b/RecapTests/Services/MeetingDetection/Detectors/TeamsMeetingDetectorSpec.swift index f4a9d31..e9f3f91 100644 --- a/RecapTests/Services/MeetingDetection/Detectors/TeamsMeetingDetectorSpec.swift +++ b/RecapTests/Services/MeetingDetection/Detectors/TeamsMeetingDetectorSpec.swift @@ -1,113 +1,114 @@ -import XCTest -import ScreenCaptureKit import Mockable +import ScreenCaptureKit +import XCTest + @testable import Recap @MainActor final class TeamsMeetingDetectorSpec: XCTestCase { - private var sut: TeamsMeetingDetector! - - override func setUp() async throws { - try await super.setUp() - sut = TeamsMeetingDetector() - } - - override func tearDown() async throws { - sut = nil - try await super.tearDown() - } - - func testMeetingAppName() { - XCTAssertEqual(sut.meetingAppName, "Microsoft Teams") - } - - func testSupportedBundleIdentifiers() { - let expected: Set = [ - "com.microsoft.teams", - "com.microsoft.teams2" - ] - XCTAssertEqual(sut.supportedBundleIdentifiers, expected) - } - - func testInitialState() { - XCTAssertFalse(sut.isMeetingActive) - XCTAssertNil(sut.meetingTitle) - } - - func testCheckForMeetingWithEmptyWindows() async { - let result = await sut.checkForMeeting(in: []) - - XCTAssertFalse(result.isActive) - XCTAssertNil(result.title) - XCTAssertEqual(result.confidence, .low) - } - - func testCheckForMeetingWithNoMatchingWindows() async { - let mockWindow = MockWindow(title: "Random Window Title") - let result = await sut.checkForMeeting(in: [mockWindow]) - - XCTAssertFalse(result.isActive) - XCTAssertNil(result.title) - XCTAssertEqual(result.confidence, .low) - } - - func testCheckForMeetingWithTeamsWindow() async { - let meetingTitle = "Microsoft Teams - Team Meeting" - let mockWindow = MockWindow(title: meetingTitle) - let result = await sut.checkForMeeting(in: [mockWindow]) - - XCTAssertTrue(result.isActive) - XCTAssertEqual(result.title, meetingTitle) - XCTAssertNotEqual(result.confidence, .low) - } - - func testCheckForMeetingWithTeamsCallWindow() async { - let meetingTitle = "Teams Call - John Doe" - let mockWindow = MockWindow(title: meetingTitle) - let result = await sut.checkForMeeting(in: [mockWindow]) - - XCTAssertTrue(result.isActive) - XCTAssertEqual(result.title, meetingTitle) - XCTAssertNotEqual(result.confidence, .low) - } - - func testCheckForMeetingWithEmptyTitle() async { - let mockWindow = MockWindow(title: "") - let result = await sut.checkForMeeting(in: [mockWindow]) - - XCTAssertFalse(result.isActive) - XCTAssertNil(result.title) - XCTAssertEqual(result.confidence, .low) - } - - func testCheckForMeetingWithNilTitle() async { - let mockWindow = MockWindow(title: nil) - let result = await sut.checkForMeeting(in: [mockWindow]) - - XCTAssertFalse(result.isActive) - XCTAssertNil(result.title) - XCTAssertEqual(result.confidence, .low) - } - - func testCheckForMeetingReturnsFirstMatch() async { - let meetingTitle1 = "Microsoft Teams - Team Meeting" - let meetingTitle2 = "Teams Call - Another Meeting" - let mockWindow1 = MockWindow(title: meetingTitle1) - let mockWindow2 = MockWindow(title: meetingTitle2) - - let result = await sut.checkForMeeting(in: [mockWindow1, mockWindow2]) - - XCTAssertTrue(result.isActive) - XCTAssertEqual(result.title, meetingTitle1) - } - - func testCheckForMeetingWithMixedCaseTeams() async { - let meetingTitle = "teams call with client" - let mockWindow = MockWindow(title: meetingTitle) - let result = await sut.checkForMeeting(in: [mockWindow]) - - XCTAssertTrue(result.isActive) - XCTAssertEqual(result.title, meetingTitle) - XCTAssertNotEqual(result.confidence, .low) - } -} \ No newline at end of file + private var sut: TeamsMeetingDetector! + + override func setUp() async throws { + try await super.setUp() + sut = TeamsMeetingDetector() + } + + override func tearDown() async throws { + sut = nil + try await super.tearDown() + } + + func testMeetingAppName() { + XCTAssertEqual(sut.meetingAppName, "Microsoft Teams") + } + + func testSupportedBundleIdentifiers() { + let expected: Set = [ + "com.microsoft.teams", + "com.microsoft.teams2" + ] + XCTAssertEqual(sut.supportedBundleIdentifiers, expected) + } + + func testInitialState() { + XCTAssertFalse(sut.isMeetingActive) + XCTAssertNil(sut.meetingTitle) + } + + func testCheckForMeetingWithEmptyWindows() async { + let result = await sut.checkForMeeting(in: []) + + XCTAssertFalse(result.isActive) + XCTAssertNil(result.title) + XCTAssertEqual(result.confidence, .low) + } + + func testCheckForMeetingWithNoMatchingWindows() async { + let mockWindow = MockWindow(title: "Random Window Title") + let result = await sut.checkForMeeting(in: [mockWindow]) + + XCTAssertFalse(result.isActive) + XCTAssertNil(result.title) + XCTAssertEqual(result.confidence, .low) + } + + func testCheckForMeetingWithTeamsWindow() async { + let meetingTitle = "Microsoft Teams - Team Meeting" + let mockWindow = MockWindow(title: meetingTitle) + let result = await sut.checkForMeeting(in: [mockWindow]) + + XCTAssertTrue(result.isActive) + XCTAssertEqual(result.title, meetingTitle) + XCTAssertNotEqual(result.confidence, .low) + } + + func testCheckForMeetingWithTeamsCallWindow() async { + let meetingTitle = "Teams Call - John Doe" + let mockWindow = MockWindow(title: meetingTitle) + let result = await sut.checkForMeeting(in: [mockWindow]) + + XCTAssertTrue(result.isActive) + XCTAssertEqual(result.title, meetingTitle) + XCTAssertNotEqual(result.confidence, .low) + } + + func testCheckForMeetingWithEmptyTitle() async { + let mockWindow = MockWindow(title: "") + let result = await sut.checkForMeeting(in: [mockWindow]) + + XCTAssertFalse(result.isActive) + XCTAssertNil(result.title) + XCTAssertEqual(result.confidence, .low) + } + + func testCheckForMeetingWithNilTitle() async { + let mockWindow = MockWindow(title: nil) + let result = await sut.checkForMeeting(in: [mockWindow]) + + XCTAssertFalse(result.isActive) + XCTAssertNil(result.title) + XCTAssertEqual(result.confidence, .low) + } + + func testCheckForMeetingReturnsFirstMatch() async { + let meetingTitle1 = "Microsoft Teams - Team Meeting" + let meetingTitle2 = "Teams Call - Another Meeting" + let mockWindow1 = MockWindow(title: meetingTitle1) + let mockWindow2 = MockWindow(title: meetingTitle2) + + let result = await sut.checkForMeeting(in: [mockWindow1, mockWindow2]) + + XCTAssertTrue(result.isActive) + XCTAssertEqual(result.title, meetingTitle1) + } + + func testCheckForMeetingWithMixedCaseTeams() async { + let meetingTitle = "teams call with client" + let mockWindow = MockWindow(title: meetingTitle) + let result = await sut.checkForMeeting(in: [mockWindow]) + + XCTAssertTrue(result.isActive) + XCTAssertEqual(result.title, meetingTitle) + XCTAssertNotEqual(result.confidence, .low) + } +} diff --git a/RecapTests/Services/MeetingDetection/Detectors/ZoomMeetingDetectorSpec.swift b/RecapTests/Services/MeetingDetection/Detectors/ZoomMeetingDetectorSpec.swift index 0bf3838..4f7af9e 100644 --- a/RecapTests/Services/MeetingDetection/Detectors/ZoomMeetingDetectorSpec.swift +++ b/RecapTests/Services/MeetingDetection/Detectors/ZoomMeetingDetectorSpec.swift @@ -1,120 +1,121 @@ -import XCTest -import ScreenCaptureKit import Mockable +import ScreenCaptureKit +import XCTest + @testable import Recap @MainActor final class ZoomMeetingDetectorSpec: XCTestCase { - private var sut: ZoomMeetingDetector! - - override func setUp() async throws { - try await super.setUp() - sut = ZoomMeetingDetector() - } - - override func tearDown() async throws { - sut = nil - try await super.tearDown() - } - - func testMeetingAppName() { - XCTAssertEqual(sut.meetingAppName, "Zoom") - } - - func testSupportedBundleIdentifiers() { - let expected: Set = ["us.zoom.xos"] - XCTAssertEqual(sut.supportedBundleIdentifiers, expected) - } - - func testInitialState() { - XCTAssertFalse(sut.isMeetingActive) - XCTAssertNil(sut.meetingTitle) - } - - func testCheckForMeetingWithEmptyWindows() async { - let result = await sut.checkForMeeting(in: []) - - XCTAssertFalse(result.isActive) - XCTAssertNil(result.title) - XCTAssertEqual(result.confidence, .low) - } - - func testCheckForMeetingWithNoMatchingWindows() async { - let mockWindow = MockWindow(title: "Random Window Title") - let result = await sut.checkForMeeting(in: [mockWindow]) - - XCTAssertFalse(result.isActive) - XCTAssertNil(result.title) - XCTAssertEqual(result.confidence, .low) - } - - func testCheckForMeetingWithZoomWindow() async { - let meetingTitle = "Zoom Meeting - Team Standup" - let mockWindow = MockWindow(title: meetingTitle) - let result = await sut.checkForMeeting(in: [mockWindow]) - - XCTAssertTrue(result.isActive) - XCTAssertEqual(result.title, meetingTitle) - XCTAssertNotEqual(result.confidence, .low) - } - - func testCheckForMeetingWithZoomCall() async { - let meetingTitle = "Zoom - Personal Meeting Room" - let mockWindow = MockWindow(title: meetingTitle) - let result = await sut.checkForMeeting(in: [mockWindow]) - - XCTAssertTrue(result.isActive) - XCTAssertEqual(result.title, meetingTitle) - XCTAssertNotEqual(result.confidence, .low) - } - - func testCheckForMeetingWithEmptyTitle() async { - let mockWindow = MockWindow(title: "") - let result = await sut.checkForMeeting(in: [mockWindow]) - - XCTAssertFalse(result.isActive) - XCTAssertNil(result.title) - XCTAssertEqual(result.confidence, .low) - } - - func testCheckForMeetingWithNilTitle() async { - let mockWindow = MockWindow(title: nil) - let result = await sut.checkForMeeting(in: [mockWindow]) - - XCTAssertFalse(result.isActive) - XCTAssertNil(result.title) - XCTAssertEqual(result.confidence, .low) - } - - func testCheckForMeetingReturnsFirstMatch() async { - let meetingTitle1 = "Zoom Meeting - Client Call" - let meetingTitle2 = "Zoom - Another Meeting" - let mockWindow1 = MockWindow(title: meetingTitle1) - let mockWindow2 = MockWindow(title: meetingTitle2) - - let result = await sut.checkForMeeting(in: [mockWindow1, mockWindow2]) - - XCTAssertTrue(result.isActive) - XCTAssertEqual(result.title, meetingTitle1) - } - - func testCheckForMeetingWithMixedCaseZoom() async { - let meetingTitle = "zoom meeting with team" - let mockWindow = MockWindow(title: meetingTitle) - let result = await sut.checkForMeeting(in: [mockWindow]) - - XCTAssertTrue(result.isActive) - XCTAssertEqual(result.title, meetingTitle) - XCTAssertNotEqual(result.confidence, .low) - } - - func testCheckForMeetingWithZoomWebinar() async { - let meetingTitle = "Zoom Webinar - Product Launch" - let mockWindow = MockWindow(title: meetingTitle) - let result = await sut.checkForMeeting(in: [mockWindow]) - - XCTAssertTrue(result.isActive) - XCTAssertEqual(result.title, meetingTitle) - XCTAssertNotEqual(result.confidence, .low) - } -} \ No newline at end of file + private var sut: ZoomMeetingDetector! + + override func setUp() async throws { + try await super.setUp() + sut = ZoomMeetingDetector() + } + + override func tearDown() async throws { + sut = nil + try await super.tearDown() + } + + func testMeetingAppName() { + XCTAssertEqual(sut.meetingAppName, "Zoom") + } + + func testSupportedBundleIdentifiers() { + let expected: Set = ["us.zoom.xos"] + XCTAssertEqual(sut.supportedBundleIdentifiers, expected) + } + + func testInitialState() { + XCTAssertFalse(sut.isMeetingActive) + XCTAssertNil(sut.meetingTitle) + } + + func testCheckForMeetingWithEmptyWindows() async { + let result = await sut.checkForMeeting(in: []) + + XCTAssertFalse(result.isActive) + XCTAssertNil(result.title) + XCTAssertEqual(result.confidence, .low) + } + + func testCheckForMeetingWithNoMatchingWindows() async { + let mockWindow = MockWindow(title: "Random Window Title") + let result = await sut.checkForMeeting(in: [mockWindow]) + + XCTAssertFalse(result.isActive) + XCTAssertNil(result.title) + XCTAssertEqual(result.confidence, .low) + } + + func testCheckForMeetingWithZoomWindow() async { + let meetingTitle = "Zoom Meeting - Team Standup" + let mockWindow = MockWindow(title: meetingTitle) + let result = await sut.checkForMeeting(in: [mockWindow]) + + XCTAssertTrue(result.isActive) + XCTAssertEqual(result.title, meetingTitle) + XCTAssertNotEqual(result.confidence, .low) + } + + func testCheckForMeetingWithZoomCall() async { + let meetingTitle = "Zoom - Personal Meeting Room" + let mockWindow = MockWindow(title: meetingTitle) + let result = await sut.checkForMeeting(in: [mockWindow]) + + XCTAssertTrue(result.isActive) + XCTAssertEqual(result.title, meetingTitle) + XCTAssertNotEqual(result.confidence, .low) + } + + func testCheckForMeetingWithEmptyTitle() async { + let mockWindow = MockWindow(title: "") + let result = await sut.checkForMeeting(in: [mockWindow]) + + XCTAssertFalse(result.isActive) + XCTAssertNil(result.title) + XCTAssertEqual(result.confidence, .low) + } + + func testCheckForMeetingWithNilTitle() async { + let mockWindow = MockWindow(title: nil) + let result = await sut.checkForMeeting(in: [mockWindow]) + + XCTAssertFalse(result.isActive) + XCTAssertNil(result.title) + XCTAssertEqual(result.confidence, .low) + } + + func testCheckForMeetingReturnsFirstMatch() async { + let meetingTitle1 = "Zoom Meeting - Client Call" + let meetingTitle2 = "Zoom - Another Meeting" + let mockWindow1 = MockWindow(title: meetingTitle1) + let mockWindow2 = MockWindow(title: meetingTitle2) + + let result = await sut.checkForMeeting(in: [mockWindow1, mockWindow2]) + + XCTAssertTrue(result.isActive) + XCTAssertEqual(result.title, meetingTitle1) + } + + func testCheckForMeetingWithMixedCaseZoom() async { + let meetingTitle = "zoom meeting with team" + let mockWindow = MockWindow(title: meetingTitle) + let result = await sut.checkForMeeting(in: [mockWindow]) + + XCTAssertTrue(result.isActive) + XCTAssertEqual(result.title, meetingTitle) + XCTAssertNotEqual(result.confidence, .low) + } + + func testCheckForMeetingWithZoomWebinar() async { + let meetingTitle = "Zoom Webinar - Product Launch" + let mockWindow = MockWindow(title: meetingTitle) + let result = await sut.checkForMeeting(in: [mockWindow]) + + XCTAssertTrue(result.isActive) + XCTAssertEqual(result.title, meetingTitle) + XCTAssertNotEqual(result.confidence, .low) + } +} diff --git a/RecapTests/Services/MeetingDetection/MeetingDetectionServiceSpec.swift b/RecapTests/Services/MeetingDetection/MeetingDetectionServiceSpec.swift index 4a41400..b12195d 100644 --- a/RecapTests/Services/MeetingDetection/MeetingDetectionServiceSpec.swift +++ b/RecapTests/Services/MeetingDetection/MeetingDetectionServiceSpec.swift @@ -1,163 +1,165 @@ -import XCTest import Combine -@testable import Recap import Mockable +import XCTest + +@testable import Recap @MainActor final class MeetingDetectionServiceSpec: XCTestCase { - private var sut: MeetingDetectionService! - private var mockAudioProcessController: MockAudioProcessControllerType! - private var cancellables: Set! - - override func setUp() async throws { - try await super.setUp() - - mockAudioProcessController = MockAudioProcessControllerType() - cancellables = Set() - - let emptyProcesses: [AudioProcess] = [] - let emptyGroups: [AudioProcessGroup] = [] - - given(mockAudioProcessController) - .processes - .willReturn(emptyProcesses) - - given(mockAudioProcessController) - .processGroups - .willReturn(emptyGroups) - - given(mockAudioProcessController) - .meetingApps - .willReturn(emptyProcesses) - - let mockPermissionsHelper = MockPermissionsHelperType() - sut = MeetingDetectionService(audioProcessController: mockAudioProcessController, permissionsHelper: mockPermissionsHelper) - } - - override func tearDown() async throws { - sut = nil - mockAudioProcessController = nil - cancellables = nil - - try await super.tearDown() - } - - // MARK: - Initialization Tests - - func testInitialState() { - XCTAssertFalse(sut.isMeetingActive) - XCTAssertNil(sut.activeMeetingInfo) - XCTAssertNil(sut.detectedMeetingApp) - XCTAssertFalse(sut.hasPermission) - XCTAssertFalse(sut.isMonitoring) - } - - // MARK: - Monitoring Tests - - func testStartMonitoring() { - XCTAssertFalse(sut.isMonitoring) - - sut.startMonitoring() - - XCTAssertTrue(sut.isMonitoring) - } - - func testStopMonitoring() { - sut.startMonitoring() - XCTAssertTrue(sut.isMonitoring) - - sut.stopMonitoring() - - XCTAssertFalse(sut.isMonitoring) - XCTAssertFalse(sut.isMeetingActive) - XCTAssertNil(sut.activeMeetingInfo) - XCTAssertNil(sut.detectedMeetingApp) - } - - func testStartMonitoringTwiceDoesNotDuplicate() { - sut.startMonitoring() - let firstIsMonitoring = sut.isMonitoring - - sut.startMonitoring() - - XCTAssertEqual(firstIsMonitoring, sut.isMonitoring) - XCTAssertTrue(sut.isMonitoring) - } - - func testMeetingStatePublisherEmitsInactive() async throws { - let expectation = XCTestExpectation(description: "Meeting state publisher emits inactive") - - sut.meetingStatePublisher - .sink { state in - if case .inactive = state { - expectation.fulfill() - } - } - .store(in: &cancellables) - - await fulfillment(of: [expectation], timeout: 1.0) - } - - func testMeetingStatePublisherRemovesDuplicates() async throws { - var receivedStates: [MeetingState] = [] - - sut.meetingStatePublisher - .sink { state in - receivedStates.append(state) - } - .store(in: &cancellables) - - try await Task.sleep(nanoseconds: 100_000_000) - - XCTAssertEqual(receivedStates.count, 1) - XCTAssertEqual(receivedStates.first, .inactive) - } - - - func testStopMonitoringClearsAllState() { - sut.startMonitoring() - - sut.stopMonitoring() - - XCTAssertFalse(sut.isMeetingActive) - XCTAssertNil(sut.activeMeetingInfo) - XCTAssertNil(sut.detectedMeetingApp) - XCTAssertFalse(sut.isMonitoring) - } - - func testMeetingDetectionServiceRespectsControllerProcesses() { - let teamsProcess = TestData.createAudioProcess( - name: "Microsoft Teams", - bundleID: "com.microsoft.teams2" - ) - - let processes: [RecapTests.AudioProcess] = [teamsProcess] - - given(mockAudioProcessController) - .processes - .willReturn(processes) - - verify(mockAudioProcessController) - .processes - .called(0) - } + private var sut: MeetingDetectionService! + private var mockAudioProcessController: MockAudioProcessControllerType! + private var cancellables: Set! + + override func setUp() async throws { + try await super.setUp() + + mockAudioProcessController = MockAudioProcessControllerType() + cancellables = Set() + + let emptyProcesses: [AudioProcess] = [] + let emptyGroups: [AudioProcessGroup] = [] + + given(mockAudioProcessController) + .processes + .willReturn(emptyProcesses) + + given(mockAudioProcessController) + .processGroups + .willReturn(emptyGroups) + + given(mockAudioProcessController) + .meetingApps + .willReturn(emptyProcesses) + + let mockPermissionsHelper = MockPermissionsHelperType() + sut = MeetingDetectionService( + audioProcessController: mockAudioProcessController, + permissionsHelper: mockPermissionsHelper) + } + + override func tearDown() async throws { + sut = nil + mockAudioProcessController = nil + cancellables = nil + + try await super.tearDown() + } + + // MARK: - Initialization Tests + + func testInitialState() { + XCTAssertFalse(sut.isMeetingActive) + XCTAssertNil(sut.activeMeetingInfo) + XCTAssertNil(sut.detectedMeetingApp) + XCTAssertFalse(sut.hasPermission) + XCTAssertFalse(sut.isMonitoring) + } + + // MARK: - Monitoring Tests + + func testStartMonitoring() { + XCTAssertFalse(sut.isMonitoring) + + sut.startMonitoring() + + XCTAssertTrue(sut.isMonitoring) + } + + func testStopMonitoring() { + sut.startMonitoring() + XCTAssertTrue(sut.isMonitoring) + + sut.stopMonitoring() + + XCTAssertFalse(sut.isMonitoring) + XCTAssertFalse(sut.isMeetingActive) + XCTAssertNil(sut.activeMeetingInfo) + XCTAssertNil(sut.detectedMeetingApp) + } + + func testStartMonitoringTwiceDoesNotDuplicate() { + sut.startMonitoring() + let firstIsMonitoring = sut.isMonitoring + + sut.startMonitoring() + + XCTAssertEqual(firstIsMonitoring, sut.isMonitoring) + XCTAssertTrue(sut.isMonitoring) + } + + func testMeetingStatePublisherEmitsInactive() async throws { + let expectation = XCTestExpectation(description: "Meeting state publisher emits inactive") + + sut.meetingStatePublisher + .sink { state in + if case .inactive = state { + expectation.fulfill() + } + } + .store(in: &cancellables) + + await fulfillment(of: [expectation], timeout: 1.0) + } + + func testMeetingStatePublisherRemovesDuplicates() async throws { + var receivedStates: [MeetingState] = [] + + sut.meetingStatePublisher + .sink { state in + receivedStates.append(state) + } + .store(in: &cancellables) + + try await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertEqual(receivedStates.count, 1) + XCTAssertEqual(receivedStates.first, .inactive) + } + + func testStopMonitoringClearsAllState() { + sut.startMonitoring() + + sut.stopMonitoring() + + XCTAssertFalse(sut.isMeetingActive) + XCTAssertNil(sut.activeMeetingInfo) + XCTAssertNil(sut.detectedMeetingApp) + XCTAssertFalse(sut.isMonitoring) + } + + func testMeetingDetectionServiceRespectsControllerProcesses() { + let teamsProcess = TestData.createAudioProcess( + name: "Microsoft Teams", + bundleID: "com.microsoft.teams2" + ) + + let processes: [RecapTests.AudioProcess] = [teamsProcess] + + given(mockAudioProcessController) + .processes + .willReturn(processes) + + verify(mockAudioProcessController) + .processes + .called(0) + } } // MARK: - Test Helpers private enum TestData { - static func createAudioProcess( - name: String, - bundleID: String? = nil - ) -> RecapTests.AudioProcess { - RecapTests.AudioProcess( - id: pid_t(Int32.random(in: 1000...9999)), - kind: .app, - name: name, - audioActive: true, - bundleID: bundleID, - bundleURL: nil, - objectID: 0 - ) - } + static func createAudioProcess( + name: String, + bundleID: String? = nil + ) -> RecapTests.AudioProcess { + RecapTests.AudioProcess( + id: pid_t(Int32.random(in: 1000...9999)), + kind: .app, + name: name, + audioActive: true, + bundleID: bundleID, + bundleURL: nil, + objectID: 0 + ) + } } diff --git a/RecapTests/UseCases/Onboarding/ViewModels/OnboardingViewModelSpec.swift b/RecapTests/UseCases/Onboarding/ViewModels/OnboardingViewModelSpec.swift index 6f42cee..0390eed 100644 --- a/RecapTests/UseCases/Onboarding/ViewModels/OnboardingViewModelSpec.swift +++ b/RecapTests/UseCases/Onboarding/ViewModels/OnboardingViewModelSpec.swift @@ -1,203 +1,204 @@ -import XCTest -import Combine import AVFoundation +import Combine import Mockable +import XCTest + @testable import Recap @MainActor final class OnboardingViewModelSpec: XCTestCase { - private var sut: OnboardingViewModel! - private var mockUserPreferencesRepository: MockUserPreferencesRepositoryType! - private var mockPermissionsHelper: MockPermissionsHelperType! - private var mockDelegate: MockOnboardingDelegate! - private var cancellables = Set() - - override func setUp() async throws { - try await super.setUp() - - mockUserPreferencesRepository = MockUserPreferencesRepositoryType() - mockPermissionsHelper = MockPermissionsHelperType() - - given(mockUserPreferencesRepository) - .getOrCreatePreferences() - .willReturn(UserPreferencesInfo()) - - given(mockPermissionsHelper) - .checkMicrophonePermissionStatus() - .willReturn(.notDetermined) - given(mockPermissionsHelper) - .checkNotificationPermissionStatus() - .willReturn(false) - given(mockPermissionsHelper) - .checkScreenRecordingPermission() - .willReturn(false) - - mockDelegate = MockOnboardingDelegate() - - sut = OnboardingViewModel( - permissionsHelper: mockPermissionsHelper, - userPreferencesRepository: mockUserPreferencesRepository - ) - sut.delegate = mockDelegate - - try await Task.sleep(nanoseconds: 100_000_000) - } - - override func tearDown() async throws { - sut = nil - mockUserPreferencesRepository = nil - mockPermissionsHelper = nil - mockDelegate = nil - cancellables.removeAll() - - try await super.tearDown() - } - - func testInitialState() async throws { - XCTAssertFalse(sut.isMicrophoneEnabled) - XCTAssertFalse(sut.isAutoDetectMeetingsEnabled) - XCTAssertTrue(sut.isAutoSummarizeEnabled) - XCTAssertTrue(sut.isLiveTranscriptionEnabled) - XCTAssertFalse(sut.hasRequiredPermissions) - XCTAssertTrue(sut.canContinue) - XCTAssertFalse(sut.showErrorToast) - XCTAssertEqual(sut.errorMessage, "") - } - - func testToggleAutoSummarize() { - XCTAssertTrue(sut.isAutoSummarizeEnabled) - - sut.toggleAutoSummarize(false) - XCTAssertFalse(sut.isAutoSummarizeEnabled) - - sut.toggleAutoSummarize(true) - XCTAssertTrue(sut.isAutoSummarizeEnabled) - } - - func testToggleLiveTranscription() { - XCTAssertTrue(sut.isLiveTranscriptionEnabled) - - sut.toggleLiveTranscription(false) - XCTAssertFalse(sut.isLiveTranscriptionEnabled) - - sut.toggleLiveTranscription(true) - XCTAssertTrue(sut.isLiveTranscriptionEnabled) - } - - func testCompleteOnboardingSuccess() async throws { - sut.isAutoDetectMeetingsEnabled = true - sut.isAutoSummarizeEnabled = false - - given(mockUserPreferencesRepository) - .updateOnboardingStatus(.value(true)) - .willReturn() - given(mockUserPreferencesRepository) - .updateAutoDetectMeetings(.value(true)) - .willReturn() - given(mockUserPreferencesRepository) - .updateAutoSummarize(.value(false)) - .willReturn() - - sut.completeOnboarding() - - try await Task.sleep(nanoseconds: 200_000_000) - - XCTAssertTrue(mockDelegate.onboardingDidCompleteCalled) - XCTAssertFalse(sut.showErrorToast) - XCTAssertEqual(sut.errorMessage, "") - - verify(mockUserPreferencesRepository) - .updateOnboardingStatus(.value(true)) - .called(1) - verify(mockUserPreferencesRepository) - .updateAutoDetectMeetings(.value(true)) - .called(1) - verify(mockUserPreferencesRepository) - .updateAutoSummarize(.value(false)) - .called(1) - } - - func testCompleteOnboardingFailure() async throws { - given(mockUserPreferencesRepository) - .updateOnboardingStatus(.any) - .willThrow(TestError.mockError) - - sut.completeOnboarding() - - try await Task.sleep(nanoseconds: 200_000_000) - - XCTAssertFalse(mockDelegate.onboardingDidCompleteCalled) - XCTAssertTrue(sut.showErrorToast) - XCTAssertEqual(sut.errorMessage, "Failed to save preferences. Please try again.") - - try await Task.sleep(nanoseconds: 3_200_000_000) - - XCTAssertFalse(sut.showErrorToast) - } - - func testAutoDetectMeetingsToggleWithPermissions() async throws { - given(mockPermissionsHelper) - .requestScreenRecordingPermission() - .willReturn(true) - given(mockPermissionsHelper) - .requestNotificationPermission() - .willReturn(true) - - await sut.toggleAutoDetectMeetings(true) - - XCTAssertTrue(sut.isAutoDetectMeetingsEnabled) - XCTAssertTrue(sut.hasRequiredPermissions) - } - - func testAutoDetectMeetingsToggleWithoutPermissions() async throws { - given(mockPermissionsHelper) - .requestScreenRecordingPermission() - .willReturn(false) - given(mockPermissionsHelper) - .requestNotificationPermission() - .willReturn(true) - - await sut.toggleAutoDetectMeetings(true) - - XCTAssertFalse(sut.isAutoDetectMeetingsEnabled) - XCTAssertFalse(sut.hasRequiredPermissions) - } - - func testAutoDetectMeetingsToggleOff() async throws { - sut.isAutoDetectMeetingsEnabled = true - sut.hasRequiredPermissions = true - - await sut.toggleAutoDetectMeetings(false) - - XCTAssertFalse(sut.isAutoDetectMeetingsEnabled) - } - - func testMicrophonePermissionToggle() async throws { - given(mockPermissionsHelper) - .requestMicrophonePermission() - .willReturn(true) - - await sut.requestMicrophonePermission(true) - - XCTAssertTrue(sut.isMicrophoneEnabled) - - await sut.requestMicrophonePermission(false) - - XCTAssertFalse(sut.isMicrophoneEnabled) - } + private var sut: OnboardingViewModel! + private var mockUserPreferencesRepository: MockUserPreferencesRepositoryType! + private var mockPermissionsHelper: MockPermissionsHelperType! + private var mockDelegate: MockOnboardingDelegate! + private var cancellables = Set() + + override func setUp() async throws { + try await super.setUp() + + mockUserPreferencesRepository = MockUserPreferencesRepositoryType() + mockPermissionsHelper = MockPermissionsHelperType() + + given(mockUserPreferencesRepository) + .getOrCreatePreferences() + .willReturn(UserPreferencesInfo()) + + given(mockPermissionsHelper) + .checkMicrophonePermissionStatus() + .willReturn(.notDetermined) + given(mockPermissionsHelper) + .checkNotificationPermissionStatus() + .willReturn(false) + given(mockPermissionsHelper) + .checkScreenRecordingPermission() + .willReturn(false) + + mockDelegate = MockOnboardingDelegate() + + sut = OnboardingViewModel( + permissionsHelper: mockPermissionsHelper, + userPreferencesRepository: mockUserPreferencesRepository + ) + sut.delegate = mockDelegate + + try await Task.sleep(nanoseconds: 100_000_000) + } + + override func tearDown() async throws { + sut = nil + mockUserPreferencesRepository = nil + mockPermissionsHelper = nil + mockDelegate = nil + cancellables.removeAll() + + try await super.tearDown() + } + + func testInitialState() async throws { + XCTAssertFalse(sut.isMicrophoneEnabled) + XCTAssertFalse(sut.isAutoDetectMeetingsEnabled) + XCTAssertTrue(sut.isAutoSummarizeEnabled) + XCTAssertTrue(sut.isLiveTranscriptionEnabled) + XCTAssertFalse(sut.hasRequiredPermissions) + XCTAssertTrue(sut.canContinue) + XCTAssertFalse(sut.showErrorToast) + XCTAssertEqual(sut.errorMessage, "") + } + + func testToggleAutoSummarize() { + XCTAssertTrue(sut.isAutoSummarizeEnabled) + + sut.toggleAutoSummarize(false) + XCTAssertFalse(sut.isAutoSummarizeEnabled) + + sut.toggleAutoSummarize(true) + XCTAssertTrue(sut.isAutoSummarizeEnabled) + } + + func testToggleLiveTranscription() { + XCTAssertTrue(sut.isLiveTranscriptionEnabled) + + sut.toggleLiveTranscription(false) + XCTAssertFalse(sut.isLiveTranscriptionEnabled) + + sut.toggleLiveTranscription(true) + XCTAssertTrue(sut.isLiveTranscriptionEnabled) + } + + func testCompleteOnboardingSuccess() async throws { + sut.isAutoDetectMeetingsEnabled = true + sut.isAutoSummarizeEnabled = false + + given(mockUserPreferencesRepository) + .updateOnboardingStatus(.value(true)) + .willReturn() + given(mockUserPreferencesRepository) + .updateAutoDetectMeetings(.value(true)) + .willReturn() + given(mockUserPreferencesRepository) + .updateAutoSummarize(.value(false)) + .willReturn() + + sut.completeOnboarding() + + try await Task.sleep(nanoseconds: 200_000_000) + + XCTAssertTrue(mockDelegate.onboardingDidCompleteCalled) + XCTAssertFalse(sut.showErrorToast) + XCTAssertEqual(sut.errorMessage, "") + + verify(mockUserPreferencesRepository) + .updateOnboardingStatus(.value(true)) + .called(1) + verify(mockUserPreferencesRepository) + .updateAutoDetectMeetings(.value(true)) + .called(1) + verify(mockUserPreferencesRepository) + .updateAutoSummarize(.value(false)) + .called(1) + } + + func testCompleteOnboardingFailure() async throws { + given(mockUserPreferencesRepository) + .updateOnboardingStatus(.any) + .willThrow(TestError.mockError) + + sut.completeOnboarding() + + try await Task.sleep(nanoseconds: 200_000_000) + + XCTAssertFalse(mockDelegate.onboardingDidCompleteCalled) + XCTAssertTrue(sut.showErrorToast) + XCTAssertEqual(sut.errorMessage, "Failed to save preferences. Please try again.") + + try await Task.sleep(nanoseconds: 3_200_000_000) + + XCTAssertFalse(sut.showErrorToast) + } + + func testAutoDetectMeetingsToggleWithPermissions() async throws { + given(mockPermissionsHelper) + .requestScreenRecordingPermission() + .willReturn(true) + given(mockPermissionsHelper) + .requestNotificationPermission() + .willReturn(true) + + await sut.toggleAutoDetectMeetings(true) + + XCTAssertTrue(sut.isAutoDetectMeetingsEnabled) + XCTAssertTrue(sut.hasRequiredPermissions) + } + + func testAutoDetectMeetingsToggleWithoutPermissions() async throws { + given(mockPermissionsHelper) + .requestScreenRecordingPermission() + .willReturn(false) + given(mockPermissionsHelper) + .requestNotificationPermission() + .willReturn(true) + + await sut.toggleAutoDetectMeetings(true) + + XCTAssertFalse(sut.isAutoDetectMeetingsEnabled) + XCTAssertFalse(sut.hasRequiredPermissions) + } + + func testAutoDetectMeetingsToggleOff() async throws { + sut.isAutoDetectMeetingsEnabled = true + sut.hasRequiredPermissions = true + + await sut.toggleAutoDetectMeetings(false) + + XCTAssertFalse(sut.isAutoDetectMeetingsEnabled) + } + + func testMicrophonePermissionToggle() async throws { + given(mockPermissionsHelper) + .requestMicrophonePermission() + .willReturn(true) + + await sut.requestMicrophonePermission(true) + + XCTAssertTrue(sut.isMicrophoneEnabled) + + await sut.requestMicrophonePermission(false) + + XCTAssertFalse(sut.isMicrophoneEnabled) + } } // MARK: - Mock Classes @MainActor private class MockOnboardingDelegate: OnboardingDelegate { - var onboardingDidCompleteCalled = false - - func onboardingDidComplete() { - onboardingDidCompleteCalled = true - } + var onboardingDidCompleteCalled = false + + func onboardingDidComplete() { + onboardingDidCompleteCalled = true + } } private enum TestError: Error { - case mockError -} \ No newline at end of file + case mockError +} diff --git a/RecapTests/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelSpec+APIKeys.swift b/RecapTests/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelSpec+APIKeys.swift new file mode 100644 index 0000000..445a1b3 --- /dev/null +++ b/RecapTests/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelSpec+APIKeys.swift @@ -0,0 +1,64 @@ +import Combine +import Mockable +import XCTest + +@testable import Recap + +@MainActor +extension GeneralSettingsViewModelSpec { + func testSaveAPIKeySuccess() async throws { + await initSut() + + given(mockKeychainService) + .store(key: .value(KeychainKey.openRouterApiKey.key), value: .value("test-api-key")) + .willReturn() + + given(mockLLMService) + .reinitializeProviders() + .willReturn() + + given(mockKeychainAPIValidator) + .validateOpenRouterAPI() + .willReturn(.valid) + + given(mockLLMService) + .selectProvider(.value(.openRouter)) + .willReturn() + + given(mockLLMService) + .getAvailableModels() + .willReturn([]) + + given(mockLLMService) + .getSelectedModel() + .willReturn(nil) + + try await sut.saveAPIKey("test-api-key") + + XCTAssertFalse(sut.showAPIKeyAlert) + XCTAssertEqual(sut.existingAPIKey, "test-api-key") + XCTAssertEqual(sut.selectedProvider, .openRouter) + } + + func testDismissAPIKeyAlert() async throws { + await initSut() + + given(mockKeychainAPIValidator) + .validateOpenRouterAPI() + .willReturn(.missingApiKey) + + given(mockKeychainService) + .retrieve(key: .value(KeychainKey.openRouterApiKey.key)) + .willReturn("existing-key") + + await sut.selectProvider(.openRouter) + + XCTAssertTrue(sut.showAPIKeyAlert) + XCTAssertEqual(sut.existingAPIKey, "existing-key") + + sut.dismissAPIKeyAlert() + + XCTAssertFalse(sut.showAPIKeyAlert) + XCTAssertNil(sut.existingAPIKey) + } +} diff --git a/RecapTests/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelSpec+ModelSelection.swift b/RecapTests/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelSpec+ModelSelection.swift new file mode 100644 index 0000000..5a30577 --- /dev/null +++ b/RecapTests/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelSpec+ModelSelection.swift @@ -0,0 +1,101 @@ +import Combine +import Mockable +import XCTest + +@testable import Recap + +@MainActor +extension GeneralSettingsViewModelSpec { + func testLoadModelsSuccess() async throws { + let testModels = [ + LLMModelInfo(id: "model1", name: "Model 1", provider: "ollama"), + LLMModelInfo(id: "model2", name: "Model 2", provider: "ollama") + ] + + await initSut( + availableModels: testModels, + selectedModel: testModels[0] + ) + + XCTAssertEqual(sut.availableModels.count, 2) + XCTAssertEqual(sut.selectedModel?.id, "model1") + XCTAssertTrue(sut.hasModels) + XCTAssertFalse(sut.isLoading) + XCTAssertNil(sut.errorMessage) + } + + func testLoadModelsError() async throws { + given(mockWarningManager) + .activeWarningsPublisher + .willReturn(Just([]).eraseToAnyPublisher()) + + given(mockLLMService) + .getUserPreferences() + .willReturn( + UserPreferencesInfo( + selectedProvider: .ollama, + autoDetectMeetings: false, + autoStopRecording: false + )) + + given(mockLLMService) + .getAvailableModels() + .willThrow( + NSError(domain: "TestError", code: 500, userInfo: [NSLocalizedDescriptionKey: "Test error"]) + ) + + given(mockLLMService) + .getSelectedModel() + .willReturn(nil) + + sut = GeneralSettingsViewModel( + llmService: mockLLMService, + userPreferencesRepository: mockUserPreferencesRepository, + keychainAPIValidator: mockKeychainAPIValidator, + keychainService: mockKeychainService, + warningManager: mockWarningManager, + fileManagerHelper: mockFileManagerHelper + ) + + try await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertNotNil(sut.errorMessage) + XCTAssertTrue(sut.errorMessage?.contains("Test error") ?? false) + XCTAssertFalse(sut.isLoading) + XCTAssertEqual(sut.availableModels.count, 0) + } + + func testSelectModelSuccess() async throws { + await initSut() + + let testModel = LLMModelInfo(id: "model1", name: "Model 1", provider: "ollama") + + given(mockLLMService) + .selectModel(id: .value("model1")) + .willReturn() + + await sut.selectModel(testModel) + + XCTAssertEqual(sut.selectedModel?.id, "model1") + XCTAssertNil(sut.errorMessage) + + verify(mockLLMService) + .selectModel(id: .value("model1")) + .called(1) + } + + func testSelectModelError() async throws { + await initSut() + + let testModel = LLMModelInfo(id: "model1", name: "Model 1", provider: "ollama") + + given(mockLLMService) + .selectModel(id: .any) + .willThrow(NSError(domain: "TestError", code: 500)) + + await sut.selectModel(testModel) + + XCTAssertNil(sut.selectedModel) + XCTAssertNotNil(sut.errorMessage) + } +} diff --git a/RecapTests/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelSpec+Preferences.swift b/RecapTests/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelSpec+Preferences.swift new file mode 100644 index 0000000..a533f7f --- /dev/null +++ b/RecapTests/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelSpec+Preferences.swift @@ -0,0 +1,103 @@ +import Combine +import Mockable +import XCTest + +@testable import Recap + +@MainActor +extension GeneralSettingsViewModelSpec { + func testToggleAutoDetectMeetingsSuccess() async throws { + await initSut() + + given(mockUserPreferencesRepository) + .updateAutoDetectMeetings(.value(true)) + .willReturn() + + await sut.toggleAutoDetectMeetings(true) + + XCTAssertTrue(sut.autoDetectMeetings) + XCTAssertNil(sut.errorMessage) + + verify(mockUserPreferencesRepository) + .updateAutoDetectMeetings(.value(true)) + .called(1) + } + + func testToggleAutoDetectMeetingsError() async throws { + await initSut() + + given(mockUserPreferencesRepository) + .updateAutoDetectMeetings(.any) + .willThrow(NSError(domain: "TestError", code: 500)) + + await sut.toggleAutoDetectMeetings(true) + + XCTAssertFalse(sut.autoDetectMeetings) + XCTAssertNotNil(sut.errorMessage) + } + + func testToggleAutoStopRecordingSuccess() async throws { + await initSut() + + given(mockUserPreferencesRepository) + .updateAutoStopRecording(.value(true)) + .willReturn() + + await sut.toggleAutoStopRecording(true) + + XCTAssertTrue(sut.isAutoStopRecording) + XCTAssertNil(sut.errorMessage) + + verify(mockUserPreferencesRepository) + .updateAutoStopRecording(.value(true)) + .called(1) + } + + func testWarningManagerIntegration() async throws { + let testWarnings = [ + WarningItem(id: "1", title: "Test Warning", message: "Test warning message") + ] + + let warningPublisher = PassthroughSubject<[WarningItem], Never>() + given(mockWarningManager) + .activeWarningsPublisher + .willReturn(warningPublisher.eraseToAnyPublisher()) + + given(mockLLMService) + .getUserPreferences() + .willReturn( + UserPreferencesInfo( + selectedProvider: .ollama, + autoDetectMeetings: false, + autoStopRecording: false + )) + + given(mockLLMService) + .getAvailableModels() + .willReturn([]) + + given(mockLLMService) + .getSelectedModel() + .willReturn(nil) + + sut = GeneralSettingsViewModel( + llmService: mockLLMService, + userPreferencesRepository: mockUserPreferencesRepository, + keychainAPIValidator: mockKeychainAPIValidator, + keychainService: mockKeychainService, + warningManager: mockWarningManager, + fileManagerHelper: mockFileManagerHelper + ) + + try await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertEqual(sut.activeWarnings.count, 0) + + warningPublisher.send(testWarnings) + + try await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertEqual(sut.activeWarnings.count, 1) + XCTAssertEqual(sut.activeWarnings.first?.title, "Test Warning") + } +} diff --git a/RecapTests/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelSpec+ProviderSelection.swift b/RecapTests/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelSpec+ProviderSelection.swift new file mode 100644 index 0000000..4d05038 --- /dev/null +++ b/RecapTests/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelSpec+ProviderSelection.swift @@ -0,0 +1,111 @@ +import Combine +import Mockable +import XCTest + +@testable import Recap + +@MainActor +extension GeneralSettingsViewModelSpec { + func testSelectProviderOllama() async throws { + let testModels = [ + LLMModelInfo(id: "ollama1", name: "Ollama Model", provider: "ollama") + ] + + given(mockWarningManager) + .activeWarningsPublisher + .willReturn(Just([]).eraseToAnyPublisher()) + + given(mockLLMService) + .getUserPreferences() + .willReturn( + UserPreferencesInfo( + selectedProvider: .ollama, + autoDetectMeetings: false, + autoStopRecording: false + )) + + given(mockLLMService) + .getAvailableModels() + .willReturn([]) + .getAvailableModels() + .willReturn(testModels) + + given(mockLLMService) + .getSelectedModel() + .willReturn(nil) + .getSelectedModel() + .willReturn(testModels[0]) + + given(mockLLMService) + .selectProvider(.value(.ollama)) + .willReturn() + + sut = GeneralSettingsViewModel( + llmService: mockLLMService, + userPreferencesRepository: mockUserPreferencesRepository, + keychainAPIValidator: mockKeychainAPIValidator, + keychainService: mockKeychainService, + warningManager: mockWarningManager, + fileManagerHelper: mockFileManagerHelper + ) + + try? await Task.sleep(nanoseconds: 100_000_000) + + await sut.selectProvider(.ollama) + + XCTAssertEqual(sut.selectedProvider, .ollama) + XCTAssertEqual(sut.availableModels.count, 1) + XCTAssertNil(sut.errorMessage) + } + + func testSelectProviderOpenRouterWithoutAPIKey() async throws { + await initSut() + + given(mockKeychainAPIValidator) + .validateOpenRouterAPI() + .willReturn(.missingApiKey) + + given(mockKeychainService) + .retrieve(key: .value(KeychainKey.openRouterApiKey.key)) + .willReturn(nil) + + await sut.selectProvider(.openRouter) + + XCTAssertTrue(sut.showAPIKeyAlert) + XCTAssertNil(sut.existingAPIKey) + XCTAssertNotEqual(sut.selectedProvider, .openRouter) + } + + func testSelectProviderOpenRouterWithValidAPIKey() async throws { + await initSut() + + given(mockKeychainAPIValidator) + .validateOpenRouterAPI() + .willReturn(.valid) + + let testModels = [ + LLMModelInfo(id: "openrouter1", name: "OpenRouter Model", provider: "openrouter") + ] + + given(mockLLMService) + .selectProvider(.value(.openRouter)) + .willReturn() + + given(mockLLMService) + .getAvailableModels() + .willReturn(testModels) + + given(mockLLMService) + .getSelectedModel() + .willReturn(nil) + + given(mockLLMService) + .selectModel(id: .any) + .willReturn() + + await sut.selectProvider(.openRouter) + + XCTAssertEqual(sut.selectedProvider, .openRouter) + XCTAssertFalse(sut.showAPIKeyAlert) + } +} diff --git a/RecapTests/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelSpec.swift b/RecapTests/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelSpec.swift index cfd9326..79e563b 100644 --- a/RecapTests/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelSpec.swift +++ b/RecapTests/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelSpec.swift @@ -1,419 +1,110 @@ -import XCTest import Combine import Mockable +import XCTest + @testable import Recap @MainActor -final class GeneralSettingsViewModelSpec: XCTestCase { - private var sut: GeneralSettingsViewModel! - private var mockLLMService: MockLLMServiceType! - private var mockUserPreferencesRepository: MockUserPreferencesRepositoryType! - private var mockKeychainAPIValidator: MockKeychainAPIValidatorType! - private var mockKeychainService: MockKeychainServiceType! - private var mockWarningManager: MockWarningManagerType! - private var cancellables = Set() - - override func setUp() async throws { - try await super.setUp() - - mockLLMService = MockLLMServiceType() - mockUserPreferencesRepository = MockUserPreferencesRepositoryType() - mockKeychainAPIValidator = MockKeychainAPIValidatorType() - mockKeychainService = MockKeychainServiceType() - mockWarningManager = MockWarningManagerType() - } - - private func initSut( - preferences: UserPreferencesInfo = UserPreferencesInfo( - selectedProvider: .ollama, - autoDetectMeetings: false, - autoStopRecording: false - ), - availableModels: [LLMModelInfo] = [], - selectedModel: LLMModelInfo? = nil, - warnings: [WarningItem] = [] - ) async { - given(mockWarningManager) - .activeWarningsPublisher - .willReturn(Just(warnings).eraseToAnyPublisher()) - - given(mockLLMService) - .getUserPreferences() - .willReturn(preferences) - - given(mockLLMService) - .getAvailableModels() - .willReturn(availableModels) - - given(mockLLMService) - .getSelectedModel() - .willReturn(selectedModel) - - sut = GeneralSettingsViewModel( - llmService: mockLLMService, - userPreferencesRepository: mockUserPreferencesRepository, - keychainAPIValidator: mockKeychainAPIValidator, - keychainService: mockKeychainService, - warningManager: mockWarningManager - ) - - try? await Task.sleep(nanoseconds: 100_000_000) - } - - override func tearDown() async throws { - sut = nil - mockLLMService = nil - mockUserPreferencesRepository = nil - mockKeychainAPIValidator = nil - mockKeychainService = nil - mockWarningManager = nil - cancellables.removeAll() - - try await super.tearDown() - } - - func testInitialState() async throws { - await initSut() - - XCTAssertFalse(sut.isLoading) - XCTAssertNil(sut.errorMessage) - XCTAssertEqual(sut.selectedProvider, .ollama) - XCTAssertFalse(sut.autoDetectMeetings) - XCTAssertFalse(sut.isAutoStopRecording) - } - - func testLoadModelsSuccess() async throws { - let testModels = [ - LLMModelInfo(id: "model1", name: "Model 1", provider: "ollama"), - LLMModelInfo(id: "model2", name: "Model 2", provider: "ollama") - ] - - await initSut( - availableModels: testModels, - selectedModel: testModels[0] - ) - - XCTAssertEqual(sut.availableModels.count, 2) - XCTAssertEqual(sut.selectedModel?.id, "model1") - XCTAssertTrue(sut.hasModels) - XCTAssertFalse(sut.isLoading) - XCTAssertNil(sut.errorMessage) - } - - func testLoadModelsError() async throws { - given(mockWarningManager) - .activeWarningsPublisher - .willReturn(Just([]).eraseToAnyPublisher()) - - given(mockLLMService) - .getUserPreferences() - .willReturn(UserPreferencesInfo( - selectedProvider: .ollama, - autoDetectMeetings: false, - autoStopRecording: false - )) - - given(mockLLMService) - .getAvailableModels() - .willThrow(NSError(domain: "TestError", code: 500, userInfo: [NSLocalizedDescriptionKey: "Test error"])) - - given(mockLLMService) - .getSelectedModel() - .willReturn(nil) - - sut = GeneralSettingsViewModel( - llmService: mockLLMService, - userPreferencesRepository: mockUserPreferencesRepository, - keychainAPIValidator: mockKeychainAPIValidator, - keychainService: mockKeychainService, - warningManager: mockWarningManager - ) - - try await Task.sleep(nanoseconds: 100_000_000) - - XCTAssertNotNil(sut.errorMessage) - XCTAssertTrue(sut.errorMessage?.contains("Test error") ?? false) - XCTAssertFalse(sut.isLoading) - XCTAssertEqual(sut.availableModels.count, 0) - } - - func testSelectModelSuccess() async throws { - await initSut() - - let testModel = LLMModelInfo(id: "model1", name: "Model 1", provider: "ollama") - - given(mockLLMService) - .selectModel(id: .value("model1")) - .willReturn() - - await sut.selectModel(testModel) - - XCTAssertEqual(sut.selectedModel?.id, "model1") - XCTAssertNil(sut.errorMessage) - - verify(mockLLMService) - .selectModel(id: .value("model1")) - .called(1) - } - - func testSelectModelError() async throws { - await initSut() - - let testModel = LLMModelInfo(id: "model1", name: "Model 1", provider: "ollama") - - given(mockLLMService) - .selectModel(id: .any) - .willThrow(NSError(domain: "TestError", code: 500)) - - await sut.selectModel(testModel) - - XCTAssertNil(sut.selectedModel) - XCTAssertNotNil(sut.errorMessage) - } - - func testSelectProviderOllama() async throws { - let testModels = [ - LLMModelInfo(id: "ollama1", name: "Ollama Model", provider: "ollama") - ] - - given(mockWarningManager) - .activeWarningsPublisher - .willReturn(Just([]).eraseToAnyPublisher()) - - given(mockLLMService) - .getUserPreferences() - .willReturn(UserPreferencesInfo( - selectedProvider: .ollama, - autoDetectMeetings: false, - autoStopRecording: false - )) - - given(mockLLMService) - .getAvailableModels() - .willReturn([]) - .getAvailableModels() - .willReturn(testModels) - - given(mockLLMService) - .getSelectedModel() - .willReturn(nil) - .getSelectedModel() - .willReturn(testModels[0]) - - given(mockLLMService) - .selectProvider(.value(.ollama)) - .willReturn() - - sut = GeneralSettingsViewModel( - llmService: mockLLMService, - userPreferencesRepository: mockUserPreferencesRepository, - keychainAPIValidator: mockKeychainAPIValidator, - keychainService: mockKeychainService, - warningManager: mockWarningManager - ) - - try? await Task.sleep(nanoseconds: 100_000_000) - - await sut.selectProvider(.ollama) - - XCTAssertEqual(sut.selectedProvider, .ollama) - XCTAssertEqual(sut.availableModels.count, 1) - XCTAssertNil(sut.errorMessage) - } - - func testSelectProviderOpenRouterWithoutAPIKey() async throws { - await initSut() - - given(mockKeychainAPIValidator) - .validateOpenRouterAPI() - .willReturn(.missingApiKey) - - given(mockKeychainService) - .retrieve(key: .value(KeychainKey.openRouterApiKey.key)) - .willReturn(nil) - - await sut.selectProvider(.openRouter) - - XCTAssertTrue(sut.showAPIKeyAlert) - XCTAssertNil(sut.existingAPIKey) - XCTAssertNotEqual(sut.selectedProvider, .openRouter) - } - - func testSelectProviderOpenRouterWithValidAPIKey() async throws { - await initSut() - - given(mockKeychainAPIValidator) - .validateOpenRouterAPI() - .willReturn(.valid) - - let testModels = [ - LLMModelInfo(id: "openrouter1", name: "OpenRouter Model", provider: "openrouter") - ] - - given(mockLLMService) - .selectProvider(.value(.openRouter)) - .willReturn() - - given(mockLLMService) - .getAvailableModels() - .willReturn(testModels) - - given(mockLLMService) - .getSelectedModel() - .willReturn(nil) - - given(mockLLMService) - .selectModel(id: .any) - .willReturn() - - await sut.selectProvider(.openRouter) - - XCTAssertEqual(sut.selectedProvider, .openRouter) - XCTAssertFalse(sut.showAPIKeyAlert) - } - - func testToggleAutoDetectMeetingsSuccess() async throws { - await initSut() - - given(mockUserPreferencesRepository) - .updateAutoDetectMeetings(.value(true)) - .willReturn() - - await sut.toggleAutoDetectMeetings(true) - - XCTAssertTrue(sut.autoDetectMeetings) - XCTAssertNil(sut.errorMessage) - - verify(mockUserPreferencesRepository) - .updateAutoDetectMeetings(.value(true)) - .called(1) - } - - func testToggleAutoDetectMeetingsError() async throws { - await initSut() - - given(mockUserPreferencesRepository) - .updateAutoDetectMeetings(.any) - .willThrow(NSError(domain: "TestError", code: 500)) - - await sut.toggleAutoDetectMeetings(true) - - XCTAssertFalse(sut.autoDetectMeetings) - XCTAssertNotNil(sut.errorMessage) - } - - func testToggleAutoStopRecordingSuccess() async throws { - await initSut() - - given(mockUserPreferencesRepository) - .updateAutoStopRecording(.value(true)) - .willReturn() - - await sut.toggleAutoStopRecording(true) - - XCTAssertTrue(sut.isAutoStopRecording) - XCTAssertNil(sut.errorMessage) - - verify(mockUserPreferencesRepository) - .updateAutoStopRecording(.value(true)) - .called(1) - } - - func testSaveAPIKeySuccess() async throws { - await initSut() - - given(mockKeychainService) - .store(key: .value(KeychainKey.openRouterApiKey.key), value: .value("test-api-key")) - .willReturn() - - given(mockKeychainAPIValidator) - .validateOpenRouterAPI() - .willReturn(.valid) - - given(mockLLMService) - .selectProvider(.value(.openRouter)) - .willReturn() - - given(mockLLMService) - .getAvailableModels() - .willReturn([]) - - given(mockLLMService) - .getSelectedModel() - .willReturn(nil) - - try await sut.saveAPIKey("test-api-key") - - XCTAssertFalse(sut.showAPIKeyAlert) - XCTAssertEqual(sut.existingAPIKey, "test-api-key") - XCTAssertEqual(sut.selectedProvider, .openRouter) - } - - func testDismissAPIKeyAlert() async throws { - await initSut() - - given(mockKeychainAPIValidator) - .validateOpenRouterAPI() - .willReturn(.missingApiKey) - - given(mockKeychainService) - .retrieve(key: .value(KeychainKey.openRouterApiKey.key)) - .willReturn("existing-key") - - await sut.selectProvider(.openRouter) - - XCTAssertTrue(sut.showAPIKeyAlert) - XCTAssertEqual(sut.existingAPIKey, "existing-key") +class GeneralSettingsViewModelSpec: XCTestCase { + var sut: GeneralSettingsViewModel! + var mockLLMService: MockLLMServiceType! + var mockUserPreferencesRepository: MockUserPreferencesRepositoryType! + var mockKeychainAPIValidator: MockKeychainAPIValidatorType! + var mockKeychainService: MockKeychainServiceType! + var mockWarningManager: MockWarningManagerType! + var mockFileManagerHelper: RecordingFileManagerHelperType! + var cancellables = Set() + + override func setUp() async throws { + try await super.setUp() + + mockLLMService = MockLLMServiceType() + mockUserPreferencesRepository = MockUserPreferencesRepositoryType() + mockKeychainAPIValidator = MockKeychainAPIValidatorType() + mockKeychainService = MockKeychainServiceType() + mockWarningManager = MockWarningManagerType() + mockFileManagerHelper = TestRecordingFileManagerHelper() + } + + func initSut( + preferences: UserPreferencesInfo = UserPreferencesInfo( + selectedProvider: .ollama, + autoDetectMeetings: false, + autoStopRecording: false + ), + availableModels: [LLMModelInfo] = [], + selectedModel: LLMModelInfo? = nil, + warnings: [WarningItem] = [] + ) async { + given(mockWarningManager) + .activeWarningsPublisher + .willReturn(Just(warnings).eraseToAnyPublisher()) + + given(mockLLMService) + .getUserPreferences() + .willReturn(preferences) + + given(mockLLMService) + .getAvailableModels() + .willReturn(availableModels) + + given(mockLLMService) + .getSelectedModel() + .willReturn(selectedModel) + + sut = GeneralSettingsViewModel( + llmService: mockLLMService, + userPreferencesRepository: mockUserPreferencesRepository, + keychainAPIValidator: mockKeychainAPIValidator, + keychainService: mockKeychainService, + warningManager: mockWarningManager, + fileManagerHelper: mockFileManagerHelper + ) + + try? await Task.sleep(nanoseconds: 100_000_000) + } + + override func tearDown() async throws { + sut = nil + mockLLMService = nil + mockUserPreferencesRepository = nil + mockKeychainAPIValidator = nil + mockKeychainService = nil + mockWarningManager = nil + mockFileManagerHelper = nil + cancellables.removeAll() + + try await super.tearDown() + } + + func testInitialState() async throws { + await initSut() + + XCTAssertFalse(sut.isLoading) + XCTAssertNil(sut.errorMessage) + XCTAssertEqual(sut.selectedProvider, .ollama) + XCTAssertFalse(sut.autoDetectMeetings) + XCTAssertFalse(sut.isAutoStopRecording) + } + +} + +private final class TestRecordingFileManagerHelper: RecordingFileManagerHelperType { + private(set) var baseDirectory: URL + + init(baseDirectory: URL = URL(fileURLWithPath: "/tmp/recap-tests", isDirectory: true)) { + self.baseDirectory = baseDirectory + } + + func getBaseDirectory() -> URL { + baseDirectory + } + + func setBaseDirectory(_ url: URL, bookmark: Data?) throws { + baseDirectory = url + } - sut.dismissAPIKeyAlert() - - XCTAssertFalse(sut.showAPIKeyAlert) - XCTAssertNil(sut.existingAPIKey) - } - - func testWarningManagerIntegration() async throws { - let testWarnings = [ - WarningItem(id: "1", title: "Test Warning", message: "Test warning message") - ] - - let warningPublisher = PassthroughSubject<[WarningItem], Never>() - given(mockWarningManager) - .activeWarningsPublisher - .willReturn(warningPublisher.eraseToAnyPublisher()) - - given(mockLLMService) - .getUserPreferences() - .willReturn(UserPreferencesInfo( - selectedProvider: .ollama, - autoDetectMeetings: false, - autoStopRecording: false - )) - - given(mockLLMService) - .getAvailableModels() - .willReturn([]) - - given(mockLLMService) - .getSelectedModel() - .willReturn(nil) - - sut = GeneralSettingsViewModel( - llmService: mockLLMService, - userPreferencesRepository: mockUserPreferencesRepository, - keychainAPIValidator: mockKeychainAPIValidator, - keychainService: mockKeychainService, - warningManager: mockWarningManager - ) - - try await Task.sleep(nanoseconds: 100_000_000) - - XCTAssertEqual(sut.activeWarnings.count, 0) - - warningPublisher.send(testWarnings) - - try await Task.sleep(nanoseconds: 100_000_000) - - XCTAssertEqual(sut.activeWarnings.count, 1) - XCTAssertEqual(sut.activeWarnings.first?.title, "Test Warning") - } + func createRecordingDirectory(for recordingID: String) throws -> URL { + baseDirectory.appendingPathComponent(recordingID, isDirectory: true) + } } diff --git a/RecapTests/UseCases/Settings/ViewModels/MeetingDetection/MeetingDetectionSettingsViewModelSpec.swift b/RecapTests/UseCases/Settings/ViewModels/MeetingDetection/MeetingDetectionSettingsViewModelSpec.swift index b028f6c..473d4e2 100644 --- a/RecapTests/UseCases/Settings/ViewModels/MeetingDetection/MeetingDetectionSettingsViewModelSpec.swift +++ b/RecapTests/UseCases/Settings/ViewModels/MeetingDetection/MeetingDetectionSettingsViewModelSpec.swift @@ -1,242 +1,243 @@ -import XCTest import Combine import Mockable +import XCTest + @testable import Recap @MainActor final class MeetingDetectionSettingsViewModelSpec: XCTestCase { - private var sut: MeetingDetectionSettingsViewModel! - private var mockDetectionService: MockMeetingDetectionServiceType! - private var mockUserPreferencesRepository: MockUserPreferencesRepositoryType! - private var mockPermissionsHelper: MockPermissionsHelperType! - private var cancellables = Set() - - override func setUp() async throws { - try await super.setUp() - - mockDetectionService = MockMeetingDetectionServiceType() - mockUserPreferencesRepository = MockUserPreferencesRepositoryType() - mockPermissionsHelper = MockPermissionsHelperType() - - let defaultPreferences = UserPreferencesInfo( - autoDetectMeetings: false - ) - - given(mockUserPreferencesRepository) - .getOrCreatePreferences() - .willReturn(defaultPreferences) - .getOrCreatePreferences() - .willReturn(UserPreferencesInfo(autoDetectMeetings: true)) - - sut = MeetingDetectionSettingsViewModel( - detectionService: mockDetectionService, - userPreferencesRepository: mockUserPreferencesRepository, - permissionsHelper: mockPermissionsHelper - ) - - try await Task.sleep(nanoseconds: 100_000_000) - } - - override func tearDown() async throws { - sut = nil - mockDetectionService = nil - mockUserPreferencesRepository = nil - mockPermissionsHelper = nil - cancellables.removeAll() - - try await super.tearDown() - } - - func testInitialStateWithoutPermission() async throws { - XCTAssertFalse(sut.hasScreenRecordingPermission) - XCTAssertFalse(sut.autoDetectMeetings) - } - - func testLoadCurrentSettingsSuccess() async throws { - let preferences = UserPreferencesInfo( - autoDetectMeetings: true - ) - - given(mockUserPreferencesRepository) - .getOrCreatePreferences() - .willReturn(preferences) - - sut = MeetingDetectionSettingsViewModel( - detectionService: mockDetectionService, - userPreferencesRepository: mockUserPreferencesRepository, - permissionsHelper: mockPermissionsHelper - ) - - try await Task.sleep(nanoseconds: 200_000_000) - - XCTAssertTrue(sut.autoDetectMeetings) - } - - func testHandleAutoDetectToggleOnWithPermission() async throws { - given(mockUserPreferencesRepository) - .updateAutoDetectMeetings(.value(true)) - .willReturn() - - given(mockPermissionsHelper) - .checkScreenCapturePermission() - .willReturn(true) - - given(mockDetectionService) - .startMonitoring() - .willReturn() - - await sut.handleAutoDetectToggle(true) - - XCTAssertTrue(sut.autoDetectMeetings) - XCTAssertTrue(sut.hasScreenRecordingPermission) - - verify(mockDetectionService) - .startMonitoring() - .called(1) - - verify(mockUserPreferencesRepository) - .updateAutoDetectMeetings(.value(true)) - .called(1) - } - - func testHandleAutoDetectToggleOnWithoutPermission() async throws { - given(mockUserPreferencesRepository) - .updateAutoDetectMeetings(.value(true)) - .willReturn() - - given(mockPermissionsHelper) - .checkScreenCapturePermission() - .willReturn(false) - - await sut.handleAutoDetectToggle(true) - - XCTAssertTrue(sut.autoDetectMeetings) - XCTAssertFalse(sut.hasScreenRecordingPermission) - - verify(mockDetectionService) - .startMonitoring() - .called(0) - } - - func testHandleAutoDetectToggleOff() async throws { - sut.autoDetectMeetings = true - - given(mockUserPreferencesRepository) - .updateAutoDetectMeetings(.value(false)) - .willReturn() - - given(mockDetectionService) - .stopMonitoring() - .willReturn() - - await sut.handleAutoDetectToggle(false) - - XCTAssertFalse(sut.autoDetectMeetings) - - verify(mockDetectionService) - .stopMonitoring() - .called(1) - - verify(mockUserPreferencesRepository) - .updateAutoDetectMeetings(.value(false)) - .called(1) - } - - func testCheckPermissionStatusWithPermissionAndAutoDetect() async throws { - sut.autoDetectMeetings = true - - given(mockPermissionsHelper) - .checkScreenCapturePermission() - .willReturn(true) - - given(mockDetectionService) - .startMonitoring() - .willReturn() - - await sut.checkPermissionStatus() - - XCTAssertTrue(sut.hasScreenRecordingPermission) - - verify(mockDetectionService) - .startMonitoring() - .called(1) - } - - func testCheckPermissionStatusWithoutPermission() async throws { - sut.autoDetectMeetings = true - - given(mockPermissionsHelper) - .checkScreenCapturePermission() - .willReturn(false) - - await sut.checkPermissionStatus() - - XCTAssertFalse(sut.hasScreenRecordingPermission) - - verify(mockDetectionService) - .startMonitoring() - .called(0) - } - - func testCheckPermissionStatusWithPermissionButAutoDetectOff() async throws { - sut.autoDetectMeetings = false - - given(mockPermissionsHelper) - .checkScreenCapturePermission() - .willReturn(true) - - await sut.checkPermissionStatus() - - XCTAssertTrue(sut.hasScreenRecordingPermission) - - verify(mockDetectionService) - .startMonitoring() - .called(0) - } - - func testHandleAutoDetectToggleWithRepositoryError() async throws { - given(mockUserPreferencesRepository) - .updateAutoDetectMeetings(.any) - .willThrow(NSError(domain: "TestError", code: 500)) - - given(mockPermissionsHelper) - .checkScreenCapturePermission() - .willReturn(false) - - await sut.handleAutoDetectToggle(true) - - XCTAssertTrue(sut.autoDetectMeetings) - } - - func testServiceStateTransitions() async throws { - given(mockUserPreferencesRepository) - .updateAutoDetectMeetings(.any) - .willReturn() - - given(mockPermissionsHelper) - .checkScreenCapturePermission() - .willReturn(true) - - given(mockDetectionService) - .startMonitoring() - .willReturn() - - given(mockDetectionService) - .stopMonitoring() - .willReturn() - - await sut.handleAutoDetectToggle(true) - XCTAssertTrue(sut.autoDetectMeetings) - - await sut.handleAutoDetectToggle(false) - XCTAssertFalse(sut.autoDetectMeetings) - - verify(mockDetectionService) - .startMonitoring() - .called(1) - - verify(mockDetectionService) - .stopMonitoring() - .called(1) - } -} \ No newline at end of file + private var sut: MeetingDetectionSettingsViewModel! + private var mockDetectionService: MockMeetingDetectionServiceType! + private var mockUserPreferencesRepository: MockUserPreferencesRepositoryType! + private var mockPermissionsHelper: MockPermissionsHelperType! + private var cancellables = Set() + + override func setUp() async throws { + try await super.setUp() + + mockDetectionService = MockMeetingDetectionServiceType() + mockUserPreferencesRepository = MockUserPreferencesRepositoryType() + mockPermissionsHelper = MockPermissionsHelperType() + + let defaultPreferences = UserPreferencesInfo( + autoDetectMeetings: false + ) + + given(mockUserPreferencesRepository) + .getOrCreatePreferences() + .willReturn(defaultPreferences) + .getOrCreatePreferences() + .willReturn(UserPreferencesInfo(autoDetectMeetings: true)) + + sut = MeetingDetectionSettingsViewModel( + detectionService: mockDetectionService, + userPreferencesRepository: mockUserPreferencesRepository, + permissionsHelper: mockPermissionsHelper + ) + + try await Task.sleep(nanoseconds: 100_000_000) + } + + override func tearDown() async throws { + sut = nil + mockDetectionService = nil + mockUserPreferencesRepository = nil + mockPermissionsHelper = nil + cancellables.removeAll() + + try await super.tearDown() + } + + func testInitialStateWithoutPermission() async throws { + XCTAssertFalse(sut.hasScreenRecordingPermission) + XCTAssertFalse(sut.autoDetectMeetings) + } + + func testLoadCurrentSettingsSuccess() async throws { + let preferences = UserPreferencesInfo( + autoDetectMeetings: true + ) + + given(mockUserPreferencesRepository) + .getOrCreatePreferences() + .willReturn(preferences) + + sut = MeetingDetectionSettingsViewModel( + detectionService: mockDetectionService, + userPreferencesRepository: mockUserPreferencesRepository, + permissionsHelper: mockPermissionsHelper + ) + + try await Task.sleep(nanoseconds: 200_000_000) + + XCTAssertTrue(sut.autoDetectMeetings) + } + + func testHandleAutoDetectToggleOnWithPermission() async throws { + given(mockUserPreferencesRepository) + .updateAutoDetectMeetings(.value(true)) + .willReturn() + + given(mockPermissionsHelper) + .checkScreenCapturePermission() + .willReturn(true) + + given(mockDetectionService) + .startMonitoring() + .willReturn() + + await sut.handleAutoDetectToggle(true) + + XCTAssertTrue(sut.autoDetectMeetings) + XCTAssertTrue(sut.hasScreenRecordingPermission) + + verify(mockDetectionService) + .startMonitoring() + .called(1) + + verify(mockUserPreferencesRepository) + .updateAutoDetectMeetings(.value(true)) + .called(1) + } + + func testHandleAutoDetectToggleOnWithoutPermission() async throws { + given(mockUserPreferencesRepository) + .updateAutoDetectMeetings(.value(true)) + .willReturn() + + given(mockPermissionsHelper) + .checkScreenCapturePermission() + .willReturn(false) + + await sut.handleAutoDetectToggle(true) + + XCTAssertTrue(sut.autoDetectMeetings) + XCTAssertFalse(sut.hasScreenRecordingPermission) + + verify(mockDetectionService) + .startMonitoring() + .called(0) + } + + func testHandleAutoDetectToggleOff() async throws { + sut.autoDetectMeetings = true + + given(mockUserPreferencesRepository) + .updateAutoDetectMeetings(.value(false)) + .willReturn() + + given(mockDetectionService) + .stopMonitoring() + .willReturn() + + await sut.handleAutoDetectToggle(false) + + XCTAssertFalse(sut.autoDetectMeetings) + + verify(mockDetectionService) + .stopMonitoring() + .called(1) + + verify(mockUserPreferencesRepository) + .updateAutoDetectMeetings(.value(false)) + .called(1) + } + + func testCheckPermissionStatusWithPermissionAndAutoDetect() async throws { + sut.autoDetectMeetings = true + + given(mockPermissionsHelper) + .checkScreenCapturePermission() + .willReturn(true) + + given(mockDetectionService) + .startMonitoring() + .willReturn() + + await sut.checkPermissionStatus() + + XCTAssertTrue(sut.hasScreenRecordingPermission) + + verify(mockDetectionService) + .startMonitoring() + .called(1) + } + + func testCheckPermissionStatusWithoutPermission() async throws { + sut.autoDetectMeetings = true + + given(mockPermissionsHelper) + .checkScreenCapturePermission() + .willReturn(false) + + await sut.checkPermissionStatus() + + XCTAssertFalse(sut.hasScreenRecordingPermission) + + verify(mockDetectionService) + .startMonitoring() + .called(0) + } + + func testCheckPermissionStatusWithPermissionButAutoDetectOff() async throws { + sut.autoDetectMeetings = false + + given(mockPermissionsHelper) + .checkScreenCapturePermission() + .willReturn(true) + + await sut.checkPermissionStatus() + + XCTAssertTrue(sut.hasScreenRecordingPermission) + + verify(mockDetectionService) + .startMonitoring() + .called(0) + } + + func testHandleAutoDetectToggleWithRepositoryError() async throws { + given(mockUserPreferencesRepository) + .updateAutoDetectMeetings(.any) + .willThrow(NSError(domain: "TestError", code: 500)) + + given(mockPermissionsHelper) + .checkScreenCapturePermission() + .willReturn(false) + + await sut.handleAutoDetectToggle(true) + + XCTAssertTrue(sut.autoDetectMeetings) + } + + func testServiceStateTransitions() async throws { + given(mockUserPreferencesRepository) + .updateAutoDetectMeetings(.any) + .willReturn() + + given(mockPermissionsHelper) + .checkScreenCapturePermission() + .willReturn(true) + + given(mockDetectionService) + .startMonitoring() + .willReturn() + + given(mockDetectionService) + .stopMonitoring() + .willReturn() + + await sut.handleAutoDetectToggle(true) + XCTAssertTrue(sut.autoDetectMeetings) + + await sut.handleAutoDetectToggle(false) + XCTAssertFalse(sut.autoDetectMeetings) + + verify(mockDetectionService) + .startMonitoring() + .called(1) + + verify(mockDetectionService) + .stopMonitoring() + .called(1) + } +} diff --git a/RecapTests/UseCases/Settings/ViewModels/Whisper/WhisperModelsViewModelSpec.swift b/RecapTests/UseCases/Settings/ViewModels/Whisper/WhisperModelsViewModelSpec.swift index ef7f889..e702f4a 100644 --- a/RecapTests/UseCases/Settings/ViewModels/Whisper/WhisperModelsViewModelSpec.swift +++ b/RecapTests/UseCases/Settings/ViewModels/Whisper/WhisperModelsViewModelSpec.swift @@ -1,183 +1,185 @@ -import XCTest import Combine import Mockable +import XCTest + @testable import Recap @MainActor final class WhisperModelsViewModelSpec: XCTestCase { - private var sut: WhisperModelsViewModel! - private var mockRepository = MockWhisperModelRepositoryType() - private var cancellables = Set() - - override func setUp() async throws { - try await super.setUp() - - given(mockRepository) - .getAllModels() - .willReturn([]) - - sut = WhisperModelsViewModel(repository: mockRepository) - try await Task.sleep(nanoseconds: 100_000_000) - } - - override func tearDown() async throws { - sut = nil - cancellables.removeAll() - - try await super.tearDown() - } - - func testLoadModelsSuccess() async throws { - sut.downloadedModels = Set(["tiny", "small"]) - sut.selectedModel = "small" - - XCTAssertEqual(sut.downloadedModels, Set(["tiny", "small"])) - XCTAssertEqual(sut.selectedModel, "small") - XCTAssertNil(sut.errorMessage) - XCTAssertFalse(sut.showingError) - } - - func testSelectModelSuccess() async throws { - sut.downloadedModels.insert("small") - - given(mockRepository) - .setSelectedModel(name: .value("small")) - .willReturn() - - let expectation = XCTestExpectation(description: "Model selection completes") - - sut.$selectedModel - .dropFirst() - .sink { selectedModel in - if selectedModel == "small" { - expectation.fulfill() - } - } - .store(in: &cancellables) - - sut.selectModel("small") - - await fulfillment(of: [expectation], timeout: 2.0) - - XCTAssertEqual(sut.selectedModel, "small") - XCTAssertNil(sut.errorMessage) - - verify(mockRepository) - .setSelectedModel(name: .value("small")) - .called(1) - } - - func testSelectModelNotDownloaded() async throws { - XCTAssertFalse(sut.downloadedModels.contains("large")) - - sut.selectModel("large") - - try await Task.sleep(nanoseconds: 100_000_000) - - XCTAssertNil(sut.selectedModel) - - verify(mockRepository) - .setSelectedModel(name: .any) - .called(0) - } - - func testSelectModelDeselection() async throws { - sut.downloadedModels.insert("small") - sut.selectedModel = "small" - - given(mockRepository) - .getAllModels() - .willReturn([createTestModel(name: "small", isDownloaded: true, isSelected: true)]) - - given(mockRepository) - .updateModel(.any) - .willReturn() - - sut.selectModel("small") - - try await Task.sleep(nanoseconds: 100_000_000) - - XCTAssertNil(sut.selectedModel) - } - - func testSelectModelError() async throws { - sut.downloadedModels.insert("small") - - given(mockRepository) - .setSelectedModel(name: .any) - .willThrow(NSError(domain: "TestError", code: 500)) - - sut.selectModel("small") - - try await Task.sleep(nanoseconds: 100_000_000) - - XCTAssertNotNil(sut.errorMessage) - XCTAssertTrue(sut.showingError) - } - - func testToggleTooltipShow() { - let position = CGPoint(x: 100, y: 200) - - XCTAssertNil(sut.showingTooltipForModel) - - sut.toggleTooltip(for: "small", at: position) - - XCTAssertEqual(sut.showingTooltipForModel, "small") - XCTAssertEqual(sut.tooltipPosition, position) - } - - func testToggleTooltipHide() { - sut.showingTooltipForModel = "small" - - sut.toggleTooltip(for: "small", at: .zero) - - XCTAssertNil(sut.showingTooltipForModel) - } - - func testGetModelInfo() { - let tinyInfo = sut.getModelInfo("tiny") - XCTAssertNotNil(tinyInfo) - XCTAssertEqual(tinyInfo?.displayName, "Tiny Model") - - let unknownInfo = sut.getModelInfo("unknown") - XCTAssertNil(unknownInfo) - } - - func testGetModelInfoWithVersionSuffix() { - let largeV2Info = sut.getModelInfo("large-v2") - XCTAssertNotNil(largeV2Info) - XCTAssertEqual(largeV2Info?.displayName, "Large Model") - - let largeV3Info = sut.getModelInfo("large-v3") - XCTAssertNotNil(largeV3Info) - XCTAssertEqual(largeV3Info?.displayName, "Large Model") - } - - func testModelDisplayName() { - XCTAssertEqual(sut.modelDisplayName("large-v2"), "Large v2") - XCTAssertEqual(sut.modelDisplayName("large-v3"), "Large v3") - XCTAssertEqual(sut.modelDisplayName("distil-whisper_distil-large-v3_turbo"), "Distil Large v3 Turbo") - XCTAssertEqual(sut.modelDisplayName("small"), "Small") - XCTAssertEqual(sut.modelDisplayName("tiny"), "Tiny") - } + private var sut: WhisperModelsViewModel! + private var mockRepository = MockWhisperModelRepositoryType() + private var cancellables = Set() + + override func setUp() async throws { + try await super.setUp() + + given(mockRepository) + .getAllModels() + .willReturn([]) + + sut = WhisperModelsViewModel(repository: mockRepository) + try await Task.sleep(nanoseconds: 100_000_000) + } + + override func tearDown() async throws { + sut = nil + cancellables.removeAll() + + try await super.tearDown() + } + + func testLoadModelsSuccess() async throws { + sut.downloadedModels = Set(["tiny", "small"]) + sut.selectedModel = "small" + + XCTAssertEqual(sut.downloadedModels, Set(["tiny", "small"])) + XCTAssertEqual(sut.selectedModel, "small") + XCTAssertNil(sut.errorMessage) + XCTAssertFalse(sut.showingError) + } + + func testSelectModelSuccess() async throws { + sut.downloadedModels.insert("small") + + given(mockRepository) + .setSelectedModel(name: .value("small")) + .willReturn() + + let expectation = XCTestExpectation(description: "Model selection completes") + + sut.$selectedModel + .dropFirst() + .sink { selectedModel in + if selectedModel == "small" { + expectation.fulfill() + } + } + .store(in: &cancellables) + + sut.selectModel("small") + + await fulfillment(of: [expectation], timeout: 2.0) + + XCTAssertEqual(sut.selectedModel, "small") + XCTAssertNil(sut.errorMessage) + + verify(mockRepository) + .setSelectedModel(name: .value("small")) + .called(1) + } + + func testSelectModelNotDownloaded() async throws { + XCTAssertFalse(sut.downloadedModels.contains("large")) + + sut.selectModel("large") + + try await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertNil(sut.selectedModel) + + verify(mockRepository) + .setSelectedModel(name: .any) + .called(0) + } + + func testSelectModelDeselection() async throws { + sut.downloadedModels.insert("small") + sut.selectedModel = "small" + + given(mockRepository) + .getAllModels() + .willReturn([createTestModel(name: "small", isDownloaded: true, isSelected: true)]) + + given(mockRepository) + .updateModel(.any) + .willReturn() + + sut.selectModel("small") + + try await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertNil(sut.selectedModel) + } + + func testSelectModelError() async throws { + sut.downloadedModels.insert("small") + + given(mockRepository) + .setSelectedModel(name: .any) + .willThrow(NSError(domain: "TestError", code: 500)) + + sut.selectModel("small") + + try await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertNotNil(sut.errorMessage) + XCTAssertTrue(sut.showingError) + } + + func testToggleTooltipShow() { + let position = CGPoint(x: 100, y: 200) + + XCTAssertNil(sut.showingTooltipForModel) + + sut.toggleTooltip(for: "small", at: position) + + XCTAssertEqual(sut.showingTooltipForModel, "small") + XCTAssertEqual(sut.tooltipPosition, position) + } + + func testToggleTooltipHide() { + sut.showingTooltipForModel = "small" + + sut.toggleTooltip(for: "small", at: .zero) + + XCTAssertNil(sut.showingTooltipForModel) + } + + func testGetModelInfo() { + let tinyInfo = sut.getModelInfo("tiny") + XCTAssertNotNil(tinyInfo) + XCTAssertEqual(tinyInfo?.displayName, "Tiny Model") + + let unknownInfo = sut.getModelInfo("unknown") + XCTAssertNil(unknownInfo) + } + + func testGetModelInfoWithVersionSuffix() { + let largeV2Info = sut.getModelInfo("large-v2") + XCTAssertNotNil(largeV2Info) + XCTAssertEqual(largeV2Info?.displayName, "Large Model") + + let largeV3Info = sut.getModelInfo("large-v3") + XCTAssertNotNil(largeV3Info) + XCTAssertEqual(largeV3Info?.displayName, "Large Model") + } + + func testModelDisplayName() { + XCTAssertEqual(sut.modelDisplayName("large-v2"), "Large v2") + XCTAssertEqual(sut.modelDisplayName("large-v3"), "Large v3") + XCTAssertEqual( + sut.modelDisplayName("distil-whisper_distil-large-v3_turbo"), "Distil Large v3 Turbo") + XCTAssertEqual(sut.modelDisplayName("small"), "Small") + XCTAssertEqual(sut.modelDisplayName("tiny"), "Tiny") + } } -private extension WhisperModelsViewModelSpec { - func createTestModel( - name: String, - isDownloaded: Bool = false, - isSelected: Bool = false, - downloadedAt: Date? = nil, - fileSizeInMB: Int64? = nil, - variant: String? = nil - ) -> WhisperModelData { - WhisperModelData( - name: name, - isDownloaded: isDownloaded, - isSelected: isSelected, - downloadedAt: downloadedAt, - fileSizeInMB: fileSizeInMB, - variant: variant - ) - } -} \ No newline at end of file +extension WhisperModelsViewModelSpec { + fileprivate func createTestModel( + name: String, + isDownloaded: Bool = false, + isSelected: Bool = false, + downloadedAt: Date? = nil, + fileSizeInMB: Int64? = nil, + variant: String? = nil + ) -> WhisperModelData { + WhisperModelData( + name: name, + isDownloaded: isDownloaded, + isSelected: isSelected, + downloadedAt: downloadedAt, + fileSizeInMB: fileSizeInMB, + variant: variant + ) + } +} diff --git a/RecapTests/UseCases/Summary/ViewModels/SummaryViewModelSpec.swift b/RecapTests/UseCases/Summary/ViewModels/SummaryViewModelSpec.swift index 79c04af..81da074 100644 --- a/RecapTests/UseCases/Summary/ViewModels/SummaryViewModelSpec.swift +++ b/RecapTests/UseCases/Summary/ViewModels/SummaryViewModelSpec.swift @@ -1,170 +1,184 @@ -import XCTest import Combine import Mockable +import XCTest + @testable import Recap @MainActor final class SummaryViewModelSpec: XCTestCase { - private var sut: SummaryViewModel! - private var mockRecordingRepository = MockRecordingRepositoryType() - private var mockProcessingCoordinator = MockProcessingCoordinatorType() - private var cancellables = Set() - - override func setUp() async throws { - try await super.setUp() - - sut = SummaryViewModel( - recordingRepository: mockRecordingRepository, - processingCoordinator: mockProcessingCoordinator - ) - } - - override func tearDown() async throws { - sut = nil - cancellables.removeAll() - - try await super.tearDown() - } - - func testLoadRecordingSuccess() async throws { - let expectedRecording = createTestRecording(id: "test-id", state: .completed) - - given(mockRecordingRepository) - .fetchRecording(id: .value("test-id")) - .willReturn(expectedRecording) - - let expectation = XCTestExpectation(description: "Loading completes") - - sut.$isLoadingRecording - .dropFirst() - .sink { isLoading in - if !isLoading { - expectation.fulfill() - } - } - .store(in: &cancellables) - - sut.loadRecording(withID: "test-id") - - await fulfillment(of: [expectation], timeout: 2.0) - - XCTAssertEqual(sut.currentRecording, expectedRecording) - XCTAssertNil(sut.errorMessage) - } - - func testLoadRecordingFailure() async throws { - let error = NSError(domain: "TestError", code: 404, userInfo: [NSLocalizedDescriptionKey: "Not found"]) - - given(mockRecordingRepository) - .fetchRecording(id: .any) - .willThrow(error) - - let expectation = XCTestExpectation(description: "Loading completes") - - sut.$isLoadingRecording - .dropFirst() - .sink { isLoading in - if !isLoading { - expectation.fulfill() - } - } - .store(in: &cancellables) - - sut.loadRecording(withID: "test-id") - - await fulfillment(of: [expectation], timeout: 2.0) - - XCTAssertNil(sut.currentRecording) - XCTAssertNotNil(sut.errorMessage) - XCTAssertTrue(sut.errorMessage?.contains("Failed to load recording") ?? false) - } - - func testProcessingStageComputation() { - sut.currentRecording = createTestRecording(state: .recorded) - XCTAssertEqual(sut.processingStage, ProcessingStatesCard.ProcessingStage.recorded) - - sut.currentRecording = createTestRecording(state: .transcribing) - XCTAssertEqual(sut.processingStage, ProcessingStatesCard.ProcessingStage.transcribing) - - sut.currentRecording = createTestRecording(state: .summarizing) - XCTAssertEqual(sut.processingStage, ProcessingStatesCard.ProcessingStage.summarizing) - - sut.currentRecording = createTestRecording(state: .completed) - XCTAssertNil(sut.processingStage) - } - - func testHasSummaryComputation() { - sut.currentRecording = createTestRecording( - state: .completed, - summaryText: "Test summary" - ) - XCTAssertTrue(sut.hasSummary) - - sut.currentRecording = createTestRecording( - state: .completed, - summaryText: nil - ) - XCTAssertFalse(sut.hasSummary) - } - - func testRetryProcessingForTranscriptionFailed() async throws { - let recording = createTestRecording(id: "test-id", state: .transcriptionFailed) - sut.currentRecording = recording - - given(mockProcessingCoordinator) - .retryProcessing(recordingID: .any) - .willReturn() - - given(mockRecordingRepository) - .fetchRecording(id: .any) - .willReturn(recording) - - await sut.retryProcessing() - - verify(mockProcessingCoordinator) - .retryProcessing(recordingID: .any) - .called(1) - } - - func testCopySummaryShowsToast() async throws { - let recording = createTestRecording( - state: .completed, - summaryText: "Test summary content" - ) - sut.currentRecording = recording - - XCTAssertFalse(sut.showingCopiedToast) - - sut.copySummary() - - XCTAssertTrue(sut.showingCopiedToast) - - try await Task.sleep(nanoseconds: 2_500_000_000) - - XCTAssertFalse(sut.showingCopiedToast) - } + private var sut: SummaryViewModel! + private var mockRecordingRepository = MockRecordingRepositoryType() + private var mockProcessingCoordinator = MockProcessingCoordinatorType() + private var mockUserPreferencesRepository: MockUserPreferencesRepositoryType! + private var cancellables = Set() + + override func setUp() async throws { + try await super.setUp() + + mockUserPreferencesRepository = MockUserPreferencesRepositoryType() + + given(mockUserPreferencesRepository) + .getOrCreatePreferences() + .willReturn(UserPreferencesInfo()) + + sut = SummaryViewModel( + recordingRepository: mockRecordingRepository, + processingCoordinator: mockProcessingCoordinator, + userPreferencesRepository: mockUserPreferencesRepository + ) + + try await Task.sleep(nanoseconds: 100_000_000) + } + + override func tearDown() async throws { + sut = nil + mockUserPreferencesRepository = nil + cancellables.removeAll() + + try await super.tearDown() + } + + func testLoadRecordingSuccess() async throws { + let expectedRecording = createTestRecording(id: "test-id", state: .completed) + + given(mockRecordingRepository) + .fetchRecording(id: .value("test-id")) + .willReturn(expectedRecording) + + let expectation = XCTestExpectation(description: "Loading completes") + + sut.$isLoadingRecording + .dropFirst() + .sink { isLoading in + if !isLoading { + expectation.fulfill() + } + } + .store(in: &cancellables) + + sut.loadRecording(withID: "test-id") + + await fulfillment(of: [expectation], timeout: 2.0) + + XCTAssertEqual(sut.currentRecording, expectedRecording) + XCTAssertNil(sut.errorMessage) + } + + func testLoadRecordingFailure() async throws { + let error = NSError( + domain: "TestError", code: 404, userInfo: [NSLocalizedDescriptionKey: "Not found"]) + + given(mockRecordingRepository) + .fetchRecording(id: .any) + .willThrow(error) + + let expectation = XCTestExpectation(description: "Loading completes") + + sut.$isLoadingRecording + .dropFirst() + .sink { isLoading in + if !isLoading { + expectation.fulfill() + } + } + .store(in: &cancellables) + + sut.loadRecording(withID: "test-id") + + await fulfillment(of: [expectation], timeout: 2.0) + + XCTAssertNil(sut.currentRecording) + XCTAssertNotNil(sut.errorMessage) + XCTAssertTrue(sut.errorMessage?.contains("Failed to load recording") ?? false) + } + + func testProcessingStageComputation() { + sut.currentRecording = createTestRecording(state: .recorded) + XCTAssertEqual(sut.processingStage, ProcessingStatesCard.ProcessingStage.recorded) + + sut.currentRecording = createTestRecording(state: .transcribing) + XCTAssertEqual(sut.processingStage, ProcessingStatesCard.ProcessingStage.transcribing) + + sut.currentRecording = createTestRecording(state: .summarizing) + XCTAssertEqual(sut.processingStage, ProcessingStatesCard.ProcessingStage.summarizing) + + sut.currentRecording = createTestRecording(state: .completed) + XCTAssertNil(sut.processingStage) + } + + func testHasSummaryComputation() { + sut.currentRecording = createTestRecording( + state: .completed, + summaryText: "Test summary" + ) + XCTAssertTrue(sut.hasSummary) + + sut.currentRecording = createTestRecording( + state: .completed, + summaryText: nil + ) + XCTAssertFalse(sut.hasSummary) + } + + func testRetryProcessingForTranscriptionFailed() async throws { + let recording = createTestRecording(id: "test-id", state: .transcriptionFailed) + sut.currentRecording = recording + + given(mockProcessingCoordinator) + .retryProcessing(recordingID: .any) + .willReturn() + + given(mockRecordingRepository) + .fetchRecording(id: .any) + .willReturn(recording) + + await sut.retryProcessing() + + verify(mockProcessingCoordinator) + .retryProcessing(recordingID: .any) + .called(1) + } + + func testCopySummaryShowsToast() async throws { + let recording = createTestRecording( + state: .completed, + summaryText: "Test summary content" + ) + sut.currentRecording = recording + + XCTAssertFalse(sut.showingCopiedToast) + + sut.copySummary() + + XCTAssertTrue(sut.showingCopiedToast) + + try await Task.sleep(nanoseconds: 2_500_000_000) + + XCTAssertFalse(sut.showingCopiedToast) + } } -private extension SummaryViewModelSpec { - func createTestRecording( - id: String = UUID().uuidString, - state: RecordingProcessingState = .completed, - summaryText: String? = nil - ) -> RecordingInfo { - RecordingInfo( - id: id, - startDate: Date(), - endDate: Date().addingTimeInterval(300), - state: state, - errorMessage: nil, - recordingURL: URL(fileURLWithPath: "/test/recording.mp4"), - microphoneURL: nil, - hasMicrophoneAudio: false, - applicationName: "Test App", - transcriptionText: "Test transcription", - summaryText: summaryText, - createdAt: Date(), - modifiedAt: Date() - ) - } +extension SummaryViewModelSpec { + fileprivate func createTestRecording( + id: String = UUID().uuidString, + state: RecordingProcessingState = .completed, + summaryText: String? = nil + ) -> RecordingInfo { + RecordingInfo( + id: id, + startDate: Date(), + endDate: Date().addingTimeInterval(300), + state: state, + errorMessage: nil, + recordingURL: URL(fileURLWithPath: "/test/recording.mp4"), + microphoneURL: nil, + hasMicrophoneAudio: false, + applicationName: "Test App", + transcriptionText: "Test transcription", + summaryText: summaryText, + timestampedTranscription: nil, + createdAt: Date(), + modifiedAt: Date() + ) + } } diff --git a/cli b/cli new file mode 100755 index 0000000..16dd256 --- /dev/null +++ b/cli @@ -0,0 +1,312 @@ +#!/bin/bash + +# Recap macOS App Build Script +# This script handles building, running, testing, and archiving the Recap app + +set -e # Exit on any error + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Configuration +PROJECT_NAME="Recap" +SCHEME_NAME="Recap" +PROJECT_FILE="Recap.xcodeproj" +ARCHIVE_DIR="Archives" +ARCHIVE_NAME="Recap-$(date +%Y-%m-%d-%H-%M-%S).xcarchive" +DIST_DIR="dist" +BUNDLE_NAME="Recap-$(date +%Y-%m-%d-%H-%M-%S)" + +# Resolve project root from this script's location (works from anywhere) +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Locate the Xcode project file, even if it's within a subfolder like "Recap/" +resolve_project_file() { + local start_dir="$1" + local found_path="" + + # First, try within the script directory up to a few levels deep + found_path=$(find "$start_dir" -maxdepth 3 -type d -name "$PROJECT_FILE" -print -quit 2>/dev/null || true) + if [[ -n "$found_path" ]]; then + echo "$found_path" + return 0 + fi + + # Next, walk upwards and search shallowly in each ancestor + local dir="$start_dir" + while [[ "$dir" != "/" ]]; do + found_path=$(find "$dir" -maxdepth 2 -type d -name "$PROJECT_FILE" -print -quit 2>/dev/null || true) + if [[ -n "$found_path" ]]; then + echo "$found_path" + return 0 + fi + dir="$(dirname "$dir")" + done + + # Finally, try current working directory as a fallback + found_path=$(find "$(pwd)" -maxdepth 3 -type d -name "$PROJECT_FILE" -print -quit 2>/dev/null || true) + if [[ -n "$found_path" ]]; then + echo "$found_path" + return 0 + fi + + return 1 +} + +PROJECT_FILE_PATH="$(resolve_project_file "$SCRIPT_DIR" || true)" +if [[ -z "$PROJECT_FILE_PATH" ]]; then + echo -e "\033[0;31m[ERROR]\033[0m Could not locate $PROJECT_FILE. Ensure it exists (e.g., Recap/$PROJECT_FILE)." + exit 1 +fi +PROJECT_ROOT="$(dirname "$PROJECT_FILE_PATH")" +cd "$PROJECT_ROOT" +PROJECT_FILE="$(basename "$PROJECT_FILE_PATH")" + +# Function to print colored output +print_status() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +print_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Function to check if Xcode is installed +check_xcode() { + if ! command -v xcodebuild &> /dev/null; then + print_error "Xcode command line tools not found. Please install Xcode and command line tools." + exit 1 + fi + print_success "Xcode command line tools found" +} + +# Function to clean build folder +clean_build() { + local configuration="${1:-Debug}" # Optional argument, defaults to Debug + print_status "Cleaning $configuration build folder..." + xcodebuild clean -project "$PROJECT_FILE" -scheme "$SCHEME_NAME" -configuration $configuration + print_success "$configuration Build folder cleaned" +} + +# Function to build the app +build_app() { + local configuration="${1:-Debug}" # Optional argument, defaults to Debug + + print_status "Building $PROJECT_NAME with configuration $configuration..." + xcodebuild build \ + -project "$PROJECT_FILE" \ + -scheme "$SCHEME_NAME" \ + -configuration "$configuration" \ + -destination "platform=macOS" + print_success "$configuration Build completed successfully" +} + +# Function to run the app +run_app() { + local configuration="${1:-Debug}" # Optional arg, defaults to Debug + + print_status "Running $PROJECT_NAME (configuration: $configuration)..." + + # Look for the app in DerivedData under the given configuration + APP_PATH=$(find ~/Library/Developer/Xcode/DerivedData \ + -type d \ + -path "*/Build/Products/$configuration*/Recap.app" \ + -exec test -f {}/Contents/MacOS/Recap \; -print | head -1) + + # If no app found, build it first with the given configuration + if [ -z "$APP_PATH" ]; then + print_warning "No built app found. Building the app first..." + build_app "$configuration" + # Try to find the app again after building + APP_PATH=$(find ~/Library/Developer/Xcode/DerivedData \ + -type d \ + -path "*/Build/Products/$configuration*/Recap.app" \ + -exec test -f {}/Contents/MacOS/Recap \; -print | head -1) + + if [ -z "$APP_PATH" ]; then + print_error "Could not find built Recap.app even after building. Check build output for errors." + exit 1 + fi + fi + + print_status "Found app at: $APP_PATH" + open "$APP_PATH" + print_success "App launched successfully" +} + +# Function to run tests +run_tests() { + print_status "Running tests..." + # Use the scheme's default test configuration (no hardcoded test plan) + xcodebuild test -project "$PROJECT_FILE" -scheme "$SCHEME_NAME" -destination "platform=macOS" + print_success "Tests completed successfully" +} + +# Function to archive the app +archive_app() { + print_status "Creating archive..." + + # Create archives directory if it doesn't exist + mkdir -p "$ARCHIVE_DIR" + + # Archive the app + xcodebuild archive \ + -project "$PROJECT_FILE" \ + -scheme "$SCHEME_NAME" \ + -configuration Release \ + -destination "platform=macOS" \ + -archivePath "$ARCHIVE_DIR/$ARCHIVE_NAME" + + print_success "Archive created: $ARCHIVE_DIR/$ARCHIVE_NAME" +} + +# Function to create a redistributable bundle +bundle_app() { + print_status "Creating redistributable bundle..." + + # Wipe out previous distributions + if [ -d "$DIST_DIR" ]; then + print_status "Cleaning previous distributions..." + rm -rf "$DIST_DIR" + fi + + # Create dist directory + mkdir -p "$DIST_DIR" + + # First, create archive if it doesn't exist or is outdated + if [ ! -d "$ARCHIVE_DIR/$ARCHIVE_NAME" ]; then + print_status "Archive not found, creating one first..." + archive_app + fi + + # Export the archive to create the .app bundle + print_status "Exporting application bundle..." + + # Create export options plist for ad-hoc distribution + EXPORT_PLIST="$DIST_DIR/ExportOptions.plist" + cat > "$EXPORT_PLIST" << EOF + + + + + method + mac-application + destination + export + + +EOF + + # Export the archive + xcodebuild -exportArchive \ + -archivePath "$ARCHIVE_DIR/$ARCHIVE_NAME" \ + -exportPath "$DIST_DIR/$BUNDLE_NAME" \ + -exportOptionsPlist "$EXPORT_PLIST" + + # Create the final distribution package + BUNDLE_PATH="$DIST_DIR/$BUNDLE_NAME.zip" + print_status "Creating distribution archive..." + + cd "$DIST_DIR/$BUNDLE_NAME" + zip -r "../$BUNDLE_NAME.zip" . -x "*.DS_Store*" "__MACOSX*" + cd "$PROJECT_ROOT" + + # Clean up intermediate files + rm -rf "$DIST_DIR/$BUNDLE_NAME" + rm -f "$EXPORT_PLIST" + + print_success "Redistributable bundle created: $BUNDLE_PATH" + print_status "Bundle contents:" + unzip -l "$BUNDLE_PATH" + + print_success "🎉 Distribution ready! Share $BUNDLE_NAME.zip with your friends!" +} + +# Function to show help +show_help() { + echo "Recap macOS App Build Script" + echo "" + echo "Usage: $0 [OPTIONS]" + echo "" + echo "Options:" + echo " build Build the app" + echo " run Run the app" + echo " test Run tests" + echo " archive Create archive" + echo " bundle Create redistributable bundle for sharing" + echo " all Build, test, and archive (in that order)" + echo " clean Clean build folder" + echo " help Show this help message" + echo "" + echo "Examples:" + echo " $0 build" + echo " $0 bundle" + echo " $0 all" + echo " $0 clean && $0 build" +} + +# Main script logic +main() { + # We already cd'ed into project root; re-validate presence of project file + if [ ! -d "$PROJECT_FILE" ] && [ ! -f "$PROJECT_FILE" ]; then + print_error "Project file $PROJECT_FILE not found in $PROJECT_ROOT." + exit 1 + fi + + # Check Xcode installation + check_xcode + + # Parse command line arguments + case "${1:-all}" in + "build") + clean_build ${@:2} + build_app ${@:2} + ;; + "run") + run_app ${@:2} + ;; + "test") + run_tests + ;; + "archive") + archive_app + ;; + "bundle") + clean_build ${@:2} + bundle_app + ;; + "all") + clean_build ${@:2} + build_app ${@:2} + run_tests + archive_app + print_success "All operations completed successfully!" + ;; + "clean") + clean_build ${@:2} + ;; + "help"|"-h"|"--help") + show_help + ;; + *) + print_error "Unknown option: $1" + show_help + exit 1 + ;; + esac +} + +# Run main function with all arguments +main "$@"