From d99e14ae5f15cfe6748038f20d2002da8c352303 Mon Sep 17 00:00:00 2001 From: Considus Date: Thu, 23 Jul 2026 20:01:28 +0100 Subject: [PATCH] Re-wire the Spotlight deep-link reveal onto the UIKit timeline (Task 6.19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The M7 rewrite left ui.spotlightTargetTakeID dangling: only the pinned-Obie row path still read it (flash only, never cleared), the collection never consumed it, and no scroll-to-item existed — a Spotlight tap opened the app and visibly did nothing. - UIKitTimelineViewController gains requestReveal/attemptReveal: scroll the target row to centre, pulse the card ember (flashingID -> reconfigure -> cell overlay, 0.4s ease both edges), then fire onRevealHandled so the host clears the one-shot state. A reveal whose row isn't in the snapshot yet stays pending and resolves on a later apply. - TimelineReadCell/TimelineSwipeCell thread isSpotlightTarget; the overlay is render-only (same ember 0.18 treatment as the old row flash). - DailiesView passes the target (filtering out the pinned Obie, which is not a collection row) and clears the Obie case itself after its rowContent flash pulses. - handleSpotlight: the existence check only runs while UNLOCKED — a cold Spotlight tap lands on the locked app where the store is the empty placeholder, and the old guard silently dropped the target, so the most common path (tap -> Face ID -> timeline) never navigated. The pending reveal now resolves after unlock; a deleted Take's reveal simply never fires. Needs device review: scroll landing position, pulse visibility both modes, and the locked -> unlock -> reveal flow. Co-Authored-By: Claude Fable 5 --- Catchlight/App/CatchlightApp.swift | 22 +++-- .../UIKitTimeline/UIKitTimeline.swift | 95 ++++++++++++++++++- Catchlight/UI/ViewModels/UIState.swift | 8 +- Catchlight/UI/Views/DailiesView.swift | 19 +++- 4 files changed, 131 insertions(+), 13 deletions(-) diff --git a/Catchlight/App/CatchlightApp.swift b/Catchlight/App/CatchlightApp.swift index d85f8e0..ded6ad4 100644 --- a/Catchlight/App/CatchlightApp.swift +++ b/Catchlight/App/CatchlightApp.swift @@ -142,14 +142,22 @@ struct CatchlightApp: App { // Return the dock to RESTING (clearing any live filter) BEFORE setting // the target, so the row is guaranteed visible on the unfiltered - // timeline. DailiesView reads `ui.spotlightTargetTakeID` to - // scroll-and-flash. We deliberately do not open the editor here — - // editing is gated for lapsed users, and a Spotlight tap shouldn't - // surface the paywall. + // timeline. The UIKit timeline consumes `ui.spotlightTargetTakeID` + // (scroll + ember pulse, then it clears the state); the pinned Obie is + // handled by DailiesView directly. We deliberately do not open the + // editor here — editing is gated for lapsed users, and a Spotlight tap + // shouldn't surface the paywall. app.ui.exitToResting() - // Verify the Take still exists; fail silently if not. - let allTakes = (try? app.dailiesVM.store.allTakes()) ?? [] - guard allTakes.contains(where: { $0.id == uuid }) else { return } + // Verify the Take still exists; fail silently if not. Only checkable + // while UNLOCKED — a cold Spotlight tap lands on the LOCKED app, where + // the store is still the empty placeholder (D-042). Set the target + // anyway in that case: the timeline's reveal stays pending until the + // row appears after unlock, and never fires for a Take that's gone + // (Spotlight may surface a deleted item before its deindex propagates). + if app.lockState == .unlocked { + let allTakes = (try? app.dailiesVM.store.allTakes()) ?? [] + guard allTakes.contains(where: { $0.id == uuid }) else { return } + } app.ui.spotlightTargetTakeID = uuid } diff --git a/Catchlight/UI/Components/UIKitTimeline/UIKitTimeline.swift b/Catchlight/UI/Components/UIKitTimeline/UIKitTimeline.swift index f887515..b3e7a7e 100644 --- a/Catchlight/UI/Components/UIKitTimeline/UIKitTimeline.swift +++ b/Catchlight/UI/Components/UIKitTimeline/UIKitTimeline.swift @@ -65,6 +65,10 @@ struct UIKitTimeline: UIViewControllerRepresentable { /// catcher behind. This avoids fighting the representable's compositing (the collection /// otherwise sits above the SwiftUI veil for hit-testing — tap-between-Takes was dead). var isEditing: Bool = false + /// Task 6.19 — the Spotlight deep-link target (nil when none). The VC scrolls the row + /// into view, pulses it, then fires `onRevealHandled` so the host clears the state. + var revealTargetID: UUID? = nil + var onRevealHandled: () -> Void = {} func makeUIViewController(context: Context) -> UIKitTimelineViewController { let vc = UIKitTimelineViewController() @@ -84,6 +88,7 @@ struct UIKitTimeline: UIViewControllerRepresentable { vc.onExport = onExport vc.onTapText = onTapText vc.onTapBackground = onTapBackground + vc.onRevealHandled = onRevealHandled return vc } @@ -104,8 +109,12 @@ struct UIKitTimeline: UIViewControllerRepresentable { vc.onExport = onExport vc.onTapText = onTapText vc.onTapBackground = onTapBackground + vc.onRevealHandled = onRevealHandled vc.apply(groups: groups) vc.updateEditing(isEditing) + // After apply, so a target set while the data was still loading (e.g. a + // Spotlight tap on the locked app) can resolve against the fresh rows. + vc.requestReveal(revealTargetID) } } @@ -140,6 +149,10 @@ struct TimelineReadCell: View { var onExport: (Take) -> Void = { _ in } /// Tap the card → begin edit-in-place (M4.1), or commit an open edit of another Take. var onTapText: (Take) -> Void = { _ in } + /// Task 6.19 — true while this row is the Spotlight deep-link target's pulse + /// window (driven by the VC's `flashingID` via reconfigure). The card overlays + /// a brief ember tint; `.animation(value:)` fades both edges. + var isSpotlightTarget: Bool = false @Environment(\.colorScheme) private var scheme private let inset = CatchlightLayout.cardSpineInset @@ -150,6 +163,16 @@ struct TimelineReadCell: View { var body: some View { ZStack(alignment: .topLeading) { TakeCardSurface(take: take, isSnoozed: isSnoozed, linksInteractive: false) // card + // Task 6.19 — brief flash when this row is the Spotlight deep-link + // target. The ember accent at low opacity reads as a gentle pulse, + // not a notification (same treatment as the pinned Obie's flash in + // DailiesView.rowContent). Render-only. + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.ckEmber.opacity(isSpotlightTarget ? 0.18 : 0)) + .animation(.easeInOut(duration: 0.4), value: isSpotlightTarget) + .allowsHitTesting(false) + ) .contentShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) .onTapGesture { onTapText(take) } .contextMenu { menuItems } @@ -285,6 +308,8 @@ struct TimelineSwipeCell: View { var onMakeObie: (Take) -> Void = { _ in } var onExport: (Take) -> Void = { _ in } var onTapText: (Take) -> Void = { _ in } + /// Threaded straight through to `TimelineReadCell` (see its `isSpotlightTarget`). + var isSpotlightTarget: Bool = false var body: some View { SwipeActionRow( @@ -309,7 +334,8 @@ struct TimelineSwipeCell: View { onTapCircle: onTapCircle, onLongPressCircle: onLongPressCircle, onToggleDone: onToggleDone, onDelete: onDelete, onSetImportant: onSetImportant, onMakeObie: onMakeObie, - onExport: onExport, onTapText: onTapText) + onExport: onExport, onTapText: onTapText, + isSpotlightTarget: isSpotlightTarget) .offset(x: offset) } } @@ -476,7 +502,8 @@ final class UIKitTimelineViewController: UIViewController, UIGestureRecognizerDe onExport: { self.onExport($0) }, onTapText: { [weak self] tapped in self?.onTapText(tapped) - }) + }, + isSpotlightTarget: self.flashingID == id) } .margins(.all, 0) case .month(let key): @@ -594,6 +621,70 @@ final class UIKitTimelineViewController: UIViewController, UIGestureRecognizerDe reconfigured.reconfigureItems(toApply) dataSource.apply(reconfigured, animatingDifferences: false) } + + // A reveal may be waiting for its row (Spotlight tap while LOCKED lands + // before the store opens) — this apply may just have delivered it. + attemptReveal() + } + + // MARK: - Spotlight deep-link reveal (Task 6.19, re-wired for the UIKit timeline 2026-07-23) + // + // The SwiftUI timeline's scroll-and-flash died in the M7 rewrite: the host set + // `ui.spotlightTargetTakeID` but nothing in this collection consumed it (only + // the pinned-Obie row path did, and nothing ever cleared it). The host now + // hands the target to `requestReveal`; this scrolls the row into view, pulses + // the card via `flashingID` → reconfigure → the cell's ember overlay, and + // fires `onRevealHandled` so the host clears the one-shot state. + + /// Fired (async) once a reveal has been actioned — the host clears + /// `ui.spotlightTargetTakeID` so a later re-tap of the same Take re-targets. + var onRevealHandled: () -> Void = {} + /// The row currently pulsing ember (read by the cell registration). + private var flashingID: UUID? + /// A reveal whose row is not in the snapshot yet. Held until `apply` delivers + /// it; deliberately never times out — for a Take that no longer exists + /// (Spotlight raced the deindex) it simply never fires. + private var pendingRevealID: UUID? + /// The last target accepted, so the host's repeated `updateUIViewController` + /// passes (the one-shot state clears asynchronously) don't re-trigger the + /// scroll. Reset when the host's target clears to nil. + private var lastRequestedRevealID: UUID? + + func requestReveal(_ id: UUID?) { + guard let id else { lastRequestedRevealID = nil; return } + guard id != lastRequestedRevealID else { return } + lastRequestedRevealID = id + pendingRevealID = id + attemptReveal() + } + + private func attemptReveal() { + guard let id = pendingRevealID, dataSource != nil, + let indexPath = dataSource.indexPath(for: .take(id)) else { return } + pendingRevealID = nil + collectionView.scrollToItem(at: indexPath, at: .centeredVertically, animated: true) + // Pulse once the scroll has settled, hold ~1s, fade out — the cell overlay's + // `.animation(value:)` animates both edges. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in + self?.setFlashing(id) + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in + self?.setFlashing(nil) + } + } + // Clear the host's one-shot target OUTSIDE the current SwiftUI update pass + // (attemptReveal can run inside updateUIViewController via apply). + DispatchQueue.main.async { [weak self] in self?.onRevealHandled() } + } + + /// Flip the pulse on/off by reconfiguring only the affected row(s). + private func setFlashing(_ id: UUID?) { + let affected = [flashingID, id].compactMap { $0 }.map { TimelineRow.take($0) } + flashingID = id + var snapshot = dataSource.snapshot() + let present = affected.filter { snapshot.itemIdentifiers.contains($0) } + guard !present.isEmpty else { return } + snapshot.reconfigureItems(present) + dataSource.apply(snapshot, animatingDifferences: false) } private var editingActive = false diff --git a/Catchlight/UI/ViewModels/UIState.swift b/Catchlight/UI/ViewModels/UIState.swift index a8ebbf4..813218b 100644 --- a/Catchlight/UI/ViewModels/UIState.swift +++ b/Catchlight/UI/ViewModels/UIState.swift @@ -157,9 +157,11 @@ final class UIState { var isStoryboardPresented = false /// Task 6.19 — Spotlight deep-link target. Set by the app's - /// `onContinueUserActivity` handler when a Take is tapped in Spotlight; - /// DailiesView reads this to scroll-and-flash the matching row. The - /// handler clears it after the highlight fires so a re-tap re-targets. + /// `onContinueUserActivity` handler when a Take is tapped in Spotlight. + /// Consumed one-shot (re-wired 2026-07-23 after the M7 rewrite left it + /// dangling): the UIKit timeline scrolls the row into view, pulses it, and + /// clears this via `onRevealHandled`; a target on the PINNED OBIE is pulsed + /// and cleared by DailiesView itself (the Obie is not a collection row). var spotlightTargetTakeID: UUID? // MARK: - Dock mode transitions diff --git a/Catchlight/UI/Views/DailiesView.swift b/Catchlight/UI/Views/DailiesView.swift index 5ea0789..8aa1f20 100644 --- a/Catchlight/UI/Views/DailiesView.swift +++ b/Catchlight/UI/Views/DailiesView.swift @@ -449,6 +449,17 @@ struct DailiesView: View { beginNewInlineEdit(take) ui.pendingInlineNewTake = nil } + // Spotlight deep-link to the PINNED OBIE (Task 6.19, re-wired 2026-07-23): + // the Obie is not a collection row, so the UIKit reveal path can't clear + // its flash — rowContent's equality-based background pulses it; clear the + // one-shot target here so the pulse ends and a re-tap re-targets. No + // scroll needed: the pinned Obie is always visible. + .onChange(of: ui.spotlightTargetTakeID) { _, target in + guard let target, target == vm.obie?.id else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { + if ui.spotlightTargetTakeID == target { ui.spotlightTargetTakeID = nil } + } + } // Inline Obie confirmation (owner 2026-06-17; re-homed to the editing long-press // menu 2026-07-06) — mirrors the timeline long-press warning, but targets the // draft (the existing Obie is demoted by the store on save). @@ -1021,7 +1032,13 @@ struct DailiesView: View { onTapBackground: { timelineBackgroundTap() }, // M4.6 — fade + disable the collection while editing (cards recede, taps fall // through to the save catcher). Covers both existing-edit and new-Take. - isEditing: ui.isEditingInPlace + isEditing: ui.isEditingInPlace, + // Task 6.19 (re-wired 2026-07-23): the Spotlight deep-link target. The + // pinned Obie never enters the collection — its flash rides rowContent's + // background and is cleared by the onChange below — so it's filtered out + // here rather than left pending in the VC forever. + revealTargetID: ui.spotlightTargetTakeID == vm.obie?.id ? nil : ui.spotlightTargetTakeID, + onRevealHandled: { ui.spotlightTargetTakeID = nil } ) }