Compare commits

..

23 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
Scarriffle
9fac13f99c feat: iOS Gruppen – Liste, Erstellen/Verwalten, kombinierte Ansicht
- Menü-Eintrag "Gruppen" -> GroupsView (Liste, Erstellen mit Icon-Auswahl +
  Mitglieder, Verwalten: umbenennen/Icon/Mitglieder/Mitglieder-Farben/löschen).
- GroupCombinedView: monatsweise Agenda der überlagerten Mitglieder-Kalender
  + Gruppenkalender; Termine mit Besitzer-Vorname bzw. 👥 + Ersteller,
  server-definierte Farben (display_color).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 20:08:53 +02:00
Scarriffle
da2e39911c feat: iOS Sharing + iCal Import/Export für lokale Kalender
Kalenderverwaltung: pro lokalem Kalender ein Menü mit Teilen (SharingView:
Benutzer aus Verzeichnis, read/read_write, entfernbar), Importieren
(.ics File Picker) und Exportieren (Share Sheet). Geteilte Kalender mit
"geteilt von"-Badge; Gruppenkalender markiert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 19:40:41 +02:00
Scarriffle
023f90be3b feat: iOS Einstellungen – Profil, Privatsphäre, geteilter Kalender
Neue Settings-Sektionen: Anzeigename + E-Mail ändern (Login-Name read-only),
Private-Termine-Sichtbarkeit (busy/hidden) und Auswahl des für Gruppen
sichtbaren Kalenders. Gezielte API-PUTs, damit nicht die ganze AppSettings
überschrieben wird.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 19:35:22 +02:00
Scarriffle
e7e4998fb9 feat: iOS Datenebene + Ersteller-Anzeige + Privat-Flag
- Modelle: CalEvent (creator, isPrivate, owner, isGroupEvent, displayColor),
  LocalCalendar (owned/sharedBy/permission/group), AppSettings
  (privateEventVisibility, groupVisibleCalendarId), UserProfile (displayName);
  neue Modelle CalGroup/GroupMember/DirectoryUser/CalendarShare.
- API: Profil-Update (Name/Login), Sharing-CRUD, Gruppen-CRUD + combined,
  Mitglieder-Farbe, iCal Import (multipart) & Export, private-Flag bei Events.
- Event-Detail zeigt "Erstellt von" (wenn != ich) + Privat-Hinweis;
  Editor hat Privat-Toggle (nur lokale Kalender). Login speichert userId.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 19:32:31 +02:00
Scarriffle
b1e0cf1fdc WIP: Widget-, Sync- & Event-Editor-Änderungen
Zwischenstand vor den Sharing/Gruppen/Import-Export-Features (gesichert,
damit die neuen Features sauber darauf aufbauen).
2026-05-31 19:22:12 +02:00
Scarriffle
e71fd7512f Widget änderungen, sync änderungen 2026-05-28 21:43:18 +02:00
26 changed files with 2703 additions and 216 deletions

View File

@@ -498,6 +498,7 @@
INFOPLIST_KEY_CFBundleDisplayName = Calendarr; INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
INFOPLIST_KEY_CFBundleName = Calendarr; INFOPLIST_KEY_CFBundleName = Calendarr;
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = YES; INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices"; INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
@@ -509,7 +510,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.2; 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;
@@ -540,6 +541,7 @@
INFOPLIST_KEY_CFBundleDisplayName = Calendarr; INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
INFOPLIST_KEY_CFBundleName = Calendarr; INFOPLIST_KEY_CFBundleName = Calendarr;
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = YES; INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices"; INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
@@ -551,7 +553,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.2; 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

@@ -16,6 +16,9 @@ struct AppSettings: Codable {
var textColor: String = "#FFFFFF" var textColor: String = "#FFFFFF"
var backgroundColor: String = "#000000" var backgroundColor: String = "#000000"
var lineColor: String = "#3A3A3C" var lineColor: String = "#3A3A3C"
var privateEventVisibility: String = "busy" // 'hidden' | 'busy'
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"
@@ -33,6 +36,9 @@ struct AppSettings: Codable {
case textColor = "text_color" case textColor = "text_color"
case backgroundColor = "background_color" case backgroundColor = "background_color"
case lineColor = "line_color" case lineColor = "line_color"
case privateEventVisibility = "private_event_visibility"
case groupVisibleCalendarId = "group_visible_calendar_id"
case defaultReminderMinutes = "default_reminder_minutes"
} }
init() {} init() {}
@@ -60,6 +66,9 @@ struct AppSettings: Codable {
textColor = try c.decodeIfPresent(String.self, forKey: .textColor) ?? d.textColor textColor = try c.decodeIfPresent(String.self, forKey: .textColor) ?? d.textColor
backgroundColor = try c.decodeIfPresent(String.self, forKey: .backgroundColor) ?? d.backgroundColor backgroundColor = try c.decodeIfPresent(String.self, forKey: .backgroundColor) ?? d.backgroundColor
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
groupVisibleCalendarId = try c.decodeIfPresent(Int.self, forKey: .groupVisibleCalendarId)
defaultReminderMinutes = try c.decodeIfPresent(Int.self, forKey: .defaultReminderMinutes)
} }
} }
@@ -95,6 +104,27 @@ struct LocalCalendar: Codable, Identifiable {
var name: String var name: String
var color: String var color: String
var enabled: Bool var enabled: Bool
var owned: Bool = true
var sharedBy: String? = nil
var permission: String? = nil
var group: Bool = false
enum CodingKeys: String, CodingKey {
case id, name, color, enabled, owned, permission, group
case sharedBy = "shared_by"
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(Int.self, forKey: .id)
name = try c.decodeIfPresent(String.self, forKey: .name) ?? ""
color = try c.decodeIfPresent(String.self, forKey: .color) ?? "#34a853"
enabled = try c.decodeIfPresent(Bool.self, forKey: .enabled) ?? true
owned = try c.decodeIfPresent(Bool.self, forKey: .owned) ?? true
sharedBy = try c.decodeIfPresent(String.self, forKey: .sharedBy)
permission = try c.decodeIfPresent(String.self, forKey: .permission)
group = try c.decodeIfPresent(Bool.self, forKey: .group) ?? false
}
} }
struct ICalSubscription: Codable, Identifiable { struct ICalSubscription: Codable, Identifiable {
@@ -173,6 +203,7 @@ struct HACalendar: Codable, Identifiable {
struct UserProfile: Codable { struct UserProfile: Codable {
let id: Int let id: Int
let username: String let username: String
var displayName: String?
var email: String? var email: String?
let isAdmin: Bool let isAdmin: Bool
let hasAvatar: Bool let hasAvatar: Bool
@@ -180,12 +211,61 @@ struct UserProfile: Codable {
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id, username, email case id, username, email
case displayName = "display_name"
case isAdmin = "is_admin" case isAdmin = "is_admin"
case hasAvatar = "has_avatar" case hasAvatar = "has_avatar"
case totpEnabled = "totp_enabled" case totpEnabled = "totp_enabled"
} }
} }
// MARK: - Sharing & groups
struct DirectoryUser: Codable, Identifiable {
let id: Int
let displayName: String
enum CodingKeys: String, CodingKey { case id; case displayName = "display_name" }
}
struct CalendarShare: Codable, Identifiable {
let userId: Int
let displayName: String?
var permission: String
var id: Int { userId }
enum CodingKeys: String, CodingKey {
case userId = "user_id"
case displayName = "display_name"
case permission
}
}
struct GroupMember: Codable, Identifiable {
let id: Int
let displayName: String?
var role: String
var color: String?
enum CodingKeys: String, CodingKey {
case id, role, color
case displayName = "display_name"
}
}
struct CalGroup: Codable, Identifiable {
let id: Int
var name: String
var icon: String?
var role: String?
var memberCount: Int?
var groupCalendarId: Int?
var groupCalendarColor: String?
var members: [GroupMember]?
enum CodingKeys: String, CodingKey {
case id, name, icon, role, members
case memberCount = "member_count"
case groupCalendarId = "group_calendar_id"
case groupCalendarColor = "group_calendar_color"
}
}
extension Color { extension Color {
init(hex: String) { init(hex: String) {
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)

View File

@@ -1,6 +1,23 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
/// Creator (or owner, in the group combined view) of an event.
/// `id` is nil for imported events.
struct EventPerson: Hashable {
let id: Int?
let displayName: String
static func from(_ json: Any?) -> EventPerson? {
guard let obj = json as? [String: Any],
let name = obj["display_name"] as? String, !name.isEmpty else { return nil }
let id: Int?
if let n = obj["id"] as? Int { id = n }
else if let s = obj["id"] as? String { id = Int(s) }
else { id = nil }
return EventPerson(id: id, displayName: name)
}
}
struct CalEvent: Identifiable, Hashable { struct CalEvent: Identifiable, Hashable {
let id: String let id: String
let url: String let url: String
@@ -15,8 +32,20 @@ struct CalEvent: Identifiable, Hashable {
var calendarName: String var calendarName: String
var calendarColor: String var calendarColor: String
var source: String var source: String
var creator: EventPerson? = nil
var isPrivate: Bool = false
// Only set in the group combined view:
var owner: EventPerson? = nil
var isGroupEvent: Bool = false
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] = []
var effectiveColor: String { color ?? calendarColor } // Group view supplies a server-resolved colour; otherwise per-event then calendar colour.
var effectiveColor: String { displayColor ?? color ?? calendarColor }
static func from(json: [String: Any]) -> CalEvent? { static func from(json: [String: Any]) -> CalEvent? {
guard guard
@@ -49,7 +78,14 @@ struct CalEvent: Identifiable, Hashable {
calendarId: json["calendar_id"].map { "\($0)" } ?? "", calendarId: json["calendar_id"].map { "\($0)" } ?? "",
calendarName: json["calendar_name"] as? String ?? "", calendarName: json["calendar_name"] as? String ?? "",
calendarColor: json["calendarColor"] as? String ?? "#4285f4", calendarColor: json["calendarColor"] as? String ?? "#4285f4",
source: json["source"] as? String ?? "local" source: json["source"] as? String ?? "local",
creator: EventPerson.from(json["creator"]),
isPrivate: json["private"] as? Bool ?? false,
owner: EventPerson.from(json["owner"]),
isGroupEvent: json["is_group_event"] as? Bool ?? false,
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()
@@ -277,11 +364,13 @@ class CalendarStore {
} }
/// Trigger a full cache reload (e.g. when cache-range setting changes). /// Trigger a full cache reload (e.g. when cache-range setting changes).
/// Intentionally keeps `events` intact so the UI stays populated while
/// the network fetch runs; `refreshFromCache` will swap in fresh data
/// atomically once it arrives.
func invalidateCache() { func invalidateCache() {
cachedStart = nil cachedStart = nil
cachedEnd = nil cachedEnd = nil
allCachedEvents = [] allCachedEvents = []
events = []
} }
private func mergeIntoCache(_ newEvents: [CalEvent], rangeStart: Date, rangeEnd: Date) { private func mergeIntoCache(_ newEvents: [CalEvent], rangeStart: Date, rangeEnd: Date) {

View File

@@ -127,6 +127,46 @@ private let strings: [String: [String: String]] = [
"settings.monday": "Montag", "settings.monday": "Montag",
"settings.sunday": "Sonntag", "settings.sunday": "Sonntag",
"settings.dimpast": "Vergangene Termine ausgrauen", "settings.dimpast": "Vergangene Termine ausgrauen",
"settings.nav.profile": "Profil",
"settings.privacy": "Privatsphäre",
"settings.private_visibility": "Private Termine für Gruppen",
"settings.private_visibility.desc": "Wie private Termine für andere Gruppenmitglieder erscheinen",
"settings.private.busy": "Als „Beschäftigt“",
"settings.private.hidden": "Ausblenden",
"settings.calendars": "Geteilter Kalender",
"settings.group_visible": "Für Gruppen sichtbar",
"settings.group_visible.desc": "Wähle, welcher deiner Kalender für Gruppenmitglieder sichtbar ist",
"group.visible.none": "Keiner",
"profile.display_name": "Anzeigename",
"profile.login_name": "Login-Name",
"accounts.shared_by": "geteilt von %@",
"share.title": "Teilen",
"share.current": "Aktuelle Freigaben",
"share.none": "Noch nicht geteilt",
"share.add": "Benutzer hinzufügen",
"share.search": "Benutzer suchen…",
"share.permission": "Berechtigung",
"perm.read": "Nur lesen",
"perm.read_write": "Lesen & schreiben",
"ics.import": "Importieren",
"ics.export": "Exportieren",
"ics.import_result": "%d importiert, %d übersprungen",
"common.info": "Info",
"common.done": "Fertig",
"groups.title": "Gruppen",
"groups.personal": "Persönlich",
"groups.view_label": "Gruppenansicht",
"groups.exit": "Verlassen",
"groups.none": "Noch keine Gruppen",
"groups.combined_empty": "Keine Termine in diesem Zeitraum",
"group.create": "Gruppe erstellen",
"group.manage": "Gruppe verwalten",
"group.name": "Name",
"group.icon": "Icon",
"group.members": "Mitglieder",
"group.calendar": "Gruppenkalender",
"group.member_colors": "Farben der Mitglieder",
"group.delete": "Gruppe löschen",
"settings.hourheight": "Stundenhöhe", "settings.hourheight": "Stundenhöhe",
"settings.hourheight.desc": "Platz pro Stunde in der Wochen- & Tagesansicht", "settings.hourheight.desc": "Platz pro Stunde in der Wochen- & Tagesansicht",
@@ -201,6 +241,7 @@ private let strings: [String: [String: String]] = [
// Event editor // Event editor
"event.title_placeholder": "Titel", "event.title_placeholder": "Titel",
"event.allday": "Ganztägig", "event.allday": "Ganztägig",
"event.private": "Privat",
"event.start": "Start", "event.start": "Start",
"event.end": "Ende", "event.end": "Ende",
"event.location": "Ort", "event.location": "Ort",
@@ -213,6 +254,8 @@ private let strings: [String: [String: String]] = [
"event.reset_color": "Zurücksetzen", "event.reset_color": "Zurücksetzen",
"event.edit_title": "Termin bearbeiten", "event.edit_title": "Termin bearbeiten",
"event.new_title": "Neuer Termin", "event.new_title": "Neuer Termin",
"event.copy_title": "Termin kopieren",
"event.copy_to": "In Kalender kopieren",
"event.save": "Sichern", "event.save": "Sichern",
"event.add": "Hinzufügen", "event.add": "Hinzufügen",
@@ -386,6 +429,46 @@ private let strings: [String: [String: String]] = [
"settings.monday": "Monday", "settings.monday": "Monday",
"settings.sunday": "Sunday", "settings.sunday": "Sunday",
"settings.dimpast": "Dim past events", "settings.dimpast": "Dim past events",
"settings.nav.profile": "Profile",
"settings.privacy": "Privacy",
"settings.private_visibility": "Private events for groups",
"settings.private_visibility.desc": "How your private events appear to other group members",
"settings.private.busy": "Show as \"Busy\"",
"settings.private.hidden": "Hide",
"settings.calendars": "Shared calendar",
"settings.group_visible": "Visible to groups",
"settings.group_visible.desc": "Choose which of your calendars group members can see",
"group.visible.none": "None",
"profile.display_name": "Display name",
"profile.login_name": "Login name",
"accounts.shared_by": "shared by %@",
"share.title": "Share",
"share.current": "Current shares",
"share.none": "Not shared yet",
"share.add": "Add user",
"share.search": "Search users…",
"share.permission": "Permission",
"perm.read": "Read only",
"perm.read_write": "Read & write",
"ics.import": "Import",
"ics.export": "Export",
"ics.import_result": "%d imported, %d skipped",
"common.info": "Info",
"common.done": "Done",
"groups.title": "Groups",
"groups.personal": "Personal",
"groups.view_label": "Group view",
"groups.exit": "Exit",
"groups.none": "No groups yet",
"groups.combined_empty": "No events in this period",
"group.create": "Create group",
"group.manage": "Manage group",
"group.name": "Name",
"group.icon": "Icon",
"group.members": "Members",
"group.calendar": "Group calendar",
"group.member_colors": "Member colours",
"group.delete": "Delete group",
"settings.hourheight": "Hour height", "settings.hourheight": "Hour height",
"settings.hourheight.desc": "Space per hour in week & day view", "settings.hourheight.desc": "Space per hour in week & day view",
@@ -460,6 +543,7 @@ private let strings: [String: [String: String]] = [
// Event editor // Event editor
"event.title_placeholder": "Title", "event.title_placeholder": "Title",
"event.allday": "All-day", "event.allday": "All-day",
"event.private": "Private",
"event.start": "Start", "event.start": "Start",
"event.end": "End", "event.end": "End",
"event.location": "Location", "event.location": "Location",
@@ -472,6 +556,8 @@ private let strings: [String: [String: String]] = [
"event.reset_color": "Reset", "event.reset_color": "Reset",
"event.edit_title": "Edit event", "event.edit_title": "Edit event",
"event.new_title": "New event", "event.new_title": "New event",
"event.copy_title": "Copy event",
"event.copy_to": "Copy to calendar",
"event.save": "Save", "event.save": "Save",
"event.add": "Add", "event.add": "Add",

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

@@ -72,6 +72,9 @@ class CalendarrAPI {
throw APIError.decodingError throw APIError.decodingError
} }
let admin = user["is_admin"] as? Bool ?? false let admin = user["is_admin"] as? Bool ?? false
// Persist id + display name for creator/owner comparisons and display.
UserDefaults.standard.set(user["id"] as? Int ?? 0, forKey: "userId")
UserDefaults.standard.set(user["display_name"] as? String ?? uname, forKey: "displayName")
return (token, uname, admin) return (token, uname, admin)
} }
@@ -225,7 +228,8 @@ 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?) async throws -> CalEvent { isAllDay: Bool, location: String, description: String, color: String?,
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,
@@ -233,9 +237,11 @@ class CalendarrAPI {
"end": formatISO(end, allDay: isAllDay), "end": formatISO(end, allDay: isAllDay),
"allDay": isAllDay, "allDay": isAllDay,
"location": location, "location": location,
"description": description "description": description,
"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 }
@@ -243,16 +249,19 @@ 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?) async throws { isAllDay: Bool, location: String, description: String, color: String?,
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),
"end": formatISO(end, allDay: isAllDay), "end": formatISO(end, allDay: isAllDay),
"allDay": isAllDay, "allDay": isAllDay,
"location": location, "location": location,
"description": description "description": description,
"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)
} }
@@ -392,4 +401,167 @@ class CalendarrAPI {
_ = try await request(path, method: "PUT", _ = try await request(path, method: "PUT",
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)
/// Update profile fields. A login-name change returns a fresh token (the old
/// one becomes invalid) the caller must store the returned token.
func updateProfile(displayName: String?, username: String?, email: String?) async throws -> String? {
var body: [String: Any] = [:]
if let d = displayName { body["display_name"] = d }
if let u = username { body["username"] = u }
if let e = email { body["email"] = e } else { body["email"] = NSNull() }
let data = try await request("/api/profile/", method: "PUT", body: body)
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
return json?["access_token"] as? String
}
// MARK: Targeted settings (avoid overwriting the whole AppSettings)
func updatePrivateVisibility(_ value: String) async throws {
_ = try await request("/api/settings/", method: "PUT", body: ["private_event_visibility": value])
}
func updateGroupVisibleCalendar(_ calendarId: Int?) async throws {
_ = try await request("/api/settings/", method: "PUT",
body: ["group_visible_calendar_id": calendarId as Any? ?? NSNull()])
}
// MARK: Sharing
func getUserDirectory() async throws -> [DirectoryUser] {
let data = try await request("/api/users/directory")
return (try? JSONDecoder().decode([DirectoryUser].self, from: data)) ?? []
}
func getShares(calendarId: Int) async throws -> [CalendarShare] {
let data = try await request("/api/local/calendars/\(calendarId)/shares")
return (try? JSONDecoder().decode([CalendarShare].self, from: data)) ?? []
}
func addShare(calendarId: Int, userId: Int, permission: String) async throws {
_ = try await request("/api/local/calendars/\(calendarId)/shares", method: "POST",
body: ["user_id": userId, "permission": permission])
}
func removeShare(calendarId: Int, userId: Int) async throws {
_ = try await request("/api/local/calendars/\(calendarId)/shares/\(userId)", method: "DELETE")
}
// MARK: Groups
func getGroups() async throws -> [CalGroup] {
let data = try await request("/api/groups/")
return (try? JSONDecoder().decode([CalGroup].self, from: data)) ?? []
}
func getGroup(id: Int) async throws -> CalGroup {
let data = try await request("/api/groups/\(id)")
guard let g = try? JSONDecoder().decode(CalGroup.self, from: data) else { throw APIError.decodingError }
return g
}
func createGroup(name: String, memberIds: [Int], icon: String?) async throws -> CalGroup {
var body: [String: Any] = ["name": name, "member_ids": memberIds]
if let icon { body["icon"] = icon }
let data = try await request("/api/groups/", method: "POST", body: body)
guard let g = try? JSONDecoder().decode(CalGroup.self, from: data) else { throw APIError.decodingError }
return g
}
func updateGroup(id: Int, name: String?, icon: String?) async throws {
var body: [String: Any] = [:]
if let name { body["name"] = name }
if let icon { body["icon"] = icon }
_ = try await request("/api/groups/\(id)", method: "PUT", body: body)
}
func deleteGroup(id: Int) async throws {
_ = try await request("/api/groups/\(id)", method: "DELETE")
}
func addGroupMember(groupId: Int, userId: Int) async throws {
_ = try await request("/api/groups/\(groupId)/members", method: "POST", body: ["user_id": userId])
}
func removeGroupMember(groupId: Int, userId: Int) async throws {
_ = try await request("/api/groups/\(groupId)/members/\(userId)", method: "DELETE")
}
func setGroupMemberColor(groupId: Int, userId: Int, color: String) async throws {
_ = try await request("/api/groups/\(groupId)/members/\(userId)/color", method: "PUT",
body: ["color": color])
}
func fetchGroupCombined(groupId: Int, start: Date, end: Date) async throws -> [CalEvent] {
let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime]
iso.timeZone = TimeZone(abbreviation: "UTC")
let s = iso.string(from: start)
let e = iso.string(from: end)
let sEnc = s.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? s
let eEnc = e.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? e
let data = try await request("/api/groups/\(groupId)/combined?start=\(sEnc)&end=\(eEnc)")
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let arr = root["events"] as? [[String: Any]] else { return [] }
return arr.compactMap { CalEvent.from(json: $0) }
}
// MARK: iCal import / export
/// Import a .ics file into a local calendar. Returns (imported, skipped, errors).
func importICS(calendarId: Int, fileURL: URL) async throws -> (imported: Int, skipped: Int, errors: [String]) {
guard let url = URL(string: baseURL + "/api/local/calendars/\(calendarId)/import") else { throw APIError.invalidURL }
let fileData = try Data(contentsOf: fileURL)
let boundary = "Boundary-\(UUID().uuidString)"
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
var bodyData = Data()
let filename = fileURL.lastPathComponent.isEmpty ? "import.ics" : fileURL.lastPathComponent
bodyData.append("--\(boundary)\r\n".data(using: .utf8)!)
bodyData.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!)
bodyData.append("Content-Type: text/calendar\r\n\r\n".data(using: .utf8)!)
bodyData.append(fileData)
bodyData.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
req.httpBody = bodyData
let (data, response) = try await URLSession.shared.data(for: req)
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
if status == 401 { throw APIError.unauthorized }
if status >= 400 {
let msg = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["detail"] as? String ?? "Fehler \(status)"
throw APIError.serverError(msg)
}
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
let errs = (json?["errors"] as? [String]) ?? []
return (json?["imported"] as? Int ?? 0, json?["skipped"] as? Int ?? 0, errs)
}
/// Export a local calendar as raw .ics bytes.
func exportICS(calendarId: Int) async throws -> Data {
return try await request("/api/local/calendars/\(calendarId)/export")
}
} }

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

@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import UniformTypeIdentifiers
struct AccountsView: View { struct AccountsView: View {
let api: CalendarrAPI let api: CalendarrAPI
@@ -16,6 +17,13 @@ struct AccountsView: View {
@State private var errorAlert: String? @State private var errorAlert: String?
@State private var banishedKeys: Set<String> = CalendarStore.loadBanishedKeys() @State private var banishedKeys: Set<String> = CalendarStore.loadBanishedKeys()
// Sharing / import / export
@State private var shareCalId: Int?
@State private var showImporter = false
@State private var importTargetCalId: Int?
@State private var exportDoc: ExportedICS?
@State private var infoMessage: String?
@AppStorage("appLanguage") private var appLang = "system" @AppStorage("appLanguage") private var appLang = "system"
var body: some View { var body: some View {
@@ -65,10 +73,55 @@ struct AccountsView: View {
}, message: { }, message: {
Text(errorAlert ?? "") Text(errorAlert ?? "")
}) })
.sheet(item: Binding(get: { shareCalId.map { IdentifiableInt(id: $0) } },
set: { shareCalId = $0?.id })) { wrap in
SharingView(api: api, calendarId: wrap.id)
}
.sheet(item: $exportDoc) { doc in
ActivityView(items: [doc.url])
}
.fileImporter(isPresented: $showImporter,
allowedContentTypes: [UTType(filenameExtension: "ics") ?? .data, .data],
allowsMultipleSelection: false) { result in
Task { await handleImport(result) }
}
.alert(L10n.t("common.info", appLang), isPresented: .constant(infoMessage != nil), actions: {
Button(L10n.t("common.ok", appLang)) { infoMessage = nil }
}, message: { Text(infoMessage ?? "") })
} }
.task { await load() } .task { await load() }
} }
private func exportCalendar(_ cal: LocalCalendar) async {
do {
let data = try await api.exportICS(calendarId: cal.id)
let safe = cal.name.components(separatedBy: CharacterSet.alphanumerics.inverted).joined(separator: "_")
let url = FileManager.default.temporaryDirectory.appendingPathComponent("\(safe.isEmpty ? "calendar" : safe).ics")
try data.write(to: url)
exportDoc = ExportedICS(url: url)
} catch {
errorAlert = error.localizedDescription
}
}
private func handleImport(_ result: Result<[URL], Error>) async {
guard let calId = importTargetCalId else { return }
switch result {
case .success(let urls):
guard let url = urls.first else { return }
let scoped = url.startAccessingSecurityScopedResource()
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
do {
let r = try await api.importICS(calendarId: calId, fileURL: url)
infoMessage = String(format: L10n.t("ics.import_result", appLang), r.imported, r.skipped)
} catch {
errorAlert = error.localizedDescription
}
case .failure(let err):
errorAlert = err.localizedDescription
}
}
// MARK: Sections // MARK: Sections
var caldavSection: some View { var caldavSection: some View {
@@ -78,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)
} }
} }
} }
@@ -110,10 +169,35 @@ 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 {
Image(systemName: "person.2.fill").font(.caption2).foregroundStyle(.secondary)
}
Spacer()
if !cal.owned, let by = cal.sharedBy {
Text(String(format: L10n.t("accounts.shared_by", appLang), by))
.font(.caption2).foregroundStyle(.secondary)
}
Menu {
if cal.owned && !cal.group {
Button { shareCalId = cal.id } label: {
Label(L10n.t("share.title", appLang), systemImage: "person.crop.circle.badge.plus")
}
}
if cal.owned || cal.permission == "read_write" {
Button { importTargetCalId = cal.id; showImporter = true } label: {
Label(L10n.t("ics.import", appLang), systemImage: "square.and.arrow.down")
}
}
Button { Task { await exportCalendar(cal) } } label: {
Label(L10n.t("ics.export", appLang), systemImage: "square.and.arrow.up")
}
} label: {
Image(systemName: "ellipsis.circle").foregroundStyle(.secondary)
}
} }
} }
.onDelete { offsets in .onDelete { offsets in
@@ -135,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))
@@ -164,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
@@ -269,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
@@ -598,3 +700,127 @@ struct AddHASheet: View {
isLoading = false isLoading = false
} }
} }
// MARK: Sharing / Import-Export helpers
struct IdentifiableInt: Identifiable { let id: Int }
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.
struct ActivityView: UIViewControllerRepresentable {
let items: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: items, applicationActivities: nil)
}
func updateUIViewController(_ vc: UIActivityViewController, context: Context) {}
}
/// Manage who a local calendar is shared with (owner only).
struct SharingView: View {
let api: CalendarrAPI
let calendarId: Int
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
@State private var shares: [CalendarShare] = []
@State private var directory: [DirectoryUser] = []
@State private var search = ""
@State private var permission = "read"
@State private var error = ""
private var candidates: [DirectoryUser] {
let sharedIds = Set(shares.map { $0.userId })
return directory.filter { !sharedIds.contains($0.id) &&
(search.isEmpty || $0.displayName.localizedCaseInsensitiveContains(search)) }
}
var body: some View {
NavigationStack {
List {
Section(L10n.t("share.current", appLang)) {
if shares.isEmpty {
Text(L10n.t("share.none", appLang)).foregroundStyle(.secondary)
} else {
ForEach(shares) { s in
HStack {
Text(s.displayName ?? "")
Spacer()
Text(s.permission == "read_write"
? L10n.t("perm.read_write", appLang)
: L10n.t("perm.read", appLang))
.font(.caption).foregroundStyle(.secondary)
}
}
.onDelete { offsets in
Task { await removeShares(offsets) }
}
}
}
Section(L10n.t("share.add", appLang)) {
Picker(L10n.t("share.permission", appLang), selection: $permission) {
Text(L10n.t("perm.read", appLang)).tag("read")
Text(L10n.t("perm.read_write", appLang)).tag("read_write")
}
TextField(L10n.t("share.search", appLang), text: $search)
.autocapitalization(.none)
ForEach(candidates) { u in
Button { Task { await addShare(u.id) } } label: {
HStack {
Text(u.displayName)
Spacer()
Image(systemName: "plus.circle").foregroundStyle(Color.accentColor)
}
}
}
}
if !error.isEmpty { Section { Text(error).foregroundStyle(.red) } }
}
.navigationTitle(L10n.t("share.title", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button(L10n.t("common.done", appLang)) { dismiss() }
}
}
}
.task { await load() }
}
private func load() async {
shares = (try? await api.getShares(calendarId: calendarId)) ?? []
directory = (try? await api.getUserDirectory()) ?? []
}
private func addShare(_ userId: Int) async {
do { try await api.addShare(calendarId: calendarId, userId: userId, permission: permission); await load() }
catch { self.error = error.localizedDescription }
}
private func removeShares(_ offsets: IndexSet) async {
for i in offsets { try? await api.removeShare(calendarId: calendarId, userId: shares[i].userId) }
await load()
}
}

View File

@@ -1,5 +1,16 @@
import SwiftUI import SwiftUI
private enum CalEditorContext: Identifiable {
case create(Date)
case edit(CalEvent)
var id: String {
switch self {
case .create(let d): return "new-\(d.timeIntervalSince1970)"
case .edit(let ev): return "edit-\(ev.id)"
}
}
}
struct CalendarHostView: View { struct CalendarHostView: View {
let api: CalendarrAPI let api: CalendarrAPI
@Binding var showMenu: Bool @Binding var showMenu: Bool
@@ -14,20 +25,18 @@ struct CalendarHostView: View {
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@State private var store = CalendarStore() @State private var store = CalendarStore()
@State private var showEditor = false @State private var editorContext: CalEditorContext? = nil
@State private var editorDate: Date = .now
@State private var editingEvent: CalEvent? = 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)
} }
@@ -40,39 +49,42 @@ struct CalendarHostView: View {
} }
} }
// MARK: Loading indicator
@ViewBuilder
private var loadingIndicator: some View {
if store.isLoading || store.isCachingBackground {
ProgressView()
.padding(14)
.background(.regularMaterial, in: Circle())
.shadow(color: .black.opacity(0.12), radius: 6, y: 2)
.transition(.opacity.combined(with: .scale(scale: 0.85)))
}
}
// MARK: Flat variant // MARK: Flat variant
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
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(hex: bgHex)) .background(Color(hex: bgHex))
.overlay(alignment: .top) { .overlay(alignment: .top) {
if store.isLoading { loadingIndicator.padding(.top, 12)
ProgressView().padding(.top, 10).transition(.opacity)
}
} }
.animation(.easeInOut(duration: 0.2), value: store.isLoading || store.isCachingBackground)
} }
.overlay(alignment: .bottomTrailing) { solidFAB } .overlay(alignment: .bottomTrailing) { solidFAB }
// Subtle background cache indicator (top-leading)
.overlay(alignment: .topLeading) {
if store.isCachingBackground {
Image(systemName: "arrow.triangle.2.circlepath")
.font(.caption2)
.foregroundStyle(.secondary)
.padding(6)
.transition(.opacity)
}
}
.modifier(calendarSheets) .modifier(calendarSheets)
.task { await startup() } .task { await startup() }
.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()
@@ -83,48 +95,53 @@ 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)
.background(Color(hex: bgHex)) .background(Color(hex: bgHex))
.overlay(alignment: .top) { .overlay(alignment: .top) {
if store.isLoading { loadingIndicator.padding(.top, 12)
ProgressView().padding(.top, 10).transition(.opacity)
}
}
.overlay(alignment: .top) {
if let err = store.lastError { errorBannerView(err).padding(.top, 8) }
} }
.animation(.easeInOut(duration: 0.2), value: store.isLoading || store.isCachingBackground)
.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") }
}
} }
} }
} }
@@ -134,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()
@@ -145,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: {
@@ -162,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
@@ -247,13 +320,9 @@ struct CalendarHostView: View {
case .month: case .month:
// Month view uses vertical scroll no horizontal swipe. // Month view uses vertical scroll no horizontal swipe.
MonthView(store: store, MonthView(store: store,
onDayTap: { editorDate = $0 }, onDayTap: { store.currentDate = $0 },
onEventTap: { selectedEvent = $0 }, onEventTap: { selectedEvent = $0 },
onCreateEvent: { day in onCreateEvent: { day in editorContext = .create(day) },
editingEvent = nil
editorDate = day
showEditor = true
},
onShowWeek: { day in onShowWeek: { day in
store.currentDate = day store.currentDate = day
store.viewType = .week store.viewType = .week
@@ -261,16 +330,11 @@ 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 },
onCreateEvent: { date in onCreateEvent: { date in editorContext = .create(date) },
editingEvent = nil
editorDate = date
showEditor = true
},
onShowMonth: { date in onShowMonth: { date in
store.currentDate = date store.currentDate = date
store.viewType = .month store.viewType = .month
@@ -283,11 +347,7 @@ struct CalendarHostView: View {
case .day: case .day:
DayView(store: store, DayView(store: store,
onEventTap: { selectedEvent = $0 }, onEventTap: { selectedEvent = $0 },
onCreateEvent: { date in onCreateEvent: { date in editorContext = .create(date) })
editingEvent = nil
editorDate = date
showEditor = true
})
.simultaneousGesture(swipe) .simultaneousGesture(swipe)
case .quarter: case .quarter:
QuarterView(store: store, onEventTap: { selectedEvent = $0 }) QuarterView(store: store, onEventTap: { selectedEvent = $0 })
@@ -302,7 +362,7 @@ struct CalendarHostView: View {
/// Standard solid FAB (flat mode) /// Standard solid FAB (flat mode)
private var solidFAB: some View { private var solidFAB: some View {
Button { Button {
editingEvent = nil; editorDate = .now; showEditor = true editorContext = .create(.now)
} label: { } label: {
Image(systemName: "plus") Image(systemName: "plus")
.font(.system(size: 22, weight: .semibold)) .font(.system(size: 22, weight: .semibold))
@@ -320,7 +380,7 @@ struct CalendarHostView: View {
private var glassFAB: some View { private var glassFAB: some View {
if #available(iOS 26, *) { if #available(iOS 26, *) {
Button { Button {
editingEvent = nil; editorDate = .now; showEditor = true editorContext = .create(.now)
} label: { } label: {
Image(systemName: "plus") Image(systemName: "plus")
.font(.system(size: 22, weight: .semibold)) .font(.system(size: 22, weight: .semibold))
@@ -338,8 +398,7 @@ struct CalendarHostView: View {
// MARK: Sheets modifier // MARK: Sheets modifier
private var calendarSheets: CalendarSheets { private var calendarSheets: CalendarSheets {
CalendarSheets(store: store, showEditor: $showEditor, CalendarSheets(store: store, editorContext: $editorContext,
editorDate: $editorDate, editingEvent: $editingEvent,
selectedEvent: $selectedEvent, showFilter: $showFilter, selectedEvent: $selectedEvent, showFilter: $showFilter,
api: api, api: api,
reload: { await onNavigate() }, reload: { await onNavigate() },
@@ -349,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)
@@ -437,9 +499,7 @@ struct CalendarHostView: View {
private struct CalendarSheets: ViewModifier { private struct CalendarSheets: ViewModifier {
let store: CalendarStore let store: CalendarStore
@Binding var showEditor: Bool @Binding var editorContext: CalEditorContext?
@Binding var editorDate: Date
@Binding var editingEvent: CalEvent?
@Binding var selectedEvent: CalEvent? @Binding var selectedEvent: CalEvent?
@Binding var showFilter: Bool @Binding var showFilter: Bool
let api: CalendarrAPI let api: CalendarrAPI
@@ -448,21 +508,23 @@ private struct CalendarSheets: ViewModifier {
func body(content: Content) -> some View { func body(content: Content) -> some View {
content content
.sheet(isPresented: $showEditor) { // Use sheet(item:) so the editing event is captured atomically
// avoiding the race where sheet(isPresented:) evaluates its content
// before the editingEvent state update propagates.
.sheet(item: $editorContext) { ctx in
let editingEv: CalEvent? = { if case .edit(let ev) = ctx { return ev }; return nil }()
let date: Date = { if case .create(let d) = ctx { return d }; return .now }()
EventEditorSheet(api: api, store: store, EventEditorSheet(api: api, store: store,
initialDate: editorDate, editingEvent: editingEvent) { initialDate: date, editingEvent: editingEv) {
// Create/edit changed server state bust the cache so the editorContext = nil
// new/updated event appears without a manual sync. await reloadForce()
editingEvent = nil; await reloadForce()
} }
} }
.sheet(item: $selectedEvent) { ev in .sheet(item: $selectedEvent) { ev in
EventDetailSheet(event: ev, api: api, store: store) { updated in EventDetailSheet(event: ev, api: api, store: store) { updated, needsForce in
selectedEvent = nil selectedEvent = nil
if let u = updated { editingEvent = u; showEditor = true } if let u = updated { editorContext = .edit(u) }
// Delete already removed the event from the cache optimistically; if needsForce { await reloadForce() } else { await reload() }
// a light cache refresh is enough here.
await reload()
} }
} }
.sheet(isPresented: $showFilter) { .sheet(isPresented: $showFilter) {

View File

@@ -4,11 +4,16 @@ struct EventDetailSheet: View {
let event: CalEvent let event: CalEvent
let api: CalendarrAPI let api: CalendarrAPI
let store: CalendarStore let store: CalendarStore
let onDone: (CalEvent?) async -> Void /// Called when the sheet should close.
/// - `editEvent`: non-nil when the user wants to edit this event
/// - `forceReload`: true when server data changed (create/copy) and the
/// caller must bypass the cache to fetch fresh events
let onDone: (_ editEvent: CalEvent?, _ forceReload: Bool) async -> Void
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
@State private var showDeleteConfirm = false @State private var showDeleteConfirm = false
@State private var isDeleting = false @State private var isDeleting = false
@State private var showCopySheet = false
private let timeFmt: DateFormatter = { private let timeFmt: DateFormatter = {
let f = DateFormatter() let f = DateFormatter()
@@ -37,13 +42,14 @@ struct EventDetailSheet: View {
} }
private var canEdit: Bool { private var canEdit: Bool {
event.source == "local" || event.source == "caldav" event.source == "local" || event.source == "caldav" || event.source == "homeassistant"
} }
/// Home Assistant events can't be edited in-app (no editor support), but private var canDelete: Bool { canEdit }
/// the server does support deleting them.
private var canDelete: Bool { private var currentUserId: Int? {
canEdit || event.source == "homeassistant" let id = UserDefaults.standard.integer(forKey: "userId")
return id == 0 ? nil : id
} }
var body: some View { var body: some View {
@@ -90,6 +96,28 @@ struct EventDetailSheet: View {
Text(event.source.capitalized) Text(event.source.capitalized)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
if let creator = event.creator, creator.id != currentUserId {
HStack {
Label("Erstellt von", systemImage: "person")
Spacer()
Text(creator.displayName)
.foregroundStyle(.secondary)
}
}
if event.isPrivate {
Label("Privat", systemImage: "lock")
.foregroundStyle(.secondary)
}
}
if !store.writableCalendars.isEmpty {
Section {
Button {
showCopySheet = true
} label: {
Label("Termin kopieren", systemImage: "doc.on.doc")
}
}
} }
if canDelete { if canDelete {
@@ -110,13 +138,13 @@ struct EventDetailSheet: View {
.toolbar { .toolbar {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .cancellationAction) {
Button("Schliessen") { Button("Schliessen") {
Task { await onDone(nil) } Task { await onDone(nil, false) }
} }
} }
if canEdit { if canEdit {
ToolbarItem(placement: .primaryAction) { ToolbarItem(placement: .primaryAction) {
Button("Bearbeiten") { Button("Bearbeiten") {
Task { await onDone(event) } Task { await onDone(event, false) }
} }
} }
} }
@@ -129,6 +157,18 @@ struct EventDetailSheet: View {
} message: { } message: {
Text("\"\(event.title)\" wird dauerhaft gelöscht.") Text("\"\(event.title)\" wird dauerhaft gelöscht.")
} }
.sheet(isPresented: $showCopySheet) {
EventEditorSheet(
api: api,
store: store,
initialDate: event.startDate,
editingEvent: nil,
copyFrom: event
) {
// Copy created a new server-side event force reload so it appears
await onDone(nil, true)
}
}
} }
} }
@@ -149,7 +189,7 @@ struct EventDetailSheet: View {
// Optimistically drop it from the cache so it vanishes immediately, // Optimistically drop it from the cache so it vanishes immediately,
// regardless of how long the source takes to propagate the delete. // regardless of how long the source takes to propagate the delete.
store.removeCachedEvent(id: event.id) store.removeCachedEvent(id: event.id)
await onDone(nil) await onDone(nil, false)
} catch { } catch {
isDeleting = false isDeleting = false
} }

View File

@@ -5,10 +5,12 @@ struct EventEditorSheet: View {
let store: CalendarStore let store: CalendarStore
let initialDate: Date let initialDate: Date
let editingEvent: CalEvent? let editingEvent: CalEvent?
var copyFrom: CalEvent? = nil
let onSaved: () async -> Void let onSaved: () async -> Void
@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()
@@ -17,10 +19,13 @@ struct EventEditorSheet: View {
@State private var notes = "" @State private var notes = ""
@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 reminders: [Int] = []
@State private var isSaving = false @State private var isSaving = false
@State private var error = "" @State private var error = ""
private var isEditing: Bool { editingEvent != nil } private var isEditing: Bool { editingEvent != nil }
private var isCopying: Bool { copyFrom != nil && editingEvent == nil }
private var selectedCal: WritableCalendar? { private var selectedCal: WritableCalendar? {
store.writableCalendars.first { $0.id == selectedCalendarId } store.writableCalendars.first { $0.id == selectedCalendarId }
@@ -73,6 +78,34 @@ struct EventEditorSheet: View {
} }
} }
if selectedCal?.source == "local" {
Section {
Toggle(L10n.t("event.private", appLang), isOn: $isPrivate)
.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)) {
HStack { HStack {
Text(L10n.t("event.color", appLang)) Text(L10n.t("event.color", appLang))
@@ -96,9 +129,11 @@ struct EventEditorSheet: View {
} }
} }
} }
.navigationTitle(isEditing .navigationTitle(
? L10n.t("event.edit_title", appLang) isEditing ? L10n.t("event.edit_title", appLang) :
: L10n.t("event.new_title", appLang)) isCopying ? L10n.t("event.copy_title", appLang) :
L10n.t("event.new_title", appLang)
)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .cancellationAction) {
@@ -116,6 +151,12 @@ struct EventEditorSheet: View {
} }
} }
.onAppear { setup() } .onAppear { setup() }
.onChange(of: startDate) { oldStart, newStart in
guard newStart >= endDate else { return }
let duration = endDate.timeIntervalSince(oldStart)
let minDuration: TimeInterval = isAllDay ? 86400 : 3600
endDate = newStart.addingTimeInterval(max(duration, minDuration))
}
} }
private func setup() { private func setup() {
@@ -123,17 +164,43 @@ struct EventEditorSheet: View {
title = ev.title title = ev.title
isAllDay = ev.isAllDay isAllDay = ev.isAllDay
startDate = ev.startDate startDate = ev.startDate
endDate = ev.endDate // All-day end dates are stored as exclusive (day after last); subtract 1 for the picker.
endDate = ev.isAllDay
? Calendar.current.date(byAdding: .day, value: -1, to: ev.endDate) ?? ev.endDate
: ev.endDate
location = ev.location location = ev.location
notes = ev.notes notes = ev.notes
color = ev.color ?? "" color = ev.color ?? ""
selectedCalendarId = ev.calendarId isPrivate = ev.isPrivate
reminders = ev.reminders
// HA events use "homeassistant-42" in CalEvent but "ha-42" in WritableCalendar
if ev.source == "homeassistant" {
let num = ev.calendarId.replacingOccurrences(of: "homeassistant-", with: "")
selectedCalendarId = "ha-\(num)"
} else {
selectedCalendarId = ev.calendarId
}
} else if let ev = copyFrom {
title = ev.title
isAllDay = ev.isAllDay
startDate = ev.startDate
endDate = ev.isAllDay
? Calendar.current.date(byAdding: .day, value: -1, to: ev.endDate) ?? ev.endDate
: ev.endDate
location = ev.location
notes = ev.notes
color = ev.color ?? ""
isPrivate = ev.isPrivate
reminders = ev.reminders
selectedCalendarId = store.writableCalendars.first?.id ?? ""
} else { } else {
let cal = Calendar.current let cal = Calendar.current
startDate = cal.date(bySettingHour: cal.component(.hour, from: initialDate), startDate = cal.date(bySettingHour: cal.component(.hour, from: initialDate),
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] }
} }
} }
@@ -149,10 +216,20 @@ struct EventEditorSheet: View {
do { do {
if let ev = editingEvent { if let ev = editingEvent {
if ev.source == "local" { switch ev.source {
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,
} else { isPrivate: isPrivate, reminders: reminders)
case "homeassistant":
// No update API exists delete the old event and recreate with new data.
let rawId = ev.calendarId.replacingOccurrences(of: "homeassistant-", with: "")
let haCalId = Int(rawId) ?? 0
try await api.deleteHAEvent(calendarId: haCalId, uid: ev.id)
try await api.createHAEvent(calendarId: haCalId, title: title,
start: start, end: end, isAllDay: isAllDay,
location: location, description: notes)
default: // caldav
let calId = Int(ev.calendarId) let calId = Int(ev.calendarId)
try await api.updateCalDAVEvent(uid: ev.id, url: ev.url, calendarId: calId, try await api.updateCalDAVEvent(uid: ev.id, url: ev.url, calendarId: calId,
title: title, start: start, end: end, isAllDay: isAllDay, title: title, start: start, end: end, isAllDay: isAllDay,
@@ -163,7 +240,8 @@ struct EventEditorSheet: View {
case "local": case "local":
_ = 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, 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"
@@ -53,8 +52,7 @@ struct MonthView: View {
headerRow headerRow
Divider() Divider()
ScrollView { ScrollView {
LazyVStack(spacing: 0) { LazyVStack(spacing: 0) { ForEach(weekStarts, id: \.self) { ws in
ForEach(weekStarts, id: \.self) { ws in
WeekRow(weekStart: ws, WeekRow(weekStart: ws,
store: store, store: store,
dividerColor: Color(hex: dividerHex), dividerColor: Color(hex: dividerHex),
@@ -72,6 +70,7 @@ struct MonthView: View {
} }
.scrollTargetLayout() .scrollTargetLayout()
} }
.scrollIndicators(.hidden)
.scrollPosition(id: $scrolledWeek, anchor: .top) .scrollPosition(id: $scrolledWeek, anchor: .top)
.onAppear { .onAppear {
if !didInitialScroll { if !didInitialScroll {
@@ -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

@@ -0,0 +1,412 @@
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
struct GroupsView: View {
let api: CalendarrAPI
@AppStorage("appLanguage") private var appLang = "system"
@State private var groups: [CalGroup] = []
@State private var isLoading = true
@State private var showCreate = false
var body: some View {
Group {
if isLoading {
ProgressView()
} else {
List {
if groups.isEmpty {
Text(L10n.t("groups.none", appLang)).foregroundStyle(.secondary)
}
ForEach(groups) { g in
NavigationLink {
GroupCombinedView(api: api, group: g)
} label: {
HStack {
GroupIconView(icon: g.icon)
Text(g.name)
Spacer()
if let n = g.memberCount {
Text("\(n)").font(.caption).foregroundStyle(.secondary)
}
}
}
.swipeActions {
NavigationLink {
GroupManageSheet(api: api, groupId: g.id) { await load() }
} label: {
Label(L10n.t("group.manage", appLang), systemImage: "slider.horizontal.3")
}.tint(.gray)
}
}
}
}
}
.navigationTitle(L10n.t("groups.title", appLang))
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button { showCreate = true } label: { Image(systemName: "plus") }
}
}
.sheet(isPresented: $showCreate) {
GroupEditSheet(api: api, existing: nil) { await load() }
}
.task { await load() }
}
private func load() async {
isLoading = true
groups = (try? await api.getGroups()) ?? []
isLoading = false
}
}
// MARK: - Create / edit a group (name + icon + members)
struct GroupEditSheet: View {
let api: CalendarrAPI
let existing: CalGroup?
let onDone: () async -> Void
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
@State private var name = ""
@State private var icon = "people"
@State private var directory: [DirectoryUser] = []
@State private var selected: Set<Int> = []
@State private var error = ""
private let icons = GroupIcons.keys
private let cols = [GridItem(.adaptive(minimum: 46))]
var body: some View {
NavigationStack {
Form {
Section(L10n.t("group.name", appLang)) {
TextField(L10n.t("group.name", appLang), text: $name)
}
Section(L10n.t("group.icon", appLang)) {
LazyVGrid(columns: cols, spacing: 8) {
ForEach(icons, id: \.self) { ic in
Image(systemName: GroupIcons.symbol(ic))
.font(.title3)
.frame(width: 44, height: 44)
.background(ic == icon ? Color.accentColor.opacity(0.25) : Color(.systemGray6))
.foregroundStyle(ic == icon ? Color.accentColor : .primary)
.clipShape(RoundedRectangle(cornerRadius: 8))
.onTapGesture { icon = ic }
}
}
}
Section(L10n.t("group.members", appLang)) {
ForEach(directory) { u in
Button {
if selected.contains(u.id) { selected.remove(u.id) } else { selected.insert(u.id) }
} label: {
HStack {
Image(systemName: selected.contains(u.id) ? "checkmark.square.fill" : "square")
.foregroundStyle(selected.contains(u.id) ? Color.accentColor : .secondary)
Text(u.displayName).foregroundStyle(.primary)
}
}
}
}
if !error.isEmpty { Section { Text(error).foregroundStyle(.red) } }
}
.navigationTitle(existing == nil ? L10n.t("group.create", appLang) : L10n.t("group.manage", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button(L10n.t("common.cancel", appLang)) { dismiss() } }
ToolbarItem(placement: .primaryAction) {
Button(L10n.t("event.save", appLang)) { Task { await save() } }
.bold().disabled(name.isEmpty)
}
}
}
.task { directory = (try? await api.getUserDirectory()) ?? [] }
}
private func save() async {
do {
_ = try await api.createGroup(name: name, memberIds: Array(selected), icon: icon)
await onDone()
dismiss()
} catch { self.error = error.localizedDescription }
}
}
// MARK: - Manage existing group (rename, icon, members, colors, delete)
struct GroupManageSheet: View {
let api: CalendarrAPI
let groupId: Int
let onDone: () async -> Void
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
@State private var group: CalGroup?
@State private var name = ""
@State private var icon = "people"
@State private var directory: [DirectoryUser] = []
@State private var memberIds: Set<Int> = []
@State private var showDeleteConfirm = false
@State private var error = ""
private let icons = GroupIcons.keys
private let cols = [GridItem(.adaptive(minimum: 46))]
var body: some View {
NavigationStack {
Form {
Section(L10n.t("group.name", appLang)) {
TextField(L10n.t("group.name", appLang), text: $name)
}
Section(L10n.t("group.icon", appLang)) {
LazyVGrid(columns: cols, spacing: 8) {
ForEach(icons, id: \.self) { ic in
Image(systemName: GroupIcons.symbol(ic))
.font(.title3)
.frame(width: 44, height: 44)
.background(ic == icon ? Color.accentColor.opacity(0.25) : Color(.systemGray6))
.foregroundStyle(ic == icon ? Color.accentColor : .primary)
.clipShape(RoundedRectangle(cornerRadius: 8))
.onTapGesture { icon = ic }
}
}
}
Section(L10n.t("group.members", appLang)) {
ForEach(directory) { u in
Button {
if memberIds.contains(u.id) { memberIds.remove(u.id) } else { memberIds.insert(u.id) }
} label: {
HStack {
Image(systemName: memberIds.contains(u.id) ? "checkmark.square.fill" : "square")
.foregroundStyle(memberIds.contains(u.id) ? Color.accentColor : .secondary)
Text(u.displayName).foregroundStyle(.primary)
}
}
}
}
if let members = group?.members {
Section(L10n.t("group.member_colors", appLang)) {
ForEach(members) { m in
MemberColorRow(api: api, groupId: groupId, member: m)
}
}
}
Section {
Button(role: .destructive) { showDeleteConfirm = true } label: {
Label(L10n.t("group.delete", appLang), systemImage: "trash")
}
}
if !error.isEmpty { Section { Text(error).foregroundStyle(.red) } }
}
.navigationTitle(L10n.t("group.manage", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button(L10n.t("common.cancel", appLang)) { dismiss() } }
ToolbarItem(placement: .primaryAction) {
Button(L10n.t("event.save", appLang)) { Task { await save() } }.bold()
}
}
.alert(L10n.t("group.delete", appLang), isPresented: $showDeleteConfirm) {
Button(L10n.t("group.delete", appLang), role: .destructive) { Task { await deleteGroup() } }
Button(L10n.t("common.cancel", appLang), role: .cancel) {}
}
}
.task { await load() }
}
private func load() async {
directory = (try? await api.getUserDirectory()) ?? []
if let g = try? await api.getGroup(id: groupId) {
group = g
name = g.name
icon = GroupIcons.isKey(g.icon) ? g.icon! : "people"
let me = UserDefaults.standard.integer(forKey: "userId")
memberIds = Set((g.members ?? []).map { $0.id }.filter { $0 != me })
}
}
private func save() async {
do {
try await api.updateGroup(id: groupId, name: name, icon: icon)
let me = UserDefaults.standard.integer(forKey: "userId")
let current = Set((group?.members ?? []).map { $0.id }.filter { $0 != me })
for id in memberIds where !current.contains(id) { try await api.addGroupMember(groupId: groupId, userId: id) }
for id in current where !memberIds.contains(id) { try await api.removeGroupMember(groupId: groupId, userId: id) }
await onDone()
dismiss()
} catch { self.error = error.localizedDescription }
}
private func deleteGroup() async {
do { try await api.deleteGroup(id: groupId); await onDone(); dismiss() }
catch { self.error = error.localizedDescription }
}
}
struct MemberColorRow: View {
let api: CalendarrAPI
let groupId: Int
let member: GroupMember
@State private var color: Color
init(api: CalendarrAPI, groupId: Int, member: GroupMember) {
self.api = api; self.groupId = groupId; self.member = member
_color = State(initialValue: Color(hex: member.color ?? "#4285f4"))
}
var body: some View {
HStack {
Text(member.displayName ?? "")
Spacer()
ColorPicker("", selection: $color, supportsOpacity: false)
.labelsHidden()
.onChange(of: color) { _, c in
Task { try? await api.setGroupMemberColor(groupId: groupId, userId: member.id, color: c.toHex()) }
}
}
}
}
// MARK: - Combined (overlay) agenda view
struct GroupCombinedView: View {
let api: CalendarrAPI
let group: CalGroup
@AppStorage("appLanguage") private var appLang = "system"
@State private var anchor = Date()
@State private var events: [CalEvent] = []
@State private var isLoading = false
private var monthRange: (Date, Date) {
let cal = Calendar.current
let start = cal.date(from: cal.dateComponents([.year, .month], from: anchor)) ?? anchor
let end = cal.date(byAdding: .month, value: 1, to: start) ?? anchor
return (start, end)
}
private var grouped: [(day: Date, items: [CalEvent])] {
let cal = Calendar.current
let dict = Dictionary(grouping: events.sorted { $0.startDate < $1.startDate }) {
cal.startOfDay(for: $0.startDate)
}
return dict.keys.sorted().map { ($0, dict[$0] ?? []) }
}
private let monthFmt: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "MMMM yyyy"; return f
}()
private let dayFmt: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "EEEE, d. MMM"; return f
}()
private let timeFmt: DateFormatter = {
let f = DateFormatter(); f.timeStyle = .short; f.dateStyle = .none; return f
}()
var body: some View {
List {
ForEach(grouped, id: \.day) { section in
Section(dayFmt.string(from: section.day)) {
ForEach(section.items) { ev in
HStack(spacing: 10) {
RoundedRectangle(cornerRadius: 3)
.fill(Color(hex: ev.effectiveColor))
.frame(width: 5, height: 34)
VStack(alignment: .leading, spacing: 2) {
Text(displayTitle(ev)).font(.body)
Text(ev.isAllDay ? L10n.t("event.allday", appLang) : timeFmt.string(from: ev.startDate))
.font(.caption).foregroundStyle(.secondary)
}
}
}
}
}
if !isLoading && events.isEmpty {
Text(L10n.t("groups.combined_empty", appLang)).foregroundStyle(.secondary)
}
}
.navigationTitle(group.name)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
HStack {
Button { shift(-1) } label: { Image(systemName: "chevron.left") }
Button { shift(1) } label: { Image(systemName: "chevron.right") }
}
}
ToolbarItem(placement: .principal) {
Text(monthFmt.string(from: anchor)).font(.headline)
}
}
.task(id: anchor) { await load() }
}
// Prefer the server-decorated title; fall back to a name prefix.
private func displayTitle(_ ev: CalEvent) -> String {
if let dt = ev.displayTitle, !dt.isEmpty { return dt }
let me = UserDefaults.standard.integer(forKey: "userId")
if let c = ev.creator, ev.isGroupEvent, c.id != me { return "\(firstName(c.displayName)): \(ev.title)" }
if let o = ev.owner, o.id != me { return "\(firstName(o.displayName)): \(ev.title)" }
return ev.title
}
private func firstName(_ s: String) -> String { s.split(separator: " ").first.map(String.init) ?? s }
private func shift(_ months: Int) {
anchor = Calendar.current.date(byAdding: .month, value: months, to: anchor) ?? anchor
}
private func load() async {
isLoading = true
let (s, e) = monthRange
events = (try? await api.fetchGroupCombined(groupId: group.id, start: s, end: e)) ?? []
isLoading = false
}
}

View File

@@ -62,6 +62,12 @@ struct MenuSheet: View {
Label(L10n.t("menu.accounts", appLang), systemImage: "tray.2") Label(L10n.t("menu.accounts", appLang), systemImage: "tray.2")
} }
NavigationLink {
GroupsView(api: api)
} label: {
Label(L10n.t("groups.title", appLang), systemImage: "person.2")
}
NavigationLink { NavigationLink {
ServerView() ServerView()
} label: { } label: {

View File

@@ -22,10 +22,24 @@ 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).
@State private var displayName = ""
@State private var loginName = ""
@State private var email = ""
@State private var privateVisibility = "busy"
@State private var groupVisibleId = 0 // 0 = none
@State private var ownLocalCals: [LocalCalendar] = []
@State private var profileMsg = ""
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Form { Form {
profilSection
privatsphaereSection
benachrichtigungenSection
geteilterKalenderSection
liquidGlassSection liquidGlassSection
cacheSection cacheSection
spracheSection spracheSection
@@ -40,6 +54,7 @@ struct SettingsView: View {
} }
// Reflect the latest server values when opening the screen. // Reflect the latest server values when opening the screen.
.task { await SettingsSync.pull(api: api) } .task { await SettingsSync.pull(api: api) }
.task { await loadProfile() }
// Appearance changes update widgets live; synced values are also pushed // Appearance changes update widgets live; synced values are also pushed
// to the server (debounced). `push` itself decides what actually gets // to the server (debounced). `push` itself decides what actually gets
// sent based on the sync toggle, so every change can simply call it. // sent based on the sync toggle, so every change can simply call it.
@@ -62,6 +77,122 @@ struct SettingsView: View {
.onChange(of: settingsSync) { _, on in if on { Task { await SettingsSync.pull(api: api) } } } .onChange(of: settingsSync) { _, on in if on { Task { await SettingsSync.pull(api: api) } } }
} }
// MARK: Profil
var profilSection: some View {
Section(L10n.t("settings.nav.profile", appLang)) {
HStack {
Text(L10n.t("profile.display_name", appLang))
Spacer()
TextField(L10n.t("profile.display_name", appLang), text: $displayName)
.multilineTextAlignment(.trailing)
}
HStack {
Text(L10n.t("profile.login_name", appLang))
Spacer()
Text(loginName).foregroundStyle(.secondary)
}
HStack {
Text("E-Mail")
Spacer()
TextField("E-Mail", text: $email)
.multilineTextAlignment(.trailing)
.keyboardType(.emailAddress)
.autocapitalization(.none)
}
Button(L10n.t("event.save", appLang)) { Task { await saveProfile() } }
if !profileMsg.isEmpty {
Text(profileMsg).font(.caption).foregroundStyle(.secondary)
}
}
}
// 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
var privatsphaereSection: some View {
Section {
Picker(L10n.t("settings.private_visibility", appLang), selection: $privateVisibility) {
Text(L10n.t("settings.private.busy", appLang)).tag("busy")
Text(L10n.t("settings.private.hidden", appLang)).tag("hidden")
}
.onChange(of: privateVisibility) { _, v in
Task { try? await api.updatePrivateVisibility(v) }
}
} header: {
Text(L10n.t("settings.privacy", appLang))
} footer: {
Text(L10n.t("settings.private_visibility.desc", appLang)).font(.caption)
}
}
// MARK: Geteilter Kalender
var geteilterKalenderSection: some View {
Section {
Picker(L10n.t("settings.group_visible", appLang), selection: $groupVisibleId) {
Text(L10n.t("group.visible.none", appLang)).tag(0)
ForEach(ownLocalCals) { cal in
Text(cal.name).tag(cal.id)
}
}
.onChange(of: groupVisibleId) { _, id in
Task { try? await api.updateGroupVisibleCalendar(id == 0 ? nil : id) }
}
} header: {
Text(L10n.t("settings.calendars", appLang))
} footer: {
Text(L10n.t("settings.group_visible.desc", appLang)).font(.caption)
}
}
private func loadProfile() async {
if let p = try? await api.getProfile() {
displayName = p.displayName ?? p.username
loginName = p.username
email = p.email ?? ""
}
if let s = try? await api.getSettings() {
privateVisibility = s.privateEventVisibility
groupVisibleId = s.groupVisibleCalendarId ?? 0
}
if let cals = try? await api.getLocalCalendars() {
ownLocalCals = cals.filter { $0.owned && !$0.group }
}
}
private func saveProfile() async {
do {
_ = try await api.updateProfile(displayName: displayName.isEmpty ? nil : displayName,
username: nil,
email: email.isEmpty ? "" : email)
UserDefaults.standard.set(displayName, forKey: "displayName")
profileMsg = L10n.t("settings.saved", appLang)
} catch {
profileMsg = error.localizedDescription
}
}
// MARK: Liquid Glass // MARK: Liquid Glass
var liquidGlassSection: some View { var liquidGlassSection: some View {

View File

@@ -0,0 +1,151 @@
import SwiftUI
import WidgetKit
struct CalendarDayWidgetView: View {
let entry: CalendarrEntry
private var snapshot: WidgetSnapshot? { entry.snapshot }
private var lang: String { snapshot?.language ?? "system" }
private var cal: Calendar {
var c = Calendar(identifier: .gregorian)
c.locale = WidgetL10n.locale(lang)
c.firstWeekday = 2
return c
}
private var weekDays: [Date] {
let start = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: entry.date)) ?? entry.date
return (0..<7).compactMap { cal.date(byAdding: .day, value: $0, to: start) }
}
private var upcomingEvents: [WidgetEvent] {
guard let s = snapshot else { return [] }
return WidgetHelpers.upcoming(from: entry.date, daysAhead: 1, in: s)
}
private var monthFmt: DateFormatter {
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang); f.dateFormat = "LLLL"; return f
}
private var weekdayFmt: DateFormatter {
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang); f.dateFormat = "EEEE"; return f
}
private var timeFmt: DateFormatter {
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang); f.dateFormat = "HH:mm"; return f
}
var body: some View {
if let s = snapshot {
let primary = Color(widgetHex: s.primaryColorHex)
let accent = Color(widgetHex: s.accentColorHex)
VStack(alignment: .leading, spacing: 0) {
header(primary: primary)
weekStrip(snapshot: s, primary: primary, accent: accent)
.padding(.vertical, 5)
Rectangle()
.fill(Color(widgetHex: s.lineColorHex).opacity(0.4))
.frame(height: 0.5)
.padding(.bottom, 6)
eventList(accent: accent)
}
} else {
Text(WidgetL10n.t("widget.no_data", lang))
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// MARK: Header
private func header(primary: Color) -> some View {
HStack(alignment: .top, spacing: 6) {
Text("\(cal.component(.day, from: entry.date))")
.font(.system(size: 36, weight: .bold))
.foregroundStyle(primary)
.frame(width: 44, alignment: .leading)
.minimumScaleFactor(0.7)
VStack(alignment: .leading, spacing: 1) {
Text(monthFmt.string(from: entry.date).uppercased())
.font(.system(size: 11, weight: .bold))
.foregroundStyle(primary)
Text("\(WidgetL10n.t("widget.today", lang)), \(weekdayFmt.string(from: entry.date))")
.font(.system(size: 10))
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.75)
}
Spacer(minLength: 0)
}
.padding(.bottom, 2)
}
// MARK: Week strip
private func weekStrip(snapshot: WidgetSnapshot, primary: Color, accent: Color) -> some View {
HStack(spacing: 0) {
ForEach(weekDays, id: \.self) { day in
let isToday = cal.isDateInToday(day)
let hasEvs = !WidgetHelpers.events(for: day, in: snapshot).isEmpty
VStack(spacing: 2) {
Text(shortDay(day))
.font(.system(size: 8, weight: .bold))
.foregroundStyle(isToday ? accent : .secondary)
ZStack {
if isToday {
Circle().fill(primary)
} else if hasEvs {
Circle().fill(accent.opacity(0.18))
}
Text("\(cal.component(.day, from: day))")
.font(.system(size: 11, weight: isToday ? .bold : .medium))
.foregroundStyle(isToday ? .white : .primary)
}
.frame(width: 22, height: 22)
}
.frame(maxWidth: .infinity)
}
}
}
private func shortDay(_ date: Date) -> String {
let f = DateFormatter()
f.locale = WidgetL10n.locale(lang)
f.dateFormat = "EEE"
return String(f.string(from: date).prefix(2)).uppercased()
}
// MARK: Event list
@ViewBuilder
private func eventList(accent: Color) -> some View {
if upcomingEvents.isEmpty {
Text(WidgetL10n.t("widget.no_events", lang))
.font(.system(size: 11))
.foregroundStyle(.secondary)
Spacer(minLength: 0)
} else {
VStack(alignment: .leading, spacing: 5) {
ForEach(upcomingEvents.prefix(3)) { ev in
HStack(alignment: .center, spacing: 6) {
RoundedRectangle(cornerRadius: 1.5)
.fill(Color(widgetHex: ev.colorHex))
.frame(width: 3, height: 26)
VStack(alignment: .leading, spacing: 1) {
Text(ev.title)
.font(.system(size: 11, weight: .semibold))
.lineLimit(1)
Text(ev.isAllDay
? WidgetL10n.t("widget.allday", lang)
: "\(timeFmt.string(from: ev.start)) \(timeFmt.string(from: ev.end))")
.font(.system(size: 9))
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
}
Spacer(minLength: 0)
}
}
}
}

View File

@@ -11,10 +11,16 @@ struct CalendarrWidgetBundle: WidgetBundle {
TwoWeeksWidget() TwoWeeksWidget()
UpcomingWidget() UpcomingWidget()
UpNextWidget() UpNextWidget()
CalendarDayWidget()
TwoMonthWidget()
NowNextEventsWidget()
LockScreenWidget()
LockScreenCountWidget()
LockScreenCountdownWidget()
} }
} }
// Shared chrome modifier keeps every widget on the same theme. // Shared chrome modifier keeps every home-screen widget on the same theme.
private struct CalendarrWidgetChrome: ViewModifier { private struct CalendarrWidgetChrome: ViewModifier {
let snapshot: WidgetSnapshot? let snapshot: WidgetSnapshot?
@@ -139,3 +145,99 @@ struct UpNextWidget: Widget {
.supportedFamilies([.systemMedium]) .supportedFamilies([.systemMedium])
} }
} }
// MARK: Calendar Day: date + week strip + events (medium)
struct CalendarDayWidget: Widget {
let kind: String = "CalendarDayWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
CalendarDayWidgetView(entry: entry).calendarrChrome(entry.snapshot)
}
.configurationDisplayName(WidgetL10n.t("widget.display.calday_title", "system"))
.description(WidgetL10n.t("widget.display.calday_desc", "system"))
.supportedFamilies([.systemMedium])
}
}
// MARK: Two Month calendar grid (medium + large)
struct TwoMonthWidget: Widget {
let kind: String = "TwoMonthWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
TwoMonthWidgetView(entry: entry).calendarrChrome(entry.snapshot)
}
.configurationDisplayName(WidgetL10n.t("widget.display.twomonth_title", "system"))
.description(WidgetL10n.t("widget.display.twomonth_desc", "system"))
.supportedFamilies([.systemMedium, .systemLarge])
}
}
// MARK: Now & Next events (medium)
struct NowNextEventsWidget: Widget {
let kind: String = "NowNextEventsWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
NowNextWidgetView(entry: entry).calendarrChrome(entry.snapshot)
}
.configurationDisplayName(WidgetL10n.t("widget.display.nownext_title", "system"))
.description(WidgetL10n.t("widget.display.nownext_desc", "system"))
.supportedFamilies([.systemMedium])
}
}
// MARK: Lock Screen: date (circular, rectangular, inline)
struct LockScreenWidget: Widget {
let kind: String = "LockScreenWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
LockScreenWidgetView(entry: entry)
.containerBackground(for: .widget) { Color.clear }
.environment(\.locale, WidgetL10n.locale(entry.snapshot?.language ?? "system"))
}
.configurationDisplayName(WidgetL10n.t("widget.display.lockscreen_title", "system"))
.description(WidgetL10n.t("widget.display.lockscreen_desc", "system"))
.supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline])
}
}
// MARK: Lock Screen: today event count (circular, rectangular, inline)
struct LockScreenCountWidget: Widget {
let kind: String = "LockScreenCountWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
LockScreenCountWidgetView(entry: entry)
.containerBackground(for: .widget) { Color.clear }
.environment(\.locale, WidgetL10n.locale(entry.snapshot?.language ?? "system"))
}
.configurationDisplayName(WidgetL10n.t("widget.display.lockscreen_count_title", "system"))
.description(WidgetL10n.t("widget.display.lockscreen_count_desc", "system"))
.supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline])
}
}
// MARK: Lock Screen: countdown to next event (circular, rectangular, inline)
struct LockScreenCountdownWidget: Widget {
let kind: String = "LockScreenCountdownWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
LockScreenCountdownWidgetView(entry: entry)
.containerBackground(for: .widget) { Color.clear }
.environment(\.locale, WidgetL10n.locale(entry.snapshot?.language ?? "system"))
}
.configurationDisplayName(WidgetL10n.t("widget.display.lockscreen_countdown_title", "system"))
.description(WidgetL10n.t("widget.display.lockscreen_countdown_desc", "system"))
.supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline])
}
}

View File

@@ -0,0 +1,288 @@
import SwiftUI
import WidgetKit
// MARK: Date widget (existing)
struct LockScreenWidgetView: View {
let entry: CalendarrEntry
@Environment(\.widgetFamily) private var family
private var snapshot: WidgetSnapshot? { entry.snapshot }
private var lang: String { snapshot?.language ?? "system" }
private var cal: Calendar {
var c = Calendar(identifier: .gregorian)
c.locale = WidgetL10n.locale(lang)
return c
}
private var nextEvent: WidgetEvent? {
guard let s = snapshot else { return nil }
return WidgetHelpers.upcoming(from: entry.date, daysAhead: 1, in: s).first
}
private var timeFmt: DateFormatter {
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang); f.dateFormat = "HH:mm"; return f
}
private var monthAbbrev: String {
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang); f.dateFormat = "LLL"
return f.string(from: entry.date).uppercased()
}
@ViewBuilder
var body: some View {
switch family {
case .accessoryCircular:
circularView
case .accessoryRectangular:
rectangularView
default:
inlineView
}
}
// MARK: Circular: today's date
private var circularView: some View {
ZStack {
AccessoryWidgetBackground()
VStack(spacing: 0) {
Text("\(cal.component(.day, from: entry.date))")
.font(.system(size: 22, weight: .bold))
.minimumScaleFactor(0.7)
.widgetAccentable()
Text(monthAbbrev)
.font(.system(size: 8, weight: .semibold))
}
}
}
// MARK: Rectangular: next event
private var rectangularView: some View {
VStack(alignment: .leading, spacing: 2) {
if let ev = nextEvent {
Text(ev.isAllDay
? WidgetL10n.t("widget.allday", lang)
: timeFmt.string(from: ev.start))
.font(.system(size: 11, weight: .semibold))
.widgetAccentable()
Text(ev.title)
.font(.system(size: 14, weight: .bold))
.lineLimit(1)
if !ev.location.isEmpty {
Text(ev.location)
.font(.system(size: 11))
.lineLimit(1)
}
} else {
Image(systemName: "calendar")
.font(.system(size: 13))
.widgetAccentable()
Text(WidgetL10n.t("widget.no_events", lang))
.font(.system(size: 13))
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
// MARK: Inline: brief next event
private var inlineView: some View {
let text: String = {
guard let ev = nextEvent else {
return WidgetL10n.t("widget.no_events", lang)
}
return ev.isAllDay ? ev.title : "\(timeFmt.string(from: ev.start)) \(ev.title)"
}()
return Label(text, systemImage: "calendar")
}
}
// MARK: Today event count widget
struct LockScreenCountWidgetView: View {
let entry: CalendarrEntry
@Environment(\.widgetFamily) private var family
private var snapshot: WidgetSnapshot? { entry.snapshot }
private var lang: String { snapshot?.language ?? "system" }
private var timeFmt: DateFormatter {
let f = DateFormatter()
f.locale = WidgetL10n.locale(lang)
f.dateFormat = "HH:mm"
return f
}
private var todayEvents: [WidgetEvent] {
guard let s = snapshot else { return [] }
return WidgetHelpers.upcoming(from: entry.date, daysAhead: 1, in: s)
}
@ViewBuilder
var body: some View {
switch family {
case .accessoryCircular:
circularView
case .accessoryRectangular:
rectangularView
default:
inlineView
}
}
private var circularView: some View {
ZStack {
AccessoryWidgetBackground()
VStack(spacing: 1) {
Image(systemName: "calendar")
.font(.system(size: 10, weight: .semibold))
.widgetAccentable()
Text("\(todayEvents.count)")
.font(.system(size: 22, weight: .bold))
.minimumScaleFactor(0.7)
.widgetAccentable()
}
}
}
private var rectangularView: some View {
let countLabel = "\(todayEvents.count) \(WidgetL10n.t("widget.events_count", lang))"
return VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 4) {
Text(WidgetL10n.t("widget.today", lang).uppercased())
.font(.system(size: 9, weight: .bold))
.widgetAccentable()
Text("· \(countLabel)")
.font(.system(size: 9))
}
if todayEvents.isEmpty {
Text(WidgetL10n.t("widget.no_events", lang))
.font(.system(size: 12))
.foregroundStyle(.secondary)
} else {
ForEach(todayEvents.prefix(2)) { ev in
HStack(spacing: 4) {
Text(ev.isAllDay ? "·" : timeFmt.string(from: ev.start))
.font(.system(size: 10, weight: .semibold))
.widgetAccentable()
.frame(width: 32, alignment: .leading)
Text(ev.title)
.font(.system(size: 11, weight: .medium))
.lineLimit(1)
}
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private var inlineView: some View {
let label = "\(todayEvents.count) \(WidgetL10n.t("widget.events_count", lang))"
return Label(label, systemImage: "calendar.badge.clock")
}
}
// MARK: Countdown to next event widget
struct LockScreenCountdownWidgetView: View {
let entry: CalendarrEntry
@Environment(\.widgetFamily) private var family
private var snapshot: WidgetSnapshot? { entry.snapshot }
private var lang: String { snapshot?.language ?? "system" }
private var timeFmt: DateFormatter {
let f = DateFormatter()
f.locale = WidgetL10n.locale(lang)
f.dateFormat = "HH:mm"
return f
}
private var nextEvent: WidgetEvent? {
guard let s = snapshot else { return nil }
return WidgetHelpers.upcoming(from: entry.date, daysAhead: 1, in: s).first
}
private var isRunning: Bool {
guard let ev = nextEvent, !ev.isAllDay else { return false }
return ev.start <= entry.date && ev.end > entry.date
}
private var countdownText: String {
guard let ev = nextEvent else { return WidgetL10n.t("widget.no_events", lang) }
if isRunning { return WidgetL10n.t("widget.running", lang) }
if ev.isAllDay { return WidgetL10n.t("widget.allday", lang) }
let total = Int(max(0, ev.start.timeIntervalSince(entry.date)) / 60)
if total < 60 { return "in \(total)m" }
let h = total / 60; let m = total % 60
return m == 0 ? "in \(h)h" : "in \(h)h \(m)m"
}
@ViewBuilder
var body: some View {
switch family {
case .accessoryCircular:
circularView
case .accessoryRectangular:
rectangularView
default:
inlineView
}
}
private var circularView: some View {
ZStack {
AccessoryWidgetBackground()
VStack(spacing: 1) {
Text(countdownText)
.font(.system(size: 13, weight: .bold))
.minimumScaleFactor(0.5)
.lineLimit(1)
.widgetAccentable()
if let ev = nextEvent, !ev.isAllDay {
Text(timeFmt.string(from: ev.start))
.font(.system(size: 8))
.lineLimit(1)
}
}
.padding(.horizontal, 4)
}
}
private var rectangularView: some View {
VStack(alignment: .leading, spacing: 2) {
if let ev = nextEvent {
Text(countdownText)
.font(.system(size: 11, weight: .semibold))
.widgetAccentable()
Text(ev.title)
.font(.system(size: 14, weight: .bold))
.lineLimit(1)
let timeStr = ev.isAllDay
? WidgetL10n.t("widget.allday", lang)
: "\(timeFmt.string(from: ev.start)) \(timeFmt.string(from: ev.end))"
Text(timeStr)
.font(.system(size: 11))
.lineLimit(1)
} else {
Image(systemName: "timer")
.font(.system(size: 13))
.widgetAccentable()
Text(WidgetL10n.t("widget.no_events", lang))
.font(.system(size: 13))
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private var inlineView: some View {
let text: String = {
guard let ev = nextEvent else { return WidgetL10n.t("widget.no_events", lang) }
return "\(ev.title) \(countdownText)"
}()
return Label(text, systemImage: "timer")
}
}

View File

@@ -0,0 +1,162 @@
import SwiftUI
import WidgetKit
struct NowNextWidgetView: View {
let entry: CalendarrEntry
private var snapshot: WidgetSnapshot? { entry.snapshot }
private var lang: String { snapshot?.language ?? "system" }
private var cal: Calendar {
var c = Calendar(identifier: .gregorian)
c.locale = WidgetL10n.locale(lang)
return c
}
private var timeFmt: DateFormatter {
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang); f.dateFormat = "HH:mm"; return f
}
private var dayOfWeekFmt: DateFormatter {
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang); f.dateFormat = "EEEE"; return f
}
// Currently running event, or next upcoming timed event, or first all-day event
private var featuredEvent: WidgetEvent? {
guard let s = snapshot else { return nil }
let pool = WidgetHelpers.upcoming(from: entry.date, daysAhead: 1, in: s)
if let running = pool.first(where: { !$0.isAllDay && $0.start <= entry.date }) { return running }
if let next = pool.first(where: { !$0.isAllDay }) { return next }
return pool.first
}
// All upcoming events today except the featured one
private var remainingEvents: [WidgetEvent] {
guard let s = snapshot else { return [] }
let pool = WidgetHelpers.upcoming(from: entry.date, daysAhead: 1, in: s)
guard let featured = featuredEvent else { return pool }
return pool.filter { $0.id != featured.id }
}
private func timeRange(_ ev: WidgetEvent) -> String {
ev.isAllDay
? WidgetL10n.t("widget.allday", lang)
: "\(timeFmt.string(from: ev.start)) \(timeFmt.string(from: ev.end))"
}
var body: some View {
if let s = snapshot {
let line = Color(widgetHex: s.lineColorHex)
VStack(spacing: 6) {
featuredCard(snapshot: s)
bottomRow(line: line)
}
} else {
Text(WidgetL10n.t("widget.no_data", lang))
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// MARK: Featured event card
private func featuredCard(snapshot: WidgetSnapshot) -> some View {
let ev = featuredEvent
let baseColor = ev.map { Color(widgetHex: $0.colorHex) } ?? Color(widgetHex: snapshot.primaryColorHex)
return ZStack(alignment: .leading) {
LinearGradient(
colors: [baseColor.opacity(0.75), baseColor],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.clipShape(RoundedRectangle(cornerRadius: 10))
HStack(spacing: 0) {
VStack(alignment: .leading, spacing: 2) {
Text(ev?.title ?? WidgetL10n.t("widget.no_events", lang))
.font(.system(size: 13, weight: .bold))
.foregroundStyle(.white)
.lineLimit(1)
Text(ev.map { timeRange($0) } ?? "")
.font(.system(size: 10))
.foregroundStyle(.white.opacity(0.85))
}
.padding(.leading, 10)
.padding(.vertical, 9)
Spacer()
if ev != nil {
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.white.opacity(0.7))
.padding(.trailing, 12)
}
}
}
.frame(maxWidth: .infinity)
.fixedSize(horizontal: false, vertical: true)
}
// MARK: Bottom: date + event list
private func bottomRow(line: Color) -> some View {
HStack(alignment: .top, spacing: 0) {
// Left: day name + large number
VStack(alignment: .leading, spacing: 0) {
Text(dayOfWeekFmt.string(from: entry.date).uppercased())
.font(.system(size: 8, weight: .bold))
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.6)
Text("\(cal.component(.day, from: entry.date))")
.font(.system(size: 30, weight: .light))
}
.frame(width: 50, alignment: .leading)
// Divider
line.opacity(0.4).frame(width: 0.5)
.padding(.horizontal, 6)
// Right: event list
VStack(alignment: .leading, spacing: 4) {
let shown = remainingEvents.prefix(2)
if shown.isEmpty {
Text(WidgetL10n.t("widget.no_events", lang))
.font(.system(size: 10))
.foregroundStyle(.secondary)
} else {
ForEach(shown) { ev in
HStack(alignment: .firstTextBaseline, spacing: 5) {
Circle()
.fill(Color(widgetHex: ev.colorHex))
.frame(width: 7, height: 7)
.padding(.top, 1)
VStack(alignment: .leading, spacing: 0) {
Text(ev.title)
.font(.system(size: 11, weight: .semibold))
.lineLimit(1)
Text(timeRange(ev))
.font(.system(size: 9))
.foregroundStyle(.secondary)
}
}
}
}
Spacer(minLength: 0)
}
Spacer(minLength: 0)
// +N badge
if remainingEvents.count > 2 {
Text("+\(remainingEvents.count - 2)")
.font(.system(size: 9, weight: .semibold))
.padding(.horizontal, 5)
.padding(.vertical, 2)
.background(.secondary.opacity(0.18), in: Capsule())
.frame(maxHeight: .infinity, alignment: .bottom)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}

View File

@@ -10,7 +10,7 @@ struct ThisWeekWidgetView: View {
private var cal: Calendar { private var cal: Calendar {
var c = Calendar(identifier: .gregorian) var c = Calendar(identifier: .gregorian)
c.locale = WidgetL10n.locale(lang) c.locale = WidgetL10n.locale(lang)
c.firstWeekday = 2 // Monday c.firstWeekday = 2
return c return c
} }
@@ -40,7 +40,7 @@ struct ThisWeekWidgetView: View {
if let s = snapshot { if let s = snapshot {
let primary = Color(widgetHex: s.primaryColorHex) let primary = Color(widgetHex: s.primaryColorHex)
let accent = Color(widgetHex: s.accentColorHex) let accent = Color(widgetHex: s.accentColorHex)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 3) {
HStack(alignment: .firstTextBaseline, spacing: 4) { HStack(alignment: .firstTextBaseline, spacing: 4) {
Text(monthHeader.split(separator: " ").first.map(String.init) ?? "") Text(monthHeader.split(separator: " ").first.map(String.init) ?? "")
.font(.system(size: 10, weight: .bold)) .font(.system(size: 10, weight: .bold))
@@ -48,22 +48,21 @@ struct ThisWeekWidgetView: View {
Text(monthHeader.split(separator: " ").dropFirst().joined(separator: " ")) Text(monthHeader.split(separator: " ").dropFirst().joined(separator: " "))
.font(.system(size: 10, weight: .semibold)) .font(.system(size: 10, weight: .semibold))
} }
GeometryReader { geo in // Equal-width columns via maxWidth no GeometryReader needed
let colW = geo.size.width / 7 HStack(spacing: 0) {
HStack(spacing: 0) { ForEach(Array(weekDays.enumerated()), id: \.offset) { idx, day in
ForEach(Array(weekDays.enumerated()), id: \.offset) { idx, day in dayColumn(day, snapshot: s, primary: primary, accent: accent)
dayColumn(day, snapshot: s, primary: primary, accent: accent) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.frame(width: colW) .overlay(alignment: .trailing) {
.overlay(alignment: .trailing) { if idx < 6 {
if idx < 6 { Rectangle()
Rectangle() .fill(Color(widgetHex: s.lineColorHex).opacity(0.35))
.fill(Color(widgetHex: s.lineColorHex).opacity(0.35)) .frame(width: 0.5)
.frame(width: 0.5)
}
} }
} }
} }
} }
.frame(maxWidth: .infinity, maxHeight: .infinity)
} }
} else { } else {
Text(WidgetL10n.t("widget.no_data", lang)) Text(WidgetL10n.t("widget.no_data", lang))
@@ -80,22 +79,22 @@ struct ThisWeekWidgetView: View {
let isToday = cal.isDateInToday(day) let isToday = cal.isDateInToday(day)
let evs = WidgetHelpers.events(for: day, in: snapshot) let evs = WidgetHelpers.events(for: day, in: snapshot)
let dayIdx = (0..<7).firstIndex { i in cal.isDate(weekDays[i], inSameDayAs: day) } ?? 0 let dayIdx = (0..<7).firstIndex { i in cal.isDate(weekDays[i], inSameDayAs: day) } ?? 0
return VStack(spacing: 1) { return VStack(alignment: .center, spacing: 1) {
Text(weekdayHeaders[dayIdx]) Text(weekdayHeaders[dayIdx])
.font(.system(size: 7.5, weight: .bold)) .font(.system(size: 7.5, weight: .bold))
.foregroundStyle(isToday ? accent : .secondary) .foregroundStyle(isToday ? accent : .secondary)
Text("\(cal.component(.day, from: day))") Text("\(cal.component(.day, from: day))")
.font(.system(size: 10, weight: isToday ? .bold : .semibold)) .font(.system(size: 10, weight: isToday ? .bold : .semibold))
.foregroundStyle(isToday ? Color.white : Color.primary) .foregroundStyle(isToday ? Color.white : Color.primary)
.frame(width: 15, height: 15) .frame(width: 16, height: 16)
.background(isToday ? primary : Color.clear) .background(isToday ? primary : Color.clear)
.clipShape(Circle()) .clipShape(Circle())
ForEach(evs.prefix(2)) { ev in ForEach(evs.prefix(3)) { ev in
eventPill(ev) eventPill(ev)
} }
if evs.count > 2 { if evs.count > 3 {
Text("+\(evs.count - 2)") Text("+\(evs.count - 3)")
.font(.system(size: 7)) .font(.system(size: 6.5))
.foregroundStyle(accent) .foregroundStyle(accent)
} }
Spacer(minLength: 0) Spacer(minLength: 0)

View File

@@ -0,0 +1,170 @@
import SwiftUI
import WidgetKit
struct TwoMonthWidgetView: View {
let entry: CalendarrEntry
@Environment(\.widgetFamily) private var family
private var snapshot: WidgetSnapshot? { entry.snapshot }
private var lang: String { snapshot?.language ?? "system" }
private var cal: Calendar {
var c = Calendar(identifier: .gregorian)
c.locale = WidgetL10n.locale(lang)
c.firstWeekday = 2
return c
}
private var thisMonth: Date {
cal.date(from: cal.dateComponents([.year, .month], from: entry.date)) ?? entry.date
}
private var nextMonth: Date {
cal.date(byAdding: .month, value: 1, to: thisMonth) ?? thisMonth
}
// Weekday header labels (M T W T F S S)
private var weekdayHeaders: [String] {
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang)
let symbols = f.veryShortWeekdaySymbols ?? cal.veryShortWeekdaySymbols
let start = cal.firstWeekday - 1
return (0..<7).map { String(symbols[(start + $0) % 7]).uppercased() }
}
// Number of date rows to show (5 for medium, 6 for large)
private var rowCount: Int { family == .systemLarge ? 6 : 5 }
var body: some View {
if let s = snapshot {
let primary = Color(widgetHex: s.primaryColorHex)
let accent = Color(widgetHex: s.accentColorHex)
let line = Color(widgetHex: s.lineColorHex)
HStack(alignment: .top, spacing: 0) {
monthColumn(monthDate: thisMonth, snapshot: s,
primary: primary, accent: accent, line: line)
.frame(maxWidth: .infinity)
line.opacity(0.35).frame(width: 0.5)
.padding(.horizontal, 3)
monthColumn(monthDate: nextMonth, snapshot: s,
primary: primary, accent: accent, line: line)
.frame(maxWidth: .infinity)
}
} else {
Text(WidgetL10n.t("widget.no_data", lang))
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// MARK: One month column
private func monthColumn(monthDate: Date,
snapshot: WidgetSnapshot,
primary: Color,
accent: Color,
line: Color) -> some View {
let monthFmt = DateFormatter()
monthFmt.locale = WidgetL10n.locale(lang)
monthFmt.dateFormat = "LLLL"
let name = monthFmt.string(from: monthDate).uppercased()
let start = gridStart(for: monthDate)
let wn = WidgetL10n.t("widget.cw", lang)
return VStack(alignment: .leading, spacing: 1) {
// Month name
Text(name)
.font(.system(size: 9, weight: .bold))
.foregroundStyle(primary)
// Column headers: KW + 7 weekdays
HStack(spacing: 0) {
Text(wn)
.font(.system(size: 6, weight: .bold))
.foregroundStyle(.secondary)
.frame(width: 14, alignment: .center)
ForEach(weekdayHeaders, id: \.self) { h in
Text(h)
.font(.system(size: 6.5, weight: .bold))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
}
}
// Date rows
ForEach(0..<rowCount, id: \.self) { row in
let rowStart = cal.date(byAdding: .day, value: row * 7, to: start)!
let weekNum = cal.component(.weekOfYear, from: rowStart)
let inMonth = cal.isDate(rowStart, equalTo: monthDate, toGranularity: .month)
|| cal.isDate(cal.date(byAdding: .day, value: 6, to: rowStart)!,
equalTo: monthDate, toGranularity: .month)
if inMonth {
HStack(spacing: 0) {
Text("\(weekNum)")
.font(.system(size: 6))
.foregroundStyle(.secondary.opacity(0.6))
.frame(width: 14, alignment: .center)
ForEach(0..<7, id: \.self) { col in
let day = cal.date(byAdding: .day, value: col, to: rowStart)!
dayCell(day, monthDate: monthDate, snapshot: snapshot,
primary: primary, accent: accent)
}
}
}
}
Spacer(minLength: 0)
}
}
// MARK: Day cell
private func dayCell(_ day: Date,
monthDate: Date,
snapshot: WidgetSnapshot,
primary: Color,
accent: Color) -> some View {
let isToday = cal.isDateInToday(day)
let inMonth = cal.isDate(day, equalTo: monthDate, toGranularity: .month)
let evs = inMonth ? WidgetHelpers.events(for: day, in: snapshot) : []
let isWeekend = { () -> Bool in
let wd = cal.component(.weekday, from: day)
return wd == 1 || wd == 7
}()
return VStack(spacing: 1) {
ZStack {
if isToday { Circle().fill(primary) }
Text("\(cal.component(.day, from: day))")
.font(.system(size: 7.5, weight: isToday ? .bold : .medium))
.foregroundStyle(
isToday ? .white :
!inMonth ? Color.secondary.opacity(0.3) :
isWeekend ? Color.primary.opacity(0.5) :
Color.primary
)
}
.frame(maxWidth: .infinity)
.frame(height: 11)
// Event dots
HStack(spacing: 1) {
ForEach(evs.prefix(3).indices, id: \.self) { i in
Circle()
.fill(Color(widgetHex: evs[i].colorHex))
.frame(width: 2.5, height: 2.5)
}
}
.frame(height: 3)
}
.padding(.bottom, 1)
}
// MARK: Grid helpers
private func gridStart(for monthDate: Date) -> Date {
let first = cal.date(from: cal.dateComponents([.year, .month], from: monthDate)) ?? monthDate
let weekday = cal.component(.weekday, from: first)
let offset = ((weekday - cal.firstWeekday) + 7) % 7
return cal.date(byAdding: .day, value: -offset, to: first) ?? first
}
}

View File

@@ -48,20 +48,35 @@ enum WidgetL10n {
"widget.more": "+%d weitere", "widget.more": "+%d weitere",
"widget.upcoming": "Nächste 5 Tage", "widget.upcoming": "Nächste 5 Tage",
"widget.no_data": "Keine Daten App einmal öffnen", "widget.no_data": "Keine Daten App einmal öffnen",
"widget.display.today_title": "Heute", "widget.display.today_title": "Heute",
"widget.display.today_desc": "Heutige Termine auf einen Blick.", "widget.display.today_desc": "Heutige Termine auf einen Blick.",
"widget.display.days_title": "Heute & Morgen", "widget.display.days_title": "Heute & Morgen",
"widget.display.days_desc": "Termine der nächsten zwei Tage.", "widget.display.days_desc": "Termine der nächsten zwei Tage.",
"widget.display.upcoming_title": "Nächste 5 Tage", "widget.display.upcoming_title": "Nächste 5 Tage",
"widget.display.upcoming_desc": "Termine der nächsten 5 Tage.", "widget.display.upcoming_desc": "Termine der nächsten 5 Tage.",
"widget.display.thisweek_title": "Diese Woche", "widget.display.thisweek_title": "Diese Woche",
"widget.display.thisweek_desc": "Wochenraster mit Terminen.", "widget.display.thisweek_desc": "Wochenraster mit Terminen.",
"widget.display.twoweeks_title": "Zwei Wochen", "widget.display.twoweeks_title": "Zwei Wochen",
"widget.display.twoweeks_desc": "Zwei-Wochen-Raster mit Terminen.", "widget.display.twoweeks_desc": "Zwei-Wochen-Raster mit Terminen.",
"widget.display.threedays_title": "Drei Tage", "widget.display.threedays_title": "Drei Tage",
"widget.display.threedays_desc": "Drei-Tages-Ansicht mit Terminen.", "widget.display.threedays_desc": "Drei-Tages-Ansicht mit Terminen.",
"widget.display.upnext_title": "Up Next + Kalender", "widget.display.upnext_title": "Up Next + Kalender",
"widget.display.upnext_desc": "Nächste Termine mit Monatsübersicht." "widget.display.upnext_desc": "Nächste Termine mit Monatsübersicht.",
"widget.display.calday_title": "Tag & Termine",
"widget.display.calday_desc": "Datum, Wochenübersicht und nächste Termine.",
"widget.display.lockscreen_title": "Datum",
"widget.display.lockscreen_desc": "Aktuelles Datum und nächster Termin.",
"widget.display.twomonth_title": "Zwei Monate",
"widget.display.twomonth_desc": "Aktueller und nächster Monat auf einen Blick.",
"widget.display.nownext_title": "Jetzt & Nächstes",
"widget.display.nownext_desc": "Aktueller Termin und nächste Ereignisse.",
"widget.cw": "KW",
"widget.running": "Läuft",
"widget.events_count": "Termine",
"widget.display.lockscreen_count_title": "Termine heute",
"widget.display.lockscreen_count_desc": "Anzahl und Liste heutiger Termine.",
"widget.display.lockscreen_countdown_title": "Countdown",
"widget.display.lockscreen_countdown_desc": "Zeit bis zum nächsten Termin."
], ],
"en": [ "en": [
"widget.today": "Today", "widget.today": "Today",
@@ -71,20 +86,35 @@ enum WidgetL10n {
"widget.more": "+%d more", "widget.more": "+%d more",
"widget.upcoming": "Next 5 days", "widget.upcoming": "Next 5 days",
"widget.no_data": "No data open the app once", "widget.no_data": "No data open the app once",
"widget.display.today_title": "Today", "widget.display.today_title": "Today",
"widget.display.today_desc": "Today's events at a glance.", "widget.display.today_desc": "Today's events at a glance.",
"widget.display.days_title": "Today & tomorrow", "widget.display.days_title": "Today & tomorrow",
"widget.display.days_desc": "Events for the next two days.", "widget.display.days_desc": "Events for the next two days.",
"widget.display.upcoming_title": "Next 5 days", "widget.display.upcoming_title": "Next 5 days",
"widget.display.upcoming_desc": "Events for the next 5 days.", "widget.display.upcoming_desc": "Events for the next 5 days.",
"widget.display.thisweek_title": "This Week", "widget.display.thisweek_title": "This Week",
"widget.display.thisweek_desc": "Week grid with events.", "widget.display.thisweek_desc": "Week grid with events.",
"widget.display.twoweeks_title": "Two Weeks", "widget.display.twoweeks_title": "Two Weeks",
"widget.display.twoweeks_desc": "Two-week grid with events.", "widget.display.twoweeks_desc": "Two-week grid with events.",
"widget.display.threedays_title": "Three Days", "widget.display.threedays_title": "Three Days",
"widget.display.threedays_desc": "Three-day view with events.", "widget.display.threedays_desc": "Three-day view with events.",
"widget.display.upnext_title": "Up Next + Calendar", "widget.display.upnext_title": "Up Next + Calendar",
"widget.display.upnext_desc": "Next events with month overview." "widget.display.upnext_desc": "Next events with month overview.",
"widget.display.calday_title": "Day & Events",
"widget.display.calday_desc": "Date, week overview and upcoming events.",
"widget.display.lockscreen_title": "Date",
"widget.display.lockscreen_desc": "Current date and next event.",
"widget.display.twomonth_title": "Two Months",
"widget.display.twomonth_desc": "Current and next month at a glance.",
"widget.display.nownext_title": "Now & Next",
"widget.display.nownext_desc": "Current event and upcoming events.",
"widget.cw": "W",
"widget.running": "Running",
"widget.events_count": "Events",
"widget.display.lockscreen_count_title": "Today's Events",
"widget.display.lockscreen_count_desc": "Count and list of today's events.",
"widget.display.lockscreen_countdown_title": "Countdown",
"widget.display.lockscreen_countdown_desc": "Time until your next event."
] ]
] ]
} }