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