Files
Calendarr-IOS/Calendarr iOS/Models/CalEvent.swift
Scarriffle cf990e4279 Add birthday feature (iOS)
- LocalCalendar/CalEvent gain is_birthday + birthday fields; API sends
  rrule/external_uid/birth_year and can create/update birthday calendars
- BirthdaysImporter: mirrors Contacts birthdays into a chosen birthday
  calendar, reconciling by external_uid (leaves manual entries untouched)
- AccountsView: "birthday calendar" toggle + notify-days when creating a
  calendar, and a "Birthdays from Contacts" section (enable + target + sync now)
- FAB long-press menu -> "New birthday" opens a minimal name+date mask
  (BirthdayEditorSheet) targeting birthday calendars, with year-unknown option
- Event bars render display_title (age) and a cake icon via EventLabel
- NSContactsUsageDescription added to both app build configs

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 21:58:31 +02:00

203 lines
7.6 KiB
Swift

import Foundation
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)
}
}
/// A partial-sync failure reported alongside an otherwise-successful
/// `/api/caldav/events` response: one specific calendar didn't sync (e.g.
/// expired credentials) even though it's still enabled. Distinct from a
/// hard fetch failure (`CalendarStore.lastError`) the request itself
/// succeeded, just not every source within it.
struct SyncError: Hashable {
let source: String
let calendarId: String? // nil on older server responses without calendar_id
let name: String
let message: String
static func from(json: [String: Any]) -> SyncError? {
guard
let source = json["source"] as? String,
let name = json["name"] as? String,
let message = json["message"] as? String
else { return nil }
let calendarId = json["calendar_id"].map { "\($0)" }
return SyncError(source: source, calendarId: calendarId, name: name, message: message)
}
}
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 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] = []
// True for events from a calendar shared with the user read-only.
var readOnly: Bool = false
// True for events from a birthday calendar clients show a cake icon and the
// server bakes the age into `displayTitle`.
var isBirthday: Bool = false
// Group view supplies a server-resolved colour; otherwise per-event then calendar colour.
var effectiveColor: String { displayColor ?? color ?? calendarColor }
// Title to render: the server-decorated one (birthday age, group prefix) wins
// over the raw title, which is kept for editing.
var renderTitle: String { displayTitle ?? title }
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",
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)") } ?? [],
readOnly: json["read_only"] as? Bool ?? false,
isBirthday: json["is_birthday"] as? Bool ?? false
)
}
}
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)
}
/// Inline label for an event in a calendar grid: a leading birthday (cake) icon
/// when the event is a birthday, followed by the render title (which carries the
/// server-computed age). Icon and text inherit the surrounding font/colour, so
/// the label matches whatever bar it's dropped into.
struct EventLabel: View {
let event: CalEvent
var body: some View {
if event.isBirthday {
HStack(spacing: 3) {
Image(systemName: "birthday.cake.fill").imageScale(.small)
Text(event.renderTitle)
}
} else {
Text(event.renderTitle)
}
}
}