diff --git a/.gitignore b/.gitignore index 6efb2fe..2f2303c 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ google_maps_api.xml /build/intermediates/lint-cache/maven.google/com/google/.DS_Store /app/src/main/res/values/.DS_Store /build/intermediates/lint-cache/maven.google/com/google/android/.DS_Store +/build/reports/ diff --git a/app/src/main/java/edu/rpi/shuttletracker/data/models/Route.kt b/app/src/main/java/edu/rpi/shuttletracker/data/models/Route.kt index 67ae420..7d363ce 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/data/models/Route.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/data/models/Route.kt @@ -5,6 +5,7 @@ import com.google.gson.JsonDeserializationContext import com.google.gson.JsonDeserializer import com.google.gson.JsonElement import com.google.gson.annotations.SerializedName +import com.google.gson.reflect.TypeToken import java.lang.reflect.Type data class Route( @@ -38,19 +39,19 @@ class RouteDeserializer : JsonDeserializer { val stops: List = context.deserialize( obj["STOPS"], - object : com.google.gson.reflect.TypeToken>() {}.type, + object : TypeToken>() {}.type, ) val polylineStops: List = context.deserialize( obj["POLYLINE_STOPS"], - object : com.google.gson.reflect.TypeToken>() {}.type, + object : TypeToken>() {}.type, ) val coordinates: List>> = context.deserialize( obj["ROUTES"], - object : com.google.gson.reflect.TypeToken>>>() {}.type, + object : TypeToken>>>() {}.type, ) // Decode dynamic stop keys, but only those listed in STOPS diff --git a/app/src/main/java/edu/rpi/shuttletracker/data/models/Vehicle.kt b/app/src/main/java/edu/rpi/shuttletracker/data/models/Vehicle.kt new file mode 100644 index 0000000..e9dc9d3 --- /dev/null +++ b/app/src/main/java/edu/rpi/shuttletracker/data/models/Vehicle.kt @@ -0,0 +1,110 @@ +package edu.rpi.shuttletracker.data.models + +import com.google.android.gms.maps.model.LatLng +import com.google.gson.annotations.SerializedName +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import java.time.Duration +import java.time.Instant +import java.time.OffsetDateTime +import java.time.temporal.ChronoUnit +import java.util.Locale +import kotlin.String + +data class Vehicle( + val id: String, + val name: String, + // from locations + val latitude: Double, + val longitude: Double, + val speedMph: Double, + val timestamp: String, + // from velocities + val routeName: String?, + val isAtStop: Boolean?, + val currentStop: String?, + // from etas + val stopTimes: Map, +) { + /** + * Turns the date stored into a time of a generalized time ago from current + * updates once per second if subscribed to + * */ + fun getTimeAgo(): Flow { + val busInstant = + OffsetDateTime.parse(timestamp).toInstant() + + return flow { + while (true) { + val now = Instant.now().truncatedTo(ChronoUnit.SECONDS) + val duration = Duration.between(busInstant, now) + + emit(formatDuration(duration) + " ago") + delay(1000) + } + } + } + + // Pretty "xh ym zs" formatter (avoids relying on Duration.toString()) + private fun formatDuration(d: Duration): String { + var secs = d.seconds + val h = secs / 3600 + secs %= 3600 + val m = secs / 60 + secs %= 60 + val s = secs + return buildString { + if (h > 0) append("${h}h ") + if (m > 0 || h > 0) append("${m}m ") + append("${s}s") + }.trim().lowercase(Locale.ROOT) + } + + fun latLng() = LatLng(latitude, longitude) +} + +data class VehicleLocation( + val name: String, + val latitude: Double, + val longitude: Double, + @SerializedName("speed_mph") val speedMph: Double, + val timestamp: String, +) + +data class VehicleStopEta( + @SerializedName("stop_times") val stopTimes: Map, + @SerializedName("timestamp") val timestamp: String, +) + +data class VehicleVelocities( + @SerializedName("route_name") val routeName: String, + @SerializedName("is_at_stop") val isAtStop: Boolean, + @SerializedName("current_stop") val currentStop: String?, +) + +object VehicleMerger { + fun merge( + locations: Map, + velocities: Map = emptyMap(), + etas: Map = emptyMap(), + ): List = + locations + .map { (vehicleId, location) -> + val velocity = velocities[vehicleId] + val eta = etas[vehicleId] + + Vehicle( + id = vehicleId, + name = location.name, + latitude = location.latitude, + longitude = location.longitude, + speedMph = location.speedMph, + timestamp = location.timestamp, + routeName = velocity?.routeName, + isAtStop = velocity?.isAtStop, + currentStop = velocity?.currentStop, + stopTimes = eta?.stopTimes ?: emptyMap(), + ) + }.sortedBy { it.name } +} diff --git a/app/src/main/java/edu/rpi/shuttletracker/data/models/vehicle/VehicleLocation.kt b/app/src/main/java/edu/rpi/shuttletracker/data/models/vehicle/VehicleLocation.kt deleted file mode 100644 index 325fff4..0000000 --- a/app/src/main/java/edu/rpi/shuttletracker/data/models/vehicle/VehicleLocation.kt +++ /dev/null @@ -1,57 +0,0 @@ -package edu.rpi.shuttletracker.data.models.vehicle - -import com.google.android.gms.maps.model.LatLng -import com.google.gson.annotations.SerializedName -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow -import java.time.Duration -import java.time.Instant -import java.time.OffsetDateTime -import java.time.temporal.ChronoUnit -import java.util.Locale - -data class VehicleLocation( - val name: String, - val latitude: Double, - val longitude: Double, - @SerializedName("speed_mph") val speedMph: Double, - @SerializedName("route_name") val routeName: String, - @SerializedName("timestamp") val date: String, -) { - /** - * Turns the date stored into a time of a generalized time ago from current - * updates once per second if subscribed to - * */ - fun getTimeAgo(): Flow { - val busInstant = - OffsetDateTime.parse(date).toInstant() - - return flow { - while (true) { - val now = Instant.now().truncatedTo(ChronoUnit.SECONDS) - val duration = Duration.between(busInstant, now) - - emit(formatDuration(duration) + " ago") - delay(1000) - } - } - } - - // Pretty "xh ym zs" formatter (avoids relying on Duration.toString()) - private fun formatDuration(d: Duration): String { - var secs = d.seconds - val h = secs / 3600 - secs %= 3600 - val m = secs / 60 - secs %= 60 - val s = secs - return buildString { - if (h > 0) append("${h}h ") - if (m > 0 || h > 0) append("${m}m ") - append("${s}s") - }.trim().lowercase(Locale.ROOT) - } - - fun latLng() = LatLng(latitude, longitude) -} diff --git a/app/src/main/java/edu/rpi/shuttletracker/data/models/vehicle/VehicleStopEta.kt b/app/src/main/java/edu/rpi/shuttletracker/data/models/vehicle/VehicleStopEta.kt deleted file mode 100644 index dabb2ee..0000000 --- a/app/src/main/java/edu/rpi/shuttletracker/data/models/vehicle/VehicleStopEta.kt +++ /dev/null @@ -1,8 +0,0 @@ -package edu.rpi.shuttletracker.data.models.vehicle - -import com.google.gson.annotations.SerializedName - -data class VehicleStopEta( - @SerializedName("stop_times") val stopTimes: Map, - @SerializedName("timestamp") val timestamp: String, -) diff --git a/app/src/main/java/edu/rpi/shuttletracker/data/network/ApiHelper.kt b/app/src/main/java/edu/rpi/shuttletracker/data/network/ApiHelper.kt index 07a22e6..b901b95 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/data/network/ApiHelper.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/data/network/ApiHelper.kt @@ -7,14 +7,17 @@ import edu.rpi.shuttletracker.data.models.Announcement import edu.rpi.shuttletracker.data.models.ErrorResponse import edu.rpi.shuttletracker.data.models.Route import edu.rpi.shuttletracker.data.models.Schedule -import edu.rpi.shuttletracker.data.models.vehicle.VehicleLocation -import edu.rpi.shuttletracker.data.models.vehicle.VehicleStopEta +import edu.rpi.shuttletracker.data.models.VehicleLocation +import edu.rpi.shuttletracker.data.models.VehicleStopEta +import edu.rpi.shuttletracker.data.models.VehicleVelocities interface ApiHelper { suspend fun getVehicleLocations(): NetworkResponse, ErrorResponse> suspend fun getVehicleEtas(): NetworkResponse, ErrorResponse> + suspend fun getVehicleVelocities(): NetworkResponse, ErrorResponse> + suspend fun getRoutes(): NetworkResponse, ErrorResponse> suspend fun getAnnouncements(): NetworkResponse, ErrorResponse> diff --git a/app/src/main/java/edu/rpi/shuttletracker/data/network/ApiHelperImpl.kt b/app/src/main/java/edu/rpi/shuttletracker/data/network/ApiHelperImpl.kt index 4cd13ac..6594351 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/data/network/ApiHelperImpl.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/data/network/ApiHelperImpl.kt @@ -7,8 +7,9 @@ import edu.rpi.shuttletracker.data.models.Announcement import edu.rpi.shuttletracker.data.models.ErrorResponse import edu.rpi.shuttletracker.data.models.Route import edu.rpi.shuttletracker.data.models.Schedule -import edu.rpi.shuttletracker.data.models.vehicle.VehicleLocation -import edu.rpi.shuttletracker.data.models.vehicle.VehicleStopEta +import edu.rpi.shuttletracker.data.models.VehicleLocation +import edu.rpi.shuttletracker.data.models.VehicleStopEta +import edu.rpi.shuttletracker.data.models.VehicleVelocities import javax.inject.Inject class ApiHelperImpl @@ -22,6 +23,9 @@ class ApiHelperImpl override suspend fun getVehicleEtas(): NetworkResponse, ErrorResponse> = apiService.getVehicleEtas() + override suspend fun getVehicleVelocities(): NetworkResponse, ErrorResponse> = + apiService.getVehicleVelocities() + override suspend fun getRoutes(): NetworkResponse, ErrorResponse> = apiService.getRoutes() override suspend fun getAnnouncements(): NetworkResponse, ErrorResponse> = diff --git a/app/src/main/java/edu/rpi/shuttletracker/data/network/ApiService.kt b/app/src/main/java/edu/rpi/shuttletracker/data/network/ApiService.kt index 58de0e9..5c8f4ab 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/data/network/ApiService.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/data/network/ApiService.kt @@ -7,8 +7,9 @@ import edu.rpi.shuttletracker.data.models.Announcement import edu.rpi.shuttletracker.data.models.ErrorResponse import edu.rpi.shuttletracker.data.models.Route import edu.rpi.shuttletracker.data.models.Schedule -import edu.rpi.shuttletracker.data.models.vehicle.VehicleLocation -import edu.rpi.shuttletracker.data.models.vehicle.VehicleStopEta +import edu.rpi.shuttletracker.data.models.VehicleLocation +import edu.rpi.shuttletracker.data.models.VehicleStopEta +import edu.rpi.shuttletracker.data.models.VehicleVelocities import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.POST @@ -20,6 +21,9 @@ interface ApiService { @GET("etas") suspend fun getVehicleEtas(): NetworkResponse, ErrorResponse> + @GET("velocities") + suspend fun getVehicleVelocities(): NetworkResponse, ErrorResponse> + @GET("routes") suspend fun getRoutes(): NetworkResponse, ErrorResponse> diff --git a/app/src/main/java/edu/rpi/shuttletracker/data/repositories/ApiRepository.kt b/app/src/main/java/edu/rpi/shuttletracker/data/repositories/ApiRepository.kt index 083c1f3..0f9469e 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/data/repositories/ApiRepository.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/data/repositories/ApiRepository.kt @@ -36,6 +36,14 @@ class ApiRepository } } + fun observeVehicleVelocities(pollMs: Long = 30_000L) = + flow { + while (currentCoroutineContext().isActive) { + emit(apiHelper.getVehicleVelocities()) + delay(pollMs) + } + } + suspend fun getRoutes() = apiHelper.getRoutes() suspend fun getAnnouncements() = apiHelper.getAnnouncements() diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt index 2a07c8a..6040d19 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsScreen.kt @@ -12,36 +12,34 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Layers import androidx.compose.material.icons.outlined.Layers import androidx.compose.material.icons.outlined.LocationDisabled import androidx.compose.material.icons.outlined.MyLocation +import androidx.compose.material.icons.outlined.Schedule import androidx.compose.material.icons.outlined.Settings import androidx.compose.material3.Badge import androidx.compose.material3.BadgedBox -import androidx.compose.material3.BottomSheetDefaults -import androidx.compose.material3.BottomSheetScaffold import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SheetValue +import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text -import androidx.compose.material3.rememberBottomSheetScaffoldState -import androidx.compose.material3.rememberStandardBottomSheetState +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -73,14 +71,16 @@ import com.google.maps.android.compose.rememberCameraPositionState import com.google.maps.android.compose.rememberUpdatedMarkerState import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph -import com.ramcosta.composedestinations.generated.destinations.ScheduleScreenDestination import com.ramcosta.composedestinations.generated.destinations.SettingsScreenDestination import com.ramcosta.composedestinations.navigation.DestinationsNavigator import edu.rpi.shuttletracker.R import edu.rpi.shuttletracker.data.models.Stop -import edu.rpi.shuttletracker.data.models.vehicle.VehicleLocation +import edu.rpi.shuttletracker.data.models.Vehicle +import edu.rpi.shuttletracker.ui.maps.components.ScheduleSheet +import edu.rpi.shuttletracker.ui.maps.components.getVehicleMarkerDescriptor import edu.rpi.shuttletracker.ui.theme.VehicleColors import edu.rpi.shuttletracker.ui.util.CheckResponseError +import kotlinx.coroutines.delay import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @@ -91,36 +91,15 @@ fun MapsScreen( viewModel: MapsViewModel = hiltViewModel(), ) { val mapsUiState = viewModel.mapsUiState.collectAsStateWithLifecycle().value - val snackbarHostState = remember { SnackbarHostState() } - val sheetState = - rememberStandardBottomSheetState( - initialValue = SheetValue.PartiallyExpanded, - ) - val scaffoldState = rememberBottomSheetScaffoldState(bottomSheetState = sheetState) - - BottomSheetScaffold( - scaffoldState = scaffoldState, - sheetPeekHeight = 160.dp, - sheetDragHandle = { BottomSheetDefaults.DragHandle() }, - sheetContainerColor = MaterialTheme.colorScheme.surface, - sheetShadowElevation = 10.dp, - sheetContent = { - val showDetails by remember(scaffoldState.bottomSheetState) { - derivedStateOf { - scaffoldState.bottomSheetState.targetValue == SheetValue.Expanded || - scaffoldState.bottomSheetState.currentValue == SheetValue.Expanded - } - } + var selectedScheduleRoute by rememberSaveable { mutableStateOf(null) } - ScheduleSheetContent( - schedule = mapsUiState.schedule, - showDetails = showDetails, - ) - }, + var showScheduleSheet by rememberSaveable { mutableStateOf(false) } + val scheduleSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + Scaffold( snackbarHost = { - // finds errors when requesting data to server CheckResponseError( mapsUiState.networkError, mapsUiState.serverError, @@ -131,40 +110,39 @@ fun MapsScreen( SnackbarHost(hostState = snackbarHostState) }, ) { padding -> + Box(Modifier.fillMaxSize()) { + ShuttleMap( + mapsUiState = mapsUiState, + padding = padding, + onSettingsClick = { navigator.navigate(SettingsScreenDestination()) }, + onScheduleClick = { showScheduleSheet = true }, + onToggleMapTypeClick = { viewModel.toggleMapType() }, + ) - ShuttleMap( - mapsUiState = mapsUiState, - padding = padding, - onScheduleClick = { navigator.navigate(ScheduleScreenDestination()) }, - onSettingsClick = { navigator.navigate(SettingsScreenDestination()) }, - onToggleMapTypeClick = { viewModel.toggleMapType() }, - ) + ScheduleSheet( + show = showScheduleSheet, + sheetState = scheduleSheetState, + schedule = mapsUiState.schedule, + routesByName = mapsUiState.routes, + selectedRoute = selectedScheduleRoute, + onSelectedRouteChange = { selectedScheduleRoute = it }, + onDismiss = { showScheduleSheet = false }, + ) + } } } -/** - * Creates the map displaying everything - * - * @param mapsUiState: The UI state of the view from the view-model - * @param padding: Padding needed for the map content padding - * to close/open the stop bottom sheet - * @param onScheduleClick: Callback invoked when the user taps the Schedule button - * @param onSettingsClick: Callback invoked when the user taps the Settings button - * @param onToggleMapTypeClick: Callback invoked when user taps the MapType button - * - * */ @Composable private fun ShuttleMap( mapsUiState: MapsUiState, padding: PaddingValues, - onScheduleClick: () -> Unit, onSettingsClick: () -> Unit, + onScheduleClick: () -> Unit, onToggleMapTypeClick: () -> Unit, ) { val context = LocalContext.current val coroutineScope = rememberCoroutineScope() - // can't show current location without location val isLocationPermissionGranted by remember { mutableStateOf( ActivityCompat.checkSelfPermission( @@ -174,7 +152,6 @@ private fun ShuttleMap( ) } - // keeps track of where the camera currently is val cameraPositionState = rememberCameraPositionState { position = @@ -190,7 +167,6 @@ private fun ShuttleMap( Box(modifier = Modifier.fillMaxSize()) { GoogleMap( modifier = Modifier.fillMaxSize(), - // makes sure the items drawn (current location and compass) are clickable contentPadding = padding, cameraPositionState = cameraPositionState, properties = @@ -211,14 +187,12 @@ private fun ShuttleMap( null }, ), - // removes the zoom control which was covered by the FAB uiSettings = MapUiSettings( zoomControlsEnabled = false, myLocationButtonEnabled = false, ), ) { - // creates the stops mapsUiState.routes.forEach { (_, route) -> route.stopDetails.forEach { (_, stop) -> StopMarker( @@ -229,14 +203,12 @@ private fun ShuttleMap( } } - // creates the vehicle markers - mapsUiState.vehicleLocations.values.forEach { + mapsUiState.vehicles.forEach { vehicle -> VehicleMarker( - vehicleLocation = it, + vehicle = vehicle, ) } - // draws the paths mapsUiState.routes.forEach { (_, route) -> val points = route.latLng() if (points.isNotEmpty()) { @@ -245,9 +217,8 @@ private fun ShuttleMap( color = Color( android.graphics.Color - .valueOf( - route.color.toColorInt(), - ).toArgb(), + .valueOf(route.color.toColorInt()) + .toArgb(), ), ) } @@ -264,13 +235,12 @@ private fun ShuttleMap( MapButtonsOverlay( modifier = Modifier - .statusBarsPadding() .padding(padding) .padding(horizontal = 10.dp), isMyLocationEnabled = isLocationPermissionGranted, mapTypeIcon = mapTypeIcon, - onScheduleClick = onScheduleClick, onSettingsClick = onSettingsClick, + onScheduleClick = onScheduleClick, onRecenterClick = { LocationServices .getFusedLocationProviderClient(context) @@ -303,9 +273,6 @@ private fun ShuttleMap( } } -/** - * Creates a marker for a stop - * */ @Composable private fun StopMarker( stop: Stop, @@ -342,36 +309,51 @@ private fun StopMarker( ) } -/** - * Creates a marker for a vehicle - * */ @Composable -private fun VehicleMarker(vehicleLocation: VehicleLocation) { +private fun VehicleMarker(vehicle: Vehicle) { val context = LocalContext.current - val markerState = rememberUpdatedMarkerState(position = vehicleLocation.latLng()) + val markerState = rememberUpdatedMarkerState(position = vehicle.latLng()) // every time the vehicle changes, update the position of the marker - LaunchedEffect(vehicleLocation) { - markerState.position = vehicleLocation.latLng() + LaunchedEffect(vehicle) { + markerState.position = vehicle.latLng() } - val vehicleColor = - when (vehicleLocation.routeName) { + val resolvedColor = + when (vehicle.routeName) { "NORTH" -> VehicleColors.North "WEST" -> VehicleColors.West - else -> VehicleColors.Default + else -> null + } + + var vehicleColor by remember { mutableStateOf(resolvedColor) } + + LaunchedEffect(resolvedColor) { + if (resolvedColor != null) { + vehicleColor = resolvedColor + } else { + delay(30_000) + if (vehicleColor == null) { + vehicleColor = VehicleColors.Default + } } + } + + val finalColor = resolvedColor ?: vehicleColor ?: VehicleColors.Default val icon = - remember(vehicleColor) { - getVehicleMarkerDescriptor(context, 25f, vehicleColor.toArgb()) + remember(finalColor) { + getVehicleMarkerDescriptor(context, 25f, finalColor.toArgb()) } // gets vehicle speed and last time it updated - val lastUpdatedAgoText = vehicleLocation.getTimeAgo().collectAsStateWithLifecycle(initialValue = "").value + val timeAgoFlow = remember(vehicle.timestamp) { vehicle.getTimeAgo() } + val lastUpdatedAgoText = + timeAgoFlow.collectAsStateWithLifecycle(initialValue = "").value + val snippetText = buildString { - append(stringResource(R.string.vehicle_speed, vehicleLocation.speedMph)) + append(stringResource(R.string.vehicle_speed, vehicle.speedMph)) if (lastUpdatedAgoText.isNotBlank()) { append(" • ") append(lastUpdatedAgoText) @@ -380,7 +362,7 @@ private fun VehicleMarker(vehicleLocation: VehicleLocation) { Marker( state = markerState, - title = stringResource(R.string.vehicle_number, vehicleLocation.name), + title = stringResource(R.string.vehicle_number, vehicle.name), icon = icon, snippet = snippetText, anchor = Offset(0.5f, 0.5f), @@ -397,8 +379,8 @@ private fun MapButtonsOverlay( modifier: Modifier = Modifier, isMyLocationEnabled: Boolean, mapTypeIcon: ImageVector, - onScheduleClick: () -> Unit, onSettingsClick: () -> Unit, + onScheduleClick: () -> Unit, onRecenterClick: () -> Unit, onToggleMapTypeClick: () -> Unit, ) { @@ -412,10 +394,6 @@ private fun MapButtonsOverlay( .align(Alignment.TopStart), verticalArrangement = Arrangement.spacedBy(10.dp), ) { -// ActionButton(icon = Icons.Outlined.Schedule) { -// onScheduleClick() -// } - ActionButton(icon = Icons.Outlined.Settings) { onSettingsClick() } @@ -441,6 +419,21 @@ private fun MapButtonsOverlay( onToggleMapTypeClick() } } + + FloatingActionButton( + onClick = onScheduleClick, + modifier = + Modifier + .align(Alignment.BottomEnd) + .padding(16.dp), + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) { + Icon( + imageVector = Icons.Outlined.Schedule, + contentDescription = "Open schedule", + ) + } } } diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsViewModel.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsViewModel.kt index 5dff16e..add1f15 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsViewModel.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/MapsViewModel.kt @@ -9,14 +9,18 @@ import dagger.hilt.android.lifecycle.HiltViewModel import edu.rpi.shuttletracker.data.models.ErrorResponse import edu.rpi.shuttletracker.data.models.Route import edu.rpi.shuttletracker.data.models.Schedule -import edu.rpi.shuttletracker.data.models.vehicle.VehicleLocation -import edu.rpi.shuttletracker.data.models.vehicle.VehicleStopEta +import edu.rpi.shuttletracker.data.models.Vehicle +import edu.rpi.shuttletracker.data.models.VehicleLocation +import edu.rpi.shuttletracker.data.models.VehicleMerger +import edu.rpi.shuttletracker.data.models.VehicleStopEta +import edu.rpi.shuttletracker.data.models.VehicleVelocities import edu.rpi.shuttletracker.data.repositories.ApiRepository import edu.rpi.shuttletracker.data.repositories.UserPreferencesRepository import edu.rpi.shuttletracker.ui.theme.ThemeMode import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -26,19 +30,18 @@ import javax.inject.Inject @HiltViewModel class MapsViewModel + // represents the ui state of the view @Inject constructor( private val apiRepository: ApiRepository, private val userPreferencesRepository: UserPreferencesRepository, ) : ViewModel() { - // represents the ui state of the view private val _mapsUiState = MutableStateFlow(MapsUiState()) val mapsUiState: StateFlow = _mapsUiState init { loadAll() - observeVehicleLocations() -// observeVehicleEtas() + observeVehicles() loadPreferences() } @@ -47,9 +50,6 @@ class MapsViewModel if (mapsUiState.value.schedule == null) loadSchedule() } - /** - * sets all the errors to none - * */ fun clearErrors() { _mapsUiState.update { it.copy( @@ -65,35 +65,38 @@ class MapsViewModel loadAll() } - private fun observeVehicleLocations() { - apiRepository - .observeVehicleLocations(pollMs = 5_000L) - .flowOn(Dispatchers.IO) - .onEach { response -> - readApiResponse(response) { buses -> - _mapsUiState.update { - it.copy(vehicleLocations = buses) - } - } - }.launchIn(viewModelScope) - } + private fun observeVehicles() { + combine( + apiRepository.observeVehicleLocations(pollMs = 5_000L), + apiRepository.observeVehicleEtas(pollMs = 5_000L), + apiRepository.observeVehicleVelocities(pollMs = 5_000L), + ) { locationsResponse, etasResponse, velocitiesResponse -> + Triple(locationsResponse, etasResponse, velocitiesResponse) + }.flowOn(Dispatchers.IO) + .onEach { (locationsResponse, etasResponse, velocitiesResponse) -> + var locations: Map = emptyMap() + var etas: Map = emptyMap() + var velocities: Map = emptyMap() + + readApiResponse(locationsResponse) { locations = it } + readApiResponse(etasResponse) { etas = it } + readApiResponse(velocitiesResponse) { velocities = it } + + val vehicles = + VehicleMerger.merge( + locations = locations, + velocities = velocities, + etas = etas, + ) - private fun observeVehicleEtas() { - apiRepository - .observeVehicleEtas(pollMs = 30_000L) - .flowOn(Dispatchers.IO) - .onEach { response -> - readApiResponse(response) { etas -> - _mapsUiState.update { - it.copy(vehicleStopEtas = etas) - } + _mapsUiState.update { + it.copy( + vehicles = vehicles, + ) } }.launchIn(viewModelScope) } - /** - * Loads all possible routes and maps the API response - * */ private fun loadRoutes() { viewModelScope.launch { readApiResponse(apiRepository.getRoutes()) { routes -> @@ -115,7 +118,6 @@ class MapsViewModel } private fun loadPreferences() { - // gets user preference for dark mode userPreferencesRepository .getThemeMode() .flowOn(Dispatchers.Default) @@ -155,9 +157,6 @@ class MapsViewModel updateMapType(next) } - /** - * Reads the network response and maps it to correct place - * */ private fun readApiResponse( response: NetworkResponse, success: (body: T) -> Unit, @@ -182,13 +181,9 @@ class MapsViewModel } } -/** - * Representation of the screen - * */ @Immutable data class MapsUiState( - val vehicleLocations: Map = emptyMap(), - val vehicleStopEtas: Map = emptyMap(), + val vehicles: List = emptyList(), val routes: Map = emptyMap(), val schedule: Schedule? = null, val networkError: NetworkResponse.NetworkError<*, ErrorResponse>? = null, diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/ScheduleSheetContent.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/ScheduleSheetContent.kt deleted file mode 100644 index e8b0b12..0000000 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/ScheduleSheetContent.kt +++ /dev/null @@ -1,375 +0,0 @@ -package edu.rpi.shuttletracker.ui.maps - -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.DividerDefaults -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SegmentedButton -import androidx.compose.material3.SegmentedButtonDefaults -import androidx.compose.material3.SingleChoiceSegmentedButtonRow -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import edu.rpi.shuttletracker.R -import edu.rpi.shuttletracker.data.models.DayOfWeek -import edu.rpi.shuttletracker.data.models.Schedule -import java.time.LocalTime -import java.time.format.DateTimeFormatter -import java.util.Calendar -import java.util.Locale - -// Header / Peak - -@Composable -fun ScheduleSheetContent( - schedule: Schedule?, - showDetails: Boolean, -) { - Column( - modifier = - Modifier - .fillMaxWidth() - .fillMaxHeight(.86f), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = stringResource(R.string.bottom_sheet_peek_title), - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(bottom = 4.dp), - ) - - Text( - text = stringResource(R.string.bottom_sheet_peek_subtitle), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 8.dp), - ) - - if (!showDetails) return - - HorizontalDivider(Modifier.fillMaxWidth(), DividerDefaults.Thickness) - - when (schedule) { - null -> EmptyState(R.string.no_schedule_found) - else -> ScheduleDetailsContent(schedule) - } - } -} - -@Composable -private fun ScheduleDetailsContent(schedule: Schedule) { - var selectedDay by remember { mutableStateOf(DayOfWeek.fromToday()) } - - val routes = - remember(selectedDay, schedule) { - routesForDay(selectedDay, schedule) - } - - var selectedRoute by remember(selectedDay, routes) { - mutableStateOf(routes.firstOrNull()) - } - - DaySelector( - selectedDay = selectedDay, - onSelect = { selectedDay = it }, - ) - - if (routes.isEmpty()) { - EmptyState(R.string.schedule_none_running) - return - } - - RouteSelector( - routes = routes, - selectedRoute = selectedRoute, - onSelect = { selectedRoute = it }, - ) - - HorizontalDivider(Modifier, DividerDefaults.Thickness) - - val times = - remember(selectedDay, selectedRoute, schedule) { - val dir = selectedRoute ?: return@remember emptyList() - consolidatedTimes(dir, selectedDay, schedule) - } - - if (times.isEmpty()) { - EmptyState(R.string.no_schedule_found) - return - } - - LazyColumn( - modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues(bottom = 24.dp), - ) { - items(times, key = { it.vehicleName + it.time + it.route }) { item -> - ScheduleTimeRow(time = item.time, vehicleName = item.vehicleName) - } - } -} - -// Selectors - -@Composable -private fun DaySelector( - selectedDay: DayOfWeek, - onSelect: (DayOfWeek) -> Unit, -) { - val scrollState = rememberScrollState() - val days = DayOfWeek.entries - - SingleChoiceSegmentedButtonRow( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp) - .horizontalScroll(scrollState), - ) { - days.forEachIndexed { index, day -> - SegmentedButton( - selected = selectedDay == day, - onClick = { onSelect(day) }, - shape = SegmentedButtonDefaults.itemShape(index = index, count = days.size), - label = { Text(day.displayName) }, - ) - } - } -} - -@Composable -private fun RouteSelector( - routes: List, - selectedRoute: String?, - onSelect: (String) -> Unit, -) { - Row( - modifier = Modifier.fillMaxWidth(0.9f), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - routes.forEach { dir -> - RouteTab( - label = - stringResource( - R.string.route_label_format, - dir - .lowercase() - .replaceFirstChar { it.titlecase() }, - ), - route = dir, - selectedRoute = selectedRoute, - onRouteSelected = onSelect, - modifier = Modifier.weight(1f).padding(bottom = 8.dp), - ) - } - } -} - -@Composable -private fun RouteTab( - label: String, - route: String, - selectedRoute: String?, - onRouteSelected: (String) -> Unit, - modifier: Modifier, -) { - val selected = route == selectedRoute - - Surface( - onClick = { onRouteSelected(route) }, - shape = RoundedCornerShape(12.dp), - tonalElevation = if (selected) 2.dp else 0.dp, - color = - if (selected) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surfaceVariant - }, - modifier = modifier, - ) { - Text( - text = label, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 10.dp), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.labelLarge, - color = - if (selected) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - } -} - -// List content - -@Composable -private fun ScheduleTimeRow( - time: String, - vehicleName: String, -) { - Column(modifier = Modifier.fillMaxWidth()) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = time, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.weight(1f), - ) - - val tagColor = vehicleTagColor(vehicleName, MaterialTheme.colorScheme.onSurfaceVariant) - - Surface( - color = tagColor.copy(alpha = 0.15f), - shape = RoundedCornerShape(6.dp), - ) { - Text( - text = vehicleName, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), - style = MaterialTheme.typography.labelSmall, - color = tagColor, - ) - } - } - - HorizontalDivider( - modifier = Modifier.padding(start = 16.dp, end = 16.dp), - thickness = 0.5.dp, - ) - } -} - -@Composable -private fun EmptyState(textRes: Int) { - Box( - Modifier - .fillMaxWidth() - .padding(24.dp), - contentAlignment = Alignment.Center, - ) { - Text(stringResource(textRes)) - } -} - -private fun vehicleTagColor( - vehicleName: String, - defaultColor: Color, -): Color { - val n = vehicleName.lowercase() - return when { - "north" in n -> Color(0xFFD32F2F) - "west" in n -> Color(0xFF1976D2) - else -> defaultColor - } -} - -// Data helpers - -/** - * Collects all unique directions (e.g. "NORTH", "WEST") running on a given day. - */ -private fun routesForDay( - day: DayOfWeek, - data: Schedule, -): List { - val scheduleMap = data.scheduleMapFor(day) - val dirs = mutableSetOf() - - for ((_, times) in scheduleMap) { - for (pair in times) { - if (pair.size > 1) dirs.add(pair[1]) - } - } - return dirs.sorted() -} - -private data class TimeInfo( - val time: String, - val route: String, - val vehicleName: String, - val minutesOfDay: Int, -) - -/** - * Parses a time string into minutes since midnight (or null if invalid). - */ -private fun parseMinutesOfDay(timeStr: String): Int? = - runCatching { - val t = - LocalTime.parse( - timeStr.trim(), - DateTimeFormatter.ofPattern("h:mm a", Locale.US), - ) - t.hour * 60 + t.minute - }.getOrNull() - -/** - * Flattens, filters, and sorts upcoming departures for one route on a given day. - */ -private fun consolidatedTimes( - route: String, - day: DayOfWeek, - data: Schedule, -): List { - val scheduleMap = data.scheduleMapFor(day) - - val now = Calendar.getInstance() - val isToday = DayOfWeek.fromToday() == day - val nowMinutes = now.get(Calendar.HOUR_OF_DAY) * 60 + now.get(Calendar.MINUTE) - - val out = mutableListOf() - - for ((vehicleName, times) in scheduleMap) { - for (pair in times) { - if (pair.size <= 1) continue - - val timeStr = pair[0] - val routeStr = pair[1] - if (routeStr != route) continue - - val minutes = parseMinutesOfDay(timeStr) ?: continue - if (isToday && minutes < nowMinutes) continue - - out += - TimeInfo( - time = timeStr, - route = routeStr, - vehicleName = vehicleName, - minutesOfDay = minutes, - ) - } - } - - return out.sortedBy { it.minutesOfDay } -} diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/ScheduleSheet.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/ScheduleSheet.kt new file mode 100644 index 0000000..d5650fe --- /dev/null +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/ScheduleSheet.kt @@ -0,0 +1,502 @@ +package edu.rpi.shuttletracker.ui.maps.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.DividerDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SheetState +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import edu.rpi.shuttletracker.R +import edu.rpi.shuttletracker.data.models.DayOfWeek +import edu.rpi.shuttletracker.data.models.Route +import edu.rpi.shuttletracker.data.models.Schedule +import edu.rpi.shuttletracker.ui.maps.utils.StopTimeInfo +import edu.rpi.shuttletracker.ui.maps.utils.consolidatedTimes +import edu.rpi.shuttletracker.ui.maps.utils.routesForDay +import java.util.Calendar +import kotlin.text.lowercase + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ScheduleSheet( + show: Boolean, + sheetState: SheetState, + schedule: Schedule?, + routesByName: Map, + selectedRoute: String?, + onSelectedRouteChange: (String) -> Unit, + onDismiss: () -> Unit, +) { + if (!show) return + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + ScheduleSheetContent( + schedule = schedule, + routesByName = routesByName, + selectedRoute = selectedRoute, + onSelectedRouteChange = onSelectedRouteChange, + ) + } +} + +@Composable +private fun ScheduleSheetContent( + schedule: Schedule?, + routesByName: Map, + selectedRoute: String?, + onSelectedRouteChange: (String) -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .fillMaxHeight(.86f), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ScheduleHeader() + + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + thickness = DividerDefaults.Thickness, + ) + + when (schedule) { + null -> EmptyState(R.string.no_schedule_found) + else -> + ScheduleDetailsContent( + schedule = schedule, + routesByName = routesByName, + selectedRoute = selectedRoute, + onSelectedRouteChange = onSelectedRouteChange, + ) + } + } +} + +@Composable +private fun ScheduleHeader() { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 6.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.schedule_title), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(bottom = 4.dp), + ) + + Text( + text = stringResource(R.string.schedule_subtitle), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } +} + +@Composable +private fun ScheduleDetailsContent( + schedule: Schedule, + routesByName: Map, + selectedRoute: String?, + onSelectedRouteChange: (String) -> Unit, +) { + var selectedDay by remember { mutableStateOf(DayOfWeek.fromToday()) } + + val routes = + remember(selectedDay, schedule) { + routesForDay(selectedDay, schedule) + } + + val activeRoute = + when { + selectedRoute in routes -> selectedRoute + routes.isNotEmpty() -> routes.first() + else -> null + } + + DaySelector( + selectedDay = selectedDay, + onSelect = { selectedDay = it }, + ) + + if (routes.isEmpty()) { + EmptyState(R.string.schedule_none_running) + return + } + + RouteSelector( + routes = routes, + selectedRoute = activeRoute, + onSelect = onSelectedRouteChange, + ) + + HorizontalDivider(Modifier, DividerDefaults.Thickness) + + val times = + remember(selectedDay, activeRoute, schedule, routesByName) { + val routeName = activeRoute ?: return@remember emptyList() + consolidatedTimes( + routeName = routeName, + day = selectedDay, + schedule = schedule, + routesByName = routesByName, + ) + } + + if (times.isEmpty()) { + EmptyState(R.string.no_schedule_found) + return + } + + var expandedRowKey by remember(selectedDay, activeRoute) { + mutableStateOf(null) + } + + val listState = rememberLazyListState() + + val now = Calendar.getInstance() + val nowMinutes = now.get(Calendar.HOUR_OF_DAY) * 60 + now.get(Calendar.MINUTE) + + val scrollIndex = + remember(times) { + times.indexOfFirst { it.minutesOfDay >= nowMinutes }.let { index -> + if (index <= 0) 0 else index - 1 + } + } + + LaunchedEffect(times, scrollIndex) { + if (times.isNotEmpty()) { + val autoExpandedItem = times[scrollIndex] + expandedRowKey = + autoExpandedItem.vehicleName + + autoExpandedItem.departureTime + + autoExpandedItem.routeName + + listState.scrollToItem(scrollIndex) + } else { + expandedRowKey = null + } + } + + LazyColumn( + state = listState, + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(bottom = 24.dp), + ) { + items(times, key = { it.vehicleName + it.departureTime + it.routeName }) { item -> + val rowKey = item.vehicleName + item.departureTime + item.routeName + + ScheduleTimeRow( + time = item.departureTime, + vehicleName = item.vehicleName, + expanded = expandedRowKey == rowKey, + stopTimes = item.stopTimes, + onToggleExpanded = { + expandedRowKey = if (expandedRowKey == rowKey) null else rowKey + }, + ) + } + } +} + +@Composable +private fun DaySelector( + selectedDay: DayOfWeek, + onSelect: (DayOfWeek) -> Unit, +) { + val scrollState = rememberScrollState() + val days = DayOfWeek.entries + + SingleChoiceSegmentedButtonRow( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp) + .horizontalScroll(scrollState), + ) { + days.forEachIndexed { index, day -> + SegmentedButton( + selected = selectedDay == day, + onClick = { onSelect(day) }, + shape = SegmentedButtonDefaults.itemShape(index = index, count = days.size), + label = { Text(day.displayName) }, + ) + } + } +} + +@Composable +private fun RouteSelector( + routes: List, + selectedRoute: String?, + onSelect: (String) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(0.9f), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + routes.forEach { dir -> + RouteTab( + route = dir, + selectedRoute = selectedRoute, + onRouteSelected = onSelect, + modifier = + Modifier + .weight(1f) + .padding(bottom = 8.dp), + ) + } + } +} + +@Composable +private fun RouteTab( + route: String, + selectedRoute: String?, + onRouteSelected: (String) -> Unit, + modifier: Modifier, +) { + val selected = route == selectedRoute + + val selectedColor = + when { + "north" in route.lowercase() -> Color(0xFFD32F2F) + "west" in route.lowercase() -> Color(0xFF1976D2) + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + + Surface( + onClick = { onRouteSelected(route) }, + shape = RoundedCornerShape(12.dp), + tonalElevation = if (selected) 2.dp else 0.dp, + color = + if (selected) { + selectedColor.copy(alpha = 0.30f) + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + modifier = modifier, + ) { + Text( + text = + stringResource( + R.string.route_label_format, + route.lowercase().replaceFirstChar { it.titlecase() }, + ), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 10.dp), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.labelLarge, + color = + if (selected) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } +} + +@Composable +private fun ScheduleTimeRow( + time: String, + vehicleName: String, + expanded: Boolean, + stopTimes: List, + onToggleExpanded: () -> Unit, +) { + val tagColor = + when { + "north" in vehicleName.lowercase() -> Color(0xFFD32F2F) + "west" in vehicleName.lowercase() -> Color(0xFF1976D2) + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { onToggleExpanded() } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = time, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + ) + + Surface( + color = tagColor.copy(alpha = 0.15f), + shape = RoundedCornerShape(6.dp), + ) { + Text( + text = vehicleName, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + style = MaterialTheme.typography.labelSmall, + color = tagColor, + ) + } + + Text( + text = if (expanded) "∨" else ">", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 12.dp), + ) + } + + if (expanded) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + thickness = 0.5.dp, + ) + } + + AnimatedVisibility( + visible = expanded, + enter = + expandVertically( + animationSpec = tween(durationMillis = 220), + ) + + fadeIn( + animationSpec = tween(durationMillis = 180), + ), + exit = + shrinkVertically( + animationSpec = tween(durationMillis = 200), + ) + + fadeOut( + animationSpec = tween(durationMillis = 150), + ), + ) { + if (stopTimes.isEmpty()) { + Text( + text = stringResource(R.string.no_stop_times), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + } else { + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + stopTimes.forEach { stopTime -> + StopTimeItem( + stopName = stopTime.stopName, + time = stopTime.time, + accentColor = tagColor, + ) + } + } + } + } + + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + thickness = 0.5.dp, + ) + } +} + +@Composable +private fun StopTimeItem( + stopName: String, + time: String, + accentColor: Color, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = + Modifier + .width(2.dp) + .height(28.dp) + .background(accentColor, RoundedCornerShape(999.dp)), + ) + + Text( + text = stopName, + style = MaterialTheme.typography.bodyMedium, + modifier = + Modifier + .weight(1f) + .padding(start = 10.dp), + ) + + Text( + text = time, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 12.dp), + ) + } +} + +@Composable +private fun EmptyState(textRes: Int) { + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(24.dp), + contentAlignment = Alignment.Center, + ) { + Text(text = stringResource(textRes)) + } +} diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/SvgVehicleMarker.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/SvgVehicleMarker.kt similarity index 98% rename from app/src/main/java/edu/rpi/shuttletracker/ui/maps/SvgVehicleMarker.kt rename to app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/SvgVehicleMarker.kt index c5de2fc..eac9027 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/SvgVehicleMarker.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/components/SvgVehicleMarker.kt @@ -1,4 +1,4 @@ -package edu.rpi.shuttletracker.ui.maps +package edu.rpi.shuttletracker.ui.maps.components import android.content.Context import android.graphics.Bitmap diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/maps/utils/ScheduleUtils.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/utils/ScheduleUtils.kt new file mode 100644 index 0000000..c9f43ea --- /dev/null +++ b/app/src/main/java/edu/rpi/shuttletracker/ui/maps/utils/ScheduleUtils.kt @@ -0,0 +1,121 @@ +package edu.rpi.shuttletracker.ui.maps.utils + +import edu.rpi.shuttletracker.data.models.DayOfWeek +import edu.rpi.shuttletracker.data.models.Route +import edu.rpi.shuttletracker.data.models.Schedule +import java.time.LocalTime +import java.time.format.DateTimeFormatter +import java.util.Locale +import kotlin.collections.component1 +import kotlin.collections.component2 +import kotlin.collections.iterator + +private val TIME_FORMATTER = DateTimeFormatter.ofPattern("h:mm a", Locale.US) + +data class StopTimeInfo( + val stopName: String, + val time: String, +) + +data class TimeInfo( + val departureTime: String, + val routeName: String, + val vehicleName: String, + val minutesOfDay: Int, + val stopTimes: List, +) + +fun routesForDay( + day: DayOfWeek, + schedule: Schedule, +): List { + val scheduleMap = schedule.scheduleMapFor(day) + val directions = mutableSetOf() + + for ((_, times) in scheduleMap) { + for (pair in times) { + if (pair.size > 1) directions.add(pair[1]) + } + } + + return directions.sorted() +} + +/** + * Flattens, filters, and sorts upcoming departures for one route on a given day. + */ +fun consolidatedTimes( + routeName: String, + day: DayOfWeek, + schedule: Schedule, + routesByName: Map, +): List { + val scheduleMap = schedule.scheduleMapFor(day) + val out = mutableListOf() + + for ((vehicleName, times) in scheduleMap) { + for (pair in times) { + if (pair.size <= 1) continue + + val departureTime = pair[0] + val scheduledRouteName = pair[1] + if (scheduledRouteName != routeName) continue + + val minutesOfDay = parseMinutesOfDay(departureTime) ?: continue + + out += + TimeInfo( + departureTime = departureTime, + routeName = scheduledRouteName, + vehicleName = vehicleName, + minutesOfDay = minutesOfDay, + stopTimes = + buildStopTimesForDeparture( + routeName = scheduledRouteName, + departureTime = departureTime, + routesByName = routesByName, + ), + ) + } + } + + return out.sortedBy { it.minutesOfDay } +} + +fun buildStopTimesForDeparture( + routeName: String, + departureTime: String, + routesByName: Map, +): List { + val route = routesByName[routeName] ?: return emptyList() + val departureLocalTime = parseLocalTime(departureTime) ?: return emptyList() + + return route.stops.mapNotNull { stopKey -> + val stop = route.stopDetails[stopKey] ?: return@mapNotNull null + val stopTime = departureLocalTime.plusMinutes(stop.offset.toLong()) + + StopTimeInfo( + stopName = stop.name, + time = formatLocalTime(stopTime), + ) + } +} + +fun parseMinutesOfDay(timeText: String): Int? = + parseLocalTime(timeText)?.let { time -> + val minutes = time.hour * 60 + time.minute + + // Moves after midnight departures at the end of the list + if (time.hour in 0..3) { + minutes + 24 * 60 + } else { + minutes + } + } + +fun parseLocalTime(timeText: String): LocalTime? = + runCatching { + LocalTime.parse(timeText.trim(), TIME_FORMATTER) + }.getOrNull() + +fun formatLocalTime(time: LocalTime): String = time.format(TIME_FORMATTER) diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/schedule/ScheduleScreen.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/schedule/ScheduleScreen.kt deleted file mode 100644 index 6126801..0000000 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/schedule/ScheduleScreen.kt +++ /dev/null @@ -1,349 +0,0 @@ -package edu.rpi.shuttletracker.ui.schedule - -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.ArrowBack -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SegmentedButton -import androidx.compose.material3.SegmentedButtonDefaults -import androidx.compose.material3.SingleChoiceSegmentedButtonRow -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.window.core.layout.WindowSizeClass -import com.ramcosta.composedestinations.annotation.Destination -import com.ramcosta.composedestinations.annotation.RootGraph -import com.ramcosta.composedestinations.navigation.DestinationsNavigator -import edu.rpi.shuttletracker.R -import edu.rpi.shuttletracker.data.models.Route -import edu.rpi.shuttletracker.ui.util.CheckResponseError -import edu.rpi.shuttletracker.ui.util.LabeledDropdown -import java.time.LocalTime -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.util.Calendar -import java.util.Locale - -@OptIn(ExperimentalMaterial3Api::class) -@Destination -@Composable -fun ScheduleScreen( - navigator: DestinationsNavigator, - viewModel: ScheduleViewModel = hiltViewModel(), - windowSizeClass: WindowSizeClass = currentWindowAdaptiveInfo().windowSizeClass, -) { - val scheduleUiState = viewModel.scheduleUiState.collectAsStateWithLifecycle().value - val routes = scheduleUiState.routes - - // Checks if height < 480 dp - val useHorizontalLayout = - !windowSizeClass - .isHeightAtLeastBreakpoint(WindowSizeClass.HEIGHT_DP_MEDIUM_LOWER_BOUND) - - val days = listOf("Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday") - val allowedRoutes = setOf("NORTH", "WEST") - - // Route dropdown values - val routeDropdownItems = remember(routes) { allowedRoutes.toList() } - var selectedRoute by remember(routeDropdownItems) { mutableStateOf(routeDropdownItems.first()) } - if (selectedRoute !in routeDropdownItems) selectedRoute = routeDropdownItems.first() - - val selectedStop = "All Stops" - - // Weekday dropdown values - val todayName = remember { days[Calendar.getInstance().get(Calendar.DAY_OF_WEEK) - 1] } - var selectedDay by remember { mutableStateOf(todayName) } - val dayIndex = remember(selectedDay) { days.indexOf(selectedDay).takeIf { it >= 0 } ?: 0 } - - // Gets schedule base times for the selected day and route (north/west) - val selectedRouteTimes: List = - remember(selectedDay, selectedRoute, scheduleUiState.schedule) { - val routeSchedule = scheduleUiState.schedule.getOrNull(dayIndex) - when (selectedRoute) { - "NORTH" -> routeSchedule?.north ?: emptyList() - "WEST" -> routeSchedule?.west ?: emptyList() - else -> emptyList() - } - } - Scaffold( - snackbarHost = { - CheckResponseError( - scheduleUiState.networkError, - scheduleUiState.serverError, - scheduleUiState.unknownError, - ignoreErrorRequest = { viewModel.clearErrors() }, - retryErrorRequest = { - viewModel.clearErrors() - viewModel.loadAll() - }, - ) - }, - topBar = { - TopAppBar( - title = { Text("Schedule") }, - navigationIcon = { - IconButton(onClick = { navigator.popBackStack() }) { - Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Back") - } - }, - ) - }, - ) { padding -> - if (useHorizontalLayout) { - Row( - modifier = - Modifier - .padding(padding) - .padding(horizontal = 16.dp, vertical = 12.dp) - .fillMaxSize(), - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - Column( - modifier = - Modifier - .widthIn(min = 260.dp, max = 360.dp) - .fillMaxHeight() - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Controls( - days = days, - selectedDay = selectedDay, - onDay = { selectedDay = it }, - routeItems = routeDropdownItems, - selectedRoute = selectedRoute, - onRoute = { selectedRoute = it }, - ) - } - - Box( - modifier = - Modifier - .fillMaxWidth() - .border(width = 1.dp, color = MaterialTheme.colorScheme.onPrimaryContainer) - .padding(8.dp), - ) { - ScheduleScroll( - selectedRouteTimes = selectedRouteTimes, - selectedStop = selectedStop, - routeData = routes[selectedRoute], - ) - } - } - } else { - Column( - modifier = - Modifier - .padding(padding) - .padding(horizontal = 16.dp, vertical = 12.dp) - .fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Controls( - days = days, - selectedDay = selectedDay, - onDay = { selectedDay = it }, - routeItems = routeDropdownItems, - selectedRoute = selectedRoute, - onRoute = { selectedRoute = it }, - ) - Box( - modifier = - Modifier - .fillMaxWidth() - .border(width = 1.dp, color = MaterialTheme.colorScheme.onPrimaryContainer) - .padding(8.dp), - ) { - ScheduleScroll( - selectedRouteTimes = selectedRouteTimes, - selectedStop = selectedStop, - routeData = routes[selectedRoute], - ) - } - } - } - } -} - -@Composable -private fun Controls( - days: List, - selectedDay: String, - onDay: (String) -> Unit, - routeItems: List, - selectedRoute: String, - onRoute: (String) -> Unit, -) { - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { - routeItems.forEachIndexed { index, option -> - SegmentedButton( - selected = selectedRoute == option, - onClick = { onRoute(option) }, - shape = SegmentedButtonDefaults.itemShape(index, routeItems.size), - label = { Text(option) }, - ) - } - } - LabeledDropdown( - label = "Weekday", - items = days, - selectedItem = selectedDay, - onItemSelected = onDay, - ) - } -} - -@Composable -private fun ScheduleScroll( - selectedRouteTimes: List, - selectedStop: String, - routeData: Route?, - centered: Boolean = false, -) { - val listState = rememberLazyListState() - val stops = routeData?.stopDetails?.values?.toList() ?: emptyList() - - val columnModifier = if (centered) Modifier.fillMaxSize() else Modifier - val columnHorizontal = if (centered) Alignment.CenterHorizontally else Alignment.Start - val columnVertical = if (centered) Arrangement.Center else Arrangement.Top - val textAlign = if (centered) TextAlign.Center else TextAlign.Start - - val reliableStops = listOf("All Stops", "Student Union") - - Column( - modifier = columnModifier, - horizontalAlignment = columnHorizontal, - verticalArrangement = columnVertical, - ) { - Text( - text = - if (selectedStop in reliableStops) { - stringResource(R.string.time_estimated_reliable) - } else { - stringResource(R.string.time_estimated_unreliable) - }, - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), - modifier = Modifier.padding(top = 4.dp, bottom = 8.dp), - ) - - if (selectedRouteTimes.isEmpty() || stops.isEmpty()) { - Text( - text = "Loading...", - modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp), - ) - return - } - - val formatterIn = remember { DateTimeFormatter.ofPattern("h:mm a", Locale.getDefault()) } - val formatterOut = remember { DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) } - - fun parseTime(timeStr: String): LocalTime = LocalTime.parse(timeStr.uppercase(Locale.getDefault()), formatterIn) - - val rowTimes = - remember(selectedRouteTimes, selectedStop, stops) { - if (selectedStop == "All Stops") { - selectedRouteTimes.map { baseStr -> - parseTime(baseStr) - } - } else { - val offset = stops.find { it.name == selectedStop }?.offset ?: 0 - selectedRouteTimes.map { baseStr -> parseTime(baseStr).plusMinutes(offset.toLong()) } - } - } - - val rowDisplay = - remember(rowTimes, selectedStop) { - rowTimes.map { time -> time.format(formatterOut) } - } - - // Auto-scroll to user time - LaunchedEffect(rowTimes, selectedStop) { - if (rowTimes.isEmpty()) return@LaunchedEffect - val now = LocalTime.now() - val firstUpcomingIndex = rowTimes.indexOfFirst { !it.isBefore(now) } - val scrollToIndex = if (firstUpcomingIndex >= 0) firstUpcomingIndex else rowTimes.lastIndex - if (scrollToIndex >= 0) listState.scrollToItem(scrollToIndex) - } - - LazyColumn( - modifier = Modifier.fillMaxSize(), - state = listState, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - items(rowDisplay) { line -> - if (selectedStop == "All Stops") { - Text( - text = "$line Student Union", - textAlign = textAlign, - modifier = - Modifier - .fillMaxWidth() - .padding( - start = 0.dp, - bottom = 4.dp, - ), - ) - stops - .filter { it.name != "Student Union" } - .forEach { stop -> - Text( - text = stop.name, - textAlign = textAlign, - modifier = - Modifier - .fillMaxWidth() - .padding( - start = if (!centered) 16.dp else 0.dp, - bottom = 2.dp, - ), - ) - } - } else { - Text( - text = line, - textAlign = textAlign, - modifier = - Modifier - .fillMaxWidth() - .padding( - bottom = 4.dp, - ), - ) - } - } - } - } -} diff --git a/app/src/main/java/edu/rpi/shuttletracker/ui/schedule/ScheduleViewModel.kt b/app/src/main/java/edu/rpi/shuttletracker/ui/schedule/ScheduleViewModel.kt deleted file mode 100644 index 22f19a4..0000000 --- a/app/src/main/java/edu/rpi/shuttletracker/ui/schedule/ScheduleViewModel.kt +++ /dev/null @@ -1,109 +0,0 @@ -package edu.rpi.shuttletracker.ui.schedule - -import androidx.compose.runtime.Immutable -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.haroldadmin.cnradapter.NetworkResponse -import dagger.hilt.android.lifecycle.HiltViewModel -import edu.rpi.shuttletracker.data.models.AggregatedSchedule -import edu.rpi.shuttletracker.data.models.ErrorResponse -import edu.rpi.shuttletracker.data.models.Route -import edu.rpi.shuttletracker.data.repositories.ApiRepository -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import javax.inject.Inject - -@HiltViewModel -class ScheduleViewModel - @Inject - constructor( - private val apiRepository: ApiRepository, - ) : ViewModel() { - // represents the ui state of the view - private val _scheduleUiState = MutableStateFlow(ScheduleUIState()) - val scheduleUiState: StateFlow = _scheduleUiState - - init { - loadAll() - } - - fun loadAll() { - if (scheduleUiState.value.schedule.isEmpty()) { - getAggregatedSchedule() - } - if (scheduleUiState.value.routes.isEmpty()) { - getRoutes() - } - } - - /** - * sets all the errors to none - * */ - fun clearErrors() { - loadAll() - _scheduleUiState.update { - it.copy( - unknownError = null, - networkError = null, - serverError = null, - ) - } - } - - private fun getAggregatedSchedule() { - viewModelScope.launch { - readApiResponse(apiRepository.getAggregatedSchedule()) { response -> - _scheduleUiState.update { - it.copy(schedule = response) - } - } - } - } - - private fun getRoutes() { - viewModelScope.launch { - readApiResponse(apiRepository.getRoutes()) { routes -> - _scheduleUiState.update { - it.copy(routes = routes) - } - } - } - } - - /** - * Reads the network response and maps it to correct place - * */ - private fun readApiResponse( - response: NetworkResponse, - success: (body: T) -> Unit, - ) { - when (response) { - is NetworkResponse.Success -> success(response.body) - is NetworkResponse.ServerError -> - _scheduleUiState.update { - it.copy(serverError = response) - } - - is NetworkResponse.NetworkError -> - _scheduleUiState.update { - it.copy(networkError = response) - } - - is NetworkResponse.UnknownError -> - _scheduleUiState.update { - it.copy(unknownError = response) - } - } - } - } - -@Immutable -data class ScheduleUIState( - val schedule: List = listOf(), - val routes: Map = emptyMap(), - val networkError: NetworkResponse.NetworkError<*, ErrorResponse>? = null, - val serverError: NetworkResponse.ServerError<*, ErrorResponse>? = null, - val unknownError: NetworkResponse.UnknownError<*, ErrorResponse>? = null, -) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 406a42d..e788175 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -91,7 +91,7 @@ Go to settings to enable permissions To settings Not now - + Permission denied. Permission granted. Grant permissions @@ -107,13 +107,17 @@ Shuttle %1$s %.1f mph Unable to determine your location - "Student Union Shuttle Arrivals" - "Chasan stop available M–F, 7:00 AM – 5:30 PM" Select a route No schedule found No shuttles running %1$s Route + + Schedule + Times are based on departures from the Student Union. + Arrival times at other stops are estimated using fixed offsets and may not be exact. + No stop times available + I understand Network error @@ -148,18 +152,5 @@ Maximum stop distance - - - Effective from %1$s to %2$s - Monday - Tuesday - Wednesday - Thursday - Friday - Saturday - Sunday - Estimated Time - Estimated Time (do not rely on) - \ No newline at end of file