diff --git a/Calendarr iOS/Models/Localization.swift b/Calendarr iOS/Models/Localization.swift index 4562157..a74a724 100644 --- a/Calendarr iOS/Models/Localization.swift +++ b/Calendarr iOS/Models/Localization.swift @@ -86,6 +86,8 @@ private let strings: [String: [String: String]] = [ "settings.sync_this": "Zwischen Geräten synchronisieren", "settings.reset": "Zurücksetzen", "settings.appearance": "Ansicht", + "settings.color.surface": "Topbar-/Oberflächenfarbe", + "settings.surface.auto": "Auto (Milchglas)", "settings.device": "Nur auf diesem Gerät", "settings.device.footer": "Diese Einstellungen gelten nur auf diesem Gerät und werden nicht synchronisiert.", @@ -452,6 +454,8 @@ private let strings: [String: [String: String]] = [ "settings.sync_this": "Sync across devices", "settings.reset": "Reset", "settings.appearance": "View", + "settings.color.surface": "Top bar / surface color", + "settings.surface.auto": "Auto (translucent)", "settings.device": "This device only", "settings.device.footer": "These settings apply to this device only and are not synced.", diff --git a/Calendarr iOS/Views/Calendar/CalendarDrawer.swift b/Calendarr iOS/Views/Calendar/CalendarDrawer.swift new file mode 100644 index 0000000..65eed21 --- /dev/null +++ b/Calendarr iOS/Views/Calendar/CalendarDrawer.swift @@ -0,0 +1,151 @@ +import SwiftUI + +/// Destinations the drawer can open (presented as sheets by the host). +enum DrawerDestination: Int, Identifiable { + case profile, settings, accounts, groups, server + var id: Int { rawValue } +} + +/// The left side drawer: central navigation + calendar visibility + group +/// switching. Replaces the old menu popup and filter sheet. +struct CalendarDrawer: View { + let api: CalendarrAPI + let store: CalendarStore + let groups: [CalGroup] + let onSwitchGroup: (CalGroup?) -> Void + let onSelectView: (CalViewType) -> Void + let onOpenDestination: (DrawerDestination) -> 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) + Divider() + navFooter + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(Color(.systemBackground)) + } + + // MARK: – Header + + private var header: some View { + HStack(spacing: 12) { + Circle() + .fill(Color.accentColor) + .frame(width: 40, height: 40) + .overlay { + Text(appState.username.prefix(1).uppercased()) + .font(.headline).foregroundStyle(.white) + } + VStack(alignment: .leading, spacing: 2) { + Text(appState.username).font(.headline).lineLimit(1) + Text(appState.serverURL + .replacingOccurrences(of: "https://", with: "") + .replacingOccurrences(of: "http://", with: "")) + .font(.caption).foregroundStyle(.secondary).lineLimit(1) + } + Spacer() + Button { onClose() } label: { + Image(systemName: "xmark").font(.system(size: 15, weight: .semibold)) + } + .buttonStyle(.plain).foregroundStyle(.secondary) + } + .padding(.horizontal, 16).padding(.top, 12).padding(.bottom, 10) + } + + // 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, 8) + } + } + + // 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, 8) + } + } + + 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) + } + + // MARK: – Nav footer (quick access) + + private var navFooter: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 4) { + navButton(L10n.t("menu.appearance", appLang), "paintpalette") { onOpenDestination(.settings) } + navButton(L10n.t("menu.accounts", appLang), "tray.2") { onOpenDestination(.accounts) } + navButton(L10n.t("menu.profile", appLang), "person.circle") { onOpenDestination(.profile) } + navButton(L10n.t("groups.title", appLang), "person.2") { onOpenDestination(.groups) } + navButton(L10n.t("menu.server", appLang), "server.rack") { onOpenDestination(.server) } + navButton(L10n.t("menu.sync", appLang), "arrow.triangle.2.circlepath") { onSync() } + navButton(L10n.t("menu.logout", appLang), "rectangle.portrait.and.arrow.right", role: .destructive) { + appState.logout() + } + } + .padding(.horizontal, 12).padding(.vertical, 10) + } + } + + private func navButton(_ label: String, _ systemImage: String, role: ButtonRole? = nil, action: @escaping () -> Void) -> some View { + Button(role: role, action: action) { + VStack(spacing: 4) { + Image(systemName: systemImage).font(.system(size: 18)) + Text(label).font(.caption2).lineLimit(1) + } + .frame(width: 64) + .foregroundStyle(role == .destructive ? Color.red : Color.accentColor) + } + .buttonStyle(.plain) + } +} diff --git a/Calendarr iOS/Views/Calendar/CalendarHostView.swift b/Calendarr iOS/Views/Calendar/CalendarHostView.swift index 5412e5b..24dd1a3 100644 --- a/Calendarr iOS/Views/Calendar/CalendarHostView.swift +++ b/Calendarr iOS/Views/Calendar/CalendarHostView.swift @@ -21,6 +21,8 @@ struct CalendarHostView: View { @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 = "" @Environment(\.scenePhase) private var scenePhase @@ -31,6 +33,8 @@ struct CalendarHostView: View { @State private var didApplyDefaultView = false @State private var groups: [CalGroup] = [] @State private var showNewBirthday = false + @State private var showDrawer = false + @State private var drawerDestination: DrawerDestination? = nil private var titleString: String { if store.viewType == .month { @@ -43,11 +47,66 @@ struct CalendarHostView: View { } var body: some View { - if liquidGlass { - glassVariant - } else { - flatVariant + 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() }, + onOpenDestination: { dest in closeDrawer(); drawerDestination = dest }, + 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) } + // 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 } + } + } + ) + } + } + .sheet(item: $drawerDestination) { dest in + switch dest { + case .profile: ProfileView(api: api) + case .settings: SettingsView(api: api) + case .accounts: AccountsView(api: api) + case .groups: GroupsView(api: api) + case .server: ServerView() + } + } + } + + private var drawerWidth: CGFloat { min(UIScreen.main.bounds.width - 40, 360) } + + private func closeDrawer() { + withAnimation(.easeInOut(duration: 0.25)) { showDrawer = false } } // MARK: – Loading indicator @@ -205,7 +264,9 @@ struct CalendarHostView: View { } private var topBar: some View { - barContents.background(.bar) + barContents.background( + surfaceHex.isEmpty ? AnyShapeStyle(Material.bar) : AnyShapeStyle(Color(hex: surfaceHex)) + ) } @ViewBuilder private var groupBanner: some View { @@ -231,51 +292,11 @@ struct CalendarHostView: View { Task { await forceReload() } } - /// The single top-bar action: a compact popup holding view / filter / - /// groups / sync, plus an "Einstellungen" entry that opens the full menu. - /// (Replaces the separate view / filter / group icons in the bar.) + /// 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 { - Menu { - // View (fixed icon, not per-view) - Menu { - ForEach(CalViewType.allCases, id: \.self) { vt in - Button { store.viewType = vt } label: { - Label(vt.label(appLang), systemImage: store.viewType == vt ? "checkmark" : vt.systemImage) - } - } - } label: { - Label(L10n.t("view.change", appLang), systemImage: "rectangle.3.group") - } - // Filter - Button { showFilter = true } label: { - Label(L10n.t("filter.button", appLang), systemImage: "line.3.horizontal.decrease.circle") - } - // Groups - if !groups.isEmpty { - Menu { - Button { switchGroup(nil) } label: { - Label(L10n.t("groups.personal", appLang), - systemImage: store.activeGroup == nil ? "checkmark" : "person") - } - ForEach(groups) { g in - Button { switchGroup(g) } label: { - Label(g.name, - systemImage: store.activeGroup?.id == g.id ? "checkmark" : GroupIcons.symbol(g.icon)) - } - } - } label: { - Label(L10n.t("groups.title", appLang), systemImage: "person.2") - } - } - // Sync - Button { Task { await syncFromServer(force: true) } } label: { - Label(L10n.t("menu.sync", appLang), systemImage: "arrow.triangle.2.circlepath") - } - Divider() - // Full settings menu - Button { showMenu = true } label: { - Label(L10n.t("menu.section.settings", appLang), systemImage: "gearshape") - } + Button { + withAnimation(.easeInOut(duration: 0.25)) { showDrawer = true } } label: { Image(systemName: "line.3.horizontal") .font(.system(size: 18, weight: .medium)) diff --git a/Calendarr iOS/Views/CalendarFilterContent.swift b/Calendarr iOS/Views/CalendarFilterContent.swift new file mode 100644 index 0000000..b534b79 --- /dev/null +++ b/Calendarr iOS/Views/CalendarFilterContent.swift @@ -0,0 +1,280 @@ +import SwiftUI + +/// The calendar-visibility list, extracted so both the modal `CalendarFilterSheet` +/// and the side drawer render the exact same rows/logic. Filtering is purely +/// client-side (hidden keys in `CalendarStore`); server-managed sources also +/// reconcile their sidebar_hidden / reminders_enabled flags on load. +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 = [] + @State private var banished: Set = [] + @State private var reminderDisabled: Set = [] + @State private var allKeys: Set = [] + @State private var groupDetail: CalGroup? = nil + @State private var hiddenGroup: Set = [] + + 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 { + List { + Section { + Button(L10n.t("filter.show_all", appLang)) { + hidden = []; store.setHiddenCalendars(hidden) + } + Button(L10n.t("filter.hide_all", appLang)) { + hidden = allKeys; store.setHiddenCalendars(hidden) + } + } + let visibleLocals = localCalendars.filter { + !banished.contains(CalendarStore.calendarKey(source: "local", calendarId: "\($0.id)")) + } + if !visibleLocals.isEmpty { + Section(L10n.t("accounts.local.header", appLang)) { + ForEach(visibleLocals) { cal in + row(name: cal.owned ? cal.name : (cal.sharedBy ?? cal.name), + colorHex: cal.color, + key: CalendarStore.calendarKey(source: "local", calendarId: "\(cal.id)"), + readOnly: !cal.owned && cal.permission != "read_write") + } + } + } + ForEach(caldavAccounts) { acc in + let cals = (acc.calendars ?? []).filter { + !banished.contains(CalendarStore.calendarKey(source: "caldav", calendarId: "\($0.id)")) + } + if !cals.isEmpty { + Section(acc.name) { + ForEach(cals) { cal in + row(name: cal.name, + colorHex: cal.color ?? acc.color, + key: CalendarStore.calendarKey(source: "caldav", calendarId: "\(cal.id)")) + } + } + } + } + let visibleSubs = icalSubs.filter { + !banished.contains(CalendarStore.calendarKey(source: "ical", calendarId: "\($0.id)")) + } + if !visibleSubs.isEmpty { + Section(L10n.t("accounts.ical.header", appLang)) { + ForEach(visibleSubs) { sub in + row(name: sub.name, colorHex: sub.color, + key: CalendarStore.calendarKey(source: "ical", calendarId: "\(sub.id)")) + } + } + } + ForEach(googleAccounts) { acc in + let cals = (acc.calendars ?? []).filter { + !banished.contains(CalendarStore.calendarKey(source: "google", calendarId: "\($0.id)")) + } + if !cals.isEmpty { + Section(acc.email) { + ForEach(cals) { cal in + row(name: cal.name, + colorHex: cal.color ?? "#4285f4", + key: CalendarStore.calendarKey(source: "google", calendarId: "\(cal.id)")) + } + } + } + } + ForEach(haAccounts) { acc in + let cals = (acc.calendars ?? []).filter { + !banished.contains(CalendarStore.calendarKey(source: "homeassistant", calendarId: "\($0.id)")) + } + if !cals.isEmpty { + Section(acc.name) { + ForEach(cals) { cal in + row(name: cal.name, + colorHex: cal.color ?? "#46bdc6", + key: CalendarStore.calendarKey(source: "homeassistant", calendarId: "\(cal.id)")) + } + } + } + } + if !banished.isEmpty { + Section { + Text(L10n.t("filter.banished_footer", appLang)) + .font(.caption).foregroundStyle(.secondary) + } + } + } + } + } + .task { await load() } + } + + @ViewBuilder + private func row(name: String, colorHex: String, key: String, readOnly: Bool = false) -> some View { + let isVisible = !hidden.contains(key) + Button { + if isVisible { hidden.insert(key) } else { hidden.remove(key) } + store.setCalendarHidden(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) + if readOnly { + Image(systemName: "lock.fill").font(.caption2).foregroundStyle(.secondary) + } + Spacer() + if reminderDisabled.contains(key) { + Image(systemName: "bell.slash").font(.caption).foregroundStyle(.secondary) + } + Image(systemName: isVisible ? "eye" : "eye.slash") + .foregroundStyle(isVisible ? Color.accentColor : .secondary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .swipeActions(edge: .leading, allowsFullSwipe: false) { + let disabled = reminderDisabled.contains(key) + Button { + toggleReminders(forKey: key) + } label: { + Label(L10n.t(disabled ? "filter.reminders_on" : "filter.reminders_off", appLang), + systemImage: disabled ? "bell" : "bell.slash") + } + .tint(.orange) + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + hidden.remove(key) + banished.insert(key) + store.setCalendarBanished(key, banished: true) + pushBanishToServer(key: key, hidden: true) + } 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) } + } + } + + @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() + 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 + + var keys = Set() + for cal in localCalendars { keys.insert(CalendarStore.calendarKey(source: "local", calendarId: "\(cal.id)")) } + for acc in caldavAccounts { for cal in acc.calendars ?? [] { keys.insert(CalendarStore.calendarKey(source: "caldav", calendarId: "\(cal.id)")) } } + for sub in icalSubs { keys.insert(CalendarStore.calendarKey(source: "ical", calendarId: "\(sub.id)")) } + for acc in googleAccounts { for cal in acc.calendars ?? [] { keys.insert(CalendarStore.calendarKey(source: "google", calendarId: "\(cal.id)")) } } + for acc in haAccounts { for cal in acc.calendars ?? [] { keys.insert(CalendarStore.calendarKey(source: "homeassistant", calendarId: "\(cal.id)")) } } + allKeys = keys + 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) } + } +} diff --git a/Calendarr iOS/Views/CalendarFilterSheet.swift b/Calendarr iOS/Views/CalendarFilterSheet.swift index bd79e95..99c652f 100644 --- a/Calendarr iOS/Views/CalendarFilterSheet.swift +++ b/Calendarr iOS/Views/CalendarFilterSheet.swift @@ -1,337 +1,23 @@ import SwiftUI -/// Lets the user toggle which calendars contribute events to the displayed -/// calendar views (and the home-screen widgets). Filtering is purely -/// client-side: hidden keys live in UserDefaults via `CalendarStore`. No -/// server roundtrip is required to toggle visibility. +/// 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" - @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 = [] - @State private var banished: Set = [] - /// Calendars whose events do not generate reminder notifications. - @State private var reminderDisabled: Set = [] - /// All non-banished keys discovered during load — used by bulk show/hide. - @State private var allKeys: Set = [] - /// Group-mode: the active group's full detail (members + colours) and the - /// per-member / group-calendar hidden keys. - @State private var groupDetail: CalGroup? = nil - @State private var hiddenGroup: Set = [] - var body: some View { NavigationStack { - 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 { - List { - let visibleLocals = localCalendars.filter { - !banished.contains(CalendarStore.calendarKey(source: "local", calendarId: "\($0.id)")) - } - if !visibleLocals.isEmpty { - Section(L10n.t("accounts.local.header", appLang)) { - ForEach(visibleLocals) { cal in - // A calendar shared with me is shown under the owner's - // name and flagged read-only when I can't write to it. - row(name: cal.owned ? cal.name : (cal.sharedBy ?? cal.name), - colorHex: cal.color, - key: CalendarStore.calendarKey(source: "local", calendarId: "\(cal.id)"), - readOnly: !cal.owned && cal.permission != "read_write") - } - } - } - ForEach(caldavAccounts) { acc in - let cals = (acc.calendars ?? []).filter { - !banished.contains(CalendarStore.calendarKey(source: "caldav", calendarId: "\($0.id)")) - } - if !cals.isEmpty { - Section(acc.name) { - ForEach(cals) { cal in - row(name: cal.name, - colorHex: cal.color ?? acc.color, - key: CalendarStore.calendarKey(source: "caldav", calendarId: "\(cal.id)")) - } - } - } - } - let visibleSubs = icalSubs.filter { - !banished.contains(CalendarStore.calendarKey(source: "ical", calendarId: "\($0.id)")) - } - if !visibleSubs.isEmpty { - Section(L10n.t("accounts.ical.header", appLang)) { - ForEach(visibleSubs) { sub in - row(name: sub.name, colorHex: sub.color, - key: CalendarStore.calendarKey(source: "ical", calendarId: "\(sub.id)")) - } - } - } - ForEach(googleAccounts) { acc in - let cals = (acc.calendars ?? []).filter { - !banished.contains(CalendarStore.calendarKey(source: "google", calendarId: "\($0.id)")) - } - if !cals.isEmpty { - Section(acc.email) { - ForEach(cals) { cal in - row(name: cal.name, - colorHex: cal.color ?? "#4285f4", - key: CalendarStore.calendarKey(source: "google", calendarId: "\(cal.id)")) - } - } - } - } - ForEach(haAccounts) { acc in - let cals = (acc.calendars ?? []).filter { - !banished.contains(CalendarStore.calendarKey(source: "homeassistant", calendarId: "\($0.id)")) - } - if !cals.isEmpty { - Section(acc.name) { - ForEach(cals) { cal in - row(name: cal.name, - colorHex: cal.color ?? "#46bdc6", - key: CalendarStore.calendarKey(source: "homeassistant", calendarId: "\(cal.id)")) - } - } - } - } - if !banished.isEmpty { - Section { - Text(L10n.t("filter.banished_footer", appLang)) - .font(.caption) - .foregroundStyle(.secondary) - } - } + CalendarFilterContent(api: api, store: store) + .navigationTitle(L10n.t("filter.title", appLang)) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button(L10n.t("nav.done", appLang)) { dismiss() } } } - } - .navigationTitle(L10n.t("filter.title", appLang)) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Menu { - Button(L10n.t("filter.show_all", appLang)) { - hidden = [] - store.setHiddenCalendars(hidden) - } - Button(L10n.t("filter.hide_all", appLang)) { - hidden = allKeys - store.setHiddenCalendars(hidden) - } - } label: { - Image(systemName: "ellipsis.circle") - } - .disabled(allKeys.isEmpty) - } - ToolbarItem(placement: .primaryAction) { - Button(L10n.t("nav.done", appLang)) { dismiss() } - } - } } - .task { await load() } - } - - @ViewBuilder - private func row(name: String, colorHex: String, key: String, readOnly: Bool = false) -> some View { - let isVisible = !hidden.contains(key) - Button { - if isVisible { hidden.insert(key) } else { hidden.remove(key) } - // New hidden state == was-visible (flip). Previous code passed the - // inverse, which persisted the opposite of what the UI showed. - store.setCalendarHidden(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) - if readOnly { - Image(systemName: "lock.fill") - .font(.caption2) - .foregroundStyle(.secondary) - } - Spacer() - if reminderDisabled.contains(key) { - Image(systemName: "bell.slash") - .font(.caption) - .foregroundStyle(.secondary) - } - Image(systemName: isVisible ? "eye" : "eye.slash") - .foregroundStyle(isVisible ? Color.accentColor : .secondary) - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .swipeActions(edge: .leading, allowsFullSwipe: false) { - let disabled = reminderDisabled.contains(key) - Button { - toggleReminders(forKey: key) - } label: { - Label(L10n.t(disabled ? "filter.reminders_on" : "filter.reminders_off", appLang), - systemImage: disabled ? "bell" : "bell.slash") - } - .tint(.orange) - } - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button(role: .destructive) { - hidden.remove(key) - banished.insert(key) - store.setCalendarBanished(key, banished: true) - pushBanishToServer(key: key, hidden: true) - } label: { - Label(L10n.t("filter.banish", appLang), systemImage: "archivebox") - } - } - } - - /// Flip a calendar's reminder mute, persist locally + on the server, reschedule. - 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 filter (hide individual members / the group calendar) - - @ViewBuilder - private var groupFilterList: some View { - if let g = groupDetail { - List { - Section(header: Label(g.name, systemImage: GroupIcons.symbol(g.icon))) { - // Only members who actually share a calendar into the group — - // avoids phantom empty rows for members who share nothing. - 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 - // Group overlay: list members (+ the group calendar) to hide individually. - 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) - - // Reconcile banished state with the server's sidebar_hidden flags - // (server wins for CalDAV/Google/HA; local/ical keep their local state). - 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 - - // Reconcile reminder-muted state from the server's reminders_enabled flags. - var rd = Set() - 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 - - var keys = Set() - for cal in localCalendars { - keys.insert(CalendarStore.calendarKey(source: "local", calendarId: "\(cal.id)")) - } - for acc in caldavAccounts { - for cal in acc.calendars ?? [] { - keys.insert(CalendarStore.calendarKey(source: "caldav", calendarId: "\(cal.id)")) - } - } - for sub in icalSubs { - keys.insert(CalendarStore.calendarKey(source: "ical", calendarId: "\(sub.id)")) - } - for acc in googleAccounts { - for cal in acc.calendars ?? [] { - keys.insert(CalendarStore.calendarKey(source: "google", calendarId: "\(cal.id)")) - } - } - for acc in haAccounts { - for cal in acc.calendars ?? [] { - keys.insert(CalendarStore.calendarKey(source: "homeassistant", calendarId: "\(cal.id)")) - } - } - allKeys = keys - isLoading = false - } - - /// For server-backed sources, persist the banish on the server too. - 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) } } } diff --git a/Calendarr iOS/Views/SettingsView.swift b/Calendarr iOS/Views/SettingsView.swift index e09efbc..fd1cbc5 100644 --- a/Calendarr iOS/Views/SettingsView.swift +++ b/Calendarr iOS/Views/SettingsView.swift @@ -11,6 +11,8 @@ struct SettingsView: View { @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 @@ -107,6 +109,15 @@ struct SettingsView: View { 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 { + Binding( + get: { Color(hex: surfaceHex.isEmpty ? "#1C1C1E" : surfaceHex) }, + set: { surfaceHex = $0.toHex() } + ) + } + @ViewBuilder private func colorRow(_ syncKey: String, _ defaultsKey: String, _ label: String, _ hex: Binding) -> some View { HStack(spacing: 12) { @@ -386,6 +397,19 @@ struct SettingsView: View { } } .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)) + } } header: { Text(L10n.t("settings.device", appLang)) } footer: {