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/App/StudyAppApp.swift b/App/StudyAppApp.swift index 5f1d012..3667acf 100644 --- a/App/StudyAppApp.swift +++ b/App/StudyAppApp.swift @@ -129,6 +129,10 @@ struct StudyAppApp: App { let target = SearchTarget(identifier: identifier) else { return } navigation.open(target) } + .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 @@ -215,6 +219,7 @@ struct StudyAppApp: App { await studIPViewModel.refreshSessionState() await studIPViewModel.syncIfDue() await reindexSearch() + publishWidgetSnapshot() await backupViewModel.runAutomaticBackupIfDue() await notificationsViewModel.refresh() } @@ -230,6 +235,11 @@ struct StudyAppApp: App { try? await container.makeReindexSearch()() } + /// 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() @@ -259,6 +269,8 @@ struct StudyAppApp: App { // Switches to tomorrow's menu after lunch; cached, so usually no request. await mensaViewModel.refresh() await reindexSearch() + 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/Config/Info.plist b/Config/Info.plist index a213840..7bb2ea5 100644 --- a/Config/Info.plist +++ b/Config/Info.plist @@ -4,6 +4,17 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) + 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/Presentation/Navigation/AppNavigation.swift b/Presentation/Navigation/AppNavigation.swift index 13925d8..5ab5b89 100644 --- a/Presentation/Navigation/AppNavigation.swift +++ b/Presentation/Navigation/AppNavigation.swift @@ -109,6 +109,17 @@ final class AppNavigation { } } + /// 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/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 7dde112..c741da6 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 */ @@ -131,6 +154,16 @@ path = StudyAppTests; sourceTree = ""; }; + 98FF6A2F130A10CD02192AAC /* Shared */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Shared; + sourceTree = ""; + }; + 42CE8B06CD5E242A1AE98810 /* Widgets */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Widgets; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -156,6 +189,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + E50997D8BB7D8528B04E7884 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -166,7 +206,9 @@ 40BE87F03087BD9B00C94AF8 /* Data */, 40BE88133087BD9B00C94AF8 /* Domain */, 40BE88793087BD9B00C94AF8 /* Presentation */, + 98FF6A2F130A10CD02192AAC /* Shared */, 40BE88903087BD9B00C94AF8 /* StudyAppTests */, + 42CE8B06CD5E242A1AE98810 /* Widgets */, 40E753193087592100CA2C5A /* LICENSE 2 */, 40E7531A3087592100CA2C5A /* MILESTONE2.md */, 40E7531B3087592100CA2C5A /* MILESTONE3.md */, @@ -180,6 +222,7 @@ 40336C1F307F945700D308EF /* StudyApp.app */, 40336C20307F945700D308EF /* StudyAppTests.xctest */, 40336C21307F945700D308EF /* StudyAppUITests.xctest */, + 8344644A76E5CBB87DCF5E0C /* StudyAppWidgets.appex */, 40AC6A0A307FCE0D0025452A /* StudyApp_README.md */, 40993992307FF19B00F43C4C /* Assets.xcassets */, ); @@ -195,10 +238,12 @@ 40F4A65F307EDDEA009E2F4B /* Sources */, 40F4A660307EDDEA009E2F4B /* Frameworks */, 409BB5C0308013EF004EC161 /* Resources */, + A273B08A88DE205EB5A882DD /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + 0D4ACFB3F5210074AA55EAB8 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 40AC6A0E307FCF7B0025452A /* .github */, @@ -206,6 +251,7 @@ 40BE87F03087BD9B00C94AF8 /* Data */, 40BE88133087BD9B00C94AF8 /* Domain */, 40BE88793087BD9B00C94AF8 /* Presentation */, + 98FF6A2F130A10CD02192AAC /* Shared */, ); name = StudyApp; packageProductDependencies = ( @@ -258,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 */ @@ -279,6 +348,9 @@ CreatedOnToolsVersion = 26.6; TestTargetID = 40F4A662307EDDEA009E2F4B; }; + 7244174F28632C5EF0D4C196 = { + CreatedOnToolsVersion = 26.6; + }; }; }; buildConfigurationList = 40F4A65E307EDDEA009E2F4B /* Build configuration list for PBXProject "StudyApp" */; @@ -301,6 +373,7 @@ 40F4A662307EDDEA009E2F4B /* StudyApp */, 40F4A671307EDDEB009E2F4B /* StudyAppTests */, 40F4A67B307EDDEB009E2F4B /* StudyAppUITests */, + 7244174F28632C5EF0D4C196 /* StudyAppWidgets */, ); }; /* End PBXProject section */ @@ -329,6 +402,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 937DD9D39BE01777817AAA09 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -353,6 +433,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 8DA1049C15C90627E0A768A1 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -366,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 */ @@ -667,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 */ @@ -706,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/StudyAppTests/WidgetSnapshotTests.swift b/StudyAppTests/WidgetSnapshotTests.swift new file mode 100644 index 0000000..480d679 --- /dev/null +++ b/StudyAppTests/WidgetSnapshotTests.swift @@ -0,0 +1,93 @@ +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) + } +} + +@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) + } +} 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/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 f1f0ff6..e411c4b 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -110,6 +110,18 @@ Active modules, notes with content and study sheets appear in the macOS search ( Code: `Domain/UseCases/SearchIndexUseCases.swift`, `Data/Spotlight/` +## 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). 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'; }