Files
Calendarr-IOS/Calendarr iOS/Views/CalendarFilterContent.swift
Scarriffle 6881c68935 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>
2026-07-15 16:19:29 +02:00

262 lines
12 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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
@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 rows: [CalRow] = []
@State private var isSorting = false
@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 {
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)
}
}
}
.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(_ cal: CalRow) -> some View {
let isVisible = !hidden.contains(cal.id)
Button {
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: cal.colorHex))
.frame(width: 14, height: 14)
.opacity(isVisible ? 1.0 : 0.35)
Text(cal.name)
.foregroundStyle(isVisible ? .primary : .secondary)
.strikethrough(!isVisible, color: .secondary)
if cal.readOnly {
Image(systemName: "lock.fill").font(.caption2).foregroundStyle(.secondary)
}
Spacer()
if reminderDisabled.contains(cal.id) {
Image(systemName: "bell.slash").font(.caption).foregroundStyle(.secondary)
}
Image(systemName: isVisible ? "eye" : "eye.slash")
.foregroundStyle(isVisible ? Color.accentColor : .secondary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.contextMenu {
Button {
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")
}
Button(role: .destructive) {
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")
}
}
}
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 (unchanged: hide individual members / group calendar)
@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
// 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
}
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) }
}
}