~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain-ios

1 collaborator
0
1
main
237 lines · 8.9 KB Raw History
import CoreLocation
import SwiftUI

struct LocationFeedView: View {
    @Environment(AuthManager.self) private var auth
    @Environment(\.dismiss) private var dismiss
    @State private var galleries: [GrainGallery] = []
    @State private var cursor: String?
    @State private var isLoading = false
    @State private var isPinned = false
    @Environment(Router.self) private var router
    @State private var zoomState = ImageZoomState()
    @State private var modals = GalleryCardModals()
    /// Full screen or nothing: a strip is too small to work a map in.
    @State private var mapFullScreen = false

    let client: XRPCClient
    let h3Index: String
    let locationName: String

    private var feedId: String {
        "location:\(h3Index)"
    }

    /// Roughly 80km across on a phone: a city and its surroundings. Wider
    /// than the 20km frame this had, which sat inside the city with only
    /// district names to read.
    private static let cityZoom = 9.0

    private var coordinate: CLLocationCoordinate2D? {
        LocationServices.h3ToCoordinate(h3Index)
    }

    var body: some View {
        ScrollView {
            LazyVStack(spacing: 0) {
                if let coord = coordinate {
                    // grain's own basemap at city scale, with the place named
                    // at its point: nothing reaches the strip, and a tap opens
                    // the same map over the whole screen where there is room
                    // to work it.
                    GrainMapView(
                        marker: MapMarker(coordinate: coord, label: localityOf(locationName)),
                        initialCenter: (coord, Self.cityZoom),
                        interactive: false
                    )
                    .frame(height: 150)
                    .overlay {
                        Color.clear
                            .contentShape(Rectangle())
                            .accessibilityLabel("Expand map")
                            .accessibilityAddTraits(.isButton)
                            .onTapGesture { mapFullScreen = true }
                    }
                    .mask(
                        LinearGradient(
                            colors: [.black, .black, .clear],
                            startPoint: .top,
                            endPoint: .bottom
                        )
                    )
                    .overlay(alignment: .topTrailing) {
                        MapAttribution().padding(6)
                    }
                    .fullScreenCover(isPresented: $mapFullScreen) {
                        ZStack(alignment: .topLeading) {
                            GrainMapView(
                                marker: MapMarker(coordinate: coord, label: localityOf(locationName)),
                                initialCenter: (coord, Self.cityZoom),
                                interactive: true
                            )
                            .ignoresSafeArea()
                            .overlay(alignment: .topTrailing) {
                                MapAttribution().padding(12)
                            }
                            Button {
                                mapFullScreen = false
                            } label: {
                                Image(systemName: "arrow.left")
                                    .font(.body.weight(.semibold))
                                    .frame(width: 40, height: 40)
                                    .background(.regularMaterial, in: Circle())
                            }
                            .buttonStyle(.plain)
                            .padding(8)
                            .accessibilityLabel("Close map")
                        }
                    }
                }

                ForEach($galleries) { $gallery in
                    let isOwner = gallery.creator.did == auth.userDID
                    let reportAction: (() -> Void)? = !isOwner ? {
                        modals.report = gallery
                    } : nil
                    let deleteAction: (() -> Void)? = isOwner ? {
                        modals.confirmDelete(gallery.uri)
                    } : nil
                    GalleryCardView(gallery: $gallery, client: client, onCommentTap: {
                        modals.commentUri = gallery.uri
                    }, onStoryTap: { author in
                        modals.storyAuthor = author
                    }, onReport: reportAction, onDelete: deleteAction, showsLocationLink: false)
                        .onAppear {
                            if gallery.id == galleries.last?.id {
                                Task { await loadMore() }
                            }
                        }
                }

                if isLoading {
                    ProgressView()
                        .padding()
                }
            }
        }
        .gesture(
            // Rightward swipe outside the carousel pops the nav. Exclusive
            // .gesture so the child TabView in GalleryCardView claims swipes
            // on the image area first.
            DragGesture(minimumDistance: 30)
                .onEnded { value in
                    let dx = value.translation.width
                    let dy = value.translation.height
                    let predicted = value.predictedEndTranslation.width
                    if dx > 80, abs(dy) < 60, predicted > 120 {
                        dismiss()
                    }
                }
        )
        .environment(zoomState)
        .modifier(ImageZoomOverlay(zoomState: zoomState))
        .navigationTitle(locationName)
        .navigationBarTitleDisplayMode(.inline)
        .toolbar {
            ToolbarItem(placement: .topBarTrailing) {
                Menu {
                    Button {
                        Task { await togglePin() }
                    } label: {
                        Label(isPinned ? "Unpin feed" : "Pin feed",
                              systemImage: isPinned ? "pin.slash" : "pin")
                    }
                } label: {
                    Image(systemName: "ellipsis")
                        .font(.system(size: 16, weight: .medium))
                }
                .tint(.primary)
                .accessibilityLabel("More options")
            }
        }
        .task {
            guard !isPreview else { return }
            await checkPinned()
        }
        .galleryCardModals(
            client: client,
            modals: modals,
            onCommentCountChanged: { uri, count in
                if let idx = galleries.firstIndex(where: { $0.uri == uri }) {
                    galleries[idx].commentCount = count
                }
            },
            onDeleted: { uri in galleries.removeAll { $0.uri == uri } }
        )
        .task {
            guard !isPreview else {
                #if DEBUG
                    galleries = PreviewData.galleries
                #endif
                return
            }
            if galleries.isEmpty {
                await loadInitial()
            }
        }
    }

    private func loadInitial() async {
        isLoading = true
        do {
            let response = try await client.getFeed(feed: "location", location: h3Index, name: locationName, auth: auth.authContext())
            galleries = response.items ?? []
            cursor = response.cursor
        } catch {}
        isLoading = false
    }

    private func loadMore() async {
        guard !isLoading, let cursor else { return }
        isLoading = true
        do {
            let response = try await client.getFeed(feed: "location", cursor: cursor, location: h3Index, name: locationName, auth: auth.authContext())
            galleries.append(contentsOf: response.items ?? [])
            self.cursor = response.cursor
        } catch {}
        isLoading = false
    }

    private func checkPinned() async {
        do {
            let response = try await client.getPreferences(auth: auth.authContext())
            isPinned = response.preferences.pinnedFeeds?.contains(where: { $0.id == feedId }) ?? false
        } catch {}
    }

    private func togglePin() async {
        do {
            let response = try await client.getPreferences(auth: auth.authContext())
            var feeds = response.preferences.pinnedFeeds ?? PinnedFeed.defaults
            if isPinned {
                feeds.removeAll { $0.id == feedId }
            } else {
                feeds.append(PinnedFeed(id: feedId, label: locationName, type: "location", path: "/location/\(h3Index)"))
            }
            try await client.putPinnedFeeds(feeds, auth: auth.authContext())
            isPinned.toggle()
        } catch {}
    }
}

struct LocationDestination: Hashable, Identifiable {
    let h3Index: String
    let name: String
    var id: String {
        h3Index
    }
}

#Preview {
    LocationFeedView(
        client: .preview,
        h3Index: "8928308280fffff",
        locationName: "San Francisco"
    )
    .previewEnvironments()
}