From c811486ea73cf4be717cf57c3a7b0f1ff9a0254f Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Fri, 23 Oct 2026 14:57:15 +0200 Subject: [PATCH] feat(ios): Kalender des Geraets anzeigen und bespielen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Kalenderveroeffentlichung in Outlook ist oft von der IT gesperrt, und iCloud laesst sich nicht ueber einen ICS-Link einbinden. Das iPhone kennt beide Konten aber bereits, also liest die App sie per EventKit direkt. Die Termine bleiben auf dem Geraet und gehen nie an den Server. Im Web und auf anderen Geraeten erscheinen sie deshalb nicht — dafuer braucht es keine fremden Zugangsdaten und keine Kopie fremder Termindaten in unserer Datenbank. Standardmaessig aus: der Schalter steht unter Konten, und erst beim Einschalten fragt EventKit nach Zugriff. Wer die Funktion nicht will, sieht keine Berechtigungsabfrage. Kaum neuer Code noetig, weil CalEvent schon source/calendarId/readOnly hat und die Sichtbarkeits-, Sortier- und Ausblendlogik an calendarKey(source:calendarId:) haengt: die Quelle "device" rutscht ohne Umbau hinein. WritableCalendar bekommt nur ein deviceId, weil EventKit Zeichenketten statt Zeilen-IDs vergibt. Lesen, Anlegen, Aendern und Loeschen gehen ueber dieselben Ansichten wie bei allen anderen Quellen. Nur die Gruppenansicht bleibt aussen vor, die ist serverseitig zusammengesetzt. Statt den Store durch zwei Views zu reichen, damit der Kalender nach dem Einschalten nachlaedt, zaehlt der Dienst eine Generation hoch; ein abweichender Stand gilt im Cache als Treffer-Fehlschlag. Co-Authored-By: Claude Opus 5 --- ios/Calendarr iOS.xcodeproj/project.pbxproj | 2 + ios/Calendarr iOS/Models/CalendarStore.swift | 28 ++- ios/Calendarr iOS/Models/Localization.swift | 8 + .../Services/DeviceCalendarService.swift | 170 ++++++++++++++++++ ios/Calendarr iOS/Views/AccountsView.swift | 27 +++ .../Views/Calendar/EventDetailSheet.swift | 2 + .../Views/Calendar/EventEditorSheet.swift | 12 +- .../Views/CalendarFilterContent.swift | 8 + 8 files changed, 254 insertions(+), 3 deletions(-) create mode 100644 ios/Calendarr iOS/Services/DeviceCalendarService.swift diff --git a/ios/Calendarr iOS.xcodeproj/project.pbxproj b/ios/Calendarr iOS.xcodeproj/project.pbxproj index 3a7d006..70ee9d6 100644 --- a/ios/Calendarr iOS.xcodeproj/project.pbxproj +++ b/ios/Calendarr iOS.xcodeproj/project.pbxproj @@ -1084,6 +1084,7 @@ INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsLocalNetworking = YES; + INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Calendarr zeigt die Kalender dieses iPhones (z. B. iCloud und Outlook) an und legt dort Termine an."; INFOPLIST_KEY_NSContactsUsageDescription = "Calendarr liest Geburtstage aus deinen Kontakten, um sie in deinen Geburtstagskalender zu übernehmen."; INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -1136,6 +1137,7 @@ INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsLocalNetworking = YES; + INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Calendarr zeigt die Kalender dieses iPhones (z. B. iCloud und Outlook) an und legt dort Termine an."; INFOPLIST_KEY_NSContactsUsageDescription = "Calendarr liest Geburtstage aus deinen Kontakten, um sie in deinen Geburtstagskalender zu übernehmen."; INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; diff --git a/ios/Calendarr iOS/Models/CalendarStore.swift b/ios/Calendarr iOS/Models/CalendarStore.swift index 0ac1a30..35a0c28 100644 --- a/ios/Calendarr iOS/Models/CalendarStore.swift +++ b/ios/Calendarr iOS/Models/CalendarStore.swift @@ -44,6 +44,12 @@ struct WritableCalendar: Identifiable { let color: String let source: String let numericId: Int + /// EventKit calendar identifier; set only for `source == "device"`, whose + /// ids are strings rather than server row ids. + var deviceId: String? = nil + + /// The id as it appears in a calendar key, whichever kind it is. + var calendarIdString: String { deviceId ?? String(numericId) } } @Observable @@ -420,8 +426,13 @@ class CalendarStore { /// Load events for a specific range. Skips the network if already cached, /// unless `force` is set (used after create/edit to pull fresh server data /// for the visible range, bypassing the cache). + /// Device-calendar generation the cache was filled at (see DeviceCalendarService). + private var deviceGeneration = DeviceCalendarService.generation + func loadEvents(api: CalendarrAPI, start: Date, end: Date, force: Bool = false) async { - if !force, isCached(start: start, end: end) { + let deviceChanged = deviceGeneration != DeviceCalendarService.generation + if deviceChanged { deviceGeneration = DeviceCalendarService.generation } + if !force, !deviceChanged, isCached(start: start, end: end) { refreshFromCache(start: start, end: end) return } @@ -453,7 +464,11 @@ class CalendarStore { let combined = try await api.fetchGroupCombined(groupId: g.id, start: start, end: end) return (combined.map { decorateGroupEvent($0) }, []) } - return try await api.fetchEvents(start: start, end: end) + let (server, errors) = try await api.fetchEvents(start: start, end: end) + // Device calendars live on the phone, so they are merged in here rather + // than coming from the server. They are deliberately absent from the + // group overlay, which is a server-side combined view. + return (server + DeviceCalendarService.shared.events(start: start, end: end), errors) } /// Prefix a combined-view event with its owner (others) or 👥 + creator @@ -685,6 +700,15 @@ class CalendarStore { for (accName, id, name, color) in haCals { result.append(WritableCalendar(id: "ha-\(id)", name: "\(accName) – \(name)", color: color, source: "homeassistant", numericId: id)) } + for cal in DeviceCalendarService.shared.calendars() where cal.allowsContentModifications { + result.append(WritableCalendar( + id: "device-\(cal.calendarIdentifier)", + name: cal.title, + color: "#8E8E93", + source: DeviceCalendarService.source, + numericId: 0, + deviceId: cal.calendarIdentifier)) + } writableCalendars = result writableCalendarErrors = errors } diff --git a/ios/Calendarr iOS/Models/Localization.swift b/ios/Calendarr iOS/Models/Localization.swift index 757a1ce..a4e6028 100644 --- a/ios/Calendarr iOS/Models/Localization.swift +++ b/ios/Calendarr iOS/Models/Localization.swift @@ -297,6 +297,10 @@ private let strings: [String: [String: String]] = [ "login.totp_placeholder": "6-stelliger Code", "login.remember": "Angemeldet bleiben", "login.signin": "Anmelden", + "device.calendars.header": "Gerätekalender", + "device.calendars.enable": "Kalender des iPhones anzeigen", + "device.calendars.desc": "Zeigt iCloud-, Outlook- und andere Kalender, die auf diesem iPhone eingerichtet sind. Sie bleiben auf dem Gerät und werden nicht an den Server übertragen — im Web und auf anderen Geräten erscheinen sie deshalb nicht.", + "device.calendars.denied": "Kein Zugriff auf die Kalender. Erlaube ihn in den Systemeinstellungen unter Datenschutz → Kalender.", "login.choose_server": "Anderen Server wählen", // Single sign-on (OpenID Connect) "sso.or": "oder", @@ -694,6 +698,10 @@ private let strings: [String: [String: String]] = [ "login.totp_placeholder": "6-digit code", "login.remember": "Stay signed in", "login.signin": "Sign in", + "device.calendars.header": "Device calendars", + "device.calendars.enable": "Show this iPhone's calendars", + "device.calendars.desc": "Shows iCloud, Outlook and other calendars set up on this iPhone. They stay on the device and are never sent to the server, so they do not appear on the web or on your other devices.", + "device.calendars.denied": "No calendar access. Grant it in System Settings under Privacy → Calendars.", "login.choose_server": "Choose another server", // Single sign-on (OpenID Connect) "sso.or": "or", diff --git a/ios/Calendarr iOS/Services/DeviceCalendarService.swift b/ios/Calendarr iOS/Services/DeviceCalendarService.swift new file mode 100644 index 0000000..636cac8 --- /dev/null +++ b/ios/Calendarr iOS/Services/DeviceCalendarService.swift @@ -0,0 +1,170 @@ +import EventKit +import Foundation +import UIKit + +/// The calendars iOS itself manages — iCloud, Outlook/Exchange, Google — read +/// and written through EventKit. +/// +/// These never reach the Calendarr server: they live on the device, so they +/// show up in this app only. That is the point. Publishing an Outlook calendar +/// is often blocked by company policy, and the phone already has the account. +/// +/// Off until the user turns it on in Accounts, so nobody is asked for calendar +/// access they did not ask for. +@MainActor +final class DeviceCalendarService { + static let shared = DeviceCalendarService() + + /// `CalEvent.source` for device events; also the prefix of their calendar key. + static let source = "device" + static let enabledKey = "deviceCalendarsEnabled" + + private let store = EKEventStore() + + private init() {} + + // MARK: - Access + + static var isEnabled: Bool { + get { UserDefaults.standard.bool(forKey: enabledKey) } + set { UserDefaults.standard.set(newValue, forKey: enabledKey) } + } + + var hasAccess: Bool { + EKEventStore.authorizationStatus(for: .event) == .fullAccess + } + + /// True once the user granted access. Write access needs full access — + /// `.writeOnly` would let us add events but never show them. + @discardableResult + func requestAccess() async -> Bool { + if hasAccess { return true } + let granted = (try? await store.requestFullAccessToEvents()) ?? false + if granted { Self.generation += 1 } + return granted + } + + /// Both switches on: the user enabled the feature *and* granted access. + var isActive: Bool { Self.isEnabled && hasAccess } + + /// Bumped when access is granted. The event cache was filled before the + /// device calendars existed, so the store treats a changed generation as a + /// cache miss — otherwise the new calendars would only turn up after a + /// restart. Cheaper than threading the store through the accounts screen. + private(set) static var generation = 0 + + // MARK: - Reading + + /// Every calendar the device knows, writable ones included. + func calendars() -> [EKCalendar] { + guard isActive else { return [] } + return store.calendars(for: .event) + .sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending } + } + + func calendar(id: String) -> EKCalendar? { + guard isActive else { return nil } + return store.calendar(withIdentifier: id) + } + + /// Device events in the range, mapped onto the app's own event type so the + /// existing views, filters and ordering treat them like any other source. + func events(start: Date, end: Date) -> [CalEvent] { + guard isActive else { return [] } + let cals = store.calendars(for: .event) + guard !cals.isEmpty else { return [] } + let predicate = store.predicateForEvents(withStart: start, end: end, calendars: cals) + return store.events(matching: predicate).compactMap(Self.map) + } + + private static func map(_ ek: EKEvent) -> CalEvent? { + // A recurring event's occurrences share one eventIdentifier, so append + // the start date to keep SwiftUI's per-event identity unique. + guard let ident = ek.eventIdentifier, + let start = ek.startDate, let end = ek.endDate else { return nil } + let cal = ek.calendar + return CalEvent( + id: "\(ident)|\(Int(start.timeIntervalSince1970))", + url: "", + title: ek.title ?? "", + startDate: start, + endDate: end, + isAllDay: ek.isAllDay, + location: ek.location ?? "", + notes: ek.notes ?? "", + color: hex(cal?.cgColor), + calendarId: cal?.calendarIdentifier ?? "", + calendarName: cal?.title ?? "", + calendarColor: hex(cal?.cgColor) ?? "#8E8E93", + source: source, + readOnly: !(cal?.allowsContentModifications ?? false) + ) + } + + /// The EKEvent behind one of our composite ids (see `map`). + private func ekEvent(for calEventId: String) -> EKEvent? { + let ident = calEventId.split(separator: "|").first.map(String.init) ?? calEventId + return store.event(withIdentifier: ident) + } + + // MARK: - Writing + + func create(calendarId: String, title: String, start: Date, end: Date, + isAllDay: Bool, location: String, notes: String) throws { + guard let cal = store.calendar(withIdentifier: calendarId) else { + throw DeviceCalendarError.calendarGone + } + let ev = EKEvent(eventStore: store) + ev.calendar = cal + apply(ev, title: title, start: start, end: end, + isAllDay: isAllDay, location: location, notes: notes) + try store.save(ev, span: .thisEvent, commit: true) + } + + func update(eventId: String, title: String, start: Date, end: Date, + isAllDay: Bool, location: String, notes: String) throws { + guard let ev = ekEvent(for: eventId) else { throw DeviceCalendarError.eventGone } + apply(ev, title: title, start: start, end: end, + isAllDay: isAllDay, location: location, notes: notes) + // .thisEvent on a recurring series edits only the occurrence the user + // opened, which is what tapping a single day implies. + try store.save(ev, span: .thisEvent, commit: true) + } + + func delete(eventId: String) throws { + guard let ev = ekEvent(for: eventId) else { throw DeviceCalendarError.eventGone } + try store.remove(ev, span: .thisEvent, commit: true) + } + + private func apply(_ ev: EKEvent, title: String, start: Date, end: Date, + isAllDay: Bool, location: String, notes: String) { + ev.title = title + ev.startDate = start + ev.endDate = end + ev.isAllDay = isAllDay + ev.location = location.isEmpty ? nil : location + ev.notes = notes.isEmpty ? nil : notes + } + + // MARK: - Helpers + + private static func hex(_ color: CGColor?) -> String? { + guard let color, let c = UIColor(cgColor: color).cgColor.components, c.count >= 3 else { return nil } + return String(format: "#%02X%02X%02X", + Int((c[0] * 255).rounded()), + Int((c[1] * 255).rounded()), + Int((c[2] * 255).rounded())) + } +} + +enum DeviceCalendarError: LocalizedError { + case calendarGone + case eventGone + + var errorDescription: String? { + switch self { + case .calendarGone: return "Dieser Gerätekalender existiert nicht mehr." + case .eventGone: return "Dieser Termin existiert auf dem Gerät nicht mehr." + } + } +} diff --git a/ios/Calendarr iOS/Views/AccountsView.swift b/ios/Calendarr iOS/Views/AccountsView.swift index e66eba8..e1b1c00 100644 --- a/ios/Calendarr iOS/Views/AccountsView.swift +++ b/ios/Calendarr iOS/Views/AccountsView.swift @@ -30,6 +30,8 @@ struct AccountsView: View { @State private var birthdayNotify = -1 @AppStorage("appLanguage") private var appLang = "system" + @AppStorage(DeviceCalendarService.enabledKey) private var deviceCalendarsEnabled = false + @State private var deviceCalendarAccessGranted = false var body: some View { NavigationStack { @@ -42,6 +44,7 @@ struct AccountsView: View { caldavSection localSection birthdayContactsSection + deviceCalendarSection icalSection googleSection haSection @@ -235,6 +238,30 @@ struct AccountsView: View { localCalendars.first { $0.isBirthday && $0.owned } } + /// Calendars the phone already has (iCloud, Outlook/Exchange, Google). + /// They stay on the device — the server never sees them — so this is the + /// way in when publishing an Outlook calendar is blocked by company policy. + @ViewBuilder var deviceCalendarSection: some View { + Section { + Toggle(L10n.t("device.calendars.enable", appLang), isOn: $deviceCalendarsEnabled) + if deviceCalendarsEnabled && !deviceCalendarAccessGranted { + Text(L10n.t("device.calendars.denied", appLang)) + .font(.caption).foregroundStyle(.secondary) + } + } header: { + Text(L10n.t("device.calendars.header", appLang)) + } footer: { + Text(L10n.t("device.calendars.desc", appLang)) + } + .onChange(of: deviceCalendarsEnabled) { _, on in + guard on else { return } + // Granting access bumps the service generation, which makes the + // calendar reload instead of serving its pre-device cache. + Task { deviceCalendarAccessGranted = await DeviceCalendarService.shared.requestAccess() } + } + .task { deviceCalendarAccessGranted = DeviceCalendarService.shared.hasAccess } + } + @ViewBuilder var birthdayContactsSection: some View { Section { if let cal = birthdayCalendar { diff --git a/ios/Calendarr iOS/Views/Calendar/EventDetailSheet.swift b/ios/Calendarr iOS/Views/Calendar/EventDetailSheet.swift index a60b8f0..faa52e8 100644 --- a/ios/Calendarr iOS/Views/Calendar/EventDetailSheet.swift +++ b/ios/Calendarr iOS/Views/Calendar/EventDetailSheet.swift @@ -264,6 +264,8 @@ struct EventDetailSheet: View { // calendarId looks like "homeassistant-42" → numeric DB id 42 let calId = Int(event.calendarId.replacingOccurrences(of: "homeassistant-", with: "")) ?? 0 try await api.deleteHAEvent(calendarId: calId, uid: event.id) + case DeviceCalendarService.source: + try DeviceCalendarService.shared.delete(eventId: event.id) default: let calId = Int(event.calendarId) try await api.deleteCalDAVEvent(uid: event.id, url: event.url, calendarId: calId) diff --git a/ios/Calendarr iOS/Views/Calendar/EventEditorSheet.swift b/ios/Calendarr iOS/Views/Calendar/EventEditorSheet.swift index 533dc34..01a11dc 100644 --- a/ios/Calendarr iOS/Views/Calendar/EventEditorSheet.swift +++ b/ios/Calendarr iOS/Views/Calendar/EventEditorSheet.swift @@ -56,7 +56,7 @@ struct EventEditorSheet: View { /// controls and explain that they won't fire. private var remindersDisabled: Bool { guard let cal = selectedCal else { return false } - let key = CalendarStore.calendarKey(source: cal.source, calendarId: String(cal.numericId)) + let key = CalendarStore.calendarKey(source: cal.source, calendarId: cal.calendarIdString) return store.reminderDisabledKeys.contains(key) } @@ -293,6 +293,8 @@ struct EventEditorSheet: View { if ev.source == "homeassistant" { let num = ev.calendarId.replacingOccurrences(of: "homeassistant-", with: "") selectedCalendarId = "ha-\(num)" + } else if ev.source == DeviceCalendarService.source { + selectedCalendarId = "device-\(ev.calendarId)" } else { selectedCalendarId = ev.calendarId } @@ -347,6 +349,10 @@ struct EventEditorSheet: View { try await api.createHAEvent(calendarId: haCalId, title: title, start: start, end: end, isAllDay: isAllDay, location: location, description: notes) + case DeviceCalendarService.source: + try DeviceCalendarService.shared.update( + eventId: ev.id, title: title, start: start, end: end, + isAllDay: isAllDay, location: location, notes: notes) default: // caldav let calId = Int(ev.calendarId) try await api.updateCalDAVEvent(uid: ev.id, url: ev.url, calendarId: calId, @@ -370,6 +376,10 @@ struct EventEditorSheet: View { try await api.createHAEvent(calendarId: cal.numericId, title: title, start: start, end: end, isAllDay: isAllDay, location: location, description: notes) + case DeviceCalendarService.source: + try DeviceCalendarService.shared.create( + calendarId: cal.calendarIdString, title: title, start: start, end: end, + isAllDay: isAllDay, location: location, notes: notes) default: // caldav try await api.createCalDAVEvent(calendarId: cal.numericId, title: title, start: start, end: end, isAllDay: isAllDay, diff --git a/ios/Calendarr iOS/Views/CalendarFilterContent.swift b/ios/Calendarr iOS/Views/CalendarFilterContent.swift index 5c524d8..e6df0c6 100644 --- a/ios/Calendarr iOS/Views/CalendarFilterContent.swift +++ b/ios/Calendarr iOS/Views/CalendarFilterContent.swift @@ -244,6 +244,14 @@ struct CalendarFilterContent: View { for sub in icalSubs { add("ical", sub.id, sub.name, sub.color) } for acc in googleAccounts { for cal in acc.calendars ?? [] { add("google", cal.id, cal.name, cal.color ?? "#4285f4") } } for acc in haAccounts { for cal in acc.calendars ?? [] { add("homeassistant", cal.id, cal.name, cal.color ?? "#46bdc6") } } + // Device calendars key on an EventKit identifier, not a server row id. + for cal in DeviceCalendarService.shared.calendars() { + let key = CalendarStore.calendarKey(source: DeviceCalendarService.source, + calendarId: cal.calendarIdentifier) + if b.contains(key) { continue } + combined.append(CalRow(id: key, name: cal.title, colorHex: "#8E8E93", + readOnly: !cal.allowsContentModifications)) + } allKeys = Set(combined.map(\.id)) let orderedKeys = store.ordered(combined.map(\.id))