From 46a8c88b8314a3e56f0dac9ea3d65329a57e3f36 Mon Sep 17 00:00:00 2001 From: Emma Simon Date: Wed, 18 Dec 2024 17:43:08 -0500 Subject: [PATCH 1/7] feat(iOS): Display schedules on stop details --- iosApp/iosApp.xcodeproj/project.pbxproj | 16 ++- iosApp/iosApp/ContentView.swift | 15 +- .../Pages/StopDetails/ExplainerPage.swift | 80 +++++++++++ .../StopDetailsFilteredDepartureDetails.swift | 46 +++--- .../StopDetails/StopDetailsFilteredView.swift | 9 +- .../Pages/StopDetails/StopDetailsPage.swift | 13 ++ iosApp/iosApp/Pages/StopDetails/StopDot.swift | 26 ++++ .../iosApp/Pages/StopDetails/TileData.swift | 25 +--- .../Pages/StopDetails/TripDetailsView.swift | 54 ++++--- ...VehicleCard.swift => TripHeaderCard.swift} | 135 +++++++++++++----- .../Pages/StopDetails/TripStopRow.swift | 10 +- .../iosApp/Pages/StopDetails/TripStops.swift | 7 +- .../ViewModels/StopDetailsViewModel.swift | 1 + .../StopDetails/TripDetailsViewTests.swift | 4 +- .../StopDetails/TripVehicleCardTests.swift | 16 +-- .../tid/mbta_app/model/LoadingPlaceholders.kt | 2 +- .../mbta_app/model/StopDetailsDepartures.kt | 1 + .../tid/mbta_app/model/TripDetailsStopList.kt | 8 +- 18 files changed, 327 insertions(+), 141 deletions(-) create mode 100644 iosApp/iosApp/Pages/StopDetails/ExplainerPage.swift create mode 100644 iosApp/iosApp/Pages/StopDetails/StopDot.swift rename iosApp/iosApp/Pages/StopDetails/{TripVehicleCard.swift => TripHeaderCard.swift} (63%) diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 7ba1ef015..a0cb1d47c 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 */; }; @@ -182,6 +182,8 @@ 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 +436,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 = ""; }; @@ -479,6 +481,8 @@ 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 = ""; }; @@ -1122,19 +1126,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 = ""; @@ -1547,7 +1553,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 +1623,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/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..a55b3bb67 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, @@ -149,7 +149,7 @@ struct StopDetailsFilteredDepartureDetails: View { ForEach(Array(statuses.enumerated()), id: \.offset) { index, row in VStack(spacing: 0) { OptionalNavigationLink( - value: row.navigationTarget, + value: nil, action: { entry in nearbyVM.pushNavEntry(entry) analytics.tappedDepartureRow( 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 63% rename from iosApp/iosApp/Pages/StopDetails/TripVehicleCard.swift rename to iosApp/iosApp/Pages/StopDetails/TripHeaderCard.swift index 3122a6339..8a519384a 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,22 @@ struct TripVehicleCard: View { .padding([.horizontal], 6) .fixedSize(horizontal: false, vertical: true) .dynamicTypeSize(...DynamicTypeSize.accessibility3) + .onTapGesture { if let onTap { onTap() } } } @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 +74,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 +89,46 @@ 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 routeAccents.type != .ferry { + Image(.faCircleInfo) + .resizable() + .frame(width: 16, height: 16) + .foregroundStyle(Color.text.opacity(0.5)) + } + } + 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 +139,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 +152,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 +192,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 +209,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 +276,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..e88c44ea3 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 @@ -148,9 +145,9 @@ struct TripStops: View { 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/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/TripDetailsViewTests.swift b/iosApp/iosAppTests/Pages/StopDetails/TripDetailsViewTests.swift index 237a2f32a..d935c878e 100644 --- a/iosApp/iosAppTests/Pages/StopDetails/TripDetailsViewTests.swift +++ b/iosApp/iosAppTests/Pages/StopDetails/TripDetailsViewTests.swift @@ -81,8 +81,8 @@ 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 testDisplaysStopList() throws { diff --git a/iosApp/iosAppTests/Pages/StopDetails/TripVehicleCardTests.swift b/iosApp/iosAppTests/Pages/StopDetails/TripVehicleCardTests.swift index 9bad09194..f892cc3b5 100644 --- a/iosApp/iosAppTests/Pages/StopDetails/TripVehicleCardTests.swift +++ b/iosApp/iosAppTests/Pages/StopDetails/TripVehicleCardTests.swift @@ -25,7 +25,7 @@ final class TripVehicleCardTests: XCTestCase { vehicle.currentStatus = .inTransitTo vehicle.tripId = "" } - let sut = TripVehicleCard( + let sut = TripHeaderCard( vehicle: vehicle, stop: stop, tripId: "", @@ -47,7 +47,7 @@ final class TripVehicleCardTests: XCTestCase { vehicle.currentStatus = .inTransitTo vehicle.tripId = "" } - let inTransitSut = TripVehicleCard( + let inTransitSut = TripHeaderCard( vehicle: inTransitVehicle, stop: stop, tripId: "", @@ -62,7 +62,7 @@ final class TripVehicleCardTests: XCTestCase { vehicle.currentStatus = .incomingAt vehicle.tripId = "" } - let incomingSut = TripVehicleCard( + let incomingSut = TripHeaderCard( vehicle: incomingVehicle, stop: stop, tripId: "", @@ -77,7 +77,7 @@ final class TripVehicleCardTests: XCTestCase { vehicle.currentStatus = .stoppedAt vehicle.tripId = "" } - let stoppedSut = TripVehicleCard( + let stoppedSut = TripHeaderCard( vehicle: stoppedVehicle, stop: stop, tripId: "", @@ -98,7 +98,7 @@ final class TripVehicleCardTests: XCTestCase { vehicle.currentStatus = .inTransitTo vehicle.tripId = "different" } - let sut = TripVehicleCard( + let sut = TripHeaderCard( vehicle: vehicle, stop: stop, tripId: "selected", @@ -121,7 +121,7 @@ final class TripVehicleCardTests: XCTestCase { vehicle.tripId = "" vehicle.stopId = stop.id } - let targeted = TripVehicleCard( + let targeted = TripHeaderCard( vehicle: vehicle, stop: stop, tripId: "", @@ -135,7 +135,7 @@ final class TripVehicleCardTests: XCTestCase { try image.actualImage().name() == "stop-pin-indicator" })) - let notTargeted = TripVehicleCard( + let notTargeted = TripHeaderCard( vehicle: vehicle, stop: stop, tripId: "", @@ -165,7 +165,7 @@ final class TripVehicleCardTests: XCTestCase { prediction.departureTime = now.addingTimeInterval(5 * 60).toKotlinInstant() } - let sut = TripVehicleCard( + let sut = TripHeaderCard( vehicle: vehicle, stop: stop, tripId: "", diff --git a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/LoadingPlaceholders.kt b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/LoadingPlaceholders.kt index 4b2853bbb..a9f30d512 100644 --- a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/LoadingPlaceholders.kt +++ b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/LoadingPlaceholders.kt @@ -123,7 +123,7 @@ object LoadingPlaceholders { val stop = objects.stop { name = "Loading" } val prediction = objects.prediction { - this.trip = trip + this.trip = objects.trip(routePattern) this.stopId = stop.id this.vehicleId = vehicle.id departureTime = Clock.System.now() + sequence.minutes 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..0f3125d8f 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, @@ -190,6 +190,7 @@ constructor(val stops: List, val terminalStop: Entry? = null) { ) } + val startTerminalEntry = getEntry(sortedEntries.firstOrNull()?.value) return TripDetailsStopList( sortedEntries .dropWhile { @@ -205,8 +206,9 @@ constructor(val stops: List, val terminalStop: Entry? = null) { vehicle.currentStatus == Vehicle.CurrentStatus.StoppedAt) } } - .mapNotNull { getEntry(it.value) }, - getEntry(sortedEntries.firstOrNull()?.value) + .mapNotNull { getEntry(it.value) } + .dropWhile { it == startTerminalEntry && it.vehicle == null }, + startTerminalEntry ) } From e10bcbdd5c6df80943abeeb11b5f400944a4c963 Mon Sep 17 00:00:00 2001 From: Emma Simon Date: Wed, 18 Dec 2024 18:44:09 -0500 Subject: [PATCH 2/7] i18n: Add machine translations for new strings --- iosApp/iosApp/Localizable.xcstrings | 209 ++++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) diff --git a/iosApp/iosApp/Localizable.xcstrings b/iosApp/iosApp/Localizable.xcstrings index b19b06d4e..6141b68ef 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" : { @@ -5720,6 +5808,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" : { @@ -8762,6 +8931,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" : { From 7e2adcfa7062dd76dd77814b088fcdf0f27cd91d Mon Sep 17 00:00:00 2001 From: Emma Simon Date: Wed, 18 Dec 2024 19:25:07 -0500 Subject: [PATCH 3/7] test: Get existing tests working --- iosApp/iosApp.xcodeproj/project.pbxproj | 12 +- .../Pages/StopDetails/TripHeaderCard.swift | 5 +- .../StopDetails/ExplainerPageTests.swift | 41 +++++++ ...dTests.swift => TripHeaderCardTests.swift} | 109 ++++++++++++++---- .../Pages/StopDetails/TripStopsTests.swift | 2 - .../TripDetails/TripDetailsPageTests.swift | 6 +- .../StopDetailsViewModelTests.swift | 3 +- 7 files changed, 146 insertions(+), 32 deletions(-) create mode 100644 iosApp/iosAppTests/Pages/StopDetails/ExplainerPageTests.swift rename iosApp/iosAppTests/Pages/StopDetails/{TripVehicleCardTests.swift => TripHeaderCardTests.swift} (66%) diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index a0cb1d47c..ff08981da 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -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,7 +176,7 @@ 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 */; }; @@ -456,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 = ""; }; @@ -474,7 +476,7 @@ 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 = ""; }; @@ -958,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 = ""; @@ -1485,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 */, @@ -1498,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 */, diff --git a/iosApp/iosApp/Pages/StopDetails/TripHeaderCard.swift b/iosApp/iosApp/Pages/StopDetails/TripHeaderCard.swift index 8a519384a..d60ed8b71 100644 --- a/iosApp/iosApp/Pages/StopDetails/TripHeaderCard.swift +++ b/iosApp/iosApp/Pages/StopDetails/TripHeaderCard.swift @@ -51,6 +51,7 @@ struct TripHeaderCard: View { .fixedSize(horizontal: false, vertical: true) .dynamicTypeSize(...DynamicTypeSize.accessibility3) .onTapGesture { if let onTap { onTap() } } + .accessibilityAddTraits(onTap != nil ? .isButton : []) } @ViewBuilder @@ -95,13 +96,15 @@ struct TripHeaderCard: View { VStack(alignment: .leading, spacing: 2) { HStack { Text("Scheduled to depart").font(Typography.footnote) - if routeAccents.type != .ferry { + 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) } 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/TripVehicleCardTests.swift b/iosApp/iosAppTests/Pages/StopDetails/TripHeaderCardTests.swift similarity index 66% rename from iosApp/iosAppTests/Pages/StopDetails/TripVehicleCardTests.swift rename to iosApp/iosAppTests/Pages/StopDetails/TripHeaderCardTests.swift index f892cc3b5..0c7d9a1fa 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 } @@ -26,12 +26,12 @@ final class TripVehicleCardTests: XCTestCase { vehicle.tripId = "" } let sut = TripHeaderCard( - vehicle: vehicle, + spec: .vehicle(vehicle, nil), stop: stop, tripId: "", targetId: "", - terminalEntry: nil, routeAccents: .init(), + onTap: nil, now: now ) @@ -48,12 +48,12 @@ final class TripVehicleCardTests: XCTestCase { vehicle.tripId = "" } let inTransitSut = TripHeaderCard( - vehicle: inTransitVehicle, + spec: .vehicle(inTransitVehicle, nil), stop: stop, tripId: "", targetId: "", - terminalEntry: nil, routeAccents: .init(), + onTap: nil, now: now ) try XCTAssertNotNil(inTransitSut.inspect().find(text: "Next stop")) @@ -63,12 +63,12 @@ final class TripVehicleCardTests: XCTestCase { vehicle.tripId = "" } let incomingSut = TripHeaderCard( - vehicle: incomingVehicle, + spec: .vehicle(incomingVehicle, nil), stop: stop, tripId: "", targetId: "", - terminalEntry: nil, routeAccents: .init(), + onTap: nil, now: now ) try XCTAssertNotNil(incomingSut.inspect().find(text: "Approaching")) @@ -78,12 +78,12 @@ final class TripVehicleCardTests: XCTestCase { vehicle.tripId = "" } let stoppedSut = TripHeaderCard( - vehicle: stoppedVehicle, + spec: .vehicle(stoppedVehicle, nil), stop: stop, tripId: "", targetId: "", - terminalEntry: nil, routeAccents: .init(), + onTap: nil, now: now ) try XCTAssertNotNil(stoppedSut.inspect().find(text: "Now at")) @@ -99,12 +99,12 @@ final class TripVehicleCardTests: XCTestCase { vehicle.tripId = "different" } let sut = TripHeaderCard( - vehicle: vehicle, + 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")) @@ -122,12 +122,12 @@ final class TripVehicleCardTests: XCTestCase { vehicle.stopId = stop.id } let targeted = TripHeaderCard( - vehicle: vehicle, + spec: .vehicle(vehicle, nil), stop: stop, tripId: "", targetId: stop.id, - terminalEntry: nil, routeAccents: .init(), + onTap: nil, now: now ) @@ -136,12 +136,12 @@ final class TripVehicleCardTests: XCTestCase { })) let notTargeted = TripHeaderCard( - vehicle: vehicle, + spec: .vehicle(vehicle, nil), stop: stop, tripId: "", targetId: "", - terminalEntry: nil, routeAccents: .init(), + onTap: nil, now: now ) @@ -166,11 +166,7 @@ final class TripVehicleCardTests: XCTestCase { } let sut = TripHeaderCard( - vehicle: vehicle, - stop: stop, - tripId: "", - targetId: stop.id, - terminalEntry: .init( + 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,69 @@ 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)) + } + + 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) + } } 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/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) From d8adb9774227bfadf4bc60536a08c48b348b06fb Mon Sep 17 00:00:00 2001 From: Emma Simon Date: Wed, 18 Dec 2024 20:07:58 -0500 Subject: [PATCH 4/7] chore: Alphabetize top level directories --- iosApp/iosApp.xcodeproj/project.pbxproj | 32 ++++++++++++------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index ff08981da..c47f647d5 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -631,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 = ""; @@ -713,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 */, ); @@ -742,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 */, @@ -755,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 = ""; From 9c1a78b84894a31597a04316c6ca608e68046e3a Mon Sep 17 00:00:00 2001 From: Emma Simon Date: Wed, 18 Dec 2024 20:43:31 -0500 Subject: [PATCH 5/7] test(iOS): Add TripDetailsView test --- .../StopDetails/TripDetailsViewTests.swift | 60 +++++++++++++++++++ .../StopDetails/TripHeaderCardTests.swift | 6 ++ 2 files changed, 66 insertions(+) diff --git a/iosApp/iosAppTests/Pages/StopDetails/TripDetailsViewTests.swift b/iosApp/iosAppTests/Pages/StopDetails/TripDetailsViewTests.swift index d935c878e..a75d05e6f 100644 --- a/iosApp/iosAppTests/Pages/StopDetails/TripDetailsViewTests.swift +++ b/iosApp/iosAppTests/Pages/StopDetails/TripDetailsViewTests.swift @@ -85,6 +85,66 @@ final class TripDetailsViewTests: XCTestCase { 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 { let now = Date.now let objects = ObjectCollectionBuilder() diff --git a/iosApp/iosAppTests/Pages/StopDetails/TripHeaderCardTests.swift b/iosApp/iosAppTests/Pages/StopDetails/TripHeaderCardTests.swift index 0c7d9a1fa..613e31b8a 100644 --- a/iosApp/iosAppTests/Pages/StopDetails/TripHeaderCardTests.swift +++ b/iosApp/iosAppTests/Pages/StopDetails/TripHeaderCardTests.swift @@ -220,6 +220,9 @@ final class TripHeaderCardTests: XCTestCase { 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 { @@ -253,5 +256,8 @@ final class TripHeaderCardTests: XCTestCase { 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" + })) } } From d9cc6dcec5ec7c4970d49b433527b96d79434884 Mon Sep 17 00:00:00 2001 From: Emma Simon Date: Thu, 19 Dec 2024 11:45:06 -0500 Subject: [PATCH 6/7] fix: Move stop list changes into splitForTarget This makes it easier to make the changes for the future first stop behavior when we need to do things more complicated than checking if the vehicle exists, and ensures it can never break the existing trip details page. --- .../iosApp/Pages/StopDetails/TripStops.swift | 3 ++- .../Pages/TripDetails/TripDetailsPage.swift | 3 ++- .../TripDetailsStopListSplitView.swift | 1 + .../tid/mbta_app/model/TripDetailsStopList.kt | 22 ++++++++++++++----- .../mbta_app/model/TripDetailsStopListTest.kt | 20 +++++++++++++++++ 5 files changed, 42 insertions(+), 7 deletions(-) diff --git a/iosApp/iosApp/Pages/StopDetails/TripStops.swift b/iosApp/iosApp/Pages/StopDetails/TripStops.swift index e88c44ea3..136cad4b3 100644 --- a/iosApp/iosApp/Pages/StopDetails/TripStops.swift +++ b/iosApp/iosApp/Pages/StopDetails/TripStops.swift @@ -45,7 +45,8 @@ struct TripStops: View { stops.splitForTarget( targetStopId: targetId, targetStopSequence: Int32(stopSequence), - globalData: global + globalData: global, + combinedStopDetails: true ) } else { nil } } 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/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 0f3125d8f..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 @@ -39,7 +39,9 @@ constructor(val stops: List, val startTerminalEntry: 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 startTerminalEntry: 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 @@ -206,8 +219,7 @@ constructor(val stops: List, val startTerminalEntry: Entry? = null) { vehicle.currentStatus == Vehicle.CurrentStatus.StoppedAt) } } - .mapNotNull { getEntry(it.value) } - .dropWhile { it == startTerminalEntry && it.vehicle == null }, + .mapNotNull { getEntry(it.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)) From 548a5e1c0999ad5fd78b3cf33942d2b380ee57b6 Mon Sep 17 00:00:00 2001 From: Emma Simon Date: Thu, 19 Dec 2024 13:56:25 -0500 Subject: [PATCH 7/7] fix: PR feedback and a couple tests --- .../StopDetailsFilteredDepartureDetails.swift | 27 ++++++------------- .../iosApp/Pages/StopDetails/TripStops.swift | 20 ++++++++------ .../TripDetailsStopListSplitViewTests.swift | 2 ++ .../tid/mbta_app/model/LoadingPlaceholders.kt | 2 +- 4 files changed, 23 insertions(+), 28 deletions(-) diff --git a/iosApp/iosApp/Pages/StopDetails/StopDetailsFilteredDepartureDetails.swift b/iosApp/iosApp/Pages/StopDetails/StopDetailsFilteredDepartureDetails.swift index a55b3bb67..d6243db70 100644 --- a/iosApp/iosApp/Pages/StopDetails/StopDetailsFilteredDepartureDetails.swift +++ b/iosApp/iosApp/Pages/StopDetails/StopDetailsFilteredDepartureDetails.swift @@ -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: nil, - 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/TripStops.swift b/iosApp/iosApp/Pages/StopDetails/TripStops.swift index 136cad4b3..be691bce3 100644 --- a/iosApp/iosApp/Pages/StopDetails/TripStops.swift +++ b/iosApp/iosApp/Pages/StopDetails/TripStops.swift @@ -133,14 +133,18 @@ 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) 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/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/LoadingPlaceholders.kt b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/LoadingPlaceholders.kt index a9f30d512..4b2853bbb 100644 --- a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/LoadingPlaceholders.kt +++ b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/LoadingPlaceholders.kt @@ -123,7 +123,7 @@ object LoadingPlaceholders { val stop = objects.stop { name = "Loading" } val prediction = objects.prediction { - this.trip = objects.trip(routePattern) + this.trip = trip this.stopId = stop.id this.vehicleId = vehicle.id departureTime = Clock.System.now() + sequence.minutes