diff --git a/Calendarr iOS.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist b/Calendarr iOS.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist
index 0190ba4..c7f1ae5 100644
--- a/Calendarr iOS.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist
+++ b/Calendarr iOS.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist
@@ -9,6 +9,11 @@
orderHint
0
+ CalendarrWidgets.xcscheme_^#shared#^_
+
+ orderHint
+ 1
+
SuppressBuildableAutocreation
diff --git a/Calendarr iOS/Calendarr iOS.entitlements b/Calendarr iOS/Calendarr iOS.entitlements
new file mode 100644
index 0000000..bb88d59
--- /dev/null
+++ b/Calendarr iOS/Calendarr iOS.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.security.application-groups
+
+ group.com.scarriffleservices.calendarr
+
+
+
diff --git a/Calendarr iOS/Models/CalendarStore.swift b/Calendarr iOS/Models/CalendarStore.swift
index c3ebf80..431168c 100644
--- a/Calendarr iOS/Models/CalendarStore.swift
+++ b/Calendarr iOS/Models/CalendarStore.swift
@@ -1,6 +1,13 @@
import Foundation
import SwiftUI
+extension Notification.Name {
+ /// Posted whenever the persistent "banished calendars" set is mutated from
+ /// outside the active `CalendarStore` (e.g. by `AccountsView`). The store
+ /// listens for this in `CalendarHostView` and refreshes its filter.
+ static let banishedCalendarsChanged = Notification.Name("banishedCalendarsChanged")
+}
+
enum CalViewType: String, CaseIterable {
case month, week, day, quarter, agenda
@@ -45,11 +52,112 @@ class CalendarStore {
var weekStartsOnMonday = true
var writableCalendars: [WritableCalendar] = []
+ /// Set of `"source:calendarId"` keys the user has chosen to hide from the
+ /// calendar views. Persisted in UserDefaults as a JSON array. Events whose
+ /// key matches one of these are filtered out before being rendered.
+ var hiddenCalendarKeys: Set = CalendarStore.loadHiddenKeys()
+
+ /// "Banished" calendars – like `hiddenCalendarKeys` but expressing a
+ /// stronger user intent: the calendar should not even appear in the quick
+ /// show/hide list. Re-activation happens in AccountsView.
+ var banishedCalendarKeys: Set = CalendarStore.loadBanishedKeys()
+
// Cache bookkeeping
private var cachedStart: Date? = nil
private var cachedEnd: Date? = nil
private var allCachedEvents: [CalEvent] = []
+ // MARK: – Hidden-calendar persistence
+
+ private static let hiddenKeysDefaultsKey = "hiddenCalendarKeys"
+
+ private static func loadHiddenKeys() -> Set {
+ guard let raw = UserDefaults.standard.string(forKey: hiddenKeysDefaultsKey),
+ let data = raw.data(using: .utf8),
+ let arr = try? JSONDecoder().decode([String].self, from: data)
+ else { return [] }
+ return Set(arr)
+ }
+
+ private func saveHiddenKeys() {
+ let arr = Array(hiddenCalendarKeys)
+ if let data = try? JSONEncoder().encode(arr),
+ let s = String(data: data, encoding: .utf8) {
+ UserDefaults.standard.set(s, forKey: Self.hiddenKeysDefaultsKey)
+ }
+ }
+
+ /// Toggle visibility of a single calendar and immediately refresh the
+ /// visible event list + widget snapshot.
+ func setCalendarHidden(_ key: String, hidden: Bool) {
+ if hidden { hiddenCalendarKeys.insert(key) } else { hiddenCalendarKeys.remove(key) }
+ saveHiddenKeys()
+ let (s, e) = rangeForCurrentView()
+ refreshFromCache(start: s, end: e)
+ publishWidgetSnapshot()
+ }
+
+ /// Replace the entire set (used by the filter sheet's bulk show/hide).
+ func setHiddenCalendars(_ keys: Set) {
+ hiddenCalendarKeys = keys
+ saveHiddenKeys()
+ let (s, e) = rangeForCurrentView()
+ refreshFromCache(start: s, end: e)
+ publishWidgetSnapshot()
+ }
+
+ static func calendarKey(source: String, calendarId: String) -> String {
+ "\(source):\(calendarId)"
+ }
+
+ // MARK: – Banished-calendar persistence
+
+ private static let banishedKeysDefaultsKey = "banishedCalendarKeys"
+
+ static func loadBanishedKeys() -> Set {
+ guard let raw = UserDefaults.standard.string(forKey: banishedKeysDefaultsKey),
+ let data = raw.data(using: .utf8),
+ let arr = try? JSONDecoder().decode([String].self, from: data)
+ else { return [] }
+ return Set(arr)
+ }
+
+ static func saveBanishedKeys(_ keys: Set) {
+ let arr = Array(keys)
+ if let data = try? JSONEncoder().encode(arr),
+ let s = String(data: data, encoding: .utf8) {
+ UserDefaults.standard.set(s, forKey: banishedKeysDefaultsKey)
+ }
+ }
+
+ /// Move a calendar to / out of the banished set. Also clears any quick
+ /// hidden flag for that key – once banished, the dual state is redundant.
+ /// Posts `.banishedCalendarsChanged` so other views in the navigation
+ /// stack (e.g. AccountsView) stay in sync.
+ func setCalendarBanished(_ key: String, banished: Bool) {
+ if banished {
+ banishedCalendarKeys.insert(key)
+ hiddenCalendarKeys.remove(key)
+ } else {
+ banishedCalendarKeys.remove(key)
+ }
+ Self.saveBanishedKeys(banishedCalendarKeys)
+ saveHiddenKeys()
+ NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil)
+ let (s, e) = rangeForCurrentView()
+ refreshFromCache(start: s, end: e)
+ publishWidgetSnapshot()
+ }
+
+ /// Re-read the banished set from UserDefaults – called when an external
+ /// view (AccountsView) mutated it. Refreshes visible events + widgets.
+ func syncBanishedFromDefaults() {
+ banishedCalendarKeys = Self.loadBanishedKeys()
+ let (s, e) = rangeForCurrentView()
+ refreshFromCache(start: s, end: e)
+ publishWidgetSnapshot()
+ }
+
var userCalendar: Calendar {
var cal = Calendar.current
cal.firstWeekday = weekStartsOnMonday ? 2 : 1
@@ -67,7 +175,10 @@ class CalendarStore {
/// Call this after navigation without hitting the network.
func refreshFromCache(start: Date, end: Date) {
events = allCachedEvents.filter { ev in
- ev.startDate < end && ev.endDate > start
+ let key = Self.calendarKey(source: ev.source, calendarId: ev.calendarId)
+ return ev.startDate < end && ev.endDate > start
+ && !hiddenCalendarKeys.contains(key)
+ && !banishedCalendarKeys.contains(key)
}
}
@@ -135,6 +246,53 @@ class CalendarStore {
cachedStart = rangeStart
cachedEnd = rangeEnd
}
+
+ publishWidgetSnapshot()
+ }
+
+ /// Write a slim snapshot of the next ~6 weeks into the App-Group container
+ /// so the widget extension can render without a network call. 42 days
+ /// covers the worst-case month grid (6 rows × 7 cols) for the calendar
+ /// widget. Also asks the system to refresh the widget timeline.
+ private func publishWidgetSnapshot() {
+ let cal = userCalendar
+ let now = Date()
+ // Include the week before today so widgets that show the current week
+ // (e.g. "This Week", "Up Next + Calendar") have data for Monday–today.
+ let from = cal.date(byAdding: .day, value: -7, to: cal.startOfDay(for: now)) ?? now
+ let to = cal.date(byAdding: .day, value: 42, to: cal.startOfDay(for: now)) ?? from
+ let visible = allCachedEvents
+ .filter { ev in
+ let key = Self.calendarKey(source: ev.source, calendarId: ev.calendarId)
+ return ev.startDate < to && ev.endDate > from
+ && !hiddenCalendarKeys.contains(key)
+ && !banishedCalendarKeys.contains(key)
+ }
+ .sorted { $0.startDate < $1.startDate }
+ .prefix(500)
+ .map { ev in
+ WidgetEvent(id: ev.id,
+ title: ev.title,
+ start: ev.startDate,
+ end: ev.endDate,
+ isAllDay: ev.isAllDay,
+ colorHex: ev.effectiveColor,
+ location: ev.location)
+ }
+ let defaults = UserDefaults.standard
+ let snap = WidgetSnapshot(
+ writtenAt: now,
+ events: Array(visible),
+ todayColorHex: defaults.string(forKey: "todayColor") ?? "#4285f4",
+ textColorHex: defaults.string(forKey: "textColor") ?? "#FFFFFF",
+ backgroundColorHex: defaults.string(forKey: "backgroundColor") ?? "#000000",
+ lineColorHex: defaults.string(forKey: "lineColor") ?? "#3A3A3C",
+ primaryColorHex: defaults.string(forKey: "primaryColor") ?? "#4285f4",
+ accentColorHex: defaults.string(forKey: "accentColor") ?? "#ea4335",
+ language: defaults.string(forKey: "appLanguage") ?? "system"
+ )
+ WidgetStore.write(snap)
+ WidgetTimelineNotifier.reload()
}
// MARK: – Writable calendars
diff --git a/Calendarr iOS/Models/Localization.swift b/Calendarr iOS/Models/Localization.swift
index 5841fad..515a06b 100644
--- a/Calendarr iOS/Models/Localization.swift
+++ b/Calendarr iOS/Models/Localization.swift
@@ -234,6 +234,20 @@ private let strings: [String: [String: String]] = [
"accounts.ha.header": "Home Assistant",
"accounts.ha.empty": "Keine Home Assistant-Konten",
"accounts.ha.add": "Home Assistant hinzufügen",
+ "profile.admin_note": "Hinweis: Die Benutzerverwaltung – sowohl das Erstellen als auch das Löschen von Benutzerkonten – erfolgt ausschließlich durch den Administrator des Servers.",
+
+ // Kalender-Filter (Sidebar)
+ "filter.title": "Kalender",
+ "filter.loading": "Lade Kalender…",
+ "filter.empty": "Keine Kalender vorhanden",
+ "filter.show_all": "Alle anzeigen",
+ "filter.hide_all": "Alle ausblenden",
+ "filter.button": "Kalender ein-/ausblenden",
+ "filter.banish": "Dauerhaft ausblenden",
+ "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",
+ "accounts.banished_unknown": "Unbekannter Kalender",
// CalDAV add sheet
"caldav.section": "Konto-Details",
@@ -474,6 +488,20 @@ private let strings: [String: [String: String]] = [
"accounts.ha.header": "Home Assistant",
"accounts.ha.empty": "No Home Assistant accounts",
"accounts.ha.add": "Add Home Assistant",
+ "profile.admin_note": "Note: User management — both the creation and deletion of user accounts — is handled exclusively by the server administrator.",
+
+ // Calendar filter (sidebar)
+ "filter.title": "Calendars",
+ "filter.loading": "Loading calendars…",
+ "filter.empty": "No calendars available",
+ "filter.show_all": "Show all",
+ "filter.hide_all": "Hide all",
+ "filter.button": "Show/hide calendars",
+ "filter.banish": "Hide permanently",
+ "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",
+ "accounts.banished_unknown": "Unknown calendar",
// CalDAV add sheet
"caldav.section": "Account details",
diff --git a/Calendarr iOS/Views/AccountsView.swift b/Calendarr iOS/Views/AccountsView.swift
index 9e9bf35..2817923 100644
--- a/Calendarr iOS/Views/AccountsView.swift
+++ b/Calendarr iOS/Views/AccountsView.swift
@@ -14,6 +14,7 @@ struct AccountsView: View {
@State private var showAddICal = false
@State private var showAddHA = false
@State private var errorAlert: String?
+ @State private var banishedKeys: Set = CalendarStore.loadBanishedKeys()
@AppStorage("appLanguage") private var appLang = "system"
@@ -24,6 +25,7 @@ struct AccountsView: View {
ProgressView(L10n.t("accounts.loading", appLang))
} else {
List {
+ if !banishedKeys.isEmpty { banishedSection }
caldavSection
localSection
icalSection
@@ -180,6 +182,69 @@ struct AccountsView: View {
}
}
+ var banishedSection: some View {
+ Section {
+ ForEach(Array(banishedKeys).sorted(), id: \.self) { key in
+ let info = resolveBanished(key)
+ HStack(spacing: 10) {
+ Circle()
+ .fill(Color(hex: info.colorHex))
+ .frame(width: 12, height: 12)
+ .opacity(0.5)
+ Text(info.name)
+ .foregroundStyle(.secondary)
+ Spacer()
+ Button(L10n.t("accounts.banished_unhide", appLang)) {
+ banishedKeys.remove(key)
+ CalendarStore.saveBanishedKeys(banishedKeys)
+ NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil)
+ }
+ .font(.callout)
+ .foregroundStyle(Color.accentColor)
+ }
+ }
+ } header: {
+ Text(L10n.t("accounts.banished_header", appLang))
+ }
+ }
+
+ private func resolveBanished(_ key: String) -> (name: String, colorHex: String) {
+ let parts = key.split(separator: ":", maxSplits: 1).map(String.init)
+ guard parts.count == 2, let id = Int(parts[1]) else {
+ return (L10n.t("accounts.banished_unknown", appLang), "#888888")
+ }
+ switch parts[0] {
+ case "local":
+ if let c = localCalendars.first(where: { $0.id == id }) {
+ return (c.name, c.color)
+ }
+ case "caldav":
+ for acc in caldavAccounts {
+ if let c = acc.calendars?.first(where: { $0.id == id }) {
+ return ("\(acc.name) – \(c.name)", c.color ?? acc.color)
+ }
+ }
+ case "ical":
+ if let s = icalSubs.first(where: { $0.id == id }) {
+ return (s.name, s.color)
+ }
+ case "google":
+ for acc in googleAccounts {
+ if let c = acc.calendars?.first(where: { $0.id == id }) {
+ return ("\(acc.email) – \(c.name)", c.color ?? "#4285f4")
+ }
+ }
+ case "homeassistant":
+ for acc in haAccounts {
+ if let c = acc.calendars?.first(where: { $0.id == id }) {
+ return ("\(acc.name) – \(c.name)", c.color ?? "#46bdc6")
+ }
+ }
+ default: break
+ }
+ return (L10n.t("accounts.banished_unknown", appLang), "#888888")
+ }
+
var haSection: some View {
Section {
if haAccounts.isEmpty {
@@ -210,6 +275,7 @@ struct AccountsView: View {
private func load() async {
isLoading = true
+ banishedKeys = CalendarStore.loadBanishedKeys()
async let c = (try? await api.getCalDAVAccounts()) ?? []
async let l = (try? await api.getLocalCalendars()) ?? []
async let i = (try? await api.getICalSubscriptions()) ?? []
diff --git a/Calendarr iOS/Views/CalendarFilterSheet.swift b/Calendarr iOS/Views/CalendarFilterSheet.swift
new file mode 100644
index 0000000..e932c1b
--- /dev/null
+++ b/Calendarr iOS/Views/CalendarFilterSheet.swift
@@ -0,0 +1,203 @@
+import SwiftUI
+
+/// Lets the user toggle which calendars contribute events to the displayed
+/// calendar views (and the home-screen widgets). Filtering is purely
+/// client-side: hidden keys live in UserDefaults via `CalendarStore`. No
+/// server roundtrip is required to toggle visibility.
+struct CalendarFilterSheet: View {
+ let api: CalendarrAPI
+ let store: CalendarStore
+ @Environment(\.dismiss) private var dismiss
+ @AppStorage("appLanguage") private var appLang = "system"
+
+ @State private var caldavAccounts: [CalDAVAccount] = []
+ @State private var localCalendars: [LocalCalendar] = []
+ @State private var icalSubs: [ICalSubscription] = []
+ @State private var googleAccounts: [GoogleAccount] = []
+ @State private var haAccounts: [HomeAssistantAccount] = []
+ @State private var isLoading = true
+ @State private var hidden: Set = []
+ @State private var banished: Set = []
+ /// All non-banished keys discovered during load — used by bulk show/hide.
+ @State private var allKeys: Set = []
+
+ var body: some View {
+ NavigationStack {
+ Group {
+ if isLoading {
+ ProgressView(L10n.t("filter.loading", appLang))
+ } else if allKeys.isEmpty {
+ Text(L10n.t("filter.empty", appLang))
+ .foregroundStyle(.secondary)
+ } else {
+ List {
+ let visibleLocals = localCalendars.filter {
+ !banished.contains(CalendarStore.calendarKey(source: "local", calendarId: "\($0.id)"))
+ }
+ if !visibleLocals.isEmpty {
+ Section(L10n.t("accounts.local.header", appLang)) {
+ ForEach(visibleLocals) { cal in
+ row(name: cal.name, colorHex: cal.color,
+ key: CalendarStore.calendarKey(source: "local", calendarId: "\(cal.id)"))
+ }
+ }
+ }
+ ForEach(caldavAccounts) { acc in
+ let cals = (acc.calendars ?? []).filter {
+ !banished.contains(CalendarStore.calendarKey(source: "caldav", calendarId: "\($0.id)"))
+ }
+ if !cals.isEmpty {
+ Section(acc.name) {
+ ForEach(cals) { cal in
+ row(name: cal.name,
+ colorHex: cal.color ?? acc.color,
+ key: CalendarStore.calendarKey(source: "caldav", calendarId: "\(cal.id)"))
+ }
+ }
+ }
+ }
+ let visibleSubs = icalSubs.filter {
+ !banished.contains(CalendarStore.calendarKey(source: "ical", calendarId: "\($0.id)"))
+ }
+ if !visibleSubs.isEmpty {
+ Section(L10n.t("accounts.ical.header", appLang)) {
+ ForEach(visibleSubs) { sub in
+ row(name: sub.name, colorHex: sub.color,
+ key: CalendarStore.calendarKey(source: "ical", calendarId: "\(sub.id)"))
+ }
+ }
+ }
+ ForEach(googleAccounts) { acc in
+ let cals = (acc.calendars ?? []).filter {
+ !banished.contains(CalendarStore.calendarKey(source: "google", calendarId: "\($0.id)"))
+ }
+ if !cals.isEmpty {
+ Section(acc.email) {
+ ForEach(cals) { cal in
+ row(name: cal.name,
+ colorHex: cal.color ?? "#4285f4",
+ key: CalendarStore.calendarKey(source: "google", calendarId: "\(cal.id)"))
+ }
+ }
+ }
+ }
+ ForEach(haAccounts) { acc in
+ let cals = (acc.calendars ?? []).filter {
+ !banished.contains(CalendarStore.calendarKey(source: "homeassistant", calendarId: "\($0.id)"))
+ }
+ if !cals.isEmpty {
+ Section(acc.name) {
+ ForEach(cals) { cal in
+ row(name: cal.name,
+ colorHex: cal.color ?? "#46bdc6",
+ key: CalendarStore.calendarKey(source: "homeassistant", calendarId: "\(cal.id)"))
+ }
+ }
+ }
+ }
+ if !banished.isEmpty {
+ Section {
+ Text(L10n.t("filter.banished_footer", appLang))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ }
+ }
+ .navigationTitle(L10n.t("filter.title", appLang))
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Menu {
+ Button(L10n.t("filter.show_all", appLang)) {
+ hidden = []
+ store.setHiddenCalendars(hidden)
+ }
+ Button(L10n.t("filter.hide_all", appLang)) {
+ hidden = allKeys
+ store.setHiddenCalendars(hidden)
+ }
+ } label: {
+ Image(systemName: "ellipsis.circle")
+ }
+ .disabled(allKeys.isEmpty)
+ }
+ ToolbarItem(placement: .primaryAction) {
+ Button(L10n.t("nav.done", appLang)) { dismiss() }
+ }
+ }
+ }
+ .task { await load() }
+ }
+
+ @ViewBuilder
+ private func row(name: String, colorHex: String, key: String) -> some View {
+ let isVisible = !hidden.contains(key)
+ Button {
+ if isVisible { hidden.insert(key) } else { hidden.remove(key) }
+ store.setCalendarHidden(key, hidden: !isVisible)
+ } label: {
+ HStack(spacing: 12) {
+ Circle()
+ .fill(Color(hex: colorHex))
+ .frame(width: 14, height: 14)
+ .opacity(isVisible ? 1.0 : 0.35)
+ Text(name)
+ .foregroundStyle(isVisible ? .primary : .secondary)
+ .strikethrough(!isVisible, color: .secondary)
+ Spacer()
+ Image(systemName: isVisible ? "eye" : "eye.slash")
+ .foregroundStyle(isVisible ? Color.accentColor : .secondary)
+ }
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .swipeActions(edge: .trailing, allowsFullSwipe: false) {
+ Button(role: .destructive) {
+ hidden.remove(key)
+ banished.insert(key)
+ store.setCalendarBanished(key, banished: true)
+ } label: {
+ Label(L10n.t("filter.banish", appLang), systemImage: "archivebox")
+ }
+ }
+ }
+
+ private func load() async {
+ isLoading = true
+ hidden = store.hiddenCalendarKeys
+ banished = store.banishedCalendarKeys
+ async let c = (try? await api.getCalDAVAccounts()) ?? []
+ async let l = (try? await api.getLocalCalendars()) ?? []
+ async let i = (try? await api.getICalSubscriptions()) ?? []
+ async let g = (try? await api.getGoogleAccounts()) ?? []
+ async let h = (try? await api.getHomeAssistantAccounts()) ?? []
+ (caldavAccounts, localCalendars, icalSubs, googleAccounts, haAccounts) = await (c, l, i, g, h)
+
+ var keys = Set()
+ for cal in localCalendars {
+ keys.insert(CalendarStore.calendarKey(source: "local", calendarId: "\(cal.id)"))
+ }
+ for acc in caldavAccounts {
+ for cal in acc.calendars ?? [] {
+ keys.insert(CalendarStore.calendarKey(source: "caldav", calendarId: "\(cal.id)"))
+ }
+ }
+ for sub in icalSubs {
+ keys.insert(CalendarStore.calendarKey(source: "ical", calendarId: "\(sub.id)"))
+ }
+ for acc in googleAccounts {
+ for cal in acc.calendars ?? [] {
+ keys.insert(CalendarStore.calendarKey(source: "google", calendarId: "\(cal.id)"))
+ }
+ }
+ for acc in haAccounts {
+ for cal in acc.calendars ?? [] {
+ keys.insert(CalendarStore.calendarKey(source: "homeassistant", calendarId: "\(cal.id)"))
+ }
+ }
+ allKeys = keys
+ isLoading = false
+ }
+}
diff --git a/Calendarr iOS/Views/ProfileView.swift b/Calendarr iOS/Views/ProfileView.swift
index a03417b..aa4117e 100644
--- a/Calendarr iOS/Views/ProfileView.swift
+++ b/Calendarr iOS/Views/ProfileView.swift
@@ -33,6 +33,7 @@ struct ProfileView: View {
kontoSection(profile: profile)
passwordSection
twoFASection(profile: profile)
+ adminNoteSection
}
}
}
@@ -141,6 +142,20 @@ struct ProfileView: View {
}
}
+ var adminNoteSection: some View {
+ Section {
+ HStack(alignment: .top, spacing: 10) {
+ Image(systemName: "info.circle")
+ .foregroundStyle(.secondary)
+ .padding(.top, 1)
+ Text(L10n.t("profile.admin_note", appLang))
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+ .padding(.vertical, 4)
+ }
+ }
+
private func load() async {
isLoading = true
defer { isLoading = false }
diff --git a/Calendarr iOS/Views/SettingsView.swift b/Calendarr iOS/Views/SettingsView.swift
index 3aaf2c7..f5eb66c 100644
--- a/Calendarr iOS/Views/SettingsView.swift
+++ b/Calendarr iOS/Views/SettingsView.swift
@@ -16,6 +16,8 @@ struct SettingsView: View {
@AppStorage("textColor") private var textHex = "#FFFFFF"
@AppStorage("backgroundColor") private var bgHex = "#000000"
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
+ @AppStorage("primaryColor") private var primaryHex = "#4285f4"
+ @AppStorage("accentColor") private var accentHex = "#ea4335"
var body: some View {
NavigationStack {
@@ -65,6 +67,16 @@ struct SettingsView: View {
.animation(.easeInOut, value: showToast)
}
.task { await load() }
+ // Live-update widgets the moment any appearance value changes, so the
+ // user sees the new colours without having to wait for the next event
+ // sync or save the settings.
+ .onChange(of: primaryHex) { _, _ in WidgetStore.republishAppearanceOnly() }
+ .onChange(of: accentHex) { _, _ in WidgetStore.republishAppearanceOnly() }
+ .onChange(of: todayHex) { _, _ in WidgetStore.republishAppearanceOnly() }
+ .onChange(of: textHex) { _, _ in WidgetStore.republishAppearanceOnly() }
+ .onChange(of: bgHex) { _, _ in WidgetStore.republishAppearanceOnly() }
+ .onChange(of: lineHex) { _, _ in WidgetStore.republishAppearanceOnly() }
+ .onChange(of: appLang) { _, _ in WidgetStore.republishAppearanceOnly() }
}
// MARK: – Liquid Glass
@@ -143,8 +155,8 @@ struct SettingsView: View {
var farbenSection: some View {
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.primary", appLang), hex: $primaryHex)
+ ColorPickerRow(label: L10n.t("settings.color.accent", appLang), hex: $accentHex)
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)
@@ -260,6 +272,8 @@ struct SettingsView: View {
textHex = s.textColor
bgHex = s.backgroundColor
lineHex = s.lineColor
+ primaryHex = s.primaryColor
+ accentHex = s.accentColor
}
}
@@ -273,6 +287,8 @@ struct SettingsView: View {
settings.textColor = textHex
settings.backgroundColor = bgHex
settings.lineColor = lineHex
+ settings.primaryColor = primaryHex
+ settings.accentColor = accentHex
do {
try await api.updateSettings(settings)
showNotice(L10n.t("settings.saved", appLang))
diff --git a/CalendarrWidgets/CalendarrTimelineProvider.swift b/CalendarrWidgets/CalendarrTimelineProvider.swift
new file mode 100644
index 0000000..a3e52a2
--- /dev/null
+++ b/CalendarrWidgets/CalendarrTimelineProvider.swift
@@ -0,0 +1,53 @@
+import WidgetKit
+
+struct CalendarrEntry: TimelineEntry {
+ let date: Date
+ let snapshot: WidgetSnapshot?
+}
+
+struct CalendarrTimelineProvider: TimelineProvider {
+ func placeholder(in context: Context) -> CalendarrEntry {
+ CalendarrEntry(date: .now, snapshot: WidgetStore.read())
+ }
+
+ func getSnapshot(in context: Context, completion: @escaping (CalendarrEntry) -> Void) {
+ completion(CalendarrEntry(date: .now, snapshot: WidgetStore.read()))
+ }
+
+ func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) {
+ let snapshot = WidgetStore.read()
+ let now = Date()
+
+ // Provide one entry per hour for the next 24h so the widget keeps
+ // re-rendering as time progresses (past events drop off, "now" advances).
+ var entries: [CalendarrEntry] = []
+ for h in 0..<24 {
+ let date = Calendar.current.date(byAdding: .hour, value: h, to: now) ?? now
+ entries.append(CalendarrEntry(date: date, snapshot: snapshot))
+ }
+ // Ask iOS to refresh in 30 min to pick up any new data the app wrote.
+ let refreshAt = Calendar.current.date(byAdding: .minute, value: 30, to: now) ?? now
+ completion(Timeline(entries: entries, policy: .after(refreshAt)))
+ }
+}
+
+// MARK: – Shared helpers used by all widget views
+
+enum WidgetHelpers {
+ static func events(for day: Date, in snapshot: WidgetSnapshot) -> [WidgetEvent] {
+ let cal = Calendar.current
+ let dayStart = cal.startOfDay(for: day)
+ let dayEnd = cal.date(byAdding: .day, value: 1, to: dayStart) ?? dayStart
+ return snapshot.events
+ .filter { $0.start < dayEnd && $0.end > dayStart }
+ .sorted { $0.start < $1.start }
+ }
+
+ static func upcoming(from now: Date, daysAhead: Int, in snapshot: WidgetSnapshot) -> [WidgetEvent] {
+ let cal = Calendar.current
+ let end = cal.date(byAdding: .day, value: daysAhead, to: cal.startOfDay(for: now)) ?? now
+ return snapshot.events
+ .filter { $0.end > now && $0.start < end }
+ .sorted { $0.start < $1.start }
+ }
+}
diff --git a/CalendarrWidgets/CalendarrWidgets.entitlements b/CalendarrWidgets/CalendarrWidgets.entitlements
new file mode 100644
index 0000000..bb88d59
--- /dev/null
+++ b/CalendarrWidgets/CalendarrWidgets.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.security.application-groups
+
+ group.com.scarriffleservices.calendarr
+
+
+
diff --git a/CalendarrWidgets/CalendarrWidgets.swift b/CalendarrWidgets/CalendarrWidgets.swift
new file mode 100644
index 0000000..9511c9e
--- /dev/null
+++ b/CalendarrWidgets/CalendarrWidgets.swift
@@ -0,0 +1,141 @@
+import WidgetKit
+import SwiftUI
+
+@main
+struct CalendarrWidgetBundle: WidgetBundle {
+ var body: some Widget {
+ TodayWidget()
+ TwoDaysWidget()
+ ThreeDaysWidget()
+ ThisWeekWidget()
+ TwoWeeksWidget()
+ UpcomingWidget()
+ UpNextWidget()
+ }
+}
+
+// Shared chrome modifier — keeps every widget on the same theme.
+private struct CalendarrWidgetChrome: ViewModifier {
+ let snapshot: WidgetSnapshot?
+
+ func body(content: Content) -> some View {
+ let lang = snapshot?.language ?? "system"
+ content
+ .containerBackground(for: .widget) {
+ Color(widgetHex: snapshot?.backgroundColorHex ?? "#000000")
+ }
+ .foregroundStyle(Color(widgetHex: snapshot?.textColorHex ?? "#FFFFFF"))
+ .environment(\.locale, WidgetL10n.locale(lang))
+ }
+}
+
+private extension View {
+ func calendarrChrome(_ snapshot: WidgetSnapshot?) -> some View {
+ modifier(CalendarrWidgetChrome(snapshot: snapshot))
+ }
+}
+
+// MARK: – Today (small)
+
+struct TodayWidget: Widget {
+ let kind: String = "TodayWidget"
+
+ var body: some WidgetConfiguration {
+ StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
+ TodayWidgetView(entry: entry).calendarrChrome(entry.snapshot)
+ }
+ .configurationDisplayName(WidgetL10n.t("widget.display.today_title", "system"))
+ .description(WidgetL10n.t("widget.display.today_desc", "system"))
+ .supportedFamilies([.systemSmall])
+ }
+}
+
+// MARK: – Today & Tomorrow (medium)
+
+struct TwoDaysWidget: Widget {
+ let kind: String = "TwoDaysWidget"
+
+ var body: some WidgetConfiguration {
+ StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
+ TwoDaysWidgetView(entry: entry).calendarrChrome(entry.snapshot)
+ }
+ .configurationDisplayName(WidgetL10n.t("widget.display.days_title", "system"))
+ .description(WidgetL10n.t("widget.display.days_desc", "system"))
+ .supportedFamilies([.systemMedium])
+ }
+}
+
+// MARK: – Three Days (medium)
+
+struct ThreeDaysWidget: Widget {
+ let kind: String = "ThreeDaysWidget"
+
+ var body: some WidgetConfiguration {
+ StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
+ ThreeDaysWidgetView(entry: entry).calendarrChrome(entry.snapshot)
+ }
+ .configurationDisplayName(WidgetL10n.t("widget.display.threedays_title", "system"))
+ .description(WidgetL10n.t("widget.display.threedays_desc", "system"))
+ .supportedFamilies([.systemMedium])
+ }
+}
+
+// MARK: – This Week (medium)
+
+struct ThisWeekWidget: Widget {
+ let kind: String = "ThisWeekWidget"
+
+ var body: some WidgetConfiguration {
+ StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
+ ThisWeekWidgetView(entry: entry).calendarrChrome(entry.snapshot)
+ }
+ .configurationDisplayName(WidgetL10n.t("widget.display.thisweek_title", "system"))
+ .description(WidgetL10n.t("widget.display.thisweek_desc", "system"))
+ .supportedFamilies([.systemMedium])
+ }
+}
+
+// MARK: – Two Weeks (medium)
+
+struct TwoWeeksWidget: Widget {
+ let kind: String = "TwoWeeksWidget"
+
+ var body: some WidgetConfiguration {
+ StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
+ TwoWeeksWidgetView(entry: entry).calendarrChrome(entry.snapshot)
+ }
+ .configurationDisplayName(WidgetL10n.t("widget.display.twoweeks_title", "system"))
+ .description(WidgetL10n.t("widget.display.twoweeks_desc", "system"))
+ .supportedFamilies([.systemMedium])
+ }
+}
+
+// MARK: – Upcoming (large + extra large on iPad)
+
+struct UpcomingWidget: Widget {
+ let kind: String = "UpcomingWidget"
+
+ var body: some WidgetConfiguration {
+ StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
+ UpcomingWidgetView(entry: entry).calendarrChrome(entry.snapshot)
+ }
+ .configurationDisplayName(WidgetL10n.t("widget.display.upcoming_title", "system"))
+ .description(WidgetL10n.t("widget.display.upcoming_desc", "system"))
+ .supportedFamilies([.systemLarge, .systemExtraLarge])
+ }
+}
+
+// MARK: – Up Next + Calendar (medium)
+
+struct UpNextWidget: Widget {
+ let kind: String = "UpNextWidget"
+
+ var body: some WidgetConfiguration {
+ StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
+ UpNextWidgetView(entry: entry).calendarrChrome(entry.snapshot)
+ }
+ .configurationDisplayName(WidgetL10n.t("widget.display.upnext_title", "system"))
+ .description(WidgetL10n.t("widget.display.upnext_desc", "system"))
+ .supportedFamilies([.systemMedium])
+ }
+}
diff --git a/CalendarrWidgets/Info.plist b/CalendarrWidgets/Info.plist
new file mode 100644
index 0000000..ec28a2e
--- /dev/null
+++ b/CalendarrWidgets/Info.plist
@@ -0,0 +1,29 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ Calendarr Widgets
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ $(PRODUCT_BUNDLE_PACKAGE_TYPE)
+ CFBundleShortVersionString
+ $(MARKETING_VERSION)
+ CFBundleVersion
+ $(CURRENT_PROJECT_VERSION)
+ NSExtension
+
+ NSExtensionPointIdentifier
+ com.apple.widgetkit-extension
+
+
+
diff --git a/CalendarrWidgets/ThisWeekWidgetView.swift b/CalendarrWidgets/ThisWeekWidgetView.swift
new file mode 100644
index 0000000..e547ade
--- /dev/null
+++ b/CalendarrWidgets/ThisWeekWidgetView.swift
@@ -0,0 +1,117 @@
+import SwiftUI
+import WidgetKit
+
+struct ThisWeekWidgetView: View {
+ let entry: CalendarrEntry
+
+ private var snapshot: WidgetSnapshot? { entry.snapshot }
+ private var lang: String { snapshot?.language ?? "system" }
+
+ private var cal: Calendar {
+ var c = Calendar(identifier: .gregorian)
+ c.locale = WidgetL10n.locale(lang)
+ c.firstWeekday = 2 // Monday
+ return c
+ }
+
+ private var weekStart: Date {
+ cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: entry.date)) ?? entry.date
+ }
+
+ private var weekDays: [Date] {
+ (0..<7).compactMap { cal.date(byAdding: .day, value: $0, to: weekStart) }
+ }
+
+ private var monthHeader: String {
+ let f = DateFormatter()
+ f.locale = WidgetL10n.locale(lang)
+ f.dateFormat = "LLLL yyyy"
+ return f.string(from: weekStart).uppercased()
+ }
+
+ private var weekdayHeaders: [String] {
+ let f = DateFormatter(); f.locale = WidgetL10n.locale(lang)
+ let symbols = f.shortWeekdaySymbols ?? cal.shortWeekdaySymbols
+ let start = cal.firstWeekday - 1
+ return (0..<7).map { String(symbols[(start + $0) % 7].prefix(2)).uppercased() }
+ }
+
+ var body: some View {
+ if let s = snapshot {
+ let primary = Color(widgetHex: s.primaryColorHex)
+ let accent = Color(widgetHex: s.accentColorHex)
+ VStack(alignment: .leading, spacing: 2) {
+ HStack(alignment: .firstTextBaseline, spacing: 4) {
+ Text(monthHeader.split(separator: " ").first.map(String.init) ?? "")
+ .font(.system(size: 10, weight: .bold))
+ .foregroundStyle(primary)
+ Text(monthHeader.split(separator: " ").dropFirst().joined(separator: " "))
+ .font(.system(size: 10, weight: .semibold))
+ }
+ GeometryReader { geo in
+ let colW = geo.size.width / 7
+ HStack(spacing: 0) {
+ ForEach(Array(weekDays.enumerated()), id: \.offset) { idx, day in
+ dayColumn(day, snapshot: s, primary: primary, accent: accent)
+ .frame(width: colW)
+ .overlay(alignment: .trailing) {
+ if idx < 6 {
+ Rectangle()
+ .fill(Color(widgetHex: s.lineColorHex).opacity(0.35))
+ .frame(width: 0.5)
+ }
+ }
+ }
+ }
+ }
+ }
+ } else {
+ Text(WidgetL10n.t("widget.no_data", lang))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ }
+
+ private func dayColumn(_ day: Date,
+ snapshot: WidgetSnapshot,
+ primary: Color,
+ accent: Color) -> some View {
+ let isToday = cal.isDateInToday(day)
+ let evs = WidgetHelpers.events(for: day, in: snapshot)
+ let dayIdx = (0..<7).firstIndex { i in cal.isDate(weekDays[i], inSameDayAs: day) } ?? 0
+ return VStack(spacing: 1) {
+ Text(weekdayHeaders[dayIdx])
+ .font(.system(size: 7.5, weight: .bold))
+ .foregroundStyle(isToday ? accent : .secondary)
+ Text("\(cal.component(.day, from: day))")
+ .font(.system(size: 10, weight: isToday ? .bold : .semibold))
+ .foregroundStyle(isToday ? Color.white : Color.primary)
+ .frame(width: 15, height: 15)
+ .background(isToday ? primary : Color.clear)
+ .clipShape(Circle())
+ ForEach(evs.prefix(2)) { ev in
+ eventPill(ev)
+ }
+ if evs.count > 2 {
+ Text("+\(evs.count - 2)")
+ .font(.system(size: 7))
+ .foregroundStyle(accent)
+ }
+ Spacer(minLength: 0)
+ }
+ .padding(.horizontal, 1)
+ }
+
+ private func eventPill(_ ev: WidgetEvent) -> some View {
+ Text(ev.title)
+ .font(.system(size: 7, weight: .medium))
+ .lineLimit(1)
+ .foregroundStyle(.white)
+ .padding(.horizontal, 2)
+ .padding(.vertical, 0.5)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(Color(widgetHex: ev.colorHex))
+ .clipShape(RoundedRectangle(cornerRadius: 1.5))
+ }
+}
diff --git a/CalendarrWidgets/ThreeDaysWidgetView.swift b/CalendarrWidgets/ThreeDaysWidgetView.swift
new file mode 100644
index 0000000..e303794
--- /dev/null
+++ b/CalendarrWidgets/ThreeDaysWidgetView.swift
@@ -0,0 +1,131 @@
+import SwiftUI
+import WidgetKit
+
+struct ThreeDaysWidgetView: View {
+ let entry: CalendarrEntry
+
+ private var snapshot: WidgetSnapshot? { entry.snapshot }
+ private var lang: String { snapshot?.language ?? "system" }
+
+ private var cal: Calendar {
+ var c = Calendar(identifier: .gregorian)
+ c.locale = WidgetL10n.locale(lang)
+ return c
+ }
+
+ private var days: [Date] {
+ let today = cal.startOfDay(for: entry.date)
+ return (0..<3).compactMap { cal.date(byAdding: .day, value: $0, to: today) }
+ }
+
+ private var monthHeader: String {
+ let f = DateFormatter()
+ f.locale = WidgetL10n.locale(lang)
+ f.dateFormat = "LLLL yyyy"
+ return f.string(from: entry.date).uppercased()
+ }
+
+ private var weekdayFmt: DateFormatter {
+ let f = DateFormatter()
+ f.locale = WidgetL10n.locale(lang)
+ f.dateFormat = "EEE"
+ return f
+ }
+
+ private var timeFmt: DateFormatter {
+ let f = DateFormatter()
+ f.locale = WidgetL10n.locale(lang)
+ f.dateFormat = "HH:mm"
+ return f
+ }
+
+ var body: some View {
+ if let s = snapshot {
+ let primary = Color(widgetHex: s.primaryColorHex)
+ let accent = Color(widgetHex: s.accentColorHex)
+ VStack(alignment: .leading, spacing: 3) {
+ HStack(alignment: .firstTextBaseline, spacing: 6) {
+ Text(monthHeader.split(separator: " ").first.map(String.init) ?? "")
+ .font(.system(size: 11, weight: .bold))
+ .foregroundStyle(primary)
+ Text(monthHeader.split(separator: " ").dropFirst().joined(separator: " "))
+ .font(.system(size: 11, weight: .semibold))
+ }
+ HStack(spacing: 0) {
+ ForEach(Array(days.enumerated()), id: \.offset) { idx, day in
+ column(for: day, snapshot: s, primary: primary, accent: accent)
+ .frame(maxWidth: .infinity, alignment: .topLeading)
+ .overlay(alignment: .trailing) {
+ if idx < 2 {
+ Rectangle()
+ .fill(Color(widgetHex: s.lineColorHex).opacity(0.4))
+ .frame(width: 0.5)
+ }
+ }
+ }
+ }
+ }
+ } else {
+ Text(WidgetL10n.t("widget.no_data", lang))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ }
+
+ private func column(for day: Date, snapshot: WidgetSnapshot, primary: Color, accent: Color) -> some View {
+ let isToday = cal.isDateInToday(day)
+ let evs = WidgetHelpers.events(for: day, in: snapshot)
+ return VStack(alignment: .leading, spacing: 2) {
+ HStack {
+ Text(weekdayFmt.string(from: day).uppercased() + ".")
+ .font(.system(size: 9, weight: .bold))
+ .foregroundStyle(isToday ? accent : .secondary)
+ Spacer()
+ Text("\(cal.component(.day, from: day))")
+ .font(.system(size: 11, weight: isToday ? .bold : .semibold))
+ .foregroundStyle(isToday ? Color.white : Color.primary)
+ .frame(width: 17, height: 17)
+ .background(isToday ? primary : Color.clear)
+ .clipShape(Circle())
+ }
+ .padding(.horizontal, 3)
+ if evs.isEmpty {
+ Text(WidgetL10n.t("widget.no_events", lang))
+ .font(.system(size: 9))
+ .foregroundStyle(.tertiary)
+ .padding(.horizontal, 3)
+ } else {
+ ForEach(evs.prefix(4)) { ev in
+ eventRow(ev)
+ }
+ if evs.count > 4 {
+ Text("+\(evs.count - 4)")
+ .font(.system(size: 8))
+ .foregroundStyle(accent)
+ .padding(.leading, 3)
+ }
+ }
+ Spacer(minLength: 0)
+ }
+ }
+
+ private func eventRow(_ ev: WidgetEvent) -> some View {
+ VStack(alignment: .leading, spacing: 0) {
+ HStack(spacing: 3) {
+ RoundedRectangle(cornerRadius: 1)
+ .fill(Color(widgetHex: ev.colorHex))
+ .frame(width: 2)
+ Text(ev.title)
+ .font(.system(size: 9, weight: .medium))
+ .lineLimit(1)
+ Spacer(minLength: 0)
+ }
+ Text(ev.isAllDay ? WidgetL10n.t("widget.allday", lang) : timeFmt.string(from: ev.start))
+ .font(.system(size: 8))
+ .foregroundStyle(.secondary)
+ .padding(.leading, 5)
+ }
+ .padding(.horizontal, 3)
+ }
+}
diff --git a/CalendarrWidgets/TodayWidgetView.swift b/CalendarrWidgets/TodayWidgetView.swift
new file mode 100644
index 0000000..2caf11b
--- /dev/null
+++ b/CalendarrWidgets/TodayWidgetView.swift
@@ -0,0 +1,84 @@
+import SwiftUI
+import WidgetKit
+
+struct TodayWidgetView: View {
+ let entry: CalendarrEntry
+
+ private var snapshot: WidgetSnapshot? { entry.snapshot }
+
+ private var todayEvents: [WidgetEvent] {
+ guard let s = snapshot else { return [] }
+ return WidgetHelpers.events(for: entry.date, in: s)
+ }
+
+ private var lang: String { snapshot?.language ?? "system" }
+
+ private var timeFmt: DateFormatter {
+ let f = DateFormatter()
+ f.locale = WidgetL10n.locale(lang)
+ f.dateFormat = "HH:mm"
+ return f
+ }
+
+ var body: some View {
+ let primary = Color(widgetHex: snapshot?.primaryColorHex ?? "#4285f4")
+ let accent = Color(widgetHex: snapshot?.accentColorHex ?? "#ea4335")
+ VStack(alignment: .leading, spacing: 4) {
+ HStack {
+ Text(WidgetL10n.t("widget.today", lang))
+ .font(.caption.weight(.bold))
+ .foregroundStyle(primary)
+ Spacer()
+ Text(headerDate)
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ }
+
+ if snapshot == nil {
+ Text(WidgetL10n.t("widget.no_data", lang))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ } else if todayEvents.isEmpty {
+ Spacer()
+ Text(WidgetL10n.t("widget.no_events", lang))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ Spacer()
+ } else {
+ ForEach(todayEvents.prefix(3)) { ev in
+ eventRow(ev)
+ }
+ if todayEvents.count > 3 {
+ Text(String(format: WidgetL10n.t("widget.more", lang), todayEvents.count - 3))
+ .font(.caption2)
+ .foregroundStyle(accent)
+ }
+ Spacer(minLength: 0)
+ }
+ }
+ }
+
+ private var headerDate: String {
+ let f = DateFormatter()
+ f.locale = WidgetL10n.locale(lang)
+ f.dateFormat = "d. MMM"
+ return f.string(from: entry.date)
+ }
+
+ private func eventRow(_ ev: WidgetEvent) -> some View {
+ HStack(spacing: 6) {
+ RoundedRectangle(cornerRadius: 2)
+ .fill(Color(widgetHex: ev.colorHex))
+ .frame(width: 3)
+ VStack(alignment: .leading, spacing: 1) {
+ Text(ev.title)
+ .font(.caption.weight(.medium))
+ .lineLimit(1)
+ Text(ev.isAllDay ? WidgetL10n.t("widget.allday", lang) : timeFmt.string(from: ev.start))
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ }
+ Spacer(minLength: 0)
+ }
+ }
+}
diff --git a/CalendarrWidgets/TwoDaysWidgetView.swift b/CalendarrWidgets/TwoDaysWidgetView.swift
new file mode 100644
index 0000000..70a258a
--- /dev/null
+++ b/CalendarrWidgets/TwoDaysWidgetView.swift
@@ -0,0 +1,109 @@
+import SwiftUI
+import WidgetKit
+
+struct TwoDaysWidgetView: View {
+ let entry: CalendarrEntry
+
+ private var snapshot: WidgetSnapshot? { entry.snapshot }
+ private var lang: String { snapshot?.language ?? "system" }
+
+ private var today: Date { Calendar.current.startOfDay(for: entry.date) }
+ private var tomorrow: Date {
+ Calendar.current.date(byAdding: .day, value: 1, to: today) ?? today
+ }
+
+ private var timeFmt: DateFormatter {
+ let f = DateFormatter()
+ f.locale = WidgetL10n.locale(lang)
+ f.dateFormat = "HH:mm"
+ return f
+ }
+
+ var body: some View {
+ if let s = snapshot {
+ let primary = Color(widgetHex: s.primaryColorHex)
+ let accent = Color(widgetHex: s.accentColorHex)
+ HStack(spacing: 8) {
+ column(for: today,
+ title: WidgetL10n.t("widget.today", lang),
+ isToday: true,
+ events: WidgetHelpers.events(for: today, in: s),
+ primary: primary, accent: accent,
+ lineColor: Color(widgetHex: s.lineColorHex))
+ Rectangle()
+ .fill(Color(widgetHex: s.lineColorHex).opacity(0.4))
+ .frame(width: 0.5)
+ column(for: tomorrow,
+ title: WidgetL10n.t("widget.tomorrow", lang),
+ isToday: false,
+ events: WidgetHelpers.events(for: tomorrow, in: s),
+ primary: primary, accent: accent,
+ lineColor: Color(widgetHex: s.lineColorHex))
+ }
+ } else {
+ Text(WidgetL10n.t("widget.no_data", lang))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ }
+
+ private func column(for day: Date,
+ title: String,
+ isToday: Bool,
+ events: [WidgetEvent],
+ primary: Color,
+ accent: Color,
+ lineColor: Color) -> some View {
+ VStack(alignment: .leading, spacing: 4) {
+ HStack(alignment: .firstTextBaseline, spacing: 4) {
+ Text(title)
+ .font(.caption.weight(.bold))
+ .foregroundStyle(isToday ? primary : accent)
+ Text(shortDate(day))
+ .font(.caption2)
+ .foregroundStyle(.tertiary)
+ }
+ if events.isEmpty {
+ Text(WidgetL10n.t("widget.no_events", lang))
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ } else {
+ ForEach(events.prefix(4)) { ev in
+ eventRow(ev)
+ }
+ if events.count > 4 {
+ Text(String(format: WidgetL10n.t("widget.more", lang), events.count - 4))
+ .font(.system(size: 9))
+ .foregroundStyle(accent)
+ }
+ }
+ Spacer(minLength: 0)
+ }
+ .frame(maxWidth: .infinity, alignment: .topLeading)
+ }
+
+ private func eventRow(_ ev: WidgetEvent) -> some View {
+ HStack(spacing: 4) {
+ RoundedRectangle(cornerRadius: 1.5)
+ .fill(Color(widgetHex: ev.colorHex))
+ .frame(width: 2)
+ VStack(alignment: .leading, spacing: 0) {
+ Text(ev.title)
+ .font(.system(size: 11, weight: .medium))
+ .lineLimit(1)
+ Text(ev.isAllDay ? WidgetL10n.t("widget.allday", lang) : timeFmt.string(from: ev.start))
+ .font(.system(size: 9))
+ .foregroundStyle(.secondary)
+ }
+ Spacer(minLength: 0)
+ }
+ }
+
+ private func shortDate(_ d: Date) -> String {
+ let f = DateFormatter()
+ f.locale = WidgetL10n.locale(lang)
+ f.dateFormat = "d. MMM"
+ return f.string(from: d)
+ }
+}
diff --git a/CalendarrWidgets/TwoWeeksWidgetView.swift b/CalendarrWidgets/TwoWeeksWidgetView.swift
new file mode 100644
index 0000000..6d18738
--- /dev/null
+++ b/CalendarrWidgets/TwoWeeksWidgetView.swift
@@ -0,0 +1,132 @@
+import SwiftUI
+import WidgetKit
+
+struct TwoWeeksWidgetView: View {
+ let entry: CalendarrEntry
+
+ private var snapshot: WidgetSnapshot? { entry.snapshot }
+ private var lang: String { snapshot?.language ?? "system" }
+
+ private var cal: Calendar {
+ var c = Calendar(identifier: .gregorian)
+ c.locale = WidgetL10n.locale(lang)
+ c.firstWeekday = 2
+ return c
+ }
+
+ private var weekStart: Date {
+ cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: entry.date)) ?? entry.date
+ }
+
+ private var fortnight: [Date] {
+ (0..<14).compactMap { cal.date(byAdding: .day, value: $0, to: weekStart) }
+ }
+
+ private var monthHeader: String {
+ let f = DateFormatter()
+ f.locale = WidgetL10n.locale(lang)
+ f.dateFormat = "LLLL yyyy"
+ return f.string(from: weekStart).uppercased()
+ }
+
+ private var weekdayHeaders: [String] {
+ let f = DateFormatter(); f.locale = WidgetL10n.locale(lang)
+ let symbols = f.veryShortWeekdaySymbols ?? cal.veryShortWeekdaySymbols
+ let start = cal.firstWeekday - 1
+ return (0..<7).map { symbols[(start + $0) % 7] }
+ }
+
+ var body: some View {
+ if let s = snapshot {
+ let primary = Color(widgetHex: s.primaryColorHex)
+ let accent = Color(widgetHex: s.accentColorHex)
+ VStack(alignment: .leading, spacing: 2) {
+ HStack(alignment: .firstTextBaseline, spacing: 4) {
+ Text(monthHeader.split(separator: " ").first.map(String.init) ?? "")
+ .font(.system(size: 9, weight: .bold))
+ .foregroundStyle(primary)
+ Text(monthHeader.split(separator: " ").dropFirst().joined(separator: " "))
+ .font(.system(size: 9, weight: .semibold))
+ }
+ weekdayRow(accent: accent)
+ GeometryReader { geo in
+ let colW = geo.size.width / 7
+ let rowH = geo.size.height / 2
+ VStack(spacing: 0) {
+ ForEach(0..<2, id: \.self) { row in
+ HStack(spacing: 0) {
+ ForEach(0..<7, id: \.self) { col in
+ let day = fortnight[row * 7 + col]
+ dayCell(day, snapshot: s, primary: primary, accent: accent)
+ .frame(width: colW, height: rowH)
+ .overlay(alignment: .trailing) {
+ if col < 6 {
+ Rectangle()
+ .fill(Color(widgetHex: s.lineColorHex).opacity(0.35))
+ .frame(width: 0.5)
+ }
+ }
+ .overlay(alignment: .top) {
+ if row == 1 {
+ Rectangle()
+ .fill(Color(widgetHex: s.lineColorHex).opacity(0.35))
+ .frame(height: 0.5)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ } else {
+ Text(WidgetL10n.t("widget.no_data", lang))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ }
+
+ private func weekdayRow(accent: Color) -> some View {
+ HStack(spacing: 0) {
+ ForEach(weekdayHeaders, id: \.self) { h in
+ Text(h)
+ .font(.system(size: 7, weight: .bold))
+ .foregroundStyle(.secondary)
+ .frame(maxWidth: .infinity)
+ }
+ }
+ }
+
+ private func dayCell(_ day: Date,
+ snapshot: WidgetSnapshot,
+ primary: Color,
+ accent: Color) -> some View {
+ let isToday = cal.isDateInToday(day)
+ let evs = WidgetHelpers.events(for: day, in: snapshot)
+ return VStack(alignment: .center, spacing: 0.5) {
+ Text("\(cal.component(.day, from: day))")
+ .font(.system(size: 8.5, weight: isToday ? .bold : .semibold))
+ .foregroundStyle(isToday ? Color.white : Color.primary)
+ .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)
+ }
+ }
+ .frame(height: 3)
+ if evs.count > 3 {
+ Text("+\(evs.count - 3)")
+ .font(.system(size: 6))
+ .foregroundStyle(accent)
+ }
+ Spacer(minLength: 0)
+ }
+ .padding(.top, 1)
+ }
+}
diff --git a/CalendarrWidgets/UpNextWidgetView.swift b/CalendarrWidgets/UpNextWidgetView.swift
new file mode 100644
index 0000000..a0bed0c
--- /dev/null
+++ b/CalendarrWidgets/UpNextWidgetView.swift
@@ -0,0 +1,173 @@
+import SwiftUI
+import WidgetKit
+
+struct UpNextWidgetView: View {
+ let entry: CalendarrEntry
+
+ private var snapshot: WidgetSnapshot? { entry.snapshot }
+ private var lang: String { snapshot?.language ?? "system" }
+
+ private var cal: Calendar {
+ var c = Calendar(identifier: .gregorian)
+ c.locale = WidgetL10n.locale(lang)
+ c.firstWeekday = 2
+ return c
+ }
+
+ private var todayEvents: [WidgetEvent] {
+ guard let s = snapshot else { return [] }
+ return WidgetHelpers.events(for: entry.date, in: s)
+ }
+
+ /// Mini-month grid: 6 rows × 7 cols starting from the first weekday of the
+ /// month, padded with neighbouring days where necessary.
+ private var monthGrid: [Date] {
+ let firstOfMonth = cal.date(from: cal.dateComponents([.year, .month], from: entry.date)) ?? entry.date
+ let weekday = cal.component(.weekday, from: firstOfMonth)
+ let offset = ((weekday - cal.firstWeekday) + 7) % 7
+ let gridStart = cal.date(byAdding: .day, value: -offset, to: firstOfMonth) ?? firstOfMonth
+ return (0..<42).compactMap { cal.date(byAdding: .day, value: $0, to: gridStart) }
+ }
+
+ private var weekdayHeaders: [String] {
+ let f = DateFormatter(); f.locale = WidgetL10n.locale(lang)
+ let symbols = f.veryShortWeekdaySymbols ?? cal.veryShortWeekdaySymbols
+ let start = cal.firstWeekday - 1
+ return (0..<7).map { symbols[(start + $0) % 7] }
+ }
+
+ private var weekdayFmt: DateFormatter {
+ let f = DateFormatter()
+ f.locale = WidgetL10n.locale(lang)
+ f.dateFormat = "EEE"
+ return f
+ }
+
+ private var monthNameFmt: DateFormatter {
+ let f = DateFormatter()
+ f.locale = WidgetL10n.locale(lang)
+ f.dateFormat = "LLL"
+ return f
+ }
+
+ private var timeFmt: DateFormatter {
+ let f = DateFormatter()
+ f.locale = WidgetL10n.locale(lang)
+ f.dateFormat = "HH:mm"
+ return f
+ }
+
+ var body: some View {
+ if let s = snapshot {
+ let primary = Color(widgetHex: s.primaryColorHex)
+ let accent = Color(widgetHex: s.accentColorHex)
+ HStack(spacing: 8) {
+ leftPanel(snapshot: s, primary: primary, accent: accent)
+ .frame(maxWidth: .infinity, alignment: .topLeading)
+ miniMonth(snapshot: s, primary: primary, accent: accent)
+ .frame(maxWidth: .infinity, alignment: .topLeading)
+ }
+ } else {
+ Text(WidgetL10n.t("widget.no_data", lang))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ }
+
+ private func leftPanel(snapshot: WidgetSnapshot, primary: Color, accent: Color) -> some View {
+ VStack(alignment: .leading, spacing: 4) {
+ HStack(alignment: .firstTextBaseline, spacing: 6) {
+ Text("\(cal.component(.day, from: entry.date))")
+ .font(.system(size: 17, weight: .bold))
+ .foregroundStyle(Color.white)
+ .frame(width: 26, height: 26)
+ .background(primary)
+ .clipShape(RoundedRectangle(cornerRadius: 5))
+ VStack(alignment: .leading, spacing: 0) {
+ Text(weekdayFmt.string(from: entry.date).uppercased() + ".")
+ .font(.system(size: 11, weight: .bold))
+ .foregroundStyle(accent)
+ Text(monthNameFmt.string(from: entry.date))
+ .font(.system(size: 11, weight: .medium))
+ .foregroundStyle(.secondary)
+ }
+ }
+ if todayEvents.isEmpty {
+ Text(WidgetL10n.t("widget.no_events", lang))
+ .font(.system(size: 10))
+ .foregroundStyle(.secondary)
+ } else {
+ ForEach(todayEvents.prefix(3)) { ev in
+ HStack(alignment: .top, spacing: 4) {
+ Circle()
+ .fill(Color(widgetHex: ev.colorHex))
+ .frame(width: 5, height: 5)
+ .padding(.top, 4)
+ VStack(alignment: .leading, spacing: 0) {
+ Text(ev.title)
+ .font(.system(size: 10, weight: .semibold))
+ .lineLimit(1)
+ Text(ev.isAllDay ? WidgetL10n.t("widget.allday", lang) : timeFmt.string(from: ev.start))
+ .font(.system(size: 9))
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ }
+ Spacer(minLength: 0)
+ }
+ }
+
+ private func miniMonth(snapshot: WidgetSnapshot, primary: Color, accent: Color) -> some View {
+ VStack(spacing: 1) {
+ HStack(spacing: 0) {
+ ForEach(weekdayHeaders, id: \.self) { h in
+ Text(h)
+ .font(.system(size: 8, weight: .bold))
+ .foregroundStyle(.secondary)
+ .frame(maxWidth: .infinity)
+ }
+ }
+ GeometryReader { geo in
+ let cellW = geo.size.width / 7
+ let cellH = geo.size.height / 6
+ VStack(spacing: 0) {
+ ForEach(0..<6, id: \.self) { row in
+ HStack(spacing: 0) {
+ ForEach(0..<7, id: \.self) { col in
+ miniDay(monthGrid[row * 7 + col],
+ snapshot: snapshot,
+ primary: primary,
+ accent: accent)
+ .frame(width: cellW, height: cellH)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private func miniDay(_ day: Date, snapshot: WidgetSnapshot, primary: Color, accent: Color) -> some View {
+ let isToday = cal.isDateInToday(day)
+ let inMonth = cal.isDate(day, equalTo: entry.date, toGranularity: .month)
+ let hasEvents = !WidgetHelpers.events(for: day, in: snapshot).isEmpty
+ return ZStack {
+ if isToday {
+ RoundedRectangle(cornerRadius: 3)
+ .fill(primary)
+ } else if hasEvents && inMonth {
+ RoundedRectangle(cornerRadius: 3)
+ .fill(accent.opacity(0.20))
+ }
+ Text("\(cal.component(.day, from: day))")
+ .font(.system(size: 9, weight: isToday ? .bold : .medium))
+ .foregroundStyle(
+ isToday ? Color.white :
+ inMonth ? Color.primary : Color.secondary.opacity(0.4)
+ )
+ }
+ .padding(0.5)
+ }
+}
diff --git a/CalendarrWidgets/UpcomingWidgetView.swift b/CalendarrWidgets/UpcomingWidgetView.swift
new file mode 100644
index 0000000..a48756c
--- /dev/null
+++ b/CalendarrWidgets/UpcomingWidgetView.swift
@@ -0,0 +1,130 @@
+import SwiftUI
+import WidgetKit
+
+private let rowHeight: CGFloat = 16
+private let dayHeaderHeight: CGFloat = 14
+private let maxEventsPerDay: Int = 3
+private let maxTotalRows: Int = 15
+
+struct UpcomingWidgetView: View {
+ let entry: CalendarrEntry
+
+ private var snapshot: WidgetSnapshot? { entry.snapshot }
+ private var lang: String { snapshot?.language ?? "system" }
+
+ private var groupedWithLimits: [(Date, [WidgetEvent], Int)] {
+ guard let s = snapshot else { return [] }
+ let cal = Calendar.current
+ let now = entry.date
+ let events = WidgetHelpers.upcoming(from: now, daysAhead: 5, in: s)
+ var buckets: [Date: [WidgetEvent]] = [:]
+ for ev in events {
+ let key = cal.startOfDay(for: ev.start)
+ buckets[key, default: []].append(ev)
+ }
+
+ var result: [(Date, [WidgetEvent], Int)] = []
+ var totalRows = 0
+
+ for date in buckets.keys.sorted() {
+ let allEventsForDay = buckets[date] ?? []
+ let eventsToShow = Array(allEventsForDay.prefix(maxEventsPerDay))
+ let hiddenCount = allEventsForDay.count - eventsToShow.count
+
+ // Account for day header + event rows + potential "more" row
+ let rowsForThisDay = 1 + eventsToShow.count + (hiddenCount > 0 ? 1 : 0)
+
+ if totalRows + rowsForThisDay <= maxTotalRows {
+ result.append((date, eventsToShow, hiddenCount))
+ totalRows += rowsForThisDay
+ } else {
+ break
+ }
+ }
+
+ return result
+ }
+
+ private var timeFmt: DateFormatter {
+ let f = DateFormatter()
+ f.locale = WidgetL10n.locale(lang)
+ f.dateFormat = "HH:mm"
+ return f
+ }
+
+ private var dayFmt: DateFormatter {
+ let f = DateFormatter()
+ f.locale = WidgetL10n.locale(lang)
+ f.dateFormat = "EEE d. MMM"
+ return f
+ }
+
+ var body: some View {
+ let primary = Color(widgetHex: snapshot?.primaryColorHex ?? "#4285f4")
+ let accent = Color(widgetHex: snapshot?.accentColorHex ?? "#ea4335")
+ VStack(alignment: .leading, spacing: 2) {
+ Text(WidgetL10n.t("widget.upcoming", lang))
+ .font(.caption.weight(.bold))
+ .foregroundStyle(primary)
+ .padding(.bottom, 2)
+ if snapshot == nil {
+ Text(WidgetL10n.t("widget.no_data", lang))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ } else if groupedWithLimits.isEmpty {
+ Text(WidgetL10n.t("widget.no_events", lang))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ } else {
+ VStack(alignment: .leading, spacing: 2) {
+ ForEach(groupedWithLimits, id: \.0) { day, evs, hiddenCount in
+ dayHeader(d: day, accent: accent)
+ ForEach(evs) { ev in
+ eventRow(ev)
+ }
+ if hiddenCount > 0 {
+ moreRow(count: hiddenCount, accent: accent)
+ }
+ }
+ }
+ Spacer(minLength: 0)
+ }
+ }
+ }
+
+ private func dayHeader(d: Date, accent: Color) -> some View {
+ let cal = Calendar.current
+ let isToday = cal.isDateInToday(d)
+ return Text(dayFmt.string(from: d))
+ .font(.system(size: 11, weight: .semibold))
+ .foregroundStyle(isToday ? accent : .secondary)
+ .frame(height: dayHeaderHeight, alignment: .bottomLeading)
+ .padding(.top, 1)
+ }
+
+ private func eventRow(_ ev: WidgetEvent) -> some View {
+ HStack(spacing: 6) {
+ RoundedRectangle(cornerRadius: 1.5)
+ .fill(Color(widgetHex: ev.colorHex))
+ .frame(width: 2.5)
+ Text(ev.isAllDay ? WidgetL10n.t("widget.allday", lang) : timeFmt.string(from: ev.start))
+ .font(.system(size: 10))
+ .foregroundStyle(.secondary)
+ .frame(width: 38, alignment: .leading)
+ Text(ev.title)
+ .font(.system(size: 10, weight: .medium))
+ .lineLimit(1)
+ Spacer(minLength: 0)
+ }
+ .frame(height: rowHeight)
+ }
+
+ private func moreRow(count: Int, accent: Color) -> some View {
+ Text(String(format: WidgetL10n.t("widget.more", lang), count))
+ .font(.system(size: 9, weight: .semibold))
+ .foregroundStyle(accent)
+ .frame(height: rowHeight)
+ }
+}
diff --git a/CalendarrWidgets/WidgetSupport.swift b/CalendarrWidgets/WidgetSupport.swift
new file mode 100644
index 0000000..56c1aa4
--- /dev/null
+++ b/CalendarrWidgets/WidgetSupport.swift
@@ -0,0 +1,90 @@
+import SwiftUI
+
+// Local copy of the Color(hex:) initializer, since the widget extension
+// is a separate target and cannot import the main app's Color extension.
+extension Color {
+ init(widgetHex hex: String) {
+ let cleaned = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
+ var int: UInt64 = 0
+ Scanner(string: cleaned).scanHexInt64(&int)
+ let r, g, b: UInt64
+ switch cleaned.count {
+ case 6:
+ (r, g, b) = ((int >> 16) & 0xFF, (int >> 8) & 0xFF, int & 0xFF)
+ default:
+ (r, g, b) = (0, 0, 0)
+ }
+ self.init(red: Double(r) / 255, green: Double(g) / 255, blue: Double(b) / 255)
+ }
+}
+
+enum WidgetL10n {
+ static func t(_ key: String, _ stored: String) -> String {
+ let lang: String
+ if stored == "de" || stored == "en" { lang = stored }
+ else {
+ let pref = Locale.preferredLanguages.first ?? "en"
+ lang = pref.lowercased().hasPrefix("de") ? "de" : "en"
+ }
+ return strings[lang]?[key] ?? strings["en"]?[key] ?? key
+ }
+
+ static func locale(_ stored: String) -> Locale {
+ let lang: String
+ if stored == "de" || stored == "en" { lang = stored }
+ else {
+ let pref = Locale.preferredLanguages.first ?? "en"
+ lang = pref.lowercased().hasPrefix("de") ? "de" : "en"
+ }
+ return Locale(identifier: lang)
+ }
+
+ private static let strings: [String: [String: String]] = [
+ "de": [
+ "widget.today": "Heute",
+ "widget.tomorrow": "Morgen",
+ "widget.no_events": "Keine Termine",
+ "widget.allday": "Ganztägig",
+ "widget.more": "+%d weitere",
+ "widget.upcoming": "Nächste 5 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.thisweek_title": "Diese Woche",
+ "widget.display.thisweek_desc": "Wochenraster mit Terminen.",
+ "widget.display.twoweeks_title": "Zwei Wochen",
+ "widget.display.twoweeks_desc": "Zwei-Wochen-Raster mit Terminen.",
+ "widget.display.threedays_title": "Drei Tage",
+ "widget.display.threedays_desc": "Drei-Tages-Ansicht mit Terminen.",
+ "widget.display.upnext_title": "Up Next + Kalender",
+ "widget.display.upnext_desc": "Nächste Termine mit Monatsübersicht."
+ ],
+ "en": [
+ "widget.today": "Today",
+ "widget.tomorrow": "Tomorrow",
+ "widget.no_events": "No events",
+ "widget.allday": "All-day",
+ "widget.more": "+%d more",
+ "widget.upcoming": "Next 5 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.thisweek_title": "This Week",
+ "widget.display.thisweek_desc": "Week grid with events.",
+ "widget.display.twoweeks_title": "Two Weeks",
+ "widget.display.twoweeks_desc": "Two-week grid with events.",
+ "widget.display.threedays_title": "Three Days",
+ "widget.display.threedays_desc": "Three-day view with events.",
+ "widget.display.upnext_title": "Up Next + Calendar",
+ "widget.display.upnext_desc": "Next events with month overview."
+ ]
+ ]
+}
diff --git a/Shared/WidgetData.swift b/Shared/WidgetData.swift
new file mode 100644
index 0000000..7fba69b
--- /dev/null
+++ b/Shared/WidgetData.swift
@@ -0,0 +1,135 @@
+import Foundation
+#if canImport(WidgetKit)
+import WidgetKit
+#endif
+
+/// App-Group identifier shared between the main app and the widget extension.
+/// IMPORTANT: This must match the App Group capability in BOTH targets
+/// and the App Group ID registered in the Apple Developer Portal.
+let widgetAppGroupID = "group.com.scarriffleservices.calendarr"
+
+/// Lightweight event representation that lives inside the widget cache.
+/// We strip everything the widget doesn't need (notes, calendar IDs, URLs).
+struct WidgetEvent: Codable, Hashable, Identifiable {
+ let id: String
+ let title: String
+ let start: Date
+ let end: Date
+ let isAllDay: Bool
+ let colorHex: String
+ let location: String
+}
+
+/// Snapshot blob the app writes to the App-Group container and the widget reads.
+struct WidgetSnapshot: Codable {
+ let writtenAt: Date
+ let events: [WidgetEvent]
+ /// Mirrors the user's chosen visual settings so the widget looks the same
+ /// as the app even when its own AppStorage in the extension is empty.
+ let todayColorHex: String
+ let textColorHex: String
+ let backgroundColorHex: String
+ let lineColorHex: String
+ let primaryColorHex: String
+ let accentColorHex: String
+ let language: String
+
+ init(writtenAt: Date,
+ events: [WidgetEvent],
+ todayColorHex: String,
+ textColorHex: String,
+ backgroundColorHex: String,
+ lineColorHex: String,
+ primaryColorHex: String,
+ accentColorHex: String,
+ language: String) {
+ self.writtenAt = writtenAt
+ self.events = events
+ self.todayColorHex = todayColorHex
+ self.textColorHex = textColorHex
+ self.backgroundColorHex = backgroundColorHex
+ self.lineColorHex = lineColorHex
+ self.primaryColorHex = primaryColorHex
+ self.accentColorHex = accentColorHex
+ self.language = language
+ }
+
+ /// Custom decoder so older caches without the new colour fields still load.
+ init(from decoder: Decoder) throws {
+ let c = try decoder.container(keyedBy: CodingKeys.self)
+ writtenAt = try c.decode(Date.self, forKey: .writtenAt)
+ events = try c.decode([WidgetEvent].self, forKey: .events)
+ todayColorHex = try c.decode(String.self, forKey: .todayColorHex)
+ textColorHex = try c.decode(String.self, forKey: .textColorHex)
+ backgroundColorHex = try c.decode(String.self, forKey: .backgroundColorHex)
+ lineColorHex = try c.decode(String.self, forKey: .lineColorHex)
+ language = try c.decode(String.self, forKey: .language)
+ primaryColorHex = try c.decodeIfPresent(String.self, forKey: .primaryColorHex) ?? "#4285f4"
+ accentColorHex = try c.decodeIfPresent(String.self, forKey: .accentColorHex) ?? "#ea4335"
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case writtenAt, events, todayColorHex, textColorHex, backgroundColorHex
+ case lineColorHex, primaryColorHex, accentColorHex, language
+ }
+}
+
+enum WidgetStore {
+ private static let cacheFilename = "widget-cache.json"
+
+ private static var containerURL: URL? {
+ FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: widgetAppGroupID)
+ }
+
+ private static var cacheURL: URL? {
+ containerURL?.appendingPathComponent(cacheFilename)
+ }
+
+ /// Called by the app whenever the event cache changes.
+ static func write(_ snapshot: WidgetSnapshot) {
+ guard let url = cacheURL else { return }
+ let encoder = JSONEncoder()
+ encoder.dateEncodingStrategy = .iso8601
+ if let data = try? encoder.encode(snapshot) {
+ try? data.write(to: url, options: .atomic)
+ }
+ }
+
+ /// Called by the widget timeline provider to load the latest snapshot.
+ static func read() -> WidgetSnapshot? {
+ guard let url = cacheURL, let data = try? Data(contentsOf: url) else { return nil }
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ return try? decoder.decode(WidgetSnapshot.self, from: data)
+ }
+
+ /// Rewrite the existing snapshot with the latest colour / language values
+ /// from UserDefaults. Used when the user tweaks an appearance setting and
+ /// we want the widgets to refresh immediately, without needing a new event
+ /// sync. No-op if there's no cached snapshot yet.
+ static func republishAppearanceOnly() {
+ guard let existing = read() else { return }
+ let defaults = UserDefaults.standard
+ let updated = WidgetSnapshot(
+ writtenAt: Date(),
+ events: existing.events,
+ todayColorHex: defaults.string(forKey: "todayColor") ?? existing.todayColorHex,
+ textColorHex: defaults.string(forKey: "textColor") ?? existing.textColorHex,
+ backgroundColorHex: defaults.string(forKey: "backgroundColor") ?? existing.backgroundColorHex,
+ lineColorHex: defaults.string(forKey: "lineColor") ?? existing.lineColorHex,
+ primaryColorHex: defaults.string(forKey: "primaryColor") ?? existing.primaryColorHex,
+ accentColorHex: defaults.string(forKey: "accentColor") ?? existing.accentColorHex,
+ language: defaults.string(forKey: "appLanguage") ?? existing.language
+ )
+ write(updated)
+ WidgetTimelineNotifier.reload()
+ }
+}
+
+enum WidgetTimelineNotifier {
+ static func reload() {
+ #if canImport(WidgetKit)
+ WidgetCenter.shared.reloadAllTimelines()
+ #endif
+ }
+}