feat(ios): Kalender des Geraets anzeigen und bespielen
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2db4b42fa1
commit
c811486ea7
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user