iOS: left side drawer (calendars + groups + nav) and opt-in top-bar colour
Replace the top-bar menu popup with an off-canvas left drawer, opened by the hamburger or a narrow left-edge swipe (kept off the content area so month/week swipe stay free). The drawer hosts the calendar visibility list, a view switcher, group switching, and quick access to Settings/Accounts/Profile/ Groups/Server plus sync and logout. - extract the filter list into reusable CalendarFilterContent (shared by the drawer and the still-present CalendarFilterSheet); the sheet is now a thin wrapper - new CalendarDrawer view; CalendarHostView hosts it as a ZStack overlay with a scrim and edge-swipe gesture, and presents drawer destinations as sheets - opt-in surface colour: surfaceColor (device-local) tints the flat top bar; empty keeps the translucent .bar material. Settings row with an Auto reset. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -86,6 +86,8 @@ private let strings: [String: [String: String]] = [
|
|||||||
"settings.sync_this": "Zwischen Geräten synchronisieren",
|
"settings.sync_this": "Zwischen Geräten synchronisieren",
|
||||||
"settings.reset": "Zurücksetzen",
|
"settings.reset": "Zurücksetzen",
|
||||||
"settings.appearance": "Ansicht",
|
"settings.appearance": "Ansicht",
|
||||||
|
"settings.color.surface": "Topbar-/Oberflächenfarbe",
|
||||||
|
"settings.surface.auto": "Auto (Milchglas)",
|
||||||
"settings.device": "Nur auf diesem Gerät",
|
"settings.device": "Nur auf diesem Gerät",
|
||||||
"settings.device.footer": "Diese Einstellungen gelten nur auf diesem Gerät und werden nicht synchronisiert.",
|
"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.sync_this": "Sync across devices",
|
||||||
"settings.reset": "Reset",
|
"settings.reset": "Reset",
|
||||||
"settings.appearance": "View",
|
"settings.appearance": "View",
|
||||||
|
"settings.color.surface": "Top bar / surface color",
|
||||||
|
"settings.surface.auto": "Auto (translucent)",
|
||||||
"settings.device": "This device only",
|
"settings.device": "This device only",
|
||||||
"settings.device.footer": "These settings apply to this device only and are not synced.",
|
"settings.device.footer": "These settings apply to this device only and are not synced.",
|
||||||
|
|
||||||
|
|||||||
151
Calendarr iOS/Views/Calendar/CalendarDrawer.swift
Normal file
151
Calendarr iOS/Views/Calendar/CalendarDrawer.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ struct CalendarHostView: View {
|
|||||||
@AppStorage("backgroundColor") private var bgHex = "#000000"
|
@AppStorage("backgroundColor") private var bgHex = "#000000"
|
||||||
@AppStorage("weekStartDay") private var weekStartDay = "monday"
|
@AppStorage("weekStartDay") private var weekStartDay = "monday"
|
||||||
@AppStorage("defaultView") private var defaultView = "month"
|
@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
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
@@ -31,6 +33,8 @@ struct CalendarHostView: View {
|
|||||||
@State private var didApplyDefaultView = false
|
@State private var didApplyDefaultView = false
|
||||||
@State private var groups: [CalGroup] = []
|
@State private var groups: [CalGroup] = []
|
||||||
@State private var showNewBirthday = false
|
@State private var showNewBirthday = false
|
||||||
|
@State private var showDrawer = false
|
||||||
|
@State private var drawerDestination: DrawerDestination? = nil
|
||||||
|
|
||||||
private var titleString: String {
|
private var titleString: String {
|
||||||
if store.viewType == .month {
|
if store.viewType == .month {
|
||||||
@@ -43,11 +47,66 @@ struct CalendarHostView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if liquidGlass {
|
ZStack(alignment: .leading) {
|
||||||
glassVariant
|
Group {
|
||||||
} else {
|
if liquidGlass { glassVariant } else { flatVariant }
|
||||||
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
|
// MARK: – Loading indicator
|
||||||
@@ -205,7 +264,9 @@ struct CalendarHostView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var topBar: some 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 {
|
@ViewBuilder private var groupBanner: some View {
|
||||||
@@ -231,51 +292,11 @@ struct CalendarHostView: View {
|
|||||||
Task { await forceReload() }
|
Task { await forceReload() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The single top-bar action: a compact popup holding view / filter /
|
/// Opens the side drawer (calendars + groups + navigation). Tinted when a
|
||||||
/// groups / sync, plus an "Einstellungen" entry that opens the full menu.
|
/// filter/group is active so the user sees state at a glance.
|
||||||
/// (Replaces the separate view / filter / group icons in the bar.)
|
|
||||||
private var menuButton: some View {
|
private var menuButton: some View {
|
||||||
Menu {
|
Button {
|
||||||
// View (fixed icon, not per-view)
|
withAnimation(.easeInOut(duration: 0.25)) { showDrawer = true }
|
||||||
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")
|
|
||||||
}
|
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "line.3.horizontal")
|
Image(systemName: "line.3.horizontal")
|
||||||
.font(.system(size: 18, weight: .medium))
|
.font(.system(size: 18, weight: .medium))
|
||||||
|
|||||||
280
Calendarr iOS/Views/CalendarFilterContent.swift
Normal file
280
Calendarr iOS/Views/CalendarFilterContent.swift
Normal file
@@ -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<String> = []
|
||||||
|
@State private var banished: Set<String> = []
|
||||||
|
@State private var reminderDisabled: Set<String> = []
|
||||||
|
@State private var allKeys: Set<String> = []
|
||||||
|
@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 {
|
||||||
|
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<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
|
||||||
|
|
||||||
|
var keys = Set<String>()
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,337 +1,23 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// Lets the user toggle which calendars contribute events to the displayed
|
/// Modal wrapper around `CalendarFilterContent` (kept for contexts that still
|
||||||
/// calendar views (and the home-screen widgets). Filtering is purely
|
/// present the filter as a sheet). The drawer embeds the same content directly.
|
||||||
/// client-side: hidden keys live in UserDefaults via `CalendarStore`. No
|
|
||||||
/// server roundtrip is required to toggle visibility.
|
|
||||||
struct CalendarFilterSheet: View {
|
struct CalendarFilterSheet: View {
|
||||||
let api: CalendarrAPI
|
let api: CalendarrAPI
|
||||||
let store: CalendarStore
|
let store: CalendarStore
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@AppStorage("appLanguage") private var appLang = "system"
|
@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> = []
|
|
||||||
/// Calendars whose events do not generate reminder notifications.
|
|
||||||
@State private var reminderDisabled: Set<String> = []
|
|
||||||
/// All non-banished keys discovered during load — used by bulk show/hide.
|
|
||||||
@State private var allKeys: Set<String> = []
|
|
||||||
/// 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<String> = []
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Group {
|
CalendarFilterContent(api: api, store: store)
|
||||||
if isLoading {
|
.navigationTitle(L10n.t("filter.title", appLang))
|
||||||
ProgressView(L10n.t("filter.loading", appLang))
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
} else if store.activeGroup != nil {
|
.toolbar {
|
||||||
groupFilterList
|
ToolbarItem(placement: .primaryAction) {
|
||||||
} else if allKeys.isEmpty {
|
Button(L10n.t("nav.done", appLang)) { dismiss() }
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.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<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
|
|
||||||
|
|
||||||
var keys = Set<String>()
|
|
||||||
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) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ struct SettingsView: View {
|
|||||||
@AppStorage("textColor") private var textHex = "#FFFFFF"
|
@AppStorage("textColor") private var textHex = "#FFFFFF"
|
||||||
@AppStorage("backgroundColor") private var bgHex = "#000000"
|
@AppStorage("backgroundColor") private var bgHex = "#000000"
|
||||||
@AppStorage("lineColor") private var lineHex = "#3A3A52"
|
@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("primaryColor") private var primaryHex = "#4285F4"
|
||||||
@AppStorage("accentColor") private var accentHex = "#EA4335"
|
@AppStorage("accentColor") private var accentHex = "#EA4335"
|
||||||
// iOS-only opacity controls (drive secondary text / grid-line opacity in the
|
// 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() })
|
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
|
@ViewBuilder
|
||||||
private func colorRow(_ syncKey: String, _ defaultsKey: String, _ label: String, _ hex: Binding<String>) -> some View {
|
private func colorRow(_ syncKey: String, _ defaultsKey: String, _ label: String, _ hex: Binding<String>) -> some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
@@ -386,6 +397,19 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tint(Color.accentColor)
|
.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: {
|
} header: {
|
||||||
Text(L10n.t("settings.device", appLang))
|
Text(L10n.t("settings.device", appLang))
|
||||||
} footer: {
|
} footer: {
|
||||||
|
|||||||
Reference in New Issue
Block a user