Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions App/Intents/ModuleEntity.swift
Original file line number Diff line number Diff line change
@@ -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

Check warning on line 17 in App/Intents/ModuleEntity.swift

View workflow job for this annotation

GitHub Actions / Build & Tests (macOS)

'nonisolated' cannot be applied to mutable stored properties; this is an error in the Swift 6 language mode

Check warning on line 17 in App/Intents/ModuleEntity.swift

View workflow job for this annotation

GitHub Actions / Build & Tests (macOS)

'nonisolated' cannot be applied to mutable stored properties; this is an error in the Swift 6 language mode

@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)
}
}
38 changes: 38 additions & 0 deletions App/Intents/StudyAppIntentServices.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
81 changes: 81 additions & 0 deletions App/Intents/StudyAppIntents.swift
Original file line number Diff line number Diff line change
@@ -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

Check warning on line 9 in App/Intents/StudyAppIntents.swift

View workflow job for this annotation

GitHub Actions / Build & Tests (macOS)

'nonisolated' cannot be applied to mutable stored properties; this is an error in the Swift 6 language mode

Check warning on line 9 in App/Intents/StudyAppIntents.swift

View workflow job for this annotation

GitHub Actions / Build & Tests (macOS)

'nonisolated' cannot be applied to mutable stored properties; this is an error in the Swift 6 language mode

@MainActor
func perform() async throws -> some IntentResult & ReturnsValue<String> & 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

Check warning on line 23 in App/Intents/StudyAppIntents.swift

View workflow job for this annotation

GitHub Actions / Build & Tests (macOS)

'nonisolated' cannot be applied to mutable stored properties; this is an error in the Swift 6 language mode

Check warning on line 23 in App/Intents/StudyAppIntents.swift

View workflow job for this annotation

GitHub Actions / Build & Tests (macOS)

'nonisolated' cannot be applied to mutable stored properties; this is an error in the Swift 6 language mode

@MainActor
func perform() async throws -> some IntentResult & ReturnsValue<String> & 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

Check warning on line 39 in App/Intents/StudyAppIntents.swift

View workflow job for this annotation

GitHub Actions / Build & Tests (macOS)

'nonisolated' cannot be applied to mutable stored properties; this is an error in the Swift 6 language mode

Check warning on line 39 in App/Intents/StudyAppIntents.swift

View workflow job for this annotation

GitHub Actions / Build & Tests (macOS)

'nonisolated' cannot be applied to mutable stored properties; this is an error in the Swift 6 language mode

@Dependency private var services: StudyAppIntentServices

Check warning on line 41 in App/Intents/StudyAppIntents.swift

View workflow job for this annotation

GitHub Actions / Build & Tests (macOS)

'nonisolated' cannot be applied to mutable stored properties; this is an error in the Swift 6 language mode

Check warning on line 41 in App/Intents/StudyAppIntents.swift

View workflow job for this annotation

GitHub Actions / Build & Tests (macOS)

'nonisolated' cannot be applied to mutable stored properties; this is an error in the Swift 6 language mode

@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"
)
}
}
5 changes: 5 additions & 0 deletions App/StudyAppApp.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import AppKit
import AppIntents
import SwiftUI

@main
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
Expand Down
2 changes: 2 additions & 0 deletions Config/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>SUEnableAutomaticChecks</key>
<true/>
<key>SUEnableInstallerLauncherService</key>
Expand Down
71 changes: 71 additions & 0 deletions Domain/UseCases/QuickInfoUseCases.swift
Original file line number Diff line number Diff line change
@@ -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)."
}
}
4 changes: 2 additions & 2 deletions StudyApp.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -282,10 +282,10 @@
};
};
buildConfigurationList = 40F4A65E307EDDEA009E2F4B /* Build configuration list for PBXProject "StudyApp" */;
developmentRegion = en;
developmentRegion = de;
hasScannedForEncodings = 0;
knownRegions = (
en,
de,
Base,
);
mainGroup = 40F4A65A307EDDEA009E2F4B;
Expand Down
59 changes: 59 additions & 0 deletions StudyAppTests/QuickInfoTests.swift
Original file line number Diff line number Diff line change
@@ -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.")
}
}
12 changes: 12 additions & 0 deletions docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading