Skip to content

Commit

Permalink
feat: Tap on map stop icons to view stop page (#117)
Browse files Browse the repository at this point in the history
* feat: Tap on map stop icons to view stop page

* test: Add test for stop page changing

* fix: ViewportProvider project file weirdness

Moving files to groups in the xcode project  doesn't move them to the
corresponding directory automatically.
  • Loading branch information
EmmaSimon authored Apr 10, 2024
1 parent 75c0f57 commit f8b4d33
Show file tree
Hide file tree
Showing 10 changed files with 227 additions and 65 deletions.
14 changes: 13 additions & 1 deletion iosApp/iosApp.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@
9A635D1F2B99103200A43C51 /* EmptyWhenModifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A635D1E2B99103200A43C51 /* EmptyWhenModifierTests.swift */; };
9A69D4902B99212400235125 /* NearbyFetcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A69D48F2B99212300235125 /* NearbyFetcherTests.swift */; };
9A6DDF912B976FDF004D141A /* EmptyWhenModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A6DDF902B976FDF004D141A /* EmptyWhenModifier.swift */; };
9A6FA0232BC70D0B0067769C /* InspectionEmissary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A6FA0222BC70D0A0067769C /* InspectionEmissary.swift */; };
9A6FA0252BC714360067769C /* HomeMapViewTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A6FA0242BC714360067769C /* HomeMapViewTest.swift */; };
9A6FA0282BC72F110067769C /* StopDetailsPageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A6FA0272BC72F110067769C /* StopDetailsPageTests.swift */; };
9A7B7CA92B98E41B0045214F /* NonNilModifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A7B7CA82B98E41B0045214F /* NonNilModifierTests.swift */; };
9A887D572B683103006F5B80 /* SearchResultView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A887D562B683103006F5B80 /* SearchResultView.swift */; };
9A887D592B698EF1006F5B80 /* SearchResultViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A887D582B698EF1006F5B80 /* SearchResultViewTests.swift */; };
Expand Down Expand Up @@ -192,7 +195,7 @@
9A3B09352B967CEC00691427 /* NonNilModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonNilModifier.swift; sourceTree = "<group>"; };
9A4E8E582B7EC4B90066B936 /* RoutePill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoutePill.swift; sourceTree = "<group>"; };
9A5830552BA3A2CE0039876E /* ViewportExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewportExtension.swift; sourceTree = "<group>"; };
9A5830572BA4A1A30039876E /* ViewportProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ViewportProvider.swift; path = iosApp/ViewportProvider.swift; sourceTree = SOURCE_ROOT; };
9A5830572BA4A1A30039876E /* ViewportProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewportProvider.swift; sourceTree = "<group>"; };
9A5B27512BB1EF45009A6FC6 /* StopSourceGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopSourceGenerator.swift; sourceTree = "<group>"; };
9A5B27532BB1EF53009A6FC6 /* RouteSourceGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteSourceGenerator.swift; sourceTree = "<group>"; };
9A5B27552BB221C1009A6FC6 /* RouteLayerGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteLayerGenerator.swift; sourceTree = "<group>"; };
Expand All @@ -208,6 +211,9 @@
9A635D1E2B99103200A43C51 /* EmptyWhenModifierTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyWhenModifierTests.swift; sourceTree = "<group>"; };
9A69D48F2B99212300235125 /* NearbyFetcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NearbyFetcherTests.swift; sourceTree = "<group>"; };
9A6DDF902B976FDF004D141A /* EmptyWhenModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyWhenModifier.swift; sourceTree = "<group>"; };
9A6FA0222BC70D0A0067769C /* InspectionEmissary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectionEmissary.swift; sourceTree = "<group>"; };
9A6FA0242BC714360067769C /* HomeMapViewTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeMapViewTest.swift; sourceTree = "<group>"; };
9A6FA0272BC72F110067769C /* StopDetailsPageTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StopDetailsPageTests.swift; sourceTree = "<group>"; };
9A7B7CA82B98E41B0045214F /* NonNilModifierTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonNilModifierTests.swift; sourceTree = "<group>"; };
9A887D562B683103006F5B80 /* SearchResultView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultView.swift; sourceTree = "<group>"; };
9A887D582B698EF1006F5B80 /* SearchResultViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultViewTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -309,6 +315,7 @@
6EED5EA92B3DF1BF0052A1B8 /* Views */,
6EED5E8E2B3DC6A00052A1B8 /* IosAppTests.swift */,
8C7FA86E2B5EEA34009B699D /* LocationDataManagerTests.swift */,
9A6FA0222BC70D0A0067769C /* InspectionEmissary.swift */,
);
path = iosAppTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -427,6 +434,7 @@
8CB823D72BC5EDBF002C87E0 /* StopDetails */ = {
isa = PBXGroup;
children = (
9A6FA0272BC72F110067769C /* StopDetailsPageTests.swift */,
8CB823D82BC5EDD2002C87E0 /* StopDetailsRouteViewTests.swift */,
8CB823DA2BC5F053002C87E0 /* StopDetailsRoutesViewTests.swift */,
8CB823DC2BC5F432002C87E0 /* StopDetailsFilteredRouteViewTests.swift */,
Expand Down Expand Up @@ -509,6 +517,7 @@
9A5B27632BB3621F009A6FC6 /* RouteLayerGeneratorTests.swift */,
9A5B27672BB36A23009A6FC6 /* StopLayerGeneratorTests.swift */,
9A5B27652BB3631F009A6FC6 /* MapTestDataHelper.swift */,
9A6FA0242BC714360067769C /* HomeMapViewTest.swift */,
);
path = Map;
sourceTree = "<group>";
Expand Down Expand Up @@ -840,10 +849,12 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
9A6FA0252BC714360067769C /* HomeMapViewTest.swift in Sources */,
9AD1D1FE2BA4D5C600182060 /* ViewportProviderTest.swift in Sources */,
9A887D592B698EF1006F5B80 /* SearchResultViewTests.swift in Sources */,
9A5B27602BB31178009A6FC6 /* StopSourceGeneratorTests.swift in Sources */,
8CB823D62BC5E85C002C87E0 /* SheetNavigationStackEntryTests.swift in Sources */,
9A6FA0232BC70D0B0067769C /* InspectionEmissary.swift in Sources */,
6EE745842B965B9C0052227E /* SocketTests.swift in Sources */,
8CB823DD2BC5F432002C87E0 /* StopDetailsFilteredRouteViewTests.swift in Sources */,
9A5B27682BB36A23009A6FC6 /* StopLayerGeneratorTests.swift in Sources */,
Expand All @@ -859,6 +870,7 @@
6E35D4D32B72CD3900A2BF95 /* HomeMapViewTests.swift in Sources */,
9A69D4902B99212400235125 /* NearbyFetcherTests.swift in Sources */,
9A5B27642BB3621F009A6FC6 /* RouteLayerGeneratorTests.swift in Sources */,
9A6FA0282BC72F110067769C /* StopDetailsPageTests.swift in Sources */,
9A635D1F2B99103200A43C51 /* EmptyWhenModifierTests.swift in Sources */,
8C7FA86F2B5EEA34009B699D /* LocationDataManagerTests.swift in Sources */,
6EED5E8F2B3DC6A00052A1B8 /* IosAppTests.swift in Sources */,
Expand Down
65 changes: 33 additions & 32 deletions iosApp/iosApp/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,30 +55,11 @@ struct ContentView: View {
nearbyFetcher: nearbyFetcher,
railRouteShapeFetcher: railRouteShapeFetcher,
viewportProvider: viewportProvider,
navigationStack: $navigationStack,
sheetHeight: $sheetHeight
)
.ignoresSafeArea(edges: .bottom)
.sheet(isPresented: .constant(true)) {
NavigationStack(path: $navigationStack) {
nearbyTransit
.navigationBarHidden(true)
.navigationDestination(for: SheetNavigationStackEntry.self) { entry in
switch entry {
case let .stopDetails(stop, _):
StopDetailsPage(
backend: backendProvider.backend,
socket: socketProvider.socket,
globalFetcher: globalFetcher,
stop: stop, filter: $navigationStack.lastStopDetailsFilter
)
}
}
}
.partialSheetDetents(
sheetDetents,
largestUndimmedDetent: .medium
)
}
.sheet(isPresented: .constant(true)) { navigationSheet }
}
}
.searchable(
Expand All @@ -90,7 +71,8 @@ struct ContentView: View {
Task {
try await globalFetcher.getGlobalData()
}
}.onChange(of: scenePhase) { newPhase in
}
.onChange(of: scenePhase) { newPhase in
if newPhase == .active {
socketProvider.socket.connect()
} else if newPhase == .background {
Expand All @@ -99,23 +81,42 @@ struct ContentView: View {
}.task { alertsFetcher.run() }
}

private var nearbyTransit: some View {
var navigationSheet: some View {
GeometryReader { proxy in
NearbyTransitPageView(
currentLocation: locationDataManager.currentLocation?.coordinate,
globalFetcher: globalFetcher,
nearbyFetcher: nearbyFetcher,
scheduleFetcher: scheduleFetcher,
predictionsFetcher: predictionsFetcher,
viewportProvider: viewportProvider,
alertsFetcher: alertsFetcher
)
NavigationStack(path: $navigationStack) {
NearbyTransitPageView(
currentLocation: locationDataManager.currentLocation?.coordinate,
globalFetcher: globalFetcher,
nearbyFetcher: nearbyFetcher,
scheduleFetcher: scheduleFetcher,
predictionsFetcher: predictionsFetcher,
viewportProvider: viewportProvider,
alertsFetcher: alertsFetcher
)
.navigationBarHidden(true)
.navigationDestination(for: SheetNavigationStackEntry.self) { entry in
switch entry {
case let .stopDetails(stop, _):
StopDetailsPage(
backend: backendProvider.backend,
socket: socketProvider.socket,
globalFetcher: globalFetcher,
viewportProvider: viewportProvider,
stop: stop, filter: $navigationStack.lastStopDetailsFilter
)
}
}
}
.onChange(of: proxy.size.height) { newValue in
// Not actually restricted to iOS 16, this just behaves terribly on iOS 15
if #available(iOS 16, *) {
sheetHeight = newValue
}
}
.partialSheetDetents(
sheetDetents,
largestUndimmedDetent: .medium
)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,27 +9,34 @@
@_spi(Experimental) import MapboxMaps

class ViewportProvider: ObservableObject {
static let defaultAnimation: ViewportAnimation = .easeInOut(duration: 1)
static let defaultCenter: CLLocationCoordinate2D = .init(latitude: 42.356395, longitude: -71.062424)
static let defaultZoom: CGFloat = StopIcons.stopZoomThreshold + 0.25

@Published var viewport: Viewport
@Published var cameraState: CameraState

init(viewport: Viewport? = nil) {
self.viewport = viewport ?? .camera(center: ViewportProvider.defaultCenter, zoom: ViewportProvider.defaultZoom)
self.viewport = viewport ?? .camera(center: Self.defaultCenter, zoom: Self.defaultZoom)
let viewportCamera = viewport?.camera
cameraState = .init(
center: viewportCamera?.center ?? ViewportProvider.defaultCenter,
center: viewportCamera?.center ?? Self.defaultCenter,
padding: viewportCamera?.padding ?? .zero,
zoom: viewportCamera?.zoom ?? ViewportProvider.defaultZoom,
zoom: viewportCamera?.zoom ?? Self.defaultZoom,
bearing: viewportCamera?.bearing ?? 0.0,
pitch: viewportCamera?.pitch ?? 0.0
)
}

func follow(animation: ViewportAnimation = .easeInOut(duration: 1)) {
func follow(animation: ViewportAnimation = defaultAnimation) {
withViewportAnimation(animation) {
self.viewport = .followPuck(zoom: self.viewport.camera?.zoom ?? ViewportProvider.defaultZoom)
self.viewport = .followPuck(zoom: self.viewport.camera?.zoom ?? Self.defaultZoom)
}
}

func animateTo(coordinates: CLLocationCoordinate2D, animation: ViewportAnimation = defaultAnimation) {
withViewportAnimation(animation) {
self.viewport = .camera(center: coordinates, zoom: Self.defaultZoom)
}
}
}
40 changes: 31 additions & 9 deletions iosApp/iosApp/Pages/Map/HomeMapView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,38 +21,44 @@ struct HomeMapView: View {
@ObservedObject var railRouteShapeFetcher: RailRouteShapeFetcher
@ObservedObject var viewportProvider: ViewportProvider

@State private var layerManager: MapLayerManager?
@StateObject private var locationDataManager: LocationDataManager
@Binding var navigationStack: [SheetNavigationStackEntry]
@Binding var sheetHeight: CGFloat

@State private var layerManager: MapLayerManager?
@State private var recenterButton: ViewAnnotation?
@State private var now = Date.now
@State private var currentStopAlerts: [String: AlertAssociatedStop] = [:]
@Binding var sheetHeight: CGFloat

let inspection = Inspection<Self>()
let timer = Timer.publish(every: 300, on: .main, in: .common).autoconnect()
let log = Logger()

init(
alertsFetcher: AlertsFetcher,
globalFetcher: GlobalFetcher,
nearbyFetcher: NearbyFetcher,
railRouteShapeFetcher: RailRouteShapeFetcher,
locationDataManager: LocationDataManager = .init(distanceFilter: 1),
viewportProvider: ViewportProvider,
locationDataManager: LocationDataManager = .init(distanceFilter: 1),
navigationStack: Binding<[SheetNavigationStackEntry]>,
sheetHeight: Binding<CGFloat>
) {
self.alertsFetcher = alertsFetcher
self.globalFetcher = globalFetcher
self.nearbyFetcher = nearbyFetcher
self.railRouteShapeFetcher = railRouteShapeFetcher
self.viewportProvider = viewportProvider
_sheetHeight = sheetHeight
_locationDataManager = StateObject(wrappedValue: locationDataManager)
_navigationStack = navigationStack
_sheetHeight = sheetHeight
}

var body: some View {
MapReader { proxy in
Map(viewport: $viewportProvider.viewport) {
Puck2D().pulsing(.none)
if isNearbyNotFollowing() {
if isNearbyNotFollowing(), navigationStack.isEmpty {
MapViewAnnotation(coordinate: nearbyFetcher.loadedLocation!) {
Circle()
.strokeBorder(.white, lineWidth: 2.5)
Expand Down Expand Up @@ -93,6 +99,7 @@ struct HomeMapView: View {
.onChange(of: currentStopAlerts) { nextStopAlerts in
handleStopAlertChange(alertsByStop: nextStopAlerts)
}
.onReceive(inspection.notice) { inspection.visit(self, $0) }
.overlay(alignment: .topTrailing) {
if !viewportProvider.viewport.isFollowing, locationDataManager.currentLocation != nil {
RecenterButton { Task { viewportProvider.follow() } }
Expand Down Expand Up @@ -179,10 +186,25 @@ struct HomeMapView: View {
layerManager?.updateSourceData(stopSourceGenerator: updatedSources)
}

func handleStopLayerTap(feature _: QueriedFeature, _: MapContentGestureContext) -> Bool {
// Each stop feature has the stop ID as the identifier
// We can also set arbitrary JSON properties if we need to
true
func handleStopLayerTap(feature: QueriedFeature, _: MapContentGestureContext) -> Bool {
guard case let .string(stopId) = feature.feature.properties?[StopSourceGenerator.propIdKey] else {
let featureId = feature.feature.identifier.debugDescription
log.error("""
Stop icon featureId=`\(featureId)` was tapped, but had invalid stop id prop. sourceId=\(feature.source)
""")
return true
}
guard let stop = globalFetcher.stops[stopId] else {
let featureId = feature.feature.identifier.debugDescription
log.error("""
Stop icon featureId=`\(featureId)` was tapped but stopId=\(stopId) didn't exist in global stops.
""")
return true
}

navigationStack.removeAll()
navigationStack.append(.stopDetails(stop, nil))
return true
}

func isNearbyNotFollowing() -> Bool {
Expand Down
39 changes: 25 additions & 14 deletions iosApp/iosApp/Pages/StopDetails/StopDetailsPage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,27 @@ import SwiftUI

struct StopDetailsPage: View {
@ObservedObject var globalFetcher: GlobalFetcher
@ObservedObject var viewportProvider: ViewportProvider

@StateObject var scheduleFetcher: ScheduleFetcher
@StateObject var predictionsFetcher: PredictionsFetcher
var stop: Stop
@Binding var filter: StopDetailsFilter?
@State var now = Date.now

let inspection = Inspection<Self>()
let timer = Timer.publish(every: 5, on: .main, in: .common).autoconnect()

init(backend: any BackendProtocol, socket: any PhoenixSocket, globalFetcher: GlobalFetcher,
stop: Stop, filter: Binding<StopDetailsFilter?>) {
init(
backend: any BackendProtocol,
socket: any PhoenixSocket,
globalFetcher: GlobalFetcher,
viewportProvider: ViewportProvider,
stop: Stop,
filter: Binding<StopDetailsFilter?>
) {
self.globalFetcher = globalFetcher
self.viewportProvider = viewportProvider
_scheduleFetcher = StateObject(wrappedValue: ScheduleFetcher(backend: backend))
_predictionsFetcher = StateObject(wrappedValue: PredictionsFetcher(socket: socket))
self.stop = stop
Expand Down Expand Up @@ -52,25 +62,26 @@ struct StopDetailsPage: View {
}
}
.navigationTitle(stop.name)
.onAppear {
getSchedule()
joinPredictions()
}
.onReceive(timer) { input in
now = input
}
.onDisappear {
leavePredictions()
}
.onAppear { changeStop(stop) }
.onChange(of: stop) { nextStop in changeStop(nextStop) }
.onReceive(inspection.notice) { inspection.visit(self, $0) }
.onReceive(timer) { input in now = input }
.onDisappear { leavePredictions() }
}

func changeStop(_ stop: Stop) {
getSchedule(stop)
joinPredictions(stop)
viewportProvider.animateTo(coordinates: stop.coordinate)
}

func getSchedule() {
func getSchedule(_ stop: Stop) {
Task {
await scheduleFetcher.getSchedule(stopIds: [stop.id])
}
}

func joinPredictions() {
func joinPredictions(_ stop: Stop) {
Task {
predictionsFetcher.run(stopIds: [stop.id])
}
Expand Down
16 changes: 16 additions & 0 deletions iosApp/iosAppTests/InspectionEmissary.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//
// InspectionEmissary.swift
// iosAppTests
//
// This is used with ViewInspector as recommended in their docs.
// See https://github.com/nalexn/ViewInspector/blob/0.9.11/guide.md#approach-2
//
// Created by Simon, Emma on 4/10/24.
// Copyright © 2024 MBTA. All rights reserved.
//

import Foundation
@testable import iosApp
import ViewInspector

extension Inspection: InspectionEmissary {}
Loading

0 comments on commit f8b4d33

Please sign in to comment.