Compare commits

..

17 Commits

Author SHA1 Message Date
Scarriffle
59a879ea23 fix: move Today button to the right so the month title sits centered (iOS)
Left side now holds only the prev/next chevrons; "Heute" moved next to the
burger menu on the right (both flat and Liquid Glass bars), so the month title
is centered again instead of pushed right by a heavy left cluster.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 10:14:08 +02:00
Scarriffle
f480b438cb feat: top bar declutter — view/filter/groups/sync into one burger popup (iOS)
The top bar now shows only nav + title + a single burger. Tapping it opens a
compact menu: View (with a fixed icon, no longer per-view), Filter, Groups
(if any), Sync, and an "Einstellungen" entry that opens the existing full menu.
Removed the separate group/view/filter icons from both the flat bar and the
Liquid Glass toolbar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 16:23:56 +02:00
Scarriffle
587a0e65fa feat: event reminders + default reminder setting + local notifications (iOS)
Per-event reminders (multiple, local calendars only) in the editor, prefilled
from a new "default reminder" setting that applies to all events otherwise.
CalEvent gains `reminders`; AppSettings/SettingsSync sync default_reminder_minutes
(always group). New NotificationScheduler requests permission and schedules the
soonest ≤60 upcoming reminders via UNUserNotificationCenter, rescheduling on
load/sync/edit and when the default changes (skipped in group overlay).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 16:21:08 +02:00
Scarriffle
e7d8effb47 fix: Liquid Glass month title as inline content strip (not the system toolbar)
The NavigationStack toolbar title never refreshes on month change on iOS 26
(4 approaches tried: principal Text, navigationTitle, @Observable store). The
title now renders as a normal inline Text in a top safe-area inset just below
the system glass toolbar — the same mechanism as the flat variant, which does
update. The system toolbar keeps the buttons + the real Liquid Glass look.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 21:30:07 +02:00
Scarriffle
68349d36e5 feat: non-emoji group icons (SF Symbols) for consistent cross-platform look
Group icons are now semantic keys (people/home/heart/work/school/sports/party/
pet/travel/music/food/star) rendered as SF Symbols in the picker, group list,
switcher, banner and filter — instead of OS emoji that looked different on every
platform. Legacy emoji values still render as a fallback. GroupCombinedView uses
the server display_title.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 19:20:32 +02:00
Scarriffle
451d3d4d6b fix: Liquid Glass month title updates via @Observable store (visibleMonth)
The system NavigationStack toolbar title would not refresh on a plain @State
change (title kept disappearing on iPhone). Moved visibleMonth into the
@Observable CalendarStore so the toolbar's read is tracked with @Observable's
fine-grained observation and refreshes on month change. Reverted the @State/.id
workaround. Real system glass bar retained.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 19:06:53 +02:00
Scarriffle
51218b9aa3 fix: restore real Liquid Glass bar; drive title via @State so it refreshes
The custom safeAreaInset bar removed the actual iOS-26 Liquid Glass look
("no glass even though enabled"). Restored the system NavigationStack glass
toolbar and instead fixed the disappearing month title by driving it from a
@State (navTitle) updated via onChange(of: titleString) and keyed with .id,
which forces the system bar to refresh on month change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 18:22:16 +02:00
Scarriffle
b61a90d960 feat: hide individual member calendars in the group view (iOS)
The calendar filter, when a group overlay is active, now lists the group's
members (+ the shared group calendar) and lets you hide each one individually
(Outlook-style). Filtering is client-side via CalendarStore.hiddenGroupKeys
(per-member gm:<id> / group-calendar gc keys), reset when switching groups.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 18:00:30 +02:00
Scarriffle
b9547c15f9 feat: render server display_title for group events (consistent across clients)
CalEvent parses display_title; the combined view uses it (group icon + owner
prefix from the server) instead of client-side decoration, with a fallback for
older servers. Raw title kept for editing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 17:54:23 +02:00
Scarriffle
8521a28520 fix: visible Liquid Glass again, group icon on group events, week today colour
- Liquid Glass: the calendar content now scrolls underneath a translucent
  safeAreaInset bar (real glass look restored) while the inline title stays
  reliable — toggling Liquid Glass is visibly different again.
- Group events are prefixed with the group's own emoji icon (from group
  settings) instead of a generic people glyph, so they're recognisable.
- Week view: today's column header now uses the configured "today" colour
  instead of the accent colour (matches the current-time line).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 17:22:02 +02:00
Scarriffle
7f76df2600 fix: Liquid Glass month title now updates reliably (custom glass bar)
navigationTitle/principal items in the NavigationStack toolbar silently fail to
refresh on scroll-driven state changes (visibleMonth), so the month label
vanished on month change and only returned after an unrelated rebuild. The glass
variant now uses a custom top bar with the same inline Text title as the flat
variant (proven to update), styled with glassEffect (iOS 26) / ultraThinMaterial.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 09:00:48 +02:00
Scarriffle
852e46fcf8 fix: month title disappears in Liquid Glass mode on month change
The glass variant rendered the month title in a `.principal` ToolbarItem, which
SwiftUI drops when the state it reads (visibleMonth) changes while on screen —
it only reappeared on an unrelated rebuild (opening/closing the menu) and
vanished again on the next month change. Switched to `.navigationTitle`, which
the system updates reliably.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 08:50:32 +02:00
Scarriffle
a62b200dfa chore: Marketing-Version auf 2.0 (Sharing/Gruppen/Import-Export-Release)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:33:12 +02:00
Scarriffle
c6f9981a54 fix: Gruppenansicht – Termine über Cache/Prefetch laden (kein Range-Gap)
Der Gruppen-Modus ersetzte events nur mit dem schmalen aktuellen Fetch, statt
denselben Cache/Prefetch-Pfad wie die Normalansicht zu nutzen -> Termine
erschienen erst nach Scrollen. Jetzt fetchForMode (personal/group) läuft durch
loadEvents + prefetchBackground + refreshFromCache; Moduswechsel lädt breit neu.
In der Gruppenansicht greift der "ausgeblendet"-Filter nicht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:14:48 +02:00
Scarriffle
815f2cf01a feat: iOS Kalenderfarben änderbar + Top-Bar entzerrt
- Kalenderverwaltung: tappbarer ColorPicker pro Kalender (lokal/iCal direkt;
  CalDAV/Google/HA klappen ihre Unterkalender mit je eigenem Farbwähler auf).
  Neue API: updateLocalCalendarColor, updateICalColor, setCalendarColor
  (caldav/google/homeassistant) -> PUT …/{id} {color}. Geteilte Kalender
  read-only (nur Besitzer).
- Top-Bar: Gruppen-Umschalter nur bei vorhandenen Gruppen, "Heute" nicht mehr
  quetschbar (fixedSize), kompaktere Icons -> "Heute" wird nicht mehr zu "H…".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:05:31 +02:00
Scarriffle
6dc8724a9a feat: iOS Gruppenansicht direkt im Kalender (Umschalter + Banner)
Gruppen sind nicht mehr nur im Menü versteckt: im Top-Bar gibt es einen
Gruppen-Umschalter (Persönlich / <Gruppe>). Beim Wählen einer Gruppe zeigt
der echte Monats-/Wochen-/Tagesansicht die kombinierte Überlagerung
(GET /groups/{id}/combined) mit server-definierten Farben und
Besitzer-Präfix; ein Banner "Gruppenansicht: <Name>" mit "Verlassen".
CalendarStore.activeGroup steuert den Modus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 20:55:28 +02:00
Scarriffle
c9803d80a3 fix: doppelter L10n-Key "settings.saved" entfernt (Runtime-Crash beim Start)
Ein doppelter Dictionary-Key liess die App beim Start abstürzen
("Dictionary literal contains duplicate keys"). Meine Dublette entfernt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 20:35:04 +02:00
17 changed files with 611 additions and 108 deletions

View File

@@ -510,7 +510,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.4; MARKETING_VERSION = 2.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios;
PRODUCT_NAME = "Calendarr iOS"; PRODUCT_NAME = "Calendarr iOS";
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -553,7 +553,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.4; MARKETING_VERSION = 2.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios;
PRODUCT_NAME = "Calendarr iOS"; PRODUCT_NAME = "Calendarr iOS";
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;

View File

@@ -18,6 +18,7 @@ struct AppSettings: Codable {
var lineColor: String = "#3A3A3C" var lineColor: String = "#3A3A3C"
var privateEventVisibility: String = "busy" // 'hidden' | 'busy' var privateEventVisibility: String = "busy" // 'hidden' | 'busy'
var groupVisibleCalendarId: Int? = nil var groupVisibleCalendarId: Int? = nil
var defaultReminderMinutes: Int? = nil // minutes before start; nil = off
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case defaultView = "default_view" case defaultView = "default_view"
@@ -37,6 +38,7 @@ struct AppSettings: Codable {
case lineColor = "line_color" case lineColor = "line_color"
case privateEventVisibility = "private_event_visibility" case privateEventVisibility = "private_event_visibility"
case groupVisibleCalendarId = "group_visible_calendar_id" case groupVisibleCalendarId = "group_visible_calendar_id"
case defaultReminderMinutes = "default_reminder_minutes"
} }
init() {} init() {}
@@ -66,6 +68,7 @@ struct AppSettings: Codable {
lineColor = try c.decodeIfPresent(String.self, forKey: .lineColor) ?? d.lineColor lineColor = try c.decodeIfPresent(String.self, forKey: .lineColor) ?? d.lineColor
privateEventVisibility = try c.decodeIfPresent(String.self, forKey: .privateEventVisibility) ?? d.privateEventVisibility privateEventVisibility = try c.decodeIfPresent(String.self, forKey: .privateEventVisibility) ?? d.privateEventVisibility
groupVisibleCalendarId = try c.decodeIfPresent(Int.self, forKey: .groupVisibleCalendarId) groupVisibleCalendarId = try c.decodeIfPresent(Int.self, forKey: .groupVisibleCalendarId)
defaultReminderMinutes = try c.decodeIfPresent(Int.self, forKey: .defaultReminderMinutes)
} }
} }

View File

@@ -38,6 +38,11 @@ struct CalEvent: Identifiable, Hashable {
var owner: EventPerson? = nil var owner: EventPerson? = nil
var isGroupEvent: Bool = false var isGroupEvent: Bool = false
var displayColor: String? = nil var displayColor: String? = nil
// Server-decorated title for the group combined view (group icon / owner
// prefix); rendered in group mode while `title` stays raw for editing.
var displayTitle: String? = nil
// Reminder offsets in minutes-before-start (0 = at start). Local events only.
var reminders: [Int] = []
// Group view supplies a server-resolved colour; otherwise per-event then calendar colour. // Group view supplies a server-resolved colour; otherwise per-event then calendar colour.
var effectiveColor: String { displayColor ?? color ?? calendarColor } var effectiveColor: String { displayColor ?? color ?? calendarColor }
@@ -78,7 +83,9 @@ struct CalEvent: Identifiable, Hashable {
isPrivate: json["private"] as? Bool ?? false, isPrivate: json["private"] as? Bool ?? false,
owner: EventPerson.from(json["owner"]), owner: EventPerson.from(json["owner"]),
isGroupEvent: json["is_group_event"] as? Bool ?? false, isGroupEvent: json["is_group_event"] as? Bool ?? false,
displayColor: (json["display_color"] as? String).flatMap { $0.isEmpty ? nil : $0 } displayColor: (json["display_color"] as? String).flatMap { $0.isEmpty ? nil : $0 },
displayTitle: (json["display_title"] as? String).flatMap { $0.isEmpty ? nil : $0 },
reminders: (json["reminders"] as? [Int]) ?? (json["reminders"] as? [Any])?.compactMap { ($0 as? Int) ?? Int("\($0)") } ?? []
) )
} }
} }

View File

@@ -51,11 +51,18 @@ class CalendarStore {
var events: [CalEvent] = [] var events: [CalEvent] = []
var viewType: CalViewType = .month var viewType: CalViewType = .month
var currentDate: Date = .now var currentDate: Date = .now
// The month currently scrolled into view (month view). Lives in the store so
// the Liquid-Glass navigation title read in the system toolbar updates
// via @Observable tracking (a plain @State did not refresh the toolbar).
var visibleMonth: Date = .now
var isLoading = false var isLoading = false
var isCachingBackground = false var isCachingBackground = false
var lastError: String? = nil var lastError: String? = nil
var weekStartsOnMonday = true var weekStartsOnMonday = true
var writableCalendars: [WritableCalendar] = [] var writableCalendars: [WritableCalendar] = []
// When set, the calendar shows the group's combined overlay instead of the
// user's own events. nil = personal view.
var activeGroup: CalGroup? = nil
/// Set of `"source:calendarId"` keys the user has chosen to hide from the /// Set of `"source:calendarId"` keys the user has chosen to hide from the
/// calendar views. Persisted in UserDefaults as a JSON array. Events whose /// calendar views. Persisted in UserDefaults as a JSON array. Events whose
@@ -67,6 +74,15 @@ class CalendarStore {
/// show/hide list. Re-activation happens in AccountsView. /// show/hide list. Re-activation happens in AccountsView.
var banishedCalendarKeys: Set<String> = CalendarStore.loadBanishedKeys() var banishedCalendarKeys: Set<String> = CalendarStore.loadBanishedKeys()
/// Group-overlay visibility: which members' calendars (`gm:<userId>`) and the
/// group calendar (`gc`) are hidden in the combined view like hiding
/// individual people in Outlook. In-memory; resets when leaving/switching a
/// group (the per-calendar hide/banish sets are for the personal view only).
var hiddenGroupKeys: Set<String> = []
static func groupMemberKey(_ ownerId: Int) -> String { "gm:\(ownerId)" }
static let groupCalendarKey = "gc"
// Cache bookkeeping // Cache bookkeeping
private var cachedStart: Date? = nil private var cachedStart: Date? = nil
private var cachedEnd: Date? = nil private var cachedEnd: Date? = nil
@@ -111,6 +127,19 @@ class CalendarStore {
publishWidgetSnapshot() publishWidgetSnapshot()
} }
/// Toggle / replace group-overlay visibility (members or the group calendar).
func setGroupKeyHidden(_ key: String, hidden: Bool) {
if hidden { hiddenGroupKeys.insert(key) } else { hiddenGroupKeys.remove(key) }
let (s, e) = rangeForCurrentView()
refreshFromCache(start: s, end: e)
}
func setHiddenGroupKeys(_ keys: Set<String>) {
hiddenGroupKeys = keys
let (s, e) = rangeForCurrentView()
refreshFromCache(start: s, end: e)
}
static func calendarKey(source: String, calendarId: String) -> String { static func calendarKey(source: String, calendarId: String) -> String {
// The events API returns `calendar_id` inconsistently: a raw numeric for // The events API returns `calendar_id` inconsistently: a raw numeric for
// CalDAV, but "<source>-<id>" for local / ical / google / homeassistant // CalDAV, but "<source>-<id>" for local / ical / google / homeassistant
@@ -216,11 +245,34 @@ class CalendarStore {
/// `start` / `end` are kept in the signature for call-site clarity. /// `start` / `end` are kept in the signature for call-site clarity.
func refreshFromCache(start: Date, end: Date) { func refreshFromCache(start: Date, end: Date) {
_ = (start, end) _ = (start, end)
// In group overlay mode the per-calendar hide/banish toggles don't apply;
// instead honour the per-member / group-calendar toggles (hiddenGroupKeys).
if activeGroup != nil {
if hiddenGroupKeys.isEmpty {
events = allCachedEvents
} else {
events = allCachedEvents.filter { ev in
if ev.isGroupEvent { return !hiddenGroupKeys.contains(Self.groupCalendarKey) }
if let o = ev.owner { return !hiddenGroupKeys.contains(Self.groupMemberKey(o.id ?? -1)) }
return true
}
}
return
}
events = allCachedEvents.filter { ev in events = allCachedEvents.filter { ev in
let key = Self.calendarKey(source: ev.source, calendarId: ev.calendarId) let key = Self.calendarKey(source: ev.source, calendarId: ev.calendarId)
return !hiddenCalendarKeys.contains(key) return !hiddenCalendarKeys.contains(key)
&& !banishedCalendarKeys.contains(key) && !banishedCalendarKeys.contains(key)
} }
// Personal events drive local reminder notifications.
NotificationScheduler.reschedule(events: allCachedEvents)
}
/// Recompute scheduled reminder notifications from the personal cache
/// (skipped while a group overlay is active).
func rescheduleNotifications() {
guard activeGroup == nil else { return }
NotificationScheduler.reschedule(events: allCachedEvents)
} }
/// Optimistically drop a just-deleted event from the cache so it disappears /// Optimistically drop a just-deleted event from the cache so it disappears
@@ -247,7 +299,7 @@ class CalendarStore {
lastError = nil lastError = nil
defer { isLoading = false } defer { isLoading = false }
do { do {
let fetched = try await api.fetchEvents(start: start, end: end) let fetched = try await fetchForMode(api: api, start: start, end: end)
mergeIntoCache(fetched, rangeStart: start, rangeEnd: end) mergeIntoCache(fetched, rangeStart: start, rangeEnd: end)
refreshFromCache(start: start, end: end) refreshFromCache(start: start, end: end)
} catch { } catch {
@@ -255,6 +307,41 @@ class CalendarStore {
} }
} }
/// Fetch events for the current mode (personal vs. group overlay). Group
/// events go through the same cache/prefetch/refresh path as personal ones,
/// so the whole visible grid is covered (no "only the middle weeks" gaps).
private func fetchForMode(api: CalendarrAPI, start: Date, end: Date) async throws -> [CalEvent] {
if let g = activeGroup {
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)
}
/// Prefix a combined-view event with its owner (others) or 👥 + creator
/// (group calendar). Colour comes from the server's display_color.
private func decorateGroupEvent(_ ev: CalEvent) -> CalEvent {
// Prefer the server-decorated title (group icon + owner prefix) so web,
// iOS and Android render group events identically. `title` stays raw.
if let dt = ev.displayTitle, !dt.isEmpty {
var e = ev
e.title = dt
return e
}
// Fallback for older servers without display_title.
var e = ev
let me = UserDefaults.standard.integer(forKey: "userId")
let groupIcon = activeGroup?.icon ?? "👥"
func first(_ s: String) -> String { s.split(separator: " ").first.map(String.init) ?? s }
if ev.isGroupEvent {
if let c = ev.creator, c.id != me { e.title = "\(groupIcon) \(first(c.displayName)): \(ev.title)" }
else { e.title = "\(groupIcon) \(ev.title)" }
} else if let o = ev.owner, o.id != me {
e.title = "\(first(o.displayName)): \(ev.title)"
}
return e
}
/// Background prefetch for ±months around today called once on startup. /// Background prefetch for ±months around today called once on startup.
func prefetchBackground(api: CalendarrAPI, months: Int) async { func prefetchBackground(api: CalendarrAPI, months: Int) async {
let cal = userCalendar let cal = userCalendar
@@ -266,7 +353,7 @@ class CalendarStore {
isCachingBackground = true isCachingBackground = true
defer { isCachingBackground = false } defer { isCachingBackground = false }
do { do {
let fetched = try await api.fetchEvents(start: start, end: end) let fetched = try await fetchForMode(api: api, start: start, end: end)
mergeIntoCache(fetched, rangeStart: start, rangeEnd: end) mergeIntoCache(fetched, rangeStart: start, rangeEnd: end)
// Refresh visible range from newly expanded cache // Refresh visible range from newly expanded cache
let (vs, ve) = rangeForCurrentView() let (vs, ve) = rangeForCurrentView()

View File

@@ -128,7 +128,6 @@ private let strings: [String: [String: String]] = [
"settings.sunday": "Sonntag", "settings.sunday": "Sonntag",
"settings.dimpast": "Vergangene Termine ausgrauen", "settings.dimpast": "Vergangene Termine ausgrauen",
"settings.nav.profile": "Profil", "settings.nav.profile": "Profil",
"settings.saved": "Gespeichert",
"settings.privacy": "Privatsphäre", "settings.privacy": "Privatsphäre",
"settings.private_visibility": "Private Termine für Gruppen", "settings.private_visibility": "Private Termine für Gruppen",
"settings.private_visibility.desc": "Wie private Termine für andere Gruppenmitglieder erscheinen", "settings.private_visibility.desc": "Wie private Termine für andere Gruppenmitglieder erscheinen",
@@ -155,6 +154,9 @@ private let strings: [String: [String: String]] = [
"common.info": "Info", "common.info": "Info",
"common.done": "Fertig", "common.done": "Fertig",
"groups.title": "Gruppen", "groups.title": "Gruppen",
"groups.personal": "Persönlich",
"groups.view_label": "Gruppenansicht",
"groups.exit": "Verlassen",
"groups.none": "Noch keine Gruppen", "groups.none": "Noch keine Gruppen",
"groups.combined_empty": "Keine Termine in diesem Zeitraum", "groups.combined_empty": "Keine Termine in diesem Zeitraum",
"group.create": "Gruppe erstellen", "group.create": "Gruppe erstellen",
@@ -162,6 +164,7 @@ private let strings: [String: [String: String]] = [
"group.name": "Name", "group.name": "Name",
"group.icon": "Icon", "group.icon": "Icon",
"group.members": "Mitglieder", "group.members": "Mitglieder",
"group.calendar": "Gruppenkalender",
"group.member_colors": "Farben der Mitglieder", "group.member_colors": "Farben der Mitglieder",
"group.delete": "Gruppe löschen", "group.delete": "Gruppe löschen",
@@ -427,7 +430,6 @@ private let strings: [String: [String: String]] = [
"settings.sunday": "Sunday", "settings.sunday": "Sunday",
"settings.dimpast": "Dim past events", "settings.dimpast": "Dim past events",
"settings.nav.profile": "Profile", "settings.nav.profile": "Profile",
"settings.saved": "Saved",
"settings.privacy": "Privacy", "settings.privacy": "Privacy",
"settings.private_visibility": "Private events for groups", "settings.private_visibility": "Private events for groups",
"settings.private_visibility.desc": "How your private events appear to other group members", "settings.private_visibility.desc": "How your private events appear to other group members",
@@ -454,6 +456,9 @@ private let strings: [String: [String: String]] = [
"common.info": "Info", "common.info": "Info",
"common.done": "Done", "common.done": "Done",
"groups.title": "Groups", "groups.title": "Groups",
"groups.personal": "Personal",
"groups.view_label": "Group view",
"groups.exit": "Exit",
"groups.none": "No groups yet", "groups.none": "No groups yet",
"groups.combined_empty": "No events in this period", "groups.combined_empty": "No events in this period",
"group.create": "Create group", "group.create": "Create group",
@@ -461,6 +466,7 @@ private let strings: [String: [String: String]] = [
"group.name": "Name", "group.name": "Name",
"group.icon": "Icon", "group.icon": "Icon",
"group.members": "Members", "group.members": "Members",
"group.calendar": "Group calendar",
"group.member_colors": "Member colours", "group.member_colors": "Member colours",
"group.delete": "Delete group", "group.delete": "Delete group",

View File

@@ -0,0 +1,37 @@
import Foundation
/// Reminder offset options (minutes before an event's start; 0 = at start) and
/// their localized labels. Shared by the event editor, the settings default
/// picker, and the notification scheduler so the choices stay consistent.
enum ReminderOptions {
/// Selectable offsets in minutes-before-start.
static let all: [Int] = [0, 5, 10, 15, 30, 60, 120, 1440, 2880]
private static func isEnglish(_ appLang: String) -> Bool {
if appLang == "en" { return true }
if appLang == "de" { return false }
return (Locale.current.language.languageCode?.identifier ?? "de").hasPrefix("en")
}
static func label(_ minutes: Int, _ appLang: String) -> String {
let en = isEnglish(appLang)
if minutes <= 0 { return en ? "At start time" : "Zur Startzeit" }
if minutes < 60 { return en ? "\(minutes) min before" : "\(minutes) Min. vorher" }
if minutes < 1440 {
let h = minutes / 60
return en ? "\(h) h before" : "\(h) Std. vorher"
}
let d = minutes / 1440
return en ? "\(d) day\(d == 1 ? "" : "s") before" : "\(d) Tag\(d == 1 ? "" : "e") vorher"
}
static func sectionTitle(_ l: String) -> String { isEnglish(l) ? "Reminders" : "Benachrichtigungen" }
static func addLabel(_ l: String) -> String { isEnglish(l) ? "Add reminder" : "Benachrichtigung hinzufügen" }
static func off(_ l: String) -> String { isEnglish(l) ? "Off" : "Aus" }
static func defaultTitle(_ l: String) -> String { isEnglish(l) ? "Default reminder" : "Standardbenachrichtigung" }
static func defaultFooter(_ l: String) -> String {
isEnglish(l)
? "Applies to all events unless an event has its own reminders."
: "Gilt für alle Termine, sofern ein Termin keine eigenen Benachrichtigungen hat."
}
}

View File

@@ -229,7 +229,7 @@ class CalendarrAPI {
func createLocalEvent(calendarId: Int, title: String, start: Date, end: Date, func createLocalEvent(calendarId: Int, title: String, start: Date, end: Date,
isAllDay: Bool, location: String, description: String, color: String?, isAllDay: Bool, location: String, description: String, color: String?,
isPrivate: Bool = false) async throws -> CalEvent { isPrivate: Bool = false, reminders: [Int]? = nil) async throws -> CalEvent {
var body: [String: Any] = [ var body: [String: Any] = [
"calendar_id": calendarId, "calendar_id": calendarId,
"title": title, "title": title,
@@ -241,6 +241,7 @@ class CalendarrAPI {
"private": isPrivate "private": isPrivate
] ]
if let c = color, !c.isEmpty { body["color"] = c } if let c = color, !c.isEmpty { body["color"] = c }
if let reminders { body["reminders"] = reminders }
let data = try await request("/api/local/events", method: "POST", body: body) let data = try await request("/api/local/events", method: "POST", body: body)
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let ev = CalEvent.from(json: json) else { throw APIError.decodingError } let ev = CalEvent.from(json: json) else { throw APIError.decodingError }
@@ -249,7 +250,7 @@ class CalendarrAPI {
func updateLocalEvent(uid: String, title: String, start: Date, end: Date, func updateLocalEvent(uid: String, title: String, start: Date, end: Date,
isAllDay: Bool, location: String, description: String, color: String?, isAllDay: Bool, location: String, description: String, color: String?,
isPrivate: Bool = false) async throws { isPrivate: Bool = false, reminders: [Int]? = nil) async throws {
var body: [String: Any] = [ var body: [String: Any] = [
"title": title, "title": title,
"start": formatISO(start, allDay: isAllDay), "start": formatISO(start, allDay: isAllDay),
@@ -260,6 +261,7 @@ class CalendarrAPI {
"private": isPrivate "private": isPrivate
] ]
if let c = color { body["color"] = c } if let c = color { body["color"] = c }
if let reminders { body["reminders"] = reminders }
_ = try await request("/api/local/events/\(uid)", method: "PUT", body: body) _ = try await request("/api/local/events/\(uid)", method: "PUT", body: body)
} }
@@ -400,6 +402,28 @@ class CalendarrAPI {
body: ["enabled": !hidden, "sidebar_hidden": hidden]) body: ["enabled": !hidden, "sidebar_hidden": hidden])
} }
// MARK: Calendar colour
func updateLocalCalendarColor(id: Int, color: String) async throws {
_ = try await request("/api/local/calendars/\(id)", method: "PUT", body: ["color": color])
}
func updateICalColor(id: Int, color: String) async throws {
_ = try await request("/api/ical/subscriptions/\(id)", method: "PUT", body: ["color": color])
}
/// Set a per-calendar colour for server-managed sources (caldav/google/homeassistant).
func setCalendarColor(source: String, calendarId: Int, color: String) async throws {
let path: String
switch source {
case "caldav": path = "/api/caldav/calendars/\(calendarId)"
case "google": path = "/api/google/calendars/\(calendarId)"
case "homeassistant": path = "/api/homeassistant/calendars/\(calendarId)"
default: return
}
_ = try await request(path, method: "PUT", body: ["color": color])
}
// MARK: Profile (display name / login name / email) // MARK: Profile (display name / login name / email)
/// Update profile fields. A login-name change returns a fresh token (the old /// Update profile fields. A login-name change returns a fresh token (the old

View File

@@ -0,0 +1,65 @@
import Foundation
import UserNotifications
extension Notification.Name {
/// Posted when the default-reminder setting changes, so the calendar host
/// can recompute the scheduled local notifications from its cached events.
static let rescheduleReminders = Notification.Name("rescheduleReminders")
}
/// Schedules local OS notifications for upcoming events. Per-event reminders
/// (local events) take precedence; otherwise the user's default reminder applies
/// to every event (incl. external). Re-run whenever events or the default change.
enum NotificationScheduler {
static func requestAuthorizationIfNeeded() {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { _, _ in }
}
/// Recompute and (re)schedule notifications from the given events. The iOS
/// pending-notification cap is 64, so only the soonest are scheduled.
static func reschedule(events: [CalEvent]) {
let center = UNUserNotificationCenter.current()
center.getNotificationSettings { settings in
guard settings.authorizationStatus == .authorized
|| settings.authorizationStatus == .provisional else { return }
let defaultMin = (UserDefaults.standard.object(forKey: "defaultReminderMinutes") as? Int) ?? -1
let now = Date()
var pending: [(fire: Date, event: CalEvent)] = []
for ev in events {
let offsets = ev.reminders.isEmpty
? (defaultMin >= 0 ? [defaultMin] : [])
: ev.reminders
for m in offsets {
let fire = ev.startDate.addingTimeInterval(-Double(m) * 60)
if fire > now { pending.append((fire, ev)) }
}
}
pending.sort { $0.fire < $1.fire }
let limited = pending.prefix(60) // stay safely under the 64 system cap
center.removeAllPendingNotificationRequests()
for item in limited {
let content = UNMutableNotificationContent()
content.title = item.event.title
content.body = bodyText(item.event)
content.sound = .default
let comps = Calendar.current.dateComponents(
[.year, .month, .day, .hour, .minute, .second], from: item.fire)
let trigger = UNCalendarNotificationTrigger(dateMatching: comps, repeats: false)
center.add(UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: trigger))
}
}
}
private static func bodyText(_ ev: CalEvent) -> String {
var parts: [String] = []
if !ev.isAllDay {
let f = DateFormatter(); f.timeStyle = .short; f.dateStyle = .none
parts.append(f.string(from: ev.startDate))
}
if !ev.location.isEmpty { parts.append(ev.location) }
return parts.joined(separator: " · ")
}
}

View File

@@ -37,6 +37,7 @@ enum SettingsSync {
static let defaultView = "defaultView" static let defaultView = "defaultView"
static let weekStartDay = "weekStartDay" static let weekStartDay = "weekStartDay"
static let dimPastEvents = "dimPastEvents" static let dimPastEvents = "dimPastEvents"
static let defaultReminder = "defaultReminderMinutes" // Int, -1 = off
// master switch // master switch
static let enabled = "settingsSync" static let enabled = "settingsSync"
} }
@@ -71,6 +72,8 @@ enum SettingsSync {
s.defaultView = str(Key.defaultView, "month") s.defaultView = str(Key.defaultView, "month")
s.weekStartDay = str(Key.weekStartDay, "monday") s.weekStartDay = str(Key.weekStartDay, "monday")
s.dimPastEvents = UserDefaults.standard.bool(forKey: Key.dimPastEvents) s.dimPastEvents = UserDefaults.standard.bool(forKey: Key.dimPastEvents)
let rem = int(Key.defaultReminder, -1)
s.defaultReminderMinutes = rem < 0 ? nil : rem
return s return s
} }
@@ -84,6 +87,7 @@ enum SettingsSync {
d.set(s.defaultView, forKey: Key.defaultView) d.set(s.defaultView, forKey: Key.defaultView)
d.set(s.weekStartDay, forKey: Key.weekStartDay) d.set(s.weekStartDay, forKey: Key.weekStartDay)
d.set(s.dimPastEvents, forKey: Key.dimPastEvents) d.set(s.dimPastEvents, forKey: Key.dimPastEvents)
d.set(s.defaultReminderMinutes ?? -1, forKey: Key.defaultReminder)
guard includeOptional else { return } guard includeOptional else { return }
// NOTE: textColor / backgroundColor / lineColor are intentionally NOT // NOTE: textColor / backgroundColor / lineColor are intentionally NOT
// synced the server has no columns for them (iOS-only). Writing the // synced the server has no columns for them (iOS-only). Writing the
@@ -134,6 +138,7 @@ enum SettingsSync {
merged.defaultView = local.defaultView merged.defaultView = local.defaultView
merged.weekStartDay = local.weekStartDay merged.weekStartDay = local.weekStartDay
merged.dimPastEvents = local.dimPastEvents merged.dimPastEvents = local.dimPastEvents
merged.defaultReminderMinutes = local.defaultReminderMinutes
if isEnabled { if isEnabled {
merged.primaryColor = local.primaryColor merged.primaryColor = local.primaryColor
merged.accentColor = local.accentColor merged.accentColor = local.accentColor

View File

@@ -131,16 +131,22 @@ struct AccountsView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else { } else {
ForEach(caldavAccounts) { acc in ForEach(caldavAccounts) { acc in
HStack { VStack(alignment: .leading, spacing: 8) {
Circle() HStack {
.fill(Color(hex: acc.color)) Circle().fill(Color(hex: acc.color)).frame(width: 12, height: 12)
.frame(width: 12, height: 12) VStack(alignment: .leading, spacing: 2) {
VStack(alignment: .leading, spacing: 2) { Text(acc.name).font(.body)
Text(acc.name).font(.body) Text(acc.url).font(.caption).foregroundStyle(.secondary).lineLimit(1)
Text(acc.url) }
.font(.caption) }
.foregroundStyle(.secondary) ForEach(acc.calendars ?? []) { cal in
.lineLimit(1) HStack {
CalendarColorDot(hex: cal.color ?? acc.color) { hex in
try? await api.setCalendarColor(source: "caldav", calendarId: cal.id, color: hex)
}
Text(cal.name).font(.callout)
}
.padding(.leading, 8)
} }
} }
} }
@@ -163,9 +169,9 @@ struct AccountsView: View {
} else { } else {
ForEach(localCalendars) { cal in ForEach(localCalendars) { cal in
HStack { HStack {
Circle() CalendarColorDot(hex: cal.color, editable: cal.owned) { hex in
.fill(Color(hex: cal.color)) try? await api.updateLocalCalendarColor(id: cal.id, color: hex)
.frame(width: 12, height: 12) }
Text(cal.name) Text(cal.name)
if cal.group { if cal.group {
Image(systemName: "person.2.fill").font(.caption2).foregroundStyle(.secondary) Image(systemName: "person.2.fill").font(.caption2).foregroundStyle(.secondary)
@@ -213,9 +219,9 @@ struct AccountsView: View {
} else { } else {
ForEach(icalSubs) { sub in ForEach(icalSubs) { sub in
HStack { HStack {
Circle() CalendarColorDot(hex: sub.color) { hex in
.fill(Color(hex: sub.color)) try? await api.updateICalColor(id: sub.id, color: hex)
.frame(width: 12, height: 12) }
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(sub.name).font(.body) Text(sub.name).font(.body)
Text(String(format: L10n.t("accounts.ical.every", appLang), sub.refreshMinutes)) Text(String(format: L10n.t("accounts.ical.every", appLang), sub.refreshMinutes))
@@ -242,10 +248,20 @@ struct AccountsView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else { } else {
ForEach(googleAccounts) { acc in ForEach(googleAccounts) { acc in
HStack { VStack(alignment: .leading, spacing: 8) {
Image(systemName: "g.circle.fill") HStack {
.foregroundStyle(.red) Image(systemName: "g.circle.fill").foregroundStyle(.red)
Text(acc.email) Text(acc.email)
}
ForEach(acc.calendars ?? []) { cal in
HStack {
CalendarColorDot(hex: cal.color ?? "#4285f4") { hex in
try? await api.setCalendarColor(source: "google", calendarId: cal.id, color: hex)
}
Text(cal.name).font(.callout)
}
.padding(.leading, 8)
}
} }
} }
.onDelete { offsets in .onDelete { offsets in
@@ -347,12 +363,20 @@ struct AccountsView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else { } else {
ForEach(haAccounts) { acc in ForEach(haAccounts) { acc in
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 8) {
Text(acc.name).font(.body) VStack(alignment: .leading, spacing: 2) {
Text(acc.url) Text(acc.name).font(.body)
.font(.caption) Text(acc.url).font(.caption).foregroundStyle(.secondary).lineLimit(1)
.foregroundStyle(.secondary) }
.lineLimit(1) ForEach(acc.calendars ?? []) { cal in
HStack {
CalendarColorDot(hex: cal.color ?? "#03a9f4") { hex in
try? await api.setCalendarColor(source: "homeassistant", calendarId: cal.id, color: hex)
}
Text(cal.name).font(.callout)
}
.padding(.leading, 8)
}
} }
} }
.onDelete { offsets in .onDelete { offsets in
@@ -682,6 +706,31 @@ struct AddHASheet: View {
struct IdentifiableInt: Identifiable { let id: Int } struct IdentifiableInt: Identifiable { let id: Int }
struct ExportedICS: Identifiable { let id = UUID(); let url: URL } struct ExportedICS: Identifiable { let id = UUID(); let url: URL }
/// A tappable colour swatch (ColorPicker) for a calendar. Persists via `onPick`
/// when the chosen colour changes. Read-only fallback when `editable` is false.
struct CalendarColorDot: View {
let hex: String
var editable: Bool = true
let onPick: (String) async -> Void
@State private var color: Color
init(hex: String, editable: Bool = true, onPick: @escaping (String) async -> Void) {
self.hex = hex; self.editable = editable; self.onPick = onPick
_color = State(initialValue: Color(hex: hex))
}
var body: some View {
if editable {
ColorPicker("", selection: $color, supportsOpacity: false)
.labelsHidden()
.frame(width: 26, height: 26)
.onChange(of: color) { _, c in Task { await onPick(c.toHex()) } }
} else {
Circle().fill(Color(hex: hex)).frame(width: 14, height: 14)
}
}
}
/// Wraps UIActivityViewController so an exported .ics can be shared/saved. /// Wraps UIActivityViewController so an exported .ics can be shared/saved.
struct ActivityView: UIViewControllerRepresentable { struct ActivityView: UIViewControllerRepresentable {
let items: [Any] let items: [Any]

View File

@@ -27,16 +27,16 @@ struct CalendarHostView: View {
@State private var store = CalendarStore() @State private var store = CalendarStore()
@State private var editorContext: CalEditorContext? = nil @State private var editorContext: CalEditorContext? = nil
@State private var selectedEvent: CalEvent? = nil @State private var selectedEvent: CalEvent? = nil
@State private var visibleMonth: Date = .now
@State private var showFilter = false @State private var showFilter = false
@State private var didApplyDefaultView = false @State private var didApplyDefaultView = false
@State private var groups: [CalGroup] = []
private var titleString: String { private var titleString: String {
if store.viewType == .month { if store.viewType == .month {
let f = DateFormatter() let f = DateFormatter()
f.locale = L10n.locale(appLang) f.locale = L10n.locale(appLang)
f.dateFormat = "LLLL yyyy" f.dateFormat = "LLLL yyyy"
return f.string(from: visibleMonth).capitalized(with: L10n.locale(appLang)) return f.string(from: store.visibleMonth).capitalized(with: L10n.locale(appLang))
} }
return store.titleForCurrentView(language: appLang) return store.titleForCurrentView(language: appLang)
} }
@@ -67,6 +67,7 @@ struct CalendarHostView: View {
private var flatVariant: some View { private var flatVariant: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
topBar topBar
groupBanner
Divider() Divider()
errorBanner errorBanner
calendarContent calendarContent
@@ -83,7 +84,7 @@ struct CalendarHostView: View {
.onChange(of: store.currentDate) { _, _ in Task { await onNavigate() } } .onChange(of: store.currentDate) { _, _ in Task { await onNavigate() } }
.onChange(of: store.viewType) { _, _ in Task { await onNavigate() } } .onChange(of: store.viewType) { _, _ in Task { await onNavigate() } }
.onChange(of: cacheMonths) { _, _ in Task { await recache() } } .onChange(of: cacheMonths) { _, _ in Task { await recache() } }
.onChange(of: visibleMonth) { _, new in Task { await ensureLoaded(around: new) } } .onChange(of: store.visibleMonth) { _, new in Task { await ensureLoaded(around: new) } }
.onChange(of: scenePhase) { _, phase in if phase == .active { Task { await SettingsSync.pull(api: api) } } } .onChange(of: scenePhase) { _, phase in if phase == .active { Task { await SettingsSync.pull(api: api) } } }
.onReceive(NotificationCenter.default.publisher(for: .banishedCalendarsChanged)) { _ in .onReceive(NotificationCenter.default.publisher(for: .banishedCalendarsChanged)) { _ in
store.syncBanishedFromDefaults() store.syncBanishedFromDefaults()
@@ -94,11 +95,19 @@ struct CalendarHostView: View {
.onReceive(NotificationCenter.default.publisher(for: .manualSyncRequested)) { _ in .onReceive(NotificationCenter.default.publisher(for: .manualSyncRequested)) { _ in
Task { await forceReload() } Task { await forceReload() }
} }
.onReceive(NotificationCenter.default.publisher(for: .rescheduleReminders)) { _ in
store.rescheduleNotifications()
}
} }
// MARK: Liquid Glass variant // MARK: Liquid Glass variant
private var glassVariant: some View { private var glassVariant: some View {
// Real iOS-26 Liquid Glass: the system NavigationStack toolbar renders the
// glass bar (buttons). The month TITLE is NOT placed in the toolbar the
// system title silently fails to refresh on month change on iOS 26 but
// as a normal inline Text in a top safe-area inset just below the glass
// bar, where it updates reliably (same mechanism as the flat variant).
NavigationStack { NavigationStack {
calendarContent calendarContent
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -107,34 +116,32 @@ struct CalendarHostView: View {
loadingIndicator.padding(.top, 12) loadingIndicator.padding(.top, 12)
} }
.animation(.easeInOut(duration: 0.2), value: store.isLoading || store.isCachingBackground) .animation(.easeInOut(duration: 0.2), value: store.isLoading || store.isCachingBackground)
.overlay(alignment: .top) {
if let err = store.lastError { errorBannerView(err).padding(.top, 8) }
}
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarLeading) { ToolbarItem(placement: .navigationBarLeading) {
HStack(spacing: 2) { HStack(spacing: 2) {
Button { store.navigatePrev() } label: { Image(systemName: "chevron.left") } Button { store.navigatePrev() } label: { Image(systemName: "chevron.left") }
Button { store.navigateNext() } label: { Image(systemName: "chevron.right") } Button { store.navigateNext() } label: { Image(systemName: "chevron.right") }
Button(L10n.t("nav.today", appLang)) { store.moveToToday() }.font(.callout)
} }
} }
ToolbarItem(placement: .principal) { ToolbarItem(placement: .navigationBarTrailing) {
HStack(spacing: 8) {
Button(L10n.t("nav.today", appLang)) { store.moveToToday() }.font(.callout)
menuButton
}
}
}
.safeAreaInset(edge: .top, spacing: 0) {
VStack(spacing: 0) {
Text(titleString) Text(titleString)
.font(.headline) .font(.headline)
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.7) .minimumScaleFactor(0.7)
} .frame(maxWidth: .infinity)
ToolbarItem(placement: .navigationBarTrailing) { .padding(.vertical, 6)
HStack(spacing: 8) { .background(.bar)
viewPickerMenu groupBanner
Button { showFilter = true } label: { errorBanner
Image(systemName: "line.3.horizontal.decrease.circle")
.foregroundStyle(store.hiddenCalendarKeys.isEmpty ? .primary : Color.accentColor)
}
.accessibilityLabel(L10n.t("filter.button", appLang))
Button { showMenu = true } label: { Image(systemName: "line.3.horizontal") }
}
} }
} }
} }
@@ -144,7 +151,7 @@ struct CalendarHostView: View {
.onChange(of: store.currentDate) { _, _ in Task { await onNavigate() } } .onChange(of: store.currentDate) { _, _ in Task { await onNavigate() } }
.onChange(of: store.viewType) { _, _ in Task { await onNavigate() } } .onChange(of: store.viewType) { _, _ in Task { await onNavigate() } }
.onChange(of: cacheMonths) { _, _ in Task { await recache() } } .onChange(of: cacheMonths) { _, _ in Task { await recache() } }
.onChange(of: visibleMonth) { _, new in Task { await ensureLoaded(around: new) } } .onChange(of: store.visibleMonth) { _, new in Task { await ensureLoaded(around: new) } }
.onChange(of: scenePhase) { _, phase in if phase == .active { Task { await SettingsSync.pull(api: api) } } } .onChange(of: scenePhase) { _, phase in if phase == .active { Task { await SettingsSync.pull(api: api) } } }
.onReceive(NotificationCenter.default.publisher(for: .banishedCalendarsChanged)) { _ in .onReceive(NotificationCenter.default.publisher(for: .banishedCalendarsChanged)) { _ in
store.syncBanishedFromDefaults() store.syncBanishedFromDefaults()
@@ -155,11 +162,17 @@ struct CalendarHostView: View {
.onReceive(NotificationCenter.default.publisher(for: .manualSyncRequested)) { _ in .onReceive(NotificationCenter.default.publisher(for: .manualSyncRequested)) { _ in
Task { await forceReload() } Task { await forceReload() }
} }
.onReceive(NotificationCenter.default.publisher(for: .rescheduleReminders)) { _ in
store.rescheduleNotifications()
}
} }
// MARK: Top bar (flat mode) // MARK: Top bar (flat mode)
private var topBar: some View { /// Shared bar contents (chevrons / today / title / group / view / filter / menu).
/// Used by both the flat and the glass top bar so the inline title which
/// updates reliably on month change is identical in both modes.
@ViewBuilder private var barContents: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
HStack(spacing: 2) { HStack(spacing: 2) {
Button { store.navigatePrev() } label: { Button { store.navigatePrev() } label: {
@@ -172,53 +185,103 @@ struct CalendarHostView: View {
.font(.system(size: 17, weight: .medium)) .font(.system(size: 17, weight: .medium))
.frame(width: 36, height: 36) .frame(width: 36, height: 36)
} }
Button(L10n.t("nav.today", appLang)) { store.moveToToday() }
.font(.callout).padding(.horizontal, 6)
} }
.padding(.leading, 8) .padding(.leading, 6)
Spacer(minLength: 8) Spacer(minLength: 6)
Text(titleString) Text(titleString)
.font(.headline) .font(.headline)
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.7) .minimumScaleFactor(0.7)
Spacer(minLength: 8) .layoutPriority(1)
viewPickerMenu Spacer(minLength: 6)
filterButton Button(L10n.t("nav.today", appLang)) { store.moveToToday() }
Button { showMenu = true } label: { .font(.callout).padding(.horizontal, 6)
Image(systemName: "line.3.horizontal") .lineLimit(1).fixedSize()
.font(.system(size: 18, weight: .medium)) menuButton
.frame(width: 40, height: 40) .padding(.trailing, 2)
}
.padding(.trailing, 4)
} }
.frame(height: 48) .frame(height: 48)
.background(.bar)
} }
private var filterButton: some View { private var topBar: some View {
Button { showFilter = true } label: { barContents.background(.bar)
Image(systemName: "line.3.horizontal.decrease.circle") }
.font(.system(size: 17, weight: .medium))
.foregroundStyle(store.hiddenCalendarKeys.isEmpty ? .primary : Color.accentColor) @ViewBuilder private var groupBanner: some View {
.frame(width: 40, height: 40) if let g = store.activeGroup {
HStack(spacing: 6) {
GroupIconView(icon: g.icon).font(.subheadline)
Text("\(L10n.t("groups.view_label", appLang)): \(g.name)")
.font(.subheadline).lineLimit(1)
Spacer()
Button(L10n.t("groups.exit", appLang)) { switchGroup(nil) }
.font(.callout)
}
.padding(.horizontal, 12).padding(.vertical, 7)
.background(Color.accentColor.opacity(0.18))
} }
.accessibilityLabel(L10n.t("filter.button", appLang))
} }
private var viewPickerMenu: some View { private func switchGroup(_ g: CalGroup?) {
store.activeGroup = g
store.hiddenGroupKeys = [] // member visibility is per-group; start fresh
// The cache holds the previous mode's events drop it and reload the
// visible range + prefetch a wide window so the whole grid is covered.
Task { await forceReload() }
}
/// The single top-bar action: a compact popup holding view / filter /
/// groups / sync, plus an "Einstellungen" entry that opens the full menu.
/// (Replaces the separate view / filter / group icons in the bar.)
private var menuButton: some View {
Menu { Menu {
ForEach(CalViewType.allCases, id: \.self) { vt in // View (fixed icon, not per-view)
Button { store.viewType = vt } label: { Menu {
Label(vt.label(appLang), systemImage: vt.systemImage) ForEach(CalViewType.allCases, id: \.self) { vt in
Button { store.viewType = vt } label: {
Label(vt.label(appLang), systemImage: store.viewType == vt ? "checkmark" : vt.systemImage)
}
}
} label: {
Label(L10n.t("view.change", appLang), systemImage: "rectangle.3.group")
}
// Filter
Button { showFilter = true } label: {
Label(L10n.t("filter.button", appLang), systemImage: "line.3.horizontal.decrease.circle")
}
// Groups
if !groups.isEmpty {
Menu {
Button { switchGroup(nil) } label: {
Label(L10n.t("groups.personal", appLang),
systemImage: store.activeGroup == nil ? "checkmark" : "person")
}
ForEach(groups) { g in
Button { switchGroup(g) } label: {
Label(g.name,
systemImage: store.activeGroup?.id == g.id ? "checkmark" : GroupIcons.symbol(g.icon))
}
}
} label: {
Label(L10n.t("groups.title", appLang), systemImage: "person.2")
} }
} }
// Sync
Button { Task { await SettingsSync.pull(api: api); await forceReload() } } label: {
Label(L10n.t("menu.sync", appLang), systemImage: "arrow.triangle.2.circlepath")
}
Divider()
// Full settings menu
Button { showMenu = true } label: {
Label(L10n.t("menu.section.settings", appLang), systemImage: "gearshape")
}
} label: { } label: {
Image(systemName: store.viewType.systemImage) Image(systemName: "line.3.horizontal")
.font(.system(size: 17, weight: .medium)) .font(.system(size: 18, weight: .medium))
.foregroundStyle(.primary) .foregroundStyle(store.activeGroup == nil && store.hiddenCalendarKeys.isEmpty ? .primary : Color.accentColor)
.frame(width: 40, height: 40) .frame(width: 36, height: 36)
} }
.accessibilityLabel(L10n.t("view.change", appLang)) .accessibilityLabel(L10n.t("nav.menu", appLang))
} }
// MARK: Error banner // MARK: Error banner
@@ -267,8 +330,7 @@ struct CalendarHostView: View {
onShowDay: { day in onShowDay: { day in
store.currentDate = day store.currentDate = day
store.viewType = .day store.viewType = .day
}, })
visibleMonth: $visibleMonth)
case .week: case .week:
WeekView(store: store, WeekView(store: store,
onEventTap: { selectedEvent = $0 }, onEventTap: { selectedEvent = $0 },
@@ -346,12 +408,15 @@ struct CalendarHostView: View {
// MARK: Loading logic // MARK: Loading logic
private func startup() async { private func startup() async {
// Ask for notification permission early so reminders can be scheduled.
NotificationScheduler.requestAuthorizationIfNeeded()
// 0. Pull settings first so week-start / default-view are correct // 0. Pull settings first so week-start / default-view are correct
// before we compute the initial range and load events. // before we compute the initial range and load events.
await SettingsSync.pull(api: api) await SettingsSync.pull(api: api)
applyServerDrivenSettings(initial: true) applyServerDrivenSettings(initial: true)
await store.loadWritableCalendars(api: api) await store.loadWritableCalendars(api: api)
groups = (try? await api.getGroups()) ?? []
// 1. Load current view immediately (visible) // 1. Load current view immediately (visible)
let (s, e) = store.rangeForCurrentView() let (s, e) = store.rangeForCurrentView()
await store.loadEvents(api: api, start: s, end: e) await store.loadEvents(api: api, start: s, end: e)

View File

@@ -10,6 +10,7 @@ struct EventEditorSheet: View {
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system" @AppStorage("appLanguage") private var appLang = "system"
@AppStorage("defaultReminderMinutes") private var defaultReminderMinutes = -1
@State private var title = "" @State private var title = ""
@State private var isAllDay = false @State private var isAllDay = false
@State private var startDate = Date() @State private var startDate = Date()
@@ -19,6 +20,7 @@ struct EventEditorSheet: View {
@State private var selectedCalendarId: String = "" @State private var selectedCalendarId: String = ""
@State private var color = "" @State private var color = ""
@State private var isPrivate = false @State private var isPrivate = false
@State private var reminders: [Int] = []
@State private var isSaving = false @State private var isSaving = false
@State private var error = "" @State private var error = ""
@@ -81,6 +83,27 @@ struct EventEditorSheet: View {
Toggle(L10n.t("event.private", appLang), isOn: $isPrivate) Toggle(L10n.t("event.private", appLang), isOn: $isPrivate)
.tint(Color.accentColor) .tint(Color.accentColor)
} }
Section(ReminderOptions.sectionTitle(appLang)) {
ForEach(Array(reminders.enumerated()), id: \.offset) { idx, _ in
Picker(ReminderOptions.sectionTitle(appLang), selection: Binding(
get: { reminders.indices.contains(idx) ? reminders[idx] : 0 },
set: { if reminders.indices.contains(idx) { reminders[idx] = $0 } }
)) {
ForEach(ReminderOptions.all, id: \.self) { opt in
Text(ReminderOptions.label(opt, appLang)).tag(opt)
}
}
.labelsHidden()
}
.onDelete { reminders.remove(atOffsets: $0) }
Button {
let next = ReminderOptions.all.first { !reminders.contains($0) } ?? 15
reminders.append(next)
} label: {
Label(ReminderOptions.addLabel(appLang), systemImage: "bell.badge.plus")
}
}
} }
Section(L10n.t("event.color_section", appLang)) { Section(L10n.t("event.color_section", appLang)) {
@@ -149,6 +172,7 @@ struct EventEditorSheet: View {
notes = ev.notes notes = ev.notes
color = ev.color ?? "" color = ev.color ?? ""
isPrivate = ev.isPrivate isPrivate = ev.isPrivate
reminders = ev.reminders
// HA events use "homeassistant-42" in CalEvent but "ha-42" in WritableCalendar // HA events use "homeassistant-42" in CalEvent but "ha-42" in WritableCalendar
if ev.source == "homeassistant" { if ev.source == "homeassistant" {
let num = ev.calendarId.replacingOccurrences(of: "homeassistant-", with: "") let num = ev.calendarId.replacingOccurrences(of: "homeassistant-", with: "")
@@ -167,6 +191,7 @@ struct EventEditorSheet: View {
notes = ev.notes notes = ev.notes
color = ev.color ?? "" color = ev.color ?? ""
isPrivate = ev.isPrivate isPrivate = ev.isPrivate
reminders = ev.reminders
selectedCalendarId = store.writableCalendars.first?.id ?? "" selectedCalendarId = store.writableCalendars.first?.id ?? ""
} else { } else {
let cal = Calendar.current let cal = Calendar.current
@@ -174,6 +199,8 @@ struct EventEditorSheet: View {
minute: 0, second: 0, of: initialDate) ?? initialDate minute: 0, second: 0, of: initialDate) ?? initialDate
endDate = startDate.addingTimeInterval(3600) endDate = startDate.addingTimeInterval(3600)
selectedCalendarId = store.writableCalendars.first?.id ?? "" selectedCalendarId = store.writableCalendars.first?.id ?? ""
// New events inherit the user's default reminder (editable).
if defaultReminderMinutes >= 0 { reminders = [defaultReminderMinutes] }
} }
} }
@@ -193,7 +220,7 @@ struct EventEditorSheet: View {
case "local": case "local":
try await api.updateLocalEvent(uid: ev.id, title: title, start: start, end: end, try await api.updateLocalEvent(uid: ev.id, title: title, start: start, end: end,
isAllDay: isAllDay, location: location, description: notes, color: colorVal, isAllDay: isAllDay, location: location, description: notes, color: colorVal,
isPrivate: isPrivate) isPrivate: isPrivate, reminders: reminders)
case "homeassistant": case "homeassistant":
// No update API exists delete the old event and recreate with new data. // No update API exists delete the old event and recreate with new data.
let rawId = ev.calendarId.replacingOccurrences(of: "homeassistant-", with: "") let rawId = ev.calendarId.replacingOccurrences(of: "homeassistant-", with: "")
@@ -214,7 +241,7 @@ struct EventEditorSheet: View {
_ = try await api.createLocalEvent(calendarId: cal.numericId, title: title, _ = try await api.createLocalEvent(calendarId: cal.numericId, title: title,
start: start, end: end, isAllDay: isAllDay, start: start, end: end, isAllDay: isAllDay,
location: location, description: notes, color: colorVal, location: location, description: notes, color: colorVal,
isPrivate: isPrivate) isPrivate: isPrivate, reminders: reminders)
case "google": case "google":
try await api.createGoogleEvent(calendarDbId: cal.numericId, title: title, try await api.createGoogleEvent(calendarDbId: cal.numericId, title: title,
start: start, end: end, isAllDay: isAllDay, start: start, end: end, isAllDay: isAllDay,

View File

@@ -19,7 +19,6 @@ struct MonthView: View {
let onCreateEvent: (Date) -> Void let onCreateEvent: (Date) -> Void
let onShowWeek: (Date) -> Void let onShowWeek: (Date) -> Void
let onShowDay: (Date) -> Void let onShowDay: (Date) -> Void
@Binding var visibleMonth: Date
@AppStorage("appLanguage") private var appLang = "system" @AppStorage("appLanguage") private var appLang = "system"
@AppStorage("monthDividerColor") private var dividerHex = "#7090c0" @AppStorage("monthDividerColor") private var dividerHex = "#7090c0"
@@ -117,8 +116,8 @@ struct MonthView: View {
private func publishVisibleMonth(from week: Date?) { private func publishVisibleMonth(from week: Date?) {
guard let w = week else { return } guard let w = week else { return }
let month = cal.date(from: cal.dateComponents([.year, .month], from: w)) ?? w let month = cal.date(from: cal.dateComponents([.year, .month], from: w)) ?? w
if visibleMonth != month { if store.visibleMonth != month {
visibleMonth = month store.visibleMonth = month
} }
} }
} }

View File

@@ -61,7 +61,7 @@ struct WeekView: View {
ForEach(weekDays, id: \.self) { day in ForEach(weekDays, id: \.self) { day in
Text(headerFmt.string(from: day).uppercased()) Text(headerFmt.string(from: day).uppercased())
.font(.system(size: 10, weight: .semibold)) .font(.system(size: 10, weight: .semibold))
.foregroundStyle(cal.isDateInToday(day) ? Color.accentColor : Color(hex: textHex).opacity(secondaryTextOpacity(textContrast))) .foregroundStyle(cal.isDateInToday(day) ? Color(hex: todayHex) : Color(hex: textHex).opacity(secondaryTextOpacity(textContrast)))
.frame(maxWidth: .infinity, minHeight: 36) .frame(maxWidth: .infinity, minHeight: 36)
.overlay(alignment: .trailing) { .overlay(alignment: .trailing) {
Rectangle().fill(Color(hex: lineHex).opacity(gridLineOpacity(lineContrast))).frame(width: 0.5) Rectangle().fill(Color(hex: lineHex).opacity(gridLineOpacity(lineContrast))).frame(width: 0.5)

View File

@@ -20,12 +20,18 @@ struct CalendarFilterSheet: View {
@State private var banished: Set<String> = [] @State private var banished: Set<String> = []
/// All non-banished keys discovered during load used by bulk show/hide. /// All non-banished keys discovered during load used by bulk show/hide.
@State private var allKeys: Set<String> = [] @State private var allKeys: Set<String> = []
/// Group-mode: the active group's full detail (members + colours) and the
/// per-member / group-calendar hidden keys.
@State private var groupDetail: CalGroup? = nil
@State private var hiddenGroup: Set<String> = []
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Group { Group {
if isLoading { if isLoading {
ProgressView(L10n.t("filter.loading", appLang)) ProgressView(L10n.t("filter.loading", appLang))
} else if store.activeGroup != nil {
groupFilterList
} else if allKeys.isEmpty { } else if allKeys.isEmpty {
Text(L10n.t("filter.empty", appLang)) Text(L10n.t("filter.empty", appLang))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -167,8 +173,61 @@ struct CalendarFilterSheet: View {
} }
} }
// MARK: Group overlay filter (hide individual members / the group calendar)
@ViewBuilder
private var groupFilterList: some View {
if let g = groupDetail {
List {
Section(header: Label(g.name, systemImage: GroupIcons.symbol(g.icon))) {
ForEach(g.members ?? []) { m in
groupRow(name: m.displayName ?? "",
colorHex: m.color ?? "#4285f4",
key: CalendarStore.groupMemberKey(m.id))
}
groupRow(name: L10n.t("group.calendar", appLang),
colorHex: g.groupCalendarColor ?? "#4285f4",
key: CalendarStore.groupCalendarKey)
}
}
} else {
Text(L10n.t("filter.empty", appLang)).foregroundStyle(.secondary)
}
}
@ViewBuilder
private func groupRow(name: String, colorHex: String, key: String) -> some View {
let isVisible = !hiddenGroup.contains(key)
Button {
if isVisible { hiddenGroup.insert(key) } else { hiddenGroup.remove(key) }
store.setGroupKeyHidden(key, hidden: isVisible)
} label: {
HStack(spacing: 12) {
Circle()
.fill(Color(hex: colorHex))
.frame(width: 14, height: 14)
.opacity(isVisible ? 1.0 : 0.35)
Text(name)
.foregroundStyle(isVisible ? .primary : .secondary)
.strikethrough(!isVisible, color: .secondary)
Spacer()
Image(systemName: isVisible ? "eye" : "eye.slash")
.foregroundStyle(isVisible ? Color.accentColor : .secondary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
private func load() async { private func load() async {
isLoading = true isLoading = true
// Group overlay: list members (+ the group calendar) to hide individually.
if let g = store.activeGroup {
hiddenGroup = store.hiddenGroupKeys
groupDetail = try? await api.getGroup(id: g.id)
isLoading = false
return
}
hidden = store.hiddenCalendarKeys hidden = store.hiddenCalendarKeys
banished = store.banishedCalendarKeys banished = store.banishedCalendarKeys
async let c = (try? await api.getCalDAVAccounts()) ?? [] async let c = (try? await api.getCalDAVAccounts()) ?? []

View File

@@ -1,5 +1,50 @@
import SwiftUI import SwiftUI
// MARK: - Group icons (cross-platform, non-emoji)
/// Canonical group-icon keys stored server-side and rendered as native SF
/// Symbols here (Material on Android, SVG on web), so groups look consistent
/// instead of relying on OS-specific emoji rendering.
enum GroupIcons {
static let keys = ["people", "home", "heart", "work", "school", "sports",
"party", "pet", "travel", "music", "food", "star"]
static func symbol(_ key: String?) -> String {
switch key {
case "people": return "person.2.fill"
case "home": return "house.fill"
case "heart": return "heart.fill"
case "work": return "briefcase.fill"
case "school": return "graduationcap.fill"
case "sports": return "figure.run"
case "party": return "party.popper.fill"
case "pet": return "pawprint.fill"
case "travel": return "airplane"
case "music": return "music.note"
case "food": return "fork.knife"
case "star": return "star.fill"
default: return "person.2.fill"
}
}
static func isKey(_ s: String?) -> Bool { if let s { return keys.contains(s) }; return false }
}
/// Render a group's icon: native SF Symbol for known keys, the legacy emoji for
/// pre-migration groups, else a default people glyph.
struct GroupIconView: View {
let icon: String?
var body: some View {
if GroupIcons.isKey(icon) {
Image(systemName: GroupIcons.symbol(icon))
} else if let e = icon, !e.isEmpty {
Text(e)
} else {
Image(systemName: "person.2.fill")
}
}
}
// MARK: - Groups list // MARK: - Groups list
struct GroupsView: View { struct GroupsView: View {
@@ -23,7 +68,7 @@ struct GroupsView: View {
GroupCombinedView(api: api, group: g) GroupCombinedView(api: api, group: g)
} label: { } label: {
HStack { HStack {
Text(g.icon ?? "👥") GroupIconView(icon: g.icon)
Text(g.name) Text(g.name)
Spacer() Spacer()
if let n = g.memberCount { if let n = g.memberCount {
@@ -71,12 +116,12 @@ struct GroupEditSheet: View {
@AppStorage("appLanguage") private var appLang = "system" @AppStorage("appLanguage") private var appLang = "system"
@State private var name = "" @State private var name = ""
@State private var icon = "👥" @State private var icon = "people"
@State private var directory: [DirectoryUser] = [] @State private var directory: [DirectoryUser] = []
@State private var selected: Set<Int> = [] @State private var selected: Set<Int> = []
@State private var error = "" @State private var error = ""
private let icons = ["👥", "👨‍👩‍👧", "🏠", "❤️", "🧑‍🤝‍🧑", "", "🎓", "💼", "🎉", "🐶", "✈️", "🎵", "🍕", "📚", "🌳", ""] private let icons = GroupIcons.keys
private let cols = [GridItem(.adaptive(minimum: 46))] private let cols = [GridItem(.adaptive(minimum: 46))]
var body: some View { var body: some View {
@@ -88,9 +133,11 @@ struct GroupEditSheet: View {
Section(L10n.t("group.icon", appLang)) { Section(L10n.t("group.icon", appLang)) {
LazyVGrid(columns: cols, spacing: 8) { LazyVGrid(columns: cols, spacing: 8) {
ForEach(icons, id: \.self) { ic in ForEach(icons, id: \.self) { ic in
Text(ic).font(.title2) Image(systemName: GroupIcons.symbol(ic))
.font(.title3)
.frame(width: 44, height: 44) .frame(width: 44, height: 44)
.background(ic == icon ? Color.accentColor.opacity(0.25) : Color(.systemGray6)) .background(ic == icon ? Color.accentColor.opacity(0.25) : Color(.systemGray6))
.foregroundStyle(ic == icon ? Color.accentColor : .primary)
.clipShape(RoundedRectangle(cornerRadius: 8)) .clipShape(RoundedRectangle(cornerRadius: 8))
.onTapGesture { icon = ic } .onTapGesture { icon = ic }
} }
@@ -144,13 +191,13 @@ struct GroupManageSheet: View {
@State private var group: CalGroup? @State private var group: CalGroup?
@State private var name = "" @State private var name = ""
@State private var icon = "👥" @State private var icon = "people"
@State private var directory: [DirectoryUser] = [] @State private var directory: [DirectoryUser] = []
@State private var memberIds: Set<Int> = [] @State private var memberIds: Set<Int> = []
@State private var showDeleteConfirm = false @State private var showDeleteConfirm = false
@State private var error = "" @State private var error = ""
private let icons = ["👥", "👨‍👩‍👧", "🏠", "❤️", "🧑‍🤝‍🧑", "", "🎓", "💼", "🎉", "🐶", "✈️", "🎵", "🍕", "📚", "🌳", ""] private let icons = GroupIcons.keys
private let cols = [GridItem(.adaptive(minimum: 46))] private let cols = [GridItem(.adaptive(minimum: 46))]
var body: some View { var body: some View {
@@ -162,9 +209,11 @@ struct GroupManageSheet: View {
Section(L10n.t("group.icon", appLang)) { Section(L10n.t("group.icon", appLang)) {
LazyVGrid(columns: cols, spacing: 8) { LazyVGrid(columns: cols, spacing: 8) {
ForEach(icons, id: \.self) { ic in ForEach(icons, id: \.self) { ic in
Text(ic).font(.title2) Image(systemName: GroupIcons.symbol(ic))
.font(.title3)
.frame(width: 44, height: 44) .frame(width: 44, height: 44)
.background(ic == icon ? Color.accentColor.opacity(0.25) : Color(.systemGray6)) .background(ic == icon ? Color.accentColor.opacity(0.25) : Color(.systemGray6))
.foregroundStyle(ic == icon ? Color.accentColor : .primary)
.clipShape(RoundedRectangle(cornerRadius: 8)) .clipShape(RoundedRectangle(cornerRadius: 8))
.onTapGesture { icon = ic } .onTapGesture { icon = ic }
} }
@@ -218,7 +267,7 @@ struct GroupManageSheet: View {
if let g = try? await api.getGroup(id: groupId) { if let g = try? await api.getGroup(id: groupId) {
group = g group = g
name = g.name name = g.name
icon = g.icon ?? "👥" icon = GroupIcons.isKey(g.icon) ? g.icon! : "people"
let me = UserDefaults.standard.integer(forKey: "userId") let me = UserDefaults.standard.integer(forKey: "userId")
memberIds = Set((g.members ?? []).map { $0.id }.filter { $0 != me }) memberIds = Set((g.members ?? []).map { $0.id }.filter { $0 != me })
} }
@@ -324,7 +373,7 @@ struct GroupCombinedView: View {
Text(L10n.t("groups.combined_empty", appLang)).foregroundStyle(.secondary) Text(L10n.t("groups.combined_empty", appLang)).foregroundStyle(.secondary)
} }
} }
.navigationTitle("\(group.icon ?? "👥") \(group.name)") .navigationTitle(group.name)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
@@ -340,13 +389,11 @@ struct GroupCombinedView: View {
.task(id: anchor) { await load() } .task(id: anchor) { await load() }
} }
// Prefix others' events with their first name; group events with 👥 + creator. // Prefer the server-decorated title; fall back to a name prefix.
private func displayTitle(_ ev: CalEvent) -> String { private func displayTitle(_ ev: CalEvent) -> String {
if let dt = ev.displayTitle, !dt.isEmpty { return dt }
let me = UserDefaults.standard.integer(forKey: "userId") let me = UserDefaults.standard.integer(forKey: "userId")
if ev.isGroupEvent { if let c = ev.creator, ev.isGroupEvent, c.id != me { return "\(firstName(c.displayName)): \(ev.title)" }
if let c = ev.creator, c.id != me { return "👥 \(firstName(c.displayName)): \(ev.title)" }
return "👥 \(ev.title)"
}
if let o = ev.owner, o.id != me { return "\(firstName(o.displayName)): \(ev.title)" } if let o = ev.owner, o.id != me { return "\(firstName(o.displayName)): \(ev.title)" }
return ev.title return ev.title
} }

View File

@@ -22,6 +22,7 @@ struct SettingsView: View {
@AppStorage("defaultView") private var defaultView = "month" @AppStorage("defaultView") private var defaultView = "month"
@AppStorage("weekStartDay") private var weekStartDay = "monday" @AppStorage("weekStartDay") private var weekStartDay = "monday"
@AppStorage("dimPastEvents") private var dimPastEvents = false @AppStorage("dimPastEvents") private var dimPastEvents = false
@AppStorage("defaultReminderMinutes") private var defaultReminderMinutes = -1
// Profile chapter (server-backed; loaded on appear). // Profile chapter (server-backed; loaded on appear).
@State private var displayName = "" @State private var displayName = ""
@@ -37,6 +38,7 @@ struct SettingsView: View {
Form { Form {
profilSection profilSection
privatsphaereSection privatsphaereSection
benachrichtigungenSection
geteilterKalenderSection geteilterKalenderSection
liquidGlassSection liquidGlassSection
cacheSection cacheSection
@@ -105,6 +107,27 @@ struct SettingsView: View {
} }
} }
// MARK: Benachrichtigungen
var benachrichtigungenSection: some View {
Section {
Picker(ReminderOptions.defaultTitle(appLang), selection: $defaultReminderMinutes) {
Text(ReminderOptions.off(appLang)).tag(-1)
ForEach(ReminderOptions.all, id: \.self) { m in
Text(ReminderOptions.label(m, appLang)).tag(m)
}
}
.onChange(of: defaultReminderMinutes) { _, _ in
SettingsSync.push(api: api)
NotificationCenter.default.post(name: .rescheduleReminders, object: nil)
}
} header: {
Text(ReminderOptions.sectionTitle(appLang))
} footer: {
Text(ReminderOptions.defaultFooter(appLang)).font(.caption)
}
}
// MARK: Privatsphäre // MARK: Privatsphäre
var privatsphaereSection: some View { var privatsphaereSection: some View {