From bbf3093fd975037fe3edc507ac2b504649e59367 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:16:13 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat(mac):=20answer=20=E2=80=9Enext=20lectu?= =?UTF-8?q?re=E2=80=9C=20and=20=E2=80=9Edue=20cards=E2=80=9C=20as=20use=20?= =?UTF-8?q?cases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NextLectureUseCase finds the running or next lecture of an active module and phrases it („Als Nächstes: Analysis I, heute um 10:15 in HS 1.“); DueCardsUseCase counts due flashcards per module. Both are plain use cases so Shortcuts, Siri and later widgets share them. Refs #47 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TmmqmKmX8VDSfoPbzTS1ew --- Domain/UseCases/QuickInfoUseCases.swift | 71 +++++++++++++++++++++++++ StudyAppTests/QuickInfoTests.swift | 59 ++++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 Domain/UseCases/QuickInfoUseCases.swift create mode 100644 StudyAppTests/QuickInfoTests.swift 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/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.") + } +} From 90da60c774d115d50773e09a56026d47fb4a34a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:16:50 +0000 Subject: [PATCH 2/4] build: declare German as the development language The interface is German throughout; declaring it lets Siri and the Shortcuts app match the German App Shortcut phrases and lets system components such as Sparkle pick German. Refs #47 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TmmqmKmX8VDSfoPbzTS1ew --- Config/Info.plist | 2 ++ StudyApp.xcodeproj/project.pbxproj | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) 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/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; From 70edd5f0184a59be5d71992dc2f58ccb45c4ae88 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:17:22 +0000 Subject: [PATCH 3/4] feat(mac): add Shortcuts and Siri actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App Intents for „Nächste Vorlesung“, „Fällige Karteikarten“ and „Karteikarten lernen“ (with a module entity), plus App Shortcuts with German phrases. Intents get StudyAppIntentServices through AppDependencyManager; the module list for Siri is refreshed on launch. Refs #47 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TmmqmKmX8VDSfoPbzTS1ew --- App/Intents/ModuleEntity.swift | 37 +++++++++++ App/Intents/StudyAppIntentServices.swift | 38 +++++++++++ App/Intents/StudyAppIntents.swift | 81 ++++++++++++++++++++++++ App/StudyAppApp.swift | 5 ++ 4 files changed, 161 insertions(+) create mode 100644 App/Intents/ModuleEntity.swift create mode 100644 App/Intents/StudyAppIntentServices.swift create mode 100644 App/Intents/StudyAppIntents.swift 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 d894132..5b2b436 100644 --- a/App/StudyAppApp.swift +++ b/App/StudyAppApp.swift @@ -1,4 +1,5 @@ import AppKit +import AppIntents import SwiftUI @main @@ -50,6 +51,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 @@ -147,6 +150,8 @@ struct StudyAppApp: App { calendarViewModel.synchronizeModules() todayViewModel.load() examsViewModel.load() + // Lets Siri offer the current modules in „Starte Karteikarten …“. + StudyAppShortcuts.updateAppShortcutParameters() if navigation.destination == nil { navigation.showToday() } From 2e0246950e3c160da23812a830669fe9618d34c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:17:22 +0000 Subject: [PATCH 4/4] docs(mac): describe the Shortcuts and Siri actions Refs #47 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TmmqmKmX8VDSfoPbzTS1ew --- docs/FEATURES.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 264d3a7..ac40aa3 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -69,6 +69,18 @@ Code: `Presentation/Grades/`, `Domain/UseCases/GradeUseCases.swift`, `Domain/Ent Code: `Presentation/StudIP/`, `Domain/UseCases/StudIPUseCases.swift`, `Data/StudIP/` · Details: [STUDIP.md](STUDIP.md) +## 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).