diff --git a/App/Intents/ModuleEntity.swift b/App/Intents/ModuleEntity.swift new file mode 100644 index 0000000..fde82be --- /dev/null +++ b/App/Intents/ModuleEntity.swift @@ -0,0 +1,37 @@ +import AppIntents +import Foundation + +/// A module as Shortcuts and Siri see it. +nonisolated struct ModuleEntity: AppEntity { + static let typeDisplayRepresentation: TypeDisplayRepresentation = "Modul" + static var defaultQuery: ModuleQuery { ModuleQuery() } + + let id: UUID + let name: String + + var displayRepresentation: DisplayRepresentation { DisplayRepresentation(title: "\(name)") } +} + +/// Finds active modules by ID or name. +nonisolated struct ModuleQuery: EntityStringQuery { + @Dependency private var services: StudyAppIntentServices + + @MainActor + func entities(for identifiers: [UUID]) async throws -> [ModuleEntity] { + try services.activeModules().filter { identifiers.contains($0.id) }.map(Self.entity) + } + + @MainActor + func entities(matching string: String) async throws -> [ModuleEntity] { + try services.activeModules().filter { $0.name.localizedCaseInsensitiveContains(string) }.map(Self.entity) + } + + @MainActor + func suggestedEntities() async throws -> [ModuleEntity] { + try services.activeModules().map(Self.entity) + } + + private static func entity(_ module: StudyModule) -> ModuleEntity { + ModuleEntity(id: module.id, name: module.name) + } +} diff --git a/App/Intents/StudyAppIntentServices.swift b/App/Intents/StudyAppIntentServices.swift new file mode 100644 index 0000000..5380ada --- /dev/null +++ b/App/Intents/StudyAppIntentServices.swift @@ -0,0 +1,38 @@ +import AppIntents +import Foundation + +/// What App Intents may do inside the running app. Registered with `AppDependencyManager` at launch, +/// so intents receive it through `@Dependency` instead of reaching for a global. +@MainActor +final class StudyAppIntentServices { + private let modules: any ModuleRepository + private let calendar: any StudyCalendarRepository + private let flashcards: any FlashcardRepository + private let navigation: AppNavigation + private let now: () -> Date + + init(container: AppContainer, navigation: AppNavigation, now: @escaping () -> Date = { .now }) { + modules = container.moduleRepository + calendar = container.studyCalendarRepository + flashcards = container.flashcardRepository + self.navigation = navigation + self.now = now + } + + func activeModules() throws -> [StudyModule] { + try modules.all().filter { !$0.isArchived }.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + } + + func nextLectureText() throws -> String { + let reference = now() + return NextLectureUseCase.text(try NextLectureUseCase(calendar: calendar, modules: modules)(now: reference), now: reference) + } + + func dueCardsText() throws -> String { + DueCardsUseCase.text(try DueCardsUseCase(modules: modules, flashcards: flashcards)(now: now())) + } + + func openFlashcards(moduleID: UUID) { + navigation.show(module: moduleID, section: .flashcards) + } +} diff --git a/App/Intents/StudyAppIntents.swift b/App/Intents/StudyAppIntents.swift new file mode 100644 index 0000000..a31ecbc --- /dev/null +++ b/App/Intents/StudyAppIntents.swift @@ -0,0 +1,81 @@ +import AppIntents +import Foundation + +/// „Was habe ich als Nächstes?“ +nonisolated struct NextLectureIntent: AppIntent { + static let title: LocalizedStringResource = "Nächste Vorlesung" + static let description = IntentDescription("Sagt, welche Vorlesung gerade läuft oder als Nächstes ansteht.") + + @Dependency private var services: StudyAppIntentServices + + @MainActor + func perform() async throws -> some IntentResult & ReturnsValue & ProvidesDialog { + let text = try services.nextLectureText() + return .result(value: text, dialog: IntentDialog(stringLiteral: text)) + } +} + +/// „Wie viele Karten sind fällig?“ +nonisolated struct DueCardsIntent: AppIntent { + static let title: LocalizedStringResource = "Fällige Karteikarten" + static let description = IntentDescription("Zählt die fälligen Karteikarten je Modul.") + + @Dependency private var services: StudyAppIntentServices + + @MainActor + func perform() async throws -> some IntentResult & ReturnsValue & ProvidesDialog { + let text = try services.dueCardsText() + return .result(value: text, dialog: IntentDialog(stringLiteral: text)) + } +} + +/// „Starte Karteikarten Analysis“: opens the module's flashcards. +nonisolated struct OpenFlashcardsIntent: AppIntent { + static let title: LocalizedStringResource = "Karteikarten lernen" + static let description = IntentDescription("Öffnet die Karteikarten eines Moduls in StudyApp.") + static let openAppWhenRun = true + + @Parameter(title: "Modul") + var module: ModuleEntity + + @Dependency private var services: StudyAppIntentServices + + @MainActor + func perform() async throws -> some IntentResult { + services.openFlashcards(moduleID: module.id) + return .result() + } +} + +/// Siri phrases and the actions shown in the Shortcuts app without any setup. +nonisolated struct StudyAppShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: NextLectureIntent(), + phrases: [ + "Was habe ich als Nächstes in \(.applicationName)?", + "Nächste Vorlesung in \(.applicationName)", + ], + shortTitle: "Nächste Vorlesung", + systemImageName: "calendar" + ) + AppShortcut( + intent: DueCardsIntent(), + phrases: [ + "Fällige Karten in \(.applicationName)", + "Wie viele Karten sind in \(.applicationName) fällig?", + ], + shortTitle: "Fällige Karten", + systemImageName: "rectangle.stack" + ) + AppShortcut( + intent: OpenFlashcardsIntent(), + phrases: [ + "Starte Karteikarten \(\.$module) in \(.applicationName)", + "Karteikarten in \(.applicationName) lernen", + ], + shortTitle: "Karteikarten lernen", + systemImageName: "brain.head.profile" + ) + } +} diff --git a/App/StudyAppApp.swift b/App/StudyAppApp.swift index 9039d7b..feb89fb 100644 --- a/App/StudyAppApp.swift +++ b/App/StudyAppApp.swift @@ -1,4 +1,5 @@ import AppKit +import AppIntents import SwiftUI @main @@ -54,6 +55,8 @@ struct StudyAppApp: App { _todayViewModel = State(initialValue: today) _assignmentsViewModel = State(initialValue: assignments) _navigation = State(initialValue: navigation) + let intentServices = StudyAppIntentServices(container: container, navigation: navigation) + AppDependencyManager.shared.add(dependency: intentServices) _studIPViewModel = State(initialValue: studIP) let notifications = container.makeNotificationsViewModel() studIP.onBackgroundSyncChanges = { summary in @@ -179,6 +182,8 @@ struct StudyAppApp: App { todayViewModel.load() examsViewModel.load() announcementsViewModel.load() + // Lets Siri offer the current modules in „Starte Karteikarten …“. + StudyAppShortcuts.updateAppShortcutParameters() if navigation.destination == nil { navigation.showToday() } diff --git a/Config/Info.plist b/Config/Info.plist index f9d062d..a213840 100644 --- a/Config/Info.plist +++ b/Config/Info.plist @@ -2,6 +2,8 @@ + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) SUEnableAutomaticChecks SUEnableInstallerLauncherService diff --git a/Domain/UseCases/QuickInfoUseCases.swift b/Domain/UseCases/QuickInfoUseCases.swift new file mode 100644 index 0000000..e13ca81 --- /dev/null +++ b/Domain/UseCases/QuickInfoUseCases.swift @@ -0,0 +1,71 @@ +import Foundation + +/// Short answers for Shortcuts and Siri: the next lecture and the due flashcards. +@MainActor +struct NextLectureUseCase { + struct Answer: Equatable { + let event: StudyCalendarEvent + let module: StudyModule + } + + let calendar: any StudyCalendarRepository + let modules: any ModuleRepository + + /// The next (or running) event of an active module. + func callAsFunction(now: Date) throws -> Answer? { + let active = Dictionary(uniqueKeysWithValues: try modules.all().filter { !$0.isArchived }.map { ($0.id, $0) }) + let event = try calendar.events() + .filter { !$0.isAllDay && $0.moduleID.map { active[$0] != nil } == true } + .upcoming(after: now, limit: 1) + .first + guard let event, let moduleID = event.moduleID, let module = active[moduleID] else { return nil } + return Answer(event: event, module: module) + } + + static func text(_ answer: Answer?, now: Date, calendar: Calendar = .current) -> String { + guard let answer else { return "Es steht keine Vorlesung an." } + let event = answer.event + let place = event.location.isEmpty ? "" : " in \(event.location)" + if event.startDate <= now { + let minutes = max(Int(event.endDate.timeIntervalSince(now) / 60), 0) + return "\(answer.module.name) läuft gerade\(place), noch \(minutes) Minuten." + } + let time = event.startDate.formatted(date: .omitted, time: .shortened) + let day: String + if calendar.isDate(event.startDate, inSameDayAs: now) { + day = "heute" + } else if let tomorrow = calendar.date(byAdding: .day, value: 1, to: now), calendar.isDate(event.startDate, inSameDayAs: tomorrow) { + day = "morgen" + } else { + day = event.startDate.formatted(.dateTime.weekday(.wide)) + } + return "Als Nächstes: \(answer.module.name), \(day) um \(time)\(place)." + } +} + +/// Due flashcards per active module, most first. +@MainActor +struct DueCardsUseCase { + struct Count: Equatable { + let module: StudyModule + let due: Int + } + + let modules: any ModuleRepository + let flashcards: any FlashcardRepository + + func callAsFunction(now: Date) throws -> [Count] { + try modules.all() + .filter { !$0.isArchived } + .map { module in Count(module: module, due: try flashcards.cards(forModule: module.id).filter { $0.isDue(at: now) }.count) } + .filter { $0.due > 0 } + .sorted { $0.due > $1.due } + } + + static func text(_ counts: [Count]) -> String { + let total = counts.reduce(0) { $0 + $1.due } + guard total > 0 else { return "Keine Karteikarten fällig." } + let details = counts.prefix(3).map { "\($0.module.name) \($0.due)" }.joined(separator: ", ") + return "\(total) \(total == 1 ? "Karte" : "Karten") fällig: \(details)." + } +} diff --git a/StudyApp.xcodeproj/project.pbxproj b/StudyApp.xcodeproj/project.pbxproj index fc2958a..7dde112 100644 --- a/StudyApp.xcodeproj/project.pbxproj +++ b/StudyApp.xcodeproj/project.pbxproj @@ -282,10 +282,10 @@ }; }; buildConfigurationList = 40F4A65E307EDDEA009E2F4B /* Build configuration list for PBXProject "StudyApp" */; - developmentRegion = en; + developmentRegion = de; hasScannedForEncodings = 0; knownRegions = ( - en, + de, Base, ); mainGroup = 40F4A65A307EDDEA009E2F4B; diff --git a/StudyAppTests/QuickInfoTests.swift b/StudyAppTests/QuickInfoTests.swift new file mode 100644 index 0000000..83bcc92 --- /dev/null +++ b/StudyAppTests/QuickInfoTests.swift @@ -0,0 +1,59 @@ +import Foundation +import Testing +@testable import StudyApp + +@MainActor +struct QuickInfoTests { + let modules = FakeModuleRepository() + let calendar = InMemoryCalendarRepository() + let flashcards = FakeFlashcardRepository() + let now = Date(timeIntervalSince1970: 1_761_642_000) + + private func module(_ name: String, archived: Bool = false) throws -> StudyModule { + let module = StudyModule(id: UUID(), name: name, code: "", color: .blue, createdAt: now, isArchived: archived) + try modules.add(module) + return module + } + + private func event(_ module: StudyModule?, inMinutes minutes: Double, length: Double = 90) -> StudyCalendarEvent { + let start = now.addingTimeInterval(minutes * 60) + return StudyCalendarEvent( + id: UUID(), externalID: UUID().uuidString, sourceID: UUID(), title: "VL", details: "", location: "HS 1", + startDate: start, endDate: start.addingTimeInterval(length * 60), moduleID: module?.id + ) + } + + @Test func nextLectureSkipsArchivedAndUnlinkedEvents() throws { + let analysis = try module("Analysis I") + let old = try module("Altes Modul", archived: true) + calendar.storedEvents = [event(old, inMinutes: 10), event(nil, inMinutes: 20), event(analysis, inMinutes: 60)] + + let answer = try NextLectureUseCase(calendar: calendar, modules: modules)(now: now) + + #expect(answer?.module.name == "Analysis I") + } + + @Test func nextLectureTextForRunningAndUpcomingLectures() throws { + let analysis = try module("Analysis I") + let running = NextLectureUseCase.Answer(event: event(analysis, inMinutes: -30), module: analysis) + #expect(NextLectureUseCase.text(running, now: now) == "Analysis I läuft gerade in HS 1, noch 60 Minuten.") + let later = NextLectureUseCase.Answer(event: event(analysis, inMinutes: 60), module: analysis) + #expect(NextLectureUseCase.text(later, now: now).hasPrefix("Als Nächstes: Analysis I, heute um ")) + #expect(NextLectureUseCase.text(nil, now: now) == "Es steht keine Vorlesung an.") + } + + @Test func dueCardsAreCountedPerActiveModule() throws { + let analysis = try module("Analysis I") + let algebra = try module("Lineare Algebra") + try flashcards.add(Flashcard(moduleID: analysis.id, front: "a", back: "b", dueAt: now.addingTimeInterval(-60))) + try flashcards.add(Flashcard(moduleID: algebra.id, front: "a", back: "b", dueAt: now.addingTimeInterval(-60))) + try flashcards.add(Flashcard(moduleID: algebra.id, front: "c", back: "d", dueAt: now.addingTimeInterval(-60))) + try flashcards.add(Flashcard(moduleID: algebra.id, front: "e", back: "f", dueAt: now.addingTimeInterval(3_600))) + + let counts = try DueCardsUseCase(modules: modules, flashcards: flashcards)(now: now) + + #expect(counts.map(\.due) == [2, 1]) + #expect(DueCardsUseCase.text(counts) == "3 Karten fällig: Lineare Algebra 2, Analysis I 1.") + #expect(DueCardsUseCase.text([]) == "Keine Karteikarten fällig.") + } +} diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 0f4bd8d..3bde4c4 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -78,6 +78,18 @@ The menu bar item shows the remaining minutes while a lecture runs. Its window l Code: `Presentation/MenuBar/` +## Shortcuts and Siri + +StudyApp offers actions in the Shortcuts app and to Siri without any setup: + +| Action | Example phrase | Result | +| --- | --- | --- | +| 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 | + +Code: `App/Intents/`, `Domain/UseCases/QuickInfoUseCases.swift` + ## Updates and settings - Automatic updates through Sparkle („Nach Updates suchen …“ in the app menu). See [RELEASING.md](RELEASING.md).