From 2bf63965b72ff9a828f84599496aa2668394ceb6 Mon Sep 17 00:00:00 2001 From: Alan Vardy Date: Sat, 26 Sep 2026 07:22:43 -0700 Subject: [PATCH 01/15] chore: start alanvardy-var-1065-add-widgets --- DELETEME | 1 + 1 file changed, 1 insertion(+) create mode 100644 DELETEME diff --git a/DELETEME b/DELETEME new file mode 100644 index 0000000..c0ef07a --- /dev/null +++ b/DELETEME @@ -0,0 +1 @@ +DELETEME (alanvardy-var-1065-add-widgets) — placeholder; git rm before merging From 41b8843f0bb7caa9ad901d6a7d1c9931fa47d6bc Mon Sep 17 00:00:00 2001 From: Alan Vardy Date: Sat, 26 Sep 2026 13:22:53 -0700 Subject: [PATCH 02/15] chore: remove DELETEME placeholder --- DELETEME | 1 - 1 file changed, 1 deletion(-) delete mode 100644 DELETEME diff --git a/DELETEME b/DELETEME deleted file mode 100644 index c0ef07a..0000000 --- a/DELETEME +++ /dev/null @@ -1 +0,0 @@ -DELETEME (alanvardy-var-1065-add-widgets) — placeholder; git rm before merging From ceba4ad7ab09067c9bebb78781af8ae3cb4b24e6 Mon Sep 17 00:00:00 2001 From: Alan Vardy Date: Sat, 26 Sep 2026 13:31:00 -0700 Subject: [PATCH 03/15] Phase 0: Core extraction --- .../Intents/CheckStitchShortcuts.swift | 1 + CheckStitch/MyApp.swift | 15 ++-- .../Sources/CheckStitchCore}/AppGroup.swift | 6 +- .../CheckStitchCore}/ChecklistEntity.swift | 31 ++++--- .../CheckStitchCore}/ChecklistMerge.swift | 7 +- .../CheckStitchCore}/ChecklistReminders.swift | 15 ++-- .../CheckStitchCore}/ChecklistStore.swift | 87 +++++++++---------- .../EventKitReminderDestination.swift | 19 ++-- .../ListChecklistsIntent.swift | 14 +-- .../CheckStitchCore/PurchaseEnvironment.swift | 25 ++++++ .../CheckStitchCore}/RunChecklistIntent.swift | 19 ++-- CheckStitchTests/AppGroupTests.swift | 1 + .../EventKitReminderDestinationTests.swift | 1 + 13 files changed, 130 insertions(+), 111 deletions(-) rename {CheckStitch => CheckStitchCore/Sources/CheckStitchCore}/AppGroup.swift (67%) rename {CheckStitch/Intents => CheckStitchCore/Sources/CheckStitchCore}/ChecklistEntity.swift (51%) rename {CheckStitch => CheckStitchCore/Sources/CheckStitchCore}/ChecklistMerge.swift (98%) rename {CheckStitch => CheckStitchCore/Sources/CheckStitchCore}/ChecklistReminders.swift (90%) rename {CheckStitch => CheckStitchCore/Sources/CheckStitchCore}/ChecklistStore.swift (91%) rename {CheckStitch => CheckStitchCore/Sources/CheckStitchCore}/EventKitReminderDestination.swift (81%) rename {CheckStitch/Intents => CheckStitchCore/Sources/CheckStitchCore}/ListChecklistsIntent.swift (73%) create mode 100644 CheckStitchCore/Sources/CheckStitchCore/PurchaseEnvironment.swift rename {CheckStitch/Intents => CheckStitchCore/Sources/CheckStitchCore}/RunChecklistIntent.swift (88%) diff --git a/CheckStitch/Intents/CheckStitchShortcuts.swift b/CheckStitch/Intents/CheckStitchShortcuts.swift index 137805a..e6f6f39 100644 --- a/CheckStitch/Intents/CheckStitchShortcuts.swift +++ b/CheckStitch/Intents/CheckStitchShortcuts.swift @@ -1,4 +1,5 @@ import AppIntents +import CheckStitchCore struct CheckStitchShortcuts: AppShortcutsProvider { static var appShortcuts: [AppShortcut] { diff --git a/CheckStitch/MyApp.swift b/CheckStitch/MyApp.swift index 93b6184..ba458da 100644 --- a/CheckStitch/MyApp.swift +++ b/CheckStitch/MyApp.swift @@ -32,6 +32,12 @@ import SwiftUI @Environment(\.scenePhase) private var scenePhase init() { + // Inject the StoreKit-backed purchase service before any consumer + // (view models, gate) reads `PurchaseEnvironment.service`. Core holds a + // StoreKit-free default so out-of-app processes (widgets, Siri) link. + PurchaseEnvironment.service = PurchaseService( + provider: StoreKitPurchaseService(), + cache: PurchaseEntitlementCache(defaults: AppGroup.defaults)) let store = ChecklistStore() let syncService = ChecklistSyncService(sync: UbiquitousChecklistSync(), store: store) syncService.start() @@ -131,12 +137,3 @@ import SwiftUI #endif } } - -/// The one purchase service the UI, the intent and the sync coordinator share. -/// Defined here (app target) because `StoreKitPurchaseService` is app-side while -/// `PurchaseService` lives in Core. -enum PurchaseEnvironment { - static let service = PurchaseService( - provider: StoreKitPurchaseService(), - cache: PurchaseEntitlementCache(defaults: AppGroup.defaults)) -} diff --git a/CheckStitch/AppGroup.swift b/CheckStitchCore/Sources/CheckStitchCore/AppGroup.swift similarity index 67% rename from CheckStitch/AppGroup.swift rename to CheckStitchCore/Sources/CheckStitchCore/AppGroup.swift index 84ccdb8..e868b34 100644 --- a/CheckStitch/AppGroup.swift +++ b/CheckStitchCore/Sources/CheckStitchCore/AppGroup.swift @@ -3,10 +3,10 @@ import Foundation /// App Group storage shared with the planned watch app (VAR-963). Falls back to /// `.standard` where the group is unavailable (unregistered simulators, /// previews) so first launch can never crash. -enum AppGroup { - static let suiteName = "group.app.alanvardy.CheckStitch" +public enum AppGroup { + public static let suiteName = "group.app.alanvardy.CheckStitch" - static var defaults: UserDefaults { + public static var defaults: UserDefaults { UserDefaults(suiteName: suiteName) ?? .standard } } \ No newline at end of file diff --git a/CheckStitch/Intents/ChecklistEntity.swift b/CheckStitchCore/Sources/CheckStitchCore/ChecklistEntity.swift similarity index 51% rename from CheckStitch/Intents/ChecklistEntity.swift rename to CheckStitchCore/Sources/CheckStitchCore/ChecklistEntity.swift index d1b5353..455013c 100644 --- a/CheckStitch/Intents/ChecklistEntity.swift +++ b/CheckStitchCore/Sources/CheckStitchCore/ChecklistEntity.swift @@ -1,29 +1,28 @@ import AppIntents -import CheckStitchCore /// Siri-facing identity for a checklist. `id` is `Checklist.id.uuidString` — the /// same stable, rename-proof key sync merges on, so a rename never orphans an /// in-flight utterance. -struct ChecklistEntity: AppEntity { - static let typeDisplayRepresentation: TypeDisplayRepresentation = "Checklist" - static var defaultQuery: ChecklistEntityQuery { ChecklistEntityQuery() } +public struct ChecklistEntity: AppEntity { + public static let typeDisplayRepresentation: TypeDisplayRepresentation = "Checklist" + public static var defaultQuery: ChecklistEntityQuery { ChecklistEntityQuery() } - let id: String - let name: String + public let id: String + public let name: String - init(id: String, name: String) { self.id = id; self.name = name } - init(_ checklist: Checklist) { self.init(id: checklist.id.uuidString, name: checklist.name) } + public init(id: String, name: String) { self.id = id; self.name = name } + public init(_ checklist: Checklist) { self.init(id: checklist.id.uuidString, name: checklist.name) } - var displayRepresentation: DisplayRepresentation { + public var displayRepresentation: DisplayRepresentation { DisplayRepresentation(title: "\(name)") } } -struct ChecklistEntityQuery: EntityStringQuery { +public struct ChecklistEntityQuery: EntityStringQuery { private let store: ChecklistStore? - init() { self.store = nil } - init(store: ChecklistStore) { self.store = store } + public init() { self.store = nil } + public init(store: ChecklistStore) { self.store = store } /// Fresh store per call when nothing is injected (design decision 7). @MainActor private func currentStore() -> ChecklistStore { @@ -31,7 +30,7 @@ struct ChecklistEntityQuery: EntityStringQuery { } @MainActor - func entities(for identifiers: [String]) async throws -> [ChecklistEntity] { + public func entities(for identifiers: [String]) async throws -> [ChecklistEntity] { let wanted = Set(identifiers) return currentStore().checklists .filter { wanted.contains($0.id.uuidString) } @@ -39,14 +38,14 @@ struct ChecklistEntityQuery: EntityStringQuery { } @MainActor - func entities(matching string: String) async throws -> [ChecklistEntity] { + public func entities(matching string: String) async throws -> [ChecklistEntity] { currentStore().checklists .filter { $0.name.localizedCaseInsensitiveContains(string) } .map(ChecklistEntity.init) } @MainActor - func suggestedEntities() async throws -> [ChecklistEntity] { + public func suggestedEntities() async throws -> [ChecklistEntity] { currentStore().checklists.map(ChecklistEntity.init) } -} +} \ No newline at end of file diff --git a/CheckStitch/ChecklistMerge.swift b/CheckStitchCore/Sources/CheckStitchCore/ChecklistMerge.swift similarity index 98% rename from CheckStitch/ChecklistMerge.swift rename to CheckStitchCore/Sources/CheckStitchCore/ChecklistMerge.swift index 3cea6da..b0242cf 100644 --- a/CheckStitch/ChecklistMerge.swift +++ b/CheckStitchCore/Sources/CheckStitchCore/ChecklistMerge.swift @@ -1,4 +1,3 @@ -import CheckStitchCore import Foundation /// Pure, deterministic merge of two checklist envelopes. The winner rule — @@ -11,8 +10,8 @@ import Foundation /// way — a folder delete is permanent (recreating a folder mints a new UUID), /// so a concurrent higher-revision rename loses to it. No store, no seam, no /// I/O: fully unit-testable in isolation. -enum ChecklistMerge { - static func merge(local: ChecklistEnvelope, remote: ChecklistEnvelope) -> ChecklistEnvelope { +public enum ChecklistMerge { + public static func merge(local: ChecklistEnvelope, remote: ChecklistEnvelope) -> ChecklistEnvelope { let tombstones = mergedTombstones(local.tombstones, remote.tombstones) let deadChecklists = Set(tombstones.filter { $0.itemID == nil }.map(\.checklistID)) let itemTombstones = tombstones.filter { $0.itemID != nil } @@ -267,4 +266,4 @@ enum ChecklistMerge { guard let device, let overDevice else { return false } return device < overDevice } -} +} \ No newline at end of file diff --git a/CheckStitch/ChecklistReminders.swift b/CheckStitchCore/Sources/CheckStitchCore/ChecklistReminders.swift similarity index 90% rename from CheckStitch/ChecklistReminders.swift rename to CheckStitchCore/Sources/CheckStitchCore/ChecklistReminders.swift index b3a8af5..5add98d 100644 --- a/CheckStitch/ChecklistReminders.swift +++ b/CheckStitchCore/Sources/CheckStitchCore/ChecklistReminders.swift @@ -1,12 +1,11 @@ -import CheckStitchCore import Foundation import os -enum ChecklistReminders { +@MainActor public enum ChecklistReminders { private static let logger = Logger(subsystem: "app.alanvardy.CheckStitch", category: "ChecklistReminders") /// Production entry point: resolves entitlement, builds the gate, then delegates. - static func create(from checklist: Checklist) async -> ReminderRunOutcome { + public static func create(from checklist: Checklist) async -> ReminderRunOutcome { await create(from: checklist, targeting: EventKitReminderDestination.shared, gate: await productionGate()) @@ -14,16 +13,16 @@ enum ChecklistReminders { /// The gate every entry point shares: the one injected purchase service plus /// the durable App-Group counter. - static func productionGate() async -> RunGate { + public static func productionGate() async -> RunGate { let purchases = PurchaseEnvironment.service await purchases.start() return RunGate(counter: RunCounter(defaults: AppGroup.defaults), isUnlocked: purchases.isUnlocked) } - static func create(from checklist: Checklist, - targeting: ReminderDestinationTargeting, - gate: RunGate) async -> ReminderRunOutcome { + public static func create(from checklist: Checklist, + targeting: ReminderDestinationTargeting, + gate: RunGate) async -> ReminderRunOutcome { // Gate first: reserve the slot (atomically) before any EventKit work, so // a refused run writes nothing and concurrent runs cannot both pass at // the limit. The slot is released again unless the run creates a reminder. @@ -78,4 +77,4 @@ enum ChecklistReminders { return .failed(error.localizedDescription) } } -} +} \ No newline at end of file diff --git a/CheckStitch/ChecklistStore.swift b/CheckStitchCore/Sources/CheckStitchCore/ChecklistStore.swift similarity index 91% rename from CheckStitch/ChecklistStore.swift rename to CheckStitchCore/Sources/CheckStitchCore/ChecklistStore.swift index 279c7a8..27bdf4a 100644 --- a/CheckStitch/ChecklistStore.swift +++ b/CheckStitchCore/Sources/CheckStitchCore/ChecklistStore.swift @@ -1,13 +1,12 @@ -import CheckStitchCore import Foundation import os -@Observable -final class ChecklistStore { +@MainActor @Observable +public final class ChecklistStore { /// Whether a `rename(id:to:)` call was applied, refused because another /// checklist already owns the requested name, or aimed at an id that no /// longer exists (deleted while its screen was visible). - enum RenameOutcome: Equatable { + public enum RenameOutcome: Equatable { case renamed case nameTaken case notFound @@ -15,24 +14,24 @@ final class ChecklistStore { /// Whether a `setDestination(_:for:)` call was applied or aimed at an id that /// no longer exists (deleted while its edit screen was visible). - enum SetDestinationOutcome: Equatable { + public enum SetDestinationOutcome: Equatable { case updated case notFound } - private(set) var checklists: [Checklist] + public private(set) var checklists: [Checklist] /// Persisted deletion records, unioned by `ChecklistMerge`. There is no /// retention/GC yet, so this only grows; it is bounded in practice by human /// deletion volume, but a future ticket should compact tombstones once no /// device can still hold the pre-delete revision. - private(set) var tombstones: [ChecklistTombstone] = [] + public private(set) var tombstones: [ChecklistTombstone] = [] /// Persisted folder state, mirroring `checklists`/`tombstones`: unions under /// `ChecklistMerge`, loaded through the same envelope, and saved verbatim. - private(set) var folders: [Folder] = [] - private(set) var folderTombstones: [FolderTombstone] = [] + public private(set) var folders: [Folder] = [] + public private(set) var folderTombstones: [FolderTombstone] = [] /// Invoked after every persisted save, except saves that are applying remote /// state (the coordinator pushes those itself). - @ObservationIgnored var onChange: (() -> Void)? + @ObservationIgnored public var onChange: (() -> Void)? @ObservationIgnored private var isApplyingRemote = false private let defaults: UserDefaults @@ -47,10 +46,10 @@ final class ChecklistStore { @ObservationIgnored private var pendingSave: Task? /// Stable per-install identifier stamped into every encoded envelope, so a /// merge can tell two producers apart (see `ChecklistMerge`). - let deviceID: String + public let deviceID: String @ObservationIgnored private let now: () -> Date - init( + public init( defaults: UserDefaults = AppGroup.defaults, key: String = "checklists.v1", textEditDelay: Duration? = .milliseconds(300), @@ -116,7 +115,7 @@ final class ChecklistStore { /// The current payload as a versioned envelope: the only thing the store /// ever encodes, keeping "store is the only encoder" literally true. - var envelope: ChecklistEnvelope { + public var envelope: ChecklistEnvelope { ChecklistEnvelope(version: ChecklistCodec.currentVersion, deviceID: deviceID, checklists: checklists, @@ -127,18 +126,18 @@ final class ChecklistStore { /// Whether remote sync state may be folded into the local payload. False /// only when the stored payload came from a newer app version. - var canAcceptRemoteChanges: Bool { canOverwriteStoredPayload } + public var canAcceptRemoteChanges: Bool { canOverwriteStoredPayload } private static let deviceIDKey = "checklist.deviceID" - func checklist(id: UUID) -> Checklist? { + public func checklist(id: UUID) -> Checklist? { checklists.first { $0.id == id } } /// The first checklist whose name collides with `name` under the store's /// trimmed, case-insensitive comparison, or `nil` when the name is free. The /// import flow's conflict primitive — `sameName` stays private. - func conflictingChecklist(named name: String) -> Checklist? { + public func conflictingChecklist(named name: String) -> Checklist? { checklists.first { Self.sameName($0.name, name) } } @@ -147,7 +146,7 @@ final class ChecklistStore { /// `"New checklist 3"`, … Creation therefore always succeeds and returns /// the checklist to open. @discardableResult - func create(name: String = "New checklist") -> Checklist { + public func create(name: String = "New checklist") -> Checklist { let checklist = Checklist(name: Self.uniqueName(basedOn: name, taken: checklists.map(\.name)), modifiedAt: now(), revision: 1) checklists.append(checklist) save() @@ -157,7 +156,7 @@ final class ChecklistStore { /// The name a duplicate is offered by default: the source name plus a /// literal " copy", left for `uniqueName` to disambiguate on commit — a /// second copy of "Groceries" is therefore offered as "Groceries copy 2". - static func duplicateName(basedOn sourceName: String) -> String { + public static func duplicateName(basedOn sourceName: String) -> String { "\(sourceName) copy" } @@ -169,7 +168,7 @@ final class ChecklistStore { /// no-op. A blank (whitespace- or newline-only) name falls back to the /// offered default. @discardableResult - func duplicate(id: UUID, name: String) -> Checklist? { + public func duplicate(id: UUID, name: String) -> Checklist? { guard let source = checklists.first(where: { $0.id == id }) else { return nil } let requested = name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? Self.duplicateName(basedOn: source.name) @@ -212,7 +211,7 @@ final class ChecklistStore { /// non-destructive "Keep Both" path; pass `name` to force one. Never re-enters /// the LWW merge. Returns the new id. @discardableResult - func importInsert(_ checklist: Checklist, as name: String? = nil) -> UUID { + public func importInsert(_ checklist: Checklist, as name: String? = nil) -> UUID { var copy = freshCopy(of: checklist) copy.name = name ?? Self.uniqueName(basedOn: copy.name, taken: checklists.map(\.name)) checklists.append(copy) @@ -226,7 +225,7 @@ final class ChecklistStore { /// replace is one push. Returns the new id, or `nil` when the local checklist /// no longer exists (silent no-op, mirroring `delete`). @discardableResult - func importReplace(id: UUID, with checklist: Checklist) -> UUID? { + public func importReplace(id: UUID, with checklist: Checklist) -> UUID? { guard let index = checklists.firstIndex(where: { $0.id == id }) else { return nil } let removed = checklists.remove(at: index) tombstones.append(ChecklistTombstone( @@ -243,7 +242,7 @@ final class ChecklistStore { /// always allowed. Callers commit this on Done rather than per keystroke, /// so the conflict is surfaced once the user confirms the name. @discardableResult - func rename(id: UUID, to name: String) -> RenameOutcome { + public func rename(id: UUID, to name: String) -> RenameOutcome { guard let index = checklists.firstIndex(where: { $0.id == id }) else { return .notFound } guard checklists.first(where: { $0.id != id && Self.sameName($0.name, name) }) == nil else { return .nameTaken @@ -259,7 +258,7 @@ final class ChecklistStore { /// whether it applied. Follows `rename`: bump revision + `modifiedAt`, then /// persist through the coalescing path. @discardableResult - func setDestination(_ identifier: String?, for id: UUID) -> SetDestinationOutcome { + public func setDestination(_ identifier: String?, for id: UUID) -> SetDestinationOutcome { guard let index = checklists.firstIndex(where: { $0.id == id }) else { return .notFound } checklists[index].destinationListIdentifier = identifier checklists[index].revision += 1 @@ -274,7 +273,7 @@ final class ChecklistStore { /// last-write-wins rule as the name and destination. An unchanged value is a /// no-op, so re-rendering the toggle never manufactures a spurious LWW win. @discardableResult - func setPrefixesReminderNumbers(_ enabled: Bool, for id: UUID) -> SetDestinationOutcome { + public func setPrefixesReminderNumbers(_ enabled: Bool, for id: UUID) -> SetDestinationOutcome { guard let index = checklists.firstIndex(where: { $0.id == id }) else { return .notFound } guard checklists[index].prefixesReminderNumbers != enabled else { return .updated } checklists[index].prefixesReminderNumbers = enabled @@ -310,7 +309,7 @@ final class ChecklistStore { /// once with that name: a single `revision: 1` create, so the title clock /// records add-time. Existing callers without a name to offer keep the /// generic default through the `addItem(to:)` overload. - func addItem(to id: UUID, title: String) { + public func addItem(to id: UUID, title: String) { guard let index = checklists.firstIndex(where: { $0.id == id }) else { return } let item = ChecklistItem(title: title, modifiedAt: now(), revision: 1) checklists[index].items.append(item) @@ -318,11 +317,11 @@ final class ChecklistStore { save() } - func addItem(to id: UUID) { + public func addItem(to id: UUID) { addItem(to: id, title: "New item") } - func updateItem(checklistID: UUID, itemID: UUID, title: String) { + public func updateItem(checklistID: UUID, itemID: UUID, title: String) { guard let checklistIndex = checklists.firstIndex(where: { $0.id == checklistID }), let itemIndex = checklists[checklistIndex].items.firstIndex(where: { $0.id == itemID }) else { return } @@ -338,7 +337,7 @@ final class ChecklistStore { /// Edits only the item's description, stamping the item's sync identity and /// debouncing like `updateItem`. Item ops never touch the checklist's own /// `revision`/`modifiedAt` (see `Checklist` doc). - func updateItemDescription(checklistID: UUID, itemID: UUID, description: String) { + public func updateItemDescription(checklistID: UUID, itemID: UUID, description: String) { guard let checklistIndex = checklists.firstIndex(where: { $0.id == checklistID }), let itemIndex = checklists[checklistIndex].items.firstIndex(where: { $0.id == itemID }) else { return } @@ -355,7 +354,7 @@ final class ChecklistStore { /// beside `updateItem(checklistID:itemID:title:)` without ambiguity. An /// unchanged value is a no-op — this is what stops a text field re-committing /// the same parse from bumping `revision` and winning a spurious LWW round. - func updateItem(checklistID: UUID, itemID: UUID, relativeDate: Int?) { + public func updateItem(checklistID: UUID, itemID: UUID, relativeDate: Int?) { guard let checklistIndex = checklists.firstIndex(where: { $0.id == checklistID }), let itemIndex = checklists[checklistIndex].items.firstIndex(where: { $0.id == itemID }) else { return } @@ -372,7 +371,7 @@ final class ChecklistStore { /// Sets an item's priority. A discrete pick, so like `relativeDate` an /// unchanged value is a no-op (never a spurious LWW win), and the save /// debounces like the other field edits. - func updateItem(checklistID: UUID, itemID: UUID, priority: ChecklistItemPriority) { + public func updateItem(checklistID: UUID, itemID: UUID, priority: ChecklistItemPriority) { guard let checklistIndex = checklists.firstIndex(where: { $0.id == checklistID }), let itemIndex = checklists[checklistIndex].items.firstIndex(where: { $0.id == itemID }) else { return } @@ -386,7 +385,7 @@ final class ChecklistStore { scheduleSave() } - func removeItems(from id: UUID, at offsets: IndexSet) { + public func removeItems(from id: UUID, at offsets: IndexSet) { guard let index = checklists.firstIndex(where: { $0.id == id }) else { return } for offset in offsets.sorted(by: >) { guard checklists[index].items.indices.contains(offset) else { continue } @@ -403,7 +402,7 @@ final class ChecklistStore { /// persisted once, so a multi-row removal is one save and one sync push. /// Out-of-range offsets are skipped; an all-out-of-range or empty set is a /// silent no-op (no tombstone, no save). - func removeChecklists(at offsets: IndexSet) { + public func removeChecklists(at offsets: IndexSet) { let removed = offsets.compactMap { checklists.indices.contains($0) ? checklists[$0] : nil } guard !removed.isEmpty else { return } for index in offsets.sorted(by: >) where checklists.indices.contains(index) { @@ -438,7 +437,7 @@ final class ChecklistStore { /// `revision` are untouched, so a pure reorder is never mistaken for an item /// edit. Unknown checklist ids and out-of-range offsets/destinations are /// silent no-ops. - func moveItems(checklistID: UUID, from offsets: IndexSet, to destination: Int) { + public func moveItems(checklistID: UUID, from offsets: IndexSet, to destination: Int) { guard let index = checklists.firstIndex(where: { $0.id == checklistID }) else { return } guard let items = Self.moved(checklists[index].items, from: offsets, to: destination) else { return } checklists[index].items = items @@ -454,7 +453,7 @@ final class ChecklistStore { /// `ChecklistMerge` keeps local order (remote-only checklists append), so a /// reorder is local-first by design and needs no revision bump. /// Out-of-range offsets/destinations are silent no-ops. - func moveChecklists(from offsets: IndexSet, to destination: Int) { + public func moveChecklists(from offsets: IndexSet, to destination: Int) { guard let reordered = Self.moved(checklists, from: offsets, to: destination) else { return } checklists = reordered save() @@ -465,7 +464,7 @@ final class ChecklistStore { /// nil or whitespace-only request falls back to "New Folder", so the create /// alert can never leave a folder with a blank header. @discardableResult - func createFolder(name: String? = nil) -> Folder { + public func createFolder(name: String? = nil) -> Folder { let requested = (name ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let folder = Folder(name: Self.uniqueName(basedOn: requested.isEmpty ? "New Folder" : requested, taken: folders.map(\.name)), modifiedAt: now(), revision: 1) @@ -480,7 +479,7 @@ final class ChecklistStore { /// Returns false for an unknown checklist or unknown folder; an unchanged /// membership is a no-op (never a spurious LWW win). @discardableResult - func moveChecklist(id: UUID, toFolder folderID: UUID?) -> Bool { + public func moveChecklist(id: UUID, toFolder folderID: UUID?) -> Bool { guard let index = checklists.firstIndex(where: { $0.id == id }) else { return false } if let folderID, !folders.contains(where: { $0.id == folderID }) { return false } guard checklists[index].folderID != folderID else { return true } @@ -495,7 +494,7 @@ final class ChecklistStore { /// folders (so re-confirming a folder's own name is a no-op, never " 2") and /// keeping the exact `rename` shape: bump revision + `modifiedAt`, then one save. @discardableResult - func renameFolder(id: UUID, to name: String) -> Folder? { + public func renameFolder(id: UUID, to name: String) -> Folder? { guard let index = folders.firstIndex(where: { $0.id == id }) else { return nil } // A blank request is a no-op: never blank a folder's header. let requested = name.trimmingCharacters(in: .whitespacesAndNewlines) @@ -515,7 +514,7 @@ final class ChecklistStore { /// flag is a no-op, never a spurious LWW win. Returns false for an unknown /// folder. @discardableResult - func setFolderCollapsed(id: UUID, _ isCollapsed: Bool) -> Bool { + public func setFolderCollapsed(id: UUID, _ isCollapsed: Bool) -> Bool { guard let index = folders.firstIndex(where: { $0.id == id }) else { return false } guard folders[index].isCollapsed != isCollapsed else { return true } folders[index].isCollapsed = isCollapsed @@ -528,7 +527,7 @@ final class ChecklistStore { /// Reorders folders. Folder order *is* the persisted array order and merge /// keeps local order (remote-only appends), so — exactly like /// `moveChecklists` — this is local-first and stamps no revision. - func moveFolders(from offsets: IndexSet, to destination: Int) { + public func moveFolders(from offsets: IndexSet, to destination: Int) { guard let reordered = Self.moved(folders, from: offsets, to: destination) else { return } folders = reordered save() @@ -538,7 +537,7 @@ final class ChecklistStore { /// (each member's coarse clock bumps so the orphan wins the LWW round), and /// one grow-only `FolderTombstone` blocks resurrection. Never writes /// checklist tombstones — the checklists survive. - func deleteFolder(id: UUID) { + public func deleteFolder(id: UUID) { guard let index = folders.firstIndex(where: { $0.id == id }) else { return } let removed = folders.remove(at: index) let deletedAt = now() @@ -552,7 +551,7 @@ final class ChecklistStore { } /// Local-only: reminders already created in Reminders are never touched. - func delete(id: UUID) { + public func delete(id: UUID) { guard let index = checklists.firstIndex(where: { $0.id == id }) else { return } let removed = checklists.remove(at: index) tombstones.append(ChecklistTombstone( @@ -564,7 +563,7 @@ final class ChecklistStore { /// when the stored payload came from a newer app version, preserving the /// never-overwrite-newer guard. Returns whether visible state changed. @discardableResult - func apply(remote: ChecklistEnvelope) -> Bool { + public func apply(remote: ChecklistEnvelope) -> Bool { guard canOverwriteStoredPayload else { return false } // Defensive: the service already rejects non-current versions via // `classify`, but a future caller must never merge a foreign shape. @@ -588,7 +587,7 @@ final class ChecklistStore { /// Persists any coalesced text edit immediately. Called when the screen is /// dismissed and when the app leaves the foreground, so the debounce window /// can never outlive the user's session. - func flushPendingSave() { + public func flushPendingSave() { guard let pending = pendingSave else { return } pendingSave = nil pending.cancel() @@ -629,4 +628,4 @@ final class ChecklistStore { } private static let logger = Logger(subsystem: "app.alanvardy.CheckStitch", category: "ChecklistStore") -} +} \ No newline at end of file diff --git a/CheckStitch/EventKitReminderDestination.swift b/CheckStitchCore/Sources/CheckStitchCore/EventKitReminderDestination.swift similarity index 81% rename from CheckStitch/EventKitReminderDestination.swift rename to CheckStitchCore/Sources/CheckStitchCore/EventKitReminderDestination.swift index cb97284..46af082 100644 --- a/CheckStitch/EventKitReminderDestination.swift +++ b/CheckStitchCore/Sources/CheckStitchCore/EventKitReminderDestination.swift @@ -1,4 +1,3 @@ -import CheckStitchCore import EventKit import Foundation @@ -7,18 +6,18 @@ import Foundation /// it must outlive every reminder it creates (`EKReminder` holds a weak /// reference to its store). @MainActor -final class EventKitReminderDestination: ReminderDestinationTargeting { - static let shared = EventKitReminderDestination() +public final class EventKitReminderDestination: ReminderDestinationTargeting { + public static let shared = EventKitReminderDestination() - init(eventStore: EKEventStore = EKEventStore()) { + public init(eventStore: EKEventStore = EKEventStore()) { self.eventStore = eventStore } - func requestAccess() async throws -> Bool { + public func requestAccess() async throws -> Bool { try await eventStore.requestFullAccessToReminders() } - func accessStatus() -> ReminderAccessStatus { + public func accessStatus() -> ReminderAccessStatus { switch EKEventStore.authorizationStatus(for: .reminder) { case .fullAccess: return .fullAccess case .notDetermined: return .notDetermined @@ -27,7 +26,7 @@ final class EventKitReminderDestination: ReminderDestinationTargeting { } } - func reminderLists() async throws -> ReminderListsSnapshot { + public func reminderLists() async throws -> ReminderListsSnapshot { ReminderListsSnapshot( options: eventStore.calendars(for: .reminder).compactMap { calendar in let identifier = calendar.calendarIdentifier @@ -37,8 +36,8 @@ final class EventKitReminderDestination: ReminderDestinationTargeting { defaultIdentifier: eventStore.defaultCalendarForNewReminders()?.calendarIdentifier) } - func create(title: String, notes: String?, priority: ChecklistItemPriority, - in list: ReminderListOption, dueDateComponents: DateComponents?) async throws { + public func create(title: String, notes: String?, priority: ChecklistItemPriority, + in list: ReminderListOption, dueDateComponents: DateComponents?) async throws { // Re-resolve by identifier: a list deleted between pre-validation and // creation must throw rather than silently fall back to a nil calendar. guard let calendar = eventStore.calendars(for: .reminder) @@ -73,4 +72,4 @@ enum ReminderDestinationError: LocalizedError { table: "Localizable", bundle: .main) .resolvedInAppLanguage() } -} +} \ No newline at end of file diff --git a/CheckStitch/Intents/ListChecklistsIntent.swift b/CheckStitchCore/Sources/CheckStitchCore/ListChecklistsIntent.swift similarity index 73% rename from CheckStitch/Intents/ListChecklistsIntent.swift rename to CheckStitchCore/Sources/CheckStitchCore/ListChecklistsIntent.swift index 2cb1ace..cd5f1bf 100644 --- a/CheckStitch/Intents/ListChecklistsIntent.swift +++ b/CheckStitchCore/Sources/CheckStitchCore/ListChecklistsIntent.swift @@ -1,17 +1,17 @@ import AppIntents -struct ListChecklistsIntent: AppIntent { - static let title: LocalizedStringResource = "List My Checklists" - static let openAppWhenRun: Bool = false +public struct ListChecklistsIntent: AppIntent { + public static let title: LocalizedStringResource = "List My Checklists" + public static let openAppWhenRun: Bool = false private let query: ChecklistEntityQuery - init() { self.query = ChecklistEntityQuery() } + public init() { self.query = ChecklistEntityQuery() } @MainActor - init(store: ChecklistStore) { self.query = ChecklistEntityQuery(store: store) } + public init(store: ChecklistStore) { self.query = ChecklistEntityQuery(store: store) } @MainActor - func perform() async throws -> some IntentResult & ProvidesDialog { + public func perform() async throws -> some IntentResult & ProvidesDialog { let names = try await query.suggestedEntities().map(\.name) return .result(dialog: IntentDialog(ListChecklistsDialogue.message(for: names))) } @@ -35,4 +35,4 @@ enum ListChecklistsDialogue { "You have \(names.count) checklists: \(names.joined(separator: ", ")).", table: "Localizable", bundle: .main) } -} +} \ No newline at end of file diff --git a/CheckStitchCore/Sources/CheckStitchCore/PurchaseEnvironment.swift b/CheckStitchCore/Sources/CheckStitchCore/PurchaseEnvironment.swift new file mode 100644 index 0000000..5004d4d --- /dev/null +++ b/CheckStitchCore/Sources/CheckStitchCore/PurchaseEnvironment.swift @@ -0,0 +1,25 @@ +import Foundation + +/// The process-wide purchase service. The app target replaces `service` at +/// launch with the StoreKit-backed instance; out-of-app processes (widgets, +/// Siri) keep the StoreKit-free default. +@MainActor +public enum PurchaseEnvironment { + public static var service: PurchaseService = PurchaseService( + provider: CachedEntitlementProvider(), + cache: PurchaseEntitlementCache(defaults: AppGroup.defaults)) +} + +/// StoreKit-free provider for cold extension processes: reports the durable +/// verified-entitlement cache and never starts a StoreKit session. +@MainActor +public struct CachedEntitlementProvider: PurchaseProviding { + public init() {} + public func offer() async -> PurchaseOffer? { nil } + public func currentEntitlement() async -> Bool { + PurchaseEntitlementCache(defaults: AppGroup.defaults).isVerified + } + public func purchase() async throws -> Bool { false } + public func restore() async throws -> Bool { false } + public func startObserving(_ onChange: @escaping @MainActor (Bool) -> Void) {} +} \ No newline at end of file diff --git a/CheckStitch/Intents/RunChecklistIntent.swift b/CheckStitchCore/Sources/CheckStitchCore/RunChecklistIntent.swift similarity index 88% rename from CheckStitch/Intents/RunChecklistIntent.swift rename to CheckStitchCore/Sources/CheckStitchCore/RunChecklistIntent.swift index acbff4d..0ca80bd 100644 --- a/CheckStitch/Intents/RunChecklistIntent.swift +++ b/CheckStitchCore/Sources/CheckStitchCore/RunChecklistIntent.swift @@ -1,5 +1,4 @@ import AppIntents -import CheckStitchCore /// Thrown when the entity id no longer resolves (checklist deleted between the /// user's pick and `perform()`). @@ -12,33 +11,33 @@ enum RunChecklistIntentError: LocalizedError { } } -struct RunChecklistIntent: AppIntent { - static let title: LocalizedStringResource = "Run Checklist" - static let openAppWhenRun: Bool = false +public struct RunChecklistIntent: AppIntent { + public static let title: LocalizedStringResource = "Run Checklist" + public static let openAppWhenRun: Bool = false @Parameter(title: "Checklist") - var checklist: ChecklistEntity + public var checklist: ChecklistEntity // test seam; nil → fresh production collaborators private let injectedStore: ChecklistStore? private let injectedTargeting: (any ReminderDestinationTargeting)? private let injectedGate: RunGate? - init() { self.injectedStore = nil; self.injectedTargeting = nil; self.injectedGate = nil } + public init() { self.injectedStore = nil; self.injectedTargeting = nil; self.injectedGate = nil } @MainActor - init(store: ChecklistStore, targeting: ReminderDestinationTargeting, gate: RunGate) { + public init(store: ChecklistStore, targeting: ReminderDestinationTargeting, gate: RunGate) { self.injectedStore = store self.injectedTargeting = targeting self.injectedGate = gate } - static var parameterSummary: some ParameterSummary { + public static var parameterSummary: some ParameterSummary { Summary("Run \(\.$checklist)") } @MainActor - func perform() async throws -> some IntentResult & ProvidesDialog { + public func perform() async throws -> some IntentResult & ProvidesDialog { let store = injectedStore ?? ChecklistStore(defaults: AppGroup.defaults) let targeting = injectedTargeting ?? EventKitReminderDestination.shared @@ -115,4 +114,4 @@ enum RunChecklistDialogue { "Couldn't create reminders for \(checklistName): \(reason)", table: "Localizable", bundle: .main) } } -} +} \ No newline at end of file diff --git a/CheckStitchTests/AppGroupTests.swift b/CheckStitchTests/AppGroupTests.swift index 0c6d10c..0c5e80a 100644 --- a/CheckStitchTests/AppGroupTests.swift +++ b/CheckStitchTests/AppGroupTests.swift @@ -1,4 +1,5 @@ @testable import CheckStitch +import CheckStitchCore import Foundation import Testing diff --git a/CheckStitchTests/EventKitReminderDestinationTests.swift b/CheckStitchTests/EventKitReminderDestinationTests.swift index e7ae016..ad68db3 100644 --- a/CheckStitchTests/EventKitReminderDestinationTests.swift +++ b/CheckStitchTests/EventKitReminderDestinationTests.swift @@ -1,3 +1,4 @@ +import CheckStitchCore import EventKit @testable import CheckStitch import Testing From bc987a39e62df0776a834d394bd75a09b274363d Mon Sep 17 00:00:00 2001 From: Alan Vardy Date: Sat, 26 Sep 2026 13:43:40 -0700 Subject: [PATCH 04/15] Phase 1: Walking skeleton widget --- CheckStitch.xcodeproj/project.pbxproj | 161 ++++++++++++++++++ .../xcschemes/CheckStitchWidget.xcscheme | 77 +++++++++ .../ChecklistWidgetDisplayModel.swift | 49 ++++++ .../ChecklistWidgetDisplayModelTests.swift | 24 +++ .../CheckStitchWidget.entitlements | 10 ++ CheckStitchWidget/ChecklistWidgetBundle.swift | 9 + CheckStitchWidget/Info.plist | 11 ++ CheckStitchWidget/SingleChecklistWidget.swift | 100 +++++++++++ Makefile | 14 +- scripts/test.sh | 4 + scripts/tests/run.sh | 2 +- 11 files changed, 459 insertions(+), 2 deletions(-) create mode 100644 CheckStitch.xcodeproj/xcshareddata/xcschemes/CheckStitchWidget.xcscheme create mode 100644 CheckStitchCore/Sources/CheckStitchCore/ChecklistWidgetDisplayModel.swift create mode 100644 CheckStitchTests/ChecklistWidgetDisplayModelTests.swift create mode 100644 CheckStitchWidget/CheckStitchWidget.entitlements create mode 100644 CheckStitchWidget/ChecklistWidgetBundle.swift create mode 100644 CheckStitchWidget/Info.plist create mode 100644 CheckStitchWidget/SingleChecklistWidget.swift diff --git a/CheckStitch.xcodeproj/project.pbxproj b/CheckStitch.xcodeproj/project.pbxproj index fc572b3..97c6917 100644 --- a/CheckStitch.xcodeproj/project.pbxproj +++ b/CheckStitch.xcodeproj/project.pbxproj @@ -12,6 +12,8 @@ 000000000000000000000062 /* CheckStitchCore in Frameworks */ = {isa = PBXBuildFile; productRef = 000000000000000000000031 /* CheckStitchCore */; }; 000000000000000000000063 /* CheckStitchCore in Frameworks */ = {isa = PBXBuildFile; productRef = 000000000000000000000031 /* CheckStitchCore */; }; 000000000000000000000064 /* CheckStitchWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 000000000000000000000071 /* CheckStitchWatch.app */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 000000000000000000000065 /* CheckStitchCore in Frameworks */ = {isa = PBXBuildFile; productRef = 000000000000000000000031 /* CheckStitchCore */; }; + 000000000000000000000066 /* CheckStitchWidget.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 000000000000000000000081 /* CheckStitchWidget.appex */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -36,6 +38,13 @@ remoteGlobalIDString = 000000000000000400000000; remoteInfo = CheckStitchWatch; }; + 000000000000000500000002 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 000000000000000000000000 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 000000000000000500000000; + remoteInfo = CheckStitchWidget; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -50,6 +59,17 @@ name = "Embed Watch Content"; runOnlyForDeploymentPostprocessing = 0; }; + 000000000000000160000000 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 000000000000000000000066 /* CheckStitchWidget.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @@ -57,6 +77,7 @@ 000000000000000000000041 /* CheckStitchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CheckStitchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 000000000000000000000051 /* CheckStitchUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CheckStitchUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 000000000000000000000071 /* CheckStitchWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CheckStitchWatch.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 000000000000000000000081 /* CheckStitchWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CheckStitchWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -83,6 +104,14 @@ path = CheckStitchWatch; sourceTree = ""; }; + 000000000000000000000080 /* CheckStitchWidget */ = { + exceptions = ( + 0000000000000000000000A2 /* Exceptions for "CheckStitchWidget" folder in "CheckStitchWidget" target */, + ); + isa = PBXFileSystemSynchronizedRootGroup; + path = CheckStitchWidget; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -93,6 +122,13 @@ ); target = 000000000000000100000000 /* CheckStitch */; }; + 0000000000000000000000A2 /* Exceptions for "CheckStitchWidget" folder in "CheckStitchWidget" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 000000000000000500000000 /* CheckStitchWidget */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFrameworksBuildPhase section */ @@ -120,6 +156,12 @@ 000000000000000000000063 /* CheckStitchCore in Frameworks */, ); }; + 000000000000000521000000 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + files = ( + 000000000000000000000065 /* CheckStitchCore in Frameworks */, + ); + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -130,6 +172,7 @@ 000000000000000000000040 /* CheckStitchTests */, 000000000000000000000050 /* CheckStitchUITests */, 000000000000000000000070 /* CheckStitchWatch */, + 000000000000000000000080 /* CheckStitchWidget */, 000000000000000000000020 /* Products */, ); sourceTree = ""; @@ -141,6 +184,7 @@ 000000000000000000000041 /* CheckStitchTests.xctest */, 000000000000000000000051 /* CheckStitchUITests.xctest */, 000000000000000000000071 /* CheckStitchWatch.app */, + 000000000000000000000081 /* CheckStitchWidget.appex */, ); name = Products; sourceTree = ""; @@ -152,6 +196,7 @@ isa = PBXNativeTarget; dependencies = ( 000000000000000400000001 /* PBXTargetDependency */, + 000000000000000500000001 /* PBXTargetDependency */, ); buildConfigurationList = 000000000000000110000000 /* Build configuration list for PBXNativeTarget "CheckStitch" */; buildPhases = ( @@ -159,6 +204,7 @@ 000000000000000130000000 /* Frameworks */, 000000000000000140000000 /* Resources */, 000000000000000150000000 /* Embed Watch Content */, + 000000000000000160000000 /* Embed Foundation Extensions */, ); buildRules = ( ); @@ -242,6 +288,29 @@ productReference = 000000000000000000000071 /* CheckStitchWatch.app */; productType = "com.apple.product-type.application"; }; + 000000000000000500000000 /* CheckStitchWidget */ = { + isa = PBXNativeTarget; + buildConfigurationList = 000000000000000510000000 /* Build configuration list for PBXNativeTarget "CheckStitchWidget" */; + buildPhases = ( + 000000000000000520000000 /* Sources */, + 000000000000000521000000 /* Frameworks */, + 000000000000000522000000 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 000000000000000000000080 /* CheckStitchWidget */, + ); + name = CheckStitchWidget; + packageProductDependencies = ( + 000000000000000000000031 /* CheckStitchCore */, + ); + productName = CheckStitchWidget; + productReference = 000000000000000000000081 /* CheckStitchWidget.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -265,6 +334,9 @@ 000000000000000400000000 = { CreatedOnToolsVersion = 26.3; }; + 000000000000000500000000 = { + CreatedOnToolsVersion = 26.3; + }; }; }; buildConfigurationList = 000000000000000010000000 /* Build configuration list for PBXProject "CheckStitch" */; @@ -293,6 +365,7 @@ 000000000000000200000000 /* CheckStitchTests */, 000000000000000300000000 /* CheckStitchUITests */, 000000000000000400000000 /* CheckStitchWatch */, + 000000000000000500000000 /* CheckStitchWidget */, ); }; /* End PBXProject section */ @@ -318,6 +391,11 @@ files = ( ); }; + 000000000000000522000000 /* Resources */ = { + isa = PBXResourcesBuildPhase; + files = ( + ); + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -341,6 +419,11 @@ files = ( ); }; + 000000000000000520000000 /* Sources */ = { + isa = PBXSourcesBuildPhase; + files = ( + ); + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -360,6 +443,12 @@ target = 000000000000000400000000 /* CheckStitchWatch */; targetProxy = 000000000000000400000002 /* PBXContainerItemProxy */; }; + 000000000000000500000001 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + platformFilter = ios; + target = 000000000000000500000000 /* CheckStitchWidget */; + targetProxy = 000000000000000500000002 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -745,6 +834,70 @@ }; name = Release; }; + 000000000000000511000000 /* Debug configuration for PBXNativeTarget "CheckStitchWidget" */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + "CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]" = CheckStitchWidget/CheckStitchWidget.entitlements; + "CODE_SIGN_ENTITLEMENTS[sdk=iphonesimulator*]" = CheckStitchWidget/CheckStitchWidget.entitlements; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 6NWX2DHB9Q; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = CheckStitchWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = CheckStitch; + INFOPLIST_KEY_NSRemindersFullAccessUsageDescription = "CheckStitch needs access to create reminders."; + INFOPLIST_KEY_NSRemindersUsageDescription = "CheckStitch needs access to create reminders."; + IPHONEOS_DEPLOYMENT_TARGET = 18.7; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = app.alanvardy.CheckStitch.widget; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 000000000000000512000000 /* Release configuration for PBXNativeTarget "CheckStitchWidget" */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + "CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]" = CheckStitchWidget/CheckStitchWidget.entitlements; + "CODE_SIGN_ENTITLEMENTS[sdk=iphonesimulator*]" = CheckStitchWidget/CheckStitchWidget.entitlements; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 6NWX2DHB9Q; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = CheckStitchWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = CheckStitch; + INFOPLIST_KEY_NSRemindersFullAccessUsageDescription = "CheckStitch needs access to create reminders."; + INFOPLIST_KEY_NSRemindersUsageDescription = "CheckStitch needs access to create reminders."; + IPHONEOS_DEPLOYMENT_TARGET = 18.7; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = app.alanvardy.CheckStitch.widget; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -788,6 +941,14 @@ ); defaultConfigurationName = Release; }; + 000000000000000510000000 /* Build configuration list for PBXNativeTarget "CheckStitchWidget" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 000000000000000511000000 /* Debug configuration for PBXNativeTarget "CheckStitchWidget" */, + 000000000000000512000000 /* Release configuration for PBXNativeTarget "CheckStitchWidget" */, + ); + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ diff --git a/CheckStitch.xcodeproj/xcshareddata/xcschemes/CheckStitchWidget.xcscheme b/CheckStitch.xcodeproj/xcshareddata/xcschemes/CheckStitchWidget.xcscheme new file mode 100644 index 0000000..2d5f36f --- /dev/null +++ b/CheckStitch.xcodeproj/xcshareddata/xcschemes/CheckStitchWidget.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/CheckStitchCore/Sources/CheckStitchCore/ChecklistWidgetDisplayModel.swift b/CheckStitchCore/Sources/CheckStitchCore/ChecklistWidgetDisplayModel.swift new file mode 100644 index 0000000..626789d --- /dev/null +++ b/CheckStitchCore/Sources/CheckStitchCore/ChecklistWidgetDisplayModel.swift @@ -0,0 +1,49 @@ +import Foundation + +/// Row the widget renders: one configured checklist, resolved against the store. +public struct ChecklistWidgetRow: Identifiable, Equatable, Sendable { + public let id: Checklist.ID + public let name: String + public let entityID: String // ChecklistEntity.ID == checklist.id.uuidString + public let isRunnable: Bool + public let needsAccess: Bool + + public init(id: Checklist.ID, name: String, entityID: String, + isRunnable: Bool, needsAccess: Bool) { + self.id = id + self.name = name + self.entityID = entityID + self.isRunnable = isRunnable + self.needsAccess = needsAccess + } +} + +public enum ChecklistWidgetAccessState: Equatable, Sendable { + case ready + case needsAccess + case needsPurchase +} + +/// Pure mapping from the store's checklists + the widget configuration to the +/// rows the widget renders. No EventKit, no WidgetKit: unit-testable in the gate. +public struct ChecklistWidgetDisplayModel: Equatable, Sendable { + /// Cap for the multi-row (large) widget; extra configured rows are dropped. + public static let rowLimit = 6 + + public init(checklists: [Checklist], configuration: [ChecklistEntity], + access: ChecklistWidgetAccessState) { + let byID = Dictionary(checklists.map { ($0.id.uuidString, $0) }, + uniquingKeysWith: { first, _ in first }) + self.rows = Array(configuration.prefix(Self.rowLimit).compactMap { entity in + guard let checklist = byID[entity.id] else { return nil } + return ChecklistWidgetRow( + id: checklist.id, + name: checklist.name, + entityID: entity.id, + isRunnable: access == .ready, + needsAccess: access != .ready) + }) + } + + public let rows: [ChecklistWidgetRow] +} \ No newline at end of file diff --git a/CheckStitchTests/ChecklistWidgetDisplayModelTests.swift b/CheckStitchTests/ChecklistWidgetDisplayModelTests.swift new file mode 100644 index 0000000..e50e6b5 --- /dev/null +++ b/CheckStitchTests/ChecklistWidgetDisplayModelTests.swift @@ -0,0 +1,24 @@ +import CheckStitchCore +import Foundation +import Testing + +struct ChecklistWidgetDisplayModelTests { + @Test + func configuredChecklistBecomesARunnableRow() { + let checklist = Checklist(name: "Groceries") + let model = ChecklistWidgetDisplayModel( + checklists: [checklist], + configuration: [ChecklistEntity(checklist)], + access: .ready) + #expect(model.rows.map(\.name) == ["Groceries"]) + #expect(model.rows.first?.isRunnable == true) + #expect(model.rows.first?.needsAccess == false) + } + + @Test + func emptyChecklistsProduceNoRows() { + let model = ChecklistWidgetDisplayModel( + checklists: [], configuration: [], access: .ready) + #expect(model.rows.isEmpty) + } +} \ No newline at end of file diff --git a/CheckStitchWidget/CheckStitchWidget.entitlements b/CheckStitchWidget/CheckStitchWidget.entitlements new file mode 100644 index 0000000..ffa780a --- /dev/null +++ b/CheckStitchWidget/CheckStitchWidget.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.app.alanvardy.CheckStitch + + + \ No newline at end of file diff --git a/CheckStitchWidget/ChecklistWidgetBundle.swift b/CheckStitchWidget/ChecklistWidgetBundle.swift new file mode 100644 index 0000000..b5a194d --- /dev/null +++ b/CheckStitchWidget/ChecklistWidgetBundle.swift @@ -0,0 +1,9 @@ +import SwiftUI +import WidgetKit + +@main +struct ChecklistWidgetBundle: WidgetBundle { + var body: some Widget { + SingleChecklistWidget() + } +} \ No newline at end of file diff --git a/CheckStitchWidget/Info.plist b/CheckStitchWidget/Info.plist new file mode 100644 index 0000000..a5a6b59 --- /dev/null +++ b/CheckStitchWidget/Info.plist @@ -0,0 +1,11 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + \ No newline at end of file diff --git a/CheckStitchWidget/SingleChecklistWidget.swift b/CheckStitchWidget/SingleChecklistWidget.swift new file mode 100644 index 0000000..8aef045 --- /dev/null +++ b/CheckStitchWidget/SingleChecklistWidget.swift @@ -0,0 +1,100 @@ +import AppIntents +import CheckStitchCore +import SwiftUI +import WidgetKit + +struct ChecklistEntry: TimelineEntry { + let date: Date + let model: ChecklistWidgetDisplayModel +} + +@MainActor +enum ChecklistWidgetLoader { + /// Reads the App Group store and folds access state. Phase 1 hard-codes the + /// first checklist; later phases pass the intent's configuration. + static func load(configuration: [ChecklistEntity]) -> ChecklistWidgetDisplayModel { + let checklists = ChecklistStore(defaults: AppGroup.defaults).checklists + let resolved = configuration.isEmpty + ? checklists.first.map { [ChecklistEntity($0)] } ?? [] + : configuration + return ChecklistWidgetDisplayModel( + checklists: checklists, configuration: resolved, access: accessState()) + } + + static func accessState() -> ChecklistWidgetAccessState { + EventKitReminderDestination.shared.accessStatus() == .fullAccess + ? .ready : .needsAccess + } +} + +struct SingleChecklistProvider: TimelineProvider { + func placeholder(in context: Context) -> ChecklistEntry { + ChecklistEntry( + date: .now, + model: ChecklistWidgetDisplayModel( + checklists: [Checklist(name: "Groceries")], + configuration: [ChecklistEntity(id: UUID().uuidString, name: "Groceries")], + access: .ready)) + } + + func getSnapshot(in context: Context, completion: @escaping @Sendable (ChecklistEntry) -> Void) { + Task { @MainActor in + completion(ChecklistEntry(date: .now, model: ChecklistWidgetLoader.load(configuration: []))) + } + } + + func getTimeline(in context: Context, completion: @escaping @Sendable (Timeline) -> Void) { + Task { @MainActor in + let entry = ChecklistEntry(date: .now, model: ChecklistWidgetLoader.load(configuration: [])) + completion(Timeline(entries: [entry], + policy: .after(.now.addingTimeInterval(15 * 60)))) + } + } +} + +struct SingleChecklistWidget: Widget { + static let kind = "SingleChecklistWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: Self.kind, provider: SingleChecklistProvider()) { entry in + SingleChecklistWidgetView(entry: entry) + .containerBackground(.fill.tertiary, for: .widget) + } + .configurationDisplayName("CheckStitch Checklist") + .description("Run a checklist without opening the app.") + .supportedFamilies([.systemSmall]) + } +} + +struct SingleChecklistWidgetView: View { + let entry: ChecklistEntry + + var body: some View { + if let row = entry.model.rows.first { + VStack(alignment: .leading, spacing: 8) { + Text(row.name).font(.headline).lineLimit(2) + if row.isRunnable { + Button(intent: runIntent(for: row)) { + Label("Create reminders", systemImage: "play.circle.fill") + } + .buttonStyle(.borderedProminent) + } else { + Label("Open CheckStitch to enable", systemImage: "exclamationmark.triangle") + .font(.caption) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } else { + Text("No checklists").font(.caption) + } + } + + /// `RunChecklistIntent` has no memberwise init (its `@Parameter` is set by the + /// system / here). Build it and assign the parameter. + @MainActor + private func runIntent(for row: ChecklistWidgetRow) -> RunChecklistIntent { + let intent = RunChecklistIntent() + intent.checklist = ChecklistEntity(id: row.entityID, name: row.name) + return intent + } +} \ No newline at end of file diff --git a/Makefile b/Makefile index afccbc8..d37320d 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,8 @@ SIM ?= $(if $(SIM_FROM_WORKTREE),$(SIM_FROM_WORKTREE),platform=iOS Simulator,nam MAC_SIM := platform=macOS WATCH_SIM := generic/platform=watchOS Simulator WATCH_SCHEME := CheckStitchWatch +WIDGET_SIM := generic/platform=iOS Simulator +WIDGET_SCHEME := CheckStitchWidget SCHEME := CheckStitch CONFIGURATION := Debug DERIVED_DATA := DerivedData @@ -18,7 +20,7 @@ WARNINGS_AS_ERRORS := SWIFT_TREAT_WARNINGS_AS_ERRORS=YES GCC_TREAT_WARNINGS_AS_E APP := $(DERIVED_DATA)/Build/Products/$(CONFIGURATION)-iphonesimulator/$(SCHEME).app MAC_APP := $(DERIVED_DATA)/Build/Products/$(CONFIGURATION)/$(SCHEME).app -.PHONY: build build-mac build-mac-signed run clean test test-unit test-ui watch-build +.PHONY: build build-mac build-mac-signed run clean test test-unit test-ui watch-build widget-build build: xcodebuild -scheme '$(SCHEME)' \ @@ -63,6 +65,16 @@ watch-build: $(WARNINGS_AS_ERRORS) \ build +# The widget extension is a second iOS-only product; compile it here so a broken +# pbxproj/widget scheme edit fails the gate. +widget-build: + xcodebuild -scheme '$(WIDGET_SCHEME)' \ + -destination '$(WIDGET_SIM)' \ + -configuration '$(CONFIGURATION)' \ + -derivedDataPath '$(DERIVED_DATA)' \ + $(WARNINGS_AS_ERRORS) \ + build + run: build bash scripts/run-simulator.sh '$(SIM)' '$(APP)' diff --git a/scripts/test.sh b/scripts/test.sh index 65e0f25..059f5e5 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -114,6 +114,10 @@ make build-mac # pbxproj/watch-scheme edit that the iOS and macOS slices would miss. make watch-build +# The widget extension is a second iOS-only product; compile it here so a broken +# pbxproj/widget scheme edit fails the gate. +make widget-build + if [[ "${GATE_TESTS_SKIP:-}" != "1" ]]; then bash scripts/tests/run.sh fi diff --git a/scripts/tests/run.sh b/scripts/tests/run.sh index 4bfdad6..cf34deb 100755 --- a/scripts/tests/run.sh +++ b/scripts/tests/run.sh @@ -392,7 +392,7 @@ run_case macos_slice_requests_outgoing_network macos_slice_requests_outgoing_net # Every gate leg that compiles Swift must carry the shared Makefile # warnings-as-errors setting. Later phases append their leg here. -WARNINGS_AS_ERRORS_LEGS=(build-mac build test-unit test-ui watch-build) +WARNINGS_AS_ERRORS_LEGS=(build-mac build test-unit test-ui watch-build widget-build) # True when every xcodebuild argv line in $1 carries both compiler flags. warnings_as_errors_logged() { From 1ef98a2d76076347b7b67385cce7215b624f34a9 Mon Sep 17 00:00:00 2001 From: Alan Vardy Date: Sat, 26 Sep 2026 13:46:11 -0700 Subject: [PATCH 05/15] Phase 2: Small widget config --- .../ChecklistConfigurationIntent.swift | 11 ++++++++++ .../ChecklistWidgetDisplayModelTests.swift | 20 ++++++++++++++++++ CheckStitchWidget/SingleChecklistWidget.swift | 21 ++++++++++--------- 3 files changed, 42 insertions(+), 10 deletions(-) create mode 100644 CheckStitchCore/Sources/CheckStitchCore/ChecklistConfigurationIntent.swift diff --git a/CheckStitchCore/Sources/CheckStitchCore/ChecklistConfigurationIntent.swift b/CheckStitchCore/Sources/CheckStitchCore/ChecklistConfigurationIntent.swift new file mode 100644 index 0000000..fb095b1 --- /dev/null +++ b/CheckStitchCore/Sources/CheckStitchCore/ChecklistConfigurationIntent.swift @@ -0,0 +1,11 @@ +import AppIntents + +public struct ChecklistConfigurationIntent: WidgetConfigurationIntent { + public static let title: LocalizedStringResource = "Checklist" + public static let description = IntentDescription("Pick the checklist this widget runs.") + + @Parameter(title: "Checklist") + public var checklist: ChecklistEntity? + + public init() {} +} \ No newline at end of file diff --git a/CheckStitchTests/ChecklistWidgetDisplayModelTests.swift b/CheckStitchTests/ChecklistWidgetDisplayModelTests.swift index e50e6b5..3b74c53 100644 --- a/CheckStitchTests/ChecklistWidgetDisplayModelTests.swift +++ b/CheckStitchTests/ChecklistWidgetDisplayModelTests.swift @@ -21,4 +21,24 @@ struct ChecklistWidgetDisplayModelTests { checklists: [], configuration: [], access: .ready) #expect(model.rows.isEmpty) } + + @Test + func selectedEntityResolvesToItsRowWhateverTheStoreOrder() { + let groceries = Checklist(name: "Groceries") + let packing = Checklist(name: "Packing") + let model = ChecklistWidgetDisplayModel( + checklists: [groceries, packing], + configuration: [ChecklistEntity(packing)], + access: .ready) + #expect(model.rows.map(\.name) == ["Packing"]) + } + + @Test + func ghostEntityProducesNoRows() { + let model = ChecklistWidgetDisplayModel( + checklists: [Checklist(name: "Groceries")], + configuration: [ChecklistEntity(id: UUID().uuidString, name: "Deleted")], + access: .ready) + #expect(model.rows.isEmpty) + } } \ No newline at end of file diff --git a/CheckStitchWidget/SingleChecklistWidget.swift b/CheckStitchWidget/SingleChecklistWidget.swift index 8aef045..bb54dd2 100644 --- a/CheckStitchWidget/SingleChecklistWidget.swift +++ b/CheckStitchWidget/SingleChecklistWidget.swift @@ -27,7 +27,7 @@ enum ChecklistWidgetLoader { } } -struct SingleChecklistProvider: TimelineProvider { +struct SingleChecklistProvider: AppIntentTimelineProvider { func placeholder(in context: Context) -> ChecklistEntry { ChecklistEntry( date: .now, @@ -37,18 +37,19 @@ struct SingleChecklistProvider: TimelineProvider { access: .ready)) } - func getSnapshot(in context: Context, completion: @escaping @Sendable (ChecklistEntry) -> Void) { - Task { @MainActor in - completion(ChecklistEntry(date: .now, model: ChecklistWidgetLoader.load(configuration: []))) + func snapshot(for configuration: ChecklistConfigurationIntent, in context: Context) async -> ChecklistEntry { + let model = await MainActor.run { + ChecklistWidgetLoader.load(configuration: configuration.checklist.map { [$0] } ?? []) } + return ChecklistEntry(date: .now, model: model) } - func getTimeline(in context: Context, completion: @escaping @Sendable (Timeline) -> Void) { - Task { @MainActor in - let entry = ChecklistEntry(date: .now, model: ChecklistWidgetLoader.load(configuration: [])) - completion(Timeline(entries: [entry], - policy: .after(.now.addingTimeInterval(15 * 60)))) + func timeline(for configuration: ChecklistConfigurationIntent, in context: Context) async -> Timeline { + let model = await MainActor.run { + ChecklistWidgetLoader.load(configuration: configuration.checklist.map { [$0] } ?? []) } + return Timeline(entries: [ChecklistEntry(date: .now, model: model)], + policy: .after(.now.addingTimeInterval(15 * 60))) } } @@ -56,7 +57,7 @@ struct SingleChecklistWidget: Widget { static let kind = "SingleChecklistWidget" var body: some WidgetConfiguration { - StaticConfiguration(kind: Self.kind, provider: SingleChecklistProvider()) { entry in + AppIntentConfiguration(kind: Self.kind, intent: ChecklistConfigurationIntent.self, provider: SingleChecklistProvider()) { entry in SingleChecklistWidgetView(entry: entry) .containerBackground(.fill.tertiary, for: .widget) } From b6d850f3bbf444c1cb968423c7c33e16f320bb81 Mon Sep 17 00:00:00 2001 From: Alan Vardy Date: Sat, 26 Sep 2026 13:50:40 -0700 Subject: [PATCH 06/15] Phase 3: Large widget --- .../MultiChecklistConfigurationIntent.swift | 11 +++ .../ChecklistWidgetDisplayModelTests.swift | 23 ++++++ CheckStitchWidget/ChecklistWidgetBundle.swift | 1 + CheckStitchWidget/MultiChecklistWidget.swift | 78 +++++++++++++++++++ 4 files changed, 113 insertions(+) create mode 100644 CheckStitchCore/Sources/CheckStitchCore/MultiChecklistConfigurationIntent.swift create mode 100644 CheckStitchWidget/MultiChecklistWidget.swift diff --git a/CheckStitchCore/Sources/CheckStitchCore/MultiChecklistConfigurationIntent.swift b/CheckStitchCore/Sources/CheckStitchCore/MultiChecklistConfigurationIntent.swift new file mode 100644 index 0000000..fb3457a --- /dev/null +++ b/CheckStitchCore/Sources/CheckStitchCore/MultiChecklistConfigurationIntent.swift @@ -0,0 +1,11 @@ +import AppIntents + +public struct MultiChecklistConfigurationIntent: WidgetConfigurationIntent { + public static let title: LocalizedStringResource = "Checklists" + public static let description = IntentDescription("Pick the checklists this widget runs.") + + @Parameter(title: "Checklists") + public var checklists: [ChecklistEntity]? + + public init() {} +} \ No newline at end of file diff --git a/CheckStitchTests/ChecklistWidgetDisplayModelTests.swift b/CheckStitchTests/ChecklistWidgetDisplayModelTests.swift index 3b74c53..45754cf 100644 --- a/CheckStitchTests/ChecklistWidgetDisplayModelTests.swift +++ b/CheckStitchTests/ChecklistWidgetDisplayModelTests.swift @@ -33,6 +33,29 @@ struct ChecklistWidgetDisplayModelTests { #expect(model.rows.map(\.name) == ["Packing"]) } + @Test + func configurationOrderIsPreservedAndMissingEntitiesAreDropped() { + let a = Checklist(name: "A") + let b = Checklist(name: "B") + let c = Checklist(name: "C") + let model = ChecklistWidgetDisplayModel( + checklists: [a, b, c], + configuration: [ChecklistEntity(c), ChecklistEntity(id: UUID().uuidString, name: "Ghost"), ChecklistEntity(a)], + access: .ready) + #expect(model.rows.map(\.name) == ["C", "A"]) + } + + @Test + func rowsAreCappedToTheRowBudget() { + let checklists = (0..<9).map { Checklist(name: "List \($0)") } + let model = ChecklistWidgetDisplayModel( + checklists: checklists, + configuration: checklists.map(ChecklistEntity.init), + access: .ready) + #expect(model.rows.count == ChecklistWidgetDisplayModel.rowLimit) + #expect(model.rows.map(\.name) == (0.. ChecklistEntry { + ChecklistEntry( + date: .now, + model: ChecklistWidgetDisplayModel( + checklists: [Checklist(name: "Groceries"), Checklist(name: "Packing")], + configuration: [ChecklistEntity(id: UUID().uuidString, name: "Groceries"), + ChecklistEntity(id: UUID().uuidString, name: "Packing")], + access: .ready)) + } + + func snapshot(for configuration: MultiChecklistConfigurationIntent, in context: Context) async -> ChecklistEntry { + let model = await MainActor.run { + ChecklistWidgetLoader.load(configuration: configuration.checklists ?? []) + } + return ChecklistEntry(date: .now, model: model) + } + + func timeline(for configuration: MultiChecklistConfigurationIntent, in context: Context) async -> Timeline { + let model = await MainActor.run { + ChecklistWidgetLoader.load(configuration: configuration.checklists ?? []) + } + return Timeline(entries: [ChecklistEntry(date: .now, model: model)], + policy: .after(.now.addingTimeInterval(15 * 60))) + } +} + +struct MultiChecklistWidget: Widget { + static let kind = "MultiChecklistWidget" + + var body: some WidgetConfiguration { + AppIntentConfiguration(kind: Self.kind, + intent: MultiChecklistConfigurationIntent.self, + provider: MultiChecklistProvider()) { entry in + MultiChecklistWidgetView(entry: entry) + .containerBackground(.fill.tertiary, for: .widget) + } + .configurationDisplayName("CheckStitch Checklists") + .description("Run any of your checklists without opening the app.") + .supportedFamilies([.systemLarge]) + } +} + +struct MultiChecklistWidgetView: View { + let entry: ChecklistEntry + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + ForEach(entry.model.rows) { row in + HStack { + Text(row.name).font(.body).lineLimit(1) + Spacer() + if row.isRunnable { + Button(intent: runIntent(for: row)) { + Image(systemName: "play.circle.fill") + } + .buttonStyle(.plain) + } else { + Image(systemName: "exclamationmark.triangle").font(.caption) + } + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + @MainActor + private func runIntent(for row: ChecklistWidgetRow) -> RunChecklistIntent { + let intent = RunChecklistIntent() + intent.checklist = ChecklistEntity(id: row.entityID, name: row.name) + return intent + } +} \ No newline at end of file From 3201944cebf32c81e42bb5c92a34a0cde94f1126 Mon Sep 17 00:00:00 2001 From: Alan Vardy Date: Sat, 26 Sep 2026 13:54:12 -0700 Subject: [PATCH 07/15] Phase 4: Access and purchase states --- CheckStitch/Info.plist | 11 ++++++++++ .../ChecklistWidgetDisplayModelTests.swift | 20 +++++++++++++++++++ CheckStitchWidget/MultiChecklistWidget.swift | 4 +++- CheckStitchWidget/SingleChecklistWidget.swift | 11 ++++++++-- 4 files changed, 43 insertions(+), 3 deletions(-) diff --git a/CheckStitch/Info.plist b/CheckStitch/Info.plist index 1be32f1..c1fb40f 100644 --- a/CheckStitch/Info.plist +++ b/CheckStitch/Info.plist @@ -17,6 +17,17 @@ + CFBundleURLTypes + + + CFBundleURLName + app.alanvardy.CheckStitch + CFBundleURLSchemes + + checkstitch + + + LSSupportsOpeningDocumentsInPlace diff --git a/CheckStitchTests/ChecklistWidgetDisplayModelTests.swift b/CheckStitchTests/ChecklistWidgetDisplayModelTests.swift index 45754cf..d423f40 100644 --- a/CheckStitchTests/ChecklistWidgetDisplayModelTests.swift +++ b/CheckStitchTests/ChecklistWidgetDisplayModelTests.swift @@ -64,4 +64,24 @@ struct ChecklistWidgetDisplayModelTests { access: .ready) #expect(model.rows.isEmpty) } + + @Test(arguments: [ChecklistWidgetAccessState.needsAccess, .needsPurchase]) + func nonReadyAccessMakesEveryRowNonRunnable(_ access: ChecklistWidgetAccessState) { + let checklist = Checklist(name: "Groceries") + let model = ChecklistWidgetDisplayModel( + checklists: [checklist], + configuration: [ChecklistEntity(checklist)], + access: access) + #expect(model.rows.allSatisfy { !$0.isRunnable && $0.needsAccess }) + } + + @Test + func readyAccessMakesRowsRunnable() { + let checklist = Checklist(name: "Groceries") + let model = ChecklistWidgetDisplayModel( + checklists: [checklist], + configuration: [ChecklistEntity(checklist)], + access: .ready) + #expect(model.rows.allSatisfy { $0.isRunnable && !$0.needsAccess }) + } } \ No newline at end of file diff --git a/CheckStitchWidget/MultiChecklistWidget.swift b/CheckStitchWidget/MultiChecklistWidget.swift index 365dd80..a03bea7 100644 --- a/CheckStitchWidget/MultiChecklistWidget.swift +++ b/CheckStitchWidget/MultiChecklistWidget.swift @@ -61,9 +61,11 @@ struct MultiChecklistWidgetView: View { } .buttonStyle(.plain) } else { - Image(systemName: "exclamationmark.triangle").font(.caption) + Label("Open CheckStitch to enable", systemImage: "exclamationmark.triangle") + .font(.caption) } } + .widgetURL(row.needsAccess ? URL(string: "checkstitch://") : nil) } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) diff --git a/CheckStitchWidget/SingleChecklistWidget.swift b/CheckStitchWidget/SingleChecklistWidget.swift index bb54dd2..dc4d5ee 100644 --- a/CheckStitchWidget/SingleChecklistWidget.swift +++ b/CheckStitchWidget/SingleChecklistWidget.swift @@ -22,8 +22,14 @@ enum ChecklistWidgetLoader { } static func accessState() -> ChecklistWidgetAccessState { - EventKitReminderDestination.shared.accessStatus() == .fullAccess - ? .ready : .needsAccess + switch EventKitReminderDestination.shared.accessStatus() { + case .fullAccess: + let unlocked = PurchaseEntitlementCache(defaults: AppGroup.defaults).isVerified + let used = RunCounter(defaults: AppGroup.defaults).count + return (!unlocked && used >= RunGate.freeRunLimit) ? .needsPurchase : .ready + case .notDetermined, .denied: + return .needsAccess + } } } @@ -85,6 +91,7 @@ struct SingleChecklistWidgetView: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .widgetURL(row.needsAccess ? URL(string: "checkstitch://") : nil) } else { Text("No checklists").font(.caption) } From af38e98a2d0eeb1641d3badb102ee27f02a67af4 Mon Sep 17 00:00:00 2001 From: Alan Vardy Date: Sat, 26 Sep 2026 14:01:13 -0700 Subject: [PATCH 08/15] Phase 5: Hardening and localization --- CheckStitchTests/LocalizationFixtures.swift | 17 +- .../LocalizationTestHelpers.swift | 3 +- CheckStitchWidget/Localizable.xcstrings | 539 ++++++++++++++++++ CheckStitchWidget/MultiChecklistWidget.swift | 47 +- CheckStitchWidget/SingleChecklistWidget.swift | 13 + scripts/l10n-check.sh | 1 + 6 files changed, 604 insertions(+), 16 deletions(-) create mode 100644 CheckStitchWidget/Localizable.xcstrings diff --git a/CheckStitchTests/LocalizationFixtures.swift b/CheckStitchTests/LocalizationFixtures.swift index 5499bc3..707063e 100644 --- a/CheckStitchTests/LocalizationFixtures.swift +++ b/CheckStitchTests/LocalizationFixtures.swift @@ -5,7 +5,7 @@ enum LocalizationFixtures { /// Catalogs guarded by the non-English-differs canary. Watch is included: /// all five of its current translations differ from English, so it needs no /// exclusion entries, and the canary then catches an English regression there. - static let guardedCatalogs: Set = ["App", "Core", "Watch"] + static let guardedCatalogs: Set = ["App", "Core", "Watch", "Widget"] /// Every key each catalog must carry. Guards against a key being dropped /// from the catalog (the UI would then render the raw key at runtime). @@ -144,6 +144,21 @@ enum LocalizationFixtures { "Sent", "Create reminders", ]), + ("Widget", [ + "CheckStitch Checklist", + "CheckStitch Checklists", + "Run a checklist without opening the app.", + "Run any of your checklists without opening the app.", + "Checklist", + "Checklists", + "Pick the checklist this widget runs.", + "Pick the checklists this widget runs.", + "Create reminders", + "Open CheckStitch to enable", + "Open CheckStitch to buy a license", + "No checklists", + "Edit this widget to pick a checklist", + ]), ] /// Per-target `InfoPlist.strings` files and the keys each must carry. diff --git a/CheckStitchTests/LocalizationTestHelpers.swift b/CheckStitchTests/LocalizationTestHelpers.swift index ea31fdd..ed745bf 100644 --- a/CheckStitchTests/LocalizationTestHelpers.swift +++ b/CheckStitchTests/LocalizationTestHelpers.swift @@ -72,10 +72,11 @@ enum Catalogs { .deletingLastPathComponent() // CheckStitchTests/ .deletingLastPathComponent() // repo root - /// The three catalogs under test. + /// The four catalogs under test. static let all: [(name: String, url: URL)] = [ ("App", repoRoot.appendingPathComponent("CheckStitch/Localizable.xcstrings")), ("Watch", repoRoot.appendingPathComponent("CheckStitchWatch/Localizable.xcstrings")), + ("Widget", repoRoot.appendingPathComponent("CheckStitchWidget/Localizable.xcstrings")), ("Core", repoRoot.appendingPathComponent( "CheckStitchCore/Sources/CheckStitchCore/Resources/Localizable.xcstrings")), ] diff --git a/CheckStitchWidget/Localizable.xcstrings b/CheckStitchWidget/Localizable.xcstrings new file mode 100644 index 0000000..e9048e6 --- /dev/null +++ b/CheckStitchWidget/Localizable.xcstrings @@ -0,0 +1,539 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "CheckStitch Checklist" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "CheckStitch Checklist" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Liste de contrôle CheckStitch" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lista de verificación de CheckStitch" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "CheckStitch-Checkliste" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "CheckStitch チェックリスト" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "CheckStitch 清单" + } + } + } + }, + "CheckStitch Checklists" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "CheckStitch Checklists" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Listes de contrôle CheckStitch" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Listas de verificación de CheckStitch" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "CheckStitch-Checklisten" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "CheckStitch チェックリスト" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "CheckStitch 清单" + } + } + } + }, + "Run a checklist without opening the app." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Run a checklist without opening the app." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exécutez une liste de contrôle sans ouvrir l'application." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ejecuta una lista de verificación sin abrir la aplicación." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Führe eine Checkliste aus, ohne die App zu öffnen." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アプリを開かずにチェックリストを実行します。" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无需打开应用即可运行清单。" + } + } + } + }, + "Run any of your checklists without opening the app." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Run any of your checklists without opening the app." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exécutez n'importe laquelle de vos listes de contrôle sans ouvrir l'application." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ejecuta cualquiera de tus listas de verificación sin abrir la aplicación." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Führe beliebige deiner Checklisten aus, ohne die App zu öffnen." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アプリを開かずに任意のチェックリストを実行します。" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无需打开应用即可运行任意清单。" + } + } + } + }, + "Checklist" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Checklist" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Liste de contrôle" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lista de verificación" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Checkliste" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "チェックリスト" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "清单" + } + } + } + }, + "Checklists" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Checklists" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Listes de contrôle" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Listas de verificación" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Checklisten" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "チェックリスト" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "清单" + } + } + } + }, + "Pick the checklist this widget runs." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pick the checklist this widget runs." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choisissez la liste de contrôle que ce widget exécute." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elige la lista de verificación que ejecuta este widget." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wähle die Checkliste, die dieses Widget ausführt." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "このウィジェットが実行するチェックリストを選択します。" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择此小部件运行的清单。" + } + } + } + }, + "Pick the checklists this widget runs." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pick the checklists this widget runs." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choisissez les listes de contrôle que ce widget exécute." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elige las listas de verificación que ejecuta este widget." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wähle die Checklisten, die dieses Widget ausführt." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "このウィジェットが実行するチェックリストを選択します。" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择此小部件运行的清单。" + } + } + } + }, + "Create reminders" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Create reminders" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Créer des rappels" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Crear recordatorios" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erinnerungen erstellen" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "リマインダーを作成" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "创建提醒" + } + } + } + }, + "Open CheckStitch to enable" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open CheckStitch to enable" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ouvrir CheckStitch pour activer" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abrir CheckStitch para habilitar" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Öffne CheckStitch zum Aktivieren" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "CheckStitchを開いて有効にする" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "打开 CheckStitch 以启用" + } + } + } + }, + "Open CheckStitch to buy a license" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open CheckStitch to buy a license" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ouvrir CheckStitch pour acheter une licence" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abrir CheckStitch para comprar una licencia" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Öffne CheckStitch zum Kauf einer Lizenz" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "CheckStitchを開いてライセンスを購入" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "打开 CheckStitch 购买许可证" + } + } + } + }, + "No checklists" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No checklists" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucune liste de contrôle" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin listas de verificación" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Checklisten" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "チェックリストがありません" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "没有清单" + } + } + } + }, + "Edit this widget to pick a checklist" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Edit this widget to pick a checklist" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modifiez ce widget pour choisir une liste de contrôle" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Edita este widget para elegir una lista de verificación" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bearbeite dieses Widget, um eine Checkliste auszuwählen" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "このウィジェットを編集してチェックリストを選択" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "编辑此小部件以选择清单" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/CheckStitchWidget/MultiChecklistWidget.swift b/CheckStitchWidget/MultiChecklistWidget.swift index a03bea7..beed506 100644 --- a/CheckStitchWidget/MultiChecklistWidget.swift +++ b/CheckStitchWidget/MultiChecklistWidget.swift @@ -50,25 +50,30 @@ struct MultiChecklistWidgetView: View { let entry: ChecklistEntry var body: some View { - VStack(alignment: .leading, spacing: 6) { - ForEach(entry.model.rows) { row in - HStack { - Text(row.name).font(.body).lineLimit(1) - Spacer() - if row.isRunnable { - Button(intent: runIntent(for: row)) { - Image(systemName: "play.circle.fill") + if entry.model.rows.isEmpty { + Text("Edit this widget to pick a checklist").font(.caption) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } else { + VStack(alignment: .leading, spacing: 6) { + ForEach(entry.model.rows) { row in + HStack { + Text(row.name).font(.body).lineLimit(1) + Spacer() + if row.isRunnable { + Button(intent: runIntent(for: row)) { + Image(systemName: "play.circle.fill") + } + .buttonStyle(.plain) + } else { + Label("Open CheckStitch to enable", systemImage: "exclamationmark.triangle") + .font(.caption) } - .buttonStyle(.plain) - } else { - Label("Open CheckStitch to enable", systemImage: "exclamationmark.triangle") - .font(.caption) } + .widgetURL(row.needsAccess ? URL(string: "checkstitch://") : nil) } - .widgetURL(row.needsAccess ? URL(string: "checkstitch://") : nil) } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } @MainActor @@ -77,4 +82,18 @@ struct MultiChecklistWidgetView: View { intent.checklist = ChecklistEntity(id: row.entityID, name: row.name) return intent } +} + +// MARK: - Previews + +#Preview("Checklists", as: .systemLarge) { + MultiChecklistWidget() +} timeline: { + ChecklistEntry( + date: Date(), + model: ChecklistWidgetDisplayModel( + checklists: [Checklist(name: "Groceries"), Checklist(name: "Packing")], + configuration: [ChecklistEntity(id: UUID().uuidString, name: "Groceries"), + ChecklistEntity(id: UUID().uuidString, name: "Packing")], + access: .ready)) } \ No newline at end of file diff --git a/CheckStitchWidget/SingleChecklistWidget.swift b/CheckStitchWidget/SingleChecklistWidget.swift index dc4d5ee..e006a48 100644 --- a/CheckStitchWidget/SingleChecklistWidget.swift +++ b/CheckStitchWidget/SingleChecklistWidget.swift @@ -105,4 +105,17 @@ struct SingleChecklistWidgetView: View { intent.checklist = ChecklistEntity(id: row.entityID, name: row.name) return intent } +} + +// MARK: - Previews + +#Preview("Checklist", as: .systemSmall) { + SingleChecklistWidget() +} timeline: { + ChecklistEntry( + date: Date(), + model: ChecklistWidgetDisplayModel( + checklists: [Checklist(name: "Groceries")], + configuration: [ChecklistEntity(id: UUID().uuidString, name: "Groceries")], + access: .ready)) } \ No newline at end of file diff --git a/scripts/l10n-check.sh b/scripts/l10n-check.sh index 332c3dd..af7ce1d 100755 --- a/scripts/l10n-check.sh +++ b/scripts/l10n-check.sh @@ -22,6 +22,7 @@ CATALOGS = { "App": Path("CheckStitch/Localizable.xcstrings"), "Core": Path("CheckStitchCore/Sources/CheckStitchCore/Resources/Localizable.xcstrings"), "Watch": Path("CheckStitchWatch/Localizable.xcstrings"), + "Widget": Path("CheckStitchWidget/Localizable.xcstrings"), } fixtures = Path("CheckStitchTests/LocalizationFixtures.swift").read_text() From 31be921049718dd2d99b60c43b1fc014bb4529c4 Mon Sep 17 00:00:00 2001 From: Alan Vardy Date: Sat, 26 Sep 2026 14:36:36 -0700 Subject: [PATCH 09/15] docs: add VAR-1065 workflow artifacts --- .../conventions.md | 71 ++ .../alanvardy-var-1065-add-widgets/design.md | 193 +++ .../implement.md | 86 ++ .../alanvardy-var-1065-add-widgets/large.md | 14 + .../alanvardy-var-1065-add-widgets/plan.md | 1078 +++++++++++++++++ .../questions.md | 47 + .../research.md | 111 ++ .../structure.md | 157 +++ .../alanvardy-var-1065-add-widgets/task.md | 6 + 9 files changed, 1763 insertions(+) create mode 100644 .pi/orksorksorks/alanvardy-var-1065-add-widgets/conventions.md create mode 100644 .pi/orksorksorks/alanvardy-var-1065-add-widgets/design.md create mode 100644 .pi/orksorksorks/alanvardy-var-1065-add-widgets/implement.md create mode 100644 .pi/orksorksorks/alanvardy-var-1065-add-widgets/large.md create mode 100644 .pi/orksorksorks/alanvardy-var-1065-add-widgets/plan.md create mode 100644 .pi/orksorksorks/alanvardy-var-1065-add-widgets/questions.md create mode 100644 .pi/orksorksorks/alanvardy-var-1065-add-widgets/research.md create mode 100644 .pi/orksorksorks/alanvardy-var-1065-add-widgets/structure.md create mode 100644 .pi/orksorksorks/alanvardy-var-1065-add-widgets/task.md diff --git a/.pi/orksorksorks/alanvardy-var-1065-add-widgets/conventions.md b/.pi/orksorksorks/alanvardy-var-1065-add-widgets/conventions.md new file mode 100644 index 0000000..9bc306b --- /dev/null +++ b/.pi/orksorksorks/alanvardy-var-1065-add-widgets/conventions.md @@ -0,0 +1,71 @@ +# Conventions — shared factual appendix + +Repo root: `/Users/vardy/dev/CheckStitch`. + +## Build / test / lint / verify commands + +- **Gate** (full): `./scripts/test.sh` — the single authoritative gate. +- `make build` — simulator build, scheme `CheckStitch` (gate leg 1). +- `make build-mac` — unsigned macOS compile leg (gate's platform check; `CODE_SIGNING_ALLOWED=NO`). +- `make build-mac-signed` — signed, runnable macOS app (`-allowProvisioningUpdates`, team-signs so `AppGroup.entitlements` incl. KVS id is embedded). Outside warnings-as-errors enforcement. +- `make run` — build then boot/install/launch on simulator. +- `make watch-build` — watchOS simulator compile of `CheckStitchWatch` (same `CheckStitchCore`, unsigned, sim-free). +- `make test` = `make test-unit` + `make test-ui`. +- `make test-unit` — `CheckStitchTests` on `platform=macOS`, `CODE_SIGNING_ALLOWED=NO`, warnings-as-errors. +- `make test-ui` — exactly one `CheckStitchUITests` smoke via `build-for-testing` + `test-without-building` on `$(SIM)`. +- `bash scripts/run-watch.sh` — build `CheckStitchWatch`, install + launch on paired watch (devicectl, name→identifier). +- `bash scripts/run-devices.sh` — install + launch on real device / host Mac / watch; honours `SCHEME`, `BUNDLE_ID`, `CONFIGURATION`, `DERIVED_DATA`; `RUN_WATCH=0` skips watch. +- `bash scripts/tests/run.sh` — shell tests (also run by gate); stubs `xcrun`/`defaults`/`make`/`open`/`osascript`. +- `scripts/l10n-check.sh` — localization key check; run **before** adding a user-facing string. +- `shellcheck scripts/*.sh scripts/tests/*.sh` — gate-lint; scripts must parse under `/bin/bash` 3.2. + +## Warnings-as-errors enforcement + +- `WARNINGS_AS_ERRORS := SWIFT_TREAT_WARNINGS_AS_ERRORS=YES GCC_TREAT_WARNINGS_AS_ERRORS=YES` (`Makefile:20`) must reach every compiling Swift gate leg; a compiler warning fails the gate. +- Pinned legs: `scripts/tests/run.sh:395` `WARNINGS_AS_ERRORS_LEGS=(build-mac build test-unit test-ui watch-build)`; `:398-416` asserts each logs both flags; `:421-429` detects a stripped flag. +- `run.sh:369-385` asserts every `ENABLE_APP_SANDBOX=YES` buildSettings block also has `ENABLE_OUTGOING_NETWORK_CONNECTIONS=YES`. +- Device helper legs (`build-mac-signed`, `run-watch.sh`, `run-devices.sh`) are intentionally outside enforcement. + +## Test-suite inventory + +| Path | Covers | Platform / gating | +|---|---|---| +| `CheckStitchTests/ChecklistCreatorTests.swift` | Core checklist creation model | Swift Testing, macOS | +| `CheckStitchTests/ChecklistRunViewModelTests.swift` | run view-model flow | `@MainActor` | +| `CheckStitchTests/ChecklistStoreTests.swift` | store loads/mutators/saves | macOS | +| `CheckStitchTests/ChecklistCodecTests.swift` | encode/decode/classify/migrations | macOS | +| `CheckStitchTests/EventKitReminderCreatorTests.swift` | legacy EventKit creator | `@MainActor` | +| `CheckStitchTests/EventKitReminderDestinationTests.swift` | destination seam | `@MainActor` | +| `CheckStitchTests/ChecklistRemindersTests.swift` | orchestration + run gate | macOS | +| `CheckStitchTests/RunChecklistIntentTests.swift` | Siri intent perform (incl. ghost-id) | `@MainActor`, injected seams | +| `CheckStitchTests/ListChecklistsIntentTests.swift` | list intent | `@MainActor` | +| `CheckStitchTests/ChecklistEntityQueryTests.swift` | entity query | `@MainActor` | +| `CheckStitchTests/ContentViewSettingsActionTests.swift` | settings action | macOS | +| `CheckStitchTests/WatchChecklistStoreTests.swift` | watch KVS store | macOS | +| `CheckStitchTests/AppGroupTests.swift` | entitlement suite-name pinning | macOS | +| `CheckStitchTests/LocalizationFixtures.swift` | `requiredKeys` for new string keys | shared fixture | +| `CheckStitchUITests/CheckStitchUITests.swift` | one XCTest smoke | simulator, XCTest | + +Notes: +- Unit suites use Swift Testing (`@Test`, `#expect`, behaviour-named functions). `@MainActor` on any suite touching EventKit or a view model. +- Test targets deliberately do **not** set `SWIFT_DEFAULT_ACTOR_ISOLATION`; suites opt in with `@MainActor`. Never restore the app default in test targets. +- Fakes live in `CheckStitchTests/TestFixtures.swift` (incl. `SpyReminderDestination`). + +## Gotchas surfaced by research + +- **Destination precedence** (`Makefile:1-19`): explicit `SIM=` > this worktree's `.simulator_id` > `name=iPhone 17` fallback. Never a bare `name=` destination in a script — it selects a shared device and wedges parallel agents. +- **Simulator lock** (`scripts/test.sh:32-…`): bounded host lock `${TMPDIR:-/tmp}/checkstitch-simulator.lock`; single EXIT trap releases it and shuts down only the gate's resolved UDID (never `all`/`booted`). `LOCK_TIMEOUT` default 60; on timeout the gate warns and runs unlocked. +- **New Swift files need no pbxproj edit** (`PBXFileSystemSynchronizedRootGroup`, `pbxproj:62-86`). A new **target** does need a `PBXNativeTarget` + committed shared scheme + Makefile leg + gate leg. +- **App Group / KVS**: `AppGroup.defaults = UserDefaults(suiteName:"group.app.alanvardy.CheckStitch") ?? .standard` (`CheckStitch/AppGroup.swift:6-11`); pinned byte-for-byte in `CheckStitch/AppGroup.entitlements`. Only signed slices embed entitlements (so only signed builds sync KV through iCloud). KVS ubiquity entitlement `com.apple.developer.ubiquity-kvstore-identifier = $(TeamIdentifierPrefix)app.alanvardy.CheckStitch`. +- **Fresh store per caller** is the established pattern for out-of-app callers: `ChecklistStore(defaults: AppGroup.defaults)` (`RunChecklistIntent.swift:42`, `ChecklistEntity.swift:28-31`). No singleton store exists. +- **Cold-process no-prompt rule**: a non-app process must not prompt for EventKit permission (`RunChecklistIntent.swift:53-54`); do a status-only `accessStatus()` pre-check. +- **Store ownership**: app's single store is created in `MyApp.init()` and injected; sync (`ChecklistSyncService`) is started there too. +- **User-facing strings**: new keys need all 6 languages in `CheckStitch/Localizable.xcstrings` (and `CheckStitchWatch/Localizable.xcstrings` for watch) + a `LocalizationFixtures.requiredKeys` entry; run `scripts/l10n-check.sh` first. `.lproj/` holds only `InfoPlist.strings`. + +## Signing values (working, do not re-derive) + +- `DEVELOPMENT_TEAM = 6NWX2DHB9Q` +- Bundle id `app.alanvardy.CheckStitch` (watch: `app.alanvardy.CheckStitch.watchkitapp`) +- App Group `group.app.alanvardy.CheckStitch` +- macOS slice signs with same team; `CODE_SIGN_IDENTITY[sdk=macosx*] = "Apple Development"`. +- On a machine without the profile, add `-allowProvisioningUpdates`. \ No newline at end of file diff --git a/.pi/orksorksorks/alanvardy-var-1065-add-widgets/design.md b/.pi/orksorksorks/alanvardy-var-1065-add-widgets/design.md new file mode 100644 index 0000000..7afaed0 --- /dev/null +++ b/.pi/orksorksorks/alanvardy-var-1065-add-widgets/design.md @@ -0,0 +1,193 @@ +# Design Discussion + +Ticket: VAR-1065 — home-screen widgets for CheckStitch. Repo root: +`/Users/vardy/dev/CheckStitch`; refs below are relative to it. + +## Current State + +- **Domain + store.** `ChecklistStore` is an `@Observable final class` in the + *app target* (`CheckStitch/ChecklistStore.swift:3`), storage = one App-Group + `UserDefaults` key `checklists.v1` holding a versioned `ChecklistEnvelope` + (`:104-111`). No singleton: `MyApp.init()` owns one instance in `@State` and + injects it (`MyApp.swift:34-46`, `ContentView.swift:632-643`). Out-of-app + callers construct `ChecklistStore(defaults: AppGroup.defaults)` fresh per + call (`RunChecklistIntent.swift:42`, `ChecklistEntity.swift:28-31`). +- **Run chain.** The only live path that materialises reminders is + `ChecklistReminders.create(from:targeting:gate:)` (`ChecklistReminders.swift:28-67`): + `gate.reserveRun()` → access → list snapshot → per-item `targeting.create` → + release the slot unless something was created. `RunGate` / + `RunCounter(freeRunLimit: 20)` are already in **Core** + (`CheckStitchCore/.../RunCounter.swift:51,66,71-92`). + `EventKitReminderDestination.shared` is `@MainActor`, holds a long-lived + `EKEventStore` (`EventKitReminderDestination.swift:10-11`). +- **Out-of-app precedent.** `RunChecklistIntent` already runs a checklist with + `openAppWhenRun=false` (`RunChecklistIntent.swift:17`): fresh store, shared + destination, **status-only** EventKit pre-check (never prompts from a cold + process, `:53-64`), gate via `ChecklistReminders.productionGate()`, outcome → + dialog (`:66-69`). `ChecklistEntity` is keyed by `Checklist.id.uuidString` + (rename-proof, `ChecklistEntity.swift:3-6,16-17`); `ChecklistEntityQuery` + reads the store `@MainActor` (`:26-52`). +- **No widget surface exists.** No `.appex`, no widget target, no + `WidgetBundle`; the only two intents are run/list. The App Group entitlement + `group.app.alanvardy.CheckStitch` is already on the app + (`CheckStitch/AppGroup.entitlements`), which is exactly the bridge a widget + extension needs to read `checklists.v1`. +- **Build.** iOS 18.7 / macOS 27.0 / watchOS 26.0, Swift 6 + (`pbxproj:422,487,541`) — interactive widgets (iOS 17+) are available. The + gate is `./scripts/test.sh`; every compiling Swift leg carries + `WARNINGS_AS_ERRORS` (`Makefile:20`, `scripts/tests/run.sh:395`). New Swift + files need no pbxproj edit, but a new **target** does. +- **Gap.** Widget extensions cannot link the app target. The reusable run + chain (`ChecklistReminders`, `EventKitReminderDestination`, `AppGroup`) and + the read model live in the app target today, so the extension cannot import + them as-is. + +## Desired End State + +A new iOS **widget extension** target ships two home-screen widgets: + +1. **Small (`systemSmall`)** — one checklist (chosen in the widget's edit UI) + with a single run button. +2. **Large (`systemLarge`)** — several checklists (chosen in the edit UI), each + row with its own run button. + +Tapping a run button creates the checklist's reminders **without opening the +app**, reusing `RunChecklistIntent` semantics. When Reminders access is not +`.fullAccess`, the widget renders a non-interactive "open app to enable" state +whose `widgetURL` launches CheckStitch. + +Verification of "correct": +- `make widget-build` compiles the extension with warnings-as-errors in the + gate; the display-model suite runs in `make test-unit` (macOS). +- On a simulator/device: place each widget, pick checklists in the edit UI, + tap a button, and confirm reminders appear in the CheckStitch Reminders list + with no app foregrounding. +- With Reminders access revoked, the widget shows the enable state and tapping + it opens the app. + +## Patterns to Follow + +- **One run primitive.** Never write a new reminder-creation path; every + widget button routes to `ChecklistReminders.create(...)` + (`ChecklistReminders.swift:28-67`) through `RunChecklistIntent`, preserving + the run gate and the `ReminderRunOutcome` mapping (`:48-86`). +- **Fresh store per cold call.** `ChecklistStore(defaults: AppGroup.defaults)` + per invocation (`RunChecklistIntent.swift:42`) — do not introduce a + singleton for the widget. +- **Cold-process no-prompt rule.** Status-only `accessStatus()` pre-check + (`RunChecklistIntent.swift:53-54`, `EventKitReminderDestination.swift:24-30`); + the widget view, not the intent, surfaces the denied state. +- **`@MainActor` everywhere the run chain touches.** The run VM, destination, + `RunCounter` and intents are all `@MainActor`; the widget display model must + be too, or hand off explicitly. +- **Reuse the entity model.** Bind widget configuration to `ChecklistEntity` + and `ChecklistEntityQuery` (`ChecklistEntity.swift:16-52`) rather than a new + identifier type — names stay rename-proof (doc `:3-6`). +- **Injectable seams + fakes.** Follow the intent's `init(store:targeting:gate:)` + (`RunChecklistIntent.swift:34-37`) and `SpyReminderDestination` + (`CheckStitchTests/TestFixtures.swift`) so the new logic is testable without + EventKit. +- **Gate citizenship.** New compiling legs carry `WARNINGS_AS_ERRORS`, join + `scripts/tests/run.sh:395`, pass `shellcheck`, and never leave a bare + `name=` destination. +- **Patterns NOT to follow:** the legacy `ChecklistCreator` + + `EventKitReminderCreator` path (per-item, no list targeting, no run gate, no + caller in the live flow — `ChecklistCreator.swift:29-33`); and putting + decision logic inside the extension target (untestable in the gate). + +## Design Decisions + +1. **Interactive `Button(intent:)`, not `widgetURL`, for the run action.** + Tapping runs `RunChecklistIntent` in the extension process + (`openAppWhenRun=false`, `RunChecklistIntent.swift:17`), satisfying + "without opening the app." `widgetURL` is reserved for the denied/unavailable + state only. +2. **Permission denial is a widget-rendered state, not an intent dialog.** + The display model exposes `.ready` vs `.needsAccess` from + `accessStatus()`; interactive widget buttons cannot show the intent's dialog, + and the Siri precedent forbids prompting from a cold process + (`RunChecklistIntent.swift:53-54`). `.needsAccess` renders a disabled row + with `widgetURL` → app. +3. **Large widget = per-checklist run buttons; no "Run All".** Each row maps to + one `RunChecklistIntent`, so the run gate accounts one slot per tap and a + `purchaseRequired` outcome is attributable to one checklist. A "Run All" + would consume N slots and partially fail at `freeRunLimit = 20` + (`RunCounter.swift:51`) — deferred (scope). +4. **`AppIntentConfiguration` with two widget kinds.** `SingleChecklistWidget` + (`systemSmall`, one `ChecklistEntity`) and `MultiChecklistWidget` + (`systemLarge`, `[ChecklistEntity]`), both via `WidgetConfigurationIntent` + and `ChecklistEntityQuery`. Reuses the existing entity picker; the user + chooses which checklists appear. +5. **iOS-only extension.** `systemSmall`/`systemLarge` on the iPhone/iPad home + screen matches "home-screen widgets"; macOS/watch widgets are out of scope + and would add a second signed slice to a provisioning-free gate. +6. **Extract the shared run seam into `CheckStitchCore`; extension is a thin + shell.** The widget extension links the local SPM package, so the pieces it + needs must live there: move `ChecklistReminders`, + `EventKitReminderDestination`, `AppGroup` (and the run/list intents and + `ChecklistEntity`) from the app target into `CheckStitchCore`, leaving the + app target as the thin consumer. The extension target then contains only + WidgetKit/SwiftUI views plus a `ChecklistWidgetDisplayModel` (also in Core) + with all naming/state/button-mapping logic. +7. **Display logic is pure and testable.** `ChecklistWidgetDisplayModel` takes + `(checklists, configuration, accessStatus)` and returns rows (`id`, `name`, + `isRunnable`, `needsAccess`). Tested macOS-hosted with Swift Testing in + `CheckStitchTests/`, per the existing suite conventions; no EventKit needed. +8. **New build leg + gate leg, following `watch-build`.** Add `make + widget-build` (extension for `generic/platform=iOS Simulator`, unsigned, + warnings-as-errors) modelled on `Makefile:78-89`, a committed shared scheme + for the extension, a line in `scripts/test.sh`, and the leg in + `WARNINGS_AS_ERRORS_LEGS` (`scripts/tests/run.sh:395`). +9. **App Group entitlement on both targets.** The extension reads + `checklists.v1` from `UserDefaults(suiteName: "group.app.alanvardy.CheckStitch")` + and the widget's App ID is prefixed by `app.alanvardy.CheckStitch` + (`CheckStitch/AppGroup.entitlements`, `AppGroup.swift:6-11`). No KVS + ubiquity entitlement is needed in the extension — it reads the App Group + store, not iCloud. +10. **User-facing widget strings are localizable.** Any new string (widget + display name, description, "open app" copy) goes through + `Localizable.xcstrings` with all 6 languages and a + `LocalizationFixtures.requiredKeys` entry; run `scripts/l10n-check.sh` + first. + +## What We're NOT Doing + +- **No "Run All" / multi-run-in-one-tap.** Per-row buttons only. +- **No macOS, watchOS, visionOS, or CarPlay widget surfaces.** +- **No Lock Screen / accessory widgets, complications, or Live Activities.** +- **No Control Center / macOS 26 `ControlWidget`.** +- **No new persistence or sync path.** The widget reads the existing App Group + payload read-only and never writes checklists. +- **No in-app widget-configuration screen** (`Option C` in Q3 rejected); config + stays in the widget's own edit UI. +- **No editing, deleting, or completing reminders from the widget** — CheckStitch + stays create-only. +- **No change to the legacy `ChecklistCreator` path** or to the intents' + behaviour beyond the file move required by Decision 6. +- **No App Store / marketing asset work** in this ticket. + +## Open Risks + +- **The `.appex` target is unverified on this machine.** Producing a second + distributable (own App ID, provisioning profile, App Group membership, and + signing) for a real device is unproven here; the simulator + unsigned + `widget-build` leg is the safe gate, and device placement is a manual check. +- **EventKit from a widget-extension process.** The Siri intent precedent says + it works, but the widget's interactive-intent time budget is tight; a slow + EventKit snapshot may be cut short. If it proves flaky, fall back to + `widgetURL` for the run action (Decision 1) — a contained change. +- **Decision 6 is a cross-cutting refactor.** Moving `ChecklistStore`'s + neighbours (`ChecklistReminders`, `EventKitReminderDestination`, `AppGroup`, + intents/entity) into Core touches app, watch, tests, and the Makefile; the + low-risk alternative is per-target source membership + (`PBXFileSystemSynchronizedBuildFileExceptionSet`), which avoids the move but + duplicates compilation. Prefer the Core move; revisit if churn explodes. +- **Widget configuration multi-select.** `[ChecklistEntity]` parameters and + small-vs-large kind split need SDK verification via compile (`swiftui-sdk` + skill); the exact `WidgetConfigurationIntent` shape is not confirmed by the + research. +- **Localization coverage** adds a per-language pass; missing keys will trip + `scripts/l10n-check.sh` and the gate. +- **Purchase gating from the widget.** A `purchaseRequired` outcome cannot open + the paywall from a widget; the widget should show that the run needs the app. + Exact copy is a design detail for planning. \ No newline at end of file diff --git a/.pi/orksorksorks/alanvardy-var-1065-add-widgets/implement.md b/.pi/orksorksorks/alanvardy-var-1065-add-widgets/implement.md new file mode 100644 index 0000000..fdaa476 --- /dev/null +++ b/.pi/orksorksorks/alanvardy-var-1065-add-widgets/implement.md @@ -0,0 +1,86 @@ +# Implementation Summary + +iOS-only CheckStitch widget extension: reads the App Group `checklists.v1` payload +and runs a checklist through the single live reminder path (`ChecklistReminders.create`). +All six phases implemented, committed, and verified. + +## Commits +| Phase | Commit | Description | +|-------|--------|-------------| +| 0 | 533da8b | Core extraction (move run seam + read model into `CheckStitchCore`) | +| 1 | 761d47b | Walking skeleton: small widget runs a checklist | +| 2 | 1d869d9 | Choose which checklist (small widget config) | +| 3 | 8a713c7 | Large widget with per-row run buttons | +| 4 | cddc8e0 | Access and purchase states | +| 5 | 58a3a12 | Hardening, empty states, localization | + +(Plus one prior cleanup: `a947321 chore: remove DELETEME placeholder` — removed +the pre-existing placeholder that blocked the rebase.) + +## Automated Checks (all passed) +- [x] `make test-unit` passes (final: 457 tests / 61 suites; all `ChecklistWidgetDisplayModelTests` + `LocalizationTests` green) +- [x] `make build-mac` passes +- [x] `make watch-build` passes (Core's EventKit/AppIntents additions watchOS-safe) +- [x] `make widget-build` passes with `SWIFT_TREAT_WARNINGS_AS_ERRORS=YES` +- [x] `make build` passes and `.appex` exists at `DerivedData/Build/Products/Debug-iphonesimulator/CheckStitch.app/PlugIns/CheckStitchWidget.appex` +- [x] `scripts/l10n-check.sh` prints `ok` (4 catalogs, 145 keys, 6 languages) — Widget catalog wired in +- [x] `shellcheck scripts/*.sh scripts/tests/*.sh` clean +- [x] `bash scripts/tests/run.sh` passes (26 passed; `widget-build` pinned in `WARNINGS_AS_ERRORS_LEGS`) +- [x] `./scripts/test.sh` prints `gate: ok` (full gate run once by the parent after all phases) + +## Deviations / Observations (from the plan) +- **Phase 0 — `ChecklistStore.conflictingChecklist` made `public`**: not in the + plan's public list, but the app-target `ChecklistImportSession.swift` (and tests) + call it, so it had to cross the package boundary — a mechanical necessity of the + pure move, not a design change. +- **Phase 0 — `PurchaseEnvironment` seam added to Core** (plan-documented deviation): + Core's `ChecklistReminders.productionGate()` needs a purchase service; the StoreKit + provider stays out of Core (watchOS compile). Core holds the StoreKit-free + `CachedEntitlementProvider` default; `MyApp.init()` injects the StoreKit-backed + service. +- **Phase 1 — widget target omits `SWIFT_DEFAULT_ACTOR_ISOLATION`** (plan-sanctioned): + WidgetKit's `TimelineProvider` requirements are nonisolated; extension opts into + `@MainActor` explicitly. `runIntent` uses `let ... ; intent.checklist = ...` (the + compiler confirmed `RunChecklistIntent(checklist:)` is not synthesized; matches + `RunChecklistIntentTests`). Callback params use `@escaping @Sendable` for WidgetKit + conformance. +- **Phase 2/3 — compiler-driven snippet fixes** (all plan-sanctioned): dropped the + old callback annotations when switching to async `AppIntentTimelineProvider`; + intent `@Parameter` fields are optional-typed (`ChecklistEntity?`, + `[ChecklistEntity]?`); added the missing `import AppIntents` in + `MultiChecklistWidget`. +- **Phase 4 — no Core API change needed**; `RunGate.freeRunLimit` lives in + `RunCounter.swift` (referenced as `RunGate.freeRunLimit`). +- **Phase 5** — "Open CheckStitch to buy a license" added as a catalog key + (plan only required the key, not a view swap); widget sources follow the repo + no-trailing-newline convention. + +## Manual Verification Items (from the plan — NOT yet confirmed) +- [ ] Phase 0: Launch the app (`make run`); the checklist list, run button, Siri + shortname entry and import/export still behave as before (pure move). +- [ ] Phase 1: `make run`; press the simulator Home button, long-press the Home + Screen, tap **+** → **CheckStitch** → add the small widget. It shows the first + checklist's name. +- [ ] Phase 1: Tap the run button. Reminders appear in Reminders → CheckStitch + **without CheckStitch coming to the foreground** (foreground the app only + afterwards to confirm; check the free-run counter is not stuck). +- [ ] Phase 2: `make run`; long-press the placed small widget → **Edit Widget** → + choose a checklist; it renders that checklist and its run button creates its + reminders. +- [ ] Phase 3: `make run`; add the large widget, edit it to select three checklists + — three ordered rows render. +- [ ] Phase 3: Tap each row's button in turn; each tap creates only that checklist's + reminders, and no other row's reminders. +- [ ] Phase 3: Confirm a 7th/8th selected checklist is not rendered (row budget). +- [ ] Phase 4: `make run`; Settings → Privacy & Security → Reminders → turn + CheckStitch off. The widget renders the "open app" state on both sizes. +- [ ] Phase 4: Tap the widget body: CheckStitch launches to the foreground. +- [ ] Phase 4: Re-enable access; the widget returns to a runnable state (may need a + timeline refresh — remove/re-add or wait). +- [ ] Phase 5: Add an unconfigured widget: the empty-state hint renders, no crash. +- [ ] Phase 5: Widget gallery shows the display name, description, and previews for + both sizes. +- [ ] Phase 5: Delete all checklists in the app; the widget shows the empty state + within the refresh window. +- [ ] Phase 5: Switch the app language (Settings → Language); the widget strings + follow on the next timeline refresh. \ No newline at end of file diff --git a/.pi/orksorksorks/alanvardy-var-1065-add-widgets/large.md b/.pi/orksorksorks/alanvardy-var-1065-add-widgets/large.md new file mode 100644 index 0000000..fc2e484 --- /dev/null +++ b/.pi/orksorksorks/alanvardy-var-1065-add-widgets/large.md @@ -0,0 +1,14 @@ +# Task + +Implement home-screen widgets for CheckStitch so users can rapidly launch +checklists without opening the app: small widgets bound to a single checklist, +and larger widgets able to trigger multiple checklists at once. This is a new +capability on top of the existing checklist/reminder model. + +## Why LARGE +NEW_SURFACE + UNKNOWNS + DESIGN_SIGN-OFF + CROSS_CUTTING — widgets are a brand +new platform interface (which widget framework/size classes the target OS +supports is unknown and must be researched), the small-vs-large widget +behaviour is an unplanned product trade-off needing human sign-off, and the +work spans the core checklist/persistence model, a new widget UI surface, and +per-platform target delivery. \ No newline at end of file diff --git a/.pi/orksorksorks/alanvardy-var-1065-add-widgets/plan.md b/.pi/orksorksorks/alanvardy-var-1065-add-widgets/plan.md new file mode 100644 index 0000000..83ed827 --- /dev/null +++ b/.pi/orksorksorks/alanvardy-var-1065-add-widgets/plan.md @@ -0,0 +1,1078 @@ +# Implementation Plan + +## Overview + +Ship an iOS-only CheckStitch widget extension that reads the existing App Group +`checklists.v1` payload and runs a checklist through the single live reminder +path (`ChecklistReminders.create`). Phase 0 moves the run seam and read model +into `CheckStitchCore` (extensions cannot link the app target); every later +phase is a vertical slice: real WidgetKit entry point → real Core logic → +rendered, tappable widget, gated when it lands. + +**Repo root for all paths below**: `/Users/vardy/dev/alanvardy-var-1065-add-widgets` +(the ticket worktree). All shell commands run from there. + +**Gate**: `./scripts/test.sh`. Fast loop: `make test-unit`. Build the extension: +`make widget-build` (new in Phase 1). + +--- + +## Phase 0 — Core extraction (horizontal exception) + +No user-visible change. Pure move + visibility: add the listed types to +`CheckStitchCore`, delete them from the app target, update consumers. Nothing +new is authored except the one purchase seam noted below (deviation, see end). + +### Changes + +#### 1. New `CheckStitchCore/Sources/CheckStitchCore/AppGroup.swift` +**Action**: create (move from `CheckStitch/AppGroup.swift`, delete the original). + +```swift +import Foundation + +public enum AppGroup { + public static let suiteName = "group.app.alanvardy.CheckStitch" + + public static var defaults: UserDefaults { + UserDefaults(suiteName: suiteName) ?? .standard + } +} +``` + +#### 2. New `CheckStitchCore/Sources/CheckStitchCore/ChecklistStore.swift` +**Action**: create (move `CheckStitch/ChecklistStore.swift`; delete original). + +- Remove the `import CheckStitchCore` line (self-import is illegal in the package). +- Annotate the type `@MainActor` to preserve its current app-target isolation + (`SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` on the app target): `@MainActor @Observable public final class ChecklistStore`. +- Make public: the type, `init(defaults:key:textEditDelay:now:)`, `checklists`, + `tombstones`, `onChange`, `deviceID`, `envelope`, `canAcceptRemoteChanges`, + `checklist(id:)`, `RenameOutcome`, `SetDestinationOutcome`, + `create(name:)`, `duplicateName(basedOn:)`, `duplicate(id:name:)`, + `importInsert(_:as:)`, `importReplace(id:with:)`, `rename(id:to:)`, + `setDestination(_:for:)`, `setPrefixesReminderNumbers(_:for:)`, + `addItem(to:title:)`, `addItem(to:)`, `updateItem(checklistID:itemID:title:)`, + `updateItemDescription(checklistID:itemID:description:)`, + `updateItem(checklistID:itemID:relativeDate:)`, + `updateItem(checklistID:itemID:priority:)`, `removeItems(from:at:)`, + `removeChecklists(at:)`, `moveItems(checklistID:from:to:)`, + `moveChecklists(from:to:)`, `delete(id:)`, `apply(remote:)`, + `flushPendingSave()`. +- Keep `save()`, `scheduleSave()`, `uniqueName`, `sameName`, `moved`, `logger`, + and all stored private state private. `@ObservationIgnored` annotations stay. +- `ChecklistStore(defaults: AppGroup.defaults)` must resolve inside Core — now it + does (both are in the same module). + +#### 3. New `CheckStitchCore/Sources/CheckStitchCore/ChecklistMerge.swift` +**Action**: create (move `CheckStitch/ChecklistMerge.swift`; delete original). + +- Remove the `import CheckStitchCore` line. +- `public enum ChecklistMerge` and `public static func merge(local:remote:)`. + Keep every private helper private. Nonisolated (pure) — no `@MainActor`. + +#### 4. New `CheckStitchCore/Sources/CheckStitchCore/ChecklistReminders.swift` +**Action**: create (move `CheckStitch/ChecklistReminders.swift`; delete original). + +- Remove the `import CheckStitchCore` line. +- `@MainActor public enum ChecklistReminders`, and make `create(from:)`, + `productionGate()`, and `create(from:targeting:gate:)` public. +- Replace the app-target `PurchaseEnvironment.service` reference with the Core + seam added in step 7 (`await purchases.start()` stays). + +#### 5. New `CheckStitchCore/Sources/CheckStitchCore/EventKitReminderDestination.swift` +**Action**: create (move `CheckStitch/EventKitReminderDestination.swift`; delete original). + +- Remove `import CheckStitchCore`; keep `import EventKit`. +- `@MainActor public final class EventKitReminderDestination: ReminderDestinationTargeting`, + with `public static let shared`, `public init(eventStore:)`, and `public` on all + four protocol methods. `enum ReminderDestinationError` stays internal. +- The `#if !os(watchOS)` guard around `save(commit:)` stays (Core still compiles + for watchOS via `make watch-build`). + +#### 6. New Core intents: `ChecklistEntity.swift`, `RunChecklistIntent.swift`, `ListChecklistsIntent.swift` +**Action**: create (move the three files from `CheckStitch/Intents/`; delete originals). + +- Remove `import CheckStitchCore` from each; add `import AppIntents` where not present. +- `ChecklistEntity`: `public struct`, `public static let typeDisplayRepresentation`, + `public static var defaultQuery`, `public let id/name`, `public init(id:name:)`, + `public init(_ checklist:)`, `public var displayRepresentation`. +- `ChecklistEntityQuery`: `public struct`, `public init()`, `public init(store:)`, + `public func entities(for:)`, `public func entities(matching:)`, + `public func suggestedEntities()`. +- `RunChecklistIntent`: `public struct`, `public static let title`, + `public static let openAppWhenRun`, `public var checklist`, + `public init()`, `public init(store:targeting:gate:)`, + `public static var parameterSummary`, `@MainActor public func perform()`. + Keep `RunChecklistIntentError` and `enum RunChecklistDialogue` internal; + `RunChecklistDialogue`'s `LocalizedStringResource(..., bundle: .main)` calls are + unchanged (app/Siri resolve against the app catalog; the widget never renders + them). +- `ListChecklistsIntent`: `public struct`, public `title`/`openAppWhenRun`, + `public init()`, `public init(store:)`, `@MainActor public func perform()`. + +#### 7. New `CheckStitchCore/Sources/CheckStitchCore/PurchaseEnvironment.swift` (deviation) +**Action**: create. + +`ChecklistReminders.productionGate()` needs a purchase service, and the current +`PurchaseEnvironment` lives in the app target with a StoreKit-backed provider +that must stay out of Core (Core also compiles for watchOS). Move the holder to +Core with a StoreKit-free default and let the app target inject the real one. + +```swift +import Foundation + +/// The process-wide purchase service. The app target replaces `service` at +/// launch with the StoreKit-backed instance; out-of-app processes (widgets, +/// Siri) keep the StoreKit-free default. +@MainActor +public enum PurchaseEnvironment { + public static var service: PurchaseService = PurchaseService( + provider: CachedEntitlementProvider(), + cache: PurchaseEntitlementCache(defaults: AppGroup.defaults)) +} + +/// StoreKit-free provider for cold extension processes: reports the durable +/// verified-entitlement cache and never starts a StoreKit session. +@MainActor +public struct CachedEntitlementProvider: PurchaseProviding { + public init() {} + public func offer() async -> PurchaseOffer? { nil } + public func currentEntitlement() async -> Bool { + PurchaseEntitlementCache(defaults: AppGroup.defaults).isVerified + } + public func purchase() async throws -> Bool { false } + public func restore() async throws -> Bool { false } + public func startObserving(_ onChange: @escaping @MainActor (Bool) -> Void) {} +} +``` + +#### 8. App-target consumer updates +**Action**: modify. + +- `CheckStitch/MyApp.swift` + - Delete `enum PurchaseEnvironment` (now in Core). + - In `init()`, before building view models, inject the StoreKit-backed service: + ```swift + PurchaseEnvironment.service = PurchaseService( + provider: StoreKitPurchaseService(), + cache: PurchaseEntitlementCache(defaults: AppGroup.defaults)) + ``` + - `_purchaseService = State(initialValue: PurchaseEnvironment.service)` is unchanged. + - The watch-coordinator closure `ChecklistReminders.create(from: $0)` and + `ChecklistStore()` call keep working via `import CheckStitchCore` (already present). +- `CheckStitch/Intents/CheckStitchShortcuts.swift` — add `import CheckStitchCore` + (it now references Core's `RunChecklistIntent`/`ListChecklistsIntent`). +- All other app files already `import CheckStitchCore`; no edits. +- Delete `CheckStitch/AppGroup.swift`, `CheckStitch/ChecklistStore.swift`, + `CheckStitch/ChecklistMerge.swift`, `CheckStitch/ChecklistReminders.swift`, + `CheckStitch/EventKitReminderDestination.swift`, + `CheckStitch/Intents/{ChecklistEntity,RunChecklistIntent,ListChecklistsIntent}.swift`. + +#### 9. Test import fixups +**Action**: modify. + +- `CheckStitchTests/AppGroupTests.swift` — add `import CheckStitchCore`. +- `CheckStitchTests/EventKitReminderDestinationTests.swift` — add `import CheckStitchCore`. +- No other suite changes: every other file touching a moved symbol already + carries `@testable import CheckStitchCore` / `import CheckStitchCore`. + +### Verification +#### Automated +- [x] `make test-unit` passes +- [x] `make build-mac` passes (Core still compiles without iOS-only API) +- [x] `make watch-build` passes (Core's EventKit/AppIntents additions are watchOS-safe) +- [x] `bash scripts/test.sh` prints `gate: ok` + +#### Manual +- [ ] Launch the app (`make run`); the checklist list, run button, Siri shortname + entry and import/export still behave as before (pure move). + +--- + +## Phase 1 — Walking skeleton: small widget runs a checklist + +User places a `systemSmall` widget; it loads the first checklist from the App +Group, shows its name, and a tap creates its reminders with no app foregrounding. +Configuration is hard-coded via the display model's `configuration` argument. + +### Changes + +#### 1. `CheckStitchCore/Sources/CheckStitchCore/ChecklistWidgetDisplayModel.swift` +**Action**: create. + +```swift +import Foundation + +/// Row the widget renders: one configured checklist, resolved against the store. +public struct ChecklistWidgetRow: Identifiable, Equatable, Sendable { + public let id: Checklist.ID + public let name: String + public let entityID: String // ChecklistEntity.ID == checklist.id.uuidString + public let isRunnable: Bool + public let needsAccess: Bool + + public init(id: Checklist.ID, name: String, entityID: String, + isRunnable: Bool, needsAccess: Bool) { + self.id = id + self.name = name + self.entityID = entityID + self.isRunnable = isRunnable + self.needsAccess = needsAccess + } +} + +public enum ChecklistWidgetAccessState: Equatable, Sendable { + case ready + case needsAccess + case needsPurchase +} + +/// Pure mapping from the store's checklists + the widget configuration to the +/// rows the widget renders. No EventKit, no WidgetKit: unit-testable in the gate. +public struct ChecklistWidgetDisplayModel: Equatable, Sendable { + /// Cap for the multi-row (large) widget; extra configured rows are dropped. + public static let rowLimit = 6 + + public init(checklists: [Checklist], configuration: [ChecklistEntity], + access: ChecklistWidgetAccessState) { + let byID = Dictionary(checklists.map { ($0.id.uuidString, $0) }, + uniquingKeysWith: { first, _ in first }) + self.rows = Array(configuration.prefix(Self.rowLimit).compactMap { entity in + guard let checklist = byID[entity.id] else { return nil } + return ChecklistWidgetRow( + id: checklist.id, + name: checklist.name, + entityID: entity.id, + isRunnable: access == .ready, + needsAccess: access != .ready) + }) + } + + public let rows: [ChecklistWidgetRow] +} +``` + +#### 2. `CheckStitchWidget/ChecklistWidgetBundle.swift` +**Action**: create. + +```swift +import SwiftUI +import WidgetKit + +@main +struct ChecklistWidgetBundle: WidgetBundle { + var body: some Widget { + SingleChecklistWidget() + } +} +``` + +#### 3. `CheckStitchWidget/SingleChecklistWidget.swift` +**Action**: create. + +```swift +import AppIntents +import CheckStitchCore +import SwiftUI +import WidgetKit + +struct ChecklistEntry: TimelineEntry { + let date: Date + let model: ChecklistWidgetDisplayModel +} + +@MainActor +enum ChecklistWidgetLoader { + /// Reads the App Group store and folds access state. Phase 1 hard-codes the + /// first checklist; later phases pass the intent's configuration. + static func load(configuration: [ChecklistEntity]) -> ChecklistWidgetDisplayModel { + let checklists = ChecklistStore(defaults: AppGroup.defaults).checklists + let resolved = configuration.isEmpty + ? checklists.first.map { [ChecklistEntity($0)] } ?? [] + : configuration + return ChecklistWidgetDisplayModel( + checklists: checklists, configuration: resolved, access: accessState()) + } + + static func accessState() -> ChecklistWidgetAccessState { + EventKitReminderDestination.shared.accessStatus() == .fullAccess + ? .ready : .needsAccess + } +} + +struct SingleChecklistProvider: TimelineProvider { + func placeholder(in context: Context) -> ChecklistEntry { + ChecklistEntry( + date: .now, + model: ChecklistWidgetDisplayModel( + checklists: [Checklist(name: "Groceries")], + configuration: [ChecklistEntity(id: UUID().uuidString, name: "Groceries")], + access: .ready)) + } + + func getSnapshot(in context: Context, completion: @escaping (ChecklistEntry) -> Void) { + Task { @MainActor in + completion(ChecklistEntry(date: .now, model: ChecklistWidgetLoader.load(configuration: []))) + } + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + Task { @MainActor in + let entry = ChecklistEntry(date: .now, model: ChecklistWidgetLoader.load(configuration: [])) + completion(Timeline(entries: [entry], + policy: .after(.now.addingTimeInterval(15 * 60)))) + } + } +} + +struct SingleChecklistWidget: Widget { + static let kind = "SingleChecklistWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: Self.kind, provider: SingleChecklistProvider()) { entry in + SingleChecklistWidgetView(entry: entry) + .containerBackground(.fill.tertiary, for: .widget) + } + .configurationDisplayName("CheckStitch Checklist") + .description("Run a checklist without opening the app.") + .supportedFamilies([.systemSmall]) + } +} + +struct SingleChecklistWidgetView: View { + let entry: ChecklistEntry + + var body: some View { + if let row = entry.model.rows.first { + VStack(alignment: .leading, spacing: 8) { + Text(row.name).font(.headline).lineLimit(2) + if row.isRunnable { + Button(intent: runIntent(for: row)) { + Label("Create reminders", systemImage: "play.circle.fill") + } + .buttonStyle(.borderedProminent) + } else { + Label("Open CheckStitch to enable", systemImage: "exclamationmark.triangle") + .font(.caption) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } else { + Text("No checklists").font(.caption) + } + } + + /// `RunChecklistIntent` has no memberwise init (its `@Parameter` is set by the + /// system / here). Build it and assign the parameter. + @MainActor + private func runIntent(for row: ChecklistWidgetRow) -> RunChecklistIntent { + var intent = RunChecklistIntent() + intent.checklist = ChecklistEntity(id: row.entityID, name: row.name) + return intent + } +} +``` + +> If the compiler synthesizes `RunChecklistIntent(checklist:)` (it does not today — +> the macro cannot see the private stored properties), the smaller form +> `RunChecklistIntent(checklist: ChecklistEntity(...))` may be used instead. +> Verify with `make widget-build`. + +#### 4. `CheckStitchWidget/CheckStitchWidget.entitlements` +**Action**: create. + +```xml + + + + + com.apple.security.application-groups + + group.app.alanvardy.CheckStitch + + + +``` + +(No KVS ubiquity entitlement: the widget reads the App Group store, not iCloud.) + +#### 5. `CheckStitchWidget/Info.plist` +**Action**: create. Needed because `NSExtension` is a dict that +`INFOPLIST_KEY_*` cannot generate. + +```xml + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + +``` + +#### 6. `CheckStitch.xcodeproj/project.pbxproj` +**Action**: modify. Add a `PBXNativeTarget` for the extension, a +`PBXFileSystemSynchronizedRootGroup` for `CheckStitchWidget/`, embed the `.appex` +in the app, and add the target to the project. Follow the existing +hand-numbered-ID convention; pick fresh unique IDs (illustrative IDs below). + +- `PBXBuildFile`: + ``` + 000000000000000000000065 /* CheckStitchCore in Frameworks */ = {isa = PBXBuildFile; productRef = 000000000000000000000031 /* CheckStitchCore */; }; + 000000000000000000000066 /* CheckStitchWidget.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 000000000000000000000081 /* CheckStitchWidget.appex */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + ``` +- `PBXContainerItemProxy`: + ``` + 000000000000000500000002 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 000000000000000000000000 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 000000000000000500000000; + remoteInfo = CheckStitchWidget; + }; + ``` +- `PBXCopyFilesBuildPhase` (PlugIns = `dstSubfolderSpec = 13`): + ``` + 000000000000000160000000 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = (000000000000000000000066 /* CheckStitchWidget.appex in Embed Foundation Extensions */,); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; + ``` +- `PBXFileReference`: + ``` + 000000000000000000000081 /* CheckStitchWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CheckStitchWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + ``` +- `PBXFileSystemSynchronizedRootGroup`: + ``` + 000000000000000000000080 /* CheckStitchWidget */ = { + exceptions = (0000000000000000000000A2 /* Exceptions for "CheckStitchWidget" folder in "CheckStitchWidget" target */,); + isa = PBXFileSystemSynchronizedRootGroup; + path = CheckStitchWidget; + sourceTree = ""; + }; + ``` +- `PBXFileSystemSynchronizedBuildFileExceptionSet` (keep `Info.plist` from being + copied as a resource; mirrors the app's `Info.plist` exception): + ``` + 0000000000000000000000A2 /* Exceptions for "CheckStitchWidget" folder in "CheckStitchWidget" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = (Info.plist,); + target = 000000000000000500000000 /* CheckStitchWidget */; + }; + ``` +- `PBXFrameworksBuildPhase`: new `000000000000000521000000` with + `000000000000000000000065`. +- `PBXGroup` (root `...000001`) children: add `000000000000000000000080`. + `Products` group children: add `000000000000000000000081`. +- `PBXNativeTarget`: + ``` + 000000000000000500000000 /* CheckStitchWidget */ = { + isa = PBXNativeTarget; + buildConfigurationList = 000000000000000510000000 /* Build configuration list for PBXNativeTarget "CheckStitchWidget" */; + buildPhases = ( + 000000000000000520000000 /* Sources */, + 000000000000000521000000 /* Frameworks */, + 000000000000000522000000 /* Resources */, + ); + buildRules = (); + dependencies = (); + fileSystemSynchronizedGroups = (000000000000000000000080 /* CheckStitchWidget */,); + name = CheckStitchWidget; + packageProductDependencies = (000000000000000000000031 /* CheckStitchCore */,); + productName = CheckStitchWidget; + productReference = 000000000000000000000081 /* CheckStitchWidget.appex */; + productType = "com.apple.product-type.app-extension"; + }; + ``` + Add empty `PBXSourcesBuildPhase` `...520000000` and `PBXResourcesBuildPhase` + `...522000000` (both `files = ();`). +- App target (`...100000000`): add `000000000000000160000000` to `buildPhases` + (after `Embed Watch Content`) and `000000000000000500000001` to `dependencies`. +- `PBXProject.TargetAttributes`: add + `000000000000000500000000 = { CreatedOnToolsVersion = 26.3; };`. +- `PBXProject.targets`: append `000000000000000500000000 /* CheckStitchWidget */`. +- `XCBuildConfiguration` Debug `...511000000` and Release `...512000000` for the + widget target (same body both): + ``` + CODE_SIGN_STYLE = Automatic; + "CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]" = CheckStitchWidget/CheckStitchWidget.entitlements; + "CODE_SIGN_ENTITLEMENTS[sdk=iphonesimulator*]" = CheckStitchWidget/CheckStitchWidget.entitlements; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 6NWX2DHB9Q; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = CheckStitchWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = CheckStitch; + INFOPLIST_KEY_NSRemindersFullAccessUsageDescription = "CheckStitch needs access to create reminders."; + INFOPLIST_KEY_NSRemindersUsageDescription = "CheckStitch needs access to create reminders."; + IPHONEOS_DEPLOYMENT_TARGET = 18.7; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = app.alanvardy.CheckStitch.widget; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + ``` + Do **not** set `SWIFT_DEFAULT_ACTOR_ISOLATION` on this target: WidgetKit's + `TimelineProvider`/`AppIntentTimelineProvider` requirements are nonisolated and + a MainActor default would make conformances fail. Use explicit `@MainActor` in + the extension code instead. +- `XCConfigurationList` `000000000000000510000000` referencing the two configs. + +> `REGISTER_APP_GROUPS = YES` may require `-allowProvisioningUpdates` for device +> builds; the unsigned simulator `widget-build` leg and `make build` are not +> affected (same as the app target today). + +#### 7. `CheckStitch.xcodeproj/xcshareddata/xcschemes/CheckStitchWidget.xcscheme` +**Action**: create. Copy `CheckStitchWatch.xcscheme` verbatim and substitute the +blueprint: +- `BlueprintIdentifier = "000000000000000500000000"` +- `BuildableName = "CheckStitchWidget.appex"` +- `BlueprintName = "CheckStitchWidget"` + +#### 8. `Makefile` +**Action**: modify. + +- Add `WIDGET_SCHEME := CheckStitchWidget` and `WIDGET_SIM := generic/platform=iOS Simulator` + near `WATCH_SCHEME`. +- Add `widget-build` to `.PHONY`. +- Add the recipe (mirrors `watch-build`): + ```make + widget-build: + xcodebuild -scheme '$(WIDGET_SCHEME)' \ + -destination '$(WIDGET_SIM)' \ + -configuration '$(CONFIGURATION)' \ + -derivedDataPath '$(DERIVED_DATA)' \ + $(WARNINGS_AS_ERRORS) \ + build + ``` + +#### 9. `scripts/test.sh` +**Action**: modify. After `make watch-build`, add: +```bash +# The widget extension is a second iOS-only product; compile it here so a broken +# pbxproj/widget scheme edit fails the gate. +make widget-build +``` + +#### 10. `scripts/tests/run.sh` +**Action**: modify the pin only (comment says "Later phases append their leg here"): +```bash +WARNINGS_AS_ERRORS_LEGS=(build-mac build test-unit test-ui watch-build widget-build) +``` +The existing `warnings_as_errors_reaches_compiling_legs` case drives +`make widget-build` with a stubbed `xcodebuild`, so the Makefile recipe must keep +`$(WARNINGS_AS_ERRORS)`. + +#### 11. `CheckStitchTests/ChecklistWidgetDisplayModelTests.swift` +**Action**: create. + +```swift +import CheckStitchCore +import Foundation +import Testing + +struct ChecklistWidgetDisplayModelTests { + @Test + func configuredChecklistBecomesARunnableRow() { + let checklist = Checklist(name: "Groceries") + let model = ChecklistWidgetDisplayModel( + checklists: [checklist], + configuration: [ChecklistEntity(checklist)], + access: .ready) + #expect(model.rows.map(\.name) == ["Groceries"]) + #expect(model.rows.first?.isRunnable == true) + #expect(model.rows.first?.needsAccess == false) + } + + @Test + func emptyChecklistsProduceNoRows() { + let model = ChecklistWidgetDisplayModel( + checklists: [], configuration: [], access: .ready) + #expect(model.rows.isEmpty) + } +} +``` + +### Verification +#### Automated +- [x] `make test-unit` passes (`ChecklistWidgetDisplayModelTests` green) +- [x] `make widget-build` passes with `SWIFT_TREAT_WARNINGS_AS_ERRORS=YES` +- [x] `make build` passes and `DerivedData/Build/Products/Debug-iphonesimulator/CheckStitch.app/PlugIns/CheckStitchWidget.appex` exists +- [x] `make build-mac` passes (extension skipped on macOS via `platformFilter = ios`) +- [x] `bash scripts/tests/run.sh` passes (`warnings_as_errors_reaches_compiling_legs` includes `widget-build`) + +#### Manual +- [ ] `make run`; press the simulator Home button, long-press the Home Screen, + tap **+** → **CheckStitch** → add the small widget. It shows the first + checklist's name. +- [ ] Tap the run button. Reminders should appear in Reminders → CheckStitch + **without CheckStitch coming to the foreground** (foreground the app only + afterwards to confirm, and check the free-run counter is not stuck). + +--- + +## Phase 2 — Choose which checklist (small widget config) + +The small widget gets an `AppIntentConfiguration`; the user picks the checklist +in the widget's edit UI. + +### Changes + +#### 1. `CheckStitchCore/Sources/CheckStitchCore/ChecklistConfigurationIntent.swift` +**Action**: create. + +```swift +import AppIntents + +public struct ChecklistConfigurationIntent: WidgetConfigurationIntent { + public static let title: LocalizedStringResource = "Checklist" + public static let description = IntentDescription("Pick the checklist this widget runs.") + + @Parameter(title: "Checklist") + public var checklist: ChecklistEntity? + + public init() {} +} +``` + +#### 2. `CheckStitchWidget/SingleChecklistWidget.swift` +**Action**: modify. + +- `SingleChecklistProvider` changes from `TimelineProvider` to + `AppIntentTimelineProvider`: + ```swift + struct SingleChecklistProvider: AppIntentTimelineProvider { + func placeholder(in context: Context) -> ChecklistEntry { /* as Phase 1 */ } + + func snapshot(for configuration: ChecklistConfigurationIntent, in context: Context) async -> ChecklistEntry { + let model = await MainActor.run { + ChecklistWidgetLoader.load(configuration: configuration.checklist.map { [$0] } ?? []) + } + return ChecklistEntry(date: .now, model: model) + } + + func timeline(for configuration: ChecklistConfigurationIntent, in context: Context) async -> Timeline { + let model = await MainActor.run { + ChecklistWidgetLoader.load(configuration: configuration.checklist.map { [$0] } ?? []) + } + return Timeline(entries: [ChecklistEntry(date: .now, model: model)], + policy: .after(.now.addingTimeInterval(15 * 60))) + } + } + ``` +- `SingleChecklistWidget` switches `StaticConfiguration` → + `AppIntentConfiguration(kind:intent:provider:)` with + `intent: ChecklistConfigurationIntent.self`. + +#### 3. `CheckStitchTests/ChecklistWidgetDisplayModelTests.swift` +**Action**: modify — add: + +```swift +@Test +func selectedEntityResolvesToItsRowWhateverTheStoreOrder() { + let groceries = Checklist(name: "Groceries") + let packing = Checklist(name: "Packing") + let model = ChecklistWidgetDisplayModel( + checklists: [groceries, packing], + configuration: [ChecklistEntity(packing)], + access: .ready) + #expect(model.rows.map(\.name) == ["Packing"]) +} + +@Test +func ghostEntityProducesNoRows() { + let model = ChecklistWidgetDisplayModel( + checklists: [Checklist(name: "Groceries")], + configuration: [ChecklistEntity(id: UUID().uuidString, name: "Deleted")], + access: .ready) + #expect(model.rows.isEmpty) +} +``` + +### Verification +#### Automated +- [x] `make test-unit` passes +- [x] `make widget-build` passes + +#### Manual +- [ ] `make run`; long-press the placed small widget → **Edit Widget** → choose a + checklist; it renders that checklist and its run button creates its + reminders. + +--- + +## Phase 3 — Large widget with per-row run buttons + +A `systemLarge` widget picks several checklists; each row runs independently +(one run-gate slot per tap). + +### Changes + +#### 1. `CheckStitchCore/Sources/CheckStitchCore/MultiChecklistConfigurationIntent.swift` +**Action**: create. + +```swift +import AppIntents + +public struct MultiChecklistConfigurationIntent: WidgetConfigurationIntent { + public static let title: LocalizedStringResource = "Checklists" + public static let description = IntentDescription("Pick the checklists this widget runs.") + + @Parameter(title: "Checklists") + public var checklists: [ChecklistEntity] + + public init() {} +} +``` + +#### 2. `CheckStitchWidget/MultiChecklistWidget.swift` +**Action**: create. + +```swift +import CheckStitchCore +import SwiftUI +import WidgetKit + +struct MultiChecklistProvider: AppIntentTimelineProvider { + func placeholder(in context: Context) -> ChecklistEntry { /* Phase 1 preview */ } + + func snapshot(for configuration: MultiChecklistConfigurationIntent, in context: Context) async -> ChecklistEntry { + let model = await MainActor.run { + ChecklistWidgetLoader.load(configuration: configuration.checklists) + } + return ChecklistEntry(date: .now, model: model) + } + + func timeline(for configuration: MultiChecklistConfigurationIntent, in context: Context) async -> Timeline { + let model = await MainActor.run { + ChecklistWidgetLoader.load(configuration: configuration.checklists) + } + return Timeline(entries: [ChecklistEntry(date: .now, model: model)], + policy: .after(.now.addingTimeInterval(15 * 60))) + } +} + +struct MultiChecklistWidget: Widget { + static let kind = "MultiChecklistWidget" + + var body: some WidgetConfiguration { + AppIntentConfiguration(kind: Self.kind, + intent: MultiChecklistConfigurationIntent.self, + provider: MultiChecklistProvider()) { entry in + MultiChecklistWidgetView(entry: entry) + .containerBackground(.fill.tertiary, for: .widget) + } + .configurationDisplayName("CheckStitch Checklists") + .description("Run any of your checklists without opening the app.") + .supportedFamilies([.systemLarge]) + } +} + +struct MultiChecklistWidgetView: View { + let entry: ChecklistEntry + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + ForEach(entry.model.rows) { row in + HStack { + Text(row.name).font(.body).lineLimit(1) + Spacer() + if row.isRunnable { + Button(intent: runIntent(for: row)) { + Image(systemName: "play.circle.fill") + } + .buttonStyle(.plain) + } else { + Image(systemName: "exclamationmark.triangle").font(.caption) + } + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + @MainActor + private func runIntent(for row: ChecklistWidgetRow) -> RunChecklistIntent { + var intent = RunChecklistIntent() + intent.checklist = ChecklistEntity(id: row.entityID, name: row.name) + return intent + } +} +``` + +`ChecklistWidgetDisplayModel` already caps `configuration` to `rowLimit` (Phase 1) +and preserves configuration order — no Core change needed. + +#### 3. `CheckStitchWidget/ChecklistWidgetBundle.swift` +**Action**: modify — add `MultiChecklistWidget()` to `body`. + +#### 4. `CheckStitchTests/ChecklistWidgetDisplayModelTests.swift` +**Action**: modify — add: + +```swift +@Test +func configurationOrderIsPreservedAndMissingEntitiesAreDropped() { + let a = Checklist(name: "A") + let b = Checklist(name: "B") + let c = Checklist(name: "C") + let model = ChecklistWidgetDisplayModel( + checklists: [a, b, c], + configuration: [ChecklistEntity(c), ChecklistEntity(id: UUID().uuidString, name: "Ghost"), ChecklistEntity(a)], + access: .ready) + #expect(model.rows.map(\.name) == ["C", "A"]) +} + +@Test +func rowsAreCappedToTheRowBudget() { + let checklists = (0..<9).map { Checklist(name: "List \($0)") } + let model = ChecklistWidgetDisplayModel( + checklists: checklists, + configuration: checklists.map(ChecklistEntity.init), + access: .ready) + #expect(model.rows.count == ChecklistWidgetDisplayModel.rowLimit) + #expect(model.rows.map(\.name) == (0.. ChecklistWidgetAccessState { + switch EventKitReminderDestination.shared.accessStatus() { + case .fullAccess: + let unlocked = PurchaseEntitlementCache(defaults: AppGroup.defaults).isVerified + let used = RunCounter(defaults: AppGroup.defaults).count + return (!unlocked && used >= RunGate.freeRunLimit) ? .needsPurchase : .ready + case .notDetermined, .denied: + return .needsAccess + } + } + ``` +- Views: when `entry.model.rows` contains a non-runnable row, render the row with + the enable copy and attach `.widgetURL(URL(string: "checkstitch://"))` to the + row/widget; the runnable path keeps `Button(intent:)` only. Example (single): + ```swift + if row.isRunnable { + Button(intent: runIntent(for: row)) { ... } + } else { + Label(enableCopy, systemImage: "exclamationmark.triangle") + .font(.caption) + } + ``` + and on the widget's root view: `.widgetURL(row.needsAccess ? URL(string: "checkstitch://") : nil)`. +- The `ChecklistWidgetDisplayModel` already forces `isRunnable = false` / + `needsAccess = true` for any non-`.ready` state — no Core change needed. + +#### 2. `CheckStitch/Info.plist` +**Action**: modify. `widgetURL("checkstitch://")` only opens the app if the scheme +is registered. Add: +```xml + CFBundleURLTypes + + + CFBundleURLName + app.alanvardy.CheckStitch + CFBundleURLSchemes + + checkstitch + + + +``` +`ContentView`'s existing `.onOpenURL` forwards it to `SharedImportInbox`, which +ignores non-file URLs — no code change required. + +#### 3. `CheckStitchTests/ChecklistWidgetDisplayModelTests.swift` +**Action**: modify — add: + +```swift +@Test(arguments: [ChecklistWidgetAccessState.needsAccess, .needsPurchase]) +func nonReadyAccessMakesEveryRowNonRunnable(_ access: ChecklistWidgetAccessState) { + let checklist = Checklist(name: "Groceries") + let model = ChecklistWidgetDisplayModel( + checklists: [checklist], + configuration: [ChecklistEntity(checklist)], + access: access) + #expect(model.rows.allSatisfy { !$0.isRunnable && $0.needsAccess }) +} + +@Test +func readyAccessMakesRowsRunnable() { + let checklist = Checklist(name: "Groceries") + let model = ChecklistWidgetDisplayModel( + checklists: [checklist], + configuration: [ChecklistEntity(checklist)], + access: .ready) + #expect(model.rows.allSatisfy { $0.isRunnable && !$0.needsAccess }) +} +``` + +### Verification +#### Automated +- [x] `make test-unit` passes +- [x] `make widget-build` passes + +#### Manual +- [ ] `make run`; Settings → Privacy & Security → Reminders → turn CheckStitch + off. The widget renders the "open app" state on both sizes. +- [ ] Tap the widget body: CheckStitch launches to the foreground. +- [ ] Re-enable access; the widget returns to a runnable state (may need a + timeline refresh — remove/re-add or wait). + +--- + +## Phase 5 — Hardening, empty states, localization + +Unconfigured/empty widgets, refresh policy, gallery metadata and previews, and +complete localization so the gate's l10n and shellcheck legs stay green. + +### Changes + +#### 1. `CheckStitchWidget/*` (views + bundle) +**Action**: modify. + +- Empty/unconfigured state: when `entry.model.rows.isEmpty`, render a hint + ("No checklists" or "Edit this widget to pick a checklist") instead of an empty + container, on both widgets. Both strings come from the widget catalog. +- Gallery metadata: `configurationDisplayName` / `description` keys live in the + widget catalog (already used in Phases 1/3); ensure they are the catalog keys. +- Previews: add `#Preview(as: .systemSmall)` / `#Preview(as: .systemLarge)` + blocks with a `ChecklistEntry` built from a `ChecklistWidgetDisplayModel` over + sample checklists (no store/EventKit). +- Refresh policy: keep `.after(.now.addingTimeInterval(15 * 60))` on both + providers. + +#### 2. `CheckStitchWidget/Localizable.xcstrings` +**Action**: create. Add every extension-facing key, all six languages +(`en`, `de`, `es`, `fr`, `ja`, `zh-Hans`) — see the `localization` skill; a new +key needs all languages before `scripts/l10n-check.sh` passes: + +| Key | +|---| +| `CheckStitch Checklist` | +| `CheckStitch Checklists` | +| `Run a checklist without opening the app.` | +| `Run any of your checklists without opening the app.` | +| `Checklist` | +| `Checklists` | +| `Pick the checklist this widget runs.` | +| `Pick the checklists this widget runs.` | +| `Create reminders` | +| `Open CheckStitch to enable` | +| `Open CheckStitch to buy a license` | +| `No checklists` | +| `Edit this widget to pick a checklist` | + +#### 3. `scripts/l10n-check.sh` +**Action**: modify — add the widget catalog to `CATALOGS`: +```python + "Widget": Path("CheckStitchWidget/Localizable.xcstrings"), +``` + +#### 4. `CheckStitchTests/LocalizationTestHelpers.swift` +**Action**: modify — add to `Catalogs.all`: +```swift + ("Widget", repoRoot.appendingPathComponent("CheckStitchWidget/Localizable.xcstrings")), +``` + +#### 5. `CheckStitchTests/LocalizationFixtures.swift` +**Action**: modify. +- `guardedCatalogs`: add `"Widget"`. +- `requiredKeys`: add a `("Widget", [...])` entry listing every key from step 2. + +#### 6. `scripts/tests/run.sh` +**Action**: modify — the `WARNINGS_AS_ERRORS_LEGS` list already contains +`widget-build` from Phase 1; no further edit unless a shell-space pin is added. +Run `shellcheck scripts/*.sh scripts/tests/*.sh` to confirm the script edits parse. + +### Verification +#### Automated +- [x] `scripts/l10n-check.sh` prints `ok` with the Widget catalog included +- [x] `make test-unit` passes (`LocalizationTests` + all widget display-model tests) +- [x] `shellcheck scripts/*.sh scripts/tests/*.sh` clean +- [x] `bash scripts/tests/run.sh` passes +- [x] `./scripts/test.sh` prints `gate: ok` + +#### Manual +- [ ] Add an unconfigured widget: the empty-state hint renders, no crash. +- [ ] Widget gallery shows the display name, description, and previews for both + sizes. +- [ ] Delete all checklists in the app; the widget shows the empty state within + the refresh window. +- [ ] Switch the app language (Settings → Language); the widget strings follow on + the next timeline refresh. + +--- + +## Deviations from `structure.md` (and why) + +1. **`PurchaseEnvironment` seam added to Core (Phase 0).** `ChecklistReminders.productionGate()` + and the moved `RunChecklistIntent` depend on the app-target `PurchaseEnvironment.service` + (StoreKit). Structure's move list did not account for it. Resolved by moving the + holder to Core with a StoreKit-free `CachedEntitlementProvider` default and + injecting the StoreKit-backed service from `MyApp.init()` — the app/Siri path + keeps its current behaviour; a cold extension process resolves the durable + `PurchaseEntitlementCache` without linking StoreKit (preserving + `CheckStitchCore`'s deliberate StoreKit-free watchOS compile). +2. **`CheckStitchWidget/Info.plist` added (Phase 1).** `NSExtension` is a nested + dict that `INFOPLIST_KEY_*` cannot generate; the widget target needs it and a + synchronized-group exception to keep it out of the resources phase. +3. **Widget target deliberately omits `SWIFT_DEFAULT_ACTOR_ISOLATION`.** The app + target sets `MainActor`, but `TimelineProvider`/`AppIntentTimelineProvider` + requirements are nonisolated; the extension opts into `@MainActor` explicitly + (same convention as the test targets). +4. **`ChecklistWidgetDisplayModel` is nonisolated/pure**, not `@MainActor` as the + design text suggested: it takes already-loaded `[Checklist]` (Sendable) and is + therefore testable without a main actor. Only `ChecklistWidgetLoader` (which + reads the store/EventKit) is `@MainActor`. +5. **Test import fixups limited to two files** (`AppGroupTests`, + `EventKitReminderDestinationTests`) — the only suites touching moved symbols + without an existing `CheckStitchCore` import. +6. **Widget catalog wired into `l10n-check.sh` and `LocalizationTestHelpers.Catalogs.all`** + (structure only said "extension `Localizable.xcstrings` + `LocalizationFixtures`"): + every other catalog is guarded by both, and an unguarded new catalog would let + a missing translation ship. \ No newline at end of file diff --git a/.pi/orksorksorks/alanvardy-var-1065-add-widgets/questions.md b/.pi/orksorksorks/alanvardy-var-1065-add-widgets/questions.md new file mode 100644 index 0000000..85d37b6 --- /dev/null +++ b/.pi/orksorksorks/alanvardy-var-1065-add-widgets/questions.md @@ -0,0 +1,47 @@ +# Research Questions + +## Context + +CheckStitch is a Swift/SwiftUI app (iOS, macOS, and watchOS targets) that turns +checklists into Reminders. Focus areas: the checklist domain model and +persistence, the flow that turns a checklist into reminders, the existing +out-of-app invocation surface (Siri/App Intents) and checklist-selection UI, +the per-platform build/delivery and entitlement model, and the platform widget +framework available on the target OS. Do not reference the goal of the task; +answer only what exists and how it works. + +## Questions + +1. Trace the full flow from a stored checklist id to created reminders: + how `ChecklistStore`, the run view model, the run gate (purchase + run + counter), `ChecklistCreator`, and the EventKit reminder destination + connect. What are the entry points, thread-model / `@MainActor` + constraints, and side effects at each stage? + +2. How does the existing out-of-app invocation surface work — the Siri/App + Intents (`RunChecklistIntent`, `ListChecklistsIntent`, `ChecklistEntity`) + and anything similar that runs a checklist without the main ContentView? + How do they construct and access the checklist store, run a checklist, and + what are their `@MainActor`, lifecycle, and error-handling requirements? + +3. What persistence and ownership patterns exist for the checklist store — + how `ChecklistStore` is constructed, whether there is a shared/singleton + instance or an owned lifetime, and how the App-Group KV store (UserDefaults) + and the KVS sync (`ChecklistSyncService` / coordinator) are initialized and + owned? What would an independent caller need to open the same store? + +4. What UI patterns exist for selecting and launching checklists — + `ChecklistSelectionView`, the list-picker, `ContentView` navigation, and + how single-selection versus multi-item selection is handled? How are + checklist names/entities surfaced to the user? + +5. What widget frameworks exist on the target OS (iOS and/or macOS) that + provide user-visible "widgets" with fixed small and large size classes and + that can be bound to an app action? Cover the SDK/API, size classes, + packaging/model, and whether the app runs in-process or via the widget + host. This is a web-research question about the platform's own offering. + +6. How is the project built and delivered for each target (iOS app, macOS app, + watchOS), and what runtime/entitlement constraints apply (App Group, + key-value store identifier, signing, provisioning flags, Makefile targets)? + Where would a new per-platform surface be introduced and how is it gated? \ No newline at end of file diff --git a/.pi/orksorksorks/alanvardy-var-1065-add-widgets/research.md b/.pi/orksorksorks/alanvardy-var-1065-add-widgets/research.md new file mode 100644 index 0000000..4a177a7 --- /dev/null +++ b/.pi/orksorksorks/alanvardy-var-1065-add-widgets/research.md @@ -0,0 +1,111 @@ +# Research Findings + +Repo root: `/Users/vardy/dev/CheckStitch`. Paths below are relative to it. + +## Q1: Flow from a stored checklist id to created reminders + +### Findings +- Three producer entry points all funnel through `CheckStitch/ChecklistReminders.swift`: + - In-app run button → `ContentView.swift:507-509` `createRemindersButton(for:)` fires `Task { await runVM.createReminders(for: id) }`. + - Siri/App Intent → `RunChecklistIntent.swift:63-66` calls `ChecklistReminders.create(from:targeting:gate:)`; gate from `productionGate()` (`:73-76`). No ContentView. + - watch sync → the iOS `ChecklistSyncCoordinator` closure `createReminders: { await ChecklistReminders.create(from: $0) }` registered at `MyApp.swift:98` (single-arg production overload). +- `ChecklistRunViewModel.createReminders(for:)` (app target, `@MainActor @Observable`): `CheckStitch/ChecklistRunViewModel.swift:38-56`. + - Guard + tap-lock: `guard !creating.contains(id), let checklist = store.checklist(id: id) else { return }` (`:39`) then `creating.insert(id)` (`:40`). + - `store.checklist(id:)` = `checklists.first { $0.id == id }` (`ChecklistStore.swift:118-120`). + - Resolves purchase first: `await purchases.start()` (`:45`) to avoid gating on stale `.unknown`; builds `RunGate(counter:isUnlocked:)` and delegates (`:47-48`). Defaults: `EventKitReminderDestination.shared` targeting, `RunCounter(defaults: AppGroup.defaults)`, `PurchaseEnvironment.service` purchases (`:17-19`). + - Side effects: min-duration spinner (`:42`); `.created` → `created` set cleared after 1s (`:52-55`); `.purchaseRequired` → `isShowingPaywall` (`:56-57`); else `runErrorMessage` (`:58-61`). +- `ChecklistReminders` orchestration + run gate: `CheckStitch/ChecklistReminders.swift:28-67`. + - `guard gate.reserveRun() else { return .purchaseRequired }` (`:35`). + - `RunGate` (`CheckStitchCore/Sources/CheckStitchCore/RunCounter.swift`): `freeRunLimit = 20` (`:51`), `permitsRun = isUnlocked || counter.count < limit` (`:66`); `reserveRun()` is synchronized, increments durable counter at most once (`:71-82`); `releaseRun()` returns the slot if nothing created (`:86-92`). + - Then `targeting.requestAccess()` (`:41`), `snapshot = reminderLists()` (`:47`), `snapshot.resolve(checklist.destinationListIdentifier)` (`:48`) → `.destinationMissing` on nil (all-or-nothing before first create). + - Per non-blank item: `targeting.create(...)` (`:58-64`) with `ChecklistTitleNumbering.title(...)` and `dueDateComponents(today: Date())`. + - Slot bookkeeping: `gate.releaseRun()` on denied/missing/failure and when `created == 0` (`:45,51,65`); slot kept only by a run that created a reminder. +- Outcomes enum: `CheckStitchCore/.../ReminderDestinationTargeting.swift:48-86` — `.created(count)`, `.destinationMissing`, `.permissionDenied`, `.purchaseRequired`, `.partiallyCreated(created:total:reason)`, `.failed(String)`; mid-loop throw with `created>0` maps to `.partiallyCreated` (`:66-72`). +- EventKit seam: `CheckStitch/EventKitReminderDestination.swift`, `@MainActor final class`, `static let shared` fresh instance (`:10-11`); long-lived `EKEventStore` (EKReminder weakly references its store). `requestAccess` → `requestFullAccessToReminders()` (`:15-16`); `reminderLists()` from `calendars(for:.reminder)`, `defaultCalendarForNewReminders()` (`:22-27`); `create(...)` re-resolves list, throws `listMissing` on TOCTOU (`:30-49`), sets title/notes/priority + optional `dueDateComponents` (`:37-43`), `save(commit:true)` (`:47`); watchOS path `#if !os(watchOS)` excluded (`:46`). +- Thread model: `ChecklistRunViewModel`, `EventKitReminderDestination`, `RunCounter` are `@MainActor`. `ChecklistCreator` (Core) is a `Sendable` struct (`ChecklistCreator.swift:29`) using the `ReminderCreating` seam (`Core/ReminderCreating.swift`); the real `EventKitReminderCreator` (`:33`) is `@MainActor`. **Legacy path**: `ChecklistCreator` + `EventKitReminderCreator` is a separate creation path (per-item, no list targeting, no run gate); its `ChecklistCreationOutcome` has no caller in the run flow above. + +## Q2: Out-of-app invocation surface (Siri / App Intents) + +### Findings +- Registration: `CheckStitch/Intents/CheckStitchShortcuts.swift:1-16` — `AppShortcutsProvider` declaring `RunChecklistIntent()` (phrases "Run %1 in $applicationName", "Create reminders from %1…", `shortTitle: "Run Checklist"`, system image `checklist`, `:7-12`) and `ListChecklistsIntent()` ("List my checklists in $applicationName", "List Checklists", `list.bullet`, `:14-15`). Auto-discovered by SPM/App target; no ContentView/MyApp wiring. +- Both intents set `openAppWhenRun: Bool = false` — `RunChecklistIntent.swift:17`, `ListChecklistsIntent.swift:4` — so they run out-of-process (Siri process) and return a speech dialog without launching the app UI. +- `ChecklistEntity` + `ChecklistEntityQuery`: `CheckStitch/Intents/ChecklistEntity.swift`. + - `id` is `Checklist.id.uuidString` (`:16-17`); rename-proof key (doc `:3-6`). + - `ChecklistEntityQuery: EntityStringQuery`; `currentStore()` at `:28-31` returns injected store or **`ChecklistStore(defaults: AppGroup.defaults)` — a fresh store per call** (design decision 7, comment `:26`). `entities(for:)` `:33-40`, `entities(matching:)` `:41-48`, `suggestedEntities()` `:49-52`; all `@MainActor`. +- `RunChecklistIntent.perform()` `:44-79`: + 1. `ChecklistStore(defaults: AppGroup.defaults)` when not injected (`:46`); `EventKitReminderDestination.shared` when not injected (`:47`). + 2. `guard let uuid = UUID(uuidString: checklist.id), let stored = store.checklist(id: uuid) else { throw RunChecklistIntentError.checklistNotFound }` (`:49-51`). + 3. **Status-only pre-check, no prompt**: `switch targeting.accessStatus()` (`:56-63`) — `.fullAccess` proceeds; `.notDetermined`/`.denied` return an early dialog. Comment `:53-54`: a cold process must never prompt from inside an intent. `accessStatus()` = `EKEventStore.authorizationStatus(for:.reminder)`; everything except `.fullAccess`/`.notDetermined` → `.denied` (`EventKitReminderDestination.swift:24-30`). + 4. `let gate = await resolveGate()` (`:65`); `resolveGate()` `:77-79` returns injected gate or `await ChecklistReminders.productionGate()`. + 5. `let outcome = await ChecklistReminders.create(from: stored, targeting: targeting, gate: gate)` (`:66-68`). + 6. `.result(dialog:…)` mapped by `RunChecklistDialogue.message(for:checklistName:)` (`:68-69`, mapper `:89-121` covering every `ReminderRunOutcome`). + - Error `RunChecklistIntentError.checklistNotFound` (`:6-14`) is `LocalizedError`, localized via `LocalizedStringResource(...).resolvedInAppLanguage()` (`:11-13`). +- `ListChecklistsIntent`: `:3-19` — holds a `ChecklistEntityQuery`; `perform()` `:13-16` → `query.suggestedEntities()` → names → `ListChecklistsDialogue.message` (`:23-39`: empty / single / plural). +- Test seams: `@MainActor init(store:targeting:gate:)` (`RunChecklistIntent.swift:34-37`); tested by `CheckStitchTests/RunChecklistIntentTests.swift` (with `SpyReminderDestination`, ghost-id case `:59-64`), `ListChecklistsIntentTests.swift:51`, `ChecklistEntityQueryTests.swift:20-79`. +- Lifecycle constraint: every entrypoint is `@MainActor`; intents run in the Siri process, construct their own store per invocation, and must not prompt permissions from a cold process (`RunChecklistIntent.swift:53-54`). +- Not present: no in-app dashboard/`AppShortcut` beyond `CheckStitchShortcuts.swift`; no `INIntent` shim; **no widget / AppIntents widget surface today**. The only two intents are `RunChecklistIntent` and `ListChecklistsIntent`. `WatchChecklistStore` (`Core/.../ChecklistSync.swift:255`) is a separate watch KVS store, not used by intents. + +## Q3: Persistence and ownership of the checklist store + +### Findings +- `ChecklistStore` is an `@Observable final class` in the **app target** (`CheckStitch/ChecklistStore.swift:3`), not Core. Init `defaults: UserDefaults = AppGroup.defaults, key = "checklists.v1", textEditDelay = 300ms` (`:49-52`). Storage = single UserDefaults key `checklists.v1` holding a versioned `ChecklistEnvelope` (`:104-111`, encoded by `ChecklistCodec`). +- On init: reads/classifies payload, migrates v1/v2/v3, `unsupportedVersion` (newer payload → `canOverwriteStoredPayload=false`, in-memory-only mutations, saves refused), `unreadable` (`:72-101`). Stable per-install `deviceID` UUID stamped into every envelope (key `checklist.deviceID`, `:55-63,118`). +- App-Group KV: `AppGroup.suiteName = "group.app.alanvardy.CheckStitch"`; `AppGroup.defaults = UserDefaults(suiteName:) ?? .standard` (`CheckStitch/AppGroup.swift:6-11`). Pinned byte-for-byte in `CheckStitch/AppGroup.entitlements` (AGENTS.md; `CheckStitchTests/AppGroupTests.swift:6-12`). Other group-backed stores: `RunCounter(defaults: AppGroup.defaults)` (`ChecklistRunViewModel.swift:16`), `PurchaseEntitlementCache(defaults: AppGroup.defaults)` via `PurchaseEnvironment.service` (`MyApp.swift:133-136`). +- **No singleton** on `ChecklistStore`. App owns one instance in `MyApp.init()`, held in `@State` (`MyApp.swift:34-46`), shared by the list/run/import-export VMs and injected via `.environment(store)` (`ContentView.swift:632-643`). Constructed on the main actor. +- Independent callers open the same store with `ChecklistStore(defaults: AppGroup.defaults)` — exactly what the intents do (`RunChecklistIntent.swift:42`, `ChecklistEntity.swift:28-31` "Fresh store per call when nothing is injected (design decision 7)"). So a fresh instance per call in the same app group is the established pattern. +- KVS sync ownership: `ChecklistSyncService` (`@MainActor @Observable`, `ChecklistSyncService.swift:15-17`): `init(sync:any ChecklistSyncing, store:, pushDelay:)` (`:27-31`); `start()` wires external-change observer + store `onChange` (`:35-43`); `syncOnLaunch()` (`:46-53`). Real adapter `UbiquitousChecklistSync` (`@MainActor`, `Core/.../ChecklistSyncing.swift:20-26`) over one long-lived `NSUbiquitousKeyValueStore .default`, key `"checklists.v1"`; `synchronize()` `:30`; `startObserving` on `.main` queue `:33-43`. +- Construction chain: `MyApp.init()` → `ChecklistSyncService(sync: UbiquitousChecklistSync(), store:)` + `syncService.start()` (`MyApp.swift:35-37`); injected via `.environment(syncService)` (`ContentView.swift:7`). Reconcile = last-write-wins via `ChecklistMerge`, coalesced, debounced push, refuses empty push on fresh install (`ChecklistSyncService.swift:109-134`); `pushNow()` synchronous bypass (`:96-103`). Store refuses remote folding when `canAcceptRemoteChanges` false (`ChecklistStore.swift:113-116`, `ChecklistSyncService.swift:115`). +- Watch path is separate: `ChecklistSyncCoordinator` (`Core/.../ChecklistSyncCoordinator.swift:6`) is the phone side of WCSession (pushes checklists + language, turns watch run requests into reminder creation); watch app owns `@State WatchChecklistStore(transport: WatchSyncAdapter())` (`CheckStitchWatch/CheckStitchWatchApp.swift:6-7`). + +## Q4: UI patterns for selecting and launching checklists + +### Findings +- Routing: `ContentView` is the sole screen wrapped in `NavigationStack(path: $path)` (`ContentView.swift:36`); `.navigationDestination(for: UUID.self)` (`:63`) pushes `ChecklistDetailView(checklistID: id)`. Selection-by-navigation is type-driven — any `UUID` reaches the detail view. +- Single-item select: `NavigationLink(checklist.name, value: checklist.id)` (`ContentView.swift:446`); new-list via `path.append(listVM.createChecklist())` (`:557`). +- List-picker: `private var checklistList` (`:360`) renders `ScrollView > LazyVStack`, `ForEach(listVM.checklists)` (`:388-391`) with `Divider()` rows, width-capped. `listVM.checklists = store.checklists` (`ChecklistListViewModel.swift:17`), names from `Checklist.name`. +- Row builder `checklistRow(for checklist)` (`ContentView.swift:423-460`): edit mode swaps in remove control (`:433-438`) + `Text(checklist.name)` + `checklistMoveControls` (`:459-478`); non-edit shows `NavigationLink` + a dedicated run button. +- Run control: `createRemindersButton(for id)` (`ContentView.swift:507-524`) calls `runVM.createReminders(for: id)` in a `Task`; per-list `ProgressView` while creating, green `checkmark.circle.fill` once created, else `play.circle.fill`; disabled while creating. +- Multi-select: `CheckStitch/ChecklistSelectionView.swift` — a shared multi-checkmark sheet. Row model `ChecklistSelectionRow` (id, name, `detail: LocalizedStringKey?`) `:7-17` (caption is a localizable key, not verbatim). Selection is a `Set` (`:23`); pure `static func toggled(_ selection, id)` (`:37-46`); `canConfirm = !selection.isEmpty` (`:32`); optional iOS-only second action (`#if os(iOS)` `:36`/`:79`); rows fully tappable `.contentShape(Rectangle())` (`:76`); presented as a medium/large detent sheet `.presentationDetents([.medium, .large])` (`:88`). +- Instantiation: import multi-select `ContentView.swift:185-196` (title "Import Checklists", rows mapped from `importExportVM.importCandidates` to `ChecklistSelectionRow`, detail = "A checklist with this name exists" when conflicting, `confirmTitle:"Import"`); export uses the distinct `ExportChecklistsView` (`ContentView.swift:176-181`). Entry via `.sheet(isPresented: Binding(...))` with explicit dismiss side effects (`:170-196`). +- Name surfacing: `NavigationLink(checklist.name,…)` labels (`:446`), plain `Text(checklist.name)` edit rows (`:451`), `Text(row.name)` in selection rows (`ChecklistSelectionView.swift:63`), import-conflict copy `\(candidate.checklist.name)` (`ContentView.swift:237`). + +## Q5: Widget frameworks on the target OS (web research) + +### Findings +- Apple's cross-platform widget framework is **WidgetKit** (SwiftUI-based; iOS 14→26, macOS Big Sur→Tahoe 26, iPadOS, watchOS, visionOS, CarPlay). This is the platform's offering of user-visible widgets with fixed size-class families. UIKit/AppKit views not supported inside widgets. Sources: [Creating a widget extension](https://developer.apple.com/documentation/widgetkit/creating-a-widget-extension), [Building Widgets Using WidgetKit and SwiftUI](https://developer.apple.com/documentation/widgetkit/building_widgets_using_widgetkit_and_swiftui). +- Fixed size classes are grid "families": `.systemSmall` (~2×2), `.systemMedium` (~4×2), `.systemLarge` (~4×4), `.systemExtraLarge` (~8×4; iPad/Mac/visionOS — not iPhone). Declare `.supportedFamilies([.systemSmall, .systemLarge])`; branch per size via `@Environment(\.widgetFamily)`. Small-only+large is a supported subset. [Supporting additional widget sizes](https://developer.apple.com/documentation/widgetkit/supporting-additional-widget-sizes), [HIG Widgets](https://developer.apple.com/design/human-interface-guidelines/widgets). +- Accessory sizes (separate fixed set): `accessoryCircular`, `accessoryRectangular`, `accessoryInline`, `accessoryCorner` — monochrome, non-interactive snapshots for iOS Lock Screen (iOS 16+) and Watch complications, same WidgetKit code. [Creating accessory widgets and watch complications](https://developer.apple.com/documentation/WidgetKit/Creating-accessory-widgets-and-watch-complications), [TechCrunch WWDC22](https://techcrunch.com/2022/06/06/apples-widgetkit-update-lets-developers-build-for-the-lock-screen-and-watch-with-the-same-code/). +- Binding to an app action: (a) deep link `widgetURL(_:)` opens the containing app (`.onOpenURL`); (b) interactive `Button(intent:)`/`Toggle` backed by **App Intents** run the action without launching the app; iOS 26 adds `WidgetPushHandler`. [Adding interactivity to widgets and Live Activities](https://developer.apple.com/documentation/WidgetKit/Adding-interactivity-to-widgets-and-Live-Activities), [widgetURL](https://developer.apple.com/documentation/SwiftUI/View/widgetURL(_:)). +- macOS: same WidgetKit on desktop + Notification Center (Sonoma+). macOS Tahoe 26 adds `ControlWidget` (SwiftUI, macOS 26+) with `ControlWidgetButton`/`ControlWidgetToggle` bound to App Intents, placeable in Control Center (small/medium/large) or menu bar. "Widget packs" is a marketing label, not a distinct API. [ControlWidget](https://developer.apple.com/documentation/swiftui/controlwidget), [WidgetKit updates](https://developer.apple.com/documentation/updates/widgetkit). +- Packaging model: a widget is a separate Xcode **"widget extension"** target whose product is an `.appex` embedded in the app bundle at `PlugIns/` (e.g. `MyApp.app/PlugIns/MyWidgetExtension.appex`) via an "Embed Foundation Extensions" build phase. One extension exposes many widgets via `@main struct ...: WidgetBundle`. The extension is its own distributable: own App ID (prefixed by the app bundle id), own provisioning/distribution profile, own entitlements and signature. [WidgetBundle](https://developer.apple.com/documentation/swiftui/widgetbundle), [Configuring App Groups](https://developer.apple.com/documentation/xcode/configuring-app-groups). +- **In-process vs host**: widgets do **not** run in the app. The system launches the extension on demand in its own sandboxed process (separate file container, ~tens-of-MB ceiling) and renders cached timeline snapshots; the app process is not involved while a widget is on screen. App↔widget data sharing requires an App Group entitlement (`com.apple.security.application-groups`, `group.`-prefixed container id) on **both** targets; app writes / widget reads via shared `UserDefaults(suiteName:)` or a shared file/database container. App Intents run in the extension process by default; only intents with `openAppWhenRun` (or conforming e.g. `AudioPlaybackIntent`, `LiveActivityIntent`) run in the app's process. [Developing a WidgetKit strategy](https://developer.apple.com/documentation/WidgetKit/Developing-a-WidgetKit-strategy). +- iOS 26 surfaces: widgets on Home Screen (user-placed), Lock Screen, StandBy, CarPlay (`systemSmall`); new `\.widgetRenderingMode`/`widgetAccentedRenderingMode`; `.systemExtraLargePortrait` on visionOS/iPad. [WWDC25 session 278](https://developer.apple.com/videos/play/wwdc2025/278/). +- Adjacent surfaces (distinct): **Live Activities** (ActivityKit: `Activity.request/update/end` + `ActivityConfiguration`) render Lock Screen / Dynamic Island real-time content with `activityFamily` sizes (`.supplementalActivityFamilies([.small,.medium])`); on macOS Tahoe and via iPhone Mirroring (iOS 18+). [ActivityKit updates](https://developer.apple.com/documentation/updates/activitykit), [9to5Mac](https://9to5mac.com/2025/06/16/macos-26-live-activities-work-even-if-your-iphone-is-on-ios-18/). +- Gaps: numeric grid units (2×2/4×2/4×4/8×4) and physical point sizes are search-synthesised from HIG mirrors/WWDC notes, not pixel-verified against a fetched Apple page; macOS Tahoe 26 specifics rest on Apple docs + press. + +## Q6: Build/delivery per target, entitlements, gating + +### Findings +- Single Makefile, three platforms: `Makefile:1-19` — `SIM` (explicit `SIM=` > worktree `.simulator_id` > `name=iPhone 17`), `MAC_SIM = platform=macOS`, `WATCH_SIM = generic/platform=watchOS Simulator`; env-overridable `SCHEME`, `WATCH_SCHEME`, `CONFIGURATION`, `DERIVED_DATA`. `WARNINGS_AS_ERRORS := SWIFT_TREAT_WARNINGS_AS_ERRORS=YES GCC_TREAT_WARNINGS_AS_ERRORS=YES` (`Makefile:20`). +- iOS: `make build` (`Makefile:26-31`); `make run` (`:61`) = build + `scripts/run-simulator.sh`. +- macOS: `make build-mac` (`:48-57`, unsigned gate leg, `CODE_SIGNING_ALLOWED=NO`); `make build-mac-signed` (`:62-72`, adds `-allowProvisioningUpdates`, team-signs → AppGroup.entitlements incl. KVS id lands in provisioning profile; comment `:42-46`). Real-device launch via `scripts/run-devices.sh` uses the signed leg (`run-devices.sh:14-19,52`). +- watchOS: `make watch-build` (`Makefile:78-89`, unsigned, sim-free); real watch via `scripts/run-watch.sh` (`run-watch.sh:1-24`, `BUNDLE_ID=app.alanvardy.CheckStitch.watchkitapp`, devicectl name→identifier resolve, never bare `name=`). +- Tests: `make test` = `test-unit` + `test-ui` (`Makefile:92`). `test-unit` (`:99-108`) on `platform=macOS`, `CODE_SIGNING_ALLOWED=NO`, warnings-as-errors. `test-ui` (`:114-129`) = one `CheckStitchUITests` smoke via `build-for-testing` + `test-without-building` on `$(SIM)`. +- Entitlements: `CheckStitch/AppGroup.entitlements` — App Group `group.app.alanvardy.CheckStitch` + KVS `com.apple.developer.ubiquity-kvstore-identifier = $(TeamIdentifierPrefix)app.alanvardy.CheckStitch`. Applied to all SDK slices: `pbxproj:504-506` (`CODE_SIGN_ENTITLEMENTS[sdk=iphoneos|iphonesimulator|macosx]`); `DEVELOPMENT_TEAM = 6NWX2DHB9Q` (`pbxproj:509`); reminders usage strings `pbxproj:516-517`; app target `SUPPORTED_PLATFORMS="iphoneos iphonesimulator macosx"`, `TARGETED_DEVICE_FAMILY="1,2"` (`pbxproj:536,542`). +- Gate `scripts/test.sh:21-…`: `disk-clean` → verify `.simulator_id` → `make build` → bounded host lock `${TMPDIR:-/tmp}/checkstitch-simulator.lock` + quit `Simulator.app` + pre-boot UDID headlessly → `make test` → `make build-mac` → `make watch-build` (comment `:120-125` — iOS-only API can compile green and break on macOS/watchOS) → `scripts/tests/run.sh` (unless `GATE_TESTS_SKIP=1`) → `shellcheck`. EXIT trap releases lock + shuts down only the gate UDID (`:113-126`). +- Where a new surface lands: new Swift files need **no pbxproj edit** (`PBXFileSystemSynchronizedRootGroup`, `pbxproj:62-86`). New **targets** are `PBXNativeTarget` entries — existing: CheckStitch app (`pbxproj:172-174`), CheckStitchTests (`196-198`), CheckStitchUITests (`220-222`), CheckStitchWatch (`241-243`), CheckStitchCore (`803`). Watch platform config `pbxproj:708` (`watchos watchsimulator`), `TARGETED_DEVICE_FAMILY=4`, `WKCompanionAppBundleIdentifier=app.alanvardy.CheckStitch` (`:700,714`). A new per-platform surface = new `PBXNativeTarget` + committed shared scheme (`CheckStitch.xcodeproj/xcshareddata/xcschemes/`) + Makefile leg (`watch-build` `Makefile:78-89` is the template) + new gate leg in `scripts/test.sh`. +- Enforcement pins: `scripts/tests/run.sh:395` `WARNINGS_AS_ERRORS_LEGS=(build-mac build test-unit test-ui watch-build)`; `:398-416` asserts each compiling leg logs both flags; `:421-429` detects a stripped flag; `:369-385` asserts every `ENABLE_APP_SANDBOX=YES` block also has `ENABLE_OUTGOING_NETWORK_CONNECTIONS=YES`; `scripts/test.sh:128-135` shellchecks every `scripts/*.sh` (must parse under `/bin/bash` 3.2). Device helper legs (`build-mac-signed`, `run-watch.sh`, `run-devices.sh`) are outside warnings-as-errors enforcement. + +## Cross-Cutting Observations +- **Reusable run primitive**: the only path that materialises reminders is `ChecklistReminders.create(...)` (full `ChecklistReminders.swift:28-67`) and `ChecklistRunViewModel.createReminders(for:)`. Any new surface wanting to "run a checklist" uses one of these plus the run gate — there is no other creation path in the live flow. +- **Established out-of-app pattern**: App Intents already run a checklist outside the app UI with `openAppWhenRun=false`, constructing a fresh `ChecklistStore(defaults: AppGroup.defaults)` per call, using `EventKitReminderDestination.shared`, resolving purchase + `RunGate` via `ChecklistReminders.productionGate()`, and doing a status-only access pre-check (no prompt from a cold process — `RunChecklistIntent.swift:53-54`). This is the direct precedent for a widget extension that must not launch the app. +- **@MainActor dominates**: run VMs, the EventKit destination (singleton `shared`), sync service, and intents are all `@MainActor`. Only the Core `ChecklistCreator` struct is `Sendable`. +- **No existing widget surface** exists in the repo — the widget side is entirely greenfield; only the intent/`EntityQuery` model and the App-Group KV store are reusable. +- **App-Group is the data bridge**: App Group entitlement already exists for the app; Q5 confirms the same entitlement (added to both targets) is exactly what lets a widget extension share the `checklists.v1` payload via `UserDefaults(suiteName:)`. + +## Open Areas +- Exact date-offset arithmetic lives in `ChecklistItem.dueDateComponents` (core) and was not read; the flow only needs `dueDateComponents(today: Date())` at `ChecklistReminders.swift:58-64`. +- `ChecklistReminders` single-arg `create(from:)` production overload (used by the watch coordinator at `MyApp.swift:98`) resolves `.shared` + `productionGate()` internally — exact body not opened. +- Q5 grid-unit physical sizes and macOS Tahoe 26 behaviour are web-synthesised, not pixel-verified or SDK-verified. +- The full CI workflow YAML (`.github/`) gates on `scripts/test.sh` but was not opened; device legs carry their own caveats. +- Whether an `.appex` widget extension target can even be produced/run in this repo's environment (simulator vs real device, provisioning/signing of a second distributable, App Group on both targets) is unverified on this machine. \ No newline at end of file diff --git a/.pi/orksorksorks/alanvardy-var-1065-add-widgets/structure.md b/.pi/orksorksorks/alanvardy-var-1065-add-widgets/structure.md new file mode 100644 index 0000000..0cd9263 --- /dev/null +++ b/.pi/orksorksorks/alanvardy-var-1065-add-widgets/structure.md @@ -0,0 +1,157 @@ +# Structure Outline + +## Approach + +Ship an iOS widget extension that reads the existing App Group payload and runs a +checklist through the one live reminder path (`ChecklistReminders` → +`EventKitReminderDestination` → `RunChecklistIntent`). Because extensions cannot +link the app target, Phase 0 is the one genuinely horizontal step: move the run +seam into `CheckStitchCore`. Every later phase is a thin vertical slice: a real +WidgetKit entry point → real Core logic → the App Group store → a rendered, +tappable widget, tested and gated when it lands. + +> **Design gap resolved here.** `ChecklistEntityQuery` (moving to Core) and the +> widget timeline both need to read checklists; the read model is +> `ChecklistStore`, which the design's Decision 6 list omitted. Phase 0 therefore +> also moves `ChecklistStore` + `ChecklistMerge` (pure, Swift-only deps) so the +> existing query implementation survives unchanged. If that churn explodes, fall +> back to a narrow `ChecklistReading` protocol in Core. + +## Phase 0 — Core extraction (horizontal exception) + +No user-visible change; the app/watch/tests keep working with `import +CheckStitchCore`. One green commit, expand-only: add the types to Core, delete +them from the app target, update consumers. Nothing new is authored. + +**Files**: `CheckStitchCore/Sources/CheckStitchCore/{AppGroup,ChecklistStore,ChecklistMerge,ChecklistReminders,EventKitReminderDestination,ChecklistEntity,RunChecklistIntent,ListChecklistsIntent}.swift`; consumers `CheckStitch/MyApp.swift`, `CheckStitch/ContentView.swift`, `CheckStitchWatch/*`, `CheckStitchTests/*`. + +**Key changes** (visibility only — bodies unchanged): +- `public enum AppGroup { public static let suiteName: String; public static var defaults: UserDefaults }` +- `public final class ChecklistStore` — `init(defaults:key:textEditDelay:)` unchanged; `public var checklists: [Checklist]`, `public func checklist(id:)` +- `public enum ChecklistReminders { public static func create(from:targeting:gate:) async -> ReminderRunOutcome; public static func productionGate() async -> RunGate }` +- `public @MainActor final class EventKitReminderDestination: ReminderDestinationTargeting` +- `public struct ChecklistEntity`, `public struct RunChecklistIntent: AppIntent`, `ListChecklistsIntent`, `ChecklistEntityQuery` + +**Contract**: the extension may `import CheckStitchCore` and use +`ChecklistStore(defaults: AppGroup.defaults)`, `ChecklistReminders.create(...)`, +and `RunChecklistIntent` exactly as the Siri path does. + +**Tests**: existing suites unchanged (`@testable import CheckStitchCore`). +**Verify**: `make test-unit`, then `bash scripts/test.sh` green — pure move, no new tests. + +--- + +## Phase 1 — Walking skeleton: small widget runs a checklist + +User places a `systemSmall` widget; it loads the first checklist from the App +Group, shows its name, and a tap creates its reminders with no app foreground. +Green tests prove packaging (`.appex` embedded, unsigned `widget-build` leg) and +interactive-App-Intent + EventKit running out of process. Configuration is +deliberately hard-coded. + +**Files**: `CheckStitchWidget/{ChecklistWidgetBundle,SingleChecklistWidget}.swift`, `CheckStitchWidget/CheckStitchWidget.entitlements`; `CheckStitchCore/Sources/CheckStitchCore/ChecklistWidgetDisplayModel.swift`; `CheckStitchTests/ChecklistWidgetDisplayModelTests.swift`; `project.pbxproj` (new `PBXNativeTarget` + Embed Foundation Extensions phase); `CheckStitch.xcodeproj/xcshareddata/xcschemes/CheckStitchWidget.xcscheme`; `Makefile` (`widget-build`), `scripts/test.sh`, `scripts/tests/run.sh` (`WARNINGS_AS_ERRORS_LEGS` + pin). + +**Key changes**: +- `struct ChecklistWidgetRow: Identifiable, Equatable, Sendable { id: Checklist.ID; name: String; entityID: ChecklistEntity.ID; isRunnable: Bool; needsAccess: Bool }` +- `enum ChecklistWidgetAccessState { ready, needsAccess, needsPurchase }` +- `struct ChecklistWidgetDisplayModel { init(checklists: [Checklist], configuration: [ChecklistEntity], access: ChecklistWidgetAccessState); var rows: [ChecklistWidgetRow] }` +- `struct SingleChecklistWidget: Widget` — `StaticConfiguration`, first-checklist timeline, `Button(intent: RunChecklistIntent(checklist:))` +- `make widget-build` — extension for `generic/platform=iOS Simulator`, unsigned, `WARNINGS_AS_ERRORS` + +**Contract**: the display-model API above; widget kind id; extension bundle id +`app.alanvardy.CheckStitch.widget`; run buttons always go through +`RunChecklistIntent`. + +**Tests**: `ChecklistWidgetDisplayModelTests` — a checklist maps to a runnable row; empty checklists → empty rows. +**Verify**: `make test-unit` and `make widget-build` pass; manual on simulator — place widget, tap run, reminders appear without foregrounding. + +--- + +## Phase 2 — Choose which checklist (small widget config) + +User picks the checklist in the widget's edit UI instead of getting the first one. + +**Files**: `CheckStitchCore/Sources/CheckStitchCore/ChecklistConfigurationIntent.swift`; `CheckStitchWidget/SingleChecklistWidget.swift`; `CheckStitchTests/ChecklistWidgetDisplayModelTests.swift`; extension `Localizable.xcstrings`. + +**Key changes**: +- `struct ChecklistConfigurationIntent: WidgetConfigurationIntent { @Parameter(title:) var checklist: ChecklistEntity?; var title/description }` +- `SingleChecklistWidget` switches `StaticConfiguration` → `AppIntentConfiguration(intent:provider:)` +- display model resolves the selected entity id to its checklist. + +**Contract**: `ChecklistConfigurationIntent` + `AppIntentConfiguration` usage; +later slices attach their own configuration intents without touching this one. + +**Tests**: selected entity maps to its row; unset/ghost entity → empty rows. +**Verify**: `make test-unit`, `make widget-build`; manual — edit widget, pick a checklist, it renders and runs. + +--- + +## Phase 3 — Large widget with per-row run buttons + +User places a `systemLarge` widget, picks several checklists, and each row runs +independently (one gate slot per tap). + +**Files**: `CheckStitchCore/Sources/CheckStitchCore/{MultiChecklistConfigurationIntent,ChecklistWidgetDisplayModel}.swift`; `CheckStitchWidget/{ChecklistWidgetBundle,MultiChecklistWidget}.swift`; `CheckStitchTests/ChecklistWidgetDisplayModelTests.swift`. + +**Key changes**: +- `struct MultiChecklistConfigurationIntent: WidgetConfigurationIntent { @Parameter(title:) var checklists: [ChecklistEntity] }` +- `struct MultiChecklistWidget: Widget` — `systemLarge`, `ForEach(model.rows)` with a per-row `Button(intent: RunChecklistIntent(checklist:))` +- display model preserves configuration order and caps to a row budget. + +**Contract**: `MultiChecklistConfigurationIntent`; row `entityID` remains the +single per-row run key. + +**Tests**: N entities → N ordered runnable rows; subset of configured entities missing from the store is dropped. +**Verify**: `make test-unit`, `make widget-build`; manual — large widget, three rows, each tap creates only that checklist's reminders. + +--- + +## Phase 4 — Access and purchase states + +When Reminders access is not `.fullAccess` (or a run needs the app), the widget +renders a non-interactive "open app to enable" state whose `widgetURL` launches +CheckStitch — the interactive button never shows the intent's dialog. + +**Files**: `CheckStitchCore/Sources/CheckStitchCore/ChecklistWidgetDisplayModel.swift`; `CheckStitchWidget/{SingleChecklistWidget,MultiChecklistWidget}.swift`; extension `Localizable.xcstrings`; `CheckStitchTests/{ChecklistWidgetDisplayModelTests,LocalizationFixtures}.swift`. + +**Key changes**: +- provider folds `EventKitReminderDestination.shared.accessStatus()` and purchase state into `ChecklistWidgetAccessState` +- display model: `access != .ready` ⇒ every row `isRunnable = false`, `needsAccess = true` +- views: runnable → `Button`, otherwise row + `widgetURL(URL(string:"checkstitch://"))` + +**Contract**: `ChecklistWidgetAccessState` is the only channel for permission +state; views never call EventKit. + +**Tests**: `.needsAccess` / `.needsPurchase` ⇒ no rows runnable; `.ready` ⇒ runnable. +**Verify**: `make test-unit`, `make widget-build`; manual — revoke Reminders access, widget shows the enable state and tapping opens the app. + +--- + +## Phase 5 — Hardening, empty states, localization + +Unconfigured/empty widgets, timeline refresh policy, widget-gallery metadata and +previews, and complete localization so the gate's l10n and shellcheck legs stay +green. + +**Files**: `CheckStitchWidget/*` (empty-state view, gallery metadata, previews); extension `Localizable.xcstrings`; `CheckStitchTests/LocalizationFixtures.swift`; `scripts/tests/run.sh` (final pin). + +**Key changes**: `TimelineReloadPolicy`/`.after` refresh; `noChecklists` empty +state; widget display name + description keys in all 6 languages. + +**Contract**: none new — the display model's row/state shape is frozen. + +**Tests**: `LocalizationFixtures.requiredKeys` includes every new key; empty-config path covered. +**Verify**: `scripts/l10n-check.sh`, `shellcheck scripts/*.sh scripts/tests/*.sh`, then full `./scripts/test.sh` (`gate: ok`). + +--- + +## Testing Checkpoints + +- After Phase 0: `make test-unit` + `bash scripts/test.sh` green (pure move). +- After Phase 1: `make test-unit`, `make widget-build`; manual small-widget run creates reminders. +- After Phase 2: `make test-unit`, `make widget-build`; picked checklist renders and runs. +- After Phase 3: manual large-widget per-row runs; `make widget-build`. +- After Phase 4: revoked-access manual check; display-model state tests green. +- After Phase 5: `scripts/l10n-check.sh`, `shellcheck`, full `./scripts/test.sh` prints `gate: ok`. + +Next: run `!1` to plan. diff --git a/.pi/orksorksorks/alanvardy-var-1065-add-widgets/task.md b/.pi/orksorksorks/alanvardy-var-1065-add-widgets/task.md new file mode 100644 index 0000000..28a667e --- /dev/null +++ b/.pi/orksorksorks/alanvardy-var-1065-add-widgets/task.md @@ -0,0 +1,6 @@ +# Task + +Implement home-screen widgets for CheckStitch so users can rapidly launch +checklists without opening the app: small widgets bound to a single checklist, +and larger widgets able to trigger multiple checklists at once. This is a new +capability on top of the existing checklist/reminder model. \ No newline at end of file From f8648b274a8e5a3bfa09fdd219e7211e4362d66d Mon Sep 17 00:00:00 2001 From: Alan Vardy Date: Sat, 26 Sep 2026 19:41:58 -0700 Subject: [PATCH 10/15] fix: distinguish purchase vs access state in widget copy Render "Open CheckStitch to buy a license" for needsPurchase rows and show the edit hint for an unconfigured widget instead of silently running the first checklist. --- .../ChecklistWidgetDisplayModel.swift | 15 +++++++++++++-- .../ChecklistWidgetDisplayModelTests.swift | 18 +++++++++++++++++- CheckStitchWidget/MultiChecklistWidget.swift | 8 +++++++- CheckStitchWidget/SingleChecklistWidget.swift | 19 ++++++++++++------- 4 files changed, 49 insertions(+), 11 deletions(-) diff --git a/CheckStitchCore/Sources/CheckStitchCore/ChecklistWidgetDisplayModel.swift b/CheckStitchCore/Sources/CheckStitchCore/ChecklistWidgetDisplayModel.swift index 626789d..2ce5b0c 100644 --- a/CheckStitchCore/Sources/CheckStitchCore/ChecklistWidgetDisplayModel.swift +++ b/CheckStitchCore/Sources/CheckStitchCore/ChecklistWidgetDisplayModel.swift @@ -6,15 +6,20 @@ public struct ChecklistWidgetRow: Identifiable, Equatable, Sendable { public let name: String public let entityID: String // ChecklistEntity.ID == checklist.id.uuidString public let isRunnable: Bool + /// True for any non-`.ready` state: the row opens the app on tap. public let needsAccess: Bool + /// True only when the free-run limit was reached without a license, so the + /// views can offer "buy a license" instead of the access prompt. + public let needsPurchase: Bool public init(id: Checklist.ID, name: String, entityID: String, - isRunnable: Bool, needsAccess: Bool) { + isRunnable: Bool, needsAccess: Bool, needsPurchase: Bool) { self.id = id self.name = name self.entityID = entityID self.isRunnable = isRunnable self.needsAccess = needsAccess + self.needsPurchase = needsPurchase } } @@ -41,9 +46,15 @@ public struct ChecklistWidgetDisplayModel: Equatable, Sendable { name: checklist.name, entityID: entity.id, isRunnable: access == .ready, - needsAccess: access != .ready) + needsAccess: access != .ready, + needsPurchase: access == .needsPurchase) }) + self.hasChecklists = !checklists.isEmpty } public let rows: [ChecklistWidgetRow] + + /// Whether the store has any checklist at all, letting the empty-state hint + /// distinguish "nothing configured yet" from "nothing to configure". + public let hasChecklists: Bool } \ No newline at end of file diff --git a/CheckStitchTests/ChecklistWidgetDisplayModelTests.swift b/CheckStitchTests/ChecklistWidgetDisplayModelTests.swift index d423f40..5c838eb 100644 --- a/CheckStitchTests/ChecklistWidgetDisplayModelTests.swift +++ b/CheckStitchTests/ChecklistWidgetDisplayModelTests.swift @@ -73,6 +73,7 @@ struct ChecklistWidgetDisplayModelTests { configuration: [ChecklistEntity(checklist)], access: access) #expect(model.rows.allSatisfy { !$0.isRunnable && $0.needsAccess }) + #expect(model.rows.allSatisfy { $0.needsPurchase == (access == .needsPurchase) }) } @Test @@ -82,6 +83,21 @@ struct ChecklistWidgetDisplayModelTests { checklists: [checklist], configuration: [ChecklistEntity(checklist)], access: .ready) - #expect(model.rows.allSatisfy { $0.isRunnable && !$0.needsAccess }) + #expect(model.rows.allSatisfy { $0.isRunnable && !$0.needsAccess && !$0.needsPurchase }) + } + + /// The empty-state hint needs to tell "nothing configured yet" (offer the + /// edit affordance) from "nothing to configure" (the store is empty). + @Test + func hasChecklistsDistinguishesUnconfiguredFromEmptyStore() { + let unconfigured = ChecklistWidgetDisplayModel( + checklists: [Checklist(name: "Groceries")], configuration: [], access: .ready) + #expect(unconfigured.rows.isEmpty) + #expect(unconfigured.hasChecklists) + + let emptyStore = ChecklistWidgetDisplayModel( + checklists: [], configuration: [], access: .ready) + #expect(emptyStore.rows.isEmpty) + #expect(!emptyStore.hasChecklists) } } \ No newline at end of file diff --git a/CheckStitchWidget/MultiChecklistWidget.swift b/CheckStitchWidget/MultiChecklistWidget.swift index beed506..b2a0dec 100644 --- a/CheckStitchWidget/MultiChecklistWidget.swift +++ b/CheckStitchWidget/MultiChecklistWidget.swift @@ -51,7 +51,10 @@ struct MultiChecklistWidgetView: View { var body: some View { if entry.model.rows.isEmpty { - Text("Edit this widget to pick a checklist").font(.caption) + Text(entry.model.hasChecklists + ? "Edit this widget to pick a checklist" + : "No checklists") + .font(.caption) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } else { VStack(alignment: .leading, spacing: 6) { @@ -64,6 +67,9 @@ struct MultiChecklistWidgetView: View { Image(systemName: "play.circle.fill") } .buttonStyle(.plain) + } else if row.needsPurchase { + Label("Open CheckStitch to buy a license", systemImage: "exclamationmark.triangle") + .font(.caption) } else { Label("Open CheckStitch to enable", systemImage: "exclamationmark.triangle") .font(.caption) diff --git a/CheckStitchWidget/SingleChecklistWidget.swift b/CheckStitchWidget/SingleChecklistWidget.swift index e006a48..3b5f63c 100644 --- a/CheckStitchWidget/SingleChecklistWidget.swift +++ b/CheckStitchWidget/SingleChecklistWidget.swift @@ -10,15 +10,13 @@ struct ChecklistEntry: TimelineEntry { @MainActor enum ChecklistWidgetLoader { - /// Reads the App Group store and folds access state. Phase 1 hard-codes the - /// first checklist; later phases pass the intent's configuration. + /// Reads the App Group store and resolves the intent's configured + /// checklists. An unconfigured widget renders the empty-state hint rather + /// than silently running an arbitrary first checklist. static func load(configuration: [ChecklistEntity]) -> ChecklistWidgetDisplayModel { let checklists = ChecklistStore(defaults: AppGroup.defaults).checklists - let resolved = configuration.isEmpty - ? checklists.first.map { [ChecklistEntity($0)] } ?? [] - : configuration return ChecklistWidgetDisplayModel( - checklists: checklists, configuration: resolved, access: accessState()) + checklists: checklists, configuration: configuration, access: accessState()) } static func accessState() -> ChecklistWidgetAccessState { @@ -85,6 +83,9 @@ struct SingleChecklistWidgetView: View { Label("Create reminders", systemImage: "play.circle.fill") } .buttonStyle(.borderedProminent) + } else if row.needsPurchase { + Label("Open CheckStitch to buy a license", systemImage: "exclamationmark.triangle") + .font(.caption) } else { Label("Open CheckStitch to enable", systemImage: "exclamationmark.triangle") .font(.caption) @@ -93,7 +94,11 @@ struct SingleChecklistWidgetView: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .widgetURL(row.needsAccess ? URL(string: "checkstitch://") : nil) } else { - Text("No checklists").font(.caption) + Text(entry.model.hasChecklists + ? "Edit this widget to pick a checklist" + : "No checklists") + .font(.caption) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } } From b8437633f58b7fa92d348f67f9d80f2fddbd70de Mon Sep 17 00:00:00 2001 From: Alan Vardy Date: Sat, 26 Sep 2026 19:42:07 -0700 Subject: [PATCH 11/15] docs: add VAR-1065 review done marker --- .../alanvardy-var-1065-add-widgets/done.md | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .pi/orksorksorks/alanvardy-var-1065-add-widgets/done.md diff --git a/.pi/orksorksorks/alanvardy-var-1065-add-widgets/done.md b/.pi/orksorksorks/alanvardy-var-1065-add-widgets/done.md new file mode 100644 index 0000000..9bca0c7 --- /dev/null +++ b/.pi/orksorksorks/alanvardy-var-1065-add-widgets/done.md @@ -0,0 +1,36 @@ +# Done + +- **Branch / head SHA**: `alanvardy-var-1065-add-widgets` @ `7aa2403` + (fix commit; review pipeline head `a562a2c` + fix `7aa2403`, both pushed). + +- **Mechanical checks**: `./scripts/test.sh` → `gate: ok` after the fix. + Covers `make build` (iOS sim), headless pre-boot + `make test`, + `make build-mac`, `make watch-build`, `make widget-build` (warnings-as-errors), + `scripts/tests/run.sh` (26 passed, 0 failed), `scripts/l10n-check.sh` (`ok`, + 4 catalogs), and `shellcheck scripts/*.sh scripts/tests/*.sh`. + No warnings flagged. No rebase conflicts (branch was already current). + +- **Review outcome**: one bounded `reviewer` pass over the full + `git diff main...HEAD` (32 files), plus a parent scan of the new widget/Core + sources. **No blockers.** + - *Applied (fix worth doing now)*: the widget collapsed `.needsAccess` and + `.needsPurchase` into one non-runnable state and rendered the access prompt + for both, leaving `"Open CheckStitch to buy a license"` a dead catalog key. + Added `ChecklistWidgetRow.needsPurchase`, branched both views' copy, and + added display-model tests. Validated with the full gate. + - *Applied (optional improvement)*: `ChecklistWidgetLoader.load` no longer + silently falls back to the first checklist for an unconfigured widget; + it now renders the empty-state hint. Added + `ChecklistWidgetDisplayModel.hasChecklists` so the hint distinguishes + "nothing configured yet" (`Edit this widget to pick a checklist`) from an + empty store (`No checklists`). + - *Deferred/declined*: reviewer's "optional array/entity optionality could + surprise" nit — verified safe at all four call sites, no action. + +- **Remaining manual items** (cannot close on static evidence; see + `.pi/orksorksorks/alanvardy-var-1065-add-widgets/implement.md` and the phase + Manual sections in `plan.md`): all on-device/simulator widget verification is + still outstanding — add/edit small and large widgets, confirm run buttons + create reminders without foregrounding the app, free-run counter behaviour, + the access-off and purchase-limit states (now with distinct copy), the + unconfigured-widget hint, and localized widget strings on a language switch. \ No newline at end of file From d2e88fe3ee38c18c0b86ee25b64dd973cb4e6652 Mon Sep 17 00:00:00 2001 From: Alan Vardy Date: Sun, 27 Sep 2026 04:45:00 -0700 Subject: [PATCH 12/15] fix: show only the play icon on the single widget run button The label text made the button read "Create reminders" inside the widget; the multi widget already used an icon-only button. Keep the localized string as the accessibility label so VoiceOver still announces the action. --- CheckStitchWidget/SingleChecklistWidget.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CheckStitchWidget/SingleChecklistWidget.swift b/CheckStitchWidget/SingleChecklistWidget.swift index 3b5f63c..2a2b3e5 100644 --- a/CheckStitchWidget/SingleChecklistWidget.swift +++ b/CheckStitchWidget/SingleChecklistWidget.swift @@ -80,7 +80,8 @@ struct SingleChecklistWidgetView: View { Text(row.name).font(.headline).lineLimit(2) if row.isRunnable { Button(intent: runIntent(for: row)) { - Label("Create reminders", systemImage: "play.circle.fill") + Image(systemName: "play.circle.fill") + .accessibilityLabel("Create reminders") } .buttonStyle(.borderedProminent) } else if row.needsPurchase { From f170a87a129879eec00082e56b7e20875bc67caa Mon Sep 17 00:00:00 2001 From: Alan Vardy Date: Sun, 27 Sep 2026 04:54:17 -0700 Subject: [PATCH 13/15] style: enlarge the small widget's play button and the list widget's rows The single-checklist widget now shows a big circular play button filling the space under the checklist name. The multi-checklist widget's row titles move from .body to .title3 so the list reads larger. --- CheckStitchWidget/MultiChecklistWidget.swift | 2 +- CheckStitchWidget/SingleChecklistWidget.swift | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CheckStitchWidget/MultiChecklistWidget.swift b/CheckStitchWidget/MultiChecklistWidget.swift index b2a0dec..0dbfd09 100644 --- a/CheckStitchWidget/MultiChecklistWidget.swift +++ b/CheckStitchWidget/MultiChecklistWidget.swift @@ -60,7 +60,7 @@ struct MultiChecklistWidgetView: View { VStack(alignment: .leading, spacing: 6) { ForEach(entry.model.rows) { row in HStack { - Text(row.name).font(.body).lineLimit(1) + Text(row.name).font(.title3).lineLimit(1) Spacer() if row.isRunnable { Button(intent: runIntent(for: row)) { diff --git a/CheckStitchWidget/SingleChecklistWidget.swift b/CheckStitchWidget/SingleChecklistWidget.swift index 2a2b3e5..374954e 100644 --- a/CheckStitchWidget/SingleChecklistWidget.swift +++ b/CheckStitchWidget/SingleChecklistWidget.swift @@ -81,9 +81,12 @@ struct SingleChecklistWidgetView: View { if row.isRunnable { Button(intent: runIntent(for: row)) { Image(systemName: "play.circle.fill") + .font(.system(size: 56)) .accessibilityLabel("Create reminders") } .buttonStyle(.borderedProminent) + .buttonBorderShape(.circle) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) } else if row.needsPurchase { Label("Open CheckStitch to buy a license", systemImage: "exclamationmark.triangle") .font(.caption) From 1188dbdbd1f7ff5eae7cdaf9893a104c99743ade Mon Sep 17 00:00:00 2001 From: Alan Vardy Date: Sun, 27 Sep 2026 05:21:55 -0700 Subject: [PATCH 14/15] feat: show a spinner then a check in the widget run button A widget renders snapshots, so the app's play -> spinner -> checkmark pattern needs persisted state rather than view-model state: - WidgetRunStateStore (Core) records beginRun/finishRun per checklist in the App Group, with the app's 1s spinner floor, a 1.5s check window, an abandoned-run timeout, and validated reads. - RunChecklistIntent marks the run before its EventKit work and finishes it after, so the store's reload hook can render the spinner mid-run. - The display model carries a per-row WidgetRunIndicator, and both widgets render it (green check on success, play otherwise). - Timeline planning emits the entry that drops the check back to the play icon, so the sequence completes without another tap. Only a full success shows the check, matching the app's never-flash- success rule. --- .../ChecklistWidgetDisplayModel.swift | 13 +- .../ReminderDestinationTargeting.swift | 8 + .../CheckStitchCore/RunChecklistIntent.swift | 23 ++- .../CheckStitchCore/WidgetRunState.swift | 183 ++++++++++++++++++ .../ChecklistWidgetDisplayModelTests.swift | 26 +++ .../RunChecklistIntentTests.swift | 40 +++- .../WidgetRunStateStoreTests.swift | 176 +++++++++++++++++ CheckStitchWidget/MultiChecklistWidget.swift | 26 ++- CheckStitchWidget/SingleChecklistWidget.swift | 82 +++++++- 9 files changed, 555 insertions(+), 22 deletions(-) create mode 100644 CheckStitchCore/Sources/CheckStitchCore/WidgetRunState.swift create mode 100644 CheckStitchTests/WidgetRunStateStoreTests.swift diff --git a/CheckStitchCore/Sources/CheckStitchCore/ChecklistWidgetDisplayModel.swift b/CheckStitchCore/Sources/CheckStitchCore/ChecklistWidgetDisplayModel.swift index 2ce5b0c..d991fd6 100644 --- a/CheckStitchCore/Sources/CheckStitchCore/ChecklistWidgetDisplayModel.swift +++ b/CheckStitchCore/Sources/CheckStitchCore/ChecklistWidgetDisplayModel.swift @@ -11,15 +11,20 @@ public struct ChecklistWidgetRow: Identifiable, Equatable, Sendable { /// True only when the free-run limit was reached without a license, so the /// views can offer "buy a license" instead of the access prompt. public let needsPurchase: Bool + /// The run button's icon: play, a spinner while a run is in flight, or the + /// transient success check. Always `.play` for a non-runnable row. + public let indicator: WidgetRunIndicator public init(id: Checklist.ID, name: String, entityID: String, - isRunnable: Bool, needsAccess: Bool, needsPurchase: Bool) { + isRunnable: Bool, needsAccess: Bool, needsPurchase: Bool, + indicator: WidgetRunIndicator = .play) { self.id = id self.name = name self.entityID = entityID self.isRunnable = isRunnable self.needsAccess = needsAccess self.needsPurchase = needsPurchase + self.indicator = indicator } } @@ -36,7 +41,8 @@ public struct ChecklistWidgetDisplayModel: Equatable, Sendable { public static let rowLimit = 6 public init(checklists: [Checklist], configuration: [ChecklistEntity], - access: ChecklistWidgetAccessState) { + access: ChecklistWidgetAccessState, + runIndicators: [Checklist.ID: WidgetRunIndicator] = [:]) { let byID = Dictionary(checklists.map { ($0.id.uuidString, $0) }, uniquingKeysWith: { first, _ in first }) self.rows = Array(configuration.prefix(Self.rowLimit).compactMap { entity in @@ -47,7 +53,8 @@ public struct ChecklistWidgetDisplayModel: Equatable, Sendable { entityID: entity.id, isRunnable: access == .ready, needsAccess: access != .ready, - needsPurchase: access == .needsPurchase) + needsPurchase: access == .needsPurchase, + indicator: runIndicators[checklist.id] ?? .play) }) self.hasChecklists = !checklists.isEmpty } diff --git a/CheckStitchCore/Sources/CheckStitchCore/ReminderDestinationTargeting.swift b/CheckStitchCore/Sources/CheckStitchCore/ReminderDestinationTargeting.swift index 11950af..870b5ab 100644 --- a/CheckStitchCore/Sources/CheckStitchCore/ReminderDestinationTargeting.swift +++ b/CheckStitchCore/Sources/CheckStitchCore/ReminderDestinationTargeting.swift @@ -80,6 +80,14 @@ public enum ReminderRunOutcome: Equatable, Sendable { case .failed(let message): return message } } + + /// True only when the run created every item. The app's transient success + /// check (`ChecklistRunViewModel.created`) and the widget's checkmark are + /// both gated on this, so a partial or failed run never flashes success. + public var didCreateAllItems: Bool { + if case .created = self { return true } + return false + } } /// Seam over the EventKit surface the run path needs: permission, list diff --git a/CheckStitchCore/Sources/CheckStitchCore/RunChecklistIntent.swift b/CheckStitchCore/Sources/CheckStitchCore/RunChecklistIntent.swift index 0ca80bd..38fcd11 100644 --- a/CheckStitchCore/Sources/CheckStitchCore/RunChecklistIntent.swift +++ b/CheckStitchCore/Sources/CheckStitchCore/RunChecklistIntent.swift @@ -22,14 +22,22 @@ public struct RunChecklistIntent: AppIntent { private let injectedStore: ChecklistStore? private let injectedTargeting: (any ReminderDestinationTargeting)? private let injectedGate: RunGate? + private let injectedRunState: WidgetRunStateStore? - public init() { self.injectedStore = nil; self.injectedTargeting = nil; self.injectedGate = nil } + public init() { + self.injectedStore = nil + self.injectedTargeting = nil + self.injectedGate = nil + self.injectedRunState = nil + } @MainActor - public init(store: ChecklistStore, targeting: ReminderDestinationTargeting, gate: RunGate) { + public init(store: ChecklistStore, targeting: ReminderDestinationTargeting, gate: RunGate, + runState: WidgetRunStateStore? = nil) { self.injectedStore = store self.injectedTargeting = targeting self.injectedGate = gate + self.injectedRunState = runState } public static var parameterSummary: some ParameterSummary { @@ -59,10 +67,21 @@ public struct RunChecklistIntent: AppIntent { } let gate = await resolveGate() + // The widget button's feedback lives here: a run recorded as `.running` + // (plus the reload the store fires) lets a timeline reload render the + // spinner, and the finished record drives the checkmark. The app path + // holds the same state in `ChecklistRunViewModel` instead. + let runState = injectedRunState ?? WidgetRunStateStore() + runState.beginRun(id: uuid, at: Date()) + // Hold the spinner for at least `minimumSpinner` so a fast EventKit save + // cannot flash past the user; tests inject zero. + async let minimumSpinner: Void = Task.sleep(for: .seconds(runState.minimumSpinner)) let outcome = await ChecklistReminders.create( from: stored, targeting: targeting, gate: gate) + try? await minimumSpinner + runState.finishRun(id: uuid, didCreate: outcome.didCreateAllItems, at: Date()) return .result(dialog: IntentDialog( RunChecklistDialogue.message(for: outcome, checklistName: stored.name))) } diff --git a/CheckStitchCore/Sources/CheckStitchCore/WidgetRunState.swift b/CheckStitchCore/Sources/CheckStitchCore/WidgetRunState.swift new file mode 100644 index 0000000..01830f4 --- /dev/null +++ b/CheckStitchCore/Sources/CheckStitchCore/WidgetRunState.swift @@ -0,0 +1,183 @@ +import Foundation +#if canImport(WidgetKit) +import WidgetKit +#endif + +/// The icon a widget's run button shows for one checklist. The app keeps this +/// in live view-model state (`ChecklistRunViewModel.creating`/`created`); a +/// widget renders from snapshots, so the phase is persisted here and read back +/// on every timeline reload. +public enum WidgetRunIndicator: Equatable, Sendable { + case play + case spinner + case checkmark +} + +/// One persisted widget-initiated run. `phase == .running` is written before the +/// EventKit work starts and `.finished` after it returns, so a timeline reload +/// that lands mid-run renders the spinner instead of the play icon. +public struct WidgetRunRecord: Codable, Equatable, Sendable { + public enum Phase: String, Codable, Sendable { + case running + case finished + } + + public let checklistID: UUID + public var phase: Phase + public var startedAt: Date + public var finishedAt: Date? + /// True only for a full success (`.created`). A partial or failed run shows + /// the play icon again — the app never flashes success for those either. + public var didCreate: Bool + + public init(checklistID: UUID, + phase: Phase, + startedAt: Date, + finishedAt: Date? = nil, + didCreate: Bool = false) { + self.checklistID = checklistID + self.phase = phase + self.startedAt = startedAt + self.finishedAt = finishedAt + self.didCreate = didCreate + } +} + +/// Durable, per-checklist widget run phases in an injected `UserDefaults` suite +/// (the App Group in production). Validated-read convention like `RunCounter`: a +/// missing or corrupt payload reads as "no run", never throws. +/// +/// `onChange` is the reload hook: every phase change asks WidgetKit for a fresh +/// timeline so the new icon actually renders. It is injectable so tests observe +/// the writes without touching WidgetKit. +@MainActor +public final class WidgetRunStateStore { + public static let defaultsKey = "widgetRunState.v1" + /// Minimum time a run stays `.running`, so a fast EventKit save cannot flash + /// the spinner past the user. Matches the app's 1s `spinnerDuration` floor. + public static let minimumSpinner: TimeInterval = 1 + /// How long the success check stays up before the play icon returns. + public static let checkmarkDuration: TimeInterval = 1.5 + /// A `.running` record older than this is treated as abandoned (the intent + /// was killed before it could finish), so the button can never stick on a + /// spinner. Also the reload policy while a run is in flight. + public static let abandonedRunTimeout: TimeInterval = 30 + + /// Finished records are only kept to render the transient check; the array + /// is capped so a long-lived install cannot grow it without bound. + private static let recordLimit = 32 + + public init(defaults: UserDefaults = AppGroup.defaults, + key: String = WidgetRunStateStore.defaultsKey, + minimumSpinner: TimeInterval = WidgetRunStateStore.minimumSpinner, + onChange: @escaping @MainActor () -> Void = WidgetRunStateStore.reloadWidgets) { + self.defaults = defaults + self.key = key + self.minimumSpinner = minimumSpinner + self.onChange = onChange + self.records = Self.decode(defaults.data(forKey: key)) + } + + /// Insertion-bounded, newest-last. Decoded once at init and written back in + /// full on every mutation (the payload is tiny). + public private(set) var records: [WidgetRunRecord] + + /// The floor the intent holds a run in `.running`; see `minimumSpinner`. + public let minimumSpinner: TimeInterval + + /// Records the start of a run and asks for a reload, so the spinner can + /// render while the EventKit work is still in flight. + public func beginRun(id: UUID, at date: Date) { + upsert(WidgetRunRecord(checklistID: id, phase: .running, startedAt: date)) + persist() + onChange() + } + + /// Records the outcome. Only `didCreate` earns the checkmark; everything + /// else returns the button to the play icon. + public func finishRun(id: UUID, didCreate: Bool, at date: Date) { + let existing = records.first { $0.checklistID == id } + upsert(WidgetRunRecord(checklistID: id, + phase: .finished, + startedAt: existing?.startedAt ?? date, + finishedAt: date, + didCreate: didCreate)) + persist() + onChange() + } + + /// The icon for one checklist at `now`. Pure given the stored records. + public func indicator(for id: UUID, at now: Date) -> WidgetRunIndicator { + guard let record = records.first(where: { $0.checklistID == id }) else { return .play } + switch record.phase { + case .running: + let elapsed = now.timeIntervalSince(record.startedAt) + return elapsed < Self.abandonedRunTimeout ? .spinner : .play + case .finished: + guard record.didCreate, + let finishedAt = record.finishedAt, + now.timeIntervalSince(finishedAt) < Self.checkmarkDuration + else { return .play } + return .checkmark + } + } + + /// Every known checklist's icon, for the display model. + public func indicators(at now: Date) -> [UUID: WidgetRunIndicator] { + Dictionary(uniqueKeysWithValues: records.map { + ($0.checklistID, indicator(for: $0.checklistID, at: now)) + }) + } + + /// When the visible check (if any) must fall back to the play icon, so the + /// timeline can schedule that transition instead of polling for it. + public func checkmarkEndsAt(at now: Date) -> Date? { + records.compactMap { record in + guard record.didCreate, + let finishedAt = record.finishedAt, + now.timeIntervalSince(finishedAt) < Self.checkmarkDuration + else { return nil } + return finishedAt.addingTimeInterval(Self.checkmarkDuration) + }.min() + } + + /// Production `onChange`: ask WidgetKit for a fresh timeline so the next + /// phase renders. A no-op where WidgetKit is unavailable. + @MainActor + public static func reloadWidgets() { + #if canImport(WidgetKit) + WidgetCenter.shared.reloadAllTimelines() + #endif + } + private func upsert(_ record: WidgetRunRecord) { + if let index = records.firstIndex(where: { $0.checklistID == record.checklistID }) { + records[index] = record + } else { + records.append(record) + } + // Drop the oldest finished records first; a running record is never + // evicted, since it is what the spinner is rendered from. + while records.count > Self.recordLimit, + let oldest = records.indices + .filter({ records[$0].phase == .finished }) + .min(by: { records[$0].startedAt < records[$1].startedAt }) { + records.remove(at: oldest) + } + } + + private func persist() { + guard let data = try? JSONEncoder().encode(records) else { return } + defaults.set(data, forKey: key) + } + + private static func decode(_ data: Data?) -> [WidgetRunRecord] { + guard let data, + let records = try? JSONDecoder().decode([WidgetRunRecord].self, from: data) + else { return [] } + return records + } + + private let defaults: UserDefaults + private let key: String + private let onChange: @MainActor () -> Void +} \ No newline at end of file diff --git a/CheckStitchTests/ChecklistWidgetDisplayModelTests.swift b/CheckStitchTests/ChecklistWidgetDisplayModelTests.swift index 5c838eb..ecb6384 100644 --- a/CheckStitchTests/ChecklistWidgetDisplayModelTests.swift +++ b/CheckStitchTests/ChecklistWidgetDisplayModelTests.swift @@ -100,4 +100,30 @@ struct ChecklistWidgetDisplayModelTests { #expect(emptyStore.rows.isEmpty) #expect(!emptyStore.hasChecklists) } + + @Test + func runIndicatorIsCarriedOntoItsRow() { + let groceries = Checklist(name: "Groceries") + let packing = Checklist(name: "Packing") + let model = ChecklistWidgetDisplayModel( + checklists: [groceries, packing], + configuration: [ChecklistEntity(groceries), ChecklistEntity(packing)], + access: .ready, + runIndicators: [groceries.id: .spinner, packing.id: .checkmark]) + + #expect(model.rows.first { $0.id == groceries.id }?.indicator == .spinner) + #expect(model.rows.first { $0.id == packing.id }?.indicator == .checkmark) + } + + /// No persisted run → the button is a play icon, not a spinner. + @Test + func rowsWithoutARunShowThePlayIcon() { + let checklist = Checklist(name: "Groceries") + let model = ChecklistWidgetDisplayModel( + checklists: [checklist], + configuration: [ChecklistEntity(checklist)], + access: .ready) + + #expect(model.rows.allSatisfy { $0.indicator == .play }) + } } \ No newline at end of file diff --git a/CheckStitchTests/RunChecklistIntentTests.swift b/CheckStitchTests/RunChecklistIntentTests.swift index 2c057b9..307df74 100644 --- a/CheckStitchTests/RunChecklistIntentTests.swift +++ b/CheckStitchTests/RunChecklistIntentTests.swift @@ -17,7 +17,10 @@ struct RunChecklistIntentTests { let intent = RunChecklistIntent( store: store, targeting: spy, - gate: RunGate(counter: RunCounter(defaults: makeIsolatedDefaults()), isUnlocked: true)) + gate: RunGate(counter: RunCounter(defaults: makeIsolatedDefaults()), isUnlocked: true), + runState: WidgetRunStateStore(defaults: makeIsolatedDefaults(), + minimumSpinner: 0, + onChange: {})) intent.checklist = ChecklistEntity(id: store.checklists[0].id.uuidString, name: "Groceries") return (intent: intent, spy: spy, store: store) } @@ -216,7 +219,10 @@ struct RunChecklistIntentTests { let intent = RunChecklistIntent( store: store, targeting: spy, - gate: RunGate(counter: counter, isUnlocked: false)) + gate: RunGate(counter: counter, isUnlocked: false), + runState: WidgetRunStateStore(defaults: makeIsolatedDefaults(), + minimumSpinner: 0, + onChange: {})) intent.checklist = ChecklistEntity(id: store.checklists[0].id.uuidString, name: "Groceries") _ = try await intent.perform() @@ -224,4 +230,34 @@ struct RunChecklistIntentTests { #expect(spy.createdTitles.isEmpty) #expect(spy.requestAccessCount == 0, "a refused run performs no EventKit work") } + + /// The widget button's feedback is driven by this record: a successful run + /// must leave a finished, checkmark-worthy phase behind. + @Test + func performRecordsTheWidgetRunPhase() async throws { + let store = ChecklistStore(defaults: makeIsolatedDefaults()) + store.create(name: "Groceries") + let checklistID = store.checklists[0].id + store.addItem(to: checklistID, title: "Milk") + let spy = SpyReminderDestination() + spy.lists = ReminderListsSnapshot( + options: [ReminderListOption(id: "list-1", title: "Reminders")], + defaultIdentifier: "list-1") + let runState = WidgetRunStateStore(defaults: makeIsolatedDefaults(), + minimumSpinner: 0, + onChange: {}) + let intent = RunChecklistIntent( + store: store, + targeting: spy, + gate: RunGate(counter: RunCounter(defaults: makeIsolatedDefaults()), isUnlocked: true), + runState: runState) + intent.checklist = ChecklistEntity(id: checklistID.uuidString, name: "Groceries") + + _ = try await intent.perform() + + #expect(runState.records.count == 1) + #expect(runState.records.first?.phase == .finished) + #expect(runState.records.first?.didCreate == true) + #expect(runState.indicator(for: checklistID, at: Date()) == .checkmark) + } } diff --git a/CheckStitchTests/WidgetRunStateStoreTests.swift b/CheckStitchTests/WidgetRunStateStoreTests.swift new file mode 100644 index 0000000..4c2e384 --- /dev/null +++ b/CheckStitchTests/WidgetRunStateStoreTests.swift @@ -0,0 +1,176 @@ +@testable import CheckStitchCore +import Foundation +import Testing + +/// Counts reload requests without touching WidgetKit. +@MainActor +private final class ReloadSpy { + private(set) var count = 0 + func record() { count += 1 } +} + +@MainActor +struct WidgetRunStateStoreTests { + private func makeStore(spy: ReloadSpy? = nil) -> WidgetRunStateStore { + WidgetRunStateStore( + defaults: makeIsolatedDefaults(), + minimumSpinner: 0, + onChange: { spy?.record() }) + } + + private let now = Date() + + @Test + func unknownChecklistShowsPlay() { + let store = makeStore() + #expect(store.indicator(for: UUID(), at: now) == .play) + } + + @Test + func runInFlightShowsSpinner() { + let store = makeStore() + let id = UUID() + store.beginRun(id: id, at: now) + #expect(store.indicator(for: id, at: now) == .spinner) + } + + @Test + func successfulRunShowsCheckmarkThenRevertsToPlay() { + let store = makeStore() + let id = UUID() + store.beginRun(id: id, at: now) + store.finishRun(id: id, didCreate: true, at: now) + + #expect(store.indicator(for: id, at: now) == .checkmark) + // Still inside the check window, at its edge. + let justInside = now.addingTimeInterval(WidgetRunStateStore.checkmarkDuration - 0.1) + #expect(store.indicator(for: id, at: justInside) == .checkmark) + // Past the window the button is a play icon again. + let after = now.addingTimeInterval(WidgetRunStateStore.checkmarkDuration) + #expect(store.indicator(for: id, at: after) == .play) + } + + @Test(arguments: [false]) + func unsuccessfulRunNeverShowsCheckmark(_ didCreate: Bool) { + let store = makeStore() + let id = UUID() + store.beginRun(id: id, at: now) + store.finishRun(id: id, didCreate: didCreate, at: now) + + #expect(store.indicator(for: id, at: now) == .play) + } + + /// A killed intent leaves a `.running` record behind; the spinner must not + /// stick forever. + @Test + func abandonedRunRevertsToPlayAfterTheTimeout() { + let store = makeStore() + let id = UUID() + store.beginRun(id: id, at: now) + + let before = now.addingTimeInterval(WidgetRunStateStore.abandonedRunTimeout - 1) + #expect(store.indicator(for: id, at: before) == .spinner) + let after = now.addingTimeInterval(WidgetRunStateStore.abandonedRunTimeout) + #expect(store.indicator(for: id, at: after) == .play) + } + + /// Validated-read convention: a payload from a newer/broken build reads as + /// "no run" rather than throwing or sticking the spinner. + @Test + func corruptPayloadReadsAsNoRun() { + let defaults = makeIsolatedDefaults() + defaults.set(Data("not json".utf8), forKey: WidgetRunStateStore.defaultsKey) + let store = WidgetRunStateStore(defaults: defaults, minimumSpinner: 0, onChange: {}) + + #expect(store.records.isEmpty) + #expect(store.indicator(for: UUID(), at: now) == .play) + } + + @Test + func phaseChangesAskForAReload() { + let spy = ReloadSpy() + let store = makeStore(spy: spy) + let id = UUID() + + store.beginRun(id: id, at: now) + #expect(spy.count == 1) + store.finishRun(id: id, didCreate: true, at: now) + #expect(spy.count == 2) + } + + @Test + func recordsSurviveANewStoreInstance() { + let defaults = makeIsolatedDefaults() + let id = UUID() + let writer = WidgetRunStateStore(defaults: defaults, minimumSpinner: 0, onChange: {}) + writer.beginRun(id: id, at: now) + writer.finishRun(id: id, didCreate: true, at: now) + + let reader = WidgetRunStateStore(defaults: defaults, minimumSpinner: 0, onChange: {}) + #expect(reader.indicator(for: id, at: now) == .checkmark) + } + + @Test + func aSecondRunReplacesTheFirstForTheSameChecklist() { + let store = makeStore() + let id = UUID() + store.beginRun(id: id, at: now) + store.finishRun(id: id, didCreate: true, at: now) + store.beginRun(id: id, at: now.addingTimeInterval(10)) + + #expect(store.records.count == 1) + #expect(store.indicator(for: id, at: now.addingTimeInterval(10)) == .spinner) + } + + /// A finish without a matching begin (the process died between them) still + /// records the outcome rather than silently doing nothing. + @Test + func finishWithoutBeginStillRecords() { + let store = makeStore() + let id = UUID() + store.finishRun(id: id, didCreate: true, at: now) + + #expect(store.indicator(for: id, at: now) == .checkmark) + } + + @Test + func indicatorsMapEveryRecordedChecklist() { + let store = makeStore() + let spinning = UUID() + let done = UUID() + store.beginRun(id: spinning, at: now) + store.beginRun(id: done, at: now) + store.finishRun(id: done, didCreate: true, at: now) + + let indicators = store.indicators(at: now) + #expect(indicators[spinning] == .spinner) + #expect(indicators[done] == .checkmark) + #expect(store.indicators(at: now.addingTimeInterval(600))[spinning] == .play) + } + + @Test + func checkmarkEndsAtReportsTheRevertMoment() { + let store = makeStore() + let id = UUID() + store.beginRun(id: id, at: now) + store.finishRun(id: id, didCreate: true, at: now) + + #expect(store.checkmarkEndsAt(at: now) + == now.addingTimeInterval(WidgetRunStateStore.checkmarkDuration)) + // No check on screen → no transition to schedule. + #expect(store.checkmarkEndsAt(at: now.addingTimeInterval(60)) == nil) + } + + /// The array is capped so a long-lived install cannot grow it without + /// bound; the newest records survive. + @Test + func recordsAreBounded() { + let store = makeStore() + for _ in 0..<40 { + let id = UUID() + store.beginRun(id: id, at: now) + store.finishRun(id: id, didCreate: true, at: now) + } + #expect(store.records.count == 32) + } +} \ No newline at end of file diff --git a/CheckStitchWidget/MultiChecklistWidget.swift b/CheckStitchWidget/MultiChecklistWidget.swift index 0dbfd09..ccc19c3 100644 --- a/CheckStitchWidget/MultiChecklistWidget.swift +++ b/CheckStitchWidget/MultiChecklistWidget.swift @@ -22,11 +22,10 @@ struct MultiChecklistProvider: AppIntentTimelineProvider { } func timeline(for configuration: MultiChecklistConfigurationIntent, in context: Context) async -> Timeline { - let model = await MainActor.run { - ChecklistWidgetLoader.load(configuration: configuration.checklists ?? []) + let plan = await MainActor.run { + ChecklistWidgetTimeline.make(configuration: configuration.checklists ?? []) } - return Timeline(entries: [ChecklistEntry(date: .now, model: model)], - policy: .after(.now.addingTimeInterval(15 * 60))) + return plan.timeline } } @@ -64,9 +63,10 @@ struct MultiChecklistWidgetView: View { Spacer() if row.isRunnable { Button(intent: runIntent(for: row)) { - Image(systemName: "play.circle.fill") + runIcon(for: row.indicator) } .buttonStyle(.plain) + .disabled(row.indicator == .spinner) } else if row.needsPurchase { Label("Open CheckStitch to buy a license", systemImage: "exclamationmark.triangle") .font(.caption) @@ -82,6 +82,22 @@ struct MultiChecklistWidgetView: View { } } + /// Per-row run icon, mirroring the app: spinner while in flight, green check + /// on success, play otherwise. + @ViewBuilder + private func runIcon(for indicator: WidgetRunIndicator) -> some View { + switch indicator { + case .play: + Image(systemName: "play.circle.fill") + case .spinner: + ProgressView() + .controlSize(.small) + case .checkmark: + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + } + } + @MainActor private func runIntent(for row: ChecklistWidgetRow) -> RunChecklistIntent { let intent = RunChecklistIntent() diff --git a/CheckStitchWidget/SingleChecklistWidget.swift b/CheckStitchWidget/SingleChecklistWidget.swift index 374954e..6d41f89 100644 --- a/CheckStitchWidget/SingleChecklistWidget.swift +++ b/CheckStitchWidget/SingleChecklistWidget.swift @@ -3,7 +3,7 @@ import CheckStitchCore import SwiftUI import WidgetKit -struct ChecklistEntry: TimelineEntry { +struct ChecklistEntry: TimelineEntry, Sendable { let date: Date let model: ChecklistWidgetDisplayModel } @@ -13,10 +13,12 @@ enum ChecklistWidgetLoader { /// Reads the App Group store and resolves the intent's configured /// checklists. An unconfigured widget renders the empty-state hint rather /// than silently running an arbitrary first checklist. - static func load(configuration: [ChecklistEntity]) -> ChecklistWidgetDisplayModel { + static func load(configuration: [ChecklistEntity], at now: Date = Date()) -> ChecklistWidgetDisplayModel { let checklists = ChecklistStore(defaults: AppGroup.defaults).checklists + let runState = WidgetRunStateStore(defaults: AppGroup.defaults) return ChecklistWidgetDisplayModel( - checklists: checklists, configuration: configuration, access: accessState()) + checklists: checklists, configuration: configuration, access: accessState(), + runIndicators: runState.indicators(at: now)) } static func accessState() -> ChecklistWidgetAccessState { @@ -49,11 +51,50 @@ struct SingleChecklistProvider: AppIntentTimelineProvider { } func timeline(for configuration: ChecklistConfigurationIntent, in context: Context) async -> Timeline { - let model = await MainActor.run { - ChecklistWidgetLoader.load(configuration: configuration.checklist.map { [$0] } ?? []) + let plan = await MainActor.run { + ChecklistWidgetTimeline.make(configuration: configuration.checklist.map { [$0] } ?? []) + } + return plan.timeline + } +} + +/// What the timeline should render, read on the main actor. Kept `Sendable` +/// because `WidgetKit.Timeline` itself is not, so the provider assembles it +/// outside `MainActor.run`. +struct ChecklistWidgetPlan: Sendable { + let entries: [ChecklistEntry] + let reloadAfter: Date + + var timeline: Timeline { + Timeline(entries: entries, policy: .after(reloadAfter)) + } +} + +/// Shared timeline plan for both widgets. A run is written to +/// `WidgetRunStateStore` by `RunChecklistIntent`, and the store's reload hook +/// asks for a new timeline — this is where the spinner and the checkmark come +/// from. The second entry is what drops a visible check back to the play icon +/// without needing another reload. +@MainActor +enum ChecklistWidgetTimeline { + static func make(configuration: [ChecklistEntity], now: Date = Date()) -> ChecklistWidgetPlan { + let runState = WidgetRunStateStore(defaults: AppGroup.defaults) + let entry = ChecklistEntry( + date: now, + model: ChecklistWidgetLoader.load(configuration: configuration, at: now)) + var entries = [entry] + if let checkmarkEnds = runState.checkmarkEndsAt(at: now) { + entries.append(ChecklistEntry( + date: checkmarkEnds, + model: ChecklistWidgetLoader.load(configuration: configuration, at: checkmarkEnds))) } - return Timeline(entries: [ChecklistEntry(date: .now, model: model)], - policy: .after(.now.addingTimeInterval(15 * 60))) + // A run in flight finishes with its own reload, so the only reason to + // re-ask is the abandoned-run timeout that unsticks a killed intent. + let isRunning = runState.records.contains { $0.phase == .running } + return ChecklistWidgetPlan( + entries: entries, + reloadAfter: now.addingTimeInterval( + isRunning ? WidgetRunStateStore.abandonedRunTimeout : 15 * 60)) } } @@ -80,12 +121,13 @@ struct SingleChecklistWidgetView: View { Text(row.name).font(.headline).lineLimit(2) if row.isRunnable { Button(intent: runIntent(for: row)) { - Image(systemName: "play.circle.fill") - .font(.system(size: 56)) - .accessibilityLabel("Create reminders") + runIcon(for: row.indicator) } .buttonStyle(.borderedProminent) .buttonBorderShape(.circle) + .tint(row.indicator == .checkmark ? Color.green : Color.accentColor) + .disabled(row.indicator == .spinner) + .accessibilityLabel("Create reminders") .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) } else if row.needsPurchase { Label("Open CheckStitch to buy a license", systemImage: "exclamationmark.triangle") @@ -106,6 +148,26 @@ struct SingleChecklistWidgetView: View { } } + /// The button's icon, mirroring the app's run button: spinner while the run + /// is in flight, green check on success, play otherwise. Widgets render + /// snapshots, so the `ProgressView` is a still spinner glyph, not a turning + /// one. + @ViewBuilder + private func runIcon(for indicator: WidgetRunIndicator) -> some View { + switch indicator { + case .play: + Image(systemName: "play.circle.fill") + .font(.system(size: 56)) + case .spinner: + ProgressView() + .controlSize(.large) + .tint(Color.white) + case .checkmark: + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 56)) + } + } + /// `RunChecklistIntent` has no memberwise init (its `@Parameter` is set by the /// system / here). Build it and assign the parameter. @MainActor From 89372a3186c6c3757d181511a5593e67e440f5f7 Mon Sep 17 00:00:00 2001 From: Alan Vardy Date: Sun, 27 Sep 2026 05:29:16 -0700 Subject: [PATCH 15/15] style: drop the button chrome behind the small widget's play icon The bordered-prominent fill drew a blue ring around the white play.circle.fill glyph. A plain style leaves the icon alone as the whole button, and the success check now colours the glyph itself, matching the list widget. --- CheckStitchWidget/SingleChecklistWidget.swift | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/CheckStitchWidget/SingleChecklistWidget.swift b/CheckStitchWidget/SingleChecklistWidget.swift index 6d41f89..4b2eb28 100644 --- a/CheckStitchWidget/SingleChecklistWidget.swift +++ b/CheckStitchWidget/SingleChecklistWidget.swift @@ -123,9 +123,7 @@ struct SingleChecklistWidgetView: View { Button(intent: runIntent(for: row)) { runIcon(for: row.indicator) } - .buttonStyle(.borderedProminent) - .buttonBorderShape(.circle) - .tint(row.indicator == .checkmark ? Color.green : Color.accentColor) + .buttonStyle(.plain) .disabled(row.indicator == .spinner) .accessibilityLabel("Create reminders") .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) @@ -151,7 +149,7 @@ struct SingleChecklistWidgetView: View { /// The button's icon, mirroring the app's run button: spinner while the run /// is in flight, green check on success, play otherwise. Widgets render /// snapshots, so the `ProgressView` is a still spinner glyph, not a turning - /// one. + /// one. Drawn without button chrome, so the icon *is* the button. @ViewBuilder private func runIcon(for indicator: WidgetRunIndicator) -> some View { switch indicator { @@ -161,10 +159,10 @@ struct SingleChecklistWidgetView: View { case .spinner: ProgressView() .controlSize(.large) - .tint(Color.white) case .checkmark: Image(systemName: "checkmark.circle.fill") .font(.system(size: 56)) + .foregroundStyle(.green) } }