diff --git a/iosApp/iosApp/Pages/NearbyTransit/NearbyTransitView.swift b/iosApp/iosApp/Pages/NearbyTransit/NearbyTransitView.swift index cc962e1ba..baa0affe7 100644 --- a/iosApp/iosApp/Pages/NearbyTransit/NearbyTransitView.swift +++ b/iosApp/iosApp/Pages/NearbyTransit/NearbyTransitView.swift @@ -21,31 +21,27 @@ struct NearbyTransitView: View { @ObservedObject var scheduleFetcher: ScheduleFetcher @ObservedObject var predictionsFetcher: PredictionsFetcher @ObservedObject var alertsFetcher: AlertsFetcher + @State var nearbyWithRealtimeInfo: [StopAssociatedRoute]? @State var now = Date.now + @State var scrollPosition: String? let timer = Timer.publish(every: 5, on: .main, in: .common).autoconnect() let inspection = Inspection() var body: some View { VStack { - if let nearby = nearbyFetcher.withRealtimeInfo( - schedules: scheduleFetcher.schedules, - predictions: predictionsFetcher.predictions, - alerts: alertsFetcher.alerts, - filterAtTime: now.toKotlinInstant() - ) { - List(nearby, id: \.route.id) { nearbyRoute in - NearbyRouteView(nearbyRoute: nearbyRoute, now: now.toKotlinInstant()) - }.putAboveWhen(predictionsFetcher.errorText) { errorText in - IconCard(iconName: "network.slash", details: errorText) - } + if let nearbyWithRealtimeInfo { + nearbyList(nearbyWithRealtimeInfo) } else { Text("Loading...") + .frame(maxWidth: .infinity) + .padding(.top, 24) } } .onAppear { getNearby(location: location) joinPredictions() + updateNearbyRoutes() didAppear?(self) } .onChange(of: globalFetcher.response) { _ in @@ -55,31 +51,59 @@ struct NearbyTransitView: View { getNearby(location: newLocation) } .onChange(of: nearbyFetcher.nearbyByRouteAndStop) { _ in + updateNearbyRoutes() getSchedule() joinPredictions() + scrollToTop() + } + .onChange(of: scheduleFetcher.schedules) { _ in + updateNearbyRoutes() + } + .onChange(of: predictionsFetcher.predictions) { _ in + updateNearbyRoutes() + } + .onChange(of: alertsFetcher.alerts) { _ in + updateNearbyRoutes() } .onChange(of: scenePhase) { newPhase in - if newPhase == .inactive { - leavePredictions() - } else if newPhase == .active { - joinPredictions() - } else if newPhase == .background { - leavePredictions() - } + onSceneChange(newPhase) } .onReceive(timer) { input in now = input + updateNearbyRoutes() } .onReceive(inspection.notice) { inspection.visit(self, $0) } .onDisappear { leavePredictions() } .replaceWhen(nearbyFetcher.errorText) { errorText in - IconCard(iconName: "network.slash", details: errorText) - .refreshable(nearbyFetcher.loading) { getNearby(location: location) } + errorCard(errorText) } } + private func nearbyList(_ routes: [StopAssociatedRoute]) -> some View { + ScrollViewReader { proxy in + List(routes, id: \.route.id) { nearbyRoute in + NearbyRouteView(nearbyRoute: nearbyRoute, now: now.toKotlinInstant()) + } + .onChange(of: scrollPosition) { id in + guard let id else { return } + withAnimation { + proxy.scrollTo(id, anchor: .center) + scrollPosition = nil + } + } + .putAboveWhen(predictionsFetcher.errorText) { errorText in + IconCard(iconName: "network.slash", details: errorText) + } + } + } + + private func errorCard(_ errorText: Text) -> some View { + IconCard(iconName: "network.slash", details: errorText) + .refreshable(nearbyFetcher.loading) { getNearby(location: location) } + } + var didAppear: ((Self) -> Void)? func getNearby(location: CLLocationCoordinate2D) { @@ -99,17 +123,39 @@ struct NearbyTransitView: View { } func joinPredictions() { - Task { - guard let stopIds = nearbyFetcher.nearbyByRouteAndStop? - .stopIds() else { return } - let stopIdList = Array(stopIds) - predictionsFetcher.run(stopIds: stopIdList) - } + guard let stopIds = nearbyFetcher.nearbyByRouteAndStop? + .stopIds() else { return } + let stopIdList = Array(stopIds) + predictionsFetcher.run(stopIds: stopIdList) } func leavePredictions() { + predictionsFetcher.leave() + } + + private func scrollToTop() { + guard let id = nearbyWithRealtimeInfo?.first?.route.id else { return } + scrollPosition = id + } + + private func updateNearbyRoutes() { + nearbyWithRealtimeInfo = nearbyFetcher.withRealtimeInfo( + schedules: scheduleFetcher.schedules, + predictions: predictionsFetcher.predictions, + alerts: alertsFetcher.alerts, + filterAtTime: now.toKotlinInstant() + ) + } + + private func onSceneChange(_ phase: ScenePhase) { Task { - predictionsFetcher.leave() + if phase == .inactive { + leavePredictions() + } else if phase == .active { + joinPredictions() + } else if phase == .background { + leavePredictions() + } } } } diff --git a/iosApp/iosApp/Utils/Backport/PartialSheetModifier.swift b/iosApp/iosApp/Utils/Backport/PartialSheetModifier.swift index fb498f496..0fc5f6fea 100644 --- a/iosApp/iosApp/Utils/Backport/PartialSheetModifier.swift +++ b/iosApp/iosApp/Utils/Backport/PartialSheetModifier.swift @@ -106,6 +106,7 @@ private extension PartialSheetRepresentable { controller.animateChanges { controller.detents = detents.map(\.uiKitDetent) controller.prefersScrollingExpandsWhenScrolledToEdge = true + controller.prefersGrabberVisible = true if let largestUndimmedDetent { controller.largestUndimmedDetentIdentifier = .init(largestUndimmedDetent.rawValue) diff --git a/iosApp/iosAppTests/Views/NearbyTransitViewTests.swift b/iosApp/iosAppTests/Views/NearbyTransitViewTests.swift index 21ca3056a..34c97e07d 100644 --- a/iosApp/iosAppTests/Views/NearbyTransitViewTests.swift +++ b/iosApp/iosAppTests/Views/NearbyTransitViewTests.swift @@ -145,27 +145,30 @@ final class NearbyTransitViewTests: XCTestCase { } func testRoutePatternsGroupedByRouteAndStop() throws { - let sut = NearbyTransitView( + var sut = NearbyTransitView( location: CLLocationCoordinate2D(latitude: 12.34, longitude: -56.78), globalFetcher: .init(backend: IdleBackend()), nearbyFetcher: Route52NearbyFetcher(), scheduleFetcher: .init(backend: IdleBackend()), - predictionsFetcher: .init(socket: MockSocket()), alertsFetcher: .init(socket: MockSocket()) + predictionsFetcher: .init(socket: MockSocket()), + alertsFetcher: .init(socket: MockSocket()) ) - - let routes = try sut.inspect().findAll(NearbyRouteView.self) - - XCTAssert(!routes.isEmpty) - guard let route = routes.first else { return } - - XCTAssertNotNil(try route.find(text: "52")) - XCTAssertNotNil(try route.find(text: "Sawmill Brook Pkwy @ Walsh Rd") - .find(NearbyStopView.self, relation: .parent).find(text: "Charles River Loop")) - XCTAssertNotNil(try route.find(text: "Sawmill Brook Pkwy @ Walsh Rd") - .find(NearbyStopView.self, relation: .parent).find(text: "Dedham Mall")) - - XCTAssertNotNil(try route.find(text: "Sawmill Brook Pkwy @ Walsh Rd - opposite side") - .find(NearbyStopView.self, relation: .parent).find(text: "Watertown Yard")) + let exp = sut.on(\.didAppear) { view in + let routes = view.findAll(NearbyRouteView.self) + XCTAssert(!routes.isEmpty) + guard let route = routes.first else { return } + + XCTAssertNotNil(try route.find(text: "52")) + XCTAssertNotNil(try route.find(text: "Sawmill Brook Pkwy @ Walsh Rd") + .find(NearbyStopView.self, relation: .parent).find(text: "Charles River Loop")) + XCTAssertNotNil(try route.find(text: "Sawmill Brook Pkwy @ Walsh Rd") + .find(NearbyStopView.self, relation: .parent).find(text: "Dedham Mall")) + + XCTAssertNotNil(try route.find(text: "Sawmill Brook Pkwy @ Walsh Rd - opposite side") + .find(NearbyStopView.self, relation: .parent).find(text: "Watertown Yard")) + } + ViewHosting.host(view: sut) + wait(for: [exp], timeout: 1) } @MainActor func testWithSchedules() throws { @@ -227,7 +230,7 @@ final class NearbyTransitViewTests: XCTestCase { } } - let sut = NearbyTransitView( + var sut = NearbyTransitView( location: CLLocationCoordinate2D(latitude: 12.34, longitude: -56.78), globalFetcher: .init(backend: IdleBackend()), nearbyFetcher: Route52NearbyFetcher(), @@ -235,18 +238,22 @@ final class NearbyTransitViewTests: XCTestCase { predictionsFetcher: FakePredictionsFetcher(objects), alertsFetcher: .init(socket: MockSocket()) ) - let patterns = try sut.inspect().findAll(ViewType.NavigationLink.self, where: { _ in true }) - .map { try $0.labelView().view(HeadsignRowView.self) } + let exp = sut.on(\.didAppear) { view in + let patterns = try view.findAll(ViewType.NavigationLink.self, where: { _ in true }) + .map { try $0.labelView().view(HeadsignRowView.self) } - XCTAssertEqual(try patterns[0].actualView().headsign, "Dedham Mall") - XCTAssertEqual(try patterns[0].find(UpcomingTripView.self).actualView().prediction, .some(UpcomingTrip.FormatSchedule(scheduleTime: time1))) - XCTAssertEqual(try patterns[0].find(ViewType.Image.self).actualImage().name(), "clock") + XCTAssertEqual(try patterns[0].actualView().headsign, "Dedham Mall") + XCTAssertEqual(try patterns[0].find(UpcomingTripView.self).actualView().prediction, .some(UpcomingTrip.FormatSchedule(scheduleTime: time1))) + XCTAssertEqual(try patterns[0].find(ViewType.Image.self).actualImage().name(), "clock") - XCTAssertEqual(try patterns[1].actualView().headsign, "Charles River Loop") - XCTAssertEqual(try patterns[1].find(UpcomingTripView.self).actualView().prediction, .some(UpcomingTrip.FormatMinutes(minutes: 10))) + XCTAssertEqual(try patterns[1].actualView().headsign, "Charles River Loop") + XCTAssertEqual(try patterns[1].find(UpcomingTripView.self).actualView().prediction, .some(UpcomingTrip.FormatMinutes(minutes: 10))) - XCTAssertEqual(try patterns[2].actualView().headsign, "Watertown Yard") - XCTAssertEqual(try patterns[2].find(UpcomingTripView.self).actualView().prediction, .none) + XCTAssertEqual(try patterns[2].actualView().headsign, "Watertown Yard") + XCTAssertEqual(try patterns[2].find(UpcomingTripView.self).actualView().prediction, .none) + } + ViewHosting.host(view: sut) + wait(for: [exp], timeout: 1) } @MainActor func testWithPredictions() throws { @@ -302,33 +309,38 @@ final class NearbyTransitViewTests: XCTestCase { let distantInstant = Date.now.addingTimeInterval(TimeInterval(DISTANT_FUTURE_CUTOFF)).addingTimeInterval(5 * 60).toKotlinInstant() let testFormatter = DateFormatter() testFormatter.timeStyle = .short - let sut = NearbyTransitView( + var sut = NearbyTransitView( location: CLLocationCoordinate2D(latitude: 12.34, longitude: -56.78), globalFetcher: .init(backend: IdleBackend()), nearbyFetcher: Route52NearbyFetcher(), scheduleFetcher: .init(backend: IdleBackend()), - predictionsFetcher: FakePredictionsFetcher(distantInstant: distantInstant), alertsFetcher: .init(socket: MockSocket()) + predictionsFetcher: FakePredictionsFetcher(distantInstant: distantInstant), + alertsFetcher: .init(socket: MockSocket()) ) - let stops = try sut.inspect().findAll(NearbyStopView.self) + let exp = sut.on(\.didAppear) { view in + let stops = view.findAll(NearbyStopView.self) - XCTAssertNotNil(try stops[0].find(text: "Charles River Loop") - .parent().find(text: "No Predictions")) + XCTAssertNotNil(try stops[0].find(text: "Charles River Loop") + .parent().find(text: "No Predictions")) - XCTAssertNotNil(try stops[0].find(text: "Dedham Mall") - .parent().find(text: "10 min")) - XCTAssertNotNil(try stops[0].find(text: "Dedham Mall") - .parent().find(text: "Overridden")) + XCTAssertNotNil(try stops[0].find(text: "Dedham Mall") + .parent().find(text: "10 min")) + XCTAssertNotNil(try stops[0].find(text: "Dedham Mall") + .parent().find(text: "Overridden")) - XCTAssertNotNil(try stops[1].find(text: "Watertown Yard") - .parent().find(text: "1 min")) + XCTAssertNotNil(try stops[1].find(text: "Watertown Yard") + .parent().find(text: "1 min")) - let expectedState = UpcomingTripView.State.some(UpcomingTrip.FormatDistantFuture(predictionTime: distantInstant)) - XCTAssert(try !stops[1].find(text: "Watertown Yard").parent() - .findAll(UpcomingTripView.self, where: { sut in - try debugPrint(sut.actualView()) - return try sut.actualView().prediction == expectedState - }).isEmpty) + let expectedState = UpcomingTripView.State.some(UpcomingTrip.FormatDistantFuture(predictionTime: distantInstant)) + XCTAssert(try !stops[1].find(text: "Watertown Yard").parent() + .findAll(UpcomingTripView.self, where: { sut in + try debugPrint(sut.actualView()) + return try sut.actualView().prediction == expectedState + }).isEmpty) + } + ViewHosting.host(view: sut) + wait(for: [exp], timeout: 1) } func testRefetchesPredictionsOnNewStops() throws { @@ -385,7 +397,7 @@ final class NearbyTransitViewTests: XCTestCase { NSTimeZone.default = TimeZone(identifier: "America/New_York")! let predictionsFetcher = PredictionsFetcher(socket: MockSocket()) - let sut = NearbyTransitView( + var sut = NearbyTransitView( location: CLLocationCoordinate2D(latitude: 12.34, longitude: -56.78), globalFetcher: .init(backend: IdleBackend()), nearbyFetcher: Route52NearbyFetcher(), @@ -408,13 +420,16 @@ final class NearbyTransitViewTests: XCTestCase { return PredictionsStreamDataResponse(objects: objects) } - predictionsFetcher.predictions = prediction(minutesAway: 2) - - XCTAssertNotNil(try sut.inspect().find(text: "2 min")) - - predictionsFetcher.predictions = prediction(minutesAway: 3) - - XCTAssertNotNil(try sut.inspect().find(text: "3 min")) + let exp = sut.on(\.didAppear) { view in + predictionsFetcher.predictions = prediction(minutesAway: 2) + try view.vStack().callOnChange(newValue: predictionsFetcher.predictions) + XCTAssertNotNil(try view.find(text: "2 min")) + predictionsFetcher.predictions = prediction(minutesAway: 3) + try view.vStack().callOnChange(newValue: predictionsFetcher.predictions) + XCTAssertNotNil(try view.find(text: "3 min")) + } + ViewHosting.host(view: sut) + wait(for: [exp], timeout: 1) } func testLeavesChannelWhenBackgrounded() throws { @@ -546,6 +561,25 @@ final class NearbyTransitViewTests: XCTestCase { wait(for: [joinExpectation], timeout: 1) } + func testScrollToTopWhenNearbyChanges() throws { + let nearbyFetcher = Route52NearbyFetcher() + var sut = NearbyTransitView( + location: CLLocationCoordinate2D(latitude: 12.34, longitude: -56.78), + globalFetcher: .init(backend: IdleBackend()), + nearbyFetcher: Route52NearbyFetcher(), + scheduleFetcher: .init(backend: IdleBackend()), + predictionsFetcher: .init(socket: MockSocket()), + alertsFetcher: .init(socket: MockSocket()) + ) + let exp = sut.on(\.didAppear) { view in + XCTAssertNil(try view.actualView().scrollPosition) + try view.vStack().callOnChange(newValue: nearbyFetcher.nearbyByRouteAndStop) + XCTAssertNotNil(try view.actualView().scrollPosition) + } + ViewHosting.host(view: sut) + wait(for: [exp], timeout: 1) + } + func testNearbyErrorMessage() throws { class FakeNearbyFetcher: NearbyFetcher { init() { @@ -580,7 +614,7 @@ final class NearbyTransitViewTests: XCTestCase { } } - let sut = NearbyTransitView( + var sut = NearbyTransitView( location: CLLocationCoordinate2D(latitude: 12.34, longitude: -56.78), globalFetcher: .init(backend: IdleBackend()), nearbyFetcher: FakeNearbyFetcher(), @@ -588,7 +622,11 @@ final class NearbyTransitViewTests: XCTestCase { predictionsFetcher: FakePredictionsFetcher(), alertsFetcher: .init(socket: MockSocket()) ) - XCTAssertNotNil(try sut.inspect().view(NearbyTransitView.self).find(text: "Failed to load predictions, test error")) + let exp = sut.on(\.didAppear) { view in + XCTAssertNotNil(try view.find(text: "Failed to load predictions, test error")) + } + ViewHosting.host(view: sut) + wait(for: [exp], timeout: 1) } @MainActor func testReloadsWhenLocationChanges() throws { @@ -672,7 +710,7 @@ final class NearbyTransitViewTests: XCTestCase { } alertsFetcher.alerts = AlertsStreamDataResponse(objects: objects) - let sut = NearbyTransitView( + var sut = NearbyTransitView( location: CLLocationCoordinate2D(latitude: 12.34, longitude: -56.78), globalFetcher: .init(backend: IdleBackend()), nearbyFetcher: Route52NearbyFetcher(), @@ -681,7 +719,11 @@ final class NearbyTransitViewTests: XCTestCase { alertsFetcher: alertsFetcher ) - XCTAssertNotNil(try sut.inspect().find(text: "Suspension")) + let exp = sut.on(\.didAppear) { view in + XCTAssertNotNil(try view.find(text: "Suspension")) + } + ViewHosting.host(view: sut) + wait(for: [exp], timeout: 1) } func testStopPageLink() throws {