Files
Calendarr-IOS/Calendarr iOS/Models/AppSettings.swift
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

291 lines
10 KiB
Swift

import SwiftUI
struct AppSettings: Codable {
var defaultView: String = "month"
var weekStartDay: String = "monday"
var primaryColor: String = "#4285f4"
var accentColor: String = "#ea4335"
var todayColor: String = "#4285f4"
var dimPastEvents: Bool = false
var textContrast: Int = 3
var lineContrast: Int = 3
var hourHeight: Int = 60
var language: String = "de"
var monthDividerColor: String = "#7090c0"
var monthLabelColor: String = "#7090c0"
var textColor: String = "#FFFFFF"
var backgroundColor: String = "#000000"
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"
case weekStartDay = "week_start_day"
case primaryColor = "primary_color"
case accentColor = "accent_color"
case todayColor = "today_color"
case dimPastEvents = "dim_past_events"
case textContrast = "text_contrast"
case lineContrast = "line_contrast"
case hourHeight = "hour_height"
case language
case monthDividerColor = "month_divider_color"
case monthLabelColor = "month_label_color"
case textColor = "text_color"
case backgroundColor = "background_color"
case lineColor = "line_color"
case privateEventVisibility = "private_event_visibility"
case groupVisibleCalendarId = "group_visible_calendar_id"
case defaultReminderMinutes = "default_reminder_minutes"
}
init() {}
/// Resilient decoding: the server only stores a subset of these fields
/// (e.g. it has no `text_color`/`background_color`/`line_color`, which are
/// iOS-only). Using `decodeIfPresent` with the property defaults means a
/// missing key no longer aborts the whole decode otherwise the entire
/// settings sync silently breaks.
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
let d = AppSettings()
defaultView = try c.decodeIfPresent(String.self, forKey: .defaultView) ?? d.defaultView
weekStartDay = try c.decodeIfPresent(String.self, forKey: .weekStartDay) ?? d.weekStartDay
primaryColor = try c.decodeIfPresent(String.self, forKey: .primaryColor) ?? d.primaryColor
accentColor = try c.decodeIfPresent(String.self, forKey: .accentColor) ?? d.accentColor
todayColor = try c.decodeIfPresent(String.self, forKey: .todayColor) ?? d.todayColor
dimPastEvents = try c.decodeIfPresent(Bool.self, forKey: .dimPastEvents) ?? d.dimPastEvents
textContrast = try c.decodeIfPresent(Int.self, forKey: .textContrast) ?? d.textContrast
lineContrast = try c.decodeIfPresent(Int.self, forKey: .lineContrast) ?? d.lineContrast
hourHeight = try c.decodeIfPresent(Int.self, forKey: .hourHeight) ?? d.hourHeight
language = try c.decodeIfPresent(String.self, forKey: .language) ?? d.language
monthDividerColor = try c.decodeIfPresent(String.self, forKey: .monthDividerColor) ?? d.monthDividerColor
monthLabelColor = try c.decodeIfPresent(String.self, forKey: .monthLabelColor) ?? d.monthLabelColor
textColor = try c.decodeIfPresent(String.self, forKey: .textColor) ?? d.textColor
backgroundColor = try c.decodeIfPresent(String.self, forKey: .backgroundColor) ?? d.backgroundColor
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)
}
}
struct CalDAVAccount: Codable, Identifiable {
let id: Int
var name: String
var url: String
var username: String
var color: String
var enabled: Bool
var calendars: [CalDAVCalendar]?
enum CodingKeys: String, CodingKey {
case id, name, url, username, color, enabled, calendars
}
}
struct CalDAVCalendar: Codable, Identifiable {
let id: Int
var name: String
var color: String?
var enabled: Bool
var sidebarHidden: Bool
enum CodingKeys: String, CodingKey {
case id, name, color, enabled
case sidebarHidden = "sidebar_hidden"
}
}
struct LocalCalendar: Codable, Identifiable {
let id: Int
var name: String
var color: String
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 {
let id: Int
var name: String
var url: String
var color: String
var enabled: Bool
var refreshMinutes: Int
var lastFetched: String?
enum CodingKeys: String, CodingKey {
case id, name, url, color, enabled
case refreshMinutes = "refresh_minutes"
case lastFetched = "last_fetched"
}
}
struct GoogleAccount: Codable, Identifiable {
let id: Int
var email: String
var calendars: [GoogleCalendar]?
}
struct GoogleCalendar: Codable, Identifiable {
let id: Int
var name: String
var color: String?
var enabled: Bool
var sidebarHidden: Bool
enum CodingKeys: String, CodingKey {
case id, name, color, enabled
case sidebarHidden = "sidebar_hidden"
}
}
struct HomeAssistantAccount: Codable, Identifiable {
let id: Int
var name: String
var url: String
var authMethod: String
var calendars: [HACalendar]?
enum CodingKeys: String, CodingKey {
case id, name, url, calendars
case authMethod = "auth_method"
}
}
struct HACalendar: Codable, Identifiable {
let id: Int
var name: String
var entityId: String
var color: String?
var enabled: Bool
var sidebarHidden: Bool
enum CodingKeys: String, CodingKey {
case id, name, color, enabled
case entityId = "entity_id"
case sidebarHidden = "sidebar_hidden"
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(Int.self, forKey: .id)
name = try c.decode(String.self, forKey: .name)
entityId = try c.decodeIfPresent(String.self, forKey: .entityId) ?? ""
color = try c.decodeIfPresent(String.self, forKey: .color)
enabled = try c.decodeIfPresent(Bool.self, forKey: .enabled) ?? true
sidebarHidden = try c.decodeIfPresent(Bool.self, forKey: .sidebarHidden) ?? false
}
}
struct UserProfile: Codable {
let id: Int
let username: String
var displayName: String?
var email: String?
let isAdmin: Bool
let hasAvatar: Bool
let totpEnabled: Bool
enum CodingKeys: String, CodingKey {
case id, username, email
case displayName = "display_name"
case isAdmin = "is_admin"
case hasAvatar = "has_avatar"
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 {
init(hex: String) {
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: hex).scanHexInt64(&int)
let r, g, b: UInt64
switch hex.count {
case 6:
(r, g, b) = ((int >> 16) & 0xFF, (int >> 8) & 0xFF, int & 0xFF)
default:
(r, g, b) = (0, 0, 0)
}
self.init(red: Double(r) / 255, green: Double(g) / 255, blue: Double(b) / 255)
}
func toHex() -> String {
let uiColor = UIColor(self)
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
uiColor.getRed(&r, green: &g, blue: &b, alpha: &a)
return String(format: "#%02X%02X%02X", Int(r * 255), Int(g * 255), Int(b * 255))
}
}