Skip to content

Add PhotoFramer app and Tuist project setup#2

Closed
kyleve wants to merge 3 commits into
mainfrom
kve/photo-framer-app
Closed

Add PhotoFramer app and Tuist project setup#2
kyleve wants to merge 3 commits into
mainfrom
kve/photo-framer-app

Conversation

@kyleve

@kyleve kyleve commented Mar 14, 2026

Copy link
Copy Markdown
Owner

Summary

  • Sets up Tuist project scaffolding: manifests (Project.swift, Tuist.swift), mise-pinned Tuist 4.40.0, ide dev script, GitHub Actions CI, and AGENTS.md
  • Adds mobileApp(), macApp(), and framework() target helpers to Project.swift
  • Adds PhotoFramer — an iOS + Mac Catalyst app for framing photos to standard print (4×6, 5×7, 8×10, 11×14) and social media (1:1, 4:5, 16:9, 9:16) sizes with crop-to-fill and fit-with-mat modes

Test plan

  • Run ./ide — Tuist generates without errors
  • Run tuist test — all unit tests pass (FrameSize, FramingService, FramingConfiguration, PhotoExporter)
  • Build and run on iOS Simulator — verify photo import, framing preview, and export
  • Build and run as Mac Catalyst — verify drag-and-drop from Finder

🤖 Generated with Claude Code

kyleve and others added 3 commits March 14, 2026 16:52
Adds a new iOS + Mac Catalyst app for framing photos to standard print
(4×6, 5×7, 8×10, 11×14) and social media (1:1, 4:5, 16:9, 9:16) sizes.
Supports importing from the photo library, file picker, and drag-and-drop.
Two framing modes: crop-to-fill and fit-with-mat with configurable border
color. Includes a mobileApp() Tuist helper and unit tests for the framing
service, models, and file export.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Restores mobileDestinations/mobileDeployment that were removed by 02f564d,
and makes framework() build for all destinations (mobile + mac) using
.multiplatform() deployment targets so frameworks can be consumed by both
mobileApp() and macApp() targets. Also fixes .accent → Color.accentColor
in PhotoImportView.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@kyleve kyleve closed this Apr 6, 2026
@kyleve kyleve deleted the kve/photo-framer-app branch April 6, 2026 19:41
kyleve added a commit that referenced this pull request Jun 30, 2026
Addresses three PR review comments on the remote-change source:
- #2: rename ScriptedStoreRemoteChangeSource.emit() -> yield() to match the
  continuation.yield() it actually makes.
- #1: mark ScriptedStoreRemoteChangeSource @_spi(Testing) + #if DEBUG per the
  agents.md testing-hook convention (it's test-only scaffolding), document it
  in Where/AGENTS.md, and switch the two test files to
  @_spi(Testing) @testable import WhereCore.
- #3: don't allow startObservingRemoteChanges to be called more than once.
  Make it private and add an @_spi(Testing) SwiftDataStore.inMemory(
  remoteChangeSource:) factory so the wiring is folded into the factories
  (make for CloudKit, inMemory for tests) — there's no public entry point to
  re-call, which keeps the unsynchronized nonisolated(unsafe) remoteChangeTask
  sound without a cancel/re-arm dance.

Co-authored-by: Cursor <cursoragent@cursor.com>
kyleve added a commit that referenced this pull request Jun 30, 2026
* Emit a committed-change signal at the WhereStore boundary

Add `WhereStore.changes()` — a fresh `AsyncStream<Void>` that pings whenever
committed data changes. `SwiftDataStore` owns a `StoreChangeBroadcaster` and
fires it once after each outermost `perform` commit (nested performs reuse the
in-flight transaction and don't save, so a transaction pings exactly once; a
rolled-back transaction stays silent). The test doubles (`TestStore` and the
two `ToggleFailingStore`s) forward `changes()` to their backing store.

This is the single read-refresh signal every write origin will funnel through;
consumers (scanner cache, session) are wired in the next commit. Salvages the
`StoreChangeBroadcaster` + its tests from the abandoned GPS-only approach.

Groundwork: no behavior change yet — nothing subscribes to `changes()`.
Co-authored-by: Cursor <cursoragent@cursor.com>

* Invalidate the data-issue cache off the store-change signal

`DataIssueScanner` now subscribes to a `storeChanges` stream and drops its
cache on every committed change, so a `force: false` read stays honest even
when no session is alive to force a rescan (e.g. a headless background GPS
ingest). `WhereServices` feeds it `store.changes()` and exposes
`dataChangeUpdates()` (also `store.changes()`) for the view-model to observe.

The scanner's invalidation task is `nonisolated(unsafe)` — written once in the
initializer, cancelled in `deinit`, never accessed concurrently.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Refresh the session purely off the store-change signal

`WhereSession` now subscribes to `services.dataChangeUpdates()` in a long-lived
`observeDataChanges()` task (wired into `start()` and the `syncAuth` launch
step, alongside the authorization observer) and re-pulls its report + data-issue
scan on every ping. The write intent methods (`setManualDay`, `setManualDays`,
`overrideDay`, `clearManualDay`) no longer refresh inline — they commit, the
store pings, and the observer re-pulls — and `dismiss` keeps only its optimistic
row removal. `select(year:)` and `appBecameActive()` keep their explicit
refreshes (navigation / lifecycle, not writes).

This collapses the two refresh paths into one: every committed write, whoever
made it (manual edit, live GPS, remote sync), refreshes the UI the same way.
The observer subscribes synchronously so a write committing immediately after
can't outrun the subscription.

Tests: add observer-driven manual-write + retain-cycle coverage; the reset
tests now wait for the observer instead of reading `trackedDayCount` inline.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fold CloudKit remote imports into the store-change signal

Add a `StoreRemoteChangeSource` seam: a `PersistentStoreRemoteChangeSource`
that bridges Core Data's `.NSPersistentStoreRemoteChange` (posted by the
CloudKit mirror on import from another device) into an `AsyncStream`, and a
`ScriptedStoreRemoteChangeSource` double so the whole path is drivable in unit
tests without CloudKit or a device. `SwiftDataStore.startObservingRemoteChanges`
forwards those events into the same `changes()` fan-out a local commit pings, and
`make` wires the production source for CloudKit storage — so a remote sync
refreshes the UI identically to a local write. Only Apple's contract (that the
mirror posts the notification) stays untested.

Tests: scripted emit + posted-notification yield from the sources; a scripted
remote change forwards through to `store.changes()`.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Document the unified read path and cover live-GPS ingest

Add a WhereServices test proving live GPS ingestion pings
`dataChangeUpdates()` — the original "Resolve tab goes stale on GPS" scenario,
now flowing through the same signal a manual edit does. Update Where/AGENTS.md
(store-change signal, scanner cache invalidation, the "one read path" layering
note, WhereSession's data-change observer) and tick the live-refresh TODO.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Update TODOs.md to mention HistoryObserver

* Guard the data-issue scan against a mid-flight year switch

`refreshDataIssues(force:)` read `selectedYear`, awaited the scan, then
assigned `dataIssues` with no recheck — so a concurrent `select(year:)`
(now more likely with `observeDataChanges()` firing on every commit) could
land an older year's issues under the newer year's label. Capture the
requested year up front and drop the result if the selection moved on,
mirroring the existing guard in `refresh()`.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop redundant inline refresh from clearSelectedYear/importBackup

Both go through `store.perform`, so each commit already pings the
store-change signal and `observeDataChanges()` re-pulls the report +
data-issue scan. The leftover inline `refresh()` + `refreshDataIssues()`
were a second, redundant pass and the only committed-write paths still
refreshing imperatively — inconsistent with the de-inlined manual-day and
dismiss intents. Rely on the single observed read path like the rest.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Cover the scanner's store-change invalidation (the original bug)

The scanner's `storeChanges` subscription — which drops the cache so a
`force: false` reader stays honest when no session is alive to force a
rescan (a headless background ingest) — had no test. The session observer
test forces a rescan, so it can't catch a severed subscription.

Add a test against the real `WhereServices` assembly: seed the cache, then
commit a dismissal through the journal and assert a *non-forced* scan (same
`now`, within the throttle interval) recomputes and drops it — proving the
cache was invalidated out-of-band by the observed `store.changes()` ping.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Track the WhereSession split as a follow-up (review finding #4)

The data-change observer is wired in the headless syncAuth launch step, so
every background GPS commit drives a UI refresh + forced rescan nothing
renders — redundant with the scanner's headless cache invalidation and the
appBecameActive() foreground catch-up. The clean fix is to scope the
subscription to presentation (a scene-scoped view-model split out of the
~800-line session), which is its own refactor; capture it as a P1 rather
than bolt it onto this PR.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Finish the scanner's default store-change stream instead of parking

`storeChanges` defaulted to `AsyncStream { _ in }`, which never yields or
finishes — so the observation task spawned in `init` parked forever (until
deinit cancellation) for every scanner built without a store (previews,
unit tests). Default to an already-finished stream so that task completes
immediately. Production always passes `store.changes()`, so behavior there
is unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Make refresh() idempotent so unrelated commits don't churn the UI

The data-change observer re-pulls on every commit, so `refresh()` flipped
`loadState` to `.loading` and reassigned `report` even when nothing it owns
changed — e.g. a dismissal (which only touches the dismissed-issues set)
re-rendered every report view. Skip the `.loading` flip when a report is
already on screen, and skip the `report`/`loadState` reassignment (and the
"loaded" log) when the freshly read report is unchanged. A genuine write
still changes the report and propagates as before; an unrelated commit is
now a no-op for the report views.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Document why reset() still invalidates the scanner inline (nit #7)

eraseAllData() pings store.changes() and the scanner self-invalidates off
it, but asynchronously. The inline invalidate() is the deterministic half —
it guarantees the cache is empty by the time reset() returns rather than
racing the observer — not dead code to delete. Note that so it survives a
future cleanup pass.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Clarify the remote-change observer's capture and call contract (nit #8)

Two readability fixes flagged in review:
- PersistentStoreRemoteChangeSource captured a local that shadowed
  `self.continuation`, which read like a mistake. Rename it to `captured`
  and explain it: the observer block must not capture `self`, or the
  NotificationCenter-retained block would keep the source alive past
  `deinit` (which removes the observer).
- startObservingRemoteChanges' leading `cancel()` implied re-arm support it
  doesn't provide; document that it must be called exactly once before the
  store is shared (the task var is nonisolated(unsafe), unsynchronized) and
  that the cancel is defensive cleanup, not a thread-safe replace.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fold remote-change wiring into factories and SPI-gate the test double

Addresses three PR review comments on the remote-change source:
- #2: rename ScriptedStoreRemoteChangeSource.emit() -> yield() to match the
  continuation.yield() it actually makes.
- #1: mark ScriptedStoreRemoteChangeSource @_spi(Testing) + #if DEBUG per the
  agents.md testing-hook convention (it's test-only scaffolding), document it
  in Where/AGENTS.md, and switch the two test files to
  @_spi(Testing) @testable import WhereCore.
- #3: don't allow startObservingRemoteChanges to be called more than once.
  Make it private and add an @_spi(Testing) SwiftDataStore.inMemory(
  remoteChangeSource:) factory so the wiring is folded into the factories
  (make for CloudKit, inMemory for tests) — there's no public entry point to
  re-call, which keeps the unsynchronized nonisolated(unsafe) remoteChangeTask
  sound without a cancel/re-arm dance.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Count dataChangeUpdates pings in the live-GPS test (PR comment #5)

PingRecorder tracked a bool, so the test could only prove "at least one ping".
Track a count and stop break-ing the consumer after the first ping, then assert
exactly one ping fired for the single ingested sample — a higher count would
catch a regression that fans out duplicate signals per commit.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Test that a committed write is visible to a later read (PR comment #4)

Answers the review question of whether peer.save() guarantees the data is
observable before changeBroadcaster.send() fires: insert + read (which
registers the row in the long-lived read context), then update the same day +
read again, asserting the read observes the updated regions rather than the
stale cached value. Covers read-after-write across the peer->main context
boundary for the subtle update case, not just inserts.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant