From 86e492fea1462d110cac6fd133724146ac424aff Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:28:26 +0000 Subject: [PATCH 1/4] feat(mac): add the widget snapshot shared by app and widgets WidgetSnapshot (next lectures, due cards per module, upcoming exams) is the contract between the app and the widget extension; the app writes it as JSON into the App Group container, widgets only read it. WidgetSnapshotStore writes only changes, StudyAppLink encodes deep links from widgets into the app, and WidgetSnapshotBuilder turns the app's state into a snapshot. The new Shared folder belongs to the app target (and later the widget target). Refs #49 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TmmqmKmX8VDSfoPbzTS1ew --- .swiftlint.yml | 2 + .../Widgets/WidgetSnapshotBuilder.swift | 42 ++++++++ Shared/WidgetSnapshot.swift | 101 ++++++++++++++++++ StudyApp.xcodeproj/project.pbxproj | 7 ++ StudyAppTests/WidgetSnapshotTests.swift | 72 +++++++++++++ 5 files changed, 224 insertions(+) create mode 100644 Presentation/Widgets/WidgetSnapshotBuilder.swift create mode 100644 Shared/WidgetSnapshot.swift create mode 100644 StudyAppTests/WidgetSnapshotTests.swift diff --git a/.swiftlint.yml b/.swiftlint.yml index 1288898..37c47af 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -4,6 +4,8 @@ included: - Data - Domain - Presentation + - Shared + - Widgets - StudyAppTests excluded: - Presentation/Resources diff --git a/Presentation/Widgets/WidgetSnapshotBuilder.swift b/Presentation/Widgets/WidgetSnapshotBuilder.swift new file mode 100644 index 0000000..647eccb --- /dev/null +++ b/Presentation/Widgets/WidgetSnapshotBuilder.swift @@ -0,0 +1,42 @@ +import Foundation + +/// Turns the app's current state into the snapshot the widgets read. +enum WidgetSnapshotBuilder { + static let lectureCount = 4 + static let examCount = 3 + + static func snapshot( + events: [StudyCalendarEvent], + modules: [StudyModule], + dueCounts: [UUID: Int], + exams: [ExamOverview], + now: Date + ) -> WidgetSnapshot { + let active = Dictionary(uniqueKeysWithValues: modules.filter { !$0.isArchived }.map { ($0.id, $0) }) + let lectures = events + .filter { !$0.isAllDay && $0.moduleID.map { active[$0] != nil } == true } + .upcoming(after: now, limit: lectureCount) + .compactMap { event -> WidgetSnapshot.Lecture? in + guard let moduleID = event.moduleID, let module = active[moduleID] else { return nil } + return WidgetSnapshot.Lecture( + moduleID: module.id, moduleName: module.name, location: event.location, start: event.startDate, end: event.endDate + ) + } + let dueModules = dueCounts + .compactMap { id, due -> WidgetSnapshot.DueModule? in + guard due > 0, let module = active[id] else { return nil } + return WidgetSnapshot.DueModule(moduleID: id, moduleName: module.name, count: due) + } + .sorted { $0.count != $1.count ? $0.count > $1.count : $0.moduleName < $1.moduleName } + let upcomingExams = exams.prefix(examCount).map { overview in + WidgetSnapshot.Exam( + moduleID: overview.module.id, + moduleName: overview.module.name, + title: overview.exam.title, + date: overview.exam.startDate, + progress: overview.plan.progress + ) + } + return WidgetSnapshot(lectures: lectures, dueModules: dueModules, exams: Array(upcomingExams)) + } +} diff --git a/Shared/WidgetSnapshot.swift b/Shared/WidgetSnapshot.swift new file mode 100644 index 0000000..5441280 --- /dev/null +++ b/Shared/WidgetSnapshot.swift @@ -0,0 +1,101 @@ +import Foundation + +/// What the widgets show. The app writes it into the shared App Group container; the widget extension +/// only reads it, so widgets never touch the database. +nonisolated struct WidgetSnapshot: Codable, Equatable, Sendable { + struct Lecture: Codable, Equatable, Sendable { + let moduleID: UUID + let moduleName: String + let location: String + let start: Date + let end: Date + } + + struct DueModule: Codable, Equatable, Sendable { + let moduleID: UUID + let moduleName: String + let count: Int + } + + struct Exam: Codable, Equatable, Sendable { + let moduleID: UUID + let moduleName: String + let title: String + let date: Date + /// Share of the study plan already done, 0…1. + let progress: Double + } + + /// The next few lectures, so the widget timeline can move on without the app. + var lectures: [Lecture] = [] + var dueModules: [DueModule] = [] + var exams: [Exam] = [] + + var dueCards: Int { dueModules.reduce(0) { $0 + $1.count } } + + /// The lecture running at `date`, otherwise the next one. + func lecture(at date: Date) -> Lecture? { + lectures.first { $0.end > date } + } + + /// Moments at which the lecture widget changes: starts and ends of the stored lectures. + func changeDates(after date: Date) -> [Date] { + lectures.flatMap { [$0.start, $0.end] }.filter { $0 > date }.sorted() + } +} + +/// Reads and writes the snapshot in the App Group container shared by the app and its widgets. +nonisolated enum WidgetSnapshotStore { + /// macOS App Group identifiers are prefixed with the team ID. + static let appGroup = "VF66Y42L4U.germany.oldenburg.mueller.tristan.StudyApp" + static let fileName = "widget-snapshot.json" + + static var defaultURL: URL? { + FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup)?.appending(path: fileName) + } + + static func load(from url: URL?) -> WidgetSnapshot? { + guard let url, let data = try? Data(contentsOf: url) else { return nil } + return try? JSONDecoder().decode(WidgetSnapshot.self, from: data) + } + + /// - Returns: `true` when the stored snapshot changed, so widgets only reload when needed. + @discardableResult + static func save(_ snapshot: WidgetSnapshot, to url: URL?) throws -> Bool { + guard let url else { return false } + guard load(from: url) != snapshot else { return false } + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try JSONEncoder().encode(snapshot).write(to: url, options: .atomic) + return true + } +} + +/// Links from widgets into the app: `studyapp://today` or `studyapp://module//
`. +nonisolated enum StudyAppLink: Equatable, Sendable { + static let scheme = "studyapp" + + case today + /// `section` is a `ModuleSection` raw value such as `slides`, `flashcards` or `exam`. + case module(UUID, section: String) + + var url: URL { + switch self { + case .today: URL(string: "\(Self.scheme)://today")! + case .module(let id, let section): URL(string: "\(Self.scheme)://module/\(id.uuidString)/\(section)")! + } + } + + init?(url: URL) { + guard url.scheme == Self.scheme else { return nil } + let parts = [url.host ?? ""] + url.pathComponents.filter { $0 != "/" } + switch parts.first { + case "today": + self = .today + case "module": + guard parts.count == 3, let id = UUID(uuidString: parts[1]) else { return nil } + self = .module(id, section: parts[2]) + default: + return nil + } + } +} diff --git a/StudyApp.xcodeproj/project.pbxproj b/StudyApp.xcodeproj/project.pbxproj index fc2958a..1e7e8de 100644 --- a/StudyApp.xcodeproj/project.pbxproj +++ b/StudyApp.xcodeproj/project.pbxproj @@ -131,6 +131,11 @@ path = StudyAppTests; sourceTree = ""; }; + 98FF6A2F130A10CD02192AAC /* Shared */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Shared; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -166,6 +171,7 @@ 40BE87F03087BD9B00C94AF8 /* Data */, 40BE88133087BD9B00C94AF8 /* Domain */, 40BE88793087BD9B00C94AF8 /* Presentation */, + 98FF6A2F130A10CD02192AAC /* Shared */, 40BE88903087BD9B00C94AF8 /* StudyAppTests */, 40E753193087592100CA2C5A /* LICENSE 2 */, 40E7531A3087592100CA2C5A /* MILESTONE2.md */, @@ -206,6 +212,7 @@ 40BE87F03087BD9B00C94AF8 /* Data */, 40BE88133087BD9B00C94AF8 /* Domain */, 40BE88793087BD9B00C94AF8 /* Presentation */, + 98FF6A2F130A10CD02192AAC /* Shared */, ); name = StudyApp; packageProductDependencies = ( diff --git a/StudyAppTests/WidgetSnapshotTests.swift b/StudyAppTests/WidgetSnapshotTests.swift new file mode 100644 index 0000000..bbc7b0f --- /dev/null +++ b/StudyAppTests/WidgetSnapshotTests.swift @@ -0,0 +1,72 @@ +import Foundation +import Testing +@testable import StudyApp + +struct WidgetSnapshotTests { + let now = Date(timeIntervalSince1970: 1_761_642_000) + + private func lecture(inMinutes minutes: Double) -> WidgetSnapshot.Lecture { + let start = now.addingTimeInterval(minutes * 60) + return WidgetSnapshot.Lecture( + moduleID: UUID(), moduleName: "Analysis I", location: "HS 1", start: start, end: start.addingTimeInterval(5_400) + ) + } + + @Test func lectureAtADateIsTheRunningOrNextOne() { + let running = lecture(inMinutes: -30) + let next = lecture(inMinutes: 120) + let snapshot = WidgetSnapshot(lectures: [running, next]) + + #expect(snapshot.lecture(at: now) == running) + #expect(snapshot.lecture(at: now.addingTimeInterval(61 * 60)) == next) + #expect(snapshot.changeDates(after: now) == [running.end, next.start, next.end]) + } + + @Test func storeWritesOnlyChangesAndReadsBack() throws { + let url = FileManager.default.temporaryDirectory.appending(path: "widget-\(UUID().uuidString)/snapshot.json") + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let snapshot = WidgetSnapshot(dueModules: [.init(moduleID: UUID(), moduleName: "Analysis I", count: 4)]) + + #expect(try WidgetSnapshotStore.save(snapshot, to: url)) + #expect(try !WidgetSnapshotStore.save(snapshot, to: url)) + #expect(WidgetSnapshotStore.load(from: url)?.dueCards == 4) + } + + @Test func linksRoundTrip() { + let module = StudyAppLink.module(UUID(), section: "flashcards") + #expect(StudyAppLink(url: module.url) == module) + #expect(StudyAppLink(url: StudyAppLink.today.url) == .today) + #expect(StudyAppLink(url: URL(string: "https://example.org")!) == nil) + #expect(StudyAppLink(url: URL(string: "studyapp://module/kaputt/slides")!) == nil) + } +} + +@MainActor +struct WidgetSnapshotBuilderTests { + let now = Date(timeIntervalSince1970: 1_761_642_000) + + @Test func keepsUpcomingLecturesOfActiveModulesAndSortsDueCards() { + let analysis = StudyModule(id: UUID(), name: "Analysis I", code: "", color: .blue, createdAt: now) + let algebra = StudyModule(id: UUID(), name: "Lineare Algebra", code: "", color: .teal, createdAt: now) + let archived = StudyModule(id: UUID(), name: "Alt", code: "", color: .red, createdAt: now, isArchived: true) + func event(_ module: StudyModule?, inHours hours: Double) -> StudyCalendarEvent { + let start = now.addingTimeInterval(hours * 3_600) + return StudyCalendarEvent( + id: UUID(), externalID: UUID().uuidString, sourceID: UUID(), title: "VL", details: "", location: "HS 1", + startDate: start, endDate: start.addingTimeInterval(5_400), moduleID: module?.id + ) + } + + let snapshot = WidgetSnapshotBuilder.snapshot( + events: [event(analysis, inHours: -3), event(archived, inHours: 1), event(nil, inHours: 2), event(algebra, inHours: 4)], + modules: [analysis, algebra, archived], + dueCounts: [analysis.id: 3, algebra.id: 8, archived.id: 5], + exams: [], + now: now + ) + + #expect(snapshot.lectures.map(\.moduleName) == ["Lineare Algebra"]) + #expect(snapshot.dueModules.map(\.count) == [8, 3]) + #expect(snapshot.dueCards == 11) + } +} From 60b096990791d924b52b401411693b3c8bf0dfb0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:29:54 +0000 Subject: [PATCH 2/4] feat(mac): add the widget extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A WidgetKit extension with three widgets – „Nächste Vorlesung“ (small and medium, with due cards), „Fällige Karteikarten“ and „Klausur-Countdown“ – reads the snapshot from the App Group container. Its timeline adds an entry at every lecture start and end, so the lecture widget turns from „Als Nächstes“ to „Läuft“ on its own; clicks open the matching place through studyapp:// links. The project gets the StudyAppWidgets target (Shared + Widgets folders), embeds it into the app, and both share the App Group; the app registers the studyapp URL scheme. The QA build and the release script check that the extension is in the bundle. Refs #49 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TmmqmKmX8VDSfoPbzTS1ew --- Config/Info.plist | 11 ++ Config/StudyApp.entitlements | 5 + Config/StudyAppWidgets-Info.plist | 11 ++ Config/StudyAppWidgets.entitlements | 13 +++ StudyApp.xcodeproj/project.pbxproj | 156 ++++++++++++++++++++++++++++ Widgets/DueCardsWidget.swift | 43 ++++++++ Widgets/ExamCountdownWidget.swift | 54 ++++++++++ Widgets/NextLectureWidget.swift | 67 ++++++++++++ Widgets/StudyAppWidgets.swift | 55 ++++++++++ scripts/qa.sh | 3 +- scripts/release.sh | 2 + 11 files changed, 419 insertions(+), 1 deletion(-) create mode 100644 Config/StudyAppWidgets-Info.plist create mode 100644 Config/StudyAppWidgets.entitlements create mode 100644 Widgets/DueCardsWidget.swift create mode 100644 Widgets/ExamCountdownWidget.swift create mode 100644 Widgets/NextLectureWidget.swift create mode 100644 Widgets/StudyAppWidgets.swift diff --git a/Config/Info.plist b/Config/Info.plist index f9d062d..f977ba3 100644 --- a/Config/Info.plist +++ b/Config/Info.plist @@ -2,6 +2,17 @@ + CFBundleURLTypes + + + CFBundleURLName + germany.oldenburg.mueller.tristan.StudyApp + CFBundleURLSchemes + + studyapp + + + SUEnableAutomaticChecks SUEnableInstallerLauncherService diff --git a/Config/StudyApp.entitlements b/Config/StudyApp.entitlements index e147e01..52d37ff 100644 --- a/Config/StudyApp.entitlements +++ b/Config/StudyApp.entitlements @@ -8,6 +8,11 @@ com.apple.security.files.user-selected.read-write + + com.apple.security.application-groups + + VF66Y42L4U.germany.oldenburg.mueller.tristan.StudyApp + diff --git a/Config/StudyAppWidgets-Info.plist b/Config/StudyAppWidgets-Info.plist new file mode 100644 index 0000000..0f118fb --- /dev/null +++ b/Config/StudyAppWidgets-Info.plist @@ -0,0 +1,11 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/Config/StudyAppWidgets.entitlements b/Config/StudyAppWidgets.entitlements new file mode 100644 index 0000000..8fec50f --- /dev/null +++ b/Config/StudyAppWidgets.entitlements @@ -0,0 +1,13 @@ + + + + + com.apple.security.app-sandbox + + + com.apple.security.application-groups + + VF66Y42L4U.germany.oldenburg.mueller.tristan.StudyApp + + + diff --git a/StudyApp.xcodeproj/project.pbxproj b/StudyApp.xcodeproj/project.pbxproj index 1e7e8de..90714a6 100644 --- a/StudyApp.xcodeproj/project.pbxproj +++ b/StudyApp.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 4038669E3086CEC500EC91D7 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 40993992307FF19B00F43C4C /* Assets.xcassets */; }; 409391DB30881A720098AA8C /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 409391DA30881A720098AA8C /* Sparkle */; }; 409BB5C430801406004EC161 /* KaTeX in Resources */ = {isa = PBXBuildFile; fileRef = 409BB5C330801406004EC161 /* KaTeX */; }; + 4EFA4AD772182BAE3B4BB0ED /* StudyAppWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8344644A76E5CBB87DCF5E0C /* StudyAppWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -27,8 +28,29 @@ remoteGlobalIDString = 40F4A662307EDDEA009E2F4B; remoteInfo = StudyApp; }; + F920A1E111FEE15BD8890576 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 40F4A65B307EDDEA009E2F4B /* Project object */; + proxyType = 1; + remoteGlobalIDString = 7244174F28632C5EF0D4C196; + remoteInfo = StudyAppWidgets; + }; /* End PBXContainerItemProxy section */ +/* Begin PBXCopyFilesBuildPhase section */ + A273B08A88DE205EB5A882DD /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 4EFA4AD772182BAE3B4BB0ED /* StudyAppWidgets.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 40336C1F307F945700D308EF /* StudyApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = StudyApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 40336C20307F945700D308EF /* StudyAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = StudyAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -44,6 +66,7 @@ 40E7531D3087592100CA2C5A /* MILESTONE5_CALENDAR.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; name = MILESTONE5_CALENDAR.md; path = docs/history/MILESTONE5_CALENDAR.md; sourceTree = ""; }; 40E7531E3087592100CA2C5A /* MILESTONE6_UNI_WORKFLOW.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; name = MILESTONE6_UNI_WORKFLOW.md; path = docs/history/MILESTONE6_UNI_WORKFLOW.md; sourceTree = ""; }; 40E7537A3087592100CA2C5A /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; + 8344644A76E5CBB87DCF5E0C /* StudyAppWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = StudyAppWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -136,6 +159,11 @@ path = Shared; sourceTree = ""; }; + 42CE8B06CD5E242A1AE98810 /* Widgets */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Widgets; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -161,6 +189,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + E50997D8BB7D8528B04E7884 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -173,6 +208,7 @@ 40BE88793087BD9B00C94AF8 /* Presentation */, 98FF6A2F130A10CD02192AAC /* Shared */, 40BE88903087BD9B00C94AF8 /* StudyAppTests */, + 42CE8B06CD5E242A1AE98810 /* Widgets */, 40E753193087592100CA2C5A /* LICENSE 2 */, 40E7531A3087592100CA2C5A /* MILESTONE2.md */, 40E7531B3087592100CA2C5A /* MILESTONE3.md */, @@ -186,6 +222,7 @@ 40336C1F307F945700D308EF /* StudyApp.app */, 40336C20307F945700D308EF /* StudyAppTests.xctest */, 40336C21307F945700D308EF /* StudyAppUITests.xctest */, + 8344644A76E5CBB87DCF5E0C /* StudyAppWidgets.appex */, 40AC6A0A307FCE0D0025452A /* StudyApp_README.md */, 40993992307FF19B00F43C4C /* Assets.xcassets */, ); @@ -201,10 +238,12 @@ 40F4A65F307EDDEA009E2F4B /* Sources */, 40F4A660307EDDEA009E2F4B /* Frameworks */, 409BB5C0308013EF004EC161 /* Resources */, + A273B08A88DE205EB5A882DD /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + 0D4ACFB3F5210074AA55EAB8 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 40AC6A0E307FCF7B0025452A /* .github */, @@ -265,6 +304,29 @@ productReference = 40336C21307F945700D308EF /* StudyAppUITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; + 7244174F28632C5EF0D4C196 /* StudyAppWidgets */ = { + isa = PBXNativeTarget; + buildConfigurationList = 1D10BC3ACAE537D3A624384D /* Build configuration list for PBXNativeTarget "StudyAppWidgets" */; + buildPhases = ( + 8DA1049C15C90627E0A768A1 /* Sources */, + E50997D8BB7D8528B04E7884 /* Frameworks */, + 937DD9D39BE01777817AAA09 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 98FF6A2F130A10CD02192AAC /* Shared */, + 42CE8B06CD5E242A1AE98810 /* Widgets */, + ); + name = StudyAppWidgets; + packageProductDependencies = ( + ); + productName = StudyAppWidgets; + productReference = 8344644A76E5CBB87DCF5E0C /* StudyAppWidgets.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -286,6 +348,9 @@ CreatedOnToolsVersion = 26.6; TestTargetID = 40F4A662307EDDEA009E2F4B; }; + 7244174F28632C5EF0D4C196 = { + CreatedOnToolsVersion = 26.6; + }; }; }; buildConfigurationList = 40F4A65E307EDDEA009E2F4B /* Build configuration list for PBXProject "StudyApp" */; @@ -308,6 +373,7 @@ 40F4A662307EDDEA009E2F4B /* StudyApp */, 40F4A671307EDDEB009E2F4B /* StudyAppTests */, 40F4A67B307EDDEB009E2F4B /* StudyAppUITests */, + 7244174F28632C5EF0D4C196 /* StudyAppWidgets */, ); }; /* End PBXProject section */ @@ -336,6 +402,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 937DD9D39BE01777817AAA09 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -360,6 +433,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 8DA1049C15C90627E0A768A1 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -373,6 +453,11 @@ target = 40F4A662307EDDEA009E2F4B /* StudyApp */; targetProxy = 40F4A67D307EDDEB009E2F4B /* PBXContainerItemProxy */; }; + 0D4ACFB3F5210074AA55EAB8 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 7244174F28632C5EF0D4C196 /* StudyAppWidgets */; + targetProxy = F920A1E111FEE15BD8890576 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -674,6 +759,68 @@ }; name = Release; }; + 2F3ACFF49996E2D04BFA9AD9 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = Config/StudyAppWidgets.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = VF66Y42L4U; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "Config/StudyAppWidgets-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = StudyApp; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = germany.oldenburg.mueller.tristan.StudyApp.Widgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + EA987C273C4C93D8791A1520 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = Config/StudyAppWidgets.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = VF66Y42L4U; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "Config/StudyAppWidgets-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = StudyApp; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = germany.oldenburg.mueller.tristan.StudyApp.Widgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -713,6 +860,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 1D10BC3ACAE537D3A624384D /* Build configuration list for PBXNativeTarget "StudyAppWidgets" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2F3ACFF49996E2D04BFA9AD9 /* Debug */, + EA987C273C4C93D8791A1520 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/Widgets/DueCardsWidget.swift b/Widgets/DueCardsWidget.swift new file mode 100644 index 0000000..0b205d4 --- /dev/null +++ b/Widgets/DueCardsWidget.swift @@ -0,0 +1,43 @@ +import SwiftUI +import WidgetKit + +/// Due flashcards in total and per module; a click opens the module with the most. +struct DueCardsWidget: Widget { + var body: some WidgetConfiguration { + StaticConfiguration(kind: "DueCards", provider: SnapshotProvider()) { entry in + DueCardsSummary(snapshot: entry.snapshot) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .widgetURL(entry.snapshot.dueModules.first.map { StudyAppLink.module($0.moduleID, section: "flashcards").url }) + .containerBackground(.fill.tertiary, for: .widget) + } + .configurationDisplayName("Fällige Karteikarten") + .description("Wie viele Karteikarten heute fällig sind.") + .supportedFamilies([.systemSmall]) + } +} + +/// Total of due cards and the modules with the most; shared by two widgets. +struct DueCardsSummary: View { + let snapshot: WidgetSnapshot + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Label("Wiederholen", systemImage: "rectangle.stack") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + Text("\(snapshot.dueCards)") + .font(.system(size: 34, weight: .bold, design: .rounded)) + .contentTransition(.numericText()) + Text(snapshot.dueCards == 1 ? "Karte fällig" : "Karten fällig") + .font(.caption) + .foregroundStyle(.secondary) + Spacer(minLength: 0) + ForEach(snapshot.dueModules.prefix(2), id: \.moduleID) { module in + Text("\(module.moduleName) \(module.count)") + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } +} diff --git a/Widgets/ExamCountdownWidget.swift b/Widgets/ExamCountdownWidget.swift new file mode 100644 index 0000000..912c949 --- /dev/null +++ b/Widgets/ExamCountdownWidget.swift @@ -0,0 +1,54 @@ +import SwiftUI +import WidgetKit + +/// Days until the next exam with the study plan progress. +struct ExamCountdownWidget: Widget { + var body: some WidgetConfiguration { + StaticConfiguration(kind: "ExamCountdown", provider: SnapshotProvider()) { entry in + ExamCountdownView(entry: entry) + .containerBackground(.fill.tertiary, for: .widget) + } + .configurationDisplayName("Klausur-Countdown") + .description("Tage bis zur nächsten Klausur und dein Fortschritt im Lernplan.") + .supportedFamilies([.systemSmall]) + } +} + +private struct ExamCountdownView: View { + let entry: SnapshotEntry + + var body: some View { + if let exam = entry.snapshot.exams.first(where: { $0.date >= Calendar.current.startOfDay(for: entry.date) }) { + let days = Calendar.current.dateComponents( + [.day], from: Calendar.current.startOfDay(for: entry.date), to: Calendar.current.startOfDay(for: exam.date) + ).day ?? 0 + VStack(alignment: .leading, spacing: 4) { + Label("Klausur", systemImage: "flag.checkered") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + Text(exam.moduleName) + .font(.headline) + .lineLimit(2) + Spacer(minLength: 0) + Text(days == 0 ? "heute" : days == 1 ? "morgen" : "in \(days) Tagen") + .font(.title3.weight(.semibold)) + .foregroundStyle(days <= 3 ? .red : .primary) + ProgressView(value: exam.progress) + Text("Lernplan \(Int((exam.progress * 100).rounded())) %") + .font(.caption2) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .widgetURL(StudyAppLink.module(exam.moduleID, section: "exam").url) + } else { + VStack(alignment: .leading, spacing: 6) { + Image(systemName: "flag.checkered") + .foregroundStyle(.secondary) + Text("Keine Klausur geplant") + .font(.headline) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .widgetURL(StudyAppLink.today.url) + } + } +} diff --git a/Widgets/NextLectureWidget.swift b/Widgets/NextLectureWidget.swift new file mode 100644 index 0000000..847af1a --- /dev/null +++ b/Widgets/NextLectureWidget.swift @@ -0,0 +1,67 @@ +import SwiftUI +import WidgetKit + +/// The running or next lecture; a click opens its slides. +struct NextLectureWidget: Widget { + var body: some WidgetConfiguration { + StaticConfiguration(kind: "NextLecture", provider: SnapshotProvider()) { entry in + NextLectureView(entry: entry) + .containerBackground(.fill.tertiary, for: .widget) + } + .configurationDisplayName("Nächste Vorlesung") + .description("Die laufende oder nächste Vorlesung mit Raum.") + .supportedFamilies([.systemSmall, .systemMedium]) + } +} + +private struct NextLectureView: View { + let entry: SnapshotEntry + @Environment(\.widgetFamily) private var family + + var body: some View { + if let lecture = entry.snapshot.lecture(at: entry.date) { + let isLive = lecture.start <= entry.date + HStack(alignment: .top, spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + Text(isLive ? "LÄUFT" : "ALS NÄCHSTES") + .font(.caption2.weight(.semibold)) + .foregroundStyle(isLive ? .red : .secondary) + Text(lecture.moduleName) + .font(.headline) + .lineLimit(2) + Spacer(minLength: 0) + if isLive { + Text("bis \(lecture.end.formatted(date: .omitted, time: .shortened))") + .font(.caption) + ProgressView(timerInterval: lecture.start...lecture.end, countsDown: false) + .labelsHidden() + .tint(.red) + } else { + Text(lecture.start, style: .relative) + .font(.caption) + } + if !lecture.location.isEmpty { + Label(lecture.location, systemImage: "mappin") + .font(.caption) + .foregroundStyle(.secondary) + } + } + if family == .systemMedium { + Divider() + DueCardsSummary(snapshot: entry.snapshot) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .widgetURL(StudyAppLink.module(lecture.moduleID, section: "slides").url) + } else { + VStack(alignment: .leading, spacing: 6) { + Image(systemName: "calendar") + .foregroundStyle(.secondary) + Text("Keine Vorlesung mehr geplant") + .font(.headline) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .widgetURL(StudyAppLink.today.url) + } + } +} diff --git a/Widgets/StudyAppWidgets.swift b/Widgets/StudyAppWidgets.swift new file mode 100644 index 0000000..40a4cac --- /dev/null +++ b/Widgets/StudyAppWidgets.swift @@ -0,0 +1,55 @@ +import SwiftUI +import WidgetKit + +/// The widgets of StudyApp. They read the snapshot the app writes and never touch the database. +@main +struct StudyAppWidgets: WidgetBundle { + var body: some Widget { + NextLectureWidget() + DueCardsWidget() + ExamCountdownWidget() + } +} + +/// One timeline entry: the snapshot as of `date`. +struct SnapshotEntry: TimelineEntry { + let date: Date + let snapshot: WidgetSnapshot +} + +/// Shared timeline: an entry now and one at every lecture start or end in the next 12 hours, so the +/// lecture widget switches from „Als Nächstes“ to „Läuft“ without the app. +struct SnapshotProvider: TimelineProvider { + static let horizon: TimeInterval = 12 * 3_600 + + func placeholder(in context: Context) -> SnapshotEntry { + SnapshotEntry(date: .now, snapshot: .preview) + } + + func getSnapshot(in context: Context, completion: @escaping (SnapshotEntry) -> Void) { + let snapshot = context.isPreview ? .preview : WidgetSnapshotStore.load(from: WidgetSnapshotStore.defaultURL) ?? WidgetSnapshot() + completion(SnapshotEntry(date: .now, snapshot: snapshot)) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let now = Date.now + let snapshot = WidgetSnapshotStore.load(from: WidgetSnapshotStore.defaultURL) ?? WidgetSnapshot() + let dates = [now] + snapshot.changeDates(after: now).filter { $0 < now.addingTimeInterval(Self.horizon) } + let entries = dates.map { SnapshotEntry(date: $0, snapshot: snapshot) } + // The app reloads the timelines when its data changes; this is only the fallback. + completion(Timeline(entries: entries, policy: .after(now.addingTimeInterval(3_600)))) + } +} + +extension WidgetSnapshot { + /// Sample data for the widget gallery. + static var preview: WidgetSnapshot { + let start = Date.now.addingTimeInterval(1_800) + let module = UUID() + return WidgetSnapshot( + lectures: [Lecture(moduleID: module, moduleName: "Analysis I", location: "HS 1", start: start, end: start.addingTimeInterval(5_400))], + dueModules: [DueModule(moduleID: module, moduleName: "Analysis I", count: 12), DueModule(moduleID: UUID(), moduleName: "Lineare Algebra", count: 8)], + exams: [Exam(moduleID: module, moduleName: "Analysis I", title: "Klausur", date: .now.addingTimeInterval(12 * 86_400), progress: 0.4)] + ) + } +} diff --git a/scripts/qa.sh b/scripts/qa.sh index eafe8a6..48709cd 100755 --- a/scripts/qa.sh +++ b/scripts/qa.sh @@ -70,7 +70,8 @@ check_bundle() { local resources="$1/Contents/Resources" [[ -f "$resources/AppIcon.icns" ]] || fail "App-Icon fehlt im Bundle – liegt Assets.xcassets in „Copy Bundle Resources“?" [[ -f "$resources/Assets.car" ]] || fail "Kompilierter Asset-Katalog (Assets.car) fehlt im Bundle." - ok "App-Icon und Asset-Katalog im Bundle" + [[ -d "$1/Contents/PlugIns/StudyAppWidgets.appex" ]] || fail "Widget-Extension fehlt im Bundle – ist sie unter „Embed Foundation Extensions“ eingetragen?" + ok "App-Icon, Asset-Katalog und Widgets im Bundle" } run_tests() { diff --git a/scripts/release.sh b/scripts/release.sh index 6a08f71..fc5c1e6 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -66,6 +66,8 @@ APP="$ARCHIVE/Products/Applications/StudyApp.app" [[ -d "$APP" ]] || { echo "✗ StudyApp.app wurde nicht gefunden: $APP" >&2; exit 1; } [[ -f "$APP/Contents/Resources/AppIcon.icns" ]] \ || { echo "✗ App-Icon fehlt im Bundle – liegt Assets.xcassets in „Copy Bundle Resources“?" >&2; exit 1; } +[[ -d "$APP/Contents/PlugIns/StudyAppWidgets.appex" ]] \ + || { echo "✗ Widget-Extension fehlt im Bundle." >&2; exit 1; } # Startet die App überhaupt? dyld lädt Sparkle nur bei gleicher Team-ID oder abgeschalteter Library Validation. team_id() { codesign -dv "$1" 2>&1 | sed -n 's/^TeamIdentifier=//p'; } From 7aa92cb58d9b0c4bd30f38d6f1d3769b4dabdf4e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:31:11 +0000 Subject: [PATCH 3/4] feat(mac): publish the widget snapshot and open widget links The app writes the widget snapshot on launch and with the five-minute loop; widgets reload only when it changed. studyapp:// links from the widgets open Today or a module section (slides resume reading). Refs #49 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TmmqmKmX8VDSfoPbzTS1ew --- App/StudyAppApp.swift | 12 ++++++++++++ App/WidgetSnapshotPublisher.swift | 21 +++++++++++++++++++++ Presentation/Navigation/AppNavigation.swift | 11 +++++++++++ StudyAppTests/WidgetSnapshotTests.swift | 21 +++++++++++++++++++++ 4 files changed, 65 insertions(+) create mode 100644 App/WidgetSnapshotPublisher.swift diff --git a/App/StudyAppApp.swift b/App/StudyAppApp.swift index d894132..1ea0943 100644 --- a/App/StudyAppApp.swift +++ b/App/StudyAppApp.swift @@ -104,6 +104,10 @@ struct StudyAppApp: App { .tint(Theme.accent) .preferredColorScheme(appearance.colorScheme) .task { await startUp() } + .onOpenURL { url in + guard let link = StudyAppLink(url: url) else { return } + navigation.open(link) + } .task { await keepClockRunning() } .task { await keepSourcesFresh() } .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in @@ -156,10 +160,16 @@ struct StudyAppApp: App { await calendarViewModel.refreshStaleSubscriptions() autopilot.run(isLaunch: false) await studIPViewModel.syncIfDue() + publishWidgetSnapshot() await backupViewModel.runAutomaticBackupIfDue() await notificationsViewModel.refresh() } + /// Hands the current lectures, due cards and exams to the widgets. + private func publishWidgetSnapshot() { + WidgetSnapshotPublisher().publish(calendar: calendarViewModel, today: todayViewModel, exams: examsViewModel) + } + /// Opens what a clicked notification refers to. private static func open(_ target: NotificationTarget, navigation: AppNavigation) { NSApplication.shared.activate() @@ -186,6 +196,8 @@ struct StudyAppApp: App { try? await Task.sleep(for: .seconds(300)) await calendarViewModel.refreshStaleSubscriptions() await studIPViewModel.syncIfDue() + todayViewModel.load() + publishWidgetSnapshot() // Picks up new or moved lectures and assignments. await notificationsViewModel.refresh() } diff --git a/App/WidgetSnapshotPublisher.swift b/App/WidgetSnapshotPublisher.swift new file mode 100644 index 0000000..1555b3c --- /dev/null +++ b/App/WidgetSnapshotPublisher.swift @@ -0,0 +1,21 @@ +import Foundation +import WidgetKit + +/// Writes the widget snapshot and reloads the widgets when it changed. +@MainActor +struct WidgetSnapshotPublisher { + var url: URL? = WidgetSnapshotStore.defaultURL + + func publish(calendar: StudyCalendarViewModel, today: TodayViewModel, exams: ExamsViewModel) { + let snapshot = WidgetSnapshotBuilder.snapshot( + events: calendar.events, + modules: today.modules, + dueCounts: today.dueCounts, + exams: exams.upcoming, + now: calendar.referenceDate + ) + // Without an App Group container (e.g. unsigned builds) there are simply no widget updates. + guard (try? WidgetSnapshotStore.save(snapshot, to: url)) == true else { return } + WidgetCenter.shared.reloadAllTimelines() + } +} diff --git a/Presentation/Navigation/AppNavigation.swift b/Presentation/Navigation/AppNavigation.swift index eaa4649..7fd1592 100644 --- a/Presentation/Navigation/AppNavigation.swift +++ b/Presentation/Navigation/AppNavigation.swift @@ -96,6 +96,17 @@ final class AppNavigation { navigate(to: .module(id, section: section)) } + /// Opens what a widget link points to; unknown sections fall back to the module overview. + func open(_ link: StudyAppLink) { + switch link { + case .today: + showToday() + case .module(let id, let rawSection): + let section = ModuleSection(rawValue: rawSection) ?? .overview + show(module: id, section: section, intent: section == .slides ? .resumeSlides : nil) + } + } + func go(to location: NavigationLocation) { navigate(to: location) } diff --git a/StudyAppTests/WidgetSnapshotTests.swift b/StudyAppTests/WidgetSnapshotTests.swift index bbc7b0f..480d679 100644 --- a/StudyAppTests/WidgetSnapshotTests.swift +++ b/StudyAppTests/WidgetSnapshotTests.swift @@ -70,3 +70,24 @@ struct WidgetSnapshotBuilderTests { #expect(snapshot.dueCards == 11) } } + +@MainActor +struct WidgetLinkNavigationTests { + @Test func linksOpenTheModuleSection() throws { + let name = "WidgetLinkNavigationTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: name)) + defer { defaults.removePersistentDomain(forName: name) } + let navigation = AppNavigation(defaults: defaults) + let module = UUID() + + navigation.open(StudyAppLink.module(module, section: "slides")) + #expect(navigation.currentLocation == .module(module, section: .slides)) + #expect(navigation.pendingIntent == .resumeSlides) + + navigation.open(StudyAppLink.module(module, section: "unbekannt")) + #expect(navigation.currentLocation == .module(module, section: .overview)) + + navigation.open(StudyAppLink.today) + #expect(navigation.currentLocation == .today) + } +} From 819f5fa090c4e1ebd035749aec4a244eb18e4ab8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:31:11 +0000 Subject: [PATCH 4/4] docs(mac): describe the widgets and the shared folder Refs #49 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TmmqmKmX8VDSfoPbzTS1ew --- docs/ARCHITECTURE.md | 2 ++ docs/FEATURES.md | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3255200..85029e8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -24,6 +24,8 @@ Dependencies point **inward**: Presentation and Data both depend on Domain, Doma | Domain | `Domain/` | Foundation | `Entities/` (value types such as `StudyModule`, `Semester`, `CourseFile`), `Repositories/` (protocols = ports), `UseCases/` (one application action each), `Support/` | | Data | `Data/` | Domain, SwiftData, PDFKit, Security, URLSession | `SwiftData/` (`@Model` records + mapping), `Repositories/` (SwiftData implementations), `Calendar/`, `StudIP/`, `Files/`, `PDF/`, `Preferences/` | | Presentation | `Presentation/` | Domain, SwiftUI, AppKit, WebKit | One folder per feature with views and view models; `DesignSystem/` for shared components and `Theme` | +| Shared | `Shared/` | Foundation | Data contract between app and widget extension (`WidgetSnapshot`, `StudyAppLink`) | +| Widgets | `Widgets/` | Shared, WidgetKit, SwiftUI | Widget extension; reads the snapshot only | | App | `App/` | everything | `AppContainer` (composition root), `StudyAppApp`, `AppUpdater` | ## Composition root diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 264d3a7..a18a8f2 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) +## Widgets + +Three widgets for the desktop and Notification Center (add them via right-click on the desktop → „Widgets bearbeiten …“): + +- **Nächste Vorlesung** (small, medium): the running or next lecture with room and progress; medium also shows the due cards. Switches from „Als Nächstes“ to „Läuft“ on its own. +- **Fällige Karteikarten** (small): total and the modules with the most due cards. +- **Klausur-Countdown** (small): days until the next exam and study plan progress. + +The app writes a small snapshot (`WidgetSnapshot`) into the App Group container shared with the widget extension on launch and every five minutes; widgets never open the database. Clicking a widget opens the slides, flashcards or exam plan through `studyapp://` links. + +Code: `Shared/`, `Widgets/` (extension target `StudyAppWidgets`), `App/WidgetSnapshotPublisher.swift` + ## Updates and settings - Automatic updates through Sparkle („Nach Updates suchen …“ in the app menu). See [RELEASING.md](RELEASING.md).