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: clickable locations on gallery cards with location feed view

- Tapping a location name navigates to a LocationFeedView showing all
galleries at that location
- Coarsens venue-level H3 indices to city-level (resolution 5) to match
web client behavior
- Added h3ToCity helper using SwiftyH3
- Wired up location navigation across feed, search, detail, and hashtag views
- Added spacing between gallery cards in feed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+186 -3
+9
Grain/Utilities/LocationServices.swift
··· 72 72 return cell.description 73 73 } 74 74 75 + /// Coarsen an H3 index to city level (resolution 5). 76 + static func h3ToCity(_ h3Index: String) -> String { 77 + guard let cell = H3Cell(h3Index), 78 + let res = try? cell.resolution, 79 + res.rawValue > 5, 80 + let parent = try? cell.parent(at: .res5) else { return h3Index } 81 + return parent.description 82 + } 83 + 75 84 /// Reverse geocode coordinates via Nominatim. 76 85 static func reverseGeocode(latitude: Double, longitude: Double) async -> NominatimResult? { 77 86 var components = URLComponents(string: "https://nominatim.openstreetmap.org/reverse")!
+6 -2
Grain/Views/Components/GalleryCardView.swift
··· 99 99 var onNavigate: () -> Void = {} 100 100 var onProfileTap: ((String) -> Void)? 101 101 var onHashtagTap: ((String) -> Void)? 102 + var onLocationTap: ((String, String) -> Void)? 102 103 var onStoryTap: ((GrainStoryAuthor) -> Void)? 103 104 @State private var isFavoriting = false 104 105 @State private var currentPage = 0 ··· 135 136 onProfileTap?(gallery.creator.did) 136 137 } 137 138 138 - VStack(alignment: .leading, spacing: 0) { 139 + VStack(alignment: .leading, spacing: 3) { 139 140 HStack(spacing: 4) { 140 141 Text(gallery.creator.displayName ?? gallery.creator.handle) 141 142 .font(.subheadline.weight(.semibold)) ··· 149 150 .foregroundStyle(.secondary) 150 151 .fixedSize() 151 152 } 152 - if let locationName = gallery.location?.name ?? gallery.address?.locality { 153 + if let location = gallery.location, let locationName = location.name ?? gallery.address?.locality { 153 154 Text(locationName) 154 155 .font(.caption) 155 156 .foregroundStyle(.secondary) 156 157 .lineLimit(1) 158 + .onTapGesture { 159 + onLocationTap?(LocationServices.h3ToCity(location.value), locationName) 160 + } 157 161 } 158 162 } 159 163
+7 -1
Grain/Views/Feed/FeedView.swift
··· 206 206 @State private var selectedUri: String? 207 207 @State private var selectedProfileDid: String? 208 208 @State private var selectedHashtag: String? 209 + @State private var selectedLocation: LocationDestination? 209 210 @State private var deletedGalleryUri: String? 210 211 @State private var zoomState = ImageZoomState() 211 212 @State private var cardStoryAuthor: GrainStoryAuthor? ··· 230 231 231 232 var body: some View { 232 233 ScrollView { 233 - LazyVStack(spacing: 0) { 234 + LazyVStack(spacing: 12) { 234 235 StoryStripView( 235 236 authors: storyAuthors, 236 237 userAvatar: userAvatar, ··· 246 247 selectedProfileDid = did 247 248 }, onHashtagTap: { tag in 248 249 selectedHashtag = tag 250 + }, onLocationTap: { h3, name in 251 + selectedLocation = LocationDestination(h3Index: h3, name: name) 249 252 }, onStoryTap: { author in 250 253 cardStoryAuthor = author 251 254 }) ··· 279 282 } 280 283 .navigationDestination(item: $selectedHashtag) { tag in 281 284 HashtagFeedView(client: client, tag: tag) 285 + } 286 + .navigationDestination(item: $selectedLocation) { loc in 287 + LocationFeedView(client: client, h3Index: loc.h3Index, locationName: loc.name) 282 288 } 283 289 .fullScreenCover(item: $cardStoryAuthor) { author in 284 290 StoryViewer(
+6
Grain/Views/Feed/HashtagFeedView.swift
··· 9 9 @State private var selectedUri: String? 10 10 @State private var selectedProfileDid: String? 11 11 @State private var selectedHashtag: String? 12 + @State private var selectedLocation: LocationDestination? 12 13 @State private var zoomState = ImageZoomState() 13 14 @State private var cardStoryAuthor: GrainStoryAuthor? 14 15 ··· 27 28 selectedProfileDid = did 28 29 }, onHashtagTap: { tag in 29 30 selectedHashtag = tag 31 + }, onLocationTap: { h3, name in 32 + selectedLocation = LocationDestination(h3Index: h3, name: name) 30 33 }, onStoryTap: { author in 31 34 cardStoryAuthor = author 32 35 }) ··· 74 77 } 75 78 .navigationDestination(item: $selectedHashtag) { tag in 76 79 HashtagFeedView(client: client, tag: tag) 80 + } 81 + .navigationDestination(item: $selectedLocation) { loc in 82 + LocationFeedView(client: client, h3Index: loc.h3Index, locationName: loc.name) 77 83 } 78 84 .fullScreenCover(item: $cardStoryAuthor) { author in 79 85 StoryViewer(
+145
Grain/Views/Feed/LocationFeedView.swift
··· 1 + import SwiftUI 2 + 3 + struct LocationFeedView: View { 4 + @Environment(AuthManager.self) private var auth 5 + @State private var galleries: [GrainGallery] = [] 6 + @State private var cursor: String? 7 + @State private var isLoading = false 8 + @State private var isPinned = false 9 + @State private var selectedUri: String? 10 + @State private var selectedProfileDid: String? 11 + @State private var selectedHashtag: String? 12 + @State private var zoomState = ImageZoomState() 13 + @State private var cardStoryAuthor: GrainStoryAuthor? 14 + 15 + let client: XRPCClient 16 + let h3Index: String 17 + let locationName: String 18 + 19 + private var feedId: String { "location:\(h3Index)" } 20 + 21 + var body: some View { 22 + ScrollView { 23 + LazyVStack(spacing: 0) { 24 + ForEach($galleries) { $gallery in 25 + GalleryCardView(gallery: $gallery, client: client, onNavigate: { 26 + selectedUri = gallery.uri 27 + }, onProfileTap: { did in 28 + selectedProfileDid = did 29 + }, onHashtagTap: { tag in 30 + selectedHashtag = tag 31 + }, onStoryTap: { author in 32 + cardStoryAuthor = author 33 + }) 34 + .onAppear { 35 + if gallery.id == galleries.last?.id { 36 + Task { await loadMore() } 37 + } 38 + } 39 + } 40 + 41 + if isLoading { 42 + ProgressView() 43 + .padding() 44 + } 45 + } 46 + } 47 + .environment(zoomState) 48 + .modifier(ImageZoomOverlay(zoomState: zoomState)) 49 + .navigationTitle(locationName) 50 + .navigationBarTitleDisplayMode(.inline) 51 + .toolbar { 52 + ToolbarItem(placement: .topBarTrailing) { 53 + Menu { 54 + Button { 55 + Task { await togglePin() } 56 + } label: { 57 + Label(isPinned ? "Unpin Feed" : "Pin Feed", 58 + systemImage: isPinned ? "pin.slash" : "pin") 59 + } 60 + } label: { 61 + Image(systemName: "ellipsis") 62 + .font(.system(size: 16, weight: .medium)) 63 + } 64 + .tint(.primary) 65 + } 66 + } 67 + .task { 68 + await checkPinned() 69 + } 70 + .navigationDestination(item: $selectedUri) { uri in 71 + GalleryDetailView(client: client, galleryUri: uri) 72 + } 73 + .navigationDestination(item: $selectedProfileDid) { did in 74 + ProfileView(client: client, did: did) 75 + } 76 + .navigationDestination(item: $selectedHashtag) { tag in 77 + HashtagFeedView(client: client, tag: tag) 78 + } 79 + .fullScreenCover(item: $cardStoryAuthor) { author in 80 + StoryViewer( 81 + authors: [author], 82 + client: client, 83 + onProfileTap: { did in 84 + cardStoryAuthor = nil 85 + selectedProfileDid = did 86 + }, 87 + onDismiss: { cardStoryAuthor = nil } 88 + ) 89 + .environment(auth) 90 + } 91 + .task { 92 + if galleries.isEmpty { 93 + await loadInitial() 94 + } 95 + } 96 + } 97 + 98 + private func loadInitial() async { 99 + isLoading = true 100 + do { 101 + let response = try await client.getFeed(feed: "location", location: h3Index, auth: auth.authContext()) 102 + galleries = response.items ?? [] 103 + cursor = response.cursor 104 + } catch {} 105 + isLoading = false 106 + } 107 + 108 + private func loadMore() async { 109 + guard !isLoading, let cursor else { return } 110 + isLoading = true 111 + do { 112 + let response = try await client.getFeed(feed: "location", cursor: cursor, location: h3Index, auth: auth.authContext()) 113 + galleries.append(contentsOf: response.items ?? []) 114 + self.cursor = response.cursor 115 + } catch {} 116 + isLoading = false 117 + } 118 + 119 + private func checkPinned() async { 120 + do { 121 + let response = try await client.getPreferences(auth: auth.authContext()) 122 + isPinned = response.preferences.pinnedFeeds?.contains(where: { $0.id == feedId }) ?? false 123 + } catch {} 124 + } 125 + 126 + private func togglePin() async { 127 + do { 128 + let response = try await client.getPreferences(auth: auth.authContext()) 129 + var feeds = response.preferences.pinnedFeeds ?? PinnedFeed.defaults 130 + if isPinned { 131 + feeds.removeAll { $0.id == feedId } 132 + } else { 133 + feeds.append(PinnedFeed(id: feedId, label: locationName, type: "location", path: "/location/\(h3Index)")) 134 + } 135 + try await client.putPinnedFeeds(feeds, auth: auth.authContext()) 136 + isPinned.toggle() 137 + } catch {} 138 + } 139 + } 140 + 141 + struct LocationDestination: Hashable, Identifiable { 142 + let h3Index: String 143 + let name: String 144 + var id: String { h3Index } 145 + }
+7
Grain/Views/Gallery/GalleryDetailView.swift
··· 6 6 @State private var viewModel: GalleryDetailViewModel 7 7 @State private var selectedProfileDid: String? 8 8 @State private var selectedHashtag: String? 9 + @State private var selectedLocation: LocationDestination? 9 10 @State private var commentText = "" 10 11 @State private var isPostingComment = false 11 12 @State private var replyingTo: GrainComment? ··· 57 58 }, 58 59 onHashtagTap: { tag in 59 60 selectedHashtag = tag 61 + }, 62 + onLocationTap: { h3, name in 63 + selectedLocation = LocationDestination(h3Index: h3, name: name) 60 64 }, 61 65 onStoryTap: { author in 62 66 cardStoryAuthor = author ··· 123 127 } 124 128 .navigationDestination(item: $selectedHashtag) { tag in 125 129 HashtagFeedView(client: client, tag: tag) 130 + } 131 + .navigationDestination(item: $selectedLocation) { loc in 132 + LocationFeedView(client: client, h3Index: loc.h3Index, locationName: loc.name) 126 133 } 127 134 .toolbar { 128 135 ToolbarItem(placement: .topBarTrailing) {
+6
Grain/Views/Search/SearchView.swift
··· 8 8 @State private var searchNavigationUri: String? 9 9 @State private var selectedProfileDid: String? 10 10 @State private var selectedHashtag: String? 11 + @State private var selectedLocation: LocationDestination? 11 12 @State private var zoomState = ImageZoomState() 12 13 @State private var cardStoryAuthor: GrainStoryAuthor? 13 14 let client: XRPCClient ··· 34 35 selectedProfileDid = did 35 36 }, onHashtagTap: { tag in 36 37 selectedHashtag = tag 38 + }, onLocationTap: { h3, name in 39 + selectedLocation = LocationDestination(h3Index: h3, name: name) 37 40 }, onStoryTap: { author in 38 41 cardStoryAuthor = author 39 42 }) ··· 96 99 } 97 100 .navigationDestination(item: $selectedHashtag) { tag in 98 101 HashtagFeedView(client: client, tag: tag) 102 + } 103 + .navigationDestination(item: $selectedLocation) { loc in 104 + LocationFeedView(client: client, h3Index: loc.h3Index, locationName: loc.name) 99 105 } 100 106 .fullScreenCover(item: $cardStoryAuthor) { author in 101 107 StoryViewer(