From 26cdccca1a4e018df6ef6d17b0ead28ff429f63e Mon Sep 17 00:00:00 2001 From: Alan Vardy Date: Sun, 27 Sep 2026 05:01:07 -0700 Subject: [PATCH 1/8] chore: start alanvardy-var-1101-add-toggle-for-watch --- DELETEME | 1 + 1 file changed, 1 insertion(+) create mode 100644 DELETEME diff --git a/DELETEME b/DELETEME new file mode 100644 index 0000000..ca136e9 --- /dev/null +++ b/DELETEME @@ -0,0 +1 @@ +DELETEME (alanvardy-var-1101-add-toggle-for-watch) — placeholder; git rm before merging From 8209c975411334dcb3a169201b63586ce3d32402 Mon Sep 17 00:00:00 2001 From: Alan Vardy Date: Sun, 27 Sep 2026 05:37:38 -0700 Subject: [PATCH 2/8] 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 ca136e9..0000000 --- a/DELETEME +++ /dev/null @@ -1 +0,0 @@ -DELETEME (alanvardy-var-1101-add-toggle-for-watch) — placeholder; git rm before merging From 28fbc3120e2b104966fd04ae1d4d34d3fb092ef6 Mon Sep 17 00:00:00 2001 From: Alan Vardy Date: Sun, 27 Sep 2026 05:50:50 -0700 Subject: [PATCH 3/8] =?UTF-8?q?Phase=201:=20Walking=20skeleton=20=E2=80=94?= =?UTF-8?q?=20toggle=20on=20iOS=20hides=20a=20loose=20checklist=20on=20the?= =?UTF-8?q?=20watch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CheckStitch/ChecklistDetailView.swift | 19 +++++ CheckStitch/Localizable.xcstrings | 82 +++++++++++++++++++ .../Sources/CheckStitchCore/Checklist.swift | 27 +++++- .../CheckStitchCore/ChecklistMerge.swift | 1 + .../CheckStitchCore/ChecklistStore.swift | 18 ++++ CheckStitchTests/ChecklistCodecTests.swift | 25 ++++++ .../ChecklistDetailViewTests.swift | 35 ++++++++ CheckStitchTests/ChecklistGroupingTests.swift | 10 +++ CheckStitchTests/ChecklistStoreTests.swift | 42 ++++++++++ CheckStitchTests/LocalizationFixtures.swift | 2 + .../WatchChecklistViewModel.swift | 5 +- 11 files changed, 263 insertions(+), 3 deletions(-) diff --git a/CheckStitch/ChecklistDetailView.swift b/CheckStitch/ChecklistDetailView.swift index d7723fe..8c14a7d 100644 --- a/CheckStitch/ChecklistDetailView.swift +++ b/CheckStitch/ChecklistDetailView.swift @@ -74,6 +74,14 @@ struct ChecklistDetailView: View { } footer: { Text("Prefix each reminder title with its position, like \"1: Buy milk\".") } + Section { + Toggle(isOn: showOnWatchBinding(checklistID: checklistID)) { + Label("Show on watch", systemImage: "applewatch") + } + .accessibilityIdentifier("checklistShowsOnWatchToggle") + } footer: { + Text("Hidden checklists stay on your iPhone but are not shown on the Apple Watch.") + } Section("Items") { ForEach(checklist.items) { item in ItemRow( @@ -260,6 +268,17 @@ struct ChecklistDetailView: View { ) } + /// Per-selection write through the store for the "Show on watch" toggle. The + /// getter reads the store so a value that arrives over sync updates the + /// toggle; `.notFound` (deleted while this screen was open) is ignored, + /// matching `numberingBinding`. + private func showOnWatchBinding(checklistID: UUID) -> Binding { + Binding( + get: { store.checklist(id: checklistID)?.showsOnWatch ?? true }, + set: { store.setShowsOnWatch($0, for: checklistID) } + ) + } + /// Per-selection write through the store (Phase 2). `nil` is the "Default /// (Inbox)" row. `.notFound` (deleted while this screen was open) is ignored, /// matching `commitDraftIfChanged`. diff --git a/CheckStitch/Localizable.xcstrings b/CheckStitch/Localizable.xcstrings index d2c0834..5abe7f5 100644 --- a/CheckStitch/Localizable.xcstrings +++ b/CheckStitch/Localizable.xcstrings @@ -2051,6 +2051,47 @@ } } }, + "Hidden checklists stay on your iPhone but are not shown on the Apple Watch." : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ausgeblendete Checklisten bleiben auf deinem iPhone, werden aber nicht auf der Apple Watch angezeigt." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hidden checklists stay on your iPhone but are not shown on the Apple Watch." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Las listas ocultas permanecen en tu iPhone, pero no se muestran en el Apple Watch." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les listes masquées restent sur votre iPhone mais ne s'affichent pas sur l'Apple Watch." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "非表示のチェックリストはiPhoneに残りますが、Apple Watchには表示されません。" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "隐藏的清单会保留在 iPhone 上,但不会显示在 Apple Watch 上。" + } + } + } + }, "How much the wallpaper fades for readability." : { "extractionState" : "manual", "localizations" : { @@ -3486,6 +3527,47 @@ } } }, + "Show on watch" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Auf der Uhr anzeigen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show on watch" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar en el reloj" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher sur la montre" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "時計に表示" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在手表上显示" + } + } + } + }, "Small" : { "extractionState" : "manual", "localizations" : { diff --git a/CheckStitchCore/Sources/CheckStitchCore/Checklist.swift b/CheckStitchCore/Sources/CheckStitchCore/Checklist.swift index 8a41350..2539037 100644 --- a/CheckStitchCore/Sources/CheckStitchCore/Checklist.swift +++ b/CheckStitchCore/Sources/CheckStitchCore/Checklist.swift @@ -147,6 +147,7 @@ public struct Checklist: Identifiable, Codable, Hashable, Sendable { id: UUID = UUID(), name: String = "New checklist", items: [ChecklistItem] = [], destinationListIdentifier: String? = nil, prefixesReminderNumbers: Bool = false, + showsOnWatch: Bool = true, folderID: UUID? = nil, modifiedAt: Date = .distantPast, revision: Int = 0, itemOrder: [UUID]? = nil, orderRevision: Int = 0, orderModifiedAt: Date = .distantPast @@ -156,6 +157,7 @@ public struct Checklist: Identifiable, Codable, Hashable, Sendable { self.items = items self.destinationListIdentifier = destinationListIdentifier self.prefixesReminderNumbers = prefixesReminderNumbers + self.showsOnWatch = showsOnWatch self.folderID = folderID self.modifiedAt = modifiedAt self.revision = revision @@ -179,6 +181,13 @@ public struct Checklist: Identifiable, Codable, Hashable, Sendable { /// key: absent in v4-and-earlier payloads decodes to `false` with no version /// bump (the `relativeDate` precedent). public var prefixesReminderNumbers: Bool + /// Whether this checklist is offered on the Apple Watch. Default-on: it is + /// the first stored boolean that defaults to `true`, so an absent key reads + /// as "shown". Additive optional key: absent in v5-and-earlier payloads + /// decodes to `true` with no version bump (the `prefixesReminderNumbers` + /// precedent). Shares the checklist's coarse `revision`/`modifiedAt` clock, + /// so a toggle is decided by the same last-write-wins rule. + public var showsOnWatch: Bool /// The id of the folder this checklist is filed under, or `nil` for loose. /// A one-field relationship sharing the checklist's coarse clock with the /// name/destination, so a membership move is decided by the same @@ -194,7 +203,7 @@ public struct Checklist: Identifiable, Codable, Hashable, Sendable { public var orderModifiedAt: Date private enum CodingKeys: String, CodingKey { - case id, name, items, destinationListIdentifier, prefixesReminderNumbers, folderID + case id, name, items, destinationListIdentifier, prefixesReminderNumbers, showsOnWatch, folderID case modifiedAt, revision, itemOrder, orderRevision, orderModifiedAt } @@ -207,6 +216,11 @@ public struct Checklist: Identifiable, Codable, Hashable, Sendable { // Additive optional field: absent key decodes to false, matching the // `description`/`relativeDate` precedent — no version bump. let prefixesReminderNumbers = try container.decodeIfPresent(Bool.self, forKey: .prefixesReminderNumbers) ?? false + // Additive optional field: absent in v5-and-earlier payloads decodes to + // `true` (shown on the watch) with no version bump, matching the + // `prefixesReminderNumbers` precedent. This is the only default-`true` + // stored field, so the `?? true` is deliberate, not an oversight. + let showsOnWatch = try container.decodeIfPresent(Bool.self, forKey: .showsOnWatch) ?? true // Additive optional field: absent in v4-and-earlier payloads decodes to // nil, matching the `destinationListIdentifier` precedent — no restamp. let folderID = try container.decodeIfPresent(UUID.self, forKey: .folderID) @@ -221,6 +235,7 @@ public struct Checklist: Identifiable, Codable, Hashable, Sendable { self = Checklist(id: id, name: name, items: items, destinationListIdentifier: destinationListIdentifier, prefixesReminderNumbers: prefixesReminderNumbers, + showsOnWatch: showsOnWatch, folderID: folderID, modifiedAt: modifiedAt, revision: revision, itemOrder: itemOrder, orderRevision: orderRevision, orderModifiedAt: orderModifiedAt) @@ -234,6 +249,7 @@ public struct Checklist: Identifiable, Codable, Hashable, Sendable { try container.encode(items, forKey: .items) try container.encode(destinationListIdentifier, forKey: .destinationListIdentifier) try container.encode(prefixesReminderNumbers, forKey: .prefixesReminderNumbers) + try container.encode(showsOnWatch, forKey: .showsOnWatch) // Write the key unconditionally, matching the "encoder writes every key" // invariant (the `relativeDate` shape). if let folderID { @@ -573,4 +589,13 @@ public enum ChecklistGrouping { sections.append(ChecklistSection(folder: nil, checklists: loose)) return sections } + + /// The loose checklists the watch renders, in global order: loose (no folder, + /// or a `folderID` no longer known) and not hidden. The phone keeps sending + /// hidden checklists; the watch filters only at render time. + public static func visibleLooseChecklists( + _ checklists: [Checklist], knownFolderIDs: Set + ) -> [Checklist] { + checklists.filter { isLoose($0, knownFolderIDs: knownFolderIDs) && $0.showsOnWatch } + } } diff --git a/CheckStitchCore/Sources/CheckStitchCore/ChecklistMerge.swift b/CheckStitchCore/Sources/CheckStitchCore/ChecklistMerge.swift index b0242cf..7ceb3b9 100644 --- a/CheckStitchCore/Sources/CheckStitchCore/ChecklistMerge.swift +++ b/CheckStitchCore/Sources/CheckStitchCore/ChecklistMerge.swift @@ -139,6 +139,7 @@ public enum ChecklistMerge { merged.name = remoteChecklist.name merged.destinationListIdentifier = remoteChecklist.destinationListIdentifier merged.prefixesReminderNumbers = remoteChecklist.prefixesReminderNumbers + merged.showsOnWatch = remoteChecklist.showsOnWatch merged.folderID = remoteChecklist.folderID merged.revision = remoteChecklist.revision merged.modifiedAt = remoteChecklist.modifiedAt diff --git a/CheckStitchCore/Sources/CheckStitchCore/ChecklistStore.swift b/CheckStitchCore/Sources/CheckStitchCore/ChecklistStore.swift index 27bdf4a..46cc31a 100644 --- a/CheckStitchCore/Sources/CheckStitchCore/ChecklistStore.swift +++ b/CheckStitchCore/Sources/CheckStitchCore/ChecklistStore.swift @@ -178,6 +178,7 @@ public final class ChecklistStore { items: source.items.map { ChecklistItem(title: $0.title, description: $0.description, modifiedAt: now(), revision: 1, relativeDate: $0.relativeDate, priority: $0.priority) }, destinationListIdentifier: source.destinationListIdentifier, prefixesReminderNumbers: source.prefixesReminderNumbers, + showsOnWatch: source.showsOnWatch, modifiedAt: now(), revision: 1 ) @@ -201,6 +202,7 @@ public final class ChecklistStore { }, destinationListIdentifier: checklist.destinationListIdentifier, prefixesReminderNumbers: checklist.prefixesReminderNumbers, + showsOnWatch: checklist.showsOnWatch, modifiedAt: now(), revision: 1 ) @@ -283,6 +285,22 @@ public final class ChecklistStore { return .updated } + /// Sets a checklist's per-checklist "Show on watch" toggle and reports + /// whether it applied. Shares the checklist's coarse `revision`/`modifiedAt` + /// clock with `rename`/`setDestination`/`setPrefixesReminderNumbers`, so the + /// toggle rides the same last-write-wins rule. An unchanged value is a no-op, + /// so re-rendering the toggle never manufactures a spurious LWW win. + @discardableResult + public func setShowsOnWatch(_ enabled: Bool, for id: UUID) -> SetDestinationOutcome { + guard let index = checklists.firstIndex(where: { $0.id == id }) else { return .notFound } + guard checklists[index].showsOnWatch != enabled else { return .updated } + checklists[index].showsOnWatch = enabled + checklists[index].revision += 1 + checklists[index].modifiedAt = now() + scheduleSave() + return .updated + } + /// The first free name in the sequence `base`, `base 2`, `base 3`, …, so a /// create never collides. `base` is trimmed first, so a typed /// `" Groceries "` disambiguates as `"Groceries 2"`, not diff --git a/CheckStitchTests/ChecklistCodecTests.swift b/CheckStitchTests/ChecklistCodecTests.swift index 7b6e5d2..94cc96e 100644 --- a/CheckStitchTests/ChecklistCodecTests.swift +++ b/CheckStitchTests/ChecklistCodecTests.swift @@ -186,6 +186,31 @@ final class ChecklistCodecTests: XCTestCase { XCTAssertEqual(ChecklistCodec.decode(data).first?.prefixesReminderNumbers, true) } + /// A v5 payload written without `showsOnWatch`: the absent key decodes to + /// `true` (the additive-optional guarantee for the first default-on field), + /// so older payloads keep showing every checklist without a version bump. + func testDecodesV5PayloadWithoutShowsOnWatchAsShown() throws { + let id = UUID().uuidString + let data = Data(#"{"version":5,"deviceID":"device-a","tombstones":[],"checklists":[{"id":"\#(id)","name":"Groceries","items":[]}]}"#.utf8) + + guard case .loaded(let envelope) = ChecklistCodec.classify(data) else { + XCTFail("expected loaded, got \(ChecklistCodec.classify(data))") + return + } + XCTAssertTrue(envelope.checklists.first?.showsOnWatch ?? false) + } + + func testShowsOnWatchSurvivesEnvelopeRoundTrip() throws { + let hidden = Checklist(name: "Hidden", items: [ChecklistItem(title: "Milk")], showsOnWatch: false) + let shown = Checklist(name: "Shown") + let envelope = ChecklistEnvelope(deviceID: "device-a", checklists: [hidden, shown]) + + let data = try ChecklistCodec.encode(envelope) + + XCTAssertEqual(ChecklistCodec.classify(data), .loaded(envelope)) + XCTAssertEqual(ChecklistCodec.decode(data).map(\.showsOnWatch), [false, true]) + } + /// A current-version (v5) envelope whose item carries no `description` key: /// must stay `.loaded` with an empty description (the additive-field /// guarantee). v3 payloads predate `relativeDate`, so they classify as diff --git a/CheckStitchTests/ChecklistDetailViewTests.swift b/CheckStitchTests/ChecklistDetailViewTests.swift index 4c03568..c146d13 100644 --- a/CheckStitchTests/ChecklistDetailViewTests.swift +++ b/CheckStitchTests/ChecklistDetailViewTests.swift @@ -279,4 +279,39 @@ struct ChecklistDetailViewTests { #expect(ImageRenderer(content: view).uiImage != nil) #endif } + + /// The flag is default-on: a freshly created checklist is shown on the watch. + @Test + func showOnWatchDefaultsOn() { + #expect(Checklist(name: "Groceries").showsOnWatch) + } + + /// Toggling off persists through the store the toggle binds to. + @Test + func togglingShowOnWatchPersistsThroughTheStore() { + let defaults = makeIsolatedDefaults() + let store = ChecklistStore(defaults: defaults, textEditDelay: nil) + let checklist = store.create(name: "Groceries") + + store.setShowsOnWatch(false, for: checklist.id) + + #expect(store.checklist(id: checklist.id)?.showsOnWatch == false) + } + + /// The new section renders on the detail screen: staging the view against a + /// store whose checklist is hidden must not crash. + @Test + func detailViewRendersWithAHiddenChecklist() { + let defaults = makeIsolatedDefaults() + let store = ChecklistStore(defaults: defaults, textEditDelay: nil) + let checklist = store.create(name: "Groceries") + store.setShowsOnWatch(false, for: checklist.id) + + let view = ChecklistDetailView(checklistID: checklist.id).environment(store) + #if os(macOS) + #expect(ImageRenderer(content: view).nsImage != nil) + #else + #expect(ImageRenderer(content: view).uiImage != nil) + #endif + } } diff --git a/CheckStitchTests/ChecklistGroupingTests.swift b/CheckStitchTests/ChecklistGroupingTests.swift index 93da7e1..b1c9b7e 100644 --- a/CheckStitchTests/ChecklistGroupingTests.swift +++ b/CheckStitchTests/ChecklistGroupingTests.swift @@ -62,4 +62,14 @@ struct ChecklistGroupingTests { #expect(sections[1].folder == nil) #expect(sections.last?.checklists == [loose]) } + + @Test + func visibleLooseChecklistsExcludeHiddenChecklists() { + let shown = Checklist(name: "Shown") + let hidden = Checklist(name: "Hidden", showsOnWatch: false) + + let visible = ChecklistGrouping.visibleLooseChecklists([shown, hidden], knownFolderIDs: []) + + #expect(visible == [shown]) + } } diff --git a/CheckStitchTests/ChecklistStoreTests.swift b/CheckStitchTests/ChecklistStoreTests.swift index bc523f8..abadcb8 100644 --- a/CheckStitchTests/ChecklistStoreTests.swift +++ b/CheckStitchTests/ChecklistStoreTests.swift @@ -1883,6 +1883,48 @@ final class ChecklistStoreTests: XCTestCase { XCTAssertTrue(store.checklists.isEmpty) } + func testSetShowsOnWatchUpdatesRevisionAndPersists() { + let suite = makeDefaults() + defer { suite.defaults.removePersistentDomain(forName: suite.suiteName) } + let clock = Clock() + let store = ChecklistStore(defaults: suite.defaults, key: key, textEditDelay: nil, now: { clock.now }) + let created = store.create() + clock.now = Date(timeIntervalSince1970: 5) + + XCTAssertEqual(store.setShowsOnWatch(false, for: created.id), .updated) + + let reloaded = makeStore(defaults: suite.defaults) + XCTAssertEqual(reloaded.checklist(id: created.id)?.showsOnWatch, false) + XCTAssertEqual(reloaded.checklist(id: created.id)?.revision, 2) + XCTAssertEqual(reloaded.checklist(id: created.id)?.modifiedAt, clock.now) + } + + func testSetShowsOnWatchUnchangedValueIsNoOp() { + let suite = makeDefaults() + defer { suite.defaults.removePersistentDomain(forName: suite.suiteName) } + let clock = Clock() + let store = ChecklistStore(defaults: suite.defaults, key: key, textEditDelay: nil, now: { clock.now }) + let created = store.create() + store.setShowsOnWatch(false, for: created.id) + let revisionAfterToggle = store.checklist(id: created.id)?.revision + let modifiedAfterToggle = store.checklist(id: created.id)?.modifiedAt + clock.now = Date(timeIntervalSince1970: 9) + + XCTAssertEqual(store.setShowsOnWatch(false, for: created.id), .updated) + + XCTAssertEqual(store.checklist(id: created.id)?.revision, revisionAfterToggle) + XCTAssertEqual(store.checklist(id: created.id)?.modifiedAt, modifiedAfterToggle) + } + + func testSetShowsOnWatchForUnknownChecklistReturnsNotFound() { + let suite = makeDefaults() + defer { suite.defaults.removePersistentDomain(forName: suite.suiteName) } + let store = makeStore(defaults: suite.defaults) + + XCTAssertEqual(store.setShowsOnWatch(false, for: UUID()), .notFound) + XCTAssertTrue(store.checklists.isEmpty) + } + // MARK: - Import primitives /// An "imported" checklist with non-default identity, so freshness is provable. diff --git a/CheckStitchTests/LocalizationFixtures.swift b/CheckStitchTests/LocalizationFixtures.swift index 707063e..658d445 100644 --- a/CheckStitchTests/LocalizationFixtures.swift +++ b/CheckStitchTests/LocalizationFixtures.swift @@ -44,6 +44,7 @@ enum LocalizationFixtures { "Edit item", "Extra Large", "Folder Name", + "Hidden checklists stay on your iPhone but are not shown on the Apple Watch.", "How much the wallpaper fades for readability.", "In %lld days", "Interface", @@ -77,6 +78,7 @@ enum LocalizationFixtures { "Rename Folder", "Settings", "Show a wallpaper behind the checklist.", + "Show on watch", "Small", "System", "Text Size", diff --git a/CheckStitchWatch/WatchChecklistViewModel.swift b/CheckStitchWatch/WatchChecklistViewModel.swift index 04d04fa..6d655b2 100644 --- a/CheckStitchWatch/WatchChecklistViewModel.swift +++ b/CheckStitchWatch/WatchChecklistViewModel.swift @@ -18,10 +18,11 @@ final class WatchChecklistViewModel { var folders: [Folder] { store.folders } /// Checklists with no folder (or a folder id the phone no longer knows), in - /// global order. Rendered as top-level rows directly under the folder rows. + /// global order, excluding any the phone has hidden. Rendered as top-level + /// rows directly under the folder rows. var looseChecklists: [Checklist] { let known = Set(store.folders.map(\.id)) - return store.checklists.filter { ChecklistGrouping.isLoose($0, knownFolderIDs: known) } + return ChecklistGrouping.visibleLooseChecklists(store.checklists, knownFolderIDs: known) } /// The checklists inside `folder`, in global order. From db0832bbd086367041a2b4bd69888d034e378808 Mon Sep 17 00:00:00 2001 From: Alan Vardy Date: Sun, 27 Sep 2026 05:55:51 -0700 Subject: [PATCH 4/8] =?UTF-8?q?Phase=202:=20Folder=20semantics=20=E2=80=94?= =?UTF-8?q?=20folder=20detail=20and=20folder=20rows=20honour=20hiding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/CheckStitchCore/Checklist.swift | 21 ++++++++++ CheckStitchTests/ChecklistGroupingTests.swift | 41 +++++++++++++++++++ CheckStitchWatch/WatchChecklistListView.swift | 2 +- .../WatchChecklistViewModel.swift | 10 ++++- 4 files changed, 71 insertions(+), 3 deletions(-) diff --git a/CheckStitchCore/Sources/CheckStitchCore/Checklist.swift b/CheckStitchCore/Sources/CheckStitchCore/Checklist.swift index 2539037..cb72bea 100644 --- a/CheckStitchCore/Sources/CheckStitchCore/Checklist.swift +++ b/CheckStitchCore/Sources/CheckStitchCore/Checklist.swift @@ -598,4 +598,25 @@ public enum ChecklistGrouping { ) -> [Checklist] { checklists.filter { isLoose($0, knownFolderIDs: knownFolderIDs) && $0.showsOnWatch } } + + /// A folder's visible checklists, in global order: filed under `folder` and + /// not hidden. + public static func visibleChecklists( + in folder: Folder, from checklists: [Checklist] + ) -> [Checklist] { + checklists.filter { $0.folderID == folder.id && $0.showsOnWatch } + } + + /// The folders the watch renders: a folder stays when it has no members, or at + /// least one visible member; it drops only when it has members and all of them + /// are hidden. Membership stays derived from `folderID` — no folder-side child + /// list is introduced. + public static func visibleFolders( + _ folders: [Folder], checklists: [Checklist] + ) -> [Folder] { + folders.filter { folder in + let members = checklists.filter { $0.folderID == folder.id } + return members.isEmpty || members.contains { $0.showsOnWatch } + } + } } diff --git a/CheckStitchTests/ChecklistGroupingTests.swift b/CheckStitchTests/ChecklistGroupingTests.swift index b1c9b7e..abda7a2 100644 --- a/CheckStitchTests/ChecklistGroupingTests.swift +++ b/CheckStitchTests/ChecklistGroupingTests.swift @@ -72,4 +72,45 @@ struct ChecklistGroupingTests { #expect(visible == [shown]) } + + @Test + func visibleChecklistsInFolderExcludeHiddenChecklists() { + let folder = Folder(name: "Errands") + let shown = Checklist(name: "Shown", folderID: folder.id) + let hidden = Checklist(name: "Hidden", showsOnWatch: false, folderID: folder.id) + + let visible = ChecklistGrouping.visibleChecklists(in: folder, from: [hidden, shown]) + + #expect(visible == [shown]) + } + + @Test + func folderWithSomeVisibleMembersStaysVisible() { + let folder = Folder(name: "Errands") + let shown = Checklist(name: "Shown", folderID: folder.id) + let hidden = Checklist(name: "Hidden", showsOnWatch: false, folderID: folder.id) + + let visible = ChecklistGrouping.visibleFolders([folder], checklists: [hidden, shown]) + + #expect(visible == [folder]) + } + + @Test + func folderWithEveryMemberHiddenIsHidden() { + let folder = Folder(name: "Errands") + let hidden = Checklist(name: "Hidden", showsOnWatch: false, folderID: folder.id) + + let visible = ChecklistGrouping.visibleFolders([folder], checklists: [hidden]) + + #expect(visible.isEmpty) + } + + @Test + func emptyFolderStaysVisible() { + let folder = Folder(name: "Empty") + + let visible = ChecklistGrouping.visibleFolders([folder], checklists: []) + + #expect(visible == [folder]) + } } diff --git a/CheckStitchWatch/WatchChecklistListView.swift b/CheckStitchWatch/WatchChecklistListView.swift index e055387..ecfc468 100644 --- a/CheckStitchWatch/WatchChecklistListView.swift +++ b/CheckStitchWatch/WatchChecklistListView.swift @@ -16,7 +16,7 @@ struct WatchChecklistListView: View { List { // Folders are rows you tap into; their checklists live // on `WatchFolderDetailView` so the top level stays short. - ForEach(viewModel.folders) { folder in + ForEach(viewModel.visibleFolders) { folder in NavigationLink { WatchFolderDetailView(folder: folder) } label: { diff --git a/CheckStitchWatch/WatchChecklistViewModel.swift b/CheckStitchWatch/WatchChecklistViewModel.swift index 6d655b2..ede02f0 100644 --- a/CheckStitchWatch/WatchChecklistViewModel.swift +++ b/CheckStitchWatch/WatchChecklistViewModel.swift @@ -25,9 +25,15 @@ final class WatchChecklistViewModel { return ChecklistGrouping.visibleLooseChecklists(store.checklists, knownFolderIDs: known) } - /// The checklists inside `folder`, in global order. + /// Folders with at least one visible checklist, plus empty folders. A folder + /// whose members are all hidden drops off the root list. + var visibleFolders: [Folder] { + ChecklistGrouping.visibleFolders(store.folders, checklists: store.checklists) + } + + /// The checklists inside `folder`, in global order, excluding hidden ones. func checklists(in folder: Folder) -> [Checklist] { - store.checklists.filter { $0.folderID == folder.id } + ChecklistGrouping.visibleChecklists(in: folder, from: store.checklists) } /// The live folder once a refresh lands, falling back to the pushed seed. From 1adff48b1e014d152b6f3c7dc11f51b01100d770 Mon Sep 17 00:00:00 2001 From: Alan Vardy Date: Sun, 27 Sep 2026 05:59:12 -0700 Subject: [PATCH 5/8] =?UTF-8?q?Phase=203:=20Hardening=20=E2=80=94=20sync?= =?UTF-8?q?=20conflicts,=20edge=20cases,=20and=20on-watch=20verification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CheckStitchTests/ChecklistCodecTests.swift | 14 +++++++++ CheckStitchTests/ChecklistGroupingTests.swift | 22 ++++++++++++++ CheckStitchTests/ChecklistMergeTests.swift | 30 +++++++++++++++++++ 3 files changed, 66 insertions(+) diff --git a/CheckStitchTests/ChecklistCodecTests.swift b/CheckStitchTests/ChecklistCodecTests.swift index 94cc96e..1a50646 100644 --- a/CheckStitchTests/ChecklistCodecTests.swift +++ b/CheckStitchTests/ChecklistCodecTests.swift @@ -211,6 +211,20 @@ final class ChecklistCodecTests: XCTestCase { XCTAssertEqual(ChecklistCodec.decode(data).map(\.showsOnWatch), [false, true]) } + /// A v5 payload that carries the new key plus a key this build does not know + /// still classifies `.loaded`: additive fields must never turn a readable + /// envelope into `.unreadable`. + func testPayloadWithUnrecognizedKeyStillClassifiesLoaded() throws { + let id = UUID().uuidString + let data = Data(#"{"version":5,"deviceID":"device-a","tombstones":[],"futureField":"x","checklists":[{"id":"\#(id)","name":"Groceries","items":[],"showsOnWatch":false,"futureKey":7}]}"#.utf8) + + guard case .loaded(let envelope) = ChecklistCodec.classify(data) else { + XCTFail("expected loaded, got \(ChecklistCodec.classify(data))") + return + } + XCTAssertEqual(envelope.checklists.first?.showsOnWatch, false) + } + /// A current-version (v5) envelope whose item carries no `description` key: /// must stay `.loaded` with an empty description (the additive-field /// guarantee). v3 payloads predate `relativeDate`, so they classify as diff --git a/CheckStitchTests/ChecklistGroupingTests.swift b/CheckStitchTests/ChecklistGroupingTests.swift index abda7a2..528646d 100644 --- a/CheckStitchTests/ChecklistGroupingTests.swift +++ b/CheckStitchTests/ChecklistGroupingTests.swift @@ -113,4 +113,26 @@ struct ChecklistGroupingTests { #expect(visible == [folder]) } + + @Test + func hiddenChecklistWithUnknownFolderIsNotShownAsLoose() { + let hidden = Checklist(name: "Hidden", showsOnWatch: false, folderID: UUID()) + let visible = Checklist(name: "Visible", folderID: UUID()) + + let loose = ChecklistGrouping.visibleLooseChecklists([hidden, visible], knownFolderIDs: []) + + #expect(loose == [visible]) + } + + @Test + func hiddenChecklistWithTombstonedFolderIsNotShown() { + let tombstoned = UUID() + let hidden = Checklist(name: "Hidden", showsOnWatch: false, folderID: tombstoned) + let shown = Checklist(name: "Shown", folderID: tombstoned) + + let loose = ChecklistGrouping.visibleLooseChecklists([hidden, shown], knownFolderIDs: []) + + #expect(loose == [shown]) + #expect(!loose.contains(hidden)) + } } diff --git a/CheckStitchTests/ChecklistMergeTests.swift b/CheckStitchTests/ChecklistMergeTests.swift index 94f35af..c4e2db5 100644 --- a/CheckStitchTests/ChecklistMergeTests.swift +++ b/CheckStitchTests/ChecklistMergeTests.swift @@ -942,6 +942,36 @@ struct ChecklistMergeTests { #expect(merged.folders.first?.isCollapsed == true, "an older remote collapse flag cannot win") } + + @Test + func winnerShowOnWatchOverwritesLoser() { + let id = UUID() + var newer = checklist(id: id, name: "newer", revision: 2, modifiedAt: Date(timeIntervalSince1970: 2)) + newer.showsOnWatch = false + let older = checklist(id: id, name: "older", revision: 1, modifiedAt: Date(timeIntervalSince1970: 1)) + + let merged = ChecklistMerge.merge( + local: envelope(device: "device-a", checklists: [older]), + remote: envelope(device: "device-b", checklists: [newer]) + ) + + #expect(merged.checklists.first?.showsOnWatch == false, "the newest editor controls the show-on-watch toggle") + } + + @Test + func loserShowOnWatchIsPreservedWhenNonWinning() { + let id = UUID() + var newer = checklist(id: id, name: "newer", revision: 2, modifiedAt: Date(timeIntervalSince1970: 2)) + newer.showsOnWatch = false + let older = checklist(id: id, name: "older", revision: 1, modifiedAt: Date(timeIntervalSince1970: 1)) + + let merged = ChecklistMerge.merge( + local: envelope(device: "device-a", checklists: [newer]), + remote: envelope(device: "device-b", checklists: [older]) + ) + + #expect(merged.checklists.first?.showsOnWatch == false, "an older revision must not leak its show-on-watch value in") + } } @MainActor From e5212293224e9d29c1b11003c136a7174b49e3fb Mon Sep 17 00:00:00 2001 From: Alan Vardy Date: Sun, 27 Sep 2026 06:04:29 -0700 Subject: [PATCH 6/8] chore: add pipeline artifacts for alanvardy-var-1101-add-toggle-for-watch --- .../conventions.md | 82 ++ .../design.md | 168 ++++ .../implement.md | 42 + .../large.md | 40 + .../plan.md | 716 ++++++++++++++++++ .../questions.md | 51 ++ .../research.md | 80 ++ .../structure.md | 143 ++++ .../task.md | 12 + 9 files changed, 1334 insertions(+) create mode 100644 .pi/orksorksorks/alanvardy-var-1101-add-toggle-for-watch/conventions.md create mode 100644 .pi/orksorksorks/alanvardy-var-1101-add-toggle-for-watch/design.md create mode 100644 .pi/orksorksorks/alanvardy-var-1101-add-toggle-for-watch/implement.md create mode 100644 .pi/orksorksorks/alanvardy-var-1101-add-toggle-for-watch/large.md create mode 100644 .pi/orksorksorks/alanvardy-var-1101-add-toggle-for-watch/plan.md create mode 100644 .pi/orksorksorks/alanvardy-var-1101-add-toggle-for-watch/questions.md create mode 100644 .pi/orksorksorks/alanvardy-var-1101-add-toggle-for-watch/research.md create mode 100644 .pi/orksorksorks/alanvardy-var-1101-add-toggle-for-watch/structure.md create mode 100644 .pi/orksorksorks/alanvardy-var-1101-add-toggle-for-watch/task.md diff --git a/.pi/orksorksorks/alanvardy-var-1101-add-toggle-for-watch/conventions.md b/.pi/orksorksorks/alanvardy-var-1101-add-toggle-for-watch/conventions.md new file mode 100644 index 0000000..752a254 --- /dev/null +++ b/.pi/orksorksorks/alanvardy-var-1101-add-toggle-for-watch/conventions.md @@ -0,0 +1,82 @@ +# Conventions (shared factual appendix) + +Canonical commands, test-suite inventory, and build/verify gotchas for the +CheckStitch repo. Structure and Plan should rely on this instead of reopening +`Makefile`/`scripts/`. + +## Canonical commands +- **Gate**: `./scripts/test.sh` — `make build` (simulator) → headless pre-boot + of this worktree's `.simulator_id` → `make test` → `make build-mac` → + `make watch-build` → `scripts/tests/run.sh` → `shellcheck scripts/*.sh`, + prints `gate: ok`. Takes a bounded host simulator lock + (`${TMPDIR:-/tmp}/checkstitch-simulator.lock`, default `LOCK_TIMEOUT=60`). +- **Fast unit loop**: `make test-unit` — runs `CheckStitchTests` on + `platform=macOS`, `CODE_SIGNING_ALLOWED=NO`. +- **UI smoke**: `make test-ui` — one `CheckStitchUITests` case via + build-for-testing → test-without-building on the worktree `.simulator_id`. +- **Build/compile**: `make build` (simulator), `make build-mac` (unsigned + macOS compile leg), `make watch-build` (watchOS compile of `CheckStitchWatch`, + sim-free). +- **Devices**: `bash scripts/run-devices.sh` (real device + host + watch, + `RUN_WATCH=0` to skip watch leg); `bash scripts/run-watch.sh` resolves the + watch by name to an identifier — never a bare name in a destination. +- **Localization**: `scripts/l10n-check.sh` (fast shell/Python mirror of the + Swift `LocalizationTests`). + +## Warnings-as-errors +Every gate leg that compiles Swift passes the shared `WARNINGS_AS_ERRORS` +Makefile variable (`SWIFT_TREAT_WARNINGS_AS_ERRORS=YES`, +`GCC_TREAT_WARNINGS_AS_ERRORS=YES`), so a compiler warning fails the gate. +`scripts/tests/run.sh` pins the flag per compiling leg; `build-mac-signed`, +`run-watch.sh`, `run-devices.sh` are outside enforcement. + +## Test-suite inventory (`CheckStitchTests/`, Swift Testing, macOS-hosted) +- `ChecklistCodecTests.swift` (`@MainActor :5`) — envelope/version/classify + outcomes, absent-key decode defaults, round trips; `PinnedV4Codec`/`PinnedV5Codec` + pin the v4/v5 `classify` switches. +- `ChecklistStoreTests.swift` (`@MainActor :5`) — store mutations incl. + `setPrefixesReminderNumbers` suite (`:1840-1882`), version migration, imports. +- `ChecklistMergeTests.swift` (`@MainActor :6`) — newest-editor-wins merge. +- `ChecklistImportSessionTests.swift` / `ChecklistImportExportViewModelTests.swift` + (`@MainActor`) — import stage/commit, future-version rejects. +- `ChecklistSyncServiceTests.swift` (`@MainActor :6`) — v1 legacy, version-4 + field-clock seeding. +- `WatchChecklistStoreTests.swift` (`@MainActor :5`) — watch store decode paths. +- `ChecklistDetailViewTests.swift` (`@MainActor :13`, `#if os(macOS)` ×4) — + the edit-checklist view tests (numbering toggle assertions expected to extend + to a new toggle). +- View/reference suites: `InterfaceSettingsViewTests.swift:12/29/37`, + `ExportChecklistsViewTests.swift:28,36`, `ContentViewSettingsActionTests`, + `AboutViewTests.swift:23`, `PurchaseSettingsViewTests.swift:66`, + `BackgroundImageStoreTests`, `OrientationPreferenceTests`. +- Localization: `LocalizationTests.swift`, `LocalizationTestHelpers.swift` + (6 langs `["en","de","es","fr","ja","zh-Hans"]` at `:67`), + `LocalizationFixtures.swift`. +- `CheckStitchUITests/CheckStitchUITests.swift` — one XCTest smoke, + `#if os(iOS)` (`:34`). macOS test phase still compiles the bundle. + +## Platform gating / actor isolation +- Test targets deliberately do **not** set `SWIFT_DEFAULT_ACTOR_ISOLATION`; + suites opt in with `@MainActor` (near-universal for codec/sync/store/view-model + suites). Never restore the app's default there. +- `#if os(...)`: whole-file (`MacWindowFrameTests.swift:1`, + `ColorCrossPlatformTests.swift:5`) or per-region inside otherwise shared files. + +## Signing +`DEVELOPMENT_TEAM = 6NWX2DHB9Q`, bundle id `app.alanvardy.CheckStitch`, App +Group `group.app.alanvardy.CheckStitch`; add `-allowProvisioningUpdates` when no +profile. macOS slice signs with the same team; `make build-mac` is the +provisioning-free gate leg. + +## Localization fixture gotcha +A new user-facing key joins **one** catalog (`App`/`Core`/`Watch`) with all six +languages, **and** is added to `LocalizationFixtures.requiredKeys` +(`LocalizationFixtures.swift:12`, grouped alphabetically per catalog) or the +Swift `everyRequiredKeyIsPresent`/`catalogsHaveAllSixLanguages` checks and the +shell `l10n-check.sh` fail. `nonEnglishValuesDifferFromEnglish` must hold unless +the `(catalog, key)` is listed in `excludedIdentities` (`:166-183`). + +## Editing files +`edit` takes one top-level `path` per call with an `edits` array; `oldText` must +be byte-exact (copy from a prior `read`). JSON/`.xcstrings` blocks are the usual +miss — prefer small unique anchors. A rejected call applies nothing. diff --git a/.pi/orksorksorks/alanvardy-var-1101-add-toggle-for-watch/design.md b/.pi/orksorksorks/alanvardy-var-1101-add-toggle-for-watch/design.md new file mode 100644 index 0000000..7aebd8a --- /dev/null +++ b/.pi/orksorksorks/alanvardy-var-1101-add-toggle-for-watch/design.md @@ -0,0 +1,168 @@ +# Design Discussion + +## Current State + +CheckStitch syncs `Checklist` / `Folder` / `ChecklistItem` values from the phone +to the Apple Watch as one serialized `ChecklistEnvelope`, versioned at +`currentVersion = 5` (`CheckStitchCore/Sources/CheckStitchCore/Checklist.swift:465`). + +- **Codec convention for additive booleans.** `Checklist.prefixesReminderNumbers` + (`Checklist.swift:222,234,254-255,269`) is the precedent: decode + `decodeIfPresent(Bool.self, ...) ?? `, encode unconditionally, and + **no** `currentVersion` bump. The version bumps only when the wire shape or a + closed domain changes so an absent key can no longer be represented + (research Q1). `Folder.isCollapsed` (`:284,294,300-301,306`) follows the same + rule. Every existing stored boolean defaults to `false`. +- **Edit-checklist UI.** `CheckStitch/ChecklistDetailView.swift` is the sole + edit-checklist screen; its Number Reminders toggle (`:70`, id + `checklistPrefixNumbersToggle` `:73`) binds through `numberingBinding` + (`:256-261`) to `ChecklistStore.setPrefixesReminderNumbers(_:for:)` + (`ChecklistStore.swift:277-283`). The view is keyed by `checklistID` (`:3-4`) + and re-reads the store in the binding getter so a synced value updates the + toggle (`:253-255`). +- **Store mutation contract** is uniform: guard `.notFound` → no-op when + unchanged → mutate → bump `revision`/`modifiedAt` → `scheduleSave()` → + return `.updated`. +- **Watch transport & rendering.** `PhoneSyncAdapter.sendContext` pushes the + whole envelope via `updateApplicationContext`; the watch ingests it into + `WatchChecklistStore` (`ChecklistSync.swift:346-359`) as `checklists`/`folders` + arrays. `WatchChecklistViewModel` derives `looseChecklists` (`:25-29`), + `checklists(in:)` via `folderID == folder.id` (`:34-36`), `current(_:)` + (`:49-52`) and `visibleItems(of:)` via `!item.isBlank` (`:61-63`). Views: + `WatchChecklistListView.swift:13-27`, `WatchFolderDetailView.swift:13-24`, + `WatchChecklistDetailView.swift:28-65`. +- **Folder membership is derived, never stored**: `Checklist.folderID: UUID?` + (`Checklist.swift:186,197,212,239-242`); the folder owns no child pointer + (research Q4). +- **Localization.** Three catalogs (`App`/`Core`/`Watch`), mapped in + `scripts/l10n-check.sh:15-19` and `LocalizationFixtures.swift:8`; a new + user-facing key needs all six languages plus a `requiredKeys` entry + (`LocalizationFixtures.swift:12`). + +## Desired End State + +A user can disable a per-checklist "Show on watch" toggle on the iOS +edit-checklist screen. The flag persists through the versioned, iCloud-synced +envelope, and the watch honours it: + +1. The toggle appears in `ChecklistDetailView`, labelled "Show on watch", + defaulting to **on**, with store-backed persistence. +2. Decoding a v5-or-earlier envelope (no `showsOnWatch` key) yields `true`; + `currentVersion` stays **5**. +3. On the watch, a check set to `false` does not appear in the main list's + loose section nor in its folder's detail list. A folder row does not appear + when it has at least one member and every member is hidden. A folder with + zero members still appears. +4. Hidden checklists remain fully visible and editable on iOS (only the watch + rendering changes). + +**Verification** +- `make test-unit` — codec absent-key default-true + round trip; store + mutation suite; view-model filtering incl. all-hidden folder and empty folder. +- `make watch-build` — watch layer compiles with the new derivations. +- `bash scripts/test.sh` — full gate (`gate: ok`), warnings-as-errors included. +- `scripts/l10n-check.sh` — new key present in all six languages. +- Static evidence is insufficient for the watch behaviour ticket (per + `AGENTS.md`): verify the installed bundle with `devicectl`/`run-watch.sh`. + +## Patterns to Follow + +- **Additive boolean on `Checklist`** — model `showsOnWatch` exactly on + `prefixesReminderNumbers` (`Checklist.swift:222,234,239-242,254-255,269`): + stored `public var` with `= true` default, `CodingKeys` entry, + `decodeIfPresent(Bool.self, forKey: .showsOnWatch) ?? true` with a comment + naming the absent-in-v5-and-earlier case, unconditional encode. No + `migrated(at:)` work — that fires only for `.migratable` v1/v2 payloads + (`ChecklistStore.swift:80-91`). +- **Store mutation shape** — `setShowsOnWatch(_ enabled: Bool, for id: UUID) -> + SetDestinationOutcome` mirroring `ChecklistStore.swift:277-283` one-for-one + (guard `.notFound`, unchanged no-op, `revision += 1`, `modifiedAt = now()`, + `scheduleSave()`), and rides the checklist's existing coarse clock. +- **Store-backed binding** — `showOnWatchBinding(checklistID:)` mirroring + `ChecklistDetailView.swift:256-261`: getter reads `store.checklist(id:)`, + setter calls the store method; doc-comment why the getter re-reads. +- **Toggle UI** — `Toggle` inside the existing Form/Section in + `ChecklistDetailView.swift:70-75`, with a `Label` (`systemImage:`), a stable + `.accessibilityIdentifier`, and a behavior footer. +- **Watch derivation in the view model** — extend `WatchChecklistViewModel` + (`:16-63`) so `looseChecklists` and `checklists(in:)` exclude hidden + checklists and a folder-visibility helper computes "≥1 member and all + members hidden" over the same `folderID`-derived child set. Views keep + rendering already-filtered collections; no filtering duplicated in + `WatchChecklistListView` / `WatchFolderDetailView`. +- **Localization** — "Show on watch" in the **App** catalog + (`CheckStitch/Localizable.xcstrings`) with `extractionState: "manual"` and + all six languages, plus an alphabetically-placed + `LocalizationFixtures.requiredKeys` App entry (`LocalizationFixtures.swift:12`). +- **Tests** — Swift Testing, behaviour-named, `@MainActor`; codec cases in + `ChecklistCodecTests.swift` (`:167,178` precedent), store cases in + `ChecklistStoreTests.swift` (`:1840-1882` precedent), a `#if os(macOS)`-gated + toggle assertion in `ChecklistDetailViewTests.swift`, and view-model filtering + tests. + +Patterns the research found that should **NOT** be followed: +- **`@State`/`$` view-local bindings** (`InterfaceSettingsView.swift:53`, + `BackgroundSettingsView.swift:11,36`) — correct for app settings, wrong here; + the flag is store-persisted and synced. +- **A `ChecklistItem`-level boolean** — no stored-boolean precedent on + `ChecklistItem` (`hasDescription`/`isBlank` are computed, `Checklist.swift:66-75`) + and it cannot express folder-level hiding. +- **Bumping `currentVersion`** — an additive `decodeIfPresent ?? true` does not + qualify (research Q1); bumping would strand older clients unnecessarily. + +## Design Decisions + +1. **Flag on `Checklist`, not `ChecklistItem`**: `Checklist.showsOnWatch` — the + task is per-checklist, `prefixesReminderNumbers` is the exact precedent, and + the watch already receives whole `Checklist` values, so the flag reaches all + three watch views with no transport change (`ChecklistSync.swift:352`). +2. **Positive polarity, `Bool = true`**: `showsOnWatch` matches the "Show on + watch" label and makes `decodeIfPresent ?? true` read as "absent → shown". + This is the first default-`true` stored field; that is acceptable and + explicit. +3. **Absent key decodes to `true`, no version bump**: older payloads and older + clients keep working; follows the additive-field rule from research Q1. +4. **Row-level hiding, not empty-content hiding**: a hidden checklist disappears + from the watch main list and folder detail; nothing navigable is left behind. + iOS is unchanged. +5. **Empty folders stay visible**: only folders with ≥1 member and all members + hidden are hidden. Avoids changing unrelated watch behaviour for empty + folders. +6. **Filtering lives in `WatchChecklistViewModel`**: one derivation serving both + watch views, unit-testable without UI. +7. **Toggle only in `ChecklistDetailView`, iOS list unchanged**: minimal UI + surface; scope is "hide on watch", not "mark hidden on phone". +8. **Rides the existing checklist coarse clock**: `revision`/`modifiedAt` + newest-editor-wins already covers a whole-checklist field; no merge-code + change. + +## What We're NOT Doing + +- No `ChecklistItem`-level flag, no per-item watch hiding. +- No change to iOS list/detail rendering (hidden checklists are not greyed, + badged, or filtered on the phone). +- No watch-side editing — the watch only renders the flag. +- No `currentVersion` bump and no new `migrated(at:)` case. +- No changes to text/dynamic-type support. +- No new merge resolution logic or per-field merge clock. +- No folder-side child list (membership stays derived from `folderID`). +- No child tickets — all work lands on the main ticket. + +## Open Risks + +- **First default-`true` stored field.** Every prior boolean defaults to + `false`; the codec round-trip and absent-key tests must prove the `true` + default explicitly, and the merge tests should confirm a `true`/`false` + conflict resolves newest-editor-wins like `prefixesReminderNumbers` does. +- **Folder-visibility edge cases.** Unknown/deleted `folderID` values and + tombstones must not make a folder vanish for the wrong reason; cover + loose-checklists-with-unknown-folder and tombstoned folders in view-model + tests. +- **Watch backwards compatibility.** An older watch build receiving a payload + with the new key ignores it (unknown key, additive) — confirm decode does not + throw on an unrecognised key. +- **Localization identity.** The German/Spanish/French/Japanese/Chinese values + must differ from English or be listed in `excludedIdentities` + (`LocalizationFixtures.swift:166-183`) to satisfy `l10n-check.sh`. +- **Static evidence is not enough.** Watch hide/show behaviour must be verified + on the installed watch bundle, not via unit tests alone. \ No newline at end of file diff --git a/.pi/orksorksorks/alanvardy-var-1101-add-toggle-for-watch/implement.md b/.pi/orksorksorks/alanvardy-var-1101-add-toggle-for-watch/implement.md new file mode 100644 index 0000000..bae4ef1 --- /dev/null +++ b/.pi/orksorksorks/alanvardy-var-1101-add-toggle-for-watch/implement.md @@ -0,0 +1,42 @@ +# Implementation Summary + +Adds an additive, default-`true` `Checklist.showsOnWatch` flag (no +`currentVersion` bump), mutated via `ChecklistStore.setShowsOnWatch` and bound +to a store-backed "Show on watch" toggle in `ChecklistDetailView`. The watch +filters its already-derived collections through new `ChecklistGrouping` helpers +so hidden checklists and all-hidden folders disappear from the watch while iOS +is unchanged. All three phases implemented, each as its own commit. + +## Commits +| Phase | Commit | Description | +|-------|--------|-------------| +| 1 | ae07832 | Walking skeleton — toggle on iOS hides a loose checklist on the watch | +| 2 | f14f769 | Folder semantics — folder detail and folder rows honour hiding | +| 3 | 423020c | Hardening — sync conflicts, edge cases, and on-watch verification | + +## Automated Checks +- [x] `make test-unit` passes across all phases (503 → 507 → 511 tests in 63 suites) +- [x] `make watch-build` — checkpoint compiles with the delegating `looseChecklists` + `visibleFolders` +- [x] `make build` — iOS target compiles (Phase 2; no accidental app-target use of the new helpers) +- [x] `scripts/l10n-check.sh` prints `l10n-check: ok` (4 catalogs, 156 keys, 6 languages) +- [x] `currentVersion` confirmed unchanged at `5` (`Checklist.swift:477`) +- [ ] `bash scripts/test.sh` full gate — deferred to the review step (checked in `plan.md` remains unchecked by design) + +## Manual Verification Items (from the plan) +- [ ] Phase 1: `make run`, open a checklist's detail screen: "Show on watch" appears in its own section, default **on**, with the footer text. +- [ ] Phase 1: Toggle it off, leave and re-enter the screen: it stays off (store-backed). +- [ ] Phase 1: Toggle back on: it flips back. +- [ ] Phase 2: `make run` and open a folder on the phone: its iOS list is unchanged (hidden members stay visible on iOS). +- [ ] Phase 2: (Watch check lands in Phase 3's live pass.) +- [ ] Phase 3: `bash scripts/run-watch.sh` builds, installs and launches `CheckStitchWatch` on the paired Apple Watch. +- [ ] Phase 3: With the watch app open, toggle "Show on watch" **off** for a loose checklist on the phone: the row disappears from the watch's main list. +- [ ] Phase 3: Toggle it back **on**: the row returns. +- [ ] Phase 3: Put two hidden checklists in one folder: the folder row disappears from the watch root, and re-enabling one member brings the folder back. +- [ ] Phase 3: Leave an empty folder: its row stays on the watch root. +- [ ] Phase 3: State what the user should see in the completion artifact (per `AGENTS.md`, sync/hide tickets cannot close on unit tests alone). + +## Notes / deviations observed by subagents +- The plan named `CheckStitch/ChecklistStore.swift` and `CheckStitch/ChecklistMerge.swift`, but those files actually live under `CheckStitchCore/Sources/CheckStitchCore/`. Phase 1 adapted the paths; content unchanged. +- The repo enforces memberwise-init argument order (`showsOnWatch` must precede `folderID`), which Phase 2 and Phase 3 test snippets had reversed. Reordered; all tests compile under warnings-as-errors. +- Phase 3's `bash scripts/test.sh` gate checkbox was intentionally left unchecked — the full project-wide gate is owned by the review step, not the phase workers. +- Before phase work, the leftover `DELETEME` placeholder (added by the `chore: start` commit) was removed in a setup commit `51b2cc4` (repo-wide convention) so that the mandated `git rebase origin/main` could run on a clean tree. \ No newline at end of file diff --git a/.pi/orksorksorks/alanvardy-var-1101-add-toggle-for-watch/large.md b/.pi/orksorksorks/alanvardy-var-1101-add-toggle-for-watch/large.md new file mode 100644 index 0000000..3a3e9d0 --- /dev/null +++ b/.pi/orksorksorks/alanvardy-var-1101-add-toggle-for-watch/large.md @@ -0,0 +1,40 @@ +# Task + +Add a per-checklist "Show on watch" toggle to the **edit checklist screen(s)** of +the CheckStitch iOS app. The toggle: + +- is labelled "Show on watch" +- defaults to **enabled** +- when disabled, hides that checklist's item on the Apple Watch +- hides a **folder** on the watch when every checklist in it is hidden + +The watch layer (`CheckStitchWatch/`) must honour the flag when rendering the +main list and the folder detail view, and hide any folder whose members are all +hidden. + +## Why LARGE + +SCHEMA + CROSS_CUTTING + CONVENTION_RISK: the per-item flag lives in the +versioned, iCloud-synced `ChecklistItem`/`ChecklistEnvelope` wire codec +(`ChecklistCodec.currentVersion = 5`) where absent-key decode-defaults and +version-bump policy are design decisions (see `Checklist.swift` decode/`migrated` +precedents), so persisting the default-on field to existing data is a data-model +change to shared sync/owned convention code; and the feature spans data model + +app UI (edit screens) + watch platform target (list + folder-hiding) with no one +existing pattern carrying it end to end. + +## Surface (from classification recon) + +- `CheckStitchCore/Sources/CheckStitchCore/Checklist.swift` — `ChecklistItem` / + `Checklist` / `Folder` structs; versioned `ChecklistEnvelope` codec + (`currentVersion = 5`), `decode` defaults, `migrated(at:)`. +- `CheckStitch/ChecklistDetailView.swift`, `CheckStitch/ItemEditView.swift` — + the edit-checklist UI where the toggle lands (check existing edit form + controls for precedent). +- `CheckStitchWatch/WatchChecklistListView.swift`, + `CheckStitchWatch/WatchFolderDetailView.swift`, + `CheckStitchWatch/WatchSyncAdapter.swift` — watch-side rendering that must + filter hidden items and hide all-hidden folders. +- `Localizable.xcstrings` + `LocalizationFixtures.requiredKeys` — new + user-facing string ("Show on watch") needs all 6 languages; run + `scripts/l10n-check.sh`. \ No newline at end of file diff --git a/.pi/orksorksorks/alanvardy-var-1101-add-toggle-for-watch/plan.md b/.pi/orksorksorks/alanvardy-var-1101-add-toggle-for-watch/plan.md new file mode 100644 index 0000000..c6fa3fa --- /dev/null +++ b/.pi/orksorksorks/alanvardy-var-1101-add-toggle-for-watch/plan.md @@ -0,0 +1,716 @@ +# Implementation Plan + +## Overview + +Add an additive, default-`true` `Checklist.showsOnWatch` flag (no +`currentVersion` bump), mutate it through `ChecklistStore.setShowsOnWatch`, +bind it to a store-backed "Show on watch" toggle in `ChecklistDetailView`, and +honour it by filtering the watch's already-derived collections so hidden +checklists (and all-hidden folders) disappear from the watch while iOS is +unchanged. + +## Deviation from `structure.md` (Option A, agreed) + +The watch-side filtering derivations move into **`CheckStitchCore`'s +`ChecklistGrouping`** (in `Checklist.swift`), with `WatchChecklistViewModel` +delegating to them. Reason: `WatchChecklistViewModel` lives in the +watchOS-only `CheckStitchWatch` target, which the macOS-hosted +`CheckStitchTests` bundle cannot import or link (its +`fileSystemSynchronizedGroups` is only `CheckStitchTests/` + the +`CheckStitchCore` package; there is no watch test leg in +`scripts/test.sh`). Putting the derivation in Core matches the existing +precedent — `WatchChecklistStore` lives in Core and is tested by +`WatchChecklistStoreTests`. + +Consequences: +- The new filter tests live in `CheckStitchTests/ChecklistGroupingTests.swift` + (Swift Testing, `@Test func`), not a new + `CheckStitchTests/WatchChecklistViewModelTests.swift`. +- `WatchChecklistViewModel.looseChecklists`, `checklists(in:)` and the new + `visibleFolders` become one-line delegations. +- The tests keep design decision #6's intent (one derivation serving both + watch views, unit-testable without UI). + +Two further small deviations, both required for correctness and noted inline: +`ChecklistMerge.mergedChecklists` copies winner fields one by one, so the new +field must join that copy; and `ChecklistStore.duplicate`/`freshCopy` carry +per-checklist booleans, so the new field must be carried there too. Test names +follow each file's existing style (XCTest files keep the `test` prefix; +Swift Testing files do not), unlike the shorter names written in +`structure.md`. + +## Phase 1: Walking skeleton — toggle on iOS hides a loose checklist on the watch + +Turning off "Show on watch" on the iOS edit-checklist screen persists the flag +through the synced envelope, and that checklist disappears from the watch main +list. One vertical slice: toggle → store → codec → envelope → watch store → +Core derivation → watch list. + +### Changes + +#### 1. Additive `showsOnWatch` field, codec, and Core loose-visibility helper +**File**: `CheckStitchCore/Sources/CheckStitchCore/Checklist.swift` +**Action**: modify + +Add `showsOnWatch: Bool = true` to the memberwise init (after +`prefixesReminderNumbers`), the stored property, the `CodingKeys` case, the +decode, the encode, and the self-heal reconstruction; **do not touch +`currentVersion = 5`** and add no `migrated(at:)` case. + +```swift +public init( + id: UUID = UUID(), name: String = "New checklist", items: [ChecklistItem] = [], + destinationListIdentifier: String? = nil, + prefixesReminderNumbers: Bool = false, + showsOnWatch: Bool = true, + folderID: UUID? = nil, + modifiedAt: Date = .distantPast, revision: Int = 0, + itemOrder: [UUID]? = nil, orderRevision: Int = 0, orderModifiedAt: Date = .distantPast +) { + // … + self.prefixesReminderNumbers = prefixesReminderNumbers + self.showsOnWatch = showsOnWatch + // … +} +``` + +Stored property (after `prefixesReminderNumbers`): + +```swift +/// Whether this checklist is offered on the Apple Watch. Default-on: it is +/// the first stored boolean that defaults to `true`, so an absent key reads +/// as "shown". Additive optional key: absent in v5-and-earlier payloads +/// decodes to `true` with no version bump (the `prefixesReminderNumbers` +/// precedent). Shares the checklist's coarse `revision`/`modifiedAt` clock, +/// so a toggle is decided by the same last-write-wins rule. +public var showsOnWatch: Bool +``` + +CodingKeys: + +```swift +case id, name, items, destinationListIdentifier, prefixesReminderNumbers, showsOnWatch, folderID +``` + +Decode (after the `prefixesReminderNumbers` decode): + +```swift +// Additive optional field: absent in v5-and-earlier payloads decodes to +// `true` (shown on the watch) with no version bump, matching the +// `prefixesReminderNumbers` precedent. This is the only default-`true` +// stored field, so the `?? true` is deliberate, not an oversight. +let showsOnWatch = try container.decodeIfPresent(Bool.self, forKey: .showsOnWatch) ?? true +``` + +Thread it into the self-heal reconstruction (this is load-bearing — omitting it +would silently rewrite a decoded `false` back to `true`): + +```swift +self = Checklist(id: id, name: name, items: items, + destinationListIdentifier: destinationListIdentifier, + prefixesReminderNumbers: prefixesReminderNumbers, + showsOnWatch: showsOnWatch, + folderID: folderID, + modifiedAt: modifiedAt, revision: revision, + itemOrder: itemOrder, orderRevision: orderRevision, orderModifiedAt: orderModifiedAt) + .normalizedOrder() +``` + +Encode (after the `prefixesReminderNumbers` encode; unconditional, matching the +"encoder writes every key" invariant): + +```swift +try container.encode(showsOnWatch, forKey: .showsOnWatch) +``` + +Then extend `ChecklistGrouping` (same file, the enum at the end) with the +Phase 1 derivation: + +```swift +/// The loose checklists the watch renders, in global order: loose (no folder, +/// or a `folderID` no longer known) and not hidden. The phone keeps sending +/// hidden checklists; the watch filters only at render time. +public static func visibleLooseChecklists( + _ checklists: [Checklist], knownFolderIDs: Set +) -> [Checklist] { + checklists.filter { isLoose($0, knownFolderIDs: knownFolderIDs) && $0.showsOnWatch } +} +``` + +#### 2. Store mutation + carry the field through copy paths +**File**: `CheckStitch/ChecklistStore.swift` +**Action**: modify + +Add `setShowsOnWatch`, directly after `setPrefixesReminderNumbers` (`:277-283`), +mirroring it one-for-one: + +```swift +/// Sets a checklist's per-checklist "Show on watch" toggle and reports +/// whether it applied. Shares the checklist's coarse `revision`/`modifiedAt` +/// clock with `rename`/`setDestination`/`setPrefixesReminderNumbers`, so the +/// toggle rides the same last-write-wins rule. An unchanged value is a no-op, +/// so re-rendering the toggle never manufactures a spurious LWW win. +@discardableResult +func setShowsOnWatch(_ enabled: Bool, for id: UUID) -> SetDestinationOutcome { + guard let index = checklists.firstIndex(where: { $0.id == id }) else { return .notFound } + guard checklists[index].showsOnWatch != enabled else { return .updated } + checklists[index].showsOnWatch = enabled + checklists[index].revision += 1 + checklists[index].modifiedAt = now() + scheduleSave() + return .updated +} +``` + +Carry the field in the two `Checklist(...)` reconstructions that copy +per-checklist booleans, so duplicating/importing does not silently reset it: +- `duplicate(id:name:)` (~`:181`): add `showsOnWatch: source.showsOnWatch,` + after the `prefixesReminderNumbers:` line. +- `freshCopy(of:)` (~`:204`): add `showsOnWatch: checklist.showsOnWatch,` + after the `prefixesReminderNumbers:` line. + +#### 3. Winner-field copy in merge +**File**: `CheckStitch/ChecklistMerge.swift` +**Action**: modify + +`mergedChecklists` copies winner fields individually rather than replacing the +whole value, so the new field must join the copy inside the `if wins(...)` +block (~`:142`). Without this, a checklist whose newest edit is on another +device loses its hidden state on merge. + +```swift +merged.prefixesReminderNumbers = remoteChecklist.prefixesReminderNumbers +merged.showsOnWatch = remoteChecklist.showsOnWatch +merged.folderID = remoteChecklist.folderID +``` + +(No new merge logic or clock — the field simply rides the existing +whole-checklist winner.) + +#### 4. Toggle + binding in the edit screen +**File**: `CheckStitch/ChecklistDetailView.swift` +**Action**: modify + +Add a new `Section` directly after the Number Reminders section +(`:69-75`), and the binding next to `numberingBinding` (`:256-261`): + +```swift +Section { + Toggle(isOn: showOnWatchBinding(checklistID: checklistID)) { + Label("Show on watch", systemImage: "applewatch") + } + .accessibilityIdentifier("checklistShowsOnWatchToggle") +} footer: { + Text("Hidden checklists stay on your iPhone but are not shown on the Apple Watch.") +} +``` + +```swift +/// Per-selection write through the store for the "Show on watch" toggle. The +/// getter reads the store so a value that arrives over sync updates the +/// toggle; `.notFound` (deleted while this screen was open) is ignored, +/// matching `numberingBinding`. +private func showOnWatchBinding(checklistID: UUID) -> Binding { + Binding( + get: { store.checklist(id: checklistID)?.showsOnWatch ?? true }, + set: { store.setShowsOnWatch($0, for: checklistID) } + ) +} +``` + +#### 5. Watch view model delegates the loose derivation +**File**: `CheckStitchWatch/WatchChecklistViewModel.swift` +**Action**: modify + +```swift +/// Checklists with no folder (or a folder id the phone no longer knows), in +/// global order, excluding any the phone has hidden. Rendered as top-level +/// rows directly under the folder rows. +var looseChecklists: [Checklist] { + let known = Set(store.folders.map(\.id)) + return ChecklistGrouping.visibleLooseChecklists(store.checklists, knownFolderIDs: known) +} +``` + +`checklists(in:)` and the views are unchanged in this phase (a hidden checklist +whose folder is known is only hidden by the Phase 2 folder work; Phase 1 proves +the loose path). + +`WatchChecklistListView.swift` needs **no Phase 1 change** — it already renders +`viewModel.looseChecklists`. + +#### 6. Localization — two new App keys +**File**: `CheckStitch/Localizable.xcstrings` +**Action**: modify + +Add both entries to the `strings` map (Xcode keeps it key-sorted), each with +`extractionState: "manual"` and all six languages. `structure.md`'s file list +named only `"Show on watch"`, but it also mandates a behavior footer, so the +footer string is a second key. + +```json +"Show on watch": { + "extractionState": "manual", + "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Auf der Uhr anzeigen" } }, + "en": { "stringUnit": { "state": "translated", "value": "Show on watch" } }, + "es": { "stringUnit": { "state": "translated", "value": "Mostrar en el reloj" } }, + "fr": { "stringUnit": { "state": "translated", "value": "Afficher sur la montre" } }, + "ja": { "stringUnit": { "state": "translated", "value": "時計に表示" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "在手表上显示" } } + } +} +``` + +```json +"Hidden checklists stay on your iPhone but are not shown on the Apple Watch.": { + "extractionState": "manual", + "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Ausgeblendete Checklisten bleiben auf deinem iPhone, werden aber nicht auf der Apple Watch angezeigt." } }, + "en": { "stringUnit": { "state": "translated", "value": "Hidden checklists stay on your iPhone but are not shown on the Apple Watch." } }, + "es": { "stringUnit": { "state": "translated", "value": "Las listas ocultas permanecen en tu iPhone, pero no se muestran en el Apple Watch." } }, + "fr": { "stringUnit": { "state": "translated", "value": "Les listes masquées restent sur votre iPhone mais ne s'affichent pas sur l'Apple Watch." } }, + "ja": { "stringUnit": { "state": "translated", "value": "非表示のチェックリストはiPhoneに残りますが、Apple Watchには表示されません。" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "隐藏的清单会保留在 iPhone 上,但不会显示在 Apple Watch 上。" } } + } +} +``` + +All non-English values differ from English, so no `excludedIdentities` entry is +needed. + +#### 7. Localization fixture +**File**: `CheckStitchTests/LocalizationFixtures.swift` +**Action**: modify + +In the `("App", [...])` list (`:12`): +- `"Show on watch",` — after `"Show a wallpaper behind the checklist.",` +- `"Hidden checklists stay on your iPhone but are not shown on the Apple Watch.",` + — before `"How much the wallpaper fades for readability.",` (alphabetical, as + the list intends). + +#### 8. Tests +**File**: `CheckStitchTests/ChecklistCodecTests.swift` (XCTest) +**Action**: modify — add after `testPrefixesReminderNumbersSurvivesEnvelopeRoundTrip` (`:178`): + +```swift +/// A v5 payload written without `showsOnWatch`: the absent key decodes to +/// `true` (the additive-optional guarantee for the first default-on field), +/// so older payloads keep showing every checklist without a version bump. +func testDecodesV5PayloadWithoutShowsOnWatchAsShown() throws { + let id = UUID().uuidString + let data = Data(#"{"version":5,"deviceID":"device-a","tombstones":[],"checklists":[{"id":"\#(id)","name":"Groceries","items":[]}]}"#.utf8) + + guard case .loaded(let envelope) = ChecklistCodec.classify(data) else { + XCTFail("expected loaded, got \(ChecklistCodec.classify(data))") + return + } + XCTAssertTrue(envelope.checklists.first?.showsOnWatch ?? false) +} + +func testShowsOnWatchSurvivesEnvelopeRoundTrip() throws { + let hidden = Checklist(name: "Hidden", items: [ChecklistItem(title: "Milk")], showsOnWatch: false) + let shown = Checklist(name: "Shown") + let envelope = ChecklistEnvelope(deviceID: "device-a", checklists: [hidden, shown]) + + let data = try ChecklistCodec.encode(envelope) + + XCTAssertEqual(ChecklistCodec.classify(data), .loaded(envelope)) + XCTAssertEqual(ChecklistCodec.decode(data).map(\.showsOnWatch), [false, true]) +} +``` + +**File**: `CheckStitchTests/ChecklistStoreTests.swift` (XCTest) +**Action**: modify — add a `setShowsOnWatch` suite after the +`setPrefixesReminderNumbers` suite (`:1882`), mirroring its three cases: + +```swift +func testSetShowsOnWatchUpdatesRevisionAndPersists() { + let suite = makeDefaults() + defer { suite.defaults.removePersistentDomain(forName: suite.suiteName) } + let clock = Clock() + let store = ChecklistStore(defaults: suite.defaults, key: key, textEditDelay: nil, now: { clock.now }) + let created = store.create() + clock.now = Date(timeIntervalSince1970: 5) + + XCTAssertEqual(store.setShowsOnWatch(false, for: created.id), .updated) + + let reloaded = makeStore(defaults: suite.defaults) + XCTAssertEqual(reloaded.checklist(id: created.id)?.showsOnWatch, false) + XCTAssertEqual(reloaded.checklist(id: created.id)?.revision, 2) + XCTAssertEqual(reloaded.checklist(id: created.id)?.modifiedAt, clock.now) +} + +func testSetShowsOnWatchUnchangedValueIsNoOp() { + let suite = makeDefaults() + defer { suite.defaults.removePersistentDomain(forName: suite.suiteName) } + let clock = Clock() + let store = ChecklistStore(defaults: suite.defaults, key: key, textEditDelay: nil, now: { clock.now }) + let created = store.create() + store.setShowsOnWatch(false, for: created.id) + let revisionAfterToggle = store.checklist(id: created.id)?.revision + let modifiedAfterToggle = store.checklist(id: created.id)?.modifiedAt + clock.now = Date(timeIntervalSince1970: 9) + + XCTAssertEqual(store.setShowsOnWatch(false, for: created.id), .updated) + + XCTAssertEqual(store.checklist(id: created.id)?.revision, revisionAfterToggle) + XCTAssertEqual(store.checklist(id: created.id)?.modifiedAt, modifiedAfterToggle) +} + +func testSetShowsOnWatchForUnknownChecklistReturnsNotFound() { + let suite = makeDefaults() + defer { suite.defaults.removePersistentDomain(forName: suite.suiteName) } + let store = makeStore(defaults: suite.defaults) + + XCTAssertEqual(store.setShowsOnWatch(false, for: UUID()), .notFound) + XCTAssertTrue(store.checklists.isEmpty) +} +``` + +**File**: `CheckStitchTests/ChecklistGroupingTests.swift` (Swift Testing) +**Action**: modify — append: + +```swift +@Test +func visibleLooseChecklistsExcludeHiddenChecklists() { + let shown = Checklist(name: "Shown") + let hidden = Checklist(name: "Hidden", showsOnWatch: false) + + let visible = ChecklistGrouping.visibleLooseChecklists([shown, hidden], knownFolderIDs: []) + + #expect(visible == [shown]) +} +``` + +**File**: `CheckStitchTests/ChecklistDetailViewTests.swift` (Swift Testing) +**Action**: modify — append (the view's private binding cannot be read from a +headless test, so the default/persistence live at the model+store layer and the +view gets a render canary, following `detailViewRendersItemsWithDescriptions`): + +```swift +/// The flag is default-on: a freshly created checklist is shown on the watch. +@Test +func showOnWatchDefaultsOn() { + #expect(Checklist(name: "Groceries").showsOnWatch) +} + +/// Toggling off persists through the store the toggle binds to. +@Test +func togglingShowOnWatchPersistsThroughTheStore() { + let defaults = makeIsolatedDefaults() + let store = ChecklistStore(defaults: defaults, textEditDelay: nil) + let checklist = store.create(name: "Groceries") + + store.setShowsOnWatch(false, for: checklist.id) + + #expect(store.checklist(id: checklist.id)?.showsOnWatch == false) +} + +/// The new section renders on the detail screen: staging the view against a +/// store whose checklist is hidden must not crash. +@Test +func detailViewRendersWithAHiddenChecklist() { + let defaults = makeIsolatedDefaults() + let store = ChecklistStore(defaults: defaults, textEditDelay: nil) + let checklist = store.create(name: "Groceries") + store.setShowsOnWatch(false, for: checklist.id) + + let view = ChecklistDetailView(checklistID: checklist.id).environment(store) + #if os(macOS) + #expect(ImageRenderer(content: view).nsImage != nil) + #else + #expect(ImageRenderer(content: view).uiImage != nil) + #endif +} +``` + +### Verification +#### Automated +- [x] `make test-unit` — the codec (absent-key + round trip), store (3 cases), + grouping, and detail-view additions pass with no regressions. +- [x] `make watch-build` — the watch target compiles with the delegating + `looseChecklists`. +- [x] `scripts/l10n-check.sh` — prints `l10n-check: ok`, including both new keys + in all six languages. +- [x] Confirm no `currentVersion` change: `rg -n 'currentVersion(:|=)' CheckStitchCore/Sources/CheckStitchCore/Checklist.swift` still shows `5`. + +#### Manual +- [ ] `make run`, open a checklist's detail screen: "Show on watch" appears in + its own section, default **on**, with the footer text. +- [ ] Toggle it off, leave and re-enter the screen: it stays off (store-backed). +- [ ] Toggle back on: it flips back. + +--- + +## Phase 2: Folder semantics — folder detail and folder rows honour hiding + +A folder's detail list shows only shown checklists, and a folder row disappears +from the watch main list when it has ≥1 member and every member is hidden. A +folder with zero members still appears. + +### Changes + +#### 1. Core folder derivations +**File**: `CheckStitchCore/Sources/CheckStitchCore/Checklist.swift` +**Action**: modify — add to `ChecklistGrouping`: + +```swift +/// A folder's visible checklists, in global order: filed under `folder` and +/// not hidden. +public static func visibleChecklists( + in folder: Folder, from checklists: [Checklist] +) -> [Checklist] { + checklists.filter { $0.folderID == folder.id && $0.showsOnWatch } +} + +/// The folders the watch renders: a folder stays when it has no members, or at +/// least one visible member; it drops only when it has members and all of them +/// are hidden. Membership stays derived from `folderID` — no folder-side child +/// list is introduced. +public static func visibleFolders( + _ folders: [Folder], checklists: [Checklist] +) -> [Folder] { + folders.filter { folder in + let members = checklists.filter { $0.folderID == folder.id } + return members.isEmpty || members.contains { $0.showsOnWatch } + } +} +``` + +#### 2. Watch view model delegates the folder derivations +**File**: `CheckStitchWatch/WatchChecklistViewModel.swift` +**Action**: modify + +```swift +/// Folders with at least one visible checklist, plus empty folders. A folder +/// whose members are all hidden drops off the root list. +var visibleFolders: [Folder] { + ChecklistGrouping.visibleFolders(store.folders, checklists: store.checklists) +} + +/// The checklists inside `folder`, in global order, excluding hidden ones. +func checklists(in folder: Folder) -> [Checklist] { + ChecklistGrouping.visibleChecklists(in: folder, from: store.checklists) +} +``` + +#### 3. Watch root list renders visible folders +**File**: `CheckStitchWatch/WatchChecklistListView.swift` +**Action**: modify + +```swift +ForEach(viewModel.visibleFolders) { folder in + NavigationLink { + WatchFolderDetailView(folder: folder) + } label: { + Label(folder.name, systemImage: "folder") + } +} +``` + +No inline filtering — the collection is already filtered. No change to the +`viewModel.checklists.isEmpty` empty-state branch or to +`WatchFolderDetailView` (it keeps rendering `viewModel.checklists(in: current)`, +which now filters). + +#### 4. Tests +**File**: `CheckStitchTests/ChecklistGroupingTests.swift` (Swift Testing) +**Action**: modify — append: + +```swift +@Test +func visibleChecklistsInFolderExcludeHiddenChecklists() { + let folder = Folder(name: "Errands") + let shown = Checklist(name: "Shown", folderID: folder.id) + let hidden = Checklist(name: "Hidden", folderID: folder.id, showsOnWatch: false) + + let visible = ChecklistGrouping.visibleChecklists(in: folder, from: [hidden, shown]) + + #expect(visible == [shown]) +} + +@Test +func folderWithSomeVisibleMembersStaysVisible() { + let folder = Folder(name: "Errands") + let shown = Checklist(name: "Shown", folderID: folder.id) + let hidden = Checklist(name: "Hidden", folderID: folder.id, showsOnWatch: false) + + let visible = ChecklistGrouping.visibleFolders([folder], checklists: [hidden, shown]) + + #expect(visible == [folder]) +} + +@Test +func folderWithEveryMemberHiddenIsHidden() { + let folder = Folder(name: "Errands") + let hidden = Checklist(name: "Hidden", folderID: folder.id, showsOnWatch: false) + + let visible = ChecklistGrouping.visibleFolders([folder], checklists: [hidden]) + + #expect(visible.isEmpty) +} + +@Test +func emptyFolderStaysVisible() { + let folder = Folder(name: "Empty") + + let visible = ChecklistGrouping.visibleFolders([folder], checklists: []) + + #expect(visible == [folder]) +} +``` + +### Verification +#### Automated +- [x] `make test-unit` — the four folder-derivation cases pass and the + Phase 1 grouping cases still pass. +- [x] `make watch-build` — the watch target compiles with `visibleFolders`. +- [x] `make build` — the iOS target compiles (no accidental app-target use of the + new `ChecklistGrouping` helpers). + +#### Manual +- [ ] `make run` and open a folder on the phone: its iOS list is unchanged + (hidden members stay visible on iOS). +- [ ] (Watch check lands in Phase 3's live pass.) + +--- + +## Phase 3: Hardening — sync conflicts, edge cases, and on-watch verification + +Pins the behaviours later changes must not break: merge winner-wins for the +flag, older-client tolerance of the new key, unknown/tombstoned folders, and +the on-device watch outcome. + +### Changes + +#### 1. Merge conflict tests +**File**: `CheckStitchTests/ChecklistMergeTests.swift` (Swift Testing) +**Action**: modify — append, mirroring `winnerNumberingOverwritesLoser` / +`loserNumberingIsPreservedWhenNonWinning` (`:755-780`): + +```swift +@Test +func winnerShowOnWatchOverwritesLoser() { + let id = UUID() + var newer = checklist(id: id, name: "newer", revision: 2, modifiedAt: Date(timeIntervalSince1970: 2)) + newer.showsOnWatch = false + let older = checklist(id: id, name: "older", revision: 1, modifiedAt: Date(timeIntervalSince1970: 1)) + + let merged = ChecklistMerge.merge( + local: envelope(device: "device-a", checklists: [older]), + remote: envelope(device: "device-b", checklists: [newer]) + ) + + #expect(merged.checklists.first?.showsOnWatch == false, "the newest editor controls the show-on-watch toggle") +} + +@Test +func loserShowOnWatchIsPreservedWhenNonWinning() { + let id = UUID() + var newer = checklist(id: id, name: "newer", revision: 2, modifiedAt: Date(timeIntervalSince1970: 2)) + newer.showsOnWatch = false + let older = checklist(id: id, name: "older", revision: 1, modifiedAt: Date(timeIntervalSince1970: 1)) + + let merged = ChecklistMerge.merge( + local: envelope(device: "device-a", checklists: [newer]), + remote: envelope(device: "device-b", checklists: [older]) + ) + + #expect(merged.checklists.first?.showsOnWatch == false, "an older revision must not leak its show-on-watch value in") +} +``` + +#### 2. Older-client / unknown-key tolerance +**File**: `CheckStitchTests/ChecklistCodecTests.swift` (XCTest) +**Action**: modify — append: + +```swift +/// A v5 payload that carries the new key plus a key this build does not know +/// still classifies `.loaded`: additive fields must never turn a readable +/// envelope into `.unreadable`. +func testPayloadWithUnrecognizedKeyStillClassifiesLoaded() throws { + let id = UUID().uuidString + let data = Data(#"{"version":5,"deviceID":"device-a","tombstones":[],"futureField":"x","checklists":[{"id":"\#(id)","name":"Groceries","items":[],"showsOnWatch":false,"futureKey":7}]}"#.utf8) + + guard case .loaded(let envelope) = ChecklistCodec.classify(data) else { + XCTFail("expected loaded, got \(ChecklistCodec.classify(data))") + return + } + XCTAssertEqual(envelope.checklists.first?.showsOnWatch, false) +} +``` + +#### 3. Unknown / tombstoned folder edge cases +**File**: `CheckStitchTests/ChecklistGroupingTests.swift` (Swift Testing) +**Action**: modify — append. Both paths resolve through the same `isLoose` +rule (a tombstoned folder arrives as an unknown id), and the tests pin that a +hidden checklist never resurfaces in the loose list while a shown sibling does. + +```swift +@Test +func hiddenChecklistWithUnknownFolderIsNotShownAsLoose() { + let hidden = Checklist(name: "Hidden", folderID: UUID(), showsOnWatch: false) + let visible = Checklist(name: "Visible", folderID: UUID()) + + let loose = ChecklistGrouping.visibleLooseChecklists([hidden, visible], knownFolderIDs: []) + + #expect(loose == [visible]) +} + +@Test +func hiddenChecklistWithTombstonedFolderIsNotShown() { + let tombstoned = UUID() + let hidden = Checklist(name: "Hidden", folderID: tombstoned, showsOnWatch: false) + let shown = Checklist(name: "Shown", folderID: tombstoned) + + let loose = ChecklistGrouping.visibleLooseChecklists([hidden, shown], knownFolderIDs: []) + + #expect(loose == [shown]) + #expect(!loose.contains(hidden)) +} +``` + +### Verification +#### Automated +- [x] `make test-unit` — merge, codec and grouping additions pass. +- [ ] `bash scripts/test.sh` — full gate prints `gate: ok` (includes + `make build`, `make test`, `make build-mac`, `make watch-build`, + `scripts/tests/run.sh`, shellcheck; warnings-as-errors enforced). +- [x] `git grep -n 'currentVersion' CheckStitchCore/Sources/CheckStitchCore/Checklist.swift` shows the value unchanged at `5`. + +#### Manual (required — static evidence cannot close this ticket) +- [ ] `bash scripts/run-watch.sh` builds, installs and launches + `CheckStitchWatch` on the paired Apple Watch. +- [ ] With the watch app open, toggle "Show on watch" **off** for a loose + checklist on the phone: the row disappears from the watch's main list. +- [ ] Toggle it back **on**: the row returns. +- [ ] Put two hidden checklists in one folder: the folder row disappears from + the watch root, and re-enabling one member brings the folder back. +- [ ] Leave an empty folder: its row stays on the watch root. +- [ ] State what the user should see in the completion artifact (per + `AGENTS.md`, sync/hide tickets cannot close on unit tests alone). + +--- + +## File inventory + +| File | Phase | Action | +| --- | --- | --- | +| `CheckStitchCore/Sources/CheckStitchCore/Checklist.swift` | 1, 2 | modify (`showsOnWatch` + codec + `ChecklistGrouping` helpers) | +| `CheckStitch/ChecklistStore.swift` | 1 | modify (`setShowsOnWatch`, carry in `duplicate`/`freshCopy`) | +| `CheckStitch/ChecklistMerge.swift` | 1 | modify (winner-field copy) | +| `CheckStitch/ChecklistDetailView.swift` | 1 | modify (toggle + binding) | +| `CheckStitch/Localizable.xcstrings` | 1 | modify (2 App keys) | +| `CheckStitchTests/LocalizationFixtures.swift` | 1 | modify (2 `requiredKeys` entries) | +| `CheckStitchWatch/WatchChecklistViewModel.swift` | 1, 2 | modify (delegations) | +| `CheckStitchWatch/WatchChecklistListView.swift` | 2 | modify (`visibleFolders`) | +| `CheckStitchWatch/WatchFolderDetailView.swift` | — | unchanged (renders filtered `members`) | +| `CheckStitchTests/ChecklistCodecTests.swift` | 1, 3 | modify (3 tests) | +| `CheckStitchTests/ChecklistStoreTests.swift` | 1 | modify (3 tests) | +| `CheckStitchTests/ChecklistGroupingTests.swift` | 1, 2, 3 | modify (7 tests) | +| `CheckStitchTests/ChecklistDetailViewTests.swift` | 1 | modify (3 tests) | +| `CheckStitchTests/ChecklistMergeTests.swift` | 3 | modify (2 tests) | + +No new files, no PBX project edits (`PBXFileSystemSynchronizedRootGroup` picks +up edits to existing files), no schema/version migration. diff --git a/.pi/orksorksorks/alanvardy-var-1101-add-toggle-for-watch/questions.md b/.pi/orksorksorks/alanvardy-var-1101-add-toggle-for-watch/questions.md new file mode 100644 index 0000000..12c3dbc --- /dev/null +++ b/.pi/orksorksorks/alanvardy-var-1101-add-toggle-for-watch/questions.md @@ -0,0 +1,51 @@ +# Research Questions + +## Context + +The codebase is an iOS app (CheckStitch) that creates Reminders checklists from +user-managed lists. Checklist/list-item/folder models live in a local SPM +package (CheckStitchCore) and are versioned + encoded into an envelope that +syncs through iCloud (NSUbiquitousKeyValueStore) and to a watchOS app +(CheckStitchWatch) via WatchConnectivity. Focus on: the versioned envelope +codec and its field-decoding conventions, the iOS edit-checklist forms and how +their controls mutate the store, and how checklist/folder/item data flows to +and renders in the watch app. + +## Questions + +1. How does the versioned `ChecklistEnvelope` codec store boolean fields on + `ChecklistItem`, `Checklist`, and `Folder`, including their decode defaults + when a key is absent from older data, and under what conditions does the + codec bump `currentVersion` versus rely on `decodeIfPresent` defaults / + `migrated(at:)`? (codebase-analyzer) + +2. What patterns exist in the iOS edit-checklist screens for boolean toggle + controls, and how do those controls bind to and invoke store mutation + methods? Trace the full path for an existing `Toggle` (e.g. the Number + Reminders toggle) from view → binding → store method → persisted field. + (codebase-pattern-finder) + +3. How does data flow from the phone to the watch layer, and how do the watch + views (main list, folder detail, checklist detail) resolve and render + checklists, folders, and items from the received envelope? Where does a + per-checklist boolean flag become visible to the watch rendering layer, and + where are items filtered into a displayed list? (codebase-analyzer) + +4. How is folder membership modeled and resolved (e.g. `folderID` on a + Checklist), and how do the watch views enumerate a folder's child checklists + for the folder detail view? How would the rendering layer know the set of + checklists that belong to a given folder when deciding what that folder + shows? (codebase-analyzer) + +5. What is the convention for adding a new user-facing string key across the + 6-language String Catalog(s) and the `LocalizationFixtures.requiredKeys` + test fixture — which catalogs exist, which one a given UI string belongs to, + and what the language/key check enforces? (codebase-pattern-finder) + +6. How are the data-model codec decode-default and version behaviors, and the + view-level toggle behaviors, covered by tests — which test files exist, what + cases they cover, and how platform gating (`#if os(...)`, `@MainActor`) is + applied? (codebase-pattern-finder) + +For each question: Describe what exists. Do not suggest improvements or propose +solutions. diff --git a/.pi/orksorksorks/alanvardy-var-1101-add-toggle-for-watch/research.md b/.pi/orksorksorks/alanvardy-var-1101-add-toggle-for-watch/research.md new file mode 100644 index 0000000..b2862ad --- /dev/null +++ b/.pi/orksorksorks/alanvardy-var-1101-add-toggle-for-watch/research.md @@ -0,0 +1,80 @@ +# Research Findings + +## Q1: How does the versioned `ChecklistEnvelope` codec store boolean fields, their decode defaults, and when does it bump `currentVersion`? + +### Findings +- **No stored boolean on `ChecklistItem`** — `ChecklistItem.hasDescription` and `.isBlank` are computed predicates (`Checklist.swift:66-75`), not CodingKeys. The only stored booleans in the model are on `Checklist` and `Folder`. (`CheckStitchCore/Sources/CheckStitchCore/Checklist.swift`) +- **`Checklist.prefixesReminderNumbers: Bool`** — default `false` (`Checklist.swift:222`); CodingKey `:234`; decode `decodeIfPresent(Bool.self, ...) ?? false` (`:254-255`, comment: absent in v4-and-earlier payloads decodes to `false`, no version bump); encode **unconditionally** writes the key (`:269`). Shares the checklist's coarse `revision`/`modifiedAt` clock (`:225-231`). +- **`Folder.isCollapsed: Bool`** — default `false` (`Checklist.swift:284`); CodingKey `:294`; decode `decodeIfPresent ?? false` with the same "absent in pre-collapse payloads, no version bump" comment (`:300-301`); encode unconditional (`:306`). Shares folder coarse clock (`:286-290`). +- Envelope-level arrays use the same additive pattern: `deviceID ?? ""`, `checklists`/`tombstones ?? []`, `folders`/`folderTombstones ?? []` (`:454-460`); encode writes all keys unconditionally (`:462-471`). +- **`currentVersion = 5`** (`Checklist.swift:465`). `classify` (`:483-521`) probes only the `version` key via `VersionProbe`; `case currentVersion` → `.loaded(JSONDecoder(...))` (`:490-492`); `case 4/3/2/1` → `.migratable(from:, envelope:)` decoding the **whole envelope verbatim** (`:493-517`); `default` → `.unsupportedVersion` (`:518-520`); any throw → `.unreadable` (`:521-523`). +- **Version-bump rule**: the envelope bumps `currentVersion` only when the wire shape or a closed domain changes so an absent key / `decodeIfPresent` default can no longer represent it. Additive boolean/optional fields land as `decodeIfPresent ?? default` with **no** bump. Closed domain warning for `priority` enum (`:135-139`). +- **`migrated(at:)` / `seededOrder()`** fire only for old-version (`migratable`) payloads to fabricate sync identity v1/v2 lack — not for the absent-boolean case. `Checklist.swift:326-365` (restamp), `:369-382` (seed ordering only). Consumers: `ChecklistStore.swift:80-91` (`case 1 → migrated(at:)`, `case 2 → seededOrder()`, v3+ verbatim; `canOverwriteStoredPayload = true` always for migratable `:89`, `false` only for `.unsupportedVersion` `:96`); `ChecklistSyncService.swift:129-145` (v1 → `migrated(at: .distantPast)`, v2 → `seededOrder()`). + +## Q2: What toggle patterns exist in the iOS edit-checklist screens, and how do they bind to the store? + +### Findings +- The edit-checklist screen is `ChecklistDetailView.swift`. **Number Reminders** `Toggle` at `ChecklistDetailView.swift:70`: `Toggle(isOn: numberingBinding(checklistID:)) { Label("Number Reminders", systemImage: "textformat.123") }` in a Form/Section, `.accessibilityIdentifier("checklistPrefixNumbersToggle")` (`:73`), behavior footer (`:75`). +- View is keyed by id, not a parent binding: `let checklistID: UUID` (`ChecklistDetailView.swift:3-4`), `@Environment(ChecklistStore.self)` (`:6`), reads via `store.checklist(id:)` (`:53`). +- **Binding pattern**: `numberingBinding(checklistID:)` (`ChecklistDetailView.swift:256-261`) returns `Binding(get: { store.checklist(id:)?.prefixesReminderNumbers ?? false }, set: { store.setPrefixesReminderNumbers($0, for: checklistID) })`. Doc comment (`:253-255`) notes the getter re-reads the store so a synced value updates the toggle. Mirrors `destinationBinding` (`:263-281`). +- **Store mutation method**: `setPrefixesReminderNumbers(_ enabled: Bool, for id:) -> SetDestinationOutcome` (`ChecklistStore.swift:277-283`): guards `notFound`, no-ops if unchanged, mutates the field, bumps `revision += 1` and `modifiedAt = now()`, calls `scheduleSave()`, returns `.updated`. +- Consumption (not part of the toggle): `ChecklistReminders.swift:33,58` reads `prefixesReminderNumbers`. +- Other booleans use `@State`/`$` view-local bindings instead: `InterfaceSettingsView.swift:53` (`$allowsLandscape`, `allowLandscapeToggle`), `BackgroundSettingsView.swift:11,36` — these differ from the store-backed edit-checklist toggle. + +## Q3: How does data flow from phone to watch and how do the watch views resolve/render it? + +### Findings +- Transport: `ChecklistSyncMessage.context(Data)` is phone→watch carrying the whole serialized `ChecklistEnvelope` (`ChecklistSync.swift:102-121`); the pocket key is `ChecklistSyncKey.context = "checklists"` (`:6`). +- **Phone push**: `PhoneSyncAdapter.sendContext` → `session.updateApplicationContext` ("latest state wins", `PhoneSyncAdapter.swift:30`). The watch never pushes context (`WatchSyncAdapter.swift:26-27` returns `false`). +- **Watch receipt** (`WatchSyncAdapter` WCSessionDelegate): cold launch reads `receivedApplicationContext` on activation (`:39-46`); live `didReceiveApplicationContext` → `receive(..., source: "context")` (`:64-65`); `didReceiveUserInfo` → `"userInfo"` (`:71-72`); `receive` decodes the message and routes to `onMessage` (`:78-83`). +- **Store ingest**: `WatchChecklistStore.start()` sets `onMessage = receive`; `.context` classifies `.loaded` or `.migratable(from: >=2)` → sets `checklists` and `folders` `@Observable` arrays (`ChecklistSync.swift:346-359`, arrays at `:265,:267`). Decodes the whole envelope; absent keys fall to the `decodeIfPresent` defaults. +- **ViewModel resolution** (`WatchChecklistViewModel.swift`): `checklists`/`folders` passthroughs (`:16,:19`); `looseChecklists` via `ChecklistGrouping.isLoose` (`:25-29`); `checklists(in:)` = filter `folderID == folder.id` (`:34-36`); `current(_:)` returns the live store copy (`:49-52`); `visibleItems(of:)` filters `!item.isBlank` (`:61-63`). +- **Views**: main list `WatchChecklistListView.swift:13-27` (folder rows → `WatchFolderDetailView`, loose rows → `WatchChecklistDetailView`); folder detail `WatchFolderDetailView.swift:13-15,24` (`List(members)`); checklist detail `WatchChecklistDetailView.swift:28-29,65` (`List(visibleItems)`). +- **Where a per-checklist boolean becomes visible**: `Checklist` travels whole through the envelope (`ChecklistSync.swift:352` holds complete values), so **any new per-checklist boolean on `Checklist` is immediately visible to all three watch views** via `current(_:)` and `store.checklists`. The existing analogous boolean `prefixesReminderNumbers` (`Checklist.swift:160-162`) demonstrates this. +- Two existing filters: folder membership (`folderID == folder.id`, `WatchChecklistViewModel.swift:35`) and item visibility (`!isBlank`, `:62`). + +## Q4: How is folder membership modeled and resolved, and how does the watch enumerate a folder's children? + +### Findings +- **One-field relationship**: `Checklist.folderID: UUID?` (`Checklist.swift:186`, "a one-field relationship... decided by the same last-write-wins rule"). Init `:150,:159`. Codec: CodingKey `:197`; decode `decodeIfPresent(UUID.self, forKey: .folderID)` (`:212`); encode value or `encodeNil` (`:239-242`, "nil is encoded, never dropped"). +- `Folder` is a separate model with its own `id` UUID (`Checklist.swift:342`, id at `public let id: UUID`). The envelope carries complete `[Checklist]` + `[Folder]` + tombstone arrays (`:407-409`). +- **Membership is derived, never stored on the Folder** — resolved by matching `$0.folderID == folder.id`: + - Watch view model: `checklists(in:)` at `WatchChecklistViewModel.swift:40-42`; loose handling `:18-22` via `ChecklistGrouping.isLoose` with known folder IDs (`ChecklistGrouping.isLoose(folderID == nil OR unknown id)`, `Checklist.swift:551-556`); `current(_ folder:)` `:45-47`. + - Core helper: `ChecklistGrouping.sections(folders:checklists:)` builds `byFolder` and a loose section (`Checklist.swift:559-571`). +- **Root list**: `WatchChecklistListView.swift` renders `viewModel.folders` as NavigationLink rows (`:20-24`) pushing `WatchFolderDetailView`, and `viewModel.looseChecklists` below (`:32-35`). +- **Folder detail**: `WatchFolderDetailView.swift` `members = viewModel.checklists(in: current)` (`:17-19`), renders `List(members)` (`:23-29`) with a "No checklists" `ContentUnavailableView` when empty. +- **Implication for folder hiding**: the rendering layer knows a folder's children purely by filtering the synchronised in-memory `store.checklists` on `folderID == folder.id`; folders own no child pointer. A "hide folder if all members hidden" rule would compute over that derived child set. + +## Q5: What is the convention for adding a user-facing string key? + +### Findings +- **Three catalogs**, mapped to targets, centralised in two synced places: + - `scripts/l10n-check.sh:15-19` `CATALOGS = { App: CheckStitch/Localizable.xcstrings, Core: CheckStitchCore/Sources/CheckStitchCore/Resources/Localizable.xcstrings, Watch: CheckStitchWatch/Localizable.xcstrings }` + - `LocalizationFixtures.swift:8` `guardedCatalogs = ["App","Core","Watch"]` +- **Which catalog**: iOS app views → **App**; code shared by Core (used by phone + watch) → **Core**; watch views → **Watch**. +- **AGENTS.md:116** codifies: a new key needs all 6 languages + a `LocalizationFixtures.requiredKeys` entry; run `scripts/l10n-check.sh` first. +- **Catalog shape**: `sourceLanguage: "en"`, a `strings` map key → `{ extractionState: "manual", localizations: { en,de,es,fr,ja,zh-Hans each { stringUnit: { state: translated, value } } } }`. +- **Test fixture**: `LocalizationFixtures.requiredKeys` (`LocalizationFixtures.swift:12`) groups keys by catalog, alphabetically; Watch list at `:140-145`. +- **Enforcers**: Swift `LocalizationTests.swift` — `catalogsParseAndHaveNonEmptyEnglish` (`:10`), `catalogsHaveAllSixLanguages` (`:26`), `everyRequiredKeyIsPresent` (`:39`), `watchCatalogCarriesEveryUIKey` (`:48`), `nonEnglishValuesDifferFromEnglish` (`:57`, honours `excludedIdentities` fixture `:166-183`), `coreCatalogValuesAreEmbeddedInTheResourceBundle` (`:74`). Shell mirror `scripts/l10n-check.sh` (6 langs `:10`, `NON_EN` `:11`, per-key six-lang check `:22-26`, differs-from-English unless excluded). + +## Q6: How are codec decode/version and view toggle behaviors tested? Platform gating? + +### Findings +- **`ChecklistCodecTests.swift`** (`@MainActor :5`): `testDecodesV5PayloadWithoutPrefixesReminderNumbersAsFalse` (`:167`, absent key → `false`, additive guarantee); `testPrefixesReminderNumbersSurvivesEnvelopeRoundTrip` (`:178`); description round trips (`:194,:208`); priority absent → `.none` (`:223`) / malformed → `.unreadable` (`:241`); version classification `:29,:43,:51,:61,:98,:334,:369,:309`; field-clock seeding `:288`; `PinnedV4Codec`/`PinnedV5Codec` pin the v4/v5 `classify` switches (`:399`). +- **`ChecklistStoreTests.swift`**: version 99 rejected (`:147`); v1 restamp (`:321`); v2 load+seed (`:416`); `setPrefixesReminderNumbers` suite `:1840-1882` — updates revision+persists (`:1840`), unchanged is no-op (`:1858`), unknown-id `.notFound` (`:1877`); version-4 absent-keys payload (`:2221`). +- **`ChecklistMergeTests.swift`**: numbering toggle newest-editor-wins (`:756,:771`). +- **Watch store**: `WatchChecklistStoreTests.swift` current-version and version-2 decode paths (`:22,:36,:51`). +- **No edit-checklist/`numberReminders` view test file exists** — the checklist-detail view is covered by `ChecklistDetailViewTests.swift` (`@MainActor :13`, `#if os(macOS)` gates at `:72,:164,:179,:238,:260,:276`). +- **Platform gating / actor isolation**: whole-file `#if os(macOS)` (`MacWindowFrameTests.swift:1`, `ColorCrossPlatformTests.swift:5`); per-region `#if os` in many view suites. `@MainActor` opt-in is near-universal (codec/sync/store suites). UI smoke `CheckStitchUITests.swift` `#if os(iOS)` (`:34`), XCTest (`:1`), `runsForEachTargetApplicationUIConfiguration=false` (`:8`). + +## Cross-Cutting Observations +- **Additive per-checklist boolean precedent is `prefixesReminderNumbers`**: a new `Checklist` boolean rides `decodeIfPresent ?? default`, no `currentVersion` bump, encode unconditional, and shares the checklist coarse `revision`/`modifiedAt` clock. The watch sees it automatically because `Checklist` travels whole. +- **All existing stored booleans default to `false`** (`prefixesReminderNumbers`, `isCollapsed`). A default-enabled flag is a new convention here but still a valid `decodeIfPresent ?? true` additive field. +- **Store mutation contract** is uniform: `set(_ value:, for id:) -> SetDestinationOutcome` (guard notFound → no-op if unchanged → mutate → bump `revision`/`modifiedAt` → `scheduleSave()`). +- **Folder membership is always derived from `folderID`**; folder-hiding must be computed over `store.checklists` / `viewModel.checklists(in:)`, never a folder-side pointer. +- **Localization** for this feature's string lands in the App catalog (`CheckStitch/Localizable.xcstrings`) with a `requiredKeys` entry + six translations. + +## Open Areas +- **Store `scheduleSave()` persistence internals** were not fully traced (shared by all store mutations, `ChecklistStore.swift:282`); relevant only insofar as the new field's save is identical to existing ones. +- **Default-true semantics**: every existing stored boolean defaults to `false`, so confirming the decode-default and codec-round-trip tests for a `default true` additive field (and any merge newest-editor-wins expectations) is design/test work beyond this pass. +- Exact watch view-model shape for computing "folder all-members-hidden" is an implementation matter; the derivation primitives (`checklists(in:)`, `store.checklists`) exist. diff --git a/.pi/orksorksorks/alanvardy-var-1101-add-toggle-for-watch/structure.md b/.pi/orksorksorks/alanvardy-var-1101-add-toggle-for-watch/structure.md new file mode 100644 index 0000000..7c955ae --- /dev/null +++ b/.pi/orksorksorks/alanvardy-var-1101-add-toggle-for-watch/structure.md @@ -0,0 +1,143 @@ +# Structure Outline + +## Approach + +Add one additive stored boolean, `Checklist.showsOnWatch` (default `true`, +`decodeIfPresent ?? true`, **no** `currentVersion` bump), mutate it through a +store method shaped exactly like `setPrefixesReminderNumbers`, bind it to a +store-backed "Show on watch" toggle in `ChecklistDetailView`, and honour it by +filtering already-derived collections in `WatchChecklistViewModel` so the +watch's existing views render less. No transport, merge-clock, or migration +work: the whole `Checklist` already travels in the envelope. + +No horizontal phase is needed — the codec change is additive (no schema +migration, no version bump, no `migrated(at:)` case). + +--- + +## Phase 1: Walking skeleton — toggle on iOS hides a loose checklist on the watch + +Turning off "Show on watch" on the iOS edit-checklist screen persists the flag +through the synced envelope, and that checklist disappears from the watch main +list. Proves the full path: toggle → store → codec → envelope → watch store → +watch view model → watch list. + +**Files**: `CheckStitchCore/Sources/CheckStitchCore/Checklist.swift`, +`CheckStitch/ChecklistStore.swift`, `CheckStitch/ChecklistDetailView.swift`, +`CheckStitch/Localizable.xcstrings`, `CheckStitchTests/LocalizationFixtures.swift`, +`CheckStitchWatch/WatchChecklistViewModel.swift`, +`CheckStitchWatch/WatchChecklistListView.swift`, +(+ tests `CheckStitchTests/ChecklistCodecTests.swift`, +`CheckStitchTests/ChecklistStoreTests.swift`, +`CheckStitchTests/ChecklistDetailViewTests.swift`, +new `CheckStitchTests/WatchChecklistViewModelTests.swift`) + +**Key changes**: +- `Checklist.showsOnWatch: Bool = true` — new stored property + `CodingKeys` + case; `decodeIfPresent(Bool.self, forKey: .showsOnWatch) ?? true` with the + "absent in v5-and-earlier payloads decodes to shown" comment; unconditional + encode. `currentVersion` stays `5`. +- `ChecklistStore.setShowsOnWatch(_ enabled: Bool, for id: UUID) -> SetDestinationOutcome` + — guard `.notFound` → no-op when unchanged → mutate → `revision += 1` / + `modifiedAt = now()` → `scheduleSave()` → `.updated`. +- `ChecklistDetailView.showOnWatchBinding(checklistID: UUID) -> Binding` + — getter re-reads `store.checklist(id:)?.showsOnWatch ?? true`, setter calls + the store method; `Toggle` with `Label(..., systemImage:)` + + `.accessibilityIdentifier("checklistShowsOnWatchToggle")` + behavior footer. +- `WatchChecklistViewModel.looseChecklists: [Checklist]` — now + `ChecklistGrouping.isLoose` **and** `showsOnWatch`. +- App-catalog `"Show on watch"` key (six languages, `extractionState: "manual"`) + + alphabetically-placed `LocalizationFixtures.requiredKeys` App entry. + +**Contract**: `Checklist.showsOnWatch` (Bool, default-true, additive, v5 wire +shape) is the flag every later slice reads; `setShowsOnWatch(_:for:)` is the +only writer; `WatchChecklistViewModel` is the only watch-side filter owner — +views never filter. + +**Tests**: +- `ChecklistCodecTests`: `payloadWithoutShowsOnWatchDecodesAsShown` (absent key → + `true`), `showsOnWatchSurvivesEnvelopeRoundTrip` (both values) — happy + sad. +- `ChecklistStoreTests`: `setShowsOnWatchUpdatesRevisionAndPersists`, + `setShowsOnWatchUnchangedIsNoOp`, `setShowsOnWatchUnknownIDIsNotFound`. +- `WatchChecklistViewModelTests` (new): `looseChecklistsExcludeHiddenChecklists`. +- `ChecklistDetailViewTests` (`#if os(macOS)` region): `showsOnWatchToggleDefaultsOn`, + `togglingShowsOnWatchPersists`. +- Localization: `everyRequiredKeyIsPresent`, `catalogsHaveAllSixLanguages`, + `nonEnglishValuesDifferFromEnglish`. + +**Verify**: `make test-unit` (all of the above green) → `make watch-build` +(watch layer compiles) → `scripts/l10n-check.sh` prints ok. + +--- + +## Phase 2: Folder semantics — folder detail and folder rows honour hiding + +A folder's detail list shows only shown checklists, and a folder row disappears +from the watch main list when it has ≥1 member and every member is hidden. A +folder with zero members still appears. Independently valuable on top of +Phase 1's wire contract. + +**Files**: `CheckStitchWatch/WatchChecklistViewModel.swift`, +`CheckStitchWatch/WatchChecklistListView.swift`, +`CheckStitchWatch/WatchFolderDetailView.swift`, +`CheckStitchTests/WatchChecklistViewModelTests.swift` + +**Key changes**: +- `WatchChecklistViewModel.checklists(in folder: Folder) -> [Checklist]` — now + also requires `showsOnWatch` (folder detail shrinks for free). +- `WatchChecklistViewModel.visibleFolders: [Folder]` — new derivation over the + same `folderID == folder.id` child set: keep when members is empty **or** any + member `showsOnWatch`; drop when members non-empty and all hidden. +- `WatchChecklistListView` renders `viewModel.visibleFolders` (no filter inline); + `WatchFolderDetailView` keeps rendering `checklists(in:)` unchanged. + +**Contract**: `visibleFolders` / filtered `checklists(in:)` are the folder +semantics; membership stays derived from `folderID` — no folder-side child list +is added. + +**Tests** (`WatchChecklistViewModelTests`): `folderDetailExcludesHiddenChecklists`, +`folderWithSomeVisibleMembersStaysVisible`, +`folderWithEveryMemberHiddenIsHidden`, `emptyFolderStaysVisible`. + +**Verify**: `make test-unit` (view-model suite + no regressions in +`ChecklistGroupingTests`) → `make watch-build`. + +--- + +## Phase 3: Hardening — sync conflicts, edge cases, and on-watch verification + +The default-`true` field survives a `true`/`false` concurrent edit, hidden +checklists whose folder is unknown/deleted stay hidden rather than reappearing +in the loose list, an older watch client ignores the new key without failing to +decode, and hide/show is verified on the installed watch bundle. + +**Files**: `CheckStitchTests/ChecklistMergeTests.swift`, +`CheckStitchTests/ChecklistCodecTests.swift`, +`CheckStitchTests/WatchChecklistViewModelTests.swift` + +**Key changes** (tests only — no new production surface expected; any fix this +slice forces stays inside the Phase 1/2 contracts): +- Merge: assert newest-editor-wins for a `showsOnWatch` conflict, mirroring the + `prefixesReminderNumbers` merge cases. +- Codec: assert a payload carrying an unrecognised key still decodes (older + client tolerance). +- View model: `hiddenChecklistWithUnknownFolderIsNotShownAsLoose`, + `hiddenChecklistWithTombstonedFolderIsNotShown`. + +**Contract**: none new — this slice only pins behaviours later changes must not +break. + +**Tests**: the four cases above. +**Verify**: `make test-unit`; then full `bash scripts/test.sh` (`gate: ok`, +warnings-as-errors included); then **live**: `bash scripts/run-watch.sh`, toggle +"Show on watch" off on the phone and confirm the row is absent on the installed +watch bundle (and returns when re-enabled) — per `AGENTS.md`, static evidence +cannot close this ticket. + +--- + +## Testing Checkpoints + +- After Phase 1: `make test-unit` + `make watch-build` + `scripts/l10n-check.sh` green; absent-key default-`true` and round-trip pinned. +- After Phase 2: `make test-unit` + `make watch-build` green; all four folder-derivation cases pinned. +- After Phase 3: `bash scripts/test.sh` prints `gate: ok`, plus the on-device watch check. \ No newline at end of file diff --git a/.pi/orksorksorks/alanvardy-var-1101-add-toggle-for-watch/task.md b/.pi/orksorksorks/alanvardy-var-1101-add-toggle-for-watch/task.md new file mode 100644 index 0000000..5e02a27 --- /dev/null +++ b/.pi/orksorksorks/alanvardy-var-1101-add-toggle-for-watch/task.md @@ -0,0 +1,12 @@ +# Task + +Add a per-checklist "Show on watch" toggle to the edit-checklist screen(s) of +the CheckStitch iOS app. The toggle is labelled "Show on watch", defaults to +enabled, and when disabled hides that checklist's items on the Apple Watch; a +folder is hidden on the watch when every checklist in it is hidden. + +The flag must be persisted in the versioned, iCloud-synced +`ChecklistEnvelope` codec (where absent-key decode-defaults and version-bump +policy are shared, owned conventions), surfaced through the iOS edit-checklist +UI, and honoured by the watch layer when rendering the main list and the folder +detail view. From 475e14c04e8f6072fb90e92142e4d7b4a99d7f31 Mon Sep 17 00:00:00 2001 From: Alan Vardy Date: Sun, 27 Sep 2026 06:55:29 -0700 Subject: [PATCH 7/8] fix: show the watch empty state whenever nothing is visible --- CheckStitchWatch/WatchChecklistListView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CheckStitchWatch/WatchChecklistListView.swift b/CheckStitchWatch/WatchChecklistListView.swift index ecfc468..c45b5da 100644 --- a/CheckStitchWatch/WatchChecklistListView.swift +++ b/CheckStitchWatch/WatchChecklistListView.swift @@ -7,7 +7,7 @@ struct WatchChecklistListView: View { var body: some View { NavigationStack { Group { - if viewModel.checklists.isEmpty { + if viewModel.visibleFolders.isEmpty && viewModel.looseChecklists.isEmpty { ContentUnavailableView( "No checklists", systemImage: "checklist", From c86f68a3e6523ecbdfb80aaebbe3c64949f427b9 Mon Sep 17 00:00:00 2001 From: Alan Vardy Date: Sun, 27 Sep 2026 06:57:48 -0700 Subject: [PATCH 8/8] chore: add review done marker for alanvardy-var-1101-add-toggle-for-watch --- .../done.md | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .pi/orksorksorks/alanvardy-var-1101-add-toggle-for-watch/done.md diff --git a/.pi/orksorksorks/alanvardy-var-1101-add-toggle-for-watch/done.md b/.pi/orksorksorks/alanvardy-var-1101-add-toggle-for-watch/done.md new file mode 100644 index 0000000..85468a8 --- /dev/null +++ b/.pi/orksorksorks/alanvardy-var-1101-add-toggle-for-watch/done.md @@ -0,0 +1,44 @@ +# Done + +- **Branch / head SHA**: `alanvardy-var-1101-add-toggle-for-watch` @ `95254be` + (pre-review head `7db2bae`; rebase against `origin/main` had no conflicts, + tree was clean and already pushed). +- **Mechanical checks**: `./scripts/test.sh` → `gate: ok` (`GATE_EXIT=0`), + run twice — once on the reviewed head and once after the applied fix. The + gate covers `make build` (simulator), headless pre-boot of this worktree's + simulator, `make test`, `make build-mac`, `make watch-build`, + `scripts/tests/run.sh` (warnings-as-errors pinned per leg) and + `shellcheck`. No warnings flagged. +- **Review outcome**: + - One bounded `reviewer` (fresh context) reviewed the full source diff plus + the cited files; parent did an independent narrow scan. **No blockers.** + Verified sound: default-`true` decode only on an absent key and a decoded + `false` survives the self-heal + round trip; winner-only + `showsOnWatch` copy in `ChecklistMerge`; no-op-on-unchanged store setter + with no spurious revision bump; `visibleFolders`/`visibleLooseChecklists`/ + `visibleChecklists` semantics for unknown/tombstoned folder ids and empty + folders; both new localization keys present in all 6 languages and in + `LocalizationFixtures.requiredKeys`. + - **Fixes applied**: `WatchChecklistListView.swift` empty-state gate changed + from `viewModel.checklists.isEmpty` to `viewModel.visibleFolders.isEmpty && + viewModel.looseChecklists.isEmpty`, so hiding every checklist shows the + `ContentUnavailableView` instead of a blank `List`. Committed as + `95254be` and pushed (`--force-with-lease`). + - **Optional improvements**: none suggested beyond the above. +- **Remaining manual items** (required — static evidence cannot close this + sync/hide ticket, per `AGENTS.md`): the paired-Apple-Watch pass from + `plan.md` is **not** done. + - [ ] `make run`: open a checklist's detail screen — "Show on watch" appears + in its own section, default **on**, with the footer text; toggling it + off and re-entering the screen keeps it off; toggling back on flips it. + - [ ] `bash scripts/run-watch.sh`: with the watch app open, toggle + "Show on watch" **off** for a loose checklist — the row disappears from + the watch main list; toggle back **on** — the row returns. + - [ ] Put two hidden checklists in one folder — the folder row disappears + from the watch root; re-enabling one member brings the folder back. + - [ ] Leave an empty folder — its row stays on the watch root. + - [ ] With every checklist hidden, the watch root shows "No checklists / + Open CheckStitch on your iPhone." (the applied fix). + - What the user should see on the watch: only checklists whose iPhone + "Show on watch" toggle is on; a folder whose members are all hidden + vanishes, an empty folder stays; iOS lists are unchanged.