From da2e39911c8d81d3bc1c822ba97bcc1bcdfaa0c7 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 31 May 2026 19:40:41 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20iOS=20Sharing=20+=20iCal=20Import/Expor?= =?UTF-8?q?t=20f=C3=BCr=20lokale=20Kalender?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kalenderverwaltung: pro lokalem Kalender ein Menü mit Teilen (SharingView: Benutzer aus Verzeichnis, read/read_write, entfernbar), Importieren (.ics File Picker) und Exportieren (Share Sheet). Geteilte Kalender mit "geteilt von"-Badge; Gruppenkalender markiert. Co-Authored-By: Claude Opus 4.8 --- Calendarr iOS/Models/Localization.swift | 28 ++++ Calendarr iOS/Views/AccountsView.swift | 177 ++++++++++++++++++++++++ 2 files changed, 205 insertions(+) diff --git a/Calendarr iOS/Models/Localization.swift b/Calendarr iOS/Models/Localization.swift index 166befd..4dfed8f 100644 --- a/Calendarr iOS/Models/Localization.swift +++ b/Calendarr iOS/Models/Localization.swift @@ -140,6 +140,20 @@ private let strings: [String: [String: String]] = [ "group.visible.none": "Keiner", "profile.display_name": "Anzeigename", "profile.login_name": "Login-Name", + "accounts.shared_by": "geteilt von %@", + "share.title": "Teilen", + "share.current": "Aktuelle Freigaben", + "share.none": "Noch nicht geteilt", + "share.add": "Benutzer hinzufügen", + "share.search": "Benutzer suchen…", + "share.permission": "Berechtigung", + "perm.read": "Nur lesen", + "perm.read_write": "Lesen & schreiben", + "ics.import": "Importieren", + "ics.export": "Exportieren", + "ics.import_result": "%d importiert, %d übersprungen", + "common.info": "Info", + "common.done": "Fertig", "settings.hourheight": "Stundenhöhe", "settings.hourheight.desc": "Platz pro Stunde in der Wochen- & Tagesansicht", @@ -415,6 +429,20 @@ private let strings: [String: [String: String]] = [ "group.visible.none": "None", "profile.display_name": "Display name", "profile.login_name": "Login name", + "accounts.shared_by": "shared by %@", + "share.title": "Share", + "share.current": "Current shares", + "share.none": "Not shared yet", + "share.add": "Add user", + "share.search": "Search users…", + "share.permission": "Permission", + "perm.read": "Read only", + "perm.read_write": "Read & write", + "ics.import": "Import", + "ics.export": "Export", + "ics.import_result": "%d imported, %d skipped", + "common.info": "Info", + "common.done": "Done", "settings.hourheight": "Hour height", "settings.hourheight.desc": "Space per hour in week & day view", diff --git a/Calendarr iOS/Views/AccountsView.swift b/Calendarr iOS/Views/AccountsView.swift index ed9033e..3679ba8 100644 --- a/Calendarr iOS/Views/AccountsView.swift +++ b/Calendarr iOS/Views/AccountsView.swift @@ -1,4 +1,5 @@ import SwiftUI +import UniformTypeIdentifiers struct AccountsView: View { let api: CalendarrAPI @@ -16,6 +17,13 @@ struct AccountsView: View { @State private var errorAlert: String? @State private var banishedKeys: Set = CalendarStore.loadBanishedKeys() + // Sharing / import / export + @State private var shareCalId: Int? + @State private var showImporter = false + @State private var importTargetCalId: Int? + @State private var exportDoc: ExportedICS? + @State private var infoMessage: String? + @AppStorage("appLanguage") private var appLang = "system" var body: some View { @@ -65,10 +73,55 @@ struct AccountsView: View { }, message: { Text(errorAlert ?? "") }) + .sheet(item: Binding(get: { shareCalId.map { IdentifiableInt(id: $0) } }, + set: { shareCalId = $0?.id })) { wrap in + SharingView(api: api, calendarId: wrap.id) + } + .sheet(item: $exportDoc) { doc in + ActivityView(items: [doc.url]) + } + .fileImporter(isPresented: $showImporter, + allowedContentTypes: [UTType(filenameExtension: "ics") ?? .data, .data], + allowsMultipleSelection: false) { result in + Task { await handleImport(result) } + } + .alert(L10n.t("common.info", appLang), isPresented: .constant(infoMessage != nil), actions: { + Button(L10n.t("common.ok", appLang)) { infoMessage = nil } + }, message: { Text(infoMessage ?? "") }) } .task { await load() } } + private func exportCalendar(_ cal: LocalCalendar) async { + do { + let data = try await api.exportICS(calendarId: cal.id) + let safe = cal.name.components(separatedBy: CharacterSet.alphanumerics.inverted).joined(separator: "_") + let url = FileManager.default.temporaryDirectory.appendingPathComponent("\(safe.isEmpty ? "calendar" : safe).ics") + try data.write(to: url) + exportDoc = ExportedICS(url: url) + } catch { + errorAlert = error.localizedDescription + } + } + + private func handleImport(_ result: Result<[URL], Error>) async { + guard let calId = importTargetCalId else { return } + switch result { + case .success(let urls): + guard let url = urls.first else { return } + let scoped = url.startAccessingSecurityScopedResource() + defer { if scoped { url.stopAccessingSecurityScopedResource() } } + do { + let r = try await api.importICS(calendarId: calId, fileURL: url) + infoMessage = String(format: L10n.t("ics.import_result", appLang), r.imported, r.skipped) + } catch { + errorAlert = error.localizedDescription + } + case .failure(let err): + errorAlert = err.localizedDescription + } + } + // MARK: – Sections var caldavSection: some View { @@ -114,6 +167,31 @@ struct AccountsView: View { .fill(Color(hex: cal.color)) .frame(width: 12, height: 12) Text(cal.name) + if cal.group { + Image(systemName: "person.2.fill").font(.caption2).foregroundStyle(.secondary) + } + Spacer() + if !cal.owned, let by = cal.sharedBy { + Text(String(format: L10n.t("accounts.shared_by", appLang), by)) + .font(.caption2).foregroundStyle(.secondary) + } + Menu { + if cal.owned && !cal.group { + Button { shareCalId = cal.id } label: { + Label(L10n.t("share.title", appLang), systemImage: "person.crop.circle.badge.plus") + } + } + if cal.owned || cal.permission == "read_write" { + Button { importTargetCalId = cal.id; showImporter = true } label: { + Label(L10n.t("ics.import", appLang), systemImage: "square.and.arrow.down") + } + } + Button { Task { await exportCalendar(cal) } } label: { + Label(L10n.t("ics.export", appLang), systemImage: "square.and.arrow.up") + } + } label: { + Image(systemName: "ellipsis.circle").foregroundStyle(.secondary) + } } } .onDelete { offsets in @@ -598,3 +676,102 @@ struct AddHASheet: View { isLoading = false } } + +// MARK: – Sharing / Import-Export helpers + +struct IdentifiableInt: Identifiable { let id: Int } +struct ExportedICS: Identifiable { let id = UUID(); let url: URL } + +/// Wraps UIActivityViewController so an exported .ics can be shared/saved. +struct ActivityView: UIViewControllerRepresentable { + let items: [Any] + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: items, applicationActivities: nil) + } + func updateUIViewController(_ vc: UIActivityViewController, context: Context) {} +} + +/// Manage who a local calendar is shared with (owner only). +struct SharingView: View { + let api: CalendarrAPI + let calendarId: Int + @Environment(\.dismiss) var dismiss + @AppStorage("appLanguage") private var appLang = "system" + + @State private var shares: [CalendarShare] = [] + @State private var directory: [DirectoryUser] = [] + @State private var search = "" + @State private var permission = "read" + @State private var error = "" + + private var candidates: [DirectoryUser] { + let sharedIds = Set(shares.map { $0.userId }) + return directory.filter { !sharedIds.contains($0.id) && + (search.isEmpty || $0.displayName.localizedCaseInsensitiveContains(search)) } + } + + var body: some View { + NavigationStack { + List { + Section(L10n.t("share.current", appLang)) { + if shares.isEmpty { + Text(L10n.t("share.none", appLang)).foregroundStyle(.secondary) + } else { + ForEach(shares) { s in + HStack { + Text(s.displayName ?? "—") + Spacer() + Text(s.permission == "read_write" + ? L10n.t("perm.read_write", appLang) + : L10n.t("perm.read", appLang)) + .font(.caption).foregroundStyle(.secondary) + } + } + .onDelete { offsets in + Task { await removeShares(offsets) } + } + } + } + Section(L10n.t("share.add", appLang)) { + Picker(L10n.t("share.permission", appLang), selection: $permission) { + Text(L10n.t("perm.read", appLang)).tag("read") + Text(L10n.t("perm.read_write", appLang)).tag("read_write") + } + TextField(L10n.t("share.search", appLang), text: $search) + .autocapitalization(.none) + ForEach(candidates) { u in + Button { Task { await addShare(u.id) } } label: { + HStack { + Text(u.displayName) + Spacer() + Image(systemName: "plus.circle").foregroundStyle(Color.accentColor) + } + } + } + } + if !error.isEmpty { Section { Text(error).foregroundStyle(.red) } } + } + .navigationTitle(L10n.t("share.title", appLang)) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(L10n.t("common.done", appLang)) { dismiss() } + } + } + } + .task { await load() } + } + + private func load() async { + shares = (try? await api.getShares(calendarId: calendarId)) ?? [] + directory = (try? await api.getUserDirectory()) ?? [] + } + private func addShare(_ userId: Int) async { + do { try await api.addShare(calendarId: calendarId, userId: userId, permission: permission); await load() } + catch { self.error = error.localizedDescription } + } + private func removeShares(_ offsets: IndexSet) async { + for i in offsets { try? await api.removeShare(calendarId: calendarId, userId: shares[i].userId) } + await load() + } +}