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
+71 -1
View File
@@ -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