Initial Commit

This commit is contained in:
Scarriffle
2026-05-17 08:32:34 +02:00
commit e5529ca653
30 changed files with 4351 additions and 0 deletions

View File

@@ -0,0 +1,295 @@
import SwiftUI
struct CalendarHostView: View {
let api: CalendarrAPI
@Binding var showMenu: Bool
@AppStorage("liquidGlass") private var liquidGlass = false
@AppStorage("cacheMonths") private var cacheMonths = 3
@State private var store = CalendarStore()
@State private var showEditor = false
@State private var editorDate: Date = .now
@State private var editingEvent: CalEvent? = nil
@State private var selectedEvent: CalEvent? = nil
var body: some View {
if liquidGlass {
glassVariant
} else {
flatVariant
}
}
// MARK: Flat variant
private var flatVariant: some View {
VStack(spacing: 0) {
topBar
Divider()
errorBanner
calendarContent
.frame(maxWidth: .infinity, maxHeight: .infinity)
.overlay(alignment: .top) {
if store.isLoading {
ProgressView().padding(.top, 10).transition(.opacity)
}
}
}
.overlay(alignment: .bottomTrailing) { solidFAB }
// Subtle background cache indicator (top-leading)
.overlay(alignment: .topLeading) {
if store.isCachingBackground {
Image(systemName: "arrow.triangle.2.circlepath")
.font(.caption2)
.foregroundStyle(.secondary)
.padding(6)
.transition(.opacity)
}
}
.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() } }
}
// MARK: Liquid Glass variant
private var glassVariant: some View {
NavigationStack {
calendarContent
.frame(maxWidth: .infinity, maxHeight: .infinity)
.overlay(alignment: .top) {
if store.isLoading {
ProgressView().padding(.top, 10).transition(.opacity)
}
}
.overlay(alignment: .top) {
if let err = store.lastError { errorBannerView(err).padding(.top, 8) }
}
.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") }
Button("Heute") { store.moveToToday() }.font(.callout)
}
}
ToolbarItem(placement: .principal) { viewPickerMenu }
ToolbarItem(placement: .navigationBarTrailing) {
Button { showMenu = true } label: { Image(systemName: "line.3.horizontal") }
}
}
}
.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() } }
}
// MARK: Top bar (flat mode)
private var topBar: 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)
}
Button("Heute") { store.moveToToday() }
.font(.callout).padding(.horizontal, 6)
}
.padding(.leading, 8)
Spacer()
viewPickerMenu
Spacer()
Button { showMenu = true } label: {
Image(systemName: "line.3.horizontal")
.font(.system(size: 18, weight: .medium))
.frame(width: 44, height: 44)
}
.padding(.trailing, 4)
}
.frame(height: 48)
.background(.bar)
}
private var viewPickerMenu: some View {
Menu {
ForEach(CalViewType.allCases, id: \.self) { vt in
Button { store.viewType = vt } label: {
Label(vt.label, systemImage: vt.systemImage)
}
}
} label: {
HStack(spacing: 4) {
Text(store.viewType.label).font(.headline)
Image(systemName: "chevron.down").font(.caption2.weight(.semibold))
}
.foregroundStyle(.primary)
.padding(.horizontal, 12).padding(.vertical, 7)
.background(.quaternary, in: Capsule())
}
}
// 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: 35, coordinateSpace: .global)
.onEnded { val in
let h = val.translation.width
let v = val.translation.height
guard abs(h) > abs(v) * 1.1, abs(h) > 50 else { return }
withAnimation(.easeInOut(duration: 0.2)) {
if h < 0 { store.navigateNext() } else { store.navigatePrev() }
}
}
switch store.viewType {
case .month:
MonthView(store: store, onDayTap: { editorDate = $0 }, onEventTap: { selectedEvent = $0 })
.simultaneousGesture(swipe)
case .week:
WeekView(store: store, onEventTap: { selectedEvent = $0 }, onTimeTap: { editorDate = $0 })
.simultaneousGesture(swipe)
case .day:
DayView(store: store, onEventTap: { selectedEvent = $0 }, onTimeTap: { editorDate = $0 })
.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 {
editingEvent = nil; editorDate = .now; showEditor = true
} 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 {
editingEvent = nil; editorDate = .now; showEditor = true
} 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, showEditor: $showEditor,
editorDate: $editorDate, editingEvent: $editingEvent,
selectedEvent: $selectedEvent, api: api,
reload: { await onNavigate() })
}
// MARK: Loading logic
private func startup() async {
await store.loadWritableCalendars(api: api)
// 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)
}
}
/// 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)
}
/// Called when cacheMonths setting changes clear cache and re-prefetch.
private func recache() async {
store.invalidateCache()
await startup()
}
}
// MARK: Shared sheet modifier
private struct CalendarSheets: ViewModifier {
let store: CalendarStore
@Binding var showEditor: Bool
@Binding var editorDate: Date
@Binding var editingEvent: CalEvent?
@Binding var selectedEvent: CalEvent?
let api: CalendarrAPI
let reload: () async -> Void
func body(content: Content) -> some View {
content
.sheet(isPresented: $showEditor) {
EventEditorSheet(api: api, store: store,
initialDate: editorDate, editingEvent: editingEvent) {
editingEvent = nil; await reload()
}
}
.sheet(item: $selectedEvent) { ev in
EventDetailSheet(event: ev, api: api, store: store) { updated in
selectedEvent = nil
if let u = updated { editingEvent = u; showEditor = true }
await reload()
}
}
}
}