Skip to content

Commit 91591c3

Browse files
authored
Trip planner: map-overlay layout (top-sheet form + bottom-sheet directions) (#1872)
* Trip planner: map-overlay layout (top-sheet form + bottom-sheet directions) Restructures the "Plan a trip" flow so the directions map is the constant backdrop instead of the form being a full screen: - The form is now a top sheet that slides down over the map. It drags like a bottom sheet: an AnchoredDraggable state drives its height, and the full form cross-fades with a compact "From -> To" summary bar as it minimizes/maximizes. - The directions are a custom bottom sheet whose drag gesture lives on the handle only, so the directions list scrolls freely inside. It opens to 40% of the screen and collapses to the handle, seated above the nav-bar chrome while its surface reaches the bottom edge (list clearance via scroll content padding). - The map (DirectionsMapViewModel) is mounted for the whole flow and draws the itinerary once a plan completes; the standalone toolbar is folded into the sheet header. TripPlanTopBar/BottomSheetScaffold usage removed; TripResultsSheet/Map reused. * Embed the trip planner into the home map as a directions focus Fold the trip planner into the home map instead of a separate screen, driven by a new CurrentFocus.Directions in the existing focus system: - CurrentFocus.Directions (persisted); HomeViewModel.enterDirections/exitDirections + MapDirective.ShowItinerary/ClearItinerary bridged in MapFeature. - MapViewModel gains a DirectionsMapController session integrated with leaveCurrentView (entering directions drops stop/route focus; exiting restores nearby stops). - The top-chrome search field is replaced by a compact trip-plan form while planning (DirectionsFormCard); a directions results sheet + itinerary render over the map; a plan error / no-route / loading state is now surfaced. - Pick a From/To point on the home map (crosshair + confirm); current-location, date/time, and advanced-settings are wired. - Retire the standalone TRIP_PLAN screen (TripPlanScreen/TripPlanFormSheet/ DirectionsMapViewModel/TripResultsMap); the drawer entry now enters directions focus and the trip-monitor notification opens Home. - Remove the address-book (contacts) picker (#1936 tracks place-intent receiving). TripPlanViewModel/TripResultsViewModel are hosted at HomeActivity scope. * Address PR #1872 review: current-location message + pick-overlay commit - setCurrentLocation: a null last-known location no longer always reports "no location permission". Check the permission state first via PermissionUtils.hasGrantedAtLeastOnePermission; show the recoverable "trying to get a fix" message when permission is granted but there's no fix yet, and the permission message only when it's actually denied. - DirectionsPickOverlay onConfirm: only clear pickTarget (dismiss the picker) after successfully committing an endpoint. With no map center available, keep the picker open instead of silently losing the selection. * Link deferred directions-restore to tracking issue #1939
1 parent c758ef7 commit 91591c3

18 files changed

Lines changed: 755 additions & 842 deletions

onebusaway-android/src/main/java/org/onebusaway/android/directions/realtime/TripPlanMonitorService.kt

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@ import org.onebusaway.android.directions.util.TripRequestBuilder
5050
import org.onebusaway.android.notifications.NotificationChannels
5151
import org.onebusaway.android.time.ServerTime
5252
import org.onebusaway.android.time.WallTime
53-
import org.onebusaway.android.ui.nav.NavRoutes
5453
import org.onebusaway.android.ui.tripplan.TripPlanRepository
5554
import java.time.Instant
5655
import kotlin.coroutines.coroutineContext
@@ -204,8 +203,9 @@ class TripPlanMonitorService : Service() {
204203

205204
/** The low-key ongoing notification required while the foreground service runs. */
206205
private fun buildOngoingNotification(target: Class<*>): Notification {
206+
// Opens HOME (the trip planner is now an on-map directions focus, not a separate destination).
207+
// Restoring the watched trip into directions focus is a deferred follow-up.
207208
val openIntent = Intent(applicationContext, target)
208-
.putExtra(NavRoutes.EXTRA_NAV_ROUTE, NavRoutes.TRIP_PLAN)
209209
.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
210210
return NotificationCompat.Builder(this, NotificationChannels.TRIP_PLAN_UPDATES_ID)
211211
.setSmallIcon(R.drawable.ic_bus)
@@ -239,7 +239,8 @@ class TripPlanMonitorService : Service() {
239239
putExtras(requestExtras)
240240
putExtra(OTPConstants.ITINERARIES, itineraries.toJson())
241241
putExtra(OTPConstants.INTENT_SOURCE, OTPConstants.Source.NOTIFICATION)
242-
putExtra(NavRoutes.EXTRA_NAV_ROUTE, NavRoutes.TRIP_PLAN)
242+
// The request/itinerary bundle rides along for a future restore-into-directions (#1939); the
243+
// intent opens HOME (no EXTRA_NAV_ROUTE) rather than the retired standalone trip-plan screen.
243244
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
244245
}
245246

onebusaway-android/src/main/java/org/onebusaway/android/map/DirectionsMapViewModel.kt

Lines changed: 0 additions & 84 deletions
This file was deleted.

onebusaway-android/src/main/java/org/onebusaway/android/map/MapViewModel.kt

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.StateFlow
2828
import kotlinx.coroutines.flow.map
2929
import kotlinx.coroutines.flow.stateIn
3030
import org.onebusaway.android.R
31+
import org.onebusaway.android.directions.model.TripItinerary
3132
import org.onebusaway.android.models.ObaRoute
3233
import org.onebusaway.android.models.ObaStop
3334
import org.onebusaway.android.models.RouteMapDirection
@@ -89,8 +90,9 @@ data class RouteHeader(
8990
* binds to the host.
9091
*
9192
* Other map screens have their own slim view models over the same building blocks — `StopsMapViewModel`
92-
* (the report + location-picker maps) and `DirectionsMapViewModel` (the trip-plan results map) — so
93-
* they pull in only the controllers they use rather than this whole coordinator.
93+
* (the report + location-picker maps) — so they pull in only the controllers they use rather than this
94+
* whole coordinator. Trip-plan directions now render on this home map too (the [DirectionsMapController]
95+
* session below), rather than a separate results-map view model.
9496
*
9597
* Scoped to the hosting Activity (obtained via `by viewModels()`), so the home screen + the trip-focus
9698
* destination share one model and the rendered state survives a configuration change. Its data
@@ -215,6 +217,13 @@ class MapViewModel @Inject constructor(
215217
scope = viewModelScope,
216218
)
217219

220+
// The trip-plan directions use case (draws an itinerary's legs + start/end pins). Reused from the
221+
// standalone trip-results map so the home map can render directions itself. `directionsActive` is the
222+
// mode flag (paralleling `routeController.isActive` for route mode); it gates teardown in
223+
// [leaveCurrentView].
224+
private val directionsController = DirectionsMapController(mapHost)
225+
private var directionsActive = false
226+
218227
// Keeps vehicle markers clear of whichever top control anchors route focus: the standalone route
219228
// banner or, for a route selected within stop focus, the always-visible search field.
220229
private val focusBannerMarkerPaddingPx =
@@ -267,8 +276,8 @@ class MapViewModel @Inject constructor(
267276
// There is no stored "mode" field: [RouteMapController.routeId] is the single source of truth
268277
// for whether a route is shown. Each transition tears down the prior view, persists the intended
269278
// restore view, and starts the new view's loaders. The home bike layer is gated purely by the user's
270-
// layer toggle (directions — the only thing that forces bikes on / filters them — is its own
271-
// DirectionsMapViewModel), so both transitions start it the same way.
279+
// layer toggle; directions — the only thing that forces bikes on / filters them — is handled by the
280+
// [showItinerary] session below, so both nearby/route transitions start the bike layer the same way.
272281

273282
/** Show nearby stops in the current viewport (the default home view). */
274283
fun showNearbyStops() {
@@ -322,6 +331,11 @@ class MapViewModel @Inject constructor(
322331
*/
323332
private fun leaveCurrentView(clearStopFocus: Boolean) {
324333
if (clearStopFocus) routeController.clearStopFocus()
334+
if (directionsActive) {
335+
directionsController.clear()
336+
renderState.clearRoutePolylines()
337+
directionsActive = false
338+
}
325339
stopsController.stop()
326340
routeController.stop()
327341
bikeController.stop()
@@ -435,6 +449,35 @@ class MapViewModel @Inject constructor(
435449
}
436450
}
437451

452+
/**
453+
* Draw [itinerary] on the home map (trip-plan directions focus) via the [DirectionsMapController]
454+
* session. The first call leaves the current view (dropping
455+
* stop/route focus + nearby stops) and enters directions mode; later calls (a different option
456+
* selected) just redraw the legs/pins. Deliberately does not restart the nearby-stops loader —
457+
* directions hides nearby stops. Exiting (any other transition → [leaveCurrentView]) tears it down.
458+
*/
459+
fun showItinerary(itinerary: TripItinerary) {
460+
if (!directionsActive) {
461+
leaveCurrentView(clearStopFocus = true)
462+
persistRoute(null)
463+
directionsActive = true
464+
}
465+
directionsController.clear()
466+
renderState.clearRoutePolylines()
467+
directionsController.start(itinerary)
468+
bikeController.start(
469+
directions = true,
470+
selectedBikeStationIds = DirectionsMapController.bikeStationIdsFromItinerary(itinerary),
471+
)
472+
}
473+
474+
/** Clear the drawn itinerary while staying in directions mode (e.g. the plan became unsubmittable). */
475+
fun clearShownItinerary() {
476+
if (!directionsActive) return
477+
directionsController.clear()
478+
renderState.clearRoutePolylines()
479+
}
480+
438481
/** Leave a route selected within stop focus and restore the stop's full adjacency presentation. */
439482
fun clearSelectedRoute() = showNearbyStops()
440483

onebusaway-android/src/main/java/org/onebusaway/android/ui/HomeActivity.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ import org.onebusaway.android.ui.home.HomeScreen
5454
import org.onebusaway.android.ui.home.HomeViewModel
5555
import org.onebusaway.android.ui.home.HomeNavHost
5656
import org.onebusaway.android.ui.home.HomeDestinationDeps
57+
import org.onebusaway.android.ui.tripplan.TripPlanViewModel
58+
import org.onebusaway.android.ui.tripresults.TripResultsViewModel
5759
import org.onebusaway.android.ui.home.LaunchIntentChannel
5860
import org.onebusaway.android.ui.home.LaunchIntentEffect
5961
import org.onebusaway.android.ui.home.SettingsRehomeEffect
@@ -110,6 +112,12 @@ class HomeActivity : AppCompatActivity() {
110112
// The help / what's-new / legend dialogs feature module. Activity-scoped.
111113
private val helpViewModel: HelpViewModel by viewModels()
112114

115+
// Trip planner, now hosted on HOME (directions focus) rather than a standalone destination. Activity-
116+
// scoped so the reactive form + results survive config changes while HOME is on screen; TripPlanViewModel
117+
// persists its form via the activity's SavedStateHandle.
118+
private val tripPlanViewModel: TripPlanViewModel by viewModels()
119+
private val tripResultsViewModel: TripResultsViewModel by viewModels()
120+
113121
override fun onCreate(savedInstanceState: Bundle?) {
114122
super.onCreate(savedInstanceState)
115123

@@ -146,6 +154,8 @@ class HomeActivity : AppCompatActivity() {
146154
donationViewModel = donationViewModel,
147155
weatherViewModel = weatherViewModel,
148156
helpViewModel = helpViewModel,
157+
tripPlanViewModel = tripPlanViewModel,
158+
tripResultsViewModel = tripResultsViewModel,
149159
arrivalsViewModelFactory = arrivalsViewModelFactory,
150160
activityActions = activityActions,
151161
),

onebusaway-android/src/main/java/org/onebusaway/android/ui/home/CurrentFocus.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@ sealed interface CurrentFocus {
2727
) : CurrentFocus
2828
data class Route(val target: RouteTarget) : CurrentFocus
2929
data class BikeStation(val id: String) : CurrentFocus
30+
31+
/**
32+
* Trip-plan directions mode. A marker with no payload: the itinerary/plan identity lives in
33+
* `TripPlanViewModel`/`TripResultsViewModel` (and persists via their own SavedStateHandle), so
34+
* duplicating it here would create a second source of truth for "which itinerary". This only says
35+
* "the map is in directions mode" — the chrome swaps to the trip-plan form and the map draws the
36+
* itinerary the results VM selects.
37+
*/
38+
data object Directions : CurrentFocus
3039
}
3140

3241
val CurrentFocus.focusedStop: FocusedStop?

onebusaway-android/src/main/java/org/onebusaway/android/ui/home/CurrentFocusPersistence.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ internal object CurrentFocusPersistence {
3939
private const val FOCUS_STOP = "stop"
4040
private const val FOCUS_ROUTE = "route"
4141
private const val FOCUS_BIKE = "bike"
42+
private const val FOCUS_DIRECTIONS = "directions"
4243
private const val NO_DIRECTION = Int.MIN_VALUE
4344

4445
fun read(state: SavedStateHandle): CurrentFocus {
@@ -49,6 +50,7 @@ internal object CurrentFocusPersistence {
4950
FOCUS_ROUTE -> readRouteTarget(state)?.let { CurrentFocus.Route(it) } ?: CurrentFocus.None
5051
FOCUS_BIKE -> state.get<String>(KEY_BIKE_STATION)?.let { CurrentFocus.BikeStation(it) }
5152
?: CurrentFocus.None
53+
FOCUS_DIRECTIONS -> CurrentFocus.Directions
5254
FOCUS_NONE -> CurrentFocus.None
5355
else -> readLegacyFocus(state, stop)
5456
}
@@ -60,6 +62,7 @@ internal object CurrentFocusPersistence {
6062
is CurrentFocus.Stop -> FOCUS_STOP
6163
is CurrentFocus.Route -> FOCUS_ROUTE
6264
is CurrentFocus.BikeStation -> FOCUS_BIKE
65+
CurrentFocus.Directions -> FOCUS_DIRECTIONS
6366
}
6467
val stop = (focus as? CurrentFocus.Stop)?.stop
6568
state[KEY_STOP_ID] = stop?.id

onebusaway-android/src/main/java/org/onebusaway/android/ui/home/HomeNavHost.kt

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ import org.onebusaway.android.ui.home.help.HelpViewModel
6767
import org.onebusaway.android.ui.home.nav.extraDestinations
6868
import org.onebusaway.android.ui.home.weather.WeatherViewModel
6969
import org.onebusaway.android.ui.mylists.myListsGraph
70+
import org.onebusaway.android.ui.tripplan.TripPlanViewModel
71+
import org.onebusaway.android.ui.tripresults.TripResultsViewModel
7072
import org.onebusaway.android.ui.nav.IntentRouteMapper
7173
import org.onebusaway.android.ui.nav.NavHelp
7274
import org.onebusaway.android.ui.nav.NavRoutes
@@ -103,6 +105,8 @@ class HomeDestinationDeps(
103105
val donationViewModel: DonationViewModel,
104106
val weatherViewModel: WeatherViewModel,
105107
val helpViewModel: HelpViewModel,
108+
val tripPlanViewModel: TripPlanViewModel,
109+
val tripResultsViewModel: TripResultsViewModel,
106110
val arrivalsViewModelFactory: ArrivalsViewModel.Factory,
107111
val activityActions: HomeActivityActions,
108112
)
@@ -178,7 +182,12 @@ fun HomeNavHost(
178182
onStarredStops = { menuNav(NavRoutes.HOME_STARRED_STOPS, R.string.analytics_label_button_press_star) },
179183
onStarredRoutes = { menuNav(NavRoutes.HOME_STARRED_ROUTES, R.string.analytics_label_button_press_star) },
180184
onReminders = { menuNav(NavRoutes.MY_REMINDERS, R.string.analytics_label_button_press_reminders) },
181-
onPlanTrip = { menuNav(NavRoutes.TRIP_PLAN, R.string.analytics_label_button_press_trip_plan) },
185+
// Trip planning is now an on-map directions focus (compact form in the top chrome +
186+
// itinerary on the home map), not a separate destination.
187+
onPlanTrip = {
188+
home.homeViewModel.enterDirections(home.mapViewModel.viewport)
189+
home.homeViewModel.reportMenuAnalytics(R.string.analytics_label_button_press_trip_plan)
190+
},
182191
onSettings = { menuNav(NavRoutes.SETTINGS, R.string.analytics_label_button_press_settings) },
183192
onSearch = { query -> navController.navigateFromHome(NavRoutes.search(query)) },
184193
onRecentStopsRoutes = {
@@ -213,6 +222,8 @@ fun HomeNavHost(
213222
donationViewModel = home.donationViewModel,
214223
weatherViewModel = home.weatherViewModel,
215224
helpViewModel = home.helpViewModel,
225+
tripPlanViewModel = home.tripPlanViewModel,
226+
tripResultsViewModel = home.tripResultsViewModel,
216227
arrivalsViewModelFactory = home.arrivalsViewModelFactory,
217228
callbacks = callbacks,
218229
)

0 commit comments

Comments
 (0)