diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 7ba1ef015..c47f647d5 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -137,7 +137,7 @@ 9A4092EB2D0258A20026EB01 /* TripStopRowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A4092EA2D0258A20026EB01 /* TripStopRowTests.swift */; }; 9A4850572CB56A1600F50EE7 /* RouteTypeExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A4850562CB56A1500F50EE7 /* RouteTypeExtension.swift */; }; 9A4D0AD52CFEA16C009C1054 /* StopDetailsFilteredDepartureDetailsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A4D0AD42CFEA16C009C1054 /* StopDetailsFilteredDepartureDetailsTests.swift */; }; - 9A4D0AD72CFF5DA6009C1054 /* TripVehicleCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A4D0AD62CFF5DA6009C1054 /* TripVehicleCard.swift */; }; + 9A4D0AD72CFF5DA6009C1054 /* TripHeaderCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A4D0AD62CFF5DA6009C1054 /* TripHeaderCard.swift */; }; 9A4D0AD92CFF98C9009C1054 /* TripStops.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A4D0AD82CFF98C9009C1054 /* TripStops.swift */; }; 9A4D0ADB2D00A860009C1054 /* TripStopRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A4D0ADA2D00A860009C1054 /* TripStopRow.swift */; }; 9A4D0ADD2D0210C3009C1054 /* TripDetailsDisclosureGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A4D0ADC2D0210C3009C1054 /* TripDetailsDisclosureGroup.swift */; }; @@ -157,6 +157,7 @@ 9A5B27582BB22BF9009A6FC6 /* MapLayerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A5B27572BB22BF8009A6FC6 /* MapLayerManager.swift */; }; 9A5B275C2BB237DE009A6FC6 /* RecenterButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A5B275B2BB237DE009A6FC6 /* RecenterButton.swift */; }; 9A60E8E72B8501BD008A8D5C /* RoutePillTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A60E8E62B8501BD008A8D5C /* RoutePillTests.swift */; }; + 9A6172D52D1395EB002AF6D2 /* ExplainerPageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A6172D42D1395EB002AF6D2 /* ExplainerPageTests.swift */; }; 9A635D1F2B99103200A43C51 /* EmptyWhenModifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A635D1E2B99103200A43C51 /* EmptyWhenModifierTests.swift */; }; 9A6A51EF2C652BB100E3AC13 /* AlertActivePeriodFormattingExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A6A51EE2C652BB100E3AC13 /* AlertActivePeriodFormattingExtension.swift */; }; 9A6ACA2B2CD0096A00299AF5 /* MoreSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A6ACA2A2CD0096A00299AF5 /* MoreSectionView.swift */; }; @@ -175,13 +176,15 @@ 9A7F12132CCB185D0042B0F1 /* TabLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A7F12122CCB185D0042B0F1 /* TabLabel.swift */; }; 9A7F12172CCFEFAA0042B0F1 /* MoreLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A7F12162CCFEFAA0042B0F1 /* MoreLink.swift */; }; 9A7F12192CCFF2D20042B0F1 /* MorePhone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A7F12182CCFF2D20042B0F1 /* MorePhone.swift */; }; - 9A8375EE2D0A14DD00E3694F /* TripVehicleCardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A8375ED2D0A14DD00E3694F /* TripVehicleCardTests.swift */; }; + 9A8375EE2D0A14DD00E3694F /* TripHeaderCardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A8375ED2D0A14DD00E3694F /* TripHeaderCardTests.swift */; }; 9A84DB102D03A6BF00A78C64 /* TripStopsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A84DB0F2D03A6BF00A78C64 /* TripStopsTests.swift */; }; 9A887D572B683103006F5B80 /* SearchResultsContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A887D562B683103006F5B80 /* SearchResultsContainer.swift */; }; 9A887D592B698EF1006F5B80 /* SearchResultViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A887D582B698EF1006F5B80 /* SearchResultViewTests.swift */; }; 9A88AAC12BD0680C00A5BF88 /* StopDetailsFilterPills.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A88AAC02BD0680C00A5BF88 /* StopDetailsFilterPills.swift */; }; 9A88AAC32BD07B3C00A5BF88 /* StopDetailsFilterPillsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A88AAC22BD07B3C00A5BF88 /* StopDetailsFilterPillsTests.swift */; }; 9A8E55AE2CFE6E55004ED059 /* StopDetailsFilteredHeaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A8E55AD2CFE6E55004ED059 /* StopDetailsFilteredHeaderTests.swift */; }; + 9A97334F2D0CD61C00242972 /* StopDot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A97334E2D0CD61C00242972 /* StopDot.swift */; }; + 9A9733522D11CFF800242972 /* ExplainerPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A9733512D11CFF800242972 /* ExplainerPage.swift */; }; 9A9BA0002D03565800BCB2BD /* TripDetailsViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A9B9FFF2D03565800BCB2BD /* TripDetailsViewTests.swift */; }; 9A9E3DC22C62AE12004AADC7 /* AlertDetailsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A9E3DC12C62AE11004AADC7 /* AlertDetailsPage.swift */; }; 9A9E3DC42C640071004AADC7 /* AlertDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A9E3DC32C640071004AADC7 /* AlertDetails.swift */; }; @@ -434,7 +437,7 @@ 9A4092EA2D0258A20026EB01 /* TripStopRowTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripStopRowTests.swift; sourceTree = ""; }; 9A4850562CB56A1500F50EE7 /* RouteTypeExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteTypeExtension.swift; sourceTree = ""; }; 9A4D0AD42CFEA16C009C1054 /* StopDetailsFilteredDepartureDetailsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopDetailsFilteredDepartureDetailsTests.swift; sourceTree = ""; }; - 9A4D0AD62CFF5DA6009C1054 /* TripVehicleCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripVehicleCard.swift; sourceTree = ""; }; + 9A4D0AD62CFF5DA6009C1054 /* TripHeaderCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripHeaderCard.swift; sourceTree = ""; }; 9A4D0AD82CFF98C9009C1054 /* TripStops.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripStops.swift; sourceTree = ""; }; 9A4D0ADA2D00A860009C1054 /* TripStopRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripStopRow.swift; sourceTree = ""; }; 9A4D0ADC2D0210C3009C1054 /* TripDetailsDisclosureGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripDetailsDisclosureGroup.swift; sourceTree = ""; }; @@ -454,6 +457,7 @@ 9A5B27572BB22BF8009A6FC6 /* MapLayerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapLayerManager.swift; sourceTree = ""; }; 9A5B275B2BB237DE009A6FC6 /* RecenterButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecenterButton.swift; sourceTree = ""; }; 9A60E8E62B8501BD008A8D5C /* RoutePillTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoutePillTests.swift; sourceTree = ""; }; + 9A6172D42D1395EB002AF6D2 /* ExplainerPageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExplainerPageTests.swift; sourceTree = ""; }; 9A635D1E2B99103200A43C51 /* EmptyWhenModifierTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyWhenModifierTests.swift; sourceTree = ""; }; 9A6A51EE2C652BB100E3AC13 /* AlertActivePeriodFormattingExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertActivePeriodFormattingExtension.swift; sourceTree = ""; }; 9A6ACA2A2CD0096A00299AF5 /* MoreSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreSectionView.swift; sourceTree = ""; }; @@ -472,13 +476,15 @@ 9A7F12122CCB185D0042B0F1 /* TabLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabLabel.swift; sourceTree = ""; }; 9A7F12162CCFEFAA0042B0F1 /* MoreLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreLink.swift; sourceTree = ""; }; 9A7F12182CCFF2D20042B0F1 /* MorePhone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MorePhone.swift; sourceTree = ""; }; - 9A8375ED2D0A14DD00E3694F /* TripVehicleCardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripVehicleCardTests.swift; sourceTree = ""; }; + 9A8375ED2D0A14DD00E3694F /* TripHeaderCardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripHeaderCardTests.swift; sourceTree = ""; }; 9A84DB0F2D03A6BF00A78C64 /* TripStopsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripStopsTests.swift; sourceTree = ""; }; 9A887D562B683103006F5B80 /* SearchResultsContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsContainer.swift; sourceTree = ""; }; 9A887D582B698EF1006F5B80 /* SearchResultViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultViewTests.swift; sourceTree = ""; }; 9A88AAC02BD0680C00A5BF88 /* StopDetailsFilterPills.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopDetailsFilterPills.swift; sourceTree = ""; }; 9A88AAC22BD07B3C00A5BF88 /* StopDetailsFilterPillsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopDetailsFilterPillsTests.swift; sourceTree = ""; }; 9A8E55AD2CFE6E55004ED059 /* StopDetailsFilteredHeaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopDetailsFilteredHeaderTests.swift; sourceTree = ""; }; + 9A97334E2D0CD61C00242972 /* StopDot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopDot.swift; sourceTree = ""; }; + 9A9733512D11CFF800242972 /* ExplainerPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExplainerPage.swift; sourceTree = ""; }; 9A9B9FFF2D03565800BCB2BD /* TripDetailsViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripDetailsViewTests.swift; sourceTree = ""; }; 9A9E3DC12C62AE11004AADC7 /* AlertDetailsPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertDetailsPage.swift; sourceTree = ""; }; 9A9E3DC32C640071004AADC7 /* AlertDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertDetails.swift; sourceTree = ""; }; @@ -625,16 +631,16 @@ isa = PBXGroup; children = ( 6EF50F4F2B988BC500833070 /* Fetchers */, - 9A6FA0222BC70D0A0067769C /* InspectionEmissary.swift */, - 6EED5E8E2B3DC6A00052A1B8 /* IosAppTests.swift */, - 8C7FA86E2B5EEA34009B699D /* LocationDataManagerTests.swift */, 6E4EACFA2B7A829C0011AB8B /* Mocks */, 9A5B275D2BB242EF009A6FC6 /* Pages */, 6EE745822B965B8D0052227E /* Phoenix */, 9ADB849E2BAD1B6B006581CE /* Utils */, - 8CA606A82CC02FBC0019C448 /* ViewInspectorExtensions.swift */, 6EFEE4292BEC0FF000810319 /* ViewModels */, 6EED5EA92B3DF1BF0052A1B8 /* Views */, + 9A6FA0222BC70D0A0067769C /* InspectionEmissary.swift */, + 6EED5E8E2B3DC6A00052A1B8 /* IosAppTests.swift */, + 8C7FA86E2B5EEA34009B699D /* LocationDataManagerTests.swift */, + 8CA606A82CC02FBC0019C448 /* ViewInspectorExtensions.swift */, ); path = iosAppTests; sourceTree = ""; @@ -707,16 +713,16 @@ 7555FF72242A565900829871 = { isa = PBXGroup; children = ( - EDE4D3B32CD0255F00D3F517 /* Launch Screen.storyboard */, - 8C304C652B69C0C300263886 /* .swift-version */, - 8C42F06F2B890BC800F9A77B /* .swiftlint.yml */, - 8C349BB72B754F2600AC7FFB /* 10 Park Plaza.gpx */, 7555FFB0242A642200829871 /* Frameworks */, 7555FF7D242A565900829871 /* iosApp */, - 6E9BCCE12B3F221A005FB96E /* iosApp.xctestplan */, 6EED5E8D2B3DC69F0052A1B8 /* iosAppTests */, 6EED5E9A2B3DC6C10052A1B8 /* iosAppUITests */, 7A3CBAAEF3F420C6E00CC576 /* Pods */, + 8C304C652B69C0C300263886 /* .swift-version */, + 8C42F06F2B890BC800F9A77B /* .swiftlint.yml */, + 8C349BB72B754F2600AC7FFB /* 10 Park Plaza.gpx */, + 6E9BCCE12B3F221A005FB96E /* iosApp.xctestplan */, + EDE4D3B32CD0255F00D3F517 /* Launch Screen.storyboard */, 9AF0937C2BDC1FC5001DF39F /* PrivacyInfo.xcprivacy */, 7555FF7C242A565900829871 /* Products */, ); @@ -736,11 +742,16 @@ isa = PBXGroup; children = ( ED24EAD62C1A940700A7BE4D /* Analytics */, - 9A3BAB712BEAB8E200DAFDE2 /* Colors.xcassets */, 9A4E8E572B7EC49A0066B936 /* ComponentViews */, + 9A9E05F22B6D6D9F0086B437 /* Fetchers */, + 9A2005C02B97B56100F562E1 /* Pages */, + 6EE7457C2B965AD40052227E /* Phoenix */, + 058557D7273AAEEB004C7B11 /* Preview Content */, + 9AC10BD82B80060E00EA4605 /* Utils */, + 9A392C952CC03ACF00DE1FBE /* ViewModels */, + 9A3BAB712BEAB8E200DAFDE2 /* Colors.xcassets */, 7555FF82242A565900829871 /* ContentView.swift */, 6E20278D2BD989630037554F /* DummyTestAppView.swift */, - 9A9E05F22B6D6D9F0086B437 /* Fetchers */, 9AB3F50E2BE45382008D9E40 /* GoogleService-Info.plist */, 058557BA273AAA24004C7B11 /* Icons.xcassets */, 7555FF8C242A565B00829871 /* Info.plist */, @@ -749,12 +760,7 @@ 2152FB032600AC8F00CF470E /* IOSApp.swift */, 9AF88E042B48913C00E08C7C /* Localizable.xcstrings */, 8CC1BB3F2B59D1F6005386FE /* LocationDataManager.swift */, - 9A2005C02B97B56100F562E1 /* Pages */, - 6EE7457C2B965AD40052227E /* Phoenix */, - 058557D7273AAEEB004C7B11 /* Preview Content */, 6E20278F2BD989AC0037554F /* ProductionAppView.swift */, - 9AC10BD82B80060E00EA4605 /* Utils */, - 9A392C952CC03ACF00DE1FBE /* ViewModels */, ); path = iosApp; sourceTree = ""; @@ -954,14 +960,15 @@ children = ( 9AF29E032CFA3F77005AA4A3 /* DepartureTileTests.swift */, 9AF093792BD962FF001DF39F /* DirectionPickerTests.swift */, + 9A6172D42D1395EB002AF6D2 /* ExplainerPageTests.swift */, 9A4D0AD42CFEA16C009C1054 /* StopDetailsFilteredDepartureDetailsTests.swift */, 9A8E55AD2CFE6E55004ED059 /* StopDetailsFilteredHeaderTests.swift */, 9A2BCBDD2CED365200FB2913 /* StopDetailsPageTests.swift */, 9A2BCBDF2CED366300FB2913 /* StopDetailsViewTests.swift */, 9A9B9FFF2D03565800BCB2BD /* TripDetailsViewTests.swift */, + 9A8375ED2D0A14DD00E3694F /* TripHeaderCardTests.swift */, 9A4092EA2D0258A20026EB01 /* TripStopRowTests.swift */, 9A84DB0F2D03A6BF00A78C64 /* TripStopsTests.swift */, - 9A8375ED2D0A14DD00E3694F /* TripVehicleCardTests.swift */, ); path = StopDetails; sourceTree = ""; @@ -1122,19 +1129,21 @@ 9A4092E82D0248880026EB01 /* ColoredRouteLine.swift */, 9AF29DF52CF54319005AA4A3 /* DepartureTile.swift */, 9AF093772BD943A4001DF39F /* DirectionPicker.swift */, + 9A9733512D11CFF800242972 /* ExplainerPage.swift */, 9ACE4FCF2CE6707900FEB006 /* StopDetailsPage.swift */, 9AF29E052CFE2C4C005AA4A3 /* StopDetailsFilteredDepartureDetails.swift */, 9AF29DFF2CF63330005AA4A3 /* StopDetailsFilteredHeader.swift */, 9AF29DF72CF5454E005AA4A3 /* StopDetailsFilteredView.swift */, 9AF29DF92CF548E5005AA4A3 /* StopDetailsUnfilteredView.swift */, 9ACE4FD12CE6709B00FEB006 /* StopDetailsView.swift */, + 9A97334E2D0CD61C00242972 /* StopDot.swift */, 9AF29E012CF79554005AA4A3 /* TileData.swift */, 9A4D0ADC2D0210C3009C1054 /* TripDetailsDisclosureGroup.swift */, 9ACE4FD32CE6D17D00FEB006 /* TripDetailsView.swift */, + 9A4D0AD62CFF5DA6009C1054 /* TripHeaderCard.swift */, 9AF29DFD2CF61EED005AA4A3 /* TripStatus.swift */, 9A4D0ADA2D00A860009C1054 /* TripStopRow.swift */, 9A4D0AD82CFF98C9009C1054 /* TripStops.swift */, - 9A4D0AD62CFF5DA6009C1054 /* TripVehicleCard.swift */, ); path = StopDetails; sourceTree = ""; @@ -1479,7 +1488,7 @@ ED5C93F62C4A1AD70086D017 /* TripDetailsHeaderTests.swift in Sources */, 9A9BA0002D03565800BCB2BD /* TripDetailsViewTests.swift in Sources */, 9A60E8E72B8501BD008A8D5C /* RoutePillTests.swift in Sources */, - 9A8375EE2D0A14DD00E3694F /* TripVehicleCardTests.swift in Sources */, + 9A8375EE2D0A14DD00E3694F /* TripHeaderCardTests.swift in Sources */, 6E35D4D32B72CD3900A2BF95 /* HomeMapViewTests.swift in Sources */, 9AF0937A2BD962FF001DF39F /* DirectionPickerTests.swift in Sources */, 9A6FA0282BC72F110067769C /* LegacyStopDetailsPageTests.swift in Sources */, @@ -1492,6 +1501,7 @@ 8C7FA8712B5F2EF2009B699D /* NearbyTransitViewTests.swift in Sources */, 8C5F47662C40842200FB71DA /* TripDetailsStopViewTests.swift in Sources */, 8CDF2C342BE9357E007FC912 /* OptionalNavigationLinkTests.swift in Sources */, + 9A6172D52D1395EB002AF6D2 /* ExplainerPageTests.swift in Sources */, 9AF29E042CFA3F77005AA4A3 /* DepartureTileTests.swift in Sources */, 8CA1FB772BF813F500384658 /* TripDetailsStopListSplitViewTests.swift in Sources */, 6E2D6CA12CC2EDD700959605 /* ErrorBannerViewModelTests.swift in Sources */, @@ -1547,7 +1557,8 @@ 9A6DDF912B976FDF004D141A /* EmptyWhenModifier.swift in Sources */, 9AF29E022CF79554005AA4A3 /* TileData.swift in Sources */, 9ADB849D2BAD05BC006581CE /* Inspection.swift in Sources */, - 9A4D0AD72CFF5DA6009C1054 /* TripVehicleCard.swift in Sources */, + 9A97334F2D0CD61C00242972 /* StopDot.swift in Sources */, + 9A4D0AD72CFF5DA6009C1054 /* TripHeaderCard.swift in Sources */, 6EE76D1B2BF532010051D608 /* RouteIcon.swift in Sources */, ED24EADE2C1A986900A7BE4D /* AnalyticsProvider.swift in Sources */, 6EE7457E2B965ADE0052227E /* Socket.swift in Sources */, @@ -1616,6 +1627,7 @@ 8CE014122BBDB96900918FAE /* StopDetailsRouteView.swift in Sources */, 8CA1FB732BF7E69000384658 /* TripDetailsStopView.swift in Sources */, ED93D6062CB6C0DD003B1C12 /* RouteResultsView.swift in Sources */, + 9A9733522D11CFF800242972 /* ExplainerPage.swift in Sources */, 6E35D4D02B72C7B700A2BF95 /* HomeMapView.swift in Sources */, 9ACE4FD02CE6707900FEB006 /* StopDetailsPage.swift in Sources */, 9A4DB77F2CA4A32800E8755B /* SearchOverlay.swift in Sources */, diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index 25decebdc..01fcc49f8 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -209,8 +209,9 @@ struct ContentView: View { if contentVM.hideMaps { navSheetContents .fullScreenCover(item: .constant(nav.coverItemIdentifiable()), onDismiss: { - if case .alertDetails = nearbyVM.navigationStack.last { - nearbyVM.goBack() + switch nearbyVM.navigationStack.last { + case .alertDetails: nearbyVM.goBack() + default: break } }, content: coverContents) .onAppear { @@ -242,8 +243,9 @@ struct ContentView: View { .fullScreenCover( item: .constant(nav.coverItemIdentifiable()), onDismiss: { - if case .alertDetails = nearbyVM.navigationStack.last { - nearbyVM.goBack() + switch nearbyVM.navigationStack.last { + case .alertDetails: nearbyVM.goBack() + default: break } }, content: coverContents @@ -268,9 +270,6 @@ struct ContentView: View { NavigationStack { VStack { switch nearbyVM.navigationStack.lastSafe() { - case .alertDetails: - EmptyView() - case let .stopDetails(stopId, stopFilter, tripFilter): // Wrapping in a TabView helps the page to animate in as a single unit // Otherwise only the header animates @@ -353,6 +352,8 @@ struct ContentView: View { .onAppear { screenTracker.track(screen: .nearbyTransit) } + + default: EmptyView() } } .animation(.easeInOut, value: nearbyVM.navigationStack.lastSafe().sheetItemIdentifiable()?.id) diff --git a/iosApp/iosApp/Localizable.xcstrings b/iosApp/iosApp/Localizable.xcstrings index e5258edfe..327fcfbdd 100644 --- a/iosApp/iosApp/Localizable.xcstrings +++ b/iosApp/iosApp/Localizable.xcstrings @@ -818,6 +818,53 @@ } } }, + "%@ scheduled to depart %@" : { + "comment" : "VoiceOver text for the departure status on the trip details page,\nex '[train] scheduled to depart [Alewife]' or '[bus] scheduled to depart [Harvard]'", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ scheduled to depart %2$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$@ programado para salir de %2$@" + } + }, + "ht" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$@ ki pwograme pou kite %2$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$@ programado para partir de %2$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$@ dự kiến khởi hành %2$@" + } + }, + "zh-Hans-CN" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$@计划从 %2$@ 出发" + } + }, + "zh-Hant-TW" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "這%1$@預定從 %2$@ 出發" + } + } + } + }, "%@ to" : { "comment" : "Label the direction a list of arrivals is for.\nPossible values include Northbound, Southbound, Inbound, Outbound, Eastbound, Westbound.\nFor example, \"[Northbound] to [Alewife]", "localizations" : { @@ -2853,6 +2900,47 @@ } } }, + "Details" : { + "comment" : "Header on the general explainer details page", + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Detalles" + } + }, + "ht" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Detay yo" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Detalhes" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Thông tin chi tiết" + } + }, + "zh-Hans-CN" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "详情" + } + }, + "zh-Hant-TW" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "詳情" + } + } + } + }, "Detour" : { "comment" : "Possible alert effect", "localizations" : { @@ -5761,6 +5849,47 @@ } } }, + "Prediction not available yet" : { + "comment" : "Headline for an explanation of why no predictions are shown", + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Predicción aún no disponible" + } + }, + "ht" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Prediksyon an poko disponib" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Previsão ainda não disponível" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Chưa có dự đoán" + } + }, + "zh-Hans-CN" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "预测尚不可用" + } + }, + "zh-Hant-TW" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "預測尚不可用" + } + } + } + }, "Predictions unavailable" : { "comment" : "The status label when no predictions exist for a route and direction", "localizations" : { @@ -6210,6 +6339,46 @@ } } }, + "Scheduled to depart" : { + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Programado para salir" + } + }, + "ht" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Pwograme pou ale" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Programado para partir" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Đã lên lịch khởi hành" + } + }, + "zh-Hans-CN" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "预计出发" + } + }, + "zh-Hant-TW" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "預定出發" + } + } + } + }, "Search by stop" : { "localizations" : { "es" : { @@ -8844,6 +9013,46 @@ } } }, + "We don’t have live predictions for this trip yet, but they will appear closer to the scheduled time. If the trip is delayed or cancelled, we’ll let you know here." : { + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Aún no tenemos predicciones en vivo para este viaje, pero aparecerán más cerca de la hora programada. Si el viaje se retrasa o se cancela, te lo informaremos aquí." + } + }, + "ht" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Nou poko gen prediksyon an dirèk pou vwayaj sa a, men yo pral parèt pi pre lè pwograme a. Si vwayaj la an reta oswa anile, n ap fè w konnen isit la." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Ainda não temos previsões ao vivo para esta viagem, mas elas aparecerão mais perto do horário programado. Se a viagem for adiada ou cancelada, informaremos você aqui." + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Chúng tôi chưa có dự đoán trực tiếp cho chuyến đi này, nhưng chúng sẽ xuất hiện gần với thời gian dự kiến hơn. Nếu chuyến đi bị hoãn hoặc hủy, chúng tôi sẽ thông báo cho bạn tại đây." + } + }, + "zh-Hans-CN" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "我们目前还没有此行程的实时预测,但会在接近预定时间时显示。如果行程延误或取消,我们会在这里通知您。" + } + }, + "zh-Hant-TW" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "我們還沒有這次旅行的即時預測,但當接近預定時間時就會顯示。如果行程延誤或取消,我們會在這裡通知您。" + } + } + } + }, "We use your location to show you nearby transit options." : { "localizations" : { "es" : { diff --git a/iosApp/iosApp/Pages/StopDetails/ExplainerPage.swift b/iosApp/iosApp/Pages/StopDetails/ExplainerPage.swift new file mode 100644 index 000000000..31245d1d2 --- /dev/null +++ b/iosApp/iosApp/Pages/StopDetails/ExplainerPage.swift @@ -0,0 +1,80 @@ +// +// ExplainerPage.swift +// iosApp +// +// Created by esimon on 12/17/24. +// Copyright © 2024 MBTA. All rights reserved. +// + +import shared +import SwiftUI + +struct Explainer { + let type: ExplainerType + let routeAccents: TripRouteAccents +} + +enum ExplainerType: String { + case noPrediction +} + +struct ExplainerPage: View { + let explainer: Explainer + let onClose: () -> Void + + @ScaledMetric private var modeIconHeight: CGFloat = 24 + + @ViewBuilder + private var header: some View { + HStack(alignment: .center, spacing: 6) { + routeIcon(explainer.routeAccents.type) + .resizable() + .aspectRatio(contentMode: .fit) + .scaledToFit() + .frame(maxHeight: modeIconHeight, alignment: .topLeading) + + Text("Details", comment: "Header on the general explainer details page").font(Typography.headline) + Spacer() + ActionButton(kind: .close) { onClose() } + } + .dynamicTypeSize(...DynamicTypeSize.accessibility1) + .padding(16) + .foregroundStyle(explainer.routeAccents.textColor) + .background(explainer.routeAccents.color) + } + + @ViewBuilder + private var explanationHeadline: some View { + switch explainer.type { + case .noPrediction: Text( + "Prediction not available yet", + comment: "Headline for an explanation of why no predictions are shown" + ) + } + } + + @ViewBuilder + private var explanationText: some View { + switch explainer.type { + case .noPrediction: Text( + "We don’t have live predictions for this trip yet, but they will appear closer to the scheduled time. If the trip is delayed or cancelled, we’ll let you know here." + ) + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + VStack(alignment: .leading, spacing: 24) { + explanationHeadline.font(Typography.title2Bold) + explanationText.font(Typography.body) + Spacer() + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.top, 24) + .padding(.bottom, 40) + } + .background(Color.fill2) + } +} diff --git a/iosApp/iosApp/Pages/StopDetails/StopDetailsFilteredDepartureDetails.swift b/iosApp/iosApp/Pages/StopDetails/StopDetailsFilteredDepartureDetails.swift index f8e9e8ade..d6243db70 100644 --- a/iosApp/iosApp/Pages/StopDetails/StopDetailsFilteredDepartureDetails.swift +++ b/iosApp/iosApp/Pages/StopDetails/StopDetailsFilteredDepartureDetails.swift @@ -38,12 +38,10 @@ struct StopDetailsFilteredDepartureDetails: View { } var body: some View { - let routeHex: String? = patternsByStop.line?.color ?? patternsByStop.representativeRoute.color - let routeColor: Color? = if let routeHex { Color(hex: routeHex) } else { nil } + let routeHex: String = patternsByStop.line?.color ?? patternsByStop.representativeRoute.color + let routeColor = Color(hex: routeHex) ZStack(alignment: .top) { - if let routeColor { - routeColor.ignoresSafeArea(.all) - } + routeColor.ignoresSafeArea(.all) Rectangle() .fill(Color.halo) .frame(height: 2) @@ -54,11 +52,9 @@ struct StopDetailsFilteredDepartureDetails: View { DirectionPicker( patternsByStop: patternsByStop, filter: stopFilter, - setFilter: { stopFilter in - setStopFilter(stopFilter) - view.scrollTo(0) - } + setFilter: { setStopFilter($0) } ) + .onChange(of: tripFilter) { filter in if let filter { view.scrollTo(filter.tripId) } } .fixedSize(horizontal: false, vertical: true) .padding([.horizontal, .top], 16) .padding(.bottom, 6) @@ -66,6 +62,7 @@ struct StopDetailsFilteredDepartureDetails: View { departureTiles(patternsByStop, view) .dynamicTypeSize(...DynamicTypeSize.accessibility3) + .onAppear { if let id = tripFilter?.tripId { view.scrollTo(id) } } } alertCards(patternsByStop, routeColor) statusRows(patternsByStop) @@ -82,6 +79,7 @@ struct StopDetailsFilteredDepartureDetails: View { } } } + .ignoresSafeArea(.all) } @@ -89,23 +87,25 @@ struct StopDetailsFilteredDepartureDetails: View { func departureTiles(_ patternsByStop: PatternsByStop, _ view: ScrollViewProxy) -> some View { ScrollView(.horizontal, showsIndicators: false) { HStack(alignment: .top, spacing: 0) { - ForEach(Array(tiles.enumerated()), id: \.offset) { data in - let tileData: TileData = data.element + ForEach(tiles) { tileData in DepartureTile( data: tileData, onTap: { - if let entry = tileData.navigationTarget { - nearbyVM.pushNavEntry(entry) - withAnimation(.easeInOut(duration: 0.5)) { - view.scrollTo(data.offset) - } + if let upcoming = tileData.upcoming { + nearbyVM.navigationStack.lastTripDetailsFilter = .init( + tripId: upcoming.trip.id, + vehicleId: upcoming.prediction?.vehicleId, + stopSequence: upcoming.stopSequence, + selectionLock: false + ) + analytics.tappedDepartureRow( + routeId: patternsByStop.routeIdentifier, + stopId: patternsByStop.stop.id, + pinned: pinned, + alert: alerts.count > 0 + ) + view.scrollTo(tileData.id) } - analytics.tappedDepartureRow( - routeId: patternsByStop.routeIdentifier, - stopId: patternsByStop.stop.id, - pinned: pinned, - alert: alerts.count > 0 - ) }, pillDecoration: patternsByStop .line != nil ? .onPrediction(route: tileData.route) : .none, @@ -148,29 +148,18 @@ struct StopDetailsFilteredDepartureDetails: View { func statusRows(_ patternsByStop: PatternsByStop) -> some View { ForEach(Array(statuses.enumerated()), id: \.offset) { index, row in VStack(spacing: 0) { - OptionalNavigationLink( - value: row.navigationTarget, - action: { entry in - nearbyVM.pushNavEntry(entry) - analytics.tappedDepartureRow( - routeId: patternsByStop.routeIdentifier, - stopId: patternsByStop.stop.id, - pinned: pinned, - alert: alerts.count > 0 - ) - }, - label: { - HeadsignRowView( - headsign: row.headsign, - predictions: row.formatted, - pillDecoration: patternsByStop.line != nil ? - .onRow(route: row.route) : .none - ) - } + HeadsignRowView( + headsign: row.headsign, + predictions: row.formatted, + pillDecoration: patternsByStop.line != nil ? + .onRow(route: row.route) : .none ) .accessibilityInputLabels([row.headsign]) .padding(.vertical, 10) .padding(.horizontal, 16) + .background(Color.fill3) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .padding(.horizontal, 16) if index < statuses.count - 1 { Divider().background(Color.halo) diff --git a/iosApp/iosApp/Pages/StopDetails/StopDetailsFilteredView.swift b/iosApp/iosApp/Pages/StopDetails/StopDetailsFilteredView.swift index e6ea128ce..db0c11c95 100644 --- a/iosApp/iosApp/Pages/StopDetails/StopDetailsFilteredView.swift +++ b/iosApp/iosApp/Pages/StopDetails/StopDetailsFilteredView.swift @@ -88,7 +88,6 @@ struct StopDetailsFilteredView: View { return TileData( upcoming: upcoming, route: route, - stopId: patternsByStop.stop.id, now: nowInstant ) } @@ -197,21 +196,21 @@ struct StopDetailsFilteredView: View { @ViewBuilder private func loadingBody() -> some View { let loadingPatterns = LoadingPlaceholders.shared.patternsByStop(routeId: stopFilter.routeId, trips: 10) - let placeholderTile = TileData( + let tiles = (0 ..< 4).map { index in TileData( route: loadingPatterns.representativeRoute, headsign: "placeholder", formatted: RealtimePatterns.FormatSome( - trips: [.init(id: "", routeType: .lightRail, format: .Boarding())], + trips: [.init(id: "\(index)", routeType: .lightRail, format: .Boarding())], secondaryAlert: nil ) - ) + ) } StopDetailsFilteredDepartureDetails( stopId: stopId, stopFilter: stopFilter, tripFilter: tripFilter, setStopFilter: setStopFilter, setTripFilter: setTripFilter, - tiles: [placeholderTile, placeholderTile, placeholderTile, placeholderTile], + tiles: tiles, statuses: statuses, alerts: alerts, patternsByStop: loadingPatterns, diff --git a/iosApp/iosApp/Pages/StopDetails/StopDetailsPage.swift b/iosApp/iosApp/Pages/StopDetails/StopDetailsPage.swift index b1d01535a..e7fc9a019 100644 --- a/iosApp/iosApp/Pages/StopDetails/StopDetailsPage.swift +++ b/iosApp/iosApp/Pages/StopDetails/StopDetailsPage.swift @@ -72,6 +72,19 @@ struct StopDetailsPage: View { var body: some View { stopDetails + .fullScreenCover( + isPresented: .init( + get: { stopDetailsVM.explainer != nil }, + set: { value in if !value { stopDetailsVM.explainer = nil } } + ) + ) { + if let explainer = stopDetailsVM.explainer { + ExplainerPage( + explainer: explainer, + onClose: { stopDetailsVM.explainer = nil } + ) + } + } .onChange(of: stopDetailsVM.global) { _ in updateDepartures() } .onChange(of: stopDetailsVM.pinnedRoutes) { _ in updateDepartures() } .onChange(of: stopDetailsVM.stopData) { stopData in diff --git a/iosApp/iosApp/Pages/StopDetails/StopDot.swift b/iosApp/iosApp/Pages/StopDetails/StopDot.swift new file mode 100644 index 000000000..13df13829 --- /dev/null +++ b/iosApp/iosApp/Pages/StopDetails/StopDot.swift @@ -0,0 +1,26 @@ +// +// StopDot.swift +// iosApp +// +// Created by esimon on 12/13/24. +// Copyright © 2024 MBTA. All rights reserved. +// + +import SwiftUI + +struct StopDot: View { + let routeAccents: TripRouteAccents + let targeted: Bool + + var body: some View { + Circle() + .strokeBorder(Color.stopDotHalo, lineWidth: 1) + .background(Circle().fill(routeAccents.color)) + .frame(width: 14, height: 14) + .overlay { + if targeted { + Image(.stopPinIndicator).padding(.bottom, 32) + } + } + } +} diff --git a/iosApp/iosApp/Pages/StopDetails/TileData.swift b/iosApp/iosApp/Pages/StopDetails/TileData.swift index f97b13ae2..0157ed01b 100644 --- a/iosApp/iosApp/Pages/StopDetails/TileData.swift +++ b/iosApp/iosApp/Pages/StopDetails/TileData.swift @@ -6,30 +6,30 @@ // Copyright © 2024 MBTA. All rights reserved. // +import Foundation import shared -struct TileData { +struct TileData: Identifiable { + let id: String let route: Route let headsign: String let formatted: RealtimePatterns.Format let upcoming: UpcomingTrip? - let navigationTarget: SheetNavigationStackEntry? init( route: Route, headsign: String, formatted: RealtimePatterns.Format, - upcoming: UpcomingTrip? = nil, - navigationTarget: SheetNavigationStackEntry? = nil + upcoming: UpcomingTrip? = nil ) { self.route = route self.headsign = headsign self.formatted = formatted self.upcoming = upcoming - self.navigationTarget = navigationTarget + id = upcoming?.trip.id ?? UUID().uuidString } - init?(upcoming: UpcomingTrip, route: Route, stopId: String, now: Instant) { + init?(upcoming: UpcomingTrip, route: Route, now: Instant) { let formatted = if let formattedUpcomingTrip = RealtimePatterns.companion.formatUpcomingTrip( now: now, upcomingTrip: upcoming, @@ -49,17 +49,6 @@ struct TileData { headsign = upcoming.trip.headsign self.formatted = formatted self.upcoming = upcoming - - if let vehicleId = upcoming.prediction?.vehicleId, let stopSequence = upcoming.stopSequence { - navigationTarget = .tripDetails( - tripId: upcoming.trip.id, - vehicleId: vehicleId, - target: .init(stopId: stopId, stopSequence: stopSequence.intValue), - routeId: upcoming.trip.routeId, - directionId: upcoming.trip.directionId - ) - } else { - navigationTarget = nil - } + id = upcoming.trip.id } } diff --git a/iosApp/iosApp/Pages/StopDetails/TripDetailsView.swift b/iosApp/iosApp/Pages/StopDetails/TripDetailsView.swift index 5b647e41b..9353f1bc5 100644 --- a/iosApp/iosApp/Pages/StopDetails/TripDetailsView.swift +++ b/iosApp/iosApp/Pages/StopDetails/TripDetailsView.swift @@ -55,7 +55,7 @@ struct TripDetailsView: View { content .task { stopDetailsVM.handleTripFilterChange(tripFilter) } .onDisappear { - if stopDetailsVM.tripData?.tripFilter == tripFilter { + if stopDetailsVM.tripData?.tripFilter == tripFilter || tripFilter == nil { stopDetailsVM.clearTripDetails() } clearMapVehicle() @@ -85,11 +85,12 @@ struct TripDetailsView: View { } } } + let vehicle = stopDetailsVM.tripData?.vehicle if let tripFilter, + tripFilter.vehicleId != nil ? vehicle != nil : true, let tripData = stopDetailsVM.tripData, tripData.tripFilter == tripFilter, let global = stopDetailsVM.global, - let vehicle = stopDetailsVM.tripData?.vehicle, let stops = TripDetailsStopList.companion.fromPieces( tripId: tripFilter.tripId, directionId: tripData.trip.directionId, @@ -99,15 +100,10 @@ struct TripDetailsView: View { alertsData: nearbyVM.alerts, globalData: global ) { - let vehicleStop: Stop? = getParentFor(vehicle.stopId, stops: global.stops) - - let terminalStop: Stop? = getParentFor(tripData.trip.stopIds?.first, stops: global.stops) - let atTerminal = terminalStop != nil && terminalStop?.id == vehicleStop?.id - && vehicle.currentStatus == .stoppedAt - let terminalEntry = atTerminal ? stops.terminalStop : nil - let routeAccents = stopDetailsVM.getTripRouteAccents() - tripDetails(tripFilter.tripId, stops, vehicle, vehicleStop, terminalEntry, routeAccents) + let terminalStop = getParentFor(tripData.trip.stopIds?.first, stops: global.stops) + let vehicleStop = getParentFor(vehicle?.stopId, stops: global.stops) + tripDetails(tripFilter.tripId, stops, terminalStop, vehicle, vehicleStop, routeAccents) } else { loadingBody() } @@ -118,44 +114,60 @@ struct TripDetailsView: View { @ViewBuilder private func tripDetails( _ tripId: String, _ stops: TripDetailsStopList, + _ terminalStop: Stop?, _ vehicle: Vehicle?, _ vehicleStop: Stop?, - _ terminalEntry: TripDetailsStopList.Entry?, _ routeAccents: TripRouteAccents ) -> some View { let vehicleShown = vehicle != nil && vehicleStop != nil VStack(spacing: 0) { - vehicleCardView(vehicle, vehicleStop, tripId, terminalEntry, routeAccents).zIndex(1) + tripHeaderCard(tripId, stops, terminalStop, vehicle, vehicleStop, routeAccents).zIndex(1) TripStops( targetId: stopId, stops: stops, stopSequence: tripFilter?.stopSequence?.intValue, - vehicleShown: vehicleShown, now: now, onTapLink: onTapStop, routeAccents: routeAccents, global: stopDetailsVM.global ) - .padding(.top, vehicleShown ? -56 : 0) + .padding(.top, -56) } } @ViewBuilder - func vehicleCardView( + func tripHeaderCard( + _ tripId: String, + _ stops: TripDetailsStopList, + _ terminalStop: Stop?, _ vehicle: Vehicle?, _ vehicleStop: Stop?, - _ tripId: String, - _ terminalEntry: TripDetailsStopList.Entry?, _ routeAccents: TripRouteAccents ) -> some View { if let vehicle, let vehicleStop { - TripVehicleCard( - vehicle: vehicle, + let atTerminal = terminalStop != nil && terminalStop?.id == vehicleStop.id + && vehicle.currentStatus == .stoppedAt + let terminalEntry = atTerminal ? stops.startTerminalEntry : nil + + TripHeaderCard( + spec: .vehicle(vehicle, terminalEntry), stop: vehicleStop, tripId: tripId, targetId: stopId, - terminalEntry: terminalEntry, routeAccents: routeAccents, + onTap: nil, + now: now + ) + } else if let terminalStop, let terminalEntry = stops.startTerminalEntry { + TripHeaderCard( + spec: .scheduled(terminalEntry), + stop: terminalStop, + tripId: tripId, + targetId: stopId, + routeAccents: routeAccents, + onTap: routeAccents.type != .ferry ? { + stopDetailsVM.explainer = .init(type: .noPrediction, routeAccents: routeAccents) + } : nil, now: now ) } @@ -166,9 +178,9 @@ struct TripDetailsView: View { tripDetails( "", placeholderInfo.stops, + nil, placeholderInfo.vehicle, placeholderInfo.vehicleStop, - nil, TripRouteAccents() ).loadingPlaceholder() } diff --git a/iosApp/iosApp/Pages/StopDetails/TripVehicleCard.swift b/iosApp/iosApp/Pages/StopDetails/TripHeaderCard.swift similarity index 62% rename from iosApp/iosApp/Pages/StopDetails/TripVehicleCard.swift rename to iosApp/iosApp/Pages/StopDetails/TripHeaderCard.swift index 3122a6339..d60ed8b71 100644 --- a/iosApp/iosApp/Pages/StopDetails/TripVehicleCard.swift +++ b/iosApp/iosApp/Pages/StopDetails/TripHeaderCard.swift @@ -1,5 +1,5 @@ // -// TripVehicleCard.swift +// TripHeaderCard.swift // iosApp // // Created by esimon on 12/3/24. @@ -10,13 +10,18 @@ import Foundation import shared import SwiftUI -struct TripVehicleCard: View { - let vehicle: Vehicle +enum TripHeaderSpec { + case vehicle(Vehicle, TripDetailsStopList.Entry?) + case scheduled(TripDetailsStopList.Entry) +} + +struct TripHeaderCard: View { + let spec: TripHeaderSpec let stop: Stop let tripId: String let targetId: String - let terminalEntry: TripDetailsStopList.Entry? let routeAccents: TripRouteAccents + let onTap: (() -> Void)? let now: Date var body: some View { @@ -28,15 +33,14 @@ struct TripVehicleCard: View { ColoredRouteLine(routeAccents.color) }.padding(.leading, 46) HStack(spacing: 8) { - vehiclePuck + tripMarker description Spacer() - liveIndicator + tripTiming } .frame(maxWidth: .infinity, minHeight: 56, alignment: .leading) - .padding(.vertical, 8) + .padding([.trailing, .vertical], 16) .padding(.leading, 30) - .padding(.trailing, 16) } .background(Color.fill3) .foregroundStyle(Color.text) @@ -46,13 +50,23 @@ struct TripVehicleCard: View { .padding([.horizontal], 6) .fixedSize(horizontal: false, vertical: true) .dynamicTypeSize(...DynamicTypeSize.accessibility3) + .onTapGesture { if let onTap { onTap() } } + .accessibilityAddTraits(onTap != nil ? .isButton : []) } @ViewBuilder private var description: some View { + switch spec { + case let .vehicle(vehicle, stopEntry): vehicleDescription(vehicle, stopEntry) + case let .scheduled(stopEntry): scheduleDescription(stopEntry) + } + } + + @ViewBuilder + private func vehicleDescription(_ vehicle: Vehicle, _ stopEntry: TripDetailsStopList.Entry?) -> some View { if vehicle.tripId == tripId { VStack(alignment: .leading, spacing: 2) { - vehicleStatusDescription(vehicle.currentStatus) + vehicleStatusDescription(vehicle.currentStatus, stopEntry) .font(Typography.footnote) Text(stop.name) .font(Typography.headlineBold) @@ -61,7 +75,7 @@ struct TripVehicleCard: View { .accessibilityAddTraits(.isHeader) .accessibilityHeading(.h2) .accessibilityLabel(Text( - "\(routeAccents.type.typeText(isOnly: true)) \(vehicleStatusText(vehicle.currentStatus)) \(stop.name)", + "\(routeAccents.type.typeText(isOnly: true)) \(vehicleStatusText(vehicle.currentStatus, stopEntry)) \(stop.name)", comment: """ VoiceOver text for the vehicle status on the trip details page, ex '[train] [approaching] [Alewife]' or '[bus] [now at] [Harvard]' @@ -76,15 +90,48 @@ struct TripVehicleCard: View { } } + @ViewBuilder + private func scheduleDescription(_ stopEntry: TripDetailsStopList.Entry?) -> some View { + if let stopEntry { + VStack(alignment: .leading, spacing: 2) { + HStack { + Text("Scheduled to depart").font(Typography.footnote) + if onTap != nil { + Image(.faCircleInfo) + .resizable() + .frame(width: 16, height: 16) + .foregroundStyle(Color.text.opacity(0.5)) + .accessibilityHidden(true) + } + } + + Text(stopEntry.stop.name) + .font(Typography.headlineBold) + } + .accessibilityElement() + .accessibilityAddTraits(.isHeader) + .accessibilityHeading(.h2) + .accessibilityLabel(Text( + "\(routeAccents.type.typeText(isOnly: true)) scheduled to depart \(stopEntry.stop.name)", + comment: """ + VoiceOver text for the departure status on the trip details page, + ex '[train] scheduled to depart [Alewife]' or '[bus] scheduled to depart [Harvard]' + """ + )) + } + } + @ViewBuilder private func vehicleStatusDescription( - _ vehicleStatus: __Bridge__Vehicle_CurrentStatus + _ vehicleStatus: __Bridge__Vehicle_CurrentStatus, + _ stopEntry: TripDetailsStopList.Entry? ) -> some View { - Text(vehicleStatusText(vehicleStatus)) + Text(vehicleStatusText(vehicleStatus, stopEntry)) } private func vehicleStatusText( - _ vehicleStatus: __Bridge__Vehicle_CurrentStatus + _ vehicleStatus: __Bridge__Vehicle_CurrentStatus, + _ stopEntry: TripDetailsStopList.Entry? ) -> String { switch vehicleStatus { case .incomingAt: NSLocalizedString( @@ -95,7 +142,7 @@ struct TripVehicleCard: View { "Next stop", comment: "Label for a vehicle's next stop. For example: Next stop Alewife" ) - case .stoppedAt: terminalEntry != nil ? NSLocalizedString( + case .stoppedAt: stopEntry != nil ? NSLocalizedString( "Waiting to depart", comment: """ Label for a vehicle stopped at a terminal station waiting to start a trip. @@ -108,7 +155,16 @@ struct TripVehicleCard: View { } } - private var vehiclePuck: some View { + @ViewBuilder + private var tripMarker: some View { + switch spec { + case let .vehicle(vehicle, _): vehiclePuck(vehicle) + case .scheduled: StopDot(routeAccents: routeAccents, targeted: targetId == stop.id).frame(width: 36, height: 36) + } + } + + @ViewBuilder + private func vehiclePuck(_ vehicle: Vehicle) -> some View { ZStack { Group { Image(.vehicleHalo) @@ -139,22 +195,13 @@ struct TripVehicleCard: View { .padding([.bottom], 6) } - var liveIndicator: some View { + var tripTiming: some View { VStack { - HStack { - Image(.liveData) - .resizable() - .frame(width: 16, height: 16) - Text("Live", comment: "Indicates that data is being updated in real-time") - .font(Typography.footnote) + switch spec { + case .vehicle: liveIndicator + case .scheduled: EmptyView() } - .opacity(0.6) - .accessibilityElement() - .accessibilityAddTraits(.isHeader) - .accessibilityLabel(Text( - "Real-time arrivals updating live", - comment: "VoiceOver label for real-time indicator icon" - )) + if let upcomingTripViewState { UpcomingTripView( prediction: upcomingTripViewState, @@ -165,12 +212,33 @@ struct TripVehicleCard: View { } } + var liveIndicator: some View { + HStack { + Image(.liveData) + .resizable() + .frame(width: 16, height: 16) + Text("Live", comment: "Indicates that data is being updated in real-time") + .font(Typography.footnote) + } + .opacity(0.6) + .accessibilityElement() + .accessibilityAddTraits(.isHeader) + .accessibilityLabel(Text( + "Real-time arrivals updating live", + comment: "VoiceOver label for real-time indicator icon" + )) + } + var upcomingTripViewState: UpcomingTripView.State? { - guard let terminalEntry else { return nil } - if let alert = terminalEntry.alert { + let entry = switch spec { + case let .vehicle(_, stopEntry): stopEntry + case let .scheduled(stopEntry): stopEntry + } + guard let entry else { return nil } + if let alert = entry.alert { return .noService(alert.effect) } else { - let formatted = terminalEntry.format(now: now.toKotlinInstant(), routeType: routeAccents.type) + let formatted = entry.format(now: now.toKotlinInstant(), routeType: routeAccents.type) return switch onEnum(of: formatted) { case .hidden, .skipped: nil default: .some(formatted) @@ -211,13 +279,13 @@ struct TripVehicleCard_Previews: PreviewProvider { } List { - TripVehicleCard( - vehicle: vehicle, + TripHeaderCard( + spec: .vehicle(vehicle, nil), stop: stop, tripId: trip.id, targetId: "", - terminalEntry: nil, routeAccents: TripRouteAccents(route: red), + onTap: nil, now: Date.now ) } diff --git a/iosApp/iosApp/Pages/StopDetails/TripStopRow.swift b/iosApp/iosApp/Pages/StopDetails/TripStopRow.swift index 81087a9f8..ea29d4c39 100644 --- a/iosApp/iosApp/Pages/StopDetails/TripStopRow.swift +++ b/iosApp/iosApp/Pages/StopDetails/TripStopRow.swift @@ -87,15 +87,7 @@ struct TripStopRow: View { ColoredRouteLine(Color.clear) } } - Circle() - .strokeBorder(Color.stopDotHalo, lineWidth: 1) - .background(Circle().fill(routeAccents.color)) - .frame(width: 14, height: 14) - .overlay { - if targeted { - Image(.stopPinIndicator).padding(.bottom, 32) - } - } + StopDot(routeAccents: routeAccents, targeted: targeted) } .padding(.leading, 37) .padding(.trailing, 8) diff --git a/iosApp/iosApp/Pages/StopDetails/TripStops.swift b/iosApp/iosApp/Pages/StopDetails/TripStops.swift index 39e958786..be691bce3 100644 --- a/iosApp/iosApp/Pages/StopDetails/TripStops.swift +++ b/iosApp/iosApp/Pages/StopDetails/TripStops.swift @@ -13,7 +13,6 @@ struct TripStops: View { let targetId: String let stops: TripDetailsStopList let stopSequence: Int? - let vehicleShown: Bool let now: Date let onTapLink: (SheetNavigationStackEntry, TripDetailsStopList.Entry, String?) -> Void let routeAccents: TripRouteAccents @@ -30,7 +29,6 @@ struct TripStops: View { targetId: String, stops: TripDetailsStopList, stopSequence: Int?, - vehicleShown: Bool = true, now: Date, onTapLink: @escaping (SheetNavigationStackEntry, TripDetailsStopList.Entry, String?) -> Void, routeAccents: TripRouteAccents, @@ -39,7 +37,6 @@ struct TripStops: View { self.targetId = targetId self.stops = stops self.stopSequence = stopSequence - self.vehicleShown = vehicleShown self.now = now self.onTapLink = onTapLink self.routeAccents = routeAccents @@ -48,7 +45,8 @@ struct TripStops: View { stops.splitForTarget( targetStopId: targetId, targetStopSequence: Int32(stopSequence), - globalData: global + globalData: global, + combinedStopDetails: true ) } else { nil } } @@ -135,22 +133,26 @@ struct TripStops: View { """ )) } - TripStopRow( - stop: target, - now: now.toKotlinInstant(), - onTapLink: onTapLink, - routeAccents: routeAccents, - targeted: true - ) - .background(Color.fill3) + if target != stops.startTerminalEntry, target.vehicle != nil { + // If the target is the first stop and there's no vehicle, + // it's already displayed in the trip header + TripStopRow( + stop: target, + now: now.toKotlinInstant(), + onTapLink: onTapLink, + routeAccents: routeAccents, + targeted: true + ) + .background(Color.fill3) + } stopList(list: splitStops.followingStops) } else { stopList(list: stops.stops) } } - .padding(.top, vehicleShown ? 56 : 0) + .padding(.top, 56) .overlay(alignment: .topLeading) { - ColoredRouteLine(routeAccents.color).frame(maxHeight: vehicleShown ? 56 : 0).padding(.leading, 42) + ColoredRouteLine(routeAccents.color).frame(maxHeight: 56).padding(.leading, 42) } .background(Color.fill2) .clipShape(RoundedRectangle(cornerRadius: 8)) diff --git a/iosApp/iosApp/Pages/TripDetails/TripDetailsPage.swift b/iosApp/iosApp/Pages/TripDetails/TripDetailsPage.swift index 0321fece4..24f353743 100644 --- a/iosApp/iosApp/Pages/TripDetails/TripDetailsPage.swift +++ b/iosApp/iosApp/Pages/TripDetails/TripDetailsPage.swift @@ -95,7 +95,8 @@ struct TripDetailsPage: View { if let target, let stopSequence = target.stopSequence, let splitStops = stops.splitForTarget( targetStopId: target.stopId, targetStopSequence: Int32(stopSequence), - globalData: globalResponse + globalData: globalResponse, + combinedStopDetails: false ) { TripDetailsStopListSplitView( splitStops: splitStops, diff --git a/iosApp/iosApp/Pages/TripDetails/TripDetailsStopListSplitView.swift b/iosApp/iosApp/Pages/TripDetails/TripDetailsStopListSplitView.swift index 4879cf912..de3edec2b 100644 --- a/iosApp/iosApp/Pages/TripDetails/TripDetailsStopListSplitView.swift +++ b/iosApp/iosApp/Pages/TripDetails/TripDetailsStopListSplitView.swift @@ -90,6 +90,7 @@ struct TripDetailsStopListSplitView: View { return TripDetailsStopListSplitView( splitStops: .init( + firstStop: nil, collapsedStops: [entry(stop1, 10, pred1)], targetStop: entry(stop2, 20, pred2), followingStops: [entry(stop3, 30, pred3)] diff --git a/iosApp/iosApp/ViewModels/StopDetailsViewModel.swift b/iosApp/iosApp/ViewModels/StopDetailsViewModel.swift index 05d929656..4cf175630 100644 --- a/iosApp/iosApp/ViewModels/StopDetailsViewModel.swift +++ b/iosApp/iosApp/ViewModels/StopDetailsViewModel.swift @@ -53,6 +53,7 @@ class StopDetailsViewModel: ObservableObject { @Published var stopData: StopData? @Published var tripData: TripData? + @Published var explainer: Explainer? let errorBannerRepository: IErrorBannerStateRepository let globalRepository: IGlobalRepository diff --git a/iosApp/iosAppTests/Pages/StopDetails/ExplainerPageTests.swift b/iosApp/iosAppTests/Pages/StopDetails/ExplainerPageTests.swift new file mode 100644 index 000000000..a538bc6e9 --- /dev/null +++ b/iosApp/iosAppTests/Pages/StopDetails/ExplainerPageTests.swift @@ -0,0 +1,41 @@ +// +// ExplainerPageTests.swift +// iosAppTests +// +// Created by esimon on 12/18/24. +// Copyright © 2024 MBTA. All rights reserved. +// + +@testable import iosApp +import shared +import SwiftUI +import ViewInspector +import XCTest + +final class ExplainerPageTests: XCTestCase { + override func setUp() { + executionTimeAllowance = 60 + } + + func testNoPrediction() throws { + let sut = ExplainerPage(explainer: .init(type: .noPrediction, routeAccents: .init()), onClose: {}) + XCTAssertNotNil(try sut.inspect().find(text: "Prediction not available yet")) + } + + func testRouteType() throws { + let sut = ExplainerPage(explainer: .init(type: .noPrediction, routeAccents: .init(type: .bus)), onClose: {}) + XCTAssertNotNil(try sut.inspect().find(ViewType.Image.self, where: { image in + try image.actualImage().name() == "mode-bus" + })) + } + + func testClose() throws { + let closeExpectation = expectation(description: "close button callback") + let sut = ExplainerPage( + explainer: .init(type: .noPrediction, routeAccents: .init()), + onClose: { closeExpectation.fulfill() } + ) + try sut.inspect().find(ActionButton.self).button().tap() + wait(for: [closeExpectation], timeout: 1) + } +} diff --git a/iosApp/iosAppTests/Pages/StopDetails/TripDetailsViewTests.swift b/iosApp/iosAppTests/Pages/StopDetails/TripDetailsViewTests.swift index 237a2f32a..a75d05e6f 100644 --- a/iosApp/iosAppTests/Pages/StopDetails/TripDetailsViewTests.swift +++ b/iosApp/iosAppTests/Pages/StopDetails/TripDetailsViewTests.swift @@ -81,8 +81,68 @@ final class TripDetailsViewTests: XCTestCase { stopDetailsVM: stopDetailsVM ) - XCTAssertNotNil(try sut.inspect().find(TripVehicleCard.self).find(text: "Next stop")) - XCTAssertNotNil(try sut.inspect().find(TripVehicleCard.self).find(text: vehicleStop.name)) + XCTAssertNotNil(try sut.inspect().find(TripHeaderCard.self).find(text: "Next stop")) + XCTAssertNotNil(try sut.inspect().find(TripHeaderCard.self).find(text: vehicleStop.name)) + } + + func testDisplaysScheduleCard() throws { + let now = Date.now + let objects = ObjectCollectionBuilder() + let route = objects.route { _ in } + let pattern = objects.routePattern(route: route) { _ in } + + let firstStop = objects.stop { _ in } + let targetStop = objects.stop { _ in } + let trip = objects.trip(routePattern: pattern) { trip in + trip.stopIds = [firstStop.id, targetStop.id] + } + + let schedule = objects.schedule { schedule in + schedule.routeId = route.id + schedule.stopId = targetStop.id + schedule.trip = trip + } + + let nearbyVM = NearbyViewModel() + nearbyVM.alerts = .init(objects: objects) + + let stopDetailsVM = StopDetailsViewModel( + globalRepository: MockGlobalRepository(response: .init(objects: objects)), + predictionsRepository: MockPredictionsRepository(connectV2Response: .companion.empty), + tripPredictionsRepository: MockTripPredictionsRepository(), + tripRepository: MockTripRepository( + tripSchedulesResponse: TripSchedulesResponse.Schedules(schedules: [schedule]), + tripResponse: .init(trip: trip) + ) + ) + stopDetailsVM.global = .init(objects: objects) + stopDetailsVM.pinnedRoutes = .init() + stopDetailsVM.stopData = .init( + stopId: targetStop.id, + schedules: .init(objects: objects), + predictionsByStop: .init(objects: objects), + predictionsLoaded: true + ) + stopDetailsVM.tripData = TripData( + tripFilter: .init(tripId: trip.id, vehicleId: nil, stopSequence: 0, selectionLock: false), + trip: trip, + tripSchedules: TripSchedulesResponse.Schedules(schedules: [schedule]), + tripPredictions: .init(objects: objects), + vehicle: nil + ) + + let sut = TripDetailsView( + tripFilter: stopDetailsVM.tripData?.tripFilter, + stopId: targetStop.id, + now: now, + errorBannerVM: .init(), + nearbyVM: nearbyVM, + mapVM: .init(), + stopDetailsVM: stopDetailsVM + ) + + XCTAssertNotNil(try sut.inspect().find(TripHeaderCard.self).find(text: "Scheduled to depart")) + XCTAssertNotNil(try sut.inspect().find(TripHeaderCard.self).find(text: targetStop.name)) } func testDisplaysStopList() throws { diff --git a/iosApp/iosAppTests/Pages/StopDetails/TripVehicleCardTests.swift b/iosApp/iosAppTests/Pages/StopDetails/TripHeaderCardTests.swift similarity index 59% rename from iosApp/iosAppTests/Pages/StopDetails/TripVehicleCardTests.swift rename to iosApp/iosAppTests/Pages/StopDetails/TripHeaderCardTests.swift index 9bad09194..613e31b8a 100644 --- a/iosApp/iosAppTests/Pages/StopDetails/TripVehicleCardTests.swift +++ b/iosApp/iosAppTests/Pages/StopDetails/TripHeaderCardTests.swift @@ -1,5 +1,5 @@ // -// TripVehicleCardTests.swift +// TripHeaderCardTests.swift // iosAppTests // // Created by esimon on 12/11/24. @@ -12,7 +12,7 @@ import SwiftUI import ViewInspector import XCTest -final class TripVehicleCardTests: XCTestCase { +final class TripHeaderCardTests: XCTestCase { override func setUp() { executionTimeAllowance = 60 } @@ -25,13 +25,13 @@ final class TripVehicleCardTests: XCTestCase { vehicle.currentStatus = .inTransitTo vehicle.tripId = "" } - let sut = TripVehicleCard( - vehicle: vehicle, + let sut = TripHeaderCard( + spec: .vehicle(vehicle, nil), stop: stop, tripId: "", targetId: "", - terminalEntry: nil, routeAccents: .init(), + onTap: nil, now: now ) @@ -47,13 +47,13 @@ final class TripVehicleCardTests: XCTestCase { vehicle.currentStatus = .inTransitTo vehicle.tripId = "" } - let inTransitSut = TripVehicleCard( - vehicle: inTransitVehicle, + let inTransitSut = TripHeaderCard( + spec: .vehicle(inTransitVehicle, nil), stop: stop, tripId: "", targetId: "", - terminalEntry: nil, routeAccents: .init(), + onTap: nil, now: now ) try XCTAssertNotNil(inTransitSut.inspect().find(text: "Next stop")) @@ -62,13 +62,13 @@ final class TripVehicleCardTests: XCTestCase { vehicle.currentStatus = .incomingAt vehicle.tripId = "" } - let incomingSut = TripVehicleCard( - vehicle: incomingVehicle, + let incomingSut = TripHeaderCard( + spec: .vehicle(incomingVehicle, nil), stop: stop, tripId: "", targetId: "", - terminalEntry: nil, routeAccents: .init(), + onTap: nil, now: now ) try XCTAssertNotNil(incomingSut.inspect().find(text: "Approaching")) @@ -77,13 +77,13 @@ final class TripVehicleCardTests: XCTestCase { vehicle.currentStatus = .stoppedAt vehicle.tripId = "" } - let stoppedSut = TripVehicleCard( - vehicle: stoppedVehicle, + let stoppedSut = TripHeaderCard( + spec: .vehicle(stoppedVehicle, nil), stop: stop, tripId: "", targetId: "", - terminalEntry: nil, routeAccents: .init(), + onTap: nil, now: now ) try XCTAssertNotNil(stoppedSut.inspect().find(text: "Now at")) @@ -98,13 +98,13 @@ final class TripVehicleCardTests: XCTestCase { vehicle.currentStatus = .inTransitTo vehicle.tripId = "different" } - let sut = TripVehicleCard( - vehicle: vehicle, + let sut = TripHeaderCard( + spec: .vehicle(vehicle, nil), stop: stop, tripId: "selected", targetId: "", - terminalEntry: nil, routeAccents: .init(), + onTap: nil, now: now ) try XCTAssertNotNil(sut.inspect().find(text: "This vehicle is completing another trip")) @@ -121,13 +121,13 @@ final class TripVehicleCardTests: XCTestCase { vehicle.tripId = "" vehicle.stopId = stop.id } - let targeted = TripVehicleCard( - vehicle: vehicle, + let targeted = TripHeaderCard( + spec: .vehicle(vehicle, nil), stop: stop, tripId: "", targetId: stop.id, - terminalEntry: nil, routeAccents: .init(), + onTap: nil, now: now ) @@ -135,13 +135,13 @@ final class TripVehicleCardTests: XCTestCase { try image.actualImage().name() == "stop-pin-indicator" })) - let notTargeted = TripVehicleCard( - vehicle: vehicle, + let notTargeted = TripHeaderCard( + spec: .vehicle(vehicle, nil), stop: stop, tripId: "", targetId: "", - terminalEntry: nil, routeAccents: .init(), + onTap: nil, now: now ) @@ -165,12 +165,8 @@ final class TripVehicleCardTests: XCTestCase { prediction.departureTime = now.addingTimeInterval(5 * 60).toKotlinInstant() } - let sut = TripVehicleCard( - vehicle: vehicle, - stop: stop, - tripId: "", - targetId: stop.id, - terminalEntry: .init( + let sut = TripHeaderCard( + spec: .vehicle(vehicle, .init( stop: stop, stopSequence: 0, alert: nil, @@ -178,8 +174,12 @@ final class TripVehicleCardTests: XCTestCase { prediction: prediction, vehicle: vehicle, routes: [] - ), + )), + stop: stop, + tripId: "", + targetId: stop.id, routeAccents: .init(), + onTap: nil, now: now ) @@ -189,4 +189,75 @@ final class TripVehicleCardTests: XCTestCase { })) try XCTAssertNotNil(sut.inspect().find(UpcomingTripView.self)) } + + func testScheduled() throws { + let now = Date.now + let objects = ObjectCollectionBuilder() + let stop = objects.stop { _ in } + + let schedule = objects.schedule { schedule in + schedule.departureTime = now.addingTimeInterval(5 * 60).toKotlinInstant() + } + + let sut = TripHeaderCard( + spec: .scheduled(.init( + stop: stop, + stopSequence: 0, + alert: nil, + schedule: schedule, + prediction: nil, + vehicle: nil, + routes: [] + )), + stop: stop, + tripId: "", + targetId: stop.id, + routeAccents: .init(), + onTap: nil, + now: now + ) + + try XCTAssertNotNil(sut.inspect().find(text: "Scheduled to depart")) + try XCTAssertNotNil(sut.inspect().find(text: stop.name)) + try XCTAssertNotNil(sut.inspect().find(UpcomingTripView.self)) + try XCTAssertThrowsError(sut.inspect().find(ViewType.Image.self, where: { image in + try image.actualImage().name() == "fa-circle-info" + })) + } + + func testScheduledTap() throws { + let now = Date.now + let objects = ObjectCollectionBuilder() + let stop = objects.stop { _ in } + + let schedule = objects.schedule { schedule in + schedule.departureTime = now.addingTimeInterval(5 * 60).toKotlinInstant() + } + + let tapExpectation = expectation(description: "card tapped") + + let sut = TripHeaderCard( + spec: .scheduled(.init( + stop: stop, + stopSequence: 0, + alert: nil, + schedule: schedule, + prediction: nil, + vehicle: nil, + routes: [] + )), + stop: stop, + tripId: "", + targetId: stop.id, + routeAccents: .init(), + onTap: { tapExpectation.fulfill() }, + now: now + ) + + try sut.inspect().find(ViewType.ZStack.self).callOnTapGesture() + wait(for: [tapExpectation], timeout: 1) + try XCTAssertNotNil(sut.inspect().find(ViewType.Image.self, where: { image in + try image.actualImage().name() == "fa-circle-info" + })) + } } diff --git a/iosApp/iosAppTests/Pages/StopDetails/TripStopsTests.swift b/iosApp/iosAppTests/Pages/StopDetails/TripStopsTests.swift index 80d10bb9e..3912db36c 100644 --- a/iosApp/iosAppTests/Pages/StopDetails/TripStopsTests.swift +++ b/iosApp/iosAppTests/Pages/StopDetails/TripStopsTests.swift @@ -97,7 +97,6 @@ final class TripStopsTests: XCTestCase { targetId: stop3Target.id, stops: stops, stopSequence: 1, - vehicleShown: true, now: now, onTapLink: { _, _, _ in }, routeAccents: TripRouteAccents(route: route), @@ -177,7 +176,6 @@ final class TripStopsTests: XCTestCase { targetId: stopTarget.id, stops: stops, stopSequence: 0, - vehicleShown: true, now: now, onTapLink: { _, _, _ in }, routeAccents: TripRouteAccents(route: route), diff --git a/iosApp/iosAppTests/Pages/TripDetails/TripDetailsPageTests.swift b/iosApp/iosAppTests/Pages/TripDetails/TripDetailsPageTests.swift index 981cc244d..013f194ec 100644 --- a/iosApp/iosAppTests/Pages/TripDetails/TripDetailsPageTests.swift +++ b/iosApp/iosAppTests/Pages/TripDetails/TripDetailsPageTests.swift @@ -154,6 +154,8 @@ final class TripDetailsPageTests: XCTestCase { let stop2 = objects.stop { stop in stop.name = "Elsewhere" } + let stop3 = objects.stop { _ in } + let stop4 = objects.stop { _ in } let trip = objects.trip { _ in } @@ -161,7 +163,7 @@ final class TripDetailsPageTests: XCTestCase { let tripRepository = FakeTripRepository( tripResponse: .init(trip: trip), - scheduleResponse: TripSchedulesResponse.StopIds(stopIds: [stop1.id, stop2.id]), + scheduleResponse: TripSchedulesResponse.StopIds(stopIds: [stop1.id, stop2.id, stop3.id, stop4.id]), onGetTripSchedules: { tripSchedulesLoaded.send() } ) @@ -176,7 +178,7 @@ final class TripDetailsPageTests: XCTestCase { tripId: tripId, vehicleId: vehicleId, routeId: trip.routeId, - target: .init(stopId: stop1.id, stopSequence: 998), + target: .init(stopId: stop3.id, stopSequence: 998), errorBannerVM: .init(), nearbyVM: nearbyVM, mapVM: .init(), diff --git a/iosApp/iosAppTests/Pages/TripDetails/TripDetailsStopListSplitViewTests.swift b/iosApp/iosAppTests/Pages/TripDetails/TripDetailsStopListSplitViewTests.swift index ac1a268f2..5f83778a3 100644 --- a/iosApp/iosAppTests/Pages/TripDetails/TripDetailsStopListSplitViewTests.swift +++ b/iosApp/iosAppTests/Pages/TripDetails/TripDetailsStopListSplitViewTests.swift @@ -36,6 +36,7 @@ final class TripDetailsStopListSplitViewTests: XCTestCase { func testNoAccordionIfFirstStop() throws { let sut = TripDetailsStopListSplitView( splitStops: .init( + firstStop: nil, collapsedStops: [], targetStop: entry(stop1, 10, pred1), followingStops: [entry(stop2, 20, pred2), entry(stop3, 30, pred3)] @@ -51,6 +52,7 @@ final class TripDetailsStopListSplitViewTests: XCTestCase { func testCollapsedStopsInAccordion() throws { let sut = TripDetailsStopListSplitView( splitStops: .init( + firstStop: nil, collapsedStops: [entry(stop1, 10, pred1), entry(stop2, 20, pred2)], targetStop: entry(stop3, 30, pred3), followingStops: [] diff --git a/iosApp/iosAppTests/ViewModels/StopDetailsViewModelTests.swift b/iosApp/iosAppTests/ViewModels/StopDetailsViewModelTests.swift index 4b3c14c20..4e841e652 100644 --- a/iosApp/iosAppTests/ViewModels/StopDetailsViewModelTests.swift +++ b/iosApp/iosAppTests/ViewModels/StopDetailsViewModelTests.swift @@ -90,7 +90,8 @@ final class StopDetailsViewModelTests: XCTestCase { ) stopDetailsVM.joinStopPredictions(stop.id) - await fulfillment(of: [connectExp], timeout: 2) + await fulfillment(of: [connectExp], timeout: 1) + try await Task.sleep(for: .seconds(1)) XCTAssertEqual(stopDetailsVM.stopData?.predictionsByStop, predictions) stopDetailsVM.leaveStopPredictions() await fulfillment(of: [disconnectExp], timeout: 1) diff --git a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/StopDetailsDepartures.kt b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/StopDetailsDepartures.kt index 5cd9e9fac..8978828e4 100644 --- a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/StopDetailsDepartures.kt +++ b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/StopDetailsDepartures.kt @@ -49,6 +49,7 @@ data class StopDetailsDepartures(val routes: List) { TripInstantDisplay.Context.StopDetailsFiltered ) ?: return@mapNotNull null + TripAndFormat(it, format) } return trips diff --git a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/TripDetailsStopList.kt b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/TripDetailsStopList.kt index bdb318ca7..9361f055f 100644 --- a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/TripDetailsStopList.kt +++ b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/TripDetailsStopList.kt @@ -9,7 +9,7 @@ import kotlinx.datetime.Instant data class TripDetailsStopList @DefaultArgumentInterop.Enabled -constructor(val stops: List, val terminalStop: Entry? = null) { +constructor(val stops: List, val startTerminalEntry: Entry? = null) { data class Entry( val stop: Stop, val stopSequence: Int, @@ -39,7 +39,9 @@ constructor(val stops: List, val terminalStop: Entry? = null) { fun splitForTarget( targetStopId: String, targetStopSequence: Int, - globalData: GlobalResponse + globalData: GlobalResponse, + // TODO: Remove this once the feature flag is removed + combinedStopDetails: Boolean = false ): TargetSplit? { var targetStopIndex = stops.indexOfFirst { @@ -54,15 +56,26 @@ constructor(val stops: List, val terminalStop: Entry? = null) { return null } - val collapsedStops = stops.subList(fromIndex = 0, toIndex = targetStopIndex) + var firstStop: Entry? = null + var collapsedStops = stops.subList(fromIndex = 0, toIndex = targetStopIndex) + val firstCollapsed = collapsedStops.firstOrNull() + if ( + combinedStopDetails && + firstCollapsed == startTerminalEntry && + startTerminalEntry?.vehicle == null + ) { + collapsedStops = collapsedStops.drop(1) + firstStop = firstCollapsed + } val targetStop = stops[targetStopIndex] val followingStops = stops.subList(fromIndex = targetStopIndex + 1, toIndex = stops.lastIndex + 1) - return TargetSplit(collapsedStops, targetStop, followingStops) + return TargetSplit(firstStop, collapsedStops, targetStop, followingStops) } data class TargetSplit( + val firstStop: Entry? = null, val collapsedStops: List, val targetStop: Entry, val followingStops: List @@ -190,6 +203,7 @@ constructor(val stops: List, val terminalStop: Entry? = null) { ) } + val startTerminalEntry = getEntry(sortedEntries.firstOrNull()?.value) return TripDetailsStopList( sortedEntries .dropWhile { @@ -206,7 +220,7 @@ constructor(val stops: List, val terminalStop: Entry? = null) { } } .mapNotNull { getEntry(it.value) }, - getEntry(sortedEntries.firstOrNull()?.value) + startTerminalEntry ) } diff --git a/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/model/TripDetailsStopListTest.kt b/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/model/TripDetailsStopListTest.kt index ee512bff6..8e91ea984 100644 --- a/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/model/TripDetailsStopListTest.kt +++ b/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/model/TripDetailsStopListTest.kt @@ -799,6 +799,26 @@ class TripDetailsStopListTest { ) } + @Test + fun `splitForTarget removes first stop from collapsed when no vehicle exists`() = test { + val list = stopListOf( + entry("A", 10), + entry("B", 20), + entry("C", 30), + entry("D", 40) + ) + + assertEquals( + TripDetailsStopList.TargetSplit( + firstStop = entry("A", 10), + collapsedStops = listOf(entry("B", 20)), + targetStop = entry("C", 30), + followingStops = listOf(entry("D", 40)), + ), + list.splitForTarget("C", 30, globalData(), true) + ) + } + @Test fun `splitForTarget returns null if target not found`() = test { val list = stopListOf(entry("A", 10), entry("B", 20), entry("C", 30))