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:
@@ -165,6 +165,49 @@ class CalendarStore {
|
|||||||
return "\(source):\(id)"
|
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
|
// MARK: – Banished-calendar persistence
|
||||||
|
|
||||||
private static let banishedKeysDefaultsKey = "banishedCalendarKeys"
|
private static let banishedKeysDefaultsKey = "banishedCalendarKeys"
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ private let strings: [String: [String: String]] = [
|
|||||||
"settings.color.surface": "Topbar-/Oberflächenfarbe",
|
"settings.color.surface": "Topbar-/Oberflächenfarbe",
|
||||||
"settings.surface.auto": "Auto (Milchglas)",
|
"settings.surface.auto": "Auto (Milchglas)",
|
||||||
"settings.device": "Nur auf diesem Gerät",
|
"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.device.footer": "Diese Einstellungen gelten nur auf diesem Gerät und werden nicht synchronisiert.",
|
||||||
|
|
||||||
"settings.cache.header": "Vorladen",
|
"settings.cache.header": "Vorladen",
|
||||||
@@ -331,6 +332,8 @@ private let strings: [String: [String: String]] = [
|
|||||||
"filter.empty": "Keine Kalender vorhanden",
|
"filter.empty": "Keine Kalender vorhanden",
|
||||||
"filter.show_all": "Alle anzeigen",
|
"filter.show_all": "Alle anzeigen",
|
||||||
"filter.hide_all": "Alle ausblenden",
|
"filter.hide_all": "Alle ausblenden",
|
||||||
|
"filter.sort": "Sortieren",
|
||||||
|
"filter.done": "Fertig",
|
||||||
"filter.button": "Kalender ein-/ausblenden",
|
"filter.button": "Kalender ein-/ausblenden",
|
||||||
"filter.banish": "Dauerhaft ausblenden",
|
"filter.banish": "Dauerhaft ausblenden",
|
||||||
"filter.reminders_on": "Benachrichtigungen an",
|
"filter.reminders_on": "Benachrichtigungen an",
|
||||||
@@ -460,6 +463,7 @@ private let strings: [String: [String: String]] = [
|
|||||||
"settings.color.surface": "Top bar / surface color",
|
"settings.color.surface": "Top bar / surface color",
|
||||||
"settings.surface.auto": "Auto (translucent)",
|
"settings.surface.auto": "Auto (translucent)",
|
||||||
"settings.device": "This device only",
|
"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.device.footer": "These settings apply to this device only and are not synced.",
|
||||||
|
|
||||||
"settings.cache.header": "Preloading",
|
"settings.cache.header": "Preloading",
|
||||||
@@ -702,6 +706,8 @@ private let strings: [String: [String: String]] = [
|
|||||||
"filter.empty": "No calendars available",
|
"filter.empty": "No calendars available",
|
||||||
"filter.show_all": "Show all",
|
"filter.show_all": "Show all",
|
||||||
"filter.hide_all": "Hide all",
|
"filter.hide_all": "Hide all",
|
||||||
|
"filter.sort": "Sort",
|
||||||
|
"filter.done": "Done",
|
||||||
"filter.button": "Show/hide calendars",
|
"filter.button": "Show/hide calendars",
|
||||||
"filter.banish": "Hide permanently",
|
"filter.banish": "Hide permanently",
|
||||||
"filter.reminders_on": "Reminders on",
|
"filter.reminders_on": "Reminders on",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ struct CalendarDrawer: View {
|
|||||||
let onSwitchGroup: (CalGroup?) -> Void
|
let onSwitchGroup: (CalGroup?) -> Void
|
||||||
let onSelectView: (CalViewType) -> Void
|
let onSelectView: (CalViewType) -> Void
|
||||||
let onOpenMenu: () -> Void
|
let onOpenMenu: () -> Void
|
||||||
|
let onSync: () -> Void
|
||||||
let onClose: () -> Void
|
let onClose: () -> Void
|
||||||
|
|
||||||
@Environment(AppState.self) private var appState
|
@Environment(AppState.self) private var appState
|
||||||
@@ -25,8 +26,6 @@ struct CalendarDrawer: View {
|
|||||||
}
|
}
|
||||||
Divider()
|
Divider()
|
||||||
CalendarFilterContent(api: api, store: store)
|
CalendarFilterContent(api: api, store: store)
|
||||||
Divider()
|
|
||||||
navFooter
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
.background(Color(.systemBackground))
|
.background(Color(.systemBackground))
|
||||||
@@ -51,11 +50,23 @@ struct CalendarDrawer: View {
|
|||||||
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||||
}
|
}
|
||||||
Spacer()
|
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: {
|
Button { onClose() } label: {
|
||||||
Image(systemName: "xmark").font(.system(size: 15, weight: .semibold))
|
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)
|
.padding(.horizontal, 16).padding(.top, 12).padding(.bottom, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,20 +122,4 @@ struct CalendarDrawer: View {
|
|||||||
.buttonStyle(.plain)
|
.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"
|
@AppStorage("defaultView") private var defaultView = "month"
|
||||||
// Opt-in: empty keeps the translucent `.bar` material; a hex tints the top bar.
|
// Opt-in: empty keeps the translucent `.bar` material; a hex tints the top bar.
|
||||||
@AppStorage("surfaceColor") private var surfaceHex = ""
|
@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
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
@@ -63,6 +65,7 @@ struct CalendarHostView: View {
|
|||||||
onSwitchGroup: { g in closeDrawer(); switchGroup(g) },
|
onSwitchGroup: { g in closeDrawer(); switchGroup(g) },
|
||||||
onSelectView: { vt in store.viewType = vt; closeDrawer() },
|
onSelectView: { vt in store.viewType = vt; closeDrawer() },
|
||||||
onOpenMenu: { closeDrawer(); showMenu = true },
|
onOpenMenu: { closeDrawer(); showMenu = true },
|
||||||
|
onSync: { closeDrawer(); Task { await syncFromServer(force: true) } },
|
||||||
onClose: { closeDrawer() }
|
onClose: { closeDrawer() }
|
||||||
)
|
)
|
||||||
.frame(width: drawerWidth)
|
.frame(width: drawerWidth)
|
||||||
@@ -70,6 +73,16 @@ struct CalendarHostView: View {
|
|||||||
.shadow(color: .black.opacity(showDrawer ? 0.25 : 0), radius: 12, x: 4)
|
.shadow(color: .black.opacity(showDrawer ? 0.25 : 0), radius: 12, x: 4)
|
||||||
.offset(x: showDrawer ? 0 : -(drawerWidth + 60))
|
.offset(x: showDrawer ? 0 : -(drawerWidth + 60))
|
||||||
.animation(.easeInOut(duration: 0.25), value: showDrawer)
|
.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
|
// A narrow leading strip opens the drawer via edge-swipe (kept off the
|
||||||
// content area so month paging / week swipe stay free).
|
// content area so month paging / week swipe stay free).
|
||||||
@@ -169,15 +182,13 @@ struct CalendarHostView: View {
|
|||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarLeading) {
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
HStack(spacing: 2) {
|
HStack(spacing: 2) {
|
||||||
|
if !hideMenuButton { menuButton }
|
||||||
Button { store.navigatePrev() } label: { Image(systemName: "chevron.left") }
|
Button { store.navigatePrev() } label: { Image(systemName: "chevron.left") }
|
||||||
Button { store.navigateNext() } label: { Image(systemName: "chevron.right") }
|
Button { store.navigateNext() } label: { Image(systemName: "chevron.right") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
HStack(spacing: 8) {
|
|
||||||
Button(L10n.t("nav.today", appLang)) { store.moveToToday() }.font(.callout)
|
Button(L10n.t("nav.today", appLang)) { store.moveToToday() }.font(.callout)
|
||||||
menuButton
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.safeAreaInset(edge: .top, spacing: 0) {
|
.safeAreaInset(edge: .top, spacing: 0) {
|
||||||
@@ -223,6 +234,8 @@ struct CalendarHostView: View {
|
|||||||
/// updates reliably on month change — is identical in both modes.
|
/// updates reliably on month change — is identical in both modes.
|
||||||
@ViewBuilder private var barContents: some View {
|
@ViewBuilder private var barContents: some View {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
|
// Menu (hamburger) on the left — the drawer opens from the left.
|
||||||
|
if !hideMenuButton { menuButton.padding(.leading, 2) }
|
||||||
HStack(spacing: 2) {
|
HStack(spacing: 2) {
|
||||||
Button { store.navigatePrev() } label: {
|
Button { store.navigatePrev() } label: {
|
||||||
Image(systemName: "chevron.left")
|
Image(systemName: "chevron.left")
|
||||||
@@ -235,7 +248,7 @@ struct CalendarHostView: View {
|
|||||||
.frame(width: 36, height: 36)
|
.frame(width: 36, height: 36)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.leading, 6)
|
.padding(.leading, hideMenuButton ? 6 : 0)
|
||||||
Spacer(minLength: 6)
|
Spacer(minLength: 6)
|
||||||
Text(titleString)
|
Text(titleString)
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
@@ -246,8 +259,7 @@ struct CalendarHostView: View {
|
|||||||
Button(L10n.t("nav.today", appLang)) { store.moveToToday() }
|
Button(L10n.t("nav.today", appLang)) { store.moveToToday() }
|
||||||
.font(.callout).padding(.horizontal, 6)
|
.font(.callout).padding(.horizontal, 6)
|
||||||
.lineLimit(1).fixedSize()
|
.lineLimit(1).fixedSize()
|
||||||
menuButton
|
.padding(.trailing, 4)
|
||||||
.padding(.trailing, 2)
|
|
||||||
}
|
}
|
||||||
.frame(height: 48)
|
.frame(height: 48)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import SwiftUI
|
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`
|
/// The calendar-visibility list, extracted so both the modal `CalendarFilterSheet`
|
||||||
/// and the side drawer render the exact same rows/logic. Filtering is purely
|
/// and the side drawer render the same rows/logic. A single flat, drag-reorderable
|
||||||
/// client-side (hidden keys in `CalendarStore`); server-managed sources also
|
/// list (no per-source grouping); visibility is client-side (`CalendarStore`),
|
||||||
/// reconcile their sidebar_hidden / reminders_enabled flags on load.
|
/// order is device-local (`CalendarStore.calendarOrder`, mirrors the web).
|
||||||
struct CalendarFilterContent: View {
|
struct CalendarFilterContent: View {
|
||||||
let api: CalendarrAPI
|
let api: CalendarrAPI
|
||||||
let store: CalendarStore
|
let store: CalendarStore
|
||||||
@@ -19,6 +27,8 @@ struct CalendarFilterContent: View {
|
|||||||
@State private var banished: Set<String> = []
|
@State private var banished: Set<String> = []
|
||||||
@State private var reminderDisabled: Set<String> = []
|
@State private var reminderDisabled: Set<String> = []
|
||||||
@State private var allKeys: 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 groupDetail: CalGroup? = nil
|
||||||
@State private var hiddenGroup: Set<String> = []
|
@State private var hiddenGroup: Set<String> = []
|
||||||
|
|
||||||
@@ -31,88 +41,11 @@ struct CalendarFilterContent: View {
|
|||||||
} else if allKeys.isEmpty {
|
} else if allKeys.isEmpty {
|
||||||
Text(L10n.t("filter.empty", appLang)).foregroundStyle(.secondary)
|
Text(L10n.t("filter.empty", appLang)).foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
controlBar
|
||||||
List {
|
List {
|
||||||
Section {
|
ForEach(rows) { cal in row(cal) }
|
||||||
HStack(spacing: 10) {
|
.onMove(perform: move)
|
||||||
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)"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !banished.isEmpty {
|
if !banished.isEmpty {
|
||||||
Section {
|
Section {
|
||||||
Text(L10n.t("filter.banished_footer", appLang))
|
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() }
|
.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
|
@ViewBuilder
|
||||||
private func row(name: String, colorHex: String, key: String, readOnly: Bool = false) -> some View {
|
private func row(_ cal: CalRow) -> some View {
|
||||||
let isVisible = !hidden.contains(key)
|
let isVisible = !hidden.contains(cal.id)
|
||||||
Button {
|
Button {
|
||||||
if isVisible { hidden.insert(key) } else { hidden.remove(key) }
|
if isVisible { hidden.insert(cal.id) } else { hidden.remove(cal.id) }
|
||||||
store.setCalendarHidden(key, hidden: isVisible)
|
store.setCalendarHidden(cal.id, hidden: isVisible)
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(Color(hex: colorHex))
|
.fill(Color(hex: cal.colorHex))
|
||||||
.frame(width: 14, height: 14)
|
.frame(width: 14, height: 14)
|
||||||
.opacity(isVisible ? 1.0 : 0.35)
|
.opacity(isVisible ? 1.0 : 0.35)
|
||||||
Text(name)
|
Text(cal.name)
|
||||||
.foregroundStyle(isVisible ? .primary : .secondary)
|
.foregroundStyle(isVisible ? .primary : .secondary)
|
||||||
.strikethrough(!isVisible, color: .secondary)
|
.strikethrough(!isVisible, color: .secondary)
|
||||||
if readOnly {
|
if cal.readOnly {
|
||||||
Image(systemName: "lock.fill").font(.caption2).foregroundStyle(.secondary)
|
Image(systemName: "lock.fill").font(.caption2).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
if reminderDisabled.contains(key) {
|
if reminderDisabled.contains(cal.id) {
|
||||||
Image(systemName: "bell.slash").font(.caption).foregroundStyle(.secondary)
|
Image(systemName: "bell.slash").font(.caption).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Image(systemName: isVisible ? "eye" : "eye.slash")
|
Image(systemName: isVisible ? "eye" : "eye.slash")
|
||||||
@@ -153,22 +114,20 @@ struct CalendarFilterContent: View {
|
|||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.swipeActions(edge: .leading, allowsFullSwipe: false) {
|
.contextMenu {
|
||||||
let disabled = reminderDisabled.contains(key)
|
|
||||||
Button {
|
Button {
|
||||||
toggleReminders(forKey: key)
|
toggleReminders(forKey: cal.id)
|
||||||
} label: {
|
} label: {
|
||||||
|
let disabled = reminderDisabled.contains(cal.id)
|
||||||
Label(L10n.t(disabled ? "filter.reminders_on" : "filter.reminders_off", appLang),
|
Label(L10n.t(disabled ? "filter.reminders_on" : "filter.reminders_off", appLang),
|
||||||
systemImage: disabled ? "bell" : "bell.slash")
|
systemImage: disabled ? "bell" : "bell.slash")
|
||||||
}
|
}
|
||||||
.tint(.orange)
|
|
||||||
}
|
|
||||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
hidden.remove(key)
|
hidden.remove(cal.id)
|
||||||
banished.insert(key)
|
banished.insert(cal.id)
|
||||||
store.setCalendarBanished(key, banished: true)
|
store.setCalendarBanished(cal.id, banished: true)
|
||||||
pushBanishToServer(key: key, hidden: true)
|
pushBanishToServer(key: cal.id, hidden: true)
|
||||||
|
rows.removeAll { $0.id == cal.id }
|
||||||
} label: {
|
} label: {
|
||||||
Label(L10n.t("filter.banish", appLang), systemImage: "archivebox")
|
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
|
@ViewBuilder
|
||||||
private var groupFilterList: some View {
|
private var groupFilterList: some View {
|
||||||
if let g = groupDetail {
|
if let g = groupDetail {
|
||||||
@@ -269,13 +230,26 @@ struct CalendarFilterContent: View {
|
|||||||
store.setReminderDisabledKeys(rd)
|
store.setReminderDisabledKeys(rd)
|
||||||
reminderDisabled = rd
|
reminderDisabled = rd
|
||||||
|
|
||||||
var keys = Set<String>()
|
// Build the flat row list (banished excluded), then sort by stored order.
|
||||||
for cal in localCalendars { keys.insert(CalendarStore.calendarKey(source: "local", calendarId: "\(cal.id)")) }
|
var combined: [CalRow] = []
|
||||||
for acc in caldavAccounts { for cal in acc.calendars ?? [] { keys.insert(CalendarStore.calendarKey(source: "caldav", calendarId: "\(cal.id)")) } }
|
func add(_ source: String, _ id: Int, _ name: String, _ colorHex: String, readOnly: Bool = false) {
|
||||||
for sub in icalSubs { keys.insert(CalendarStore.calendarKey(source: "ical", calendarId: "\(sub.id)")) }
|
let key = CalendarStore.calendarKey(source: source, calendarId: "\(id)")
|
||||||
for acc in googleAccounts { for cal in acc.calendars ?? [] { keys.insert(CalendarStore.calendarKey(source: "google", calendarId: "\(cal.id)")) } }
|
if b.contains(key) { return }
|
||||||
for acc in haAccounts { for cal in acc.calendars ?? [] { keys.insert(CalendarStore.calendarKey(source: "homeassistant", calendarId: "\(cal.id)")) } }
|
combined.append(CalRow(id: key, name: name, colorHex: colorHex, readOnly: readOnly))
|
||||||
allKeys = keys
|
}
|
||||||
|
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
|
isLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import SwiftUI
|
|||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
let api: CalendarrAPI
|
let api: CalendarrAPI
|
||||||
@AppStorage("liquidGlass") private var liquidGlass = false
|
@AppStorage("liquidGlass") private var liquidGlass = false
|
||||||
|
@AppStorage("hideMenuButton") private var hideMenuButton = false
|
||||||
@AppStorage("cacheMonths") private var cacheMonths = 3
|
@AppStorage("cacheMonths") private var cacheMonths = 3
|
||||||
@AppStorage("appLanguage") private var appLang = "system"
|
@AppStorage("appLanguage") private var appLang = "system"
|
||||||
@AppStorage("monthDividerColor") private var dividerHex = "#7090C0"
|
@AppStorage("monthDividerColor") private var dividerHex = "#7090C0"
|
||||||
@@ -416,6 +417,8 @@ struct SettingsView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.accessibilityLabel(L10n.t("settings.surface.auto", appLang))
|
.accessibilityLabel(L10n.t("settings.surface.auto", appLang))
|
||||||
}
|
}
|
||||||
|
Toggle(L10n.t("settings.hide_menu_button", appLang), isOn: $hideMenuButton)
|
||||||
|
.tint(Color.accentColor)
|
||||||
} header: {
|
} header: {
|
||||||
Text(L10n.t("settings.device", appLang))
|
Text(L10n.t("settings.device", appLang))
|
||||||
} footer: {
|
} footer: {
|
||||||
|
|||||||
Reference in New Issue
Block a user