From f7ab56e888ce1dafc84465051561e3817bdff246 Mon Sep 17 00:00:00 2001 From: Melody Horn Date: Wed, 10 Apr 2024 14:11:30 -0600 Subject: [PATCH] feat: show filtered departures in stop details (#124) * implement filter * rethink binding one more time * add tests * rename NearbyStopRoutePatternView to HeadsignRowView * also move UpcomingTripView into ComponentViews * rearrange splitPerTrip * filter to route in FilteredRouteView constructor * make stack deeper for lastStopDetailsFilter test --- iosApp/iosApp.xcodeproj/project.pbxproj | 38 ++++++-- .../HeadsignRowView.swift} | 26 +++--- .../UpcomingTripView.swift | 0 iosApp/iosApp/ContentView.swift | 4 +- iosApp/iosApp/Localizable.xcstrings | 3 + .../Pages/NearbyTransit/NearbyStopView.swift | 2 +- .../StopDetailsFilteredRouteView.swift | 65 ++++++++++++++ .../Pages/StopDetails/StopDetailsPage.swift | 8 +- .../StopDetails/StopDetailsRouteView.swift | 13 ++- .../StopDetails/StopDetailsRoutesView.swift | 11 ++- .../Utils/SheetNavigationStackEntry.swift | 24 ++++++ .../StopDetailsFilteredRouteViewTests.swift | 77 +++++++++++++++++ .../StopDetailsRouteViewTests.swift | 38 ++++++++ .../StopDetailsRoutesViewTests.swift | 50 +++++++++++ .../SheetNavigationStackEntryTests.swift | 59 +++++++++++++ .../Views/NearbyTransitViewTests.swift | 2 +- .../tid/mbta_app/model/StopAssociatedRoute.kt | 3 + .../tid/mbta_app/model/PatternsByStopTest.kt | 86 +++++++++++++++++++ 18 files changed, 477 insertions(+), 32 deletions(-) rename iosApp/iosApp/{Pages/NearbyTransit/NearbyStopRoutePatternView.swift => ComponentViews/HeadsignRowView.swift} (62%) rename iosApp/iosApp/{Pages/NearbyTransit => ComponentViews}/UpcomingTripView.swift (100%) create mode 100644 iosApp/iosApp/Pages/StopDetails/StopDetailsFilteredRouteView.swift create mode 100644 iosApp/iosAppTests/Pages/StopDetails/StopDetailsFilteredRouteViewTests.swift create mode 100644 iosApp/iosAppTests/Pages/StopDetails/StopDetailsRouteViewTests.swift create mode 100644 iosApp/iosAppTests/Pages/StopDetails/StopDetailsRoutesViewTests.swift create mode 100644 iosApp/iosAppTests/Utils/SheetNavigationStackEntryTests.swift create mode 100644 shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/model/PatternsByStopTest.kt diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 99b1e4331..b5dd33317 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -29,10 +29,15 @@ 6EF50F512B988BF600833070 /* PredictionsFetcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EF50F502B988BF600833070 /* PredictionsFetcherTests.swift */; }; 7555FF83242A565900829871 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7555FF82242A565900829871 /* ContentView.swift */; }; 8C5054582BB5EB6C00C6A51C /* StopDetailsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C5054572BB5EB6C00C6A51C /* StopDetailsPage.swift */; }; + 8C6A48402BC09A2E0032A554 /* StopDetailsFilteredRouteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C6A483F2BC09A2E0032A554 /* StopDetailsFilteredRouteView.swift */; }; 8C7FA86F2B5EEA34009B699D /* LocationDataManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C7FA86E2B5EEA34009B699D /* LocationDataManagerTests.swift */; }; 8C7FA8712B5F2EF2009B699D /* NearbyTransitViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C7FA8702B5F2EF2009B699D /* NearbyTransitViewTests.swift */; }; 8C7FA8732B5F36D6009B699D /* Backend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C7FA8722B5F36D6009B699D /* Backend.swift */; }; 8C84D33E2B5AEE0200192C0A /* NearbyTransitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C84D33D2B5AEE0200192C0A /* NearbyTransitView.swift */; }; + 8CB823D62BC5E85C002C87E0 /* SheetNavigationStackEntryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CB823D52BC5E85C002C87E0 /* SheetNavigationStackEntryTests.swift */; }; + 8CB823D92BC5EDD2002C87E0 /* StopDetailsRouteViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CB823D82BC5EDD2002C87E0 /* StopDetailsRouteViewTests.swift */; }; + 8CB823DB2BC5F053002C87E0 /* StopDetailsRoutesViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CB823DA2BC5F053002C87E0 /* StopDetailsRoutesViewTests.swift */; }; + 8CB823DD2BC5F432002C87E0 /* StopDetailsFilteredRouteViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CB823DC2BC5F432002C87E0 /* StopDetailsFilteredRouteViewTests.swift */; }; 8CC1BB402B59D1F6005386FE /* LocationDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC1BB3F2B59D1F6005386FE /* LocationDataManager.swift */; }; 8CD1F8CD2B7164C100F419D4 /* PredictionsFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CD1F8CC2B7164C100F419D4 /* PredictionsFetcher.swift */; }; 8CE0140E2BBDB7C300918FAE /* RoutePillSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE0140D2BBDB7C300918FAE /* RoutePillSection.swift */; }; @@ -47,7 +52,7 @@ 9A1631E62B76CAB400F667F4 /* GlobalFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A1631E52B76CAB400F667F4 /* GlobalFetcher.swift */; }; 9A2005C52B97B5EA00F562E1 /* NearbyRouteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A2005C42B97B5EA00F562E1 /* NearbyRouteView.swift */; }; 9A2005C72B97B63300F562E1 /* NearbyStopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A2005C62B97B63300F562E1 /* NearbyStopView.swift */; }; - 9A2005C92B97B65900F562E1 /* NearbyStopRoutePatternView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A2005C82B97B65900F562E1 /* NearbyStopRoutePatternView.swift */; }; + 9A2005C92B97B65900F562E1 /* HeadsignRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A2005C82B97B65900F562E1 /* HeadsignRowView.swift */; }; 9A2005CB2B97B68700F562E1 /* UpcomingTripView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A2005CA2B97B68700F562E1 /* UpcomingTripView.swift */; }; 9A37F3052BACCC40001714FE /* DoubleRoundedExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A37F3042BACCC40001714FE /* DoubleRoundedExtension.swift */; }; 9A37F3072BACCCA5001714FE /* CoordinateExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A37F3062BACCCA5001714FE /* CoordinateExtension.swift */; }; @@ -157,10 +162,15 @@ 8C349BB72B754F2600AC7FFB /* 10 Park Plaza.gpx */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = "10 Park Plaza.gpx"; sourceTree = ""; }; 8C42F06F2B890BC800F9A77B /* .swiftlint.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = .swiftlint.yml; sourceTree = ""; }; 8C5054572BB5EB6C00C6A51C /* StopDetailsPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopDetailsPage.swift; sourceTree = ""; }; + 8C6A483F2BC09A2E0032A554 /* StopDetailsFilteredRouteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopDetailsFilteredRouteView.swift; sourceTree = ""; }; 8C7FA86E2B5EEA34009B699D /* LocationDataManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationDataManagerTests.swift; sourceTree = ""; }; 8C7FA8702B5F2EF2009B699D /* NearbyTransitViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NearbyTransitViewTests.swift; sourceTree = ""; }; 8C7FA8722B5F36D6009B699D /* Backend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backend.swift; sourceTree = ""; }; 8C84D33D2B5AEE0200192C0A /* NearbyTransitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NearbyTransitView.swift; sourceTree = ""; }; + 8CB823D52BC5E85C002C87E0 /* SheetNavigationStackEntryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SheetNavigationStackEntryTests.swift; sourceTree = ""; }; + 8CB823D82BC5EDD2002C87E0 /* StopDetailsRouteViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopDetailsRouteViewTests.swift; sourceTree = ""; }; + 8CB823DA2BC5F053002C87E0 /* StopDetailsRoutesViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopDetailsRoutesViewTests.swift; sourceTree = ""; }; + 8CB823DC2BC5F432002C87E0 /* StopDetailsFilteredRouteViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopDetailsFilteredRouteViewTests.swift; sourceTree = ""; }; 8CC1BB3F2B59D1F6005386FE /* LocationDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationDataManager.swift; sourceTree = ""; }; 8CD1F8CC2B7164C100F419D4 /* PredictionsFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PredictionsFetcher.swift; sourceTree = ""; }; 8CE0140D2BBDB7C300918FAE /* RoutePillSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoutePillSection.swift; sourceTree = ""; }; @@ -175,7 +185,7 @@ 9A1631E52B76CAB400F667F4 /* GlobalFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalFetcher.swift; sourceTree = ""; }; 9A2005C42B97B5EA00F562E1 /* NearbyRouteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NearbyRouteView.swift; sourceTree = ""; }; 9A2005C62B97B63300F562E1 /* NearbyStopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NearbyStopView.swift; sourceTree = ""; }; - 9A2005C82B97B65900F562E1 /* NearbyStopRoutePatternView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NearbyStopRoutePatternView.swift; sourceTree = ""; }; + 9A2005C82B97B65900F562E1 /* HeadsignRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadsignRowView.swift; sourceTree = ""; }; 9A2005CA2B97B68700F562E1 /* UpcomingTripView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpcomingTripView.swift; sourceTree = ""; }; 9A37F3042BACCC40001714FE /* DoubleRoundedExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoubleRoundedExtension.swift; sourceTree = ""; }; 9A37F3062BACCCA5001714FE /* CoordinateExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoordinateExtension.swift; sourceTree = ""; }; @@ -409,6 +419,17 @@ 8C5054572BB5EB6C00C6A51C /* StopDetailsPage.swift */, 8CE0140F2BBDB8DC00918FAE /* StopDetailsRoutesView.swift */, 8CE014112BBDB96900918FAE /* StopDetailsRouteView.swift */, + 8C6A483F2BC09A2E0032A554 /* StopDetailsFilteredRouteView.swift */, + ); + path = StopDetails; + sourceTree = ""; + }; + 8CB823D72BC5EDBF002C87E0 /* StopDetails */ = { + isa = PBXGroup; + children = ( + 8CB823D82BC5EDD2002C87E0 /* StopDetailsRouteViewTests.swift */, + 8CB823DA2BC5F053002C87E0 /* StopDetailsRoutesViewTests.swift */, + 8CB823DC2BC5F432002C87E0 /* StopDetailsFilteredRouteViewTests.swift */, ); path = StopDetails; sourceTree = ""; @@ -430,8 +451,6 @@ 8C84D33D2B5AEE0200192C0A /* NearbyTransitView.swift */, 9A2005C42B97B5EA00F562E1 /* NearbyRouteView.swift */, 9A2005C62B97B63300F562E1 /* NearbyStopView.swift */, - 9A2005C82B97B65900F562E1 /* NearbyStopRoutePatternView.swift */, - 9A2005CA2B97B68700F562E1 /* UpcomingTripView.swift */, 9AC4FDEE2BACE1EC004479BF /* NearbyTransitLocationProvider.swift */, 9AC4FDF02BACE216004479BF /* NearbyTransitPageView.swift */, ); @@ -467,6 +486,8 @@ 9A4E8E582B7EC4B90066B936 /* RoutePill.swift */, 9AB44A102B8FC43E00E8FFB3 /* IconCard.swift */, 8CE0140D2BBDB7C300918FAE /* RoutePillSection.swift */, + 9A2005C82B97B65900F562E1 /* HeadsignRowView.swift */, + 9A2005CA2B97B68700F562E1 /* UpcomingTripView.swift */, ); path = ComponentViews; sourceTree = ""; @@ -474,6 +495,7 @@ 9A5B275D2BB242EF009A6FC6 /* Pages */ = { isa = PBXGroup; children = ( + 8CB823D72BC5EDBF002C87E0 /* StopDetails */, 9A5B275E2BB24326009A6FC6 /* Map */, ); path = Pages; @@ -532,6 +554,7 @@ isa = PBXGroup; children = ( 9ADB849F2BAD1B84006581CE /* DebouncerTests.swift */, + 8CB823D52BC5E85C002C87E0 /* SheetNavigationStackEntryTests.swift */, ); path = Utils; sourceTree = ""; @@ -820,7 +843,9 @@ 9AD1D1FE2BA4D5C600182060 /* ViewportProviderTest.swift in Sources */, 9A887D592B698EF1006F5B80 /* SearchResultViewTests.swift in Sources */, 9A5B27602BB31178009A6FC6 /* StopSourceGeneratorTests.swift in Sources */, + 8CB823D62BC5E85C002C87E0 /* SheetNavigationStackEntryTests.swift in Sources */, 6EE745842B965B9C0052227E /* SocketTests.swift in Sources */, + 8CB823DD2BC5F432002C87E0 /* StopDetailsFilteredRouteViewTests.swift in Sources */, 9A5B27682BB36A23009A6FC6 /* StopLayerGeneratorTests.swift in Sources */, 8CEA10272BA4C83D001C6EB9 /* AlertsFetcherTests.swift in Sources */, 6EF50F482B9889D600833070 /* MockSocket.swift in Sources */, @@ -838,7 +863,9 @@ 8C7FA86F2B5EEA34009B699D /* LocationDataManagerTests.swift in Sources */, 6EED5E8F2B3DC6A00052A1B8 /* IosAppTests.swift in Sources */, 8C7FA8712B5F2EF2009B699D /* NearbyTransitViewTests.swift in Sources */, + 8CB823DB2BC5F053002C87E0 /* StopDetailsRoutesViewTests.swift in Sources */, 6E4EACFC2B7A82AC0011AB8B /* MockLocationFetcher.swift in Sources */, + 8CB823D92BC5EDD2002C87E0 /* StopDetailsRouteViewTests.swift in Sources */, 6EE745882B965C2B0052227E /* MessageTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -873,7 +900,7 @@ 9AB44A132B911E6400E8FFB3 /* DateExtension.swift in Sources */, 6EE745862B965C130052227E /* Message.swift in Sources */, 9A9E05F62B6D6EF70086B437 /* NearbyFetcher.swift in Sources */, - 9A2005C92B97B65900F562E1 /* NearbyStopRoutePatternView.swift in Sources */, + 9A2005C92B97B65900F562E1 /* HeadsignRowView.swift in Sources */, 9A4E8E592B7EC4B90066B936 /* RoutePill.swift in Sources */, 8CE014142BBDBE5200918FAE /* BackendProvider.swift in Sources */, 9A5B27562BB221C1009A6FC6 /* RouteLayerGenerator.swift in Sources */, @@ -907,6 +934,7 @@ 9A5830562BA3A2CE0039876E /* ViewportExtension.swift in Sources */, 9A5830582BA4A1A30039876E /* ViewportProvider.swift in Sources */, 9A5B27522BB1EF45009A6FC6 /* StopSourceGenerator.swift in Sources */, + 8C6A48402BC09A2E0032A554 /* StopDetailsFilteredRouteView.swift in Sources */, 9AC4FDEF2BACE1EC004479BF /* NearbyTransitLocationProvider.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/iosApp/iosApp/Pages/NearbyTransit/NearbyStopRoutePatternView.swift b/iosApp/iosApp/ComponentViews/HeadsignRowView.swift similarity index 62% rename from iosApp/iosApp/Pages/NearbyTransit/NearbyStopRoutePatternView.swift rename to iosApp/iosApp/ComponentViews/HeadsignRowView.swift index b6e7fde25..fddec8277 100644 --- a/iosApp/iosApp/Pages/NearbyTransit/NearbyStopRoutePatternView.swift +++ b/iosApp/iosApp/ComponentViews/HeadsignRowView.swift @@ -1,5 +1,5 @@ // -// NearbyStopRoutePatternView.swift +// HeadsignRowView.swift // iosApp // // Created by Simon, Emma on 3/5/24. @@ -9,7 +9,7 @@ import shared import SwiftUI -struct NearbyStopRoutePatternView: View { +struct HeadsignRowView: View { let headsign: String let predictions: PatternsByHeadsign.Format @@ -43,16 +43,18 @@ struct NearbyStopRoutePatternView_Previews: PreviewProvider { prediction.trip = trip prediction.departureTime = now.addingTimeInterval(5 * 60).toKotlinInstant() } - NearbyStopRoutePatternView(headsign: "Some", predictions: PatternsByHeadsign.FormatSome(trips: [ - .init(trip: .init(trip: trip, prediction: prediction), now: now.toKotlinInstant()), - ])) - NearbyStopRoutePatternView(headsign: "None", predictions: PatternsByHeadsign.FormatNone.shared) - NearbyStopRoutePatternView(headsign: "Loading", predictions: PatternsByHeadsign.FormatLoading.shared) - NearbyStopRoutePatternView(headsign: "No Service", predictions: PatternsByHeadsign.FormatNoService(alert: - ObjectCollectionBuilder.Single.shared.alert { alert in - alert.effect = .suspension - } - )) + List { + HeadsignRowView(headsign: "Some", predictions: PatternsByHeadsign.FormatSome(trips: [ + .init(trip: .init(trip: trip, prediction: prediction), now: now.toKotlinInstant()), + ])) + HeadsignRowView(headsign: "None", predictions: PatternsByHeadsign.FormatNone.shared) + HeadsignRowView(headsign: "Loading", predictions: PatternsByHeadsign.FormatLoading.shared) + HeadsignRowView(headsign: "No Service", predictions: PatternsByHeadsign.FormatNoService( + alert: ObjectCollectionBuilder.Single.shared.alert { alert in + alert.effect = .suspension + } + )) + } } } } diff --git a/iosApp/iosApp/Pages/NearbyTransit/UpcomingTripView.swift b/iosApp/iosApp/ComponentViews/UpcomingTripView.swift similarity index 100% rename from iosApp/iosApp/Pages/NearbyTransit/UpcomingTripView.swift rename to iosApp/iosApp/ComponentViews/UpcomingTripView.swift diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index b9abf39eb..5eb16280c 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -64,12 +64,12 @@ struct ContentView: View { .navigationBarHidden(true) .navigationDestination(for: SheetNavigationStackEntry.self) { entry in switch entry { - case let .stopDetails(stop, filter): + case let .stopDetails(stop, _): StopDetailsPage( backend: backendProvider.backend, socket: socketProvider.socket, globalFetcher: globalFetcher, - stop: stop, filter: filter + stop: stop, filter: $navigationStack.lastStopDetailsFilter ) } } diff --git a/iosApp/iosApp/Localizable.xcstrings b/iosApp/iosApp/Localizable.xcstrings index 74720e4e0..ed113f025 100644 --- a/iosApp/iosApp/Localizable.xcstrings +++ b/iosApp/iosApp/Localizable.xcstrings @@ -12,6 +12,9 @@ }, "BRD" : { + }, + "Clear Filter" : { + }, "Couldn't load nearby transit, connection was interrupted" : { diff --git a/iosApp/iosApp/Pages/NearbyTransit/NearbyStopView.swift b/iosApp/iosApp/Pages/NearbyTransit/NearbyStopView.swift index 83aeeefe5..5a274c1c9 100644 --- a/iosApp/iosApp/Pages/NearbyTransit/NearbyStopView.swift +++ b/iosApp/iosApp/Pages/NearbyTransit/NearbyStopView.swift @@ -21,7 +21,7 @@ struct NearbyStopView: View { patternsAtStop.stop, .init(routeId: patternsAtStop.route.id, directionId: patternsByHeadsign.directionId()) )) { - NearbyStopRoutePatternView( + HeadsignRowView( headsign: patternsByHeadsign.headsign, predictions: patternsByHeadsign.format(now: now) ) diff --git a/iosApp/iosApp/Pages/StopDetails/StopDetailsFilteredRouteView.swift b/iosApp/iosApp/Pages/StopDetails/StopDetailsFilteredRouteView.swift new file mode 100644 index 000000000..12786e54d --- /dev/null +++ b/iosApp/iosApp/Pages/StopDetails/StopDetailsFilteredRouteView.swift @@ -0,0 +1,65 @@ +// +// StopDetailsFilteredRouteView.swift +// iosApp +// +// Created by Horn, Melody on 2024-04-05. +// Copyright © 2024 MBTA. All rights reserved. +// + +import Foundation +import shared +import SwiftUI + +struct StopDetailsFilteredRouteView: View { + let patternsByStop: PatternsByStop + let now: Instant + @Binding var filter: StopDetailsFilter? + + struct RowData { + let tripId: String + let headsign: String + let formatted: PatternsByHeadsign.Format + + init?(trip: UpcomingTrip, route: Route, expectedDirection: Int32?, now: Instant) { + if trip.trip.directionId != expectedDirection { + return nil + } + + tripId = trip.trip.id + headsign = trip.trip.headsign + formatted = PatternsByHeadsign( + route: route, headsign: headsign, patterns: [], upcomingTrips: [trip], alertsHere: nil + ).format(now: now) + + if !(formatted is PatternsByHeadsign.FormatSome) { + return nil + } + } + } + + let rows: [RowData] + + init(departures: StopDetailsDepartures, now: Instant, filter filterBinding: Binding) { + _filter = filterBinding + let filter = filterBinding.wrappedValue + let patternsByStop = departures.routes.first(where: { $0.route.id == filter?.routeId })! + self.patternsByStop = patternsByStop + self.now = now + + let expectedDirection: Int32? = filter?.directionId + rows = patternsByStop.allUpcomingTrips().compactMap { + RowData(trip: $0, route: patternsByStop.route, expectedDirection: expectedDirection, now: now) + } + } + + var body: some View { + Button(action: { filter = nil }, label: { Text("Clear Filter") }) + List { + RoutePillSection(route: patternsByStop.route) { + ForEach(rows, id: \.tripId) { row in + HeadsignRowView(headsign: row.headsign, predictions: row.formatted) + } + } + } + } +} diff --git a/iosApp/iosApp/Pages/StopDetails/StopDetailsPage.swift b/iosApp/iosApp/Pages/StopDetails/StopDetailsPage.swift index f365720c8..b316dd3ce 100644 --- a/iosApp/iosApp/Pages/StopDetails/StopDetailsPage.swift +++ b/iosApp/iosApp/Pages/StopDetails/StopDetailsPage.swift @@ -16,18 +16,18 @@ struct StopDetailsPage: View { @StateObject var scheduleFetcher: ScheduleFetcher @StateObject var predictionsFetcher: PredictionsFetcher var stop: Stop - var filter: StopDetailsFilter? + @Binding var filter: StopDetailsFilter? @State var now = Date.now let timer = Timer.publish(every: 5, on: .main, in: .common).autoconnect() init(backend: any BackendProtocol, socket: any PhoenixSocket, globalFetcher: GlobalFetcher, - stop: Stop, filter: StopDetailsFilter?) { + stop: Stop, filter: Binding) { self.globalFetcher = globalFetcher _scheduleFetcher = StateObject(wrappedValue: ScheduleFetcher(backend: backend)) _predictionsFetcher = StateObject(wrappedValue: PredictionsFetcher(socket: socket)) self.stop = stop - self.filter = filter + _filter = filter } var body: some View { @@ -46,7 +46,7 @@ struct StopDetailsPage: View { schedules: scheduleFetcher.schedules, predictions: predictionsFetcher.predictions, filterAtTime: now.toKotlinInstant() - ), now: now.toKotlinInstant()) + ), now: now.toKotlinInstant(), filter: $filter) } else { ProgressView() } diff --git a/iosApp/iosApp/Pages/StopDetails/StopDetailsRouteView.swift b/iosApp/iosApp/Pages/StopDetails/StopDetailsRouteView.swift index a5aa69768..b73e74a0b 100644 --- a/iosApp/iosApp/Pages/StopDetails/StopDetailsRouteView.swift +++ b/iosApp/iosApp/Pages/StopDetails/StopDetailsRouteView.swift @@ -13,14 +13,19 @@ import SwiftUI struct StopDetailsRouteView: View { let patternsByStop: PatternsByStop let now: Instant + @Binding var filter: StopDetailsFilter? var body: some View { RoutePillSection(route: patternsByStop.route) { ForEach(patternsByStop.patternsByHeadsign, id: \.headsign) { patternsByHeadsign in - NearbyStopRoutePatternView( - headsign: patternsByHeadsign.headsign, - predictions: patternsByHeadsign.format(now: now) - ) + Button(action: { + filter = .init(routeId: patternsByHeadsign.route.id, directionId: patternsByHeadsign.directionId()) + }, label: { + HeadsignRowView( + headsign: patternsByHeadsign.headsign, + predictions: patternsByHeadsign.format(now: now) + ) + }) } } } diff --git a/iosApp/iosApp/Pages/StopDetails/StopDetailsRoutesView.swift b/iosApp/iosApp/Pages/StopDetails/StopDetailsRoutesView.swift index 2ae0d976a..1ca4c6336 100644 --- a/iosApp/iosApp/Pages/StopDetails/StopDetailsRoutesView.swift +++ b/iosApp/iosApp/Pages/StopDetails/StopDetailsRoutesView.swift @@ -13,10 +13,15 @@ import SwiftUI struct StopDetailsRoutesView: View { let departures: StopDetailsDepartures let now: Instant + @Binding var filter: StopDetailsFilter? var body: some View { - List(departures.routes, id: \.route.id) { patternsByStop in - StopDetailsRouteView(patternsByStop: patternsByStop, now: now) + if let filter { + StopDetailsFilteredRouteView(departures: departures, now: now, filter: $filter) + } else { + List(departures.routes, id: \.route.id) { patternsByStop in + StopDetailsRouteView(patternsByStop: patternsByStop, now: now, filter: $filter) + } } } } @@ -66,5 +71,5 @@ struct StopDetailsRoutesView: View { upcomingTrips: [.init(trip: trip2, schedule: schedule2)], alertsHere: nil), ]), - ]), now: Date.now.toKotlinInstant()) + ]), now: Date.now.toKotlinInstant(), filter: .constant(nil)) } diff --git a/iosApp/iosApp/Utils/SheetNavigationStackEntry.swift b/iosApp/iosApp/Utils/SheetNavigationStackEntry.swift index fdc7a431e..80daf56b3 100644 --- a/iosApp/iosApp/Utils/SheetNavigationStackEntry.swift +++ b/iosApp/iosApp/Utils/SheetNavigationStackEntry.swift @@ -17,3 +17,27 @@ struct StopDetailsFilter: Hashable { enum SheetNavigationStackEntry: Hashable { case stopDetails(Stop, StopDetailsFilter?) } + +extension [SheetNavigationStackEntry] { + /// Retrieves and updates the bottom-most ``StopDetailsFilter`` in the navigation stack. + /// + /// Implemented as an extension property so that + /// [`Binding.subscript(dynamicMember:)`][binding-subscript] + /// can automatically create a binding to the filter based on the binding to the stack. + /// + /// [binding-subscript]: https://developer.apple.com/documentation/swiftui/binding/subscript(dynamicmember:) + var lastStopDetailsFilter: StopDetailsFilter? { + get { + switch self.last { + case let .stopDetails(_, filter): filter + case _: nil + } + } + set { + if case let .stopDetails(stop, _) = self.last { + _ = self.popLast() + self.append(.stopDetails(stop, newValue)) + } + } + } +} diff --git a/iosApp/iosAppTests/Pages/StopDetails/StopDetailsFilteredRouteViewTests.swift b/iosApp/iosAppTests/Pages/StopDetails/StopDetailsFilteredRouteViewTests.swift new file mode 100644 index 000000000..396c4c1bc --- /dev/null +++ b/iosApp/iosAppTests/Pages/StopDetails/StopDetailsFilteredRouteViewTests.swift @@ -0,0 +1,77 @@ +// +// StopDetailsFilteredRouteViewTests.swift +// iosAppTests +// +// Created by Horn, Melody on 2024-04-09. +// Copyright © 2024 MBTA. All rights reserved. +// + +@testable import iosApp +import shared +import SwiftUI +import ViewInspector +import XCTest + +final class StopDetailsFilteredRouteViewTests: XCTestCase { + private func testData() -> (departures: StopDetailsDepartures, routeId: String, now: Instant) { + let objects = ObjectCollectionBuilder() + let route = objects.route() + let stop = objects.stop { _ in } + + let patternNorth = objects.routePattern(route: route) { pattern in + pattern.directionId = 0 + pattern.representativeTrip { $0.headsign = "North" } + } + let patternSouth = objects.routePattern(route: route) { pattern in + pattern.directionId = 1 + pattern.representativeTrip { $0.headsign = "South" } + } + + let now = Date.now + + let tripNorth = objects.trip(routePattern: patternNorth) + let predictionNorth = objects.prediction { + $0.trip = tripNorth + $0.departureTime = now.toKotlinInstant() + } + let tripSouth = objects.trip(routePattern: patternSouth) + let predictionSouth = objects.prediction { + $0.trip = tripSouth + $0.departureTime = now.toKotlinInstant() + } + + let patternsByStop = PatternsByStop(route: route, stop: stop, patternsByHeadsign: [ + .init(route: route, headsign: "North", patterns: [patternNorth], upcomingTrips: [objects.upcomingTrip(prediction: predictionNorth)], alertsHere: nil), + .init(route: route, headsign: "South", patterns: [patternSouth], upcomingTrips: [objects.upcomingTrip(prediction: predictionSouth)], alertsHere: nil), + ]) + + let departures = StopDetailsDepartures(routes: [patternsByStop]) + + return (departures: departures, routeId: route.id, now: now.toKotlinInstant()) + } + + func testAppliesFilter() throws { + let (departures: departures, routeId: routeId, now: now) = testData() + + let sut = StopDetailsFilteredRouteView( + departures: departures, + now: now, + filter: .constant(.init(routeId: routeId, directionId: 0)) + ) + + XCTAssertNotNil(try sut.inspect().find(text: "North")) + XCTAssertNil(try? sut.inspect().find(text: "South")) + } + + func testClearsFilter() throws { + let (departures: departures, routeId: routeId, now: now) = testData() + + let filter = Binding(wrappedValue: .init(routeId: routeId, directionId: 1)) + + let sut = StopDetailsFilteredRouteView(departures: departures, now: now, filter: filter) + + try sut.inspect().find(button: "Clear Filter").tap() + + XCTAssertNil(filter.wrappedValue) + } +} diff --git a/iosApp/iosAppTests/Pages/StopDetails/StopDetailsRouteViewTests.swift b/iosApp/iosAppTests/Pages/StopDetails/StopDetailsRouteViewTests.swift new file mode 100644 index 000000000..a72ef8dda --- /dev/null +++ b/iosApp/iosAppTests/Pages/StopDetails/StopDetailsRouteViewTests.swift @@ -0,0 +1,38 @@ +// +// StopDetailsRouteViewTests.swift +// iosAppTests +// +// Created by Horn, Melody on 2024-04-09. +// Copyright © 2024 MBTA. All rights reserved. +// + +@testable import iosApp +import shared +import SwiftUI +import ViewInspector +import XCTest + +final class StopDetailsRouteViewTests: XCTestCase { + func testSetFilter() throws { + let objects = ObjectCollectionBuilder() + let route = objects.route() + let stop = objects.stop { _ in } + + let now = Date.now.toKotlinInstant() + + let filter = Binding(wrappedValue: nil) + + let northPattern = objects.routePattern(route: route) { $0.directionId = 0 } + let southPattern = objects.routePattern(route: route) { $0.directionId = 1 } + let patternsByHeadsignNorth = PatternsByHeadsign(route: route, headsign: "North", patterns: [northPattern], upcomingTrips: nil, alertsHere: nil) + let patternsByHeadsignSouth = PatternsByHeadsign(route: route, headsign: "South", patterns: [southPattern], upcomingTrips: nil, alertsHere: nil) + let patternsByStop = PatternsByStop(route: route, stop: stop, patternsByHeadsign: [patternsByHeadsignNorth, patternsByHeadsignSouth]) + let sut = StopDetailsRouteView(patternsByStop: patternsByStop, now: now, filter: filter) + + XCTAssertNil(filter.wrappedValue) + try sut.inspect().find(button: "North").tap() + XCTAssertEqual(filter.wrappedValue, .init(routeId: route.id, directionId: 0)) + try sut.inspect().find(button: "South").tap() + XCTAssertEqual(filter.wrappedValue, .init(routeId: route.id, directionId: 1)) + } +} diff --git a/iosApp/iosAppTests/Pages/StopDetails/StopDetailsRoutesViewTests.swift b/iosApp/iosAppTests/Pages/StopDetails/StopDetailsRoutesViewTests.swift new file mode 100644 index 000000000..6879d895a --- /dev/null +++ b/iosApp/iosAppTests/Pages/StopDetails/StopDetailsRoutesViewTests.swift @@ -0,0 +1,50 @@ +// +// StopDetailsRoutesViewTests.swift +// iosAppTests +// +// Created by Horn, Melody on 2024-04-09. +// Copyright © 2024 MBTA. All rights reserved. +// + +@testable import iosApp +import shared +import SwiftUI +import ViewInspector +import XCTest + +final class StopDetailsRoutesViewTests: XCTestCase { + private func testData() -> (departures: StopDetailsDepartures, routeId: String) { + let objects = ObjectCollectionBuilder() + let route = objects.route() + let stop = objects.stop { _ in } + let wrongRoute = objects.route() + + let departures = StopDetailsDepartures(routes: [ + PatternsByStop(route: route, stop: stop, patternsByHeadsign: []), + PatternsByStop(route: wrongRoute, stop: stop, patternsByHeadsign: []), + ]) + + return (departures: departures, routeId: route.id) + } + + func testShowsListWithoutFilter() throws { + let (departures: departures, routeId: _) = testData() + + let sut = StopDetailsRoutesView(departures: departures, now: Date.now.toKotlinInstant(), filter: .constant(nil)) + + XCTAssertNotNil(try sut.inspect().list()) + XCTAssertNil(try? sut.inspect().find(StopDetailsFilteredRouteView.self)) + } + + func testShowsFilteredWithFilter() throws { + let (departures: departures, routeId: routeId) = testData() + + let filter = StopDetailsFilter(routeId: routeId, directionId: 0) + let sut = StopDetailsRoutesView(departures: departures, now: Date.now.toKotlinInstant(), filter: .constant(filter)) + + let actualView = try sut.inspect().find(StopDetailsFilteredRouteView.self).actualView() + XCTAssertEqual(actualView.filter, filter) + XCTAssertEqual(actualView.patternsByStop, departures.routes[0]) + XCTAssertNil(try? sut.inspect().list()) + } +} diff --git a/iosApp/iosAppTests/Utils/SheetNavigationStackEntryTests.swift b/iosApp/iosAppTests/Utils/SheetNavigationStackEntryTests.swift new file mode 100644 index 000000000..7b3f3040d --- /dev/null +++ b/iosApp/iosAppTests/Utils/SheetNavigationStackEntryTests.swift @@ -0,0 +1,59 @@ +// +// SheetNavigationStackEntryTests.swift +// iosAppTests +// +// Created by Horn, Melody on 2024-04-09. +// Copyright © 2024 MBTA. All rights reserved. +// + +@testable import iosApp +import shared +import XCTest + +final class SheetNavigationStackEntryTests: XCTestCase { + func testLastFilterEmpty() throws { + var stack: [SheetNavigationStackEntry] = [] + + XCTAssertNil(stack.lastStopDetailsFilter) + + stack.lastStopDetailsFilter = .init(routeId: "A", directionId: 1) + + XCTAssertEqual(stack, []) + } + + func testLastFilterShallow() throws { + let stop = ObjectCollectionBuilder.Single.shared.stop { _ in } + var stack: [SheetNavigationStackEntry] = [.stopDetails(stop, nil)] + + XCTAssertNil(stack.lastStopDetailsFilter) + + stack.lastStopDetailsFilter = .init(routeId: "A", directionId: 1) + + XCTAssertEqual(stack, [.stopDetails(stop, .init(routeId: "A", directionId: 1))]) + XCTAssertEqual(stack.lastStopDetailsFilter, .init(routeId: "A", directionId: 1)) + + stack.lastStopDetailsFilter = nil + + XCTAssertEqual(stack, [.stopDetails(stop, nil)]) + XCTAssertEqual(stack.lastStopDetailsFilter, nil) + } + + func testLastFilterDeep() throws { + let stop = ObjectCollectionBuilder.Single.shared.stop { _ in } + let otherStop = ObjectCollectionBuilder.Single.shared.stop { _ in } + let previousEntries: [SheetNavigationStackEntry] = [ + .stopDetails(otherStop, .init(routeId: "A", directionId: 1)), + .stopDetails(otherStop, .init(routeId: "B", directionId: 1)), + .stopDetails(otherStop, .init(routeId: "C", directionId: 0)), + .stopDetails(otherStop, .init(routeId: "D", directionId: 0)), + ] + var stack: [SheetNavigationStackEntry] = previousEntries + [.stopDetails(stop, .init(routeId: "E", directionId: 0))] + + XCTAssertEqual(stack.lastStopDetailsFilter, .init(routeId: "E", directionId: 0)) + + stack.lastStopDetailsFilter = nil + + XCTAssertEqual(stack, previousEntries + [.stopDetails(stop, nil)]) + XCTAssertEqual(stack.lastStopDetailsFilter, nil) + } +} diff --git a/iosApp/iosAppTests/Views/NearbyTransitViewTests.swift b/iosApp/iosAppTests/Views/NearbyTransitViewTests.swift index da79a1dc4..3e33bf39d 100644 --- a/iosApp/iosAppTests/Views/NearbyTransitViewTests.swift +++ b/iosApp/iosAppTests/Views/NearbyTransitViewTests.swift @@ -254,7 +254,7 @@ final class NearbyTransitViewTests: XCTestCase { ) let patterns = try sut.inspect().findAll(ViewType.NavigationLink.self, where: { _ in true }) - .map { try $0.labelView().view(NearbyStopRoutePatternView.self) } + .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))) diff --git a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/StopAssociatedRoute.kt b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/StopAssociatedRoute.kt index 6725113ae..8d07043f9 100644 --- a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/StopAssociatedRoute.kt +++ b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/StopAssociatedRoute.kt @@ -175,6 +175,9 @@ data class PatternsByStop( @OptIn(ExperimentalTurfApi::class) fun distanceFrom(position: Position) = distance(position, this.position) + + fun allUpcomingTrips(): List = + this.patternsByHeadsign.flatMap { it.upcomingTrips ?: emptyList() }.sorted() } /** diff --git a/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/model/PatternsByStopTest.kt b/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/model/PatternsByStopTest.kt new file mode 100644 index 000000000..35e9b425b --- /dev/null +++ b/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/model/PatternsByStopTest.kt @@ -0,0 +1,86 @@ +package com.mbta.tid.mbta_app.model + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.minutes +import kotlinx.datetime.Clock + +class PatternsByStopTest { + @Test + fun `splitPerTrip divides and sorts properly`() { + val objects = ObjectCollectionBuilder() + + val route = objects.route() + val stop = objects.stop() + val routePatternAshmont = + objects.routePattern(route) { representativeTrip { headsign = "Ashmont" } } + val routePatternBraintree = + objects.routePattern(route) { representativeTrip { headsign = "Braintree" } } + + val time = Clock.System.now() + + val tripAshmont1 = objects.trip(routePatternAshmont) + val predictionAshmont1 = + objects.prediction { + trip = tripAshmont1 + departureTime = time + 1.minutes + } + val upcomingTripAshmont1 = objects.upcomingTrip(predictionAshmont1) + + val tripBraintree1 = objects.trip(routePatternBraintree) + val scheduleBraintree1 = + objects.schedule { + trip = tripBraintree1 + departureTime = time + 2.minutes + } + val predictionBraintree1 = + objects.prediction(scheduleBraintree1) { departureTime = time + 1.9.minutes } + val upcomingTripBraintree1 = objects.upcomingTrip(scheduleBraintree1, predictionBraintree1) + + val tripBraintree2 = objects.trip(routePatternBraintree) + val predictionBraintree2 = + objects.prediction { + trip = tripBraintree2 + departureTime = time + 6.minutes + } + val upcomingTripBraintree2 = objects.upcomingTrip(predictionBraintree2) + + val tripAshmont2 = objects.trip(routePatternAshmont) + val scheduleAshmont2 = + objects.schedule { + trip = tripAshmont2 + departureTime = time + 10.minutes + } + val upcomingTripAshmont2 = objects.upcomingTrip(scheduleAshmont2) + + val patternsByStop = + PatternsByStop( + route, + stop, + listOf( + PatternsByHeadsign( + route, + "Ashmont", + listOf(routePatternAshmont), + listOf(upcomingTripAshmont1, upcomingTripAshmont2) + ), + PatternsByHeadsign( + route, + "Braintree", + listOf(routePatternBraintree), + listOf(upcomingTripBraintree1, upcomingTripBraintree2) + ) + ) + ) + + assertEquals( + listOf( + upcomingTripAshmont1, + upcomingTripBraintree1, + upcomingTripBraintree2, + upcomingTripAshmont2 + ), + patternsByStop.allUpcomingTrips() + ) + } +}