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:
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user