Compare commits
17 Commits
9fac13f99c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59a879ea23 | ||
|
|
f480b438cb | ||
|
|
587a0e65fa | ||
|
|
e7d8effb47 | ||
|
|
68349d36e5 | ||
|
|
451d3d4d6b | ||
|
|
51218b9aa3 | ||
|
|
b61a90d960 | ||
|
|
b9547c15f9 | ||
|
|
8521a28520 | ||
|
|
7f76df2600 | ||
|
|
852e46fcf8 | ||
|
|
a62b200dfa | ||
|
|
c6f9981a54 | ||
|
|
815f2cf01a | ||
|
|
6dc8724a9a | ||
|
|
c9803d80a3 |
@@ -510,7 +510,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4;
|
||||
MARKETING_VERSION = 2.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios;
|
||||
PRODUCT_NAME = "Calendarr iOS";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
@@ -553,7 +553,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4;
|
||||
MARKETING_VERSION = 2.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios;
|
||||
PRODUCT_NAME = "Calendarr iOS";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
|
||||
@@ -18,6 +18,7 @@ struct AppSettings: Codable {
|
||||
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 {
|
||||
case defaultView = "default_view"
|
||||
@@ -37,6 +38,7 @@ struct AppSettings: Codable {
|
||||
case lineColor = "line_color"
|
||||
case privateEventVisibility = "private_event_visibility"
|
||||
case groupVisibleCalendarId = "group_visible_calendar_id"
|
||||
case defaultReminderMinutes = "default_reminder_minutes"
|
||||
}
|
||||
|
||||
init() {}
|
||||
@@ -66,6 +68,7 @@ struct AppSettings: Codable {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,11 @@ struct CalEvent: Identifiable, Hashable {
|
||||
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] = []
|
||||
|
||||
// Group view supplies a server-resolved colour; otherwise per-event then calendar colour.
|
||||
var effectiveColor: String { displayColor ?? color ?? calendarColor }
|
||||
@@ -78,7 +83,9 @@ struct CalEvent: Identifiable, Hashable {
|
||||
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 }
|
||||
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)") } ?? []
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,11 +51,18 @@ class CalendarStore {
|
||||
var events: [CalEvent] = []
|
||||
var viewType: CalViewType = .month
|
||||
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 isCachingBackground = false
|
||||
var lastError: String? = nil
|
||||
var weekStartsOnMonday = true
|
||||
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
|
||||
/// 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.
|
||||
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
|
||||
private var cachedStart: Date? = nil
|
||||
private var cachedEnd: Date? = nil
|
||||
@@ -111,6 +127,19 @@ class CalendarStore {
|
||||
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 {
|
||||
// The events API returns `calendar_id` inconsistently: a raw numeric for
|
||||
// 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.
|
||||
func refreshFromCache(start: Date, end: Date) {
|
||||
_ = (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
|
||||
let key = Self.calendarKey(source: ev.source, calendarId: ev.calendarId)
|
||||
return !hiddenCalendarKeys.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
|
||||
@@ -247,7 +299,7 @@ class CalendarStore {
|
||||
lastError = nil
|
||||
defer { isLoading = false }
|
||||
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)
|
||||
refreshFromCache(start: start, end: end)
|
||||
} 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.
|
||||
func prefetchBackground(api: CalendarrAPI, months: Int) async {
|
||||
let cal = userCalendar
|
||||
@@ -266,7 +353,7 @@ class CalendarStore {
|
||||
isCachingBackground = true
|
||||
defer { isCachingBackground = false }
|
||||
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)
|
||||
// Refresh visible range from newly expanded cache
|
||||
let (vs, ve) = rangeForCurrentView()
|
||||
|
||||
@@ -128,7 +128,6 @@ private let strings: [String: [String: String]] = [
|
||||
"settings.sunday": "Sonntag",
|
||||
"settings.dimpast": "Vergangene Termine ausgrauen",
|
||||
"settings.nav.profile": "Profil",
|
||||
"settings.saved": "Gespeichert",
|
||||
"settings.privacy": "Privatsphäre",
|
||||
"settings.private_visibility": "Private Termine für Gruppen",
|
||||
"settings.private_visibility.desc": "Wie private Termine für andere Gruppenmitglieder erscheinen",
|
||||
@@ -155,6 +154,9 @@ private let strings: [String: [String: String]] = [
|
||||
"common.info": "Info",
|
||||
"common.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",
|
||||
@@ -162,6 +164,7 @@ private let strings: [String: [String: String]] = [
|
||||
"group.name": "Name",
|
||||
"group.icon": "Icon",
|
||||
"group.members": "Mitglieder",
|
||||
"group.calendar": "Gruppenkalender",
|
||||
"group.member_colors": "Farben der Mitglieder",
|
||||
"group.delete": "Gruppe löschen",
|
||||
|
||||
@@ -427,7 +430,6 @@ private let strings: [String: [String: String]] = [
|
||||
"settings.sunday": "Sunday",
|
||||
"settings.dimpast": "Dim past events",
|
||||
"settings.nav.profile": "Profile",
|
||||
"settings.saved": "Saved",
|
||||
"settings.privacy": "Privacy",
|
||||
"settings.private_visibility": "Private events for groups",
|
||||
"settings.private_visibility.desc": "How your private events appear to other group members",
|
||||
@@ -454,6 +456,9 @@ private let strings: [String: [String: String]] = [
|
||||
"common.info": "Info",
|
||||
"common.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",
|
||||
@@ -461,6 +466,7 @@ private let strings: [String: [String: String]] = [
|
||||
"group.name": "Name",
|
||||
"group.icon": "Icon",
|
||||
"group.members": "Members",
|
||||
"group.calendar": "Group calendar",
|
||||
"group.member_colors": "Member colours",
|
||||
"group.delete": "Delete group",
|
||||
|
||||
|
||||
37
Calendarr iOS/Models/ReminderOptions.swift
Normal file
37
Calendarr iOS/Models/ReminderOptions.swift
Normal 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."
|
||||
}
|
||||
}
|
||||
@@ -229,7 +229,7 @@ class CalendarrAPI {
|
||||
|
||||
func createLocalEvent(calendarId: Int, title: String, start: Date, end: Date,
|
||||
isAllDay: Bool, location: String, description: String, color: String?,
|
||||
isPrivate: Bool = false) async throws -> CalEvent {
|
||||
isPrivate: Bool = false, reminders: [Int]? = nil) async throws -> CalEvent {
|
||||
var body: [String: Any] = [
|
||||
"calendar_id": calendarId,
|
||||
"title": title,
|
||||
@@ -241,6 +241,7 @@ class CalendarrAPI {
|
||||
"private": isPrivate
|
||||
]
|
||||
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)
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let ev = CalEvent.from(json: json) else { throw APIError.decodingError }
|
||||
@@ -249,7 +250,7 @@ class CalendarrAPI {
|
||||
|
||||
func updateLocalEvent(uid: String, title: String, start: Date, end: Date,
|
||||
isAllDay: Bool, location: String, description: String, color: String?,
|
||||
isPrivate: Bool = false) async throws {
|
||||
isPrivate: Bool = false, reminders: [Int]? = nil) async throws {
|
||||
var body: [String: Any] = [
|
||||
"title": title,
|
||||
"start": formatISO(start, allDay: isAllDay),
|
||||
@@ -260,6 +261,7 @@ class CalendarrAPI {
|
||||
"private": isPrivate
|
||||
]
|
||||
if let c = color { body["color"] = c }
|
||||
if let reminders { body["reminders"] = reminders }
|
||||
_ = try await request("/api/local/events/\(uid)", method: "PUT", body: body)
|
||||
}
|
||||
|
||||
@@ -400,6 +402,28 @@ class CalendarrAPI {
|
||||
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
|
||||
|
||||
65
Calendarr iOS/Services/NotificationScheduler.swift
Normal file
65
Calendarr iOS/Services/NotificationScheduler.swift
Normal 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: " · ")
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ enum SettingsSync {
|
||||
static let defaultView = "defaultView"
|
||||
static let weekStartDay = "weekStartDay"
|
||||
static let dimPastEvents = "dimPastEvents"
|
||||
static let defaultReminder = "defaultReminderMinutes" // Int, -1 = off
|
||||
// master switch
|
||||
static let enabled = "settingsSync"
|
||||
}
|
||||
@@ -71,6 +72,8 @@ enum SettingsSync {
|
||||
s.defaultView = str(Key.defaultView, "month")
|
||||
s.weekStartDay = str(Key.weekStartDay, "monday")
|
||||
s.dimPastEvents = UserDefaults.standard.bool(forKey: Key.dimPastEvents)
|
||||
let rem = int(Key.defaultReminder, -1)
|
||||
s.defaultReminderMinutes = rem < 0 ? nil : rem
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -84,6 +87,7 @@ enum SettingsSync {
|
||||
d.set(s.defaultView, forKey: Key.defaultView)
|
||||
d.set(s.weekStartDay, forKey: Key.weekStartDay)
|
||||
d.set(s.dimPastEvents, forKey: Key.dimPastEvents)
|
||||
d.set(s.defaultReminderMinutes ?? -1, forKey: Key.defaultReminder)
|
||||
guard includeOptional else { return }
|
||||
// NOTE: textColor / backgroundColor / lineColor are intentionally NOT
|
||||
// synced – the server has no columns for them (iOS-only). Writing the
|
||||
@@ -134,6 +138,7 @@ enum SettingsSync {
|
||||
merged.defaultView = local.defaultView
|
||||
merged.weekStartDay = local.weekStartDay
|
||||
merged.dimPastEvents = local.dimPastEvents
|
||||
merged.defaultReminderMinutes = local.defaultReminderMinutes
|
||||
if isEnabled {
|
||||
merged.primaryColor = local.primaryColor
|
||||
merged.accentColor = local.accentColor
|
||||
|
||||
@@ -131,16 +131,22 @@ struct AccountsView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(caldavAccounts) { acc in
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(Color(hex: acc.color))
|
||||
.frame(width: 12, height: 12)
|
||||
Circle().fill(Color(hex: acc.color)).frame(width: 12, height: 12)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(acc.name).font(.body)
|
||||
Text(acc.url)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
Text(acc.url).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
}
|
||||
ForEach(acc.calendars ?? []) { cal in
|
||||
HStack {
|
||||
CalendarColorDot(hex: cal.color ?? acc.color) { hex in
|
||||
try? await api.setCalendarColor(source: "caldav", calendarId: cal.id, color: hex)
|
||||
}
|
||||
Text(cal.name).font(.callout)
|
||||
}
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,9 +169,9 @@ struct AccountsView: View {
|
||||
} else {
|
||||
ForEach(localCalendars) { cal in
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(Color(hex: cal.color))
|
||||
.frame(width: 12, height: 12)
|
||||
CalendarColorDot(hex: cal.color, editable: cal.owned) { hex in
|
||||
try? await api.updateLocalCalendarColor(id: cal.id, color: hex)
|
||||
}
|
||||
Text(cal.name)
|
||||
if cal.group {
|
||||
Image(systemName: "person.2.fill").font(.caption2).foregroundStyle(.secondary)
|
||||
@@ -213,9 +219,9 @@ struct AccountsView: View {
|
||||
} else {
|
||||
ForEach(icalSubs) { sub in
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(Color(hex: sub.color))
|
||||
.frame(width: 12, height: 12)
|
||||
CalendarColorDot(hex: sub.color) { hex in
|
||||
try? await api.updateICalColor(id: sub.id, color: hex)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(sub.name).font(.body)
|
||||
Text(String(format: L10n.t("accounts.ical.every", appLang), sub.refreshMinutes))
|
||||
@@ -242,11 +248,21 @@ struct AccountsView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(googleAccounts) { acc in
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Image(systemName: "g.circle.fill")
|
||||
.foregroundStyle(.red)
|
||||
Image(systemName: "g.circle.fill").foregroundStyle(.red)
|
||||
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
|
||||
Task { await deleteGoogle(offsets: offsets) }
|
||||
@@ -347,12 +363,20 @@ struct AccountsView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(haAccounts) { acc in
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(acc.name).font(.body)
|
||||
Text(acc.url)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
Text(acc.url).font(.caption).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
|
||||
@@ -682,6 +706,31 @@ struct AddHASheet: View {
|
||||
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]
|
||||
|
||||
@@ -27,16 +27,16 @@ struct CalendarHostView: View {
|
||||
@State private var store = CalendarStore()
|
||||
@State private var editorContext: CalEditorContext? = nil
|
||||
@State private var selectedEvent: CalEvent? = nil
|
||||
@State private var visibleMonth: Date = .now
|
||||
@State private var showFilter = false
|
||||
@State private var didApplyDefaultView = false
|
||||
@State private var groups: [CalGroup] = []
|
||||
|
||||
private var titleString: String {
|
||||
if store.viewType == .month {
|
||||
let f = DateFormatter()
|
||||
f.locale = L10n.locale(appLang)
|
||||
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)
|
||||
}
|
||||
@@ -67,6 +67,7 @@ struct CalendarHostView: View {
|
||||
private var flatVariant: some View {
|
||||
VStack(spacing: 0) {
|
||||
topBar
|
||||
groupBanner
|
||||
Divider()
|
||||
errorBanner
|
||||
calendarContent
|
||||
@@ -83,7 +84,7 @@ struct CalendarHostView: View {
|
||||
.onChange(of: store.currentDate) { _, _ in Task { await onNavigate() } }
|
||||
.onChange(of: store.viewType) { _, _ in Task { await onNavigate() } }
|
||||
.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) } } }
|
||||
.onReceive(NotificationCenter.default.publisher(for: .banishedCalendarsChanged)) { _ in
|
||||
store.syncBanishedFromDefaults()
|
||||
@@ -94,11 +95,19 @@ struct CalendarHostView: View {
|
||||
.onReceive(NotificationCenter.default.publisher(for: .manualSyncRequested)) { _ in
|
||||
Task { await forceReload() }
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .rescheduleReminders)) { _ in
|
||||
store.rescheduleNotifications()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Liquid Glass variant
|
||||
|
||||
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 {
|
||||
calendarContent
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
@@ -107,34 +116,32 @@ struct CalendarHostView: View {
|
||||
loadingIndicator.padding(.top, 12)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.2), value: store.isLoading || store.isCachingBackground)
|
||||
.overlay(alignment: .top) {
|
||||
if let err = store.lastError { errorBannerView(err).padding(.top, 8) }
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
HStack(spacing: 2) {
|
||||
Button { store.navigatePrev() } label: { Image(systemName: "chevron.left") }
|
||||
Button { store.navigateNext() } label: { Image(systemName: "chevron.right") }
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
HStack(spacing: 8) {
|
||||
Button(L10n.t("nav.today", appLang)) { store.moveToToday() }.font(.callout)
|
||||
menuButton
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .principal) {
|
||||
}
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
VStack(spacing: 0) {
|
||||
Text(titleString)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
HStack(spacing: 8) {
|
||||
viewPickerMenu
|
||||
Button { showFilter = true } label: {
|
||||
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") }
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 6)
|
||||
.background(.bar)
|
||||
groupBanner
|
||||
errorBanner
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,7 +151,7 @@ struct CalendarHostView: View {
|
||||
.onChange(of: store.currentDate) { _, _ in Task { await onNavigate() } }
|
||||
.onChange(of: store.viewType) { _, _ in Task { await onNavigate() } }
|
||||
.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) } } }
|
||||
.onReceive(NotificationCenter.default.publisher(for: .banishedCalendarsChanged)) { _ in
|
||||
store.syncBanishedFromDefaults()
|
||||
@@ -155,11 +162,17 @@ struct CalendarHostView: View {
|
||||
.onReceive(NotificationCenter.default.publisher(for: .manualSyncRequested)) { _ in
|
||||
Task { await forceReload() }
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .rescheduleReminders)) { _ in
|
||||
store.rescheduleNotifications()
|
||||
}
|
||||
}
|
||||
|
||||
// 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: 2) {
|
||||
Button { store.navigatePrev() } label: {
|
||||
@@ -172,53 +185,103 @@ struct CalendarHostView: View {
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
Button(L10n.t("nav.today", appLang)) { store.moveToToday() }
|
||||
.font(.callout).padding(.horizontal, 6)
|
||||
}
|
||||
.padding(.leading, 8)
|
||||
Spacer(minLength: 8)
|
||||
.padding(.leading, 6)
|
||||
Spacer(minLength: 6)
|
||||
Text(titleString)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
Spacer(minLength: 8)
|
||||
viewPickerMenu
|
||||
filterButton
|
||||
Button { showMenu = true } label: {
|
||||
Image(systemName: "line.3.horizontal")
|
||||
.font(.system(size: 18, weight: .medium))
|
||||
.frame(width: 40, height: 40)
|
||||
}
|
||||
.padding(.trailing, 4)
|
||||
.layoutPriority(1)
|
||||
Spacer(minLength: 6)
|
||||
Button(L10n.t("nav.today", appLang)) { store.moveToToday() }
|
||||
.font(.callout).padding(.horizontal, 6)
|
||||
.lineLimit(1).fixedSize()
|
||||
menuButton
|
||||
.padding(.trailing, 2)
|
||||
}
|
||||
.frame(height: 48)
|
||||
.background(.bar)
|
||||
}
|
||||
|
||||
private var filterButton: some View {
|
||||
Button { showFilter = true } label: {
|
||||
Image(systemName: "line.3.horizontal.decrease.circle")
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.foregroundStyle(store.hiddenCalendarKeys.isEmpty ? .primary : Color.accentColor)
|
||||
.frame(width: 40, height: 40)
|
||||
}
|
||||
.accessibilityLabel(L10n.t("filter.button", appLang))
|
||||
private var topBar: some View {
|
||||
barContents.background(.bar)
|
||||
}
|
||||
|
||||
private var viewPickerMenu: some View {
|
||||
@ViewBuilder private var groupBanner: some View {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
// View (fixed icon, not per-view)
|
||||
Menu {
|
||||
ForEach(CalViewType.allCases, id: \.self) { vt in
|
||||
Button { store.viewType = vt } label: {
|
||||
Label(vt.label(appLang), systemImage: vt.systemImage)
|
||||
Label(vt.label(appLang), systemImage: store.viewType == vt ? "checkmark" : vt.systemImage)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: store.viewType.systemImage)
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.foregroundStyle(.primary)
|
||||
.frame(width: 40, height: 40)
|
||||
Label(L10n.t("view.change", appLang), systemImage: "rectangle.3.group")
|
||||
}
|
||||
.accessibilityLabel(L10n.t("view.change", appLang))
|
||||
// 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: {
|
||||
Image(systemName: "line.3.horizontal")
|
||||
.font(.system(size: 18, weight: .medium))
|
||||
.foregroundStyle(store.activeGroup == nil && store.hiddenCalendarKeys.isEmpty ? .primary : Color.accentColor)
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
.accessibilityLabel(L10n.t("nav.menu", appLang))
|
||||
}
|
||||
|
||||
// MARK: – Error banner
|
||||
@@ -267,8 +330,7 @@ struct CalendarHostView: View {
|
||||
onShowDay: { day in
|
||||
store.currentDate = day
|
||||
store.viewType = .day
|
||||
},
|
||||
visibleMonth: $visibleMonth)
|
||||
})
|
||||
case .week:
|
||||
WeekView(store: store,
|
||||
onEventTap: { selectedEvent = $0 },
|
||||
@@ -346,12 +408,15 @@ struct CalendarHostView: View {
|
||||
// MARK: – Loading logic
|
||||
|
||||
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
|
||||
// before we compute the initial range and load events.
|
||||
await SettingsSync.pull(api: api)
|
||||
applyServerDrivenSettings(initial: true)
|
||||
|
||||
await store.loadWritableCalendars(api: api)
|
||||
groups = (try? await api.getGroups()) ?? []
|
||||
// 1. Load current view immediately (visible)
|
||||
let (s, e) = store.rangeForCurrentView()
|
||||
await store.loadEvents(api: api, start: s, end: e)
|
||||
|
||||
@@ -10,6 +10,7 @@ struct EventEditorSheet: View {
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@AppStorage("appLanguage") private var appLang = "system"
|
||||
@AppStorage("defaultReminderMinutes") private var defaultReminderMinutes = -1
|
||||
@State private var title = ""
|
||||
@State private var isAllDay = false
|
||||
@State private var startDate = Date()
|
||||
@@ -19,6 +20,7 @@ struct EventEditorSheet: View {
|
||||
@State private var selectedCalendarId: String = ""
|
||||
@State private var color = ""
|
||||
@State private var isPrivate = false
|
||||
@State private var reminders: [Int] = []
|
||||
@State private var isSaving = false
|
||||
@State private var error = ""
|
||||
|
||||
@@ -81,6 +83,27 @@ struct EventEditorSheet: View {
|
||||
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)) {
|
||||
@@ -149,6 +172,7 @@ struct EventEditorSheet: View {
|
||||
notes = ev.notes
|
||||
color = ev.color ?? ""
|
||||
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: "")
|
||||
@@ -167,6 +191,7 @@ struct EventEditorSheet: View {
|
||||
notes = ev.notes
|
||||
color = ev.color ?? ""
|
||||
isPrivate = ev.isPrivate
|
||||
reminders = ev.reminders
|
||||
selectedCalendarId = store.writableCalendars.first?.id ?? ""
|
||||
} else {
|
||||
let cal = Calendar.current
|
||||
@@ -174,6 +199,8 @@ struct EventEditorSheet: View {
|
||||
minute: 0, second: 0, of: initialDate) ?? initialDate
|
||||
endDate = startDate.addingTimeInterval(3600)
|
||||
selectedCalendarId = store.writableCalendars.first?.id ?? ""
|
||||
// New events inherit the user's default reminder (editable).
|
||||
if defaultReminderMinutes >= 0 { reminders = [defaultReminderMinutes] }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,7 +220,7 @@ struct EventEditorSheet: View {
|
||||
case "local":
|
||||
try await api.updateLocalEvent(uid: ev.id, title: title, start: start, end: end,
|
||||
isAllDay: isAllDay, location: location, description: notes, color: colorVal,
|
||||
isPrivate: isPrivate)
|
||||
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: "")
|
||||
@@ -214,7 +241,7 @@ struct EventEditorSheet: View {
|
||||
_ = try await api.createLocalEvent(calendarId: cal.numericId, title: title,
|
||||
start: start, end: end, isAllDay: isAllDay,
|
||||
location: location, description: notes, color: colorVal,
|
||||
isPrivate: isPrivate)
|
||||
isPrivate: isPrivate, reminders: reminders)
|
||||
case "google":
|
||||
try await api.createGoogleEvent(calendarDbId: cal.numericId, title: title,
|
||||
start: start, end: end, isAllDay: isAllDay,
|
||||
|
||||
@@ -19,7 +19,6 @@ struct MonthView: View {
|
||||
let onCreateEvent: (Date) -> Void
|
||||
let onShowWeek: (Date) -> Void
|
||||
let onShowDay: (Date) -> Void
|
||||
@Binding var visibleMonth: Date
|
||||
|
||||
@AppStorage("appLanguage") private var appLang = "system"
|
||||
@AppStorage("monthDividerColor") private var dividerHex = "#7090c0"
|
||||
@@ -117,8 +116,8 @@ struct MonthView: View {
|
||||
private func publishVisibleMonth(from week: Date?) {
|
||||
guard let w = week else { return }
|
||||
let month = cal.date(from: cal.dateComponents([.year, .month], from: w)) ?? w
|
||||
if visibleMonth != month {
|
||||
visibleMonth = month
|
||||
if store.visibleMonth != month {
|
||||
store.visibleMonth = month
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ struct WeekView: View {
|
||||
ForEach(weekDays, id: \.self) { day in
|
||||
Text(headerFmt.string(from: day).uppercased())
|
||||
.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)
|
||||
.overlay(alignment: .trailing) {
|
||||
Rectangle().fill(Color(hex: lineHex).opacity(gridLineOpacity(lineContrast))).frame(width: 0.5)
|
||||
|
||||
@@ -20,12 +20,18 @@ struct CalendarFilterSheet: View {
|
||||
@State private var banished: Set<String> = []
|
||||
/// All non-banished keys discovered during load — used by bulk show/hide.
|
||||
@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 {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if isLoading {
|
||||
ProgressView(L10n.t("filter.loading", appLang))
|
||||
} else if store.activeGroup != nil {
|
||||
groupFilterList
|
||||
} else if allKeys.isEmpty {
|
||||
Text(L10n.t("filter.empty", appLang))
|
||||
.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 {
|
||||
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
|
||||
banished = store.banishedCalendarKeys
|
||||
async let c = (try? await api.getCalDAVAccounts()) ?? []
|
||||
|
||||
@@ -1,5 +1,50 @@
|
||||
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 {
|
||||
@@ -23,7 +68,7 @@ struct GroupsView: View {
|
||||
GroupCombinedView(api: api, group: g)
|
||||
} label: {
|
||||
HStack {
|
||||
Text(g.icon ?? "👥")
|
||||
GroupIconView(icon: g.icon)
|
||||
Text(g.name)
|
||||
Spacer()
|
||||
if let n = g.memberCount {
|
||||
@@ -71,12 +116,12 @@ struct GroupEditSheet: View {
|
||||
@AppStorage("appLanguage") private var appLang = "system"
|
||||
|
||||
@State private var name = ""
|
||||
@State private var icon = "👥"
|
||||
@State private var icon = "people"
|
||||
@State private var directory: [DirectoryUser] = []
|
||||
@State private var selected: Set<Int> = []
|
||||
@State private var error = ""
|
||||
|
||||
private let icons = ["👥", "👨👩👧", "🏠", "❤️", "🧑🤝🧑", "⚽", "🎓", "💼", "🎉", "🐶", "✈️", "🎵", "🍕", "📚", "🌳", "⭐"]
|
||||
private let icons = GroupIcons.keys
|
||||
private let cols = [GridItem(.adaptive(minimum: 46))]
|
||||
|
||||
var body: some View {
|
||||
@@ -88,9 +133,11 @@ struct GroupEditSheet: View {
|
||||
Section(L10n.t("group.icon", appLang)) {
|
||||
LazyVGrid(columns: cols, spacing: 8) {
|
||||
ForEach(icons, id: \.self) { ic in
|
||||
Text(ic).font(.title2)
|
||||
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 }
|
||||
}
|
||||
@@ -144,13 +191,13 @@ struct GroupManageSheet: View {
|
||||
|
||||
@State private var group: CalGroup?
|
||||
@State private var name = ""
|
||||
@State private var icon = "👥"
|
||||
@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 = ["👥", "👨👩👧", "🏠", "❤️", "🧑🤝🧑", "⚽", "🎓", "💼", "🎉", "🐶", "✈️", "🎵", "🍕", "📚", "🌳", "⭐"]
|
||||
private let icons = GroupIcons.keys
|
||||
private let cols = [GridItem(.adaptive(minimum: 46))]
|
||||
|
||||
var body: some View {
|
||||
@@ -162,9 +209,11 @@ struct GroupManageSheet: View {
|
||||
Section(L10n.t("group.icon", appLang)) {
|
||||
LazyVGrid(columns: cols, spacing: 8) {
|
||||
ForEach(icons, id: \.self) { ic in
|
||||
Text(ic).font(.title2)
|
||||
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 }
|
||||
}
|
||||
@@ -218,7 +267,7 @@ struct GroupManageSheet: View {
|
||||
if let g = try? await api.getGroup(id: groupId) {
|
||||
group = g
|
||||
name = g.name
|
||||
icon = g.icon ?? "👥"
|
||||
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 })
|
||||
}
|
||||
@@ -324,7 +373,7 @@ struct GroupCombinedView: View {
|
||||
Text(L10n.t("groups.combined_empty", appLang)).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.navigationTitle("\(group.icon ?? "👥") \(group.name)")
|
||||
.navigationTitle(group.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
@@ -340,13 +389,11 @@ struct GroupCombinedView: View {
|
||||
.task(id: anchor) { await load() }
|
||||
}
|
||||
|
||||
// Prefix others' events with their first name; group events with 👥 + creator.
|
||||
// Prefer the server-decorated title; fall back to a name prefix.
|
||||
private func displayTitle(_ ev: CalEvent) -> String {
|
||||
if let dt = ev.displayTitle, !dt.isEmpty { return dt }
|
||||
let me = UserDefaults.standard.integer(forKey: "userId")
|
||||
if ev.isGroupEvent {
|
||||
if let c = ev.creator, c.id != me { return "👥 \(firstName(c.displayName)): \(ev.title)" }
|
||||
return "👥 \(ev.title)"
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ struct SettingsView: View {
|
||||
@AppStorage("defaultView") private var defaultView = "month"
|
||||
@AppStorage("weekStartDay") private var weekStartDay = "monday"
|
||||
@AppStorage("dimPastEvents") private var dimPastEvents = false
|
||||
@AppStorage("defaultReminderMinutes") private var defaultReminderMinutes = -1
|
||||
|
||||
// Profile chapter (server-backed; loaded on appear).
|
||||
@State private var displayName = ""
|
||||
@@ -37,6 +38,7 @@ struct SettingsView: View {
|
||||
Form {
|
||||
profilSection
|
||||
privatsphaereSection
|
||||
benachrichtigungenSection
|
||||
geteilterKalenderSection
|
||||
liquidGlassSection
|
||||
cacheSection
|
||||
@@ -105,6 +107,27 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Benachrichtigungen
|
||||
|
||||
var benachrichtigungenSection: some View {
|
||||
Section {
|
||||
Picker(ReminderOptions.defaultTitle(appLang), selection: $defaultReminderMinutes) {
|
||||
Text(ReminderOptions.off(appLang)).tag(-1)
|
||||
ForEach(ReminderOptions.all, id: \.self) { m in
|
||||
Text(ReminderOptions.label(m, appLang)).tag(m)
|
||||
}
|
||||
}
|
||||
.onChange(of: defaultReminderMinutes) { _, _ in
|
||||
SettingsSync.push(api: api)
|
||||
NotificationCenter.default.post(name: .rescheduleReminders, object: nil)
|
||||
}
|
||||
} header: {
|
||||
Text(ReminderOptions.sectionTitle(appLang))
|
||||
} footer: {
|
||||
Text(ReminderOptions.defaultFooter(appLang)).font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Privatsphäre
|
||||
|
||||
var privatsphaereSection: some View {
|
||||
|
||||
Reference in New Issue
Block a user