Files
Calendarr-IOS/Calendarr iOS/Views/Calendar/CalendarHostView.swift
Scarriffle 59a879ea23 fix: move Today button to the right so the month title sits centered (iOS)
Left side now holds only the prev/next chevrons; "Heute" moved next to the
burger menu on the right (both flat and Liquid Glass bars), so the month title
is centered again instead of pushed right by a heavy left cluster.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 10:14:08 +02:00

535 lines
22 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
private enum CalEditorContext: Identifiable {
case create(Date)
case edit(CalEvent)
var id: String {
switch self {
case .create(let d): return "new-\(d.timeIntervalSince1970)"
case .edit(let ev): return "edit-\(ev.id)"
}
}
}
struct CalendarHostView: View {
let api: CalendarrAPI
@Binding var showMenu: Bool
@AppStorage("liquidGlass") private var liquidGlass = false
@AppStorage("cacheMonths") private var cacheMonths = 3
@AppStorage("appLanguage") private var appLang = "system"
@AppStorage("backgroundColor") private var bgHex = "#000000"
@AppStorage("weekStartDay") private var weekStartDay = "monday"
@AppStorage("defaultView") private var defaultView = "month"
@Environment(\.scenePhase) private var scenePhase
@State private var store = CalendarStore()
@State private var editorContext: CalEditorContext? = nil
@State private var selectedEvent: CalEvent? = nil
@State private var showFilter = false
@State private var didApplyDefaultView = false
@State private var groups: [CalGroup] = []
private var titleString: String {
if store.viewType == .month {
let f = DateFormatter()
f.locale = L10n.locale(appLang)
f.dateFormat = "LLLL yyyy"
return f.string(from: store.visibleMonth).capitalized(with: L10n.locale(appLang))
}
return store.titleForCurrentView(language: appLang)
}
var body: some View {
if liquidGlass {
glassVariant
} else {
flatVariant
}
}
// MARK: Loading indicator
@ViewBuilder
private var loadingIndicator: some View {
if store.isLoading || store.isCachingBackground {
ProgressView()
.padding(14)
.background(.regularMaterial, in: Circle())
.shadow(color: .black.opacity(0.12), radius: 6, y: 2)
.transition(.opacity.combined(with: .scale(scale: 0.85)))
}
}
// MARK: Flat variant
private var flatVariant: some View {
VStack(spacing: 0) {
topBar
groupBanner
Divider()
errorBanner
calendarContent
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(hex: bgHex))
.overlay(alignment: .top) {
loadingIndicator.padding(.top, 12)
}
.animation(.easeInOut(duration: 0.2), value: store.isLoading || store.isCachingBackground)
}
.overlay(alignment: .bottomTrailing) { solidFAB }
.modifier(calendarSheets)
.task { await startup() }
.onChange(of: store.currentDate) { _, _ in Task { await onNavigate() } }
.onChange(of: store.viewType) { _, _ in Task { await onNavigate() } }
.onChange(of: cacheMonths) { _, _ in Task { await recache() } }
.onChange(of: store.visibleMonth) { _, new in Task { await ensureLoaded(around: new) } }
.onChange(of: scenePhase) { _, phase in if phase == .active { Task { await SettingsSync.pull(api: api) } } }
.onReceive(NotificationCenter.default.publisher(for: .banishedCalendarsChanged)) { _ in
store.syncBanishedFromDefaults()
}
.onReceive(NotificationCenter.default.publisher(for: .settingsDidChange)) { _ in
applyServerDrivenSettings(initial: false)
}
.onReceive(NotificationCenter.default.publisher(for: .manualSyncRequested)) { _ in
Task { await forceReload() }
}
.onReceive(NotificationCenter.default.publisher(for: .rescheduleReminders)) { _ in
store.rescheduleNotifications()
}
}
// MARK: Liquid Glass variant
private var glassVariant: some View {
// Real iOS-26 Liquid Glass: the system NavigationStack toolbar renders the
// glass bar (buttons). The month TITLE is NOT placed in the toolbar the
// system title silently fails to refresh on month change on iOS 26 but
// as a normal inline Text in a top safe-area inset just below the glass
// bar, where it updates reliably (same mechanism as the flat variant).
NavigationStack {
calendarContent
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(hex: bgHex))
.overlay(alignment: .top) {
loadingIndicator.padding(.top, 12)
}
.animation(.easeInOut(duration: 0.2), value: store.isLoading || store.isCachingBackground)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
HStack(spacing: 2) {
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) {
VStack(spacing: 0) {
Text(titleString)
.font(.headline)
.lineLimit(1)
.minimumScaleFactor(0.7)
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.background(.bar)
groupBanner
errorBanner
}
}
}
.overlay(alignment: .bottomTrailing) { glassFAB }
.modifier(calendarSheets)
.task { await startup() }
.onChange(of: store.currentDate) { _, _ in Task { await onNavigate() } }
.onChange(of: store.viewType) { _, _ in Task { await onNavigate() } }
.onChange(of: cacheMonths) { _, _ in Task { await recache() } }
.onChange(of: store.visibleMonth) { _, new in Task { await ensureLoaded(around: new) } }
.onChange(of: scenePhase) { _, phase in if phase == .active { Task { await SettingsSync.pull(api: api) } } }
.onReceive(NotificationCenter.default.publisher(for: .banishedCalendarsChanged)) { _ in
store.syncBanishedFromDefaults()
}
.onReceive(NotificationCenter.default.publisher(for: .settingsDidChange)) { _ in
applyServerDrivenSettings(initial: false)
}
.onReceive(NotificationCenter.default.publisher(for: .manualSyncRequested)) { _ in
Task { await forceReload() }
}
.onReceive(NotificationCenter.default.publisher(for: .rescheduleReminders)) { _ in
store.rescheduleNotifications()
}
}
// MARK: Top bar (flat mode)
/// Shared bar contents (chevrons / today / title / group / view / filter / menu).
/// Used by both the flat and the glass top bar so the inline title which
/// updates reliably on month change is identical in both modes.
@ViewBuilder private var barContents: some View {
HStack(spacing: 0) {
HStack(spacing: 2) {
Button { store.navigatePrev() } label: {
Image(systemName: "chevron.left")
.font(.system(size: 17, weight: .medium))
.frame(width: 36, height: 36)
}
Button { store.navigateNext() } label: {
Image(systemName: "chevron.right")
.font(.system(size: 17, weight: .medium))
.frame(width: 36, height: 36)
}
}
.padding(.leading, 6)
Spacer(minLength: 6)
Text(titleString)
.font(.headline)
.lineLimit(1)
.minimumScaleFactor(0.7)
.layoutPriority(1)
Spacer(minLength: 6)
Button(L10n.t("nav.today", appLang)) { store.moveToToday() }
.font(.callout).padding(.horizontal, 6)
.lineLimit(1).fixedSize()
menuButton
.padding(.trailing, 2)
}
.frame(height: 48)
}
private var topBar: some View {
barContents.background(.bar)
}
@ViewBuilder private var groupBanner: some View {
if let g = store.activeGroup {
HStack(spacing: 6) {
GroupIconView(icon: g.icon).font(.subheadline)
Text("\(L10n.t("groups.view_label", appLang)): \(g.name)")
.font(.subheadline).lineLimit(1)
Spacer()
Button(L10n.t("groups.exit", appLang)) { switchGroup(nil) }
.font(.callout)
}
.padding(.horizontal, 12).padding(.vertical, 7)
.background(Color.accentColor.opacity(0.18))
}
}
private func switchGroup(_ g: CalGroup?) {
store.activeGroup = g
store.hiddenGroupKeys = [] // member visibility is per-group; start fresh
// The cache holds the previous mode's events drop it and reload the
// visible range + prefetch a wide window so the whole grid is covered.
Task { await forceReload() }
}
/// The single top-bar action: a compact popup holding view / filter /
/// groups / sync, plus an "Einstellungen" entry that opens the full menu.
/// (Replaces the separate view / filter / group icons in the bar.)
private var menuButton: some View {
Menu {
// View (fixed icon, not per-view)
Menu {
ForEach(CalViewType.allCases, id: \.self) { vt in
Button { store.viewType = vt } label: {
Label(vt.label(appLang), systemImage: store.viewType == vt ? "checkmark" : vt.systemImage)
}
}
} label: {
Label(L10n.t("view.change", appLang), systemImage: "rectangle.3.group")
}
// Filter
Button { showFilter = true } label: {
Label(L10n.t("filter.button", appLang), systemImage: "line.3.horizontal.decrease.circle")
}
// Groups
if !groups.isEmpty {
Menu {
Button { switchGroup(nil) } label: {
Label(L10n.t("groups.personal", appLang),
systemImage: store.activeGroup == nil ? "checkmark" : "person")
}
ForEach(groups) { g in
Button { switchGroup(g) } label: {
Label(g.name,
systemImage: store.activeGroup?.id == g.id ? "checkmark" : GroupIcons.symbol(g.icon))
}
}
} label: {
Label(L10n.t("groups.title", appLang), systemImage: "person.2")
}
}
// Sync
Button { Task { await SettingsSync.pull(api: api); await forceReload() } } label: {
Label(L10n.t("menu.sync", appLang), systemImage: "arrow.triangle.2.circlepath")
}
Divider()
// Full settings menu
Button { showMenu = true } label: {
Label(L10n.t("menu.section.settings", appLang), systemImage: "gearshape")
}
} label: {
Image(systemName: "line.3.horizontal")
.font(.system(size: 18, weight: .medium))
.foregroundStyle(store.activeGroup == nil && store.hiddenCalendarKeys.isEmpty ? .primary : Color.accentColor)
.frame(width: 36, height: 36)
}
.accessibilityLabel(L10n.t("nav.menu", appLang))
}
// MARK: Error banner
@ViewBuilder private var errorBanner: some View {
if let err = store.lastError { errorBannerView(err) }
}
private func errorBannerView(_ err: String) -> some View {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.yellow)
Text(err).font(.caption).foregroundStyle(.white).lineLimit(2)
Spacer()
Button { Task { await onNavigate() } } label: {
Image(systemName: "arrow.clockwise").foregroundStyle(.white)
}
}
.padding(.horizontal, 12).padding(.vertical, 8)
.background(Color.red.opacity(0.85))
}
// MARK: Calendar content (with swipe)
@ViewBuilder
private var calendarContent: some View {
let swipe = DragGesture(minimumDistance: 14, coordinateSpace: .local)
.onEnded { val in
let h = val.translation.width
let v = val.translation.height
guard abs(h) > abs(v) * 1.2, abs(h) > 28 else { return }
withAnimation(.easeInOut(duration: 0.2)) {
if h < 0 { store.navigateNext() } else { store.navigatePrev() }
}
}
switch store.viewType {
case .month:
// Month view uses vertical scroll no horizontal swipe.
MonthView(store: store,
onDayTap: { store.currentDate = $0 },
onEventTap: { selectedEvent = $0 },
onCreateEvent: { day in editorContext = .create(day) },
onShowWeek: { day in
store.currentDate = day
store.viewType = .week
},
onShowDay: { day in
store.currentDate = day
store.viewType = .day
})
case .week:
WeekView(store: store,
onEventTap: { selectedEvent = $0 },
onCreateEvent: { date in editorContext = .create(date) },
onShowMonth: { date in
store.currentDate = date
store.viewType = .month
},
onShowDay: { date in
store.currentDate = date
store.viewType = .day
})
.simultaneousGesture(swipe)
case .day:
DayView(store: store,
onEventTap: { selectedEvent = $0 },
onCreateEvent: { date in editorContext = .create(date) })
.simultaneousGesture(swipe)
case .quarter:
QuarterView(store: store, onEventTap: { selectedEvent = $0 })
.simultaneousGesture(swipe)
case .agenda:
AgendaView(store: store, onEventTap: { selectedEvent = $0 })
}
}
// MARK: FAB buttons
/// Standard solid FAB (flat mode)
private var solidFAB: some View {
Button {
editorContext = .create(.now)
} label: {
Image(systemName: "plus")
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 56, height: 56)
.background(Color.accentColor)
.clipShape(Circle())
.shadow(radius: 4, y: 2)
}
.padding(.trailing, 20).padding(.bottom, 20)
}
/// Liquid Glass FAB (iOS 26) with glass effect; falls back to solid on older OS
@ViewBuilder
private var glassFAB: some View {
if #available(iOS 26, *) {
Button {
editorContext = .create(.now)
} label: {
Image(systemName: "plus")
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(.primary)
.frame(width: 56, height: 56)
}
.buttonStyle(.plain)
.glassEffect(in: Circle())
.padding(.trailing, 20).padding(.bottom, 20)
} else {
solidFAB
}
}
// MARK: Sheets modifier
private var calendarSheets: CalendarSheets {
CalendarSheets(store: store, editorContext: $editorContext,
selectedEvent: $selectedEvent, showFilter: $showFilter,
api: api,
reload: { await onNavigate() },
reloadForce: { await reloadVisible(force: true) })
}
// MARK: Loading logic
private func startup() async {
// Ask for notification permission early so reminders can be scheduled.
NotificationScheduler.requestAuthorizationIfNeeded()
// 0. Pull settings first so week-start / default-view are correct
// before we compute the initial range and load events.
await SettingsSync.pull(api: api)
applyServerDrivenSettings(initial: true)
await store.loadWritableCalendars(api: api)
groups = (try? await api.getGroups()) ?? []
// 1. Load current view immediately (visible)
let (s, e) = store.rangeForCurrentView()
await store.loadEvents(api: api, start: s, end: e)
// 2. Background prefetch for the configured range (non-blocking)
Task(priority: .background) {
await store.prefetchBackground(api: api, months: cacheMonths)
}
// 3. Periodic settings pull (tied to this .task's lifetime).
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(600))
if Task.isCancelled { break }
await SettingsSync.pull(api: api)
}
}
/// Apply the server-driven "always sync" settings to the live store.
/// `weekStartsOnMonday` is applied every time; the default view is applied
/// only once at startup so it never overrides the user's manual switches.
private func applyServerDrivenSettings(initial: Bool) {
store.weekStartsOnMonday = (weekStartDay != "sunday")
if initial, !didApplyDefaultView {
didApplyDefaultView = true
if let vt = CalViewType(rawValue: defaultView) {
store.viewType = vt
}
}
}
/// Called on every navigation instant if within cache, fetches otherwise.
private func onNavigate() async {
let (s, e) = store.rangeForCurrentView()
await store.loadEvents(api: api, start: s, end: e)
}
/// Re-fetch the visible range. With `force` it bypasses the cache so a just
/// created/edited event shows up immediately (the server is authoritative).
private func reloadVisible(force: Bool) async {
let (s, e) = store.rangeForCurrentView()
await store.loadEvents(api: api, start: s, end: e, force: force)
}
/// Called when cacheMonths setting changes clear cache and re-prefetch.
private func recache() async {
store.invalidateCache()
await startup()
}
/// Manual sync from the menu: drop the event cache and re-fetch from the
/// server (the periodic loop in `startup()` is untouched, so we don't spawn
/// a second one). Settings were already pulled by the menu action.
private func forceReload() async {
store.invalidateCache()
let (s, e) = store.rangeForCurrentView()
await store.loadEvents(api: api, start: s, end: e)
Task(priority: .background) {
await store.prefetchBackground(api: api, months: cacheMonths)
}
}
/// Called when the user scrolls into a new month refreshes the visible range
/// immediately from cache, then fetches on demand if needed.
private func ensureLoaded(around month: Date) async {
let cal = store.userCalendar
let monthStart = cal.date(from: cal.dateComponents([.year, .month], from: month)) ?? month
let s = cal.date(byAdding: .month, value: -1, to: monthStart) ?? monthStart
let e = cal.date(byAdding: .month, value: 2, to: monthStart) ?? monthStart
// Only narrow the visible event set if the requested window is already cached.
// Otherwise keep the current events visible until the network fetch finishes,
// so previous/current/next month events don't disappear temporarily.
if store.isCached(start: s, end: e) {
store.refreshFromCache(start: s, end: e)
}
await store.loadEvents(api: api, start: s, end: e)
}
}
// MARK: Shared sheet modifier
private struct CalendarSheets: ViewModifier {
let store: CalendarStore
@Binding var editorContext: CalEditorContext?
@Binding var selectedEvent: CalEvent?
@Binding var showFilter: Bool
let api: CalendarrAPI
let reload: () async -> Void
let reloadForce: () async -> Void
func body(content: Content) -> some View {
content
// Use sheet(item:) so the editing event is captured atomically
// avoiding the race where sheet(isPresented:) evaluates its content
// before the editingEvent state update propagates.
.sheet(item: $editorContext) { ctx in
let editingEv: CalEvent? = { if case .edit(let ev) = ctx { return ev }; return nil }()
let date: Date = { if case .create(let d) = ctx { return d }; return .now }()
EventEditorSheet(api: api, store: store,
initialDate: date, editingEvent: editingEv) {
editorContext = nil
await reloadForce()
}
}
.sheet(item: $selectedEvent) { ev in
EventDetailSheet(event: ev, api: api, store: store) { updated, needsForce in
selectedEvent = nil
if let u = updated { editorContext = .edit(u) }
if needsForce { await reloadForce() } else { await reload() }
}
}
.sheet(isPresented: $showFilter) {
CalendarFilterSheet(api: api, store: store)
}
}
}