iOS: localization fixes, per-calendar reminders, widget polish

C1 — Localization: route the remaining hardcoded German strings through
L10n (LoginView, ServerSetupView, SettingsView email, EventDetailSheet) so
"System Default" + English device language shows fully English text.

C2 — Per-calendar reminders: parse the new reminders_enabled flag on every
calendar type; CalendarStore persists a reminderDisabledKeys set and passes
it to NotificationScheduler, which skips events of muted calendars (default
and per-event reminders). Filter sheet gains a per-calendar reminder toggle
(leading swipe + bell.slash indicator), reconciled from the server and
synced back via PUT.

C3 — Widgets:
- Shared WidgetTime.range helper; Today / Today & Tomorrow / Three Days /
  Up Next now show start–end instead of only the start time.
- This Week: show up to 6 events per day (was 3) to use the height.
- Two Weeks: mini event-title pills instead of bare dots.
- Two Months: weeks expand to fill the column (no more empty lower third).
- Day & Events: smaller header/strip/rows so content stops clipping.
- Next 5 days → Next 7 days (range + labels), higher row cap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-06-09 20:14:39 +02:00
parent 13d80981c6
commit c0edca338e
20 changed files with 256 additions and 65 deletions

View File

@@ -11,6 +11,7 @@ struct EventDetailSheet: View {
let onDone: (_ editEvent: CalEvent?, _ forceReload: Bool) async -> Void
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
@State private var showDeleteConfirm = false
@State private var isDeleting = false
@State private var showCopySheet = false
@@ -33,10 +34,10 @@ struct EventDetailSheet: View {
if event.isAllDay {
if Calendar.current.isDate(event.startDate, inSameDayAs: event.endDate) ||
event.endDate == event.startDate {
return "Ganztägig · \(dateFmt.string(from: event.startDate))"
return "\(L10n.t("event.allday", appLang)) · \(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 "\(L10n.t("event.allday", appLang)) · \(dateFmt.string(from: event.startDate)) \(dateFmt.string(from: end))"
}
return "\(timeFmt.string(from: event.startDate)) \(timeFmt.string(from: event.endDate))"
}
@@ -85,27 +86,27 @@ struct EventDetailSheet: View {
Section {
HStack {
Label("Kalender", systemImage: "calendar")
Label(L10n.t("event.calendar_section", appLang), systemImage: "calendar")
Spacer()
Text(event.calendarName)
.foregroundStyle(.secondary)
}
HStack {
Label("Quelle", systemImage: "server.rack")
Label(L10n.t("detail.source", appLang), systemImage: "server.rack")
Spacer()
Text(event.source.capitalized)
.foregroundStyle(.secondary)
}
if let creator = event.creator, creator.id != currentUserId {
HStack {
Label("Erstellt von", systemImage: "person")
Label(L10n.t("detail.created_by", appLang), systemImage: "person")
Spacer()
Text(creator.displayName)
.foregroundStyle(.secondary)
}
}
if event.isPrivate {
Label("Privat", systemImage: "lock")
Label(L10n.t("event.private", appLang), systemImage: "lock")
.foregroundStyle(.secondary)
}
}
@@ -115,7 +116,7 @@ struct EventDetailSheet: View {
Button {
showCopySheet = true
} label: {
Label("Termin kopieren", systemImage: "doc.on.doc")
Label(L10n.t("event.copy_title", appLang), systemImage: "doc.on.doc")
}
}
}
@@ -125,7 +126,7 @@ struct EventDetailSheet: View {
Button(role: .destructive) {
showDeleteConfirm = true
} label: {
Label("Termin löschen", systemImage: "trash")
Label(L10n.t("detail.delete", appLang), systemImage: "trash")
.foregroundStyle(.red)
}
.disabled(isDeleting)
@@ -133,29 +134,29 @@ struct EventDetailSheet: View {
}
}
.listStyle(.insetGrouped)
.navigationTitle("Termin")
.navigationTitle(L10n.t("detail.title", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Schliessen") {
Button(L10n.t("common.close", appLang)) {
Task { await onDone(nil, false) }
}
}
if canEdit {
ToolbarItem(placement: .primaryAction) {
Button("Bearbeiten") {
Button(L10n.t("detail.edit", appLang)) {
Task { await onDone(event, false) }
}
}
}
}
.alert("Termin löschen?", isPresented: $showDeleteConfirm) {
Button("Löschen", role: .destructive) {
.alert(L10n.t("detail.delete_confirm_title", appLang), isPresented: $showDeleteConfirm) {
Button(L10n.t("common.delete", appLang), role: .destructive) {
Task { await deleteEvent() }
}
Button("Abbrechen", role: .cancel) {}
Button(L10n.t("common.cancel", appLang), role: .cancel) {}
} message: {
Text("\"\(event.title)\" wird dauerhaft gelöscht.")
Text("\"\(event.title)\" \(L10n.t("detail.delete_msg_suffix", appLang))")
}
.sheet(isPresented: $showCopySheet) {
EventEditorSheet(

View File

@@ -18,6 +18,8 @@ struct CalendarFilterSheet: View {
@State private var isLoading = true
@State private var hidden: Set<String> = []
@State private var banished: Set<String> = []
/// Calendars whose events do not generate reminder notifications.
@State private var reminderDisabled: Set<String> = []
/// All non-banished keys discovered during load used by bulk show/hide.
@State private var allKeys: Set<String> = []
/// Group-mode: the active group's full detail (members + colours) and the
@@ -155,12 +157,27 @@ struct CalendarFilterSheet: View {
.foregroundStyle(isVisible ? .primary : .secondary)
.strikethrough(!isVisible, color: .secondary)
Spacer()
if reminderDisabled.contains(key) {
Image(systemName: "bell.slash")
.font(.caption)
.foregroundStyle(.secondary)
}
Image(systemName: isVisible ? "eye" : "eye.slash")
.foregroundStyle(isVisible ? Color.accentColor : .secondary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.swipeActions(edge: .leading, allowsFullSwipe: false) {
let disabled = reminderDisabled.contains(key)
Button {
toggleReminders(forKey: key)
} label: {
Label(L10n.t(disabled ? "filter.reminders_on" : "filter.reminders_off", appLang),
systemImage: disabled ? "bell" : "bell.slash")
}
.tint(.orange)
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
hidden.remove(key)
@@ -173,6 +190,17 @@ struct CalendarFilterSheet: View {
}
}
/// Flip a calendar's reminder mute, persist locally + on the server, reschedule.
private func toggleReminders(forKey key: String) {
let nowDisabled = !reminderDisabled.contains(key)
if nowDisabled { reminderDisabled.insert(key) } else { reminderDisabled.remove(key) }
store.setReminderDisabled(key, disabled: nowDisabled)
if let parsed = CalendarStore.parseCalendarKey(key) {
Task { try? await api.setCalendarRemindersEnabled(
source: parsed.source, calendarId: parsed.id, enabled: !nowDisabled) }
}
}
// MARK: Group overlay filter (hide individual members / the group calendar)
@ViewBuilder
@@ -250,6 +278,19 @@ struct CalendarFilterSheet: View {
store.setBanishedCalendars(b)
banished = b
// Reconcile reminder-muted state from the server's reminders_enabled flags.
var rd = Set<String>()
func applyReminders(_ source: String, _ id: Int, _ enabled: Bool) {
if !enabled { rd.insert(CalendarStore.calendarKey(source: source, calendarId: "\(id)")) }
}
for cal in localCalendars { applyReminders("local", cal.id, cal.remindersEnabled) }
for acc in caldavAccounts { for cal in acc.calendars ?? [] { applyReminders("caldav", cal.id, cal.remindersEnabled ?? true) } }
for sub in icalSubs { applyReminders("ical", sub.id, sub.remindersEnabled ?? true) }
for acc in googleAccounts { for cal in acc.calendars ?? [] { applyReminders("google", cal.id, cal.remindersEnabled ?? true) } }
for acc in haAccounts { for cal in acc.calendars ?? [] { applyReminders("homeassistant", cal.id, cal.remindersEnabled) } }
store.setReminderDisabledKeys(rd)
reminderDisabled = rd
var keys = Set<String>()
for cal in localCalendars {
keys.insert(CalendarStore.calendarKey(source: "local", calendarId: "\(cal.id)"))

View File

@@ -2,6 +2,7 @@ import SwiftUI
struct LoginView: View {
@Environment(AppState.self) var appState
@AppStorage("appLanguage") private var appLang = "system"
@State private var username = ""
@State private var password = ""
@State private var totpCode = ""
@@ -32,10 +33,10 @@ struct LoginView: View {
VStack(spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
Text("Benutzername")
Text(L10n.t("login.username", appLang))
.font(.footnote.weight(.medium))
.foregroundStyle(.secondary)
TextField("Benutzername", text: $username)
TextField(L10n.t("login.username", appLang), text: $username)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.padding(12)
@@ -44,10 +45,10 @@ struct LoginView: View {
}
VStack(alignment: .leading, spacing: 6) {
Text("Passwort")
Text(L10n.t("login.password", appLang))
.font(.footnote.weight(.medium))
.foregroundStyle(.secondary)
SecureField("Passwort", text: $password)
SecureField(L10n.t("login.password", appLang), text: $password)
.padding(12)
.background(.quaternary)
.clipShape(RoundedRectangle(cornerRadius: 10))
@@ -55,10 +56,10 @@ struct LoginView: View {
if needsTOTP {
VStack(alignment: .leading, spacing: 6) {
Text("2FA-Code")
Text(L10n.t("login.totp", appLang))
.font(.footnote.weight(.medium))
.foregroundStyle(.secondary)
TextField("6-stelliger Code", text: $totpCode)
TextField(L10n.t("login.totp_placeholder", appLang), text: $totpCode)
.keyboardType(.numberPad)
.padding(12)
.background(.quaternary)
@@ -67,7 +68,7 @@ struct LoginView: View {
.transition(.move(edge: .top).combined(with: .opacity))
}
Toggle("Angemeldet bleiben", isOn: $rememberMe)
Toggle(L10n.t("login.remember", appLang), isOn: $rememberMe)
.tint(Color.accentColor)
if !error.isEmpty {
@@ -84,7 +85,7 @@ struct LoginView: View {
if isLoading {
ProgressView().tint(.white)
} else {
Text("Anmelden").fontWeight(.semibold)
Text(L10n.t("login.signin", appLang)).fontWeight(.semibold)
}
}
.frame(maxWidth: .infinity)
@@ -100,7 +101,7 @@ struct LoginView: View {
Spacer().frame(height: 40)
Button("Anderen Server wählen") {
Button(L10n.t("login.choose_server", appLang)) {
appState.resetServer()
}
.font(.footnote)

View File

@@ -2,6 +2,7 @@ import SwiftUI
struct ServerSetupView: View {
@Environment(AppState.self) var appState
@AppStorage("appLanguage") private var appLang = "system"
@State private var urlInput = ""
@State private var error = ""
@State private var isChecking = false
@@ -18,13 +19,13 @@ struct ServerSetupView: View {
.foregroundStyle(Color.accentColor)
Text("Calendarr")
.font(.largeTitle.bold())
Text("Server verbinden")
Text(L10n.t("server.connect_title", appLang))
.font(.subheadline)
.foregroundStyle(.secondary)
}
VStack(alignment: .leading, spacing: 8) {
Text("Server-URL")
Text(L10n.t("server.url", appLang))
.font(.footnote.weight(.medium))
.foregroundStyle(.secondary)
TextField("https://calendarr.example.com", text: $urlInput)
@@ -50,7 +51,7 @@ struct ServerSetupView: View {
ProgressView()
.tint(.white)
} else {
Text("Verbinden")
Text(L10n.t("server.connect", appLang))
.fontWeight(.semibold)
}
}
@@ -88,7 +89,7 @@ struct ServerSetupView: View {
_ = try await CalendarrAPI.checkSetupRequired(baseURL: url)
appState.saveServer(url: url)
} catch {
self.error = "Server nicht erreichbar. URL prüfen."
self.error = L10n.t("server.unreachable", appLang)
}
}
}

View File

@@ -93,9 +93,9 @@ struct SettingsView: View {
Text(loginName).foregroundStyle(.secondary)
}
HStack {
Text("E-Mail")
Text(L10n.t("settings.email", appLang))
Spacer()
TextField("E-Mail", text: $email)
TextField(L10n.t("settings.email", appLang), text: $email)
.multilineTextAlignment(.trailing)
.keyboardType(.emailAddress)
.autocapitalization(.none)