diff --git a/App/Intents/StudyAppIntentServices.swift b/App/Intents/StudyAppIntentServices.swift index 5380ada..7788d4c 100644 --- a/App/Intents/StudyAppIntentServices.swift +++ b/App/Intents/StudyAppIntentServices.swift @@ -8,13 +8,20 @@ final class StudyAppIntentServices { private let modules: any ModuleRepository private let calendar: any StudyCalendarRepository private let flashcards: any FlashcardRepository + private let studyTimer: StudyTimerViewModel private let navigation: AppNavigation private let now: () -> Date - init(container: AppContainer, navigation: AppNavigation, now: @escaping () -> Date = { .now }) { + init( + container: AppContainer, + studyTimer: StudyTimerViewModel, + navigation: AppNavigation, + now: @escaping () -> Date = { .now } + ) { modules = container.moduleRepository calendar = container.studyCalendarRepository flashcards = container.flashcardRepository + self.studyTimer = studyTimer self.navigation = navigation self.now = now } @@ -32,6 +39,11 @@ final class StudyAppIntentServices { DueCardsUseCase.text(try DueCardsUseCase(modules: modules, flashcards: flashcards)(now: now())) } + /// Uses the app's one timer, so the toolbar and the menu bar show it running. + func startStudyTimer(moduleID: UUID?) async -> String { + await studyTimer.startFromShortcut(moduleID: moduleID) + } + func openFlashcards(moduleID: UUID) { navigation.show(module: moduleID, section: .flashcards) } diff --git a/App/Intents/StudyAppIntents.swift b/App/Intents/StudyAppIntents.swift index a31ecbc..ab30f4e 100644 --- a/App/Intents/StudyAppIntents.swift +++ b/App/Intents/StudyAppIntents.swift @@ -47,6 +47,23 @@ nonisolated struct OpenFlashcardsIntent: AppIntent { } } +/// „Starte den Lerntimer“: starts the study timer, optionally for a module. +nonisolated struct StartStudyTimerIntent: AppIntent { + static let title: LocalizedStringResource = "Lerntimer starten" + static let description = IntentDescription("Startet den Lerntimer in StudyApp, auf Wunsch für ein Modul.") + + @Parameter(title: "Modul") + var module: ModuleEntity? + + @Dependency private var services: StudyAppIntentServices + + @MainActor + func perform() async throws -> some IntentResult & ReturnsValue & ProvidesDialog { + let text = await services.startStudyTimer(moduleID: module?.id) + return .result(value: text, dialog: IntentDialog(stringLiteral: text)) + } +} + /// Siri phrases and the actions shown in the Shortcuts app without any setup. nonisolated struct StudyAppShortcuts: AppShortcutsProvider { static var appShortcuts: [AppShortcut] { @@ -77,5 +94,14 @@ nonisolated struct StudyAppShortcuts: AppShortcutsProvider { shortTitle: "Karteikarten lernen", systemImageName: "brain.head.profile" ) + AppShortcut( + intent: StartStudyTimerIntent(), + phrases: [ + "Starte den Lerntimer in \(.applicationName)", + "Lerntimer in \(.applicationName) starten", + ], + shortTitle: "Lerntimer starten", + systemImageName: "timer" + ) } } diff --git a/App/StudyAppApp.swift b/App/StudyAppApp.swift index f058672..6b49581 100644 --- a/App/StudyAppApp.swift +++ b/App/StudyAppApp.swift @@ -60,7 +60,7 @@ struct StudyAppApp: App { _todayViewModel = State(initialValue: today) _assignmentsViewModel = State(initialValue: assignments) _navigation = State(initialValue: navigation) - let intentServices = StudyAppIntentServices(container: container, navigation: navigation) + let intentServices = StudyAppIntentServices(container: container, studyTimer: studyTimer, navigation: navigation) AppDependencyManager.shared.add(dependency: intentServices) _studIPViewModel = State(initialValue: studIP) let notifications = container.makeNotificationsViewModel() diff --git a/Presentation/StudyTimer/StudyTimerViewModel.swift b/Presentation/StudyTimer/StudyTimerViewModel.swift index ba1590f..3a63f49 100644 --- a/Presentation/StudyTimer/StudyTimerViewModel.swift +++ b/Presentation/StudyTimer/StudyTimerViewModel.swift @@ -67,6 +67,16 @@ final class StudyTimerViewModel { await scheduleEndNotification() } + /// Starts or continues the timer for Shortcuts and Siri and returns what to say. A running timer keeps + /// running; the module only applies to a timer that was idle. + func startFromShortcut(moduleID: UUID?) async -> String { + if timer.isRunning { return "Der Lerntimer läuft schon: \(timer.phase.title), noch \(remainingText)." } + if !isActive, let moduleID { self.moduleID = moduleID } + let resumes = timer.isPaused + await start() + return "Lerntimer läuft\(resumes ? " weiter" : ""): \(timer.phase.title), noch \(remainingText)." + } + func pause() { now = clock() timer.pause(at: now) diff --git a/StudyAppTests/StudyTimerTests.swift b/StudyAppTests/StudyTimerTests.swift index b97e635..7460d21 100644 --- a/StudyAppTests/StudyTimerTests.swift +++ b/StudyAppTests/StudyTimerTests.swift @@ -183,6 +183,23 @@ struct StudyTimerViewModelTests { #expect(scheduler.scheduled[StudyNotification.studyTimerIdentifier] == nil) } + @Test func shortcutStartsAnIdleTimerForTheModuleAndKeepsARunningOne() async { + let viewModel = makeViewModel() + let module = UUID() + + #expect(await viewModel.startFromShortcut(moduleID: module) == "Lerntimer läuft: Fokus, noch 25:00.") + #expect(viewModel.moduleID == module) + + clock.now += 60 + viewModel.tick() + #expect(await viewModel.startFromShortcut(moduleID: UUID()) == "Der Lerntimer läuft schon: Fokus, noch 24:00.") + #expect(viewModel.moduleID == module) + + viewModel.pause() + #expect(await viewModel.startFromShortcut(moduleID: nil) == "Lerntimer läuft weiter: Fokus, noch 24:00.") + viewModel.reset() + } + @Test func formatsDurationsAndClock() { #expect(StudyTimerViewModel.durationText(45) == "45 Min.") #expect(StudyTimerViewModel.durationText(120) == "2 Std.") diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 9c7b738..1474ad6 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -97,6 +97,7 @@ StudyApp offers actions in the Shortcuts app and to Siri without any setup: | Nächste Vorlesung | „Was habe ich als Nächstes in StudyApp?“ | Spoken/returned text, e.g. „Als Nächstes: Analysis I, heute um 10:15 in HS 1.“ | | Fällige Karteikarten | „Fällige Karten in StudyApp“ | „24 Karten fällig: Analysis I 12, …“ | | Karteikarten lernen | „Starte Karteikarten Analysis I in StudyApp“ | Opens the module's flashcards | +| Lerntimer starten | „Starte den Lerntimer in StudyApp“ | Starts or continues the study timer, optionally for a module: „Lerntimer läuft: Fokus, noch 25:00.“ | Code: `App/Intents/`, `Domain/UseCases/QuickInfoUseCases.swift`