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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user