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,489 @@
import SwiftUI
struct AccountsView: View {
let api: CalendarrAPI
@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 showAddCalDAV = false
@State private var showAddLocal = false
@State private var showAddICal = false
@State private var showAddHA = false
@State private var errorAlert: String?
var body: some View {
NavigationStack {
Group {
if isLoading {
ProgressView("Lade Konten…")
} else {
List {
caldavSection
localSection
icalSection
googleSection
haSection
}
}
}
.navigationTitle("Konten")
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Menu {
Button("CalDAV-Konto") { showAddCalDAV = true }
Button("Lokaler Kalender") { showAddLocal = true }
Button("iCal-URL abonnieren") { showAddICal = true }
Button("Home Assistant") { showAddHA = true }
} label: {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showAddCalDAV) {
AddCalDAVSheet(api: api) { await load() }
}
.sheet(isPresented: $showAddLocal) {
AddLocalCalSheet(api: api) { await load() }
}
.sheet(isPresented: $showAddICal) {
AddICalSheet(api: api) { await load() }
}
.sheet(isPresented: $showAddHA) {
AddHASheet(api: api) { await load() }
}
.alert("Fehler", isPresented: .constant(errorAlert != nil), actions: {
Button("OK") { errorAlert = nil }
}, message: {
Text(errorAlert ?? "")
})
}
.task { await load() }
}
// MARK: Sections
var caldavSection: some View {
Section {
if caldavAccounts.isEmpty {
Text("Keine CalDAV-Konten")
.foregroundStyle(.secondary)
} else {
ForEach(caldavAccounts) { acc in
HStack {
Circle()
.fill(Color(hex: acc.color))
.frame(width: 12, height: 12)
VStack(alignment: .leading, spacing: 2) {
Text(acc.name).font(.body)
Text(acc.url)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
}
.onDelete { offsets in
Task { await deleteCalDAV(offsets: offsets) }
}
}
Button("CalDAV hinzufügen") { showAddCalDAV = true }
.foregroundStyle(Color.accentColor)
} header: {
Text("CalDAV-Konten")
}
}
var localSection: some View {
Section {
if localCalendars.isEmpty {
Text("Keine lokalen Kalender")
.foregroundStyle(.secondary)
} else {
ForEach(localCalendars) { cal in
HStack {
Circle()
.fill(Color(hex: cal.color))
.frame(width: 12, height: 12)
Text(cal.name)
}
}
.onDelete { offsets in
Task { await deleteLocal(offsets: offsets) }
}
}
Button("Lokalen Kalender erstellen") { showAddLocal = true }
.foregroundStyle(Color.accentColor)
} header: {
Text("Lokale Kalender")
}
}
var icalSection: some View {
Section {
if icalSubs.isEmpty {
Text("Keine Abonnements")
.foregroundStyle(.secondary)
} else {
ForEach(icalSubs) { sub in
HStack {
Circle()
.fill(Color(hex: sub.color))
.frame(width: 12, height: 12)
VStack(alignment: .leading, spacing: 2) {
Text(sub.name).font(.body)
Text("Alle \(sub.refreshMinutes) Min.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.onDelete { offsets in
Task { await deleteICal(offsets: offsets) }
}
}
Button("iCal-URL abonnieren") { showAddICal = true }
.foregroundStyle(Color.accentColor)
} header: {
Text("iCal-Abonnements")
}
}
var googleSection: some View {
Section {
if googleAccounts.isEmpty {
Text("Keine Google-Konten")
.foregroundStyle(.secondary)
} else {
ForEach(googleAccounts) { acc in
HStack {
Image(systemName: "g.circle.fill")
.foregroundStyle(.red)
Text(acc.email)
}
}
.onDelete { offsets in
Task { await deleteGoogle(offsets: offsets) }
}
}
Text("Google-Konten werden über den Browser verknüpft")
.font(.caption)
.foregroundStyle(.secondary)
} header: {
Text("Google-Konten")
}
}
var haSection: some View {
Section {
if haAccounts.isEmpty {
Text("Keine Home Assistant-Konten")
.foregroundStyle(.secondary)
} else {
ForEach(haAccounts) { acc in
VStack(alignment: .leading, spacing: 2) {
Text(acc.name).font(.body)
Text(acc.url)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
.onDelete { offsets in
Task { await deleteHA(offsets: offsets) }
}
}
Button("Home Assistant hinzufügen") { showAddHA = true }
.foregroundStyle(Color.accentColor)
} header: {
Text("Home Assistant")
}
}
// MARK: Actions
private func load() async {
isLoading = true
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)
isLoading = false
}
private func deleteCalDAV(offsets: IndexSet) async {
for i in offsets {
try? await api.deleteCalDAVAccount(id: caldavAccounts[i].id)
}
await load()
}
private func deleteLocal(offsets: IndexSet) async {
for i in offsets {
try? await api.deleteLocalCalendar(id: localCalendars[i].id)
}
await load()
}
private func deleteICal(offsets: IndexSet) async {
for i in offsets {
try? await api.deleteICalSubscription(id: icalSubs[i].id)
}
await load()
}
private func deleteGoogle(offsets: IndexSet) async {
for i in offsets {
try? await api.deleteGoogleAccount(id: googleAccounts[i].id)
}
await load()
}
private func deleteHA(offsets: IndexSet) async {
for i in offsets {
try? await api.deleteHomeAssistantAccount(id: haAccounts[i].id)
}
await load()
}
}
// MARK: Add Sheets
struct AddCalDAVSheet: View {
let api: CalendarrAPI
let onDone: () async -> Void
@Environment(\.dismiss) var dismiss
@State private var name = ""
@State private var url = ""
@State private var username = ""
@State private var password = ""
@State private var color = Color(hex: "#4285f4")
@State private var isLoading = false
@State private var error = ""
var body: some View {
NavigationStack {
Form {
Section("Konto-Details") {
TextField("Anzeigename", text: $name)
TextField("CalDAV-URL", text: $url)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
TextField("Benutzername", text: $username)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
SecureField("Passwort", text: $password)
}
Section("Farbe") {
ColorPicker("Farbe", selection: $color, supportsOpacity: false)
}
if !error.isEmpty {
Section {
Text(error).foregroundStyle(.red)
}
}
}
.navigationTitle("CalDAV-Konto")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen") { dismiss() }
}
ToolbarItem(placement: .primaryAction) {
Button("Verbinden") {
Task { await save() }
}
.bold()
.disabled(name.isEmpty || url.isEmpty || username.isEmpty || password.isEmpty || isLoading)
}
}
}
}
private func save() async {
isLoading = true
error = ""
do {
_ = try await api.addCalDAVAccount(name: name, url: url, username: username, password: password, color: color.toHex())
await onDone()
dismiss()
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
}
struct AddLocalCalSheet: View {
let api: CalendarrAPI
let onDone: () async -> Void
@Environment(\.dismiss) var dismiss
@State private var name = ""
@State private var color = Color(hex: "#34a853")
@State private var isLoading = false
@State private var error = ""
var body: some View {
NavigationStack {
Form {
Section {
TextField("Name", text: $name)
ColorPicker("Farbe", selection: $color, supportsOpacity: false)
}
if !error.isEmpty {
Section { Text(error).foregroundStyle(.red) }
}
}
.navigationTitle("Lokaler Kalender")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Abbrechen") { dismiss() } }
ToolbarItem(placement: .primaryAction) {
Button("Erstellen") {
Task { await save() }
}
.bold()
.disabled(name.isEmpty || isLoading)
}
}
}
}
private func save() async {
isLoading = true
do {
_ = try await api.addLocalCalendar(name: name, color: color.toHex())
await onDone()
dismiss()
} catch { self.error = error.localizedDescription }
isLoading = false
}
}
struct AddICalSheet: View {
let api: CalendarrAPI
let onDone: () async -> Void
@Environment(\.dismiss) var dismiss
@State private var name = ""
@State private var url = ""
@State private var color = Color(hex: "#46bdc6")
@State private var refreshMinutes = 60
@State private var isLoading = false
@State private var error = ""
let refreshOptions = [(15, "Alle 15 Min."), (30, "Alle 30 Min."), (60, "Stündlich"), (360, "Alle 6 Std."), (1440, "Täglich")]
var body: some View {
NavigationStack {
Form {
Section("Abonnement") {
TextField("Name", text: $name)
TextField("iCal-URL", text: $url)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
ColorPicker("Farbe", selection: $color, supportsOpacity: false)
}
Section("Aktualisierung") {
Picker("Intervall", selection: $refreshMinutes) {
ForEach(refreshOptions, id: \.0) { opt in
Text(opt.1).tag(opt.0)
}
}
}
if !error.isEmpty {
Section { Text(error).foregroundStyle(.red) }
}
}
.navigationTitle("iCal abonnieren")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Abbrechen") { dismiss() } }
ToolbarItem(placement: .primaryAction) {
Button("Abonnieren") {
Task { await save() }
}
.bold()
.disabled(name.isEmpty || url.isEmpty || isLoading)
}
}
}
}
private func save() async {
isLoading = true
do {
_ = try await api.addICalSubscription(name: name, url: url, color: color.toHex(), refreshMinutes: refreshMinutes)
await onDone()
dismiss()
} catch { self.error = error.localizedDescription }
isLoading = false
}
}
struct AddHASheet: View {
let api: CalendarrAPI
let onDone: () async -> Void
@Environment(\.dismiss) var dismiss
@State private var name = ""
@State private var url = ""
@State private var token = ""
@State private var isLoading = false
@State private var error = ""
var body: some View {
NavigationStack {
Form {
Section("Home Assistant") {
TextField("Anzeigename", text: $name)
TextField("URL (z.B. http://homeassistant.local:8123)", text: $url)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
}
Section("Authentifizierung") {
SecureField("Long-Lived Access Token", text: $token)
Text("Token erstellen unter: Profil → Sicherheit → Long-Lived Access Tokens")
.font(.caption)
.foregroundStyle(.secondary)
}
if !error.isEmpty {
Section { Text(error).foregroundStyle(.red) }
}
}
.navigationTitle("Home Assistant")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Abbrechen") { dismiss() } }
ToolbarItem(placement: .primaryAction) {
Button("Verbinden") {
Task { await save() }
}
.bold()
.disabled(name.isEmpty || url.isEmpty || token.isEmpty || isLoading)
}
}
}
}
private func save() async {
isLoading = true
do {
_ = try await api.addHomeAssistantAccount(name: name, url: url, token: token)
await onDone()
dismiss()
} catch { self.error = error.localizedDescription }
isLoading = false
}
}

View File

@@ -0,0 +1,107 @@
import SwiftUI
struct AgendaView: View {
let store: CalendarStore
let onEventTap: (CalEvent) -> Void
private var cal: Calendar { store.userCalendar }
private var grouped: [(Date, [CalEvent])] {
let start = cal.startOfDay(for: .now)
let end = cal.date(byAdding: .day, value: 90, to: start)!
var dict: [Date: [CalEvent]] = [:]
for ev in store.events(in: start, end: end) {
let key = cal.startOfDay(for: ev.startDate)
dict[key, default: []].append(ev)
}
return dict.keys.sorted().map { ($0, dict[$0]!.sorted { $0.startDate < $1.startDate }) }
}
private let dayFmt: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "EEEE, d. MMMM yyyy"
return f
}()
private let timeFmt: DateFormatter = {
let f = DateFormatter()
f.timeStyle = .short
f.dateStyle = .none
return f
}()
var body: some View {
if grouped.isEmpty {
ContentUnavailableView(
"Keine Termine",
systemImage: "calendar",
description: Text("In den nächsten 90 Tagen sind keine Termine vorhanden.")
)
} else {
List {
ForEach(grouped, id: \.0) { day, evs in
Section {
ForEach(evs) { ev in
Button { onEventTap(ev) } label: {
AgendaEventRow(event: ev, timeFmt: timeFmt)
}
.buttonStyle(.plain)
}
} header: {
Text(dayFmt.string(from: day))
.font(.footnote.weight(.semibold))
.foregroundStyle(cal.isDateInToday(day) ? Color.accentColor : .secondary)
}
}
}
.listStyle(.plain)
}
}
}
private struct AgendaEventRow: View {
let event: CalEvent
let timeFmt: DateFormatter
var timeString: String {
if event.isAllDay { return "Ganztägig" }
return timeFmt.string(from: event.startDate)
}
var body: some View {
HStack(spacing: 12) {
RoundedRectangle(cornerRadius: 2)
.fill(Color(hex: event.effectiveColor))
.frame(width: 4, height: 40)
VStack(alignment: .leading, spacing: 3) {
Text(event.title)
.font(.body.weight(.medium))
.foregroundStyle(.primary)
HStack(spacing: 6) {
Text(timeString)
.font(.caption)
.foregroundStyle(.secondary)
if !event.location.isEmpty {
Text("·")
.foregroundStyle(.secondary)
Text(event.location)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Text(event.calendarName)
.font(.caption2)
.foregroundStyle(.tertiary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.vertical, 4)
}
}

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()
}
}
}
}

View File

@@ -0,0 +1,125 @@
import SwiftUI
struct DayView: View {
let store: CalendarStore
let onEventTap: (CalEvent) -> Void
let onTimeTap: (Date) -> Void
private var cal: Calendar { store.userCalendar }
private var allDayEvents: [CalEvent] { store.events(on: store.currentDate).filter(\.isAllDay) }
private var timedEvents: [CalEvent] { store.events(on: store.currentDate).filter { !$0.isAllDay } }
var body: some View {
VStack(spacing: 0) {
if !allDayEvents.isEmpty { allDayStrip }
GeometryReader { geo in
ScrollViewReader { proxy in
ScrollView {
ZStack(alignment: .topLeading) {
// Background grid
HStack(alignment: .top, spacing: 0) {
timeLabels
VStack(spacing: 0) {
ForEach(hours, id: \.self) { _ in
Rectangle()
.fill(Color(.separator).opacity(0.4))
.frame(height: 0.5)
Color.clear.frame(height: hourHeight - 0.5)
}
}
.frame(width: geo.size.width - timeColumnWidth)
.contentShape(Rectangle())
.onTapGesture { loc in
let h = Int(loc.y / hourHeight)
let m = Int((loc.y.truncatingRemainder(dividingBy: hourHeight)) / hourHeight * 60)
let date = cal.date(bySettingHour: h, minute: m, second: 0, of: store.currentDate) ?? store.currentDate
onTimeTap(date)
}
}
// Events
let evWidth = geo.size.width - timeColumnWidth - 2
ForEach(timedEvents) { ev in
Button(action: { onEventTap(ev) }) {
EventBlock(event: ev)
}
.buttonStyle(.plain)
.frame(width: evWidth, height: max(eventHeight(ev), 18))
.offset(x: timeColumnWidth + 1, y: eventTop(ev))
}
// Current time
if cal.isDateInToday(store.currentDate) {
let lineY = nowLineY()
HStack(spacing: 0) {
Spacer().frame(width: timeColumnWidth - 4)
Circle().fill(Color.red).frame(width: 8, height: 8)
Rectangle().fill(Color.red)
.frame(width: geo.size.width - timeColumnWidth - 4, height: 1.5)
}
.offset(y: lineY - 0.75)
}
}
.frame(width: geo.size.width, height: hourHeight * 24 + 80)
.id("grid")
}
.onAppear { scrollToCurrentHour(proxy) }
.onChange(of: store.currentDate) { _, _ in scrollToCurrentHour(proxy) }
}
}
}
}
private var allDayStrip: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(allDayEvents) { ev in
Button(action: { onEventTap(ev) }) {
Text(ev.title)
.font(.caption.weight(.medium))
.foregroundStyle(.white)
.padding(.horizontal, 8).padding(.vertical, 4)
.background(Color(hex: ev.effectiveColor))
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 12).padding(.vertical, 6)
}
.overlay(alignment: .bottom) { Divider() }
}
private var timeLabels: some View {
VStack(spacing: 0) {
ForEach(hours, id: \.self) { h in
ZStack(alignment: .topTrailing) {
Color.clear.frame(height: hourHeight)
Text(String(format: "%02d:00", h))
.font(.system(size: 10))
.foregroundStyle(.secondary)
.offset(y: -6)
}
}
Color.clear.frame(height: 80)
}
.frame(width: timeColumnWidth)
}
private func nowLineY() -> CGFloat {
let cal = Calendar.current
let h = CGFloat(cal.component(.hour, from: Date()))
let m = CGFloat(cal.component(.minute, from: Date()))
return h * hourHeight + m * hourHeight / 60
}
private func scrollToCurrentHour(_ proxy: ScrollViewProxy) {
let h = Calendar.current.component(.hour, from: .now)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
withAnimation(.easeOut(duration: 0.3)) {
proxy.scrollTo("grid", anchor: UnitPoint(x: 0, y: CGFloat(max(h - 1, 0)) / 24.0))
}
}
}
}

View File

@@ -0,0 +1,143 @@
import SwiftUI
struct EventDetailSheet: View {
let event: CalEvent
let api: CalendarrAPI
let store: CalendarStore
let onDone: (CalEvent?) async -> Void
@Environment(\.dismiss) var dismiss
@State private var showDeleteConfirm = false
@State private var isDeleting = false
private let timeFmt: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .medium
f.timeStyle = .short
return f
}()
private let dateFmt: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .medium
f.timeStyle = .none
return f
}()
private var timeString: String {
if event.isAllDay {
if Calendar.current.isDate(event.startDate, inSameDayAs: event.endDate) ||
event.endDate == event.startDate {
return "Ganztägig · \(dateFmt.string(from: event.startDate))"
}
let end = Calendar.current.date(byAdding: .day, value: -1, to: event.endDate) ?? event.endDate
return "Ganztägig · \(dateFmt.string(from: event.startDate)) \(dateFmt.string(from: end))"
}
return "\(timeFmt.string(from: event.startDate)) \(timeFmt.string(from: event.endDate))"
}
private var canEdit: Bool {
event.source == "local" || event.source == "caldav"
}
var body: some View {
NavigationStack {
List {
Section {
HStack(alignment: .top, spacing: 12) {
RoundedRectangle(cornerRadius: 4)
.fill(Color(hex: event.effectiveColor))
.frame(width: 6, height: 44)
VStack(alignment: .leading, spacing: 4) {
Text(event.title)
.font(.title3.bold())
Text(event.calendarName)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
}
Section {
Label(timeString, systemImage: "clock")
if !event.location.isEmpty {
Label(event.location, systemImage: "mappin.and.ellipse")
}
if !event.notes.isEmpty {
Label(event.notes, systemImage: "text.alignleft")
}
}
Section {
HStack {
Label("Kalender", systemImage: "calendar")
Spacer()
Text(event.calendarName)
.foregroundStyle(.secondary)
}
HStack {
Label("Quelle", systemImage: "server.rack")
Spacer()
Text(event.source.capitalized)
.foregroundStyle(.secondary)
}
}
if canEdit {
Section {
Button(role: .destructive) {
showDeleteConfirm = true
} label: {
Label("Termin löschen", systemImage: "trash")
.foregroundStyle(.red)
}
.disabled(isDeleting)
}
}
}
.listStyle(.insetGrouped)
.navigationTitle("Termin")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Schliessen") {
Task { await onDone(nil) }
}
}
if canEdit {
ToolbarItem(placement: .primaryAction) {
Button("Bearbeiten") {
Task { await onDone(event) }
}
}
}
}
.confirmationDialog("Termin löschen?", isPresented: $showDeleteConfirm, titleVisibility: .visible) {
Button("Löschen", role: .destructive) {
Task { await deleteEvent() }
}
Button("Abbrechen", role: .cancel) {}
} message: {
Text("\"\(event.title)\" wird dauerhaft gelöscht.")
}
}
}
private func deleteEvent() async {
isDeleting = true
do {
if event.source == "local" {
try await api.deleteLocalEvent(uid: event.id)
} else {
let calId = Int(event.calendarId)
try await api.deleteCalDAVEvent(uid: event.id, url: event.url, calendarId: calId)
}
await onDone(nil)
} catch {
isDeleting = false
}
}
}

View File

@@ -0,0 +1,182 @@
import SwiftUI
struct EventEditorSheet: View {
let api: CalendarrAPI
let store: CalendarStore
let initialDate: Date
let editingEvent: CalEvent?
let onSaved: () async -> Void
@Environment(\.dismiss) var dismiss
@State private var title = ""
@State private var isAllDay = false
@State private var startDate = Date()
@State private var endDate = Date().addingTimeInterval(3600)
@State private var location = ""
@State private var notes = ""
@State private var selectedCalendarId: String = ""
@State private var color = ""
@State private var isSaving = false
@State private var error = ""
private var isEditing: Bool { editingEvent != nil }
private var selectedCal: WritableCalendar? {
store.writableCalendars.first { $0.id == selectedCalendarId }
}
var body: some View {
NavigationStack {
Form {
Section {
TextField("Titel", text: $title)
.font(.body.weight(.medium))
}
Section {
Toggle("Ganztägig", isOn: $isAllDay.animation())
.tint(Color.accentColor)
if isAllDay {
DatePicker("Start", selection: $startDate, displayedComponents: .date)
DatePicker("Ende", selection: $endDate, displayedComponents: .date)
} else {
DatePicker("Start", selection: $startDate)
DatePicker("Ende", selection: $endDate)
}
}
Section {
TextField("Ort", text: $location)
TextField("Beschreibung", text: $notes, axis: .vertical)
.lineLimit(3...6)
}
Section("Kalender") {
if store.writableCalendars.isEmpty {
Text("Keine beschreibbaren Kalender vorhanden")
.foregroundStyle(.secondary)
.font(.callout)
} else {
Picker("Kalender", selection: $selectedCalendarId) {
ForEach(store.writableCalendars) { cal in
HStack {
Circle()
.fill(Color(hex: cal.color))
.frame(width: 10, height: 10)
Text(cal.name)
}
.tag(cal.id)
}
}
}
}
Section("Farbe") {
HStack {
Text("Terminfarbe")
Spacer()
ColorPicker("", selection: Binding(
get: { Color(hex: color.isEmpty ? (selectedCal?.color ?? "#4285f4") : color) },
set: { color = $0.toHex() }
), supportsOpacity: false)
.labelsHidden()
if !color.isEmpty {
Button("Zurücksetzen") { color = "" }
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
if !error.isEmpty {
Section {
Text(error).foregroundStyle(.red)
}
}
}
.navigationTitle(isEditing ? "Termin bearbeiten" : "Neuer Termin")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen") { dismiss() }
}
ToolbarItem(placement: .primaryAction) {
Button(isEditing ? "Sichern" : "Hinzufügen") {
Task { await save() }
}
.bold()
.disabled(title.isEmpty || selectedCalendarId.isEmpty || isSaving)
}
}
}
.onAppear { setup() }
}
private func setup() {
if let ev = editingEvent {
title = ev.title
isAllDay = ev.isAllDay
startDate = ev.startDate
endDate = ev.endDate
location = ev.location
notes = ev.notes
color = ev.color ?? ""
selectedCalendarId = ev.calendarId
} else {
let cal = Calendar.current
startDate = cal.date(bySettingHour: cal.component(.hour, from: initialDate),
minute: 0, second: 0, of: initialDate) ?? initialDate
endDate = startDate.addingTimeInterval(3600)
selectedCalendarId = store.writableCalendars.first?.id ?? ""
}
}
private func save() async {
guard let cal = selectedCal else { return }
isSaving = true
error = ""
defer { isSaving = false }
let colorVal: String? = color.isEmpty ? nil : color
let start = isAllDay ? Calendar.current.startOfDay(for: startDate) : startDate
let end = isAllDay ? Calendar.current.startOfDay(for: Calendar.current.date(byAdding: .day, value: 1, to: endDate)!) : endDate
do {
if let ev = editingEvent {
if ev.source == "local" {
try await api.updateLocalEvent(uid: ev.id, title: title, start: start, end: end,
isAllDay: isAllDay, location: location, description: notes, color: colorVal)
} else {
let calId = Int(ev.calendarId)
try await api.updateCalDAVEvent(uid: ev.id, url: ev.url, calendarId: calId,
title: title, start: start, end: end, isAllDay: isAllDay,
location: location, description: notes, color: colorVal)
}
} else {
switch cal.source {
case "local":
_ = try await api.createLocalEvent(calendarId: cal.numericId, title: title,
start: start, end: end, isAllDay: isAllDay,
location: location, description: notes, color: colorVal)
case "google":
try await api.createGoogleEvent(calendarDbId: cal.numericId, title: title,
start: start, end: end, isAllDay: isAllDay,
location: location, description: notes)
case "homeassistant":
try await api.createHAEvent(calendarId: cal.numericId, title: title,
start: start, end: end, isAllDay: isAllDay,
location: location, description: notes)
default: // caldav
try await api.createCalDAVEvent(calendarId: cal.numericId, title: title,
start: start, end: end, isAllDay: isAllDay,
location: location, description: notes, color: colorVal)
}
}
await onSaved()
dismiss()
} catch {
self.error = error.localizedDescription
}
}
}

View File

@@ -0,0 +1,150 @@
import SwiftUI
struct MonthView: View {
let store: CalendarStore
let onDayTap: (Date) -> Void
let onEventTap: (CalEvent) -> Void
private var cal: Calendar { store.userCalendar }
private var monthStart: Date {
cal.date(from: cal.dateComponents([.year, .month], from: store.currentDate))!
}
private var gridDays: [Date] {
let firstWeekday = cal.firstWeekday
let weekday = cal.component(.weekday, from: monthStart)
let offset = ((weekday - firstWeekday) + 7) % 7
let gridStart = cal.date(byAdding: .day, value: -offset, to: monthStart)!
return (0..<42).compactMap { cal.date(byAdding: .day, value: $0, to: gridStart) }
}
private var rowCount: Int { gridDays.count / 7 } // always 6
private var weekdayHeaders: [String] {
let symbols = cal.shortWeekdaySymbols
let start = cal.firstWeekday - 1
return (0..<7).map { String(symbols[(start + $0) % 7].prefix(2)) }
}
var body: some View {
VStack(spacing: 0) {
// Day-of-week header row (fixed height)
HStack(spacing: 0) {
ForEach(weekdayHeaders, id: \.self) { d in
Text(d)
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, minHeight: 28)
}
}
Divider()
// Grid fills all remaining space using GeometryReader
GeometryReader { geo in
let rowH = geo.size.height / CGFloat(rowCount)
VStack(spacing: 0) {
ForEach(0..<rowCount, id: \.self) { row in
HStack(spacing: 0) {
ForEach(0..<7, id: \.self) { col in
let day = gridDays[row * 7 + col]
DayCell(
date: day,
isCurrentMonth: cal.isDate(day, equalTo: monthStart, toGranularity: .month),
isToday: cal.isDateInToday(day),
events: store.events(on: day),
rowHeight: rowH,
onTap: { onDayTap(day) },
onEventTap: onEventTap
)
}
}
.frame(height: rowH)
}
}
}
}
}
}
private struct DayCell: View {
let date: Date
let isCurrentMonth: Bool
let isToday: Bool
let events: [CalEvent]
let rowHeight: CGFloat
let onTap: () -> Void
let onEventTap: (CalEvent) -> Void
private var maxVisible: Int {
max(1, Int((rowHeight - 32) / 16))
}
var body: some View {
VStack(alignment: .leading, spacing: 2) {
// Day number
Button(action: onTap) {
Text("\(Calendar.current.component(.day, from: date))")
.font(.system(size: 13, weight: isToday ? .bold : .regular))
.foregroundStyle(
isToday ? Color.white :
isCurrentMonth ? Color.primary : Color.secondary.opacity(0.4)
)
.frame(width: 26, height: 26)
.background(isToday ? Color.accentColor : Color.clear)
.clipShape(Circle())
}
.buttonStyle(.plain)
.padding(.leading, 4)
.padding(.top, 2)
// Events
ForEach(events.prefix(maxVisible)) { ev in
Button { onEventTap(ev) } label: {
EventChip(event: ev)
}
.buttonStyle(.plain)
}
if events.count > maxVisible {
Text("+\(events.count - maxVisible)")
.font(.system(size: 9, weight: .medium))
.foregroundStyle(.secondary)
.padding(.leading, 4)
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.overlay(alignment: .trailing) {
Rectangle().fill(Color(.separator)).frame(width: 0.5)
}
.overlay(alignment: .bottom) {
Rectangle().fill(Color(.separator)).frame(height: 0.5)
}
}
}
private struct EventChip: View {
let event: CalEvent
var body: some View {
HStack(spacing: 3) {
if !event.isAllDay {
Circle()
.fill(Color(hex: event.effectiveColor))
.frame(width: 6, height: 6)
}
Text(event.title)
.font(.system(size: 10, weight: .medium))
.lineLimit(1)
.foregroundStyle(event.isAllDay ? .white : .primary)
}
.padding(.horizontal, event.isAllDay ? 4 : 2)
.padding(.vertical, 1)
.frame(maxWidth: .infinity, alignment: .leading)
.background(event.isAllDay ? Color(hex: event.effectiveColor) : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 3))
.padding(.horizontal, 2)
}
}

View File

@@ -0,0 +1,118 @@
import SwiftUI
struct QuarterView: View {
let store: CalendarStore
let onEventTap: (CalEvent) -> Void
private var cal: Calendar { store.userCalendar }
private var months: [Date] {
let start = cal.date(from: cal.dateComponents([.year, .month], from: store.currentDate))!
return (0..<3).compactMap { cal.date(byAdding: .month, value: $0, to: start) }
}
var body: some View {
ScrollView {
VStack(spacing: 0) {
ForEach(months, id: \.self) { month in
MiniMonthBlock(month: month, store: store, onEventTap: onEventTap)
Divider()
}
}
}
}
}
private struct MiniMonthBlock: View {
let month: Date
let store: CalendarStore
let onEventTap: (CalEvent) -> Void
private var cal: Calendar { store.userCalendar }
private let monthFmt: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "MMMM yyyy"; return f
}()
private var gridDays: [Date] {
let firstWeekday = cal.firstWeekday
let weekday = cal.component(.weekday, from: month)
let offset = ((weekday - firstWeekday) + 7) % 7
let gridStart = cal.date(byAdding: .day, value: -offset, to: month)!
let rows = 6
return (0..<(rows * 7)).compactMap { cal.date(byAdding: .day, value: $0, to: gridStart) }
}
private var weekdayHeaders: [String] {
let symbols = cal.veryShortWeekdaySymbols
let start = cal.firstWeekday - 1
return (0..<7).map { symbols[(start + $0) % 7] }
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(monthFmt.string(from: month))
.font(.headline.weight(.semibold))
.padding(.horizontal, 16)
.padding(.top, 12)
HStack(spacing: 0) {
ForEach(weekdayHeaders, id: \.self) { d in
Text(d)
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
}
}
.padding(.horizontal, 8)
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 0), count: 7), spacing: 2) {
ForEach(gridDays, id: \.self) { day in
MiniDayCell(
date: day,
isCurrentMonth: cal.isDate(day, equalTo: month, toGranularity: .month),
isToday: cal.isDateInToday(day),
events: store.events(on: day),
onEventTap: onEventTap
)
}
}
.padding(.horizontal, 8)
.padding(.bottom, 12)
}
}
}
private struct MiniDayCell: View {
let date: Date
let isCurrentMonth: Bool
let isToday: Bool
let events: [CalEvent]
let onEventTap: (CalEvent) -> Void
var body: some View {
VStack(spacing: 1) {
Text("\(Calendar.current.component(.day, from: date))")
.font(.system(size: 12, weight: isToday ? .bold : .regular))
.foregroundStyle(
isToday ? Color.white :
isCurrentMonth ? Color.primary : Color.secondary.opacity(0.3)
)
.frame(width: 22, height: 22)
.background(isToday ? Color.accentColor : Color.clear)
.clipShape(Circle())
// Up to 3 event dots
HStack(spacing: 2) {
ForEach(events.prefix(3)) { ev in
Circle()
.fill(Color(hex: ev.effectiveColor))
.frame(width: 4, height: 4)
.onTapGesture { onEventTap(ev) }
}
}
.frame(height: 6)
}
.frame(minHeight: 36)
}
}

View File

@@ -0,0 +1,45 @@
import SwiftUI
// Shared constants used by WeekView, DayView, EventEditorSheet
let hourHeight: CGFloat = 60
let timeColumnWidth: CGFloat = 44
let hours = Array(0..<24)
// Position helpers
func eventTop(_ ev: CalEvent) -> CGFloat {
let cal = Calendar.current
let h = CGFloat(cal.component(.hour, from: ev.startDate))
let m = CGFloat(cal.component(.minute, from: ev.startDate))
return h * hourHeight + m * hourHeight / 60
}
func eventHeight(_ ev: CalEvent) -> CGFloat {
let dur = ev.endDate.timeIntervalSince(ev.startDate)
return max(CGFloat(dur / 3600) * hourHeight, 20)
}
// Shared event block used in WeekView and DayView
struct EventBlock: View {
let event: CalEvent
var body: some View {
RoundedRectangle(cornerRadius: 4)
.fill(Color(hex: event.effectiveColor).opacity(0.85))
.overlay(alignment: .topLeading) {
VStack(alignment: .leading, spacing: 1) {
Text(event.title)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.white)
.lineLimit(2)
if !event.location.isEmpty {
Text(event.location)
.font(.system(size: 10))
.foregroundStyle(.white.opacity(0.85))
.lineLimit(1)
}
}
.padding(4)
}
.padding(.horizontal, 1)
}
}

View File

@@ -0,0 +1,196 @@
import SwiftUI
struct WeekView: View {
let store: CalendarStore
let onEventTap: (CalEvent) -> Void
let onTimeTap: (Date) -> Void
private var cal: Calendar { store.userCalendar }
private var weekDays: [Date] {
let start = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: store.currentDate))!
return (0..<7).compactMap { cal.date(byAdding: .day, value: $0, to: start) }
}
private var timedEvents: [(Int, CalEvent)] {
weekDays.enumerated().flatMap { idx, day in
store.events(on: day).filter { !$0.isAllDay }.map { (idx, $0) }
}
}
private var allDayEvents: [CalEvent] {
let s = weekDays.first ?? .now
let e = cal.date(byAdding: .day, value: 1, to: weekDays.last ?? .now)!
return store.events(in: s, end: e).filter(\.isAllDay)
}
private var todayIndex: Int? {
weekDays.firstIndex(where: { cal.isDateInToday($0) })
}
private let headerFmt: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "EEE d"; return f
}()
var body: some View {
VStack(spacing: 0) {
columnHeaders
Divider()
if !allDayEvents.isEmpty { allDayRow }
timeGrid
}
}
// MARK: Column headers (fixed height no Color.clear tricks)
private var columnHeaders: some View {
HStack(spacing: 0) {
Spacer().frame(width: timeColumnWidth, height: 36) // fixed height!
ForEach(weekDays, id: \.self) { day in
Text(headerFmt.string(from: day).uppercased())
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(cal.isDateInToday(day) ? Color.accentColor : .secondary)
.frame(maxWidth: .infinity, minHeight: 36)
.overlay(alignment: .trailing) {
Rectangle().fill(Color(.separator)).frame(width: 0.5)
}
}
}
}
// MARK: All-day strip
private var allDayRow: some View {
HStack(spacing: 0) {
Spacer().frame(width: timeColumnWidth)
ForEach(weekDays, id: \.self) { day in
let dayEvs = allDayEvents.filter { ev in
let ds = cal.startOfDay(for: day)
let de = cal.date(byAdding: .day, value: 1, to: ds)!
return ev.startDate < de && ev.endDate > ds
}
VStack(spacing: 1) {
ForEach(dayEvs.prefix(2)) { ev in
Button { onEventTap(ev) } label: {
Text(ev.title)
.font(.system(size: 9, weight: .medium))
.foregroundStyle(.white)
.lineLimit(1)
.frame(maxWidth: .infinity)
.padding(.vertical, 2)
.background(Color(hex: ev.effectiveColor))
.clipShape(RoundedRectangle(cornerRadius: 2))
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 1)
.frame(maxWidth: .infinity)
.overlay(alignment: .trailing) {
Rectangle().fill(Color(.separator)).frame(width: 0.5)
}
}
}
.padding(.vertical, 4)
.overlay(alignment: .bottom) { Divider() }
}
// MARK: Time grid (GeometryReader OUTSIDE ScrollView clean layout)
private var timeGrid: some View {
GeometryReader { geo in
let colW = (geo.size.width - timeColumnWidth) / 7
ScrollViewReader { proxy in
ScrollView {
ZStack(alignment: .topLeading) {
// Background: time labels + vertical grid lines
HStack(alignment: .top, spacing: 0) {
timeLabels
ForEach(Array(weekDays.enumerated()), id: \.offset) { _, day in
VStack(spacing: 0) {
ForEach(hours, id: \.self) { _ in
Rectangle()
.fill(Color(.separator).opacity(0.4))
.frame(height: 0.5)
Color.clear.frame(height: hourHeight - 0.5)
}
}
.frame(width: colW)
.contentShape(Rectangle())
.onTapGesture { loc in
let h = Int(loc.y / hourHeight)
let m = Int((loc.y.truncatingRemainder(dividingBy: hourHeight)) / hourHeight * 60)
let date = cal.date(bySettingHour: h, minute: m, second: 0, of: day) ?? day
onTimeTap(date)
}
.overlay(alignment: .trailing) {
Rectangle().fill(Color(.separator)).frame(width: 0.5)
}
}
}
// Events positioned using known column widths (no GeometryReader inside)
ForEach(timedEvents, id: \.1.id) { dayIdx, ev in
Button(action: { onEventTap(ev) }) {
EventBlock(event: ev)
}
.buttonStyle(.plain)
.frame(width: colW - 2, height: max(eventHeight(ev), 18))
.offset(x: timeColumnWidth + CGFloat(dayIdx) * colW + 1,
y: eventTop(ev))
}
// Current time line
if let ti = todayIndex {
let lineY = eventTop(Date.now)
HStack(spacing: 0) {
Spacer().frame(width: timeColumnWidth + CGFloat(ti) * colW - 4)
Circle().fill(Color.red).frame(width: 8, height: 8)
Rectangle().fill(Color.red).frame(width: colW - 4, height: 1.5)
}
.offset(y: lineY - 0.75)
}
}
.frame(width: geo.size.width, height: hourHeight * 24 + 80)
.id("grid")
}
.onAppear { scrollToCurrentHour(proxy) }
.onChange(of: store.currentDate) { _, _ in scrollToCurrentHour(proxy) }
}
}
}
private var timeLabels: some View {
VStack(spacing: 0) {
ForEach(hours, id: \.self) { h in
ZStack(alignment: .topTrailing) {
Color.clear.frame(height: hourHeight)
Text(String(format: "%02d:00", h))
.font(.system(size: 10))
.foregroundStyle(.secondary)
.offset(y: -6)
}
}
Color.clear.frame(height: 80) // FAB space
}
.frame(width: timeColumnWidth)
}
private func scrollToCurrentHour(_ proxy: ScrollViewProxy) {
let h = Calendar.current.component(.hour, from: .now)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
withAnimation(.easeOut(duration: 0.3)) {
proxy.scrollTo("grid", anchor: UnitPoint(x: 0, y: CGFloat(max(h - 1, 0)) / 24.0))
}
}
}
}
// Trick to compute eventTop from a Date instead of CalEvent
private func eventTop(_ date: Date) -> CGFloat {
let cal = Calendar.current
let h = CGFloat(cal.component(.hour, from: date))
let m = CGFloat(cal.component(.minute, from: date))
return h * hourHeight + m * hourHeight / 60
}

View File

@@ -0,0 +1,135 @@
import SwiftUI
struct LoginView: View {
@Environment(AppState.self) var appState
@State private var username = ""
@State private var password = ""
@State private var totpCode = ""
@State private var rememberMe = true
@State private var needsTOTP = false
@State private var error = ""
@State private var isLoading = false
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 0) {
Spacer().frame(height: 60)
VStack(spacing: 8) {
Image(systemName: "calendar")
.font(.system(size: 48, weight: .light))
.foregroundStyle(Color.accentColor)
Text("Calendarr")
.font(.largeTitle.bold())
Text(appState.serverURL
.replacingOccurrences(of: "https://", with: "")
.replacingOccurrences(of: "http://", with: ""))
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.bottom, 40)
VStack(spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
Text("Benutzername")
.font(.footnote.weight(.medium))
.foregroundStyle(.secondary)
TextField("Benutzername", text: $username)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.padding(12)
.background(.quaternary)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
VStack(alignment: .leading, spacing: 6) {
Text("Passwort")
.font(.footnote.weight(.medium))
.foregroundStyle(.secondary)
SecureField("Passwort", text: $password)
.padding(12)
.background(.quaternary)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
if needsTOTP {
VStack(alignment: .leading, spacing: 6) {
Text("2FA-Code")
.font(.footnote.weight(.medium))
.foregroundStyle(.secondary)
TextField("6-stelliger Code", text: $totpCode)
.keyboardType(.numberPad)
.padding(12)
.background(.quaternary)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
.transition(.move(edge: .top).combined(with: .opacity))
}
Toggle("Angemeldet bleiben", isOn: $rememberMe)
.tint(Color.accentColor)
if !error.isEmpty {
Text(error)
.font(.caption)
.foregroundStyle(.red)
.frame(maxWidth: .infinity, alignment: .leading)
}
Button {
Task { await login() }
} label: {
HStack {
if isLoading {
ProgressView().tint(.white)
} else {
Text("Anmelden").fontWeight(.semibold)
}
}
.frame(maxWidth: .infinity)
.padding(14)
.background(Color.accentColor)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.disabled(username.isEmpty || password.isEmpty || isLoading)
}
.padding(.horizontal, 32)
.animation(.easeInOut, value: needsTOTP)
Spacer().frame(height: 40)
Button("Anderen Server wählen") {
appState.resetServer()
}
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.navigationBarHidden(true)
}
}
private func login() async {
isLoading = true
error = ""
defer { isLoading = false }
do {
let code = needsTOTP ? (totpCode.isEmpty ? nil : totpCode) : nil
let result = try await CalendarrAPI.login(
baseURL: appState.serverURL,
username: username,
password: password,
totpCode: code,
rememberMe: rememberMe
)
appState.saveLogin(token: result.token, user: result.username, admin: result.isAdmin)
} catch APIError.twoFactorRequired {
withAnimation { needsTOTP = true }
} catch {
self.error = error.localizedDescription
}
}
}

View File

@@ -0,0 +1,18 @@
import SwiftUI
struct MainTabView: View {
@Environment(AppState.self) var appState
@State private var showMenu = false
var api: CalendarrAPI {
CalendarrAPI(baseURL: appState.serverURL, token: appState.authToken)
}
var body: some View {
CalendarHostView(api: api, showMenu: $showMenu)
.ignoresSafeArea(edges: .bottom)
.sheet(isPresented: $showMenu) {
MenuSheet(api: api)
}
}
}

View File

@@ -0,0 +1,91 @@
import SwiftUI
struct MenuSheet: View {
let api: CalendarrAPI
@Environment(AppState.self) var appState
@Environment(\.dismiss) var dismiss
var body: some View {
NavigationStack {
List {
// User info header
Section {
HStack(spacing: 12) {
Circle()
.fill(Color.accentColor)
.frame(width: 44, height: 44)
.overlay {
Text(appState.username.prefix(1).uppercased())
.font(.title3.bold())
.foregroundStyle(.white)
}
VStack(alignment: .leading, spacing: 2) {
Text(appState.username).font(.headline)
Text(appState.serverURL
.replacingOccurrences(of: "https://", with: "")
.replacingOccurrences(of: "http://", with: ""))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
if appState.isAdmin {
Spacer()
Text("Admin")
.font(.caption2.weight(.semibold))
.padding(.horizontal, 8).padding(.vertical, 3)
.background(Color.accentColor.opacity(0.15))
.foregroundStyle(Color.accentColor)
.clipShape(Capsule())
}
}
.padding(.vertical, 4)
}
// Navigation links direct destination syntax (no value-based nav)
Section("Einstellungen") {
NavigationLink {
ProfileView(api: api)
} label: {
Label("Profil", systemImage: "person.circle")
}
NavigationLink {
SettingsView(api: api)
} label: {
Label("Darstellung", systemImage: "paintpalette")
}
NavigationLink {
AccountsView(api: api)
} label: {
Label("Konten & Kalender", systemImage: "tray.2")
}
NavigationLink {
ServerView()
} label: {
Label("Server", systemImage: "server.rack")
}
}
Section {
Button(role: .destructive) {
dismiss()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
appState.logout()
}
} label: {
Label("Abmelden", systemImage: "rectangle.portrait.and.arrow.right")
}
}
}
.navigationTitle("Menü")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Fertig") { dismiss() }
}
}
}
}
}

View File

@@ -0,0 +1,306 @@
import SwiftUI
struct ProfileView: View {
let api: CalendarrAPI
@State private var profile: UserProfile?
@State private var isLoading = true
@State private var newEmail = ""
@State private var currentPW = ""
@State private var newPW = ""
@State private var confirmPW = ""
@State private var toast = ""
@State private var showToast = false
@State private var show2FASetup = false
@State private var show2FADisable = false
@State private var totpQR = ""
@State private var totpSecret = ""
@State private var totpCode = ""
@State private var disablePW = ""
@State private var isSaving2FA = false
var body: some View {
NavigationStack {
Group {
if isLoading {
ProgressView("Lade Profil…")
} else if let profile {
Form {
kontoSection(profile: profile)
passwordSection
twoFASection(profile: profile)
}
}
}
.navigationTitle("Profil")
.navigationBarTitleDisplayMode(.large)
.overlay(alignment: .bottom) {
if showToast {
Text(toast)
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background(.regularMaterial)
.clipShape(Capsule())
.padding(.bottom, 20)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.animation(.easeInOut, value: showToast)
.sheet(isPresented: $show2FASetup) {
TwoFASetupSheet(
qrURL: totpQR,
secret: totpSecret,
code: $totpCode,
isSaving: isSaving2FA
) {
Task { await enable2FA() }
}
}
.sheet(isPresented: $show2FADisable) {
TwoFADisableSheet(password: $disablePW) {
Task { await disable2FA() }
}
}
}
.task { await load() }
}
func kontoSection(profile: UserProfile) -> some View {
Section("Konto") {
HStack {
Text("Benutzername")
Spacer()
Text(profile.username)
.foregroundStyle(.secondary)
}
HStack {
Text("Rolle")
Spacer()
Text(profile.isAdmin ? "Administrator" : "Benutzer")
.foregroundStyle(.secondary)
}
HStack {
Text("E-Mail")
Spacer()
TextField("Keine E-Mail", text: $newEmail)
.multilineTextAlignment(.trailing)
.foregroundStyle(.secondary)
.keyboardType(.emailAddress)
.textInputAutocapitalization(.never)
}
Button("E-Mail speichern") {
Task { await saveEmail() }
}
.foregroundStyle(Color.accentColor)
}
}
var passwordSection: some View {
Section("Passwort ändern") {
SecureField("Aktuelles Passwort", text: $currentPW)
SecureField("Neues Passwort", text: $newPW)
SecureField("Neues Passwort wiederholen", text: $confirmPW)
Button("Passwort ändern") {
Task { await changePassword() }
}
.foregroundStyle(Color.accentColor)
.disabled(currentPW.isEmpty || newPW.isEmpty || confirmPW.isEmpty)
}
}
func twoFASection(profile: UserProfile) -> some View {
Section("Zwei-Faktor-Authentifizierung") {
if profile.totpEnabled {
HStack {
Image(systemName: "checkmark.shield.fill")
.foregroundStyle(.green)
Text("2FA ist aktiviert")
}
Button("2FA deaktivieren") {
show2FADisable = true
}
.foregroundStyle(.red)
} else {
HStack {
Image(systemName: "shield")
.foregroundStyle(.secondary)
Text("2FA ist deaktiviert")
.foregroundStyle(.secondary)
}
Button("2FA einrichten") {
Task { await setup2FA() }
}
.foregroundStyle(Color.accentColor)
}
}
}
private func load() async {
isLoading = true
defer { isLoading = false }
if let p = try? await api.getProfile() {
profile = p
newEmail = p.email ?? ""
}
}
private func saveEmail() async {
do {
try await api.updateEmail(newEmail)
showNotice("E-Mail gespeichert")
} catch {
showNotice(error.localizedDescription)
}
}
private func changePassword() async {
guard newPW == confirmPW else {
showNotice("Passwörter stimmen nicht überein")
return
}
do {
try await api.changePassword(current: currentPW, new: newPW)
currentPW = ""; newPW = ""; confirmPW = ""
showNotice("Passwort geändert")
} catch {
showNotice(error.localizedDescription)
}
}
private func setup2FA() async {
do {
let result = try await api.setup2FA()
totpSecret = result.secret
totpQR = result.qrUrl
totpCode = ""
show2FASetup = true
} catch {
showNotice(error.localizedDescription)
}
}
private func enable2FA() async {
isSaving2FA = true
do {
try await api.enable2FA(code: totpCode)
show2FASetup = false
showNotice("2FA aktiviert")
await load()
} catch {
showNotice(error.localizedDescription)
}
isSaving2FA = false
}
private func disable2FA() async {
do {
try await api.disable2FA(password: disablePW)
show2FADisable = false
showNotice("2FA deaktiviert")
await load()
} catch {
showNotice(error.localizedDescription)
}
}
private func showNotice(_ msg: String) {
toast = msg
withAnimation { showToast = true }
Task {
try? await Task.sleep(for: .seconds(2))
withAnimation { showToast = false }
}
}
}
struct TwoFASetupSheet: View {
let qrURL: String
let secret: String
@Binding var code: String
let isSaving: Bool
let onEnable: () -> Void
@Environment(\.dismiss) var dismiss
var body: some View {
NavigationStack {
Form {
Section {
Text("Scanne den QR-Code mit deiner Authenticator-App (z.B. Bitwarden, Google Authenticator).")
.font(.body)
}
Section("QR-Code / Manueller Schlüssel") {
if let url = URL(string: qrURL) {
AsyncImage(url: url) { phase in
switch phase {
case .success(let img):
img.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 200)
.frame(maxWidth: .infinity)
default:
ProgressView()
.frame(maxWidth: .infinity)
}
}
.padding(.vertical, 8)
}
HStack {
Text(secret)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
Spacer()
Button {
UIPasteboard.general.string = secret
} label: {
Image(systemName: "doc.on.doc")
}
.foregroundStyle(Color.accentColor)
}
}
Section("Bestätigung") {
TextField("6-stelliger Code", text: $code)
.keyboardType(.numberPad)
}
}
.navigationTitle("2FA einrichten")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Abbrechen") { dismiss() } }
ToolbarItem(placement: .primaryAction) {
Button("Aktivieren") { onEnable() }
.bold()
.disabled(code.count < 6 || isSaving)
}
}
}
}
}
struct TwoFADisableSheet: View {
@Binding var password: String
let onDisable: () -> Void
@Environment(\.dismiss) var dismiss
var body: some View {
NavigationStack {
Form {
Section("Passwort zum Deaktivieren") {
SecureField("Passwort", text: $password)
}
}
.navigationTitle("2FA deaktivieren")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Abbrechen") { dismiss() } }
ToolbarItem(placement: .primaryAction) {
Button("Deaktivieren") { onDisable() }
.bold()
.foregroundStyle(.red)
.disabled(password.isEmpty)
}
}
}
}
}

View File

@@ -0,0 +1,15 @@
import SwiftUI
struct RootView: View {
@Environment(AppState.self) var appState
var body: some View {
if !appState.isConfigured {
ServerSetupView()
} else if !appState.isLoggedIn {
LoginView()
} else {
MainTabView()
}
}
}

View File

@@ -0,0 +1,94 @@
import SwiftUI
struct ServerSetupView: View {
@Environment(AppState.self) var appState
@State private var urlInput = ""
@State private var error = ""
@State private var isChecking = false
var body: some View {
NavigationStack {
VStack(spacing: 0) {
Spacer()
VStack(spacing: 24) {
VStack(spacing: 8) {
Image(systemName: "calendar")
.font(.system(size: 56, weight: .light))
.foregroundStyle(Color.accentColor)
Text("Calendarr")
.font(.largeTitle.bold())
Text("Server verbinden")
.font(.subheadline)
.foregroundStyle(.secondary)
}
VStack(alignment: .leading, spacing: 8) {
Text("Server-URL")
.font(.footnote.weight(.medium))
.foregroundStyle(.secondary)
TextField("https://calendarr.example.com", text: $urlInput)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
.padding(12)
.background(.quaternary)
.clipShape(RoundedRectangle(cornerRadius: 10))
if !error.isEmpty {
Text(error)
.font(.caption)
.foregroundStyle(.red)
}
}
Button {
Task { await connect() }
} label: {
HStack {
if isChecking {
ProgressView()
.tint(.white)
} else {
Text("Verbinden")
.fontWeight(.semibold)
}
}
.frame(maxWidth: .infinity)
.padding(14)
.background(Color.accentColor)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.disabled(urlInput.isEmpty || isChecking)
}
.padding(.horizontal, 32)
Spacer()
Text("© 2026 Scarriffleservices")
.font(.caption2)
.foregroundStyle(.tertiary)
.padding(.bottom, 24)
}
.navigationBarHidden(true)
}
}
private func connect() async {
isChecking = true
error = ""
defer { isChecking = false }
var url = urlInput.trimmingCharacters(in: .whitespacesAndNewlines)
if !url.hasPrefix("http") { url = "https://" + url }
if url.hasSuffix("/") { url = String(url.dropLast()) }
do {
_ = try await CalendarrAPI.checkSetupRequired(baseURL: url)
appState.saveServer(url: url)
} catch {
self.error = "Server nicht erreichbar. URL prüfen."
}
}
}

View File

@@ -0,0 +1,168 @@
import SwiftUI
struct ServerView: View {
@Environment(AppState.self) var appState
@State private var showLogoutConfirm = false
@State private var showChangeServer = false
@State private var showImpressum = false
var serverHost: String {
appState.serverURL
.replacingOccurrences(of: "https://", with: "")
.replacingOccurrences(of: "http://", with: "")
}
var body: some View {
NavigationStack {
Form {
Section("Verbundener Server") {
HStack {
Image(systemName: "server.rack")
.foregroundStyle(Color.accentColor)
.frame(width: 28)
VStack(alignment: .leading, spacing: 2) {
Text(serverHost)
.font(.body)
Text(appState.serverURL)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
HStack {
Image(systemName: "person.fill")
.foregroundStyle(.secondary)
.frame(width: 28)
Text(appState.username)
if appState.isAdmin {
Spacer()
Text("Admin")
.font(.caption.weight(.semibold))
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color.accentColor.opacity(0.15))
.foregroundStyle(Color.accentColor)
.clipShape(Capsule())
}
}
}
Section {
Button {
showChangeServer = true
} label: {
HStack {
Image(systemName: "arrow.triangle.swap")
.frame(width: 28)
Text("Server wechseln")
}
}
.foregroundStyle(.primary)
Button(role: .destructive) {
showLogoutConfirm = true
} label: {
HStack {
Image(systemName: "rectangle.portrait.and.arrow.right")
.frame(width: 28)
Text("Abmelden")
}
}
}
Section("Info") {
Button {
showImpressum = true
} label: {
HStack {
Image(systemName: "info.circle")
.frame(width: 28)
Text("Impressum")
}
}
.foregroundStyle(.secondary)
HStack {
Text("Version")
Spacer()
Text("1.0")
.foregroundStyle(.secondary)
}
}
}
.navigationTitle("Server")
.navigationBarTitleDisplayMode(.large)
.confirmationDialog("Abmelden", isPresented: $showLogoutConfirm, titleVisibility: .visible) {
Button("Abmelden", role: .destructive) {
appState.logout()
}
Button("Abbrechen", role: .cancel) {}
} message: {
Text("Du wirst von \(serverHost) abgemeldet.")
}
.confirmationDialog("Server wechseln", isPresented: $showChangeServer, titleVisibility: .visible) {
Button("Server wechseln", role: .destructive) {
appState.resetServer()
}
Button("Abbrechen", role: .cancel) {}
} message: {
Text("Verbindung zu \(serverHost) wird getrennt und alle lokalen Anmeldedaten werden gelöscht.")
}
.sheet(isPresented: $showImpressum) {
ImpressumView()
}
}
}
}
struct ImpressumView: View {
@Environment(\.dismiss) var dismiss
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Group {
Text("Scarriffleservices")
.font(.title2.bold())
Text("Software & Webentwicklung")
.foregroundStyle(.secondary)
}
Divider()
Text("Diese Software wurde von Scarriffleservices mit grösster Sorgfalt entwickelt und bereitgestellt. Alle Rechte vorbehalten © 2026 Scarriffleservices.")
VStack(alignment: .leading, spacing: 6) {
Text("Datenspeicherung").font(.headline)
Text("Alle Anwendungsdaten werden auf dem Server gespeichert und verarbeitet, auf dem diese Calendarr-Instanz betrieben wird. Der Speicherort hängt damit vom Betreiber des jeweiligen Servers ab. Bei Nutzung der Google Kalender-Anbindung werden Daten über die Google API ausgetauscht; für diese Daten gelten die Datenschutzbestimmungen von Google. Bei Nutzung der Home Assistant-Anbindung werden Daten mit der jeweiligen Home Assistant-Instanz ausgetauscht. Home Assistant ist ein Projekt der Open Home Foundation.")
}
VStack(alignment: .leading, spacing: 6) {
Text("Haftungsausschluss").font(.headline)
Text("Trotz sorgfältiger Erstellung wird keine Haftung für die Richtigkeit, Vollständigkeit oder Aktualität der bereitgestellten Inhalte übernommen. Die Nutzung erfolgt auf eigene Verantwortung.")
}
VStack(alignment: .leading, spacing: 6) {
Text("Kontakt").font(.headline)
Link("scarriffleservices@gmail.com", destination: URL(string: "mailto:scarriffleservices@gmail.com")!)
}
Divider()
Text("Calendarr v11 · iOS App v1.0")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(24)
}
.navigationTitle("Impressum")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Schliessen") { dismiss() }
}
}
}
}
}

View File

@@ -0,0 +1,316 @@
import SwiftUI
struct SettingsView: View {
let api: CalendarrAPI
@State private var settings = AppSettings()
@State private var isLoading = true
@State private var isSaving = false
@State private var toast = ""
@State private var showToast = false
@AppStorage("liquidGlass") private var liquidGlass = false
@AppStorage("cacheMonths") private var cacheMonths = 3
var body: some View {
NavigationStack {
Group {
if isLoading {
ProgressView("Lade Einstellungen…")
} else {
Form {
liquidGlassSection
cacheSection
spracheSection
farbenSection
schriftSection
linienSection
ansichtSection
stundenSection
}
}
}
.navigationTitle("Darstellung")
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
Task { await save() }
} label: {
if isSaving {
ProgressView()
} else {
Text("Speichern").bold()
}
}
.disabled(isSaving)
}
}
.overlay(alignment: .bottom) {
if showToast {
Text(toast)
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background(.regularMaterial)
.clipShape(Capsule())
.padding(.bottom, 20)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.animation(.easeInOut, value: showToast)
}
.task { await load() }
}
// MARK: Liquid Glass
var liquidGlassSection: some View {
Section {
Toggle(isOn: $liquidGlass) {
Label {
VStack(alignment: .leading, spacing: 2) {
Text("Liquid Glass")
Text("Verwendet die neue iOS\u{202F}26 Glasoptik mit transparenter Navigationsleiste")
.font(.caption)
.foregroundStyle(.secondary)
}
} icon: {
Image(systemName: "sparkles")
.foregroundStyle(.blue)
}
}
.tint(Color.accentColor)
} header: {
Text("App-Design")
} footer: {
Text("Änderung wirkt sofort kein Neustart nötig.")
.font(.caption)
}
}
// MARK: Cache
var cacheSection: some View {
Section {
VStack(alignment: .leading, spacing: 10) {
Label {
VStack(alignment: .leading, spacing: 2) {
Text("Vorladen")
Text("Events werden beim Start im Hintergrund für diesen Zeitraum geladen, danach ist Wischen sofort.")
.font(.caption)
.foregroundStyle(.secondary)
}
} icon: {
Image(systemName: "arrow.down.circle")
.foregroundStyle(.green)
}
Picker("Zeitraum", selection: $cacheMonths) {
Text("±1 Monat").tag(1)
Text("±3 Monate").tag(3)
Text("±6 Monate").tag(6)
Text("±1 Jahr").tag(12)
}
.pickerStyle(.segmented)
}
.padding(.vertical, 4)
} header: {
Text("Vorladen")
} footer: {
Text("Mehr Monate = längerer initialer Ladevorgang, danach komplett ohne Wartezeiten navigierbar.")
.font(.caption)
}
}
// MARK: Sprache
var spracheSection: some View {
Section("Sprache") {
Picker("Sprache", selection: $settings.language) {
Text("Deutsch").tag("de")
Text("English").tag("en")
}
}
}
// MARK: Farben
var farbenSection: some View {
Section("Farben") {
ColorPickerRow(label: "Primärfarbe", hex: $settings.primaryColor)
ColorPickerRow(label: "Akzentfarbe", hex: $settings.accentColor)
ColorPickerRow(label: "Heutige-Tag-Farbe", hex: $settings.todayColor)
ColorPickerRow(label: "Monatswechsel-Linie", hex: $settings.monthDividerColor)
ColorPickerRow(label: "Monatskürzel", hex: $settings.monthLabelColor)
}
}
// MARK: Schriftkontrast
var schriftSection: some View {
Section {
VStack(alignment: .leading, spacing: 10) {
Text("Schriftkontrast")
.font(.headline)
Text("Helligkeit der Beschriftungen und Texte")
.font(.caption)
.foregroundStyle(.secondary)
ContrastSelector(
value: $settings.textContrast,
options: [
(1, "Dunkel"),
(2, "Mittel"),
(3, "Hell"),
(4, "Maximum")
]
)
}
.padding(.vertical, 4)
}
}
// MARK: Linienkontrast
var linienSection: some View {
Section {
VStack(alignment: .leading, spacing: 10) {
Text("Linienkontrast")
.font(.headline)
Text("Sichtbarkeit von Trennlinien und Rahmen")
.font(.caption)
.foregroundStyle(.secondary)
ContrastSelector(
value: $settings.lineContrast,
options: [
(1, "Kaum"),
(2, "Subtil"),
(3, "Normal"),
(4, "Stark")
]
)
}
.padding(.vertical, 4)
}
}
// MARK: Ansicht
var ansichtSection: some View {
Section("Kalenderansicht") {
Picker("Standardansicht", selection: $settings.defaultView) {
Text("Monat").tag("month")
Text("Woche").tag("week")
Text("Tag").tag("day")
Text("Quartal").tag("quarter")
Text("Termine").tag("agenda")
}
Picker("Erster Wochentag", selection: $settings.weekStartDay) {
Text("Montag").tag("monday")
Text("Sonntag").tag("sunday")
}
Toggle("Vergangene Termine ausgrauen", isOn: $settings.dimPastEvents)
.tint(Color.accentColor)
}
}
// MARK: Stundenhöhe
var stundenSection: some View {
Section {
VStack(alignment: .leading, spacing: 10) {
Text("Stundenhöhe")
.font(.headline)
Text("Platz pro Stunde in der Wochen- & Tagesansicht")
.font(.caption)
.foregroundStyle(.secondary)
ContrastSelector(
value: $settings.hourHeight,
options: [
(28, "Kompakt"),
(44, "Normal"),
(60, "Komfort"),
(80, "Gross")
]
)
}
.padding(.vertical, 4)
}
}
// MARK: Actions
private func load() async {
isLoading = true
defer { isLoading = false }
if let s = try? await api.getSettings() { settings = s }
}
private func save() async {
isSaving = true
defer { isSaving = false }
do {
try await api.updateSettings(settings)
showNotice("Gespeichert")
} catch {
showNotice(error.localizedDescription)
}
}
private func showNotice(_ msg: String) {
toast = msg
withAnimation { showToast = true }
Task {
try? await Task.sleep(for: .seconds(2))
withAnimation { showToast = false }
}
}
}
// MARK: Reusable Components
struct ColorPickerRow: View {
let label: String
@Binding var hex: String
var color: Binding<Color> {
Binding(
get: { Color(hex: hex) },
set: { hex = $0.toHex() }
)
}
var body: some View {
HStack {
Text(label)
Spacer()
ColorPicker("", selection: color, supportsOpacity: false)
.labelsHidden()
Text(hex.uppercased())
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
.frame(width: 68, alignment: .trailing)
}
}
}
struct ContrastSelector<T: Hashable & Equatable>: View {
@Binding var value: T
let options: [(T, String)]
var body: some View {
HStack(spacing: 8) {
ForEach(Array(options.enumerated()), id: \.offset) { _, opt in
Button {
value = opt.0
} label: {
Text(opt.1)
.font(.caption.weight(.medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(value == opt.0 ? Color.accentColor : Color(.systemGray5))
.foregroundStyle(value == opt.0 ? .white : .primary)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
}
}
}
}