diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 51ffcdcc..6ca7c4a0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -45,24 +45,18 @@ android { defaultConfig { applicationId = "edu.rpi.shuttletracker" minSdk = 26 - targetSdk = 36 + targetSdk = 37 versionCode = 17 versionName = "2.5.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - vectorDrawables { - useSupportLibrary = true - } } buildTypes { release { isMinifyEnabled = true isShrinkResources = true - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro", - ) + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt")) } debug { @@ -101,7 +95,6 @@ dependencies { androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.junit4) - debugImplementation(libs.androidx.ui.tooling.preview) debugImplementation(libs.androidx.ui.test.manifest) debugImplementation(libs.androidx.ui.tooling) @@ -109,7 +102,6 @@ dependencies { implementation(libs.hilt.android) ksp(libs.hilt.compiler) implementation(libs.androidx.hilt.lifecycle.viewmodel.compose) - ksp(libs.androidx.hilt.compiler) // retrofit implementation(libs.retrofit) @@ -119,7 +111,6 @@ dependencies { implementation(libs.logging.interceptor) // compose - implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.viewmodel.navigation3) implementation(libs.androidx.lifecycle.runtime.compose) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro deleted file mode 100644 index 7cb931b8..00000000 --- a/app/proguard-rules.pro +++ /dev/null @@ -1,21 +0,0 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle.kts. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/edu/rpi/shuttletracker/feature/map/MapsScreenNavigationTest.kt b/app/src/androidTest/java/edu/rpi/shuttletracker/feature/map/MapsScreenNavigationTest.kt index a9034fc2..edb674a7 100644 --- a/app/src/androidTest/java/edu/rpi/shuttletracker/feature/map/MapsScreenNavigationTest.kt +++ b/app/src/androidTest/java/edu/rpi/shuttletracker/feature/map/MapsScreenNavigationTest.kt @@ -8,7 +8,6 @@ import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 import edu.rpi.shuttletracker.core.network.NetworkResult import edu.rpi.shuttletracker.core.ui.theme.ShuttleTrackerTheme -import edu.rpi.shuttletracker.feature.etas.EtasViewModel import edu.rpi.shuttletracker.feature.schedule.ScheduleViewModel import edu.rpi.shuttletracker.testing.fakes.FakeShuttleRepository import edu.rpi.shuttletracker.testing.fakes.FakeUserPreferences @@ -82,7 +81,6 @@ class MapsScreenNavigationTest { val preferences = FakeUserPreferences() val viewModel = MapsViewModel(repository, preferences) val scheduleViewModel = ScheduleViewModel(repository) - val etasViewModel = EtasViewModel(repository, preferences) composeRule.setContent { ShuttleTrackerTheme(dynamicColor = false) { @@ -90,7 +88,6 @@ class MapsScreenNavigationTest { onOpenSettings = {}, viewModel = viewModel, scheduleViewModel = scheduleViewModel, - etasViewModel = etasViewModel, ) } } diff --git a/app/src/androidTest/java/edu/rpi/shuttletracker/feature/settings/about/AboutScreenTest.kt b/app/src/androidTest/java/edu/rpi/shuttletracker/feature/settings/about/AboutScreenTest.kt index 97206ef0..6c6541a7 100644 --- a/app/src/androidTest/java/edu/rpi/shuttletracker/feature/settings/about/AboutScreenTest.kt +++ b/app/src/androidTest/java/edu/rpi/shuttletracker/feature/settings/about/AboutScreenTest.kt @@ -61,7 +61,6 @@ class AboutScreenTest { ShuttleTrackerTheme(dynamicColor = false) { AboutScreen( onBack = {}, - onOpenLibraries = {}, viewModel = remember { AboutViewModel(preferences) }, ) } diff --git a/app/src/androidTest/java/edu/rpi/shuttletracker/testing/fakes/FakeShuttleRepository.kt b/app/src/androidTest/java/edu/rpi/shuttletracker/testing/fakes/FakeShuttleRepository.kt index d0cf1385..b9c48819 100644 --- a/app/src/androidTest/java/edu/rpi/shuttletracker/testing/fakes/FakeShuttleRepository.kt +++ b/app/src/androidTest/java/edu/rpi/shuttletracker/testing/fakes/FakeShuttleRepository.kt @@ -22,7 +22,6 @@ class FakeShuttleRepository : ShuttleRepository { val announcements = MutableSharedFlow>>(replay = 1) var routesResult: NetworkResult> = NetworkResult.Success(emptyMap()) - var announcementsResult: NetworkResult> = NetworkResult.Success(emptyList()) var scheduleResult: NetworkResult? = null var observeLocationsCalls = 0 @@ -30,7 +29,6 @@ class FakeShuttleRepository : ShuttleRepository { var observeVelocitiesCalls = 0 var observeAnnouncementsCalls = 0 var routesCalls = 0 - var announcementsCalls = 0 var scheduleCalls = 0 override fun observeVehicleLocations(pollMs: Long): Flow>> { @@ -58,11 +56,6 @@ class FakeShuttleRepository : ShuttleRepository { return routesResult } - override suspend fun getAnnouncements(): NetworkResult> { - announcementsCalls++ - return announcementsResult - } - override suspend fun getSchedule(): NetworkResult { scheduleCalls++ return checkNotNull(scheduleResult) { "Set scheduleResult before creating a view model" } diff --git a/app/src/androidTest/java/edu/rpi/shuttletracker/testing/fixtures/TestModels.kt b/app/src/androidTest/java/edu/rpi/shuttletracker/testing/fixtures/TestModels.kt index 36f2b510..17f6eb6f 100644 --- a/app/src/androidTest/java/edu/rpi/shuttletracker/testing/fixtures/TestModels.kt +++ b/app/src/androidTest/java/edu/rpi/shuttletracker/testing/fixtures/TestModels.kt @@ -12,7 +12,6 @@ fun testRoute() = Route( color = "#D32F2F", stops = listOf("union", "academy"), - polylineStops = emptyList(), coordinates = listOf(listOf(listOf(42.730, -73.680), listOf(42.731, -73.679))), stopDetails = mapOf( diff --git a/app/src/main/java/edu/rpi/shuttletracker/app/MainActivity.kt b/app/src/main/java/edu/rpi/shuttletracker/app/MainActivity.kt index 35499859..0a258f01 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/app/MainActivity.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/app/MainActivity.kt @@ -1,7 +1,6 @@ package edu.rpi.shuttletracker.app import android.content.Intent -import android.net.Uri import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -14,6 +13,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle import dagger.hilt.android.AndroidEntryPoint import edu.rpi.shuttletracker.app.navigation.AppNavigation @@ -98,7 +98,7 @@ class MainActivity : ComponentActivity() { when (val destination = resolveNotificationTapDestination(url)) { is NotificationTapDestination.ExternalUrl -> - startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(destination.url))) + startActivity(Intent(Intent.ACTION_VIEW, destination.url.toUri())) NotificationTapDestination.Map -> Unit } } diff --git a/app/src/main/java/edu/rpi/shuttletracker/app/di/NetworkModule.kt b/app/src/main/java/edu/rpi/shuttletracker/app/di/NetworkModule.kt index 3f214b8f..aaa2e873 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/app/di/NetworkModule.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/app/di/NetworkModule.kt @@ -64,23 +64,15 @@ object NetworkModule { val cacheSize = (5 * 1024 * 1024).toLong() val myCache = Cache(context.cacheDir, cacheSize) - return if (BuildConfig.DEBUG) { - val loggingInterceptor = HttpLoggingInterceptor() - - loggingInterceptor.level = HttpLoggingInterceptor.Level.BODY - OkHttpClient - .Builder() - .cache(myCache) - .addInterceptor(cacheInterceptor) - .addInterceptor(loggingInterceptor) - .build() - } else { - OkHttpClient - .Builder() - .cache(myCache) - .addInterceptor(cacheInterceptor) - .build() - } + return OkHttpClient + .Builder() + .cache(myCache) + .addInterceptor(cacheInterceptor) + .apply { + if (BuildConfig.DEBUG) { + addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)) + } + }.build() } @Provides diff --git a/app/src/main/java/edu/rpi/shuttletracker/app/navigation/AppNavigation.kt b/app/src/main/java/edu/rpi/shuttletracker/app/navigation/AppNavigation.kt index 1e18e7f0..9885fe06 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/app/navigation/AppNavigation.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/app/navigation/AppNavigation.kt @@ -10,7 +10,6 @@ import androidx.navigation3.ui.NavDisplay import edu.rpi.shuttletracker.feature.map.MapsScreen import edu.rpi.shuttletracker.feature.settings.SettingsScreen import edu.rpi.shuttletracker.feature.settings.about.AboutScreen -import edu.rpi.shuttletracker.feature.settings.about.LibrariesScreen import edu.rpi.shuttletracker.feature.settings.developerMenu.DevMenuScreen import edu.rpi.shuttletracker.feature.setup.SetupScreen import kotlinx.serialization.Serializable @@ -27,9 +26,6 @@ private data object SettingsRoute : NavKey @Serializable private data object AboutRoute : NavKey -@Serializable -private data object LibrariesRoute : NavKey - @Serializable private data object DeveloperOptionsRoute : NavKey @@ -90,13 +86,7 @@ fun AppNavigation(setupCompleted: Boolean) { ) } entry { - AboutScreen( - onBack = ::navigateBack, - onOpenLibraries = { navigateTo(LibrariesRoute) }, - ) - } - entry { - LibrariesScreen(onOpened = ::navigateBack) + AboutScreen(onBack = ::navigateBack) } entry { DevMenuScreen(onBack = ::navigateBack) diff --git a/app/src/main/java/edu/rpi/shuttletracker/core/ui/Errors.kt b/app/src/main/java/edu/rpi/shuttletracker/core/ui/Errors.kt index b0d7ee91..94f499a6 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/core/ui/Errors.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/core/ui/Errors.kt @@ -17,55 +17,45 @@ import edu.rpi.shuttletracker.core.network.NetworkError * time), and calls back so the ViewModel can clear or retry. See `MapsScreen`/`ScheduleScreen` for * how features wire this up. * - * @param networkError: a network error, null if none - * @param serverError: a server error, null if none - * @param unknownError: an unknown error, null if none + * @param error a network error, null if none * * @param ignoreErrorRequest: what happens when error is ignored * @param retryErrorRequest: what happens when you want to retry what caused the error * */ @Composable fun CheckResponseError( - networkError: NetworkError.Connectivity? = null, - serverError: NetworkError.Http? = null, - unknownError: NetworkError.Unknown? = null, + error: NetworkError? = null, ignoreErrorRequest: () -> Unit = {}, retryErrorRequest: () -> Unit = {}, ) { val networkMessage = stringResource(R.string.error_network) val serverMessage = stringResource(R.string.error_server) val unknownMessage = stringResource(R.string.error_unknown) - val activeError = - when { - networkError != null -> - ErrorContent( - networkError, - networkMessage, - when (networkError) { - is NetworkError.NoConnection -> networkError.cause?.message.orEmpty() - is NetworkError.Timeout -> networkError.cause?.message.orEmpty() - }, - ) - serverError != null -> ErrorContent(serverError, serverMessage, serverError.displayMessage) - unknownError != null -> ErrorContent(unknownError, unknownMessage, unknownError.displayMessage) - else -> null + val errorType = + when (error) { + is NetworkError.Connectivity -> networkMessage + is NetworkError.Http -> serverMessage + is NetworkError.Unknown -> unknownMessage + null -> "" + } + val errorBody = + when (error) { + is NetworkError.NoConnection -> error.cause?.message.orEmpty() + is NetworkError.Timeout -> error.cause?.message.orEmpty() + is NetworkError.Http -> error.displayMessage + is NetworkError.Unknown -> error.displayMessage + null -> "" } Error( - error = activeError?.error, + error = error, onPrimaryRequest = retryErrorRequest, onDismissRequest = ignoreErrorRequest, - errorType = activeError?.type.orEmpty(), - errorBody = activeError?.body.orEmpty(), + errorType = errorType, + errorBody = errorBody, ) } -private data class ErrorContent( - val error: Any, - val type: String, - val body: String, -) - /** * @param error: the error you want to display * @param onPrimaryRequest: what happens when you want to retry what caused the error diff --git a/app/src/main/java/edu/rpi/shuttletracker/core/ui/theme/Theme.kt b/app/src/main/java/edu/rpi/shuttletracker/core/ui/theme/Theme.kt index 21832389..b61294d2 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/core/ui/theme/Theme.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/core/ui/theme/Theme.kt @@ -114,7 +114,6 @@ fun ShuttleTrackerTheme( MaterialTheme( colorScheme = colorScheme, - typography = Typography, content = content, ) } diff --git a/app/src/main/java/edu/rpi/shuttletracker/core/ui/theme/Type.kt b/app/src/main/java/edu/rpi/shuttletracker/core/ui/theme/Type.kt deleted file mode 100644 index 59c5c8d6..00000000 --- a/app/src/main/java/edu/rpi/shuttletracker/core/ui/theme/Type.kt +++ /dev/null @@ -1,6 +0,0 @@ -package edu.rpi.shuttletracker.core.ui.theme - -import androidx.compose.material3.Typography - -/** The app's text styles. Currently just Material 3's defaults - customize here if that changes. */ -val Typography = Typography() diff --git a/app/src/main/java/edu/rpi/shuttletracker/core/util/UrlValidation.kt b/app/src/main/java/edu/rpi/shuttletracker/core/util/UrlValidation.kt index de603185..e07a7232 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/core/util/UrlValidation.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/core/util/UrlValidation.kt @@ -4,13 +4,13 @@ import java.net.URI import java.net.URISyntaxException /** - * Only `http`/`https` URLs with a host are safe to open; anything else (custom schemes, relative + * Only `https` URLs with a host are safe to open; anything else (unencrypted/custom schemes, relative * paths, malformed URIs) is rejected rather than crashing or launching an unintended target. * */ fun isSafeHttpUrl(url: String): Boolean = try { val uri = URI(url) - uri.scheme?.lowercase() in setOf("http", "https") && !uri.host.isNullOrBlank() + uri.scheme.equals("https", ignoreCase = true) && !uri.host.isNullOrBlank() } catch (_: URISyntaxException) { false } catch (_: IllegalArgumentException) { diff --git a/app/src/main/java/edu/rpi/shuttletracker/data/mapper/ShuttleMappers.kt b/app/src/main/java/edu/rpi/shuttletracker/data/mapper/ShuttleMappers.kt index 4a28691e..2b32c716 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/data/mapper/ShuttleMappers.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/data/mapper/ShuttleMappers.kt @@ -30,7 +30,7 @@ fun VehicleLocationDto.toModel(): VehicleLocation { return VehicleLocation(name, latitude, longitude, speedMph, timestamp, headingDegrees) } -fun VehicleStopEtaDto.toModel() = VehicleStopEta(stopTimes, timestamp) +fun VehicleStopEtaDto.toModel() = VehicleStopEta(stopTimes) fun VehicleVelocitiesDto.toModel() = VehicleVelocities(routeName, isAtStop, currentStop) @@ -41,7 +41,7 @@ fun StopDto.toModel(): Stop { return Stop(coordinates, offset, name) } -fun RouteDto.toModel() = Route(color, stops, polylineStops, coordinates, stopDetails.mapValues { it.value.toModel() }) +fun RouteDto.toModel() = Route(color, stops, coordinates, stopDetails.mapValues { it.value.toModel() }) fun AnnouncementDto.toModel(): Announcement = Announcement( 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 fd3e0f58..2f25674c 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 @@ -10,7 +10,6 @@ import com.google.android.gms.maps.model.LatLng data class Route( val color: String, val stops: List, - val polylineStops: List, val coordinates: List>>, val stopDetails: Map, ) { diff --git a/app/src/main/java/edu/rpi/shuttletracker/data/models/Schedule.kt b/app/src/main/java/edu/rpi/shuttletracker/data/models/Schedule.kt index 6e81ca63..bdbf2276 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/data/models/Schedule.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/data/models/Schedule.kt @@ -1,6 +1,6 @@ package edu.rpi.shuttletracker.data.models -import java.util.Calendar +import java.time.DayOfWeek /** * The weekly shuttle schedule. Each day of the week names a schedule *type* ("weekday", @@ -41,25 +41,3 @@ data class Schedule( else -> emptyMap() } } - -/** Wraps [java.util.Calendar]'s day constants so the rest of the app never has to touch them directly. */ -enum class DayOfWeek( - val displayName: String, - val calendarConst: Int, -) { - MONDAY("Mon", Calendar.MONDAY), - TUESDAY("Tue", Calendar.TUESDAY), - WEDNESDAY("Wed", Calendar.WEDNESDAY), - THURSDAY("Thu", Calendar.THURSDAY), - FRIDAY("Fri", Calendar.FRIDAY), - SATURDAY("Sat", Calendar.SATURDAY), - SUNDAY("Sun", Calendar.SUNDAY), - ; - - companion object { - fun fromToday(): DayOfWeek { - val today = Calendar.getInstance().get(Calendar.DAY_OF_WEEK) - return entries.firstOrNull { it.calendarConst == today } ?: MONDAY - } - } -} 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 index 35027d74..926b7ee8 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/data/models/Vehicle.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/data/models/Vehicle.kt @@ -83,7 +83,6 @@ data class VehicleLocation( /** The `/etas` endpoint's data, one per vehicle: its live ETA at each stop it's approaching. */ data class VehicleStopEta( val stopTimes: Map, - val timestamp: String, ) /** The `/velocities` endpoint's data, one per vehicle: which route it's on and its stop status. */ diff --git a/app/src/main/java/edu/rpi/shuttletracker/data/remote/dto/ShuttleDtos.kt b/app/src/main/java/edu/rpi/shuttletracker/data/remote/dto/ShuttleDtos.kt index df2e4af6..0509a061 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/data/remote/dto/ShuttleDtos.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/data/remote/dto/ShuttleDtos.kt @@ -95,6 +95,5 @@ data class ScheduleDto( /** The body of a non-2xx API response, when the backend sends one. */ @Serializable data class ErrorResponse( - val error: Boolean = false, val reason: String? = null, ) diff --git a/app/src/main/java/edu/rpi/shuttletracker/data/remote/dto/VehicleDtos.kt b/app/src/main/java/edu/rpi/shuttletracker/data/remote/dto/VehicleDtos.kt index d9c399fe..b6e1deb5 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/data/remote/dto/VehicleDtos.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/data/remote/dto/VehicleDtos.kt @@ -20,7 +20,6 @@ data class VehicleLocationDto( @Serializable data class VehicleStopEtaDto( @SerialName("stop_times") val stopTimes: Map, - val timestamp: String, ) @Serializable diff --git a/app/src/main/java/edu/rpi/shuttletracker/data/repository/DefaultShuttleRepository.kt b/app/src/main/java/edu/rpi/shuttletracker/data/repository/DefaultShuttleRepository.kt index b60115e3..bcf1a981 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/data/repository/DefaultShuttleRepository.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/data/repository/DefaultShuttleRepository.kt @@ -52,7 +52,5 @@ class DefaultShuttleRepository override suspend fun getRoutes() = remoteDataSource.getRoutes() - override suspend fun getAnnouncements() = remoteDataSource.getAnnouncements() - override suspend fun getSchedule() = remoteDataSource.getSchedule() } diff --git a/app/src/main/java/edu/rpi/shuttletracker/data/repository/ShuttleRepository.kt b/app/src/main/java/edu/rpi/shuttletracker/data/repository/ShuttleRepository.kt index 056c103b..d4966d24 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/data/repository/ShuttleRepository.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/data/repository/ShuttleRepository.kt @@ -25,7 +25,5 @@ interface ShuttleRepository { suspend fun getRoutes(): NetworkResult> - suspend fun getAnnouncements(): NetworkResult> - suspend fun getSchedule(): NetworkResult } diff --git a/app/src/main/java/edu/rpi/shuttletracker/feature/etas/EtasScreen.kt b/app/src/main/java/edu/rpi/shuttletracker/feature/etas/EtasScreen.kt index 3f2a5d13..22ba9d91 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/feature/etas/EtasScreen.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/feature/etas/EtasScreen.kt @@ -1,20 +1,18 @@ package edu.rpi.shuttletracker.feature.etas import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Scaffold import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.compose.LifecycleStartEffect -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import edu.rpi.shuttletracker.core.ui.CheckResponseError +import edu.rpi.shuttletracker.data.models.Route +import edu.rpi.shuttletracker.data.models.Vehicle import edu.rpi.shuttletracker.feature.etas.components.StopEtaList import edu.rpi.shuttletracker.feature.etas.components.StopEtaSheet import edu.rpi.shuttletracker.feature.etas.utils.buildStopsWithEtas @@ -27,59 +25,33 @@ import edu.rpi.shuttletracker.feature.etas.utils.buildStopsWithEtas @OptIn(ExperimentalMaterial3Api::class) @Composable fun EtasScreen( - viewModel: EtasViewModel = hiltViewModel(), + routes: Map, + vehicles: List, + routesLoaded: Boolean, showTitle: Boolean = true, ) { - val uiState by viewModel.etasUiState.collectAsStateWithLifecycle() val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var selectedRouteFilter by rememberSaveable { mutableStateOf(null) } + var selectedStopKey by rememberSaveable { mutableStateOf(null) } - LifecycleStartEffect(viewModel) { - viewModel.startVehiclePolling() - onStopOrDispose { - viewModel.stopVehiclePolling() - } - } - - Scaffold( - contentWindowInsets = WindowInsets(0, 0, 0, 0), - snackbarHost = { - CheckResponseError( - uiState.networkError, - uiState.serverError, - uiState.unknownError, - ignoreErrorRequest = viewModel::clearErrors, - retryErrorRequest = viewModel::retry, - ) - }, - ) { contentPadding -> - Box( - Modifier - .fillMaxSize() - .padding(contentPadding), - ) { - val allVehicles = uiState.vehicles + uiState.fakeVehicles - - StopEtaList( - routes = uiState.routes, - vehicles = allVehicles, - routesLoaded = uiState.routesLoaded, - selectedRouteFilter = uiState.selectedRouteFilter, - onRouteFilterChange = viewModel::selectRouteFilter, - onStopClick = viewModel::selectStop, - showTitle = showTitle, - ) + Box(Modifier.fillMaxSize()) { + StopEtaList( + routes = routes, + vehicles = vehicles, + routesLoaded = routesLoaded, + selectedRouteFilter = selectedRouteFilter, + onRouteFilterChange = { selectedRouteFilter = it }, + onStopClick = { selectedStopKey = it }, + showTitle = showTitle, + ) - val stops = - remember(uiState.routes, allVehicles) { - buildStopsWithEtas(uiState.routes, allVehicles) - } - val selectedStop = uiState.selectedStopKey?.let { stopKey -> stops.find { it.stopKey == stopKey } } + val stops = remember(routes, vehicles) { buildStopsWithEtas(routes, vehicles) } + val selectedStop = selectedStopKey?.let { stopKey -> stops.find { it.stopKey == stopKey } } - StopEtaSheet( - stop = selectedStop, - sheetState = sheetState, - onDismiss = { viewModel.selectStop(null) }, - ) - } + StopEtaSheet( + stop = selectedStop, + sheetState = sheetState, + onDismiss = { selectedStopKey = null }, + ) } } diff --git a/app/src/main/java/edu/rpi/shuttletracker/feature/etas/EtasViewModel.kt b/app/src/main/java/edu/rpi/shuttletracker/feature/etas/EtasViewModel.kt deleted file mode 100644 index 3163de2c..00000000 --- a/app/src/main/java/edu/rpi/shuttletracker/feature/etas/EtasViewModel.kt +++ /dev/null @@ -1,205 +0,0 @@ -package edu.rpi.shuttletracker.feature.etas - -import androidx.compose.runtime.Immutable -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import edu.rpi.shuttletracker.core.network.NetworkError -import edu.rpi.shuttletracker.core.network.NetworkResult -import edu.rpi.shuttletracker.data.local.preferences.UserPreferences -import edu.rpi.shuttletracker.data.models.Route -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.repository.ShuttleRepository -import edu.rpi.shuttletracker.feature.map.utils.buildFakeVehicles -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import javax.inject.Inject - -private const val FAKE_VEHICLE_TICK_MS = 1_000L - -/** - * Backs [EtasScreen]. Loads [routes] once (like [edu.rpi.shuttletracker.feature.map.MapsViewModel] - * does), and polls the same three vehicle endpoints while [startVehiclePolling] is active, merging - * them into [EtasUiState.vehicles]. [feature.etas.utils.buildStopsWithEtas] then turns routes + - * vehicles into the per-stop list the screen shows. [EtasUiState.fakeVehicles] mirrors - * [edu.rpi.shuttletracker.feature.map.MapsViewModel]'s dev-mode fake shuttles. - * */ -@HiltViewModel -class EtasViewModel - @Inject - constructor( - private val shuttleRepository: ShuttleRepository, - private val userPreferences: UserPreferences, - ) : ViewModel() { - private val _etasUiState = MutableStateFlow(EtasUiState()) - val etasUiState: StateFlow = _etasUiState.asStateFlow() - private var vehiclePollingJob: Job? = null - private var routesJob: Job? = null - private var fakeVehiclesJob: Job? = null - - init { - if (!etasUiState.value.routesLoaded) loadRoutes() - - combine( - userPreferences.getDevOptions(), - userPreferences.getFakeShuttlesEnabled(), - ) { devOptionsEnabled, fakeShuttlesEnabled -> devOptionsEnabled && fakeShuttlesEnabled } - .distinctUntilChanged() - .onEach { fakeShuttlesActive -> - if (fakeShuttlesActive) startFakeVehicles() else stopFakeVehicles() - }.launchIn(viewModelScope) - } - - /** Mirrors [edu.rpi.shuttletracker.feature.map.MapsViewModel.startFakeVehicles]. */ - private fun startFakeVehicles() { - if (fakeVehiclesJob?.isActive == true) return - - fakeVehiclesJob = - viewModelScope.launch { - var elapsedMs = 0L - while (isActive) { - val fakeVehicles = buildFakeVehicles(etasUiState.value.routes, elapsedMs) - - _etasUiState.update { it.copy(fakeVehicles = fakeVehicles) } - - delay(FAKE_VEHICLE_TICK_MS) - elapsedMs += FAKE_VEHICLE_TICK_MS - } - } - } - - private fun stopFakeVehicles() { - fakeVehiclesJob?.cancel() - fakeVehiclesJob = null - _etasUiState.update { it.copy(fakeVehicles = emptyList()) } - } - - fun startVehiclePolling() { - if (vehiclePollingJob?.isActive == true) return - - vehiclePollingJob = - combine( - shuttleRepository.observeVehicleLocations(pollMs = 5_000L), - shuttleRepository.observeVehicleEtas(pollMs = 5_000L), - shuttleRepository.observeVehicleVelocities(pollMs = 5_000L), - ) { locationsResponse, etasResponse, velocitiesResponse -> - Triple(locationsResponse, etasResponse, velocitiesResponse) - }.onEach { (locationsResponse, etasResponse, velocitiesResponse) -> - var locations: Map = emptyMap() - var etas: Map = emptyMap() - var velocities: Map = emptyMap() - - // A live response this cycle proves whatever was wrong last cycle isn't - // blocking us now - cleared here (not in readApiResponse) since that's shared - // with the independent routes load, which shouldn't affect it. - if (locationsResponse is NetworkResult.Success || - etasResponse is NetworkResult.Success || - velocitiesResponse is NetworkResult.Success - ) { - _etasUiState.update { - it.copy(networkError = null, serverError = null, unknownError = null) - } - } - - readApiResponse(locationsResponse) { locations = it } - readApiResponse(etasResponse) { etas = it } - readApiResponse(velocitiesResponse) { velocities = it } - - _etasUiState.update { - it.copy( - vehicles = - VehicleMerger.merge( - locations = locations, - velocities = velocities, - etas = etas, - ), - ) - } - }.launchIn(viewModelScope) - } - - fun stopVehiclePolling() { - vehiclePollingJob?.cancel() - vehiclePollingJob = null - } - - fun selectRouteFilter(routeName: String?) { - _etasUiState.update { it.copy(selectedRouteFilter = routeName) } - } - - fun selectStop(stopKey: String?) { - _etasUiState.update { it.copy(selectedStopKey = stopKey) } - } - - fun clearErrors() { - _etasUiState.update { - it.copy( - unknownError = null, - networkError = null, - serverError = null, - ) - } - } - - fun retry() { - clearErrors() - if (!etasUiState.value.routesLoaded) loadRoutes() - } - - private fun loadRoutes() { - if (routesJob?.isActive == true) return - routesJob = - viewModelScope.launch { - readApiResponse(shuttleRepository.getRoutes()) { routes -> - _etasUiState.update { - it.copy(routes = routes, routesLoaded = true) - } - } - } - } - - private fun readApiResponse( - response: NetworkResult, - success: (body: T) -> Unit, - ) { - when (response) { - is NetworkResult.Success -> success(response.data) - is NetworkResult.Failure -> - when (val error = response.error) { - is NetworkError.Connectivity -> - _etasUiState.update { it.copy(networkError = error) } - is NetworkError.Http -> - _etasUiState.update { it.copy(serverError = error) } - is NetworkError.Unknown -> - _etasUiState.update { it.copy(unknownError = error) } - } - } - } - } - -@Immutable -data class EtasUiState( - val vehicles: List = emptyList(), - val fakeVehicles: List = emptyList(), - val routes: Map = emptyMap(), - val routesLoaded: Boolean = false, - val selectedRouteFilter: String? = null, - val selectedStopKey: String? = null, - val networkError: NetworkError.Connectivity? = null, - val serverError: NetworkError.Http? = null, - val unknownError: NetworkError.Unknown? = null, -) diff --git a/app/src/main/java/edu/rpi/shuttletracker/feature/map/MapContent.kt b/app/src/main/java/edu/rpi/shuttletracker/feature/map/MapContent.kt index 1f8ec6ac..af08a674 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/feature/map/MapContent.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/feature/map/MapContent.kt @@ -1,6 +1,7 @@ package edu.rpi.shuttletracker.feature.map import android.Manifest +import android.content.Context import android.content.pm.PackageManager import android.location.Location import androidx.compose.foundation.isSystemInDarkTheme @@ -15,6 +16,7 @@ import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf @@ -30,14 +32,15 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.core.app.ActivityCompat import androidx.core.graphics.toColorInt +import androidx.lifecycle.compose.LifecycleResumeEffect import com.google.android.gms.location.LocationServices import com.google.android.gms.maps.CameraUpdateFactory import com.google.android.gms.maps.model.CameraPosition import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps.model.LatLngBounds import com.google.android.gms.maps.model.MapStyleOptions +import com.google.maps.android.compose.CameraMoveStartedReason import com.google.maps.android.compose.GoogleMap -import com.google.maps.android.compose.MapEffect import com.google.maps.android.compose.MapProperties import com.google.maps.android.compose.MapType import com.google.maps.android.compose.MapUiSettings @@ -48,7 +51,6 @@ import edu.rpi.shuttletracker.data.models.Stop import edu.rpi.shuttletracker.feature.map.components.AnnouncementStrip import edu.rpi.shuttletracker.feature.map.components.DeveloperVehicleView import kotlinx.coroutines.launch -import com.google.android.gms.maps.GoogleMap as AndroidGoogleMap private val CampusCenter = LatLng(42.73068146020498, -73.67619731950525) private val CampusBounds = @@ -68,8 +70,8 @@ private const val PAN_DETECTION_THRESHOLD_METERS = 20f * Mirrors the stock Google Maps app's location FAB: [NotFollowing] until tapped, then * [Following] the user north-up, then [FollowingTilted] into a 3D perspective on a second tap. * A user gesture that actually re-targets the camera (a pan) drops back to [NotFollowing]; a - * gesture that only changes zoom/rotation in place does not (see the `MapEffect` in - * [ShuttleMap]) so the button never claims to be following a camera the user just took over. + * gesture that only changes zoom/rotation in place does not, so the button never claims to be + * following a camera the user just took over. * */ private enum class LocationFollowMode { NotFollowing, @@ -92,16 +94,11 @@ internal fun ShuttleMap( ) { val context = LocalContext.current val coroutineScope = rememberCoroutineScope() - val hasLocationPermission = - remember { - listOf( - Manifest.permission.ACCESS_FINE_LOCATION, - Manifest.permission.ACCESS_COARSE_LOCATION, - ).any { permission -> - ActivityCompat.checkSelfPermission(context, permission) == - PackageManager.PERMISSION_GRANTED - } - } + var hasLocationPermission by remember(context) { mutableStateOf(context.hasLocationPermission()) } + LifecycleResumeEffect(context) { + hasLocationPermission = context.hasLocationPermission() + onPauseOrDispose {} + } val cameraPositionState = rememberCameraPositionState { position = CameraPosition.fromLatLngZoom(CampusCenter, 14.3f) @@ -114,6 +111,30 @@ internal fun ShuttleMap( val useDarkMap = uiState.themeMode.isDarkTheme(isSystemInDarkTheme()) val fallbackRouteColor = MaterialTheme.colorScheme.primary + LaunchedEffect(cameraPositionState.isMoving) { + if (cameraPositionState.isMoving) { + if (cameraPositionState.cameraMoveStartedReason == CameraMoveStartedReason.GESTURE) { + gestureStartTarget = cameraPositionState.position.target + } + return@LaunchedEffect + } + + val start = gestureStartTarget ?: return@LaunchedEffect + gestureStartTarget = null + val end = cameraPositionState.position.target + val distanceMeters = FloatArray(1) + Location.distanceBetween( + start.latitude, + start.longitude, + end.latitude, + end.longitude, + distanceMeters, + ) + if (distanceMeters[0] > PAN_DETECTION_THRESHOLD_METERS) { + followMode = LocationFollowMode.NotFollowing + } + } + Box(Modifier.fillMaxSize()) { GoogleMap( modifier = Modifier.fillMaxSize(), @@ -139,33 +160,6 @@ internal fun ShuttleMap( myLocationButtonEnabled = false, ), ) { - // Only drop follow mode for a real pan, not an in-place pinch-zoom/rotate - compare - // the target when a gesture starts vs. where it lands. - MapEffect(Unit) { map -> - map.setOnCameraMoveStartedListener { reason -> - if (reason == AndroidGoogleMap.OnCameraMoveStartedListener.REASON_GESTURE) { - gestureStartTarget = map.cameraPosition.target - } - } - map.setOnCameraIdleListener { - val start = gestureStartTarget ?: return@setOnCameraIdleListener - gestureStartTarget = null - - val end = map.cameraPosition.target - val distanceMeters = FloatArray(1) - Location.distanceBetween( - start.latitude, - start.longitude, - end.latitude, - end.longitude, - distanceMeters, - ) - if (distanceMeters[0] > PAN_DETECTION_THRESHOLD_METERS) { - followMode = LocationFollowMode.NotFollowing - } - } - } - val uniqueStops = uiState.routes.values .flatMap { it.stopDetails.values } @@ -302,26 +296,30 @@ internal fun ShuttleMap( when (followMode) { LocationFollowMode.NotFollowing -> { - LocationServices - .getFusedLocationProviderClient(context) - .lastLocation - .addOnSuccessListener { location: Location? -> - location ?: return@addOnSuccessListener - coroutineScope.launch { - cameraPositionState.animate( - CameraUpdateFactory.newCameraPosition( - CameraPosition( - LatLng(location.latitude, location.longitude), - cameraPositionState.position.zoom, - 0f, - 0f, + try { + LocationServices + .getFusedLocationProviderClient(context) + .lastLocation + .addOnSuccessListener { location: Location? -> + location ?: return@addOnSuccessListener + coroutineScope.launch { + cameraPositionState.animate( + CameraUpdateFactory.newCameraPosition( + CameraPosition( + LatLng(location.latitude, location.longitude), + cameraPositionState.position.zoom, + 0f, + 0f, + ), ), - ), - durationMs = 1000, - ) + durationMs = 1000, + ) + } + followMode = LocationFollowMode.Following } - followMode = LocationFollowMode.Following - } + } catch (_: SecurityException) { + hasLocationPermission = false + } } // Already centered north-up: tilt into a 3D perspective, like the stock app's @@ -344,7 +342,7 @@ internal fun ShuttleMap( } // Tilted: flatten back to north-up rather than dropping out of follow mode - - // only an actual map drag (the MapEffect above) should do that. + // only an actual map drag should do that. LocationFollowMode.FollowingTilted -> { coroutineScope.launch { cameraPositionState.animate( @@ -396,3 +394,11 @@ internal fun ShuttleMap( } private fun String.toComposeColorOrNull(): Color? = runCatching { Color(toColorInt()) }.getOrNull() + +private fun Context.hasLocationPermission(): Boolean = + listOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION, + ).any { permission -> + ActivityCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED + } diff --git a/app/src/main/java/edu/rpi/shuttletracker/feature/map/MapsScreen.kt b/app/src/main/java/edu/rpi/shuttletracker/feature/map/MapsScreen.kt index 1d488745..d9be873b 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/feature/map/MapsScreen.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/feature/map/MapsScreen.kt @@ -8,6 +8,8 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -38,7 +40,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import edu.rpi.shuttletracker.R import edu.rpi.shuttletracker.core.ui.CheckResponseError import edu.rpi.shuttletracker.feature.etas.EtasScreen -import edu.rpi.shuttletracker.feature.etas.EtasViewModel import edu.rpi.shuttletracker.feature.map.components.AnnouncementSheet import edu.rpi.shuttletracker.feature.schedule.ScheduleScreen import edu.rpi.shuttletracker.feature.schedule.ScheduleViewModel @@ -60,8 +61,8 @@ private enum class MainTab( * The app's home screen: switches between Map ([MapTab]), [EtasScreen], and [ScheduleScreen] with * a bottom nav bar, or a side [NavigationRail] once the window is wide enough (a rotated phone, * a foldable, a tablet) that a bottom bar would waste horizontal space. This is the entry point - * [edu.rpi.shuttletracker.app.navigation.AppNavigation] routes to, and each tab gets its own - * ViewModel so switching tabs never loses that tab's state. + * [edu.rpi.shuttletracker.app.navigation.AppNavigation] routes to. All three pager pages stay + * composed so switching tabs preserves the live map and each tab's UI state. * */ @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3WindowSizeClassApi::class) @Composable @@ -69,10 +70,10 @@ fun MapsScreen( onOpenSettings: () -> Unit, viewModel: MapsViewModel = hiltViewModel(), scheduleViewModel: ScheduleViewModel = hiltViewModel(), - etasViewModel: EtasViewModel = hiltViewModel(), ) { val uiState by viewModel.mapsUiState.collectAsStateWithLifecycle() - var selectedTab by rememberSaveable { mutableStateOf(MainTab.Map) } + val pagerState = rememberPagerState { MainTab.entries.size } + val selectedTab = MainTab.entries[pagerState.currentPage] var isAnnouncementsSheetVisible by rememberSaveable { mutableStateOf(false) } val announcementsSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -86,13 +87,22 @@ fun MapsScreen( val isDark = MaterialTheme.colorScheme.background.luminance() <= 0.5f val navContainerColor = if (isDark) MaterialTheme.colorScheme.surfaceVariant else null + LifecycleStartEffect(viewModel, selectedTab) { + if (selectedTab != MainTab.Schedule) viewModel.startVehiclePolling() + if (selectedTab == MainTab.Map) viewModel.startAnnouncementRefresh() + onStopOrDispose { + viewModel.stopVehiclePolling() + viewModel.stopAnnouncementRefresh() + } + } + Row(Modifier.fillMaxSize()) { if (useNavigationRail) { NavigationRail { MainTab.entries.forEach { tab -> NavigationRailItem( selected = selectedTab == tab, - onClick = { selectedTab = tab }, + onClick = { pagerState.requestScrollToPage(tab.ordinal) }, icon = { Icon(painterResource(tab.iconRes), contentDescription = null) }, label = { Text(stringResource(tab.labelRes)) }, ) @@ -104,9 +114,7 @@ fun MapsScreen( modifier = Modifier.weight(1f), snackbarHost = { CheckResponseError( - uiState.networkError, - uiState.serverError, - uiState.unknownError, + uiState.error, ignoreErrorRequest = viewModel::clearErrors, retryErrorRequest = viewModel::retry, ) @@ -117,7 +125,7 @@ fun MapsScreen( MainTab.entries.forEach { tab -> NavigationBarItem( selected = selectedTab == tab, - onClick = { selectedTab = tab }, + onClick = { pagerState.requestScrollToPage(tab.ordinal) }, icon = { Icon(painterResource(tab.iconRes), contentDescription = null) }, label = { Text(stringResource(tab.labelRes)) }, ) @@ -126,50 +134,60 @@ fun MapsScreen( } }, ) { contentPadding -> - when (selectedTab) { - MainTab.Map -> - MapTab( - viewModel = viewModel, - uiState = uiState, - contentPadding = contentPadding, - onSettingsClick = onOpenSettings, - isAnnouncementsSheetVisible = isAnnouncementsSheetVisible, - onAnnouncementsSheetVisibleChange = { isAnnouncementsSheetVisible = it }, - announcementsSheetState = announcementsSheetState, - ) + HorizontalPager( + state = pagerState, + beyondViewportPageCount = MainTab.entries.lastIndex, + userScrollEnabled = false, + ) { page -> + when (MainTab.entries[page]) { + MainTab.Map -> + MapTab( + viewModel = viewModel, + uiState = uiState, + contentPadding = contentPadding, + onSettingsClick = onOpenSettings, + isAnnouncementsSheetVisible = isAnnouncementsSheetVisible, + onAnnouncementsSheetVisibleChange = { isAnnouncementsSheetVisible = it }, + announcementsSheetState = announcementsSheetState, + ) - MainTab.Etas -> - Box( - Modifier - .fillMaxSize() - .padding(contentPadding), - ) { - // The rail already labels the selected tab "ETAs", so the in-content title - // would just repeat it - only show it with a bottom bar instead. - EtasScreen(viewModel = etasViewModel, showTitle = !useNavigationRail) - } + MainTab.Etas -> + Box( + Modifier + .fillMaxSize() + .padding(contentPadding), + ) { + // The rail already labels the selected tab "ETAs", so the in-content title + // would just repeat it - only show it with a bottom bar instead. + EtasScreen( + routes = uiState.routes, + vehicles = uiState.vehicles + uiState.fakeVehicles, + routesLoaded = uiState.routesLoaded, + showTitle = !useNavigationRail, + ) + } - MainTab.Schedule -> - Box( - Modifier - .fillMaxSize() - .padding(contentPadding), - ) { - ScheduleScreen( - viewModel = scheduleViewModel, - showTitle = !useNavigationRail, - isWideLayout = useNavigationRail, - ) - } + MainTab.Schedule -> + Box( + Modifier + .fillMaxSize() + .padding(contentPadding), + ) { + ScheduleScreen( + viewModel = scheduleViewModel, + routesByName = uiState.routes, + showTitle = !useNavigationRail, + isWideLayout = useNavigationRail, + ) + } + } } } } } /** - * Vehicle and announcement polling are scoped to this composable's own lifetime, not the whole - * screen's, so switching to another tab actually stops the live 5-second polling instead of - * leaving it running in the background indefinitely. + * Map content and announcement sheet. * */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -182,15 +200,6 @@ private fun MapTab( onAnnouncementsSheetVisibleChange: (Boolean) -> Unit, announcementsSheetState: SheetState, ) { - LifecycleStartEffect(viewModel) { - viewModel.startVehiclePolling() - viewModel.startAnnouncementRefresh() - onStopOrDispose { - viewModel.stopVehiclePolling() - viewModel.stopAnnouncementRefresh() - } - } - Box(Modifier.fillMaxSize()) { ShuttleMap( uiState = uiState, diff --git a/app/src/main/java/edu/rpi/shuttletracker/feature/map/MapsViewModel.kt b/app/src/main/java/edu/rpi/shuttletracker/feature/map/MapsViewModel.kt index 94ac2adb..041de9c2 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/feature/map/MapsViewModel.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/feature/map/MapsViewModel.kt @@ -53,6 +53,7 @@ class MapsViewModel private var routesJob: Job? = null private var announcementsJob: Job? = null private var fakeVehiclesJob: Job? = null + private var failedRequest: MapRequest? = null init { loadAll() @@ -64,18 +65,29 @@ class MapsViewModel } fun clearErrors() { - _mapsUiState.update { - it.copy( - unknownError = null, - networkError = null, - serverError = null, - ) - } + failedRequest = null + _mapsUiState.update { it.copy(error = null) } } fun retry() { + val request = failedRequest clearErrors() - loadAll() + when (request) { + MapRequest.Routes -> loadRoutes() + MapRequest.Vehicles -> { + if (vehiclePollingJob != null) { + stopVehiclePolling() + startVehiclePolling() + } + } + MapRequest.Announcements -> { + if (announcementsJob != null) { + stopAnnouncementRefresh() + startAnnouncementRefresh() + } + } + null -> loadAll() + } } fun startVehiclePolling() { @@ -100,19 +112,18 @@ class MapsViewModel etasResponse is NetworkResult.Success || velocitiesResponse is NetworkResult.Success ) { + clearError(MapRequest.Vehicles) _mapsUiState.update { it.copy( - networkError = null, - serverError = null, - unknownError = null, + error = null, vehiclesUpdatedAt = Instant.now(), ) } } - readApiResponse(locationsResponse) { locations = it } - readApiResponse(etasResponse) { etas = it } - readApiResponse(velocitiesResponse) { velocities = it } + readApiResponse(locationsResponse, MapRequest.Vehicles) { locations = it } + readApiResponse(etasResponse, MapRequest.Vehicles) { etas = it } + readApiResponse(velocitiesResponse, MapRequest.Vehicles) { velocities = it } _mapsUiState.update { it.copy( @@ -130,6 +141,7 @@ class MapsViewModel fun stopVehiclePolling() { vehiclePollingJob?.cancel() vehiclePollingJob = null + clearError(MapRequest.Vehicles) } /** @@ -147,7 +159,8 @@ class MapsViewModel if (mapsUiState.value.simulateAnnouncements) return@onEach // A failed refresh must not clear announcements already on screen. - readApiResponse(result) { announcements -> + readApiResponse(result, MapRequest.Announcements) { announcements -> + clearError(MapRequest.Announcements) _mapsUiState.update { it.copy( announcements = announcements.displayable(), @@ -161,6 +174,7 @@ class MapsViewModel fun stopAnnouncementRefresh() { announcementsJob?.cancel() announcementsJob = null + clearError(MapRequest.Announcements) } /** @@ -196,9 +210,10 @@ class MapsViewModel if (routesJob?.isActive == true) return routesJob = viewModelScope.launch { - readApiResponse(shuttleRepository.getRoutes()) { routes -> + readApiResponse(shuttleRepository.getRoutes(), MapRequest.Routes) { routes -> + clearError(MapRequest.Routes) _mapsUiState.update { - it.copy(routes = routes) + it.copy(routes = routes, routesLoaded = true) } } } @@ -296,39 +311,44 @@ class MapsViewModel /** On [NetworkResult.Success] calls [success]; on [NetworkResult.Failure] puts the error into UI state. */ private fun readApiResponse( response: NetworkResult, + request: MapRequest, success: (body: T) -> Unit, ) { when (response) { is NetworkResult.Success -> success(response.data) - is NetworkResult.Failure -> - when (val error = response.error) { - is NetworkError.Connectivity -> - _mapsUiState.update { it.copy(networkError = error) } - is NetworkError.Http -> - _mapsUiState.update { it.copy(serverError = error) } - is NetworkError.Unknown -> - _mapsUiState.update { it.copy(unknownError = error) } - } + is NetworkResult.Failure -> { + failedRequest = request + _mapsUiState.update { it.copy(error = response.error) } + } } } + + private fun clearError(request: MapRequest) { + if (failedRequest == request) clearErrors() + } } private const val ANNOUNCEMENT_POLL_MS = 5 * 60 * 1000L private const val FAKE_VEHICLE_TICK_MS = 1_000L +private enum class MapRequest { + Routes, + Vehicles, + Announcements, +} + /** Everything the Map tab needs to render. See [MapsViewModel] for how each field gets filled in. */ @Immutable data class MapsUiState( val vehicles: List = emptyList(), val fakeVehicles: List = emptyList(), val routes: Map = emptyMap(), + val routesLoaded: Boolean = false, val announcements: List = emptyList(), val announcementsUpdatedAt: Instant? = null, val vehiclesUpdatedAt: Instant? = null, val simulateAnnouncements: Boolean = false, - val networkError: NetworkError.Connectivity? = null, - val serverError: NetworkError.Http? = null, - val unknownError: NetworkError.Unknown? = null, + val error: NetworkError? = null, val themeMode: ThemeMode = ThemeMode.System, val mapType: MapType = MapType.NORMAL, val shuttleAnimationsEnabled: Boolean = false, diff --git a/app/src/main/java/edu/rpi/shuttletracker/feature/schedule/ScheduleScreen.kt b/app/src/main/java/edu/rpi/shuttletracker/feature/schedule/ScheduleScreen.kt index 9c2f4e86..d995cdcf 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/feature/schedule/ScheduleScreen.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/feature/schedule/ScheduleScreen.kt @@ -14,11 +14,13 @@ import androidx.compose.ui.Modifier import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import edu.rpi.shuttletracker.core.ui.CheckResponseError +import edu.rpi.shuttletracker.data.models.Route import edu.rpi.shuttletracker.feature.schedule.components.ScheduleContent /** The Schedule tab: fetches routes/schedule via [ScheduleViewModel] and renders them with [ScheduleContent]. */ @Composable fun ScheduleScreen( + routesByName: Map, viewModel: ScheduleViewModel = hiltViewModel(), showTitle: Boolean = true, isWideLayout: Boolean = false, @@ -30,9 +32,7 @@ fun ScheduleScreen( contentWindowInsets = WindowInsets(0, 0, 0, 0), snackbarHost = { CheckResponseError( - uiState.networkError, - uiState.serverError, - uiState.unknownError, + uiState.error, ignoreErrorRequest = viewModel::clearErrors, retryErrorRequest = viewModel::retry, ) @@ -46,7 +46,7 @@ fun ScheduleScreen( ScheduleContent( schedule = uiState.schedule, isLoading = uiState.isScheduleLoading, - routesByName = uiState.routes, + routesByName = routesByName, selectedRoute = selectedRoute, onSelectedRouteChange = { selectedRoute = it }, showTitle = showTitle, diff --git a/app/src/main/java/edu/rpi/shuttletracker/feature/schedule/ScheduleViewModel.kt b/app/src/main/java/edu/rpi/shuttletracker/feature/schedule/ScheduleViewModel.kt index ab75afeb..7be5829c 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/feature/schedule/ScheduleViewModel.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/feature/schedule/ScheduleViewModel.kt @@ -6,7 +6,6 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import edu.rpi.shuttletracker.core.network.NetworkError import edu.rpi.shuttletracker.core.network.NetworkResult -import edu.rpi.shuttletracker.data.models.Route import edu.rpi.shuttletracker.data.models.Schedule import edu.rpi.shuttletracker.data.repository.ShuttleRepository import kotlinx.coroutines.Job @@ -18,9 +17,7 @@ import kotlinx.coroutines.launch import javax.inject.Inject /** - * Backs [ScheduleScreen]. Loads routes and the schedule once each (same load-if-missing pattern as - * [edu.rpi.shuttletracker.feature.map.MapsViewModel]'s routes), and [refresh] drops both and - * reloads on demand. + * Backs [ScheduleScreen]. Loads the schedule once and exposes retryable error state. * */ @HiltViewModel class ScheduleViewModel @@ -30,7 +27,6 @@ class ScheduleViewModel ) : ViewModel() { private val _scheduleUiState = MutableStateFlow(ScheduleUiState()) val scheduleUiState: StateFlow = _scheduleUiState.asStateFlow() - private var routesJob: Job? = null private var scheduleJob: Job? = null init { @@ -38,18 +34,11 @@ class ScheduleViewModel } private fun loadAll() { - if (scheduleUiState.value.routes.isEmpty()) loadRoutes() if (scheduleUiState.value.schedule == null) loadSchedule() } fun clearErrors() { - _scheduleUiState.update { - it.copy( - unknownError = null, - networkError = null, - serverError = null, - ) - } + _scheduleUiState.update { it.copy(error = null) } } fun retry() { @@ -57,32 +46,6 @@ class ScheduleViewModel loadAll() } - /** - * Drops the cached routes and schedule, then fetches both again, so a stale cache never - * survives an explicit user refresh. Not currently wired to any button in [ScheduleScreen] - - * it's here (and tested) as the supported way to invalidate the cache when one is added. - * */ - fun refresh() { - routesJob?.cancel() - routesJob = null - scheduleJob?.cancel() - scheduleJob = null - _scheduleUiState.update { it.copy(routes = emptyMap(), schedule = null) } - loadAll() - } - - private fun loadRoutes() { - if (routesJob?.isActive == true) return - routesJob = - viewModelScope.launch { - readApiResponse(shuttleRepository.getRoutes()) { routes -> - _scheduleUiState.update { - it.copy(routes = routes) - } - } - } - } - private fun loadSchedule() { if (scheduleJob?.isActive == true) return _scheduleUiState.update { it.copy(isScheduleLoading = true) } @@ -103,15 +66,7 @@ class ScheduleViewModel ) { when (response) { is NetworkResult.Success -> success(response.data) - is NetworkResult.Failure -> - when (val error = response.error) { - is NetworkError.Connectivity -> - _scheduleUiState.update { it.copy(networkError = error) } - is NetworkError.Http -> - _scheduleUiState.update { it.copy(serverError = error) } - is NetworkError.Unknown -> - _scheduleUiState.update { it.copy(unknownError = error) } - } + is NetworkResult.Failure -> _scheduleUiState.update { it.copy(error = response.error) } } } } @@ -121,8 +76,5 @@ class ScheduleViewModel data class ScheduleUiState( val schedule: Schedule? = null, val isScheduleLoading: Boolean = true, - val routes: Map = emptyMap(), - val networkError: NetworkError.Connectivity? = null, - val serverError: NetworkError.Http? = null, - val unknownError: NetworkError.Unknown? = null, + val error: NetworkError? = null, ) diff --git a/app/src/main/java/edu/rpi/shuttletracker/feature/schedule/components/ScheduleContent.kt b/app/src/main/java/edu/rpi/shuttletracker/feature/schedule/components/ScheduleContent.kt index ef14ac0f..66cab0e4 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/feature/schedule/components/ScheduleContent.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/feature/schedule/components/ScheduleContent.kt @@ -51,14 +51,18 @@ import androidx.compose.ui.semantics.stateDescription 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.feature.schedule.utils.RPI_ZONE_ID import edu.rpi.shuttletracker.feature.schedule.utils.StopTimeInfo import edu.rpi.shuttletracker.feature.schedule.utils.consolidatedTimes import edu.rpi.shuttletracker.feature.schedule.utils.routesForDay import edu.rpi.shuttletracker.feature.schedule.utils.scrollIndexFor -import java.util.Calendar +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.LocalTime +import java.time.format.TextStyle +import java.util.Locale import kotlin.text.lowercase /** @@ -135,7 +139,7 @@ private fun ScheduleDetailsContent( onSelectedRouteChange: (String) -> Unit, isWideLayout: Boolean, ) { - var selectedDay by remember { mutableStateOf(DayOfWeek.fromToday()) } + var selectedDay by remember { mutableStateOf(LocalDate.now(RPI_ZONE_ID).dayOfWeek) } val routes = remember(selectedDay, schedule) { @@ -188,6 +192,7 @@ private fun ScheduleDetailsContent( routes = routes, selectedRoute = activeRoute, onSelect = onSelectedRouteChange, + modifier = Modifier.fillMaxWidth(0.9f), ) } @@ -215,8 +220,8 @@ private fun ScheduleDetailsContent( val listState = rememberLazyListState() - val now = Calendar.getInstance() - val nowMinutes = now.get(Calendar.HOUR_OF_DAY) * 60 + now.get(Calendar.MINUTE) + val now = LocalTime.now(RPI_ZONE_ID) + val nowMinutes = now.hour * 60 + now.minute val scrollIndex = remember(times) { scrollIndexFor(times, nowMinutes) } @@ -237,7 +242,7 @@ private fun ScheduleDetailsContent( itemsIndexed( items = times, key = { index, item -> - "$index|${item.vehicleName}|${item.departureTime}|${item.routeName}" + "$index|${item.vehicleName}|${item.departureTime}" }, ) { index, item -> ScheduleTimeRow( @@ -274,7 +279,7 @@ private fun DaySelector( selected = selectedDay == day, onClick = { onSelect(day) }, shape = SegmentedButtonDefaults.itemShape(index = index, count = days.size), - label = { Text(day.displayName) }, + label = { Text(day.getDisplayName(TextStyle.SHORT, Locale.US)) }, ) } } @@ -285,7 +290,7 @@ private fun RouteSelector( routes: List, selectedRoute: String?, onSelect: (String) -> Unit, - modifier: Modifier = Modifier.fillMaxWidth(0.9f), + modifier: Modifier = Modifier, ) { Row( modifier = modifier, diff --git a/app/src/main/java/edu/rpi/shuttletracker/feature/schedule/utils/ScheduleUtils.kt b/app/src/main/java/edu/rpi/shuttletracker/feature/schedule/utils/ScheduleUtils.kt index df8ea4cd..274ff871 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/feature/schedule/utils/ScheduleUtils.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/feature/schedule/utils/ScheduleUtils.kt @@ -1,10 +1,12 @@ package edu.rpi.shuttletracker.feature.schedule.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.DayOfWeek +import java.time.LocalDate import java.time.LocalDateTime import java.time.LocalTime +import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.Locale import kotlin.collections.component1 @@ -12,6 +14,7 @@ import kotlin.collections.component2 import kotlin.collections.iterator private val TIME_FORMATTER = DateTimeFormatter.ofPattern("h:mm a", Locale.US) +val RPI_ZONE_ID: ZoneId = ZoneId.of("America/New_York") /** One stop's expected time for a single departure - a row inside an expanded [TimeInfo]. */ data class StopTimeInfo( @@ -22,7 +25,6 @@ data class StopTimeInfo( /** One scheduled departure: a vehicle leaving at a time, with every stop's estimated time along the way. */ data class TimeInfo( val departureTime: String, - val routeName: String, val vehicleName: String, val minutesOfDay: Int, val stopTimes: List, @@ -70,7 +72,6 @@ fun consolidatedTimes( out += TimeInfo( departureTime = departureTime, - routeName = scheduledRouteName, vehicleName = vehicleName, minutesOfDay = minutesOfDay, stopTimes = @@ -111,8 +112,8 @@ fun nextScheduledArrival( stopKey: String, schedule: Schedule, routesByName: Map, - day: DayOfWeek = DayOfWeek.fromToday(), - now: LocalDateTime = LocalDateTime.now(), + day: DayOfWeek = LocalDate.now(RPI_ZONE_ID).dayOfWeek, + now: LocalDateTime = LocalDateTime.now(RPI_ZONE_ID), ): LocalDateTime? { val scheduleMap = schedule.scheduleMapFor(day) var next: LocalDateTime? = null diff --git a/app/src/main/java/edu/rpi/shuttletracker/feature/settings/about/AboutScreen.kt b/app/src/main/java/edu/rpi/shuttletracker/feature/settings/about/AboutScreen.kt index bd41c51d..8bb11af5 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/feature/settings/about/AboutScreen.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/feature/settings/about/AboutScreen.kt @@ -1,5 +1,6 @@ package edu.rpi.shuttletracker.feature.settings.about +import android.content.Intent import android.widget.Toast import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -15,6 +16,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.Typography import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -31,8 +33,10 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.google.android.gms.oss.licenses.v2.OssLicensesMenuActivity import edu.rpi.shuttletracker.BuildConfig import edu.rpi.shuttletracker.R +import edu.rpi.shuttletracker.core.ui.theme.shuttleTrackerColorScheme import edu.rpi.shuttletracker.feature.settings.components.SettingsItem /** @@ -44,7 +48,6 @@ import edu.rpi.shuttletracker.feature.settings.components.SettingsItem @Composable fun AboutScreen( onBack: () -> Unit, - onOpenLibraries: () -> Unit, viewModel: AboutViewModel = hiltViewModel(), ) { val uriHandler = LocalUriHandler.current @@ -59,6 +62,7 @@ fun AboutScreen( val repositoryIssuesUrl = stringResource(R.string.url_repository_issues) val privacyPolicyUrl = stringResource(R.string.url_private_policy) val devOptionsActivatedMessage = stringResource(R.string.dev_options_activated) + val librariesTitle = stringResource(R.string.libraries_used) Scaffold( topBar = { @@ -109,8 +113,16 @@ fun AboutScreen( SettingsItem( icon = R.drawable.ic_description, - title = stringResource(R.string.libraries_used), - onClick = onOpenLibraries, + title = librariesTitle, + onClick = { + OssLicensesMenuActivity.setActivityTitle(librariesTitle) + OssLicensesMenuActivity.setTheme( + shuttleTrackerColorScheme(context, darkTheme = false), + shuttleTrackerColorScheme(context, darkTheme = true), + Typography(), + ) + context.startActivity(Intent(context, OssLicensesMenuActivity::class.java)) + }, ) var timesClicked by remember { mutableIntStateOf(10) } diff --git a/app/src/main/java/edu/rpi/shuttletracker/feature/settings/about/LibrariesScreen.kt b/app/src/main/java/edu/rpi/shuttletracker/feature/settings/about/LibrariesScreen.kt deleted file mode 100644 index 67495911..00000000 --- a/app/src/main/java/edu/rpi/shuttletracker/feature/settings/about/LibrariesScreen.kt +++ /dev/null @@ -1,33 +0,0 @@ -package edu.rpi.shuttletracker.feature.settings.about - -import android.content.Intent -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import com.google.android.gms.oss.licenses.v2.OssLicensesMenuActivity -import edu.rpi.shuttletracker.R -import edu.rpi.shuttletracker.core.ui.theme.Typography -import edu.rpi.shuttletracker.core.ui.theme.shuttleTrackerColorScheme - -/** - * Not a real screen - the open-source licenses list is a separate Google Play Services `Activity` - * ([OssLicensesMenuActivity]), not a Compose destination. This composable just themes and launches - * it as a side effect, then immediately calls [onOpened] to pop itself off the back stack. - * */ -@Composable -fun LibrariesScreen(onOpened: () -> Unit) { - val context = LocalContext.current - val title = stringResource(R.string.libraries_used) - - LaunchedEffect(context, title) { - OssLicensesMenuActivity.setActivityTitle(title) - OssLicensesMenuActivity.setTheme( - shuttleTrackerColorScheme(context, darkTheme = false), - shuttleTrackerColorScheme(context, darkTheme = true), - Typography, - ) - context.startActivity(Intent(context, OssLicensesMenuActivity::class.java)) - onOpened() - } -} diff --git a/app/src/main/java/edu/rpi/shuttletracker/widget/EtaWidget.kt b/app/src/main/java/edu/rpi/shuttletracker/widget/EtaWidget.kt index 2fa74705..cefaed1a 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/widget/EtaWidget.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/widget/EtaWidget.kt @@ -51,8 +51,8 @@ import androidx.glance.unit.ColorProvider import edu.rpi.shuttletracker.R import edu.rpi.shuttletracker.app.MainActivity import edu.rpi.shuttletracker.core.ui.theme.VehicleColors +import edu.rpi.shuttletracker.feature.schedule.utils.RPI_ZONE_ID import java.time.Instant -import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.Locale import androidx.glance.appwidget.action.actionStartActivity as actionStartActivityIntent @@ -502,7 +502,7 @@ private fun EmptyMessage(textRes: Int) { } private fun formatTime(epochMillis: Long): String = - TIME_FORMATTER.format(Instant.ofEpochMilli(epochMillis).atZone(ZoneId.systemDefault())) + TIME_FORMATTER.format(Instant.ofEpochMilli(epochMillis).atZone(RPI_ZONE_ID)) private fun String.lowercaseTitle(): String = lowercase(Locale.ROOT).replaceFirstChar { it.titlecase(Locale.ROOT) } diff --git a/app/src/main/java/edu/rpi/shuttletracker/widget/EtaWidgetUpdater.kt b/app/src/main/java/edu/rpi/shuttletracker/widget/EtaWidgetUpdater.kt index 2eb84242..eb555b0b 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/widget/EtaWidgetUpdater.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/widget/EtaWidgetUpdater.kt @@ -18,9 +18,9 @@ import edu.rpi.shuttletracker.data.repository.ShuttleRepository import edu.rpi.shuttletracker.feature.etas.utils.buildStopsWithEtas import edu.rpi.shuttletracker.feature.etas.utils.vehiclesForStop import edu.rpi.shuttletracker.feature.map.utils.buildFakeVehicles +import edu.rpi.shuttletracker.feature.schedule.utils.RPI_ZONE_ID import edu.rpi.shuttletracker.feature.schedule.utils.nextScheduledArrival import kotlinx.coroutines.flow.first -import java.time.ZoneId /** Keys into each [EtaWidget] instance's [androidx.glance.state.PreferencesGlanceStateDefinition] state. */ object EtaWidgetKeys { @@ -140,7 +140,7 @@ object EtaWidgetUpdater { routes: Map, ): Long? = nextScheduledArrival(stopKey = stopKey, schedule = schedule, routesByName = routes) - ?.atZone(ZoneId.systemDefault()) + ?.atZone(RPI_ZONE_ID) ?.toInstant() ?.toEpochMilli() diff --git a/app/src/main/java/edu/rpi/shuttletracker/widget/WidgetSnapshot.kt b/app/src/main/java/edu/rpi/shuttletracker/widget/WidgetSnapshot.kt index 6145e4ba..35c759d5 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/widget/WidgetSnapshot.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/widget/WidgetSnapshot.kt @@ -32,7 +32,6 @@ data class WidgetEtaSnapshot( @Serializable data class WidgetStopSnapshot( val stopName: String, - val routeNames: List, val etas: List, ) @@ -88,7 +87,6 @@ fun List.toWidgetStopSnapshots(): List = .map { stop -> WidgetStopSnapshot( stopName = stop.stop.name, - routeNames = stop.routeNames, etas = stop.etas.take(MAX_ETAS_PER_STOP).map { eta -> WidgetEtaSnapshot( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a050aa43..11ed2c34 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,5 +1,5 @@ - Shuttle Tracker + Shubble 3 diff --git a/app/src/main/res/xml/eta_widget_info.xml b/app/src/main/res/xml/eta_widget_info.xml index 00f4347a..33a292a0 100644 --- a/app/src/main/res/xml/eta_widget_info.xml +++ b/app/src/main/res/xml/eta_widget_info.xml @@ -1,5 +1,6 @@ + android:widgetCategory="home_screen" + tools:targetApi="s" /> diff --git a/app/src/test/java/edu/rpi/shuttletracker/background/service/NotificationTapDestinationTest.kt b/app/src/test/java/edu/rpi/shuttletracker/background/service/NotificationTapDestinationTest.kt index 4fe5b3a9..801a6d21 100644 --- a/app/src/test/java/edu/rpi/shuttletracker/background/service/NotificationTapDestinationTest.kt +++ b/app/src/test/java/edu/rpi/shuttletracker/background/service/NotificationTapDestinationTest.kt @@ -18,10 +18,8 @@ class NotificationTapDestinationTest { } @Test - fun `a valid http url opens externally`() { - val destination = resolveNotificationTapDestination("http://example.com") - - assertThat(destination).isEqualTo(NotificationTapDestination.ExternalUrl("http://example.com")) + fun `an unencrypted http url falls back to the map`() { + assertThat(resolveNotificationTapDestination("http://example.com")).isEqualTo(NotificationTapDestination.Map) } @Test diff --git a/app/src/test/java/edu/rpi/shuttletracker/core/util/UrlValidationTest.kt b/app/src/test/java/edu/rpi/shuttletracker/core/util/UrlValidationTest.kt index a593c803..84280a48 100644 --- a/app/src/test/java/edu/rpi/shuttletracker/core/util/UrlValidationTest.kt +++ b/app/src/test/java/edu/rpi/shuttletracker/core/util/UrlValidationTest.kt @@ -5,13 +5,14 @@ import org.junit.Test class UrlValidationTest { @Test - fun `http and https urls are safe`() { + fun `https urls are safe`() { assertThat(isSafeHttpUrl("https://example.com")).isTrue() - assertThat(isSafeHttpUrl("http://example.com/path?query=1")).isTrue() + assertThat(isSafeHttpUrl("HTTPS://example.com/path?query=1")).isTrue() } @Test - fun `non-http schemes are rejected`() { + fun `unencrypted and non-web schemes are rejected`() { + assertThat(isSafeHttpUrl("http://example.com")).isFalse() assertThat(isSafeHttpUrl("javascript:alert(1)")).isFalse() assertThat(isSafeHttpUrl("ftp://example.com")).isFalse() assertThat(isSafeHttpUrl("intent://evil")).isFalse() diff --git a/app/src/test/java/edu/rpi/shuttletracker/data/repository/DefaultShuttleRepositoryTest.kt b/app/src/test/java/edu/rpi/shuttletracker/data/repository/DefaultShuttleRepositoryTest.kt index 946b5648..0be1fb93 100644 --- a/app/src/test/java/edu/rpi/shuttletracker/data/repository/DefaultShuttleRepositoryTest.kt +++ b/app/src/test/java/edu/rpi/shuttletracker/data/repository/DefaultShuttleRepositoryTest.kt @@ -59,11 +59,9 @@ class DefaultShuttleRepositoryTest { fun `one-shot repository calls delegate to the remote data source`() = runTest { repository.getRoutes() - repository.getAnnouncements() repository.getSchedule() assertThat(remote.routesCalls).isEqualTo(1) - assertThat(remote.announcementCalls).isEqualTo(1) assertThat(remote.scheduleCalls).isEqualTo(1) } diff --git a/app/src/test/java/edu/rpi/shuttletracker/feature/etas/EtasViewModelTest.kt b/app/src/test/java/edu/rpi/shuttletracker/feature/etas/EtasViewModelTest.kt deleted file mode 100644 index 2d8c9b58..00000000 --- a/app/src/test/java/edu/rpi/shuttletracker/feature/etas/EtasViewModelTest.kt +++ /dev/null @@ -1,198 +0,0 @@ -package edu.rpi.shuttletracker.feature.etas - -import com.google.common.truth.Truth.assertThat -import edu.rpi.shuttletracker.core.network.NetworkError -import edu.rpi.shuttletracker.core.network.NetworkResult -import edu.rpi.shuttletracker.testing.coroutine.MainDispatcherRule -import edu.rpi.shuttletracker.testing.fakes.FakeShuttleRepository -import edu.rpi.shuttletracker.testing.fakes.FakeUserPreferences -import edu.rpi.shuttletracker.testing.fixtures.testRoute -import edu.rpi.shuttletracker.testing.fixtures.testVehicleEta -import edu.rpi.shuttletracker.testing.fixtures.testVehicleLocation -import edu.rpi.shuttletracker.testing.fixtures.testVehicleVelocity -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runCurrent -import kotlinx.coroutines.test.runTest -import org.junit.Before -import org.junit.Rule -import org.junit.Test - -@OptIn(ExperimentalCoroutinesApi::class) -class EtasViewModelTest { - @get:Rule - val mainDispatcherRule = MainDispatcherRule() - - private lateinit var repository: FakeShuttleRepository - private lateinit var preferences: FakeUserPreferences - - @Before - fun setUp() { - repository = - FakeShuttleRepository().apply { - routesResult = NetworkResult.Success(mapOf("NORTH" to testRoute())) - } - preferences = FakeUserPreferences() - } - - @Test - fun `initial load caches routes`() = - runTest { - val viewModel = createViewModel() - advanceUntilIdle() - - assertThat(viewModel.etasUiState.value.routes).containsKey("NORTH") - assertThat(repository.routesCalls).isEqualTo(1) - } - - @Test - fun `vehicle polling merges the three endpoint results`() = - runTest { - val viewModel = createViewModel() - viewModel.startVehiclePolling() - repository.vehicleLocations.emit(NetworkResult.Success(mapOf("bus-1" to testVehicleLocation()))) - repository.vehicleEtas.emit(NetworkResult.Success(mapOf("bus-1" to testVehicleEta()))) - repository.vehicleVelocities.emit(NetworkResult.Success(mapOf("bus-1" to testVehicleVelocity()))) - advanceUntilIdle() - - val vehicle = - viewModel.etasUiState.value.vehicles - .single() - assertThat(vehicle.routeName).isEqualTo("NORTH") - assertThat(vehicle.stopTimes).containsKey("union") - } - - @Test - fun `starting polling twice creates only one set of streams`() = - runTest { - val viewModel = createViewModel() - - viewModel.startVehiclePolling() - viewModel.startVehiclePolling() - - assertThat(repository.observeLocationsCalls).isEqualTo(1) - assertThat(repository.observeEtasCalls).isEqualTo(1) - assertThat(repository.observeVelocitiesCalls).isEqualTo(1) - } - - @Test - fun `stopping polling prevents later vehicle updates`() = - runTest { - val viewModel = createViewModel() - viewModel.startVehiclePolling() - repository.vehicleLocations.emit(NetworkResult.Success(mapOf("bus-1" to testVehicleLocation()))) - repository.vehicleEtas.emit(NetworkResult.Success(emptyMap())) - repository.vehicleVelocities.emit(NetworkResult.Success(emptyMap())) - advanceUntilIdle() - viewModel.stopVehiclePolling() - - repository.vehicleLocations.emit(NetworkResult.Success(mapOf("bus-2" to testVehicleLocation("West Bus")))) - repository.vehicleEtas.emit(NetworkResult.Success(emptyMap())) - repository.vehicleVelocities.emit(NetworkResult.Success(emptyMap())) - advanceUntilIdle() - - assertThat( - viewModel.etasUiState.value.vehicles - .single() - .id, - ).isEqualTo("bus-1") - } - - @Test - fun `selecting a route filter and a stop updates ui state`() = - runTest { - val viewModel = createViewModel() - advanceUntilIdle() - - viewModel.selectRouteFilter("NORTH") - viewModel.selectStop("union") - - assertThat(viewModel.etasUiState.value.selectedRouteFilter).isEqualTo("NORTH") - assertThat(viewModel.etasUiState.value.selectedStopKey).isEqualTo("union") - - viewModel.selectStop(null) - assertThat(viewModel.etasUiState.value.selectedStopKey).isNull() - } - - @Test - fun `connectivity failure is exposed in ui state`() = - runTest { - val viewModel = createViewModel() - viewModel.startVehiclePolling() - repository.vehicleLocations.emit(NetworkResult.Failure(NetworkError.NoConnection())) - repository.vehicleEtas.emit(NetworkResult.Success(emptyMap())) - repository.vehicleVelocities.emit(NetworkResult.Success(emptyMap())) - advanceUntilIdle() - - assertThat(viewModel.etasUiState.value.networkError).isInstanceOf(NetworkError.NoConnection::class.java) - } - - @Test - fun `empty routes response marks routes as loaded instead of leaving ui stuck loading`() = - runTest { - repository.routesResult = NetworkResult.Success(emptyMap()) - val viewModel = createViewModel() - advanceUntilIdle() - - assertThat(viewModel.etasUiState.value.routesLoaded).isTrue() - assertThat(viewModel.etasUiState.value.routes).isEmpty() - } - - @Test - fun `retry clears the error and reloads missing routes`() = - runTest { - repository.routesResult = NetworkResult.Failure(NetworkError.Unknown()) - val viewModel = createViewModel() - advanceUntilIdle() - repository.routesResult = NetworkResult.Success(mapOf("NORTH" to testRoute())) - - viewModel.retry() - advanceUntilIdle() - - assertThat(viewModel.etasUiState.value.unknownError).isNull() - assertThat(viewModel.etasUiState.value.routes).containsKey("NORTH") - assertThat(repository.routesCalls).isEqualTo(2) - } - - // The fake vehicle ticker loops forever with delay(), so advanceUntilIdle() would hang while - // it's running; runCurrent() steps the virtual clock by a bounded amount instead. - - @Test - fun `fake vehicles only start once both dev options and the fake shuttle toggle are on`() = - runTest { - val viewModel = createViewModel() - advanceUntilIdle() - - preferences.devOptions.value = true - runCurrent() - assertThat(viewModel.etasUiState.value.fakeVehicles).isEmpty() - - preferences.fakeShuttlesEnabled.value = true - runCurrent() - assertThat(viewModel.etasUiState.value.fakeVehicles).hasSize(1) - - preferences.fakeShuttlesEnabled.value = false - runCurrent() - } - - @Test - fun `fake vehicles carry synthesized stop etas so the etas tab has something to show`() = - runTest { - val viewModel = createViewModel() - advanceUntilIdle() - - preferences.devOptions.value = true - preferences.fakeShuttlesEnabled.value = true - runCurrent() - - val fakeVehicle = - viewModel.etasUiState.value.fakeVehicles - .single() - assertThat(fakeVehicle.stopTimes.keys).containsExactly("union", "academy") - - preferences.fakeShuttlesEnabled.value = false - runCurrent() - } - - private fun createViewModel() = EtasViewModel(repository, preferences) -} diff --git a/app/src/test/java/edu/rpi/shuttletracker/feature/etas/utils/EtaUtilsTest.kt b/app/src/test/java/edu/rpi/shuttletracker/feature/etas/utils/EtaUtilsTest.kt index 9715cd3e..a420d195 100644 --- a/app/src/test/java/edu/rpi/shuttletracker/feature/etas/utils/EtaUtilsTest.kt +++ b/app/src/test/java/edu/rpi/shuttletracker/feature/etas/utils/EtaUtilsTest.kt @@ -15,7 +15,6 @@ class EtaUtilsTest { Route( color = "#D32F2F", stops = listOf("union", "academy"), - polylineStops = emptyList(), coordinates = emptyList(), stopDetails = mapOf("union" to union, "academy" to academy), ) @@ -24,7 +23,6 @@ class EtaUtilsTest { Route( color = "#1976D2", stops = listOf("union"), - polylineStops = emptyList(), coordinates = emptyList(), stopDetails = mapOf("union" to union), ) @@ -89,7 +87,6 @@ class EtaUtilsTest { Route( color = "#00FF00", stops = listOf("academy"), - polylineStops = emptyList(), coordinates = emptyList(), stopDetails = mapOf("academy" to academy), ) diff --git a/app/src/test/java/edu/rpi/shuttletracker/feature/map/MapsViewModelTest.kt b/app/src/test/java/edu/rpi/shuttletracker/feature/map/MapsViewModelTest.kt index 5bd6edf6..88308618 100644 --- a/app/src/test/java/edu/rpi/shuttletracker/feature/map/MapsViewModelTest.kt +++ b/app/src/test/java/edu/rpi/shuttletracker/feature/map/MapsViewModelTest.kt @@ -115,7 +115,7 @@ class MapsViewModelTest { repository.vehicleVelocities.emit(NetworkResult.Success(emptyMap())) advanceUntilIdle() - assertThat(viewModel.mapsUiState.value.networkError).isInstanceOf(NetworkError.NoConnection::class.java) + assertThat(viewModel.mapsUiState.value.error).isInstanceOf(NetworkError.NoConnection::class.java) } @Test @@ -129,11 +129,43 @@ class MapsViewModelTest { viewModel.retry() advanceUntilIdle() - assertThat(viewModel.mapsUiState.value.unknownError).isNull() + assertThat(viewModel.mapsUiState.value.error).isNull() assertThat(viewModel.mapsUiState.value.routes).containsKey("NORTH") assertThat(repository.routesCalls).isEqualTo(2) } + @Test + fun `retry restarts a failed vehicle refresh immediately`() = + runTest { + val viewModel = createViewModel() + viewModel.startVehiclePolling() + repository.vehicleLocations.emit(NetworkResult.Failure(NetworkError.NoConnection())) + repository.vehicleEtas.emit(NetworkResult.Success(emptyMap())) + repository.vehicleVelocities.emit(NetworkResult.Success(emptyMap())) + advanceUntilIdle() + + viewModel.retry() + runCurrent() + + assertThat(repository.observeLocationsCalls).isEqualTo(2) + assertThat(repository.observeEtasCalls).isEqualTo(2) + assertThat(repository.observeVelocitiesCalls).isEqualTo(2) + } + + @Test + fun `retry restarts a failed announcement refresh immediately`() = + runTest { + val viewModel = createViewModel() + viewModel.startAnnouncementRefresh() + repository.announcements.emit(NetworkResult.Failure(NetworkError.NoConnection())) + advanceUntilIdle() + + viewModel.retry() + runCurrent() + + assertThat(repository.observeAnnouncementsCalls).isEqualTo(2) + } + @Test fun `announcement refresh loads and filters to active unexpired announcements`() = runTest { @@ -173,7 +205,7 @@ class MapsViewModelTest { viewModel.mapsUiState.value.announcements .map { it.id }, ).containsExactly("first") - assertThat(viewModel.mapsUiState.value.networkError).isInstanceOf(NetworkError.NoConnection::class.java) + assertThat(viewModel.mapsUiState.value.error).isInstanceOf(NetworkError.NoConnection::class.java) } @Test diff --git a/app/src/test/java/edu/rpi/shuttletracker/feature/map/utils/FakeShuttleUtilsTest.kt b/app/src/test/java/edu/rpi/shuttletracker/feature/map/utils/FakeShuttleUtilsTest.kt index 80bcf377..3188acb0 100644 --- a/app/src/test/java/edu/rpi/shuttletracker/feature/map/utils/FakeShuttleUtilsTest.kt +++ b/app/src/test/java/edu/rpi/shuttletracker/feature/map/utils/FakeShuttleUtilsTest.kt @@ -21,7 +21,6 @@ class FakeShuttleUtilsTest { Route( color = "#D32F2F", stops = emptyList(), - polylineStops = emptyList(), coordinates = listOf( listOf( diff --git a/app/src/test/java/edu/rpi/shuttletracker/feature/schedule/ScheduleViewModelTest.kt b/app/src/test/java/edu/rpi/shuttletracker/feature/schedule/ScheduleViewModelTest.kt index fe1c7041..ce1263e0 100644 --- a/app/src/test/java/edu/rpi/shuttletracker/feature/schedule/ScheduleViewModelTest.kt +++ b/app/src/test/java/edu/rpi/shuttletracker/feature/schedule/ScheduleViewModelTest.kt @@ -5,7 +5,6 @@ import edu.rpi.shuttletracker.core.network.NetworkError import edu.rpi.shuttletracker.core.network.NetworkResult import edu.rpi.shuttletracker.testing.coroutine.MainDispatcherRule import edu.rpi.shuttletracker.testing.fakes.FakeShuttleRepository -import edu.rpi.shuttletracker.testing.fixtures.testRoute import edu.rpi.shuttletracker.testing.fixtures.testSchedule import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle @@ -25,88 +24,56 @@ class ScheduleViewModelTest { fun setUp() { repository = FakeShuttleRepository().apply { - routesResult = NetworkResult.Success(mapOf("NORTH" to testRoute())) scheduleResult = NetworkResult.Success(testSchedule()) } } @Test - fun `initial load caches routes and schedule`() = + fun `initial load caches schedule`() = runTest { val viewModel = createViewModel() advanceUntilIdle() - assertThat(viewModel.scheduleUiState.value.routes).containsKey("NORTH") assertThat(viewModel.scheduleUiState.value.schedule).isNotNull() assertThat(viewModel.scheduleUiState.value.isScheduleLoading).isFalse() - assertThat(repository.routesCalls).isEqualTo(1) assertThat(repository.scheduleCalls).isEqualTo(1) } @Test - fun `creating another view model reuses cached data instead of refetching`() = + fun `each view model loads schedule once`() = runTest { createViewModel() advanceUntilIdle() createViewModel() advanceUntilIdle() - // The fake repository has no cache of its own, so each fresh view model instance - // fetches once on init; this pins that a single instance never refetches on its own. - assertThat(repository.routesCalls).isEqualTo(2) assertThat(repository.scheduleCalls).isEqualTo(2) } - @Test - fun `refresh drops the cache and fetches routes and schedule again`() = - runTest { - val viewModel = createViewModel() - advanceUntilIdle() - - viewModel.refresh() - advanceUntilIdle() - - assertThat(repository.routesCalls).isEqualTo(2) - assertThat(repository.scheduleCalls).isEqualTo(2) - assertThat(viewModel.scheduleUiState.value.routes).containsKey("NORTH") - assertThat(viewModel.scheduleUiState.value.schedule).isNotNull() - } - - @Test - fun `refresh shows loading while the schedule is being refetched`() = - runTest { - val viewModel = createViewModel() - advanceUntilIdle() - - viewModel.refresh() - - assertThat(viewModel.scheduleUiState.value.isScheduleLoading).isTrue() - } - @Test fun `a failed load is exposed in ui state`() = runTest { - repository.routesResult = NetworkResult.Failure(NetworkError.Unknown()) + repository.scheduleResult = NetworkResult.Failure(NetworkError.Unknown()) val viewModel = createViewModel() advanceUntilIdle() - assertThat(viewModel.scheduleUiState.value.unknownError).isNotNull() + assertThat(viewModel.scheduleUiState.value.error).isNotNull() } @Test - fun `retry clears the error and reloads missing routes`() = + fun `retry clears the error and reloads missing schedule`() = runTest { - repository.routesResult = NetworkResult.Failure(NetworkError.Unknown()) + repository.scheduleResult = NetworkResult.Failure(NetworkError.Unknown()) val viewModel = createViewModel() advanceUntilIdle() - repository.routesResult = NetworkResult.Success(mapOf("NORTH" to testRoute())) + repository.scheduleResult = NetworkResult.Success(testSchedule()) viewModel.retry() advanceUntilIdle() - assertThat(viewModel.scheduleUiState.value.unknownError).isNull() - assertThat(viewModel.scheduleUiState.value.routes).containsKey("NORTH") - assertThat(repository.routesCalls).isEqualTo(2) + assertThat(viewModel.scheduleUiState.value.error).isNull() + assertThat(viewModel.scheduleUiState.value.schedule).isNotNull() + assertThat(repository.scheduleCalls).isEqualTo(2) } private fun createViewModel() = ScheduleViewModel(repository) diff --git a/app/src/test/java/edu/rpi/shuttletracker/feature/schedule/utils/ScheduleUtilsTest.kt b/app/src/test/java/edu/rpi/shuttletracker/feature/schedule/utils/ScheduleUtilsTest.kt index 4e60ec24..13fa1a28 100644 --- a/app/src/test/java/edu/rpi/shuttletracker/feature/schedule/utils/ScheduleUtilsTest.kt +++ b/app/src/test/java/edu/rpi/shuttletracker/feature/schedule/utils/ScheduleUtilsTest.kt @@ -1,10 +1,10 @@ package edu.rpi.shuttletracker.feature.schedule.utils import com.google.common.truth.Truth.assertThat -import edu.rpi.shuttletracker.data.models.DayOfWeek import edu.rpi.shuttletracker.testing.fixtures.testRoute import edu.rpi.shuttletracker.testing.fixtures.testSchedule import org.junit.Test +import java.time.DayOfWeek import java.time.LocalDateTime import java.time.LocalTime @@ -78,7 +78,6 @@ class ScheduleUtilsTest { private fun timeInfo(minutesOfDay: Int) = TimeInfo( departureTime = "", - routeName = "NORTH", vehicleName = "Bus 1", minutesOfDay = minutesOfDay, stopTimes = emptyList(), diff --git a/app/src/test/java/edu/rpi/shuttletracker/testing/fakes/FakeShuttleRepository.kt b/app/src/test/java/edu/rpi/shuttletracker/testing/fakes/FakeShuttleRepository.kt index cb97b30e..6660996c 100644 --- a/app/src/test/java/edu/rpi/shuttletracker/testing/fakes/FakeShuttleRepository.kt +++ b/app/src/test/java/edu/rpi/shuttletracker/testing/fakes/FakeShuttleRepository.kt @@ -18,7 +18,6 @@ class FakeShuttleRepository : ShuttleRepository { val announcements = MutableSharedFlow>>(replay = 1) var routesResult: NetworkResult> = NetworkResult.Success(emptyMap()) - var announcementsResult: NetworkResult> = NetworkResult.Success(emptyList()) var scheduleResult: NetworkResult? = null var observeLocationsCalls = 0 @@ -26,7 +25,6 @@ class FakeShuttleRepository : ShuttleRepository { var observeVelocitiesCalls = 0 var observeAnnouncementsCalls = 0 var routesCalls = 0 - var announcementsCalls = 0 var scheduleCalls = 0 override fun observeVehicleLocations(pollMs: Long): Flow>> { @@ -54,11 +52,6 @@ class FakeShuttleRepository : ShuttleRepository { return routesResult } - override suspend fun getAnnouncements(): NetworkResult> { - announcementsCalls++ - return announcementsResult - } - override suspend fun getSchedule(): NetworkResult { scheduleCalls++ return checkNotNull(scheduleResult) { "Set scheduleResult before creating MapsViewModel" } diff --git a/app/src/test/java/edu/rpi/shuttletracker/testing/fixtures/TestModels.kt b/app/src/test/java/edu/rpi/shuttletracker/testing/fixtures/TestModels.kt index 4cd30dd7..edfe8986 100644 --- a/app/src/test/java/edu/rpi/shuttletracker/testing/fixtures/TestModels.kt +++ b/app/src/test/java/edu/rpi/shuttletracker/testing/fixtures/TestModels.kt @@ -14,7 +14,6 @@ fun testRoute() = Route( color = "#D32F2F", stops = listOf("union", "academy"), - polylineStops = emptyList(), coordinates = listOf(listOf(listOf(42.730, -73.680), listOf(42.731, -73.679))), stopDetails = mapOf( @@ -71,6 +70,6 @@ fun testVehicleLocation(name: String = "North Bus") = headingDegrees = 90, ) -fun testVehicleEta() = VehicleStopEta(mapOf("union" to "2 min"), "2026-01-15T08:00:00-05:00") +fun testVehicleEta() = VehicleStopEta(mapOf("union" to "2 min")) fun testVehicleVelocity() = VehicleVelocities("NORTH", false, null) diff --git a/app/src/test/java/edu/rpi/shuttletracker/widget/WidgetSnapshotTest.kt b/app/src/test/java/edu/rpi/shuttletracker/widget/WidgetSnapshotTest.kt index 4dc511e5..b6d9e08a 100644 --- a/app/src/test/java/edu/rpi/shuttletracker/widget/WidgetSnapshotTest.kt +++ b/app/src/test/java/edu/rpi/shuttletracker/widget/WidgetSnapshotTest.kt @@ -14,7 +14,6 @@ class WidgetSnapshotTest { Route( color = "#D32F2F", stops = listOf("union", "academy"), - polylineStops = emptyList(), coordinates = emptyList(), stopDetails = mapOf("union" to union, "academy" to academy), ) @@ -89,7 +88,6 @@ class WidgetSnapshotTest { listOf( WidgetStopSnapshot( stopName = "Student Union", - routeNames = listOf("NORTH", "WEST"), etas = listOf( WidgetEtaSnapshot(routeName = "NORTH", etaEpochMillis = 1000L), @@ -98,7 +96,6 @@ class WidgetSnapshotTest { ), WidgetStopSnapshot( stopName = "Academy Hall", - routeNames = listOf("WEST"), etas = listOf(WidgetEtaSnapshot(routeName = "WEST", etaEpochMillis = 3000L)), ), ), diff --git a/build.gradle.kts b/build.gradle.kts index 38b21abe..af047e11 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,5 @@ buildscript { dependencies { - classpath(libs.google.services) classpath(libs.secrets.gradle.plugin) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1e53aae0..686c1275 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ core-ktx = "1.19.0" datastore-preferences = "1.2.1" espresso-core = "3.7.0" -firebase-bom = "34.16.0" +firebase-bom = "34.17.0" google-services = "4.5.0" google-oss-licenses = "17.5.1" @@ -29,10 +29,10 @@ secrets-gradle-plugin = "2.0.1" compose-bom = "2026.06.01" ksp = "2.3.10" -android-gradle-plugin = "9.3.0" +android-gradle-plugin = "9.3.1" kotlin = "2.4.10" kotlinx-serialization = "1.11.0" -navigation3 = "1.1.4" +navigation3 = "1.1.5" ktlint = "14.2.0" @@ -45,13 +45,11 @@ activity-compose = { module = "androidx.activity:activity-compose", version.ref androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore-preferences" } androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso-core" } -androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hilt-navigation-compose" } androidx-hilt-lifecycle-viewmodel-compose = { module = "androidx.hilt:hilt-lifecycle-viewmodel-compose", version.ref = "hilt-navigation-compose" } androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-junit" } androidx-junit4 = { module = "androidx.compose.ui:ui-test-junit4"} androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle-runtime-ktx" } -androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle-runtime-ktx" } androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycle-runtime-ktx" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } @@ -71,7 +69,6 @@ core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" } firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebase-bom" } firebase-messaging = { module = "com.google.firebase:firebase-messaging" } -google-services = { module = "com.google.gms:google-services", version.ref = "google-services" } google-oss-licenses = { module = "com.google.android.gms:play-services-oss-licenses", version.ref = "google-oss-licenses" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } diff --git a/settings.gradle.kts b/settings.gradle.kts index e978aa09..f1c45676 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -11,13 +11,6 @@ pluginManagement { } } } - resolutionStrategy { - eachPlugin { - if (requested.id.id == "com.google.android.gms.oss-licenses-plugin") { - useModule("com.google.android.gms:oss-licenses-plugin:0.13.0") - } - } - } } @Suppress("UnstableApiUsage")