chore(repo): iOS-App als ios/ ins Hauptrepo uebernehmen

Calendarr-IOS wird mit vollstaendiger Historie als Unterverzeichnis
ios/ eingebunden. Damit liegen Server, Web, Android und iOS in einem
gemeinsamen Repo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-14 09:20:12 +02:00
co-authored by Claude Opus 5
107 changed files with 19373 additions and 0 deletions
+922
View File
@@ -0,0 +1,922 @@
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?
// Contacts → birthday-calendar sync (opt-in, bound to one birthday calendar).
@AppStorage("birthdaysSyncEnabled") private var birthdaysSyncEnabled = false
@State private var isSyncingBirthdays = false
@State private var birthdayNotify = -1
@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
birthdayContactsSection
icalSection
googleSection
haSection
}
.onChange(of: birthdaysSyncEnabled) { _, on in
// Enabling only asks for Contacts access — it must NOT create
// the birthday calendar. That's an explicit action.
if on { Task { _ = await BirthdaysImporter.requestAccess() } }
}
}
}
.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 }
// Only one birthday calendar per account.
if birthdayCalendar == nil {
Button(L10n.t("accounts.add.birthday", appLang)) {
Task { await createBirthdayCalendar() }
}
}
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
VStack(alignment: .leading, spacing: 8) {
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)
}
}
ForEach(acc.calendars ?? []) { cal in
HStack {
CalendarColorDot(hex: cal.color ?? acc.color) { hex in
try? await api.setCalendarColor(source: "caldav", calendarId: cal.id, color: hex)
}
Text(cal.name).font(.callout)
}
.padding(.leading, 8)
}
}
}
.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 {
// Recipients of a shared calendar may recolour it (their
// own per-user colour); renaming stays owner-only.
CalendarColorDot(hex: cal.color, editable: true) { hex in
try? await api.updateLocalCalendarColor(id: cal.id, color: hex)
}
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))
}
}
/// The user's single birthday calendar (created on first sync/activation).
private var birthdayCalendar: LocalCalendar? {
localCalendars.first { $0.isBirthday && $0.owned }
}
@ViewBuilder var birthdayContactsSection: some View {
Section {
if let cal = birthdayCalendar {
Toggle(L10n.t("birthday.contacts.sync", appLang), isOn: $birthdaysSyncEnabled)
if birthdaysSyncEnabled {
BirthdayNotifyPicker(days: $birthdayNotify, appLang: appLang)
.onChange(of: birthdayNotify) { _, v in
Task { try? await api.updateLocalCalendarBirthday(id: cal.id, notifyDaysBefore: v) }
}
Button {
Task { await syncBirthdays() }
} label: {
HStack {
Text(L10n.t("birthday.contacts.sync_now", appLang))
if isSyncingBirthdays { Spacer(); ProgressView() }
}
}
.disabled(isSyncingBirthdays)
}
} else {
// No birthday calendar yet — sync has nothing to fill. Create one
// first via the + menu (never auto-created by sync).
Text(L10n.t("birthday.contacts.need_calendar", appLang))
.font(.caption).foregroundStyle(.secondary)
}
} header: {
Text(L10n.t("birthday.contacts.header", appLang))
} footer: {
Text(L10n.t("birthday.contacts.hint", appLang))
}
}
/// Explicitly create the single birthday calendar (from the + menu).
private func createBirthdayCalendar() async {
_ = await BirthdaysImporter.ensureBirthdayCalendar(api: api)
await load()
}
private func syncBirthdays() async {
isSyncingBirthdays = true
defer { isSyncingBirthdays = false }
guard await BirthdaysImporter.requestAccess() else {
errorAlert = L10n.t("birthday.contacts.denied", appLang)
return
}
await BirthdaysImporter.sync(api: api)
await load() // pick up the (possibly newly created) birthday calendar
infoMessage = L10n.t("birthday.contacts.synced", appLang)
// Let the calendar refresh so imported birthdays show up right away.
NotificationCenter.default.post(name: .manualSyncRequested, object: nil)
}
var icalSection: some View {
Section {
if icalSubs.isEmpty {
Text(L10n.t("accounts.ical.empty", appLang))
.foregroundStyle(.secondary)
} else {
ForEach(icalSubs) { sub in
HStack {
CalendarColorDot(hex: sub.color) { hex in
try? await api.updateICalColor(id: sub.id, color: hex)
}
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
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: "g.circle.fill").foregroundStyle(.red)
Text(acc.email)
}
ForEach(acc.calendars ?? []) { cal in
HStack {
CalendarColorDot(hex: cal.color ?? "#4285f4") { hex in
try? await api.setCalendarColor(source: "google", calendarId: cal.id, color: hex)
}
Text(cal.name).font(.callout)
}
.padding(.leading, 8)
}
}
}
.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: 8) {
VStack(alignment: .leading, spacing: 2) {
Text(acc.name).font(.body)
Text(acc.url).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
ForEach(acc.calendars ?? []) { cal in
HStack {
CalendarColorDot(hex: cal.color ?? "#03a9f4") { hex in
try? await api.setCalendarColor(source: "homeassistant", calendarId: cal.id, color: hex)
}
Text(cal.name).font(.callout)
}
.padding(.leading, 8)
}
}
}
.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)
}
// Reflect the birthday calendar's current reminder setting in the picker.
birthdayNotify = birthdayCalendar?.birthdayNotifyDaysBefore ?? -1
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
}
}
/// Reusable "notify N days before" picker for birthday calendars.
/// -1 = off, 0 = on the day, N = N days before.
struct BirthdayNotifyPicker: View {
@Binding var days: Int
let appLang: String
var body: some View {
Picker(L10n.t("birthday.notify", appLang), selection: $days) {
Text(L10n.t("birthday.notify.off", appLang)).tag(-1)
Text(L10n.t("birthday.notify.same_day", appLang)).tag(0)
Text(L10n.t("birthday.notify.one_day", appLang)).tag(1)
ForEach([2, 3, 7], id: \.self) { d in
Text(String(format: L10n.t("birthday.notify.days", appLang), d)).tag(d)
}
}
}
}
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 }
/// A tappable colour swatch (ColorPicker) for a calendar. Persists via `onPick`
/// when the chosen colour changes. Read-only fallback when `editable` is false.
struct CalendarColorDot: View {
let hex: String
var editable: Bool = true
let onPick: (String) async -> Void
@State private var color: Color
init(hex: String, editable: Bool = true, onPick: @escaping (String) async -> Void) {
self.hex = hex; self.editable = editable; self.onPick = onPick
_color = State(initialValue: Color(hex: hex))
}
var body: some View {
if editable {
ColorPicker("", selection: $color, supportsOpacity: false)
.labelsHidden()
.frame(width: 26, height: 26)
.onChange(of: color) { _, c in Task { await onPick(c.toHex()) } }
} else {
Circle().fill(Color(hex: hex)).frame(width: 14, height: 14)
}
}
}
/// 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()
}
}
@@ -0,0 +1,111 @@
import SwiftUI
struct AgendaView: View {
let store: CalendarStore
let onEventTap: (CalEvent) -> Void
@AppStorage("appLanguage") private var appLang = "system"
private var cal: Calendar { store.userCalendar }
private var grouped: [(Date, [CalEvent])] {
let start = cal.startOfDay(for: .now)
let end = cal.date(byAdding: .day, value: 90, to: start)!
var dict: [Date: [CalEvent]] = [:]
for ev in store.events(in: start, end: end) {
let key = cal.startOfDay(for: ev.startDate)
dict[key, default: []].append(ev)
}
return dict.keys.sorted().map { ($0, dict[$0]!.sorted { $0.startDate < $1.startDate }) }
}
private var dayFmt: DateFormatter {
let f = DateFormatter()
f.locale = L10n.locale(appLang)
f.dateFormat = "EEEE, d. MMMM yyyy"
return f
}
private var timeFmt: DateFormatter {
let f = DateFormatter()
f.locale = L10n.locale(appLang)
f.timeStyle = .short
f.dateStyle = .none
return f
}
var body: some View {
if grouped.isEmpty {
ContentUnavailableView(
L10n.t("cal.no_events_title", appLang),
systemImage: "calendar",
description: Text(L10n.t("cal.no_events_body", appLang))
)
} else {
List {
ForEach(grouped, id: \.0) { day, evs in
Section {
ForEach(evs) { ev in
Button { onEventTap(ev) } label: {
AgendaEventRow(event: ev, timeFmt: timeFmt, allDayLabel: L10n.t("cal.allday", appLang))
}
.buttonStyle(.plain)
}
} header: {
Text(dayFmt.string(from: day))
.font(.footnote.weight(.semibold))
.foregroundStyle(cal.isDateInToday(day) ? Color.accentColor : .secondary)
}
}
}
.listStyle(.plain)
}
}
}
private struct AgendaEventRow: View {
let event: CalEvent
let timeFmt: DateFormatter
let allDayLabel: String
var timeString: String {
if event.isAllDay { return allDayLabel }
return timeFmt.string(from: event.startDate)
}
var body: some View {
HStack(spacing: 12) {
RoundedRectangle(cornerRadius: 2)
.fill(Color(hex: event.effectiveColor))
.frame(width: 4, height: 40)
VStack(alignment: .leading, spacing: 3) {
EventLabel(event: event)
.font(.body.weight(.medium))
.foregroundStyle(.primary)
HStack(spacing: 6) {
Text(timeString)
.font(.caption)
.foregroundStyle(.secondary)
if !event.location.isEmpty {
Text("·")
.foregroundStyle(.secondary)
Text(event.location)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Text(event.calendarName)
.font(.caption2)
.foregroundStyle(.tertiary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.vertical, 4)
}
}
@@ -0,0 +1,101 @@
import SwiftUI
/// Minimal "new birthday" mask for the single birthday calendar: enter a name
/// and a date (optionally "year unknown"). Saves an all-day, yearly-recurring
/// local event; the server adds the age suffix and cake icon on read. If no
/// birthday calendar exists yet, offers to activate one.
struct BirthdayEditorSheet: View {
let api: CalendarrAPI
var onDone: () async -> Void
@AppStorage("appLanguage") private var appLang = "system"
@Environment(\.dismiss) private var dismiss
@State private var calendar: LocalCalendar? = nil
@State private var name = ""
@State private var date = Date()
@State private var yearUnknown = false
@State private var loading = true
@State private var saving = false
@State private var activating = false
private var canSave: Bool {
!saving && calendar != nil
&& !name.trimmingCharacters(in: .whitespaces).isEmpty
}
var body: some View {
NavigationStack {
Form {
if loading {
HStack { Spacer(); ProgressView(); Spacer() }
} else if calendar == nil {
Section {
Text(L10n.t("birthday.activate_hint", appLang))
.foregroundStyle(.secondary)
Button(L10n.t("birthday.activate", appLang)) { Task { await activate() } }
.disabled(activating)
}
} else {
Section(L10n.t("birthday.person", appLang)) {
TextField(L10n.t("birthday.person_placeholder", appLang), text: $name)
}
Section(L10n.t("birthday.date", appLang)) {
DatePicker(L10n.t("birthday.date", appLang), selection: $date,
displayedComponents: [.date])
Toggle(L10n.t("birthday.year_unknown", appLang), isOn: $yearUnknown)
}
}
}
.navigationTitle(L10n.t("birthday.new", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(L10n.t("common.cancel", appLang)) { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button(L10n.t("event.save", appLang)) { Task { await save() } }
.disabled(!canSave)
}
}
.task { await load() }
}
}
private func load() async {
calendar = await BirthdaysImporter.birthdayCalendar(api: api)
loading = false
}
private func activate() async {
activating = true
calendar = await BirthdaysImporter.ensureBirthdayCalendar(api: api)
activating = false
}
private func save() async {
guard let cal = calendar else { return }
saving = true
let calc = Calendar.current
let comps = calc.dateComponents([.year, .month, .day], from: date)
let month = comps.month ?? 1
let day = comps.day ?? 1
let year = yearUnknown ? nil : comps.year
// All-day anchor at local noon; anchor year = birth year (else 1970) so
// the yearly rule expands across any queried range.
var anchor = DateComponents()
anchor.year = year ?? 1970
anchor.month = month
anchor.day = day
anchor.hour = 12
let start = calc.date(from: anchor) ?? date
let end = calc.date(byAdding: .day, value: 1, to: start) ?? start
_ = try? await api.createLocalEvent(
calendarId: cal.id, title: name.trimmingCharacters(in: .whitespaces),
start: start, end: end, isAllDay: true, location: "", description: "",
color: nil, rrule: "FREQ=YEARLY", birthYear: year
)
await onDone()
dismiss()
}
}
@@ -0,0 +1,125 @@
import SwiftUI
/// The left side drawer: calendar visibility + group/view switching, plus a
/// single entry into the full menu (Settings/Accounts/Profile/…/Sync/Logout).
struct CalendarDrawer: View {
let api: CalendarrAPI
let store: CalendarStore
let groups: [CalGroup]
let onSwitchGroup: (CalGroup?) -> Void
let onSelectView: (CalViewType) -> Void
let onOpenMenu: () -> Void
let onSync: () -> Void
let onClose: () -> Void
@Environment(AppState.self) private var appState
@AppStorage("appLanguage") private var appLang = "system"
var body: some View {
VStack(spacing: 0) {
header
Divider()
viewSwitcher
if !groups.isEmpty {
Divider()
groupSwitcher
}
Divider()
CalendarFilterContent(api: api, store: store)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.background(Color(.systemBackground))
}
// MARK: – Header
private var header: some View {
HStack(spacing: 14) {
Circle()
.fill(Color.accentColor)
.frame(width: 48, height: 48)
.overlay {
Text(appState.username.prefix(1).uppercased())
.font(.title3.bold()).foregroundStyle(.white)
}
VStack(alignment: .leading, spacing: 2) {
Text(appState.username).font(.title3.weight(.semibold)).lineLimit(1)
Text(appState.serverURL
.replacingOccurrences(of: "https://", with: "")
.replacingOccurrences(of: "http://", with: ""))
.font(.footnote).foregroundStyle(.secondary).lineLimit(1)
}
Spacer()
HStack(spacing: 20) {
Button { onSync() } label: {
Image(systemName: "arrow.triangle.2.circlepath").font(.system(size: 19, weight: .medium))
}
.buttonStyle(.plain).foregroundStyle(Color.accentColor)
.accessibilityLabel(L10n.t("menu.sync", appLang))
Button { onOpenMenu() } label: {
Image(systemName: "gearshape").font(.system(size: 19, weight: .medium))
}
.buttonStyle(.plain).foregroundStyle(Color.accentColor)
.accessibilityLabel(L10n.t("menu.section.settings", appLang))
Button { onClose() } label: {
Image(systemName: "xmark").font(.system(size: 17, weight: .semibold))
}
.buttonStyle(.plain).foregroundStyle(.secondary)
}
}
.padding(.horizontal, 18).padding(.top, 20).padding(.bottom, 16)
}
// MARK: – View switcher
private var viewSwitcher: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(CalViewType.allCases, id: \.self) { vt in
let selected = store.viewType == vt
Button { onSelectView(vt) } label: {
Label(vt.label(appLang), systemImage: vt.systemImage)
.font(.caption.weight(.medium))
.padding(.horizontal, 12).padding(.vertical, 7)
.background(selected ? Color.accentColor : Color(.secondarySystemBackground))
.foregroundStyle(selected ? .white : .primary)
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 16).padding(.vertical, 13)
}
}
// MARK: – Group switcher
private var groupSwitcher: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
chip(label: L10n.t("groups.personal", appLang),
systemImage: "person",
selected: store.activeGroup == nil) { onSwitchGroup(nil) }
ForEach(groups) { g in
chip(label: g.name,
systemImage: GroupIcons.symbol(g.icon),
selected: store.activeGroup?.id == g.id) { onSwitchGroup(g) }
}
}
.padding(.horizontal, 16).padding(.vertical, 13)
}
}
private func chip(label: String, systemImage: String, selected: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Label(label, systemImage: systemImage)
.font(.caption.weight(.medium))
.padding(.horizontal, 12).padding(.vertical, 7)
.background(selected ? Color.accentColor : Color(.secondarySystemBackground))
.foregroundStyle(selected ? .white : .primary)
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
}
@@ -0,0 +1,626 @@
import SwiftUI
private enum CalEditorContext: Identifiable {
case create(Date)
case edit(CalEvent)
var id: String {
switch self {
case .create(let d): return "new-\(d.timeIntervalSince1970)"
case .edit(let ev): return "edit-\(ev.id)"
}
}
}
struct CalendarHostView: View {
let api: CalendarrAPI
@Binding var showMenu: Bool
@AppStorage("liquidGlass") private var liquidGlass = false
@AppStorage("cacheMonths") private var cacheMonths = 3
@AppStorage("appLanguage") private var appLang = "system"
@AppStorage("backgroundColor") private var bgHex = "#000000"
@AppStorage("weekStartDay") private var weekStartDay = "monday"
@AppStorage("defaultView") private var defaultView = "month"
// Opt-in: empty keeps the translucent `.bar` material; a hex tints the top bar.
@AppStorage("surfaceColor") private var surfaceHex = ""
// Device-local: hide the hamburger (drawer then opens only via edge-swipe).
@AppStorage("hideMenuButton") private var hideMenuButton = false
@Environment(\.scenePhase) private var scenePhase
@State private var store = CalendarStore()
@State private var editorContext: CalEditorContext? = nil
@State private var selectedEvent: CalEvent? = nil
@State private var showFilter = false
@State private var didApplyDefaultView = false
@State private var groups: [CalGroup] = []
@State private var showNewBirthday = false
@State private var showDrawer = false
private var titleString: String {
if store.viewType == .month {
let f = DateFormatter()
f.locale = L10n.locale(appLang)
f.dateFormat = "LLLL yyyy"
return f.string(from: store.visibleMonth).capitalized(with: L10n.locale(appLang))
}
return store.titleForCurrentView(language: appLang)
}
var body: some View {
ZStack(alignment: .leading) {
Group {
if liquidGlass { glassVariant } else { flatVariant }
}
// Dim scrim behind the open drawer.
if showDrawer {
Color.black.opacity(0.35)
.ignoresSafeArea()
.transition(.opacity)
.onTapGesture { closeDrawer() }
}
// Off-canvas left drawer.
CalendarDrawer(
api: api, store: store, groups: groups,
onSwitchGroup: { g in closeDrawer(); switchGroup(g) },
onSelectView: { vt in store.viewType = vt; closeDrawer() },
onOpenMenu: { closeDrawer(); showMenu = true },
onSync: { closeDrawer(); Task { await syncFromServer(force: true) } },
onClose: { closeDrawer() }
)
.frame(width: drawerWidth)
.frame(maxHeight: .infinity, alignment: .top)
.shadow(color: .black.opacity(showDrawer ? 0.25 : 0), radius: 12, x: 4)
.offset(x: showDrawer ? 0 : -(drawerWidth + 60))
.animation(.easeInOut(duration: 0.25), value: showDrawer)
// Swipe the drawer toward the left edge to close it.
.simultaneousGesture(
DragGesture(minimumDistance: 20)
.onEnded { v in
if showDrawer, v.translation.width < -45,
abs(v.translation.width) > abs(v.translation.height) {
closeDrawer()
}
}
)
}
// A narrow leading strip opens the drawer via edge-swipe (kept off the
// content area so month paging / week swipe stay free).
.overlay(alignment: .leading) {
if !showDrawer {
Color.clear
.frame(width: 18)
.frame(maxHeight: .infinity)
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 12, coordinateSpace: .local)
.onEnded { v in
if v.translation.width > 45,
abs(v.translation.width) > abs(v.translation.height) {
withAnimation(.easeInOut(duration: 0.25)) { showDrawer = true }
}
}
)
}
}
}
private var drawerWidth: CGFloat { min(UIScreen.main.bounds.width - 40, 360) }
private func closeDrawer() {
withAnimation(.easeInOut(duration: 0.25)) { showDrawer = false }
}
// MARK: – Loading indicator
@ViewBuilder
private var loadingIndicator: some View {
if store.isLoading || store.isCachingBackground {
ProgressView()
.padding(14)
.background(.regularMaterial, in: Circle())
.shadow(color: .black.opacity(0.12), radius: 6, y: 2)
.transition(.opacity.combined(with: .scale(scale: 0.85)))
}
}
// MARK: – Flat variant
private var flatVariant: some View {
VStack(spacing: 0) {
topBar
groupBanner
Divider()
errorBanner
calendarContent
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(hex: bgHex))
.overlay(alignment: .top) {
loadingIndicator.padding(.top, 12)
}
.animation(.easeInOut(duration: 0.2), value: store.isLoading || store.isCachingBackground)
}
.overlay(alignment: .bottomTrailing) { solidFAB }
.modifier(calendarSheets)
.task { await startup() }
.onChange(of: store.currentDate) { _, _ in Task { await onNavigate() } }
.onChange(of: store.viewType) { _, _ in Task { await onNavigate() } }
.onChange(of: cacheMonths) { _, _ in Task { await recache() } }
.onChange(of: store.visibleMonth) { _, new in Task { await ensureLoaded(around: new) } }
.onChange(of: scenePhase) { _, phase in if phase == .active { Task { await syncFromServer() } } }
.onReceive(NotificationCenter.default.publisher(for: .banishedCalendarsChanged)) { _ in
store.syncBanishedFromDefaults()
}
.onReceive(NotificationCenter.default.publisher(for: .settingsDidChange)) { _ in
applyServerDrivenSettings(initial: false)
}
.onReceive(NotificationCenter.default.publisher(for: .manualSyncRequested)) { _ in
Task { await forceReload() }
}
.onReceive(NotificationCenter.default.publisher(for: .rescheduleReminders)) { _ in
store.rescheduleNotifications()
}
}
// MARK: – Liquid Glass variant
private var glassVariant: some View {
// Real iOS-26 Liquid Glass: the system NavigationStack toolbar renders the
// glass bar (buttons). The month TITLE is NOT placed in the toolbar — the
// system title silently fails to refresh on month change on iOS 26 — but
// as a normal inline Text in a top safe-area inset just below the glass
// bar, where it updates reliably (same mechanism as the flat variant).
NavigationStack {
calendarContent
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(hex: bgHex))
.overlay(alignment: .top) {
loadingIndicator.padding(.top, 12)
}
.animation(.easeInOut(duration: 0.2), value: store.isLoading || store.isCachingBackground)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
HStack(spacing: 2) {
if !hideMenuButton { menuButton }
Button { store.navigatePrev() } label: { Image(systemName: "chevron.left") }
Button { store.navigateNext() } label: { Image(systemName: "chevron.right") }
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button(L10n.t("nav.today", appLang)) { store.moveToToday() }.font(.callout)
}
}
.safeAreaInset(edge: .top, spacing: 0) {
VStack(spacing: 0) {
Text(titleString)
.font(.headline)
.lineLimit(1)
.minimumScaleFactor(0.7)
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.background(.bar)
groupBanner
errorBanner
}
}
}
.overlay(alignment: .bottomTrailing) { glassFAB }
.modifier(calendarSheets)
.task { await startup() }
.onChange(of: store.currentDate) { _, _ in Task { await onNavigate() } }
.onChange(of: store.viewType) { _, _ in Task { await onNavigate() } }
.onChange(of: cacheMonths) { _, _ in Task { await recache() } }
.onChange(of: store.visibleMonth) { _, new in Task { await ensureLoaded(around: new) } }
.onChange(of: scenePhase) { _, phase in if phase == .active { Task { await syncFromServer() } } }
.onReceive(NotificationCenter.default.publisher(for: .banishedCalendarsChanged)) { _ in
store.syncBanishedFromDefaults()
}
.onReceive(NotificationCenter.default.publisher(for: .settingsDidChange)) { _ in
applyServerDrivenSettings(initial: false)
}
.onReceive(NotificationCenter.default.publisher(for: .manualSyncRequested)) { _ in
Task { await forceReload() }
}
.onReceive(NotificationCenter.default.publisher(for: .rescheduleReminders)) { _ in
store.rescheduleNotifications()
}
}
// MARK: – Top bar (flat mode)
/// Shared bar contents (chevrons / today / title / group / view / filter / menu).
/// Used by both the flat and the glass top bar so the inline title — which
/// updates reliably on month change — is identical in both modes.
@ViewBuilder private var barContents: some View {
HStack(spacing: 0) {
// Menu (hamburger) on the left — the drawer opens from the left.
if !hideMenuButton { menuButton.padding(.leading, 2) }
HStack(spacing: 2) {
Button { store.navigatePrev() } label: {
Image(systemName: "chevron.left")
.font(.system(size: 17, weight: .medium))
.frame(width: 36, height: 36)
}
Button { store.navigateNext() } label: {
Image(systemName: "chevron.right")
.font(.system(size: 17, weight: .medium))
.frame(width: 36, height: 36)
}
}
.padding(.leading, hideMenuButton ? 6 : 0)
Spacer(minLength: 6)
Text(titleString)
.font(.headline)
.lineLimit(1)
.minimumScaleFactor(0.7)
.layoutPriority(1)
Spacer(minLength: 6)
Button(L10n.t("nav.today", appLang)) { store.moveToToday() }
.font(.callout).padding(.horizontal, 6)
.lineLimit(1).fixedSize()
.padding(.trailing, 4)
}
.frame(height: 48)
}
private var topBar: some View {
barContents.background(
surfaceHex.isEmpty ? AnyShapeStyle(Material.bar) : AnyShapeStyle(Color(hex: surfaceHex))
)
}
@ViewBuilder private var groupBanner: some View {
if let g = store.activeGroup {
HStack(spacing: 6) {
GroupIconView(icon: g.icon).font(.subheadline)
Text("\(L10n.t("groups.view_label", appLang)): \(g.name)")
.font(.subheadline).lineLimit(1)
Spacer()
Button(L10n.t("groups.exit", appLang)) { switchGroup(nil) }
.font(.callout)
}
.padding(.horizontal, 12).padding(.vertical, 7)
.background(Color.accentColor.opacity(0.18))
}
}
private func switchGroup(_ g: CalGroup?) {
store.activeGroup = g
store.hiddenGroupKeys = [] // member visibility is per-group; start fresh
// The cache holds the previous mode's events — drop it and reload the
// visible range + prefetch a wide window so the whole grid is covered.
Task { await forceReload() }
}
/// Opens the side drawer (calendars + groups + navigation). Tinted when a
/// filter/group is active so the user sees state at a glance.
private var menuButton: some View {
Button {
withAnimation(.easeInOut(duration: 0.25)) { showDrawer = true }
} label: {
Image(systemName: "line.3.horizontal")
.font(.system(size: 18, weight: .medium))
.foregroundStyle(store.activeGroup == nil && store.hiddenCalendarKeys.isEmpty ? .primary : Color.accentColor)
.frame(width: 36, height: 36)
}
.accessibilityLabel(L10n.t("nav.menu", appLang))
}
// MARK: – Error banners
// `lastError` (whole fetch failed) and `syncErrors` (fetch succeeded, but
// individual calendars didn't sync) are independent conditions and can
// both be shown at once.
@ViewBuilder private var errorBanner: some View {
if let err = store.lastError { errorBannerView(err) }
if !store.syncErrors.isEmpty { syncErrorBannerView(store.syncErrors) }
}
private func errorBannerView(_ err: String) -> some View {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.yellow)
Text(err).font(.caption).foregroundStyle(.white).lineLimit(2)
Spacer()
Button { Task { await onNavigate() } } label: {
Image(systemName: "arrow.clockwise").foregroundStyle(.white)
}
}
.padding(.horizontal, 12).padding(.vertical, 8)
.background(Color.red.opacity(0.85))
}
/// One or more calendars didn't sync on the last fetch (e.g. expired
/// credentials) even though they're still enabled. Same visual language
/// as `errorBannerView`, joined into a single compact banner.
private func syncErrorBannerView(_ errors: [SyncError]) -> some View {
let text = errors.map { "\($0.source) (\($0.name)): \($0.message)" }.joined(separator: "\n")
return HStack(alignment: .top, spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.yellow)
Text(text).font(.caption).foregroundStyle(.white).lineLimit(errors.count + 1)
Spacer()
Button { Task { await onNavigate() } } label: {
Image(systemName: "arrow.clockwise").foregroundStyle(.white)
}
}
.padding(.horizontal, 12).padding(.vertical, 8)
.background(Color.red.opacity(0.85))
}
// MARK: – Calendar content (with swipe)
@ViewBuilder
private var calendarContent: some View {
let swipe = DragGesture(minimumDistance: 14, coordinateSpace: .local)
.onEnded { val in
let h = val.translation.width
let v = val.translation.height
guard abs(h) > abs(v) * 1.2, abs(h) > 28 else { return }
withAnimation(.easeInOut(duration: 0.2)) {
if h < 0 { store.navigateNext() } else { store.navigatePrev() }
}
}
switch store.viewType {
case .month:
// Month view uses vertical scroll – no horizontal swipe.
MonthView(store: store,
onDayTap: { store.currentDate = $0 },
onEventTap: { selectedEvent = $0 },
onCreateEvent: { day in editorContext = .create(day) },
onShowWeek: { day in
store.currentDate = day
store.viewType = .week
},
onShowDay: { day in
store.currentDate = day
store.viewType = .day
})
case .week:
WeekView(store: store,
onEventTap: { selectedEvent = $0 },
onCreateEvent: { date in editorContext = .create(date) },
onShowMonth: { date in
store.currentDate = date
store.viewType = .month
},
onShowDay: { date in
store.currentDate = date
store.viewType = .day
})
.simultaneousGesture(swipe)
case .day:
DayView(store: store,
onEventTap: { selectedEvent = $0 },
onCreateEvent: { date in editorContext = .create(date) })
.simultaneousGesture(swipe)
case .quarter:
QuarterView(store: store, onEventTap: { selectedEvent = $0 })
.simultaneousGesture(swipe)
case .agenda:
AgendaView(store: store, onEventTap: { selectedEvent = $0 })
}
}
// MARK: – FAB buttons
/// Long-press menu on the create button: a plain new event, or a new birthday
/// (which opens a minimal name + date mask that only targets birthday calendars).
@ViewBuilder private var fabMenu: some View {
Button {
editorContext = .create(.now)
} label: {
Label(L10n.t("event.new_title", appLang), systemImage: "plus")
}
Button {
showNewBirthday = true
} label: {
Label(L10n.t("birthday.new", appLang), systemImage: "birthday.cake.fill")
}
}
/// Standard solid FAB (flat mode)
private var solidFAB: some View {
Button {
editorContext = .create(.now)
} label: {
Image(systemName: "plus")
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 56, height: 56)
.background(Color.accentColor)
.clipShape(Circle())
.shadow(radius: 4, y: 2)
}
.contextMenu { fabMenu }
.padding(.trailing, 20).padding(.bottom, 20)
}
/// Liquid Glass FAB (iOS 26) with glass effect; falls back to solid on older OS
@ViewBuilder
private var glassFAB: some View {
if #available(iOS 26, *) {
Button {
editorContext = .create(.now)
} label: {
Image(systemName: "plus")
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(.primary)
.frame(width: 56, height: 56)
}
.buttonStyle(.plain)
.glassEffect(in: Circle())
.contextMenu { fabMenu }
.padding(.trailing, 20).padding(.bottom, 20)
} else {
solidFAB
}
}
// MARK: – Sheets modifier
private var calendarSheets: CalendarSheets {
CalendarSheets(store: store, editorContext: $editorContext,
selectedEvent: $selectedEvent, showFilter: $showFilter,
showNewBirthday: $showNewBirthday,
api: api,
reload: { await onNavigate() },
reloadForce: { await reloadVisible(force: true) })
}
// MARK: – Loading logic
private func startup() async {
// Ask for notification permission early so reminders can be scheduled.
NotificationScheduler.requestAuthorizationIfNeeded()
// 0. Pull settings first so week-start / default-view are correct
// before we compute the initial range and load events.
await SettingsSync.pull(api: api)
applyServerDrivenSettings(initial: true)
await store.loadWritableCalendars(api: api)
// Reconcile per-calendar visibility with the server BEFORE the first
// load so a calendar hidden/shown on the web is honoured immediately
// (banished set correct before events are filtered).
_ = await store.reconcileCalendarVisibility(api: api)
groups = (try? await api.getGroups()) ?? []
// 1. Load current view immediately (visible)
let (s, e) = store.rangeForCurrentView()
await store.loadEvents(api: api, start: s, end: e)
// 2. Background prefetch for the configured range (non-blocking)
Task(priority: .background) {
await store.prefetchBackground(api: api, months: cacheMonths)
}
// 2b. Mirror Contacts birthdays into the bound birthday calendar, if the
// user enabled it, then refresh so new birthdays appear immediately.
if BirthdaysImporter.isEnabled {
Task(priority: .background) {
await BirthdaysImporter.sync(api: api)
await forceReload()
}
}
// 3. Periodic settings + visibility pull (tied to this .task's lifetime).
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(600))
if Task.isCancelled { break }
await syncFromServer()
}
}
/// Apply the server-driven "always sync" settings to the live store.
/// `weekStartsOnMonday` is applied every time; the default view is applied
/// only once at startup so it never overrides the user's manual switches.
private func applyServerDrivenSettings(initial: Bool) {
store.weekStartsOnMonday = (weekStartDay != "sunday")
if initial, !didApplyDefaultView {
didApplyDefaultView = true
if let vt = CalViewType(rawValue: defaultView) {
store.viewType = vt
}
}
}
/// Called on every navigation – instant if within cache, fetches otherwise.
private func onNavigate() async {
let (s, e) = store.rangeForCurrentView()
await store.loadEvents(api: api, start: s, end: e)
}
/// Re-fetch the visible range. With `force` it bypasses the cache so a just
/// created/edited event shows up immediately (the server is authoritative).
private func reloadVisible(force: Bool) async {
let (s, e) = store.rangeForCurrentView()
await store.loadEvents(api: api, start: s, end: e, force: force)
}
/// Called when cacheMonths setting changes – clear cache and re-prefetch.
private func recache() async {
store.invalidateCache()
await startup()
}
/// Manual sync from the menu: drop the event cache and re-fetch from the
/// server (the periodic loop in `startup()` is untouched, so we don't spawn
/// a second one). Settings were already pulled by the menu action.
private func forceReload() async {
store.invalidateCache()
let (s, e) = store.rangeForCurrentView()
await store.loadEvents(api: api, start: s, end: e)
Task(priority: .background) {
await store.prefetchBackground(api: api, months: cacheMonths)
}
}
/// Pull server-driven settings AND reconcile per-calendar visibility in one
/// step (used on launch, resume and the periodic loop). If the server
/// changed a calendar's `sidebar_hidden` — e.g. hidden/shown on the web or
/// another device — or `force` is set (manual sync), refetch so the change
/// shows up without the user opening the filter sheet.
private func syncFromServer(force: Bool = false) async {
await SettingsSync.pull(api: api)
// Mirror Contacts birthdays on every server sync (manual, resume,
// periodic) — the user expects "sync with server" to include birthdays.
if BirthdaysImporter.isEnabled { await BirthdaysImporter.sync(api: api) }
let changed = await store.reconcileCalendarVisibility(api: api)
if changed || force { await forceReload() }
}
/// Called when the user scrolls into a new month – refreshes the visible range
/// immediately from cache, then fetches on demand if needed.
private func ensureLoaded(around month: Date) async {
let cal = store.userCalendar
let monthStart = cal.date(from: cal.dateComponents([.year, .month], from: month)) ?? month
let s = cal.date(byAdding: .month, value: -1, to: monthStart) ?? monthStart
let e = cal.date(byAdding: .month, value: 2, to: monthStart) ?? monthStart
// Only narrow the visible event set if the requested window is already cached.
// Otherwise keep the current events visible until the network fetch finishes,
// so previous/current/next month events don't disappear temporarily.
if store.isCached(start: s, end: e) {
store.refreshFromCache(start: s, end: e)
}
await store.loadEvents(api: api, start: s, end: e)
}
}
// MARK: – Shared sheet modifier
private struct CalendarSheets: ViewModifier {
let store: CalendarStore
@Binding var editorContext: CalEditorContext?
@Binding var selectedEvent: CalEvent?
@Binding var showFilter: Bool
@Binding var showNewBirthday: Bool
let api: CalendarrAPI
let reload: () async -> Void
let reloadForce: () async -> Void
func body(content: Content) -> some View {
content
.sheet(isPresented: $showNewBirthday) {
BirthdayEditorSheet(api: api) { await reloadForce() }
}
// Use sheet(item:) so the editing event is captured atomically –
// avoiding the race where sheet(isPresented:) evaluates its content
// before the editingEvent state update propagates.
.sheet(item: $editorContext) { ctx in
let editingEv: CalEvent? = { if case .edit(let ev) = ctx { return ev }; return nil }()
let date: Date = { if case .create(let d) = ctx { return d }; return .now }()
EventEditorSheet(api: api, store: store,
initialDate: date, editingEvent: editingEv) {
editorContext = nil
await reloadForce()
}
}
.sheet(item: $selectedEvent) { ev in
EventDetailSheet(event: ev, api: api, store: store) { updated, needsForce in
selectedEvent = nil
if let u = updated { editorContext = .edit(u) }
if needsForce { await reloadForce() } else { await reload() }
}
}
.sheet(isPresented: $showFilter) {
CalendarFilterSheet(api: api, store: store)
}
}
}
@@ -0,0 +1,159 @@
import SwiftUI
struct DayView: View {
let store: CalendarStore
let onEventTap: (CalEvent) -> Void
let onCreateEvent: (Date) -> Void
@AppStorage("appLanguage") private var appLang = "system"
@AppStorage("todayColor") private var todayHex = "#4285f4"
@AppStorage("textColor") private var textHex = "#FFFFFF"
@AppStorage("lineColor") private var lineHex = "#3A3A52"
@AppStorage("textContrast") private var textContrast = 3
@AppStorage("hourHeight") private var hourHeightPref = 60 // observed for live re-layout
private var cal: Calendar { store.userCalendar }
private var allDayEvents: [CalEvent] {
store.events(on: store.currentDate).filter { $0.isAllDay || eventSpansMultipleDays($0) }
}
private var timedEvents: [CalEvent] {
store.events(on: store.currentDate).filter { !$0.isAllDay && !eventSpansMultipleDays($0) }
}
var body: some View {
VStack(spacing: 0) {
if !allDayEvents.isEmpty { allDayStrip }
GeometryReader { geo in
ScrollViewReader { proxy in
ScrollView {
ZStack(alignment: .topLeading) {
// Background grid with per-hour context menus
HStack(alignment: .top, spacing: 0) {
timeLabels
VStack(spacing: 0) {
ForEach(hours, id: \.self) { hour in
DayHourSlot(day: store.currentDate, hour: hour,
hourHeight: hourHeight,
language: appLang,
onCreateEvent: onCreateEvent)
}
}
.frame(width: geo.size.width - timeColumnWidth)
}
// Events
let evWidth = geo.size.width - timeColumnWidth - 2
ForEach(timedEvents) { ev in
Button(action: { onEventTap(ev) }) {
EventBlock(event: ev)
}
.buttonStyle(.plain)
.frame(width: evWidth, height: max(eventHeight(ev), 18))
.offset(x: timeColumnWidth + 1, y: eventTop(ev))
}
// Current time
if cal.isDateInToday(store.currentDate) {
let lineY = nowLineY()
let nowColor = Color(hex: todayHex)
HStack(spacing: 0) {
Spacer().frame(width: timeColumnWidth - 4)
Circle().fill(nowColor).frame(width: 8, height: 8)
Rectangle().fill(nowColor)
.frame(width: geo.size.width - timeColumnWidth - 4, height: 1.5)
}
.offset(y: lineY - 0.75)
}
}
.frame(width: geo.size.width, height: hourHeight * 24 + 80)
.id("grid")
}
.onAppear { scrollToCurrentHour(proxy) }
.onChange(of: store.currentDate) { _, _ in scrollToCurrentHour(proxy) }
}
}
}
}
private var allDayStrip: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(allDayEvents) { ev in
Button(action: { onEventTap(ev) }) {
EventLabel(event: ev)
.font(.caption.weight(.medium))
.foregroundStyle(.white)
.padding(.horizontal, 8).padding(.vertical, 4)
.background(Color(hex: ev.effectiveColor))
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 12).padding(.vertical, 6)
}
.overlay(alignment: .bottom) { Divider() }
}
private var timeLabels: some View {
VStack(spacing: 0) {
ForEach(hours, id: \.self) { h in
ZStack(alignment: .topTrailing) {
Color.clear.frame(height: hourHeight)
Text(String(format: "%02d:00", h))
.font(.system(size: 10))
.foregroundStyle(Color(hex: textHex).opacity(secondaryTextOpacity(textContrast)))
.offset(y: -6)
}
}
Color.clear.frame(height: 80)
}
.frame(width: timeColumnWidth)
}
private func nowLineY() -> CGFloat {
let cal = Calendar.current
let h = CGFloat(cal.component(.hour, from: Date()))
let m = CGFloat(cal.component(.minute, from: Date()))
return h * hourHeight + m * hourHeight / 60
}
private func scrollToCurrentHour(_ proxy: ScrollViewProxy) {
let h = Calendar.current.component(.hour, from: .now)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
withAnimation(.easeOut(duration: 0.3)) {
proxy.scrollTo("grid", anchor: UnitPoint(x: 0, y: CGFloat(max(h - 1, 0)) / 24.0))
}
}
}
}
// One-hour slot for the single-column day view.
private struct DayHourSlot: View {
let day: Date
let hour: Int
let hourHeight: CGFloat
let language: String
let onCreateEvent: (Date) -> Void
@AppStorage("lineColor") private var lineHex = "#3A3A52"
@AppStorage("lineContrast") private var lineContrast = 3
private var date: Date {
Calendar.current.date(bySettingHour: hour, minute: 0, second: 0, of: day) ?? day
}
var body: some View {
VStack(spacing: 0) {
Rectangle().fill(Color(hex: lineHex).opacity(gridLineOpacity(lineContrast))).frame(height: 0.5)
Color.clear.frame(height: hourHeight - 0.5)
}
.contentShape(Rectangle())
.contextMenu {
Button { onCreateEvent(date) } label: {
Label(L10n.t("cal.new_event", language), systemImage: "plus")
}
}
}
}
@@ -0,0 +1,198 @@
import SwiftUI
struct EventDetailSheet: View {
let event: CalEvent
let api: CalendarrAPI
let store: CalendarStore
/// Called when the sheet should close.
/// - `editEvent`: non-nil when the user wants to edit this event
/// - `forceReload`: true when server data changed (create/copy) and the
/// caller must bypass the cache to fetch fresh events
let onDone: (_ editEvent: CalEvent?, _ forceReload: Bool) async -> Void
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
@State private var showDeleteConfirm = false
@State private var isDeleting = false
@State private var showCopySheet = false
private let timeFmt: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .medium
f.timeStyle = .short
return f
}()
private let dateFmt: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .medium
f.timeStyle = .none
return f
}()
private var timeString: String {
if event.isAllDay {
if Calendar.current.isDate(event.startDate, inSameDayAs: event.endDate) ||
event.endDate == event.startDate {
return "\(L10n.t("event.allday", appLang)) · \(dateFmt.string(from: event.startDate))"
}
let end = Calendar.current.date(byAdding: .day, value: -1, to: event.endDate) ?? event.endDate
return "\(L10n.t("event.allday", appLang)) · \(dateFmt.string(from: event.startDate)) – \(dateFmt.string(from: end))"
}
return "\(timeFmt.string(from: event.startDate)) – \(timeFmt.string(from: event.endDate))"
}
private var canEdit: Bool {
event.source == "local" || event.source == "caldav" || event.source == "homeassistant"
}
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 {
Section {
HStack(alignment: .top, spacing: 12) {
RoundedRectangle(cornerRadius: 4)
.fill(Color(hex: event.effectiveColor))
.frame(width: 6, height: 44)
VStack(alignment: .leading, spacing: 4) {
Text(event.renderTitle)
.font(.title3.bold())
Text(event.calendarName)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
}
Section {
Label(timeString, systemImage: "clock")
if !event.location.isEmpty {
Label(event.location, systemImage: "mappin.and.ellipse")
}
if !event.notes.isEmpty {
Label(event.notes, systemImage: "text.alignleft")
}
}
Section {
HStack {
Label(L10n.t("event.calendar_section", appLang), systemImage: "calendar")
Spacer()
Text(event.calendarName)
.foregroundStyle(.secondary)
}
HStack {
Label(L10n.t("detail.source", appLang), systemImage: "server.rack")
Spacer()
Text(event.source.capitalized)
.foregroundStyle(.secondary)
}
if let creator = event.creator, creator.id != currentUserId {
HStack {
Label(L10n.t("detail.created_by", appLang), systemImage: "person")
Spacer()
Text(creator.displayName)
.foregroundStyle(.secondary)
}
}
if event.isPrivate {
Label(L10n.t("event.private", appLang), systemImage: "lock")
.foregroundStyle(.secondary)
}
}
if !store.writableCalendars.isEmpty {
Section {
Button {
showCopySheet = true
} label: {
Label(L10n.t("event.copy_title", appLang), systemImage: "doc.on.doc")
}
}
}
if canDelete {
Section {
Button(role: .destructive) {
showDeleteConfirm = true
} label: {
Label(L10n.t("detail.delete", appLang), systemImage: "trash")
.foregroundStyle(.red)
}
.disabled(isDeleting)
}
}
}
.listStyle(.insetGrouped)
.navigationTitle(L10n.t("detail.title", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(L10n.t("common.close", appLang)) {
Task { await onDone(nil, false) }
}
}
if canEdit {
ToolbarItem(placement: .primaryAction) {
Button(L10n.t("detail.edit", appLang)) {
Task { await onDone(event, false) }
}
}
}
}
.alert(L10n.t("detail.delete_confirm_title", appLang), isPresented: $showDeleteConfirm) {
Button(L10n.t("common.delete", appLang), role: .destructive) {
Task { await deleteEvent() }
}
Button(L10n.t("common.cancel", appLang), role: .cancel) {}
} message: {
Text("\"\(event.renderTitle)\" \(L10n.t("detail.delete_msg_suffix", appLang))")
}
.sheet(isPresented: $showCopySheet) {
EventEditorSheet(
api: api,
store: store,
initialDate: event.startDate,
editingEvent: nil,
copyFrom: event
) {
// Copy created a new server-side event → force reload so it appears
await onDone(nil, true)
}
}
}
}
private func deleteEvent() async {
isDeleting = true
do {
switch event.source {
case "local":
try await api.deleteLocalEvent(uid: event.id)
case "homeassistant":
// calendarId looks like "homeassistant-42" → numeric DB id 42
let calId = Int(event.calendarId.replacingOccurrences(of: "homeassistant-", with: "")) ?? 0
try await api.deleteHAEvent(calendarId: calId, uid: event.id)
default:
let calId = Int(event.calendarId)
try await api.deleteCalDAVEvent(uid: event.id, url: event.url, calendarId: calId)
}
// Optimistically drop it from the cache so it vanishes immediately,
// regardless of how long the source takes to propagate the delete.
store.removeCachedEvent(id: event.id)
await onDone(nil, false)
} catch {
isDeleting = false
}
}
}
@@ -0,0 +1,327 @@
import SwiftUI
struct EventEditorSheet: View {
let api: CalendarrAPI
let store: CalendarStore
let initialDate: Date
let editingEvent: CalEvent?
var copyFrom: CalEvent? = nil
let onSaved: () async -> Void
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
@AppStorage("defaultReminderMinutes") private var defaultReminderMinutes = -1
@AppStorage("defaultEventDurationMinutes") private var defaultEventDurationMinutes = 60
@State private var title = ""
@State private var isAllDay = false
@State private var startDate = Date()
@State private var endDate = Date().addingTimeInterval(3600)
@State private var location = ""
@State private var notes = ""
@State private var selectedCalendarId: String = ""
@State private var color = ""
@State private var isPrivate = false
@State private var reminders: [Int] = []
@State private var isSaving = false
@State private var error = ""
private var isEditing: Bool { editingEvent != nil }
private var isCopying: Bool { copyFrom != nil && editingEvent == nil }
private var selectedCal: WritableCalendar? {
store.writableCalendars.first { $0.id == selectedCalendarId }
}
/// True when the selected calendar has its reminders muted: we keep any
/// existing reminders on the event (never delete them) but grey out the
/// controls and explain that they won't fire.
private var remindersDisabled: Bool {
guard let cal = selectedCal else { return false }
let key = CalendarStore.calendarKey(source: cal.source, calendarId: String(cal.numericId))
return store.reminderDisabledKeys.contains(key)
}
var body: some View {
NavigationStack {
Form {
Section {
TextField(L10n.t("event.title_placeholder", appLang), text: $title)
.font(.body.weight(.medium))
}
Section {
Toggle(L10n.t("event.allday", appLang), isOn: $isAllDay.animation())
.tint(Color.accentColor)
if isAllDay {
DatePicker(L10n.t("event.start", appLang), selection: $startDate, displayedComponents: .date)
DatePicker(L10n.t("event.end", appLang), selection: $endDate, displayedComponents: .date)
} else {
DatePicker(L10n.t("event.start", appLang), selection: $startDate)
DatePicker(L10n.t("event.end", appLang), selection: $endDate)
}
}
Section {
TextField(L10n.t("event.location", appLang), text: $location)
TextField(L10n.t("event.description", appLang), text: $notes, axis: .vertical)
.lineLimit(3...6)
}
Section(L10n.t("event.calendar_section", appLang)) {
if store.writableCalendars.isEmpty {
Text(L10n.t("event.no_writable", appLang))
.foregroundStyle(.secondary)
.font(.callout)
} else {
Picker(L10n.t("event.calendar_picker", appLang), selection: $selectedCalendarId) {
ForEach(store.writableCalendars) { cal in
HStack {
Circle()
.fill(Color(hex: cal.color))
.frame(width: 10, height: 10)
Text(cal.name)
}
.tag(cal.id)
}
}
}
}
if selectedCal?.source == "local" {
Section {
Toggle(L10n.t("event.private", appLang), isOn: $isPrivate)
.tint(Color.accentColor)
}
Section {
ForEach(reminders.indices, id: \.self) { idx in
ReminderEditRow(minutes: $reminders[idx], appLang: appLang)
}
.onDelete { reminders.remove(atOffsets: $0) }
Button {
let next = ReminderOptions.presets.first { !reminders.contains($0) }
?? ReminderOptions.customDefault
reminders.append(next)
} label: {
Label(ReminderOptions.addLabel(appLang), systemImage: "bell.badge.plus")
}
} header: {
Text(ReminderOptions.sectionTitle(appLang))
} footer: {
if remindersDisabled {
Text(ReminderOptions.disabledNote(appLang)).foregroundStyle(.orange)
}
}
.disabled(remindersDisabled)
}
Section(L10n.t("event.color_section", appLang)) {
HStack {
Text(L10n.t("event.color", appLang))
Spacer()
ColorPicker("", selection: Binding(
get: { Color(hex: color.isEmpty ? (selectedCal?.color ?? "#4285f4") : color) },
set: { color = $0.toHex() }
), supportsOpacity: false)
.labelsHidden()
if !color.isEmpty {
Button(L10n.t("event.reset_color", appLang)) { color = "" }
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
if !error.isEmpty {
Section {
Text(error).foregroundStyle(.red)
}
}
}
.navigationTitle(
isEditing ? L10n.t("event.edit_title", appLang) :
isCopying ? L10n.t("event.copy_title", appLang) :
L10n.t("event.new_title", appLang)
)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(L10n.t("common.cancel", appLang)) { dismiss() }
}
ToolbarItem(placement: .primaryAction) {
Button(isEditing
? L10n.t("event.save", appLang)
: L10n.t("event.add", appLang)) {
Task { await save() }
}
.bold()
.disabled(title.isEmpty || selectedCalendarId.isEmpty || isSaving)
}
}
}
.onAppear { setup() }
.onChange(of: startDate) { oldStart, newStart in
guard newStart >= endDate else { return }
let duration = endDate.timeIntervalSince(oldStart)
let minDuration: TimeInterval = isAllDay ? 86400 : 3600
endDate = newStart.addingTimeInterval(max(duration, minDuration))
}
}
private func setup() {
if let ev = editingEvent {
title = ev.title
isAllDay = ev.isAllDay
startDate = ev.startDate
// All-day end dates are stored as exclusive (day after last); subtract 1 for the picker.
endDate = ev.isAllDay
? Calendar.current.date(byAdding: .day, value: -1, to: ev.endDate) ?? ev.endDate
: ev.endDate
location = ev.location
notes = ev.notes
color = ev.color ?? ""
isPrivate = ev.isPrivate
reminders = ev.reminders
// HA events use "homeassistant-42" in CalEvent but "ha-42" in WritableCalendar
if ev.source == "homeassistant" {
let num = ev.calendarId.replacingOccurrences(of: "homeassistant-", with: "")
selectedCalendarId = "ha-\(num)"
} else {
selectedCalendarId = ev.calendarId
}
} else if let ev = copyFrom {
title = ev.title
isAllDay = ev.isAllDay
startDate = ev.startDate
endDate = ev.isAllDay
? Calendar.current.date(byAdding: .day, value: -1, to: ev.endDate) ?? ev.endDate
: ev.endDate
location = ev.location
notes = ev.notes
color = ev.color ?? ""
isPrivate = ev.isPrivate
reminders = ev.reminders
selectedCalendarId = store.writableCalendars.first?.id ?? ""
} else {
let cal = Calendar.current
startDate = cal.date(bySettingHour: cal.component(.hour, from: initialDate),
minute: 0, second: 0, of: initialDate) ?? initialDate
let durMin = defaultEventDurationMinutes > 0 ? defaultEventDurationMinutes : 60
endDate = startDate.addingTimeInterval(Double(durMin) * 60)
selectedCalendarId = store.writableCalendars.first?.id ?? ""
// New events inherit the user's default reminder (editable).
if defaultReminderMinutes >= 0 { reminders = [defaultReminderMinutes] }
}
}
private func save() async {
guard let cal = selectedCal else { return }
isSaving = true
error = ""
defer { isSaving = false }
let colorVal: String? = color.isEmpty ? nil : color
let start = isAllDay ? Calendar.current.startOfDay(for: startDate) : startDate
let end = isAllDay ? Calendar.current.startOfDay(for: Calendar.current.date(byAdding: .day, value: 1, to: endDate)!) : endDate
do {
if let ev = editingEvent {
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,
isPrivate: isPrivate, reminders: reminders)
case "homeassistant":
// No update API exists – delete the old event and recreate with new data.
let rawId = ev.calendarId.replacingOccurrences(of: "homeassistant-", with: "")
let haCalId = Int(rawId) ?? 0
try await api.deleteHAEvent(calendarId: haCalId, uid: ev.id)
try await api.createHAEvent(calendarId: haCalId, title: title,
start: start, end: end, isAllDay: isAllDay,
location: location, description: notes)
default: // caldav
let calId = Int(ev.calendarId)
try await api.updateCalDAVEvent(uid: ev.id, url: ev.url, calendarId: calId,
title: title, start: start, end: end, isAllDay: isAllDay,
location: location, description: notes, color: colorVal)
}
} else {
switch cal.source {
case "local":
_ = try await api.createLocalEvent(calendarId: cal.numericId, title: title,
start: start, end: end, isAllDay: isAllDay,
location: location, description: notes, color: colorVal,
isPrivate: isPrivate, reminders: reminders)
case "google":
try await api.createGoogleEvent(calendarDbId: cal.numericId, title: title,
start: start, end: end, isAllDay: isAllDay,
location: location, description: notes)
case "homeassistant":
try await api.createHAEvent(calendarId: cal.numericId, title: title,
start: start, end: end, isAllDay: isAllDay,
location: location, description: notes)
default: // caldav
try await api.createCalDAVEvent(calendarId: cal.numericId, title: title,
start: start, end: end, isAllDay: isAllDay,
location: location, description: notes, color: colorVal)
}
}
await onSaved()
dismiss()
} catch {
self.error = error.localizedDescription
}
}
}
/// One reminder row: a preset picker plus, when "Custom" is chosen, a number
/// stepper and a unit picker. The value is always stored as minutes-before-start.
private struct ReminderEditRow: View {
@Binding var minutes: Int
let appLang: String
private let customTag = -1
private var isPreset: Bool { ReminderOptions.presets.contains(minutes) }
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Picker("", selection: Binding(
get: { isPreset ? minutes : customTag },
set: { newVal in
if newVal == customTag {
if ReminderOptions.presets.contains(minutes) { minutes = ReminderOptions.customDefault }
} else {
minutes = newVal
}
}
)) {
ForEach(ReminderOptions.presets, id: \.self) { Text(ReminderOptions.label($0, appLang)).tag($0) }
Text(ReminderOptions.customLabel(appLang)).tag(customTag)
}
.labelsHidden()
if !isPreset {
HStack {
Stepper(value: Binding(
get: { ReminderOptions.split(minutes).value },
set: { minutes = max(1, $0) * ReminderOptions.split(minutes).unit.mult }
), in: 1...999) {
Text("\(ReminderOptions.split(minutes).value)")
.monospacedDigit()
}
Picker("", selection: Binding(
get: { ReminderOptions.split(minutes).unit },
set: { newUnit in minutes = ReminderOptions.split(minutes).value * newUnit.mult }
)) {
ForEach(ReminderOptions.Unit.allCases) { u in
Text(ReminderOptions.unitLabel(u, appLang)).tag(u)
}
}
.labelsHidden()
Text(ReminderOptions.beforeLabel(appLang)).foregroundStyle(.secondary)
}
}
}
}
}
@@ -0,0 +1,619 @@
import SwiftUI
// Cover ~10 years back and ~77 years ahead, so the scrollable range goes well
// past 2100 — enough room for any vacation that's actually getting planned.
private let weeksBack = 520
private let weeksAhead = 4000
// Paged mode uses one page per month over a bounded range (keeps the TabView light).
private let monthsBack = 120
private let monthsAhead = 600
private let weekdayHeaderHeight: CGFloat = 28
private let dayNumberRowHeight: CGFloat = 22
private let laneHeight: CGFloat = 16
private let laneSpacing: CGFloat = 2
private let maxLanesPerWeek = 5
private enum DividerEdge { case none, topHighlight, bottomHighlight }
struct MonthView: View {
let store: CalendarStore
let onDayTap: (Date) -> Void
let onEventTap: (CalEvent) -> Void
let onCreateEvent: (Date) -> Void
let onShowWeek: (Date) -> Void
let onShowDay: (Date) -> Void
@AppStorage("appLanguage") private var appLang = "system"
@AppStorage("monthDividerColor") private var dividerHex = "#7090c0"
@AppStorage("monthLabelColor") private var labelHex = "#7090c0"
@AppStorage("textColor") private var textHex = "#FFFFFF"
@AppStorage("lineColor") private var lineHex = "#3A3A52"
@AppStorage("textContrast") private var textContrast = 3
@AppStorage("monthViewPaged") private var monthPaged = false
@State private var scrolledWeek: Date? = nil
@State private var didInitialScroll = false
@State private var pagedMonth: Date = Calendar.current.date(
from: Calendar.current.dateComponents([.year, .month], from: .now)) ?? .now
private var cal: Calendar { store.userCalendar }
/// First day of the month containing `date`.
private func monthStart(for date: Date) -> Date {
cal.date(from: cal.dateComponents([.year, .month], from: date)) ?? date
}
/// One entry per month across the bounded paged range.
private var monthStarts: [Date] {
let base = monthStart(for: .now)
return (-monthsBack...monthsAhead).compactMap {
cal.date(byAdding: .month, value: $0, to: base)
}
}
private var weekStarts: [Date] {
let today = cal.startOfDay(for: .now)
let thisWeek = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: today))!
return (-weeksBack...weeksAhead).compactMap {
cal.date(byAdding: .weekOfYear, value: $0, to: thisWeek)
}
}
private var weekdayHeaders: [String] {
let fmt = DateFormatter(); fmt.locale = L10n.locale(appLang)
let symbols = fmt.shortWeekdaySymbols ?? cal.shortWeekdaySymbols
let start = cal.firstWeekday - 1
return (0..<7).map { i in String(symbols[(start + i) % 7].prefix(2)) }
}
var body: some View {
VStack(spacing: 0) {
headerRow
Divider()
if monthPaged { pagedBody } else { scrollBody }
}
}
// Continuous vertical scroll feed (default).
private var scrollBody: some View {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(weekStarts, id: \.self) { ws in
weekRow(for: ws, fillHeight: false)
.id(ws)
}
}
.scrollTargetLayout()
}
.scrollIndicators(.hidden)
.scrollPosition(id: $scrolledWeek, anchor: .top)
.onAppear {
if !didInitialScroll {
didInitialScroll = true
scrolledWeek = weekStart(for: store.currentDate)
publishVisibleMonth(from: scrolledWeek)
}
}
.onChange(of: store.currentDate) { _, newDate in
let target = weekStart(for: newDate)
if scrolledWeek != target {
withAnimation(.easeInOut(duration: 0.25)) {
scrolledWeek = target
}
}
}
.onChange(of: scrolledWeek) { _, newWeek in
publishVisibleMonth(from: newWeek)
}
}
// One month per page, swipe left/right to change month.
private var pagedBody: some View {
TabView(selection: $pagedMonth) {
ForEach(monthStarts, id: \.self) { m in
monthGrid(for: m).tag(m)
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
.onAppear {
pagedMonth = monthStart(for: store.currentDate)
if store.visibleMonth != pagedMonth { store.visibleMonth = pagedMonth }
}
.onChange(of: pagedMonth) { _, m in
if store.visibleMonth != m { store.visibleMonth = m }
// Drive the shared date so events for the new month load; guard the
// month so we don't fight the reverse onChange below.
if monthStart(for: store.currentDate) != m { store.currentDate = m }
}
.onChange(of: store.currentDate) { _, d in
let m = monthStart(for: d)
if pagedMonth != m { withAnimation(.easeInOut(duration: 0.25)) { pagedMonth = m } }
}
}
/// A single month page: six height-filling week rows.
private func monthGrid(for month: Date) -> some View {
let firstWeek = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: month)) ?? month
let weeks = (0..<6).compactMap { cal.date(byAdding: .weekOfYear, value: $0, to: firstWeek) }
return VStack(spacing: 0) {
ForEach(weeks, id: \.self) { ws in
weekRow(for: ws, fillHeight: true)
}
}
}
private func weekRow(for ws: Date, fillHeight: Bool) -> some View {
WeekRow(weekStart: ws,
fillHeight: fillHeight,
store: store,
dividerColor: Color(hex: dividerHex),
labelColor: Color(hex: labelHex),
textColor: Color(hex: textHex),
lineColor: Color(hex: lineHex),
language: appLang,
onDayTap: onDayTap,
onEventTap: onEventTap,
onCreateEvent: onCreateEvent,
onShowWeek: onShowWeek,
onShowDay: onShowDay)
}
private var headerRow: some View {
HStack(spacing: 0) {
ForEach(weekdayHeaders, id: \.self) { d in
Text(d)
.font(.caption2.weight(.semibold))
.foregroundStyle(Color(hex: textHex).opacity(secondaryTextOpacity(textContrast)))
.frame(maxWidth: .infinity, minHeight: weekdayHeaderHeight)
}
}
}
private func weekStart(for date: Date) -> Date {
cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: date))!
}
/// Determine the header month from the currently-scrolled week.
/// Rule: take the month of the topmost visible week's start day. This
/// means as long as the "1." of the next month is still visible in the
/// top row, the header keeps showing the previous month – and only flips
/// to the new month once its "1." has scrolled out of view above.
private func publishVisibleMonth(from week: Date?) {
guard let w = week else { return }
let month = cal.date(from: cal.dateComponents([.year, .month], from: w)) ?? w
if store.visibleMonth != month {
store.visibleMonth = month
}
}
}
// MARK: – Week Row
private struct WeekRow: View {
let weekStart: Date
// Scroll mode uses a fixed row height; paged mode fills the page evenly.
var fillHeight: Bool = false
let store: CalendarStore
let dividerColor: Color
let labelColor: Color
let textColor: Color
let lineColor: Color
let language: String
let onDayTap: (Date) -> Void
let onEventTap: (CalEvent) -> Void
let onCreateEvent: (Date) -> Void
let onShowWeek: (Date) -> Void
let onShowDay: (Date) -> Void
private var cal: Calendar { store.userCalendar }
private var days: [Date] {
(0..<7).compactMap { cal.date(byAdding: .day, value: $0, to: weekStart) }
}
private var weekNumber: Int { cal.component(.weekOfYear, from: weekStart) }
private func columnRange(for ev: CalEvent) -> (startCol: Int, span: Int) {
let weekEnd = cal.date(byAdding: .day, value: 7, to: weekStart)!
let evStart = max(cal.startOfDay(for: ev.startDate), weekStart)
// All-day end is already exclusive; timed end-of-day-on-same-day shouldn't add a column.
let rawEnd: Date
if ev.isAllDay {
rawEnd = ev.endDate
} else {
// Treat timed events as occupying days from start up to and including the day of end.
rawEnd = cal.date(byAdding: .day, value: 1, to: cal.startOfDay(for: ev.endDate))!
}
let evEnd = min(rawEnd, weekEnd)
let sc = max(0, cal.dateComponents([.day], from: weekStart, to: evStart).day ?? 0)
let lastIncl = (cal.dateComponents([.day], from: weekStart, to: evEnd).day ?? 0) - 1
let ec = min(6, lastIncl)
return (sc, max(1, ec - sc + 1))
}
/// Greedy lane packing for events overlapping this week.
private func packEvents() -> (placed: [(event: CalEvent, lane: Int, startCol: Int, span: Int)],
extraPerCol: [Int]) {
let weekEndExclusive = cal.date(byAdding: .day, value: 7, to: weekStart)!
let evs = store.events(in: weekStart, end: weekEndExclusive)
.sorted { a, b in
if a.startDate != b.startDate { return a.startDate < b.startDate }
return a.endDate > b.endDate
}
var laneLastEnd: [Int] = []
var placed: [(CalEvent, Int, Int, Int)] = []
var overflowPerCol = [Int](repeating: 0, count: 7)
for ev in evs {
let (sc, sp) = columnRange(for: ev)
var assigned: Int? = nil
for laneIdx in 0..<laneLastEnd.count {
if laneLastEnd[laneIdx] < sc {
laneLastEnd[laneIdx] = sc + sp - 1
assigned = laneIdx
break
}
}
if assigned == nil {
if laneLastEnd.count < maxLanesPerWeek {
laneLastEnd.append(sc + sp - 1)
assigned = laneLastEnd.count - 1
}
}
if let lane = assigned {
placed.append((ev, lane, sc, sp))
} else {
for c in sc...min(6, sc + sp - 1) {
overflowPerCol[c] += 1
}
}
}
return (placed.map { (event: $0.0, lane: $0.1, startCol: $0.2, span: $0.3) },
overflowPerCol)
}
var body: some View {
let weekEndExclusive = cal.date(byAdding: .day, value: 7, to: weekStart)!
let (placed, extras) = packEvents()
let allWeekEvents = store.events(in: weekStart, end: weekEndExclusive)
let rowHeight = dayNumberRowHeight + CGFloat(maxLanesPerWeek) * (laneHeight + laneSpacing) + 16
let mondayIdx = days.firstIndex(where: { cal.component(.weekday, from: $0) == 2 }) ?? 0
// Where in this row does a new month start? (col 1...6 = mid-row step; nil = no step)
let midRowBoundaryCol: Int? = {
for idx in 1..<7 where cal.component(.day, from: days[idx]) == 1 { return idx }
return nil
}()
let rowStartsNewMonth = cal.component(.day, from: days[0]) == 1
GeometryReader { geo in
let cellW = geo.size.width / 7
ZStack(alignment: .topLeading) {
HStack(spacing: 0) {
ForEach(Array(days.enumerated()), id: \.offset) { idx, day in
let edge: DividerEdge = {
if let b = midRowBoundaryCol {
return idx < b ? .bottomHighlight : .topHighlight
}
return rowStartsNewMonth ? .topHighlight : .none
}()
let dayStart = cal.startOfDay(for: day)
let dayEnd = cal.date(byAdding: .day, value: 1, to: dayStart)!
let eventsForDay = allWeekEvents.filter {
$0.startDate < dayEnd && $0.endDate > dayStart
}
DayCell(date: day,
isToday: cal.isDateInToday(day),
monthLabelColor: labelColor,
dividerColor: dividerColor,
textColor: textColor,
lineColor: lineColor,
language: language,
extraCount: extras[idx],
weekNumber: idx == mondayIdx ? weekNumber : nil,
cwLabel: L10n.t("cal.cw", language),
edge: edge,
dayEvents: eventsForDay,
onTap: { onDayTap(day) },
onCreateEvent: { onCreateEvent(day) },
onShowWeek: { onShowWeek(day) },
onShowDay: { onShowDay(day) })
.frame(width: cellW, height: geo.size.height)
}
}
ForEach(Array(placed.enumerated()), id: \.offset) { _, p in
Button { onEventTap(p.event) } label: {
EventBar(event: p.event)
.frame(width: cellW * CGFloat(p.span) - 2, height: laneHeight)
}
.buttonStyle(.plain)
.offset(x: CGFloat(p.startCol) * cellW + 1,
y: dayNumberRowHeight + CGFloat(p.lane) * (laneHeight + laneSpacing))
}
// Vertical connector at the month-boundary column – ties the bottom-line
// of old-month cells to the top-line of new-month cells into a step.
if let b = midRowBoundaryCol {
Rectangle()
.fill(dividerColor)
.frame(width: 1.5, height: geo.size.height)
.offset(x: CGFloat(b) * cellW - 0.75, y: 0)
}
}
}
.frame(height: fillHeight ? nil : rowHeight)
.frame(maxHeight: fillHeight ? .infinity : nil)
}
}
// MARK: – Day Cell
private struct DayCell: View {
let date: Date
let isToday: Bool
let monthLabelColor: Color
let dividerColor: Color
let textColor: Color
let lineColor: Color
let language: String
let extraCount: Int
let weekNumber: Int?
let cwLabel: String
let edge: DividerEdge
let dayEvents: [CalEvent]
let onTap: () -> Void
let onCreateEvent: () -> Void
let onShowWeek: () -> Void
let onShowDay: () -> Void
@AppStorage("textContrast") private var textContrast = 3
@AppStorage("lineContrast") private var lineContrast = 3
private var cal: Calendar { Calendar.current }
private var dayNum: Int { cal.component(.day, from: date) }
private var isFirstOfMonth: Bool { dayNum == 1 }
private var monthAbbrev: String {
let f = DateFormatter()
f.locale = L10n.locale(language)
f.dateFormat = "LLL"
return f.string(from: date).uppercased()
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Button(action: onTap) {
HStack(spacing: 4) {
Text("\(dayNum)")
.font(.system(size: 13, weight: isToday ? .bold : .regular))
.foregroundStyle(isToday ? Color.white : textColor)
.frame(width: 22, height: 22)
.background(isToday ? Color.accentColor : Color.clear)
.clipShape(Circle())
if isFirstOfMonth {
Text(monthAbbrev)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(monthLabelColor)
.lineLimit(1)
.fixedSize()
}
Spacer(minLength: 0)
}
.padding(.leading, 4)
.padding(.top, 2)
}
.buttonStyle(.plain)
Spacer(minLength: 0)
HStack(spacing: 0) {
if extraCount > 0 {
Text("+\(extraCount)")
.font(.system(size: 9, weight: .medium))
.foregroundStyle(textColor.opacity(secondaryTextOpacity(textContrast)))
.padding(.leading, 4)
}
Spacer(minLength: 0)
if let wn = weekNumber {
Text("\(cwLabel) \(wn)")
.font(.system(size: 9, weight: .medium))
.foregroundStyle(textColor.opacity(secondaryTextOpacity(textContrast)))
.padding(.trailing, 4)
}
}
.padding(.bottom, 1)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.overlay(alignment: .trailing) {
Rectangle().fill(lineColor.opacity(gridLineOpacity(lineContrast))).frame(width: 0.5)
}
.overlay(alignment: .top) {
Rectangle()
.fill(edge == .topHighlight ? dividerColor : lineColor.opacity(gridLineOpacity(lineContrast)))
.frame(height: edge == .topHighlight ? 1.5 : 0.5)
}
.overlay(alignment: .bottom) {
if edge == .bottomHighlight {
Rectangle().fill(dividerColor).frame(height: 1.5)
}
}
.contentShape(Rectangle())
.contextMenu {
Button { onCreateEvent() } label: {
Label(L10n.t("cal.new_event", language), systemImage: "plus")
}
Button { onShowWeek() } label: {
Label(L10n.t("cal.show_in_week_view", language),
systemImage: "calendar.day.timeline.leading")
}
Button { onShowDay() } label: {
Label(L10n.t("cal.show_in_day_view", language), systemImage: "sun.max")
}
} preview: {
DayContextPreviewView(date: date, events: dayEvents, language: language)
}
}
}
// MARK: – Day Context Menu Preview
private struct DayContextPreviewView: View {
let date: Date
let events: [CalEvent]
let language: String
private var cal: Calendar { .current }
private var sortedEvents: [CalEvent] {
events.sorted { a, b in
if a.isAllDay != b.isAllDay { return a.isAllDay }
return a.startDate < b.startDate
}
}
private var weekdayAbbr: String {
let fmt = DateFormatter()
fmt.locale = L10n.locale(language)
fmt.dateFormat = "EEE"
return fmt.string(from: date).uppercased()
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(weekdayAbbr)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Text("\(cal.component(.day, from: date))")
.font(.title2.weight(.bold))
}
if !sortedEvents.isEmpty {
Divider()
ForEach(sortedEvents) { ev in
if ev.isAllDay {
DayPreviewAllDayBar(event: ev, date: date)
} else {
DayPreviewTimedRow(event: ev)
}
}
} else {
Text("–")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(12)
.frame(minWidth: 220, maxWidth: 300)
}
}
private struct DayPreviewAllDayBar: View {
let event: CalEvent
let date: Date
private var cal: Calendar { .current }
var body: some View {
let color = Color(hex: event.effectiveColor)
let dayStart = cal.startOfDay(for: date)
let dayEnd = cal.date(byAdding: .day, value: 1, to: dayStart)!
let cLeft = event.startDate < dayStart
let cRight = event.endDate > dayEnd // endDate is exclusive for allDay
EventLabel(event: event)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.white)
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 3)
.padding(.leading, cLeft ? 14 : 8)
.padding(.trailing, cRight ? 14 : 8)
.background(color)
.clipShape(ChevronBarShape(continuesLeft: cLeft, continuesRight: cRight))
}
}
private struct ChevronBarShape: Shape {
var continuesLeft: Bool
var continuesRight: Bool
private let tip: CGFloat = 8
func path(in rect: CGRect) -> Path {
guard continuesLeft || continuesRight else {
return RoundedRectangle(cornerRadius: 4).path(in: rect)
}
var p = Path()
let t = min(tip, rect.width / 2)
if continuesLeft && continuesRight {
p.move(to: CGPoint(x: t, y: 0))
p.addLine(to: CGPoint(x: rect.maxX - t, y: 0))
p.addLine(to: CGPoint(x: rect.maxX, y: rect.midY))
p.addLine(to: CGPoint(x: rect.maxX - t, y: rect.maxY))
p.addLine(to: CGPoint(x: t, y: rect.maxY))
p.addLine(to: CGPoint(x: 0, y: rect.midY))
} else if continuesLeft {
p.move(to: CGPoint(x: t, y: 0))
p.addLine(to: CGPoint(x: rect.maxX, y: 0))
p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
p.addLine(to: CGPoint(x: t, y: rect.maxY))
p.addLine(to: CGPoint(x: 0, y: rect.midY))
} else {
p.move(to: CGPoint(x: 0, y: 0))
p.addLine(to: CGPoint(x: rect.maxX - t, y: 0))
p.addLine(to: CGPoint(x: rect.maxX, y: rect.midY))
p.addLine(to: CGPoint(x: rect.maxX - t, y: rect.maxY))
p.addLine(to: CGPoint(x: 0, y: rect.maxY))
}
p.closeSubpath()
return p
}
}
private struct DayPreviewTimedRow: View {
let event: CalEvent
var body: some View {
HStack(spacing: 6) {
Circle()
.fill(Color(hex: event.effectiveColor))
.frame(width: 7, height: 7)
Text(timeString)
.font(.system(size: 11))
.foregroundStyle(.secondary)
.frame(width: 44, alignment: .leading)
EventLabel(event: event)
.font(.system(size: 11, weight: .medium))
.lineLimit(1)
Spacer(minLength: 0)
}
}
private var timeString: String {
let fmt = DateFormatter()
fmt.dateFormat = "HH:mm"
return fmt.string(from: event.startDate)
}
}
// MARK: – Event Bar
private struct EventBar: View {
let event: CalEvent
@AppStorage("dimPastEvents") private var dimPast = false
private var isPast: Bool { event.endDate < .now }
var body: some View {
HStack(spacing: 3) {
EventLabel(event: event)
.font(.system(size: 10, weight: .medium))
.lineLimit(1)
.foregroundStyle(.white)
.padding(.leading, 4)
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(hex: event.effectiveColor))
.clipShape(RoundedRectangle(cornerRadius: 3))
.opacity(dimPast && isPast ? 0.5 : 1.0)
}
}
@@ -0,0 +1,118 @@
import SwiftUI
struct QuarterView: View {
let store: CalendarStore
let onEventTap: (CalEvent) -> Void
private var cal: Calendar { store.userCalendar }
private var months: [Date] {
let start = cal.date(from: cal.dateComponents([.year, .month], from: store.currentDate))!
return (0..<3).compactMap { cal.date(byAdding: .month, value: $0, to: start) }
}
var body: some View {
ScrollView {
VStack(spacing: 0) {
ForEach(months, id: \.self) { month in
MiniMonthBlock(month: month, store: store, onEventTap: onEventTap)
Divider()
}
}
}
}
}
private struct MiniMonthBlock: View {
let month: Date
let store: CalendarStore
let onEventTap: (CalEvent) -> Void
private var cal: Calendar { store.userCalendar }
private let monthFmt: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "MMMM yyyy"; return f
}()
private var gridDays: [Date] {
let firstWeekday = cal.firstWeekday
let weekday = cal.component(.weekday, from: month)
let offset = ((weekday - firstWeekday) + 7) % 7
let gridStart = cal.date(byAdding: .day, value: -offset, to: month)!
let rows = 6
return (0..<(rows * 7)).compactMap { cal.date(byAdding: .day, value: $0, to: gridStart) }
}
private var weekdayHeaders: [String] {
let symbols = cal.veryShortWeekdaySymbols
let start = cal.firstWeekday - 1
return (0..<7).map { symbols[(start + $0) % 7] }
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(monthFmt.string(from: month))
.font(.headline.weight(.semibold))
.padding(.horizontal, 16)
.padding(.top, 12)
HStack(spacing: 0) {
ForEach(weekdayHeaders, id: \.self) { d in
Text(d)
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
}
}
.padding(.horizontal, 8)
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 0), count: 7), spacing: 2) {
ForEach(gridDays, id: \.self) { day in
MiniDayCell(
date: day,
isCurrentMonth: cal.isDate(day, equalTo: month, toGranularity: .month),
isToday: cal.isDateInToday(day),
events: store.events(on: day),
onEventTap: onEventTap
)
}
}
.padding(.horizontal, 8)
.padding(.bottom, 12)
}
}
}
private struct MiniDayCell: View {
let date: Date
let isCurrentMonth: Bool
let isToday: Bool
let events: [CalEvent]
let onEventTap: (CalEvent) -> Void
var body: some View {
VStack(spacing: 1) {
Text("\(Calendar.current.component(.day, from: date))")
.font(.system(size: 12, weight: isToday ? .bold : .regular))
.foregroundStyle(
isToday ? Color.white :
isCurrentMonth ? Color.primary : Color.secondary.opacity(0.3)
)
.frame(width: 22, height: 22)
.background(isToday ? Color.accentColor : Color.clear)
.clipShape(Circle())
// Up to 3 event dots
HStack(spacing: 2) {
ForEach(events.prefix(3)) { ev in
Circle()
.fill(Color(hex: ev.effectiveColor))
.frame(width: 4, height: 4)
.onTapGesture { onEventTap(ev) }
}
}
.frame(height: 6)
}
.frame(minHeight: 36)
}
}
@@ -0,0 +1,93 @@
import SwiftUI
// Shared constants used by WeekView, DayView, EventEditorSheet
let timeColumnWidth: CGFloat = 44
let hours = Array(0..<24)
/// Live hour-row height, driven by the synced `hourHeight` setting.
/// Falls back to 60 when unset (fresh install / value 0). Views that lay out
/// against this also observe `@AppStorage("hourHeight")` so their body
/// re-renders when it changes.
var hourHeight: CGFloat {
let v = UserDefaults.standard.integer(forKey: "hourHeight")
return v > 0 ? CGFloat(v) : 60
}
/// Opacity for secondary text (weekday headers, time labels, "+N"/"KW"),
/// mapped from the 1–4 `textContrast` level. Level 3 ≈ the previous hard-coded
/// look so existing installs are visually unchanged.
func secondaryTextOpacity(_ level: Int) -> Double {
switch level {
case 1: return 0.4
case 2: return 0.55
case 4: return 1.0
default: return 0.75
}
}
/// Opacity for grid lines / separators, mapped from the 1–4 `lineContrast`
/// level. Level 3 ≈ the previous hard-coded ~0.4 look.
func gridLineOpacity(_ level: Int) -> Double {
switch level {
case 1: return 0.15
case 2: return 0.3
case 4: return 0.8
default: return 0.5
}
}
/// A timed (non-all-day) event that crosses a day boundary. Such events must
/// NOT be placed in the hourly grid — their height would be `duration ×
/// hourHeight`, i.e. taller than the whole day, rendering as a giant block
/// (and, sharing one id across days, only drawing on the first day). They are
/// shown in the all-day strip instead, like all-day events.
func eventSpansMultipleDays(_ ev: CalEvent) -> Bool {
guard !ev.isAllDay, ev.endDate > ev.startDate else { return false }
let cal = Calendar.current
// End is exclusive: an event ending exactly at midnight is still single-day.
let lastInstant = ev.endDate.addingTimeInterval(-1)
return !cal.isDate(ev.startDate, inSameDayAs: lastInstant)
}
// Position helpers
func eventTop(_ ev: CalEvent) -> CGFloat {
let cal = Calendar.current
let h = CGFloat(cal.component(.hour, from: ev.startDate))
let m = CGFloat(cal.component(.minute, from: ev.startDate))
return h * hourHeight + m * hourHeight / 60
}
func eventHeight(_ ev: CalEvent) -> CGFloat {
let dur = ev.endDate.timeIntervalSince(ev.startDate)
return max(CGFloat(dur / 3600) * hourHeight, 20)
}
// Shared event block used in WeekView and DayView
struct EventBlock: View {
let event: CalEvent
@AppStorage("dimPastEvents") private var dimPast = false
private var isPast: Bool { event.endDate < .now }
var body: some View {
RoundedRectangle(cornerRadius: 4)
.fill(Color(hex: event.effectiveColor).opacity(0.85))
.overlay(alignment: .topLeading) {
VStack(alignment: .leading, spacing: 1) {
EventLabel(event: event)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.white)
.lineLimit(2)
if !event.location.isEmpty {
Text(event.location)
.font(.system(size: 10))
.foregroundStyle(.white.opacity(0.85))
.lineLimit(1)
}
}
.padding(4)
}
.padding(.horizontal, 1)
.opacity(dimPast && isPast ? 0.5 : 1.0)
}
}
@@ -0,0 +1,270 @@
import SwiftUI
struct WeekView: View {
let store: CalendarStore
let onEventTap: (CalEvent) -> Void
let onCreateEvent: (Date) -> Void
let onShowMonth: (Date) -> Void
let onShowDay: (Date) -> Void
@AppStorage("appLanguage") private var appLang = "system"
@AppStorage("todayColor") private var todayHex = "#4285f4"
@AppStorage("textColor") private var textHex = "#FFFFFF"
@AppStorage("lineColor") private var lineHex = "#3A3A52"
@AppStorage("textContrast") private var textContrast = 3
@AppStorage("lineContrast") private var lineContrast = 3
@AppStorage("hourHeight") private var hourHeightPref = 60 // observed for live re-layout
private var cal: Calendar { store.userCalendar }
private var weekDays: [Date] {
let start = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: store.currentDate))!
return (0..<7).compactMap { cal.date(byAdding: .day, value: $0, to: start) }
}
private var timedEvents: [(Int, CalEvent)] {
weekDays.enumerated().flatMap { idx, day in
store.events(on: day)
.filter { !$0.isAllDay && !eventSpansMultipleDays($0) }
.map { (idx, $0) }
}
}
private var allDayEvents: [CalEvent] {
let s = weekDays.first ?? .now
let e = cal.date(byAdding: .day, value: 1, to: weekDays.last ?? .now)!
return store.events(in: s, end: e).filter { $0.isAllDay || eventSpansMultipleDays($0) }
}
private var todayIndex: Int? {
weekDays.firstIndex(where: { cal.isDateInToday($0) })
}
private let headerFmt: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "EEE d"; return f
}()
var body: some View {
VStack(spacing: 0) {
columnHeaders
Divider()
if !allDayEvents.isEmpty { allDayRow }
timeGrid
}
}
// MARK: – Column headers (fixed height — no Color.clear tricks)
private var columnHeaders: some View {
HStack(spacing: 0) {
Spacer().frame(width: timeColumnWidth, height: 36) // fixed height!
ForEach(weekDays, id: \.self) { day in
Text(headerFmt.string(from: day).uppercased())
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(cal.isDateInToday(day) ? Color(hex: todayHex) : Color(hex: textHex).opacity(secondaryTextOpacity(textContrast)))
.frame(maxWidth: .infinity, minHeight: 36)
.overlay(alignment: .trailing) {
Rectangle().fill(Color(hex: lineHex).opacity(gridLineOpacity(lineContrast))).frame(width: 0.5)
}
}
}
}
// MARK: – All-day strip
private let allDayLaneHeight: CGFloat = 16
/// All-day / multi-day events laid out as continuous bars: each event spans
/// its start→end columns (clamped to this week) and is packed into lanes so
/// overlapping bars stack — mirrors the month view instead of repeating the
/// event once per day.
private func allDayBars() -> [(ev: CalEvent, start: Int, end: Int, lane: Int)] {
var spans: [(ev: CalEvent, start: Int, end: Int)] = []
for ev in allDayEvents {
var start: Int? = nil
var end = 0
for (i, day) in weekDays.enumerated() {
let ds = cal.startOfDay(for: day)
let de = cal.date(byAdding: .day, value: 1, to: ds)!
if ev.startDate < de && ev.endDate > ds {
if start == nil { start = i }
end = i
}
}
if let s = start { spans.append((ev, s, end)) }
}
// Longer / earlier bars first, then greedily pack into lanes.
spans.sort { a, b in a.start != b.start ? a.start < b.start : (a.end - a.start) > (b.end - b.start) }
var laneEnd: [Int] = [] // last occupied column index per lane
var out: [(ev: CalEvent, start: Int, end: Int, lane: Int)] = []
for s in spans {
var lane = 0
while lane < laneEnd.count && laneEnd[lane] >= s.start { lane += 1 }
if lane == laneEnd.count { laneEnd.append(s.end) } else { laneEnd[lane] = s.end }
out.append((s.ev, s.start, s.end, lane))
}
return out
}
private var allDayRow: some View {
let bars = allDayBars()
let laneCount = max(1, (bars.map { $0.lane }.max() ?? -1) + 1)
return GeometryReader { geo in
let colW = (geo.size.width - timeColumnWidth) / 7
ZStack(alignment: .topLeading) {
ForEach(bars, id: \.ev.id) { bar in
Button { onEventTap(bar.ev) } label: {
EventLabel(event: bar.ev)
.font(.system(size: 9, weight: .medium))
.foregroundStyle(.white)
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 3)
.frame(height: allDayLaneHeight - 2)
.background(Color(hex: bar.ev.effectiveColor))
.clipShape(RoundedRectangle(cornerRadius: 2))
}
.buttonStyle(.plain)
.frame(width: colW * CGFloat(bar.end - bar.start + 1) - 3)
.offset(x: timeColumnWidth + colW * CGFloat(bar.start) + 1.5,
y: CGFloat(bar.lane) * allDayLaneHeight)
}
}
}
.frame(height: CGFloat(laneCount) * allDayLaneHeight)
.padding(.vertical, 4)
.overlay(alignment: .bottom) { Divider() }
}
// MARK: – Time grid (GeometryReader OUTSIDE ScrollView → clean layout)
private var timeGrid: some View {
GeometryReader { geo in
let colW = (geo.size.width - timeColumnWidth) / 7
ScrollViewReader { proxy in
ScrollView {
ZStack(alignment: .topLeading) {
// Background: time labels + per-hour cells per day
HStack(alignment: .top, spacing: 0) {
timeLabels
ForEach(Array(weekDays.enumerated()), id: \.offset) { _, day in
VStack(spacing: 0) {
ForEach(hours, id: \.self) { hour in
HourSlot(day: day, hour: hour,
hourHeight: hourHeight,
language: appLang,
onCreateEvent: onCreateEvent,
onShowMonth: onShowMonth,
onShowDay: onShowDay)
}
}
.frame(width: colW)
.overlay(alignment: .trailing) {
Rectangle().fill(Color(hex: lineHex).opacity(gridLineOpacity(lineContrast))).frame(width: 0.5)
}
}
}
// Events – positioned using known column widths (no GeometryReader inside)
ForEach(timedEvents, id: \.1.id) { dayIdx, ev in
Button(action: { onEventTap(ev) }) {
EventBlock(event: ev)
}
.buttonStyle(.plain)
.frame(width: colW - 2, height: max(eventHeight(ev), 18))
.offset(x: timeColumnWidth + CGFloat(dayIdx) * colW + 1,
y: eventTop(ev))
}
// Current time line
if let ti = todayIndex {
let lineY = eventTop(Date.now)
let nowColor = Color(hex: todayHex)
HStack(spacing: 0) {
Spacer().frame(width: timeColumnWidth + CGFloat(ti) * colW - 4)
Circle().fill(nowColor).frame(width: 8, height: 8)
Rectangle().fill(nowColor).frame(width: colW - 4, height: 1.5)
}
.offset(y: lineY - 0.75)
}
}
.frame(width: geo.size.width, height: hourHeight * 24 + 80)
.id("grid")
}
.onAppear { scrollToCurrentHour(proxy) }
.onChange(of: store.currentDate) { _, _ in scrollToCurrentHour(proxy) }
}
}
}
private var timeLabels: some View {
VStack(spacing: 0) {
ForEach(hours, id: \.self) { h in
ZStack(alignment: .topTrailing) {
Color.clear.frame(height: hourHeight)
Text(String(format: "%02d:00", h))
.font(.system(size: 10))
.foregroundStyle(Color(hex: textHex).opacity(secondaryTextOpacity(textContrast)))
.offset(y: -6)
}
}
Color.clear.frame(height: 80) // FAB space
}
.frame(width: timeColumnWidth)
}
private func scrollToCurrentHour(_ proxy: ScrollViewProxy) {
let h = Calendar.current.component(.hour, from: .now)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
withAnimation(.easeOut(duration: 0.3)) {
proxy.scrollTo("grid", anchor: UnitPoint(x: 0, y: CGFloat(max(h - 1, 0)) / 24.0))
}
}
}
}
// Trick to compute eventTop from a Date instead of CalEvent
private func eventTop(_ date: Date) -> CGFloat {
let cal = Calendar.current
let h = CGFloat(cal.component(.hour, from: date))
let m = CGFloat(cal.component(.minute, from: date))
return h * hourHeight + m * hourHeight / 60
}
// One-hour slot with native long-press context menu.
struct HourSlot: View {
let day: Date
let hour: Int
let hourHeight: CGFloat
let language: String
let onCreateEvent: (Date) -> Void
let onShowMonth: (Date) -> Void
let onShowDay: (Date) -> Void
@AppStorage("lineColor") private var lineHex = "#3A3A52"
@AppStorage("lineContrast") private var lineContrast = 3
private var date: Date {
Calendar.current.date(bySettingHour: hour, minute: 0, second: 0, of: day) ?? day
}
var body: some View {
VStack(spacing: 0) {
Rectangle().fill(Color(hex: lineHex).opacity(gridLineOpacity(lineContrast))).frame(height: 0.5)
Color.clear.frame(height: hourHeight - 0.5)
}
.contentShape(Rectangle())
.contextMenu {
Button { onCreateEvent(date) } label: {
Label(L10n.t("cal.new_event", language), systemImage: "plus")
}
Button { onShowMonth(date) } label: {
Label(L10n.t("cal.show_in_month_view", language), systemImage: "calendar")
}
Button { onShowDay(date) } label: {
Label(L10n.t("cal.show_in_day_view", language), systemImage: "sun.max")
}
}
}
}
@@ -0,0 +1,259 @@
import SwiftUI
/// A single calendar row in the flat, reorderable list.
private struct CalRow: Identifiable {
let id: String // "source:id" key
let name: String
let colorHex: String
let readOnly: Bool
}
/// The calendar-visibility list, extracted so both the modal `CalendarFilterSheet`
/// and the side drawer render the same rows/logic. A single flat, drag-reorderable
/// list (no per-source grouping); visibility is client-side (`CalendarStore`),
/// order is device-local (`CalendarStore.calendarOrder`, mirrors the web).
struct CalendarFilterContent: View {
let api: CalendarrAPI
let store: CalendarStore
@AppStorage("appLanguage") private var appLang = "system"
@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 hidden: Set<String> = []
@State private var banished: Set<String> = []
@State private var reminderDisabled: Set<String> = []
@State private var allKeys: Set<String> = []
@State private var rows: [CalRow] = []
@State private var isSorting = false
@State private var groupDetail: CalGroup? = nil
@State private var hiddenGroup: Set<String> = []
var body: some View {
Group {
if isLoading {
ProgressView(L10n.t("filter.loading", appLang))
} else if store.activeGroup != nil {
groupFilterList
} else if allKeys.isEmpty {
Text(L10n.t("filter.empty", appLang)).foregroundStyle(.secondary)
} else {
VStack(spacing: 0) {
controlBar
List {
ForEach(rows) { cal in row(cal) }
.onMove(perform: move)
if !banished.isEmpty {
Section {
Text(L10n.t("filter.banished_footer", appLang))
.font(.caption).foregroundStyle(.secondary)
}
}
}
.listStyle(.plain)
.environment(\.editMode, .constant(isSorting ? .active : .inactive))
}
}
}
.task { await load() }
}
// Section header + sort toggle, above the list so it stays tappable while
// the list is in edit mode. (No show/hide-all buttons — rarely needed with a
// handful of calendars, and the web has none either.)
private var controlBar: some View {
HStack {
Text(L10n.t("filter.title", appLang))
.font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary)
Spacer()
Button(isSorting ? L10n.t("filter.done", appLang) : L10n.t("filter.sort", appLang)) {
withAnimation { isSorting.toggle() }
}
.buttonStyle(.borderless)
.font(.subheadline)
}
.padding(.horizontal, 16).padding(.top, 6).padding(.bottom, 4)
}
private func move(from source: IndexSet, to destination: Int) {
rows.move(fromOffsets: source, toOffset: destination)
store.setCalendarOrder(rows.map(\.id))
}
@ViewBuilder
private func row(_ cal: CalRow) -> some View {
let isVisible = !hidden.contains(cal.id)
Button {
if isVisible { hidden.insert(cal.id) } else { hidden.remove(cal.id) }
store.setCalendarHidden(cal.id, hidden: isVisible)
} label: {
HStack(spacing: 12) {
Circle()
.fill(Color(hex: cal.colorHex))
.frame(width: 14, height: 14)
.opacity(isVisible ? 1.0 : 0.35)
Text(cal.name)
.foregroundStyle(isVisible ? .primary : .secondary)
.strikethrough(!isVisible, color: .secondary)
if cal.readOnly {
Image(systemName: "lock.fill").font(.caption2).foregroundStyle(.secondary)
}
Spacer()
if reminderDisabled.contains(cal.id) {
Image(systemName: "bell.slash").font(.caption).foregroundStyle(.secondary)
}
Image(systemName: isVisible ? "eye" : "eye.slash")
.foregroundStyle(isVisible ? Color.accentColor : .secondary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.contextMenu {
Button {
toggleReminders(forKey: cal.id)
} label: {
let disabled = reminderDisabled.contains(cal.id)
Label(L10n.t(disabled ? "filter.reminders_on" : "filter.reminders_off", appLang),
systemImage: disabled ? "bell" : "bell.slash")
}
Button(role: .destructive) {
hidden.remove(cal.id)
banished.insert(cal.id)
store.setCalendarBanished(cal.id, banished: true)
pushBanishToServer(key: cal.id, hidden: true)
rows.removeAll { $0.id == cal.id }
} label: {
Label(L10n.t("filter.banish", appLang), systemImage: "archivebox")
}
}
}
private func toggleReminders(forKey key: String) {
let nowDisabled = !reminderDisabled.contains(key)
if nowDisabled { reminderDisabled.insert(key) } else { reminderDisabled.remove(key) }
store.setReminderDisabled(key, disabled: nowDisabled)
if let parsed = CalendarStore.parseCalendarKey(key) {
Task { try? await api.setCalendarRemindersEnabled(
source: parsed.source, calendarId: parsed.id, enabled: !nowDisabled) }
}
}
// MARK: – Group overlay (unchanged: hide individual members / group calendar)
@ViewBuilder
private var groupFilterList: some View {
if let g = groupDetail {
List {
Section(header: Label(g.name, systemImage: GroupIcons.symbol(g.icon))) {
ForEach((g.members ?? []).filter { $0.sharesCalendar }) { m in
groupRow(name: m.displayName ?? "—",
colorHex: m.color ?? "#4285f4",
key: CalendarStore.groupMemberKey(m.id))
}
groupRow(name: L10n.t("group.calendar", appLang),
colorHex: g.groupCalendarColor ?? "#4285f4",
key: CalendarStore.groupCalendarKey)
}
}
} else {
Text(L10n.t("filter.empty", appLang)).foregroundStyle(.secondary)
}
}
@ViewBuilder
private func groupRow(name: String, colorHex: String, key: String) -> some View {
let isVisible = !hiddenGroup.contains(key)
Button {
if isVisible { hiddenGroup.insert(key) } else { hiddenGroup.remove(key) }
store.setGroupKeyHidden(key, hidden: isVisible)
} label: {
HStack(spacing: 12) {
Circle()
.fill(Color(hex: colorHex))
.frame(width: 14, height: 14)
.opacity(isVisible ? 1.0 : 0.35)
Text(name)
.foregroundStyle(isVisible ? .primary : .secondary)
.strikethrough(!isVisible, color: .secondary)
Spacer()
Image(systemName: isVisible ? "eye" : "eye.slash")
.foregroundStyle(isVisible ? Color.accentColor : .secondary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
private func load() async {
isLoading = true
if let g = store.activeGroup {
hiddenGroup = store.hiddenGroupKeys
groupDetail = try? await api.getGroup(id: g.id)
isLoading = false
return
}
hidden = store.hiddenCalendarKeys
banished = store.banishedCalendarKeys
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)
var b = store.banishedCalendarKeys
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) } }
store.setBanishedCalendars(b)
banished = b
var rd = Set<String>()
func applyReminders(_ source: String, _ id: Int, _ enabled: Bool) {
if !enabled { rd.insert(CalendarStore.calendarKey(source: source, calendarId: "\(id)")) }
}
for cal in localCalendars { applyReminders("local", cal.id, cal.remindersEnabled) }
for acc in caldavAccounts { for cal in acc.calendars ?? [] { applyReminders("caldav", cal.id, cal.remindersEnabled ?? true) } }
for sub in icalSubs { applyReminders("ical", sub.id, sub.remindersEnabled ?? true) }
for acc in googleAccounts { for cal in acc.calendars ?? [] { applyReminders("google", cal.id, cal.remindersEnabled ?? true) } }
for acc in haAccounts { for cal in acc.calendars ?? [] { applyReminders("homeassistant", cal.id, cal.remindersEnabled) } }
store.setReminderDisabledKeys(rd)
reminderDisabled = rd
// Build the flat row list (banished excluded), then sort by stored order.
var combined: [CalRow] = []
func add(_ source: String, _ id: Int, _ name: String, _ colorHex: String, readOnly: Bool = false) {
let key = CalendarStore.calendarKey(source: source, calendarId: "\(id)")
if b.contains(key) { return }
combined.append(CalRow(id: key, name: name, colorHex: colorHex, readOnly: readOnly))
}
for cal in localCalendars {
add("local", cal.id, cal.owned ? cal.name : (cal.sharedBy ?? cal.name), cal.color,
readOnly: !cal.owned && cal.permission != "read_write")
}
for acc in caldavAccounts { for cal in acc.calendars ?? [] { add("caldav", cal.id, cal.name, cal.color ?? acc.color) } }
for sub in icalSubs { add("ical", sub.id, sub.name, sub.color) }
for acc in googleAccounts { for cal in acc.calendars ?? [] { add("google", cal.id, cal.name, cal.color ?? "#4285f4") } }
for acc in haAccounts { for cal in acc.calendars ?? [] { add("homeassistant", cal.id, cal.name, cal.color ?? "#46bdc6") } }
allKeys = Set(combined.map(\.id))
let orderedKeys = store.ordered(combined.map(\.id))
let byKey = Dictionary(uniqueKeysWithValues: combined.map { ($0.id, $0) })
rows = orderedKeys.compactMap { byKey[$0] }
isLoading = false
}
private func pushBanishToServer(key: String, hidden: Bool) {
guard let parsed = CalendarStore.parseCalendarKey(key),
CalendarStore.serverManagedSources.contains(parsed.source) else { return }
Task { try? await api.setCalendarSidebarHidden(source: parsed.source, calendarId: parsed.id, hidden: hidden) }
}
}
@@ -0,0 +1,23 @@
import SwiftUI
/// Modal wrapper around `CalendarFilterContent` (kept for contexts that still
/// present the filter as a sheet). The drawer embeds the same content directly.
struct CalendarFilterSheet: View {
let api: CalendarrAPI
let store: CalendarStore
@Environment(\.dismiss) private var dismiss
@AppStorage("appLanguage") private var appLang = "system"
var body: some View {
NavigationStack {
CalendarFilterContent(api: api, store: store)
.navigationTitle(L10n.t("filter.title", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(L10n.t("nav.done", appLang)) { dismiss() }
}
}
}
}
}
+412
View File
@@ -0,0 +1,412 @@
import SwiftUI
// MARK: - Group icons (cross-platform, non-emoji)
/// Canonical group-icon keys stored server-side and rendered as native SF
/// Symbols here (Material on Android, SVG on web), so groups look consistent
/// instead of relying on OS-specific emoji rendering.
enum GroupIcons {
static let keys = ["people", "home", "heart", "work", "school", "sports",
"party", "pet", "travel", "music", "food", "star"]
static func symbol(_ key: String?) -> String {
switch key {
case "people": return "person.2.fill"
case "home": return "house.fill"
case "heart": return "heart.fill"
case "work": return "briefcase.fill"
case "school": return "graduationcap.fill"
case "sports": return "figure.run"
case "party": return "party.popper.fill"
case "pet": return "pawprint.fill"
case "travel": return "airplane"
case "music": return "music.note"
case "food": return "fork.knife"
case "star": return "star.fill"
default: return "person.2.fill"
}
}
static func isKey(_ s: String?) -> Bool { if let s { return keys.contains(s) }; return false }
}
/// Render a group's icon: native SF Symbol for known keys, the legacy emoji for
/// pre-migration groups, else a default people glyph.
struct GroupIconView: View {
let icon: String?
var body: some View {
if GroupIcons.isKey(icon) {
Image(systemName: GroupIcons.symbol(icon))
} else if let e = icon, !e.isEmpty {
Text(e)
} else {
Image(systemName: "person.2.fill")
}
}
}
// MARK: - Groups list
struct GroupsView: View {
let api: CalendarrAPI
@AppStorage("appLanguage") private var appLang = "system"
@State private var groups: [CalGroup] = []
@State private var isLoading = true
@State private var showCreate = false
var body: some View {
Group {
if isLoading {
ProgressView()
} else {
List {
if groups.isEmpty {
Text(L10n.t("groups.none", appLang)).foregroundStyle(.secondary)
}
ForEach(groups) { g in
NavigationLink {
GroupCombinedView(api: api, group: g)
} label: {
HStack {
GroupIconView(icon: g.icon)
Text(g.name)
Spacer()
if let n = g.memberCount {
Text("\(n)").font(.caption).foregroundStyle(.secondary)
}
}
}
.swipeActions {
NavigationLink {
GroupManageSheet(api: api, groupId: g.id) { await load() }
} label: {
Label(L10n.t("group.manage", appLang), systemImage: "slider.horizontal.3")
}.tint(.gray)
}
}
}
}
}
.navigationTitle(L10n.t("groups.title", appLang))
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button { showCreate = true } label: { Image(systemName: "plus") }
}
}
.sheet(isPresented: $showCreate) {
GroupEditSheet(api: api, existing: nil) { await load() }
}
.task { await load() }
}
private func load() async {
isLoading = true
groups = (try? await api.getGroups()) ?? []
isLoading = false
}
}
// MARK: - Create / edit a group (name + icon + members)
struct GroupEditSheet: View {
let api: CalendarrAPI
let existing: CalGroup?
let onDone: () async -> Void
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
@State private var name = ""
@State private var icon = "people"
@State private var directory: [DirectoryUser] = []
@State private var selected: Set<Int> = []
@State private var error = ""
private let icons = GroupIcons.keys
private let cols = [GridItem(.adaptive(minimum: 46))]
var body: some View {
NavigationStack {
Form {
Section(L10n.t("group.name", appLang)) {
TextField(L10n.t("group.name", appLang), text: $name)
}
Section(L10n.t("group.icon", appLang)) {
LazyVGrid(columns: cols, spacing: 8) {
ForEach(icons, id: \.self) { ic in
Image(systemName: GroupIcons.symbol(ic))
.font(.title3)
.frame(width: 44, height: 44)
.background(ic == icon ? Color.accentColor.opacity(0.25) : Color(.systemGray6))
.foregroundStyle(ic == icon ? Color.accentColor : .primary)
.clipShape(RoundedRectangle(cornerRadius: 8))
.onTapGesture { icon = ic }
}
}
}
Section(L10n.t("group.members", appLang)) {
ForEach(directory) { u in
Button {
if selected.contains(u.id) { selected.remove(u.id) } else { selected.insert(u.id) }
} label: {
HStack {
Image(systemName: selected.contains(u.id) ? "checkmark.square.fill" : "square")
.foregroundStyle(selected.contains(u.id) ? Color.accentColor : .secondary)
Text(u.displayName).foregroundStyle(.primary)
}
}
}
}
if !error.isEmpty { Section { Text(error).foregroundStyle(.red) } }
}
.navigationTitle(existing == nil ? L10n.t("group.create", appLang) : L10n.t("group.manage", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button(L10n.t("common.cancel", appLang)) { dismiss() } }
ToolbarItem(placement: .primaryAction) {
Button(L10n.t("event.save", appLang)) { Task { await save() } }
.bold().disabled(name.isEmpty)
}
}
}
.task { directory = (try? await api.getUserDirectory()) ?? [] }
}
private func save() async {
do {
_ = try await api.createGroup(name: name, memberIds: Array(selected), icon: icon)
await onDone()
dismiss()
} catch { self.error = error.localizedDescription }
}
}
// MARK: - Manage existing group (rename, icon, members, colors, delete)
struct GroupManageSheet: View {
let api: CalendarrAPI
let groupId: Int
let onDone: () async -> Void
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
@State private var group: CalGroup?
@State private var name = ""
@State private var icon = "people"
@State private var directory: [DirectoryUser] = []
@State private var memberIds: Set<Int> = []
@State private var showDeleteConfirm = false
@State private var error = ""
private let icons = GroupIcons.keys
private let cols = [GridItem(.adaptive(minimum: 46))]
var body: some View {
NavigationStack {
Form {
Section(L10n.t("group.name", appLang)) {
TextField(L10n.t("group.name", appLang), text: $name)
}
Section(L10n.t("group.icon", appLang)) {
LazyVGrid(columns: cols, spacing: 8) {
ForEach(icons, id: \.self) { ic in
Image(systemName: GroupIcons.symbol(ic))
.font(.title3)
.frame(width: 44, height: 44)
.background(ic == icon ? Color.accentColor.opacity(0.25) : Color(.systemGray6))
.foregroundStyle(ic == icon ? Color.accentColor : .primary)
.clipShape(RoundedRectangle(cornerRadius: 8))
.onTapGesture { icon = ic }
}
}
}
Section(L10n.t("group.members", appLang)) {
ForEach(directory) { u in
Button {
if memberIds.contains(u.id) { memberIds.remove(u.id) } else { memberIds.insert(u.id) }
} label: {
HStack {
Image(systemName: memberIds.contains(u.id) ? "checkmark.square.fill" : "square")
.foregroundStyle(memberIds.contains(u.id) ? Color.accentColor : .secondary)
Text(u.displayName).foregroundStyle(.primary)
}
}
}
}
if let members = group?.members {
Section(L10n.t("group.member_colors", appLang)) {
ForEach(members) { m in
MemberColorRow(api: api, groupId: groupId, member: m)
}
}
}
Section {
Button(role: .destructive) { showDeleteConfirm = true } label: {
Label(L10n.t("group.delete", appLang), systemImage: "trash")
}
}
if !error.isEmpty { Section { Text(error).foregroundStyle(.red) } }
}
.navigationTitle(L10n.t("group.manage", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button(L10n.t("common.cancel", appLang)) { dismiss() } }
ToolbarItem(placement: .primaryAction) {
Button(L10n.t("event.save", appLang)) { Task { await save() } }.bold()
}
}
.alert(L10n.t("group.delete", appLang), isPresented: $showDeleteConfirm) {
Button(L10n.t("group.delete", appLang), role: .destructive) { Task { await deleteGroup() } }
Button(L10n.t("common.cancel", appLang), role: .cancel) {}
}
}
.task { await load() }
}
private func load() async {
directory = (try? await api.getUserDirectory()) ?? []
if let g = try? await api.getGroup(id: groupId) {
group = g
name = g.name
icon = GroupIcons.isKey(g.icon) ? g.icon! : "people"
let me = UserDefaults.standard.integer(forKey: "userId")
memberIds = Set((g.members ?? []).map { $0.id }.filter { $0 != me })
}
}
private func save() async {
do {
try await api.updateGroup(id: groupId, name: name, icon: icon)
let me = UserDefaults.standard.integer(forKey: "userId")
let current = Set((group?.members ?? []).map { $0.id }.filter { $0 != me })
for id in memberIds where !current.contains(id) { try await api.addGroupMember(groupId: groupId, userId: id) }
for id in current where !memberIds.contains(id) { try await api.removeGroupMember(groupId: groupId, userId: id) }
await onDone()
dismiss()
} catch { self.error = error.localizedDescription }
}
private func deleteGroup() async {
do { try await api.deleteGroup(id: groupId); await onDone(); dismiss() }
catch { self.error = error.localizedDescription }
}
}
struct MemberColorRow: View {
let api: CalendarrAPI
let groupId: Int
let member: GroupMember
@State private var color: Color
init(api: CalendarrAPI, groupId: Int, member: GroupMember) {
self.api = api; self.groupId = groupId; self.member = member
_color = State(initialValue: Color(hex: member.color ?? "#4285f4"))
}
var body: some View {
HStack {
Text(member.displayName ?? "—")
Spacer()
ColorPicker("", selection: $color, supportsOpacity: false)
.labelsHidden()
.onChange(of: color) { _, c in
Task { try? await api.setGroupMemberColor(groupId: groupId, userId: member.id, color: c.toHex()) }
}
}
}
}
// MARK: - Combined (overlay) agenda view
struct GroupCombinedView: View {
let api: CalendarrAPI
let group: CalGroup
@AppStorage("appLanguage") private var appLang = "system"
@State private var anchor = Date()
@State private var events: [CalEvent] = []
@State private var isLoading = false
private var monthRange: (Date, Date) {
let cal = Calendar.current
let start = cal.date(from: cal.dateComponents([.year, .month], from: anchor)) ?? anchor
let end = cal.date(byAdding: .month, value: 1, to: start) ?? anchor
return (start, end)
}
private var grouped: [(day: Date, items: [CalEvent])] {
let cal = Calendar.current
let dict = Dictionary(grouping: events.sorted { $0.startDate < $1.startDate }) {
cal.startOfDay(for: $0.startDate)
}
return dict.keys.sorted().map { ($0, dict[$0] ?? []) }
}
private let monthFmt: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "MMMM yyyy"; return f
}()
private let dayFmt: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "EEEE, d. MMM"; return f
}()
private let timeFmt: DateFormatter = {
let f = DateFormatter(); f.timeStyle = .short; f.dateStyle = .none; return f
}()
var body: some View {
List {
ForEach(grouped, id: \.day) { section in
Section(dayFmt.string(from: section.day)) {
ForEach(section.items) { ev in
HStack(spacing: 10) {
RoundedRectangle(cornerRadius: 3)
.fill(Color(hex: ev.effectiveColor))
.frame(width: 5, height: 34)
VStack(alignment: .leading, spacing: 2) {
Text(displayTitle(ev)).font(.body)
Text(ev.isAllDay ? L10n.t("event.allday", appLang) : timeFmt.string(from: ev.startDate))
.font(.caption).foregroundStyle(.secondary)
}
}
}
}
}
if !isLoading && events.isEmpty {
Text(L10n.t("groups.combined_empty", appLang)).foregroundStyle(.secondary)
}
}
.navigationTitle(group.name)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
HStack {
Button { shift(-1) } label: { Image(systemName: "chevron.left") }
Button { shift(1) } label: { Image(systemName: "chevron.right") }
}
}
ToolbarItem(placement: .principal) {
Text(monthFmt.string(from: anchor)).font(.headline)
}
}
.task(id: anchor) { await load() }
}
// Prefer the server-decorated title; fall back to a name prefix.
private func displayTitle(_ ev: CalEvent) -> String {
if let dt = ev.displayTitle, !dt.isEmpty { return dt }
let me = UserDefaults.standard.integer(forKey: "userId")
if let c = ev.creator, ev.isGroupEvent, c.id != me { return "\(firstName(c.displayName)): \(ev.title)" }
if let o = ev.owner, o.id != me { return "\(firstName(o.displayName)): \(ev.title)" }
return ev.title
}
private func firstName(_ s: String) -> String { s.split(separator: " ").first.map(String.init) ?? s }
private func shift(_ months: Int) {
anchor = Calendar.current.date(byAdding: .month, value: months, to: anchor) ?? anchor
}
private func load() async {
isLoading = true
let (s, e) = monthRange
events = (try? await api.fetchGroupCombined(groupId: group.id, start: s, end: e)) ?? []
isLoading = false
}
}
+215
View File
@@ -0,0 +1,215 @@
import Foundation
import SwiftUI
struct LoginView: View {
@Environment(AppState.self) var appState
@AppStorage("appLanguage") private var appLang = "system"
@State private var username = ""
@State private var password = ""
@State private var totpCode = ""
@State private var rememberMe = true
@State private var needsTOTP = false
@State private var error = ""
@State private var isLoading = false
@State private var ssoProviders: [OIDCProvider] = []
/// Key of the provider whose flow is running — per-provider, so a second
/// button does not also spin.
@State private var ssoBusyKey: String?
@State private var oidc = OIDCAuthenticator()
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 0) {
Spacer().frame(height: 60)
VStack(spacing: 8) {
Image(systemName: "calendar")
.font(.system(size: 48, weight: .light))
.foregroundStyle(Color.accentColor)
Text("Calendarr")
.font(.largeTitle.bold())
Text(appState.serverURL
.replacingOccurrences(of: "https://", with: "")
.replacingOccurrences(of: "http://", with: ""))
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.bottom, 40)
VStack(spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
Text(L10n.t("login.username", appLang))
.font(.footnote.weight(.medium))
.foregroundStyle(.secondary)
TextField(L10n.t("login.username", appLang), text: $username)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.padding(12)
.background(.quaternary)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
VStack(alignment: .leading, spacing: 6) {
Text(L10n.t("login.password", appLang))
.font(.footnote.weight(.medium))
.foregroundStyle(.secondary)
SecureField(L10n.t("login.password", appLang), text: $password)
.padding(12)
.background(.quaternary)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
if needsTOTP {
VStack(alignment: .leading, spacing: 6) {
Text(L10n.t("login.totp", appLang))
.font(.footnote.weight(.medium))
.foregroundStyle(.secondary)
TextField(L10n.t("login.totp_placeholder", appLang), text: $totpCode)
.keyboardType(.numberPad)
.padding(12)
.background(.quaternary)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
.transition(.move(edge: .top).combined(with: .opacity))
}
Toggle(L10n.t("login.remember", appLang), isOn: $rememberMe)
.tint(Color.accentColor)
if !error.isEmpty {
Text(error)
.font(.caption)
.foregroundStyle(.red)
.frame(maxWidth: .infinity, alignment: .leading)
}
Button {
Task { await login() }
} label: {
HStack {
if isLoading {
ProgressView().tint(.white)
} else {
Text(L10n.t("login.signin", appLang)).fontWeight(.semibold)
}
}
.frame(maxWidth: .infinity)
.padding(14)
.background(Color.accentColor)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.disabled(username.isEmpty || password.isEmpty || isLoading)
// Single sign-on — hidden entirely when the server has
// no provider configured.
if !ssoProviders.isEmpty {
HStack(spacing: 12) {
Rectangle().frame(height: 1).foregroundStyle(.quaternary)
Text(L10n.t("sso.or", appLang))
.font(.caption)
.foregroundStyle(.secondary)
Rectangle().frame(height: 1).foregroundStyle(.quaternary)
}
.padding(.top, 8)
ForEach(ssoProviders) { provider in
Button {
Task { await signInWithSSO(provider) }
} label: {
HStack {
if ssoBusyKey == provider.key {
ProgressView()
} else {
Text(String(format: L10n.t("sso.login_with", appLang),
provider.name))
.fontWeight(.semibold)
}
}
.frame(maxWidth: .infinity)
.padding(14)
.background(Color.accentColor.opacity(0.12))
.foregroundStyle(Color.accentColor)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.disabled(isLoading || ssoBusyKey != nil)
}
}
}
.padding(.horizontal, 32)
.animation(.easeInOut, value: needsTOTP)
.animation(.easeInOut, value: ssoProviders)
Spacer().frame(height: 40)
Button(L10n.t("login.choose_server", appLang)) {
appState.resetServer()
}
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.navigationBarHidden(true)
.task { await loadSSOProviders() }
}
}
/// Best-effort: an older server or a network hiccup simply means no button.
private func loadSSOProviders() async {
guard ssoProviders.isEmpty else { return }
ssoProviders = (try? await CalendarrAPI.oidcProviders(baseURL: appState.serverURL)) ?? []
}
private func signInWithSSO(_ provider: OIDCProvider) async {
ssoBusyKey = provider.key
error = ""
defer { ssoBusyKey = nil }
do {
let tokens = try await oidc.authorize(provider: provider)
let result = try await CalendarrAPI.exchangeOIDC(
baseURL: appState.serverURL,
provider: provider.key,
clientID: provider.mobileClientID,
idToken: tokens.idToken,
accessToken: tokens.accessToken,
nonce: tokens.nonce
)
// Keep the provider's refresh token so the session can be renewed
// later without sending the user through the browser again.
OIDCAuthenticator.store(state: tokens.serialisedState, providerKey: provider.key)
appState.saveLogin(token: result.token, user: result.username, admin: result.isAdmin)
} catch let e as OIDCError {
// Cancelling the browser sheet is a normal action, not an error.
if e.slug == "oidc_cancelled" { return }
let key = "sso.err." + e.slug
let msg = L10n.t(key, appLang)
error = msg == key ? L10n.t("sso.err.generic", appLang) : msg
} catch {
self.error = error.localizedDescription
}
}
private func login() async {
isLoading = true
error = ""
defer { isLoading = false }
do {
let code = needsTOTP ? (totpCode.isEmpty ? nil : totpCode) : nil
let result = try await CalendarrAPI.login(
baseURL: appState.serverURL,
username: username,
password: password,
totpCode: code,
rememberMe: rememberMe
)
appState.saveLogin(token: result.token, user: result.username, admin: result.isAdmin)
} catch APIError.twoFactorRequired {
withAnimation { needsTOTP = true }
} catch {
self.error = error.localizedDescription
}
}
}
+18
View File
@@ -0,0 +1,18 @@
import SwiftUI
struct MainTabView: View {
@Environment(AppState.self) var appState
@State private var showMenu = false
var api: CalendarrAPI {
CalendarrAPI(baseURL: appState.serverURL, token: appState.authToken)
}
var body: some View {
CalendarHostView(api: api, showMenu: $showMenu)
.ignoresSafeArea(edges: .bottom)
.sheet(isPresented: $showMenu) {
MenuSheet(api: api)
}
}
}
+121
View File
@@ -0,0 +1,121 @@
import SwiftUI
struct MenuSheet: View {
let api: CalendarrAPI
@Environment(AppState.self) var appState
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
@State private var isSyncing = false
var body: some View {
NavigationStack {
List {
// User info header
Section {
HStack(spacing: 12) {
Circle()
.fill(Color.accentColor)
.frame(width: 44, height: 44)
.overlay {
Text(appState.username.prefix(1).uppercased())
.font(.title3.bold())
.foregroundStyle(.white)
}
VStack(alignment: .leading, spacing: 2) {
Text(appState.username).font(.headline)
Text(appState.serverURL
.replacingOccurrences(of: "https://", with: "")
.replacingOccurrences(of: "http://", with: ""))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
if appState.isAdmin {
Spacer()
Text(L10n.t("menu.admin", appLang))
.font(.caption2.weight(.semibold))
.padding(.horizontal, 8).padding(.vertical, 3)
.background(Color.accentColor.opacity(0.15))
.foregroundStyle(Color.accentColor)
.clipShape(Capsule())
}
}
.padding(.vertical, 4)
}
Section(L10n.t("menu.section.settings", appLang)) {
NavigationLink {
ProfileView(api: api)
} label: {
Label(L10n.t("menu.profile", appLang), systemImage: "person.circle")
}
NavigationLink {
SettingsView(api: api)
} label: {
Label(L10n.t("menu.appearance", appLang), systemImage: "paintpalette")
}
NavigationLink {
AccountsView(api: api)
} label: {
Label(L10n.t("menu.accounts", appLang), systemImage: "tray.2")
}
NavigationLink {
GroupsView(api: api)
} label: {
Label(L10n.t("groups.title", appLang), systemImage: "person.2")
}
NavigationLink {
ServerView()
} label: {
Label(L10n.t("menu.server", appLang), systemImage: "server.rack")
}
}
Section(L10n.t("menu.sync.section", appLang)) {
Button {
Task { await syncNow() }
} label: {
HStack {
Label(L10n.t("menu.sync", appLang), systemImage: "arrow.triangle.2.circlepath")
Spacer()
if isSyncing { ProgressView() }
}
}
.disabled(isSyncing)
}
Section {
Button(role: .destructive) {
dismiss()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
appState.logout()
}
} label: {
Label(L10n.t("menu.logout", appLang), systemImage: "rectangle.portrait.and.arrow.right")
}
}
}
.navigationTitle(L10n.t("nav.menu", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(L10n.t("nav.done", appLang)) { dismiss() }
}
}
}
}
/// Manual sync: pull appearance/behaviour settings from the server, then
/// ask the calendar host to re-fetch events (cache-busting).
private func syncNow() async {
isSyncing = true
await SettingsSync.pull(api: api)
NotificationCenter.default.post(name: .manualSyncRequested, object: nil)
isSyncing = false
dismiss()
}
}
+327
View File
@@ -0,0 +1,327 @@
import SwiftUI
struct ProfileView: View {
let api: CalendarrAPI
@State private var profile: UserProfile?
@State private var isLoading = true
@State private var newEmail = ""
@State private var currentPW = ""
@State private var newPW = ""
@State private var confirmPW = ""
@State private var toast = ""
@State private var showToast = false
@State private var show2FASetup = false
@State private var show2FADisable = false
@State private var totpQR = ""
@State private var totpSecret = ""
@State private var totpCode = ""
@State private var disablePW = ""
@State private var isSaving2FA = false
@AppStorage("appLanguage") private var appLang = "system"
var body: some View {
NavigationStack {
Group {
if isLoading {
ProgressView(L10n.t("profile.loading", appLang))
} else if let profile {
Form {
kontoSection(profile: profile)
passwordSection
twoFASection(profile: profile)
adminNoteSection
}
}
}
.navigationTitle(L10n.t("profile.title", appLang))
.navigationBarTitleDisplayMode(.large)
.overlay(alignment: .bottom) {
if showToast {
Text(toast)
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background(.regularMaterial)
.clipShape(Capsule())
.padding(.bottom, 20)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.animation(.easeInOut, value: showToast)
.sheet(isPresented: $show2FASetup) {
TwoFASetupSheet(
qrURL: totpQR,
secret: totpSecret,
code: $totpCode,
isSaving: isSaving2FA
) {
Task { await enable2FA() }
}
}
.sheet(isPresented: $show2FADisable) {
TwoFADisableSheet(password: $disablePW) {
Task { await disable2FA() }
}
}
}
.task { await load() }
}
func kontoSection(profile: UserProfile) -> some View {
Section(L10n.t("profile.account", appLang)) {
HStack {
Text(L10n.t("profile.username", appLang))
Spacer()
Text(profile.username)
.foregroundStyle(.secondary)
}
HStack {
Text(L10n.t("profile.role", appLang))
Spacer()
Text(profile.isAdmin
? L10n.t("profile.role.admin", appLang)
: L10n.t("profile.role.user", appLang))
.foregroundStyle(.secondary)
}
HStack {
Text(L10n.t("profile.email", appLang))
Spacer()
TextField(L10n.t("profile.no_email", appLang), text: $newEmail)
.multilineTextAlignment(.trailing)
.foregroundStyle(.secondary)
.keyboardType(.emailAddress)
.textInputAutocapitalization(.never)
}
Button(L10n.t("profile.save_email", appLang)) {
Task { await saveEmail() }
}
.foregroundStyle(Color.accentColor)
}
}
var passwordSection: some View {
Section(L10n.t("profile.change_password", appLang)) {
SecureField(L10n.t("profile.current_password", appLang), text: $currentPW)
SecureField(L10n.t("profile.new_password", appLang), text: $newPW)
SecureField(L10n.t("profile.new_password_repeat", appLang), text: $confirmPW)
Button(L10n.t("profile.change_password", appLang)) {
Task { await changePassword() }
}
.foregroundStyle(Color.accentColor)
.disabled(currentPW.isEmpty || newPW.isEmpty || confirmPW.isEmpty)
}
}
func twoFASection(profile: UserProfile) -> some View {
Section(L10n.t("profile.twofa", appLang)) {
if profile.totpEnabled {
HStack {
Image(systemName: "checkmark.shield.fill")
.foregroundStyle(.green)
Text(L10n.t("profile.twofa.active", appLang))
}
Button(L10n.t("profile.twofa.disable", appLang)) {
show2FADisable = true
}
.foregroundStyle(.red)
} else {
HStack {
Image(systemName: "shield")
.foregroundStyle(.secondary)
Text(L10n.t("profile.twofa.inactive", appLang))
.foregroundStyle(.secondary)
}
Button(L10n.t("profile.twofa.enable", appLang)) {
Task { await setup2FA() }
}
.foregroundStyle(Color.accentColor)
}
}
}
var adminNoteSection: some View {
Section {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "info.circle")
.foregroundStyle(.secondary)
.padding(.top, 1)
Text(L10n.t("profile.admin_note", appLang))
.font(.footnote)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
}
private func load() async {
isLoading = true
defer { isLoading = false }
if let p = try? await api.getProfile() {
profile = p
newEmail = p.email ?? ""
}
}
private func saveEmail() async {
do {
try await api.updateEmail(newEmail)
showNotice(L10n.t("profile.email_saved", appLang))
} catch {
showNotice(error.localizedDescription)
}
}
private func changePassword() async {
guard newPW == confirmPW else {
showNotice(L10n.t("profile.password_mismatch", appLang))
return
}
do {
try await api.changePassword(current: currentPW, new: newPW)
currentPW = ""; newPW = ""; confirmPW = ""
showNotice(L10n.t("profile.password_changed", appLang))
} catch {
showNotice(error.localizedDescription)
}
}
private func setup2FA() async {
do {
let result = try await api.setup2FA()
totpSecret = result.secret
totpQR = result.qrUrl
totpCode = ""
show2FASetup = true
} catch {
showNotice(error.localizedDescription)
}
}
private func enable2FA() async {
isSaving2FA = true
do {
try await api.enable2FA(code: totpCode)
show2FASetup = false
showNotice(L10n.t("profile.twofa.enabled_toast", appLang))
await load()
} catch {
showNotice(error.localizedDescription)
}
isSaving2FA = false
}
private func disable2FA() async {
do {
try await api.disable2FA(password: disablePW)
show2FADisable = false
showNotice(L10n.t("profile.twofa.disabled_toast", appLang))
await load()
} catch {
showNotice(error.localizedDescription)
}
}
private func showNotice(_ msg: String) {
toast = msg
withAnimation { showToast = true }
Task {
try? await Task.sleep(for: .seconds(2))
withAnimation { showToast = false }
}
}
}
struct TwoFASetupSheet: View {
let qrURL: String
let secret: String
@Binding var code: String
let isSaving: Bool
let onEnable: () -> Void
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
var body: some View {
NavigationStack {
Form {
Section {
Text(L10n.t("twofa.scan_hint", appLang))
.font(.body)
}
Section(L10n.t("twofa.qr_section", appLang)) {
if let url = URL(string: qrURL) {
AsyncImage(url: url) { phase in
switch phase {
case .success(let img):
img.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 200)
.frame(maxWidth: .infinity)
default:
ProgressView()
.frame(maxWidth: .infinity)
}
}
.padding(.vertical, 8)
}
HStack {
Text(secret)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
Spacer()
Button {
UIPasteboard.general.string = secret
} label: {
Image(systemName: "doc.on.doc")
}
.foregroundStyle(Color.accentColor)
}
}
Section(L10n.t("twofa.confirmation", appLang)) {
TextField(L10n.t("twofa.code_placeholder", appLang), text: $code)
.keyboardType(.numberPad)
}
}
.navigationTitle(L10n.t("twofa.setup_title", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button(L10n.t("common.cancel", appLang)) { dismiss() } }
ToolbarItem(placement: .primaryAction) {
Button(L10n.t("twofa.activate", appLang)) { onEnable() }
.bold()
.disabled(code.count < 6 || isSaving)
}
}
}
}
}
struct TwoFADisableSheet: View {
@Binding var password: String
let onDisable: () -> Void
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
var body: some View {
NavigationStack {
Form {
Section(L10n.t("twofa.password_section", appLang)) {
SecureField(L10n.t("twofa.password_placeholder", appLang), text: $password)
}
}
.navigationTitle(L10n.t("twofa.disable_title", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button(L10n.t("common.cancel", appLang)) { dismiss() } }
ToolbarItem(placement: .primaryAction) {
Button(L10n.t("twofa.disable", appLang)) { onDisable() }
.bold()
.foregroundStyle(.red)
.disabled(password.isEmpty)
}
}
}
}
}
+18
View File
@@ -0,0 +1,18 @@
import SwiftUI
struct RootView: View {
@Environment(AppState.self) var appState
var body: some View {
if !appState.isConfigured {
ServerSetupView()
} else if !appState.isLoggedIn {
LoginView()
} else {
MainTabView()
// Renew an SSO session before it lapses, so a user signed in
// via a provider is not bounced back to the login screen.
.task { await OIDCSessionRefresher.refreshIfNeeded(appState) }
}
}
}
@@ -0,0 +1,95 @@
import SwiftUI
struct ServerSetupView: View {
@Environment(AppState.self) var appState
@AppStorage("appLanguage") private var appLang = "system"
@State private var urlInput = ""
@State private var error = ""
@State private var isChecking = false
var body: some View {
NavigationStack {
VStack(spacing: 0) {
Spacer()
VStack(spacing: 24) {
VStack(spacing: 8) {
Image(systemName: "calendar")
.font(.system(size: 56, weight: .light))
.foregroundStyle(Color.accentColor)
Text("Calendarr")
.font(.largeTitle.bold())
Text(L10n.t("server.connect_title", appLang))
.font(.subheadline)
.foregroundStyle(.secondary)
}
VStack(alignment: .leading, spacing: 8) {
Text(L10n.t("server.url", appLang))
.font(.footnote.weight(.medium))
.foregroundStyle(.secondary)
TextField("https://calendarr.example.com", text: $urlInput)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
.padding(12)
.background(.quaternary)
.clipShape(RoundedRectangle(cornerRadius: 10))
if !error.isEmpty {
Text(error)
.font(.caption)
.foregroundStyle(.red)
}
}
Button {
Task { await connect() }
} label: {
HStack {
if isChecking {
ProgressView()
.tint(.white)
} else {
Text(L10n.t("server.connect", appLang))
.fontWeight(.semibold)
}
}
.frame(maxWidth: .infinity)
.padding(14)
.background(Color.accentColor)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.disabled(urlInput.isEmpty || isChecking)
}
.padding(.horizontal, 32)
Spacer()
Text("© 2026 Scarriffleservices")
.font(.caption2)
.foregroundStyle(.tertiary)
.padding(.bottom, 24)
}
.navigationBarHidden(true)
}
}
private func connect() async {
isChecking = true
error = ""
defer { isChecking = false }
var url = urlInput.trimmingCharacters(in: .whitespacesAndNewlines)
if !url.hasPrefix("http") { url = "https://" + url }
if url.hasSuffix("/") { url = String(url.dropLast()) }
do {
_ = try await CalendarrAPI.checkSetupRequired(baseURL: url)
appState.saveServer(url: url)
} catch {
self.error = L10n.t("server.unreachable", appLang)
}
}
}
+170
View File
@@ -0,0 +1,170 @@
import SwiftUI
struct ServerView: View {
@Environment(AppState.self) var appState
@State private var showLogoutConfirm = false
@State private var showChangeServer = false
@State private var showImpressum = false
@AppStorage("appLanguage") private var appLang = "system"
var serverHost: String {
appState.serverURL
.replacingOccurrences(of: "https://", with: "")
.replacingOccurrences(of: "http://", with: "")
}
var body: some View {
NavigationStack {
Form {
Section(L10n.t("server.connected", appLang)) {
HStack {
Image(systemName: "server.rack")
.foregroundStyle(Color.accentColor)
.frame(width: 28)
VStack(alignment: .leading, spacing: 2) {
Text(serverHost)
.font(.body)
Text(appState.serverURL)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
HStack {
Image(systemName: "person.fill")
.foregroundStyle(.secondary)
.frame(width: 28)
Text(appState.username)
if appState.isAdmin {
Spacer()
Text(L10n.t("menu.admin", appLang))
.font(.caption.weight(.semibold))
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color.accentColor.opacity(0.15))
.foregroundStyle(Color.accentColor)
.clipShape(Capsule())
}
}
}
Section {
Button {
showChangeServer = true
} label: {
HStack {
Image(systemName: "arrow.triangle.swap")
.frame(width: 28)
Text(L10n.t("server.switch", appLang))
}
}
.foregroundStyle(.primary)
Button(role: .destructive) {
showLogoutConfirm = true
} label: {
HStack {
Image(systemName: "rectangle.portrait.and.arrow.right")
.frame(width: 28)
Text(L10n.t("menu.logout", appLang))
}
}
}
Section(L10n.t("server.info", appLang)) {
Button {
showImpressum = true
} label: {
HStack {
Image(systemName: "info.circle")
.frame(width: 28)
Text(L10n.t("server.imprint", appLang))
}
}
.foregroundStyle(.secondary)
HStack {
Text(L10n.t("server.version", appLang))
Spacer()
Text("1.0")
.foregroundStyle(.secondary)
}
}
}
.navigationTitle(L10n.t("server.title", appLang))
.navigationBarTitleDisplayMode(.large)
.confirmationDialog(L10n.t("server.logout_title", appLang), isPresented: $showLogoutConfirm, titleVisibility: .visible) {
Button(L10n.t("menu.logout", appLang), role: .destructive) {
appState.logout()
}
Button(L10n.t("common.cancel", appLang), role: .cancel) {}
} message: {
Text(String(format: L10n.t("server.logout_msg", appLang), serverHost))
}
.confirmationDialog(L10n.t("server.switch", appLang), isPresented: $showChangeServer, titleVisibility: .visible) {
Button(L10n.t("server.switch", appLang), role: .destructive) {
appState.resetServer()
}
Button(L10n.t("common.cancel", appLang), role: .cancel) {}
} message: {
Text(String(format: L10n.t("server.switch_msg", appLang), serverHost))
}
.sheet(isPresented: $showImpressum) {
ImpressumView()
}
}
}
}
struct ImpressumView: View {
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Group {
Text(L10n.t("imprint.company", appLang))
.font(.title2.bold())
Text(L10n.t("imprint.role", appLang))
.foregroundStyle(.secondary)
}
Divider()
Text(L10n.t("imprint.copyright", appLang))
VStack(alignment: .leading, spacing: 6) {
Text(L10n.t("imprint.storage.title", appLang)).font(.headline)
Text(L10n.t("imprint.storage.body", appLang))
}
VStack(alignment: .leading, spacing: 6) {
Text(L10n.t("imprint.disclaimer.title", appLang)).font(.headline)
Text(L10n.t("imprint.disclaimer.body", appLang))
}
VStack(alignment: .leading, spacing: 6) {
Text(L10n.t("imprint.contact.title", appLang)).font(.headline)
Link("scarriffleservices@gmail.com", destination: URL(string: "mailto:scarriffleservices@gmail.com")!)
}
Divider()
Text("Calendarr v11 · iOS App v1.0")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(24)
}
.navigationTitle(L10n.t("server.imprint", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(L10n.t("common.close", appLang)) { dismiss() }
}
}
}
}
}
+461
View File
@@ -0,0 +1,461 @@
import SwiftUI
struct SettingsView: View {
let api: CalendarrAPI
@AppStorage("liquidGlass") private var liquidGlass = false
@AppStorage("hideMenuButton") private var hideMenuButton = false
@AppStorage("cacheMonths") private var cacheMonths = 3
@AppStorage("appLanguage") private var appLang = "system"
@AppStorage("monthDividerColor") private var dividerHex = "#7090C0"
@AppStorage("monthLabelColor") private var labelHex = "#7090C0"
@AppStorage("todayColor") private var todayHex = "#4285F4"
@AppStorage("textColor") private var textHex = "#FFFFFF"
@AppStorage("backgroundColor") private var bgHex = "#000000"
@AppStorage("lineColor") private var lineHex = "#3A3A52"
// Device-local top-bar/surface colour. Empty = translucent .bar material.
@AppStorage("surfaceColor") private var surfaceHex = ""
@AppStorage("primaryColor") private var primaryHex = "#4285F4"
@AppStorage("accentColor") private var accentHex = "#EA4335"
// iOS-only opacity controls (drive secondary text / grid-line opacity in the
// calendar views); kept device-local, not part of the cross-device sync.
@AppStorage("textContrast") private var textContrast = 3
@AppStorage("lineContrast") private var lineContrast = 3
@AppStorage("hourHeight") private var hourHeight = 60
@AppStorage("defaultView") private var defaultView = "month"
@AppStorage("weekStartDay") private var weekStartDay = "monday"
@AppStorage("dimPastEvents") private var dimPastEvents = false
@AppStorage("monthViewPaged") private var monthViewPaged = false
@AppStorage("defaultReminderMinutes") private var defaultReminderMinutes = -1
@AppStorage("defaultEventDurationMinutes") private var defaultEventDurationMinutes = 60
// Profile chapter (server-backed; loaded on appear). Account settings are not
// part of the per-device sync — they are always account-wide.
@State private var displayName = ""
@State private var loginName = ""
@State private var email = ""
@State private var privateVisibility = "busy"
@State private var groupVisibleId = 0 // 0 = none
@State private var ownLocalCals: [LocalCalendar] = []
@State private var directoryHidden = false
@State private var profileMsg = ""
// Per-setting sync flags (account-wide; refreshed from the server on pull).
@State private var syncFlags: [String: Bool] = SettingsSync.flags()
// Canonical default colours (single source for the reset buttons).
private static let defaultHex: [String: String] = [
"primaryColor": "#4285F4", "accentColor": "#EA4335", "todayColor": "#4285F4",
"textColor": "#FFFFFF", "backgroundColor": "#000000", "lineColor": "#3A3A52",
"monthDividerColor": "#7090C0", "monthLabelColor": "#7090C0",
]
var body: some View {
NavigationStack {
Form {
globalSyncSection
profilSection
privatsphaereSection
geteilterKalenderSection
termineSection
ansichtSection
farbenSection
cacheSection
geraetSection
}
.navigationTitle(L10n.t("settings.title", appLang))
.navigationBarTitleDisplayMode(.large)
}
// Reflect the latest server values (and flag map) when opening the screen.
.task { await SettingsSync.pull(api: api); syncFlags = SettingsSync.flags() }
.task { await loadProfile() }
// Value edits update widgets live; a synced value is also pushed (debounced).
// `push` decides what actually gets sent based on each key's flag.
.onChange(of: primaryHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
.onChange(of: accentHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
.onChange(of: todayHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
.onChange(of: textHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
.onChange(of: bgHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
.onChange(of: lineHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
.onChange(of: dividerHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
.onChange(of: labelHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
.onChange(of: hourHeight) { _, _ in SettingsSync.push(api: api) }
.onChange(of: defaultView) { _, _ in SettingsSync.push(api: api) }
.onChange(of: weekStartDay) { _, _ in SettingsSync.push(api: api) }
.onChange(of: dimPastEvents) { _, _ in SettingsSync.push(api: api) }
.onChange(of: monthViewPaged){ _, _ in SettingsSync.push(api: api) }
.onChange(of: cacheMonths) { _, _ in SettingsSync.push(api: api) }
.onChange(of: defaultEventDurationMinutes) { _, _ in SettingsSync.push(api: api) }
.onChange(of: appLang) { _, _ in WidgetStore.republishAppearanceOnly() }
}
// MARK: – Reusable row builders
/// Leading per-row sync toggle: linked (accent) = synced across devices.
@ViewBuilder
private func syncIcon(_ key: String) -> some View {
let on = syncFlags[key] == true
Button {
SettingsSync.setSynced(key, !on, api: api)
syncFlags = SettingsSync.flags()
} label: {
Image(systemName: on ? "link.circle.fill" : "link.circle")
.imageScale(.large)
.foregroundStyle(on ? Color.accentColor : Color.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel(L10n.t("settings.sync_this", appLang))
}
private func colorBinding(_ hex: Binding<String>) -> Binding<Color> {
Binding(get: { Color(hex: hex.wrappedValue) }, set: { hex.wrappedValue = $0.toHex() })
}
// Surface colour: empty string means "auto" (translucent .bar); the picker
// shows a neutral dark until the user chooses a concrete colour.
private var surfaceBinding: Binding<Color> {
Binding(
get: { Color(hex: surfaceHex.isEmpty ? "#1C1C1E" : surfaceHex) },
set: { surfaceHex = $0.toHex() }
)
}
@ViewBuilder
private func colorRow(_ syncKey: String, _ defaultsKey: String, _ label: String, _ hex: Binding<String>) -> some View {
HStack(spacing: 12) {
syncIcon(syncKey)
Text(label)
Spacer()
ColorPicker("", selection: colorBinding(hex), supportsOpacity: false)
.labelsHidden()
Text(hex.wrappedValue.uppercased())
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
.frame(width: 64, alignment: .trailing)
Button {
hex.wrappedValue = Self.defaultHex[defaultsKey] ?? "#000000"
} label: {
Image(systemName: "arrow.uturn.backward")
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.accessibilityLabel(L10n.t("settings.reset", appLang))
}
}
// MARK: – Global sync
var globalSyncSection: some View {
Section {
Toggle(isOn: allSyncedBinding) {
Label {
VStack(alignment: .leading, spacing: 2) {
Text(L10n.t("settings.sync_all", appLang))
Text(L10n.t("settings.sync_all.desc", appLang))
.font(.caption).foregroundStyle(.secondary)
}
} icon: {
Image(systemName: "arrow.triangle.2.circlepath").foregroundStyle(.teal)
}
}
.tint(Color.accentColor)
}
}
private var allSyncedBinding: Binding<Bool> {
Binding(
get: { SettingsSync.syncableKeys.allSatisfy { syncFlags[$0] == true } },
set: { on in SettingsSync.setAllSynced(on, api: api); syncFlags = SettingsSync.flags() }
)
}
// MARK: – Profil (account identity — not synced)
var profilSection: some View {
Section(L10n.t("settings.nav.profile", appLang)) {
HStack {
Text(L10n.t("profile.display_name", appLang))
Spacer()
TextField(L10n.t("profile.display_name", appLang), text: $displayName)
.multilineTextAlignment(.trailing)
}
HStack {
Text(L10n.t("profile.login_name", appLang))
Spacer()
Text(loginName).foregroundStyle(.secondary)
}
HStack {
Text(L10n.t("settings.email", appLang))
Spacer()
TextField(L10n.t("settings.email", appLang), text: $email)
.multilineTextAlignment(.trailing)
.keyboardType(.emailAddress)
.autocapitalization(.none)
}
Toggle(isOn: $directoryHidden) {
VStack(alignment: .leading, spacing: 2) {
Text(L10n.t("settings.directory_hidden", appLang))
Text(L10n.t("settings.directory_hidden.desc", appLang))
.font(.caption).foregroundStyle(.secondary)
}
}
Button(L10n.t("event.save", appLang)) { Task { await saveProfile() } }
if !profileMsg.isEmpty {
Text(profileMsg).font(.caption).foregroundStyle(.secondary)
}
}
}
// MARK: – Privatsphäre (account — not synced)
var privatsphaereSection: some View {
Section {
Picker(L10n.t("settings.private_visibility", appLang), selection: $privateVisibility) {
Text(L10n.t("settings.private.busy", appLang)).tag("busy")
Text(L10n.t("settings.private.hidden", appLang)).tag("hidden")
}
.onChange(of: privateVisibility) { _, v in
Task { try? await api.updatePrivateVisibility(v) }
}
} header: {
Text(L10n.t("settings.privacy", appLang))
} footer: {
Text(L10n.t("settings.private_visibility.desc", appLang)).font(.caption)
}
}
// MARK: – Geteilter Kalender (account — not synced)
var geteilterKalenderSection: some View {
Section {
Picker(L10n.t("settings.group_visible", appLang), selection: $groupVisibleId) {
Text(L10n.t("group.visible.none", appLang)).tag(0)
ForEach(ownLocalCals) { cal in
Text(cal.name).tag(cal.id)
}
}
.onChange(of: groupVisibleId) { _, id in
Task { try? await api.updateGroupVisibleCalendar(id == 0 ? nil : id) }
}
} header: {
Text(L10n.t("settings.calendars", appLang))
} footer: {
Text(L10n.t("settings.group_visible.desc", appLang)).font(.caption)
}
}
// MARK: – Termine (synced)
var termineSection: some View {
Section(L10n.t("settings.calview", appLang)) {
HStack(spacing: 12) {
syncIcon("default_event_duration_minutes")
Text(L10n.t("settings.default_duration", appLang))
Spacer()
Picker("", selection: $defaultEventDurationMinutes) {
ForEach([15, 30, 45, 60, 90, 120, 240], id: \.self) { m in
Text(ReminderOptions.durationLabel(m, appLang)).tag(m)
}
}
.pickerStyle(.menu).labelsHidden()
}
HStack(spacing: 12) {
syncIcon("default_reminder_minutes")
Text(ReminderOptions.defaultTitle(appLang))
Spacer()
Picker("", selection: $defaultReminderMinutes) {
Text(ReminderOptions.off(appLang)).tag(-1)
ForEach(ReminderOptions.all, id: \.self) { m in
Text(ReminderOptions.label(m, appLang)).tag(m)
}
}
.pickerStyle(.menu).labelsHidden()
.onChange(of: defaultReminderMinutes) { _, _ in
SettingsSync.push(api: api)
NotificationCenter.default.post(name: .rescheduleReminders, object: nil)
}
}
}
}
// MARK: – Ansicht (synced)
var ansichtSection: some View {
Section(L10n.t("settings.appearance", appLang)) {
HStack(spacing: 12) {
syncIcon("default_view")
Text(L10n.t("settings.defaultview", appLang))
Spacer()
Picker("", selection: $defaultView) {
Text(L10n.t("view.month", appLang)).tag("month")
Text(L10n.t("view.week", appLang)).tag("week")
Text(L10n.t("view.day", appLang)).tag("day")
Text(L10n.t("view.quarter", appLang)).tag("quarter")
Text(L10n.t("view.agenda", appLang)).tag("agenda")
}
.pickerStyle(.menu).labelsHidden()
}
HStack(spacing: 12) {
syncIcon("week_start_day")
Text(L10n.t("settings.firstweekday", appLang))
Spacer()
Picker("", selection: $weekStartDay) {
Text(L10n.t("settings.monday", appLang)).tag("monday")
Text(L10n.t("settings.sunday", appLang)).tag("sunday")
}
.pickerStyle(.menu).labelsHidden()
}
HStack(spacing: 12) {
syncIcon("dim_past_events")
Toggle(L10n.t("settings.dimpast", appLang), isOn: $dimPastEvents).tint(Color.accentColor)
}
HStack(spacing: 12) {
syncIcon("month_view_paged")
Text(L10n.t("settings.month_mode", appLang))
Spacer()
Picker("", selection: $monthViewPaged) {
Text(L10n.t("settings.month_mode.scroll", appLang)).tag(false)
Text(L10n.t("settings.month_mode.paged", appLang)).tag(true)
}
.pickerStyle(.menu).labelsHidden()
}
HStack(spacing: 12) {
syncIcon("hour_height")
Text(L10n.t("settings.hourheight", appLang))
Spacer()
Picker("", selection: $hourHeight) {
Text(L10n.t("settings.hourheight.compact", appLang)).tag(28)
Text(L10n.t("settings.hourheight.normal", appLang)).tag(44)
Text(L10n.t("settings.hourheight.comfort", appLang)).tag(60)
Text(L10n.t("settings.hourheight.large", appLang)).tag(80)
}
.pickerStyle(.menu).labelsHidden()
}
}
}
// MARK: – Farben (synced)
var farbenSection: some View {
Section(L10n.t("settings.colors", appLang)) {
colorRow("primary_color", "primaryColor", L10n.t("settings.color.primary", appLang), $primaryHex)
colorRow("accent_color", "accentColor", L10n.t("settings.color.accent", appLang), $accentHex)
colorRow("today_color", "todayColor", L10n.t("settings.color.today", appLang), $todayHex)
colorRow("text_color", "textColor", L10n.t("settings.color.text", appLang), $textHex)
colorRow("bg_color", "backgroundColor", L10n.t("settings.color.background", appLang), $bgHex)
colorRow("line_color", "lineColor", L10n.t("settings.color.line", appLang), $lineHex)
colorRow("month_divider_color", "monthDividerColor", L10n.t("settings.color.divider", appLang), $dividerHex)
colorRow("month_label_color", "monthLabelColor", L10n.t("settings.color.label", appLang), $labelHex)
}
}
// MARK: – Cache (synced)
var cacheSection: some View {
Section {
HStack(spacing: 12) {
syncIcon("cache_months")
Text(L10n.t("settings.cache.range", appLang))
Spacer()
Picker("", selection: $cacheMonths) {
Text(L10n.t("settings.cache.1m", appLang)).tag(1)
Text(L10n.t("settings.cache.3m", appLang)).tag(3)
Text(L10n.t("settings.cache.6m", appLang)).tag(6)
Text(L10n.t("settings.cache.1y", appLang)).tag(12)
}
.pickerStyle(.menu).labelsHidden()
}
} header: {
Text(L10n.t("settings.cache.header", appLang))
} footer: {
Text(L10n.t("settings.cache.footer", appLang)).font(.caption)
}
}
// MARK: – Gerät (device-local: language, contrast, liquid glass)
var geraetSection: some View {
Section {
Picker(L10n.t("settings.language", appLang), selection: $appLang) {
Text(L10n.t("lang.system", appLang)).tag("system")
Text(L10n.t("lang.german", appLang)).tag("de")
Text(L10n.t("lang.english", appLang)).tag("en")
}
Picker(L10n.t("settings.textcontrast", appLang), selection: $textContrast) {
Text(L10n.t("settings.contrast.dark", appLang)).tag(1)
Text(L10n.t("settings.contrast.medium", appLang)).tag(2)
Text(L10n.t("settings.contrast.bright", appLang)).tag(3)
Text(L10n.t("settings.contrast.max", appLang)).tag(4)
}
Picker(L10n.t("settings.linecontrast", appLang), selection: $lineContrast) {
Text(L10n.t("settings.linecontrast.barely", appLang)).tag(1)
Text(L10n.t("settings.linecontrast.subtle", appLang)).tag(2)
Text(L10n.t("settings.linecontrast.normal", appLang)).tag(3)
Text(L10n.t("settings.linecontrast.strong", appLang)).tag(4)
}
Toggle(isOn: $liquidGlass) {
Label {
VStack(alignment: .leading, spacing: 2) {
Text(L10n.t("settings.liquidglass", appLang))
Text(L10n.t("settings.liquidglass.desc", appLang))
.font(.caption).foregroundStyle(.secondary)
}
} icon: {
Image(systemName: "sparkles").foregroundStyle(.blue)
}
}
.tint(Color.accentColor)
HStack(spacing: 12) {
Text(L10n.t("settings.color.surface", appLang))
Spacer()
Text(surfaceHex.isEmpty ? L10n.t("settings.surface.auto", appLang) : surfaceHex.uppercased())
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
ColorPicker("", selection: surfaceBinding, supportsOpacity: false)
.labelsHidden()
Button { surfaceHex = "" } label: { Image(systemName: "arrow.uturn.backward") }
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.accessibilityLabel(L10n.t("settings.surface.auto", appLang))
}
Toggle(L10n.t("settings.hide_menu_button", appLang), isOn: $hideMenuButton)
.tint(Color.accentColor)
} header: {
Text(L10n.t("settings.device", appLang))
} footer: {
Text(L10n.t("settings.device.footer", appLang)).font(.caption)
}
}
// MARK: – Profile load / save
private func loadProfile() async {
if let p = try? await api.getProfile() {
displayName = p.displayName ?? p.username
loginName = p.username
email = p.email ?? ""
directoryHidden = p.directoryHidden
}
if let s = try? await api.getSettings() {
privateVisibility = s.privateEventVisibility
groupVisibleId = s.groupVisibleCalendarId ?? 0
}
if let cals = try? await api.getLocalCalendars() {
// A birthday calendar may be shared directly, but never stand in as
// the group-visible personal calendar.
ownLocalCals = cals.filter { $0.owned && !$0.group && !$0.isBirthday }
}
}
private func saveProfile() async {
do {
_ = try await api.updateProfile(displayName: displayName.isEmpty ? nil : displayName,
username: nil,
email: email.isEmpty ? "" : email,
directoryHidden: directoryHidden)
UserDefaults.standard.set(displayName, forKey: "displayName")
profileMsg = L10n.t("settings.saved", appLang)
} catch {
profileMsg = error.localizedDescription
}
}
}