feat(widget): per-residence widget configuration (iOS, gitea#6)
Android UI Tests / ui-tests (pull_request) Has been cancelled
Android UI Tests / ui-tests (pull_request) Has been cancelled
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user