iOS client for Grain grain.social
ios photography atproto
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: gallery editor — photo strip, reorder, captions, and sheet flow (#11)

* 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>

authored by

Hima
Claude Sonnet 4.6
Chad Miller
and committed by
GitHub
9d69c0f1 8e0adeb2

+2493 -591
+2
.env.example
··· 5 5 APPLE_TEAM_ID=XXXXXXXXXX 6 6 # Bundle identifier — defaults to social.grain.grain if unset 7 7 BUNDLE_ID=social.grain.grain 8 + # Simulator UDID to target with `just sim` 9 + SIM_UDID=XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
+1
.gitignore
··· 41 41 # Zed personal config 42 42 .zed/keymap.json 43 43 .zed/tasks.json 44 + buildServer.json
+6
DEVELOPMENT.md
··· 22 22 open Grain.xcodeproj 23 23 ``` 24 24 25 + If using a non-Xcode editor with SourceKit-LSP (e.g. Zed), run once after generating: 26 + 27 + ```bash 28 + xcode-build-server config -scheme Grain -project Grain.xcodeproj 29 + ``` 30 + 25 31 No local backend needed — `just sim` and `just device` both hit the production API at grain.social. 26 32 27 33 ### Device builds
+4 -4
Grain/API/AuthManager.swift
··· 21 21 private var refreshTask: Task<Void, Error>? 22 22 23 23 #if PRODUCTION_API || !targetEnvironment(simulator) 24 - static let serverURL = URL(string: "https://grain.social")! 24 + nonisolated static let serverURL = URL(string: "https://grain.social")! 25 25 #else 26 - static let serverURL = URL(string: "http://127.0.0.1:3000")! 26 + nonisolated static let serverURL = URL(string: "http://127.0.0.1:3000")! 27 27 #endif 28 - static let clientID = "grain-native://app" 29 - static let redirectURI = "grain://oauth/callback" 28 + nonisolated static let clientID = "grain-native://app" 29 + nonisolated static let redirectURI = "grain://oauth/callback" 30 30 31 31 init() { 32 32 // Restore session from Keychain — allow expired tokens since we can refresh
+2
Grain/Grain-Debug.entitlements
··· 2 2 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3 3 <plist version="1.0"> 4 4 <dict> 5 + <key>com.apple.security.get-task-allow</key> 6 + <true/> 5 7 </dict> 6 8 </plist>
+22 -2
Grain/Utilities/PreviewData.swift
··· 209 209 guard let path = Bundle.main.url(forResource: entry.name, withExtension: "jpg")?.path, 210 210 let fullImage = UIImage(contentsOfFile: path) else { return nil } 211 211 let thumb = PhotoItem.makeThumbnail(from: fullImage) 212 - var item = PhotoItem(thumbnail: thumb, source: .camera(fullImage, metadata: nil)) 212 + let carousel = PhotoItem.makeCarouselPreview(from: fullImage, width: 393) 213 + var item = PhotoItem(thumbnail: thumb, carouselPreview: carousel, source: .camera(fullImage, metadata: nil)) 213 214 item.alt = entry.alt 214 215 return item 215 216 } 216 217 // Pad with gradient fallbacks if fewer than 15 items total 217 218 for (colors, label) in fallbackColors { 218 219 let thumb = gradientThumb(colors: colors) 219 - var item = PhotoItem(thumbnail: thumb, source: .camera(thumb, metadata: nil)) 220 + var item = PhotoItem(thumbnail: thumb, carouselPreview: thumb, source: .camera(thumb, metadata: nil)) 220 221 item.alt = label 221 222 items.append(item) 223 + } 224 + return items 225 + } 226 + 227 + /// Same as `photoItems` but with mock EXIF on every even-indexed item, 228 + /// so previews that show the ExifChip can see both the with/without states. 229 + static var photoItemsWithExif: [PhotoItem] { 230 + let mockExif = ExifSummary( 231 + camera: "RICOH GR IIIx", 232 + lens: nil, 233 + exposure: nil, 234 + shutterSpeed: "1/250", 235 + iso: "400", 236 + focalLength: "40mm", 237 + aperture: "f/2.8" 238 + ) 239 + var items = photoItems 240 + for i in stride(from: 0, to: items.count, by: 2) { 241 + items[i].exifSummary = mockExif 222 242 } 223 243 return items 224 244 }
+26
Grain/Utilities/PreviewHelpers.swift
··· 1 1 import Foundation 2 + import SwiftUI 2 3 3 4 /// True when code is running inside an Xcode preview canvas. 4 5 /// Use to skip network calls that would block or slow down previews. 5 6 var isPreview: Bool { 6 7 ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" 7 8 } 9 + 10 + // MARK: - Preview Modifiers 11 + 12 + extension XRPCClient { 13 + /// Shared preview client — avoids repeating `XRPCClient(baseURL: AuthManager.serverURL)`. 14 + static var preview: XRPCClient { XRPCClient(baseURL: AuthManager.serverURL) } 15 + } 16 + 17 + extension View { 18 + /// Applies the standard Grain preview styling: dark mode + accent color. 19 + /// Use on every #Preview so the canvas matches the real app. 20 + func grainPreview() -> some View { 21 + self 22 + .preferredColorScheme(.dark) 23 + .tint(Color("AccentColor")) 24 + } 25 + 26 + /// Injects the standard Grain environment objects required by most previews. 27 + func previewEnvironments() -> some View { 28 + environment(AuthManager()) 29 + .environment(StoryStatusCache()) 30 + .environment(ViewedStoryStorage()) 31 + .environment(LabelDefinitionsCache()) 32 + } 33 + }
+28 -4
Grain/Views/Components/AvatarView.swift
··· 49 49 } 50 50 51 51 #Preview { 52 - HStack(spacing: 20) { 53 - AvatarView(url: nil) 54 - AvatarView(url: nil, size: 48) 55 - AvatarView(url: nil, size: 80) 52 + VStack(spacing: 24) { 53 + // Fallback state — no URL, all three canonical sizes side by side 54 + VStack(spacing: 8) { 55 + Text("Fallback (nil URL)") 56 + .font(.caption) 57 + .foregroundStyle(.secondary) 58 + HStack(spacing: 20) { 59 + AvatarView(url: nil, size: 32) 60 + AvatarView(url: nil, size: 48) 61 + AvatarView(url: nil, size: 80) 62 + } 63 + } 64 + 65 + Divider() 66 + 67 + // Bad URL — exercises the loading-failed → fallback path 68 + VStack(spacing: 8) { 69 + Text("Bad URL (load failure fallback)") 70 + .font(.caption) 71 + .foregroundStyle(.secondary) 72 + HStack(spacing: 20) { 73 + AvatarView(url: "https://invalid.example/avatar.jpg", size: 32) 74 + AvatarView(url: "https://invalid.example/avatar.jpg", size: 48) 75 + AvatarView(url: "https://invalid.example/avatar.jpg", size: 80) 76 + } 77 + } 56 78 } 57 79 .padding() 80 + .background(Color(.systemBackground)) 81 + .grainPreview() 58 82 }
+18 -3
Grain/Views/Components/ContentLabelPicker.swift
··· 53 53 } 54 54 55 55 #Preview { 56 - @Previewable @State var labels = Set<String>() 56 + // Pre-select "sexual" so the disclosure group auto-expands on first render 57 + // and the checkmark row is immediately visible. The cache has no network 58 + // definitions, so labels fall back to .capitalized ("Nudity", "Sexual", "Gore"). 59 + @Previewable @State var labelsWithSelection: Set = ["sexual"] 60 + @Previewable @State var labelsEmpty: Set<String> = [] 61 + 57 62 Form { 58 - ContentLabelPicker(selectedLabels: $labels) 63 + // Expanded — one item pre-checked 64 + Section(header: Text("Pre-selected (expanded)")) { 65 + ContentLabelPicker(selectedLabels: $labelsWithSelection) 66 + } 67 + 68 + // Collapsed — nothing selected 69 + Section(header: Text("Empty (collapsed)")) { 70 + ContentLabelPicker(selectedLabels: $labelsEmpty) 71 + } 59 72 } 60 - .environment(LabelDefinitionsCache()) 73 + .previewEnvironments() 74 + .preferredColorScheme(.dark) 75 + .tint(Color("AccentColor")) 61 76 }
+16 -14
Grain/Views/Components/ContentWarningOverlay.swift
··· 75 75 } 76 76 } 77 77 78 - #Preview("ContentWarningOverlay") { 79 - ContentWarningOverlay(name: "Nudity", action: .hide) {} 80 - .frame(height: 200) 81 - } 78 + #Preview { 79 + VStack(spacing: 24) { 80 + ContentWarningOverlay(name: "Nudity", action: .hide) {} 81 + .frame(height: 200) 82 82 83 - #Preview("MediaWarningOverlay") { 84 - MediaWarningOverlay(name: "Sexual Content") {} 85 - .frame(height: 200) 86 - .background(Color.gray.opacity(0.2)) 87 - } 83 + MediaWarningOverlay(name: "Sexual Content") {} 84 + .frame(height: 200) 85 + .background(Color.gray.opacity(0.2)) 88 86 89 - #Preview("LabelBadge") { 90 - HStack { 91 - LabelBadge(name: "sensitive") 92 - LabelBadge(name: "gore") 87 + HStack { 88 + LabelBadge(name: "sensitive") 89 + LabelBadge(name: "gore") 90 + } 91 + .padding() 93 92 } 94 - .padding() 93 + .frame(maxWidth: .infinity, maxHeight: .infinity) 94 + .background(Color.black) 95 + .preferredColorScheme(.dark) 96 + .tint(Color("AccentColor")) 95 97 }
+32 -6
Grain/Views/Components/CustomFullScreenCover.swift
··· 59 59 } 60 60 61 61 #Preview { 62 - @Previewable @State var show = false 63 - VStack { 64 - Button("Show Cover") { show = true } 62 + // Show a live trigger + cover so both states render in the canvas. 63 + // isPresented starts true so the covered content is visible immediately. 64 + @Previewable @State var isPresented = true 65 + 66 + ZStack { 67 + Color(.systemBackground).ignoresSafeArea() 68 + 69 + VStack(spacing: 24) { 70 + Text("Background content") 71 + .font(.title2) 72 + .foregroundStyle(.primary) 73 + 74 + Button(isPresented ? "Dismiss Cover" : "Show Cover") { 75 + isPresented.toggle() 76 + } 77 + .buttonStyle(.borderedProminent) 78 + } 65 79 } 66 - .customFullScreenCover(isPresented: $show) { 80 + .customFullScreenCover(isPresented: $isPresented) { 67 81 ZStack { 68 - Color.blue.ignoresSafeArea() 69 - Text("Custom Cover").foregroundStyle(.white) 82 + Color.indigo.opacity(0.92).ignoresSafeArea() 83 + VStack(spacing: 16) { 84 + Image(systemName: "photo.stack") 85 + .font(.system(size: 56)) 86 + .foregroundStyle(.white) 87 + Text("Full-screen cover content") 88 + .font(.headline) 89 + .foregroundStyle(.white) 90 + Button("Dismiss") { isPresented = false } 91 + .buttonStyle(.bordered) 92 + .tint(.white) 93 + } 70 94 } 71 95 } 96 + .preferredColorScheme(.dark) 97 + .tint(Color("AccentColor")) 72 98 }
+2
Grain/Views/Components/ExpandableDescriptionView.swift
··· 96 96 ) 97 97 } 98 98 .padding() 99 + .preferredColorScheme(.dark) 100 + .tint(Color("AccentColor")) 99 101 }
+65 -55
Grain/Views/Components/GalleryCardView.swift
··· 131 131 } 132 132 } 133 133 134 + private struct PageIndicatorView: View { 135 + let photos: [GrainPhoto] 136 + let currentPage: Int 137 + let hasPortrait: Bool 138 + 139 + var body: some View { 140 + if photos.count > 1 { 141 + HStack(spacing: 5) { 142 + let total = photos.count 143 + let maxVisible = 5 144 + let start = total <= maxVisible ? 0 : min(max(currentPage - 2, 0), total - maxVisible) 145 + let end = total <= maxVisible ? total : start + maxVisible 146 + 147 + ForEach(start ..< end, id: \.self) { index in 148 + let distance = abs(index - currentPage) 149 + let currentIsLandscape = photos[currentPage].aspectRatio.ratio >= 1 150 + let dotColor: Color = hasPortrait && currentIsLandscape ? .secondary : .white 151 + Circle() 152 + .fill(dotColor.opacity(index == currentPage ? 1.0 : distance == 1 ? 0.5 : distance == 2 ? 0.3 : 0.2)) 153 + .frame( 154 + width: distance <= 1 ? 6 : distance == 2 ? 4 : 3, 155 + height: distance <= 1 ? 6 : distance == 2 ? 4 : 3 156 + ) 157 + .animation(.easeInOut(duration: 0.2), value: currentPage) 158 + } 159 + } 160 + .padding(.vertical, 8) 161 + } 162 + } 163 + } 164 + 165 + private struct CopiedToastView: View { 166 + var body: some View { 167 + HStack(spacing: 6) { 168 + Image(systemName: "doc.on.doc.fill") 169 + .font(.caption) 170 + Text("Link copied") 171 + .font(.subheadline.weight(.medium)) 172 + } 173 + .padding(.horizontal, 16) 174 + .padding(.vertical, 10) 175 + .background(.ultraThinMaterial, in: Capsule()) 176 + .shadow(color: .black.opacity(0.15), radius: 8, y: 4) 177 + .transition(.scale.combined(with: .opacity)) 178 + } 179 + } 180 + 181 + private extension View { 182 + /// Overlays a "Link copied" toast and drives its entrance/exit animation. 183 + /// Bundles the overlay and animation so they can't be accidentally separated. 184 + func copiedToast(isShowing: Bool) -> some View { 185 + overlay { if isShowing { CopiedToastView() } } 186 + .animation(.spring(response: 0.4, dampingFraction: 0.7), value: isShowing) 187 + } 188 + } 189 + 134 190 struct GalleryCardView: View { 135 191 @Environment(AuthManager.self) private var auth 136 192 @Environment(StoryStatusCache.self) private var storyStatusCache ··· 189 245 engagementRow 190 246 captionSection(lr: lr) 191 247 } 192 - .overlay { 193 - copiedToastOverlay 194 - } 195 - .animation(.spring(response: 0.4, dampingFraction: 0.7), value: showCopiedToast) 248 + .copiedToast(isShowing: showCopiedToast) 196 249 } 197 250 198 251 private var cardHeader: some View { ··· 300 353 } 301 354 .allowsHitTesting(lr.action != .warnMedia || gallery.labelRevealed) 302 355 303 - pageIndicator(photos: photos, hasPortrait: hasPortrait) 356 + PageIndicatorView(photos: photos, currentPage: currentPage, hasPortrait: hasPortrait) 304 357 altButton(photos: photos) 305 358 306 359 // Double-tap heart animations ··· 341 394 } 342 395 343 396 @ViewBuilder 344 - private func pageIndicator(photos: [GrainPhoto], hasPortrait: Bool) -> some View { 345 - if photos.count > 1 { 346 - HStack(spacing: 5) { 347 - let total = photos.count 348 - let maxVisible = 5 349 - let start = total <= maxVisible ? 0 : min(max(currentPage - 2, 0), total - maxVisible) 350 - let end = total <= maxVisible ? total : start + maxVisible 351 - 352 - ForEach(start ..< end, id: \.self) { index in 353 - let distance = abs(index - currentPage) 354 - let currentIsLandscape = photos[currentPage].aspectRatio.ratio >= 1 355 - let dotColor: Color = hasPortrait && currentIsLandscape ? .secondary : .white 356 - Circle() 357 - .fill(dotColor.opacity(index == currentPage ? 1.0 : distance == 1 ? 0.5 : distance == 2 ? 0.3 : 0.2)) 358 - .frame( 359 - width: distance <= 1 ? 6 : distance == 2 ? 4 : 3, 360 - height: distance <= 1 ? 6 : distance == 2 ? 4 : 3 361 - ) 362 - .animation(.easeInOut(duration: 0.2), value: currentPage) 363 - } 364 - } 365 - .padding(.vertical, 8) 366 - } 367 - } 368 - 369 - @ViewBuilder 370 397 private func altButton(photos: [GrainPhoto]) -> some View { 371 398 if let alt = photos[currentPage].alt, !alt.isEmpty { 372 399 VStack { ··· 405 432 .animation(.spring(response: 0.3, dampingFraction: 0.6), value: isFavorited) 406 433 ZStack { 407 434 let count = gallery.favCount ?? 0 408 - Text(String(repeating: "8", count: max(1, "\(count)".count))) 435 + Text("\(count)".digitWidthProxy) 409 436 .hidden() 410 437 Text("\(count)") 411 438 } ··· 516 543 .padding(.bottom, 16) 517 544 } 518 545 519 - @ViewBuilder 520 - private var copiedToastOverlay: some View { 521 - if showCopiedToast { 522 - HStack(spacing: 6) { 523 - Image(systemName: "doc.on.doc.fill") 524 - .font(.caption) 525 - Text("Link copied") 526 - .font(.subheadline.weight(.medium)) 527 - } 528 - .padding(.horizontal, 16) 529 - .padding(.vertical, 10) 530 - .background(.ultraThinMaterial, in: Capsule()) 531 - .shadow(color: .black.opacity(0.15), radius: 8, y: 4) 532 - .transition(.scale.combined(with: .opacity)) 533 - } 534 - } 535 - 536 546 private func addParticleBurst() { 537 547 let id = UUID() 538 548 likeParticleBursts.append(id) ··· 607 617 ScrollView { 608 618 GalleryCardView( 609 619 gallery: $gallery, 610 - client: XRPCClient(baseURL: AuthManager.serverURL) 620 + client: .preview 611 621 ) 612 622 GalleryCardView( 613 623 gallery: .constant(PreviewData.gallery2), 614 - client: XRPCClient(baseURL: AuthManager.serverURL) 624 + client: .preview 615 625 ) 616 626 } 617 - .environment(AuthManager()) 618 - .environment(StoryStatusCache()) 619 - .environment(ViewedStoryStorage()) 620 - .environment(LabelDefinitionsCache()) 627 + .previewEnvironments() 628 + .preferredColorScheme(.dark) 629 + .tint(Color("AccentColor")) 630 + .frame(maxHeight: .infinity, alignment: .top) 621 631 }
+97
Grain/Views/Components/LocationPickerRows.swift
··· 1 + import SwiftUI 2 + 3 + /// Reusable Form rows for location search and selection. 4 + /// Renders a confirmed-location row when `resolvedLocation` is set, 5 + /// or a photo suggestion + debounced search field + results list when it isn't. 6 + struct LocationPickerRows: View { 7 + @Binding var resolvedLocation: (h3: String, name: String, address: [String: AnyCodable]?)? 8 + let photoLocationResult: NominatimResult? 9 + var photoLocationLabel: String = "Use photo location" 10 + let onSelectLocation: (NominatimResult) -> Void 11 + 12 + @State private var locationQuery = "" 13 + @State private var locationSuggestions: [NominatimResult] = [] 14 + @State private var isSearchingLocation = false 15 + @State private var searchTask: Task<Void, Never>? 16 + 17 + var body: some View { 18 + if let loc = resolvedLocation { 19 + HStack { 20 + Label(loc.name, systemImage: "mappin.and.ellipse") 21 + .font(.subheadline) 22 + .lineLimit(1) 23 + Spacer() 24 + Button { 25 + resolvedLocation = nil 26 + } label: { 27 + Image(systemName: "xmark.circle.fill") 28 + .foregroundStyle(.secondary) 29 + } 30 + } 31 + } else { 32 + if let photoLoc = photoLocationResult { 33 + Button { selectResult(photoLoc) } label: { 34 + HStack(spacing: 10) { 35 + Image(systemName: "location.fill") 36 + .foregroundStyle(.secondary) 37 + .frame(width: 20) 38 + VStack(alignment: .leading, spacing: 1) { 39 + Text(photoLocationLabel) 40 + .font(.subheadline) 41 + Text(photoLoc.name) 42 + .font(.caption) 43 + .foregroundStyle(.secondary) 44 + } 45 + } 46 + } 47 + .foregroundStyle(.primary) 48 + } 49 + 50 + HStack { 51 + Image(systemName: "magnifyingglass") 52 + .foregroundStyle(.secondary) 53 + TextField("Search for a location...", text: $locationQuery) 54 + .textInputAutocapitalization(.never) 55 + .onChange(of: locationQuery) { 56 + searchTask?.cancel() 57 + let query = locationQuery 58 + searchTask = Task { 59 + try? await Task.sleep(for: .milliseconds(300)) 60 + guard !Task.isCancelled else { return } 61 + await performSearch(query: query) 62 + } 63 + } 64 + if isSearchingLocation { 65 + ProgressView().controlSize(.small) 66 + } 67 + } 68 + 69 + ForEach(locationSuggestions, id: \.placeId) { result in 70 + Button { selectResult(result) } label: { 71 + VStack(alignment: .leading, spacing: 2) { 72 + Text(result.name) 73 + .font(.subheadline) 74 + .foregroundStyle(.primary) 75 + if let context = result.context { 76 + Text(context) 77 + .font(.caption) 78 + .foregroundStyle(.secondary) 79 + } 80 + } 81 + } 82 + } 83 + } 84 + } 85 + 86 + private func selectResult(_ result: NominatimResult) { 87 + onSelectLocation(result) 88 + locationQuery = "" 89 + locationSuggestions = [] 90 + } 91 + 92 + private func performSearch(query: String) async { 93 + isSearchingLocation = true 94 + defer { isSearchingLocation = false } 95 + locationSuggestions = await LocationServices.searchLocation(query: query) 96 + } 97 + }
+2 -2
Grain/Views/Components/ReportView.swift
··· 111 111 112 112 #Preview { 113 113 ReportView( 114 - client: XRPCClient(baseURL: AuthManager.serverURL), 114 + client: .preview, 115 115 subjectUri: "at://did:plc:preview/social.grain.gallery/r1", 116 116 subjectCid: "cid" 117 117 ) 118 - .environment(AuthManager()) 118 + .previewEnvironments() 119 119 }
+2
Grain/Views/Components/RichTextView.swift
··· 209 209 RichTextView(text: "Visit https://grain.social for more.") 210 210 } 211 211 .padding() 212 + .preferredColorScheme(.dark) 213 + .tint(Color("AccentColor")) 212 214 }
+11 -4
Grain/Views/Components/SuggestedFollowsView.swift
··· 99 99 100 100 #Preview { 101 101 @Previewable @State var suggestions = [ 102 - SuggestedItem(did: "did:plc:a", handle: "alice.bsky.social", displayName: "Alice", description: "Photographer"), 103 - SuggestedItem(did: "did:plc:b", handle: "bob.bsky.social", displayName: "Bob"), 102 + SuggestedItem(did: "did:plc:a", handle: "alice.grain.social", displayName: "Alice", description: "Landscape & travel photographer based in SF.", followersCount: 1240), 103 + SuggestedItem(did: "did:plc:b", handle: "bob.grain.social", displayName: "Bob", description: "Street photography from Tokyo.", followersCount: 873), 104 + SuggestedItem(did: "did:plc:c", handle: "cara.grain.social", displayName: "Cara", description: "Film photographer. Mostly medium format.", followersCount: 4200), 105 + SuggestedItem(did: "did:plc:d", handle: "david.grain.social", displayName: "David", description: "Documentary work and long-form essays.", followersCount: 310), 106 + SuggestedItem(did: "did:plc:e", handle: "elena.grain.social", displayName: "Elena", description: "Portrait & editorial. Available for hire.", followersCount: 6700), 107 + SuggestedItem(did: "did:plc:f", handle: "felix.grain.social", displayName: "Felix", description: "Night sky and astrophotography.", followersCount: 520), 108 + SuggestedItem(did: "did:plc:g", handle: "grace.grain.social", displayName: "Grace", description: "Color theory nerd. Fuji only.", followersCount: 2100), 104 109 ] 105 110 SuggestedFollowsView( 106 - client: XRPCClient(baseURL: AuthManager.serverURL), 111 + client: .preview, 107 112 suggestions: $suggestions 108 113 ) 109 - .environment(AuthManager()) 114 + .previewEnvironments() 115 + .preferredColorScheme(.dark) 116 + .tint(Color("AccentColor")) 110 117 }
+141 -29
Grain/Views/Components/ZoomableImage.swift
··· 12 12 var anchor: UnitPoint = .center 13 13 var offset: CGSize = .zero 14 14 var imageURL: String = "" 15 + var localImage: UIImage? 15 16 var aspectRatio: CGFloat = 1 16 17 var sourceFrame: CGRect = .zero 17 18 } ··· 30 31 let localX = zoomState.sourceFrame.midX - overlayGlobal.x 31 32 let localY = zoomState.sourceFrame.midY - overlayGlobal.y 32 33 33 - LazyImage(url: URL(string: zoomState.imageURL)) { state in 34 - if let image = state.image { 35 - image 34 + Group { 35 + if let local = zoomState.localImage { 36 + Image(uiImage: local) 36 37 .resizable() 37 38 .aspectRatio(zoomState.aspectRatio, contentMode: .fit) 39 + } else { 40 + LazyImage(url: URL(string: zoomState.imageURL)) { state in 41 + if let image = state.image { 42 + image 43 + .resizable() 44 + .aspectRatio(zoomState.aspectRatio, contentMode: .fit) 45 + } 46 + } 38 47 } 39 48 } 40 49 .frame( ··· 121 130 parent.onBegan(anchor, globalFrame) 122 131 case .changed: 123 132 state.scale = max(startScale * gesture.scale, 1) 124 - case .ended, .cancelled: 133 + case .ended, .cancelled, .failed: 134 + // .failed is essential — without it a pinch interrupted by another 135 + // recognizer (e.g. parent ScrollView claiming the gesture) leaves 136 + // the zoom state stuck and the snap-back never fires. 125 137 startScale = state.scale 126 138 parent.onEnded() 127 139 default: ··· 140 152 guard parent.zoomState.scale > 1 else { return } 141 153 let translation = gesture.translation(in: gesture.view) 142 154 parent.zoomState.offset = CGSize(width: translation.x, height: translation.y) 143 - case .ended, .cancelled: 155 + case .ended, .cancelled, .failed: 156 + // Include .failed so that an interrupted pan (e.g. a third finger 157 + // touched down, or a sibling recognizer claimed the gesture) still 158 + // triggers the snap-back instead of leaving the offset frozen. 144 159 parent.onEnded() 145 160 default: 146 161 break ··· 152 167 // MARK: - ZoomableImage 153 168 154 169 struct ZoomableImage: View { 155 - let url: String 156 - var thumbURL: String? 170 + enum Source { 171 + case url(String, thumbURL: String? = nil) 172 + case local(UIImage) 173 + } 174 + 175 + let source: Source 157 176 let aspectRatio: CGFloat 177 + /// Higher-resolution image to show in the zoom overlay. When nil, the 178 + /// overlay falls back to the same image used for normal display. Pass a 179 + /// lazily-loaded hi-res version here so the normal display path uses a 180 + /// lighter image while zoom gets more detail if it's ready. 181 + var zoomImage: UIImage? 158 182 var onDoubleTap: ((CGPoint) -> Void)? 159 183 @Environment(ImageZoomState.self) private var zoomState: ImageZoomState? 160 184 161 185 @State private var snapBackTask: Task<Void, Never>? 186 + @State private var resetTask: Task<Void, Never>? 187 + /// Per-instance flag flipped on in `onBegan` and off in `resetZoom`. We use this 188 + /// instead of comparing `zoomState.localImage === source` because the rendered 189 + /// image instance can change mid-lifetime (e.g. GalleryEditor's preview cache 190 + /// swaps a low-res thumbnail for the high-res preview), which would defeat 191 + /// identity-based equality and leave the base image visible behind the overlay. 192 + @State private var isZoomingMe = false 193 + 194 + init(url: String, thumbURL: String? = nil, aspectRatio: CGFloat, onDoubleTap: ((CGPoint) -> Void)? = nil) { 195 + source = .url(url, thumbURL: thumbURL) 196 + self.aspectRatio = aspectRatio 197 + self.onDoubleTap = onDoubleTap 198 + } 199 + 200 + init(localImage: UIImage, aspectRatio: CGFloat, zoomImage: UIImage? = nil, onDoubleTap: ((CGPoint) -> Void)? = nil) { 201 + source = .local(localImage) 202 + self.aspectRatio = aspectRatio 203 + self.zoomImage = zoomImage 204 + self.onDoubleTap = onDoubleTap 205 + } 206 + 207 + private var sourceID: String { 208 + switch source { 209 + case let .url(url, _): "url:\(url)" 210 + case let .local(image): "local:\(ObjectIdentifier(image).hashValue)" 211 + } 212 + } 213 + 214 + private var isCurrentlyZoomed: Bool { 215 + guard let zoomState, zoomState.showOverlay else { return false } 216 + switch source { 217 + case let .url(url, _): 218 + return zoomState.imageURL == url 219 + case .local: 220 + // Identity (`===`) comparison fails when the rendered UIImage instance 221 + // changes during the same view lifetime — e.g. when PhotoEditor's 222 + // preview cache swaps a 150pt thumbnail for the 1500pt preview. 223 + // The per-instance flag is set in onBegan and cleared in resetZoom, 224 + // so it tracks the gesture session rather than image identity. 225 + return isZoomingMe 226 + } 227 + } 162 228 163 229 var body: some View { 164 - // PinchZoomOverlay is a ZStack sibling (not an overlay on LazyImage) so the 230 + // PinchZoomOverlay is a ZStack sibling (not an overlay on the image) so the 165 231 // UIView fills the full carousel slot — black-bar areas are tappable and 166 232 // UIKit coordinates already map to the carousel ZStack space with no correction. 167 233 ZStack { 234 + sourceView 235 + .opacity(isCurrentlyZoomed ? 0 : 1) 236 + 237 + if let zoomState { 238 + PinchZoomOverlay( 239 + zoomState: zoomState, 240 + onBegan: { anchor, frame in 241 + switch source { 242 + case let .url(url, _): 243 + zoomState.imageURL = url 244 + zoomState.localImage = nil 245 + case let .local(image): 246 + zoomState.imageURL = "" 247 + // Prefer zoomImage (hi-res) if available; fall back to 248 + // the carousel preview already in memory. 249 + zoomState.localImage = zoomImage ?? image 250 + } 251 + zoomState.aspectRatio = aspectRatio 252 + zoomState.anchor = anchor 253 + zoomState.sourceFrame = frame 254 + zoomState.showOverlay = true 255 + isZoomingMe = true 256 + }, 257 + onEnded: { scheduleSnapBack() }, 258 + onDoubleTap: onDoubleTap 259 + ) 260 + } 261 + } 262 + .frame(maxWidth: .infinity, maxHeight: .infinity) 263 + .onChange(of: sourceID) { resetZoom() } 264 + .onDisappear { 265 + snapBackTask?.cancel() 266 + resetTask?.cancel() 267 + } 268 + } 269 + 270 + @ViewBuilder 271 + private var sourceView: some View { 272 + switch source { 273 + case let .url(url, thumbURL): 168 274 LazyImage(request: ImageRequest(url: URL(string: url), priority: .veryHigh)) { state in 169 275 if let image = state.image { 170 276 image ··· 190 296 .aspectRatio(aspectRatio, contentMode: .fit) 191 297 } 192 298 } 193 - .opacity(zoomState?.showOverlay == true && zoomState?.imageURL == url ? 0 : 1) 194 - 195 - if let zoomState { 196 - PinchZoomOverlay( 197 - zoomState: zoomState, 198 - onBegan: { anchor, frame in 199 - zoomState.imageURL = url 200 - zoomState.aspectRatio = aspectRatio 201 - zoomState.anchor = anchor 202 - zoomState.sourceFrame = frame 203 - zoomState.showOverlay = true 204 - }, 205 - onEnded: { scheduleSnapBack() }, 206 - onDoubleTap: onDoubleTap 207 - ) 208 - } 299 + case let .local(image): 300 + Image(uiImage: image) 301 + .resizable() 302 + .aspectRatio(aspectRatio, contentMode: .fit) 209 303 } 210 - .frame(maxWidth: .infinity, maxHeight: .infinity) 211 - .onChange(of: url) { resetZoom() } 212 304 } 213 305 214 306 private func scheduleSnapBack() { ··· 227 319 zoomState.scale = 1 228 320 zoomState.offset = .zero 229 321 } 230 - Task { @MainActor in 322 + resetTask?.cancel() 323 + resetTask = Task { @MainActor in 231 324 try? await Task.sleep(for: .milliseconds(120)) 325 + guard !Task.isCancelled else { return } 232 326 zoomState.showOverlay = false 327 + isZoomingMe = false 233 328 } 234 329 } 235 330 } 236 331 237 332 #Preview { 238 - ZoomableImage(url: "", aspectRatio: 4 / 3) 333 + // Use a solid color rendered into a UIImage so zoom state is exercisable 334 + // without a network dependency. The 4:3 gradient stands in for a real photo. 335 + let size = CGSize(width: 400, height: 300) 336 + let renderer = UIGraphicsImageRenderer(size: size) 337 + let sampleImage = renderer.image { ctx in 338 + let colors = [UIColor.systemIndigo.cgColor, UIColor.systemTeal.cgColor] 339 + let gradient = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(), 340 + colors: colors as CFArray, 341 + locations: [0, 1])! 342 + ctx.cgContext.drawLinearGradient(gradient, 343 + start: .zero, 344 + end: CGPoint(x: size.width, y: size.height), 345 + options: []) 346 + } 347 + 348 + ZoomableImage(localImage: sampleImage, aspectRatio: 4 / 3) 239 349 .environment(ImageZoomState()) 240 350 .frame(maxWidth: .infinity) 241 - .padding() 351 + .background(Color.black) 352 + .preferredColorScheme(.dark) 353 + .tint(Color("AccentColor")) 242 354 }
+175
Grain/Views/Create/AdaptivePhotoLayout.swift
··· 1 + import SwiftUI 2 + 3 + // MARK: - Layout value key 4 + 5 + /// Each cell tags itself with its array index so the Layout knows 6 + /// which subview to place at which position. 7 + struct PhotoIndexKey: LayoutValueKey { 8 + static let defaultValue: Int = 0 9 + } 10 + 11 + // MARK: - Adaptive photo layout 12 + 13 + /// Single Layout that places photo cells in three modes: horizontal strip, 14 + /// 3-column grid, or vertical captions list. When `mode` changes inside 15 + /// `withAnimation`, SwiftUI interpolates each subview's position/size from 16 + /// old to new — no matchedGeometryEffect, no conditional view swaps. 17 + struct AdaptivePhotoLayout: Layout { 18 + var mode: EditorMode 19 + var containerWidth: CGFloat 20 + var stripScrollOffset: CGFloat = 0 21 + var dragPlacement: ReorderDragPlacement? 22 + 23 + // Strip — values must match StripScrollState.thumbSize / .spacing 24 + private let stripThumbSize: CGFloat = 72 25 + private let stripSpacing: CGFloat = 20 26 + private let stripVerticalPadding: CGFloat = 22 27 + 28 + // Grid 29 + private let gridColumnCount = 3 30 + private let gridSpacing: CGFloat = 4 31 + private let gridOuterPadding: CGFloat = 16 32 + private let gridTopPadding: CGFloat = 22 33 + private let gridBottomPadding: CGFloat = 12 34 + 35 + // Captions 36 + private let captionsHorizontalPadding: CGFloat = 16 37 + private let captionsVerticalPadding: CGFloat = 10 38 + 39 + private var gridCellSide: CGFloat { 40 + let total = max(0, containerWidth - gridOuterPadding * 2 - gridSpacing * CGFloat(gridColumnCount - 1)) 41 + return max(1, total / CGFloat(gridColumnCount)) 42 + } 43 + 44 + private var gridStride: CGFloat { 45 + gridCellSide + gridSpacing 46 + } 47 + 48 + // MARK: - Layout protocol 49 + 50 + func sizeThatFits( 51 + proposal: ProposedViewSize, 52 + subviews: Subviews, 53 + cache _: inout () 54 + ) -> CGSize { 55 + let width = proposal.width ?? containerWidth 56 + guard !subviews.isEmpty else { return CGSize(width: width, height: 0) } 57 + 58 + switch mode { 59 + case .preview: 60 + return CGSize(width: width, height: stripThumbSize + stripVerticalPadding * 2) 61 + 62 + case .reorder: 63 + let rows = Int(ceil(Double(subviews.count) / Double(gridColumnCount))) 64 + let height = CGFloat(rows) * gridCellSide 65 + + CGFloat(max(0, rows - 1)) * gridSpacing 66 + + gridTopPadding + gridBottomPadding 67 + return CGSize(width: width, height: height) 68 + 69 + case .captions: 70 + var height: CGFloat = 0 71 + let rowWidth = max(0, width - captionsHorizontalPadding * 2) 72 + let rowProposal = ProposedViewSize(width: rowWidth, height: nil) 73 + for subview in subviews { 74 + let size = subview.sizeThatFits(rowProposal) 75 + height += size.height + captionsVerticalPadding * 2 76 + } 77 + return CGSize(width: width, height: height) 78 + } 79 + } 80 + 81 + func placeSubviews( 82 + in bounds: CGRect, 83 + proposal _: ProposedViewSize, 84 + subviews: Subviews, 85 + cache _: inout () 86 + ) { 87 + switch mode { 88 + case .preview: placeAsStrip(in: bounds, subviews: subviews) 89 + case .reorder: placeAsGrid(in: bounds, subviews: subviews) 90 + case .captions: placeAsCaptions(in: bounds, subviews: subviews) 91 + } 92 + } 93 + 94 + // MARK: - Strip placement 95 + 96 + private func placeAsStrip(in bounds: CGRect, subviews: Subviews) { 97 + let proposal = ProposedViewSize(width: stripThumbSize, height: stripThumbSize) 98 + let centerY = bounds.midY 99 + for subview in subviews { 100 + let index = subview[PhotoIndexKey.self] 101 + let x = bounds.minX + stripScrollOffset 102 + + CGFloat(index) * (stripThumbSize + stripSpacing) 103 + + stripThumbSize / 2 104 + subview.place(at: CGPoint(x: x, y: centerY), anchor: .center, proposal: proposal) 105 + } 106 + } 107 + 108 + // MARK: - Grid placement 109 + 110 + private func placeAsGrid(in bounds: CGRect, subviews: Subviews) { 111 + let proposal = ProposedViewSize(width: gridCellSide, height: gridCellSide) 112 + for subview in subviews { 113 + let index = subview[PhotoIndexKey.self] 114 + let col = index % gridColumnCount 115 + let row = index / gridColumnCount 116 + 117 + var offsetX: CGFloat = 0 118 + var offsetY: CGFloat = 0 119 + 120 + if let drag = dragPlacement { 121 + if index == drag.draggedIndex { 122 + // Dragged cell stays at its natural slot — the view-level 123 + // .offset modifier positions it at the finger in real time. 124 + // Keeping dragOffset out of the Layout ensures the sibling 125 + // spring animation transaction is never contaminated by the 126 + // immediate dragOffset update. 127 + } else { 128 + let slotDelta: Int = if drag.currentIndex > drag.draggedIndex, 129 + index > drag.draggedIndex, index <= drag.currentIndex 130 + { 131 + -1 132 + } else if drag.currentIndex < drag.draggedIndex, 133 + index >= drag.currentIndex, index < drag.draggedIndex 134 + { 135 + 1 136 + } else { 137 + 0 138 + } 139 + 140 + if slotDelta != 0 { 141 + let newIdx = index + slotDelta 142 + let newRow = newIdx / gridColumnCount 143 + let newCol = newIdx % gridColumnCount 144 + offsetX = CGFloat(newCol - col) * gridStride 145 + offsetY = CGFloat(newRow - row) * gridStride 146 + } 147 + } 148 + } 149 + 150 + let x = bounds.minX + gridOuterPadding 151 + + CGFloat(col) * gridStride + gridCellSide / 2 + offsetX 152 + let y = bounds.minY + gridTopPadding 153 + + CGFloat(row) * gridStride + gridCellSide / 2 + offsetY 154 + subview.place(at: CGPoint(x: x, y: y), anchor: .center, proposal: proposal) 155 + } 156 + } 157 + 158 + // MARK: - Captions placement 159 + 160 + private func placeAsCaptions(in bounds: CGRect, subviews: Subviews) { 161 + var y = bounds.minY 162 + let rowWidth = max(0, bounds.width - captionsHorizontalPadding * 2) 163 + let rowProposal = ProposedViewSize(width: rowWidth, height: nil) 164 + for subview in subviews { 165 + let size = subview.sizeThatFits(rowProposal) 166 + y += captionsVerticalPadding 167 + subview.place( 168 + at: CGPoint(x: bounds.minX + captionsHorizontalPadding, y: y), 169 + anchor: .topLeading, 170 + proposal: rowProposal 171 + ) 172 + y += size.height + captionsVerticalPadding 173 + } 174 + } 175 + }
+76
Grain/Views/Create/CaptionsListPrototype.swift
··· 1 + import SwiftUI 2 + import UIKit 3 + 4 + struct CaptionsListPrototype: View { 5 + @State var items: [PhotoItem] = [] 6 + @State private var sendExif = true 7 + 8 + var body: some View { 9 + NavigationStack { 10 + List { 11 + Toggle("Send EXIF", isOn: $sendExif) 12 + .listRowBackground(Color(.systemBackground)) 13 + 14 + ForEach($items) { $item in 15 + let exifState: ExifState = { 16 + guard item.exifSummary != nil else { return .absent } 17 + return sendExif ? .active : .inactive 18 + }() 19 + 20 + HStack(alignment: .top, spacing: 12) { 21 + Image(uiImage: item.thumbnail) 22 + .resizable() 23 + .scaledToFill() 24 + .frame(width: 60, height: 60) 25 + .clipped() 26 + .cornerRadius(8) 27 + 28 + VStack(alignment: .leading, spacing: 6) { 29 + TextField("Add a description for accessibility", text: $item.alt, axis: .vertical) 30 + .font(.subheadline) 31 + .lineLimit(2 ... 4) 32 + 33 + ExifChip(state: exifState) 34 + } 35 + } 36 + .padding(.vertical, 10) 37 + // Explicit row background prevents the drag ghost from going invisible. 38 + .listRowBackground(Color(.systemBackground)) 39 + .swipeActions(edge: .trailing) { 40 + Button(role: .destructive) { 41 + if let index = items.firstIndex(where: { $0.id == item.id }) { 42 + items.remove(at: index) 43 + } 44 + } label: { 45 + Label("Delete", systemImage: "trash") 46 + } 47 + } 48 + } 49 + } 50 + .listStyle(.plain) 51 + .navigationTitle("Captions") 52 + .navigationBarTitleDisplayMode(.inline) 53 + } // NavigationStack 54 + } 55 + } 56 + 57 + #Preview { 58 + let mockExif = ExifSummary( 59 + camera: "RICOH GR IIIx", 60 + lens: nil, 61 + exposure: nil, 62 + shutterSpeed: "1/250", 63 + iso: "400", 64 + focalLength: "40mm", 65 + aperture: "f/2.8" 66 + ) 67 + var items = PreviewData.photoItems 68 + // Assign exif to every other item so both states are visible in the list. 69 + for i in stride(from: 0, to: items.count, by: 2) { 70 + items[i].exifSummary = mockExif 71 + } 72 + return CaptionsListPrototype(items: items) 73 + .preferredColorScheme(.dark) 74 + .tint(Color("AccentColor")) 75 + .frame(maxHeight: .infinity, alignment: .top) 76 + }
+348 -200
Grain/Views/Create/CreateGalleryView.swift
··· 3 3 import os 4 4 import PhotosUI 5 5 import SwiftUI 6 + import UIKit 6 7 7 8 private let logger = Logger(subsystem: "social.grain.grain", category: "Create") 9 + private let createSignposter = OSSignposter(subsystem: "social.grain.grain", category: "PhotoLoading.TaskGroup") 10 + 11 + /// Limits concurrent photo-load tasks to avoid overwhelming the Swift cooperative thread pool. 12 + private actor LoadThrottle { 13 + private let maxConcurrent: Int 14 + private var active = 0 15 + private var waiters: [CheckedContinuation<Void, Never>] = [] 16 + 17 + init(maxConcurrent: Int) { 18 + self.maxConcurrent = maxConcurrent 19 + } 20 + 21 + func acquire(spid: OSSignpostID) async { 22 + if active < maxConcurrent { 23 + active += 1 24 + let a = active 25 + createSignposter.emitEvent("ThrottleAcquired", id: spid, "active=\(a),waiters=0") 26 + } else { 27 + let a = active, w = waiters.count 28 + let waitState = createSignposter.beginInterval("ThrottleWait", id: spid, "active=\(a),waiters=\(w)") 29 + await withCheckedContinuation { self.waiters.append($0) } 30 + let a2 = active 31 + createSignposter.endInterval("ThrottleWait", waitState, "active=\(a2)") 32 + } 33 + } 34 + 35 + func release(spid: OSSignpostID) { 36 + if let next = waiters.first { 37 + waiters.removeFirst() 38 + next.resume() 39 + let a = active, w = waiters.count 40 + createSignposter.emitEvent("ThrottleHandoff", id: spid, "active=\(a),waiters=\(w)") 41 + } else { 42 + active -= 1 43 + let a = active 44 + createSignposter.emitEvent("ThrottleReleased", id: spid, "active=\(a)") 45 + } 46 + } 47 + } 8 48 9 49 struct CreateGalleryView: View { 10 50 @Environment(AuthManager.self) private var auth ··· 15 55 @State private var isUploading = false 16 56 @State private var errorMessage: String? 17 57 @State private var resolvedLocation: (h3: String, name: String, address: [String: AnyCodable]?)? 18 - @State private var locationQuery = "" 19 - @State private var locationSuggestions: [NominatimResult] = [] 20 - @State private var isSearchingLocation = false 21 - @State private var locationSearchTask: Task<Void, Never>? 22 58 @State private var showCamera = false 23 59 @State private var photoItems: [PhotoItem] = [] 24 60 @State private var mentionState = MentionAutocompleteState() 25 61 @State private var postToBluesky = false 26 62 @State private var selectedLabels: Set<String> = [] 27 63 @State private var selectedPhotoID: UUID? 64 + @State private var photoLoadTask: Task<Void, Never>? 65 + /// Picker item identifiers that the user removed via the editor's X button. 66 + /// loadPickerPhotos skips these so deleted photos don't reappear. 67 + /// Cleared when the user explicitly re-selects items in the picker. 68 + @State private var editorRemovedIDs: Set<String> = [] 69 + @State private var lastPickerCount = 0 28 70 @State private var photoLocationResult: NominatimResult? 29 71 @State private var sendExif = true 30 72 @State private var includeLocation = true 73 + @State private var imageZoomState = ImageZoomState() 74 + /// True from the moment a cell is touched (arming window) through the end of 75 + /// the drag. Drives .scrollDisabled on the Form so neither the pre-fire hold 76 + /// nor the drag itself lets the Form scroll underneath the reorder gesture. 77 + @State private var isReordering = false 78 + /// True for the duration of a strip↔grid↔captions mode morph inside 79 + /// GalleryEditor. Drives `.scrollDisabled` alongside `isReordering` so 80 + /// UIKit's UICollectionView doesn't adjust scroll offset mid-morph — 81 + /// that adjustment shifts matched-geometry source/destination frames 82 + /// into different scroll contexts, producing wrong-direction morphs. 83 + @State private var isAnimatingMode = false 84 + @State private var editorMode: EditorMode = .preview 85 + @State private var showDiscardAlert = false 31 86 32 87 let client: XRPCClient 33 88 var onCreated: (() -> Void)? ··· 35 90 private let maxTitle = 100 36 91 private let maxDescription = 1000 37 92 93 + private var hasChanges: Bool { 94 + !photoItems.isEmpty || !title.isEmpty || !description.isEmpty || 95 + resolvedLocation != nil || !selectedLabels.isEmpty 96 + } 97 + 38 98 var body: some View { 39 - NavigationStack { 99 + // The Form is wrapped in an outer ZStack so `ImageZoomOverlay` attaches at 100 + // the ZStack level rather than to the Form directly. Applied to the Form, 101 + // its `.overlay { ... }` content lives inside the Form's own clipping 102 + // context (Form is a UICollectionView under the hood) which can leave the 103 + // zoomed image visually beneath sibling chrome on some transitions. Mounting 104 + // the overlay one level above guarantees it composites on top of every- 105 + // thing, mirroring how FeedView nests its zoom overlay above ScrollView. 106 + ZStack { 40 107 Form { 41 108 photosSection 42 109 gallerySection 43 110 photoEditorSection 111 + postPreviewSection 44 112 cameraDataSection 45 113 ContentLabelPicker(selectedLabels: $selectedLabels) 46 114 Section { 47 115 Toggle("Post to Bluesky", isOn: $postToBluesky) 116 + } footer: { 117 + Text("Includes location, description, and the first 4 photos.") 48 118 } 49 119 errorSection 50 120 } 51 - .safeAreaInset(edge: .bottom) { 52 - MentionSuggestionOverlay(state: mentionState) { suggestion in 53 - mentionState.complete(handle: suggestion.handle, in: &description) 54 - } 121 + // Lock the Form's vertical scroll while the zoom overlay is up so a 122 + // pinch that drifts vertically can't scroll the page underneath the 123 + // overlay. Also stays locked during reorder, same as before. 124 + .scrollDisabled(isReordering || isAnimatingMode || imageZoomState.showOverlay) 125 + .scrollDismissesKeyboard(.interactively) 126 + .background(SheetGestureDisabler(isDisabled: isReordering)) 127 + } 128 + .interactiveDismissDisabled(isReordering) 129 + .safeAreaInset(edge: .bottom) { 130 + MentionSuggestionOverlay(state: mentionState) { suggestion in 131 + mentionState.complete(handle: suggestion.handle, in: &description) 55 132 } 56 - .onChange(of: selectedPhotos) { 57 - Task { 58 - await loadPickerPhotos() 59 - if let id = selectedPhotoID, !photoItems.contains(where: { $0.id == id }) { 60 - selectedPhotoID = photoItems.first?.id 61 - } else if selectedPhotoID == nil { 62 - selectedPhotoID = photoItems.first?.id 63 - } 64 - await detectLocation() 65 - } 133 + } 134 + .onChange(of: selectedPhotos) { 135 + // If the user added items in the picker, clear any editor-removed 136 + // IDs that they re-selected so those photos load again. 137 + if selectedPhotos.count > lastPickerCount { 138 + let currentIDs = Set(selectedPhotos.compactMap(\.itemIdentifier)) 139 + editorRemovedIDs.subtract(currentIDs) 66 140 } 67 - .fullScreenCover(isPresented: $showCamera) { 68 - CameraPicker { image, metadata in 69 - let thumb = PhotoItem.makeThumbnail(from: image) 70 - let exif = metadata.flatMap { makeExifSummary(from: $0) } 71 - let item = PhotoItem(thumbnail: thumb, source: .camera(image, metadata: metadata), exifSummary: exif) 72 - photoItems.append(item) 73 - if selectedPhotoID == nil { selectedPhotoID = item.id } 141 + lastPickerCount = selectedPhotos.count 142 + 143 + createSignposter.emitEvent("TaskSpawned", "source=selectedPhotos,count=\(selectedPhotos.count)") 144 + photoLoadTask?.cancel() 145 + photoLoadTask = Task { 146 + await loadPickerPhotos() 147 + guard !Task.isCancelled else { return } 148 + if let id = selectedPhotoID, !photoItems.contains(where: { $0.id == id }) { 149 + selectedPhotoID = photoItems.first?.id 150 + } else if selectedPhotoID == nil { 151 + selectedPhotoID = photoItems.first?.id 74 152 } 75 - .ignoresSafeArea() 153 + await detectLocation() 76 154 } 77 - .task { 78 - if let authContext = await auth.authContext(), 79 - let prefs = try? await client.getPreferences(auth: authContext).preferences 80 - { 81 - if let exif = prefs.includeExif { sendExif = exif } 82 - if let location = prefs.includeLocation { includeLocation = location } 155 + } 156 + // Re-derive the suggested location whenever the *first* photo changes 157 + // (reorder, removal, etc.) so "Use first photo location" stays accurate. 158 + .onChange(of: photoItems.first?.id) { 159 + createSignposter.emitEvent("TaskSpawned", "source=firstPhotoChange,itemCount=\(photoItems.count)") 160 + Task { await detectLocation() } 161 + } 162 + .fullScreenCover(isPresented: $showCamera) { 163 + CameraPicker { image, metadata in 164 + let thumb = PhotoItem.makeThumbnail(from: image) 165 + let carousel = PhotoItem.makeCarouselPreview(from: image, width: UIScreen.main.bounds.width) 166 + let exif = metadata.flatMap { makeExifSummary(from: $0) } 167 + let item = PhotoItem(thumbnail: thumb, carouselPreview: carousel, source: .camera(image, metadata: metadata), exifSummary: exif) 168 + photoItems.append(item) 169 + if selectedPhotoID == nil { selectedPhotoID = item.id } 170 + } 171 + .ignoresSafeArea() 172 + } 173 + .task { 174 + if let authContext = await auth.authContext(), 175 + let prefs = try? await client.getPreferences(auth: authContext).preferences 176 + { 177 + if let exif = prefs.includeExif { sendExif = exif } 178 + if let location = prefs.includeLocation { includeLocation = location } 179 + } 180 + } 181 + .navigationTitle("New Gallery") 182 + .navigationBarTitleDisplayMode(.inline) 183 + .toolbar { 184 + ToolbarItem(placement: .topBarLeading) { 185 + Button { 186 + if hasChanges { 187 + showDiscardAlert = true 188 + } else { 189 + dismiss() 190 + } 191 + } label: { 192 + Image(systemName: "xmark") 193 + .foregroundStyle(hasChanges ? Color.accentColor : .primary) 83 194 } 84 195 } 85 - .navigationTitle("New Gallery") 86 - .toolbar { 87 - ToolbarItem(placement: .cancellationAction) { 88 - Button("Cancel") { dismiss() } 89 - } 90 - ToolbarItem(placement: .topBarTrailing) { 91 - Button { 92 - Task { await createGallery() } 93 - } label: { 94 - if isUploading { 95 - ProgressView() 96 - } else { 97 - Text("Post") 98 - .bold() 99 - } 196 + ToolbarItem(placement: .topBarTrailing) { 197 + Button { 198 + Task { await createGallery() } 199 + } label: { 200 + if isUploading { 201 + ProgressView() 202 + } else { 203 + Text("Post") 204 + .bold() 100 205 } 101 - .disabled(title.isEmpty || photoItems.isEmpty || isUploading || title.count > maxTitle || description.count > maxDescription) 102 206 } 207 + .disabled(title.isEmpty || photoItems.isEmpty || isUploading || title.count > maxTitle || description.count > maxDescription) 103 208 } 104 209 } 210 + .interactiveDismissDisabled(hasChanges) 211 + .alert("Discard gallery?", isPresented: $showDiscardAlert) { 212 + Button("Discard", role: .destructive) { dismiss() } 213 + Button("Keep Editing", role: .cancel) {} 214 + } 215 + .environment(imageZoomState) 216 + .modifier(ImageZoomOverlay(zoomState: imageZoomState)) 105 217 } 106 218 107 219 // MARK: - Form Sections ··· 111 223 PhotosPicker( 112 224 selection: $selectedPhotos, 113 225 maxSelectionCount: 20, 114 - matching: .images 226 + selectionBehavior: .continuousAndOrdered, 227 + matching: .images, 228 + photoLibrary: .shared() 115 229 ) { 116 230 Label("Select Photos", systemImage: "photo.on.rectangle.angled") 117 231 } ··· 121 235 } label: { 122 236 Label("Take Photo", systemImage: "camera") 123 237 } 124 - 125 - if !photoItems.isEmpty { 126 - ReorderablePhotoStrip(items: $photoItems, selectedPhotoID: $selectedPhotoID) 127 - Label("Touch and hold a photo to reorder", systemImage: "hand.draw") 128 - .font(.caption) 129 - .foregroundStyle(.secondary) 130 - } 131 238 } 132 239 } 133 240 134 241 @ViewBuilder 135 242 private var photoEditorSection: some View { 136 243 if !photoItems.isEmpty { 137 - Section("Alt Text") { 138 - Text("Alt text describes images for blind and low-vision users, and helps give context to everyone.") 139 - .font(.caption) 140 - .foregroundStyle(.secondary) 141 - ForEach($photoItems) { $item in 142 - VStack(alignment: .leading, spacing: 8) { 143 - HStack(alignment: .top, spacing: 12) { 144 - Image(uiImage: item.thumbnail) 145 - .resizable() 146 - .scaledToFit() 147 - .frame(width: 60) 148 - 149 - TextField("Describe this photo...", text: $item.alt, axis: .vertical) 150 - .font(.subheadline) 151 - .lineLimit(2 ... 4) 152 - } 153 - if let exif = item.exifSummary { 154 - VStack(alignment: .leading, spacing: 2) { 155 - if let camera = exif.camera { 156 - Text(camera).font(.caption) 157 - } 158 - HStack { 159 - Text([exif.shutterSpeed, exif.iso].compactMap(\.self).joined(separator: " ")) 160 - .font(.caption) 161 - Spacer() 162 - Text([exif.focalLength, exif.aperture].compactMap(\.self).joined(separator: " ")) 163 - .font(.caption) 164 - } 165 - } 166 - .foregroundStyle(sendExif ? .secondary : .tertiary) 167 - .padding(.leading, 72) 168 - } 169 - } 170 - .padding(.vertical, 4) 244 + GalleryEditor( 245 + items: $photoItems, 246 + selectedPhotoID: $selectedPhotoID, 247 + isReordering: $isReordering, 248 + isAnimatingMode: $isAnimatingMode, 249 + mode: $editorMode, 250 + sendExif: sendExif, 251 + onDeleteItem: { item in 252 + guard case let .picker(pickerItem) = item.source, 253 + let id = pickerItem.itemIdentifier else { return } 254 + editorRemovedIDs.insert(id) 255 + selectedPhotos.removeAll { $0.itemIdentifier == id } 171 256 } 257 + ) 258 + } 259 + } 260 + 261 + @ViewBuilder 262 + private var postPreviewSection: some View { 263 + if editorMode == .preview, !photoItems.isEmpty { 264 + Section { 265 + PhotoCarouselView( 266 + items: photoItems, 267 + selectedPhotoID: $selectedPhotoID, 268 + sendExif: sendExif 269 + ) 270 + .id(photoItems.count) 271 + .listRowInsets(EdgeInsets()) 272 + .listRowSeparator(.hidden) 273 + .listRowBackground(Color.black) 274 + } header: { 275 + Text("Preview") 172 276 } 277 + .transition(.opacity) 173 278 } 174 279 } 175 280 ··· 208 313 } 209 314 } 210 315 211 - @ViewBuilder 212 316 private var locationRow: some View { 213 - if let loc = resolvedLocation { 214 - HStack { 215 - Label(loc.name, systemImage: "mappin.and.ellipse") 216 - .font(.subheadline) 217 - .lineLimit(1) 218 - Spacer() 219 - Button { 220 - resolvedLocation = nil 221 - locationQuery = "" 222 - } label: { 223 - Image(systemName: "xmark.circle.fill") 224 - .foregroundStyle(.secondary) 225 - } 226 - } 227 - } else { 228 - if let photoLoc = photoLocationResult { 229 - Button { selectLocation(photoLoc) } label: { 230 - HStack(spacing: 10) { 231 - Image(systemName: "location.fill") 232 - .foregroundStyle(.secondary) 233 - .frame(width: 20) 234 - VStack(alignment: .leading, spacing: 1) { 235 - Text("Use photo location") 236 - .font(.subheadline) 237 - Text(photoLoc.name) 238 - .font(.caption) 239 - .foregroundStyle(.secondary) 240 - } 241 - } 242 - } 243 - .foregroundStyle(.primary) 244 - } 245 - locationSearchField 246 - ForEach(locationSuggestions, id: \.placeId) { result in 247 - Button { 248 - selectLocation(result) 249 - } label: { 250 - VStack(alignment: .leading, spacing: 2) { 251 - Text(result.name) 252 - .font(.subheadline) 253 - .foregroundStyle(.primary) 254 - if let context = result.context { 255 - Text(context) 256 - .font(.caption) 257 - .foregroundStyle(.secondary) 258 - } 259 - } 260 - } 261 - } 262 - } 263 - } 264 - 265 - private var locationSearchField: some View { 266 - HStack { 267 - Image(systemName: "magnifyingglass") 268 - .foregroundStyle(.secondary) 269 - TextField("Search for a location...", text: $locationQuery) 270 - .textInputAutocapitalization(.never) 271 - .onChange(of: locationQuery) { 272 - locationSearchTask?.cancel() 273 - let query = locationQuery 274 - locationSearchTask = Task { 275 - try? await Task.sleep(for: .milliseconds(300)) 276 - guard !Task.isCancelled else { return } 277 - await searchLocation(query: query) 278 - } 279 - } 280 - if isSearchingLocation { 281 - ProgressView() 282 - .controlSize(.small) 283 - } 284 - } 317 + LocationPickerRows( 318 + resolvedLocation: $resolvedLocation, 319 + photoLocationResult: photoLocationResult, 320 + photoLocationLabel: "Use first photo location", 321 + onSelectLocation: selectLocation 322 + ) 285 323 } 286 324 287 325 @ViewBuilder ··· 325 363 return pickerItem.itemIdentifier 326 364 }) 327 365 328 - // Only load and append truly new selections 329 - for item in selectedPhotos where !(item.itemIdentifier.map { existingIDs.contains($0) } ?? false) { 330 - if let data = try? await item.loadTransferable(type: Data.self), 331 - let image = UIImage(data: data) 332 - { 333 - let thumb = PhotoItem.makeThumbnail(from: image) 334 - let exif = makeExifSummary(from: data) 335 - photoItems.append(PhotoItem(thumbnail: thumb, source: .picker(item), exifSummary: exif)) 366 + let newSelections = selectedPhotos.filter { 367 + let isExisting = $0.itemIdentifier.map { existingIDs.contains($0) } ?? false 368 + let isRemoved = $0.itemIdentifier.map { editorRemovedIDs.contains($0) } ?? false 369 + return !isExisting && !isRemoved 370 + } 371 + guard !newSelections.isEmpty else { return } 372 + 373 + // Load all new items concurrently, preserving selection order. 374 + // Capture screen width here (main actor) before task bodies run on 375 + // background threads where UIScreen.main is unavailable. 376 + let batchState = createSignposter.beginInterval("LoadPickerBatch", id: createSignposter.makeSignpostID(), "count=\(newSelections.count)") 377 + let carouselWidth = UIScreen.main.bounds.width 378 + var loaded: [(index: Int, item: PhotoItem)] = [] 379 + let throttle = LoadThrottle(maxConcurrent: 8) 380 + await withTaskGroup(of: (Int, PhotoItem?).self) { group in 381 + for (index, pickerItem) in newSelections.enumerated() { 382 + let spid = createSignposter.makeSignpostID() 383 + group.addTask { 384 + await throttle.acquire(spid: spid) 385 + defer { Task { await throttle.release(spid: spid) } } 386 + let state = createSignposter.beginInterval("LoadPhoto", id: spid, "index=\(index)") 387 + guard let data = try? await pickerItem.loadTransferable(type: Data.self), 388 + let image = UIImage(data: data) 389 + else { 390 + createSignposter.endInterval("LoadPhoto", state, "result=nil") 391 + return (index, nil) 392 + } 393 + let thumb = PhotoItem.makeThumbnail(from: image) 394 + let carousel = PhotoItem.makeCarouselPreview(from: image, width: carouselWidth) 395 + let exif = makeExifSummary(from: data) 396 + createSignposter.endInterval("LoadPhoto", state, "result=ok") 397 + return (index, PhotoItem(thumbnail: thumb, carouselPreview: carousel, source: .picker(pickerItem), exifSummary: exif)) 398 + } 399 + } 400 + for await (index, item) in group { 401 + if let item { loaded.append((index, item)) } 336 402 } 337 403 } 404 + createSignposter.endInterval("LoadPickerBatch", batchState, "loaded=\(loaded.count)") 405 + 406 + // Dedup: with .continuousAndOrdered the picker fires onChange per-item, 407 + // so a previous load may have already added some of these. 408 + let alreadyLoaded = Set(photoItems.compactMap { item -> String? in 409 + guard case let .picker(p) = item.source else { return nil } 410 + return p.itemIdentifier 411 + }) 412 + let deduped = loaded.sorted(by: { $0.index < $1.index }).map(\.item).filter { item in 413 + guard case let .picker(p) = item.source else { return true } 414 + return !(p.itemIdentifier.map { alreadyLoaded.contains($0) } ?? false) 415 + } 416 + photoItems += deduped 338 417 } 339 418 340 419 private func detectLocation() async { 341 - // Always extract GPS from the first photo so "Use photo location" can be offered 420 + // Always derive from the *currently first* photo so reordering re-runs detection. 342 421 photoLocationResult = nil 343 - for item in photoItems { 344 - var gps: (latitude: Double, longitude: Double)? 422 + guard let first = photoItems.first else { return } 345 423 346 - switch item.source { 347 - case let .picker(pickerItem): 348 - if let data = try? await pickerItem.loadTransferable(type: Data.self) { 349 - gps = ImageProcessing.extractGPS(from: data) 350 - } 351 - case .camera: 352 - continue 424 + let state = createSignposter.beginInterval("DetectLocation", id: createSignposter.makeSignpostID()) 425 + var gps: (latitude: Double, longitude: Double)? 426 + switch first.source { 427 + case let .picker(pickerItem): 428 + if let data = try? await pickerItem.loadTransferable(type: Data.self) { 429 + gps = ImageProcessing.extractGPS(from: data) 353 430 } 431 + case .camera: 432 + createSignposter.endInterval("DetectLocation", state, "source=camera,skipped") 433 + return 434 + } 354 435 355 - guard let gps else { continue } 436 + guard let gps else { 437 + createSignposter.endInterval("DetectLocation", state, "result=noGPS") 438 + return 439 + } 356 440 357 - if let result = await LocationServices.reverseGeocode(latitude: gps.latitude, longitude: gps.longitude) { 358 - photoLocationResult = result 359 - if includeLocation, resolvedLocation == nil { 360 - selectLocation(result) 361 - } 441 + if let result = await LocationServices.reverseGeocode(latitude: gps.latitude, longitude: gps.longitude) { 442 + photoLocationResult = result 443 + if includeLocation, resolvedLocation == nil { 444 + selectLocation(result) 362 445 } 363 - break 364 446 } 365 - } 366 - 367 - private func searchLocation(query: String) async { 368 - isSearchingLocation = true 369 - defer { isSearchingLocation = false } 370 - locationSuggestions = await LocationServices.searchLocation(query: query) 447 + createSignposter.endInterval("DetectLocation", state, "result=ok") 371 448 } 372 449 373 450 private func selectLocation(_ result: NominatimResult) { 374 451 let h3 = LocationServices.latLonToH3(latitude: result.latitude, longitude: result.longitude) 375 452 resolvedLocation = (h3: h3, name: result.name, address: result.address) 376 - locationQuery = "" 377 - locationSuggestions = [] 378 453 } 379 454 380 455 // MARK: - Create Gallery ··· 497 572 struct PhotoItem: Identifiable { 498 573 let id = UUID() 499 574 let thumbnail: UIImage 575 + /// Screen-width image for the carousel. Built at creation time via 576 + /// `UIGraphicsImageRenderer`, which forces a full decode during the draw 577 + /// call — so the resulting UIImage is backed by a decoded bitmap and 578 + /// displays with zero decode work. Kept in memory for the editor session 579 + /// so the carousel never stalls on first draw regardless of scroll speed. 580 + let carouselPreview: UIImage 500 581 let source: PhotoSource 501 582 var alt: String = "" 502 583 var exifSummary: ExifSummary? 503 584 585 + /// Thumbnail's natural width-to-height ratio. Computed once from `thumbnail.size` 586 + /// and used everywhere a cell needs aspect geometry — single source of truth. 587 + var naturalAspect: CGFloat { 588 + let h = thumbnail.size.height 589 + guard h > 0 else { return 1 } 590 + return thumbnail.size.width / h 591 + } 592 + 504 593 static func makeThumbnail(from image: UIImage, maxSize: CGFloat = 150) -> UIImage { 505 594 let scale = min(maxSize / image.size.width, maxSize / image.size.height, 1) 595 + let newSize = CGSize(width: image.size.width * scale, height: image.size.height * scale) 596 + let renderer = UIGraphicsImageRenderer(size: newSize) 597 + return renderer.image { _ in 598 + image.draw(in: CGRect(origin: .zero, size: newSize)) 599 + } 600 + } 601 + 602 + /// Downscale `image` so its width matches `width` (default: screen width), 603 + /// preserving aspect ratio. The renderer applies UIScreen.main.scale so 604 + /// the output is pixel-perfect at 1× zoom in the carousel without 605 + /// upscaling on any standard iPhone. 606 + static func makeCarouselPreview(from image: UIImage, width: CGFloat) -> UIImage { 607 + let scale = min(width / image.size.width, 1) 506 608 let newSize = CGSize(width: image.size.width * scale, height: image.size.height * scale) 507 609 let renderer = UIGraphicsImageRenderer(size: newSize) 508 610 return renderer.image { _ in ··· 712 814 summary.shutterSpeed = et < 1 ? "1/\(Int((1 / et).rounded()))s" : "\(et)s" 713 815 } 714 816 if let fn = exifDict?[kCGImagePropertyExifFNumber as String] as? Double { 715 - summary.aperture = "f/\(fn)" 817 + summary.aperture = formatAperture(fn) 716 818 } 717 819 if let isoRaw = exifDict?[kCGImagePropertyExifISOSpeedRatings as String] as? [Any], 718 820 let iso = (isoRaw.first as? NSNumber)?.intValue ··· 766 868 NavigationStack { 767 869 CreateGalleryViewPreview(photoItems: $photos, selectedPhotoID: $selectedID) 768 870 } 769 - .environment(AuthManager()) 770 - .environment(LabelDefinitionsCache()) 871 + .previewEnvironments() 771 872 .onAppear { selectedID = photos.first?.id } 772 873 } 773 874 ··· 789 890 } header: { 790 891 Text("Gallery") 791 892 } 893 + Section { 894 + GalleryEditor( 895 + items: $photoItems, 896 + selectedPhotoID: $selectedPhotoID, 897 + isReordering: .constant(false), 898 + isAnimatingMode: .constant(false), 899 + mode: .constant(.preview), 900 + sendExif: true 901 + ) 902 + } 792 903 } 793 904 .navigationTitle("New Gallery") 794 905 .toolbar { 795 906 ToolbarItem(placement: .cancellationAction) { Button("Cancel") {} } 796 907 ToolbarItem(placement: .topBarTrailing) { Button("Post") {}.bold() } 908 + } 909 + .grainPreview() 910 + } 911 + } 912 + 913 + // MARK: - Sheet gesture disabler 914 + 915 + /// Disables the `UISheetPresentationController`'s pan gesture while active so 916 + /// the card cannot move at all — `interactiveDismissDisabled` only prevents 917 + /// the dismiss *completion*, not the downward *motion*. Used during photo 918 + /// reorder so the sheet stays perfectly still while a photo is picked up. 919 + private struct SheetGestureDisabler: UIViewRepresentable { 920 + let isDisabled: Bool 921 + 922 + func makeUIView(context _: Context) -> UIView { 923 + UIView() 924 + } 925 + 926 + func updateUIView(_ uiView: UIView, context _: Context) { 927 + // Capture before hopping to async so we get the value at call time. 928 + let disabled = isDisabled 929 + DispatchQueue.main.async { 930 + // Walk the responder chain from our UIView up to the first 931 + // UIViewController whose presentationController is the sheet. 932 + var responder: UIResponder? = uiView 933 + while let r = responder { 934 + if let vc = r as? UIViewController, 935 + vc.presentationController is UISheetPresentationController, 936 + let presentedView = vc.presentationController?.presentedView 937 + { 938 + for gesture in presentedView.gestureRecognizers ?? [] where gesture is UIPanGestureRecognizer { 939 + gesture.isEnabled = !disabled 940 + } 941 + return 942 + } 943 + responder = r.next 944 + } 797 945 } 798 946 } 799 947 }
+33 -24
Grain/Views/Create/DragToReorder.swift
··· 1 1 import SwiftUI 2 2 3 - /// Visual treatment for a draggable, selectable thumbnail in a reorderable collection. 3 + /// Visual treatment for a selectable thumbnail in a reorderable collection. 4 + /// Only covers the rounded corner clip + selection ring; the pickup animation 5 + /// (scale, opacity, shadow) lives at PhotoThumbnailCell's outer body so the X 6 + /// button scales together with the photo. 4 7 struct ReorderableThumbnail: ViewModifier { 5 - let isDragging: Bool 6 8 let isSelected: Bool 7 9 let cornerRadius: CGFloat 8 10 ··· 14 16 .stroke(Color.accentColor, lineWidth: 2.5) 15 17 .opacity(isSelected ? 1 : 0) 16 18 ) 17 - .scaleEffect(isDragging ? 1.13 : 1) 18 - .shadow( 19 - color: isDragging ? .black.opacity(0.25) : .clear, 20 - radius: 8, y: 4 21 - ) 22 - .opacity(isDragging ? 0.92 : 1) 23 - .zIndex(isDragging ? 1 : 0) 24 19 } 25 20 } 26 21 27 22 extension View { 28 - func reorderableThumbnail(isDragging: Bool, isSelected: Bool, cornerRadius: CGFloat = 8) -> some View { 29 - modifier(ReorderableThumbnail(isDragging: isDragging, isSelected: isSelected, cornerRadius: cornerRadius)) 23 + func reorderableThumbnail(isSelected: Bool, cornerRadius: CGFloat = 8) -> some View { 24 + modifier(ReorderableThumbnail(isSelected: isSelected, cornerRadius: cornerRadius)) 30 25 } 31 26 } 32 27 33 28 #Preview { 34 - HStack(spacing: 8) { 35 - RoundedRectangle(cornerRadius: 8) 36 - .fill(Color.gray.opacity(0.3)) 37 - .frame(width: 72, height: 72) 38 - .reorderableThumbnail(isDragging: false, isSelected: false) 39 - RoundedRectangle(cornerRadius: 8) 40 - .fill(Color.gray.opacity(0.3)) 41 - .frame(width: 72, height: 72) 42 - .reorderableThumbnail(isDragging: false, isSelected: true) 43 - RoundedRectangle(cornerRadius: 8) 44 - .fill(Color.gray.opacity(0.3)) 45 - .frame(width: 72, height: 72) 46 - .reorderableThumbnail(isDragging: true, isSelected: false) 29 + // Show a row of thumbnails with varied colors simulating real photos, 30 + // plus a larger card size, to exercise both the unselected and selected 31 + // ring states at a glance. 32 + let thumbnailColors: [Color] = [.indigo, .teal, .orange, .pink] 33 + VStack(spacing: 20) { 34 + // Standard 72pt strip (unselected, selected, unselected, unselected) 35 + HStack(spacing: 8) { 36 + ForEach(Array(thumbnailColors.enumerated()), id: \.offset) { idx, color in 37 + RoundedRectangle(cornerRadius: 8) 38 + .fill(color.opacity(0.55)) 39 + .frame(width: 72, height: 72) 40 + .reorderableThumbnail(isSelected: idx == 1) 41 + } 42 + } 43 + 44 + // Larger card size — exercises the cornerRadius parameter 45 + HStack(spacing: 12) { 46 + ForEach(Array(thumbnailColors.prefix(3).enumerated()), id: \.offset) { idx, color in 47 + RoundedRectangle(cornerRadius: 12) 48 + .fill(color.opacity(0.55)) 49 + .frame(width: 100, height: 100) 50 + .reorderableThumbnail(isSelected: idx == 0, cornerRadius: 12) 51 + } 52 + } 47 53 } 48 54 .padding() 55 + .background(Color(.systemBackground)) 56 + .preferredColorScheme(.dark) 57 + .tint(Color("AccentColor")) 49 58 }
+24
Grain/Views/Create/ExifChip.swift
··· 1 + import SwiftUI 2 + 3 + /// Three-state exif indicator: absent = no exif data, inactive = has exif but 4 + /// sendExif is off, active = has exif and will be uploaded. 5 + enum ExifState: Equatable { 6 + case absent, inactive, active 7 + } 8 + 9 + /// EXIF camera badge shown on photo thumbnails. 10 + struct ExifChip: View { 11 + let state: ExifState 12 + 13 + var body: some View { 14 + if state != .absent { 15 + let on = state == .active 16 + Image(systemName: "camera.fill") 17 + .font(.system(size: 9, weight: .medium)) 18 + .foregroundStyle(.white) 19 + .frame(width: 19, height: 19) 20 + .background(.black.opacity(0.6), in: RoundedRectangle(cornerRadius: 4)) 21 + .opacity(on ? 1 : 0.5) 22 + } 23 + } 24 + }
+679
Grain/Views/Create/GalleryEditor.swift
··· 1 + import os 2 + import SwiftUI 3 + import UIKit 4 + 5 + private let photoLoadingSignposter = OSSignposter(subsystem: "social.grain.grain", category: "PhotoLoading.Preview") 6 + private let morphSignposter = OSSignposter(subsystem: "social.grain.grain", category: "Animation.Morph") 7 + private let reorderSignposter = OSSignposter(subsystem: "social.grain.grain", category: "Animation.Reorder") 8 + 9 + // MARK: - Preview cache store 10 + 11 + /// Holds the LRU preview-image cache that the carousel uses for hi-res zoom. 12 + /// 13 + /// Extracted from `GalleryEditor` so that cache mutations — cache hits, task 14 + /// bookkeeping — only invalidate `PhotoCarouselView`, which actually reads 15 + /// `previewCache`. Any mutation on a plain `@State` dictionary on `PhotoEditor` 16 + /// would have re-rendered the entire editor, even though none of those views 17 + /// touch this data. 18 + /// 19 + /// `@Observable` lets us be surgical: only `previewCache` participates in 20 + /// tracking, while the three bookkeeping properties are marked 21 + /// `@ObservationIgnored` so writes to them don't trigger a re-render at all. 22 + @Observable 23 + @MainActor 24 + final class PreviewCacheStore { 25 + var previewCache: [UUID: UIImage] = [:] 26 + 27 + @ObservationIgnored private var previewCacheOrder: [UUID] = [] 28 + @ObservationIgnored private var loadingPreviewIDs: Set<UUID> = [] 29 + @ObservationIgnored private var prefetchTasks: [UUID: Task<Void, Never>] = [:] 30 + 31 + let previewMaxDimension: CGFloat = 1500 32 + let previewCacheLimit = 5 33 + 34 + func cancelAllTasks() { 35 + prefetchTasks.values.forEach { $0.cancel() } 36 + prefetchTasks.removeAll() 37 + } 38 + 39 + func prefetchPreviewsAroundSelection(items: [PhotoItem], selectedPhotoID: UUID?) { 40 + guard let id = selectedPhotoID, 41 + let centerIdx = items.firstIndex(where: { $0.id == id }) else { return } 42 + let state = photoLoadingSignposter.beginInterval("PrefetchWindow", id: photoLoadingSignposter.makeSignpostID(), "center=\(centerIdx),total=\(items.count)") 43 + for offset in -2 ... 2 { 44 + let idx = centerIdx + offset 45 + guard items.indices.contains(idx) else { continue } 46 + loadPreviewIfNeeded(for: items[idx], items: items) 47 + } 48 + photoLoadingSignposter.endInterval("PrefetchWindow", state) 49 + } 50 + 51 + private func loadPreviewIfNeeded(for item: PhotoItem, items: [PhotoItem]) { 52 + guard previewCache[item.id] == nil, 53 + !loadingPreviewIDs.contains(item.id) else { return } 54 + loadingPreviewIDs.insert(item.id) 55 + let id = item.id 56 + let source = item.source 57 + let maxDim = previewMaxDimension 58 + let cacheLimit = previewCacheLimit 59 + let task = Task.detached(priority: .utility) { [weak self] in 60 + let spid = photoLoadingSignposter.makeSignpostID() 61 + let previewState = photoLoadingSignposter.beginInterval("LoadPreview", id: spid) 62 + let image = await PreviewCacheStore.loadPreviewImage(source: source, maxDimension: maxDim) 63 + photoLoadingSignposter.endInterval("LoadPreview", previewState, "success=\(image != nil)") 64 + await MainActor.run { 65 + guard let self else { return } 66 + self.prefetchTasks.removeValue(forKey: id) 67 + self.loadingPreviewIDs.remove(id) 68 + guard let image else { return } 69 + guard items.contains(where: { $0.id == id }) else { return } 70 + self.previewCache[id] = image 71 + self.previewCacheOrder.removeAll { $0 == id } 72 + self.previewCacheOrder.append(id) 73 + while self.previewCacheOrder.count > cacheLimit { 74 + let evict = self.previewCacheOrder.removeFirst() 75 + self.previewCache.removeValue(forKey: evict) 76 + } 77 + } 78 + } 79 + prefetchTasks[id] = task 80 + } 81 + 82 + private nonisolated static func loadPreviewImage(source: PhotoSource, maxDimension: CGFloat) async -> UIImage? { 83 + switch source { 84 + case let .picker(pickerItem): 85 + let transferState = photoLoadingSignposter.beginInterval("LoadTransferable", id: photoLoadingSignposter.makeSignpostID()) 86 + guard let data = try? await pickerItem.loadTransferable(type: Data.self), 87 + let image = UIImage(data: data) 88 + else { 89 + photoLoadingSignposter.endInterval("LoadTransferable", transferState, "result=nil") 90 + return nil 91 + } 92 + photoLoadingSignposter.endInterval("LoadTransferable", transferState, "bytes=\(data.count)") 93 + let thumbState = photoLoadingSignposter.beginInterval("MakeThumbnail", id: photoLoadingSignposter.makeSignpostID()) 94 + let resized = PhotoItem.makeThumbnail(from: image, maxSize: maxDimension) 95 + photoLoadingSignposter.endInterval("MakeThumbnail", thumbState) 96 + let decodeState = photoLoadingSignposter.beginInterval("Decompress", id: photoLoadingSignposter.makeSignpostID()) 97 + let result = await resized.byPreparingForDisplay() ?? resized 98 + photoLoadingSignposter.endInterval("Decompress", decodeState) 99 + return result 100 + case let .camera(image, _): 101 + let thumbState = photoLoadingSignposter.beginInterval("MakeThumbnail", id: photoLoadingSignposter.makeSignpostID()) 102 + let resized = PhotoItem.makeThumbnail(from: image, maxSize: maxDimension) 103 + photoLoadingSignposter.endInterval("MakeThumbnail", thumbState) 104 + let decodeState = photoLoadingSignposter.beginInterval("Decompress", id: photoLoadingSignposter.makeSignpostID()) 105 + let result = await resized.byPreparingForDisplay() ?? resized 106 + photoLoadingSignposter.endInterval("Decompress", decodeState) 107 + return result 108 + } 109 + } 110 + } 111 + 112 + // MARK: - Photo carousel view 113 + 114 + struct PhotoCarouselView: View { 115 + let items: [PhotoItem] 116 + @Binding var selectedPhotoID: UUID? 117 + let sendExif: Bool 118 + 119 + @State private var showingCarouselAlt = false 120 + @State private var cacheStore = PreviewCacheStore() 121 + 122 + private var selectedIndex: Int? { 123 + guard let id = selectedPhotoID else { return nil } 124 + return items.firstIndex(where: { $0.id == id }) 125 + } 126 + 127 + var body: some View { 128 + if items.isEmpty { 129 + EmptyView() 130 + } else { 131 + let ratios = items.map(\.naturalAspect) 132 + let hasMixedRatios = Set(ratios.map { Int($0 * 100) }).count > 1 133 + let safeIdx = min(max(selectedIndex ?? 0, 0), items.count - 1) 134 + let carouselRatio: CGFloat = hasMixedRatios 135 + ? max(ratios.min() ?? 1, 0.56) 136 + : ratios[safeIdx] 137 + 138 + VStack(spacing: 0) { 139 + ZStack(alignment: .bottom) { 140 + Color.black 141 + 142 + TabView(selection: $selectedPhotoID) { 143 + ForEach(items) { item in 144 + ZStack { 145 + ZoomableImage( 146 + localImage: item.carouselPreview, 147 + aspectRatio: item.naturalAspect, 148 + zoomImage: cacheStore.previewCache[item.id] 149 + ) 150 + 151 + if showingCarouselAlt { 152 + let alt = item.alt.trimmingCharacters(in: .whitespaces) 153 + if !alt.isEmpty { 154 + ZStack { 155 + Color.black.opacity(0.6) 156 + Text(alt) 157 + .font(.subheadline) 158 + .foregroundStyle(.white) 159 + .multilineTextAlignment(.center) 160 + .padding(20) 161 + } 162 + .allowsHitTesting(false) 163 + } 164 + } 165 + } 166 + .tag(item.id as UUID?) 167 + } 168 + } 169 + .tabViewStyle(.page(indexDisplayMode: .never)) 170 + 171 + pageIndicator 172 + altPillIndicator(for: safeIdx) 173 + } 174 + .frame(maxWidth: .infinity) 175 + .aspectRatio(carouselRatio, contentMode: .fit) 176 + .contentShape(Rectangle()) 177 + .onTapGesture { 178 + let alt = items[safeIdx].alt.trimmingCharacters(in: .whitespaces) 179 + guard !alt.isEmpty else { return } 180 + withAnimation(.smooth) { showingCarouselAlt.toggle() } 181 + } 182 + .onChange(of: selectedPhotoID) { _, newID in 183 + showingCarouselAlt = false 184 + if newID != nil { 185 + cacheStore.prefetchPreviewsAroundSelection(items: items, selectedPhotoID: selectedPhotoID) 186 + } 187 + } 188 + .onAppear { 189 + cacheStore.prefetchPreviewsAroundSelection(items: items, selectedPhotoID: selectedPhotoID) 190 + } 191 + .onDisappear { 192 + cacheStore.cancelAllTasks() 193 + } 194 + 195 + if items.contains(where: { $0.exifSummary != nil }), let idx = selectedIndex { 196 + ExifInfoView( 197 + exif: items[idx].exifSummary?.displayData, 198 + reserveCameraRow: items.contains(where: { $0.exifSummary?.camera != nil }), 199 + reserveLensRow: items.contains(where: { $0.exifSummary?.lens != nil }), 200 + style: AnyShapeStyle(sendExif ? .secondary : .tertiary) 201 + ) 202 + .transaction { $0.animation = nil } 203 + .padding(.horizontal, 12) 204 + .padding(.top, 8) 205 + .padding(.bottom, 12) 206 + .frame(maxWidth: .infinity, alignment: .leading) 207 + } 208 + } 209 + .background(Color(.secondarySystemBackground)) 210 + } 211 + } 212 + 213 + @ViewBuilder 214 + private var pageIndicator: some View { 215 + if items.count > 1 { 216 + let current = selectedIndex ?? 0 217 + let ratios = items.map(\.naturalAspect) 218 + let hasPortrait = ratios.contains { $0 < 1 } 219 + HStack(spacing: 5) { 220 + let total = items.count 221 + let maxVisible = 5 222 + let start = total <= maxVisible ? 0 : min(max(current - 2, 0), total - maxVisible) 223 + let end = total <= maxVisible ? total : start + maxVisible 224 + 225 + ForEach(start ..< end, id: \.self) { index in 226 + let distance = abs(index - current) 227 + let currentIsLandscape = ratios[current] >= 1 228 + let dotColor: Color = hasPortrait && currentIsLandscape ? .secondary : .white 229 + Circle() 230 + .fill(dotColor.opacity(index == current ? 1.0 : distance == 1 ? 0.5 : distance == 2 ? 0.3 : 0.2)) 231 + .frame( 232 + width: distance <= 1 ? 6 : distance == 2 ? 4 : 3, 233 + height: distance <= 1 ? 6 : distance == 2 ? 4 : 3 234 + ) 235 + .animation(.smooth, value: current) 236 + } 237 + } 238 + .padding(.vertical, 8) 239 + } 240 + } 241 + 242 + @ViewBuilder 243 + private func altPillIndicator(for index: Int) -> some View { 244 + let hasAlt = !items[index].alt.trimmingCharacters(in: .whitespaces).isEmpty 245 + VStack { 246 + Spacer() 247 + HStack { 248 + Spacer() 249 + Text("ALT") 250 + .font(.caption2.weight(.bold)) 251 + .padding(.horizontal, 6) 252 + .padding(.vertical, 3) 253 + .background(.black.opacity(0.6), in: RoundedRectangle(cornerRadius: 4)) 254 + .foregroundStyle(.white) 255 + .opacity(hasAlt ? 1 : 0.5) 256 + .padding(8) 257 + } 258 + } 259 + .allowsHitTesting(false) 260 + } 261 + } 262 + 263 + // MARK: - Editor mode 264 + 265 + enum EditorMode: Equatable, CaseIterable { 266 + case preview 267 + case reorder 268 + case captions 269 + 270 + var label: String { 271 + switch self { 272 + case .preview: "Preview" 273 + case .reorder: "Reorder" 274 + case .captions: "Captions" 275 + } 276 + } 277 + } 278 + 279 + // MARK: - Cell geometry 280 + 281 + struct CellGeometry: Equatable { 282 + let mode: EditorMode 283 + let maskSide: CGFloat 284 + let photoAspect: CGFloat 285 + 286 + var photoSize: CGSize { 287 + switch mode { 288 + case .preview: 289 + photoAspect >= 1 290 + ? CGSize(width: maskSide * photoAspect, height: maskSide) 291 + : CGSize(width: maskSide, height: maskSide / photoAspect) 292 + case .reorder: 293 + photoAspect >= 1 294 + ? CGSize(width: maskSide, height: maskSide / photoAspect) 295 + : CGSize(width: maskSide * photoAspect, height: maskSide) 296 + case .captions: 297 + photoAspect >= 1 298 + ? CGSize(width: maskSide * photoAspect, height: maskSide) 299 + : CGSize(width: maskSide, height: maskSide / photoAspect) 300 + } 301 + } 302 + 303 + var maskCornerRadius: CGFloat { 304 + mode == .reorder ? 0 : 8 305 + } 306 + } 307 + 308 + // MARK: - Wallet-style removal transition 309 + 310 + private struct WalletRemoveModifier: ViewModifier { 311 + let isRemoving: Bool 312 + func body(content: Content) -> some View { 313 + content 314 + .scaleEffect(isRemoving ? 0.5 : 1, anchor: .center) 315 + .offset(y: isRemoving ? -20 : 0) 316 + .opacity(isRemoving ? 0 : 1) 317 + } 318 + } 319 + 320 + extension AnyTransition { 321 + static var walletRemove: AnyTransition { 322 + .asymmetric( 323 + insertion: .scale(scale: 0.85).combined(with: .opacity), 324 + removal: .modifier( 325 + active: WalletRemoveModifier(isRemoving: true), 326 + identity: WalletRemoveModifier(isRemoving: false) 327 + ) 328 + ) 329 + } 330 + } 331 + 332 + // MARK: - GalleryEditor 333 + 334 + struct GalleryEditor: View { 335 + @Binding var items: [PhotoItem] 336 + @Binding var selectedPhotoID: UUID? 337 + @Binding var isReordering: Bool 338 + @Binding var isAnimatingMode: Bool 339 + @Binding var mode: EditorMode 340 + let sendExif: Bool 341 + var onDeleteItem: ((PhotoItem) -> Void)? 342 + @State private var gridContainerWidth: CGFloat = 0 343 + @State private var stripState = StripScrollState() 344 + @State private var reorderState = ReorderDragState() 345 + 346 + private var selectedIndex: Int? { 347 + guard let id = selectedPhotoID else { return nil } 348 + return items.firstIndex(where: { $0.id == id }) 349 + } 350 + 351 + // Grid constants (shared with AdaptivePhotoLayout) 352 + private let gridColumnCount = 3 353 + private let gridSpacing: CGFloat = 4 354 + private let gridOuterPadding: CGFloat = 16 355 + 356 + private var gridCellSide: CGFloat { 357 + let total = max(0, gridContainerWidth - gridOuterPadding * 2 - gridSpacing * CGFloat(gridColumnCount - 1)) 358 + return max(1, total / CGFloat(gridColumnCount)) 359 + } 360 + 361 + private var gridStride: CGSize { 362 + let side = gridCellSide + gridSpacing 363 + return CGSize(width: side, height: side) 364 + } 365 + 366 + private var maskSide: CGFloat { 367 + switch mode { 368 + case .preview: StripScrollState.thumbSize 369 + case .reorder: gridCellSide 370 + case .captions: 60 371 + } 372 + } 373 + 374 + private var dragPlacement: ReorderDragPlacement? { 375 + guard let start = reorderState.dragStartIndex, 376 + let current = reorderState.dragCurrentIndex 377 + else { return nil } 378 + return ReorderDragPlacement(draggedIndex: start, currentIndex: current) 379 + } 380 + 381 + /// Mode binding with animation gate. Uses `withAnimation` completion 382 + /// instead of timer-based gate drop — no matchedGeometryEffect means 383 + /// completion actually fires. Safety-net timer per debugging playbook. 384 + private var modeBinding: Binding<EditorMode> { 385 + Binding( 386 + get: { mode }, 387 + set: { newMode in 388 + guard newMode != mode, !isAnimatingMode, !reorderState.isDragging else { return } 389 + 390 + let morphSpid = morphSignposter.makeSignpostID() 391 + let morphState = morphSignposter.beginInterval( 392 + "MorphAnimation", id: morphSpid, 393 + "from=\(mode.label),to=\(newMode.label)" 394 + ) 395 + 396 + isAnimatingMode = true 397 + withAnimation(.smooth) { 398 + mode = newMode 399 + } completion: { 400 + morphSignposter.endInterval("MorphAnimation", morphState, "path=completion") 401 + isAnimatingMode = false 402 + } 403 + // Safety net — completion should fire, but iOS can drop it 404 + // if a concurrent transaction replaces the animation mid-flight. 405 + Task { @MainActor in 406 + try? await Task.sleep(for: .milliseconds(800)) 407 + isAnimatingMode = false 408 + } 409 + } 410 + ) 411 + } 412 + 413 + var body: some View { 414 + // MARK: Photos section 415 + 416 + Section { 417 + AdaptivePhotoLayout( 418 + mode: mode, 419 + containerWidth: gridContainerWidth, 420 + stripScrollOffset: stripState.currentOffset, 421 + dragPlacement: dragPlacement 422 + ) { 423 + ForEach($items) { $item in 424 + let index = items.firstIndex(where: { $0.id == item.id }) ?? 0 425 + let exifState: ExifState = { 426 + guard item.exifSummary != nil else { return .absent } 427 + return sendExif ? .active : .inactive 428 + }() 429 + 430 + cellView(item: $item, index: index, exifState: exifState) 431 + // Live drag position: offset tracks the finger without animation. 432 + // dragOffset is kept out of the Layout so the sibling spring 433 + // animation is never contaminated by the immediate offset update. 434 + .offset( 435 + x: reorderState.draggedID == item.id ? reorderState.dragOffset.width : 0, 436 + y: reorderState.draggedID == item.id ? reorderState.dragOffset.height : 0 437 + ) 438 + .zIndex(reorderState.draggedID == item.id ? 1000 : 0) 439 + .gesture( 440 + ReorderRecognizer(isEnabled: mode == .reorder) { phase, translation in 441 + handleReorder(phase: phase, translation: translation, itemID: item.id, index: index) 442 + } 443 + ) 444 + .transition(mode == .preview ? .walletRemove : .opacity) 445 + .layoutValue(key: PhotoIndexKey.self, value: index) 446 + } 447 + } 448 + // No explicit .clipped() — the Form Section row clips its content 449 + // naturally. Adding .clipped() here over-clips cells at strip edges 450 + // (the X button and cell frame extend past the layout bounds when 451 + // partially scrolled off-screen, cutting into the visible portion). 452 + .contentShape(Rectangle()) 453 + .animation(.smooth, value: mode) 454 + .gesture( 455 + StripPanRecognizer( 456 + isEnabled: mode == .preview, 457 + onChanged: { stripState.dragTranslation = $0 }, 458 + onEnded: { t, p in 459 + stripState.handleDragEnded( 460 + translation: t, 461 + predictedEnd: p, 462 + containerWidth: gridContainerWidth, 463 + itemCount: items.count 464 + ) 465 + } 466 + ) 467 + ) 468 + .listRowInsets(EdgeInsets()) 469 + .listRowSeparator(.hidden) 470 + .onGeometryChange(for: CGFloat.self, of: { $0.size.height }) { newHeight in 471 + // Signpost the actual rendered row height so Instruments shows 472 + // whether the section box is sized correctly per mode. 473 + morphSignposter.emitEvent("LayoutRowHeight", "mode=\(mode.label),h=\(Int(newHeight))") 474 + } 475 + .onGeometryChange(for: CGFloat.self, of: { $0.size.width }) { newWidth in 476 + guard newWidth > 0 else { return } 477 + var t = Transaction() 478 + t.animation = nil 479 + withTransaction(t) { gridContainerWidth = newWidth } 480 + } 481 + .onChange(of: gridContainerWidth) { _, newWidth in 482 + guard newWidth > 0, !isAnimatingMode else { return } 483 + if let id = selectedPhotoID, 484 + let idx = items.firstIndex(where: { $0.id == id }) 485 + { 486 + stripState.scrollToIndex(idx, itemCount: items.count, containerWidth: newWidth, animated: false) 487 + } 488 + } 489 + .onChange(of: selectedPhotoID) { _, newID in 490 + guard mode == .preview, !isAnimatingMode, 491 + let newID, let idx = items.firstIndex(where: { $0.id == newID }) 492 + else { return } 493 + stripState.scrollToIndex(idx, itemCount: items.count, containerWidth: gridContainerWidth) 494 + } 495 + .onChange(of: mode) { _, newMode in 496 + if newMode == .preview, gridContainerWidth > 0, 497 + let id = selectedPhotoID, 498 + let idx = items.firstIndex(where: { $0.id == id }) 499 + { 500 + stripState.scrollToIndex(idx, itemCount: items.count, containerWidth: gridContainerWidth, animated: false) 501 + } 502 + } 503 + } header: { 504 + Picker("Mode", selection: modeBinding) { 505 + ForEach(EditorMode.allCases, id: \.self) { m in 506 + Text(m.label).tag(m) 507 + } 508 + } 509 + .pickerStyle(.segmented) 510 + .disabled(isAnimatingMode) 511 + } 512 + } 513 + 514 + // MARK: - Cell view 515 + 516 + private func cellView(item: Binding<PhotoItem>, index: Int, exifState: ExifState) -> some View { 517 + HStack(alignment: .top, spacing: mode == .captions ? 12 : 0) { 518 + PhotoThumbnailCell( 519 + item: item, 520 + geometry: CellGeometry( 521 + mode: mode, 522 + maskSide: maskSide, 523 + photoAspect: item.wrappedValue.naturalAspect 524 + ), 525 + isSelected: mode == .preview && selectedPhotoID == item.wrappedValue.id, 526 + isDragging: reorderState.draggedID == item.wrappedValue.id, 527 + hideDelete: mode != .preview, 528 + deleteOpacity: mode == .preview 529 + ? stripState.deleteOpacity(cellIndex: index, containerWidth: gridContainerWidth) 530 + : 1, 531 + exifState: mode == .reorder ? .absent : exifState, 532 + isAnimatingMode: isAnimatingMode, 533 + onTap: { handleCellTap(itemID: item.wrappedValue.id, index: index) }, 534 + onDelete: { handleDelete(itemID: item.wrappedValue.id) } 535 + ) 536 + 537 + if mode == .captions { 538 + TextField("Add a description", text: item.alt, axis: .vertical) 539 + .font(.subheadline) 540 + .lineLimit(2 ... 4) 541 + 542 + Spacer(minLength: 0) 543 + 544 + if !item.wrappedValue.alt.isEmpty { 545 + Button { 546 + item.wrappedValue.alt = "" 547 + } label: { 548 + Image(systemName: "xmark.circle.fill") 549 + .font(.system(size: 18)) 550 + .foregroundStyle(.secondary) 551 + .padding(6) 552 + .contentShape(Rectangle()) 553 + } 554 + .buttonStyle(.plain) 555 + .accessibilityLabel("Clear caption") 556 + } 557 + } 558 + } 559 + .overlay(alignment: .bottom) { 560 + if mode == .captions { 561 + Divider() 562 + .padding(.leading, maskSide + 12) 563 + } 564 + } 565 + } 566 + 567 + // MARK: - Handlers 568 + 569 + private func handleCellTap(itemID: UUID, index: Int) { 570 + guard mode == .preview else { return } 571 + withAnimation(.snappy) { 572 + selectedPhotoID = itemID 573 + stripState.baseOffset = StripScrollState.offset( 574 + forIndex: index, 575 + itemCount: items.count, 576 + containerWidth: gridContainerWidth 577 + ) 578 + } 579 + } 580 + 581 + private func handleDelete(itemID: UUID) { 582 + if let item = items.first(where: { $0.id == itemID }) { 583 + onDeleteItem?(item) 584 + } 585 + if selectedPhotoID == itemID, 586 + let removedIdx = items.firstIndex(where: { $0.id == itemID }) 587 + { 588 + let newID: UUID? = removedIdx > 0 589 + ? items[removedIdx - 1].id 590 + : removedIdx < items.count - 1 ? items[removedIdx + 1].id : nil 591 + selectedPhotoID = newID 592 + } 593 + let curve: Animation = mode == .preview 594 + ? .smooth 595 + : .spring(response: 0.3, dampingFraction: 0.8) 596 + withAnimation(curve) { 597 + items.removeAll { $0.id == itemID } 598 + } 599 + } 600 + 601 + private func handleReorder( 602 + phase: ReorderRecognizer.Phase, 603 + translation: CGSize, 604 + itemID: UUID, 605 + index: Int 606 + ) { 607 + switch phase { 608 + case .arming: 609 + reorderSignposter.emitEvent("Arming", "idx=\(index)") 610 + isReordering = true 611 + case .began: 612 + reorderSignposter.emitEvent("DragBegan", "idx=\(index)") 613 + reorderState.beginDrag(itemID: itemID, at: index) 614 + case .changed: 615 + if let proposed = reorderState.handleDragChanged( 616 + translation: translation, 617 + itemCount: items.count, 618 + columnCount: gridColumnCount, 619 + stride: gridStride 620 + ) { 621 + reorderSignposter.emitEvent( 622 + "SlotChange", 623 + "from=\(reorderState.dragCurrentIndex ?? -1),to=\(proposed)" 624 + ) 625 + withAnimation(.spring(response: 0.32, dampingFraction: 0.78)) { 626 + reorderState.dragCurrentIndex = proposed 627 + } 628 + reorderState.selectionGenerator.selectionChanged() 629 + } 630 + case .ended, .cancelled: 631 + if let start = reorderState.dragStartIndex, 632 + let current = reorderState.dragCurrentIndex, 633 + start != current 634 + { 635 + reorderSignposter.emitEvent("DragEnded", "start=\(start),final=\(current)") 636 + withAnimation(.snappy) { 637 + items.move( 638 + fromOffsets: IndexSet(integer: start), 639 + toOffset: current > start ? current + 1 : current 640 + ) 641 + reorderState.dragOffset = .zero 642 + reorderState.dragStartIndex = nil 643 + reorderState.dragCurrentIndex = nil 644 + isReordering = false 645 + } completion: { 646 + reorderState.draggedID = nil 647 + } 648 + } else { 649 + reorderSignposter.emitEvent("DragCancelled", "idx=\(index)") 650 + reorderState.reset() 651 + isReordering = false 652 + } 653 + } 654 + } 655 + } 656 + 657 + #Preview { 658 + @Previewable @State var state: [PhotoItem] = PreviewData.photoItemsWithExif 659 + @Previewable @State var selected: UUID? 660 + @Previewable @State var mode: EditorMode = .preview 661 + @Previewable @State var isReordering = false 662 + @Previewable @State var isAnimatingMode = false 663 + @Previewable @State var zoomState = ImageZoomState() 664 + Form { 665 + GalleryEditor( 666 + items: $state, 667 + selectedPhotoID: $selected, 668 + isReordering: $isReordering, 669 + isAnimatingMode: $isAnimatingMode, 670 + mode: $mode, 671 + sendExif: false 672 + ) 673 + } 674 + .scrollDisabled(isReordering || isAnimatingMode) 675 + .environment(zoomState) 676 + .modifier(ImageZoomOverlay(zoomState: zoomState)) 677 + .onAppear { selected = state.first?.id } 678 + .grainPreview() 679 + }
+127
Grain/Views/Create/PhotoThumbnailCell.swift
··· 1 + import SwiftUI 2 + 3 + /// Shared photo cell used across all three editor modes (strip, grid, captions). 4 + /// 5 + /// Layout model: three independently animatable pieces in a ZStack: 6 + /// 7 + /// 1. **Photo** — rendered at `geometry.photoSize` (natural-aspect rectangle 8 + /// scaled by mode-specific rule: fill in preview, fit in reorder). 9 + /// 2. **Mask** — `maskSide x maskSide` square frame + `.clipped()`. The mask 10 + /// animates between modes (72pt strip → column-width grid → 60pt captions). 11 + /// 3. **X button** — `.position(x: maskSide, y: 0)` so its center sits on the 12 + /// mask's top-right corner. Follows the mask as it animates. 13 + /// 14 + /// With the `AdaptivePhotoLayout` approach, the same cell instance persists 15 + /// across mode switches. SwiftUI interpolates maskSide and position changes 16 + /// automatically — no matchedGeometryEffect needed. 17 + struct PhotoThumbnailCell: View { 18 + @Binding var item: PhotoItem 19 + var geometry: CellGeometry 20 + var isSelected: Bool = false 21 + var isDragging: Bool = false 22 + var hideDelete: Bool = false 23 + var deleteOpacity: CGFloat = 1 24 + var exifState: ExifState = .absent 25 + /// True during mode transitions. Gates per-property `.animation()` so 26 + /// selection/drag springs don't compete with the Layout transition. 27 + var isAnimatingMode: Bool = false 28 + let onTap: () -> Void 29 + let onDelete: () -> Void 30 + 31 + var body: some View { 32 + ZStack(alignment: .topLeading) { 33 + // Photo 34 + Image(uiImage: item.thumbnail) 35 + .resizable() 36 + .frame(width: geometry.photoSize.width, height: geometry.photoSize.height) 37 + .frame(width: geometry.maskSide, height: geometry.maskSide) 38 + .clipShape(RoundedRectangle(cornerRadius: geometry.maskCornerRadius, style: .continuous)) 39 + .overlay( 40 + RoundedRectangle(cornerRadius: geometry.maskCornerRadius, style: .continuous) 41 + .strokeBorder(Color.accentColor, lineWidth: isSelected ? 2.5 : 0) 42 + ) 43 + .opacity(geometry.mode == .preview && !isSelected && !isDragging ? 0.5 : 1.0) 44 + .overlay(alignment: .bottomTrailing) { 45 + altPill.opacity(hideDelete ? 0 : 1) 46 + } 47 + .overlay(alignment: .bottomLeading) { 48 + ExifChip(state: exifState) 49 + .padding(5) 50 + } 51 + 52 + // X button 53 + deleteButton 54 + .scaleEffect((hideDelete ? 0 : deleteOpacity) / (isDragging ? 1.1 : 1)) 55 + .position(x: geometry.maskSide, y: 0) 56 + .allowsHitTesting(!hideDelete && deleteOpacity > 0) 57 + } 58 + .frame(width: geometry.maskSide, height: geometry.maskSide) 59 + .contentShape(Rectangle()) 60 + .animation( 61 + isAnimatingMode ? nil : .easeInOut(duration: 0.2), 62 + value: isSelected 63 + ) 64 + .scaleEffect(isDragging ? 1.1 : 1) 65 + .opacity(isDragging ? 0.8 : 1) 66 + .shadow(color: isDragging ? .black.opacity(0.25) : .clear, radius: 10, y: 6) 67 + .animation(isAnimatingMode ? nil : .spring(response: 0.28, dampingFraction: 0.72), value: isDragging) 68 + .onTapGesture { onTap() } 69 + } 70 + 71 + @ViewBuilder private var altPill: some View { 72 + let hasAlt = !item.alt.trimmingCharacters(in: .whitespaces).isEmpty 73 + Text("ALT") 74 + .font(.caption2.weight(.bold)) 75 + .padding(.horizontal, 6) 76 + .padding(.vertical, 3) 77 + .background(.black.opacity(0.6), in: RoundedRectangle(cornerRadius: 4)) 78 + .foregroundStyle(.white) 79 + .opacity(hasAlt ? 1 : 0.5) 80 + .padding(5) 81 + .allowsHitTesting(false) 82 + .accessibilityLabel(hasAlt ? "Has alt text" : "No alt text") 83 + } 84 + 85 + private var deleteButton: some View { 86 + Button(action: onDelete) { 87 + Image(systemName: "xmark.circle.fill") 88 + .font(.system(size: 22)) 89 + .foregroundStyle(.white, Color.accentColor) 90 + .frame(width: 44, height: 44) 91 + .contentShape(Circle().scale(0.7)) 92 + } 93 + .buttonStyle(.plain) 94 + } 95 + } 96 + 97 + #Preview { 98 + let items = Array(PreviewData.photoItemsWithExif.prefix(3)) 99 + HStack(spacing: 20) { 100 + PhotoThumbnailCell( 101 + item: .constant(items[0]), 102 + geometry: CellGeometry(mode: .preview, maskSide: 72, photoAspect: items[0].naturalAspect), 103 + isSelected: true, 104 + exifState: .active, 105 + onTap: {}, 106 + onDelete: {} 107 + ) 108 + PhotoThumbnailCell( 109 + item: .constant(items[1]), 110 + geometry: CellGeometry(mode: .preview, maskSide: 72, photoAspect: items[1].naturalAspect), 111 + isSelected: false, 112 + exifState: .inactive, 113 + onTap: {}, 114 + onDelete: {} 115 + ) 116 + PhotoThumbnailCell( 117 + item: .constant(items[2]), 118 + geometry: CellGeometry(mode: .reorder, maskSide: 110, photoAspect: items[2].naturalAspect), 119 + isSelected: false, 120 + exifState: .absent, 121 + onTap: {}, 122 + onDelete: {} 123 + ) 124 + } 125 + .padding(30) 126 + .grainPreview() 127 + }
+86
Grain/Views/Create/ReorderDragState.swift
··· 1 + import SwiftUI 2 + import UIKit 3 + 4 + // MARK: - Reorder drag state 5 + 6 + /// Manages drag-to-reorder state for the photo grid. Extracted from 7 + /// ReorderablePhotoGrid so the state is owned by GalleryEditor and the 8 + /// AdaptivePhotoLayout can read it for cell displacement. 9 + @Observable 10 + @MainActor 11 + final class ReorderDragState { 12 + var draggedID: UUID? 13 + var dragStartIndex: Int? 14 + var dragCurrentIndex: Int? 15 + var dragOffset: CGSize = .zero 16 + 17 + @ObservationIgnored var impactGenerator = UIImpactFeedbackGenerator(style: .medium) 18 + @ObservationIgnored var selectionGenerator = UISelectionFeedbackGenerator() 19 + 20 + var isDragging: Bool { 21 + draggedID != nil 22 + } 23 + 24 + func beginDrag(itemID: UUID, at index: Int) { 25 + guard draggedID == nil else { return } 26 + draggedID = itemID 27 + dragStartIndex = index 28 + dragCurrentIndex = index 29 + impactGenerator.prepare() 30 + selectionGenerator.prepare() 31 + impactGenerator.impactOccurred() 32 + } 33 + 34 + /// Updates `dragOffset` and returns the proposed slot index if it changed. 35 + /// The caller is responsible for wrapping `dragCurrentIndex` assignment in 36 + /// `withAnimation` — calling `withAnimation` from inside @Observable methods 37 + /// doesn't reliably propagate animation transactions to the Layout after the 38 + /// first update, causing subsequent slot changes to snap instead of animate. 39 + @discardableResult 40 + func handleDragChanged( 41 + translation: CGSize, 42 + itemCount: Int, 43 + columnCount: Int, 44 + stride: CGSize 45 + ) -> Int? { 46 + guard let start = dragStartIndex, 47 + stride.width > 0, stride.height > 0 48 + else { return nil } 49 + dragOffset = translation 50 + 51 + let colDelta = Int((translation.width / stride.width).rounded()) 52 + let rowDelta = Int((translation.height / stride.height).rounded()) 53 + 54 + let startRow = start / columnCount 55 + let startCol = start % columnCount 56 + let proposedRow = max(0, startRow + rowDelta) 57 + let proposedCol = max(0, min(columnCount - 1, startCol + colDelta)) 58 + let rawProposed = proposedRow * columnCount + proposedCol 59 + let proposed = max(0, min(itemCount - 1, rawProposed)) 60 + 61 + guard proposed != dragCurrentIndex else { return nil } 62 + return proposed 63 + } 64 + 65 + func reset() { 66 + draggedID = nil 67 + dragStartIndex = nil 68 + dragCurrentIndex = nil 69 + dragOffset = .zero 70 + } 71 + } 72 + 73 + // MARK: - Drag placement snapshot 74 + 75 + /// Lightweight value snapshot of drag state for the Layout. Using a struct 76 + /// (not the @Observable directly) keeps the Layout as pure value-type math. 77 + /// 78 + /// `dragOffset` is intentionally excluded — it is applied as a view-level 79 + /// `.offset` modifier on the dragged cell, not through the Layout. This keeps 80 + /// the Layout parameter clean: it only changes when `currentIndex` changes 81 + /// (inside `withAnimation`), so SwiftUI can animate sibling displacements 82 + /// without the immediate dragOffset update contaminating the transaction. 83 + struct ReorderDragPlacement: Equatable { 84 + let draggedIndex: Int 85 + let currentIndex: Int 86 + }
+77
Grain/Views/Create/ReorderLockers.swift
··· 1 + import SwiftUI 2 + import UIKit 3 + 4 + /// Disables the nearest ancestor `UIScrollView`'s pan gesture recognizer when 5 + /// `isDisabled` is true. Unlike SwiftUI's `.scrollDisabled`, this only blocks 6 + /// USER-driven panning — programmatic `setContentOffset` / `ScrollViewProxy.scrollTo` 7 + /// continues to work, which is essential for the strip's auto-scroll during a 8 + /// reorder drag. 9 + /// 10 + /// Attach as `.background { ScrollPanLocker(isDisabled: isReordering) }` on the 11 + /// SwiftUI view that lives inside the scroll view whose pan you want to lock. 12 + struct ScrollPanLocker: UIViewRepresentable { 13 + let isDisabled: Bool 14 + 15 + func makeUIView(context _: Context) -> RecognizerFinderView { 16 + let view = RecognizerFinderView(frame: .zero) 17 + view.isUserInteractionEnabled = false 18 + view.backgroundColor = .clear 19 + return view 20 + } 21 + 22 + func updateUIView(_ uiView: RecognizerFinderView, context _: Context) { 23 + guard let scrollView = uiView.enclosingScrollView() else { return } 24 + // Toggling isEnabled on the pan recognizer immediately cancels any 25 + // in-flight drag and blocks new user pans, but leaves programmatic 26 + // setContentOffset untouched. 27 + scrollView.panGestureRecognizer.isEnabled = !isDisabled 28 + } 29 + 30 + final class RecognizerFinderView: UIView { 31 + func enclosingScrollView() -> UIScrollView? { 32 + var responder: UIResponder? = self 33 + while let next = responder?.next { 34 + if let scrollView = next as? UIScrollView { 35 + return scrollView 36 + } 37 + responder = next 38 + } 39 + return nil 40 + } 41 + } 42 + } 43 + 44 + /// Disables the enclosing `UINavigationController`'s interactive-pop gesture 45 + /// recognizer when `isDisabled` is true. Use to prevent iOS's edge-swipe-to-pop 46 + /// from firing while the user is in a reorder drag. 47 + /// 48 + /// Attach as `.background { InteractivePopLocker(isDisabled: isReordering) }` 49 + /// on the root view of the pushed view controller. 50 + struct InteractivePopLocker: UIViewRepresentable { 51 + let isDisabled: Bool 52 + 53 + func makeUIView(context _: Context) -> ResponderFinderView { 54 + let view = ResponderFinderView(frame: .zero) 55 + view.isUserInteractionEnabled = false 56 + view.backgroundColor = .clear 57 + return view 58 + } 59 + 60 + func updateUIView(_ uiView: ResponderFinderView, context _: Context) { 61 + guard let nav = uiView.enclosingNavigationController() else { return } 62 + nav.interactivePopGestureRecognizer?.isEnabled = !isDisabled 63 + } 64 + 65 + final class ResponderFinderView: UIView { 66 + func enclosingNavigationController() -> UINavigationController? { 67 + var responder: UIResponder? = self 68 + while let next = responder?.next { 69 + if let vc = next as? UIViewController { 70 + return vc.navigationController 71 + } 72 + responder = next 73 + } 74 + return nil 75 + } 76 + } 77 + }
+139
Grain/Views/Create/ReorderRecognizer.swift
··· 1 + import SwiftUI 2 + import UIKit 3 + 4 + /// UIKit-backed long-press-then-drag gesture for reorderable cells inside Form rows. 5 + /// 6 + /// SwiftUI's `LongPressGesture.sequenced(DragGesture)` doesn't cooperate with 7 + /// `UIScrollView`'s pan recognizer on real touch hardware — even with 8 + /// `.simultaneousGesture`, it stalls scroll AND blocks inner `.onTapGesture` during 9 + /// its arming window. Dropping to UIKit lets us: 10 + /// • set `cancelsTouchesInView = false` so taps still bubble up 11 + /// • set `delaysTouchesBegan = false` so scrolls aren't held back 12 + /// • assign a `UIGestureRecognizerDelegate` that returns `true` from 13 + /// `gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:)` so scroll pans and 14 + /// SwiftUI tap recognizers fire alongside this one 15 + /// 16 + /// `UIGestureRecognizerRepresentable` (iOS 18+) intentionally has no 17 + /// simultaneous-recognition hook on the protocol itself — the only way to wire it is 18 + /// via the recognizer's UIKit delegate. The Coordinator does double duty: it both 19 + /// stores the gesture's start location AND acts as the delegate. 20 + /// 21 + /// `UILongPressGestureRecognizer` continues sending `.changed` events as the finger 22 + /// moves after the long press fires, so a single recognizer handles the entire 23 + /// long-press → drag → release lifecycle without sequencing two gestures. 24 + struct ReorderRecognizer: UIGestureRecognizerRepresentable { 25 + enum Phase { 26 + /// Fired the moment a touch begins on the cell — before the 0.18s hold 27 + /// completes. Lets the parent lock scroll immediately so the Form can't 28 + /// steal the vertical component of a reorder drag. 29 + case arming 30 + case began 31 + case changed 32 + case ended 33 + case cancelled 34 + } 35 + 36 + let minimumPressDuration: TimeInterval 37 + /// When false, `gestureRecognizerShouldBegin` returns false so the 38 + /// recognizer never fires. Used to gate on editor mode. 39 + var isEnabled: Bool 40 + let onChange: (Phase, CGSize) -> Void 41 + 42 + init( 43 + minimumPressDuration: TimeInterval = 0.18, 44 + isEnabled: Bool = true, 45 + onChange: @escaping (Phase, CGSize) -> Void 46 + ) { 47 + self.minimumPressDuration = minimumPressDuration 48 + self.isEnabled = isEnabled 49 + self.onChange = onChange 50 + } 51 + 52 + func makeCoordinator(converter _: CoordinateSpaceConverter) -> Coordinator { 53 + Coordinator(isEnabled: isEnabled, onChange: onChange) 54 + } 55 + 56 + func makeUIGestureRecognizer(context: Context) -> UILongPressGestureRecognizer { 57 + let recognizer = UILongPressGestureRecognizer() 58 + recognizer.minimumPressDuration = minimumPressDuration 59 + // Tight arming tolerance — if the finger drifts more than this during 60 + // the pre-fire window, UIKit fails the recognizer and the parent 61 + // scroll pan takes over. Without this (previously unbounded), a user 62 + // trying to flick-scroll the grid would still commit the long press 63 + // at 0.18s and accidentally start a reorder drag. Note: this is the 64 + // ARMING tolerance only; once `.began` fires the finger can move 65 + // freely (UILongPressGestureRecognizer stops consulting 66 + // allowableMovement after recognition succeeds). 67 + recognizer.allowableMovement = 10 68 + // Critical for coexistence with SwiftUI taps and scroll pans: 69 + recognizer.cancelsTouchesInView = false 70 + recognizer.delaysTouchesBegan = false 71 + recognizer.delaysTouchesEnded = false 72 + // The delegate is what unlocks scroll + tap working alongside us. The 73 + // Representable protocol has NO simultaneous-recognition hook, so the only 74 + // way to get this is the UIKit delegate (verified against iOS 26 SDK 75 + // swiftinterface). 76 + recognizer.delegate = context.coordinator 77 + return recognizer 78 + } 79 + 80 + func updateUIGestureRecognizer(_ recognizer: UILongPressGestureRecognizer, context: Context) { 81 + recognizer.minimumPressDuration = minimumPressDuration 82 + context.coordinator.isEnabled = isEnabled 83 + context.coordinator.onChange = onChange 84 + } 85 + 86 + func handleUIGestureRecognizerAction(_ recognizer: UILongPressGestureRecognizer, context: Context) { 87 + let coordinator = context.coordinator 88 + let location = recognizer.location(in: recognizer.view) 89 + 90 + switch recognizer.state { 91 + case .began: 92 + coordinator.startLocation = location 93 + coordinator.onChange(.began, .zero) 94 + case .changed: 95 + guard let start = coordinator.startLocation else { return } 96 + let translation = CGSize( 97 + width: location.x - start.x, 98 + height: location.y - start.y 99 + ) 100 + coordinator.onChange(.changed, translation) 101 + case .ended: 102 + coordinator.onChange(.ended, .zero) 103 + coordinator.startLocation = nil 104 + case .cancelled, .failed: 105 + coordinator.onChange(.cancelled, .zero) 106 + coordinator.startLocation = nil 107 + default: 108 + break 109 + } 110 + } 111 + 112 + @MainActor 113 + final class Coordinator: NSObject, UIGestureRecognizerDelegate { 114 + var startLocation: CGPoint? 115 + var isEnabled: Bool 116 + var onChange: (Phase, CGSize) -> Void 117 + 118 + init(isEnabled: Bool, onChange: @escaping (Phase, CGSize) -> Void) { 119 + self.isEnabled = isEnabled 120 + self.onChange = onChange 121 + } 122 + 123 + nonisolated func gestureRecognizerShouldBegin( 124 + _: UIGestureRecognizer 125 + ) -> Bool { 126 + MainActor.assumeIsolated { 127 + if isEnabled { onChange(.arming, .zero) } 128 + return isEnabled 129 + } 130 + } 131 + 132 + nonisolated func gestureRecognizer( 133 + _: UIGestureRecognizer, 134 + shouldRecognizeSimultaneouslyWith _: UIGestureRecognizer 135 + ) -> Bool { 136 + true 137 + } 138 + } 139 + }
-99
Grain/Views/Create/ReorderablePhotoStrip.swift
··· 1 - import SwiftUI 2 - 3 - struct ReorderablePhotoStrip: View { 4 - @Binding var items: [PhotoItem] 5 - @Binding var selectedPhotoID: UUID? 6 - @State private var draggingID: UUID? 7 - @State private var dragOffset: CGFloat = 0 8 - private let thumbSize: CGFloat = 72 9 - private let spacing: CGFloat = 8 10 - 11 - var body: some View { 12 - ScrollView(.horizontal, showsIndicators: false) { 13 - HStack(spacing: spacing) { 14 - ForEach(items) { item in 15 - ZStack(alignment: .topTrailing) { 16 - Image(uiImage: item.thumbnail) 17 - .resizable() 18 - .scaledToFill() 19 - .frame(width: thumbSize, height: thumbSize) 20 - .clipShape(RoundedRectangle(cornerRadius: 8)) 21 - 22 - Button { 23 - withAnimation { 24 - items.removeAll { $0.id == item.id } 25 - if selectedPhotoID == item.id { 26 - selectedPhotoID = items.first?.id 27 - } 28 - } 29 - } label: { 30 - Image(systemName: "xmark.circle.fill") 31 - .font(.system(size: 18)) 32 - .foregroundStyle(.white, Color("AccentColor")) 33 - } 34 - .buttonStyle(.plain) 35 - .offset(x: 9, y: -9) 36 - } 37 - .offset(x: draggingID == item.id ? dragOffset : 0) 38 - .opacity(draggingID == item.id ? 0.8 : 1) 39 - .scaleEffect(draggingID == item.id ? 1.08 : 1) 40 - .zIndex(draggingID == item.id ? 1 : 0) 41 - .gesture( 42 - LongPressGesture(minimumDuration: 0.25) 43 - .sequenced(before: DragGesture(minimumDistance: 0)) 44 - .onChanged { value in 45 - switch value { 46 - case .second(true, let drag): 47 - if draggingID == nil { 48 - draggingID = item.id 49 - UIImpactFeedbackGenerator(style: .medium).impactOccurred() 50 - } 51 - if let drag { 52 - dragOffset = drag.translation.width 53 - reorderIfNeeded() 54 - } 55 - default: 56 - break 57 - } 58 - } 59 - .onEnded { _ in 60 - withAnimation(.easeInOut(duration: 0.2)) { 61 - draggingID = nil 62 - dragOffset = 0 63 - } 64 - } 65 - ) 66 - } 67 - } 68 - .padding(.vertical, 10) 69 - } 70 - } 71 - 72 - private func reorderIfNeeded() { 73 - guard let draggingID, 74 - let currentIndex = items.firstIndex(where: { $0.id == draggingID }) 75 - else { return } 76 - 77 - let step = thumbSize + spacing 78 - let steps = Int((dragOffset / step).rounded()) 79 - let targetIndex = max(0, min(items.count - 1, currentIndex + steps)) 80 - if targetIndex != currentIndex { 81 - withAnimation(.easeInOut(duration: 0.15)) { 82 - items.move( 83 - fromOffsets: IndexSet(integer: currentIndex), 84 - toOffset: targetIndex > currentIndex ? targetIndex + 1 : targetIndex 85 - ) 86 - } 87 - dragOffset -= CGFloat(targetIndex - currentIndex) * step 88 - } 89 - } 90 - } 91 - 92 - #Preview { 93 - @Previewable @State var state: [PhotoItem] = PreviewData.photoItems 94 - @Previewable @State var selected: UUID? 95 - ReorderablePhotoStrip(items: $state, selectedPhotoID: $selected) 96 - .padding() 97 - .onAppear { selected = state.first?.id } 98 - .preferredColorScheme(.dark) 99 - }
+186
Grain/Views/Create/StripScrollState.swift
··· 1 + import os 2 + import SwiftUI 3 + import UIKit 4 + 5 + private let stripSignposter = OSSignposter(subsystem: "social.grain.grain", category: "Animation.Strip") 6 + 7 + // MARK: - Strip scroll state 8 + 9 + /// Manages horizontal scroll position for the photo strip. Extracted from 10 + /// PhotoStrip so the state persists across mode switches (owned by GalleryEditor). 11 + /// 12 + /// `@Observable` so per-property mutations (e.g. dragTranslation every frame) 13 + /// only invalidate views that read that specific property. 14 + @Observable 15 + @MainActor 16 + final class StripScrollState { 17 + var baseOffset: CGFloat = 0 18 + var dragTranslation: CGFloat = 0 19 + 20 + var currentOffset: CGFloat { 21 + baseOffset + dragTranslation 22 + } 23 + 24 + static let thumbSize: CGFloat = 72 25 + static let spacing: CGFloat = 20 26 + 27 + // MARK: - Pure layout math 28 + 29 + /// Centered offset for cell at `idx` within container width `W`. 30 + static func offset(forIndex idx: Int, itemCount: Int, containerWidth W: CGFloat) -> CGFloat { 31 + guard itemCount > 0, W > 0 else { return 0 } 32 + let stride = thumbSize + spacing 33 + let halfCell = thumbSize / 2 34 + let unclamped = W / 2 - halfCell - CGFloat(idx) * stride 35 + return clamp(unclamped, itemCount: itemCount, containerWidth: W) 36 + } 37 + 38 + /// Clamp offset so HStack can't scroll past its own bounds. 39 + static func clamp(_ offset: CGFloat, itemCount: Int, containerWidth W: CGFloat) -> CGFloat { 40 + guard itemCount > 0, W > 0 else { return 0 } 41 + let contentWidth = CGFloat(itemCount) * thumbSize + CGFloat(max(0, itemCount - 1)) * spacing 42 + let minOffset = min(0, W - contentWidth) 43 + return max(minOffset, min(0, offset)) 44 + } 45 + 46 + // MARK: - Actions 47 + 48 + func handleDragEnded( 49 + translation: CGFloat, 50 + predictedEnd: CGFloat, 51 + containerWidth W: CGFloat, 52 + itemCount: Int 53 + ) { 54 + let originalBase = baseOffset 55 + let committed = Self.clamp(originalBase + translation, itemCount: itemCount, containerWidth: W) 56 + baseOffset = committed 57 + dragTranslation = 0 58 + 59 + let projected = Self.clamp(originalBase + predictedEnd, itemCount: itemCount, containerWidth: W) 60 + let nearest = (0 ..< itemCount).min(by: { 61 + abs(Self.offset(forIndex: $0, itemCount: itemCount, containerWidth: W) - projected) 62 + < abs(Self.offset(forIndex: $1, itemCount: itemCount, containerWidth: W) - projected) 63 + }) ?? 0 64 + 65 + stripSignposter.emitEvent("StripDragEnded", "snap=\(nearest)") 66 + withAnimation(.snappy) { 67 + baseOffset = Self.offset(forIndex: nearest, itemCount: itemCount, containerWidth: W) 68 + } 69 + } 70 + 71 + func scrollToIndex(_ idx: Int, itemCount: Int, containerWidth: CGFloat, animated: Bool = true) { 72 + let target = Self.offset(forIndex: idx, itemCount: itemCount, containerWidth: containerWidth) 73 + guard abs(target - baseOffset) >= 0.5 else { return } 74 + if animated { 75 + withAnimation(.smooth) { baseOffset = target } 76 + } else { 77 + baseOffset = target 78 + } 79 + } 80 + 81 + /// X-button fade opacity based on cell's screen position relative to the 82 + /// container edges. Fades over one xRadius of margin so the button vanishes 83 + /// exactly as it reaches the clip boundary. 84 + func deleteOpacity(cellIndex idx: Int, containerWidth W: CGFloat) -> CGFloat { 85 + let cellLeft = currentOffset + CGFloat(idx) * (Self.thumbSize + Self.spacing) 86 + let offLeft = -cellLeft 87 + let offRight = (cellLeft + Self.thumbSize) - W 88 + let xCenterX = cellLeft + Self.thumbSize 89 + let xRadius: CGFloat = 11 90 + let xPadding: CGFloat = 2 91 + 92 + if offLeft > 0 { 93 + let dist = xCenterX 94 + return dist >= xRadius + xPadding ? 1 : max(0, (dist - xPadding) / xRadius) 95 + } else if offRight > 0 { 96 + let dist = W - cellLeft 97 + return dist >= xRadius + xPadding ? 1 : max(0, (dist - xPadding) / xRadius) 98 + } 99 + return 1 100 + } 101 + } 102 + 103 + // MARK: - UIKit-backed pan recognizer 104 + 105 + /// Horizontal pan for strip scroll. UIKit-backed so the Form's vertical 106 + /// scroll still works. `isEnabled` gates on mode so the recognizer is 107 + /// inert when the layout is in grid or captions mode. 108 + struct StripPanRecognizer: UIGestureRecognizerRepresentable { 109 + var isEnabled: Bool = true 110 + let onChanged: (CGFloat) -> Void 111 + let onEnded: (_ translation: CGFloat, _ predictedEnd: CGFloat) -> Void 112 + 113 + func makeCoordinator(converter _: CoordinateSpaceConverter) -> Coordinator { 114 + Coordinator(isEnabled: isEnabled, onChanged: onChanged, onEnded: onEnded) 115 + } 116 + 117 + func makeUIGestureRecognizer(context: Context) -> UIPanGestureRecognizer { 118 + let recognizer = UIPanGestureRecognizer() 119 + recognizer.cancelsTouchesInView = false 120 + recognizer.delaysTouchesBegan = false 121 + recognizer.delaysTouchesEnded = false 122 + recognizer.delegate = context.coordinator 123 + return recognizer 124 + } 125 + 126 + func updateUIGestureRecognizer(_: UIPanGestureRecognizer, context: Context) { 127 + context.coordinator.isEnabled = isEnabled 128 + context.coordinator.onChanged = onChanged 129 + context.coordinator.onEnded = onEnded 130 + } 131 + 132 + func handleUIGestureRecognizerAction(_ recognizer: UIPanGestureRecognizer, context: Context) { 133 + let coordinator = context.coordinator 134 + let translation = recognizer.translation(in: recognizer.view).x 135 + switch recognizer.state { 136 + case .changed: 137 + coordinator.onChanged(translation) 138 + case .ended: 139 + let velocity = recognizer.velocity(in: recognizer.view).x 140 + coordinator.onEnded(translation, translation + velocity * 0.25) 141 + case .cancelled, .failed: 142 + coordinator.onEnded(translation, translation) 143 + default: 144 + break 145 + } 146 + } 147 + 148 + @MainActor 149 + final class Coordinator: NSObject, UIGestureRecognizerDelegate { 150 + var isEnabled: Bool 151 + var onChanged: (CGFloat) -> Void 152 + var onEnded: (_ translation: CGFloat, _ predictedEnd: CGFloat) -> Void 153 + 154 + init( 155 + isEnabled: Bool, 156 + onChanged: @escaping (CGFloat) -> Void, 157 + onEnded: @escaping (_ translation: CGFloat, _ predictedEnd: CGFloat) -> Void 158 + ) { 159 + self.isEnabled = isEnabled 160 + self.onChanged = onChanged 161 + self.onEnded = onEnded 162 + } 163 + 164 + nonisolated func gestureRecognizerShouldBegin( 165 + _ gestureRecognizer: UIGestureRecognizer 166 + ) -> Bool { 167 + MainActor.assumeIsolated { 168 + guard isEnabled else { return false } 169 + guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return true } 170 + let velocity = pan.velocity(in: pan.view) 171 + return abs(velocity.x) > abs(velocity.y) 172 + } 173 + } 174 + 175 + nonisolated func gestureRecognizer( 176 + _: UIGestureRecognizer, 177 + shouldRecognizeSimultaneouslyWith _: UIGestureRecognizer 178 + ) -> Bool { 179 + // Prevent simultaneous H+V scrolling: once our horizontal pan begins, 180 + // the List's vertical scroll recognizer cannot also activate. 181 + // Vertical swipes never reach here — gestureRecognizerShouldBegin 182 + // returns false for them, so list scroll still works normally. 183 + false 184 + } 185 + } 186 + }
+12 -5
Grain/Views/Feed/FeedView.swift
··· 13 13 @State private var deepLinkGalleryUri: String? 14 14 @State private var deepLinkStoryAuthor: GrainStoryAuthor? 15 15 @State private var showFeedsManagement = false 16 + @State private var feedRefreshID = UUID() 16 17 17 18 let client: XRPCClient 18 19 @Binding var pendingDeepLink: DeepLink? ··· 47 48 }, 48 49 prefsViewModel: prefsViewModel 49 50 ) 51 + .id(feedRefreshID) 50 52 } 51 53 } 52 54 .navigationBarTitleDisplayMode(.inline) ··· 107 109 } 108 110 .navigationDestination(item: $deepLinkGalleryUri) { uri in 109 111 GalleryDetailView(client: client, galleryUri: uri) 112 + } 113 + .sheet(isPresented: $showCreate) { 114 + NavigationStack { 115 + CreateGalleryView(client: client) { 116 + showCreate = false 117 + feedRefreshID = UUID() 118 + } 119 + } 110 120 } 111 121 .fullScreenCover(item: $deepLinkStoryAuthor) { author in 112 122 StoryViewer( ··· 431 441 } 432 442 433 443 #Preview { 434 - FeedView(client: XRPCClient(baseURL: AuthManager.serverURL)) 435 - .environment(AuthManager()) 436 - .environment(StoryStatusCache()) 437 - .environment(ViewedStoryStorage()) 438 - .environment(LabelDefinitionsCache()) 444 + FeedView(client: .preview) 445 + .previewEnvironments() 439 446 }
+2 -5
Grain/Views/Feed/HashtagFeedView.swift
··· 185 185 } 186 186 187 187 #Preview { 188 - HashtagFeedView(client: XRPCClient(baseURL: AuthManager.serverURL), tag: "35mm") 189 - .environment(AuthManager()) 190 - .environment(StoryStatusCache()) 191 - .environment(ViewedStoryStorage()) 192 - .environment(LabelDefinitionsCache()) 188 + HashtagFeedView(client: .preview, tag: "35mm") 189 + .previewEnvironments() 193 190 }
+2 -5
Grain/Views/Feed/LocationFeedView.swift
··· 222 222 223 223 #Preview { 224 224 LocationFeedView( 225 - client: XRPCClient(baseURL: AuthManager.serverURL), 225 + client: .preview, 226 226 h3Index: "8928308280fffff", 227 227 locationName: "San Francisco" 228 228 ) 229 - .environment(AuthManager()) 230 - .environment(StoryStatusCache()) 231 - .environment(ViewedStoryStorage()) 232 - .environment(LabelDefinitionsCache()) 229 + .previewEnvironments() 233 230 }
+2 -3
Grain/Views/Gallery/GalleryDetailView.swift
··· 289 289 290 290 #Preview { 291 291 GalleryDetailView( 292 - client: XRPCClient(baseURL: AuthManager.serverURL), 292 + client: .preview, 293 293 galleryUri: "at://did:plc:preview/social.grain.gallery/r1" 294 294 ) 295 - .environment(AuthManager()) 296 - .environment(LabelDefinitionsCache()) 295 + .previewEnvironments() 297 296 }
+1 -1
Grain/Views/LoginView.swift
··· 317 317 318 318 #Preview { 319 319 LoginView() 320 - .environment(AuthManager()) 320 + .previewEnvironments() 321 321 }
+1 -10
Grain/Views/MainTabView.swift
··· 99 99 } 100 100 } 101 101 } 102 - .sheet(isPresented: $showCreate) { 103 - CreateGalleryView(client: client) { 104 - selectedTab = .feed 105 - feedRefreshID = UUID() 106 - } 107 - } 108 102 .onReceive(NotificationCenter.default.publisher(for: .grainShortcutAction)) { notification in 109 103 guard let rawValue = notification.object as? String, 110 104 let action = GrainShortcutAction(rawValue: rawValue) ··· 134 128 135 129 #Preview { 136 130 MainTabView(pendingDeepLink: .constant(nil)) 137 - .environment(AuthManager()) 131 + .previewEnvironments() 138 132 .environment(PushManager()) 139 - .environment(StoryStatusCache()) 140 - .environment(ViewedStoryStorage()) 141 - .environment(LabelDefinitionsCache()) 142 133 }
+3 -2
Grain/Views/Notifications/NotificationsView.swift
··· 157 157 } 158 158 159 159 #Preview { 160 - let client = XRPCClient(baseURL: AuthManager.serverURL) 160 + let client = XRPCClient.preview 161 161 let vm = NotificationsViewModel(client: client) 162 162 vm.notifications = PreviewData.notifications 163 163 vm.unseenCount = 3 164 164 return NotificationsView(client: client, viewModel: vm) 165 - .environment(AuthManager()) 165 + .previewEnvironments() 166 + .frame(maxHeight: .infinity, alignment: .top) 166 167 }
+11 -6
Grain/Views/Profile/FollowListView.swift
··· 282 282 } 283 283 284 284 #Preview { 285 - FollowListView( 286 - client: XRPCClient(baseURL: AuthManager.serverURL), 287 - did: "did:plc:preview", 288 - mode: .followers 289 - ) 290 - .environment(AuthManager()) 285 + NavigationStack { 286 + FollowListView( 287 + client: .preview, 288 + did: "did:plc:preview", 289 + mode: .followers 290 + ) 291 + } 292 + .previewEnvironments() 293 + .preferredColorScheme(.dark) 294 + .tint(Color("AccentColor")) 295 + .frame(maxHeight: .infinity, alignment: .top) 291 296 }
+2 -4
Grain/Views/Profile/ProfileView.swift
··· 809 809 } 810 810 811 811 #Preview { 812 - ProfileView(client: XRPCClient(baseURL: AuthManager.serverURL), did: "did:plc:preview") 813 - .environment(AuthManager()) 814 - .environment(ViewedStoryStorage()) 815 - .environment(LabelDefinitionsCache()) 812 + ProfileView(client: .preview, did: "did:plc:preview") 813 + .previewEnvironments() 816 814 }
+3 -4
Grain/Views/Search/SearchView.swift
··· 235 235 } 236 236 237 237 #Preview { 238 - SearchView(client: XRPCClient(baseURL: AuthManager.serverURL)) 239 - .environment(AuthManager()) 240 - .environment(StoryStatusCache()) 241 - .environment(ViewedStoryStorage()) 238 + SearchView(client: .preview) 239 + .previewEnvironments() 240 + .frame(maxHeight: .infinity, alignment: .top) 242 241 }
+2 -2
Grain/Views/Settings/EditProfileView.swift
··· 284 284 } 285 285 286 286 #Preview { 287 - EditProfileView(client: XRPCClient(baseURL: AuthManager.serverURL)) 288 - .environment(AuthManager()) 287 + EditProfileView(client: .preview) 288 + .previewEnvironments() 289 289 }
+2 -2
Grain/Views/Settings/SettingsView.swift
··· 114 114 } 115 115 116 116 #Preview { 117 - SettingsView(client: XRPCClient(baseURL: AuthManager.serverURL)) 118 - .environment(AuthManager()) 117 + SettingsView(client: .preview) 118 + .previewEnvironments() 119 119 }
+9 -88
Grain/Views/Stories/StoryCreateView.swift
··· 13 13 @State private var previewImage: UIImage? 14 14 @State private var showCamera = false 15 15 @State private var resolvedLocation: (h3: String, name: String, address: [String: AnyCodable]?)? 16 - @State private var locationQuery = "" 17 - @State private var locationSuggestions: [NominatimResult] = [] 18 - @State private var isSearchingLocation = false 19 - @State private var locationSearchTask: Task<Void, Never>? 20 16 @State private var photoLocationResult: NominatimResult? 21 17 @State private var includeLocation = true 22 18 @State private var isUploading = false ··· 48 44 } 49 45 50 46 Section("Location") { 51 - if let loc = resolvedLocation { 52 - HStack { 53 - Label(loc.name, systemImage: "mappin.and.ellipse") 54 - .font(.subheadline) 55 - .lineLimit(1) 56 - Spacer() 57 - Button { 58 - resolvedLocation = nil 59 - locationQuery = "" 60 - } label: { 61 - Image(systemName: "xmark.circle.fill") 62 - .foregroundStyle(.secondary) 63 - } 64 - } 65 - } else { 66 - if let photoLoc = photoLocationResult { 67 - Button { selectLocation(photoLoc) } label: { 68 - HStack(spacing: 10) { 69 - Image(systemName: "location.fill") 70 - .foregroundStyle(.secondary) 71 - .frame(width: 20) 72 - VStack(alignment: .leading, spacing: 1) { 73 - Text("Use photo location") 74 - .font(.subheadline) 75 - Text(photoLoc.name) 76 - .font(.caption) 77 - .foregroundStyle(.secondary) 78 - } 79 - } 80 - } 81 - .foregroundStyle(.primary) 82 - } 83 - 84 - HStack { 85 - Image(systemName: "magnifyingglass") 86 - .foregroundStyle(.secondary) 87 - TextField("Search for a location...", text: $locationQuery) 88 - .textInputAutocapitalization(.never) 89 - .onChange(of: locationQuery) { 90 - locationSearchTask?.cancel() 91 - let query = locationQuery 92 - locationSearchTask = Task { 93 - try? await Task.sleep(for: .milliseconds(300)) 94 - guard !Task.isCancelled else { return } 95 - await searchLocation(query: query) 96 - } 97 - } 98 - if isSearchingLocation { 99 - ProgressView() 100 - .controlSize(.small) 101 - } 102 - } 103 - 104 - ForEach(locationSuggestions, id: \.placeId) { result in 105 - Button { 106 - selectLocation(result) 107 - } label: { 108 - VStack(alignment: .leading, spacing: 2) { 109 - Text(result.name) 110 - .font(.subheadline) 111 - .foregroundStyle(.primary) 112 - if let context = result.context { 113 - Text(context) 114 - .font(.caption) 115 - .foregroundStyle(.secondary) 116 - } 117 - } 118 - } 119 - } 120 - } 47 + LocationPickerRows( 48 + resolvedLocation: $resolvedLocation, 49 + photoLocationResult: photoLocationResult, 50 + onSelectLocation: selectLocation 51 + ) 121 52 } 122 53 123 54 ContentLabelPicker(selectedLabels: $selectedLabels) ··· 182 113 } 183 114 selectedPhoto = nil 184 115 resolvedLocation = nil 185 - locationQuery = "" 186 - locationSuggestions = [] 187 116 photoLocationResult = nil 188 117 } 189 118 ··· 202 131 previewImage = image 203 132 204 133 resolvedLocation = nil 205 - locationQuery = "" 206 - locationSuggestions = [] 207 134 photoLocationResult = nil 208 135 if let gps = ImageProcessing.extractGPS(from: data), 209 136 let result = await LocationServices.reverseGeocode(latitude: gps.latitude, longitude: gps.longitude) ··· 304 231 305 232 // MARK: - Location 306 233 307 - private func searchLocation(query: String) async { 308 - isSearchingLocation = true 309 - defer { isSearchingLocation = false } 310 - locationSuggestions = await LocationServices.searchLocation(query: query) 311 - } 312 - 313 234 private func selectLocation(_ result: NominatimResult) { 314 235 let h3 = LocationServices.latLonToH3(latitude: result.latitude, longitude: result.longitude) 315 236 resolvedLocation = (h3: h3, name: result.name, address: result.address) 316 - locationQuery = "" 317 - locationSuggestions = [] 318 237 } 319 238 } 320 239 321 240 #Preview { 322 - StoryCreateView(client: XRPCClient(baseURL: AuthManager.serverURL)) 323 - .environment(AuthManager()) 241 + StoryCreateView(client: .preview) 242 + .previewEnvironments() 243 + .preferredColorScheme(.dark) 244 + .tint(Color("AccentColor")) 324 245 }
+2
Grain/Views/Stories/StoryRingView.swift
··· 56 56 } 57 57 } 58 58 .padding() 59 + .preferredColorScheme(.dark) 60 + .tint(Color("AccentColor")) 59 61 }
+1 -1
Grain/Views/Stories/StoryStripView.swift
··· 131 131 onAuthorTap: { _, _ in }, 132 132 onCreateTap: {} 133 133 ) 134 - .environment(ViewedStoryStorage()) 134 + .previewEnvironments() 135 135 }
+3 -2
GrainTests/FeedEndpointModelTests.swift
··· 43 43 44 44 // MARK: - PinnedFeed.defaults 45 45 46 - func testDefaultsContainsTwoFeeds() { 47 - XCTAssertEqual(PinnedFeed.defaults.count, 2) 46 + func testDefaultsContainsThreeFeeds() { 47 + XCTAssertEqual(PinnedFeed.defaults.count, 3) 48 48 XCTAssertEqual(PinnedFeed.defaults[0].id, "recent") 49 49 XCTAssertEqual(PinnedFeed.defaults[1].id, "following") 50 + XCTAssertEqual(PinnedFeed.defaults[2].id, "foryou") 50 51 } 51 52 }
+4 -4
GrainTests/PhotoEditorTests.swift GrainTests/GalleryEditorTests.swift
··· 2 2 import UIKit 3 3 import XCTest 4 4 5 - final class PhotoEditorTests: XCTestCase { 5 + final class GalleryEditorTests: XCTestCase { 6 6 // MARK: - PhotoItem Selection Stability 7 7 8 8 func testSelectionStableThroughReorder() throws { 9 9 let img = UIImage() 10 - var items = (0 ..< 5).map { _ in PhotoItem(thumbnail: img, source: .camera(img, metadata: nil)) } 10 + var items = (0 ..< 5).map { _ in PhotoItem(thumbnail: img, carouselPreview: img, source: .camera(img, metadata: nil)) } 11 11 let selectedID = items[2].id 12 12 13 13 // Move item at index 0 to index 3 ··· 21 21 22 22 func testSelectionFallsBackOnDeletion() { 23 23 let img = UIImage() 24 - var items = (0 ..< 3).map { _ in PhotoItem(thumbnail: img, source: .camera(img, metadata: nil)) } 24 + var items = (0 ..< 3).map { _ in PhotoItem(thumbnail: img, carouselPreview: img, source: .camera(img, metadata: nil)) } 25 25 var selectedID: UUID? = items[2].id 26 26 27 27 // Remove the selected item ··· 37 37 38 38 func testAltTextPreservedAcrossSelection() { 39 39 let img = UIImage() 40 - var items = (0 ..< 3).map { _ in PhotoItem(thumbnail: img, source: .camera(img, metadata: nil)) } 40 + var items = (0 ..< 3).map { _ in PhotoItem(thumbnail: img, carouselPreview: img, source: .camera(img, metadata: nil)) } 41 41 items[0].alt = "First photo" 42 42 items[1].alt = "Second photo" 43 43
+4 -1
project.yml
··· 48 48 CURRENT_PROJECT_VERSION: "46" 49 49 CODE_SIGN_STYLE: Automatic 50 50 DEVELOPMENT_ASSET_PATHS: "\"$(SRCROOT)/Grain/Preview Content\"" 51 + configs: 52 + Debug: 53 + CODE_SIGN_ENTITLEMENTS: Grain/Grain-Debug.entitlements 51 54 dependencies: 52 55 - package: Nuke 53 56 product: Nuke ··· 107 110 targets: 108 111 - GrainTests 109 112 profile: 110 - config: Release 113 + config: Debug 111 114 analyze: 112 115 config: Debug 113 116 archive: