Add localization (DE/EN), vertical-scroll month view, context menus, custom colors

- Vertical-scroll month view with multi-day event spans, zig-zag month
  divider, CW number per week, on-demand event loading while scrolling
- Top bar redesign: icon-only view picker on right, month title centered
- Long-press context menus on day cells (month) and hour slots (week/day)
  for "New event", "Open in week view", "Open in day view", "Open in month view"
- Localization system with system/de/en switch covering top bar, view picker,
  settings, menu, profile, server, accounts, event editor, agenda
- Three new color pickers (text/background/line) + today-marker color
  applied in calendar views; current-time line now uses today color
- App icon: removed alpha channel, accent color set to icon green (#20A050)
- TestFlight: ITSAppUsesNonExemptEncryption=NO baked into Info.plist keys

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-05-19 22:00:49 +02:00
parent e5529ca653
commit 8b3cc11e25
20 changed files with 1623 additions and 388 deletions

View File

@@ -15,11 +15,13 @@ struct AccountsView: View {
@State private var showAddHA = false
@State private var errorAlert: String?
@AppStorage("appLanguage") private var appLang = "system"
var body: some View {
NavigationStack {
Group {
if isLoading {
ProgressView("Lade Konten…")
ProgressView(L10n.t("accounts.loading", appLang))
} else {
List {
caldavSection
@@ -30,15 +32,15 @@ struct AccountsView: View {
}
}
}
.navigationTitle("Konten")
.navigationTitle(L10n.t("accounts.title", appLang))
.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 }
Button(L10n.t("accounts.add.caldav", appLang)) { showAddCalDAV = true }
Button(L10n.t("accounts.add.local", appLang)) { showAddLocal = true }
Button(L10n.t("accounts.add.ical", appLang)) { showAddICal = true }
Button(L10n.t("accounts.add.ha", appLang)) { showAddHA = true }
} label: {
Image(systemName: "plus")
}
@@ -56,8 +58,8 @@ struct AccountsView: View {
.sheet(isPresented: $showAddHA) {
AddHASheet(api: api) { await load() }
}
.alert("Fehler", isPresented: .constant(errorAlert != nil), actions: {
Button("OK") { errorAlert = nil }
.alert(L10n.t("common.error", appLang), isPresented: .constant(errorAlert != nil), actions: {
Button(L10n.t("common.ok", appLang)) { errorAlert = nil }
}, message: {
Text(errorAlert ?? "")
})
@@ -70,7 +72,7 @@ struct AccountsView: View {
var caldavSection: some View {
Section {
if caldavAccounts.isEmpty {
Text("Keine CalDAV-Konten")
Text(L10n.t("accounts.caldav.empty", appLang))
.foregroundStyle(.secondary)
} else {
ForEach(caldavAccounts) { acc in
@@ -91,17 +93,17 @@ struct AccountsView: View {
Task { await deleteCalDAV(offsets: offsets) }
}
}
Button("CalDAV hinzufügen") { showAddCalDAV = true }
Button(L10n.t("accounts.caldav.add", appLang)) { showAddCalDAV = true }
.foregroundStyle(Color.accentColor)
} header: {
Text("CalDAV-Konten")
Text(L10n.t("accounts.caldav.header", appLang))
}
}
var localSection: some View {
Section {
if localCalendars.isEmpty {
Text("Keine lokalen Kalender")
Text(L10n.t("accounts.local.empty", appLang))
.foregroundStyle(.secondary)
} else {
ForEach(localCalendars) { cal in
@@ -116,17 +118,17 @@ struct AccountsView: View {
Task { await deleteLocal(offsets: offsets) }
}
}
Button("Lokalen Kalender erstellen") { showAddLocal = true }
Button(L10n.t("accounts.local.add", appLang)) { showAddLocal = true }
.foregroundStyle(Color.accentColor)
} header: {
Text("Lokale Kalender")
Text(L10n.t("accounts.local.header", appLang))
}
}
var icalSection: some View {
Section {
if icalSubs.isEmpty {
Text("Keine Abonnements")
Text(L10n.t("accounts.ical.empty", appLang))
.foregroundStyle(.secondary)
} else {
ForEach(icalSubs) { sub in
@@ -136,7 +138,7 @@ struct AccountsView: View {
.frame(width: 12, height: 12)
VStack(alignment: .leading, spacing: 2) {
Text(sub.name).font(.body)
Text("Alle \(sub.refreshMinutes) Min.")
Text(String(format: L10n.t("accounts.ical.every", appLang), sub.refreshMinutes))
.font(.caption)
.foregroundStyle(.secondary)
}
@@ -146,17 +148,17 @@ struct AccountsView: View {
Task { await deleteICal(offsets: offsets) }
}
}
Button("iCal-URL abonnieren") { showAddICal = true }
Button(L10n.t("accounts.ical.add", appLang)) { showAddICal = true }
.foregroundStyle(Color.accentColor)
} header: {
Text("iCal-Abonnements")
Text(L10n.t("accounts.ical.header", appLang))
}
}
var googleSection: some View {
Section {
if googleAccounts.isEmpty {
Text("Keine Google-Konten")
Text(L10n.t("accounts.google.empty", appLang))
.foregroundStyle(.secondary)
} else {
ForEach(googleAccounts) { acc in
@@ -170,18 +172,18 @@ struct AccountsView: View {
Task { await deleteGoogle(offsets: offsets) }
}
}
Text("Google-Konten werden über den Browser verknüpft")
Text(L10n.t("accounts.google.hint", appLang))
.font(.caption)
.foregroundStyle(.secondary)
} header: {
Text("Google-Konten")
Text(L10n.t("accounts.google.header", appLang))
}
}
var haSection: some View {
Section {
if haAccounts.isEmpty {
Text("Keine Home Assistant-Konten")
Text(L10n.t("accounts.ha.empty", appLang))
.foregroundStyle(.secondary)
} else {
ForEach(haAccounts) { acc in
@@ -197,10 +199,10 @@ struct AccountsView: View {
Task { await deleteHA(offsets: offsets) }
}
}
Button("Home Assistant hinzufügen") { showAddHA = true }
Button(L10n.t("accounts.ha.add", appLang)) { showAddHA = true }
.foregroundStyle(Color.accentColor)
} header: {
Text("Home Assistant")
Text(L10n.t("accounts.ha.header", appLang))
}
}
@@ -259,6 +261,7 @@ struct AddCalDAVSheet: View {
let api: CalendarrAPI
let onDone: () async -> Void
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
@State private var name = ""
@State private var url = ""
@@ -271,19 +274,19 @@ struct AddCalDAVSheet: View {
var body: some View {
NavigationStack {
Form {
Section("Konto-Details") {
TextField("Anzeigename", text: $name)
TextField("CalDAV-URL", text: $url)
Section(L10n.t("caldav.section", appLang)) {
TextField(L10n.t("caldav.display_name", appLang), text: $name)
TextField(L10n.t("caldav.url", appLang), text: $url)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
TextField("Benutzername", text: $username)
TextField(L10n.t("caldav.username", appLang), text: $username)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
SecureField("Passwort", text: $password)
SecureField(L10n.t("caldav.password", appLang), text: $password)
}
Section("Farbe") {
ColorPicker("Farbe", selection: $color, supportsOpacity: false)
Section(L10n.t("caldav.color_section", appLang)) {
ColorPicker(L10n.t("caldav.color_label", appLang), selection: $color, supportsOpacity: false)
}
if !error.isEmpty {
Section {
@@ -291,14 +294,14 @@ struct AddCalDAVSheet: View {
}
}
}
.navigationTitle("CalDAV-Konto")
.navigationTitle(L10n.t("caldav.title", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen") { dismiss() }
Button(L10n.t("common.cancel", appLang)) { dismiss() }
}
ToolbarItem(placement: .primaryAction) {
Button("Verbinden") {
Button(L10n.t("caldav.connect", appLang)) {
Task { await save() }
}
.bold()
@@ -326,6 +329,7 @@ struct AddLocalCalSheet: View {
let api: CalendarrAPI
let onDone: () async -> Void
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
@State private var name = ""
@State private var color = Color(hex: "#34a853")
@@ -336,19 +340,19 @@ struct AddLocalCalSheet: View {
NavigationStack {
Form {
Section {
TextField("Name", text: $name)
ColorPicker("Farbe", selection: $color, supportsOpacity: false)
TextField(L10n.t("local.name", appLang), text: $name)
ColorPicker(L10n.t("local.color", appLang), selection: $color, supportsOpacity: false)
}
if !error.isEmpty {
Section { Text(error).foregroundStyle(.red) }
}
}
.navigationTitle("Lokaler Kalender")
.navigationTitle(L10n.t("local.title", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Abbrechen") { dismiss() } }
ToolbarItem(placement: .cancellationAction) { Button(L10n.t("common.cancel", appLang)) { dismiss() } }
ToolbarItem(placement: .primaryAction) {
Button("Erstellen") {
Button(L10n.t("local.create", appLang)) {
Task { await save() }
}
.bold()
@@ -373,6 +377,7 @@ struct AddICalSheet: View {
let api: CalendarrAPI
let onDone: () async -> Void
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
@State private var name = ""
@State private var url = ""
@@ -381,21 +386,27 @@ struct AddICalSheet: View {
@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")]
private var refreshOptions: [(Int, String)] {
[(15, L10n.t("ical.refresh.15m", appLang)),
(30, L10n.t("ical.refresh.30m", appLang)),
(60, L10n.t("ical.refresh.1h", appLang)),
(360, L10n.t("ical.refresh.6h", appLang)),
(1440, L10n.t("ical.refresh.1d", appLang))]
}
var body: some View {
NavigationStack {
Form {
Section("Abonnement") {
TextField("Name", text: $name)
TextField("iCal-URL", text: $url)
Section(L10n.t("ical.subscription", appLang)) {
TextField(L10n.t("ical.name", appLang), text: $name)
TextField(L10n.t("ical.url", appLang), text: $url)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
ColorPicker("Farbe", selection: $color, supportsOpacity: false)
ColorPicker(L10n.t("ical.color", appLang), selection: $color, supportsOpacity: false)
}
Section("Aktualisierung") {
Picker("Intervall", selection: $refreshMinutes) {
Section(L10n.t("ical.refresh_section", appLang)) {
Picker(L10n.t("ical.interval", appLang), selection: $refreshMinutes) {
ForEach(refreshOptions, id: \.0) { opt in
Text(opt.1).tag(opt.0)
}
@@ -405,12 +416,12 @@ struct AddICalSheet: View {
Section { Text(error).foregroundStyle(.red) }
}
}
.navigationTitle("iCal abonnieren")
.navigationTitle(L10n.t("ical.title", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Abbrechen") { dismiss() } }
ToolbarItem(placement: .cancellationAction) { Button(L10n.t("common.cancel", appLang)) { dismiss() } }
ToolbarItem(placement: .primaryAction) {
Button("Abonnieren") {
Button(L10n.t("ical.subscribe", appLang)) {
Task { await save() }
}
.bold()
@@ -435,6 +446,7 @@ struct AddHASheet: View {
let api: CalendarrAPI
let onDone: () async -> Void
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
@State private var name = ""
@State private var url = ""
@@ -445,16 +457,16 @@ struct AddHASheet: View {
var body: some View {
NavigationStack {
Form {
Section("Home Assistant") {
TextField("Anzeigename", text: $name)
TextField("URL (z.B. http://homeassistant.local:8123)", text: $url)
Section(L10n.t("ha.section", appLang)) {
TextField(L10n.t("ha.display_name", appLang), text: $name)
TextField(L10n.t("ha.url_placeholder", appLang), 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")
Section(L10n.t("ha.auth_section", appLang)) {
SecureField(L10n.t("ha.token", appLang), text: $token)
Text(L10n.t("ha.token_hint", appLang))
.font(.caption)
.foregroundStyle(.secondary)
}
@@ -462,12 +474,12 @@ struct AddHASheet: View {
Section { Text(error).foregroundStyle(.red) }
}
}
.navigationTitle("Home Assistant")
.navigationTitle(L10n.t("accounts.add.ha", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Abbrechen") { dismiss() } }
ToolbarItem(placement: .cancellationAction) { Button(L10n.t("common.cancel", appLang)) { dismiss() } }
ToolbarItem(placement: .primaryAction) {
Button("Verbinden") {
Button(L10n.t("ha.connect", appLang)) {
Task { await save() }
}
.bold()

View File

@@ -3,6 +3,7 @@ import SwiftUI
struct AgendaView: View {
let store: CalendarStore
let onEventTap: (CalEvent) -> Void
@AppStorage("appLanguage") private var appLang = "system"
private var cal: Calendar { store.userCalendar }
@@ -17,25 +18,27 @@ struct AgendaView: View {
return dict.keys.sorted().map { ($0, dict[$0]!.sorted { $0.startDate < $1.startDate }) }
}
private let dayFmt: DateFormatter = {
private var dayFmt: DateFormatter {
let f = DateFormatter()
f.locale = L10n.locale(appLang)
f.dateFormat = "EEEE, d. MMMM yyyy"
return f
}()
}
private let timeFmt: DateFormatter = {
private var timeFmt: DateFormatter {
let f = DateFormatter()
f.locale = L10n.locale(appLang)
f.timeStyle = .short
f.dateStyle = .none
return f
}()
}
var body: some View {
if grouped.isEmpty {
ContentUnavailableView(
"Keine Termine",
L10n.t("cal.no_events_title", appLang),
systemImage: "calendar",
description: Text("In den nächsten 90 Tagen sind keine Termine vorhanden.")
description: Text(L10n.t("cal.no_events_body", appLang))
)
} else {
List {
@@ -43,7 +46,7 @@ struct AgendaView: View {
Section {
ForEach(evs) { ev in
Button { onEventTap(ev) } label: {
AgendaEventRow(event: ev, timeFmt: timeFmt)
AgendaEventRow(event: ev, timeFmt: timeFmt, allDayLabel: L10n.t("cal.allday", appLang))
}
.buttonStyle(.plain)
}
@@ -62,9 +65,10 @@ struct AgendaView: View {
private struct AgendaEventRow: View {
let event: CalEvent
let timeFmt: DateFormatter
let allDayLabel: String
var timeString: String {
if event.isAllDay { return "Ganztägig" }
if event.isAllDay { return allDayLabel }
return timeFmt.string(from: event.startDate)
}

View File

@@ -6,12 +6,25 @@ struct CalendarHostView: View {
@AppStorage("liquidGlass") private var liquidGlass = false
@AppStorage("cacheMonths") private var cacheMonths = 3
@AppStorage("appLanguage") private var appLang = "system"
@AppStorage("backgroundColor") private var bgHex = "#000000"
@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
@State private var visibleMonth: Date = .now
private var titleString: String {
if store.viewType == .month {
let f = DateFormatter()
f.locale = L10n.locale(appLang)
f.dateFormat = "LLLL yyyy"
return f.string(from: visibleMonth).capitalized(with: L10n.locale(appLang))
}
return store.titleForCurrentView(language: appLang)
}
var body: some View {
if liquidGlass {
@@ -30,6 +43,7 @@ struct CalendarHostView: View {
errorBanner
calendarContent
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(hex: bgHex))
.overlay(alignment: .top) {
if store.isLoading {
ProgressView().padding(.top, 10).transition(.opacity)
@@ -52,6 +66,7 @@ struct CalendarHostView: View {
.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: visibleMonth) { _, new in Task { await ensureLoaded(around: new) } }
}
// MARK: Liquid Glass variant
@@ -60,6 +75,7 @@ struct CalendarHostView: View {
NavigationStack {
calendarContent
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(hex: bgHex))
.overlay(alignment: .top) {
if store.isLoading {
ProgressView().padding(.top, 10).transition(.opacity)
@@ -74,12 +90,20 @@ struct CalendarHostView: View {
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)
Button(L10n.t("nav.today", appLang)) { store.moveToToday() }.font(.callout)
}
}
ToolbarItem(placement: .principal) { viewPickerMenu }
ToolbarItem(placement: .principal) {
Text(titleString)
.font(.headline)
.lineLimit(1)
.minimumScaleFactor(0.7)
}
ToolbarItem(placement: .navigationBarTrailing) {
Button { showMenu = true } label: { Image(systemName: "line.3.horizontal") }
HStack(spacing: 8) {
viewPickerMenu
Button { showMenu = true } label: { Image(systemName: "line.3.horizontal") }
}
}
}
}
@@ -89,6 +113,7 @@ struct CalendarHostView: View {
.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: visibleMonth) { _, new in Task { await ensureLoaded(around: new) } }
}
// MARK: Top bar (flat mode)
@@ -106,17 +131,21 @@ struct CalendarHostView: View {
.font(.system(size: 17, weight: .medium))
.frame(width: 36, height: 36)
}
Button("Heute") { store.moveToToday() }
Button(L10n.t("nav.today", appLang)) { store.moveToToday() }
.font(.callout).padding(.horizontal, 6)
}
.padding(.leading, 8)
Spacer()
Spacer(minLength: 8)
Text(titleString)
.font(.headline)
.lineLimit(1)
.minimumScaleFactor(0.7)
Spacer(minLength: 8)
viewPickerMenu
Spacer()
Button { showMenu = true } label: {
Image(systemName: "line.3.horizontal")
.font(.system(size: 18, weight: .medium))
.frame(width: 44, height: 44)
.frame(width: 40, height: 40)
}
.padding(.trailing, 4)
}
@@ -128,18 +157,16 @@ struct CalendarHostView: View {
Menu {
ForEach(CalViewType.allCases, id: \.self) { vt in
Button { store.viewType = vt } label: {
Label(vt.label, systemImage: vt.systemImage)
Label(vt.label(appLang), 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())
Image(systemName: store.viewType.systemImage)
.font(.system(size: 17, weight: .medium))
.foregroundStyle(.primary)
.frame(width: 40, height: 40)
}
.accessibilityLabel(L10n.t("view.change", appLang))
}
// MARK: Error banner
@@ -165,24 +192,60 @@ struct CalendarHostView: View {
@ViewBuilder
private var calendarContent: some View {
let swipe = DragGesture(minimumDistance: 35, coordinateSpace: .global)
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.1, abs(h) > 50 else { return }
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:
MonthView(store: store, onDayTap: { editorDate = $0 }, onEventTap: { selectedEvent = $0 })
.simultaneousGesture(swipe)
// Month view uses vertical scroll no horizontal swipe.
MonthView(store: store,
onDayTap: { editorDate = $0 },
onEventTap: { selectedEvent = $0 },
onCreateEvent: { day in
editingEvent = nil
editorDate = day
showEditor = true
},
onShowWeek: { day in
store.currentDate = day
store.viewType = .week
},
onShowDay: { day in
store.currentDate = day
store.viewType = .day
},
visibleMonth: $visibleMonth)
case .week:
WeekView(store: store, onEventTap: { selectedEvent = $0 }, onTimeTap: { editorDate = $0 })
WeekView(store: store,
onEventTap: { selectedEvent = $0 },
onCreateEvent: { date in
editingEvent = nil
editorDate = date
showEditor = true
},
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 }, onTimeTap: { editorDate = $0 })
DayView(store: store,
onEventTap: { selectedEvent = $0 },
onCreateEvent: { date in
editingEvent = nil
editorDate = date
showEditor = true
})
.simultaneousGesture(swipe)
case .quarter:
QuarterView(store: store, onEventTap: { selectedEvent = $0 })
@@ -263,6 +326,16 @@ struct CalendarHostView: View {
store.invalidateCache()
await startup()
}
/// Called when the user scrolls into a new month fetches a ±1 month window
/// around it on demand. `loadEvents` skips the network if cached.
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
await store.loadEvents(api: api, start: s, end: e)
}
}
// MARK: Shared sheet modifier

View File

@@ -3,7 +3,12 @@ import SwiftUI
struct DayView: View {
let store: CalendarStore
let onEventTap: (CalEvent) -> Void
let onTimeTap: (Date) -> Void
let onCreateEvent: (Date) -> Void
@AppStorage("appLanguage") private var appLang = "system"
@AppStorage("todayColor") private var todayHex = "#4285f4"
@AppStorage("textColor") private var textHex = "#FFFFFF"
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
private var cal: Calendar { store.userCalendar }
private var allDayEvents: [CalEvent] { store.events(on: store.currentDate).filter(\.isAllDay) }
@@ -17,25 +22,18 @@ struct DayView: View {
ScrollViewReader { proxy in
ScrollView {
ZStack(alignment: .topLeading) {
// Background grid
// Background grid with per-hour context menus
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)
ForEach(hours, id: \.self) { hour in
DayHourSlot(day: store.currentDate, hour: hour,
hourHeight: hourHeight,
language: appLang,
onCreateEvent: onCreateEvent)
}
}
.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
@@ -52,10 +50,11 @@ struct DayView: View {
// Current time
if cal.isDateInToday(store.currentDate) {
let lineY = nowLineY()
let nowColor = Color(hex: todayHex)
HStack(spacing: 0) {
Spacer().frame(width: timeColumnWidth - 4)
Circle().fill(Color.red).frame(width: 8, height: 8)
Rectangle().fill(Color.red)
Circle().fill(nowColor).frame(width: 8, height: 8)
Rectangle().fill(nowColor)
.frame(width: geo.size.width - timeColumnWidth - 4, height: 1.5)
}
.offset(y: lineY - 0.75)
@@ -98,7 +97,7 @@ struct DayView: View {
Color.clear.frame(height: hourHeight)
Text(String(format: "%02d:00", h))
.font(.system(size: 10))
.foregroundStyle(.secondary)
.foregroundStyle(Color(hex: textHex).opacity(0.6))
.offset(y: -6)
}
}
@@ -123,3 +122,31 @@ struct DayView: View {
}
}
}
// One-hour slot for the single-column day view.
private struct DayHourSlot: View {
let day: Date
let hour: Int
let hourHeight: CGFloat
let language: String
let onCreateEvent: (Date) -> Void
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
private var date: Date {
Calendar.current.date(bySettingHour: hour, minute: 0, second: 0, of: day) ?? day
}
var body: some View {
VStack(spacing: 0) {
Rectangle().fill(Color(hex: lineHex).opacity(0.4)).frame(height: 0.5)
Color.clear.frame(height: hourHeight - 0.5)
}
.contentShape(Rectangle())
.contextMenu {
Button { onCreateEvent(date) } label: {
Label(L10n.t("cal.new_event", language), systemImage: "plus")
}
}
}
}

View File

@@ -8,6 +8,7 @@ struct EventEditorSheet: View {
let onSaved: () async -> Void
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
@State private var title = ""
@State private var isAllDay = false
@State private var startDate = Date()
@@ -29,36 +30,36 @@ struct EventEditorSheet: View {
NavigationStack {
Form {
Section {
TextField("Titel", text: $title)
TextField(L10n.t("event.title_placeholder", appLang), text: $title)
.font(.body.weight(.medium))
}
Section {
Toggle("Ganztägig", isOn: $isAllDay.animation())
Toggle(L10n.t("event.allday", appLang), isOn: $isAllDay.animation())
.tint(Color.accentColor)
if isAllDay {
DatePicker("Start", selection: $startDate, displayedComponents: .date)
DatePicker("Ende", selection: $endDate, displayedComponents: .date)
DatePicker(L10n.t("event.start", appLang), selection: $startDate, displayedComponents: .date)
DatePicker(L10n.t("event.end", appLang), selection: $endDate, displayedComponents: .date)
} else {
DatePicker("Start", selection: $startDate)
DatePicker("Ende", selection: $endDate)
DatePicker(L10n.t("event.start", appLang), selection: $startDate)
DatePicker(L10n.t("event.end", appLang), selection: $endDate)
}
}
Section {
TextField("Ort", text: $location)
TextField("Beschreibung", text: $notes, axis: .vertical)
TextField(L10n.t("event.location", appLang), text: $location)
TextField(L10n.t("event.description", appLang), text: $notes, axis: .vertical)
.lineLimit(3...6)
}
Section("Kalender") {
Section(L10n.t("event.calendar_section", appLang)) {
if store.writableCalendars.isEmpty {
Text("Keine beschreibbaren Kalender vorhanden")
Text(L10n.t("event.no_writable", appLang))
.foregroundStyle(.secondary)
.font(.callout)
} else {
Picker("Kalender", selection: $selectedCalendarId) {
Picker(L10n.t("event.calendar_picker", appLang), selection: $selectedCalendarId) {
ForEach(store.writableCalendars) { cal in
HStack {
Circle()
@@ -72,9 +73,9 @@ struct EventEditorSheet: View {
}
}
Section("Farbe") {
Section(L10n.t("event.color_section", appLang)) {
HStack {
Text("Terminfarbe")
Text(L10n.t("event.color", appLang))
Spacer()
ColorPicker("", selection: Binding(
get: { Color(hex: color.isEmpty ? (selectedCal?.color ?? "#4285f4") : color) },
@@ -82,7 +83,7 @@ struct EventEditorSheet: View {
), supportsOpacity: false)
.labelsHidden()
if !color.isEmpty {
Button("Zurücksetzen") { color = "" }
Button(L10n.t("event.reset_color", appLang)) { color = "" }
.font(.caption)
.foregroundStyle(.secondary)
}
@@ -95,14 +96,18 @@ struct EventEditorSheet: View {
}
}
}
.navigationTitle(isEditing ? "Termin bearbeiten" : "Neuer Termin")
.navigationTitle(isEditing
? L10n.t("event.edit_title", appLang)
: L10n.t("event.new_title", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen") { dismiss() }
Button(L10n.t("common.cancel", appLang)) { dismiss() }
}
ToolbarItem(placement: .primaryAction) {
Button(isEditing ? "Sichern" : "Hinzufügen") {
Button(isEditing
? L10n.t("event.save", appLang)
: L10n.t("event.add", appLang)) {
Task { await save() }
}
.bold()

View File

@@ -1,150 +1,388 @@
import SwiftUI
private let weeksBack = 104
private let weeksAhead = 104
private let weekdayHeaderHeight: CGFloat = 28
private let dayNumberRowHeight: CGFloat = 22
private let laneHeight: CGFloat = 16
private let laneSpacing: CGFloat = 2
private let maxLanesPerWeek = 5
private enum DividerEdge { case none, topHighlight, bottomHighlight }
struct MonthView: View {
let store: CalendarStore
let onDayTap: (Date) -> Void
let onEventTap: (CalEvent) -> Void
let onCreateEvent: (Date) -> Void
let onShowWeek: (Date) -> Void
let onShowDay: (Date) -> Void
@Binding var visibleMonth: Date
@AppStorage("appLanguage") private var appLang = "system"
@AppStorage("monthDividerColor") private var dividerHex = "#7090c0"
@AppStorage("monthLabelColor") private var labelHex = "#7090c0"
@AppStorage("textColor") private var textHex = "#FFFFFF"
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
@State private var scrolledWeek: Date? = nil
@State private var didInitialScroll = false
private var cal: Calendar { store.userCalendar }
private var monthStart: Date {
cal.date(from: cal.dateComponents([.year, .month], from: store.currentDate))!
private var weekStarts: [Date] {
let today = cal.startOfDay(for: .now)
let thisWeek = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: today))!
return (-weeksBack...weeksAhead).compactMap {
cal.date(byAdding: .weekOfYear, value: $0, to: thisWeek)
}
}
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 fmt = DateFormatter(); fmt.locale = L10n.locale(appLang)
let symbols = fmt.shortWeekdaySymbols ?? cal.shortWeekdaySymbols
let start = cal.firstWeekday - 1
return (0..<7).map { String(symbols[(start + $0) % 7].prefix(2)) }
return (0..<7).map { i in String(symbols[(start + i) % 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)
headerRow
Divider()
ScrollView {
LazyVStack(spacing: 0) {
ForEach(weekStarts, id: \.self) { ws in
WeekRow(weekStart: ws,
store: store,
dividerColor: Color(hex: dividerHex),
labelColor: Color(hex: labelHex),
textColor: Color(hex: textHex),
lineColor: Color(hex: lineHex),
language: appLang,
onDayTap: onDayTap,
onEventTap: onEventTap,
onCreateEvent: onCreateEvent,
onShowWeek: onShowWeek,
onShowDay: onShowDay)
.id(ws)
}
}
.scrollTargetLayout()
}
.scrollPosition(id: $scrolledWeek, anchor: .top)
.onAppear {
if !didInitialScroll {
didInitialScroll = true
scrolledWeek = weekStart(for: store.currentDate)
publishVisibleMonth(from: scrolledWeek)
}
}
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)
.onChange(of: store.currentDate) { _, newDate in
let target = weekStart(for: newDate)
if scrolledWeek != target {
withAnimation(.easeInOut(duration: 0.25)) {
scrolledWeek = target
}
}
}
.onChange(of: scrolledWeek) { _, newWeek in
publishVisibleMonth(from: newWeek)
}
}
}
private var headerRow: some View {
HStack(spacing: 0) {
ForEach(weekdayHeaders, id: \.self) { d in
Text(d)
.font(.caption2.weight(.semibold))
.foregroundStyle(Color(hex: textHex).opacity(0.7))
.frame(maxWidth: .infinity, minHeight: weekdayHeaderHeight)
}
}
}
private func weekStart(for date: Date) -> Date {
cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: date))!
}
/// Treat the visible month as the one that "owns" Thursday of the current week
/// matches ISO week-month conventions and avoids flicker on month boundaries.
private func publishVisibleMonth(from week: Date?) {
guard let w = week else { return }
let thursday = cal.date(byAdding: .day, value: 3, to: w) ?? w
let m = cal.date(from: cal.dateComponents([.year, .month], from: thursday)) ?? thursday
if visibleMonth != m { visibleMonth = m }
}
}
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
// MARK: Week Row
private var maxVisible: Int {
max(1, Int((rowHeight - 32) / 16))
private struct WeekRow: View {
let weekStart: Date
let store: CalendarStore
let dividerColor: Color
let labelColor: Color
let textColor: Color
let lineColor: Color
let language: String
let onDayTap: (Date) -> Void
let onEventTap: (CalEvent) -> Void
let onCreateEvent: (Date) -> Void
let onShowWeek: (Date) -> Void
let onShowDay: (Date) -> Void
private var cal: Calendar { store.userCalendar }
private var days: [Date] {
(0..<7).compactMap { cal.date(byAdding: .day, value: $0, to: weekStart) }
}
private var weekNumber: Int { cal.component(.weekOfYear, from: weekStart) }
private func columnRange(for ev: CalEvent) -> (startCol: Int, span: Int) {
let weekEnd = cal.date(byAdding: .day, value: 7, to: weekStart)!
let evStart = max(cal.startOfDay(for: ev.startDate), weekStart)
// All-day end is already exclusive; timed end-of-day-on-same-day shouldn't add a column.
let rawEnd: Date
if ev.isAllDay {
rawEnd = ev.endDate
} else {
// Treat timed events as occupying days from start up to and including the day of end.
rawEnd = cal.date(byAdding: .day, value: 1, to: cal.startOfDay(for: ev.endDate))!
}
let evEnd = min(rawEnd, weekEnd)
let sc = max(0, cal.dateComponents([.day], from: weekStart, to: evStart).day ?? 0)
let lastIncl = (cal.dateComponents([.day], from: weekStart, to: evEnd).day ?? 0) - 1
let ec = min(6, lastIncl)
return (sc, max(1, ec - sc + 1))
}
/// Greedy lane packing for events overlapping this week.
private func packEvents() -> (placed: [(event: CalEvent, lane: Int, startCol: Int, span: Int)],
extraPerCol: [Int]) {
let weekEndExclusive = cal.date(byAdding: .day, value: 7, to: weekStart)!
let evs = store.events(in: weekStart, end: weekEndExclusive)
.sorted { a, b in
if a.startDate != b.startDate { return a.startDate < b.startDate }
return a.endDate > b.endDate
}
var laneLastEnd: [Int] = []
var placed: [(CalEvent, Int, Int, Int)] = []
var overflowPerCol = [Int](repeating: 0, count: 7)
for ev in evs {
let (sc, sp) = columnRange(for: ev)
var assigned: Int? = nil
for laneIdx in 0..<laneLastEnd.count {
if laneLastEnd[laneIdx] < sc {
laneLastEnd[laneIdx] = sc + sp - 1
assigned = laneIdx
break
}
}
if assigned == nil {
if laneLastEnd.count < maxLanesPerWeek {
laneLastEnd.append(sc + sp - 1)
assigned = laneLastEnd.count - 1
}
}
if let lane = assigned {
placed.append((ev, lane, sc, sp))
} else {
for c in sc...min(6, sc + sp - 1) {
overflowPerCol[c] += 1
}
}
}
return (placed.map { (event: $0.0, lane: $0.1, startCol: $0.2, span: $0.3) },
overflowPerCol)
}
var body: some View {
VStack(alignment: .leading, spacing: 2) {
// Day number
let (placed, extras) = packEvents()
let rowHeight = dayNumberRowHeight + CGFloat(maxLanesPerWeek) * (laneHeight + laneSpacing) + 4
let mondayIdx = days.firstIndex(where: { cal.component(.weekday, from: $0) == 2 }) ?? 0
// Where in this row does a new month start? (col 1...6 = mid-row step; nil = no step)
let midRowBoundaryCol: Int? = {
for idx in 1..<7 where cal.component(.day, from: days[idx]) == 1 { return idx }
return nil
}()
let rowStartsNewMonth = cal.component(.day, from: days[0]) == 1
GeometryReader { geo in
let cellW = geo.size.width / 7
ZStack(alignment: .topLeading) {
HStack(spacing: 0) {
ForEach(Array(days.enumerated()), id: \.offset) { idx, day in
let edge: DividerEdge = {
if let b = midRowBoundaryCol {
return idx < b ? .bottomHighlight : .topHighlight
}
return rowStartsNewMonth ? .topHighlight : .none
}()
DayCell(date: day,
isToday: cal.isDateInToday(day),
monthLabelColor: labelColor,
dividerColor: dividerColor,
textColor: textColor,
lineColor: lineColor,
language: language,
extraCount: extras[idx],
weekNumber: idx == mondayIdx ? weekNumber : nil,
cwLabel: L10n.t("cal.cw", language),
edge: edge,
onTap: { onDayTap(day) },
onCreateEvent: { onCreateEvent(day) },
onShowWeek: { onShowWeek(day) },
onShowDay: { onShowDay(day) })
.frame(width: cellW, height: rowHeight)
}
}
ForEach(Array(placed.enumerated()), id: \.offset) { _, p in
Button { onEventTap(p.event) } label: {
EventBar(event: p.event)
.frame(width: cellW * CGFloat(p.span) - 2, height: laneHeight)
}
.buttonStyle(.plain)
.offset(x: CGFloat(p.startCol) * cellW + 1,
y: dayNumberRowHeight + CGFloat(p.lane) * (laneHeight + laneSpacing))
}
// Vertical connector at the month-boundary column ties the bottom-line
// of old-month cells to the top-line of new-month cells into a step.
if let b = midRowBoundaryCol {
Rectangle()
.fill(dividerColor)
.frame(width: 1.5, height: rowHeight)
.offset(x: CGFloat(b) * cellW - 0.75, y: 0)
}
}
}
.frame(height: rowHeight)
}
}
// MARK: Day Cell
private struct DayCell: View {
let date: Date
let isToday: Bool
let monthLabelColor: Color
let dividerColor: Color
let textColor: Color
let lineColor: Color
let language: String
let extraCount: Int
let weekNumber: Int?
let cwLabel: String
let edge: DividerEdge
let onTap: () -> Void
let onCreateEvent: () -> Void
let onShowWeek: () -> Void
let onShowDay: () -> Void
private var cal: Calendar { Calendar.current }
private var dayNum: Int { cal.component(.day, from: date) }
private var isFirstOfMonth: Bool { dayNum == 1 }
private var monthAbbrev: String {
let f = DateFormatter()
f.locale = L10n.locale(language)
f.dateFormat = "LLL"
return f.string(from: date).uppercased()
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
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())
HStack(spacing: 4) {
Text("\(dayNum)")
.font(.system(size: 13, weight: isToday ? .bold : .regular))
.foregroundStyle(isToday ? Color.white : textColor)
.frame(width: 22, height: 22)
.background(isToday ? Color.accentColor : Color.clear)
.clipShape(Circle())
if isFirstOfMonth {
Text(monthAbbrev)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(monthLabelColor)
.lineLimit(1)
.fixedSize()
}
Spacer(minLength: 0)
}
.padding(.leading, 4)
.padding(.top, 2)
}
.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)
HStack(spacing: 0) {
if extraCount > 0 {
Text("+\(extraCount)")
.font(.system(size: 9, weight: .medium))
.foregroundStyle(textColor.opacity(0.6))
.padding(.leading, 4)
}
Spacer(minLength: 0)
if let wn = weekNumber {
Text("\(cwLabel) \(wn)")
.font(.system(size: 9, weight: .medium))
.foregroundStyle(textColor.opacity(0.6))
.padding(.trailing, 4)
}
}
.padding(.bottom, 1)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.overlay(alignment: .trailing) {
Rectangle().fill(Color(.separator)).frame(width: 0.5)
Rectangle().fill(lineColor.opacity(0.4)).frame(width: 0.5)
}
.overlay(alignment: .top) {
Rectangle()
.fill(edge == .topHighlight ? dividerColor : lineColor.opacity(0.3))
.frame(height: edge == .topHighlight ? 1.5 : 0.5)
}
.overlay(alignment: .bottom) {
Rectangle().fill(Color(.separator)).frame(height: 0.5)
if edge == .bottomHighlight {
Rectangle().fill(dividerColor).frame(height: 1.5)
}
}
.contentShape(Rectangle())
.contextMenu {
Button { onCreateEvent() } label: {
Label(L10n.t("cal.new_event", language), systemImage: "plus")
}
Button { onShowWeek() } label: {
Label(L10n.t("cal.show_in_week_view", language),
systemImage: "calendar.day.timeline.leading")
}
Button { onShowDay() } label: {
Label(L10n.t("cal.show_in_day_view", language), systemImage: "sun.max")
}
}
}
}
private struct EventChip: View {
// MARK: Event Bar
private struct EventBar: 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)
.foregroundStyle(.white)
.padding(.leading, 4)
Spacer(minLength: 0)
}
.padding(.horizontal, event.isAllDay ? 4 : 2)
.padding(.vertical, 1)
.frame(maxWidth: .infinity, alignment: .leading)
.background(event.isAllDay ? Color(hex: event.effectiveColor) : Color.clear)
.background(Color(hex: event.effectiveColor))
.clipShape(RoundedRectangle(cornerRadius: 3))
.padding(.horizontal, 2)
}
}

View File

@@ -3,7 +3,14 @@ import SwiftUI
struct WeekView: View {
let store: CalendarStore
let onEventTap: (CalEvent) -> Void
let onTimeTap: (Date) -> Void
let onCreateEvent: (Date) -> Void
let onShowMonth: (Date) -> Void
let onShowDay: (Date) -> Void
@AppStorage("appLanguage") private var appLang = "system"
@AppStorage("todayColor") private var todayHex = "#4285f4"
@AppStorage("textColor") private var textHex = "#FFFFFF"
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
private var cal: Calendar { store.userCalendar }
@@ -49,10 +56,10 @@ struct WeekView: View {
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)
.foregroundStyle(cal.isDateInToday(day) ? Color.accentColor : Color(hex: textHex).opacity(0.7))
.frame(maxWidth: .infinity, minHeight: 36)
.overlay(alignment: .trailing) {
Rectangle().fill(Color(.separator)).frame(width: 0.5)
Rectangle().fill(Color(hex: lineHex)).frame(width: 0.5)
}
}
}
@@ -104,28 +111,23 @@ struct WeekView: View {
ScrollViewReader { proxy in
ScrollView {
ZStack(alignment: .topLeading) {
// Background: time labels + vertical grid lines
// Background: time labels + per-hour cells per day
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)
ForEach(hours, id: \.self) { hour in
HourSlot(day: day, hour: hour,
hourHeight: hourHeight,
language: appLang,
onCreateEvent: onCreateEvent,
onShowMonth: onShowMonth,
onShowDay: onShowDay)
}
}
.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)
Rectangle().fill(Color(hex: lineHex)).frame(width: 0.5)
}
}
}
@@ -144,10 +146,11 @@ struct WeekView: View {
// Current time line
if let ti = todayIndex {
let lineY = eventTop(Date.now)
let nowColor = Color(hex: todayHex)
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)
Circle().fill(nowColor).frame(width: 8, height: 8)
Rectangle().fill(nowColor).frame(width: colW - 4, height: 1.5)
}
.offset(y: lineY - 0.75)
}
@@ -168,7 +171,7 @@ struct WeekView: View {
Color.clear.frame(height: hourHeight)
Text(String(format: "%02d:00", h))
.font(.system(size: 10))
.foregroundStyle(.secondary)
.foregroundStyle(Color(hex: textHex).opacity(0.6))
.offset(y: -6)
}
}
@@ -194,3 +197,39 @@ private func eventTop(_ date: Date) -> CGFloat {
let m = CGFloat(cal.component(.minute, from: date))
return h * hourHeight + m * hourHeight / 60
}
// One-hour slot with native long-press context menu.
struct HourSlot: View {
let day: Date
let hour: Int
let hourHeight: CGFloat
let language: String
let onCreateEvent: (Date) -> Void
let onShowMonth: (Date) -> Void
let onShowDay: (Date) -> Void
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
private var date: Date {
Calendar.current.date(bySettingHour: hour, minute: 0, second: 0, of: day) ?? day
}
var body: some View {
VStack(spacing: 0) {
Rectangle().fill(Color(hex: lineHex).opacity(0.4)).frame(height: 0.5)
Color.clear.frame(height: hourHeight - 0.5)
}
.contentShape(Rectangle())
.contextMenu {
Button { onCreateEvent(date) } label: {
Label(L10n.t("cal.new_event", language), systemImage: "plus")
}
Button { onShowMonth(date) } label: {
Label(L10n.t("cal.show_in_month_view", language), systemImage: "calendar")
}
Button { onShowDay(date) } label: {
Label(L10n.t("cal.show_in_day_view", language), systemImage: "sun.max")
}
}
}
}

View File

@@ -4,6 +4,7 @@ struct MenuSheet: View {
let api: CalendarrAPI
@Environment(AppState.self) var appState
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
var body: some View {
NavigationStack {
@@ -30,7 +31,7 @@ struct MenuSheet: View {
}
if appState.isAdmin {
Spacer()
Text("Admin")
Text(L10n.t("menu.admin", appLang))
.font(.caption2.weight(.semibold))
.padding(.horizontal, 8).padding(.vertical, 3)
.background(Color.accentColor.opacity(0.15))
@@ -41,30 +42,29 @@ struct MenuSheet: View {
.padding(.vertical, 4)
}
// Navigation links direct destination syntax (no value-based nav)
Section("Einstellungen") {
Section(L10n.t("menu.section.settings", appLang)) {
NavigationLink {
ProfileView(api: api)
} label: {
Label("Profil", systemImage: "person.circle")
Label(L10n.t("menu.profile", appLang), systemImage: "person.circle")
}
NavigationLink {
SettingsView(api: api)
} label: {
Label("Darstellung", systemImage: "paintpalette")
Label(L10n.t("menu.appearance", appLang), systemImage: "paintpalette")
}
NavigationLink {
AccountsView(api: api)
} label: {
Label("Konten & Kalender", systemImage: "tray.2")
Label(L10n.t("menu.accounts", appLang), systemImage: "tray.2")
}
NavigationLink {
ServerView()
} label: {
Label("Server", systemImage: "server.rack")
Label(L10n.t("menu.server", appLang), systemImage: "server.rack")
}
}
@@ -75,15 +75,15 @@ struct MenuSheet: View {
appState.logout()
}
} label: {
Label("Abmelden", systemImage: "rectangle.portrait.and.arrow.right")
Label(L10n.t("menu.logout", appLang), systemImage: "rectangle.portrait.and.arrow.right")
}
}
}
.navigationTitle("Menü")
.navigationTitle(L10n.t("nav.menu", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Fertig") { dismiss() }
Button(L10n.t("nav.done", appLang)) { dismiss() }
}
}
}

View File

@@ -21,11 +21,13 @@ struct ProfileView: View {
@State private var disablePW = ""
@State private var isSaving2FA = false
@AppStorage("appLanguage") private var appLang = "system"
var body: some View {
NavigationStack {
Group {
if isLoading {
ProgressView("Lade Profil…")
ProgressView(L10n.t("profile.loading", appLang))
} else if let profile {
Form {
kontoSection(profile: profile)
@@ -34,7 +36,7 @@ struct ProfileView: View {
}
}
}
.navigationTitle("Profil")
.navigationTitle(L10n.t("profile.title", appLang))
.navigationBarTitleDisplayMode(.large)
.overlay(alignment: .bottom) {
if showToast {
@@ -68,29 +70,31 @@ struct ProfileView: View {
}
func kontoSection(profile: UserProfile) -> some View {
Section("Konto") {
Section(L10n.t("profile.account", appLang)) {
HStack {
Text("Benutzername")
Text(L10n.t("profile.username", appLang))
Spacer()
Text(profile.username)
.foregroundStyle(.secondary)
}
HStack {
Text("Rolle")
Text(L10n.t("profile.role", appLang))
Spacer()
Text(profile.isAdmin ? "Administrator" : "Benutzer")
Text(profile.isAdmin
? L10n.t("profile.role.admin", appLang)
: L10n.t("profile.role.user", appLang))
.foregroundStyle(.secondary)
}
HStack {
Text("E-Mail")
Text(L10n.t("profile.email", appLang))
Spacer()
TextField("Keine E-Mail", text: $newEmail)
TextField(L10n.t("profile.no_email", appLang), text: $newEmail)
.multilineTextAlignment(.trailing)
.foregroundStyle(.secondary)
.keyboardType(.emailAddress)
.textInputAutocapitalization(.never)
}
Button("E-Mail speichern") {
Button(L10n.t("profile.save_email", appLang)) {
Task { await saveEmail() }
}
.foregroundStyle(Color.accentColor)
@@ -98,11 +102,11 @@ struct ProfileView: View {
}
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") {
Section(L10n.t("profile.change_password", appLang)) {
SecureField(L10n.t("profile.current_password", appLang), text: $currentPW)
SecureField(L10n.t("profile.new_password", appLang), text: $newPW)
SecureField(L10n.t("profile.new_password_repeat", appLang), text: $confirmPW)
Button(L10n.t("profile.change_password", appLang)) {
Task { await changePassword() }
}
.foregroundStyle(Color.accentColor)
@@ -111,14 +115,14 @@ struct ProfileView: View {
}
func twoFASection(profile: UserProfile) -> some View {
Section("Zwei-Faktor-Authentifizierung") {
Section(L10n.t("profile.twofa", appLang)) {
if profile.totpEnabled {
HStack {
Image(systemName: "checkmark.shield.fill")
.foregroundStyle(.green)
Text("2FA ist aktiviert")
Text(L10n.t("profile.twofa.active", appLang))
}
Button("2FA deaktivieren") {
Button(L10n.t("profile.twofa.disable", appLang)) {
show2FADisable = true
}
.foregroundStyle(.red)
@@ -126,10 +130,10 @@ struct ProfileView: View {
HStack {
Image(systemName: "shield")
.foregroundStyle(.secondary)
Text("2FA ist deaktiviert")
Text(L10n.t("profile.twofa.inactive", appLang))
.foregroundStyle(.secondary)
}
Button("2FA einrichten") {
Button(L10n.t("profile.twofa.enable", appLang)) {
Task { await setup2FA() }
}
.foregroundStyle(Color.accentColor)
@@ -149,7 +153,7 @@ struct ProfileView: View {
private func saveEmail() async {
do {
try await api.updateEmail(newEmail)
showNotice("E-Mail gespeichert")
showNotice(L10n.t("profile.email_saved", appLang))
} catch {
showNotice(error.localizedDescription)
}
@@ -157,13 +161,13 @@ struct ProfileView: View {
private func changePassword() async {
guard newPW == confirmPW else {
showNotice("Passwörter stimmen nicht überein")
showNotice(L10n.t("profile.password_mismatch", appLang))
return
}
do {
try await api.changePassword(current: currentPW, new: newPW)
currentPW = ""; newPW = ""; confirmPW = ""
showNotice("Passwort geändert")
showNotice(L10n.t("profile.password_changed", appLang))
} catch {
showNotice(error.localizedDescription)
}
@@ -186,7 +190,7 @@ struct ProfileView: View {
do {
try await api.enable2FA(code: totpCode)
show2FASetup = false
showNotice("2FA aktiviert")
showNotice(L10n.t("profile.twofa.enabled_toast", appLang))
await load()
} catch {
showNotice(error.localizedDescription)
@@ -198,7 +202,7 @@ struct ProfileView: View {
do {
try await api.disable2FA(password: disablePW)
show2FADisable = false
showNotice("2FA deaktiviert")
showNotice(L10n.t("profile.twofa.disabled_toast", appLang))
await load()
} catch {
showNotice(error.localizedDescription)
@@ -222,15 +226,16 @@ struct TwoFASetupSheet: View {
let isSaving: Bool
let onEnable: () -> Void
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
var body: some View {
NavigationStack {
Form {
Section {
Text("Scanne den QR-Code mit deiner Authenticator-App (z.B. Bitwarden, Google Authenticator).")
Text(L10n.t("twofa.scan_hint", appLang))
.font(.body)
}
Section("QR-Code / Manueller Schlüssel") {
Section(L10n.t("twofa.qr_section", appLang)) {
if let url = URL(string: qrURL) {
AsyncImage(url: url) { phase in
switch phase {
@@ -259,17 +264,17 @@ struct TwoFASetupSheet: View {
.foregroundStyle(Color.accentColor)
}
}
Section("Bestätigung") {
TextField("6-stelliger Code", text: $code)
Section(L10n.t("twofa.confirmation", appLang)) {
TextField(L10n.t("twofa.code_placeholder", appLang), text: $code)
.keyboardType(.numberPad)
}
}
.navigationTitle("2FA einrichten")
.navigationTitle(L10n.t("twofa.setup_title", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Abbrechen") { dismiss() } }
ToolbarItem(placement: .cancellationAction) { Button(L10n.t("common.cancel", appLang)) { dismiss() } }
ToolbarItem(placement: .primaryAction) {
Button("Aktivieren") { onEnable() }
Button(L10n.t("twofa.activate", appLang)) { onEnable() }
.bold()
.disabled(code.count < 6 || isSaving)
}
@@ -282,20 +287,21 @@ struct TwoFADisableSheet: View {
@Binding var password: String
let onDisable: () -> Void
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
var body: some View {
NavigationStack {
Form {
Section("Passwort zum Deaktivieren") {
SecureField("Passwort", text: $password)
Section(L10n.t("twofa.password_section", appLang)) {
SecureField(L10n.t("twofa.password_placeholder", appLang), text: $password)
}
}
.navigationTitle("2FA deaktivieren")
.navigationTitle(L10n.t("twofa.disable_title", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Abbrechen") { dismiss() } }
ToolbarItem(placement: .cancellationAction) { Button(L10n.t("common.cancel", appLang)) { dismiss() } }
ToolbarItem(placement: .primaryAction) {
Button("Deaktivieren") { onDisable() }
Button(L10n.t("twofa.disable", appLang)) { onDisable() }
.bold()
.foregroundStyle(.red)
.disabled(password.isEmpty)

View File

@@ -5,6 +5,7 @@ struct ServerView: View {
@State private var showLogoutConfirm = false
@State private var showChangeServer = false
@State private var showImpressum = false
@AppStorage("appLanguage") private var appLang = "system"
var serverHost: String {
appState.serverURL
@@ -15,7 +16,7 @@ struct ServerView: View {
var body: some View {
NavigationStack {
Form {
Section("Verbundener Server") {
Section(L10n.t("server.connected", appLang)) {
HStack {
Image(systemName: "server.rack")
.foregroundStyle(Color.accentColor)
@@ -36,7 +37,7 @@ struct ServerView: View {
Text(appState.username)
if appState.isAdmin {
Spacer()
Text("Admin")
Text(L10n.t("menu.admin", appLang))
.font(.caption.weight(.semibold))
.padding(.horizontal, 8)
.padding(.vertical, 3)
@@ -54,7 +55,7 @@ struct ServerView: View {
HStack {
Image(systemName: "arrow.triangle.swap")
.frame(width: 28)
Text("Server wechseln")
Text(L10n.t("server.switch", appLang))
}
}
.foregroundStyle(.primary)
@@ -65,48 +66,48 @@ struct ServerView: View {
HStack {
Image(systemName: "rectangle.portrait.and.arrow.right")
.frame(width: 28)
Text("Abmelden")
Text(L10n.t("menu.logout", appLang))
}
}
}
Section("Info") {
Section(L10n.t("server.info", appLang)) {
Button {
showImpressum = true
} label: {
HStack {
Image(systemName: "info.circle")
.frame(width: 28)
Text("Impressum")
Text(L10n.t("server.imprint", appLang))
}
}
.foregroundStyle(.secondary)
HStack {
Text("Version")
Text(L10n.t("server.version", appLang))
Spacer()
Text("1.0")
.foregroundStyle(.secondary)
}
}
}
.navigationTitle("Server")
.navigationTitle(L10n.t("server.title", appLang))
.navigationBarTitleDisplayMode(.large)
.confirmationDialog("Abmelden", isPresented: $showLogoutConfirm, titleVisibility: .visible) {
Button("Abmelden", role: .destructive) {
.confirmationDialog(L10n.t("server.logout_title", appLang), isPresented: $showLogoutConfirm, titleVisibility: .visible) {
Button(L10n.t("menu.logout", appLang), role: .destructive) {
appState.logout()
}
Button("Abbrechen", role: .cancel) {}
Button(L10n.t("common.cancel", appLang), role: .cancel) {}
} message: {
Text("Du wirst von \(serverHost) abgemeldet.")
Text(String(format: L10n.t("server.logout_msg", appLang), serverHost))
}
.confirmationDialog("Server wechseln", isPresented: $showChangeServer, titleVisibility: .visible) {
Button("Server wechseln", role: .destructive) {
.confirmationDialog(L10n.t("server.switch", appLang), isPresented: $showChangeServer, titleVisibility: .visible) {
Button(L10n.t("server.switch", appLang), role: .destructive) {
appState.resetServer()
}
Button("Abbrechen", role: .cancel) {}
Button(L10n.t("common.cancel", appLang), role: .cancel) {}
} message: {
Text("Verbindung zu \(serverHost) wird getrennt und alle lokalen Anmeldedaten werden gelöscht.")
Text(String(format: L10n.t("server.switch_msg", appLang), serverHost))
}
.sheet(isPresented: $showImpressum) {
ImpressumView()
@@ -117,34 +118,35 @@ struct ServerView: View {
struct ImpressumView: View {
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Group {
Text("Scarriffleservices")
Text(L10n.t("imprint.company", appLang))
.font(.title2.bold())
Text("Software & Webentwicklung")
Text(L10n.t("imprint.role", appLang))
.foregroundStyle(.secondary)
}
Divider()
Text("Diese Software wurde von Scarriffleservices mit grösster Sorgfalt entwickelt und bereitgestellt. Alle Rechte vorbehalten © 2026 Scarriffleservices.")
Text(L10n.t("imprint.copyright", appLang))
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.")
Text(L10n.t("imprint.storage.title", appLang)).font(.headline)
Text(L10n.t("imprint.storage.body", appLang))
}
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.")
Text(L10n.t("imprint.disclaimer.title", appLang)).font(.headline)
Text(L10n.t("imprint.disclaimer.body", appLang))
}
VStack(alignment: .leading, spacing: 6) {
Text("Kontakt").font(.headline)
Text(L10n.t("imprint.contact.title", appLang)).font(.headline)
Link("scarriffleservices@gmail.com", destination: URL(string: "mailto:scarriffleservices@gmail.com")!)
}
@@ -156,11 +158,11 @@ struct ImpressumView: View {
}
.padding(24)
}
.navigationTitle("Impressum")
.navigationTitle(L10n.t("server.imprint", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Schliessen") { dismiss() }
Button(L10n.t("common.close", appLang)) { dismiss() }
}
}
}

View File

@@ -7,14 +7,21 @@ struct SettingsView: View {
@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
@AppStorage("liquidGlass") private var liquidGlass = false
@AppStorage("cacheMonths") private var cacheMonths = 3
@AppStorage("appLanguage") private var appLang = "system"
@AppStorage("monthDividerColor") private var dividerHex = "#7090c0"
@AppStorage("monthLabelColor") private var labelHex = "#7090c0"
@AppStorage("todayColor") private var todayHex = "#4285f4"
@AppStorage("textColor") private var textHex = "#FFFFFF"
@AppStorage("backgroundColor") private var bgHex = "#000000"
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
var body: some View {
NavigationStack {
Group {
if isLoading {
ProgressView("Lade Einstellungen…")
ProgressView(L10n.t("settings.loading", appLang))
} else {
Form {
liquidGlassSection
@@ -28,7 +35,7 @@ struct SettingsView: View {
}
}
}
.navigationTitle("Darstellung")
.navigationTitle(L10n.t("settings.title", appLang))
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .primaryAction) {
@@ -38,7 +45,7 @@ struct SettingsView: View {
if isSaving {
ProgressView()
} else {
Text("Speichern").bold()
Text(L10n.t("settings.save", appLang)).bold()
}
}
.disabled(isSaving)
@@ -67,8 +74,8 @@ struct SettingsView: View {
Toggle(isOn: $liquidGlass) {
Label {
VStack(alignment: .leading, spacing: 2) {
Text("Liquid Glass")
Text("Verwendet die neue iOS\u{202F}26 Glasoptik mit transparenter Navigationsleiste")
Text(L10n.t("settings.liquidglass", appLang))
Text(L10n.t("settings.liquidglass.desc", appLang))
.font(.caption)
.foregroundStyle(.secondary)
}
@@ -79,9 +86,9 @@ struct SettingsView: View {
}
.tint(Color.accentColor)
} header: {
Text("App-Design")
Text(L10n.t("settings.appdesign", appLang))
} footer: {
Text("Änderung wirkt sofort kein Neustart nötig.")
Text(L10n.t("settings.liquidglass.footer", appLang))
.font(.caption)
}
}
@@ -93,8 +100,8 @@ struct SettingsView: View {
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.")
Text(L10n.t("settings.cache.title", appLang))
Text(L10n.t("settings.cache.desc", appLang))
.font(.caption)
.foregroundStyle(.secondary)
}
@@ -103,19 +110,19 @@ struct SettingsView: View {
.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)
Picker(L10n.t("settings.cache.range", appLang), selection: $cacheMonths) {
Text(L10n.t("settings.cache.1m", appLang)).tag(1)
Text(L10n.t("settings.cache.3m", appLang)).tag(3)
Text(L10n.t("settings.cache.6m", appLang)).tag(6)
Text(L10n.t("settings.cache.1y", appLang)).tag(12)
}
.pickerStyle(.segmented)
}
.padding(.vertical, 4)
} header: {
Text("Vorladen")
Text(L10n.t("settings.cache.header", appLang))
} footer: {
Text("Mehr Monate = längerer initialer Ladevorgang, danach komplett ohne Wartezeiten navigierbar.")
Text(L10n.t("settings.cache.footer", appLang))
.font(.caption)
}
}
@@ -123,10 +130,11 @@ struct SettingsView: View {
// MARK: Sprache
var spracheSection: some View {
Section("Sprache") {
Picker("Sprache", selection: $settings.language) {
Text("Deutsch").tag("de")
Text("English").tag("en")
Section(L10n.t("settings.language", appLang)) {
Picker(L10n.t("settings.language", appLang), selection: $appLang) {
Text(L10n.t("lang.system", appLang)).tag("system")
Text(L10n.t("lang.german", appLang)).tag("de")
Text(L10n.t("lang.english", appLang)).tag("en")
}
}
}
@@ -134,12 +142,15 @@ struct SettingsView: View {
// 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)
Section(L10n.t("settings.colors", appLang)) {
ColorPickerRow(label: L10n.t("settings.color.primary", appLang), hex: $settings.primaryColor)
ColorPickerRow(label: L10n.t("settings.color.accent", appLang), hex: $settings.accentColor)
ColorPickerRow(label: L10n.t("settings.color.today", appLang), hex: $todayHex)
ColorPickerRow(label: L10n.t("settings.color.text", appLang), hex: $textHex)
ColorPickerRow(label: L10n.t("settings.color.background", appLang), hex: $bgHex)
ColorPickerRow(label: L10n.t("settings.color.line", appLang), hex: $lineHex)
ColorPickerRow(label: L10n.t("settings.color.divider", appLang), hex: $dividerHex)
ColorPickerRow(label: L10n.t("settings.color.label", appLang), hex: $labelHex)
}
}
@@ -148,18 +159,18 @@ struct SettingsView: View {
var schriftSection: some View {
Section {
VStack(alignment: .leading, spacing: 10) {
Text("Schriftkontrast")
Text(L10n.t("settings.textcontrast", appLang))
.font(.headline)
Text("Helligkeit der Beschriftungen und Texte")
Text(L10n.t("settings.textcontrast.desc", appLang))
.font(.caption)
.foregroundStyle(.secondary)
ContrastSelector(
value: $settings.textContrast,
options: [
(1, "Dunkel"),
(2, "Mittel"),
(3, "Hell"),
(4, "Maximum")
(1, L10n.t("settings.contrast.dark", appLang)),
(2, L10n.t("settings.contrast.medium", appLang)),
(3, L10n.t("settings.contrast.bright", appLang)),
(4, L10n.t("settings.contrast.max", appLang))
]
)
}
@@ -172,18 +183,18 @@ struct SettingsView: View {
var linienSection: some View {
Section {
VStack(alignment: .leading, spacing: 10) {
Text("Linienkontrast")
Text(L10n.t("settings.linecontrast", appLang))
.font(.headline)
Text("Sichtbarkeit von Trennlinien und Rahmen")
Text(L10n.t("settings.linecontrast.desc", appLang))
.font(.caption)
.foregroundStyle(.secondary)
ContrastSelector(
value: $settings.lineContrast,
options: [
(1, "Kaum"),
(2, "Subtil"),
(3, "Normal"),
(4, "Stark")
(1, L10n.t("settings.linecontrast.barely", appLang)),
(2, L10n.t("settings.linecontrast.subtle", appLang)),
(3, L10n.t("settings.linecontrast.normal", appLang)),
(4, L10n.t("settings.linecontrast.strong", appLang))
]
)
}
@@ -194,19 +205,19 @@ struct SettingsView: View {
// 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")
Section(L10n.t("settings.calview", appLang)) {
Picker(L10n.t("settings.defaultview", appLang), selection: $settings.defaultView) {
Text(L10n.t("view.month", appLang)).tag("month")
Text(L10n.t("view.week", appLang)).tag("week")
Text(L10n.t("view.day", appLang)).tag("day")
Text(L10n.t("view.quarter", appLang)).tag("quarter")
Text(L10n.t("view.agenda", appLang)).tag("agenda")
}
Picker("Erster Wochentag", selection: $settings.weekStartDay) {
Text("Montag").tag("monday")
Text("Sonntag").tag("sunday")
Picker(L10n.t("settings.firstweekday", appLang), selection: $settings.weekStartDay) {
Text(L10n.t("settings.monday", appLang)).tag("monday")
Text(L10n.t("settings.sunday", appLang)).tag("sunday")
}
Toggle("Vergangene Termine ausgrauen", isOn: $settings.dimPastEvents)
Toggle(L10n.t("settings.dimpast", appLang), isOn: $settings.dimPastEvents)
.tint(Color.accentColor)
}
}
@@ -216,18 +227,18 @@ struct SettingsView: View {
var stundenSection: some View {
Section {
VStack(alignment: .leading, spacing: 10) {
Text("Stundenhöhe")
Text(L10n.t("settings.hourheight", appLang))
.font(.headline)
Text("Platz pro Stunde in der Wochen- & Tagesansicht")
Text(L10n.t("settings.hourheight.desc", appLang))
.font(.caption)
.foregroundStyle(.secondary)
ContrastSelector(
value: $settings.hourHeight,
options: [
(28, "Kompakt"),
(44, "Normal"),
(60, "Komfort"),
(80, "Gross")
(28, L10n.t("settings.hourheight.compact", appLang)),
(44, L10n.t("settings.hourheight.normal", appLang)),
(60, L10n.t("settings.hourheight.comfort", appLang)),
(80, L10n.t("settings.hourheight.large", appLang))
]
)
}
@@ -240,15 +251,31 @@ struct SettingsView: View {
private func load() async {
isLoading = true
defer { isLoading = false }
if let s = try? await api.getSettings() { settings = s }
if let s = try? await api.getSettings() {
settings = s
// Mirror server-side color settings so calendar views (which read AppStorage) see them.
dividerHex = s.monthDividerColor
labelHex = s.monthLabelColor
todayHex = s.todayColor
textHex = s.textColor
bgHex = s.backgroundColor
lineHex = s.lineColor
}
}
private func save() async {
isSaving = true
defer { isSaving = false }
// Push local AppStorage colors back into the settings struct before saving.
settings.monthDividerColor = dividerHex
settings.monthLabelColor = labelHex
settings.todayColor = todayHex
settings.textColor = textHex
settings.backgroundColor = bgHex
settings.lineColor = lineHex
do {
try await api.updateSettings(settings)
showNotice("Gespeichert")
showNotice(L10n.t("settings.saved", appLang))
} catch {
showNotice(error.localizedDescription)
}