Initial Commit

This commit is contained in:
Scarriffle
2026-05-17 08:32:34 +02:00
commit e5529ca653
30 changed files with 4351 additions and 0 deletions

View File

@@ -0,0 +1,165 @@
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"
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"
}
}
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
}
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
enum CodingKeys: String, CodingKey {
case id, name, color, enabled
case entityId = "entity_id"
}
}
struct UserProfile: Codable {
let id: Int
let username: String
var email: String?
let isAdmin: Bool
let hasAvatar: Bool
let totpEnabled: Bool
enum CodingKeys: String, CodingKey {
case id, username, email
case isAdmin = "is_admin"
case hasAvatar = "has_avatar"
case totpEnabled = "totp_enabled"
}
}
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))
}
}

View File

@@ -0,0 +1,115 @@
import Foundation
import SwiftUI
struct CalEvent: Identifiable, Hashable {
let id: String
let url: String
var title: String
var startDate: Date
var endDate: Date
var isAllDay: Bool
var location: String
var notes: String
var color: String?
var calendarId: String
var calendarName: String
var calendarColor: String
var source: String
var effectiveColor: String { color ?? calendarColor }
static func from(json: [String: Any]) -> CalEvent? {
guard
let title = json["title"] as? String,
let startStr = json["start"] as? String,
let endStr = json["end"] as? String
else { return nil }
// id can be String (local UUID) or Int (CalDAV numeric)
let id: String
if let s = json["id"] as? String { id = s }
else if let n = json["id"] as? Int { id = String(n) }
else { return nil }
let isAllDay = json["allDay"] as? Bool ?? false
let startDate = parseDate(startStr, allDay: isAllDay)
let endDate = parseDate(endStr, allDay: isAllDay)
guard let s = startDate, let e = endDate else { return nil }
return CalEvent(
id: id,
url: json["url"] as? String ?? "",
title: title,
startDate: s,
endDate: e,
isAllDay: isAllDay,
location: json["location"] as? String ?? "",
notes: json["description"] as? String ?? "",
color: (json["color"] as? String).flatMap { $0.isEmpty ? nil : $0 },
calendarId: json["calendar_id"].map { "\($0)" } ?? "",
calendarName: json["calendar_name"] as? String ?? "",
calendarColor: json["calendarColor"] as? String ?? "#4285f4",
source: json["source"] as? String ?? "local"
)
}
}
private let isoFull: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return f
}()
private let isoBasic: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime]
return f
}()
private let dateOnly: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
f.timeZone = .current
return f
}()
// Handles all date formats the backend may produce:
// "2026-05-17" "2026-05-17T10:00:00Z" "2026-05-17T10:00:00+02:00"
// "2026-05-17T10:00:00.000Z" "2026-05-17T10:00:00" "2026-05-17 10:00:00+00:00"
private let noTZFormatter: DateFormatter = {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
f.timeZone = TimeZone(abbreviation: "UTC")
return f
}()
private let spaceSepFormatter: DateFormatter = {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.dateFormat = "yyyy-MM-dd HH:mm:ssZ"
return f
}()
func parseDate(_ s: String, allDay: Bool) -> Date? {
let clean = s.trimmingCharacters(in: .whitespaces)
if allDay || (clean.count == 10 && !clean.contains("T")) {
return dateOnly.date(from: String(clean.prefix(10)))
}
// Try each formatter in order of likelihood
if let d = isoFull.date(from: clean) { return d }
if let d = isoBasic.date(from: clean) { return d }
// Python isoformat uses space separator: "2026-05-17 10:00:00+00:00"
if let d = spaceSepFormatter.date(from: clean) { return d }
// No timezone treat as UTC
if let d = noTZFormatter.date(from: String(clean.prefix(19))) { return d }
// Last resort: just parse the date part
return dateOnly.date(from: String(clean.prefix(10)))
}
func formatISO(_ date: Date, allDay: Bool) -> String {
if allDay {
return dateOnly.string(from: date)
}
return isoBasic.string(from: date)
}

View File

@@ -0,0 +1,248 @@
import Foundation
import SwiftUI
enum CalViewType: String, CaseIterable {
case month, week, day, quarter, agenda
var label: String {
switch self {
case .month: return "Monat"
case .week: return "Woche"
case .day: return "Tag"
case .quarter: return "Quartal"
case .agenda: return "Termine"
}
}
var systemImage: String {
switch self {
case .month: return "calendar"
case .week: return "calendar.day.timeline.leading"
case .day: return "sun.max"
case .quarter: return "calendar.badge.clock"
case .agenda: return "list.bullet"
}
}
}
struct WritableCalendar: Identifiable {
let id: String
let name: String
let color: String
let source: String
let numericId: Int
}
@Observable
class CalendarStore {
// Visible state
var events: [CalEvent] = []
var viewType: CalViewType = .month
var currentDate: Date = .now
var isLoading = false
var isCachingBackground = false
var lastError: String? = nil
var weekStartsOnMonday = true
var writableCalendars: [WritableCalendar] = []
// Cache bookkeeping
private var cachedStart: Date? = nil
private var cachedEnd: Date? = nil
private var allCachedEvents: [CalEvent] = []
var userCalendar: Calendar {
var cal = Calendar.current
cal.firstWeekday = weekStartsOnMonday ? 2 : 1
return cal
}
// MARK: Cache helpers
func isCached(start: Date, end: Date) -> Bool {
guard let cs = cachedStart, let ce = cachedEnd else { return false }
return cs <= start && ce >= end
}
/// Fast in-memory refresh of `events` for the current visible range.
/// Call this after navigation without hitting the network.
func refreshFromCache(start: Date, end: Date) {
events = allCachedEvents.filter { ev in
ev.startDate < end && ev.endDate > start
}
}
// MARK: Network loading
/// Load events for a specific range skips network if already cached.
func loadEvents(api: CalendarrAPI, start: Date, end: Date) async {
if isCached(start: start, end: end) {
refreshFromCache(start: start, end: end)
return
}
isLoading = true
lastError = nil
defer { isLoading = false }
do {
let fetched = try await api.fetchEvents(start: start, end: end)
mergeIntoCache(fetched, rangeStart: start, rangeEnd: end)
refreshFromCache(start: start, end: end)
} catch {
lastError = error.localizedDescription
}
}
/// Background prefetch for ±months around today called once on startup.
func prefetchBackground(api: CalendarrAPI, months: Int) async {
let cal = userCalendar
let now = Date()
let start = cal.date(byAdding: .month, value: -months, to: cal.startOfDay(for: now))!
let end = cal.date(byAdding: .month, value: months + 1, to: cal.startOfDay(for: now))!
guard !isCached(start: start, end: end) else { return }
isCachingBackground = true
defer { isCachingBackground = false }
do {
let fetched = try await api.fetchEvents(start: start, end: end)
mergeIntoCache(fetched, rangeStart: start, rangeEnd: end)
// Refresh visible range from newly expanded cache
let (vs, ve) = rangeForCurrentView()
refreshFromCache(start: vs, end: ve)
} catch {
// Background fetch failure is silent
}
}
/// Trigger a full cache reload (e.g. when cache-range setting changes).
func invalidateCache() {
cachedStart = nil
cachedEnd = nil
allCachedEvents = []
events = []
}
private func mergeIntoCache(_ newEvents: [CalEvent], rangeStart: Date, rangeEnd: Date) {
// Remove old events that overlap with the newly fetched range (avoid duplicates)
let retained = allCachedEvents.filter { ev in
ev.startDate >= rangeEnd || ev.endDate <= rangeStart
}
allCachedEvents = retained + newEvents
// Extend cached range
if let cs = cachedStart, let ce = cachedEnd {
cachedStart = min(cs, rangeStart)
cachedEnd = max(ce, rangeEnd)
} else {
cachedStart = rangeStart
cachedEnd = rangeEnd
}
}
// MARK: Writable calendars
func loadWritableCalendars(api: CalendarrAPI) async {
async let localCals = (try? await api.getLocalCalendars()) ?? []
async let caldavAccs = (try? await api.getCalDAVAccounts()) ?? []
async let googleCals = (try? await api.getGoogleCalendars()) ?? []
async let haCals = (try? await api.getHACalendars()) ?? []
var result: [WritableCalendar] = []
for cal in await localCals {
result.append(WritableCalendar(id: "local-\(cal.id)", name: cal.name, color: cal.color, source: "local", numericId: cal.id))
}
for acc in await caldavAccs where acc.enabled {
for cal in acc.calendars ?? [] where cal.enabled {
result.append(WritableCalendar(id: "caldav-\(cal.id)", name: "\(acc.name) \(cal.name)", color: cal.color ?? acc.color, source: "caldav", numericId: cal.id))
}
}
for (email, id, name, color) in await googleCals {
result.append(WritableCalendar(id: "google-\(id)", name: "\(email) \(name)", color: color, source: "google", numericId: id))
}
for (accName, id, name, color) in await haCals {
result.append(WritableCalendar(id: "ha-\(id)", name: "\(accName) \(name)", color: color, source: "homeassistant", numericId: id))
}
writableCalendars = result
}
// MARK: Query helpers
func events(on date: Date) -> [CalEvent] {
let cal = userCalendar
let dayStart = cal.startOfDay(for: date)
let dayEnd = cal.date(byAdding: .day, value: 1, to: dayStart)!
return events.filter { ev in ev.startDate < dayEnd && ev.endDate > dayStart }
.sorted { $0.startDate < $1.startDate }
}
func events(in start: Date, end: Date) -> [CalEvent] {
events.filter { ev in ev.startDate < end && ev.endDate > start }
.sorted { $0.startDate < $1.startDate }
}
// MARK: Navigation
func moveToToday() { currentDate = .now }
func navigatePrev() {
currentDate = userCalendar.date(byAdding: navComponent, value: navAmount * -1, to: currentDate) ?? currentDate
}
func navigateNext() {
currentDate = userCalendar.date(byAdding: navComponent, value: navAmount, to: currentDate) ?? currentDate
}
private var navComponent: Calendar.Component {
switch viewType {
case .week: return .weekOfYear
case .day: return .day
default: return .month
}
}
private var navAmount: Int { viewType == .quarter ? 3 : 1 }
func rangeForCurrentView() -> (Date, Date) {
let cal = userCalendar
switch viewType {
case .month:
let start = cal.date(from: cal.dateComponents([.year, .month], from: currentDate))!
return (cal.date(byAdding: .month, value: -1, to: start)!,
cal.date(byAdding: .month, value: 2, to: start)!)
case .quarter:
let start = cal.date(from: cal.dateComponents([.year, .month], from: currentDate))!
return (start, cal.date(byAdding: .month, value: 4, to: start)!)
case .week:
let weekStart = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: currentDate))!
return (weekStart, cal.date(byAdding: .day, value: 8, to: weekStart)!)
case .day:
let dayStart = cal.startOfDay(for: currentDate)
return (dayStart, cal.date(byAdding: .day, value: 1, to: dayStart)!)
case .agenda:
let start = cal.startOfDay(for: .now)
return (start, cal.date(byAdding: .day, value: 90, to: start)!)
}
}
func titleForCurrentView() -> String {
let cal = userCalendar
let fmt = DateFormatter()
switch viewType {
case .month:
fmt.dateFormat = "MMMM yyyy"
return fmt.string(from: currentDate)
case .quarter:
fmt.dateFormat = "MMM yyyy"
let m3 = cal.date(byAdding: .month, value: 2, to: currentDate) ?? currentDate
return "\(fmt.string(from: currentDate)) \(fmt.string(from: m3))"
case .week:
let weekStart = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: currentDate))!
let weekEnd = cal.date(byAdding: .day, value: 6, to: weekStart)!
fmt.dateFormat = "d. MMM"
let ef = DateFormatter(); ef.dateFormat = "d. MMM yyyy"
return "\(fmt.string(from: weekStart)) \(ef.string(from: weekEnd))"
case .day:
fmt.dateFormat = "EEEE, d. MMMM yyyy"
return fmt.string(from: currentDate)
case .agenda:
return "Termine"
}
}
}