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:
@@ -92,10 +92,12 @@ struct CalDAVCalendar: Codable, Identifiable {
|
||||
var color: String?
|
||||
var enabled: Bool
|
||||
var sidebarHidden: Bool
|
||||
var remindersEnabled: Bool?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name, color, enabled
|
||||
case sidebarHidden = "sidebar_hidden"
|
||||
case remindersEnabled = "reminders_enabled"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,10 +110,12 @@ struct LocalCalendar: Codable, Identifiable {
|
||||
var sharedBy: String? = nil
|
||||
var permission: String? = nil
|
||||
var group: Bool = false
|
||||
var remindersEnabled: Bool = true
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name, color, enabled, owned, permission, group
|
||||
case sharedBy = "shared_by"
|
||||
case remindersEnabled = "reminders_enabled"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
@@ -124,6 +128,7 @@ struct LocalCalendar: Codable, Identifiable {
|
||||
sharedBy = try c.decodeIfPresent(String.self, forKey: .sharedBy)
|
||||
permission = try c.decodeIfPresent(String.self, forKey: .permission)
|
||||
group = try c.decodeIfPresent(Bool.self, forKey: .group) ?? false
|
||||
remindersEnabled = try c.decodeIfPresent(Bool.self, forKey: .remindersEnabled) ?? true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,11 +140,13 @@ struct ICalSubscription: Codable, Identifiable {
|
||||
var enabled: Bool
|
||||
var refreshMinutes: Int
|
||||
var lastFetched: String?
|
||||
var remindersEnabled: Bool?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name, url, color, enabled
|
||||
case refreshMinutes = "refresh_minutes"
|
||||
case lastFetched = "last_fetched"
|
||||
case remindersEnabled = "reminders_enabled"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,10 +162,12 @@ struct GoogleCalendar: Codable, Identifiable {
|
||||
var color: String?
|
||||
var enabled: Bool
|
||||
var sidebarHidden: Bool
|
||||
var remindersEnabled: Bool?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name, color, enabled
|
||||
case sidebarHidden = "sidebar_hidden"
|
||||
case remindersEnabled = "reminders_enabled"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,11 +191,13 @@ struct HACalendar: Codable, Identifiable {
|
||||
var color: String?
|
||||
var enabled: Bool
|
||||
var sidebarHidden: Bool
|
||||
var remindersEnabled: Bool = true
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name, color, enabled
|
||||
case entityId = "entity_id"
|
||||
case sidebarHidden = "sidebar_hidden"
|
||||
case remindersEnabled = "reminders_enabled"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
@@ -197,6 +208,7 @@ struct HACalendar: Codable, Identifiable {
|
||||
color = try c.decodeIfPresent(String.self, forKey: .color)
|
||||
enabled = try c.decodeIfPresent(Bool.self, forKey: .enabled) ?? true
|
||||
sidebarHidden = try c.decodeIfPresent(Bool.self, forKey: .sidebarHidden) ?? false
|
||||
remindersEnabled = try c.decodeIfPresent(Bool.self, forKey: .remindersEnabled) ?? true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,11 @@ class CalendarStore {
|
||||
/// show/hide list. Re-activation happens in AccountsView.
|
||||
var banishedCalendarKeys: Set<String> = CalendarStore.loadBanishedKeys()
|
||||
|
||||
/// Set of `"source:calendarId"` keys whose events must NOT generate reminder
|
||||
/// notifications (mirrors the server `reminders_enabled=false` flag). Persisted
|
||||
/// in UserDefaults; reconciled from the server in the filter sheet.
|
||||
var reminderDisabledKeys: Set<String> = CalendarStore.loadReminderDisabledKeys()
|
||||
|
||||
/// Group-overlay visibility: which members' calendars (`gm:<userId>`) and the
|
||||
/// group calendar (`gc`) are hidden in the combined view — like hiding
|
||||
/// individual people in Outlook. In-memory; resets when leaving/switching a
|
||||
@@ -213,6 +218,42 @@ class CalendarStore {
|
||||
publishWidgetSnapshot()
|
||||
}
|
||||
|
||||
// MARK: – Reminder-disabled-calendar persistence
|
||||
|
||||
private static let reminderDisabledKeysDefaultsKey = "reminderDisabledCalendarKeys"
|
||||
|
||||
static func loadReminderDisabledKeys() -> Set<String> {
|
||||
guard let raw = UserDefaults.standard.string(forKey: reminderDisabledKeysDefaultsKey),
|
||||
let data = raw.data(using: .utf8),
|
||||
let arr = try? JSONDecoder().decode([String].self, from: data)
|
||||
else { return [] }
|
||||
return Set(arr)
|
||||
}
|
||||
|
||||
static func saveReminderDisabledKeys(_ keys: Set<String>) {
|
||||
if let data = try? JSONEncoder().encode(Array(keys)),
|
||||
let s = String(data: data, encoding: .utf8) {
|
||||
UserDefaults.standard.set(s, forKey: reminderDisabledKeysDefaultsKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle whether a single calendar's events generate reminders, then
|
||||
/// reschedule notifications from the cache.
|
||||
func setReminderDisabled(_ key: String, disabled: Bool) {
|
||||
if disabled { reminderDisabledKeys.insert(key) } else { reminderDisabledKeys.remove(key) }
|
||||
Self.saveReminderDisabledKeys(reminderDisabledKeys)
|
||||
rescheduleNotifications()
|
||||
}
|
||||
|
||||
/// Replace the whole set (used when reconciling with the server's
|
||||
/// `reminders_enabled` flags in the filter sheet).
|
||||
func setReminderDisabledKeys(_ keys: Set<String>) {
|
||||
guard keys != reminderDisabledKeys else { return }
|
||||
reminderDisabledKeys = keys
|
||||
Self.saveReminderDisabledKeys(keys)
|
||||
rescheduleNotifications()
|
||||
}
|
||||
|
||||
/// Split a `"source:calendarId"` key back into its parts.
|
||||
static func parseCalendarKey(_ key: String) -> (source: String, id: Int)? {
|
||||
guard let colon = key.firstIndex(of: ":") else { return nil }
|
||||
@@ -265,14 +306,14 @@ class CalendarStore {
|
||||
&& !banishedCalendarKeys.contains(key)
|
||||
}
|
||||
// Personal events drive local reminder notifications.
|
||||
NotificationScheduler.reschedule(events: allCachedEvents)
|
||||
NotificationScheduler.reschedule(events: allCachedEvents, disabledCalendarKeys: reminderDisabledKeys)
|
||||
}
|
||||
|
||||
/// Recompute scheduled reminder notifications from the personal cache
|
||||
/// (skipped while a group overlay is active).
|
||||
func rescheduleNotifications() {
|
||||
guard activeGroup == nil else { return }
|
||||
NotificationScheduler.reschedule(events: allCachedEvents)
|
||||
NotificationScheduler.reschedule(events: allCachedEvents, disabledCalendarKeys: reminderDisabledKeys)
|
||||
}
|
||||
|
||||
/// Optimistically drop a just-deleted event from the cache so it disappears
|
||||
|
||||
@@ -259,6 +259,30 @@ private let strings: [String: [String: String]] = [
|
||||
"event.save": "Sichern",
|
||||
"event.add": "Hinzufügen",
|
||||
|
||||
// Event detail
|
||||
"detail.title": "Termin",
|
||||
"detail.source": "Quelle",
|
||||
"detail.created_by": "Erstellt von",
|
||||
"detail.delete": "Termin löschen",
|
||||
"detail.edit": "Bearbeiten",
|
||||
"detail.delete_confirm_title": "Termin löschen?",
|
||||
"detail.delete_msg_suffix": "wird dauerhaft gelöscht.",
|
||||
"common.delete": "Löschen",
|
||||
|
||||
// Login / server setup
|
||||
"login.username": "Benutzername",
|
||||
"login.password": "Passwort",
|
||||
"login.totp": "2FA-Code",
|
||||
"login.totp_placeholder": "6-stelliger Code",
|
||||
"login.remember": "Angemeldet bleiben",
|
||||
"login.signin": "Anmelden",
|
||||
"login.choose_server": "Anderen Server wählen",
|
||||
"server.connect_title": "Server verbinden",
|
||||
"server.url": "Server-URL",
|
||||
"server.connect": "Verbinden",
|
||||
"server.unreachable": "Server nicht erreichbar. URL prüfen.",
|
||||
"settings.email": "E-Mail",
|
||||
|
||||
// Accounts
|
||||
"accounts.title": "Konten",
|
||||
"accounts.loading": "Lade Konten…",
|
||||
@@ -292,6 +316,8 @@ private let strings: [String: [String: String]] = [
|
||||
"filter.hide_all": "Alle ausblenden",
|
||||
"filter.button": "Kalender ein-/ausblenden",
|
||||
"filter.banish": "Dauerhaft ausblenden",
|
||||
"filter.reminders_on": "Benachrichtigungen an",
|
||||
"filter.reminders_off": "Benachrichtigungen aus",
|
||||
"filter.banished_footer": "Dauerhaft ausgeblendete Kalender erscheinen unter »Konten & Kalender« und können dort wieder eingeblendet werden.",
|
||||
"accounts.banished_header": "Ausgeblendete Kalender",
|
||||
"accounts.banished_unhide": "Wieder einblenden",
|
||||
@@ -561,6 +587,30 @@ private let strings: [String: [String: String]] = [
|
||||
"event.save": "Save",
|
||||
"event.add": "Add",
|
||||
|
||||
// Event detail
|
||||
"detail.title": "Event",
|
||||
"detail.source": "Source",
|
||||
"detail.created_by": "Created by",
|
||||
"detail.delete": "Delete event",
|
||||
"detail.edit": "Edit",
|
||||
"detail.delete_confirm_title": "Delete event?",
|
||||
"detail.delete_msg_suffix": "will be permanently deleted.",
|
||||
"common.delete": "Delete",
|
||||
|
||||
// Login / server setup
|
||||
"login.username": "Username",
|
||||
"login.password": "Password",
|
||||
"login.totp": "2FA code",
|
||||
"login.totp_placeholder": "6-digit code",
|
||||
"login.remember": "Stay signed in",
|
||||
"login.signin": "Sign in",
|
||||
"login.choose_server": "Choose another server",
|
||||
"server.connect_title": "Connect server",
|
||||
"server.url": "Server URL",
|
||||
"server.connect": "Connect",
|
||||
"server.unreachable": "Server unreachable. Check the URL.",
|
||||
"settings.email": "Email",
|
||||
|
||||
// Accounts
|
||||
"accounts.title": "Accounts",
|
||||
"accounts.loading": "Loading accounts…",
|
||||
@@ -594,6 +644,8 @@ private let strings: [String: [String: String]] = [
|
||||
"filter.hide_all": "Hide all",
|
||||
"filter.button": "Show/hide calendars",
|
||||
"filter.banish": "Hide permanently",
|
||||
"filter.reminders_on": "Reminders on",
|
||||
"filter.reminders_off": "Reminders off",
|
||||
"filter.banished_footer": "Permanently hidden calendars appear under “Accounts & Calendars”, where you can show them again.",
|
||||
"accounts.banished_header": "Hidden calendars",
|
||||
"accounts.banished_unhide": "Show again",
|
||||
|
||||
@@ -402,6 +402,21 @@ class CalendarrAPI {
|
||||
body: ["enabled": !hidden, "sidebar_hidden": hidden])
|
||||
}
|
||||
|
||||
/// Toggle a calendar's server-side `reminders_enabled` flag. Supported for
|
||||
/// all source types (caldav/google/homeassistant/local/ical).
|
||||
func setCalendarRemindersEnabled(source: String, calendarId: Int, enabled: Bool) async throws {
|
||||
let path: String
|
||||
switch source {
|
||||
case "caldav": path = "/api/caldav/calendars/\(calendarId)"
|
||||
case "google": path = "/api/google/calendars/\(calendarId)"
|
||||
case "homeassistant": path = "/api/homeassistant/calendars/\(calendarId)"
|
||||
case "local": path = "/api/local/calendars/\(calendarId)"
|
||||
case "ical": path = "/api/ical/subscriptions/\(calendarId)"
|
||||
default: return
|
||||
}
|
||||
_ = try await request(path, method: "PUT", body: ["reminders_enabled": enabled])
|
||||
}
|
||||
|
||||
// MARK: – Calendar colour
|
||||
|
||||
func updateLocalCalendarColor(id: Int, color: String) async throws {
|
||||
|
||||
@@ -18,7 +18,9 @@ enum NotificationScheduler {
|
||||
|
||||
/// Recompute and (re)schedule notifications from the given events. The iOS
|
||||
/// pending-notification cap is 64, so only the soonest are scheduled.
|
||||
static func reschedule(events: [CalEvent]) {
|
||||
/// `disabledCalendarKeys` ("source:id") are calendars with reminders turned
|
||||
/// off — their events never generate notifications.
|
||||
static func reschedule(events: [CalEvent], disabledCalendarKeys: Set<String> = []) {
|
||||
let center = UNUserNotificationCenter.current()
|
||||
center.getNotificationSettings { settings in
|
||||
guard settings.authorizationStatus == .authorized
|
||||
@@ -28,6 +30,11 @@ enum NotificationScheduler {
|
||||
let now = Date()
|
||||
var pending: [(fire: Date, event: CalEvent)] = []
|
||||
for ev in events {
|
||||
// Skip calendars the user muted for reminders.
|
||||
if !disabledCalendarKeys.isEmpty {
|
||||
let key = CalendarStore.calendarKey(source: ev.source, calendarId: ev.calendarId)
|
||||
if disabledCalendarKeys.contains(key) { continue }
|
||||
}
|
||||
let offsets = ev.reminders.isEmpty
|
||||
? (defaultMin >= 0 ? [defaultMin] : [])
|
||||
: ev.reminders
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)"))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -41,11 +41,11 @@ struct CalendarDayWidgetView: View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header(primary: primary)
|
||||
weekStrip(snapshot: s, primary: primary, accent: accent)
|
||||
.padding(.vertical, 5)
|
||||
.padding(.vertical, 3)
|
||||
Rectangle()
|
||||
.fill(Color(widgetHex: s.lineColorHex).opacity(0.4))
|
||||
.frame(height: 0.5)
|
||||
.padding(.bottom, 6)
|
||||
.padding(.bottom, 4)
|
||||
eventList(accent: accent)
|
||||
}
|
||||
} else {
|
||||
@@ -61,9 +61,9 @@ struct CalendarDayWidgetView: View {
|
||||
private func header(primary: Color) -> some View {
|
||||
HStack(alignment: .top, spacing: 6) {
|
||||
Text("\(cal.component(.day, from: entry.date))")
|
||||
.font(.system(size: 36, weight: .bold))
|
||||
.font(.system(size: 30, weight: .bold))
|
||||
.foregroundStyle(primary)
|
||||
.frame(width: 44, alignment: .leading)
|
||||
.frame(width: 40, alignment: .leading)
|
||||
.minimumScaleFactor(0.7)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(monthFmt.string(from: entry.date).uppercased())
|
||||
@@ -125,12 +125,12 @@ struct CalendarDayWidgetView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 0)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(upcomingEvents.prefix(3)) { ev in
|
||||
HStack(alignment: .center, spacing: 6) {
|
||||
RoundedRectangle(cornerRadius: 1.5)
|
||||
.fill(Color(widgetHex: ev.colorHex))
|
||||
.frame(width: 3, height: 26)
|
||||
.frame(width: 3, height: 22)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(ev.title)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
|
||||
@@ -89,11 +89,11 @@ struct ThisWeekWidgetView: View {
|
||||
.frame(width: 16, height: 16)
|
||||
.background(isToday ? primary : Color.clear)
|
||||
.clipShape(Circle())
|
||||
ForEach(evs.prefix(3)) { ev in
|
||||
ForEach(evs.prefix(6)) { ev in
|
||||
eventPill(ev)
|
||||
}
|
||||
if evs.count > 3 {
|
||||
Text("+\(evs.count - 3)")
|
||||
if evs.count > 6 {
|
||||
Text("+\(evs.count - 6)")
|
||||
.font(.system(size: 6.5))
|
||||
.foregroundStyle(accent)
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ struct ThreeDaysWidgetView: View {
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
Text(ev.isAllDay ? WidgetL10n.t("widget.allday", lang) : timeFmt.string(from: ev.start))
|
||||
Text(WidgetTime.range(ev, lang: lang))
|
||||
.font(.system(size: 8))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.leading, 5)
|
||||
|
||||
@@ -74,7 +74,7 @@ struct TodayWidgetView: View {
|
||||
Text(ev.title)
|
||||
.font(.caption.weight(.medium))
|
||||
.lineLimit(1)
|
||||
Text(ev.isAllDay ? WidgetL10n.t("widget.allday", lang) : timeFmt.string(from: ev.start))
|
||||
Text(WidgetTime.range(ev, lang: lang))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ struct TwoDaysWidgetView: View {
|
||||
Text(ev.title)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.lineLimit(1)
|
||||
Text(ev.isAllDay ? WidgetL10n.t("widget.allday", lang) : timeFmt.string(from: ev.start))
|
||||
Text(WidgetTime.range(ev, lang: lang))
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
@@ -110,9 +110,11 @@ struct TwoMonthWidgetView: View {
|
||||
primary: primary, accent: accent)
|
||||
}
|
||||
}
|
||||
// Distribute weeks across the full column height instead of
|
||||
// top-packing them (which left the lower portion empty).
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -111,19 +111,22 @@ struct TwoWeeksWidgetView: View {
|
||||
.frame(width: 12, height: 12)
|
||||
.background(isToday ? primary : Color.clear)
|
||||
.clipShape(Circle())
|
||||
// Up to 3 colored dots
|
||||
HStack(spacing: 1) {
|
||||
ForEach(evs.prefix(3).indices, id: \.self) { i in
|
||||
Circle()
|
||||
.fill(Color(widgetHex: evs[i].colorHex))
|
||||
.frame(width: 3, height: 3)
|
||||
// Up to 2 mini event-title pills (the cell has room for titles).
|
||||
ForEach(evs.prefix(2)) { ev in
|
||||
Text(ev.title)
|
||||
.font(.system(size: 6, weight: .medium))
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 1.5)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(widgetHex: ev.colorHex))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 1.5))
|
||||
}
|
||||
}
|
||||
.frame(height: 3)
|
||||
if evs.count > 3 {
|
||||
Text("+\(evs.count - 3)")
|
||||
if evs.count > 2 {
|
||||
Text("+\(evs.count - 2)")
|
||||
.font(.system(size: 6))
|
||||
.foregroundStyle(accent)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ struct UpNextWidgetView: View {
|
||||
Text(ev.title)
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.lineLimit(1)
|
||||
Text(ev.isAllDay ? WidgetL10n.t("widget.allday", lang) : timeFmt.string(from: ev.start))
|
||||
Text(WidgetTime.range(ev, lang: lang))
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import WidgetKit
|
||||
private let rowHeight: CGFloat = 16
|
||||
private let dayHeaderHeight: CGFloat = 14
|
||||
private let maxEventsPerDay: Int = 3
|
||||
private let maxTotalRows: Int = 15
|
||||
private let maxTotalRows: Int = 22
|
||||
|
||||
struct UpcomingWidgetView: View {
|
||||
let entry: CalendarrEntry
|
||||
@@ -16,7 +16,7 @@ struct UpcomingWidgetView: View {
|
||||
guard let s = snapshot else { return [] }
|
||||
let cal = Calendar.current
|
||||
let now = entry.date
|
||||
let events = WidgetHelpers.upcoming(from: now, daysAhead: 5, in: s)
|
||||
let events = WidgetHelpers.upcoming(from: now, daysAhead: 7, in: s)
|
||||
var buckets: [Date: [WidgetEvent]] = [:]
|
||||
for ev in events {
|
||||
let key = cal.startOfDay(for: ev.start)
|
||||
|
||||
@@ -18,6 +18,21 @@ extension Color {
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared event time formatting for all widgets: "start – end", or the
|
||||
/// localized all-day label. Keeps every widget's event row consistent.
|
||||
enum WidgetTime {
|
||||
static func range(_ ev: WidgetEvent, lang: String) -> String {
|
||||
if ev.isAllDay { return WidgetL10n.t("widget.allday", lang) }
|
||||
let f = DateFormatter()
|
||||
f.locale = WidgetL10n.locale(lang)
|
||||
f.dateFormat = "HH:mm"
|
||||
let start = f.string(from: ev.start)
|
||||
// Hide a redundant identical end time (zero-length events).
|
||||
if ev.end <= ev.start { return start }
|
||||
return "\(start) – \(f.string(from: ev.end))"
|
||||
}
|
||||
}
|
||||
|
||||
enum WidgetL10n {
|
||||
static func t(_ key: String, _ stored: String) -> String {
|
||||
let lang: String
|
||||
@@ -46,14 +61,14 @@ enum WidgetL10n {
|
||||
"widget.no_events": "Keine Termine",
|
||||
"widget.allday": "Ganztägig",
|
||||
"widget.more": "+%d weitere",
|
||||
"widget.upcoming": "Nächste 5 Tage",
|
||||
"widget.upcoming": "Nächste 7 Tage",
|
||||
"widget.no_data": "Keine Daten – App einmal öffnen",
|
||||
"widget.display.today_title": "Heute",
|
||||
"widget.display.today_desc": "Heutige Termine auf einen Blick.",
|
||||
"widget.display.days_title": "Heute & Morgen",
|
||||
"widget.display.days_desc": "Termine der nächsten zwei Tage.",
|
||||
"widget.display.upcoming_title": "Nächste 5 Tage",
|
||||
"widget.display.upcoming_desc": "Termine der nächsten 5 Tage.",
|
||||
"widget.display.upcoming_title": "Nächste 7 Tage",
|
||||
"widget.display.upcoming_desc": "Termine der nächsten 7 Tage.",
|
||||
"widget.display.thisweek_title": "Diese Woche",
|
||||
"widget.display.thisweek_desc": "Wochenraster mit Terminen.",
|
||||
"widget.display.twoweeks_title": "Zwei Wochen",
|
||||
@@ -84,14 +99,14 @@ enum WidgetL10n {
|
||||
"widget.no_events": "No events",
|
||||
"widget.allday": "All-day",
|
||||
"widget.more": "+%d more",
|
||||
"widget.upcoming": "Next 5 days",
|
||||
"widget.upcoming": "Next 7 days",
|
||||
"widget.no_data": "No data – open the app once",
|
||||
"widget.display.today_title": "Today",
|
||||
"widget.display.today_desc": "Today's events at a glance.",
|
||||
"widget.display.days_title": "Today & tomorrow",
|
||||
"widget.display.days_desc": "Events for the next two days.",
|
||||
"widget.display.upcoming_title": "Next 5 days",
|
||||
"widget.display.upcoming_desc": "Events for the next 5 days.",
|
||||
"widget.display.upcoming_title": "Next 7 days",
|
||||
"widget.display.upcoming_desc": "Events for the next 7 days.",
|
||||
"widget.display.thisweek_title": "This Week",
|
||||
"widget.display.thisweek_desc": "Week grid with events.",
|
||||
"widget.display.twoweeks_title": "Two Weeks",
|
||||
|
||||
Reference in New Issue
Block a user