From c0edca338eb41302cc0394f21ac8f13de8ed7e2a Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Tue, 9 Jun 2026 20:14:39 +0200 Subject: [PATCH] iOS: localization fixes, per-calendar reminders, widget polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Calendarr iOS/Models/AppSettings.swift | 12 +++++ Calendarr iOS/Models/CalendarStore.swift | 45 +++++++++++++++- Calendarr iOS/Models/Localization.swift | 52 +++++++++++++++++++ Calendarr iOS/Services/CalendarrAPI.swift | 15 ++++++ .../Services/NotificationScheduler.swift | 9 +++- .../Views/Calendar/EventDetailSheet.swift | 31 +++++------ Calendarr iOS/Views/CalendarFilterSheet.swift | 41 +++++++++++++++ Calendarr iOS/Views/LoginView.swift | 19 +++---- Calendarr iOS/Views/ServerSetupView.swift | 9 ++-- Calendarr iOS/Views/SettingsView.swift | 4 +- CalendarrWidgets/CalendarDayWidgetView.swift | 12 ++--- CalendarrWidgets/ThisWeekWidgetView.swift | 6 +-- CalendarrWidgets/ThreeDaysWidgetView.swift | 2 +- CalendarrWidgets/TodayWidgetView.swift | 2 +- CalendarrWidgets/TwoDaysWidgetView.swift | 2 +- CalendarrWidgets/TwoMonthWidgetView.swift | 4 +- CalendarrWidgets/TwoWeeksWidgetView.swift | 23 ++++---- CalendarrWidgets/UpNextWidgetView.swift | 2 +- CalendarrWidgets/UpcomingWidgetView.swift | 4 +- CalendarrWidgets/WidgetSupport.swift | 27 +++++++--- 20 files changed, 256 insertions(+), 65 deletions(-) diff --git a/Calendarr iOS/Models/AppSettings.swift b/Calendarr iOS/Models/AppSettings.swift index 8bf2642..d392685 100644 --- a/Calendarr iOS/Models/AppSettings.swift +++ b/Calendarr iOS/Models/AppSettings.swift @@ -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 } } diff --git a/Calendarr iOS/Models/CalendarStore.swift b/Calendarr iOS/Models/CalendarStore.swift index 691a860..95c7ed2 100644 --- a/Calendarr iOS/Models/CalendarStore.swift +++ b/Calendarr iOS/Models/CalendarStore.swift @@ -74,6 +74,11 @@ class CalendarStore { /// show/hide list. Re-activation happens in AccountsView. var banishedCalendarKeys: Set = 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 = CalendarStore.loadReminderDisabledKeys() + /// Group-overlay visibility: which members' calendars (`gm:`) 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 { + 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) { + 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) { + 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 diff --git a/Calendarr iOS/Models/Localization.swift b/Calendarr iOS/Models/Localization.swift index 67a2a2b..1f16d87 100644 --- a/Calendarr iOS/Models/Localization.swift +++ b/Calendarr iOS/Models/Localization.swift @@ -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", diff --git a/Calendarr iOS/Services/CalendarrAPI.swift b/Calendarr iOS/Services/CalendarrAPI.swift index c245fe8..8ffdc22 100644 --- a/Calendarr iOS/Services/CalendarrAPI.swift +++ b/Calendarr iOS/Services/CalendarrAPI.swift @@ -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 { diff --git a/Calendarr iOS/Services/NotificationScheduler.swift b/Calendarr iOS/Services/NotificationScheduler.swift index e65f36b..0a402b9 100644 --- a/Calendarr iOS/Services/NotificationScheduler.swift +++ b/Calendarr iOS/Services/NotificationScheduler.swift @@ -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 = []) { 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 diff --git a/Calendarr iOS/Views/Calendar/EventDetailSheet.swift b/Calendarr iOS/Views/Calendar/EventDetailSheet.swift index 2bf8d39..9816188 100644 --- a/Calendarr iOS/Views/Calendar/EventDetailSheet.swift +++ b/Calendarr iOS/Views/Calendar/EventDetailSheet.swift @@ -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( diff --git a/Calendarr iOS/Views/CalendarFilterSheet.swift b/Calendarr iOS/Views/CalendarFilterSheet.swift index 9f00ed9..ccd79dc 100644 --- a/Calendarr iOS/Views/CalendarFilterSheet.swift +++ b/Calendarr iOS/Views/CalendarFilterSheet.swift @@ -18,6 +18,8 @@ struct CalendarFilterSheet: View { @State private var isLoading = true @State private var hidden: Set = [] @State private var banished: Set = [] + /// Calendars whose events do not generate reminder notifications. + @State private var reminderDisabled: Set = [] /// All non-banished keys discovered during load — used by bulk show/hide. @State private var allKeys: Set = [] /// 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() + 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() for cal in localCalendars { keys.insert(CalendarStore.calendarKey(source: "local", calendarId: "\(cal.id)")) diff --git a/Calendarr iOS/Views/LoginView.swift b/Calendarr iOS/Views/LoginView.swift index f7f8509..a5b9ff8 100644 --- a/Calendarr iOS/Views/LoginView.swift +++ b/Calendarr iOS/Views/LoginView.swift @@ -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) diff --git a/Calendarr iOS/Views/ServerSetupView.swift b/Calendarr iOS/Views/ServerSetupView.swift index 9918266..e0da48e 100644 --- a/Calendarr iOS/Views/ServerSetupView.swift +++ b/Calendarr iOS/Views/ServerSetupView.swift @@ -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) } } } diff --git a/Calendarr iOS/Views/SettingsView.swift b/Calendarr iOS/Views/SettingsView.swift index 9a8ed37..f098bde 100644 --- a/Calendarr iOS/Views/SettingsView.swift +++ b/Calendarr iOS/Views/SettingsView.swift @@ -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) diff --git a/CalendarrWidgets/CalendarDayWidgetView.swift b/CalendarrWidgets/CalendarDayWidgetView.swift index 91521dc..974c60c 100644 --- a/CalendarrWidgets/CalendarDayWidgetView.swift +++ b/CalendarrWidgets/CalendarDayWidgetView.swift @@ -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)) diff --git a/CalendarrWidgets/ThisWeekWidgetView.swift b/CalendarrWidgets/ThisWeekWidgetView.swift index 0bdf1bd..dc8542b 100644 --- a/CalendarrWidgets/ThisWeekWidgetView.swift +++ b/CalendarrWidgets/ThisWeekWidgetView.swift @@ -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) } diff --git a/CalendarrWidgets/ThreeDaysWidgetView.swift b/CalendarrWidgets/ThreeDaysWidgetView.swift index e303794..6c0bea9 100644 --- a/CalendarrWidgets/ThreeDaysWidgetView.swift +++ b/CalendarrWidgets/ThreeDaysWidgetView.swift @@ -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) diff --git a/CalendarrWidgets/TodayWidgetView.swift b/CalendarrWidgets/TodayWidgetView.swift index 2caf11b..b513b56 100644 --- a/CalendarrWidgets/TodayWidgetView.swift +++ b/CalendarrWidgets/TodayWidgetView.swift @@ -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) } diff --git a/CalendarrWidgets/TwoDaysWidgetView.swift b/CalendarrWidgets/TwoDaysWidgetView.swift index 70a258a..c7fd7dd 100644 --- a/CalendarrWidgets/TwoDaysWidgetView.swift +++ b/CalendarrWidgets/TwoDaysWidgetView.swift @@ -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) } diff --git a/CalendarrWidgets/TwoMonthWidgetView.swift b/CalendarrWidgets/TwoMonthWidgetView.swift index 2838ca3..1bc3568 100644 --- a/CalendarrWidgets/TwoMonthWidgetView.swift +++ b/CalendarrWidgets/TwoMonthWidgetView.swift @@ -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) } } diff --git a/CalendarrWidgets/TwoWeeksWidgetView.swift b/CalendarrWidgets/TwoWeeksWidgetView.swift index 6d18738..0138f9c 100644 --- a/CalendarrWidgets/TwoWeeksWidgetView.swift +++ b/CalendarrWidgets/TwoWeeksWidgetView.swift @@ -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) } diff --git a/CalendarrWidgets/UpNextWidgetView.swift b/CalendarrWidgets/UpNextWidgetView.swift index a0bed0c..b4112d3 100644 --- a/CalendarrWidgets/UpNextWidgetView.swift +++ b/CalendarrWidgets/UpNextWidgetView.swift @@ -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) } diff --git a/CalendarrWidgets/UpcomingWidgetView.swift b/CalendarrWidgets/UpcomingWidgetView.swift index a48756c..7011699 100644 --- a/CalendarrWidgets/UpcomingWidgetView.swift +++ b/CalendarrWidgets/UpcomingWidgetView.swift @@ -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) diff --git a/CalendarrWidgets/WidgetSupport.swift b/CalendarrWidgets/WidgetSupport.swift index 8df82c0..cc7968e 100644 --- a/CalendarrWidgets/WidgetSupport.swift +++ b/CalendarrWidgets/WidgetSupport.swift @@ -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",