feat(widget): per-residence widget configuration (iOS, gitea#6)
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:
Trey T
2026-05-11 13:14:58 -05:00
parent fdcf82757d
commit 498e6b8064
5 changed files with 390 additions and 9 deletions
+80 -6
View File
@@ -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<SimpleEntry> {
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)