From 6881c68935d1b964217edbaf89bd9572765ea273 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 15 Jul 2026 16:19:29 +0200 Subject: [PATCH] iOS drawer: flat reorderable calendars, long-press menu, left-swipe close, header actions Testing feedback: - calendars are now one flat, drag-reorderable list (no per-source grouping); order is device-local (CalendarStore.calendarOrder, mirrors the web cal_order). Reordering is done via a 'Sort' toggle (native edit mode with drag handles). - banish + reminder-mute moved from row swipe actions to a long-press context menu, which frees the horizontal swipe: swiping the drawer left closes it. - the hamburger moved to the LEFT (drawer opens left); new device-local setting 'Hide menu button' (open via edge-swipe only), not synced. - the settings entry moved from the footer into the header as an icon, next to a restored manual-sync icon and the close button; the footer button is gone. Co-Authored-By: Claude Opus 4.8 --- Calendarr iOS/Models/CalendarStore.swift | 43 ++++ Calendarr iOS/Models/Localization.swift | 6 + .../Views/Calendar/CalendarDrawer.swift | 37 ++-- .../Views/Calendar/CalendarHostView.swift | 26 ++- .../Views/CalendarFilterContent.swift | 200 ++++++++---------- Calendarr iOS/Views/SettingsView.swift | 3 + 6 files changed, 174 insertions(+), 141 deletions(-) diff --git a/Calendarr iOS/Models/CalendarStore.swift b/Calendarr iOS/Models/CalendarStore.swift index 41004b2..f1a723b 100644 --- a/Calendarr iOS/Models/CalendarStore.swift +++ b/Calendarr iOS/Models/CalendarStore.swift @@ -165,6 +165,49 @@ class CalendarStore { return "\(source):\(id)" } + // MARK: – Calendar order (device-local, mirrors the web `cal_order`) + + private static let orderDefaultsKey = "calendarOrder" + + /// Persisted display order of calendar keys ("source:id"). Device-local; the + /// drawer's flat calendar list is sorted by this. + private(set) var calendarOrder: [String] = CalendarStore.loadOrder() + + private static func loadOrder() -> [String] { + guard let raw = UserDefaults.standard.string(forKey: orderDefaultsKey), + let data = raw.data(using: .utf8), + let arr = try? JSONDecoder().decode([String].self, from: data) + else { return [] } + return arr + } + + private func saveOrder() { + if let data = try? JSONEncoder().encode(calendarOrder), + let s = String(data: data, encoding: .utf8) { + UserDefaults.standard.set(s, forKey: Self.orderDefaultsKey) + } + } + + /// Replace the stored order (after a drag-reorder). + func setCalendarOrder(_ keys: [String]) { + calendarOrder = keys + saveOrder() + } + + /// Sort the given keys by the stored order (unknown keys → end), then persist + /// the normalized order so newly-added calendars stick (mirrors the web). + func ordered(_ keys: [String]) -> [String] { + let current = calendarOrder + func idx(_ key: String) -> Int { current.firstIndex(of: key) ?? Int.max } + let sorted = keys.sorted { a, b in + let ia = idx(a), ib = idx(b) + return ia != ib ? ia < ib : a < b + } + calendarOrder = sorted + saveOrder() + return sorted + } + // MARK: – Banished-calendar persistence private static let banishedKeysDefaultsKey = "banishedCalendarKeys" diff --git a/Calendarr iOS/Models/Localization.swift b/Calendarr iOS/Models/Localization.swift index 6b1e67e..f577319 100644 --- a/Calendarr iOS/Models/Localization.swift +++ b/Calendarr iOS/Models/Localization.swift @@ -89,6 +89,7 @@ private let strings: [String: [String: String]] = [ "settings.color.surface": "Topbar-/Oberflächenfarbe", "settings.surface.auto": "Auto (Milchglas)", "settings.device": "Nur auf diesem Gerät", + "settings.hide_menu_button": "Menü-Button ausblenden", "settings.device.footer": "Diese Einstellungen gelten nur auf diesem Gerät und werden nicht synchronisiert.", "settings.cache.header": "Vorladen", @@ -331,6 +332,8 @@ private let strings: [String: [String: String]] = [ "filter.empty": "Keine Kalender vorhanden", "filter.show_all": "Alle anzeigen", "filter.hide_all": "Alle ausblenden", + "filter.sort": "Sortieren", + "filter.done": "Fertig", "filter.button": "Kalender ein-/ausblenden", "filter.banish": "Dauerhaft ausblenden", "filter.reminders_on": "Benachrichtigungen an", @@ -460,6 +463,7 @@ private let strings: [String: [String: String]] = [ "settings.color.surface": "Top bar / surface color", "settings.surface.auto": "Auto (translucent)", "settings.device": "This device only", + "settings.hide_menu_button": "Hide menu button", "settings.device.footer": "These settings apply to this device only and are not synced.", "settings.cache.header": "Preloading", @@ -702,6 +706,8 @@ private let strings: [String: [String: String]] = [ "filter.empty": "No calendars available", "filter.show_all": "Show all", "filter.hide_all": "Hide all", + "filter.sort": "Sort", + "filter.done": "Done", "filter.button": "Show/hide calendars", "filter.banish": "Hide permanently", "filter.reminders_on": "Reminders on", diff --git a/Calendarr iOS/Views/Calendar/CalendarDrawer.swift b/Calendarr iOS/Views/Calendar/CalendarDrawer.swift index 0fe320e..e562e50 100644 --- a/Calendarr iOS/Views/Calendar/CalendarDrawer.swift +++ b/Calendarr iOS/Views/Calendar/CalendarDrawer.swift @@ -9,6 +9,7 @@ struct CalendarDrawer: View { let onSwitchGroup: (CalGroup?) -> Void let onSelectView: (CalViewType) -> Void let onOpenMenu: () -> Void + let onSync: () -> Void let onClose: () -> Void @Environment(AppState.self) private var appState @@ -25,8 +26,6 @@ struct CalendarDrawer: View { } Divider() CalendarFilterContent(api: api, store: store) - Divider() - navFooter } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .background(Color(.systemBackground)) @@ -51,10 +50,22 @@ struct CalendarDrawer: View { .font(.caption).foregroundStyle(.secondary).lineLimit(1) } Spacer() - Button { onClose() } label: { - Image(systemName: "xmark").font(.system(size: 15, weight: .semibold)) + HStack(spacing: 18) { + Button { onSync() } label: { + Image(systemName: "arrow.triangle.2.circlepath").font(.system(size: 16, weight: .medium)) + } + .buttonStyle(.plain).foregroundStyle(Color.accentColor) + .accessibilityLabel(L10n.t("menu.sync", appLang)) + Button { onOpenMenu() } label: { + Image(systemName: "gearshape").font(.system(size: 16, weight: .medium)) + } + .buttonStyle(.plain).foregroundStyle(Color.accentColor) + .accessibilityLabel(L10n.t("menu.section.settings", appLang)) + Button { onClose() } label: { + Image(systemName: "xmark").font(.system(size: 15, weight: .semibold)) + } + .buttonStyle(.plain).foregroundStyle(.secondary) } - .buttonStyle(.plain).foregroundStyle(.secondary) } .padding(.horizontal, 16).padding(.top, 12).padding(.bottom, 10) } @@ -111,20 +122,4 @@ struct CalendarDrawer: View { .buttonStyle(.plain) } - // MARK: – Nav footer (single entry into the full menu) - - private var navFooter: some View { - Button { onOpenMenu() } label: { - Label(L10n.t("menu.section.settings", appLang), systemImage: "gearshape") - .font(.body.weight(.medium)) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - } - .buttonStyle(.plain) - .foregroundStyle(Color.accentColor) - .padding(.horizontal, 16) - // Extra bottom inset so the button clears the home indicator and the - // screen's rounded corners. - .padding(.bottom, 20) - } } diff --git a/Calendarr iOS/Views/Calendar/CalendarHostView.swift b/Calendarr iOS/Views/Calendar/CalendarHostView.swift index 48e4ae0..6b11164 100644 --- a/Calendarr iOS/Views/Calendar/CalendarHostView.swift +++ b/Calendarr iOS/Views/Calendar/CalendarHostView.swift @@ -23,6 +23,8 @@ struct CalendarHostView: View { @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 @@ -63,6 +65,7 @@ struct CalendarHostView: View { 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) @@ -70,6 +73,16 @@ struct CalendarHostView: View { .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). @@ -169,15 +182,13 @@ struct CalendarHostView: View { .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) { - HStack(spacing: 8) { - Button(L10n.t("nav.today", appLang)) { store.moveToToday() }.font(.callout) - menuButton - } + Button(L10n.t("nav.today", appLang)) { store.moveToToday() }.font(.callout) } } .safeAreaInset(edge: .top, spacing: 0) { @@ -223,6 +234,8 @@ struct CalendarHostView: View { /// 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") @@ -235,7 +248,7 @@ struct CalendarHostView: View { .frame(width: 36, height: 36) } } - .padding(.leading, 6) + .padding(.leading, hideMenuButton ? 6 : 0) Spacer(minLength: 6) Text(titleString) .font(.headline) @@ -246,8 +259,7 @@ struct CalendarHostView: View { Button(L10n.t("nav.today", appLang)) { store.moveToToday() } .font(.callout).padding(.horizontal, 6) .lineLimit(1).fixedSize() - menuButton - .padding(.trailing, 2) + .padding(.trailing, 4) } .frame(height: 48) } diff --git a/Calendarr iOS/Views/CalendarFilterContent.swift b/Calendarr iOS/Views/CalendarFilterContent.swift index ff97b50..1838178 100644 --- a/Calendarr iOS/Views/CalendarFilterContent.swift +++ b/Calendarr iOS/Views/CalendarFilterContent.swift @@ -1,9 +1,17 @@ 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 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. +/// 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 @@ -19,6 +27,8 @@ struct CalendarFilterContent: View { @State private var banished: Set = [] @State private var reminderDisabled: Set = [] @State private var allKeys: Set = [] + @State private var rows: [CalRow] = [] + @State private var isSorting = false @State private var groupDetail: CalGroup? = nil @State private var hiddenGroup: Set = [] @@ -31,120 +41,71 @@ struct CalendarFilterContent: View { } else if allKeys.isEmpty { Text(L10n.t("filter.empty", appLang)).foregroundStyle(.secondary) } else { - List { - Section { - HStack(spacing: 10) { - Button(L10n.t("filter.show_all", appLang)) { - hidden = []; store.setHiddenCalendars(hidden) - } - .buttonStyle(.bordered).frame(maxWidth: .infinity) - Button(L10n.t("filter.hide_all", appLang)) { - hidden = allKeys; store.setHiddenCalendars(hidden) - } - .buttonStyle(.bordered).frame(maxWidth: .infinity) - } - .listRowInsets(EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12)) - // No surrounding grouped-section card — just the two button pills. - .listRowBackground(Color.clear) - } - 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") + 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) } } } - 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) - } - } + .listStyle(.plain) + .environment(\.editMode, .constant(isSorting ? .active : .inactive)) } } } .task { await load() } } + // Above the list so it stays tappable even while the list is in edit mode. + private var controlBar: some View { + HStack(spacing: 8) { + Button(L10n.t("filter.show_all", appLang)) { + hidden = []; store.setHiddenCalendars(hidden) + } + .buttonStyle(.bordered) + Button(L10n.t("filter.hide_all", appLang)) { + hidden = allKeys; store.setHiddenCalendars(hidden) + } + .buttonStyle(.bordered) + Spacer() + Button(isSorting ? L10n.t("filter.done", appLang) : L10n.t("filter.sort", appLang)) { + withAnimation { isSorting.toggle() } + } + .buttonStyle(.bordered) + } + .padding(.horizontal, 16).padding(.vertical, 8) + } + + private func move(from source: IndexSet, to destination: Int) { + rows.move(fromOffsets: source, toOffset: destination) + store.setCalendarOrder(rows.map(\.id)) + } + @ViewBuilder - private func row(name: String, colorHex: String, key: String, readOnly: Bool = false) -> some View { - let isVisible = !hidden.contains(key) + private func row(_ cal: CalRow) -> some View { + let isVisible = !hidden.contains(cal.id) Button { - if isVisible { hidden.insert(key) } else { hidden.remove(key) } - store.setCalendarHidden(key, hidden: isVisible) + 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: colorHex)) + .fill(Color(hex: cal.colorHex)) .frame(width: 14, height: 14) .opacity(isVisible ? 1.0 : 0.35) - Text(name) + Text(cal.name) .foregroundStyle(isVisible ? .primary : .secondary) .strikethrough(!isVisible, color: .secondary) - if readOnly { + if cal.readOnly { Image(systemName: "lock.fill").font(.caption2).foregroundStyle(.secondary) } Spacer() - if reminderDisabled.contains(key) { + if reminderDisabled.contains(cal.id) { Image(systemName: "bell.slash").font(.caption).foregroundStyle(.secondary) } Image(systemName: isVisible ? "eye" : "eye.slash") @@ -153,22 +114,20 @@ struct CalendarFilterContent: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .swipeActions(edge: .leading, allowsFullSwipe: false) { - let disabled = reminderDisabled.contains(key) + .contextMenu { Button { - toggleReminders(forKey: key) + 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") } - .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) + 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") } @@ -185,6 +144,8 @@ struct CalendarFilterContent: View { } } + // MARK: – Group overlay (unchanged: hide individual members / group calendar) + @ViewBuilder private var groupFilterList: some View { if let g = groupDetail { @@ -269,13 +230,26 @@ struct CalendarFilterContent: View { 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 + // 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 } diff --git a/Calendarr iOS/Views/SettingsView.swift b/Calendarr iOS/Views/SettingsView.swift index 65b654a..51b2372 100644 --- a/Calendarr iOS/Views/SettingsView.swift +++ b/Calendarr iOS/Views/SettingsView.swift @@ -3,6 +3,7 @@ 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" @@ -416,6 +417,8 @@ struct SettingsView: View { .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: {