* Support local UIImage in ZoomableImage / ImageZoomState
Adds a Source enum to ZoomableImage with .url and .local cases plus a
matching ImageZoomOverlay branch that renders an Image(uiImage:) when
the state holds a local image. The legacy URL initializer is preserved
so existing feed call sites are unchanged. LocalZoomableViewer is
removed; the create gallery preview now uses the same zoom infrastructure
as the feed.
* Push CreateGalleryView instead of presenting as a sheet
Switches the create flow from a modal sheet to a NavigationStack push
inside the feed tab. The push lives on FeedView via .navigationDestination
(isPresented:), and CreateGalleryView no longer wraps its body in its own
NavigationStack — title and trailing toolbar item attach to the parent
stack. Removes the Cancel toolbar button since the iOS edge-swipe back
handles dismissal natively. MainTabView drops the .sheet wiring.
Also pulls in the location detection changes that came along with this
restructure: detectLocation() now reads items.first only and re-runs on
photoItems.first?.id changes, plus the "Use first photo location"
copy update.
* feat: photo editor strip + grid reorder with UIKit gestures
- PhotoEditor with inline Form sections, carousel, tap-to-preview alt overlay
- ReorderablePhotoStrip with custom long-press+drag gesture, auto-scroll,
live sibling reorder preview
- ReorderablePhotoGrid with 2D drag math and row-boundary wraps
- PhotoThumbnailCell extracted as shared cell with outside-corner X button
- Uniform grid slot sizing driven by most-portrait photo
- UIKit gesture recognizers replace SwiftUI gestures for strip and grid
* feat: high-res preview cache for post preview carousel
The carousel was rendering item.thumbnail (downsized to 150pt for the
strip/grid) and looked blurry full-screen. PhotoEditor now keeps a small
LRU cache of preview-sized images (1500pt max) loaded on demand whenever
selectedPhotoID changes.
The cache is bounded by previewCacheLimit (5 entries) so peak memory stays
sane even on the maxSelectionCount=20 ceiling — at ~5MB per decoded preview
that's a worst case of ~25MB on top of existing app memory. Picker items
go through PhotosPickerItem.loadTransferable; camera items reuse the
UIImage already in PhotoSource.camera. Fallback during load is the
existing 150pt thumbnail so the carousel is never blank.
* fix: editor polish — grid bindings, pickup animation, matched-geometry prep
- Wire simultaneous UIKit gesture recognition via delegate
- Replace bindingFor with ForEach($items) safe per-id bindings
- Pickup animation scales + fades cell on drag start
- Lock parent form scroll during cell pickup
- Matched-geometry namespace prep for strip↔grid transition
- Grid cells use photo's natural aspect ratio
- Lock back-swipe and strip scroll while reordering
* fix: zoom overlay tracks gesture session, not image identity
ZoomableImage's isCurrentlyZoomed used `zoomState.localImage === image` for
.local sources, which fails when the rendered UIImage instance changes during
the same view lifetime — e.g. PhotoEditor swapping a 150pt thumbnail for a
1500pt preview as its cache fills. The base image stayed visible behind the
overlay because the identity check couldn't resolve.
Replaces it with a per-instance @State flag flipped on in onBegan and off in
resetZoom, so 'this view is currently the one being zoomed' tracks the gesture
session rather than the photo bytes. URL sources still compare by URL string
since those don't churn.
Also adds .failed to handlePinch / handlePan switch cases so an interrupted
pinch or pan (third finger lands, sibling recognizer claims the gesture)
still routes through onEnded — without it the zoom state could freeze and
the snap-back never fired.
* feat: remove reorder from strip, rename grid as "Reorder" mode
Per HIG modality guidance, reorder is now a distinct MODE rather than a
gesture available everywhere. Gesture-wise, reorder lives exclusively in
the grid; entering / exiting the grid is a clear mode transition with
text affordances (not icon-only).
Strip changes (Grain/Views/Create/ReorderablePhotoStrip.swift →
PhotoStrip.swift):
- Deleted: ReorderRecognizer gesture, handleReorder / beginDrag /
handleDragChanged / autoScrollIfNeeded / handleDragEnded / resetDragState,
xOffset live-preview math, ScrollPanLocker, all drag state
(draggedID / dragStartIndex / dragCurrentIndex / dragOffsetX /
scrollOffsetX / lastScrollOffsetX / viewportWidth / lastAutoScrollAt),
onScrollGeometryChange delta compensation, isReordering binding.
- Kept: tap-to-select, delete, wallet-remove transition, scrollTo the
selected photo on selection change.
- Struct renamed ReorderablePhotoStrip → PhotoStrip to reflect the new
read-only role. The file size drops from ~350 lines to ~115.
PhotoEditor header changes:
- Section title swaps "Photos" ↔ "Reorder" on the isExpanded toggle.
- Trailing button is now a text affordance, not an icon: "Reorder"
(semibold) to enter, "Done" (bold, accent) to exit. Same visual
pattern as Apple Photos' Select mode.
- Accessibility label updated to "Enter reorder mode" / "Exit reorder
mode" so VoiceOver conveys the modal nature.
- Strip no longer takes isReordering — PhotoEditor drops that argument
at the PhotoStrip callsite. Grid still takes it (its reorder gesture
still needs to drive scroll lock + back-swipe lock).
* refactor: replace Mode enum with CellGeometry, extract ExifSettingsRow
PhotoThumbnailCell now receives a CellGeometry bundle (photoSize, maskSide,
maskCornerRadius) instead of a Mode enum, preventing callers from passing
inconsistent values and centralising layout math in the parent. Adds
MatchedPhotoModifier for the strip↔grid transition.
ExifInfoView extracts ExifSettingsRow as a standalone component with stable
max-width proxies and a spring animation on value changes.
* fix: EXIF view slides under carousel instead of snapping on selection change
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor: replace EditorMode binary enum with 3-case CaseIterable
* feat: add captions mode with inline alt-text list
* fix: isAnimatingMode safety net, carousel gating, and transition cleanup
* fix: gallery photo editor — selection ring, captions crash, grid layout
- Replace matched-geometry selection ring with per-cell SelectionBorderShape
trim animation; border draws from X-button corner, easeInOut curve avoids
spring overshoot on bounded trim values, drawingGroup() offloads path
re-computation to Metal each frame
- Add ExifChip component for inline EXIF display in cells
- Fix captions↔strip/grid crash: withAnimation(.smooth) on a Form row height
change (short strip → tall captions list) drove UICollectionViewCompositional-
Layout into recursive invalidation (depth 100 assertion); captions transitions
are now instant
- Fix reorder grid photos stuck at origin: containerWidth sentinel was 0 so
cellSide = 0 on first render; initialise from UIScreen.main.bounds.width and
suppress animation on geometry corrections; accept containerWidth from parent
so the value is known before the grid mounts
- Strip scroll stutter: two concurrent withAnimation(.snappy) contexts (onTap +
onChange) competed on the same layout; onTap now sets selectedPhotoID without
animation (value-based ring animation handles the ring, onChange handles scroll)
- Simplify carousel gate from complex opacity/isAnimatingMode logic to plain
mode == .preview with .transition(.opacity)
* refactor: centralize aspect ratio as PhotoItem.naturalAspect
Add a single naturalAspect computed property to PhotoItem (w/h of thumbnail)
as the authoritative source for cell aspect geometry. Also replaces
UIScreen.main.bounds.width with hardcoded 393 in preview data so Xcode
previews don't depend on the host screen at render time.
* feat: captions list with unified thumbnails
* instrument: add OSSignposter tracing for photo loading and morph animation
Adds signpost intervals and events across the gallery create flow so
Instruments (os_signpost) can show timing for:
- Photo picker batch loading and per-photo load/thumb/decompress stages
- Hi-res preview prefetch window and per-image load/decode pipeline
- Strip↔grid morph animation start, completion-handler path, and safety-net path
- Strip scroll events: tap, post-morph deferred scroll, carousel fallback, delete
- MatchedPhotoModifier applied vs. passthrough (for debugging geometry capture)
* chore: support SIM_UDID targeting in just sim recipe
Add SIM_UDID to .env.example and update the sim recipe to target that
specific simulator when the env var is set and the device is booted.
Falls back to 'booted' when unset, preserving existing behaviour.
* fix: captions polish — EXIF badge, ExifChip cleanup, morph diagnostics
- Detach preview image loading from main actor to eliminate scroll hangs
- Simplify ExifChip API — remove compact and cameraName params
- Add compact EXIF badge to captions list thumbnail overlay
- Fix signposter closure captures for strict concurrency
- Pre-seed strip scroll from selectedPhotoID
- Clean up captions mode UI polish
* refactor: replace UIScrollView strip with pure @State offset + drag gesture
PhotoStrip now positions its HStack via @State baseOffset + @GestureState
dragTranslation instead of a ScrollView-backed UIScrollView. This means
layout settles in one synchronous SwiftUI pass — matched-geometry captures
destination frames at their final resting positions on the first render,
eliminating the old two-phase morph-then-scroll artifact.
- Strip: HStack with offset(), DragGesture for free-scroll + velocity snap
- Editor: drop the 80ms pre-delay workaround; both transition directions
are now symmetric (no UIScrollView async catchup to wait for)
- ThumbnailCell: swap selection ring overlay for accent glow shadows + scale
* feat(captions): widen caption TextField with reliable mode morphs
- Widen caption TextField for comfortable editing
- Restructure mode conditionals and dynamic isMatchedSource
for reliable captions morphs across strip ↔ reorder ↔ captions
* fix: update test to match 3-feed defaults (recent, following, foryou)
* refactor: replace PhotoStrip/ReorderablePhotoGrid with AdaptivePhotoLayout
Split drag state into ReorderDragState, scroll state into StripScrollState,
and replaced the two monolithic views with AdaptivePhotoLayout. Simplified
PhotoThumbnailCell, removed matchedNamespace from CaptionsListPrototype,
and stripped verbose inline docs from PhotoEditor/PreviewCacheStore.
* fix: prevent simultaneous H+V gesture recognition in strip pan
* fix: strip clip, xmark sizing, and captions divider polish
* feat: present CreateGallery as sheet with discard-changes guard
* fix: block form scroll from touch-down through end of reorder drag
* fix: block sheet swipe-dismiss when gallery has unsaved changes
* fix: use alert for discard confirmation, tint X red when dirty
* swift build
* drag animation problems
* Update DEVELOPMENT.md
* Chnage discard alert copy
* fix drag
* Rename PhotoEditor to GalleryEditor
Renames the struct, test class, and source files; updates all call
sites and comments throughout the codebase; regenerates xcodeproj.
* feat: improve photo editor UX and fix picker sync
- Replace scale+glow thumbnail selection with border+dim approach
- Fix tap target issues with .contentShape(Rectangle())
- Sync photo picker selection when removing photos via editor X button
(add .continuousAndOrdered + .shared() to PhotosPicker)
- Add dedup guard to prevent duplicate photos from rapid picker events
- Make ExifChip square to match ALT pill styling
- Use adaptive background for editor (light/dark mode support)
- Change "Post Preview" header to "Preview"
- Add always-visible Bluesky cross-post footer note
- Refresh feed after gallery creation via feedRefreshID
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Chad Miller <chadtmiller15@gmail.com>