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 + } +}