From 1dd5f7da864a2b4dd3d2e895175bddc02906713f Mon Sep 17 00:00:00 2001 From: Rawand Ahmed Shaswar Date: Tue, 5 Aug 2025 23:28:38 +0300 Subject: [PATCH 1/7] feature: add onboarding process with a bit of refactor --- .github/workflows/linter.yml | 4 +- Recap.xcodeproj/project.pbxproj | 6 +- Recap/Audio/Capture/MicrophoneCapture.swift | 9 - .../Audio/Capture/MicrophoneCaptureType.swift | 3 +- .../Session/RecordingSessionManager.swift | 8 +- .../RecapDataModel.xcdatamodel/contents | 1 + .../Availability/AvailabilityHelper.swift} | 15 +- .../Permissions/PermissionsHelper.swift | 54 ++++ .../Permissions/PermissionsHelperType.swift | 18 ++ .../MenuBarPanelManager+Delegates.swift | 41 +++ .../MenuBarPanelManager+Onboarding.swift | 18 ++ .../MenuBar/Manager/MenuBarPanelManager.swift | 58 +++- .../Manager/StatusBar/StatusBarManager.swift | 1 + Recap/MenuBar/SlidingPanel.swift | 4 +- .../Models/UserPreferencesInfo.swift | 4 + .../UserPreferencesRepository.swift | 57 ++++ .../UserPreferencesRepositoryType.swift | 8 + .../AvailabilityCoordinatorType.swift | 12 - .../DependencyContainer+Helpers.swift | 8 + .../DependencyContainer+Services.swift | 2 +- .../DependencyContainer+ViewModels.swift | 7 + .../Services/LLM/Core/LLMTaskManageable.swift | 7 +- .../LLM/Providers/Ollama/OllamaProvider.swift | 19 +- .../OpenRouter/OpenRouterProvider.swift | 18 +- .../Detectors/GoogleMeetDetector.swift | 2 +- .../Detectors/MeetingDetectorType.swift | 10 +- .../Detectors/TeamsMeetingDetector.swift | 2 +- .../Detectors/ZoomMeetingDetector.swift | 2 +- .../SystemLifecycleManager.swift | 3 +- .../Models/SummarizationRequest.swift | 3 +- .../Summarization/SummarizationService.swift | 2 +- .../Notifications/NotificationService.swift | 10 - .../NotificationServiceType.swift | 3 +- .../Validation/EnvironmentValidator.swift | 0 .../Validation/EnvironmentValidatorType.swift | 0 .../Warnings/ProviderWarningCoordinator.swift | 0 .../Warnings/WarningManager.swift | 3 +- .../Warnings/WarningManagerType.swift | 0 .../RecapViewModel+MeetingDetection.swift | 5 +- .../Components/PermissionCard.swift | 138 +++++++++ .../Onboarding/View/OnboardingView.swift | 270 ++++++++++++++++++ .../ViewModel/OnboardingViewModel.swift | 98 +++++++ .../ViewModel/OnboardingViewModelType.swift | 25 ++ .../Detectors/GoogleMeetDetectorSpec.swift | 125 ++++++++ .../Detectors/MockSCWindow.swift | 12 + .../Detectors/TeamsMeetingDetectorSpec.swift | 113 ++++++++ .../Detectors/ZoomMeetingDetectorSpec.swift | 120 ++++++++ .../ViewModels/OnboardingViewModelSpec.swift | 203 +++++++++++++ 48 files changed, 1451 insertions(+), 80 deletions(-) rename Recap/{Services/Availability/AvailabilityCoordinator.swift => Helpers/Availability/AvailabilityHelper.swift} (80%) create mode 100644 Recap/Helpers/Permissions/PermissionsHelper.swift create mode 100644 Recap/Helpers/Permissions/PermissionsHelperType.swift create mode 100644 Recap/MenuBar/Manager/MenuBarPanelManager+Delegates.swift create mode 100644 Recap/MenuBar/Manager/MenuBarPanelManager+Onboarding.swift delete mode 100644 Recap/Services/Availability/AvailabilityCoordinatorType.swift create mode 100644 Recap/Services/DependencyContainer/DependencyContainer+Helpers.swift rename Recap/Services/{ => Utilities}/Notifications/NotificationService.swift (82%) rename Recap/Services/{ => Utilities}/Notifications/NotificationServiceType.swift (81%) rename Recap/Services/{ => Utilities}/Validation/EnvironmentValidator.swift (100%) rename Recap/Services/{ => Utilities}/Validation/EnvironmentValidatorType.swift (100%) rename Recap/Services/{ => Utilities}/Warnings/ProviderWarningCoordinator.swift (100%) rename Recap/Services/{ => Utilities}/Warnings/WarningManager.swift (98%) rename Recap/Services/{ => Utilities}/Warnings/WarningManagerType.swift (100%) create mode 100644 Recap/UseCases/Onboarding/Components/PermissionCard.swift create mode 100644 Recap/UseCases/Onboarding/View/OnboardingView.swift create mode 100644 Recap/UseCases/Onboarding/ViewModel/OnboardingViewModel.swift create mode 100644 Recap/UseCases/Onboarding/ViewModel/OnboardingViewModelType.swift create mode 100644 RecapTests/Services/MeetingDetection/Detectors/GoogleMeetDetectorSpec.swift create mode 100644 RecapTests/Services/MeetingDetection/Detectors/MockSCWindow.swift create mode 100644 RecapTests/Services/MeetingDetection/Detectors/TeamsMeetingDetectorSpec.swift create mode 100644 RecapTests/Services/MeetingDetection/Detectors/ZoomMeetingDetectorSpec.swift create mode 100644 RecapTests/UseCases/Onboarding/ViewModels/OnboardingViewModelSpec.swift diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index d7691f4..ecf45b5 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -1,4 +1,4 @@ -name: Run SwiftLint +name: Build and Lint on: push: @@ -51,7 +51,7 @@ jobs: -project Recap.xcodeproj \ -scheme Recap - - name: Build Debug + - name: Build Project run: | xcodebuild build \ -project Recap.xcodeproj \ diff --git a/Recap.xcodeproj/project.pbxproj b/Recap.xcodeproj/project.pbxproj index 0fa9dd2..e44566a 100644 --- a/Recap.xcodeproj/project.pbxproj +++ b/Recap.xcodeproj/project.pbxproj @@ -50,6 +50,8 @@ Helpers/Constants/AppConstants.swift, Helpers/Constants/UIConstants.swift, Helpers/MeetingDetection/MeetingPatternMatcher.swift, + Helpers/Permissions/PermissionsHelper.swift, + Helpers/Permissions/PermissionsHelperType.swift, Repositories/Models/LLMProvider.swift, Repositories/Models/RecordingInfo.swift, Repositories/Models/UserPreferencesInfo.swift, @@ -76,9 +78,11 @@ Services/Summarization/Models/SummarizationResult.swift, Services/Summarization/SummarizationServiceType.swift, Services/Transcription/TranscriptionServiceType.swift, - Services/Warnings/WarningManagerType.swift, + Services/Utilities/Warnings/WarningManagerType.swift, UIComponents/Buttons/PillButton.swift, UIComponents/Cards/ActionableWarningCard.swift, + UseCases/Onboarding/ViewModel/OnboardingViewModel.swift, + UseCases/Onboarding/ViewModel/OnboardingViewModelType.swift, UseCases/Settings/Components/MeetingDetection/MeetingDetectionView.swift, UseCases/Settings/Components/Reusable/CustomToggle.swift, UseCases/Settings/Components/SettingsCard.swift, diff --git a/Recap/Audio/Capture/MicrophoneCapture.swift b/Recap/Audio/Capture/MicrophoneCapture.swift index b0c1c8f..89ce959 100644 --- a/Recap/Audio/Capture/MicrophoneCapture.swift +++ b/Recap/Audio/Capture/MicrophoneCapture.swift @@ -39,15 +39,6 @@ final class MicrophoneCapture: MicrophoneCaptureType { cleanup() } - func requestPermission() async -> Bool { - await withCheckedContinuation { continuation in - AVCaptureDevice.requestAccess(for: .audio) { granted in - self.logger.info("Microphone permission granted: \(granted)") - continuation.resume(returning: granted) - } - } - } - func start(outputURL: URL, targetFormat: AudioStreamBasicDescription? = nil) throws { self.outputURL = outputURL diff --git a/Recap/Audio/Capture/MicrophoneCaptureType.swift b/Recap/Audio/Capture/MicrophoneCaptureType.swift index 87b2b49..496b109 100644 --- a/Recap/Audio/Capture/MicrophoneCaptureType.swift +++ b/Recap/Audio/Capture/MicrophoneCaptureType.swift @@ -12,8 +12,7 @@ import AudioToolbox protocol MicrophoneCaptureType: ObservableObject { var audioLevel: Float { get } var recordingFormat: AVAudioFormat? { get } - - func requestPermission() async -> Bool + func start(outputURL: URL, targetFormat: AudioStreamBasicDescription?) throws func stop() } diff --git a/Recap/Audio/Processing/Session/RecordingSessionManager.swift b/Recap/Audio/Processing/Session/RecordingSessionManager.swift index 68b1b30..0487867 100644 --- a/Recap/Audio/Processing/Session/RecordingSessionManager.swift +++ b/Recap/Audio/Processing/Session/RecordingSessionManager.swift @@ -8,9 +8,11 @@ protocol RecordingSessionManaging { final class RecordingSessionManager: RecordingSessionManaging { private let logger = Logger(subsystem: AppConstants.Logging.subsystem, category: String(describing: RecordingSessionManager.self)) private let microphoneCapture: MicrophoneCapture + private let permissionsHelper: PermissionsHelperType - init(microphoneCapture: MicrophoneCapture) { + init(microphoneCapture: MicrophoneCapture, permissionsHelper: PermissionsHelperType) { self.microphoneCapture = microphoneCapture + self.permissionsHelper = permissionsHelper } func startSession(configuration: RecordingConfiguration) async throws -> AudioRecordingCoordinatorType { @@ -27,8 +29,8 @@ final class RecordingSessionManager: RecordingSessionManaging { let microphoneCaptureToUse = configuration.enableMicrophone ? microphoneCapture : nil if configuration.enableMicrophone { - let hasPermission = await microphoneCapture.requestPermission() - guard hasPermission else { + let hasPermission = await permissionsHelper.checkMicrophonePermissionStatus() + guard hasPermission == .authorized else { throw AudioCaptureError.microphonePermissionDenied } } diff --git a/Recap/DataModels/RecapDataModel.xcdatamodeld/RecapDataModel.xcdatamodel/contents b/Recap/DataModels/RecapDataModel.xcdatamodeld/RecapDataModel.xcdatamodel/contents index 5d843c3..aaf5ef8 100644 --- a/Recap/DataModels/RecapDataModel.xcdatamodeld/RecapDataModel.xcdatamodel/contents +++ b/Recap/DataModels/RecapDataModel.xcdatamodeld/RecapDataModel.xcdatamodel/contents @@ -15,6 +15,7 @@ + diff --git a/Recap/Services/Availability/AvailabilityCoordinator.swift b/Recap/Helpers/Availability/AvailabilityHelper.swift similarity index 80% rename from Recap/Services/Availability/AvailabilityCoordinator.swift rename to Recap/Helpers/Availability/AvailabilityHelper.swift index db1e8c3..c6b658b 100644 --- a/Recap/Services/Availability/AvailabilityCoordinator.swift +++ b/Recap/Helpers/Availability/AvailabilityHelper.swift @@ -2,7 +2,18 @@ import Foundation import Combine @MainActor -final class AvailabilityCoordinator: AvailabilityCoordinatorType { +protocol AvailabilityHelperType: AnyObject { + 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() @@ -50,4 +61,4 @@ final class AvailabilityCoordinator: AvailabilityCoordinatorType { isAvailable = available return available } -} \ No newline at end of file +} diff --git a/Recap/Helpers/Permissions/PermissionsHelper.swift b/Recap/Helpers/Permissions/PermissionsHelper.swift new file mode 100644 index 0000000..2ed8698 --- /dev/null +++ b/Recap/Helpers/Permissions/PermissionsHelper.swift @@ -0,0 +1,54 @@ +import Foundation +import AVFoundation +import UserNotifications +import ScreenCaptureKit + +@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 requestNotificationPermission() async -> Bool { + do { + let center = UNUserNotificationCenter.current() + let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge]) + return granted + } catch { + return false + } + } + + 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 + } + } +} diff --git a/Recap/Helpers/Permissions/PermissionsHelperType.swift b/Recap/Helpers/Permissions/PermissionsHelperType.swift new file mode 100644 index 0000000..e6f1d2c --- /dev/null +++ b/Recap/Helpers/Permissions/PermissionsHelperType.swift @@ -0,0 +1,18 @@ +import Foundation +import AVFoundation +#if MOCKING +import Mockable +#endif + +#if MOCKING +@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 +} \ No newline at end of file diff --git a/Recap/MenuBar/Manager/MenuBarPanelManager+Delegates.swift b/Recap/MenuBar/Manager/MenuBarPanelManager+Delegates.swift new file mode 100644 index 0000000..2157ab0 --- /dev/null +++ b/Recap/MenuBar/Manager/MenuBarPanelManager+Delegates.swift @@ -0,0 +1,41 @@ +import SwiftUI +import AppKit + +extension MenuBarPanelManager: OnboardingDelegate { + func onboardingDidComplete() { + Task { + await transitionFromOnboardingToMain() + } + } + + 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() + } + } + } +} \ No newline at end of file diff --git a/Recap/MenuBar/Manager/MenuBarPanelManager+Onboarding.swift b/Recap/MenuBar/Manager/MenuBarPanelManager+Onboarding.swift new file mode 100644 index 0000000..7e1d140 --- /dev/null +++ b/Recap/MenuBar/Manager/MenuBarPanelManager+Onboarding.swift @@ -0,0 +1,18 @@ +import SwiftUI +import AppKit + +extension MenuBarPanelManager { + @MainActor + func createOnboardingPanel() -> SlidingPanel { + let onboardingViewModel = dependencyContainer.makeOnboardingViewModel() + 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 diff --git a/Recap/MenuBar/Manager/MenuBarPanelManager.swift b/Recap/MenuBar/Manager/MenuBarPanelManager.swift index de13ab8..57434ad 100644 --- a/Recap/MenuBar/Manager/MenuBarPanelManager.swift +++ b/Recap/MenuBar/Manager/MenuBarPanelManager.swift @@ -24,6 +24,7 @@ final class MenuBarPanelManager: MenuBarPanelManagerType, ObservableObject { let appSelectionViewModel: AppSelectionViewModel let previousRecapsViewModel: PreviousRecapsViewModel let whisperModelsViewModel: WhisperModelsViewModel + let userPreferencesRepository: UserPreferencesRepositoryType let dependencyContainer: DependencyContainer init( @@ -33,12 +34,14 @@ final class MenuBarPanelManager: MenuBarPanelManagerType, ObservableObject { audioProcessController: AudioProcessController, appSelectionViewModel: AppSelectionViewModel, previousRecapsViewModel: PreviousRecapsViewModel, + userPreferencesRepository: UserPreferencesRepositoryType, dependencyContainer: DependencyContainer ) { self.statusBarManager = statusBarManager self.audioProcessController = audioProcessController self.appSelectionViewModel = appSelectionViewModel self.whisperModelsViewModel = whisperModelsViewModel + self.userPreferencesRepository = userPreferencesRepository self.dependencyContainer = dependencyContainer self.previousRecapsViewModel = previousRecapsViewModel setupDelegates() @@ -48,7 +51,7 @@ final class MenuBarPanelManager: MenuBarPanelManagerType, ObservableObject { statusBarManager.delegate = self } - private func createPanel() -> SlidingPanel? { + func createMainPanel() -> SlidingPanel { let viewModel = dependencyContainer.createRecapViewModel() viewModel.delegate = self let contentView = RecapHomeView(viewModel: viewModel) @@ -61,7 +64,7 @@ final class MenuBarPanelManager: MenuBarPanelManagerType, ObservableObject { return newPanel } - private func positionPanel(_ panel: NSPanel, size: CGSize? = nil) { + func positionPanel(_ panel: NSPanel, size: CGSize? = nil) { guard let statusButton = statusBarManager.statusButton, let statusWindow = statusButton.window, let screen = statusWindow.screen else { return } @@ -77,12 +80,59 @@ final class MenuBarPanelManager: MenuBarPanelManagerType, ObservableObject { ) } - private func showPanel() { if panel == nil { - panel = createPanel() + 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 createPanelBasedOnOnboardingStatus(isOnboarded: Bool) async { + if !isOnboarded { + panel = createOnboardingPanel() + } else { + panel = createMainPanel() } + if let panel = panel { + positionPanel(panel) + } + } + + private func createMainPanelAndPosition() async { + panel = createMainPanel() + 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 showExistingPanel() { guard let panel = panel else { return } positionPanel(panel) diff --git a/Recap/MenuBar/Manager/StatusBar/StatusBarManager.swift b/Recap/MenuBar/Manager/StatusBar/StatusBarManager.swift index b716988..ede2289 100644 --- a/Recap/MenuBar/Manager/StatusBar/StatusBarManager.swift +++ b/Recap/MenuBar/Manager/StatusBar/StatusBarManager.swift @@ -1,5 +1,6 @@ import AppKit +@MainActor protocol StatusBarDelegate: AnyObject { func statusItemClicked() func quitRequested() diff --git a/Recap/MenuBar/SlidingPanel.swift b/Recap/MenuBar/SlidingPanel.swift index e6816a1..2294418 100644 --- a/Recap/MenuBar/SlidingPanel.swift +++ b/Recap/MenuBar/SlidingPanel.swift @@ -1,5 +1,6 @@ import AppKit +@MainActor protocol SlidingPanelDelegate: AnyObject { func panelDidReceiveClickOutside() } @@ -68,7 +69,6 @@ final class SlidingPanel: NSPanel, SlidingPanelType { return visualEffect } - private func setupEventMonitoring() { eventMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] event in self?.handleGlobalClick(event) @@ -112,4 +112,4 @@ extension SlidingPanel { contentView.trailingAnchor.constraint(equalTo: container.trailingAnchor) ]) } -} \ No newline at end of file +} diff --git a/Recap/Repositories/Models/UserPreferencesInfo.swift b/Recap/Repositories/Models/UserPreferencesInfo.swift index 53186b0..8fb7fe9 100644 --- a/Recap/Repositories/Models/UserPreferencesInfo.swift +++ b/Recap/Repositories/Models/UserPreferencesInfo.swift @@ -8,6 +8,7 @@ struct UserPreferencesInfo: Identifiable { let autoSummarizeEnabled: Bool let autoDetectMeetings: Bool let autoStopRecording: Bool + let onboarded: Bool let summaryPromptTemplate: String? let createdAt: Date let modifiedAt: Date @@ -19,6 +20,7 @@ struct UserPreferencesInfo: Identifiable { 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() @@ -32,6 +34,7 @@ struct UserPreferencesInfo: Identifiable { autoSummarizeEnabled: Bool = true, autoDetectMeetings: Bool = false, autoStopRecording: Bool = false, + onboarded: Bool = false, summaryPromptTemplate: String? = nil, createdAt: Date = Date(), modifiedAt: Date = Date() @@ -42,6 +45,7 @@ struct UserPreferencesInfo: Identifiable { self.autoSummarizeEnabled = autoSummarizeEnabled self.autoDetectMeetings = autoDetectMeetings self.autoStopRecording = autoStopRecording + self.onboarded = onboarded self.summaryPromptTemplate = summaryPromptTemplate self.createdAt = createdAt self.modifiedAt = modifiedAt diff --git a/Recap/Repositories/UserPreferences/UserPreferencesRepository.swift b/Recap/Repositories/UserPreferences/UserPreferencesRepository.swift index 4e0cae6..0c1990f 100644 --- a/Recap/Repositories/UserPreferences/UserPreferencesRepository.swift +++ b/Recap/Repositories/UserPreferences/UserPreferencesRepository.swift @@ -180,4 +180,61 @@ final class UserPreferencesRepository: UserPreferencesRepositoryType { 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) + } + } } diff --git a/Recap/Repositories/UserPreferences/UserPreferencesRepositoryType.swift b/Recap/Repositories/UserPreferences/UserPreferencesRepositoryType.swift index c581157..97bd6b7 100644 --- a/Recap/Repositories/UserPreferences/UserPreferencesRepositoryType.swift +++ b/Recap/Repositories/UserPreferences/UserPreferencesRepositoryType.swift @@ -1,5 +1,11 @@ import Foundation +#if MOCKING +import Mockable +#endif +#if MOCKING +@Mockable +#endif @MainActor protocol UserPreferencesRepositoryType { func getOrCreatePreferences() async throws -> UserPreferencesInfo @@ -7,5 +13,7 @@ protocol UserPreferencesRepositoryType { 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 } \ No newline at end of file diff --git a/Recap/Services/Availability/AvailabilityCoordinatorType.swift b/Recap/Services/Availability/AvailabilityCoordinatorType.swift deleted file mode 100644 index 1915446..0000000 --- a/Recap/Services/Availability/AvailabilityCoordinatorType.swift +++ /dev/null @@ -1,12 +0,0 @@ -import Foundation -import Combine - -@MainActor -protocol AvailabilityCoordinatorType: AnyObject { - var isAvailable: Bool { get } - var availabilityPublisher: AnyPublisher { get } - - func startMonitoring() - func stopMonitoring() - func checkAvailabilityNow() async -> Bool -} \ No newline at end of file diff --git a/Recap/Services/DependencyContainer/DependencyContainer+Helpers.swift b/Recap/Services/DependencyContainer/DependencyContainer+Helpers.swift new file mode 100644 index 0000000..6204eca --- /dev/null +++ b/Recap/Services/DependencyContainer/DependencyContainer+Helpers.swift @@ -0,0 +1,8 @@ +import Foundation + +extension DependencyContainer { + + func makePermissionsHelper() -> PermissionsHelperType { + PermissionsHelper() + } +} \ No newline at end of file diff --git a/Recap/Services/DependencyContainer/DependencyContainer+Services.swift b/Recap/Services/DependencyContainer/DependencyContainer+Services.swift index 9025e09..2d087c2 100644 --- a/Recap/Services/DependencyContainer/DependencyContainer+Services.swift +++ b/Recap/Services/DependencyContainer/DependencyContainer+Services.swift @@ -29,7 +29,7 @@ extension DependencyContainer { guard let micCapture = microphoneCapture as? MicrophoneCapture else { fatalError("microphoneCapture is not of type MicrophoneCapture") } - return RecordingSessionManager(microphoneCapture: micCapture) + return RecordingSessionManager(microphoneCapture: micCapture, permissionsHelper: makePermissionsHelper()) } func makeMicrophoneCapture() -> MicrophoneCaptureType { diff --git a/Recap/Services/DependencyContainer/DependencyContainer+ViewModels.swift b/Recap/Services/DependencyContainer/DependencyContainer+ViewModels.swift index 28f6e76..d121b22 100644 --- a/Recap/Services/DependencyContainer/DependencyContainer+ViewModels.swift +++ b/Recap/Services/DependencyContainer/DependencyContainer+ViewModels.swift @@ -29,4 +29,11 @@ extension DependencyContainer { userPreferencesRepository: userPreferencesRepository ) } + + func makeOnboardingViewModel() -> OnboardingViewModel { + OnboardingViewModel( + permissionsHelper: PermissionsHelper(), + userPreferencesRepository: userPreferencesRepository + ) + } } \ No newline at end of file diff --git a/Recap/Services/LLM/Core/LLMTaskManageable.swift b/Recap/Services/LLM/Core/LLMTaskManageable.swift index 14a9c15..f0ade6a 100644 --- a/Recap/Services/LLM/Core/LLMTaskManageable.swift +++ b/Recap/Services/LLM/Core/LLMTaskManageable.swift @@ -1,5 +1,6 @@ import Foundation +@MainActor protocol LLMTaskManageable: AnyObject { var currentTask: Task? { get set } func cancelCurrentTask() @@ -19,7 +20,9 @@ extension LLMTaskManageable { return try await withTaskCancellationHandler { try await operation() } onCancel: { - cancelCurrentTask() + Task.detached { [weak self] in + await self?.cancelCurrentTask() + } } } -} \ No newline at end of file +} diff --git a/Recap/Services/LLM/Providers/Ollama/OllamaProvider.swift b/Recap/Services/LLM/Providers/Ollama/OllamaProvider.swift index 1aed896..bc451bf 100644 --- a/Recap/Services/LLM/Providers/Ollama/OllamaProvider.swift +++ b/Recap/Services/LLM/Providers/Ollama/OllamaProvider.swift @@ -8,35 +8,38 @@ final class OllamaProvider: LLMProviderType, LLMTaskManageable { let name = "Ollama" var isAvailable: Bool { - availabilityCoordinator.isAvailable + availabilityHelper.isAvailable } var availabilityPublisher: AnyPublisher { - availabilityCoordinator.availabilityPublisher + availabilityHelper.availabilityPublisher } var currentTask: Task? private let apiClient: OllamaAPIClient - private let availabilityCoordinator: AvailabilityCoordinatorType + private let availabilityHelper: AvailabilityHelper init(baseURL: String = "http://localhost", port: Int = 11434) { self.apiClient = OllamaAPIClient(baseURL: baseURL, port: port) - self.availabilityCoordinator = AvailabilityCoordinator( + + self.availabilityHelper = AvailabilityHelper( checkInterval: 30.0, availabilityCheck: { [weak apiClient] in await apiClient?.checkAvailability() ?? false } ) - availabilityCoordinator.startMonitoring() + availabilityHelper.startMonitoring() } deinit { - cancelCurrentTask() + Task.detached { [weak self] in + await self?.cancelCurrentTask() + } } func checkAvailability() async -> Bool { - await availabilityCoordinator.checkAvailabilityNow() + await availabilityHelper.checkAvailabilityNow() } func listModels() async throws -> [OllamaModel] { @@ -79,4 +82,4 @@ final class OllamaProvider: LLMProviderType, LLMTaskManageable { } } -} \ No newline at end of file +} diff --git a/Recap/Services/LLM/Providers/OpenRouter/OpenRouterProvider.swift b/Recap/Services/LLM/Providers/OpenRouter/OpenRouterProvider.swift index 15d5e70..d69150f 100644 --- a/Recap/Services/LLM/Providers/OpenRouter/OpenRouterProvider.swift +++ b/Recap/Services/LLM/Providers/OpenRouter/OpenRouterProvider.swift @@ -8,36 +8,38 @@ final class OpenRouterProvider: LLMProviderType, LLMTaskManageable { let name = "OpenRouter" var isAvailable: Bool { - availabilityCoordinator.isAvailable + availabilityHelper.isAvailable } var availabilityPublisher: AnyPublisher { - availabilityCoordinator.availabilityPublisher + availabilityHelper.availabilityPublisher } var currentTask: Task? private let apiClient: OpenRouterAPIClient - private let availabilityCoordinator: AvailabilityCoordinatorType + private let availabilityHelper: AvailabilityHelper init(apiKey: String? = nil) { let resolvedApiKey = apiKey ?? ProcessInfo.processInfo.environment["OPENROUTER_API_KEY"] self.apiClient = OpenRouterAPIClient(apiKey: resolvedApiKey) - self.availabilityCoordinator = AvailabilityCoordinator( + self.availabilityHelper = AvailabilityHelper( checkInterval: 60.0, availabilityCheck: { [weak apiClient] in await apiClient?.checkAvailability() ?? false } ) - availabilityCoordinator.startMonitoring() + availabilityHelper.startMonitoring() } deinit { - cancelCurrentTask() + Task.detached { [weak self] in + await self?.cancelCurrentTask() + } } func checkAvailability() async -> Bool { - await availabilityCoordinator.checkAvailabilityNow() + await availabilityHelper.checkAvailabilityNow() } func listModels() async throws -> [OpenRouterModel] { @@ -79,4 +81,4 @@ final class OpenRouterProvider: LLMProviderType, LLMTaskManageable { throw LLMError.invalidPrompt } } -} \ No newline at end of file +} diff --git a/Recap/Services/MeetingDetection/Detectors/GoogleMeetDetector.swift b/Recap/Services/MeetingDetection/Detectors/GoogleMeetDetector.swift index 65b967e..eaafa5d 100644 --- a/Recap/Services/MeetingDetection/Detectors/GoogleMeetDetector.swift +++ b/Recap/Services/MeetingDetection/Detectors/GoogleMeetDetector.swift @@ -20,7 +20,7 @@ final class GoogleMeetDetector: MeetingDetectorType { self.patternMatcher = MeetingPatternMatcher(patterns: MeetingPatternMatcher.googleMeetPatterns) } - func checkForMeeting(in windows: [SCWindow]) async -> MeetingDetectionResult { + func checkForMeeting(in windows: [any WindowTitleProviding]) async -> MeetingDetectionResult { for window in windows { guard let title = window.title, !title.isEmpty else { continue } diff --git a/Recap/Services/MeetingDetection/Detectors/MeetingDetectorType.swift b/Recap/Services/MeetingDetection/Detectors/MeetingDetectorType.swift index 7b2f22e..e075ec1 100644 --- a/Recap/Services/MeetingDetection/Detectors/MeetingDetectorType.swift +++ b/Recap/Services/MeetingDetection/Detectors/MeetingDetectorType.swift @@ -4,6 +4,14 @@ import ScreenCaptureKit import Mockable #endif +// MARK: - Window Protocol for Testing + +protocol WindowTitleProviding { + var title: String? { get } +} + +extension SCWindow: WindowTitleProviding {} + @MainActor #if MOCKING @Mockable @@ -14,7 +22,7 @@ protocol MeetingDetectorType: ObservableObject { var meetingAppName: String { get } var supportedBundleIdentifiers: Set { get } - func checkForMeeting(in windows: [SCWindow]) async -> MeetingDetectionResult + func checkForMeeting(in windows: [any WindowTitleProviding]) async -> MeetingDetectionResult } struct MeetingDetectionResult { diff --git a/Recap/Services/MeetingDetection/Detectors/TeamsMeetingDetector.swift b/Recap/Services/MeetingDetection/Detectors/TeamsMeetingDetector.swift index bcdc225..62d38d1 100644 --- a/Recap/Services/MeetingDetection/Detectors/TeamsMeetingDetector.swift +++ b/Recap/Services/MeetingDetection/Detectors/TeamsMeetingDetector.swift @@ -18,7 +18,7 @@ final class TeamsMeetingDetector: MeetingDetectorType { self.patternMatcher = MeetingPatternMatcher(patterns: MeetingPatternMatcher.teamsPatterns) } - func checkForMeeting(in windows: [SCWindow]) async -> MeetingDetectionResult { + func checkForMeeting(in windows: [any WindowTitleProviding]) async -> MeetingDetectionResult { for window in windows { guard let title = window.title, !title.isEmpty else { continue } diff --git a/Recap/Services/MeetingDetection/Detectors/ZoomMeetingDetector.swift b/Recap/Services/MeetingDetection/Detectors/ZoomMeetingDetector.swift index eacb3bf..1d7fa86 100644 --- a/Recap/Services/MeetingDetection/Detectors/ZoomMeetingDetector.swift +++ b/Recap/Services/MeetingDetection/Detectors/ZoomMeetingDetector.swift @@ -15,7 +15,7 @@ final class ZoomMeetingDetector: MeetingDetectorType { self.patternMatcher = MeetingPatternMatcher(patterns: MeetingPatternMatcher.zoomPatterns) } - func checkForMeeting(in windows: [SCWindow]) async -> MeetingDetectionResult { + func checkForMeeting(in windows: [any WindowTitleProviding]) async -> MeetingDetectionResult { for window in windows { guard let title = window.title, !title.isEmpty else { continue } diff --git a/Recap/Services/Processing/SystemLifecycle/SystemLifecycleManager.swift b/Recap/Services/Processing/SystemLifecycle/SystemLifecycleManager.swift index 3253a01..c7ff029 100644 --- a/Recap/Services/Processing/SystemLifecycle/SystemLifecycleManager.swift +++ b/Recap/Services/Processing/SystemLifecycle/SystemLifecycleManager.swift @@ -1,6 +1,7 @@ import Foundation import AppKit +@MainActor protocol SystemLifecycleDelegate: AnyObject { func systemWillSleep() func systemDidWake() @@ -45,4 +46,4 @@ final class SystemLifecycleManager { NSWorkspace.shared.notificationCenter.removeObserver(observer) } } -} \ No newline at end of file +} diff --git a/Recap/Services/Summarization/Models/SummarizationRequest.swift b/Recap/Services/Summarization/Models/SummarizationRequest.swift index 36e638a..4691791 100644 --- a/Recap/Services/Summarization/Models/SummarizationRequest.swift +++ b/Recap/Services/Summarization/Models/SummarizationRequest.swift @@ -1,5 +1,6 @@ import Foundation +// TODO: Clean up struct SummarizationRequest { let transcriptText: String let metadata: TranscriptMetadata? @@ -36,4 +37,4 @@ struct SummarizationRequest { ) } } -} \ No newline at end of file +} diff --git a/Recap/Services/Summarization/SummarizationService.swift b/Recap/Services/Summarization/SummarizationService.swift index 80f25c7..9cf9ade 100644 --- a/Recap/Services/Summarization/SummarizationService.swift +++ b/Recap/Services/Summarization/SummarizationService.swift @@ -93,7 +93,7 @@ final class SummarizationService: SummarizationServiceType { return LLMOptions( temperature: 0.7, - maxTokens: 8192, + maxTokens: maxTokens, keepAliveMinutes: 5 ) } diff --git a/Recap/Services/Notifications/NotificationService.swift b/Recap/Services/Utilities/Notifications/NotificationService.swift similarity index 82% rename from Recap/Services/Notifications/NotificationService.swift rename to Recap/Services/Utilities/Notifications/NotificationService.swift index 984e640..e337bfe 100644 --- a/Recap/Services/Notifications/NotificationService.swift +++ b/Recap/Services/Utilities/Notifications/NotificationService.swift @@ -7,16 +7,6 @@ final class NotificationService: NotificationServiceType { private let logger = Logger(subsystem: AppConstants.Logging.subsystem, category: "NotificationService") private let notificationCenter = UNUserNotificationCenter.current() - func requestPermission() async -> Bool { - do { - let granted = try await notificationCenter.requestAuthorization(options: [.alert, .sound, .badge]) - return granted - } catch { - logger.error("Failed to request notification permission: \(error)") - return false - } - } - func sendMeetingStartedNotification(appName: String, title: String) async { let content = UNMutableNotificationContent() content.title = "\(appName): Meeting Detected" diff --git a/Recap/Services/Notifications/NotificationServiceType.swift b/Recap/Services/Utilities/Notifications/NotificationServiceType.swift similarity index 81% rename from Recap/Services/Notifications/NotificationServiceType.swift rename to Recap/Services/Utilities/Notifications/NotificationServiceType.swift index f74e7a2..3a1f739 100644 --- a/Recap/Services/Notifications/NotificationServiceType.swift +++ b/Recap/Services/Utilities/Notifications/NotificationServiceType.swift @@ -2,7 +2,6 @@ import Foundation @MainActor protocol NotificationServiceType { - func requestPermission() async -> Bool func sendMeetingStartedNotification(appName: String, title: String) async func sendMeetingEndedNotification() async -} \ No newline at end of file +} diff --git a/Recap/Services/Validation/EnvironmentValidator.swift b/Recap/Services/Utilities/Validation/EnvironmentValidator.swift similarity index 100% rename from Recap/Services/Validation/EnvironmentValidator.swift rename to Recap/Services/Utilities/Validation/EnvironmentValidator.swift diff --git a/Recap/Services/Validation/EnvironmentValidatorType.swift b/Recap/Services/Utilities/Validation/EnvironmentValidatorType.swift similarity index 100% rename from Recap/Services/Validation/EnvironmentValidatorType.swift rename to Recap/Services/Utilities/Validation/EnvironmentValidatorType.swift diff --git a/Recap/Services/Warnings/ProviderWarningCoordinator.swift b/Recap/Services/Utilities/Warnings/ProviderWarningCoordinator.swift similarity index 100% rename from Recap/Services/Warnings/ProviderWarningCoordinator.swift rename to Recap/Services/Utilities/Warnings/ProviderWarningCoordinator.swift diff --git a/Recap/Services/Warnings/WarningManager.swift b/Recap/Services/Utilities/Warnings/WarningManager.swift similarity index 98% rename from Recap/Services/Warnings/WarningManager.swift rename to Recap/Services/Utilities/Warnings/WarningManager.swift index 6e6cf7d..400a824 100644 --- a/Recap/Services/Warnings/WarningManager.swift +++ b/Recap/Services/Utilities/Warnings/WarningManager.swift @@ -1,7 +1,6 @@ import Foundation import Combine -@MainActor final class WarningManager: WarningManagerType { @Published private(set) var activeWarnings: [WarningItem] = [] @@ -30,4 +29,4 @@ final class WarningManager: WarningManagerType { addWarning(warning) } } -} \ No newline at end of file +} diff --git a/Recap/Services/Warnings/WarningManagerType.swift b/Recap/Services/Utilities/Warnings/WarningManagerType.swift similarity index 100% rename from Recap/Services/Warnings/WarningManagerType.swift rename to Recap/Services/Utilities/Warnings/WarningManagerType.swift diff --git a/Recap/UseCases/Home/ViewModel/RecapViewModel+MeetingDetection.swift b/Recap/UseCases/Home/ViewModel/RecapViewModel+MeetingDetection.swift index 0eb1be2..07a791a 100644 --- a/Recap/UseCases/Home/ViewModel/RecapViewModel+MeetingDetection.swift +++ b/Recap/UseCases/Home/ViewModel/RecapViewModel+MeetingDetection.swift @@ -74,7 +74,7 @@ private extension RecapViewModel { // MARK: - App Auto-Selection private extension RecapViewModel { func autoSelectAppIfAvailable(_ detectedApp: AudioProcess?) { - guard let detectedApp = detectedApp else { + guard let detectedApp else { return } @@ -86,13 +86,12 @@ private extension RecapViewModel { private extension RecapViewModel { func sendMeetingStartedNotification(appName: String, title: String) { Task { - await notificationService.requestPermission() await notificationService.sendMeetingStartedNotification(appName: appName, title: title) } } func sendMeetingEndedNotification() { - // TODO: Analyze audio levels, and if silence is detected, send a notification here. + // TODO: Later we will analyze audio levels, and if silence is detected, send a notification here. } } diff --git a/Recap/UseCases/Onboarding/Components/PermissionCard.swift b/Recap/UseCases/Onboarding/Components/PermissionCard.swift new file mode 100644 index 0000000..0f78d75 --- /dev/null +++ b/Recap/UseCases/Onboarding/Components/PermissionCard.swift @@ -0,0 +1,138 @@ +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) + } + + 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 + ) + ) + ) + } +} + +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) + } +} + +#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 } + ) + } + .padding(75) + .background(Color.black) +} diff --git a/Recap/UseCases/Onboarding/View/OnboardingView.swift b/Recap/UseCases/Onboarding/View/OnboardingView.swift new file mode 100644 index 0000000..86c8166 --- /dev/null +++ b/Recap/UseCases/Onboarding/View/OnboardingView.swift @@ -0,0 +1,270 @@ +import SwiftUI + +struct OnboardingView: View { + @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) + } + .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() + } + } + .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) + } + } + + 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", + isEnabled: Binding( + get: { viewModel.isAutoSummarizeEnabled }, + set: { _ in } + ), + onToggle: { enabled in + viewModel.toggleAutoSummarize(enabled) + } + ) + + 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 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()) + + 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 + ) + ) + } +} + +#Preview { + OnboardingView( + viewModel: OnboardingViewModel( + permissionsHelper: PermissionsHelper(), + userPreferencesRepository: PreviewUserPreferencesRepository() + ) + ) + .frame(width: 600, height: 700) +} + +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 {} +} diff --git a/Recap/UseCases/Onboarding/ViewModel/OnboardingViewModel.swift b/Recap/UseCases/Onboarding/ViewModel/OnboardingViewModel.swift new file mode 100644 index 0000000..c86fc07 --- /dev/null +++ b/Recap/UseCases/Onboarding/ViewModel/OnboardingViewModel.swift @@ -0,0 +1,98 @@ +import Foundation +import AVFoundation + +@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 + } + + 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 { + 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 + } + } +} diff --git a/Recap/UseCases/Onboarding/ViewModel/OnboardingViewModelType.swift b/Recap/UseCases/Onboarding/ViewModel/OnboardingViewModelType.swift new file mode 100644 index 0000000..447dfd6 --- /dev/null +++ b/Recap/UseCases/Onboarding/ViewModel/OnboardingViewModelType.swift @@ -0,0 +1,25 @@ +import Foundation + +@MainActor +protocol OnboardingDelegate: AnyObject { + 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 } + 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 diff --git a/RecapTests/Services/MeetingDetection/Detectors/GoogleMeetDetectorSpec.swift b/RecapTests/Services/MeetingDetection/Detectors/GoogleMeetDetectorSpec.swift new file mode 100644 index 0000000..1163570 --- /dev/null +++ b/RecapTests/Services/MeetingDetection/Detectors/GoogleMeetDetectorSpec.swift @@ -0,0 +1,125 @@ +import XCTest +import ScreenCaptureKit +import Mockable +@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 diff --git a/RecapTests/Services/MeetingDetection/Detectors/MockSCWindow.swift b/RecapTests/Services/MeetingDetection/Detectors/MockSCWindow.swift new file mode 100644 index 0000000..8abdd79 --- /dev/null +++ b/RecapTests/Services/MeetingDetection/Detectors/MockSCWindow.swift @@ -0,0 +1,12 @@ +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 diff --git a/RecapTests/Services/MeetingDetection/Detectors/TeamsMeetingDetectorSpec.swift b/RecapTests/Services/MeetingDetection/Detectors/TeamsMeetingDetectorSpec.swift new file mode 100644 index 0000000..f4a9d31 --- /dev/null +++ b/RecapTests/Services/MeetingDetection/Detectors/TeamsMeetingDetectorSpec.swift @@ -0,0 +1,113 @@ +import XCTest +import ScreenCaptureKit +import Mockable +@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 diff --git a/RecapTests/Services/MeetingDetection/Detectors/ZoomMeetingDetectorSpec.swift b/RecapTests/Services/MeetingDetection/Detectors/ZoomMeetingDetectorSpec.swift new file mode 100644 index 0000000..0bf3838 --- /dev/null +++ b/RecapTests/Services/MeetingDetection/Detectors/ZoomMeetingDetectorSpec.swift @@ -0,0 +1,120 @@ +import XCTest +import ScreenCaptureKit +import Mockable +@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 diff --git a/RecapTests/UseCases/Onboarding/ViewModels/OnboardingViewModelSpec.swift b/RecapTests/UseCases/Onboarding/ViewModels/OnboardingViewModelSpec.swift new file mode 100644 index 0000000..6f42cee --- /dev/null +++ b/RecapTests/UseCases/Onboarding/ViewModels/OnboardingViewModelSpec.swift @@ -0,0 +1,203 @@ +import XCTest +import Combine +import AVFoundation +import Mockable +@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) + } +} + +// MARK: - Mock Classes + +@MainActor +private class MockOnboardingDelegate: OnboardingDelegate { + var onboardingDidCompleteCalled = false + + func onboardingDidComplete() { + onboardingDidCompleteCalled = true + } +} + +private enum TestError: Error { + case mockError +} \ No newline at end of file From 6293d6e9913d63f94dbf921d333302ce2ac105b1 Mon Sep 17 00:00:00 2001 From: Rawand Ahmed Shaswar Date: Tue, 5 Aug 2025 23:54:14 +0300 Subject: [PATCH 2/7] relocating files --- .github/workflows/linter.yml | 3 +++ .github/workflows/pr-tests.yml | 1 + .../AudioRecordingCoordinator.swift | 4 ++-- .../Session/RecordingSessionManager.swift | 4 ++-- .../DependencyContainer+Coordinators.swift | 0 .../DependencyContainer+Helpers.swift | 2 +- .../DependencyContainer+Managers.swift | 0 .../DependencyContainer+Repositories.swift | 2 +- .../DependencyContainer+Services.swift | 12 ++++------ .../DependencyContainer+ViewModels.swift | 0 .../DependencyContainer.swift | 10 +++++++- .../Permissions/PermissionsHelperType.swift | 2 +- .../MenuBarPanelManager+Delegates.swift | 2 +- .../MenuBarPanelManager+Onboarding.swift | 3 +-- .../MenuBarPanelManager+PreviousRecaps.swift | 4 +--- .../MenuBarPanelManager+Settings.swift | 7 +++--- .../Manager/MenuBarPanelManager+Summary.swift | 2 +- .../MenuBar/Manager/MenuBarPanelManager.swift | 23 ++++++++++++++----- .../UserPreferencesRepositoryType.swift | 2 +- .../Core/MeetingDetectionServiceType.swift | 2 +- .../ViewModel/OnboardingViewModelType.swift | 2 +- Recap/UseCases/Summary/SummaryView.swift | 6 ++--- 22 files changed, 55 insertions(+), 38 deletions(-) rename Recap/{Services => }/DependencyContainer/DependencyContainer+Coordinators.swift (100%) rename Recap/{Services => }/DependencyContainer/DependencyContainer+Helpers.swift (98%) rename Recap/{Services => }/DependencyContainer/DependencyContainer+Managers.swift (100%) rename Recap/{Services => }/DependencyContainer/DependencyContainer+Repositories.swift (99%) rename Recap/{Services => }/DependencyContainer/DependencyContainer+Services.swift (71%) rename Recap/{Services => }/DependencyContainer/DependencyContainer+ViewModels.swift (100%) rename Recap/{Services => }/DependencyContainer/DependencyContainer.swift (88%) diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index ecf45b5..c083119 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -11,6 +11,7 @@ jobs: lint: name: SwiftLint runs-on: macos-15 + if: github.event.pull_request.draft == false steps: - name: Checkout code @@ -27,6 +28,7 @@ jobs: build: name: Build runs-on: macos-15 + if: github.event.pull_request.draft == false steps: - name: Checkout code @@ -75,6 +77,7 @@ jobs: name: Test runs-on: macos-15 needs: build + if: github.event.pull_request.draft == false steps: - name: Checkout code diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml index b296213..8a02d0b 100644 --- a/.github/workflows/pr-tests.yml +++ b/.github/workflows/pr-tests.yml @@ -12,6 +12,7 @@ jobs: test: name: Test PR runs-on: macos-15 + if: github.event.pull_request.draft == false steps: - name: Checkout code diff --git a/Recap/Audio/Processing/AudioRecordingCoordinator/AudioRecordingCoordinator.swift b/Recap/Audio/Processing/AudioRecordingCoordinator/AudioRecordingCoordinator.swift index 0ba6b0e..c6fd8b3 100644 --- a/Recap/Audio/Processing/AudioRecordingCoordinator/AudioRecordingCoordinator.swift +++ b/Recap/Audio/Processing/AudioRecordingCoordinator/AudioRecordingCoordinator.swift @@ -6,7 +6,7 @@ final class AudioRecordingCoordinator: AudioRecordingCoordinatorType { private let logger = Logger(subsystem: AppConstants.Logging.subsystem, category: String(describing: AudioRecordingCoordinator.self)) private let configuration: RecordingConfiguration - private let microphoneCapture: MicrophoneCapture? + private let microphoneCapture: MicrophoneCaptureType? private let processTap: ProcessTap private var isRunning = false @@ -14,7 +14,7 @@ final class AudioRecordingCoordinator: AudioRecordingCoordinatorType { init( configuration: RecordingConfiguration, - microphoneCapture: MicrophoneCapture?, + microphoneCapture: MicrophoneCaptureType?, processTap: ProcessTap ) { self.configuration = configuration diff --git a/Recap/Audio/Processing/Session/RecordingSessionManager.swift b/Recap/Audio/Processing/Session/RecordingSessionManager.swift index 0487867..c133091 100644 --- a/Recap/Audio/Processing/Session/RecordingSessionManager.swift +++ b/Recap/Audio/Processing/Session/RecordingSessionManager.swift @@ -7,10 +7,10 @@ protocol RecordingSessionManaging { final class RecordingSessionManager: RecordingSessionManaging { private let logger = Logger(subsystem: AppConstants.Logging.subsystem, category: String(describing: RecordingSessionManager.self)) - private let microphoneCapture: MicrophoneCapture + private let microphoneCapture: MicrophoneCaptureType private let permissionsHelper: PermissionsHelperType - init(microphoneCapture: MicrophoneCapture, permissionsHelper: PermissionsHelperType) { + init(microphoneCapture: MicrophoneCaptureType, permissionsHelper: PermissionsHelperType) { self.microphoneCapture = microphoneCapture self.permissionsHelper = permissionsHelper } diff --git a/Recap/Services/DependencyContainer/DependencyContainer+Coordinators.swift b/Recap/DependencyContainer/DependencyContainer+Coordinators.swift similarity index 100% rename from Recap/Services/DependencyContainer/DependencyContainer+Coordinators.swift rename to Recap/DependencyContainer/DependencyContainer+Coordinators.swift diff --git a/Recap/Services/DependencyContainer/DependencyContainer+Helpers.swift b/Recap/DependencyContainer/DependencyContainer+Helpers.swift similarity index 98% rename from Recap/Services/DependencyContainer/DependencyContainer+Helpers.swift rename to Recap/DependencyContainer/DependencyContainer+Helpers.swift index 6204eca..fc6fcdf 100644 --- a/Recap/Services/DependencyContainer/DependencyContainer+Helpers.swift +++ b/Recap/DependencyContainer/DependencyContainer+Helpers.swift @@ -5,4 +5,4 @@ extension DependencyContainer { func makePermissionsHelper() -> PermissionsHelperType { PermissionsHelper() } -} \ No newline at end of file +} diff --git a/Recap/Services/DependencyContainer/DependencyContainer+Managers.swift b/Recap/DependencyContainer/DependencyContainer+Managers.swift similarity index 100% rename from Recap/Services/DependencyContainer/DependencyContainer+Managers.swift rename to Recap/DependencyContainer/DependencyContainer+Managers.swift diff --git a/Recap/Services/DependencyContainer/DependencyContainer+Repositories.swift b/Recap/DependencyContainer/DependencyContainer+Repositories.swift similarity index 99% rename from Recap/Services/DependencyContainer/DependencyContainer+Repositories.swift rename to Recap/DependencyContainer/DependencyContainer+Repositories.swift index 918a22b..f584108 100644 --- a/Recap/Services/DependencyContainer/DependencyContainer+Repositories.swift +++ b/Recap/DependencyContainer/DependencyContainer+Repositories.swift @@ -17,4 +17,4 @@ extension DependencyContainer { func makeUserPreferencesRepository() -> UserPreferencesRepositoryType { UserPreferencesRepository(coreDataManager: coreDataManager) } -} \ No newline at end of file +} diff --git a/Recap/Services/DependencyContainer/DependencyContainer+Services.swift b/Recap/DependencyContainer/DependencyContainer+Services.swift similarity index 71% rename from Recap/Services/DependencyContainer/DependencyContainer+Services.swift rename to Recap/DependencyContainer/DependencyContainer+Services.swift index 2d087c2..2669d8a 100644 --- a/Recap/Services/DependencyContainer/DependencyContainer+Services.swift +++ b/Recap/DependencyContainer/DependencyContainer+Services.swift @@ -17,7 +17,7 @@ extension DependencyContainer { TranscriptionService(whisperModelRepository: whisperModelRepository) } - func makeMeetingDetectionService() -> MeetingDetectionServiceType { + func makeMeetingDetectionService() -> any MeetingDetectionServiceType { MeetingDetectionService(audioProcessController: audioProcessController) } @@ -26,17 +26,15 @@ extension DependencyContainer { } func makeRecordingSessionManager() -> RecordingSessionManaging { - guard let micCapture = microphoneCapture as? MicrophoneCapture else { - fatalError("microphoneCapture is not of type MicrophoneCapture") - } - return RecordingSessionManager(microphoneCapture: micCapture, permissionsHelper: makePermissionsHelper()) + RecordingSessionManager(microphoneCapture: microphoneCapture, + permissionsHelper: makePermissionsHelper()) } - func makeMicrophoneCapture() -> MicrophoneCaptureType { + func makeMicrophoneCapture() -> any MicrophoneCaptureType { MicrophoneCapture() } func makeNotificationService() -> NotificationServiceType { NotificationService() } -} \ No newline at end of file +} diff --git a/Recap/Services/DependencyContainer/DependencyContainer+ViewModels.swift b/Recap/DependencyContainer/DependencyContainer+ViewModels.swift similarity index 100% rename from Recap/Services/DependencyContainer/DependencyContainer+ViewModels.swift rename to Recap/DependencyContainer/DependencyContainer+ViewModels.swift diff --git a/Recap/Services/DependencyContainer/DependencyContainer.swift b/Recap/DependencyContainer/DependencyContainer.swift similarity index 88% rename from Recap/Services/DependencyContainer/DependencyContainer.swift rename to Recap/DependencyContainer/DependencyContainer.swift index 8fb841a..7fa4789 100644 --- a/Recap/Services/DependencyContainer/DependencyContainer.swift +++ b/Recap/DependencyContainer/DependencyContainer.swift @@ -20,6 +20,9 @@ final class DependencyContainer { 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() @@ -46,7 +49,12 @@ final class DependencyContainer { audioProcessController: audioProcessController, appSelectionViewModel: appSelectionViewModel, previousRecapsViewModel: previousRecapsViewModel, - dependencyContainer: self + recapViewModel: recapViewModel, + onboardingViewModel: onboardingViewModel, + summaryViewModel: summaryViewModel, + generalSettingsViewModel: generalSettingsViewModel, + userPreferencesRepository: userPreferencesRepository, + meetingDetectionService: meetingDetectionService ) } diff --git a/Recap/Helpers/Permissions/PermissionsHelperType.swift b/Recap/Helpers/Permissions/PermissionsHelperType.swift index e6f1d2c..18f9526 100644 --- a/Recap/Helpers/Permissions/PermissionsHelperType.swift +++ b/Recap/Helpers/Permissions/PermissionsHelperType.swift @@ -15,4 +15,4 @@ protocol PermissionsHelperType: AnyObject { func checkMicrophonePermissionStatus() -> AVAuthorizationStatus func checkNotificationPermissionStatus() async -> Bool func checkScreenRecordingPermission() -> Bool -} \ No newline at end of file +} diff --git a/Recap/MenuBar/Manager/MenuBarPanelManager+Delegates.swift b/Recap/MenuBar/Manager/MenuBarPanelManager+Delegates.swift index 2157ab0..bd52227 100644 --- a/Recap/MenuBar/Manager/MenuBarPanelManager+Delegates.swift +++ b/Recap/MenuBar/Manager/MenuBarPanelManager+Delegates.swift @@ -38,4 +38,4 @@ extension MenuBarPanelManager: OnboardingDelegate { } } } -} \ No newline at end of file +} diff --git a/Recap/MenuBar/Manager/MenuBarPanelManager+Onboarding.swift b/Recap/MenuBar/Manager/MenuBarPanelManager+Onboarding.swift index 7e1d140..4bb27a6 100644 --- a/Recap/MenuBar/Manager/MenuBarPanelManager+Onboarding.swift +++ b/Recap/MenuBar/Manager/MenuBarPanelManager+Onboarding.swift @@ -4,9 +4,8 @@ import AppKit extension MenuBarPanelManager { @MainActor func createOnboardingPanel() -> SlidingPanel { - let onboardingViewModel = dependencyContainer.makeOnboardingViewModel() onboardingViewModel.delegate = self - let contentView = OnboardingView(viewModel: onboardingViewModel) + let contentView = OnboardingView(viewModel: onboardingViewModel) let hostingController = NSHostingController(rootView: contentView) hostingController.view.wantsLayer = true hostingController.view.layer?.cornerRadius = 12 diff --git a/Recap/MenuBar/Manager/MenuBarPanelManager+PreviousRecaps.swift b/Recap/MenuBar/Manager/MenuBarPanelManager+PreviousRecaps.swift index 906dc01..e7eed5b 100644 --- a/Recap/MenuBar/Manager/MenuBarPanelManager+PreviousRecaps.swift +++ b/Recap/MenuBar/Manager/MenuBarPanelManager+PreviousRecaps.swift @@ -10,11 +10,9 @@ extension MenuBarPanelManager { guard let statusButton = statusBarManager.statusButton, let windowManager = previousRecapsWindowManager else { return } - let viewModel = previousRecapsViewModel - windowManager.showRecapsWindow( relativeTo: statusButton, - viewModel: viewModel, + viewModel: previousRecapsViewModel, onRecordingSelected: { [weak self] recording in self?.handleRecordingSelection(recording) }, diff --git a/Recap/MenuBar/Manager/MenuBarPanelManager+Settings.swift b/Recap/MenuBar/Manager/MenuBarPanelManager+Settings.swift index 00fae28..4117701 100644 --- a/Recap/MenuBar/Manager/MenuBarPanelManager+Settings.swift +++ b/Recap/MenuBar/Manager/MenuBarPanelManager+Settings.swift @@ -3,12 +3,11 @@ import AppKit extension MenuBarPanelManager { func createSettingsPanel() -> SlidingPanel? { - let generalSettingsViewModel = dependencyContainer.createGeneralSettingsViewModel() - let contentView = SettingsView( + let contentView = SettingsView( whisperModelsViewModel: whisperModelsViewModel, generalSettingsViewModel: generalSettingsViewModel, - meetingDetectionService: dependencyContainer.meetingDetectionService, - userPreferencesRepository: dependencyContainer.userPreferencesRepository + meetingDetectionService: meetingDetectionService, + userPreferencesRepository: userPreferencesRepository ) { [weak self] in self?.hideSettingsPanel() } diff --git a/Recap/MenuBar/Manager/MenuBarPanelManager+Summary.swift b/Recap/MenuBar/Manager/MenuBarPanelManager+Summary.swift index edebd16..284546a 100644 --- a/Recap/MenuBar/Manager/MenuBarPanelManager+Summary.swift +++ b/Recap/MenuBar/Manager/MenuBarPanelManager+Summary.swift @@ -7,7 +7,7 @@ extension MenuBarPanelManager { onClose: { [weak self] in self?.hideSummaryPanel() }, - viewModel: dependencyContainer.createSummaryViewModel(), + viewModel: summaryViewModel, recordingID: recordingID ) let hostingController = NSHostingController(rootView: contentView) diff --git a/Recap/MenuBar/Manager/MenuBarPanelManager.swift b/Recap/MenuBar/Manager/MenuBarPanelManager.swift index 57434ad..2e1b7d3 100644 --- a/Recap/MenuBar/Manager/MenuBarPanelManager.swift +++ b/Recap/MenuBar/Manager/MenuBarPanelManager.swift @@ -24,8 +24,12 @@ final class MenuBarPanelManager: MenuBarPanelManagerType, ObservableObject { 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 dependencyContainer: DependencyContainer + let meetingDetectionService: any MeetingDetectionServiceType init( statusBarManager: StatusBarManagerType, @@ -34,15 +38,23 @@ final class MenuBarPanelManager: MenuBarPanelManagerType, ObservableObject { audioProcessController: AudioProcessController, appSelectionViewModel: AppSelectionViewModel, previousRecapsViewModel: PreviousRecapsViewModel, + recapViewModel: RecapViewModel, + onboardingViewModel: OnboardingViewModel, + summaryViewModel: SummaryViewModel, + generalSettingsViewModel: GeneralSettingsViewModel, userPreferencesRepository: UserPreferencesRepositoryType, - dependencyContainer: DependencyContainer + 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.dependencyContainer = dependencyContainer + self.meetingDetectionService = meetingDetectionService self.previousRecapsViewModel = previousRecapsViewModel setupDelegates() } @@ -52,9 +64,8 @@ final class MenuBarPanelManager: MenuBarPanelManagerType, ObservableObject { } func createMainPanel() -> SlidingPanel { - let viewModel = dependencyContainer.createRecapViewModel() - viewModel.delegate = self - let contentView = RecapHomeView(viewModel: viewModel) + recapViewModel.delegate = self + let contentView = RecapHomeView(viewModel: recapViewModel) let hostingController = NSHostingController(rootView: contentView) hostingController.view.wantsLayer = true hostingController.view.layer?.cornerRadius = 12 diff --git a/Recap/Repositories/UserPreferences/UserPreferencesRepositoryType.swift b/Recap/Repositories/UserPreferences/UserPreferencesRepositoryType.swift index 97bd6b7..e87ef01 100644 --- a/Recap/Repositories/UserPreferences/UserPreferencesRepositoryType.swift +++ b/Recap/Repositories/UserPreferences/UserPreferencesRepositoryType.swift @@ -16,4 +16,4 @@ protocol UserPreferencesRepositoryType { func updateAutoSummarize(_ enabled: Bool) async throws func updateSummaryPromptTemplate(_ template: String?) async throws func updateOnboardingStatus(_ completed: Bool) async throws -} \ No newline at end of file +} diff --git a/Recap/Services/MeetingDetection/Core/MeetingDetectionServiceType.swift b/Recap/Services/MeetingDetection/Core/MeetingDetectionServiceType.swift index cb0cdc2..ecf23a8 100644 --- a/Recap/Services/MeetingDetection/Core/MeetingDetectionServiceType.swift +++ b/Recap/Services/MeetingDetection/Core/MeetingDetectionServiceType.swift @@ -42,4 +42,4 @@ enum MeetingState: Equatable { return false } } -} \ No newline at end of file +} diff --git a/Recap/UseCases/Onboarding/ViewModel/OnboardingViewModelType.swift b/Recap/UseCases/Onboarding/ViewModel/OnboardingViewModelType.swift index 447dfd6..7a8ac1a 100644 --- a/Recap/UseCases/Onboarding/ViewModel/OnboardingViewModelType.swift +++ b/Recap/UseCases/Onboarding/ViewModel/OnboardingViewModelType.swift @@ -12,7 +12,7 @@ protocol OnboardingViewModelType: ObservableObject { var isAutoSummarizeEnabled: Bool { get } var isLiveTranscriptionEnabled: Bool { get } var hasRequiredPermissions: Bool { get } - var showErrorToast: Bool { get } + var showErrorToast: Bool { get set } var errorMessage: String { get } var canContinue: Bool { get } var delegate: OnboardingDelegate? { get set } diff --git a/Recap/UseCases/Summary/SummaryView.swift b/Recap/UseCases/Summary/SummaryView.swift index fb4e3a0..41f790d 100644 --- a/Recap/UseCases/Summary/SummaryView.swift +++ b/Recap/UseCases/Summary/SummaryView.swift @@ -1,14 +1,14 @@ import SwiftUI import MarkdownUI -struct SummaryView: View { +struct SummaryView: View { let onClose: () -> Void - @ObservedObject var viewModel: SummaryViewModel + @ObservedObject var viewModel: ViewModel let recordingID: String? init( onClose: @escaping () -> Void, - viewModel: SummaryViewModel, + viewModel: ViewModel, recordingID: String? = nil ) { self.onClose = onClose From 4dbabaad5b2f489be700aab636b56f589c958c64 Mon Sep 17 00:00:00 2001 From: Rawand Ahmed Shaswar Date: Wed, 6 Aug 2025 23:25:50 +0300 Subject: [PATCH 3/7] Add Keychain Service --- Recap.xcodeproj/project.pbxproj | 128 ++---- Recap.xctestplan | 36 ++ .../DependencyContainer+Services.swift | 10 +- .../DependencyContainer+ViewModels.swift | 6 +- .../DependencyContainer.swift | 5 +- .../Permissions/PermissionsHelper.swift | 9 + .../Permissions/PermissionsHelperType.swift | 1 + .../Keychain/KeychainAPIValidator.swift | 31 ++ .../Keychain/KeychainAPIValidatorType.swift | 37 ++ .../Keychain/KeychainService+Extensions.swift | 19 + Recap/Services/Keychain/KeychainService.swift | 115 +++++ .../Keychain/KeychainServiceType.swift | 42 ++ Recap/Services/LLM/LLMServiceType.swift | 6 + .../Core/MeetingDetectionService.swift | 15 +- .../Core/MeetingDetectionServiceType.swift | 1 - .../Validation/EnvironmentValidator.swift | 18 - .../Validation/EnvironmentValidatorType.swift | 28 -- .../Warnings/WarningManagerType.swift | 6 + Recap/UIComponents/Alerts/CenteredAlert.swift | 89 ++++ .../RecapViewModel+MeetingDetection.swift | 2 +- .../Home/ViewModel/RecapViewModel.swift | 5 +- .../Onboarding/View/OnboardingView.swift | 10 +- .../MeetingDetectionSettingsCard.swift | 118 ----- .../MeetingDetectionView.swift | 15 +- .../Components/OpenRouterAPIKeyAlert.swift | 146 ++++++ .../Reusable/CustomPasswordField.swift | 101 +++++ .../TabViews/GeneralSettingsView.swift | 32 ++ Recap/UseCases/Settings/SettingsView.swift | 16 +- .../General/GeneralSettingsViewModel.swift | 45 +- .../GeneralSettingsViewModelType.swift | 4 + .../MeetingDetectionSettingsViewModel.swift | 9 +- .../UserPreferencesInfo+TestHelpers.swift | 30 ++ .../MeetingDetectionServiceSpec.swift | 3 +- .../GeneralSettingsViewModelSpec.swift | 419 ++++++++++++++++++ ...eetingDetectionSettingsViewModelSpec.swift | 242 ++++++++++ 35 files changed, 1484 insertions(+), 315 deletions(-) create mode 100644 Recap.xctestplan create mode 100644 Recap/Services/Keychain/KeychainAPIValidator.swift create mode 100644 Recap/Services/Keychain/KeychainAPIValidatorType.swift create mode 100644 Recap/Services/Keychain/KeychainService+Extensions.swift create mode 100644 Recap/Services/Keychain/KeychainService.swift create mode 100644 Recap/Services/Keychain/KeychainServiceType.swift delete mode 100644 Recap/Services/Utilities/Validation/EnvironmentValidator.swift delete mode 100644 Recap/Services/Utilities/Validation/EnvironmentValidatorType.swift create mode 100644 Recap/UIComponents/Alerts/CenteredAlert.swift delete mode 100644 Recap/UseCases/Settings/Components/MeetingDetection/MeetingDetectionSettingsCard.swift create mode 100644 Recap/UseCases/Settings/Components/OpenRouterAPIKeyAlert.swift create mode 100644 Recap/UseCases/Settings/Components/Reusable/CustomPasswordField.swift create mode 100644 RecapTests/Helpers/UserPreferencesInfo+TestHelpers.swift create mode 100644 RecapTests/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelSpec.swift create mode 100644 RecapTests/UseCases/Settings/ViewModels/MeetingDetection/MeetingDetectionSettingsViewModelSpec.swift diff --git a/Recap.xcodeproj/project.pbxproj b/Recap.xcodeproj/project.pbxproj index e44566a..7982d3e 100644 --- a/Recap.xcodeproj/project.pbxproj +++ b/Recap.xcodeproj/project.pbxproj @@ -23,19 +23,11 @@ remoteGlobalIDString = A72106512E3016590073C515; remoteInfo = Recap; }; - A721066B2E30165B0073C515 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = A721064A2E3016590073C515 /* Project object */; - proxyType = 1; - remoteGlobalIDString = A72106512E3016590073C515; - remoteInfo = Recap; - }; /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ A72106522E3016590073C515 /* Recap.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Recap.app; sourceTree = BUILT_PRODUCTS_DIR; }; A72106602E30165B0073C515 /* RecapTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RecapTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - A721066A2E30165B0073C515 /* RecapUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RecapUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -46,12 +38,16 @@ Audio/Models/AudioProcessGroup.swift, Audio/Processing/Detection/AudioProcessControllerType.swift, DataModels/RecapDataModel.xcdatamodeld, + Helpers/Availability/AvailabilityHelper.swift, "Helpers/Colors/Color+Extension.swift", Helpers/Constants/AppConstants.swift, Helpers/Constants/UIConstants.swift, Helpers/MeetingDetection/MeetingPatternMatcher.swift, Helpers/Permissions/PermissionsHelper.swift, Helpers/Permissions/PermissionsHelperType.swift, + Repositories/LLMModels/LLMModelRepository.swift, + Repositories/LLMModels/LLMModelRepositoryType.swift, + Repositories/Models/LLMModelInfo.swift, Repositories/Models/LLMProvider.swift, Repositories/Models/RecordingInfo.swift, Repositories/Models/UserPreferencesInfo.swift, @@ -60,7 +56,24 @@ Repositories/UserPreferences/UserPreferencesRepository.swift, Repositories/UserPreferences/UserPreferencesRepositoryType.swift, Services/CoreData/CoreDataManagerType.swift, + Services/Keychain/KeychainAPIValidator.swift, + Services/Keychain/KeychainAPIValidatorType.swift, + Services/Keychain/KeychainService.swift, + "Services/Keychain/KeychainService+Extensions.swift", + Services/Keychain/KeychainServiceType.swift, Services/LLM/Core/LLMError.swift, + Services/LLM/Core/LLMModelType.swift, + Services/LLM/Core/LLMOptions.swift, + Services/LLM/Core/LLMProviderType.swift, + Services/LLM/Core/LLMTaskManageable.swift, + Services/LLM/LLMService.swift, + Services/LLM/LLMServiceType.swift, + Services/LLM/Providers/Ollama/OllamaAPIClient.swift, + Services/LLM/Providers/Ollama/OllamaModel.swift, + Services/LLM/Providers/Ollama/OllamaProvider.swift, + Services/LLM/Providers/OpenRouter/OpenRouterAPIClient.swift, + Services/LLM/Providers/OpenRouter/OpenRouterModel.swift, + Services/LLM/Providers/OpenRouter/OpenRouterProvider.swift, Services/MeetingDetection/Core/MeetingDetectionService.swift, Services/MeetingDetection/Core/MeetingDetectionServiceType.swift, Services/MeetingDetection/Detectors/GoogleMeetDetector.swift, @@ -78,6 +91,8 @@ Services/Summarization/Models/SummarizationResult.swift, Services/Summarization/SummarizationServiceType.swift, Services/Transcription/TranscriptionServiceType.swift, + Services/Utilities/Warnings/ProviderWarningCoordinator.swift, + Services/Utilities/Warnings/WarningManager.swift, Services/Utilities/Warnings/WarningManagerType.swift, UIComponents/Buttons/PillButton.swift, UIComponents/Cards/ActionableWarningCard.swift, @@ -86,6 +101,8 @@ UseCases/Settings/Components/MeetingDetection/MeetingDetectionView.swift, UseCases/Settings/Components/Reusable/CustomToggle.swift, UseCases/Settings/Components/SettingsCard.swift, + UseCases/Settings/ViewModels/General/GeneralSettingsViewModel.swift, + UseCases/Settings/ViewModels/General/GeneralSettingsViewModelType.swift, UseCases/Settings/ViewModels/MeetingDetection/MeetingDetectionSettingsViewModel.swift, UseCases/Settings/ViewModels/MeetingDetection/MeetingDetectionSettingsViewModelType.swift, UseCases/Summary/Components/ProcessingProgressBar.swift, @@ -134,13 +151,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - A72106672E30165B0073C515 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -158,7 +168,6 @@ children = ( A72106522E3016590073C515 /* Recap.app */, A72106602E30165B0073C515 /* RecapTests.xctest */, - A721066A2E30165B0073C515 /* RecapUITests.xctest */, ); name = Products; sourceTree = ""; @@ -217,26 +226,6 @@ productReference = A72106602E30165B0073C515 /* RecapTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; - A72106692E30165B0073C515 /* RecapUITests */ = { - isa = PBXNativeTarget; - buildConfigurationList = A721067A2E30165B0073C515 /* Build configuration list for PBXNativeTarget "RecapUITests" */; - buildPhases = ( - A72106662E30165B0073C515 /* Sources */, - A72106672E30165B0073C515 /* Frameworks */, - A72106682E30165B0073C515 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - A721066C2E30165B0073C515 /* PBXTargetDependency */, - ); - name = RecapUITests; - packageProductDependencies = ( - ); - productName = RecapUITests; - productReference = A721066A2E30165B0073C515 /* RecapUITests.xctest */; - productType = "com.apple.product-type.bundle.ui-testing"; - }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -254,10 +243,6 @@ CreatedOnToolsVersion = 16.4; TestTargetID = A72106512E3016590073C515; }; - A72106692E30165B0073C515 = { - CreatedOnToolsVersion = 16.4; - TestTargetID = A72106512E3016590073C515; - }; }; }; buildConfigurationList = A721064D2E3016590073C515 /* Build configuration list for PBXProject "Recap" */; @@ -282,7 +267,6 @@ targets = ( A72106512E3016590073C515 /* Recap */, A721065F2E30165B0073C515 /* RecapTests */, - A72106692E30165B0073C515 /* RecapUITests */, ); }; /* End PBXProject section */ @@ -302,13 +286,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - A72106682E30165B0073C515 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -326,13 +303,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - A72106662E30165B0073C515 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -341,11 +311,6 @@ target = A72106512E3016590073C515 /* Recap */; targetProxy = A72106612E30165B0073C515 /* PBXContainerItemProxy */; }; - A721066C2E30165B0073C515 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = A72106512E3016590073C515 /* Recap */; - targetProxy = A721066B2E30165B0073C515 /* PBXContainerItemProxy */; - }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -571,40 +536,6 @@ }; name = Release; }; - A721067B2E30165B0073C515 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = EY7EQX6JC5; - GENERATE_INFOPLIST_FILE = YES; - MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = dev.rawa.RecapUITests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TEST_TARGET_NAME = Recap; - }; - name = Debug; - }; - A721067C2E30165B0073C515 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = EY7EQX6JC5; - GENERATE_INFOPLIST_FILE = YES; - MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = dev.rawa.RecapUITests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TEST_TARGET_NAME = Recap; - }; - name = Release; - }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -635,15 +566,6 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - A721067A2E30165B0073C515 /* Build configuration list for PBXNativeTarget "RecapUITests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - A721067B2E30165B0073C515 /* Debug */, - A721067C2E30165B0073C515 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/Recap.xctestplan b/Recap.xctestplan new file mode 100644 index 0000000..20fb629 --- /dev/null +++ b/Recap.xctestplan @@ -0,0 +1,36 @@ +{ + "configurations" : [ + { + "id" : "16849F79-ACBC-4890-8336-EF7543A98E8A", + "name" : "Test Scheme Action", + "options" : { + + } + } + ], + "defaultOptions" : { + "targetForVariableExpansion" : { + "containerPath" : "container:Recap.xcodeproj", + "identifier" : "A72106512E3016590073C515", + "name" : "Recap" + } + }, + "testTargets" : [ + { + "parallelizable" : true, + "target" : { + "containerPath" : "container:Recap.xcodeproj", + "identifier" : "A721065F2E30165B0073C515", + "name" : "RecapTests" + } + }, + { + "parallelizable" : true, + "target" : { + "containerPath" : "container:Recap.xcodeproj", + "identifier" : "A72106692E30165B0073C515", + "name" : "RecapUITests" + } + ], + "version" : 1 +} diff --git a/Recap/DependencyContainer/DependencyContainer+Services.swift b/Recap/DependencyContainer/DependencyContainer+Services.swift index 2669d8a..44fc223 100644 --- a/Recap/DependencyContainer/DependencyContainer+Services.swift +++ b/Recap/DependencyContainer/DependencyContainer+Services.swift @@ -18,7 +18,7 @@ extension DependencyContainer { } func makeMeetingDetectionService() -> any MeetingDetectionServiceType { - MeetingDetectionService(audioProcessController: audioProcessController) + MeetingDetectionService(audioProcessController: audioProcessController, permissionsHelper: makePermissionsHelper()) } func makeMeetingAppDetectionService() -> MeetingAppDetecting { @@ -37,4 +37,12 @@ extension DependencyContainer { 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 d121b22..2562622 100644 --- a/Recap/DependencyContainer/DependencyContainer+ViewModels.swift +++ b/Recap/DependencyContainer/DependencyContainer+ViewModels.swift @@ -18,7 +18,8 @@ extension DependencyContainer { GeneralSettingsViewModel( llmService: llmService, userPreferencesRepository: userPreferencesRepository, - environmentValidator: EnvironmentValidator(), + keychainAPIValidator: keychainAPIValidator, + keychainService: keychainService, warningManager: warningManager ) } @@ -26,7 +27,8 @@ extension DependencyContainer { func makeMeetingDetectionSettingsViewModel() -> MeetingDetectionSettingsViewModel { MeetingDetectionSettingsViewModel( detectionService: meetingDetectionService, - userPreferencesRepository: userPreferencesRepository + userPreferencesRepository: userPreferencesRepository, + permissionsHelper: makePermissionsHelper() ) } diff --git a/Recap/DependencyContainer/DependencyContainer.swift b/Recap/DependencyContainer/DependencyContainer.swift index 7fa4789..bc5609b 100644 --- a/Recap/DependencyContainer/DependencyContainer.swift +++ b/Recap/DependencyContainer/DependencyContainer.swift @@ -32,6 +32,8 @@ final class DependencyContainer { 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 @@ -69,7 +71,8 @@ final class DependencyContainer { meetingDetectionService: meetingDetectionService, userPreferencesRepository: userPreferencesRepository, notificationService: notificationService, - appSelectionCoordinator: appSelectionCoordinator + appSelectionCoordinator: appSelectionCoordinator, + permissionsHelper: makePermissionsHelper() ) } diff --git a/Recap/Helpers/Permissions/PermissionsHelper.swift b/Recap/Helpers/Permissions/PermissionsHelper.swift index 2ed8698..f8346ee 100644 --- a/Recap/Helpers/Permissions/PermissionsHelper.swift +++ b/Recap/Helpers/Permissions/PermissionsHelper.swift @@ -51,4 +51,13 @@ final class PermissionsHelper: PermissionsHelperType { return true } } + + func checkScreenCapturePermission() async -> Bool { + do { + let _ = try await SCShareableContent.current + return true + } catch { + return false + } + } } diff --git a/Recap/Helpers/Permissions/PermissionsHelperType.swift b/Recap/Helpers/Permissions/PermissionsHelperType.swift index 18f9526..2702347 100644 --- a/Recap/Helpers/Permissions/PermissionsHelperType.swift +++ b/Recap/Helpers/Permissions/PermissionsHelperType.swift @@ -15,4 +15,5 @@ protocol PermissionsHelperType: AnyObject { func checkMicrophonePermissionStatus() -> AVAuthorizationStatus func checkNotificationPermissionStatus() async -> Bool func checkScreenRecordingPermission() -> Bool + func checkScreenCapturePermission() async -> Bool } diff --git a/Recap/Services/Keychain/KeychainAPIValidator.swift b/Recap/Services/Keychain/KeychainAPIValidator.swift new file mode 100644 index 0000000..ceea460 --- /dev/null +++ b/Recap/Services/Keychain/KeychainAPIValidator.swift @@ -0,0 +1,31 @@ +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 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 new file mode 100644 index 0000000..ec2e574 --- /dev/null +++ b/Recap/Services/Keychain/KeychainAPIValidatorType.swift @@ -0,0 +1,37 @@ +import Foundation +#if MOCKING +import Mockable +#endif + +#if MOCKING +@Mockable +#endif +protocol KeychainAPIValidatorType { + func validateOpenRouterAPI() -> APIValidationResult +} + +enum APIValidationResult { + 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." + } + } +} diff --git a/Recap/Services/Keychain/KeychainService+Extensions.swift b/Recap/Services/Keychain/KeychainService+Extensions.swift new file mode 100644 index 0000000..d96dd66 --- /dev/null +++ b/Recap/Services/Keychain/KeychainService+Extensions.swift @@ -0,0 +1,19 @@ +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) + } +} diff --git a/Recap/Services/Keychain/KeychainService.swift b/Recap/Services/Keychain/KeychainService.swift new file mode 100644 index 0000000..20fbfd6 --- /dev/null +++ b/Recap/Services/Keychain/KeychainService.swift @@ -0,0 +1,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 + } + + 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) + } + } + + 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 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 + } + + 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 new file mode 100644 index 0000000..d6cab91 --- /dev/null +++ b/Recap/Services/Keychain/KeychainServiceType.swift @@ -0,0 +1,42 @@ +import Foundation +#if MOCKING +import Mockable +#endif + +#if MOCKING +@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 +} + +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)" + } + } +} + +enum KeychainKey: String, CaseIterable { + case openRouterApiKey = "openrouter_api_key" + + var key: String { + return "com.recap.\(rawValue)" + } +} diff --git a/Recap/Services/LLM/LLMServiceType.swift b/Recap/Services/LLM/LLMServiceType.swift index 4bec26e..f8a4604 100644 --- a/Recap/Services/LLM/LLMServiceType.swift +++ b/Recap/Services/LLM/LLMServiceType.swift @@ -1,7 +1,13 @@ import Foundation import Combine +#if MOCKING +import Mockable +#endif @MainActor +#if MOCKING +@Mockable +#endif protocol LLMServiceType: AnyObject { var currentProvider: (any LLMProviderType)? { get } var availableProviders: [any LLMProviderType] { get } diff --git a/Recap/Services/MeetingDetection/Core/MeetingDetectionService.swift b/Recap/Services/MeetingDetection/Core/MeetingDetectionService.swift index e9bc0ba..abc1ad4 100644 --- a/Recap/Services/MeetingDetection/Core/MeetingDetectionService.swift +++ b/Recap/Services/MeetingDetection/Core/MeetingDetectionService.swift @@ -34,9 +34,11 @@ final class MeetingDetectionService: MeetingDetectionServiceType { 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) { + init(audioProcessController: any AudioProcessControllerType, permissionsHelper: any PermissionsHelperType) { self.audioProcessController = audioProcessController + self.permissionsHelper = permissionsHelper setupDetectors() } @@ -122,17 +124,6 @@ final class MeetingDetectionService: MeetingDetectionServiceType { } } - func checkPermission() async -> Bool { - do { - _ = try await SCShareableContent.current - hasPermission = true - return true - } catch { - hasPermission = false - logger.warning("Screen recording permission denied: \(error.localizedDescription)") - return false - } - } private func findMatchingAudioProcess(bundleIdentifiers: Set) -> AudioProcess? { audioProcessController.processes.first { process in diff --git a/Recap/Services/MeetingDetection/Core/MeetingDetectionServiceType.swift b/Recap/Services/MeetingDetection/Core/MeetingDetectionServiceType.swift index ecf23a8..4a79c61 100644 --- a/Recap/Services/MeetingDetection/Core/MeetingDetectionServiceType.swift +++ b/Recap/Services/MeetingDetection/Core/MeetingDetectionServiceType.swift @@ -19,7 +19,6 @@ protocol MeetingDetectionServiceType: ObservableObject { func startMonitoring() func stopMonitoring() - func checkPermission() async -> Bool } struct ActiveMeetingInfo { diff --git a/Recap/Services/Utilities/Validation/EnvironmentValidator.swift b/Recap/Services/Utilities/Validation/EnvironmentValidator.swift deleted file mode 100644 index 68bab8e..0000000 --- a/Recap/Services/Utilities/Validation/EnvironmentValidator.swift +++ /dev/null @@ -1,18 +0,0 @@ -import Foundation - -final class EnvironmentValidator: EnvironmentValidatorType { - private let processInfo: ProcessInfo - - init(processInfo: ProcessInfo = .processInfo) { - self.processInfo = processInfo - } - - func validateOpenRouterEnvironment() -> ValidationResult { - guard let apiKey = processInfo.environment["OPENROUTER_API_KEY"], - !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - return .missingApiKey - } - - return .valid - } -} \ No newline at end of file diff --git a/Recap/Services/Utilities/Validation/EnvironmentValidatorType.swift b/Recap/Services/Utilities/Validation/EnvironmentValidatorType.swift deleted file mode 100644 index b8bd95c..0000000 --- a/Recap/Services/Utilities/Validation/EnvironmentValidatorType.swift +++ /dev/null @@ -1,28 +0,0 @@ -import Foundation - -protocol EnvironmentValidatorType { - func validateOpenRouterEnvironment() -> ValidationResult -} - -enum ValidationResult { - case valid - case missingApiKey - - var isValid: Bool { - switch self { - case .valid: - return true - case .missingApiKey: - return false - } - } - - var errorMessage: String? { - switch self { - case .valid: - return nil - case .missingApiKey: - return "OpenRouter API key not found. Please set OPENROUTER_API_KEY environment variable." - } - } -} \ No newline at end of file diff --git a/Recap/Services/Utilities/Warnings/WarningManagerType.swift b/Recap/Services/Utilities/Warnings/WarningManagerType.swift index 7ea4026..e8da102 100644 --- a/Recap/Services/Utilities/Warnings/WarningManagerType.swift +++ b/Recap/Services/Utilities/Warnings/WarningManagerType.swift @@ -1,6 +1,12 @@ import Foundation import Combine +#if MOCKING +import Mockable +#endif +#if MOCKING +@Mockable +#endif protocol WarningManagerType: ObservableObject { var activeWarnings: [WarningItem] { get } var activeWarningsPublisher: AnyPublisher<[WarningItem], Never> { get } diff --git a/Recap/UIComponents/Alerts/CenteredAlert.swift b/Recap/UIComponents/Alerts/CenteredAlert.swift new file mode 100644 index 0000000..d41517c --- /dev/null +++ b/Recap/UIComponents/Alerts/CenteredAlert.swift @@ -0,0 +1,89 @@ +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) + ) + ) + } + + 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) + } + } + } + .frame(width: 600, height: 400) + .background(Color.black) +} diff --git a/Recap/UseCases/Home/ViewModel/RecapViewModel+MeetingDetection.swift b/Recap/UseCases/Home/ViewModel/RecapViewModel+MeetingDetection.swift index 07a791a..4def952 100644 --- a/Recap/UseCases/Home/ViewModel/RecapViewModel+MeetingDetection.swift +++ b/Recap/UseCases/Home/ViewModel/RecapViewModel+MeetingDetection.swift @@ -36,7 +36,7 @@ private extension RecapViewModel { } func startMonitoringIfPermissionGranted() async { - if await meetingDetectionService.checkPermission() { + if await permissionsHelper.checkScreenCapturePermission() { meetingDetectionService.startMonitoring() } else { logger.warning("Meeting detection permission denied") diff --git a/Recap/UseCases/Home/ViewModel/RecapViewModel.swift b/Recap/UseCases/Home/ViewModel/RecapViewModel.swift index bafd460..47318ce 100644 --- a/Recap/UseCases/Home/ViewModel/RecapViewModel.swift +++ b/Recap/UseCases/Home/ViewModel/RecapViewModel.swift @@ -35,6 +35,7 @@ final class RecapViewModel: ObservableObject { let userPreferencesRepository: UserPreferencesRepositoryType let notificationService: any NotificationServiceType var appSelectionCoordinator: any AppSelectionCoordinatorType + let permissionsHelper: any PermissionsHelperType var timer: Timer? var levelTimer: Timer? @@ -56,7 +57,8 @@ final class RecapViewModel: ObservableObject { meetingDetectionService: any MeetingDetectionServiceType, userPreferencesRepository: UserPreferencesRepositoryType, notificationService: any NotificationServiceType, - appSelectionCoordinator: any AppSelectionCoordinatorType + appSelectionCoordinator: any AppSelectionCoordinatorType, + permissionsHelper: any PermissionsHelperType ) { self.recordingCoordinator = recordingCoordinator self.processingCoordinator = processingCoordinator @@ -68,6 +70,7 @@ final class RecapViewModel: ObservableObject { self.userPreferencesRepository = userPreferencesRepository self.notificationService = notificationService self.appSelectionCoordinator = appSelectionCoordinator + self.permissionsHelper = permissionsHelper setupBindings() setupWarningObserver() diff --git a/Recap/UseCases/Onboarding/View/OnboardingView.swift b/Recap/UseCases/Onboarding/View/OnboardingView.swift index 86c8166..d3685b7 100644 --- a/Recap/UseCases/Onboarding/View/OnboardingView.swift +++ b/Recap/UseCases/Onboarding/View/OnboardingView.swift @@ -151,13 +151,14 @@ struct OnboardingView: View { VStack(spacing: 12) { PermissionCard( title: "Auto Summarize", - description: "Generate summaries after each recording", + description: "Generate summaries after each recording - Coming Soon!", isEnabled: Binding( - get: { viewModel.isAutoSummarizeEnabled }, + get: { false }, set: { _ in } ), + isDisabled: true, onToggle: { enabled in - viewModel.toggleAutoSummarize(enabled) + } ) @@ -226,6 +227,7 @@ struct OnboardingView: View { ) } .buttonStyle(PlainButtonStyle()) + .padding(.all, 6) Spacer() } @@ -252,7 +254,7 @@ struct OnboardingView: View { userPreferencesRepository: PreviewUserPreferencesRepository() ) ) - .frame(width: 600, height: 700) + .frame(width: 600, height: 500) } private class PreviewUserPreferencesRepository: UserPreferencesRepositoryType { diff --git a/Recap/UseCases/Settings/Components/MeetingDetection/MeetingDetectionSettingsCard.swift b/Recap/UseCases/Settings/Components/MeetingDetection/MeetingDetectionSettingsCard.swift deleted file mode 100644 index a2616cc..0000000 --- a/Recap/UseCases/Settings/Components/MeetingDetection/MeetingDetectionSettingsCard.swift +++ /dev/null @@ -1,118 +0,0 @@ -import SwiftUI -import ScreenCaptureKit - -struct MeetingDetectionSettingsCard: View { - @ObservedObject private var generalSettingsViewModel: GeneralViewModel - @ObservedObject private var viewModel: MeetingViewModel - - init(generalSettingsViewModel: GeneralViewModel, viewModel: MeetingViewModel) { - self.generalSettingsViewModel = generalSettingsViewModel - self.viewModel = viewModel - } - - var body: some View { - GeometryReader { geometry in - SettingsCard(title: "Meeting Detection") { - if viewModel.autoDetectMeetings && !viewModel.hasScreenRecordingPermission { - WarningCard( - 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 - ) - } - - 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 { - VStack(alignment: .leading, spacing: 8) { - PillButton( - text: "Open System Settings", - icon: "gear" - ) { - viewModel.openScreenRecordingPreferences() - } - - Text("This permission allows Recap to read window titles only. No screen content is captured or recorded.") - .font(.system(size: 10)) - .foregroundColor(.secondary) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - } - } else { - HStack { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green) - .font(.system(size: 12)) - Text("Screen Recording permission granted") - .font(.system(size: 11)) - .foregroundColor(.secondary) - } - } - } - .padding(.top, 8) - } - } - } - .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() - } - } - -} diff --git a/Recap/UseCases/Settings/Components/MeetingDetection/MeetingDetectionView.swift b/Recap/UseCases/Settings/Components/MeetingDetection/MeetingDetectionView.swift index 0813ee2..2ffa4d8 100644 --- a/Recap/UseCases/Settings/Components/MeetingDetection/MeetingDetectionView.swift +++ b/Recap/UseCases/Settings/Components/MeetingDetection/MeetingDetectionView.swift @@ -27,6 +27,7 @@ struct MeetingDetectionView: V }, 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") { @@ -50,13 +51,15 @@ struct MeetingDetectionView: V if viewModel.autoDetectMeetings { VStack(spacing: 12) { if !viewModel.hasScreenRecordingPermission { - Text("Please enable Screen Recording permission above to continue.") - .font(.system(size: 10)) - .foregroundColor(.secondary) - .multilineTextAlignment(.leading) + HStack { + Text("Please enable Screen Recording permission above to continue.") + .font(.system(size: 10)) + .foregroundColor(.secondary) + .multilineTextAlignment(.leading) + Spacer() + } } } - .padding(.top, 8) } } } @@ -64,6 +67,8 @@ struct MeetingDetectionView: V } .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 { diff --git a/Recap/UseCases/Settings/Components/OpenRouterAPIKeyAlert.swift b/Recap/UseCases/Settings/Components/OpenRouterAPIKeyAlert.swift new file mode 100644 index 0000000..1aa9101 --- /dev/null +++ b/Recap/UseCases/Settings/Components/OpenRouterAPIKeyAlert.swift @@ -0,0 +1,146 @@ +import SwiftUI + +struct OpenRouterAPIKeyAlert: View { + @Binding var isPresented: Bool + @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() + } + } + } + } + } + .onAppear { + if let existingKey = existingKey { + apiKey = existingKey + } + } + } + + private var inputSection: some View { + VStack(alignment: .leading, spacing: 12) { + CustomPasswordField( + label: "API Key", + 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) + 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 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 + } +} + +#Preview { + VStack { + Rectangle() + .fill(Color.gray.opacity(0.3)) + .overlay( + Text("Background Content") + .foregroundColor(.white) + ) + } + .frame(height: 400) + .overlay( + OpenRouterAPIKeyAlert( + isPresented: .constant(true), + existingKey: nil, + onSave: { key 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/CustomPasswordField.swift b/Recap/UseCases/Settings/Components/Reusable/CustomPasswordField.swift new file mode 100644 index 0000000..9cb7039 --- /dev/null +++ b/Recap/UseCases/Settings/Components/Reusable/CustomPasswordField.swift @@ -0,0 +1,101 @@ +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 + ) + ) + ) + + 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 diff --git a/Recap/UseCases/Settings/Components/TabViews/GeneralSettingsView.swift b/Recap/UseCases/Settings/Components/TabViews/GeneralSettingsView.swift index 6e98d48..117ba8b 100644 --- a/Recap/UseCases/Settings/Components/TabViews/GeneralSettingsView.swift +++ b/Recap/UseCases/Settings/Components/TabViews/GeneralSettingsView.swift @@ -101,6 +101,32 @@ struct GeneralSettingsView: View { 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)) + } + } + } + .animation(.spring(response: 0.4, dampingFraction: 0.8), value: viewModel.showAPIKeyAlert) + ) } @@ -139,6 +165,8 @@ private final class PreviewGeneralSettingsViewModel: ObservableObject, GeneralSe @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", @@ -170,4 +198,8 @@ private final class PreviewGeneralSettingsViewModel: ObservableObject, GeneralSe func toggleAutoStopRecording(_ enabled: Bool) async { isAutoStopRecording = enabled } + func saveAPIKey(_ apiKey: String) async throws {} + func dismissAPIKeyAlert() { + showAPIKeyAlert = false + } } diff --git a/Recap/UseCases/Settings/SettingsView.swift b/Recap/UseCases/Settings/SettingsView.swift index 4a824ec..ed2e2c3 100644 --- a/Recap/UseCases/Settings/SettingsView.swift +++ b/Recap/UseCases/Settings/SettingsView.swift @@ -27,7 +27,7 @@ struct SettingsView: View { init( whisperModelsViewModel: WhisperModelsViewModel, generalSettingsViewModel: GeneralViewModel, - meetingDetectionService: MeetingDetectionServiceType, + meetingDetectionService: any MeetingDetectionServiceType, userPreferencesRepository: UserPreferencesRepositoryType, onClose: @escaping () -> Void ) { @@ -35,7 +35,8 @@ struct SettingsView: View { self.generalSettingsViewModel = generalSettingsViewModel self._meetingDetectionViewModel = StateObject(wrappedValue: MeetingDetectionSettingsViewModel( detectionService: meetingDetectionService, - userPreferencesRepository: userPreferencesRepository + userPreferencesRepository: userPreferencesRepository, + permissionsHelper: PermissionsHelper() )) self.onClose = onClose } @@ -143,14 +144,23 @@ struct SettingsView: View { SettingsView( whisperModelsViewModel: whisperModelsViewModel, generalSettingsViewModel: generalSettingsViewModel, - meetingDetectionService: MeetingDetectionService(audioProcessController: AudioProcessController()), + 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: ObservableObject, GeneralSettingsViewModelType { + var showAPIKeyAlert: Bool = false + + var existingAPIKey: String? = nil + + func saveAPIKey(_ apiKey: String) async throws {} + + func dismissAPIKeyAlert() {} + var activeWarnings: [WarningItem] = [] @Published var availableModels: [LLMModelInfo] = [ diff --git a/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModel.swift b/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModel.swift index 37f05de..029f0fb 100644 --- a/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModel.swift +++ b/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModel.swift @@ -2,7 +2,7 @@ import Foundation import Combine @MainActor -final class GeneralSettingsViewModel: ObservableObject, GeneralSettingsViewModelType { +final class GeneralSettingsViewModel: GeneralSettingsViewModelType { @Published private(set) var availableModels: [LLMModelInfo] = [] @Published private(set) var selectedModel: LLMModelInfo? @Published private(set) var selectedProvider: LLMProvider = .default @@ -13,6 +13,8 @@ final class GeneralSettingsViewModel: ObservableObject, GeneralSettingsViewModel @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 @@ -24,19 +26,22 @@ final class GeneralSettingsViewModel: ObservableObject, GeneralSettingsViewModel private let llmService: LLMServiceType private let userPreferencesRepository: UserPreferencesRepositoryType - private let environmentValidator: EnvironmentValidatorType - private let warningManager: WarningManagerType + private let keychainAPIValidator: KeychainAPIValidatorType + private let keychainService: KeychainServiceType + private let warningManager: any WarningManagerType private var cancellables = Set() init( llmService: LLMServiceType, userPreferencesRepository: UserPreferencesRepositoryType, - environmentValidator: EnvironmentValidatorType = EnvironmentValidator(), - warningManager: WarningManagerType + keychainAPIValidator: KeychainAPIValidatorType, + keychainService: KeychainServiceType, + warningManager: any WarningManagerType ) { self.llmService = llmService self.userPreferencesRepository = userPreferencesRepository - self.environmentValidator = environmentValidator + self.keychainAPIValidator = keychainAPIValidator + self.keychainService = keychainService self.warningManager = warningManager setupWarningObserver() @@ -100,15 +105,15 @@ final class GeneralSettingsViewModel: ObservableObject, GeneralSettingsViewModel errorMessage = nil if provider == .openRouter { - let validation = environmentValidator.validateOpenRouterEnvironment() + let validation = keychainAPIValidator.validateOpenRouterAPI() if !validation.isValid { - if let message = validation.errorMessage { - showValidationToast(message) + do { + existingAPIKey = try keychainService.retrieveOpenRouterAPIKey() + } catch { + existingAPIKey = nil } - selectedProvider = .ollama - try? await llmService.selectProvider(.ollama) - await loadModels() + showAPIKeyAlert = true return } } @@ -167,4 +172,18 @@ final class GeneralSettingsViewModel: ObservableObject, GeneralSettingsViewModel isAutoStopRecording = !enabled } } -} \ No newline at end of file + + func saveAPIKey(_ apiKey: String) async throws { + try keychainService.storeOpenRouterAPIKey(apiKey) + + existingAPIKey = apiKey + showAPIKeyAlert = false + + await selectProvider(.openRouter) + } + + func dismissAPIKeyAlert() { + showAPIKeyAlert = false + existingAPIKey = nil + } +} diff --git a/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelType.swift b/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelType.swift index e84ce80..bccceb1 100644 --- a/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelType.swift +++ b/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelType.swift @@ -15,10 +15,14 @@ protocol GeneralSettingsViewModelType: ObservableObject { var showToast: Bool { get } var toastMessage: String { get } var activeWarnings: [WarningItem] { 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 saveAPIKey(_ apiKey: String) async throws + func dismissAPIKeyAlert() } \ No newline at end of file diff --git a/Recap/UseCases/Settings/ViewModels/MeetingDetection/MeetingDetectionSettingsViewModel.swift b/Recap/UseCases/Settings/ViewModels/MeetingDetection/MeetingDetectionSettingsViewModel.swift index 42f3eaf..de34410 100644 --- a/Recap/UseCases/Settings/ViewModels/MeetingDetection/MeetingDetectionSettingsViewModel.swift +++ b/Recap/UseCases/Settings/ViewModels/MeetingDetection/MeetingDetectionSettingsViewModel.swift @@ -8,11 +8,14 @@ final class MeetingDetectionSettingsViewModel: MeetingDetectionSettingsViewModel private let detectionService: any MeetingDetectionServiceType private let userPreferencesRepository: UserPreferencesRepositoryType + private let permissionsHelper: any PermissionsHelperType init(detectionService: any MeetingDetectionServiceType, - userPreferencesRepository: UserPreferencesRepositoryType) { + userPreferencesRepository: UserPreferencesRepositoryType, + permissionsHelper: any PermissionsHelperType) { self.detectionService = detectionService self.userPreferencesRepository = userPreferencesRepository + self.permissionsHelper = permissionsHelper Task { await loadCurrentSettings() @@ -37,7 +40,7 @@ final class MeetingDetectionSettingsViewModel: MeetingDetectionSettingsViewModel } if enabled { - let hasPermission = await detectionService.checkPermission() + let hasPermission = await permissionsHelper.checkScreenCapturePermission() hasScreenRecordingPermission = hasPermission if hasPermission { @@ -52,7 +55,7 @@ final class MeetingDetectionSettingsViewModel: MeetingDetectionSettingsViewModel } func checkPermissionStatus() async { - hasScreenRecordingPermission = await detectionService.checkPermission() + hasScreenRecordingPermission = await permissionsHelper.checkScreenCapturePermission() if autoDetectMeetings && hasScreenRecordingPermission { detectionService.startMonitoring() diff --git a/RecapTests/Helpers/UserPreferencesInfo+TestHelpers.swift b/RecapTests/Helpers/UserPreferencesInfo+TestHelpers.swift new file mode 100644 index 0000000..b38ce1a --- /dev/null +++ b/RecapTests/Helpers/UserPreferencesInfo+TestHelpers.swift @@ -0,0 +1,30 @@ +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 + ) + } +} diff --git a/RecapTests/Services/MeetingDetection/MeetingDetectionServiceSpec.swift b/RecapTests/Services/MeetingDetection/MeetingDetectionServiceSpec.swift index 2cf7332..4a41400 100644 --- a/RecapTests/Services/MeetingDetection/MeetingDetectionServiceSpec.swift +++ b/RecapTests/Services/MeetingDetection/MeetingDetectionServiceSpec.swift @@ -30,7 +30,8 @@ final class MeetingDetectionServiceSpec: XCTestCase { .meetingApps .willReturn(emptyProcesses) - sut = MeetingDetectionService(audioProcessController: mockAudioProcessController) + let mockPermissionsHelper = MockPermissionsHelperType() + sut = MeetingDetectionService(audioProcessController: mockAudioProcessController, permissionsHelper: mockPermissionsHelper) } override func tearDown() async throws { diff --git a/RecapTests/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelSpec.swift b/RecapTests/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelSpec.swift new file mode 100644 index 0000000..cfd9326 --- /dev/null +++ b/RecapTests/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelSpec.swift @@ -0,0 +1,419 @@ +import XCTest +import Combine +import Mockable +@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") + + 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") + } +} diff --git a/RecapTests/UseCases/Settings/ViewModels/MeetingDetection/MeetingDetectionSettingsViewModelSpec.swift b/RecapTests/UseCases/Settings/ViewModels/MeetingDetection/MeetingDetectionSettingsViewModelSpec.swift new file mode 100644 index 0000000..b028f6c --- /dev/null +++ b/RecapTests/UseCases/Settings/ViewModels/MeetingDetection/MeetingDetectionSettingsViewModelSpec.swift @@ -0,0 +1,242 @@ +import XCTest +import Combine +import Mockable +@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 From 517375c6d91586d71553aa5e8108019d1fafca7a Mon Sep 17 00:00:00 2001 From: Rawand Ahmed Shaswar Date: Wed, 6 Aug 2025 23:25:53 +0300 Subject: [PATCH 4/7] Update Recap.xctestplan --- Recap.xctestplan | 1 + 1 file changed, 1 insertion(+) diff --git a/Recap.xctestplan b/Recap.xctestplan index 20fb629..f2dc89a 100644 --- a/Recap.xctestplan +++ b/Recap.xctestplan @@ -30,6 +30,7 @@ "containerPath" : "container:Recap.xcodeproj", "identifier" : "A72106692E30165B0073C515", "name" : "RecapUITests" + } } ], "version" : 1 From ce4862bb574606046567a75b2a980786dac0b88b Mon Sep 17 00:00:00 2001 From: Rawand Ahmed Shaswar Date: Wed, 6 Aug 2025 23:29:41 +0300 Subject: [PATCH 5/7] Resolve comments --- Recap/Services/LLM/Core/LLMTaskManageable.swift | 2 +- Recap/Services/LLM/Providers/Ollama/OllamaProvider.swift | 2 +- .../Services/LLM/Providers/OpenRouter/OpenRouterProvider.swift | 2 +- Recap/Services/Utilities/Warnings/WarningManagerType.swift | 3 ++- Recap/UseCases/Home/ViewModel/RecapViewModel.swift | 2 +- 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/Recap/Services/LLM/Core/LLMTaskManageable.swift b/Recap/Services/LLM/Core/LLMTaskManageable.swift index f0ade6a..0a44129 100644 --- a/Recap/Services/LLM/Core/LLMTaskManageable.swift +++ b/Recap/Services/LLM/Core/LLMTaskManageable.swift @@ -20,7 +20,7 @@ extension LLMTaskManageable { return try await withTaskCancellationHandler { try await operation() } onCancel: { - Task.detached { [weak self] in + Task { [weak self] in await self?.cancelCurrentTask() } } diff --git a/Recap/Services/LLM/Providers/Ollama/OllamaProvider.swift b/Recap/Services/LLM/Providers/Ollama/OllamaProvider.swift index bc451bf..eb8f488 100644 --- a/Recap/Services/LLM/Providers/Ollama/OllamaProvider.swift +++ b/Recap/Services/LLM/Providers/Ollama/OllamaProvider.swift @@ -33,7 +33,7 @@ final class OllamaProvider: LLMProviderType, LLMTaskManageable { } deinit { - Task.detached { [weak self] in + Task { [weak self] in await self?.cancelCurrentTask() } } diff --git a/Recap/Services/LLM/Providers/OpenRouter/OpenRouterProvider.swift b/Recap/Services/LLM/Providers/OpenRouter/OpenRouterProvider.swift index d69150f..11f63ba 100644 --- a/Recap/Services/LLM/Providers/OpenRouter/OpenRouterProvider.swift +++ b/Recap/Services/LLM/Providers/OpenRouter/OpenRouterProvider.swift @@ -33,7 +33,7 @@ final class OpenRouterProvider: LLMProviderType, LLMTaskManageable { } deinit { - Task.detached { [weak self] in + Task { [weak self] in await self?.cancelCurrentTask() } } diff --git a/Recap/Services/Utilities/Warnings/WarningManagerType.swift b/Recap/Services/Utilities/Warnings/WarningManagerType.swift index e8da102..38ff820 100644 --- a/Recap/Services/Utilities/Warnings/WarningManagerType.swift +++ b/Recap/Services/Utilities/Warnings/WarningManagerType.swift @@ -4,6 +4,7 @@ import Combine import Mockable #endif +@MainActor #if MOCKING @Mockable #endif @@ -54,4 +55,4 @@ enum WarningSeverity { return "FF3B30" } } -} \ No newline at end of file +} diff --git a/Recap/UseCases/Home/ViewModel/RecapViewModel.swift b/Recap/UseCases/Home/ViewModel/RecapViewModel.swift index 47318ce..bb13528 100644 --- a/Recap/UseCases/Home/ViewModel/RecapViewModel.swift +++ b/Recap/UseCases/Home/ViewModel/RecapViewModel.swift @@ -171,7 +171,7 @@ final class RecapViewModel: ObservableObject { } deinit { - Task.detached { [weak self] in + Task { [weak self] in await self?.stopTimers() } } From b03510c880e4c654d7d57cd13e1a14e0caeb5859 Mon Sep 17 00:00:00 2001 From: Rawand Ahmed Shaswar Date: Wed, 6 Aug 2025 23:37:48 +0300 Subject: [PATCH 6/7] Fix issues --- .../Components/TabViews/GeneralSettingsView.swift | 7 ++++++- Recap/UseCases/Settings/SettingsView.swift | 4 +--- .../General/GeneralSettingsViewModel.swift | 15 +++++++++++++++ .../General/GeneralSettingsViewModelType.swift | 4 +++- 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/Recap/UseCases/Settings/Components/TabViews/GeneralSettingsView.swift b/Recap/UseCases/Settings/Components/TabViews/GeneralSettingsView.swift index 9fe3c5e..cd7e562 100644 --- a/Recap/UseCases/Settings/Components/TabViews/GeneralSettingsView.swift +++ b/Recap/UseCases/Settings/Components/TabViews/GeneralSettingsView.swift @@ -90,7 +90,7 @@ struct GeneralSettingsView: View { VStack(alignment: .leading, spacing: 12) { CustomTextEditor( title: "Prompt Template", - text: viewModel.customPromptTemplate, + text: $viewModel.customPromptTemplate, placeholder: "Enter your custom prompt template here...", height: 120 ) @@ -178,9 +178,14 @@ struct GeneralSettingsView: View { } private final class PreviewGeneralSettingsViewModel: GeneralSettingsViewModelType { + 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") diff --git a/Recap/UseCases/Settings/SettingsView.swift b/Recap/UseCases/Settings/SettingsView.swift index 74998c6..ba6f1a4 100644 --- a/Recap/UseCases/Settings/SettingsView.swift +++ b/Recap/UseCases/Settings/SettingsView.swift @@ -152,7 +152,7 @@ struct SettingsView: View { } // Just used for previews only! -private final class PreviewGeneralSettingsViewModel: ObservableObject, GeneralSettingsViewModelType { +private final class PreviewGeneralSettingsViewModel: GeneralSettingsViewModelType { var showAPIKeyAlert: Bool = false var existingAPIKey: String? = nil @@ -161,8 +161,6 @@ private final class PreviewGeneralSettingsViewModel: ObservableObject, GeneralSe func dismissAPIKeyAlert() {} - var activeWarnings: [WarningItem] = [] - @Published var availableModels: [LLMModelInfo] = [ LLMModelInfo(name: "llama3.2", provider: "ollama"), LLMModelInfo(name: "codellama", provider: "ollama") diff --git a/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModel.swift b/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModel.swift index ce179b3..8211017 100644 --- a/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModel.swift +++ b/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModel.swift @@ -177,6 +177,21 @@ final class GeneralSettingsViewModel: GeneralSettingsViewModelType { } } + func updateCustomPromptTemplate(_ template: String) async { + customPromptTemplateValue = template + + do { + let templateToSave = template.isEmpty ? nil : template + try await userPreferencesRepository.updateSummaryPromptTemplate(templateToSave) + } catch { + errorMessage = error.localizedDescription + } + } + + func resetToDefaultPrompt() async { + await updateCustomPromptTemplate(UserPreferencesInfo.defaultPromptTemplate) + } + func toggleAutoStopRecording(_ enabled: Bool) async { errorMessage = nil isAutoStopRecording = enabled diff --git a/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelType.swift b/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelType.swift index 16b4ab0..aea1899 100644 --- a/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelType.swift +++ b/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelType.swift @@ -24,6 +24,8 @@ protocol GeneralSettingsViewModelType: ObservableObject { 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() -} \ No newline at end of file +} From e18cd61b5c9fc47bda5cb9f0d8acf9a904727a0d Mon Sep 17 00:00:00 2001 From: Rawand Ahmed Shaswar Date: Wed, 6 Aug 2025 23:39:35 +0300 Subject: [PATCH 7/7] Fix issues --- .../Settings/Components/TabViews/GeneralSettingsView.swift | 2 +- Recap/UseCases/Settings/SettingsView.swift | 2 ++ .../ViewModels/General/GeneralSettingsViewModelType.swift | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Recap/UseCases/Settings/Components/TabViews/GeneralSettingsView.swift b/Recap/UseCases/Settings/Components/TabViews/GeneralSettingsView.swift index cd7e562..f942887 100644 --- a/Recap/UseCases/Settings/Components/TabViews/GeneralSettingsView.swift +++ b/Recap/UseCases/Settings/Components/TabViews/GeneralSettingsView.swift @@ -90,7 +90,7 @@ struct GeneralSettingsView: View { VStack(alignment: .leading, spacing: 12) { CustomTextEditor( title: "Prompt Template", - text: $viewModel.customPromptTemplate, + text: viewModel.customPromptTemplate, placeholder: "Enter your custom prompt template here...", height: 120 ) diff --git a/Recap/UseCases/Settings/SettingsView.swift b/Recap/UseCases/Settings/SettingsView.swift index ba6f1a4..0d6a763 100644 --- a/Recap/UseCases/Settings/SettingsView.swift +++ b/Recap/UseCases/Settings/SettingsView.swift @@ -153,6 +153,8 @@ struct SettingsView: View { // Just used for previews only! private final class PreviewGeneralSettingsViewModel: GeneralSettingsViewModelType { + var customPromptTemplate: Binding = .constant("Hello") + var showAPIKeyAlert: Bool = false var existingAPIKey: String? = nil diff --git a/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelType.swift b/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelType.swift index aea1899..cfa3dc8 100644 --- a/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelType.swift +++ b/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelType.swift @@ -16,6 +16,7 @@ protocol GeneralSettingsViewModelType: ObservableObject { var showToast: Bool { get } var toastMessage: String { get } var activeWarnings: [WarningItem] { get } + var customPromptTemplate: Binding { get } var showAPIKeyAlert: Bool { get } var existingAPIKey: String? { get }