Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 15 additions & 7 deletions Catchlight/App/CatchlightApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
95 changes: 93 additions & 2 deletions Catchlight/UI/Components/UIKitTimeline/UIKitTimeline.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -84,6 +88,7 @@ struct UIKitTimeline: UIViewControllerRepresentable {
vc.onExport = onExport
vc.onTapText = onTapText
vc.onTapBackground = onTapBackground
vc.onRevealHandled = onRevealHandled
return vc
}

Expand All @@ -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)
}
}

Expand Down Expand Up @@ -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
Expand All @@ -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 }
Expand Down Expand Up @@ -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(
Expand All @@ -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)
}
}
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions Catchlight/UI/ViewModels/UIState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 18 additions & 1 deletion Catchlight/UI/Views/DailiesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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 }
)
}

Expand Down
Loading