diff --git a/Calendarr iOS.xcodeproj/project.pbxproj b/Calendarr iOS.xcodeproj/project.pbxproj index bebfaa5..a6d7336 100644 --- a/Calendarr iOS.xcodeproj/project.pbxproj +++ b/Calendarr iOS.xcodeproj/project.pbxproj @@ -10,13 +10,13 @@ 3934CF4630544C2E003958B8 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3934CF4530544C2E003958B8 /* WidgetKit.framework */; }; 3934CF4830544C2E003958B8 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3934CF4730544C2E003958B8 /* SwiftUI.framework */; }; 3934CF5530544C2E003958B8 /* CalendarrWatchComplications.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 3934CF4330544C2E003958B8 /* CalendarrWatchComplications.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 39E1B0010001000000000001 /* CalendarrWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 3934CF1A30544C01003958B8 /* CalendarrWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; AA9701000001000000000001 /* AppAuth in Frameworks */ = {isa = PBXBuildFile; productRef = AA9701000002000000000001 /* AppAuth */; }; CA1E4D0A0002000000000001 /* CalendarrCore in Frameworks */ = {isa = PBXBuildFile; productRef = CA1E4D0A0001000000000001 /* CalendarrCore */; }; CA1E4D0A0002000000000002 /* CalendarrCore in Frameworks */ = {isa = PBXBuildFile; productRef = CA1E4D0A0001000000000002 /* CalendarrCore */; }; CA1E4D0A0002000000000003 /* CalendarrCore in Frameworks */ = {isa = PBXBuildFile; productRef = CA1E4D0A0001000000000003 /* CalendarrCore */; }; CA1E4D0A0002000000000004 /* CalendarrCore in Frameworks */ = {isa = PBXBuildFile; productRef = CA1E4D0A0001000000000004 /* CalendarrCore */; }; D0001A0CABCDEF0100AB5001 /* CalendarrWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D0001A02ABCDEF0100AB5001 /* CalendarrWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - 39E1B0010001000000000001 /* CalendarrWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 3934CF1A30544C01003958B8 /* CalendarrWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -48,13 +48,6 @@ remoteGlobalIDString = 3934CF4230544C2E003958B8; remoteInfo = CalendarrWatchComplications; }; - D0001A0DABCDEF0100AB5001 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = C0000301FB4E10100AB5001 /* Project object */; - proxyType = 1; - remoteGlobalIDString = D0001A01ABCDEF0100AB5001; - remoteInfo = CalendarrWidgets; - }; 39E1B0010003000000000001 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = C0000301FB4E10100AB5001 /* Project object */; @@ -62,6 +55,13 @@ remoteGlobalIDString = 3934CF1930544C01003958B8; remoteInfo = CalendarrWatch; }; + D0001A0DABCDEF0100AB5001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = C0000301FB4E10100AB5001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = D0001A01ABCDEF0100AB5001; + remoteInfo = CalendarrWidgets; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -76,17 +76,6 @@ name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; }; - D0001A0BABCDEF0100AB5001 /* Embed Foundation Extensions */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 13; - files = ( - D0001A0CABCDEF0100AB5001 /* CalendarrWidgets.appex in Embed Foundation Extensions */, - ); - name = "Embed Foundation Extensions"; - runOnlyForDeploymentPostprocessing = 0; - }; 39E1B0010002000000000001 /* Embed Watch Content */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -98,6 +87,17 @@ name = "Embed Watch Content"; runOnlyForDeploymentPostprocessing = 0; }; + D0001A0BABCDEF0100AB5001 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + D0001A0CABCDEF0100AB5001 /* CalendarrWidgets.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @@ -158,6 +158,11 @@ path = CalendarrWatchComplications; sourceTree = ""; }; + 39E1B0010005000000000001 /* CalendarrWatchShared */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = CalendarrWatchShared; + sourceTree = ""; + }; C0000D01FC4E10100AB5001 /* Calendarr iOS */ = { isa = PBXFileSystemSynchronizedRootGroup; path = "Calendarr iOS"; @@ -260,6 +265,7 @@ 3934CF4930544C2E003958B8 /* CalendarrWatchComplications */, 3934CF4430544C2E003958B8 /* Frameworks */, C0000C01FB4E10100AB5001 /* Products */, + 39E1B0010005000000000001 /* CalendarrWatchShared */, ); sourceTree = ""; }; @@ -319,6 +325,7 @@ ); fileSystemSynchronizedGroups = ( 3934CF1B30544C01003958B8 /* CalendarrWatch */, + 39E1B0010005000000000001 /* CalendarrWatchShared */, ); name = CalendarrWatch; packageProductDependencies = ( @@ -388,6 +395,7 @@ ); fileSystemSynchronizedGroups = ( 3934CF4930544C2E003958B8 /* CalendarrWatchComplications */, + 39E1B0010005000000000001 /* CalendarrWatchShared */, ); name = CalendarrWatchComplications; packageProductDependencies = ( @@ -641,16 +649,16 @@ target = 3934CF4230544C2E003958B8 /* CalendarrWatchComplications */; targetProxy = 3934CF5330544C2E003958B8 /* PBXContainerItemProxy */; }; - D0001A0EABCDEF0100AB5001 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = D0001A01ABCDEF0100AB5001 /* CalendarrWidgets */; - targetProxy = D0001A0DABCDEF0100AB5001 /* PBXContainerItemProxy */; - }; 39E1B0010004000000000001 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 3934CF1930544C01003958B8 /* CalendarrWatch */; targetProxy = 39E1B0010003000000000001 /* PBXContainerItemProxy */; }; + D0001A0EABCDEF0100AB5001 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D0001A01ABCDEF0100AB5001 /* CalendarrWidgets */; + targetProxy = D0001A0DABCDEF0100AB5001 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -709,6 +717,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = CalendarrWatch/CalendarrWatch.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = PP34X97WS3; @@ -722,7 +731,6 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - CODE_SIGN_ENTITLEMENTS = CalendarrWatch/CalendarrWatch.entitlements; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.watchkitapp; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; @@ -743,6 +751,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = CalendarrWatch/CalendarrWatch.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = PP34X97WS3; @@ -756,7 +765,6 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - CODE_SIGN_ENTITLEMENTS = CalendarrWatch/CalendarrWatch.entitlements; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.watchkitapp; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; @@ -867,6 +875,7 @@ buildSettings = { ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_ENTITLEMENTS = CalendarrWatchComplications/CalendarrWatchComplications.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = PP34X97WS3; @@ -881,7 +890,6 @@ "@executable_path/../../../../Frameworks", ); MARKETING_VERSION = 1.0; - CODE_SIGN_ENTITLEMENTS = CalendarrWatchComplications/CalendarrWatchComplications.entitlements; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.watchkitapp.complications; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; @@ -901,6 +909,7 @@ buildSettings = { ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_ENTITLEMENTS = CalendarrWatchComplications/CalendarrWatchComplications.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = PP34X97WS3; @@ -915,7 +924,6 @@ "@executable_path/../../../../Frameworks", ); MARKETING_VERSION = 1.0; - CODE_SIGN_ENTITLEMENTS = CalendarrWatchComplications/CalendarrWatchComplications.entitlements; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.watchkitapp.complications; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; @@ -1329,6 +1337,10 @@ isa = XCSwiftPackageProductDependency; productName = CalendarrCore; }; + CA1E4D0A0001000000000002 /* CalendarrCore */ = { + isa = XCSwiftPackageProductDependency; + productName = CalendarrCore; + }; CA1E4D0A0001000000000003 /* CalendarrCore */ = { isa = XCSwiftPackageProductDependency; productName = CalendarrCore; @@ -1337,10 +1349,6 @@ isa = XCSwiftPackageProductDependency; productName = CalendarrCore; }; - CA1E4D0A0001000000000002 /* CalendarrCore */ = { - isa = XCSwiftPackageProductDependency; - productName = CalendarrCore; - }; /* End XCSwiftPackageProductDependency section */ }; rootObject = C0000301FB4E10100AB5001 /* Project object */; diff --git a/CalendarrWatch/CalendarrWatchApp.swift b/CalendarrWatch/CalendarrWatchApp.swift index d059cc7..325d550 100644 --- a/CalendarrWatch/CalendarrWatchApp.swift +++ b/CalendarrWatch/CalendarrWatchApp.swift @@ -1,17 +1,19 @@ -// -// CalendarrWatchApp.swift -// CalendarrWatch Watch App -// -// Created by Guido Schmit on 11.09.2026. -// - import SwiftUI @main -struct CalendarrWatch_Watch_AppApp: App { +struct CalendarrWatchApp: App { + @State private var model = WatchSnapshotModel() + + init() { + // The session has to be live before the first payload arrives, and a + // complication push can wake this app with no UI on screen at all. + WatchSnapshotReceiver.shared.activate() + } + var body: some Scene { WindowGroup { - ContentView() + WatchAgendaView() + .environment(model) } } } diff --git a/CalendarrWatch/ContentView.swift b/CalendarrWatch/ContentView.swift deleted file mode 100644 index 01101ce..0000000 --- a/CalendarrWatch/ContentView.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// ContentView.swift -// CalendarrWatch Watch App -// -// Created by Guido Schmit on 11.09.2026. -// - -import SwiftUI - -struct ContentView: View { - var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundStyle(.tint) - Text("Hello, world!") - } - .padding() - } -} - -#Preview { - ContentView() -} diff --git a/CalendarrWatch/Views/WatchAgendaView.swift b/CalendarrWatch/Views/WatchAgendaView.swift new file mode 100644 index 0000000..bb36979 --- /dev/null +++ b/CalendarrWatch/Views/WatchAgendaView.swift @@ -0,0 +1,114 @@ +import SwiftUI +import CalendarrCore + +/// The whole app: a scrolling agenda grouped by day, plus a way to reach the +/// calendar filter. Read-only by design — the snapshot carries neither +/// recurrence rules nor permissions, so it is deliberately not enough to edit. +/// +/// Deliberately does **not** apply `snapshot.theme` to the background. The +/// always-on display and OLED make black the right choice here; the theme only +/// contributes the per-event accent colours. +struct WatchAgendaView: View { + @Environment(WatchSnapshotModel.self) private var model + @State private var showingFilter = false + + private var calendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.locale = WatchL10n.locale(model.language) + return calendar + } + + var body: some View { + NavigationStack { + content + .navigationTitle(WatchL10n.t("watch.title", model.language)) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { showingFilter = true } label: { + Image(systemName: "line.3.horizontal.decrease.circle") + } + .accessibilityLabel(WatchL10n.t("watch.calendars", model.language)) + } + } + .sheet(isPresented: $showingFilter) { WatchCalendarFilterView() } + } + .onAppear { WatchSnapshotReceiver.shared.requestRefresh() } + } + + @ViewBuilder + private var content: some View { + if let snapshot = model.visible, !groups(in: snapshot).isEmpty { + List { + ForEach(groups(in: snapshot), id: \.day) { group in + Section(WatchEventFormat.dayHeader(group.day, language: model.language, + calendar: calendar)) { + ForEach(group.events) { event in + NavigationLink { + WatchEventDetailView(event: event, + calendarName: model.calendarName(for: event.calendarKey), + language: model.language) + } label: { + WatchEventRow(event: event, language: model.language) + } + } + } + } + Section { + VStack(alignment: .leading, spacing: 2) { + Text("\(WatchL10n.t("watch.as_of", model.language)) \(WatchEventFormat.time(snapshot.writtenAt, language: model.language))") + // Beyond the coverage window the snapshot carries no + // information, which is not the same as "nothing + // scheduled". Saying so is what keeps the empty tail of + // the list from reading as a free afternoon. + Text("\(WatchL10n.t("watch.no_information", model.language)) \(coverageEndLine(snapshot))") + if let error = WatchSnapshotReceiver.shared.lastError { + Text(error).foregroundStyle(.orange) + } + } + .font(.system(size: 10)) + .foregroundStyle(.tertiary) + } + } + .listStyle(.carousel) + } else { + WatchStatusView(result: model.result, language: model.language) + } + } + + private struct DayGroup { + let day: Date + let events: [SnapshotEvent] + } + + /// The last day the snapshot can speak for, formatted for the footer. + private func coverageEndLine(_ snapshot: CalendarrSnapshot) -> String { + let formatter = DateFormatter() + formatter.locale = WatchL10n.locale(model.language) + formatter.setLocalizedDateFormatFromTemplate("d MMM") + let lastCovered = calendar.date(byAdding: .day, value: -1, to: snapshot.coverageEnd) + return formatter.string(from: lastCovered ?? snapshot.coverageEnd) + } + + /// Group the covered window by day. Days the snapshot cannot speak for are + /// simply absent — never rendered as "nothing scheduled", which would look + /// correct and be false. + private func groups(in snapshot: CalendarrSnapshot) -> [DayGroup] { + let calendar = self.calendar + let from = max(snapshot.coverageStart, calendar.startOfDay(for: Date())) + var buckets: [Date: [SnapshotEvent]] = [:] + + for event in snapshot.events where event.end > from { + var day = calendar.startOfDay(for: max(event.start, from)) + // A multi-day event appears on each day it covers. + while day < event.end && day < snapshot.coverageEnd { + buckets[day, default: []].append(event) + guard let next = calendar.date(byAdding: .day, value: 1, to: day) else { break } + day = next + } + } + + return buckets + .map { DayGroup(day: $0.key, events: $0.value.sorted { $0.start < $1.start }) } + .sorted { $0.day < $1.day } + } +} diff --git a/CalendarrWatch/Views/WatchCalendarFilterView.swift b/CalendarrWatch/Views/WatchCalendarFilterView.swift new file mode 100644 index 0000000..83f267e --- /dev/null +++ b/CalendarrWatch/Views/WatchCalendarFilterView.swift @@ -0,0 +1,49 @@ +import SwiftUI +import CalendarrCore + +/// Narrows what this watch shows, on top of what the phone already filtered. +/// One toggle here affects the agenda and every complication at once. +struct WatchCalendarFilterView: View { + @Environment(WatchSnapshotModel.self) private var model + + var body: some View { + NavigationStack { + List { + if model.calendars.isEmpty { + Text(WatchL10n.t("watch.waiting", model.language)) + .font(.footnote) + .foregroundStyle(.secondary) + } else { + ForEach(model.calendars) { calendar in + row(calendar) + } + } + } + .navigationTitle(WatchL10n.t("watch.calendars", model.language)) + } + } + + private func row(_ calendar: SnapshotCalendar) -> some View { + let shown = !model.isHidden(calendar.id) + return Button { + model.setHidden(calendar.id, shown) + } label: { + HStack(spacing: 8) { + // A checkbox, not a Toggle: a switch reads as a state indicator + // you wait on rather than a control you tap. + Image(systemName: shown ? "checkmark.square.fill" : "square") + .font(.system(size: 16)) + .foregroundStyle(shown ? Color.accentColor : Color.secondary) + Circle() + .fill(Color(watchHex: calendar.colorHex)) + .frame(width: 7, height: 7) + Text(calendar.name) + .font(.system(size: 14)) + .lineLimit(1) + Spacer(minLength: 0) + } + } + .buttonStyle(.plain) + .accessibilityAddTraits(shown ? [.isSelected] : []) + } +} diff --git a/CalendarrWatch/Views/WatchEventDetailView.swift b/CalendarrWatch/Views/WatchEventDetailView.swift new file mode 100644 index 0000000..c0d5ec1 --- /dev/null +++ b/CalendarrWatch/Views/WatchEventDetailView.swift @@ -0,0 +1,60 @@ +import SwiftUI +import CalendarrCore + +/// Everything the snapshot carries about one event, which is deliberately not +/// everything the server knows: notes, recurrence and permissions are dropped +/// on the way out, so there is nothing here to edit. +struct WatchEventDetailView: View { + let event: SnapshotEvent + /// Nil when the calendar list has not arrived yet, which is possible right + /// after a first install. + let calendarName: String? + let language: String + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top, spacing: 6) { + Circle() + .fill(Color(watchHex: event.colorHex)) + .frame(width: 8, height: 8) + .padding(.top, 5) + Text(event.title) + .font(.headline) + .fixedSize(horizontal: false, vertical: true) + } + + Label(WatchEventFormat.timeRange(event, language: language), + systemImage: "clock") + .font(.footnote) + + Label(dateLine, systemImage: "calendar") + .font(.footnote) + .foregroundStyle(.secondary) + + if !event.location.isEmpty { + Label(event.location, systemImage: "mappin.and.ellipse") + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + if let calendarName { + Label(calendarName, systemImage: "list.bullet.rectangle") + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 4) + } + } + + private var dateLine: String { + let formatter = DateFormatter() + formatter.locale = WatchL10n.locale(language) + formatter.setLocalizedDateFormatFromTemplate("EEEE d MMMM") + return formatter.string(from: event.start) + } +} diff --git a/CalendarrWatch/Views/WatchEventRow.swift b/CalendarrWatch/Views/WatchEventRow.swift new file mode 100644 index 0000000..aff41da --- /dev/null +++ b/CalendarrWatch/Views/WatchEventRow.swift @@ -0,0 +1,76 @@ +import SwiftUI +import CalendarrCore + +/// One event: a colour bar for the calendar, the time range, the title. +struct WatchEventRow: View { + let event: SnapshotEvent + let language: String + + var body: some View { + HStack(alignment: .top, spacing: 7) { + RoundedRectangle(cornerRadius: 2) + .fill(Color(watchHex: event.colorHex)) + .frame(width: 3) + VStack(alignment: .leading, spacing: 1) { + Text(WatchEventFormat.timeRange(event, language: language)) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + Text(event.title) + .font(.system(size: 14)) + .lineLimit(2) + } + Spacer(minLength: 0) + } + .padding(.vertical, 2) + .fixedSize(horizontal: false, vertical: true) + } +} + +extension Color { + /// Local copy of the hex initialiser: `CalendarrCore` is Foundation-only by + /// design, so colour parsing cannot live there. + init(watchHex hex: String) { + let cleaned = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var value: UInt64 = 0 + Scanner(string: cleaned).scanHexInt64(&value) + guard cleaned.count == 6 else { self = .gray; return } + self.init(red: Double((value >> 16) & 0xFF) / 255, + green: Double((value >> 8) & 0xFF) / 255, + blue: Double(value & 0xFF) / 255) + } +} + +enum WatchEventFormat { + static func timeRange(_ event: SnapshotEvent, language: String) -> String { + if event.isAllDay { return WatchL10n.t("watch.allday", language) } + let formatter = DateFormatter() + formatter.locale = WatchL10n.locale(language) + formatter.dateFormat = "HH:mm" + let start = formatter.string(from: event.start) + guard event.end > event.start else { return start } + return "\(start) – \(formatter.string(from: event.end))" + } + + static func time(_ date: Date, language: String) -> String { + let formatter = DateFormatter() + formatter.locale = WatchL10n.locale(language) + formatter.dateFormat = "HH:mm" + return formatter.string(from: date) + } + + /// "Heute", "Morgen", else "Do 11. Sep". + static func dayHeader(_ day: Date, language: String, + calendar: Calendar, now: Date = Date()) -> String { + if calendar.isDate(day, inSameDayAs: now) { + return WatchL10n.t("watch.today", language) + } + if let tomorrow = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: now)), + calendar.isDate(day, inSameDayAs: tomorrow) { + return WatchL10n.t("watch.tomorrow", language) + } + let formatter = DateFormatter() + formatter.locale = WatchL10n.locale(language) + formatter.setLocalizedDateFormatFromTemplate("EEE d MMM") + return formatter.string(from: day) + } +} diff --git a/CalendarrWatch/Views/WatchStatusView.swift b/CalendarrWatch/Views/WatchStatusView.swift new file mode 100644 index 0000000..574a784 --- /dev/null +++ b/CalendarrWatch/Views/WatchStatusView.swift @@ -0,0 +1,44 @@ +import SwiftUI +import CalendarrCore + +/// Every reason there is nothing to show, named. "Never received", "signed out" +/// and "genuinely empty" call for different sentences, and showing the wrong one +/// sends the user looking in the wrong place. +struct WatchStatusView: View { + let result: SnapshotReadResult + let language: String + + var body: some View { + VStack(spacing: 8) { + Image(systemName: symbol) + .font(.system(size: 22)) + .foregroundStyle(.secondary) + Text(message) + .font(.footnote) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 6) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var symbol: String { + switch result { + case .ok: return "calendar" + case .loggedOut: return "person.crop.circle.badge.xmark" + case .neverWritten: return "iphone.and.arrow.forward" + case .incompatible: return "arrow.down.circle" + case .unreadable: return "exclamationmark.triangle" + } + } + + private var message: String { + switch result { + case .ok: return WatchL10n.t("watch.no_events", language) + case .loggedOut: return WatchL10n.t("watch.sign_in", language) + case .neverWritten: return WatchL10n.t("watch.waiting", language) + case .incompatible: return WatchL10n.t("watch.update", language) + case .unreadable: return WatchL10n.t("watch.unreadable", language) + } + } +} diff --git a/CalendarrWatch/WatchSnapshotModel.swift b/CalendarrWatch/WatchSnapshotModel.swift new file mode 100644 index 0000000..342d815 --- /dev/null +++ b/CalendarrWatch/WatchSnapshotModel.swift @@ -0,0 +1,52 @@ +import Foundation +import WidgetKit +import CalendarrCore + +/// What the watch views read. Holds the last received snapshot, the calendar +/// list, and this watch's filter. +@MainActor +@Observable +final class WatchSnapshotModel { + private(set) var result: SnapshotReadResult = .neverWritten + private(set) var calendars: [SnapshotCalendar] = [] + private(set) var hiddenKeys: Set = WatchFilterStore.load() + + private let store = SnapshotStore() + private var changeToken: SnapshotChangeNotifier.ObservationToken? + + init() { + reload() + // The receiver writes through SnapshotStore, which broadcasts. Listening + // here means the UI does not have to be notified separately. + changeToken = SnapshotChangeNotifier.observe { [weak self] in + Task { @MainActor in self?.reload() } + } + } + + func reload() { + result = store.read() + calendars = store.readCalendars() + } + + /// The snapshot with this watch's filter applied, or nil when there is + /// nothing usable to show. + var visible: CalendarrSnapshot? { + result.snapshot?.filtered(excludingCalendarKeys: hiddenKeys) + } + + var language: String { result.snapshot?.language ?? "system" } + + func isHidden(_ key: String) -> Bool { hiddenKeys.contains(key) } + + /// The display name for an event's calendar. Events carry only a + /// `calendarKey`; the names travel separately, in the calendar list. + func calendarName(for key: String) -> String? { + calendars.first { $0.id == key }?.name + } + + func setHidden(_ key: String, _ hidden: Bool) { + if hidden { hiddenKeys.insert(key) } else { hiddenKeys.remove(key) } + WatchFilterStore.save(hiddenKeys) + WidgetCenter.shared.reloadAllTimelines() + } +} diff --git a/CalendarrWatch/WatchSnapshotReceiver.swift b/CalendarrWatch/WatchSnapshotReceiver.swift new file mode 100644 index 0000000..0260fee --- /dev/null +++ b/CalendarrWatch/WatchSnapshotReceiver.swift @@ -0,0 +1,81 @@ +import Foundation +import WatchConnectivity +import WidgetKit +@_spi(Writer) import CalendarrCore + +/// Takes what the phone sends and writes it into this watch's own App Group +/// container, using the same `SnapshotStore` the phone writes with. One wire +/// format, one reader, no drift. +/// +/// Only the app runs a `WCSession`; a widget extension cannot. The complications +/// always read what this wrote. +@MainActor +final class WatchSnapshotReceiver: NSObject { + static let shared = WatchSnapshotReceiver() + + private let store = SnapshotStore() + private let sessions = SharedSessionStore() + + /// Shown in the agenda footer, because a silent failure here looks exactly + /// like an empty calendar. + private(set) var lastError: String? + + func activate() { + guard WCSession.isSupported() else { return } + WCSession.default.delegate = self + WCSession.default.activate() + } + + /// Ask the phone for the current state. Covers "the app is open in front of + /// me and looks stale"; the other two paths are push-driven. + func requestRefresh() { + let session = WCSession.default + guard session.activationState == .activated, session.isReachable else { return } + + session.sendMessage(["request": "snapshot"], + replyHandler: { reply in + Task { @MainActor in self.apply(reply) } + }, + errorHandler: { error in + Task { @MainActor in self.lastError = error.localizedDescription } + }) + } + + func apply(_ dict: [String: Any]) { + guard !dict.isEmpty else { return } + do { + let payload = try WatchTransportPayload.decode(dict) + // This write posts SnapshotChangeNotifier, which is what the read + // model listens for — so the UI updates without being told twice. + try store.write(payload.snapshot) + try store.writeCalendars(payload.calendars) + if let session = payload.session { try sessions.write(session) } + WidgetCenter.shared.reloadAllTimelines() + lastError = nil + } catch { + lastError = error.localizedDescription + } + } +} + +extension WatchSnapshotReceiver: WCSessionDelegate { + nonisolated func session(_ session: WCSession, + activationDidCompleteWith state: WCSessionActivationState, + error: Error?) { + if let error { + Task { @MainActor in self.lastError = error.localizedDescription } + } + } + + /// The main path: latest state only, delivered when this app launches or wakes. + nonisolated func session(_ session: WCSession, + didReceiveApplicationContext applicationContext: [String: Any]) { + Task { @MainActor in self.apply(applicationContext) } + } + + /// The complication push, which is the only path that wakes this app while + /// it is not running. + nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) { + Task { @MainActor in self.apply(userInfo) } + } +} diff --git a/CalendarrWatchComplications/CalendarrWatch.swift b/CalendarrWatchComplications/CalendarrWatch.swift deleted file mode 100644 index ac6c300..0000000 --- a/CalendarrWatchComplications/CalendarrWatch.swift +++ /dev/null @@ -1,86 +0,0 @@ -// -// CalendarrWatch.swift -// CalendarrWatch -// -// Created by Guido Schmit on 11.09.2026. -// - -import WidgetKit -import SwiftUI - -struct Provider: TimelineProvider { - func placeholder(in context: Context) -> SimpleEntry { - SimpleEntry(date: Date(), emoji: "😀") - } - - func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) { - let entry = SimpleEntry(date: Date(), emoji: "😀") - completion(entry) - } - - func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) { - var entries: [SimpleEntry] = [] - - // Generate a timeline consisting of five entries an hour apart, starting from the current date. - let currentDate = Date() - for hourOffset in 0 ..< 5 { - let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)! - let entry = SimpleEntry(date: entryDate, emoji: "😀") - entries.append(entry) - } - - let timeline = Timeline(entries: entries, policy: .atEnd) - completion(timeline) - } - -// func relevances() async -> WidgetRelevances { -// // Generate a list containing the contexts this widget is relevant in. -// } -} - -struct SimpleEntry: TimelineEntry { - let date: Date - let emoji: String -} - -struct CalendarrWatchEntryView : View { - var entry: Provider.Entry - - var body: some View { - VStack { - HStack { - Text("Time:") - Text(entry.date, style: .time) - } - - Text("Emoji:") - Text(entry.emoji) - } - } -} - -struct CalendarrWatch: Widget { - let kind: String = "CalendarrWatch" - - var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: Provider()) { entry in - if #available(watchOS 10.0, *) { - CalendarrWatchEntryView(entry: entry) - .containerBackground(.fill.tertiary, for: .widget) - } else { - CalendarrWatchEntryView(entry: entry) - .padding() - .background() - } - } - .configurationDisplayName("My Widget") - .description("This is an example widget.") - } -} - -#Preview(as: .accessoryRectangular) { - CalendarrWatch() -} timeline: { - SimpleEntry(date: .now, emoji: "😀") - SimpleEntry(date: .now, emoji: "🤩") -} diff --git a/CalendarrWatchComplications/CalendarrWatchBundle.swift b/CalendarrWatchComplications/CalendarrWatchBundle.swift deleted file mode 100644 index e9dbbf2..0000000 --- a/CalendarrWatchComplications/CalendarrWatchBundle.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// CalendarrWatchBundle.swift -// CalendarrWatch -// -// Created by Guido Schmit on 11.09.2026. -// - -import WidgetKit -import SwiftUI - -@main -struct CalendarrWatchBundle: WidgetBundle { - var body: some Widget { - CalendarrWatch() - CalendarrWatchControl() - } -} diff --git a/CalendarrWatchComplications/CalendarrWatchComplications.swift b/CalendarrWatchComplications/CalendarrWatchComplications.swift new file mode 100644 index 0000000..f789503 --- /dev/null +++ b/CalendarrWatchComplications/CalendarrWatchComplications.swift @@ -0,0 +1,145 @@ +import WidgetKit +import SwiftUI + +@main +struct CalendarrWatchComplicationBundle: WidgetBundle { + var body: some Widget { + WatchNextEventWidget() + WatchCornerCountdownWidget() + WatchCornerTimeWidget() + WatchCountdownWidget() + WatchDateWidget() + WatchTodayCountWidget() + WatchNextTwoWidget() + WatchDatePlusEventWidget() + } +} + +private extension View { + /// Accessory families paint their own background; ours must stay out of + /// the way so the watch face's tint comes through. + func complicationChrome(_ language: String) -> some View { + containerBackground(for: .widget) { Color.clear } + .environment(\.locale, WatchL10n.locale(language)) + } +} + +struct WatchNextEventWidget: Widget { + let kind = "WatchNextEvent" + + var body: some WidgetConfiguration { + AppIntentConfiguration(kind: kind, + intent: WatchCalendarSelectionIntent.self, + provider: WatchComplicationProvider()) { entry in + NextEventComplicationView(entry: entry).complicationChrome(entry.language) + } + .configurationDisplayName("Nächster Termin") + .description("Zeit, Titel und Ort des nächsten Termins.") + .supportedFamilies([.accessoryRectangular, .accessoryInline]) + } +} + +struct WatchCornerCountdownWidget: Widget { + let kind = "WatchCornerCountdown" + + var body: some WidgetConfiguration { + AppIntentConfiguration(kind: kind, + intent: WatchCalendarSelectionIntent.self, + provider: WatchComplicationProvider()) { entry in + CornerCountdownComplicationView(entry: entry).complicationChrome(entry.language) + } + .configurationDisplayName("Restzeit (Ecke)") + .description("Restzeit bis zum nächsten Termin, Name am Rand.") + .supportedFamilies([.accessoryCorner]) + } +} + +struct WatchCornerTimeWidget: Widget { + let kind = "WatchCornerTime" + + var body: some WidgetConfiguration { + AppIntentConfiguration(kind: kind, + intent: WatchCalendarSelectionIntent.self, + provider: WatchComplicationProvider()) { entry in + CornerTimeComplicationView(entry: entry).complicationChrome(entry.language) + } + .configurationDisplayName("Uhrzeit (Ecke)") + .description("Uhrzeit des nächsten Termins, Name am Rand.") + .supportedFamilies([.accessoryCorner]) + } +} + +struct WatchCountdownWidget: Widget { + let kind = "WatchCountdown" + + var body: some WidgetConfiguration { + AppIntentConfiguration(kind: kind, + intent: WatchCalendarSelectionIntent.self, + provider: WatchComplicationProvider()) { entry in + CountdownComplicationView(entry: entry).complicationChrome(entry.language) + } + .configurationDisplayName("Countdown") + .description("Ring, der bis zum nächsten Termin leerläuft.") + .supportedFamilies([.accessoryCircular, .accessoryInline]) + } +} + +struct WatchDateWidget: Widget { + let kind = "WatchDate" + + var body: some WidgetConfiguration { + AppIntentConfiguration(kind: kind, + intent: WatchCalendarSelectionIntent.self, + provider: WatchComplicationProvider()) { entry in + DateComplicationView(entry: entry).complicationChrome(entry.language) + } + .configurationDisplayName("Datum") + .description("Tag und Monat.") + .supportedFamilies([.accessoryCircular, .accessoryInline]) + } +} + +struct WatchTodayCountWidget: Widget { + let kind = "WatchTodayCount" + + var body: some WidgetConfiguration { + AppIntentConfiguration(kind: kind, + intent: WatchCalendarSelectionIntent.self, + provider: WatchComplicationProvider()) { entry in + TodayCountComplicationView(entry: entry).complicationChrome(entry.language) + } + .configurationDisplayName("Termine heute") + .description("Anzahl der heutigen Termine.") + .supportedFamilies([.accessoryCircular, .accessoryInline]) + } +} + +struct WatchNextTwoWidget: Widget { + let kind = "WatchNextTwo" + + var body: some WidgetConfiguration { + AppIntentConfiguration(kind: kind, + intent: WatchCalendarSelectionIntent.self, + provider: WatchComplicationProvider()) { entry in + NextTwoComplicationView(entry: entry).complicationChrome(entry.language) + } + .configurationDisplayName("Die nächsten zwei") + .description("Die beiden nächsten Termine.") + .supportedFamilies([.accessoryRectangular]) + } +} + +struct WatchDatePlusEventWidget: Widget { + let kind = "WatchDatePlusEvent" + + var body: some WidgetConfiguration { + AppIntentConfiguration(kind: kind, + intent: WatchCalendarSelectionIntent.self, + provider: WatchComplicationProvider()) { entry in + DatePlusEventComplicationView(entry: entry).complicationChrome(entry.language) + } + .configurationDisplayName("Datum + Termin") + .description("Datum und nächster Termin in einem Slot.") + .supportedFamilies([.accessoryRectangular]) + } +} diff --git a/CalendarrWatchComplications/CalendarrWatchControl.swift b/CalendarrWatchComplications/CalendarrWatchControl.swift deleted file mode 100644 index 64e323e..0000000 --- a/CalendarrWatchComplications/CalendarrWatchControl.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// CalendarrWatchControl.swift -// CalendarrWatch -// -// Created by Guido Schmit on 11.09.2026. -// - -import AppIntents -import SwiftUI -import WidgetKit - -struct CalendarrWatchControl: ControlWidget { - var body: some ControlWidgetConfiguration { - StaticControlConfiguration( - kind: ".watchkitapp.CalendarrWatch", - provider: Provider() - ) { value in - ControlWidgetToggle( - "Start Timer", - isOn: value, - action: StartTimerIntent() - ) { isRunning in - Label(isRunning ? "On" : "Off", systemImage: "timer") - } - } - .displayName("Timer") - .description("A an example control that runs a timer.") - } -} - -extension CalendarrWatchControl { - struct Provider: ControlValueProvider { - var previewValue: Bool { - false - } - - func currentValue() async throws -> Bool { - let isRunning = true // Check if the timer is running - return isRunning - } - } -} - -struct StartTimerIntent: SetValueIntent { - static let title: LocalizedStringResource = "Start a timer" - - @Parameter(title: "Timer is running") - var value: Bool - - func perform() async throws -> some IntentResult { - // Start / stop the timer based on `value`. - return .result() - } -} diff --git a/CalendarrWatchComplications/CornerComplicationViews.swift b/CalendarrWatchComplications/CornerComplicationViews.swift new file mode 100644 index 0000000..b96bb86 --- /dev/null +++ b/CalendarrWatchComplications/CornerComplicationViews.swift @@ -0,0 +1,49 @@ +import SwiftUI +import WidgetKit +import CalendarrCore + +// A corner holds **one** value plus the curved bezel label. A second inner line +// runs into the curved text — do not add one. `.widgetLabel` is what makes +// WidgetKit lay the text along the edge; there is no arc to draw by hand. + +/// Value: live countdown. Label: time and title. +struct CornerCountdownComplicationView: View { + let entry: WatchComplicationEntry + + var body: some View { + let next = WatchComplicationSupport.nextEvent(in: entry.snapshot, at: entry.date) + Group { + if let next { + Text(WatchComplicationSupport.countdownTarget(next, at: entry.date), style: .timer) + .widgetAccentable() + } else { + Image(systemName: "calendar") + } + } + .widgetLabel { + Text(WatchComplicationSupport.cornerLabel(next, at: entry.date, language: entry.language)) + } + } +} + +/// Value: the appointment's clock time. Label: the title. +struct CornerTimeComplicationView: View { + let entry: WatchComplicationEntry + + var body: some View { + let next = WatchComplicationSupport.nextEvent(in: entry.snapshot, at: entry.date) + Group { + if let next { + Text(next.isAllDay + ? WatchL10n.t("watch.allday", entry.language) + : WatchComplicationSupport.time(next.start, language: entry.language)) + .widgetAccentable() + } else { + Image(systemName: "calendar") + } + } + .widgetLabel { + Text(next?.title ?? WatchL10n.t("watch.no_events", entry.language)) + } + } +} diff --git a/CalendarrWatchComplications/RectangularComplicationViews.swift b/CalendarrWatchComplications/RectangularComplicationViews.swift new file mode 100644 index 0000000..4c4b13e --- /dev/null +++ b/CalendarrWatchComplications/RectangularComplicationViews.swift @@ -0,0 +1,113 @@ +import SwiftUI +import WidgetKit +import CalendarrCore + +/// Time, title, location. The variant that answers the actual question. +struct NextEventComplicationView: View { + let entry: WatchComplicationEntry + @Environment(\.widgetFamily) private var family + + var body: some View { + let next = WatchComplicationSupport.nextEvent(in: entry.snapshot, at: entry.date) + switch family { + case .accessoryInline: + inline(next) + default: + rectangular(next) + } + } + + @ViewBuilder + private func rectangular(_ next: SnapshotEvent?) -> some View { + VStack(alignment: .leading, spacing: 1) { + if let next { + Text(next.isAllDay + ? WatchL10n.t("watch.allday", entry.language) + : WatchComplicationSupport.time(next.start, language: entry.language)) + .font(.system(size: 12, weight: .semibold)) + .widgetAccentable() + Text(next.title) + .font(.system(size: 15)) + .lineLimit(1) + if !next.location.isEmpty { + Text(next.location) + .font(.system(size: 11)) + .lineLimit(1) + } + } else { + Image(systemName: "calendar").font(.system(size: 13)).widgetAccentable() + Text(WatchL10n.t("watch.no_events", entry.language)).font(.system(size: 13)) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private func inline(_ next: SnapshotEvent?) -> some View { + let text: String = { + guard let next else { return WatchL10n.t("watch.no_events", entry.language) } + if next.isAllDay { return next.title } + return "\(WatchComplicationSupport.time(next.start, language: entry.language)) \(next.title)" + }() + return Label(text, systemImage: "calendar") + } +} + +/// The next two, so back-to-back appointments are visible at once. +struct NextTwoComplicationView: View { + let entry: WatchComplicationEntry + + var body: some View { + let events = WatchComplicationSupport.upcoming(in: entry.snapshot, at: entry.date, limit: 2) + VStack(alignment: .leading, spacing: 3) { + if events.isEmpty { + Image(systemName: "calendar").font(.system(size: 13)).widgetAccentable() + Text(WatchL10n.t("watch.no_events", entry.language)).font(.system(size: 13)) + } else { + ForEach(events) { event in + HStack(spacing: 5) { + Text(event.isAllDay + ? WatchL10n.t("watch.allday", entry.language) + : WatchComplicationSupport.time(event.start, language: entry.language)) + .font(.system(size: 12, weight: .semibold)) + .widgetAccentable() + Text(event.title) + .font(.system(size: 13)) + .lineLimit(1) + } + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } +} + +/// Weekday and date plus the next appointment, so one slot does both jobs. +struct DatePlusEventComplicationView: View { + let entry: WatchComplicationEntry + + var body: some View { + let next = WatchComplicationSupport.nextEvent(in: entry.snapshot, at: entry.date) + VStack(alignment: .leading, spacing: 2) { + Text(dateLine) + .font(.system(size: 11, weight: .semibold)) + .widgetAccentable() + if let next { + Text(next.isAllDay + ? next.title + : "\(WatchComplicationSupport.time(next.start, language: entry.language)) \(next.title)") + .font(.system(size: 14)) + .lineLimit(1) + } else { + Text(WatchL10n.t("watch.no_events", entry.language)).font(.system(size: 14)) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private var dateLine: String { + let formatter = DateFormatter() + formatter.locale = WatchL10n.locale(entry.language) + formatter.setLocalizedDateFormatFromTemplate("EEE d MMMM") + return formatter.string(from: entry.date).uppercased() + } +} diff --git a/CalendarrWatchComplications/SmallComplicationViews.swift b/CalendarrWatchComplications/SmallComplicationViews.swift new file mode 100644 index 0000000..b06eb57 --- /dev/null +++ b/CalendarrWatchComplications/SmallComplicationViews.swift @@ -0,0 +1,113 @@ +import SwiftUI +import WidgetKit +import CalendarrCore + +/// A draining ring plus the remaining time. +struct CountdownComplicationView: View { + let entry: WatchComplicationEntry + @Environment(\.widgetFamily) private var family + + var body: some View { + let next = WatchComplicationSupport.nextEvent(in: entry.snapshot, at: entry.date) + switch family { + case .accessoryInline: + inline(next) + default: + circular(next) + } + } + + @ViewBuilder + private func circular(_ next: SnapshotEvent?) -> some View { + if let next { + let target = WatchComplicationSupport.countdownTarget(next, at: entry.date) + Gauge(value: WatchComplicationSupport.gaugeFraction(to: target, at: entry.date)) { + Image(systemName: "calendar") + } currentValueLabel: { + Text(target, style: .timer) + .font(.system(size: 12)) + } + .gaugeStyle(.accessoryCircularCapacity) + .widgetAccentable() + } else { + ZStack { + AccessoryWidgetBackground() + Image(systemName: "calendar").font(.system(size: 18)) + } + } + } + + @ViewBuilder + private func inline(_ next: SnapshotEvent?) -> some View { + if let next { + Label { + Text(WatchComplicationSupport.countdownTarget(next, at: entry.date), style: .timer) + } icon: { + Image(systemName: "timer") + } + } else { + Label(WatchL10n.t("watch.no_events", entry.language), systemImage: "calendar") + } + } +} + +/// Day number and month. No appointment information — a date replacement. +struct DateComplicationView: View { + let entry: WatchComplicationEntry + @Environment(\.widgetFamily) private var family + + private var day: Int { + Calendar(identifier: .gregorian).component(.day, from: entry.date) + } + + var body: some View { + switch family { + case .accessoryInline: + Label("\(WatchComplicationSupport.monthAbbreviation(entry.date, language: entry.language)) \(day)", + systemImage: "calendar") + default: + ZStack { + AccessoryWidgetBackground() + VStack(spacing: 0) { + Text("\(day)") + .font(.system(size: 22, weight: .bold)) + .minimumScaleFactor(0.7) + .widgetAccentable() + Text(WatchComplicationSupport.monthAbbreviation(entry.date, language: entry.language)) + .font(.system(size: 8, weight: .semibold)) + } + } + } + } +} + +/// How many appointments today. Says "busy", not "what". +struct TodayCountComplicationView: View { + let entry: WatchComplicationEntry + @Environment(\.widgetFamily) private var family + + private var count: Int { + WatchComplicationSupport.todayCount(in: entry.snapshot, at: entry.date) + } + + var body: some View { + switch family { + case .accessoryInline: + Label("\(count) \(WatchL10n.t("watch.events_today", entry.language))", + systemImage: "calendar") + default: + ZStack { + AccessoryWidgetBackground() + VStack(spacing: 0) { + Text("\(count)") + .font(.system(size: 22, weight: .bold)) + .widgetAccentable() + Text(WatchL10n.t("watch.events_today", entry.language).uppercased()) + .font(.system(size: 7, weight: .semibold)) + .lineLimit(1) + .minimumScaleFactor(0.6) + } + } + } + } +} diff --git a/CalendarrWatchComplications/WatchCalendarSelectionIntent.swift b/CalendarrWatchComplications/WatchCalendarSelectionIntent.swift new file mode 100644 index 0000000..35c4116 --- /dev/null +++ b/CalendarrWatchComplications/WatchCalendarSelectionIntent.swift @@ -0,0 +1,44 @@ +import AppIntents +import WidgetKit +import CalendarrCore + +/// One calendar option in a complication's configuration. +struct WatchCalendarEntity: AppEntity, Identifiable { + let id: String + let name: String + + static var typeDisplayRepresentation: TypeDisplayRepresentation = "Kalender" + static var defaultQuery = WatchCalendarEntityQuery() + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation(title: LocalizedStringResource(stringLiteral: name)) + } +} + +/// Reads the calendars from this watch's App Group container, so configuration +/// needs no network call and no phone. +struct WatchCalendarEntityQuery: EntityQuery { + func entities(for identifiers: [String]) async throws -> [WatchCalendarEntity] { + let wanted = Set(identifiers) + return SnapshotStore().readCalendars() + .filter { wanted.contains($0.id) } + .map { WatchCalendarEntity(id: $0.id, name: $0.name) } + } + + func suggestedEntities() async throws -> [WatchCalendarEntity] { + SnapshotStore().readCalendars().map { WatchCalendarEntity(id: $0.id, name: $0.name) } + } +} + +/// Per-complication override of the calendar selection. The in-app toggles are +/// the primary path — intent configuration on a watch face is awkward to +/// operate — so an empty selection means "whatever the app is set to". +struct WatchCalendarSelectionIntent: WidgetConfigurationIntent { + static var title: LocalizedStringResource = "Kalender auswählen" + static var description = IntentDescription("Leer = die Auswahl aus der Watch-App.") + + // Must be optional: WidgetConfigurationIntent requires optional parameter + // types, and the watchOS SDK enforces it where iOS lets a bare array pass. + @Parameter(title: "Kalender") + var selectedCalendars: [WatchCalendarEntity]? +} diff --git a/CalendarrWatchComplications/WatchComplicationProvider.swift b/CalendarrWatchComplications/WatchComplicationProvider.swift new file mode 100644 index 0000000..cd5abf9 --- /dev/null +++ b/CalendarrWatchComplications/WatchComplicationProvider.swift @@ -0,0 +1,62 @@ +import WidgetKit +import AppIntents +import SwiftUI +import CalendarrCore + +struct WatchComplicationEntry: TimelineEntry { + let date: Date + let snapshot: CalendarrSnapshot? + + var language: String { snapshot?.language ?? "system" } +} + +/// Builds every Calendarr complication's timeline. +/// +/// Entries land on event boundaries rather than on a fixed schedule, so the +/// complication flips in the exact minute the next appointment changes without +/// a network call and without spending refresh budget. New *data* arrives by +/// push from the phone, which is why the refresh policy is `.atEnd`: when the +/// timeline simply runs out, regenerating from the same snapshot is cheap and +/// correct. +struct WatchComplicationProvider: AppIntentTimelineProvider { + typealias Entry = WatchComplicationEntry + typealias Intent = WatchCalendarSelectionIntent + + func placeholder(in context: Context) -> WatchComplicationEntry { + WatchComplicationEntry(date: .now, snapshot: nil) + } + + func snapshot(for configuration: WatchCalendarSelectionIntent, + in context: Context) async -> WatchComplicationEntry { + WatchComplicationEntry(date: .now, snapshot: resolved(configuration)) + } + + func timeline(for configuration: WatchCalendarSelectionIntent, + in context: Context) async -> Timeline { + let snapshot = resolved(configuration) + let now = Date() + let dates = snapshot.map { ComplicationTimeline.entryDates(from: now, in: $0) } ?? [now] + return Timeline(entries: dates.map { WatchComplicationEntry(date: $0, snapshot: snapshot) }, + policy: .atEnd) + } + + /// What the complication gallery offers before anyone configures anything. + /// Required on watchOS — iOS has a default implementation, the watch does + /// not, because the gallery has to be populated. + /// + /// One entry, with nothing selected: that means "whatever the app is set + /// to", which is the filter the user already chose there. + func recommendations() -> [AppIntentRecommendation] { + [AppIntentRecommendation(intent: WatchCalendarSelectionIntent(), + description: Text("Calendarr"))] + } + + /// The app-level filter applies first; a per-complication selection narrows + /// further. An empty selection means "whatever the app is set to". + private func resolved(_ configuration: WatchCalendarSelectionIntent) -> CalendarrSnapshot? { + guard case .ok(let snapshot) = SnapshotStore().read() else { return nil } + return snapshot + .filtered(excludingCalendarKeys: WatchFilterStore.load()) + .filtered(toCalendarKeys: Set((configuration.selectedCalendars ?? []).map(\.id))) + } +} diff --git a/CalendarrWatchComplications/WatchComplicationSupport.swift b/CalendarrWatchComplications/WatchComplicationSupport.swift new file mode 100644 index 0000000..db566b6 --- /dev/null +++ b/CalendarrWatchComplications/WatchComplicationSupport.swift @@ -0,0 +1,58 @@ +import Foundation +import CalendarrCore + +enum WatchComplicationSupport { + /// The first event that has not ended yet — including one running now, + /// which is what "what is next" means when you are already in a meeting. + /// Matches `WatchPushPolicy.nextEventIdentity`, deliberately. + static func nextEvent(in snapshot: CalendarrSnapshot?, at now: Date) -> SnapshotEvent? { + snapshot?.events.filter { $0.end > now }.min { $0.start < $1.start } + } + + static func upcoming(in snapshot: CalendarrSnapshot?, at now: Date, limit: Int) -> [SnapshotEvent] { + guard let snapshot else { return [] } + return Array(snapshot.events.filter { $0.end > now }.sorted { $0.start < $1.start }.prefix(limit)) + } + + static func todayCount(in snapshot: CalendarrSnapshot?, at now: Date, + calendar: Calendar = Calendar(identifier: .gregorian)) -> Int { + guard let snapshot else { return 0 } + let dayStart = calendar.startOfDay(for: now) + guard let dayEnd = calendar.date(byAdding: .day, value: 1, to: dayStart) else { return 0 } + return snapshot.events.filter { $0.start < dayEnd && $0.end > dayStart }.count + } + + /// What a countdown counts to: the start if it has not begun, otherwise the + /// end. Counting up from a start time that has passed reads as broken. + static func countdownTarget(_ event: SnapshotEvent, at now: Date) -> Date { + event.start > now ? event.start : event.end + } + + /// How full a draining ring should be, over a two-hour lead window. + static func gaugeFraction(to target: Date, at now: Date) -> Double { + let window: TimeInterval = 2 * 3600 + return min(1, max(0, target.timeIntervalSince(now) / window)) + } + + static func time(_ date: Date, language: String) -> String { + let formatter = DateFormatter() + formatter.locale = WatchL10n.locale(language) + formatter.dateFormat = "HH:mm" + return formatter.string(from: date) + } + + static func monthAbbreviation(_ date: Date, language: String) -> String { + let formatter = DateFormatter() + formatter.locale = WatchL10n.locale(language) + formatter.dateFormat = "LLL" + return formatter.string(from: date).uppercased() + } + + /// The curved bezel label for a corner complication: time plus title, or a + /// plain "no events". + static func cornerLabel(_ event: SnapshotEvent?, at now: Date, language: String) -> String { + guard let event else { return WatchL10n.t("watch.no_events", language) } + if event.isAllDay { return event.title } + return "\(time(event.start, language: language)) \(event.title)" + } +} diff --git a/CalendarrWatchShared/WatchFilterStore.swift b/CalendarrWatchShared/WatchFilterStore.swift new file mode 100644 index 0000000..f1ecb58 --- /dev/null +++ b/CalendarrWatchShared/WatchFilterStore.swift @@ -0,0 +1,23 @@ +import Foundation +import CalendarrCore + +/// Which calendars this watch hides, on top of what the phone already filtered. +/// +/// The snapshot arrives with hidden and banished calendars already removed, so +/// this narrows further and can never contradict the phone. It lives in the App +/// Group so one toggle in the app affects every complication at once. +enum WatchFilterStore { + private static let key = "watchHiddenCalendarKeys" + + private static var defaults: UserDefaults? { + UserDefaults(suiteName: CalendarrAppGroup.current) + } + + static func load() -> Set { + Set(defaults?.stringArray(forKey: key) ?? []) + } + + static func save(_ keys: Set) { + defaults?.set(keys.sorted(), forKey: key) + } +} diff --git a/CalendarrWatchShared/WatchL10n.swift b/CalendarrWatchShared/WatchL10n.swift new file mode 100644 index 0000000..5e53409 --- /dev/null +++ b/CalendarrWatchShared/WatchL10n.swift @@ -0,0 +1,55 @@ +import Foundation + +/// German and English strings for the watch app and its complications. +/// +/// A local copy rather than a shared one: `CalendarrCore` is deliberately +/// Foundation-only and holds no presentation concerns, and the iOS `WidgetL10n` +/// belongs to a target the watch cannot link against. +enum WatchL10n { + static func t(_ key: String, _ stored: String) -> String { + strings[resolve(stored)]?[key] ?? strings["en"]?[key] ?? key + } + + static func locale(_ stored: String) -> Locale { + Locale(identifier: resolve(stored) == "de" ? "de_DE" : "en_US") + } + + private static func resolve(_ stored: String) -> String { + if stored == "de" || stored == "en" { return stored } + let preferred = Locale.preferredLanguages.first ?? "en" + return preferred.lowercased().hasPrefix("de") ? "de" : "en" + } + + private static let strings: [String: [String: String]] = [ + "de": [ + "watch.title": "Calendarr", + "watch.calendars": "Kalender", + "watch.allday": "Ganztägig", + "watch.no_events": "Keine Termine", + "watch.today": "Heute", + "watch.tomorrow": "Morgen", + "watch.events_today": "Termine", + "watch.sign_in": "Auf dem iPhone anmelden", + "watch.update": "Watch-App aktualisieren", + "watch.waiting": "Warte auf Daten vom iPhone", + "watch.unreadable": "Daten nicht lesbar", + "watch.as_of": "Stand", + "watch.no_information": "Keine Daten nach", + ], + "en": [ + "watch.title": "Calendarr", + "watch.calendars": "Calendars", + "watch.allday": "All-day", + "watch.no_events": "No events", + "watch.today": "Today", + "watch.tomorrow": "Tomorrow", + "watch.events_today": "events", + "watch.sign_in": "Sign in on your iPhone", + "watch.update": "Update the watch app", + "watch.waiting": "Waiting for data from your iPhone", + "watch.unreadable": "Data unreadable", + "watch.as_of": "As of", + "watch.no_information": "No data after", + ], + ] +} diff --git a/docs/superpowers/specs/2026-09-11-apple-watch-app-design.md b/docs/superpowers/specs/2026-09-11-apple-watch-app-design.md index e553ab4..15b0b27 100644 --- a/docs/superpowers/specs/2026-09-11-apple-watch-app-design.md +++ b/docs/superpowers/specs/2026-09-11-apple-watch-app-design.md @@ -1,7 +1,7 @@ # Apple Watch app and watch-face complications Date: 2026-09-11 -Status: approved — ready for implementation planning +Status: implemented — pending on-device verification ## Goal