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

grain.social/grain-ios

1 collaborator
0
1
main
323 lines · 13.2 KB Raw History
import CoreLocation
import SwiftUI

/// Every place, on a map that never goes away, with a sheet over it.
///
/// The map fills the window and its camera is the screen's state: nothing you
/// open moves it. The sheet peeks over the bottom third and drags up; by
/// default it holds the index tiles, and picking a place — a pin, a tile —
/// swaps them for that place's galleries while the camera flies there. The
/// arrow in the sheet brings the index back with the map untouched, and a tap
/// on another pin just swaps places. A push that came back to a map that had
/// forgotten where it was made every second look start over.
///
/// A gallery opens over the whole screen, as on Android: the sheet steps
/// aside for the push and comes back when the map does, with the place and
/// the camera exactly as they were.
struct LocationsView: View {
    /// A chip on explore names a place; the screen then opens on it, the map
    /// flying there instead of framing the world.
    struct InitialPlace: Hashable {
        var cell: String?
        var name: String?
        var galleryCount = 0
    }

    @Environment(AuthManager.self) private var auth
    @Environment(Router.self) private var router
    @State private var viewModel: LocationsViewModel
    @State private var camera: MapCameraRequest?
    @State private var sheetPresented = false
    @State private var fitted = false
    /// `task` runs again every time the screen reappears — after a gallery is
    /// popped, say — and re-selecting the chip's place then threw away
    /// wherever the reader had got to. The first appearance is the only one
    /// that loads.
    @State private var loaded = false
    /// How deep this screen sits in the tab's stack, taken when it first
    /// appears. The sheet belongs up only while nothing is pushed over it.
    @State private var depth: Int?

    let client: XRPCClient
    let initialPlace: InitialPlace

    /// How much of the window the sheet takes when it is out of the way.
    private static let peekFraction: CGFloat = 0.38
    /// How far up it can be dragged: a strip of the map stays visible above it.
    private static let maxFraction: CGFloat = 0.88
    /// The web fits with `maxZoom: 5`: the index opens on the world.
    private static let fitMaxZoom = 5.0
    /// City scale: far enough in that a picked place reads as somewhere.
    private static let placeZoom = 9.0

    init(client: XRPCClient, initialPlace: InitialPlace = InitialPlace(), viewModel: LocationsViewModel? = nil) {
        self.client = client
        self.initialPlace = initialPlace
        _viewModel = State(initialValue: viewModel ?? LocationsViewModel(client: client))
    }

    private var openedOnPlace: Bool {
        initialPlace.cell != nil && initialPlace.name != nil
    }

    private var marker: MapMarker? {
        guard let place = viewModel.selected, let coordinate = place.coordinate else { return nil }
        return MapMarker(coordinate: coordinate, label: localityOf(place.name))
    }

    var body: some View {
        GeometryReader { proxy in
            GrainMapView(
                pins: viewModel.pins,
                marker: marker,
                camera: camera,
                interactive: true,
                // The camera frames the part above the sheet and below the
                // bar, so a fit or a fly-to lands in view rather than under
                // either.
                contentInsets: UIEdgeInsets(
                    top: proxy.safeAreaInsets.top,
                    left: 0,
                    bottom: proxy.size.height * Self.peekFraction,
                    right: 0
                ),
                onSelectPin: { pin in
                    select(cell: pin.h3Index, name: pin.name, galleryCount: pin.galleryCount)
                }
            )
            .ignoresSafeArea()
            // Under the bar, where neither the sheet nor the back button is.
            .overlay(alignment: .topTrailing) {
                MapAttribution()
                    .padding(.trailing, 12)
                    .padding(.top, 6)
            }
        }
        .navigationBarTitleDisplayMode(.inline)
        .toolbarBackground(.hidden, for: .navigationBar)
        // Not `onAppear` alone: a swipe back from a gallery fires it the
        // moment the gesture starts, with the gallery still on the stack, and
        // a sheet presented into a pop that might be cancelled fought the
        // transition and left the screen stuck. The path only changes once a
        // pop completes, so it is what decides.
        .onAppear { syncSheet() }
        .onDisappear { sheetPresented = false }
        .onChange(of: router.path.count) { syncSheet() }
        .sheet(isPresented: $sheetPresented) { sheet }
        .task {
            guard !isPreview, !loaded else { return }
            loaded = true
            let context = await auth.authContext()
            if let cell = initialPlace.cell, let name = initialPlace.name {
                viewModel.select(cell: cell, name: name, galleryCount: initialPlace.galleryCount, auth: context)
            }
            await viewModel.load(auth: context)
        }
        // Once, when the pins arrive: the index opens on the world, and after
        // that the camera is the reader's. Not when the screen opened on a
        // place, whose fly-to the fit would land on top of.
        .onChange(of: viewModel.pins) {
            guard !fitted, !viewModel.pins.isEmpty, !openedOnPlace else { return }
            fitted = true
            camera = MapCameraRequest(move: .fit(maxZoom: Self.fitMaxZoom))
        }
        // Fly to a picked place, keeping whatever zoom the reader had worked
        // their way down to unless it is too far out to show a city.
        .onChange(of: viewModel.selected?.h3Index, initial: true) {
            guard let coordinate = viewModel.selected?.coordinate else { return }
            camera = MapCameraRequest(move: .fly(latitude: coordinate.latitude, longitude: coordinate.longitude, minZoom: Self.placeZoom))
        }
    }

    /// The credentials are a hop away, and a tap handler can't wait for
    /// them — so the sheet swaps first and the galleries follow.
    private func select(cell: String, name: String, galleryCount: Int) {
        Task {
            await viewModel.select(cell: cell, name: name, galleryCount: galleryCount, auth: auth.authContext())
        }
    }

    /// The push goes on the tab's stack, so the gallery has the whole
    /// window; the sheet would otherwise sit over it. `syncSheet` brings the
    /// sheet back once the gallery is popped.
    private func openGallery(_ uri: String) {
        router.push(.gallery(uri: uri))
        sheetPresented = false
    }

    /// Present the sheet when this screen is the top of the stack, and only
    /// then. Presenting waits a beat for the pop's animation to finish;
    /// dismissing is immediate.
    private func syncSheet() {
        let count = router.path.count
        if depth == nil {
            depth = count
        }
        guard count == depth else {
            sheetPresented = false
            return
        }
        Task {
            try? await Task.sleep(for: .milliseconds(250))
            guard router.path.count == depth else { return }
            sheetPresented = true
        }
    }

    private var sheet: some View {
        Group {
            if let place = viewModel.selected {
                PlaceSheet(place: place, onBack: viewModel.clearSelection, onOpenGallery: openGallery) {
                    await viewModel.loadMoreGalleries(auth: auth.authContext())
                }
            } else {
                IndexSheet(viewModel: viewModel) { place in
                    select(cell: place.h3Index, name: place.name, galleryCount: place.galleryCount)
                }
            }
        }
        .presentationDetents([.fraction(Self.peekFraction), .fraction(Self.maxFraction)])
        .presentationBackgroundInteraction(.enabled(upThrough: .fraction(Self.peekFraction)))
        .presentationDragIndicator(.visible)
        .interactiveDismissDisabled()
    }
}

/// The index: a heading and the top thirty as tiles.
struct IndexSheet: View {
    let viewModel: LocationsViewModel
    let onSelect: (LocationItem) -> Void

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 0) {
                SheetHeading(title: "Locations", meta: nil, onBack: nil)
                if viewModel.places.isEmpty {
                    SheetState(loading: viewModel.isLoading, text: viewModel.failed ? "Couldn't load locations." : "No locations yet.")
                } else {
                    LazyVGrid(columns: [GridItem(.flexible(), spacing: 2), GridItem(.flexible(), spacing: 2)], spacing: 2) {
                        // Unkeyed by cell on purpose: two places can legitimately
                        // share one, and the web's keyed list threw on exactly that.
                        ForEach(Array(viewModel.places.enumerated()), id: \.offset) { _, place in
                            BrowseTile(
                                title: place.name,
                                meta: counted(place.galleryCount, "gallery", "galleries"),
                                thumbs: place.thumbs ?? []
                            ) {
                                onSelect(place)
                            }
                        }
                    }
                }
            }
        }
    }
}

/// One place: its name over a grid of its galleries, paged.
struct PlaceSheet: View {
    let place: SelectedPlace
    let onBack: () -> Void
    let onOpenGallery: (String) -> Void
    let onLoadMore: () async -> Void

    @State private var width: CGFloat = 0
    private static let columns = 3
    private static let spacing: CGFloat = 2

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 0) {
                SheetHeading(title: place.name, meta: counted(place.galleryCount, "gallery", "galleries"), onBack: onBack)
                if place.galleries.isEmpty {
                    SheetState(loading: place.isLoading, text: "No galleries here yet.")
                } else {
                    let cellWidth = max((width - Self.spacing * CGFloat(Self.columns - 1)) / CGFloat(Self.columns), 0)
                    let cellSize = CGSize(width: cellWidth, height: cellWidth * 4 / 3)
                    LazyVGrid(
                        columns: Array(repeating: GridItem(.flexible(), spacing: Self.spacing), count: Self.columns),
                        spacing: Self.spacing
                    ) {
                        ForEach(place.galleries) { gallery in
                            GalleryThumbnailCell(gallery: gallery, cellSize: cellSize) {
                                onOpenGallery(gallery.uri)
                            }
                            .aspectRatio(3 / 4, contentMode: .fit)
                            .onAppear {
                                if gallery.id == place.galleries.last?.id {
                                    Task { await onLoadMore() }
                                }
                            }
                        }
                    }
                    if place.isLoadingMore {
                        ProgressView()
                            .frame(maxWidth: .infinity)
                            .padding()
                    }
                }
            }
        }
        // Keyed on the place so a swap starts the grid at the top rather than
        // wherever the last place was scrolled to.
        .id(place.h3Index)
        .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { width = $0 }
    }
}

private struct SheetHeading: View {
    let title: String
    let meta: String?
    let onBack: (() -> Void)?

    var body: some View {
        HStack(spacing: 4) {
            if let onBack {
                Button(action: onBack) {
                    Image(systemName: "arrow.left")
                        .font(.body.weight(.semibold))
                        .frame(width: 40, height: 40)
                        .contentShape(Rectangle())
                }
                .buttonStyle(.plain)
                .accessibilityLabel("All locations")
            }
            VStack(alignment: .leading, spacing: 2) {
                Text(title)
                    .font(.title2.weight(.bold))
                    .lineLimit(1)
                if let meta {
                    Text(meta)
                        .font(.caption)
                        .foregroundStyle(.secondary)
                }
            }
            Spacer(minLength: 0)
        }
        .padding(.leading, onBack == nil ? 16 : 6)
        .padding(.trailing, 16)
        // Room under the drag indicator and over the grid, so the heading
        // reads as the sheet's own rather than the first row's.
        .padding(.top, 26)
        .padding(.bottom, 18)
    }
}

private struct SheetState: View {
    let loading: Bool
    let text: String

    var body: some View {
        Group {
            if loading {
                ProgressView()
            } else {
                Text(text)
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
            }
        }
        .frame(maxWidth: .infinity)
        .padding(.vertical, 60)
    }
}