From 498e6b80646684c244828996246d571205ba0522 Mon Sep 17 00:00:00 2001 From: Trey T Date: Mon, 11 May 2026 13:14:58 -0500 Subject: [PATCH] feat(widget): per-residence widget configuration (iOS, gitea#6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users with multiple residences can now pick which one a given home- screen widget shows tasks for. Pinning two widgets — one per house — lets each surface tasks for only that residence; users who keep the configuration untouched continue to see all residences (the previous default), so single-home users see no behavioural change. Implementation (iOS only — Android Glance follow-up is scoped in the issue): * `ConfigurationAppIntent` (HoneyDue widget extension) gains an optional `@Parameter` of type `WidgetResidenceEntity`. `AppIntents` renders it as a residence picker in the widget edit sheet. * `WidgetResidenceEntity` + `WidgetResidenceEntityQuery` resolve the user's residences from a new `widget_residences.json` sidecar in the App Group container (avoids a network call at config time). * `WidgetDataManager.saveResidences(from:)` writes that sidecar from the main app whenever `DataManagerObservable.myResidences` updates. Logout clears it along with the rest of the widget cache. * `WidgetDataManager.WidgetTask` + the widget extension's `CacheManager.CustomTask` both gain an optional `residence_id` field. Optional so older app builds that wrote pre-#6 widget cache continue to decode — those tasks pass through the filter for unscoped widgets and are hidden from scoped ones (safer than guessing). * `CacheManager.getUpcomingTasks(forResidenceId:)` and the pure helper `WidgetDataManager.filterTasks(_:forResidenceId:)` apply the filter. `Provider.timeline` / `snapshot` read `configuration.residence?.intId` and pass it through. Tests: new `WidgetResidenceFilterTests` (HoneyDueTests target, 5 cases) cover nil-passthrough, matching-id, no-match, missing-residence on a task, and order preservation. All five green. No Android changes in this commit — Glance widgets need a separate configuration activity and an actionStartActivity wiring that's non-trivial; tracking as a follow-up in the same issue. Co-Authored-By: Claude Opus 4.7 (1M context) --- iosApp/HoneyDue/AppIntent.swift | 72 ++++++++- iosApp/HoneyDue/HoneyDue.swift | 86 ++++++++++- .../WidgetResidenceFilterTests.swift | 90 +++++++++++ .../iosApp/Data/DataManagerObservable.swift | 6 +- iosApp/iosApp/Helpers/WidgetDataManager.swift | 145 +++++++++++++++++- 5 files changed, 390 insertions(+), 9 deletions(-) create mode 100644 iosApp/HoneyDueTests/WidgetResidenceFilterTests.swift diff --git a/iosApp/HoneyDue/AppIntent.swift b/iosApp/HoneyDue/AppIntent.swift index 10796d4..c83cac5 100644 --- a/iosApp/HoneyDue/AppIntent.swift +++ b/iosApp/HoneyDue/AppIntent.swift @@ -10,9 +10,79 @@ import AppIntents import Foundation // MARK: - Widget Configuration Intent + +/// Per-instance widget configuration. The `residence` parameter (added +/// for gitea#6) lets users with multiple residences pick which one a +/// given widget tile shows tasks for. When unset the widget continues +/// to display tasks across every residence — that's the single-home +/// default and matches pre-#6 behaviour for users who only have one +/// property. struct ConfigurationAppIntent: WidgetConfigurationIntent { static var title: LocalizedStringResource { "honeyDue Configuration" } - static var description: IntentDescription { "Configure your honeyDue widget" } + static var description: IntentDescription { + IntentDescription("Pick which residence this widget shows tasks for.") + } + + @Parameter(title: "Residence") + var residence: WidgetResidenceEntity? +} + +// MARK: - Residence Entity (configuration picker) + +/// `AppEntity` exposing the user's residences to the widget's +/// configuration sheet. Reads from the `widget_residences.json` +/// sidecar that the main app writes via +/// `WidgetDataManager.saveResidences(...)`. +struct WidgetResidenceEntity: AppEntity, Identifiable, Hashable { + /// Backing residence id (matches `Residence.id` on the server). Stored + /// as `String` because `AppEntity.id` requires `Hashable`-conformance + /// for stable widget reconfiguration — Apple's docs recommend a stable + /// string identifier over `Int` so the widget timeline survives + /// device-id changes. + var id: String + + var name: String + + /// Convenience integer form for `CacheManager.getUpcomingTasks`. + var intId: Int? { Int(id) } + + static var typeDisplayRepresentation: TypeDisplayRepresentation { + TypeDisplayRepresentation(name: "Residence") + } + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation(title: "\(name)") + } + + static var defaultQuery = WidgetResidenceEntityQuery() +} + +/// Provides the residence choices the configuration sheet displays. The +/// list is sourced from the App-Group-shared `widget_residences.json` +/// the main app maintains; on a brand-new install (or signed-out state) +/// the sheet falls back to showing only the "All residences" implicit +/// option exposed by the optional parameter. +struct WidgetResidenceEntityQuery: EntityQuery { + /// Look up specific residences by id (used when the system needs to + /// re-resolve a saved configuration after the user reopens the + /// widget edit sheet). + func entities(for identifiers: [WidgetResidenceEntity.ID]) async throws -> [WidgetResidenceEntity] { + let known = loadAll() + return identifiers.compactMap { id in known.first(where: { $0.id == id }) } + } + + /// Populate the picker. Sorted alphabetically so the list is stable + /// across refreshes — `WidgetDataManager.saveResidences` writes in + /// the order the API returned, which can shuffle on server-side + /// re-sorts. + func suggestedEntities() async throws -> [WidgetResidenceEntity] { + loadAll().sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + + private func loadAll() -> [WidgetResidenceEntity] { + let raw = CacheManager.getResidences() + return raw.map { WidgetResidenceEntity(id: String($0.id), name: $0.name) } + } } // MARK: - Complete Task Intent diff --git a/iosApp/HoneyDue/HoneyDue.swift b/iosApp/HoneyDue/HoneyDue.swift index 2e49928..93a2007 100644 --- a/iosApp/HoneyDue/HoneyDue.swift +++ b/iosApp/HoneyDue/HoneyDue.swift @@ -84,6 +84,12 @@ class CacheManager { let inProgress: Bool let dueDate: String? let category: String? + /// Owning residence id. Decoded from `residence_id` in the widget + /// cache. Optional so older app builds (pre-gitea#6) that omitted + /// the key still decode successfully — in that case the widget + /// behaves like the legacy "all residences" mode regardless of + /// what the configuration intent picks. + let residenceId: Int? let residenceName: String? let isOverdue: Bool let isDueWithin7Days: Bool @@ -93,12 +99,45 @@ class CacheManager { case id, title, description, priority, category case inProgress = "in_progress" case dueDate = "due_date" + case residenceId = "residence_id" case residenceName = "residence_name" case isOverdue = "is_overdue" case isDueWithin7Days = "is_due_within_7_days" case isDue8To30Days = "is_due_8_to_30_days" } + /// Custom init with a default `residenceId` so the existing + /// #Preview literal-task sites compile without each having to + /// add the new parameter. Production decode uses the synthesized + /// `Decodable` path. + init( + id: Int, + title: String, + description: String?, + priority: String?, + inProgress: Bool, + dueDate: String?, + category: String?, + residenceId: Int? = nil, + residenceName: String?, + isOverdue: Bool, + isDueWithin7Days: Bool, + isDue8To30Days: Bool + ) { + self.id = id + self.title = title + self.description = description + self.priority = priority + self.inProgress = inProgress + self.dueDate = dueDate + self.category = category + self.residenceId = residenceId + self.residenceName = residenceName + self.isOverdue = isOverdue + self.isDueWithin7Days = isDueWithin7Days + self.isDue8To30Days = isDue8To30Days + } + /// Whether this task is pending completion (tapped on widget, waiting for sync) var isPendingCompletion: Bool { WidgetActionManager.shared.isTaskPendingCompletion(taskId: id) @@ -147,14 +186,19 @@ class CacheManager { } } - static func getUpcomingTasks() -> [CustomTask] { + static func getUpcomingTasks(forResidenceId residenceId: Int? = nil) -> [CustomTask] { let allTasks = getData() // Filter for actionable tasks (not completed, including in-progress and overdue) - // Also exclude tasks that are pending completion via widget + // Also exclude tasks that are pending completion via widget. + // When a residence is configured for this widget instance + // (gitea#6), drop tasks owned by other residences. let upcoming = allTasks.filter { task in - // Include if: not pending completion - return task.shouldShow + guard task.shouldShow else { return false } + if let residenceId, let taskResidenceId = task.residenceId { + return taskResidenceId == residenceId + } + return true } // Sort by due date (earliest first), with overdue at top @@ -171,6 +215,36 @@ class CacheManager { return date1 < date2 } } + + // MARK: - Residence sidecar (gitea#6) + + private static let residencesFileName = "widget_residences.json" + + private static var residencesFileURL: URL? { + sharedContainerURL?.appendingPathComponent(residencesFileName) + } + + struct WidgetResidence: Codable, Identifiable, Hashable { + let id: Int + let name: String + } + + /// Synchronously load every residence the main app has persisted for + /// widget configuration. Empty when the user is signed out or the + /// sidecar has not yet been written. + static func getResidences() -> [WidgetResidence] { + guard let fileURL = residencesFileURL, + FileManager.default.fileExists(atPath: fileURL.path) else { + return [] + } + do { + let data = try Data(contentsOf: fileURL) + return try JSONDecoder().decode([WidgetResidence].self, from: data) + } catch { + print("CacheManager: Error decoding residences - \(error)") + return [] + } + } } struct Provider: AppIntentTimelineProvider { @@ -184,7 +258,7 @@ struct Provider: AppIntentTimelineProvider { } func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> SimpleEntry { - let tasks = CacheManager.getUpcomingTasks() + let tasks = CacheManager.getUpcomingTasks(forResidenceId: configuration.residence?.intId) let isInteractive = WidgetActionManager.shared.shouldShowInteractiveWidget() return SimpleEntry( date: Date(), @@ -195,7 +269,7 @@ struct Provider: AppIntentTimelineProvider { } func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline { - let tasks = CacheManager.getUpcomingTasks() + let tasks = CacheManager.getUpcomingTasks(forResidenceId: configuration.residence?.intId) let isInteractive = WidgetActionManager.shared.shouldShowInteractiveWidget() // Use a longer refresh interval during overnight hours (11pm-6am) diff --git a/iosApp/HoneyDueTests/WidgetResidenceFilterTests.swift b/iosApp/HoneyDueTests/WidgetResidenceFilterTests.swift new file mode 100644 index 0000000..ac24df6 --- /dev/null +++ b/iosApp/HoneyDueTests/WidgetResidenceFilterTests.swift @@ -0,0 +1,90 @@ +import XCTest +@testable import honeyDue + +/// Tests for the per-residence widget filter added in gitea#6. +/// +/// `WidgetDataManager.filterTasks(_:forResidenceId:)` is the pure +/// function the widget timeline provider calls when a configuration +/// intent has a residence selected. These tests guarantee the contract +/// stays stable: nil → pass-through, matching id → only matching tasks, +/// no match → empty, missing residenceId on a task → never leaks into +/// a residence-scoped widget. +final class WidgetResidenceFilterTests: XCTestCase { + + private func makeTask( + id: Int, + residenceId: Int? = nil + ) -> WidgetDataManager.WidgetTask { + WidgetDataManager.WidgetTask( + id: id, + title: "Task \(id)", + description: nil, + priority: nil, + inProgress: false, + dueDate: nil, + category: nil, + residenceId: residenceId, + residenceName: nil, + isOverdue: false, + isDueWithin7Days: false, + isDue8To30Days: false + ) + } + + func testNilResidenceReturnsAllTasks() { + // "All residences" config — widget passes nil, gets every task. + let tasks = [ + makeTask(id: 1, residenceId: 10), + makeTask(id: 2, residenceId: 20), + makeTask(id: 3, residenceId: nil), + ] + let result = WidgetDataManager.filterTasks(tasks, forResidenceId: nil) + XCTAssertEqual(result.map(\.id), [1, 2, 3]) + } + + func testMatchingResidenceKeepsOnlyMatchingTasks() { + let tasks = [ + makeTask(id: 1, residenceId: 10), + makeTask(id: 2, residenceId: 20), + makeTask(id: 3, residenceId: 10), + makeTask(id: 4, residenceId: 30), + ] + let result = WidgetDataManager.filterTasks(tasks, forResidenceId: 10) + XCTAssertEqual(result.map(\.id), [1, 3]) + } + + func testUnknownResidenceReturnsEmpty() { + let tasks = [ + makeTask(id: 1, residenceId: 10), + makeTask(id: 2, residenceId: 20), + ] + let result = WidgetDataManager.filterTasks(tasks, forResidenceId: 999) + XCTAssertTrue(result.isEmpty) + } + + func testNilResidenceIdOnTaskDoesNotMatchScopedConfiguration() { + // A task written by an older app build (no `residence_id` in JSON) + // must NOT leak into a residence-scoped widget — we'd rather hide + // it than misattribute it to the wrong home. + let tasks = [ + makeTask(id: 1, residenceId: 10), + makeTask(id: 2, residenceId: nil), + ] + let result = WidgetDataManager.filterTasks(tasks, forResidenceId: 10) + XCTAssertEqual(result.map(\.id), [1]) + } + + func testFilterPreservesInputOrder() { + // The filter is a pure subset op — no sorting side effects. + // Timeline provider relies on this so its sort step (overdue + // first, then by due date) operates on already-filtered tasks. + let tasks = [ + makeTask(id: 5, residenceId: 1), + makeTask(id: 3, residenceId: 1), + makeTask(id: 7, residenceId: 1), + makeTask(id: 1, residenceId: 2), + ] + let result = WidgetDataManager.filterTasks(tasks, forResidenceId: 1) + XCTAssertEqual(result.map(\.id), [5, 3, 7]) + } +} diff --git a/iosApp/iosApp/Data/DataManagerObservable.swift b/iosApp/iosApp/Data/DataManagerObservable.swift index d2995d8..9c6b9a1 100644 --- a/iosApp/iosApp/Data/DataManagerObservable.swift +++ b/iosApp/iosApp/Data/DataManagerObservable.swift @@ -274,11 +274,14 @@ class DataManagerObservable: ObservableObject { } observationTasks.append(residencesTask) - // MyResidences + // MyResidences. Mirror every update into the widget App Group + // sidecar so the widget's configuration intent (gitea#6) can + // offer the current residence list without making a network call. let myResidencesTask = Task { [weak self] in for await response in DataManager.shared.myResidences { guard let self else { return } self.myResidences = response + WidgetDataManager.shared.saveResidences(from: response) } } observationTasks.append(myResidencesTask) @@ -732,6 +735,7 @@ class DataManagerObservable: ObservableObject { inProgress: task.inProgress, dueDate: task.effectiveDueDate, category: task.categoryName, + residenceId: Int(task.residenceId), residenceName: nil, isOverdue: overdueIds.contains(task.id), isDueWithin7Days: isDueWithin7Days, diff --git a/iosApp/iosApp/Helpers/WidgetDataManager.swift b/iosApp/iosApp/Helpers/WidgetDataManager.swift index c89847a..a87eb0b 100644 --- a/iosApp/iosApp/Helpers/WidgetDataManager.swift +++ b/iosApp/iosApp/Helpers/WidgetDataManager.swift @@ -24,6 +24,7 @@ final class WidgetDataManager { Bundle.main.infoDictionary?["AppGroupIdentifier"] as? String ?? "group.com.myhoneydue.honeyDue.dev" }() private let tasksFileName = "widget_tasks.json" + private let residencesFileName = "widget_residences.json" private let actionsFileName = "widget_pending_actions.json" private let pendingTasksFileName = "widget_pending_tasks.json" private let tokenKey = "widget_auth_token" @@ -295,7 +296,15 @@ final class WidgetDataManager { !loadPendingActionsSync().isEmpty } - /// Task model for widget display - simplified version of TaskDetail + /// Task model for widget display - simplified version of TaskDetail. + /// + /// `residenceId` (added for gitea#6 per-residence widget selection) + /// is encoded as `residence_id` to match the widget extension's + /// `CacheManager.CustomTask` JSON shape. The extension uses it to + /// filter the timeline when the user picks a specific residence in + /// the widget configuration intent. Older JSON written by previous + /// app versions omitted the key — the field is optional so decode + /// of pre-existing widget caches still succeeds. struct WidgetTask: Codable { let id: Int let title: String @@ -304,6 +313,7 @@ final class WidgetDataManager { let inProgress: Bool let dueDate: String? let category: String? + let residenceId: Int? let residenceName: String? let isOverdue: Bool let isDueWithin7Days: Bool @@ -313,11 +323,53 @@ final class WidgetDataManager { case id, title, description, priority, category case inProgress = "in_progress" case dueDate = "due_date" + case residenceId = "residence_id" case residenceName = "residence_name" case isOverdue = "is_overdue" case isDueWithin7Days = "is_due_within_7_days" case isDue8To30Days = "is_due_8_to_30_days" } + + /// Custom init with a default `residenceId` so existing test + /// literals (TaskMetricsTests) compile without each adding the + /// new field. Production code that has the residence id passes + /// it explicitly. + init( + id: Int, + title: String, + description: String?, + priority: String?, + inProgress: Bool, + dueDate: String?, + category: String?, + residenceId: Int? = nil, + residenceName: String?, + isOverdue: Bool, + isDueWithin7Days: Bool, + isDue8To30Days: Bool + ) { + self.id = id + self.title = title + self.description = description + self.priority = priority + self.inProgress = inProgress + self.dueDate = dueDate + self.category = category + self.residenceId = residenceId + self.residenceName = residenceName + self.isOverdue = isOverdue + self.isDueWithin7Days = isDueWithin7Days + self.isDue8To30Days = isDue8To30Days + } + } + + /// Lightweight residence identifier for widget configuration. Persists + /// `(id, name)` of every residence the user belongs to so the widget + /// extension can populate its `ResidenceEntityQuery` without making + /// a network call (gitea#6). + struct WidgetResidence: Codable, Equatable { + let id: Int + let name: String } /// Metrics calculated from an array of tasks - shared between app and widget @@ -418,6 +470,12 @@ final class WidgetDataManager { } } + // `task.residenceId` is non-optional Int32 on Kotlin so always + // promotes safely. `residenceName` is left blank because the + // widget already resolves it via the saved residences file + // (gitea#6) — keeping the field around for forward-compat + // with the existing JSON shape consumed by older widget + // builds. let widgetTask = WidgetTask( id: Int(task.id), title: task.title, @@ -426,6 +484,7 @@ final class WidgetDataManager { inProgress: task.inProgress, dueDate: task.effectiveDueDate, category: task.categoryName ?? "", + residenceId: Int(task.residenceId), residenceName: "", isOverdue: isOverdue, isDueWithin7Days: isDueWithin7Days, @@ -540,10 +599,94 @@ final class WidgetDataManager { print("WidgetDataManager: Error clearing cache - \(error)") } + // Also clear residences so the configuration intent stops + // offering stale options after sign-out. + if let resURL = self.residencesFileURL { + try? FileManager.default.removeItem(at: resURL) + } + DispatchQueue.main.async { WidgetCenter.shared.reloadAllTimelines() } } } + // MARK: - Residences (per-residence widget selection, gitea#6) + + /// Path to the residence sidecar file inside the App Group container. + private var residencesFileURL: URL? { + sharedContainerURL?.appendingPathComponent(residencesFileName) + } + + /// Persist the user's residences (id + name) to the App Group so the + /// widget extension's configuration intent can offer them as choices. + /// Call whenever `DataManagerObservable.myResidences` updates. + func saveResidences(_ residences: [WidgetResidence]) { + guard let fileURL = residencesFileURL else { + print("WidgetDataManager: Unable to access shared container for residences") + return + } + + fileQueue.async { + do { + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + let data = try encoder.encode(residences) + try data.write(to: fileURL, options: .atomic) + print("WidgetDataManager: Saved \(residences.count) residences for widget config") + } catch { + print("WidgetDataManager: Error saving residences - \(error)") + } + + DispatchQueue.main.async { + // Configuration intent reads on-demand, but reload the + // currently-pinned widgets so the visible task list + // refreshes against any rename. + self.reloadWidgetTimelinesIfNeeded() + } + } + } + + /// Convenience: save from a Kotlin `MyResidencesResponse` directly. + func saveResidences(from myResidences: MyResidencesResponse?) { + let residences = (myResidences?.residences ?? []).map { r in + WidgetResidence(id: Int(r.id), name: r.name) + } + saveResidences(residences) + } + + /// Load the persisted residences synchronously. Used by the widget + /// extension's `ResidenceEntityQuery` (`AppIntents` requires sync + /// reads). + func loadResidencesSync() -> [WidgetResidence] { + guard let fileURL = residencesFileURL else { return [] } + + return fileQueue.sync { + guard FileManager.default.fileExists(atPath: fileURL.path) else { return [] } + do { + let data = try Data(contentsOf: fileURL) + return try JSONDecoder().decode([WidgetResidence].self, from: data) + } catch { + print("WidgetDataManager: Error loading residences - \(error)") + return [] + } + } + } + + // MARK: - Pure filter (covered by tests) + + /// Return only the tasks for `residenceId`. When `residenceId` is + /// `nil`, returns the input unchanged — that's the "All residences" + /// configuration option in the widget. + /// + /// Factored out as a pure function so it can be exercised from unit + /// tests without booting the widget timeline provider. + static func filterTasks( + _ tasks: [WidgetTask], + forResidenceId residenceId: Int? + ) -> [WidgetTask] { + guard let residenceId else { return tasks } + return tasks.filter { $0.residenceId == residenceId } + } + }