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 <noreply@anthropic.com>
778 lines
31 KiB
Swift
778 lines
31 KiB
Swift
import SwiftUI
|
||
import UniformTypeIdentifiers
|
||
|
||
struct AccountsView: View {
|
||
let api: CalendarrAPI
|
||
@State private var caldavAccounts: [CalDAVAccount] = []
|
||
@State private var localCalendars: [LocalCalendar] = []
|
||
@State private var icalSubs: [ICalSubscription] = []
|
||
@State private var googleAccounts: [GoogleAccount] = []
|
||
@State private var haAccounts: [HomeAssistantAccount] = []
|
||
@State private var isLoading = true
|
||
|
||
@State private var showAddCalDAV = false
|
||
@State private var showAddLocal = false
|
||
@State private var showAddICal = false
|
||
@State private var showAddHA = false
|
||
@State private var errorAlert: String?
|
||
@State private var banishedKeys: Set<String> = 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 {
|
||
NavigationStack {
|
||
Group {
|
||
if isLoading {
|
||
ProgressView(L10n.t("accounts.loading", appLang))
|
||
} else {
|
||
List {
|
||
if !banishedKeys.isEmpty { banishedSection }
|
||
caldavSection
|
||
localSection
|
||
icalSection
|
||
googleSection
|
||
haSection
|
||
}
|
||
}
|
||
}
|
||
.navigationTitle(L10n.t("accounts.title", appLang))
|
||
.navigationBarTitleDisplayMode(.large)
|
||
.toolbar {
|
||
ToolbarItem(placement: .primaryAction) {
|
||
Menu {
|
||
Button(L10n.t("accounts.add.caldav", appLang)) { showAddCalDAV = true }
|
||
Button(L10n.t("accounts.add.local", appLang)) { showAddLocal = true }
|
||
Button(L10n.t("accounts.add.ical", appLang)) { showAddICal = true }
|
||
Button(L10n.t("accounts.add.ha", appLang)) { showAddHA = true }
|
||
} label: {
|
||
Image(systemName: "plus")
|
||
}
|
||
}
|
||
}
|
||
.sheet(isPresented: $showAddCalDAV) {
|
||
AddCalDAVSheet(api: api) { await load() }
|
||
}
|
||
.sheet(isPresented: $showAddLocal) {
|
||
AddLocalCalSheet(api: api) { await load() }
|
||
}
|
||
.sheet(isPresented: $showAddICal) {
|
||
AddICalSheet(api: api) { await load() }
|
||
}
|
||
.sheet(isPresented: $showAddHA) {
|
||
AddHASheet(api: api) { await load() }
|
||
}
|
||
.alert(L10n.t("common.error", appLang), isPresented: .constant(errorAlert != nil), actions: {
|
||
Button(L10n.t("common.ok", appLang)) { errorAlert = nil }
|
||
}, 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 {
|
||
Section {
|
||
if caldavAccounts.isEmpty {
|
||
Text(L10n.t("accounts.caldav.empty", appLang))
|
||
.foregroundStyle(.secondary)
|
||
} else {
|
||
ForEach(caldavAccounts) { acc in
|
||
HStack {
|
||
Circle()
|
||
.fill(Color(hex: acc.color))
|
||
.frame(width: 12, height: 12)
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(acc.name).font(.body)
|
||
Text(acc.url)
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
.lineLimit(1)
|
||
}
|
||
}
|
||
}
|
||
.onDelete { offsets in
|
||
Task { await deleteCalDAV(offsets: offsets) }
|
||
}
|
||
}
|
||
Button(L10n.t("accounts.caldav.add", appLang)) { showAddCalDAV = true }
|
||
.foregroundStyle(Color.accentColor)
|
||
} header: {
|
||
Text(L10n.t("accounts.caldav.header", appLang))
|
||
}
|
||
}
|
||
|
||
var localSection: some View {
|
||
Section {
|
||
if localCalendars.isEmpty {
|
||
Text(L10n.t("accounts.local.empty", appLang))
|
||
.foregroundStyle(.secondary)
|
||
} else {
|
||
ForEach(localCalendars) { cal in
|
||
HStack {
|
||
Circle()
|
||
.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
|
||
Task { await deleteLocal(offsets: offsets) }
|
||
}
|
||
}
|
||
Button(L10n.t("accounts.local.add", appLang)) { showAddLocal = true }
|
||
.foregroundStyle(Color.accentColor)
|
||
} header: {
|
||
Text(L10n.t("accounts.local.header", appLang))
|
||
}
|
||
}
|
||
|
||
var icalSection: some View {
|
||
Section {
|
||
if icalSubs.isEmpty {
|
||
Text(L10n.t("accounts.ical.empty", appLang))
|
||
.foregroundStyle(.secondary)
|
||
} else {
|
||
ForEach(icalSubs) { sub in
|
||
HStack {
|
||
Circle()
|
||
.fill(Color(hex: sub.color))
|
||
.frame(width: 12, height: 12)
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(sub.name).font(.body)
|
||
Text(String(format: L10n.t("accounts.ical.every", appLang), sub.refreshMinutes))
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
.onDelete { offsets in
|
||
Task { await deleteICal(offsets: offsets) }
|
||
}
|
||
}
|
||
Button(L10n.t("accounts.ical.add", appLang)) { showAddICal = true }
|
||
.foregroundStyle(Color.accentColor)
|
||
} header: {
|
||
Text(L10n.t("accounts.ical.header", appLang))
|
||
}
|
||
}
|
||
|
||
var googleSection: some View {
|
||
Section {
|
||
if googleAccounts.isEmpty {
|
||
Text(L10n.t("accounts.google.empty", appLang))
|
||
.foregroundStyle(.secondary)
|
||
} else {
|
||
ForEach(googleAccounts) { acc in
|
||
HStack {
|
||
Image(systemName: "g.circle.fill")
|
||
.foregroundStyle(.red)
|
||
Text(acc.email)
|
||
}
|
||
}
|
||
.onDelete { offsets in
|
||
Task { await deleteGoogle(offsets: offsets) }
|
||
}
|
||
}
|
||
Text(L10n.t("accounts.google.hint", appLang))
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
} header: {
|
||
Text(L10n.t("accounts.google.header", appLang))
|
||
}
|
||
}
|
||
|
||
var banishedSection: some View {
|
||
Section {
|
||
ForEach(Array(banishedKeys).sorted(), id: \.self) { key in
|
||
let info = resolveBanished(key)
|
||
HStack(spacing: 10) {
|
||
Circle()
|
||
.fill(Color(hex: info.colorHex))
|
||
.frame(width: 12, height: 12)
|
||
.opacity(0.5)
|
||
Text(info.name)
|
||
.foregroundStyle(.secondary)
|
||
Spacer()
|
||
Button(L10n.t("accounts.banished_unhide", appLang)) {
|
||
unbanish(key)
|
||
}
|
||
.font(.callout)
|
||
.foregroundStyle(Color.accentColor)
|
||
}
|
||
}
|
||
} header: {
|
||
Text(L10n.t("accounts.banished_header", appLang))
|
||
}
|
||
}
|
||
|
||
/// Re-show a banished calendar. For server-backed sources this clears the
|
||
/// server's sidebar_hidden (re-enabling the calendar); for local/ical it's
|
||
/// just the local set.
|
||
private func unbanish(_ key: String) {
|
||
banishedKeys.remove(key)
|
||
CalendarStore.saveBanishedKeys(banishedKeys)
|
||
NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil)
|
||
if let parsed = CalendarStore.parseCalendarKey(key),
|
||
CalendarStore.serverManagedSources.contains(parsed.source) {
|
||
// The server excluded this calendar's events while hidden, so they
|
||
// aren't in the cache. Re-enable on the server, then force a refetch
|
||
// so the events actually reappear without a manual sync.
|
||
Task {
|
||
try? await api.setCalendarSidebarHidden(source: parsed.source, calendarId: parsed.id, hidden: false)
|
||
NotificationCenter.default.post(name: .manualSyncRequested, object: nil)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func resolveBanished(_ key: String) -> (name: String, colorHex: String) {
|
||
let parts = key.split(separator: ":", maxSplits: 1).map(String.init)
|
||
guard parts.count == 2, let id = Int(parts[1]) else {
|
||
return (L10n.t("accounts.banished_unknown", appLang), "#888888")
|
||
}
|
||
switch parts[0] {
|
||
case "local":
|
||
if let c = localCalendars.first(where: { $0.id == id }) {
|
||
return (c.name, c.color)
|
||
}
|
||
case "caldav":
|
||
for acc in caldavAccounts {
|
||
if let c = acc.calendars?.first(where: { $0.id == id }) {
|
||
return ("\(acc.name) – \(c.name)", c.color ?? acc.color)
|
||
}
|
||
}
|
||
case "ical":
|
||
if let s = icalSubs.first(where: { $0.id == id }) {
|
||
return (s.name, s.color)
|
||
}
|
||
case "google":
|
||
for acc in googleAccounts {
|
||
if let c = acc.calendars?.first(where: { $0.id == id }) {
|
||
return ("\(acc.email) – \(c.name)", c.color ?? "#4285f4")
|
||
}
|
||
}
|
||
case "homeassistant":
|
||
for acc in haAccounts {
|
||
if let c = acc.calendars?.first(where: { $0.id == id }) {
|
||
return ("\(acc.name) – \(c.name)", c.color ?? "#46bdc6")
|
||
}
|
||
}
|
||
default: break
|
||
}
|
||
return (L10n.t("accounts.banished_unknown", appLang), "#888888")
|
||
}
|
||
|
||
var haSection: some View {
|
||
Section {
|
||
if haAccounts.isEmpty {
|
||
Text(L10n.t("accounts.ha.empty", appLang))
|
||
.foregroundStyle(.secondary)
|
||
} else {
|
||
ForEach(haAccounts) { acc in
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(acc.name).font(.body)
|
||
Text(acc.url)
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
.lineLimit(1)
|
||
}
|
||
}
|
||
.onDelete { offsets in
|
||
Task { await deleteHA(offsets: offsets) }
|
||
}
|
||
}
|
||
Button(L10n.t("accounts.ha.add", appLang)) { showAddHA = true }
|
||
.foregroundStyle(Color.accentColor)
|
||
} header: {
|
||
Text(L10n.t("accounts.ha.header", appLang))
|
||
}
|
||
}
|
||
|
||
// MARK: – Actions
|
||
|
||
private func load() async {
|
||
isLoading = true
|
||
banishedKeys = CalendarStore.loadBanishedKeys()
|
||
async let c = (try? await api.getCalDAVAccounts()) ?? []
|
||
async let l = (try? await api.getLocalCalendars()) ?? []
|
||
async let i = (try? await api.getICalSubscriptions()) ?? []
|
||
async let g = (try? await api.getGoogleAccounts()) ?? []
|
||
async let h = (try? await api.getHomeAssistantAccounts()) ?? []
|
||
(caldavAccounts, localCalendars, icalSubs, googleAccounts, haAccounts) = await (c, l, i, g, h)
|
||
|
||
// Reconcile banished list with the server's sidebar_hidden (server wins
|
||
// for CalDAV/Google/HA; local/ical keep their local state).
|
||
var b = banishedKeys
|
||
func applyServerHidden(_ source: String, _ id: Int, _ hidden: Bool) {
|
||
let key = CalendarStore.calendarKey(source: source, calendarId: "\(id)")
|
||
if hidden { b.insert(key) } else { b.remove(key) }
|
||
}
|
||
for acc in caldavAccounts { for cal in acc.calendars ?? [] { applyServerHidden("caldav", cal.id, cal.sidebarHidden) } }
|
||
for acc in googleAccounts { for cal in acc.calendars ?? [] { applyServerHidden("google", cal.id, cal.sidebarHidden) } }
|
||
for acc in haAccounts { for cal in acc.calendars ?? [] { applyServerHidden("homeassistant", cal.id, cal.sidebarHidden) } }
|
||
if b != banishedKeys {
|
||
banishedKeys = b
|
||
CalendarStore.saveBanishedKeys(b)
|
||
NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil)
|
||
}
|
||
isLoading = false
|
||
}
|
||
|
||
private func deleteCalDAV(offsets: IndexSet) async {
|
||
for i in offsets {
|
||
try? await api.deleteCalDAVAccount(id: caldavAccounts[i].id)
|
||
}
|
||
await load()
|
||
}
|
||
|
||
private func deleteLocal(offsets: IndexSet) async {
|
||
for i in offsets {
|
||
try? await api.deleteLocalCalendar(id: localCalendars[i].id)
|
||
}
|
||
await load()
|
||
}
|
||
|
||
private func deleteICal(offsets: IndexSet) async {
|
||
for i in offsets {
|
||
try? await api.deleteICalSubscription(id: icalSubs[i].id)
|
||
}
|
||
await load()
|
||
}
|
||
|
||
private func deleteGoogle(offsets: IndexSet) async {
|
||
for i in offsets {
|
||
try? await api.deleteGoogleAccount(id: googleAccounts[i].id)
|
||
}
|
||
await load()
|
||
}
|
||
|
||
private func deleteHA(offsets: IndexSet) async {
|
||
for i in offsets {
|
||
try? await api.deleteHomeAssistantAccount(id: haAccounts[i].id)
|
||
}
|
||
await load()
|
||
}
|
||
}
|
||
|
||
// MARK: – Add Sheets
|
||
|
||
struct AddCalDAVSheet: View {
|
||
let api: CalendarrAPI
|
||
let onDone: () async -> Void
|
||
@Environment(\.dismiss) var dismiss
|
||
@AppStorage("appLanguage") private var appLang = "system"
|
||
|
||
@State private var name = ""
|
||
@State private var url = ""
|
||
@State private var username = ""
|
||
@State private var password = ""
|
||
@State private var color = Color(hex: "#4285f4")
|
||
@State private var isLoading = false
|
||
@State private var error = ""
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
Form {
|
||
Section(L10n.t("caldav.section", appLang)) {
|
||
TextField(L10n.t("caldav.display_name", appLang), text: $name)
|
||
TextField(L10n.t("caldav.url", appLang), text: $url)
|
||
.textInputAutocapitalization(.never)
|
||
.autocorrectionDisabled()
|
||
.keyboardType(.URL)
|
||
TextField(L10n.t("caldav.username", appLang), text: $username)
|
||
.textInputAutocapitalization(.never)
|
||
.autocorrectionDisabled()
|
||
SecureField(L10n.t("caldav.password", appLang), text: $password)
|
||
}
|
||
Section(L10n.t("caldav.color_section", appLang)) {
|
||
ColorPicker(L10n.t("caldav.color_label", appLang), selection: $color, supportsOpacity: false)
|
||
}
|
||
if !error.isEmpty {
|
||
Section {
|
||
Text(error).foregroundStyle(.red)
|
||
}
|
||
}
|
||
}
|
||
.navigationTitle(L10n.t("caldav.title", appLang))
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .cancellationAction) {
|
||
Button(L10n.t("common.cancel", appLang)) { dismiss() }
|
||
}
|
||
ToolbarItem(placement: .primaryAction) {
|
||
Button(L10n.t("caldav.connect", appLang)) {
|
||
Task { await save() }
|
||
}
|
||
.bold()
|
||
.disabled(name.isEmpty || url.isEmpty || username.isEmpty || password.isEmpty || isLoading)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func save() async {
|
||
isLoading = true
|
||
error = ""
|
||
do {
|
||
_ = try await api.addCalDAVAccount(name: name, url: url, username: username, password: password, color: color.toHex())
|
||
await onDone()
|
||
dismiss()
|
||
} catch {
|
||
self.error = error.localizedDescription
|
||
}
|
||
isLoading = false
|
||
}
|
||
}
|
||
|
||
struct AddLocalCalSheet: View {
|
||
let api: CalendarrAPI
|
||
let onDone: () async -> Void
|
||
@Environment(\.dismiss) var dismiss
|
||
@AppStorage("appLanguage") private var appLang = "system"
|
||
|
||
@State private var name = ""
|
||
@State private var color = Color(hex: "#34a853")
|
||
@State private var isLoading = false
|
||
@State private var error = ""
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
Form {
|
||
Section {
|
||
TextField(L10n.t("local.name", appLang), text: $name)
|
||
ColorPicker(L10n.t("local.color", appLang), selection: $color, supportsOpacity: false)
|
||
}
|
||
if !error.isEmpty {
|
||
Section { Text(error).foregroundStyle(.red) }
|
||
}
|
||
}
|
||
.navigationTitle(L10n.t("local.title", appLang))
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .cancellationAction) { Button(L10n.t("common.cancel", appLang)) { dismiss() } }
|
||
ToolbarItem(placement: .primaryAction) {
|
||
Button(L10n.t("local.create", appLang)) {
|
||
Task { await save() }
|
||
}
|
||
.bold()
|
||
.disabled(name.isEmpty || isLoading)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func save() async {
|
||
isLoading = true
|
||
do {
|
||
_ = try await api.addLocalCalendar(name: name, color: color.toHex())
|
||
await onDone()
|
||
dismiss()
|
||
} catch { self.error = error.localizedDescription }
|
||
isLoading = false
|
||
}
|
||
}
|
||
|
||
struct AddICalSheet: View {
|
||
let api: CalendarrAPI
|
||
let onDone: () async -> Void
|
||
@Environment(\.dismiss) var dismiss
|
||
@AppStorage("appLanguage") private var appLang = "system"
|
||
|
||
@State private var name = ""
|
||
@State private var url = ""
|
||
@State private var color = Color(hex: "#46bdc6")
|
||
@State private var refreshMinutes = 60
|
||
@State private var isLoading = false
|
||
@State private var error = ""
|
||
|
||
private var refreshOptions: [(Int, String)] {
|
||
[(15, L10n.t("ical.refresh.15m", appLang)),
|
||
(30, L10n.t("ical.refresh.30m", appLang)),
|
||
(60, L10n.t("ical.refresh.1h", appLang)),
|
||
(360, L10n.t("ical.refresh.6h", appLang)),
|
||
(1440, L10n.t("ical.refresh.1d", appLang))]
|
||
}
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
Form {
|
||
Section(L10n.t("ical.subscription", appLang)) {
|
||
TextField(L10n.t("ical.name", appLang), text: $name)
|
||
TextField(L10n.t("ical.url", appLang), text: $url)
|
||
.textInputAutocapitalization(.never)
|
||
.autocorrectionDisabled()
|
||
.keyboardType(.URL)
|
||
ColorPicker(L10n.t("ical.color", appLang), selection: $color, supportsOpacity: false)
|
||
}
|
||
Section(L10n.t("ical.refresh_section", appLang)) {
|
||
Picker(L10n.t("ical.interval", appLang), selection: $refreshMinutes) {
|
||
ForEach(refreshOptions, id: \.0) { opt in
|
||
Text(opt.1).tag(opt.0)
|
||
}
|
||
}
|
||
}
|
||
if !error.isEmpty {
|
||
Section { Text(error).foregroundStyle(.red) }
|
||
}
|
||
}
|
||
.navigationTitle(L10n.t("ical.title", appLang))
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .cancellationAction) { Button(L10n.t("common.cancel", appLang)) { dismiss() } }
|
||
ToolbarItem(placement: .primaryAction) {
|
||
Button(L10n.t("ical.subscribe", appLang)) {
|
||
Task { await save() }
|
||
}
|
||
.bold()
|
||
.disabled(name.isEmpty || url.isEmpty || isLoading)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func save() async {
|
||
isLoading = true
|
||
do {
|
||
_ = try await api.addICalSubscription(name: name, url: url, color: color.toHex(), refreshMinutes: refreshMinutes)
|
||
await onDone()
|
||
dismiss()
|
||
} catch { self.error = error.localizedDescription }
|
||
isLoading = false
|
||
}
|
||
}
|
||
|
||
struct AddHASheet: View {
|
||
let api: CalendarrAPI
|
||
let onDone: () async -> Void
|
||
@Environment(\.dismiss) var dismiss
|
||
@AppStorage("appLanguage") private var appLang = "system"
|
||
|
||
@State private var name = ""
|
||
@State private var url = ""
|
||
@State private var token = ""
|
||
@State private var isLoading = false
|
||
@State private var error = ""
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
Form {
|
||
Section(L10n.t("ha.section", appLang)) {
|
||
TextField(L10n.t("ha.display_name", appLang), text: $name)
|
||
TextField(L10n.t("ha.url_placeholder", appLang), text: $url)
|
||
.textInputAutocapitalization(.never)
|
||
.autocorrectionDisabled()
|
||
.keyboardType(.URL)
|
||
}
|
||
Section(L10n.t("ha.auth_section", appLang)) {
|
||
SecureField(L10n.t("ha.token", appLang), text: $token)
|
||
Text(L10n.t("ha.token_hint", appLang))
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
if !error.isEmpty {
|
||
Section { Text(error).foregroundStyle(.red) }
|
||
}
|
||
}
|
||
.navigationTitle(L10n.t("accounts.add.ha", appLang))
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .cancellationAction) { Button(L10n.t("common.cancel", appLang)) { dismiss() } }
|
||
ToolbarItem(placement: .primaryAction) {
|
||
Button(L10n.t("ha.connect", appLang)) {
|
||
Task { await save() }
|
||
}
|
||
.bold()
|
||
.disabled(name.isEmpty || url.isEmpty || token.isEmpty || isLoading)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func save() async {
|
||
isLoading = true
|
||
do {
|
||
_ = try await api.addHomeAssistantAccount(name: name, url: url, token: token)
|
||
await onDone()
|
||
dismiss()
|
||
} catch { self.error = error.localizedDescription }
|
||
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()
|
||
}
|
||
}
|