diff --git a/Calendarr iOS/Models/AppSettings.swift b/Calendarr iOS/Models/AppSettings.swift index d7a6962..bbf5248 100644 --- a/Calendarr iOS/Models/AppSettings.swift +++ b/Calendarr iOS/Models/AppSettings.swift @@ -16,6 +16,8 @@ struct AppSettings: Codable { var textColor: String = "#FFFFFF" var backgroundColor: String = "#000000" var lineColor: String = "#3A3A3C" + var privateEventVisibility: String = "busy" // 'hidden' | 'busy' + var groupVisibleCalendarId: Int? = nil enum CodingKeys: String, CodingKey { case defaultView = "default_view" @@ -33,6 +35,8 @@ struct AppSettings: Codable { case textColor = "text_color" case backgroundColor = "background_color" case lineColor = "line_color" + case privateEventVisibility = "private_event_visibility" + case groupVisibleCalendarId = "group_visible_calendar_id" } init() {} @@ -60,6 +64,8 @@ struct AppSettings: Codable { 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) } } @@ -95,6 +101,27 @@ struct LocalCalendar: Codable, Identifiable { 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 { @@ -173,6 +200,7 @@ struct HACalendar: Codable, Identifiable { struct UserProfile: Codable { let id: Int let username: String + var displayName: String? var email: String? let isAdmin: Bool let hasAvatar: Bool @@ -180,12 +208,61 @@ struct UserProfile: Codable { 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) diff --git a/Calendarr iOS/Models/CalEvent.swift b/Calendarr iOS/Models/CalEvent.swift index 195e522..efdbb40 100644 --- a/Calendarr iOS/Models/CalEvent.swift +++ b/Calendarr iOS/Models/CalEvent.swift @@ -1,6 +1,23 @@ 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) + } +} + struct CalEvent: Identifiable, Hashable { let id: String let url: String @@ -15,8 +32,15 @@ struct CalEvent: Identifiable, Hashable { 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 - 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? { guard @@ -49,7 +73,12 @@ struct CalEvent: Identifiable, Hashable { calendarId: json["calendar_id"].map { "\($0)" } ?? "", calendarName: json["calendar_name"] as? String ?? "", 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 } ) } } diff --git a/Calendarr iOS/Models/Localization.swift b/Calendarr iOS/Models/Localization.swift index 0e896ef..c2524cb 100644 --- a/Calendarr iOS/Models/Localization.swift +++ b/Calendarr iOS/Models/Localization.swift @@ -201,6 +201,7 @@ private let strings: [String: [String: String]] = [ // Event editor "event.title_placeholder": "Titel", "event.allday": "Ganztägig", + "event.private": "Privat", "event.start": "Start", "event.end": "Ende", "event.location": "Ort", @@ -462,6 +463,7 @@ private let strings: [String: [String: String]] = [ // Event editor "event.title_placeholder": "Title", "event.allday": "All-day", + "event.private": "Private", "event.start": "Start", "event.end": "End", "event.location": "Location", diff --git a/Calendarr iOS/Services/CalendarrAPI.swift b/Calendarr iOS/Services/CalendarrAPI.swift index a1f53c3..bb6a9ab 100644 --- a/Calendarr iOS/Services/CalendarrAPI.swift +++ b/Calendarr iOS/Services/CalendarrAPI.swift @@ -72,6 +72,9 @@ class CalendarrAPI { throw APIError.decodingError } 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) } @@ -225,7 +228,8 @@ class CalendarrAPI { } 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] = [ "calendar_id": calendarId, "title": title, @@ -233,7 +237,8 @@ class CalendarrAPI { "end": formatISO(end, allDay: isAllDay), "allDay": isAllDay, "location": location, - "description": description + "description": description, + "private": isPrivate ] if let c = color, !c.isEmpty { body["color"] = c } 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, - 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] = [ "title": title, "start": formatISO(start, allDay: isAllDay), "end": formatISO(end, allDay: isAllDay), "allDay": isAllDay, "location": location, - "description": description + "description": description, + "private": isPrivate ] if let c = color { body["color"] = c } _ = try await request("/api/local/events/\(uid)", method: "PUT", body: body) @@ -392,4 +399,134 @@ class CalendarrAPI { _ = try await request(path, method: "PUT", 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") + } } diff --git a/Calendarr iOS/Views/Calendar/EventDetailSheet.swift b/Calendarr iOS/Views/Calendar/EventDetailSheet.swift index eaad07c..2bf8d39 100644 --- a/Calendarr iOS/Views/Calendar/EventDetailSheet.swift +++ b/Calendarr iOS/Views/Calendar/EventDetailSheet.swift @@ -47,6 +47,11 @@ struct EventDetailSheet: View { 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 { NavigationStack { List { @@ -91,6 +96,18 @@ struct EventDetailSheet: View { Text(event.source.capitalized) .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 { diff --git a/Calendarr iOS/Views/Calendar/EventEditorSheet.swift b/Calendarr iOS/Views/Calendar/EventEditorSheet.swift index d411325..1f9b6c8 100644 --- a/Calendarr iOS/Views/Calendar/EventEditorSheet.swift +++ b/Calendarr iOS/Views/Calendar/EventEditorSheet.swift @@ -18,6 +18,7 @@ struct EventEditorSheet: View { @State private var notes = "" @State private var selectedCalendarId: String = "" @State private var color = "" + @State private var isPrivate = false @State private var isSaving = false @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)) { HStack { Text(L10n.t("event.color", appLang)) @@ -140,6 +148,7 @@ struct EventEditorSheet: View { location = ev.location notes = ev.notes color = ev.color ?? "" + isPrivate = ev.isPrivate // HA events use "homeassistant-42" in CalEvent but "ha-42" in WritableCalendar if ev.source == "homeassistant" { let num = ev.calendarId.replacingOccurrences(of: "homeassistant-", with: "") @@ -157,6 +166,7 @@ struct EventEditorSheet: View { location = ev.location notes = ev.notes color = ev.color ?? "" + isPrivate = ev.isPrivate selectedCalendarId = store.writableCalendars.first?.id ?? "" } else { let cal = Calendar.current @@ -182,7 +192,8 @@ struct EventEditorSheet: View { switch ev.source { case "local": 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": // No update API exists – delete the old event and recreate with new data. let rawId = ev.calendarId.replacingOccurrences(of: "homeassistant-", with: "") @@ -202,7 +213,8 @@ struct EventEditorSheet: View { case "local": _ = try await api.createLocalEvent(calendarId: cal.numericId, title: title, start: start, end: end, isAllDay: isAllDay, - location: location, description: notes, color: colorVal) + location: location, description: notes, color: colorVal, + isPrivate: isPrivate) case "google": try await api.createGoogleEvent(calendarDbId: cal.numericId, title: title, start: start, end: end, isAllDay: isAllDay,