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>
This commit is contained in:
Scarriffle
2026-05-31 19:32:31 +02:00
parent b1e0cf1fdc
commit e7e4998fb9
6 changed files with 282 additions and 8 deletions

View File

@@ -16,6 +16,8 @@ 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
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case defaultView = "default_view" case defaultView = "default_view"
@@ -33,6 +35,8 @@ 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"
} }
init() {} init() {}
@@ -60,6 +64,8 @@ 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)
} }
} }
@@ -95,6 +101,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 +200,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 +208,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,15 @@ 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
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 +73,12 @@ 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 }
) )
} }
} }

View File

@@ -201,6 +201,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",
@@ -462,6 +463,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",

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) async throws -> CalEvent {
var body: [String: Any] = [ var body: [String: Any] = [
"calendar_id": calendarId, "calendar_id": calendarId,
"title": title, "title": title,
@@ -233,7 +237,8 @@ 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 }
let data = try await request("/api/local/events", method: "POST", body: body) let data = try await request("/api/local/events", method: "POST", body: body)
@@ -243,14 +248,16 @@ 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) 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 }
_ = try await request("/api/local/events/\(uid)", method: "PUT", body: body) _ = try await request("/api/local/events/\(uid)", method: "PUT", body: body)
@@ -392,4 +399,134 @@ 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: 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: 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

@@ -47,6 +47,11 @@ struct EventDetailSheet: View {
private var canDelete: Bool { canEdit } private var canDelete: Bool { canEdit }
private var currentUserId: Int? {
let id = UserDefaults.standard.integer(forKey: "userId")
return id == 0 ? nil : id
}
var body: some View { var body: some View {
NavigationStack { NavigationStack {
List { List {
@@ -91,6 +96,18 @@ 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 { if !store.writableCalendars.isEmpty {

View File

@@ -18,6 +18,7 @@ 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 isSaving = false @State private var isSaving = false
@State private var error = "" @State private var error = ""
@@ -75,6 +76,13 @@ struct EventEditorSheet: View {
} }
} }
if selectedCal?.source == "local" {
Section {
Toggle(L10n.t("event.private", appLang), isOn: $isPrivate)
.tint(Color.accentColor)
}
}
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))
@@ -140,6 +148,7 @@ struct EventEditorSheet: View {
location = ev.location location = ev.location
notes = ev.notes notes = ev.notes
color = ev.color ?? "" color = ev.color ?? ""
isPrivate = ev.isPrivate
// HA events use "homeassistant-42" in CalEvent but "ha-42" in WritableCalendar // HA events use "homeassistant-42" in CalEvent but "ha-42" in WritableCalendar
if ev.source == "homeassistant" { if ev.source == "homeassistant" {
let num = ev.calendarId.replacingOccurrences(of: "homeassistant-", with: "") let num = ev.calendarId.replacingOccurrences(of: "homeassistant-", with: "")
@@ -157,6 +166,7 @@ struct EventEditorSheet: View {
location = ev.location location = ev.location
notes = ev.notes notes = ev.notes
color = ev.color ?? "" color = ev.color ?? ""
isPrivate = ev.isPrivate
selectedCalendarId = store.writableCalendars.first?.id ?? "" selectedCalendarId = store.writableCalendars.first?.id ?? ""
} else { } else {
let cal = Calendar.current let cal = Calendar.current
@@ -182,7 +192,8 @@ struct EventEditorSheet: View {
switch ev.source { switch ev.source {
case "local": 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,
isPrivate: isPrivate)
case "homeassistant": case "homeassistant":
// No update API exists delete the old event and recreate with new data. // No update API exists delete the old event and recreate with new data.
let rawId = ev.calendarId.replacingOccurrences(of: "homeassistant-", with: "") let rawId = ev.calendarId.replacingOccurrences(of: "homeassistant-", with: "")
@@ -202,7 +213,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)
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,