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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-15 16:19:29 +02:00
parent daf0345120
commit 6881c68935
6 changed files with 174 additions and 141 deletions

View File

@@ -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"

View File

@@ -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",

View File

@@ -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,11 +50,23 @@ struct CalendarDrawer: View {
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
Spacer()
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)
}
}
.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)
}
}

View File

@@ -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
}
}
}
.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)
}

View File

@@ -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<String> = []
@State private var reminderDisabled: Set<String> = []
@State private var allKeys: Set<String> = []
@State private var rows: [CalRow] = []
@State private var isSorting = false
@State private var groupDetail: CalGroup? = nil
@State private var hiddenGroup: Set<String> = []
@@ -31,88 +41,11 @@ struct CalendarFilterContent: View {
} else if allKeys.isEmpty {
Text(L10n.t("filter.empty", appLang)).foregroundStyle(.secondary)
} else {
VStack(spacing: 0) {
controlBar
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")
}
}
}
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)"))
}
}
}
}
ForEach(rows) { cal in row(cal) }
.onMove(perform: move)
if !banished.isEmpty {
Section {
Text(L10n.t("filter.banished_footer", appLang))
@@ -120,31 +53,59 @@ struct CalendarFilterContent: View {
}
}
}
.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<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
// 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
}

View File

@@ -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: {