diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6ca7c4a0..1274c476 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -98,41 +98,40 @@ dependencies { debugImplementation(libs.androidx.ui.test.manifest) debugImplementation(libs.androidx.ui.tooling) - // hilt + // Dependency injection implementation(libs.hilt.android) ksp(libs.hilt.compiler) implementation(libs.androidx.hilt.lifecycle.viewmodel.compose) - // retrofit + // Networking implementation(libs.retrofit) implementation(libs.converter.kotlinx.serialization) - // okhttp implementation(libs.logging.interceptor) - // compose + // Compose implementation(libs.androidx.lifecycle.viewmodel.navigation3) implementation(libs.androidx.lifecycle.runtime.compose) - // navigation + // Navigation implementation(libs.navigation3.runtime) implementation(libs.navigation3.ui) implementation(libs.kotlinx.serialization.json) - // maps + // Maps implementation(libs.maps.compose) implementation(libs.play.services.maps) implementation(libs.play.services.location) implementation(libs.google.oss.licenses) - // datastore (similar to SharedPreferences) + // DataStore implementation(libs.androidx.datastore.preferences) - // firebase + // Firebase implementation(platform(libs.firebase.bom)) implementation(libs.firebase.messaging) - // home screen widget + // Home-screen widget implementation(libs.glance.appwidget) implementation(libs.glance.material3) implementation(libs.androidx.work.runtime.ktx) diff --git a/app/src/androidTest/java/edu/rpi/shuttletracker/feature/etas/components/EtaComponentsTest.kt b/app/src/androidTest/java/edu/rpi/shuttletracker/feature/etas/components/EtaComponentsTest.kt index 6af47c3e..2576de3f 100644 --- a/app/src/androidTest/java/edu/rpi/shuttletracker/feature/etas/components/EtaComponentsTest.kt +++ b/app/src/androidTest/java/edu/rpi/shuttletracker/feature/etas/components/EtaComponentsTest.kt @@ -83,7 +83,6 @@ class EtaComponentsTest { fun stopsWithNoApproachingVehicleShowTheEmptyEtaMessage() { setListContent(routes = mapOf("NORTH" to testRoute()), vehicles = emptyList()) - // Both of NORTH's stops (union, academy) lack a live eta here. composeRule.onAllNodesWithText("No live ETAs").assertCountEquals(2) } @@ -161,7 +160,6 @@ class EtaComponentsTest { } } - // The sheet's appear animation runs as a coroutine, so give it a chance to settle. composeRule.waitForIdle() composeRule.onNodeWithText("Student Union").assertIsDisplayed() 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 cad9104b..41e65b40 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 @@ -21,10 +21,7 @@ import org.junit.Test import org.junit.runner.RunWith import java.time.Instant -/** - * Constructs MapsViewModel directly with fakes (bypassing hiltViewModel()) so the bottom - * navigation can be exercised without a Hilt test runner. - * */ +/** Uses a directly constructed ViewModel so navigation tests do not need a Hilt runner. */ @RunWith(AndroidJUnit4::class) class MapsScreenNavigationTest { @get:Rule diff --git a/app/src/androidTest/java/edu/rpi/shuttletracker/feature/schedule/components/ScheduleContentTest.kt b/app/src/androidTest/java/edu/rpi/shuttletracker/feature/schedule/components/ScheduleContentTest.kt index 613c45b1..db78bf95 100644 --- a/app/src/androidTest/java/edu/rpi/shuttletracker/feature/schedule/components/ScheduleContentTest.kt +++ b/app/src/androidTest/java/edu/rpi/shuttletracker/feature/schedule/components/ScheduleContentTest.kt @@ -57,8 +57,7 @@ class ScheduleContentTest { setContent() composeRule.onNodeWithText("Wed").performClick() - // The auto-expand LaunchedEffect races the click's own recomposition, so explicitly force - // a row open rather than assuming one is already expanded when this assertion runs. + // Explicitly open a row because auto-expand and click recomposition can race. composeRule.onNodeWithText("7:00 AM").performClick() composeRule.waitForIdle() 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 6c6541a7..7c7daac6 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 @@ -36,8 +36,7 @@ class AboutScreenTest { repeat(10) { composeRule.onNodeWithText("Version").performClick() } - // The 10th tap's unlock write goes through viewModelScope.launch, so it isn't guaranteed - // to have landed in the fake preferences the instant performClick() returns. + // Wait for the asynchronous preference write from the final tap. composeRule.waitForIdle() assertTrue(preferences.devOptions.value) diff --git a/app/src/androidTest/java/edu/rpi/shuttletracker/feature/settings/developerMenu/DevMenuScreenTest.kt b/app/src/androidTest/java/edu/rpi/shuttletracker/feature/settings/developerMenu/DevMenuScreenTest.kt index 23528d76..32abb8d5 100644 --- a/app/src/androidTest/java/edu/rpi/shuttletracker/feature/settings/developerMenu/DevMenuScreenTest.kt +++ b/app/src/androidTest/java/edu/rpi/shuttletracker/feature/settings/developerMenu/DevMenuScreenTest.kt @@ -12,11 +12,7 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -/** - * Constructs DevMenuViewModel directly with a fake, bypassing hiltViewModel(), the same way - * MapsScreenNavigationTest does - DevMenuContent itself is private, so the real screen is - * exercised instead of trying to reach into it. - * */ +/** Uses a directly constructed ViewModel to test the private-backed screen without Hilt. */ @RunWith(AndroidJUnit4::class) class DevMenuScreenTest { @get:Rule 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 b9c48819..8654b1c6 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 @@ -11,10 +11,7 @@ import edu.rpi.shuttletracker.data.repository.ShuttleRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow -/** - * Mirrors app/src/test's fake of the same name. Duplicated here because androidTest and test are - * separate source sets with no shared fixtures module in this project. - * */ +/** Duplicated because `test` and `androidTest` do not share fakes. */ class FakeShuttleRepository : ShuttleRepository { val vehicleLocations = MutableSharedFlow>>(replay = 1) val vehicleEtas = MutableSharedFlow>>(replay = 1) diff --git a/app/src/androidTest/java/edu/rpi/shuttletracker/testing/fakes/FakeUserPreferences.kt b/app/src/androidTest/java/edu/rpi/shuttletracker/testing/fakes/FakeUserPreferences.kt index 80c933a9..bdbec759 100644 --- a/app/src/androidTest/java/edu/rpi/shuttletracker/testing/fakes/FakeUserPreferences.kt +++ b/app/src/androidTest/java/edu/rpi/shuttletracker/testing/fakes/FakeUserPreferences.kt @@ -6,10 +6,7 @@ import edu.rpi.shuttletracker.data.local.preferences.UserPreferences import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -/** - * Mirrors app/src/test's fake of the same name. Duplicated here because androidTest and test are - * separate source sets with no shared fixtures module in this project. - * */ +/** Duplicated because `test` and `androidTest` do not share fakes. */ class FakeUserPreferences : UserPreferences { val mapType = MutableStateFlow(MapType.NORMAL) val privacyPolicyAccepted = MutableStateFlow(false) 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 17f6eb6f..95d85004 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 @@ -4,10 +4,7 @@ import edu.rpi.shuttletracker.data.models.Route import edu.rpi.shuttletracker.data.models.Schedule import edu.rpi.shuttletracker.data.models.Stop -/** - * Mirrors app/src/test's fixtures of the same name. Duplicated here because androidTest and test - * are separate source sets with no shared fixtures module in this project. - * */ +/** Duplicated because `test` and `androidTest` do not share fixtures. */ fun testRoute() = Route( color = "#D32F2F", diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8ccaa5be..ad99dce4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -40,11 +40,7 @@ android:name="com.google.android.geo.API_KEY" android:value="${MAPS_API_KEY}" /> - + @@ -61,10 +57,7 @@ android:resource="@xml/eta_widget_info" /> - + - + 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 0a258f01..64c8a938 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/app/MainActivity.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/app/MainActivity.kt @@ -26,12 +26,7 @@ import edu.rpi.shuttletracker.data.local.preferences.UserPreferences import kotlinx.coroutines.flow.map import javax.inject.Inject -/** - * The app's one and only [android.app.Activity]. Every screen you see is Compose content set here - * via [setContent] and hosted by [AppNavigation] - there is no second Activity to navigate to. - * Also decides whether to show setup or the map first, based on [UserPreferences], and turns a - * tapped push notification into either "just open the app" or an external URL. - * */ +/** Hosts the Compose UI, selects the initial screen, and handles notification taps. */ @AndroidEntryPoint class MainActivity : ComponentActivity() { @Inject @@ -86,12 +81,7 @@ class MainActivity : ComponentActivity() { handleNotificationTap(intent) } - /** - * The map is already the app's home screen, so a tapped push notification needs no - * navigation of its own - the only special case is an optional safe `url` to open instead. - * Covers both the foreground PendingIntent we build in [FirebaseService] and the intent FCM - * builds automatically for background/terminated taps, since both use the same extra name. - * */ + /** Opens a notification's optional safe URL; otherwise the app stays on its home screen. */ private fun handleNotificationTap(intent: Intent?) { val url = intent?.getStringExtra(FirebaseService.EXTRA_URL) ?: return intent.removeExtra(FirebaseService.EXTRA_URL) diff --git a/app/src/main/java/edu/rpi/shuttletracker/app/ShuttleTrackerApplication.kt b/app/src/main/java/edu/rpi/shuttletracker/app/ShuttleTrackerApplication.kt index f03d4438..1d50c18f 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/app/ShuttleTrackerApplication.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/app/ShuttleTrackerApplication.kt @@ -4,12 +4,7 @@ import android.app.Application import dagger.hilt.android.HiltAndroidApp import edu.rpi.shuttletracker.background.notification.Notifications -/** - * The app's [Application] class. Marking it `@HiltAndroidApp` is what turns on Hilt dependency - * injection for the whole app - every `@AndroidEntryPoint`/`@HiltViewModel` elsewhere depends on - * this. Also does the one-time setup that has to happen before any screen shows, like creating - * notification channels. - * */ +/** Enables Hilt and performs process-wide startup work. */ @HiltAndroidApp class ShuttleTrackerApplication : Application() { override fun onCreate() { diff --git a/app/src/main/java/edu/rpi/shuttletracker/app/di/DataModule.kt b/app/src/main/java/edu/rpi/shuttletracker/app/di/DataModule.kt index 884a7b2a..91f78195 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/app/di/DataModule.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/app/di/DataModule.kt @@ -10,11 +10,7 @@ import edu.rpi.shuttletracker.data.repository.DefaultShuttleRepository import edu.rpi.shuttletracker.data.repository.ShuttleRepository import javax.inject.Singleton -/** - * Tells Hilt which implementation to hand out for each data-layer interface. `@Binds` just says - * "when something asks for [ShuttleRemoteDataSource] or [ShuttleRepository], give it this class" - - * this is what lets features and tests depend on the interface only. - * */ +/** Binds data interfaces to their production implementations. */ @Module @InstallIn(SingletonComponent::class) abstract class DataModule { 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 aaa2e873..4c49f84d 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 @@ -20,12 +20,7 @@ import retrofit2.Retrofit import retrofit2.converter.kotlinx.serialization.asConverterFactory import javax.inject.Singleton -/** - * Builds the networking stack the app talks to the backend with: an [OkHttpClient] (with a small - * disk cache so recent responses are usable offline), a [Retrofit] instance configured for JSON, - * and the generated [ShuttleApi] implementation. Everything here is a `@Singleton` - one instance - * for the whole app. - * */ +/** Provides the shared HTTP cache, client, JSON converter, and Shuttle API. */ @Module @InstallIn(SingletonComponent::class) object NetworkModule { @@ -38,12 +33,9 @@ object NetworkModule { var request = chain.request() if (request.url.pathSegments.lastOrNull() in LivePolledPaths) { - // Vehicle locations/etas/velocities are meaningless once stale - never write them to - // the disk cache and never serve them from it, so going offline fails outright - // instead of silently replaying old vehicle positions as if they were live. + // Live vehicle data must never be replayed from cache. request = request.newBuilder().header("Cache-Control", "no-store").build() } else if (!context.hasNetwork()) { - // 2 week cache for offline request = request .newBuilder() @@ -60,7 +52,6 @@ object NetworkModule { @ApplicationContext context: Context, cacheInterceptor: Interceptor, ): OkHttpClient { - // 5 mb of cache val cacheSize = (5 * 1024 * 1024).toLong() val myCache = Cache(context.cacheDir, cacheSize) @@ -96,5 +87,5 @@ object NetworkModule { fun provideShuttleApi(retrofit: Retrofit): ShuttleApi = retrofit.create(ShuttleApi::class.java) } -/** Endpoints that poll live vehicle state - these must never be cached or replayed while stale. */ +/** Live endpoints that must bypass the HTTP cache. */ private val LivePolledPaths = setOf("locations", "etas", "velocities") diff --git a/app/src/main/java/edu/rpi/shuttletracker/app/di/PreferencesModule.kt b/app/src/main/java/edu/rpi/shuttletracker/app/di/PreferencesModule.kt index bd1de260..abfe5753 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/app/di/PreferencesModule.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/app/di/PreferencesModule.kt @@ -23,11 +23,7 @@ import javax.inject.Singleton private const val USER_PREFERENCES = "user_preferences" -/** - * Provides the single Jetpack DataStore file the app's settings are saved in, and binds - * [UserPreferences] to its real [DataStoreUserPreferences] implementation. Migrates from the old - * SharedPreferences file automatically the first time this runs. - * */ +/** Provides app preferences and migrates the legacy SharedPreferences file. */ @Module @InstallIn(SingletonComponent::class) abstract class PreferencesModule { 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 9885fe06..20aba4bb 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 @@ -29,12 +29,7 @@ private data object AboutRoute : NavKey @Serializable private data object DeveloperOptionsRoute : NavKey -/** - * Owns the app's complete navigation state and maps route keys to feature screens. - * - * Feature screens receive callbacks instead of a navigation object, keeping them easy to preview, - * test, and reuse. - */ +/** Maps route keys to screens and passes navigation callbacks into each feature. */ @Composable fun AppNavigation(setupCompleted: Boolean) { val startRoute: NavKey = if (setupCompleted) MapsRoute else SetupRoute diff --git a/app/src/main/java/edu/rpi/shuttletracker/background/notification/Notifications.kt b/app/src/main/java/edu/rpi/shuttletracker/background/notification/Notifications.kt index 679aa418..59777b4d 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/background/notification/Notifications.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/background/notification/Notifications.kt @@ -7,12 +7,7 @@ import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat.IMPORTANCE_DEFAULT import edu.rpi.shuttletracker.R -/** - * Creates the notification channel(s) the app posts to, and cleans up channels from - * removed/never-shipped features so they don't linger in the user's system settings. Called once - * from [edu.rpi.shuttletracker.app.ShuttleTrackerApplication] on startup. Based on the - * notification generator for Tachiyomi. - * */ +/** Creates current notification channels and removes obsolete ones at startup. */ object Notifications { private const val GROUP_PUSH = "group_push" const val CHANNEL_PUSH = "push_channel" @@ -34,11 +29,9 @@ object Notifications { fun createChannels(context: Context) { val notificationManager = NotificationManagerCompat.from(context) - // deletes channels/groups from removed or never-shipped features so they don't linger deprecatedChannels.forEach(notificationManager::deleteNotificationChannel) deprecatedGroups.forEach(notificationManager::deleteNotificationChannelGroup) - // creates notification groups notificationManager.createNotificationChannelGroupsCompat( listOf( buildNotificationChannelGroup( @@ -48,7 +41,6 @@ object Notifications { ), ) - // create notification channels notificationManager.createNotificationChannelsCompat( listOf( buildNotificationChannel( diff --git a/app/src/main/java/edu/rpi/shuttletracker/background/service/FirebaseService.kt b/app/src/main/java/edu/rpi/shuttletracker/background/service/FirebaseService.kt index 794b37d8..33d7ddf1 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/background/service/FirebaseService.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/background/service/FirebaseService.kt @@ -13,27 +13,16 @@ import edu.rpi.shuttletracker.R import edu.rpi.shuttletracker.app.MainActivity import edu.rpi.shuttletracker.background.notification.Notifications -/** - * Firebase Cloud Messaging is a manually-operated push channel: staff send notifications directly - * from the Firebase Console, independent of the shuttle API and its announcement banners. This - * service only has to render what Firebase hands it and route taps back into the app. - * */ +/** Renders staff-sent Firebase messages independently of API announcement banners. */ @AndroidEntryPoint class FirebaseService : FirebaseMessagingService() { - /** - * Debug-only: prints the registration token so it can be pasted into the Firebase Console's - * "Send test message" field, which targets a single device rather than the whole app. - * */ + /** Logs the registration token in debug builds for Firebase test messages. */ @Suppress("OVERRIDE_DEPRECATION") override fun onNewToken(token: String) { if (BuildConfig.DEBUG) Log.d("FCM_TOKEN", token) } - /** - * Only fires when the app is in the foreground; Firebase Console notification+data messages - * are otherwise displayed automatically (using the manifest's default icon/color/channel) - * when the app is backgrounded or not running, and never reach this callback. - * */ + /** Handles foreground messages; Firebase renders background messages from manifest defaults. */ override fun onMessageReceived(message: RemoteMessage) { super.onMessageReceived(message) @@ -89,17 +78,10 @@ class FirebaseService : FirebaseMessagingService() { } companion object { - /** - * Matches the raw FCM data key so the same extra name works whether we built the - * PendingIntent ourselves (foreground) or Firebase copied its data payload onto the - * launcher intent for us (background/terminated). - * */ + /** Shared URL key for intents created here or by Firebase. */ const val EXTRA_URL = "url" - /** - * A constant ID would silently replace every previous push; the message ID is stable per - * notification but unique across them, falling back to the clock only if Firebase omits it. - * */ + /** Uses a stable unique ID so new pushes do not replace older ones. */ private fun notificationIdFor(message: RemoteMessage): Int = message.messageId?.hashCode() ?: System.currentTimeMillis().toInt() } diff --git a/app/src/main/java/edu/rpi/shuttletracker/background/service/NotificationTapDestination.kt b/app/src/main/java/edu/rpi/shuttletracker/background/service/NotificationTapDestination.kt index 56d8ff8d..c881246d 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/background/service/NotificationTapDestination.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/background/service/NotificationTapDestination.kt @@ -2,12 +2,7 @@ package edu.rpi.shuttletracker.background.service import edu.rpi.shuttletracker.core.util.isSafeHttpUrl -/** - * Where a tapped push notification should take the user. Firebase Console messages may carry a - * custom `url` data field for either the foreground path (our own PendingIntent extra) or the - * background/terminated path (FCM copies its data payload onto the launcher intent's extras) - - * both funnel through this same resolver so the safety check only lives in one place. - * */ +/** Resolves all notification taps through the same URL safety check. */ sealed interface NotificationTapDestination { data object Map : NotificationTapDestination diff --git a/app/src/main/java/edu/rpi/shuttletracker/core/network/ConnectivityExtensions.kt b/app/src/main/java/edu/rpi/shuttletracker/core/network/ConnectivityExtensions.kt index 19eecd50..12f4f2fa 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/core/network/ConnectivityExtensions.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/core/network/ConnectivityExtensions.kt @@ -12,9 +12,7 @@ fun Context.hasNetwork(): Boolean { return when { actNw.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true actNw.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true - // for other device how are able to connect with Ethernet actNw.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> true - // for check internet over Bluetooth actNw.hasTransport(NetworkCapabilities.TRANSPORT_BLUETOOTH) -> true else -> false } diff --git a/app/src/main/java/edu/rpi/shuttletracker/core/network/NetworkError.kt b/app/src/main/java/edu/rpi/shuttletracker/core/network/NetworkError.kt index d13d40df..6a7bbb9e 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/core/network/NetworkError.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/core/network/NetworkError.kt @@ -1,12 +1,7 @@ package edu.rpi.shuttletracker.core.network -/** - * Every way a [ShuttleRepository][edu.rpi.shuttletracker.data.repository.ShuttleRepository] call - * can fail, carried inside a [NetworkResult.Failure] instead of being thrown, so a ViewModel can - * just `when` over it and update UI state - no try/catch needed at the call site. - * */ +/** Network failures returned as values instead of thrown to callers. */ sealed interface NetworkError { - /** No connection or the request timed out - retrying later will likely work. */ sealed interface Connectivity : NetworkError data class NoConnection( @@ -17,14 +12,12 @@ sealed interface NetworkError { val cause: Throwable? = null, ) : Connectivity - /** The server responded, but with an error status code (4xx/5xx). */ data class Http( val statusCode: Int, val message: String? = null, val displayMessage: String = message.orEmpty(), ) : NetworkError - /** Anything else - an unexpected exception while making or parsing the request. */ data class Unknown( val cause: Throwable? = null, val displayMessage: String = cause.toString(), diff --git a/app/src/main/java/edu/rpi/shuttletracker/core/network/NetworkResult.kt b/app/src/main/java/edu/rpi/shuttletracker/core/network/NetworkResult.kt index 9567d90c..298b7660 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/core/network/NetworkResult.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/core/network/NetworkResult.kt @@ -1,10 +1,6 @@ package edu.rpi.shuttletracker.core.network -/** - * The outcome of any network call the app makes: either [Success] with the data, or [Failure] - * with a [NetworkError]. Every `ShuttleRepository`/`ShuttleRemoteDataSource` function returns one - * of these instead of throwing, so callers handle failure as a normal value. - * */ +/** A network call's data or its handled [NetworkError]. */ sealed interface NetworkResult { data class Success( val data: T, 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 94f499a6..629b98bf 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 @@ -11,17 +11,7 @@ import androidx.compose.ui.res.stringResource import edu.rpi.shuttletracker.R import edu.rpi.shuttletracker.core.network.NetworkError -/** - * Drop this in a `Scaffold`'s `snackbarHost` slot alongside a ViewModel's error state fields. - * Shows a snackbar with a Retry action for whichever error is non-null (at most one is shown at a - * time), and calls back so the ViewModel can clear or retry. See `MapsScreen`/`ScheduleScreen` for - * how features wire this up. - * - * @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 - * */ +/** Shows a network error in a snackbar with dismiss and retry actions. */ @Composable fun CheckResponseError( error: NetworkError? = null, @@ -56,12 +46,6 @@ fun CheckResponseError( ) } -/** - * @param error: the error you want to display - * @param onPrimaryRequest: what happens when you want to retry what caused the error - * - * @param errorType: What kind of error has occurred - * */ @Composable fun Error( error: Any?, diff --git a/app/src/main/java/edu/rpi/shuttletracker/core/ui/theme/Color.kt b/app/src/main/java/edu/rpi/shuttletracker/core/ui/theme/Color.kt index aaf38090..e4dabd3b 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/core/ui/theme/Color.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/core/ui/theme/Color.kt @@ -2,9 +2,7 @@ package edu.rpi.shuttletracker.core.ui.theme import androidx.compose.ui.graphics.Color -// Material 3 color tokens generated from the app's brand color (Material Theme Builder). Used -// by Theme.kt to build the light/dark ColorScheme - you generally shouldn't need to reference -// these directly, use MaterialTheme.colorScheme instead. +// Generated brand tokens. UI code should normally use MaterialTheme.colorScheme. val md_theme_light_primary = Color(0xFFBF0027) val md_theme_light_onPrimary = Color(0xFFFFFFFF) @@ -66,19 +64,13 @@ val md_theme_dark_surfaceTint = Color(0xFFFFB3B0) val md_theme_dark_outlineVariant = Color(0xFF524342) val md_theme_dark_scrim = Color(0xFF000000) -/** Colors used to tint a shuttle marker/chip by its route, so NORTH and WEST are visually distinct. */ object VehicleColors { val Default = Color(0xFF444444) val North = Color(0xFFFF0000) val West = Color(0xFF0000FF) } -/** - * Material 3 has no built-in "warning" color role, so announcement banners carry their own - * light/dark container colors distinct from the error and info roles they sit alongside. The dark - * variant is muted rather than a straight dark version of the light one - a vivid amber reads as - * too alarming against the app's near-black dark background. - * */ +/** Warning colors are custom because Material 3 has no warning role. */ object AnnouncementWarningColors { val LightContainer = Color(0xFFFFDEA6) val LightOnContainer = Color(0xFF261A00) @@ -86,11 +78,7 @@ object AnnouncementWarningColors { val DarkOnContainer = Color(0xFFEAD5A8) } -/** - * A softer dark-mode red for error announcements than the shared `errorContainer` role - that one - * is deliberately vivid to signal danger, which reads as too harsh for a routine service banner. - * Light mode still uses `errorContainer` directly. - * */ +/** A softer dark-mode red for routine service announcements. */ object AnnouncementErrorColors { val DarkContainer = Color(0xFF4A2C2C) val DarkOnContainer = Color(0xFFF2D6D3) 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 b61294d2..dceeb17a 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 @@ -78,10 +78,7 @@ private val DarkColors = scrim = md_theme_dark_scrim, ) -/** - * Picks the [ColorScheme] to use: Android 12+'s wallpaper-based "dynamic color" if enabled and - * available, otherwise the app's own [LightColors]/[DarkColors] palette. - * */ +/** Uses dynamic color when available, otherwise the app's light or dark palette. */ fun shuttleTrackerColorScheme( context: Context, darkTheme: Boolean, @@ -96,15 +93,10 @@ fun shuttleTrackerColorScheme( else -> LightColors } -/** - * Wraps [content] in the app's [MaterialTheme]. This should sit at the very root of the Compose - * tree (see [edu.rpi.shuttletracker.app.MainActivity]) - everything else just reads - * `MaterialTheme.colorScheme`/`MaterialTheme.typography` and gets these values automatically. - * */ +/** Applies the app's Material theme to the Compose tree. */ @Composable fun ShuttleTrackerTheme( themeMode: ThemeMode = ThemeMode.System, - // Dynamic color is available on Android 12+ dynamicColor: Boolean = true, content: @Composable () -> Unit, ) { diff --git a/app/src/main/java/edu/rpi/shuttletracker/core/ui/theme/ThemeMode.kt b/app/src/main/java/edu/rpi/shuttletracker/core/ui/theme/ThemeMode.kt index ed973c0f..bd82e6a7 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/core/ui/theme/ThemeMode.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/core/ui/theme/ThemeMode.kt @@ -1,6 +1,5 @@ package edu.rpi.shuttletracker.core.ui.theme -/** The user's saved theme preference (see [UserPreferences][edu.rpi.shuttletracker.data.local.preferences.UserPreferences]). */ enum class ThemeMode { System, Light, 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 e07a7232..abfd63be 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 @@ -3,10 +3,7 @@ package edu.rpi.shuttletracker.core.util import java.net.URI import java.net.URISyntaxException -/** - * 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. - * */ +/** Accepts only absolute HTTPS URLs before they are opened outside the app. */ fun isSafeHttpUrl(url: String): Boolean = try { val uri = URI(url) diff --git a/app/src/main/java/edu/rpi/shuttletracker/data/local/preferences/DataStoreUserPreferences.kt b/app/src/main/java/edu/rpi/shuttletracker/data/local/preferences/DataStoreUserPreferences.kt index b69e11bc..cf70d9c0 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/data/local/preferences/DataStoreUserPreferences.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/data/local/preferences/DataStoreUserPreferences.kt @@ -11,11 +11,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import javax.inject.Inject -/** - * The real [UserPreferences], backed by Jetpack DataStore. Each setting is one key in the same - * shared `Preferences` file. To add a new setting: add a `*_key`, then a `get`/`save` pair here - * and in the [UserPreferences] interface (and its test fakes). - * */ +/** DataStore-backed settings. Add new settings here, in [UserPreferences], and in its test fakes. */ class DataStoreUserPreferences @Inject constructor( diff --git a/app/src/main/java/edu/rpi/shuttletracker/data/local/preferences/UserPreferences.kt b/app/src/main/java/edu/rpi/shuttletracker/data/local/preferences/UserPreferences.kt index bbb69651..50181d14 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/data/local/preferences/UserPreferences.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/data/local/preferences/UserPreferences.kt @@ -4,11 +4,7 @@ import com.google.maps.android.compose.MapType import edu.rpi.shuttletracker.core.ui.theme.ThemeMode import kotlinx.coroutines.flow.Flow -/** - * Reads and saves the user's app settings. - * - * The interface lets tests replace DataStore with an in-memory fake. - */ +/** Reads and saves settings; tests replace this with an in-memory fake. */ interface UserPreferences { fun getMapType(): Flow diff --git a/app/src/main/java/edu/rpi/shuttletracker/data/mapper/AnnouncementDateParsing.kt b/app/src/main/java/edu/rpi/shuttletracker/data/mapper/AnnouncementDateParsing.kt index 3c60db45..888b25ad 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/data/mapper/AnnouncementDateParsing.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/data/mapper/AnnouncementDateParsing.kt @@ -14,10 +14,7 @@ import java.util.Locale private val ANNOUNCEMENT_ZONE: ZoneId = ZoneId.of("America/New_York") -/** - * Accepts both zero-padded ("2026-01-15") and non-zero-padded ("2026-1-5") month/day values, with - * optional seconds. Matches local timestamps the API sends without an offset. - * */ +/** Accepts the API's padded or unpadded local timestamps, with optional seconds. */ private val FLEXIBLE_LOCAL_DATE_TIME: DateTimeFormatter = DateTimeFormatterBuilder() .appendValue(ChronoField.YEAR, 4) @@ -35,11 +32,7 @@ private val FLEXIBLE_LOCAL_DATE_TIME: DateTimeFormatter = .optionalEnd() .toFormatter(Locale.US) -/** - * Tolerantly parses announcement timestamps: offset/zoned ISO first, then a local timestamp - * (zero-padded or not) interpreted in [ANNOUNCEMENT_ZONE]. Returns null instead of throwing so a - * malformed or absent value never crashes the mapper. - * */ +/** Parses offset timestamps first, then local API timestamps; malformed values return null. */ fun parseAnnouncementInstant(raw: String?): Instant? { if (raw.isNullOrBlank()) return null 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 1723bf19..a7d03ea2 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 @@ -18,10 +18,7 @@ import edu.rpi.shuttletracker.data.remote.dto.VehicleStopEtaDto import edu.rpi.shuttletracker.data.remote.dto.VehicleVelocitiesDto import java.time.OffsetDateTime -// One `toModel()` extension per DTO, converting the raw network shape (data/remote/dto/) into the -// app's own model (data/models/). Some also validate the data (e.g. lat/lng ranges) and throw if -// the API sent something malformed, so a bad response fails fast instead of silently corrupting -// the UI - callers see this surface as a NetworkError.Unknown via ShuttleRemoteDataSource. +// Convert API DTOs to app models and reject malformed values before they reach the UI. fun VehicleLocationDto.toModel(): VehicleLocation { require(latitude.isFinite() && latitude in -90.0..90.0) { "Invalid vehicle latitude" } diff --git a/app/src/main/java/edu/rpi/shuttletracker/data/models/Announcement.kt b/app/src/main/java/edu/rpi/shuttletracker/data/models/Announcement.kt index dcfb324c..8eb1f810 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/data/models/Announcement.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/data/models/Announcement.kt @@ -2,9 +2,7 @@ package edu.rpi.shuttletracker.data.models import java.time.Instant -/** - * Severity is ordered so [severityRank] can drive display sorting: errors first, info last. - * */ +/** Ordered from highest to lowest display priority. */ enum class AnnouncementType( val severityRank: Int, ) { @@ -23,7 +21,6 @@ enum class AnnouncementType( } } -/** A service announcement, shown as a banner on the map. See [displayable] for which ones show. */ data class Announcement( val id: String, val message: String, @@ -33,16 +30,11 @@ data class Announcement( val createdAt: Instant? = null, ) -/** - * An absent or unparseable [Announcement.expiresAt] must not hide an otherwise active announcement. - * */ +/** Missing expiration dates do not hide active announcements. */ fun Announcement.isDisplayable(now: Instant = Instant.now()): Boolean = active && (expiresAt == null || expiresAt.isAfter(now)) -/** - * Severity first, then newest [Announcement.createdAt]; missing dates sort last within their severity - * while staying stable relative to each other. - * */ +/** Sorts by severity, then newest creation time; missing dates sort last. */ val AnnouncementDisplayOrder: Comparator = compareBy { it.type.severityRank } .thenByDescending { it.createdAt ?: Instant.MIN } 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 2f25674c..e4971df2 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 @@ -2,18 +2,14 @@ package edu.rpi.shuttletracker.data.models import com.google.android.gms.maps.model.LatLng -/** - * One shuttle route (e.g. "NORTH"). [coordinates] is the polyline to draw on the map - a list of - * line segments, each a list of `[latitude, longitude]` pairs. [stops] is the ordered list of stop - * keys on this route; look each one up in [stopDetails] for its [Stop] (name, position, offset). - * */ +/** A route with ordered stop keys and polyline `[latitude, longitude]` pairs. */ data class Route( val color: String, val stops: List, val coordinates: List>>, val stopDetails: Map, ) { - /** [coordinates] flattened into map-ready points, in order. */ + /** Flattens all polyline segments into map-ready points. */ fun latLng(): List = buildList { coordinates.forEach { polyline -> 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 bdbf2276..6ce8d092 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 @@ -2,12 +2,7 @@ package edu.rpi.shuttletracker.data.models import java.time.DayOfWeek -/** - * The weekly shuttle schedule. Each day of the week names a schedule *type* ("weekday", - * "saturday", or "sunday" - see [scheduleTypeFor]), and each type maps to its own set of - * departures ([weekday]/[saturdaySchedule]/[sundaySchedule]); [scheduleMapFor] resolves a day - * straight to its departures in one call. - * */ +/** Weekly schedule whose days select a weekday, Saturday, or Sunday departure table. */ data class Schedule( val monday: String, val tuesday: String, @@ -16,8 +11,7 @@ data class Schedule( val friday: String, val saturday: String, val sunday: String, - // Map> - // ex. AM WEST Bus 1 -> list of ["7:00 AM", "WEST"] + // Each bus maps to [departure time, route] pairs. val weekday: Map>>, val saturdaySchedule: Map>>, val sundaySchedule: Map>>, diff --git a/app/src/main/java/edu/rpi/shuttletracker/data/models/Stop.kt b/app/src/main/java/edu/rpi/shuttletracker/data/models/Stop.kt index 905c562d..b5e36423 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/data/models/Stop.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/data/models/Stop.kt @@ -2,11 +2,7 @@ package edu.rpi.shuttletracker.data.models import com.google.android.gms.maps.model.LatLng -/** - * One stop on a route. [coordinates] is `[latitude, longitude]`. [offset] is minutes from that - * route's departure time until a shuttle is expected here - used to build the printed schedule's - * per-stop times (see `feature/schedule/utils/buildStopTimesForDeparture`). - * */ +/** A route stop; [offset] is its minutes after the route's departure time. */ data class Stop( val coordinates: List, val offset: Int, 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 b09c3bfb..ba424b35 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 @@ -11,12 +11,7 @@ import java.time.temporal.ChronoUnit import java.util.Locale import kotlin.String -/** - * A shuttle, assembled by [VehicleMerger] from three separate API endpoints (see the `// from` - * comments on each group of fields below) since the backend doesn't return one combined object. - * [stopTimes] maps a stop key to that vehicle's live ETA there (an ISO timestamp string), used to - * build the ETAs tab (`feature/etas/utils/EtaUtils.kt`). - * */ +/** A shuttle assembled from the locations, velocities, and ETAs endpoints. */ data class Vehicle( val id: String, val name: String, @@ -33,10 +28,7 @@ data class Vehicle( // from etas val stopTimes: Map, ) { - /** - * Turns the date stored into a time of a generalized time ago from current - * updates once per second if subscribed to - * */ + /** Emits the age of the latest location update once per second. */ fun getTimeAgo(): Flow { val busInstant = OffsetDateTime.parse(timestamp).toInstant() @@ -52,7 +44,6 @@ data class Vehicle( } } - // Pretty "xh ym zs" formatter (avoids relying on Duration.toString()) private fun formatDuration(d: Duration): String { var secs = d.seconds val h = secs / 3600 @@ -70,7 +61,6 @@ data class Vehicle( fun latLng() = LatLng(latitude, longitude) } -/** The `/locations` endpoint's data, one per vehicle, before [VehicleMerger] combines it into a [Vehicle]. */ data class VehicleLocation( val name: String, val latitude: Double, @@ -80,24 +70,17 @@ data class VehicleLocation( val headingDegrees: Int?, ) -/** The `/etas` endpoint's data, one per vehicle: its live ETA at each stop it's approaching. */ data class VehicleStopEta( val stopTimes: Map, ) -/** The `/velocities` endpoint's data, one per vehicle: which route it's on and its stop status. */ data class VehicleVelocities( val routeName: String?, val isAtStop: Boolean, val currentStop: String?, ) -/** - * Combines the three per-endpoint vehicle types into a full [Vehicle] list, keyed by vehicle ID. - * [velocities] and [etas] are optional per vehicle (a vehicle with only a location still shows up, - * just without a route/ETA yet); [locations] is required since a vehicle you can't place makes no - * sense to show at all. - * */ +/** Merges endpoint data by vehicle ID; a location is required, while velocity and ETA are optional. */ object VehicleMerger { fun merge( locations: Map, diff --git a/app/src/main/java/edu/rpi/shuttletracker/data/remote/RetrofitShuttleRemoteDataSource.kt b/app/src/main/java/edu/rpi/shuttletracker/data/remote/RetrofitShuttleRemoteDataSource.kt index e19e7203..8d4ba817 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/data/remote/RetrofitShuttleRemoteDataSource.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/data/remote/RetrofitShuttleRemoteDataSource.kt @@ -13,11 +13,7 @@ import java.io.IOException import java.net.SocketTimeoutException import javax.inject.Inject -/** - * The real [ShuttleRemoteDataSource], calling [ShuttleApi] over the network. Every function funnels - * through [execute], which converts a Retrofit [Response] (and any thrown exception, including one - * from a `toModel()` mapper) into a [NetworkResult] - so nothing here ever throws out to a caller. - * */ +/** Calls [ShuttleApi] and converts responses or exceptions into [NetworkResult]. */ class RetrofitShuttleRemoteDataSource @Inject constructor( diff --git a/app/src/main/java/edu/rpi/shuttletracker/data/remote/ShuttleApi.kt b/app/src/main/java/edu/rpi/shuttletracker/data/remote/ShuttleApi.kt index b1e642cb..87513c57 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/data/remote/ShuttleApi.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/data/remote/ShuttleApi.kt @@ -9,11 +9,7 @@ import edu.rpi.shuttletracker.data.remote.dto.VehicleVelocitiesDto import retrofit2.Response import retrofit2.http.GET -/** - * The Retrofit description of the backend's REST endpoints (the Shubble API, see the repo README). - * Each function is a raw HTTP call returning raw DTOs - nothing here should be called directly by - * a feature; go through [edu.rpi.shuttletracker.data.repository.ShuttleRepository] instead. - * */ +/** Retrofit endpoints for the Shubble API. Features should use `ShuttleRepository`. */ interface ShuttleApi { @GET("locations") suspend fun getVehicleLocations(): Response> diff --git a/app/src/main/java/edu/rpi/shuttletracker/data/remote/ShuttleRemoteDataSource.kt b/app/src/main/java/edu/rpi/shuttletracker/data/remote/ShuttleRemoteDataSource.kt index 073c56a4..99b0ca7d 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/data/remote/ShuttleRemoteDataSource.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/data/remote/ShuttleRemoteDataSource.kt @@ -8,11 +8,7 @@ import edu.rpi.shuttletracker.data.models.VehicleLocation import edu.rpi.shuttletracker.data.models.VehicleStopEta import edu.rpi.shuttletracker.data.models.VehicleVelocities -/** - * Describes the app's remote shuttle operations. - * - * The interface lets tests replace the Retrofit implementation with a small fake. - */ +/** Remote shuttle operations, separated from Retrofit for testing. */ interface ShuttleRemoteDataSource { suspend fun getVehicleLocations(): NetworkResult> diff --git a/app/src/main/java/edu/rpi/shuttletracker/data/remote/dto/RouteDto.kt b/app/src/main/java/edu/rpi/shuttletracker/data/remote/dto/RouteDto.kt index f50ae0e9..0806b44e 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/data/remote/dto/RouteDto.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/data/remote/dto/RouteDto.kt @@ -13,12 +13,7 @@ import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.jsonObject -/** - * A route's JSON looks like `{"COLOR": ..., "STOPS": [...], "union": {...}, "academy": {...}}` - - * fixed route fields and per-stop objects are siblings in the same JSON object, keyed by stop - * name. That's not something `@Serializable` can express directly, hence [RouteDtoSerializer] - * below doing it by hand. - * */ +/** Route fields and stop objects are sibling JSON keys, so this DTO needs a custom serializer. */ @Serializable(with = RouteDtoSerializer::class) data class RouteDto( val color: String, @@ -35,11 +30,7 @@ data class StopDto( @SerialName("NAME") val name: String, ) -/** - * Splits a route's JSON object into the fixed [RouteFields] (decoded normally) and everything else, - * treating each remaining key as a stop name mapping to a [StopDto]. Serializing does the reverse: - * flatten [RouteFields] and the stop map back into one JSON object. - * */ +/** Separates fixed route fields from dynamically named stop objects and reverses that when encoding. */ object RouteDtoSerializer : KSerializer { private val fixedKeys = setOf("COLOR", "STOPS", "POLYLINE_STOPS", "ROUTES") 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 0509a061..de21c627 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 @@ -15,8 +15,6 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.encodeToJsonElement -// DTOs for the announcements, schedule, and generic error-body shapes returned by the API. - @Serializable data class AnnouncementDto( val id: String, @@ -32,11 +30,7 @@ data class AnnouncementsResponseDto( val announcements: List = emptyList(), ) -/** - * The API is documented to return a wrapped `{"announcements": [...]}` object, but the live - * endpoint has been observed returning a bare JSON array instead. Tolerate both shapes so a - * mismatch between the documented and actual response never crashes decoding. - * */ +/** Accepts both the documented wrapper and the bare array returned by the live endpoint. */ object AnnouncementsResponseDtoSerializer : KSerializer { private val listSerializer = ListSerializer(AnnouncementDto.serializer()) @@ -55,9 +49,7 @@ object AnnouncementsResponseDtoSerializer : KSerializer>> 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 93c76f54..d5edcffc 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 @@ -17,11 +17,7 @@ import edu.rpi.shuttletracker.feature.etas.components.StopEtaList import edu.rpi.shuttletracker.feature.etas.components.StopEtaSheet import edu.rpi.shuttletracker.feature.etas.utils.buildStopsWithEtas -/** - * The ETAs tab: a [StopEtaList] of stops with live arrival previews, and a [StopEtaSheet] with the - * full details for whichever stop is tapped. Vehicle polling starts/stops with this composable's - * own lifecycle via [LifecycleStartEffect], so it only runs while this tab is actually visible. - * */ +/** Shows live arrival previews and a detail sheet for the selected stop. */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun EtasScreen( diff --git a/app/src/main/java/edu/rpi/shuttletracker/feature/etas/components/StopEtaList.kt b/app/src/main/java/edu/rpi/shuttletracker/feature/etas/components/StopEtaList.kt index a9b433bb..a4276b5a 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/feature/etas/components/StopEtaList.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/feature/etas/components/StopEtaList.kt @@ -41,10 +41,7 @@ import edu.rpi.shuttletracker.feature.etas.utils.StopWithEtas import edu.rpi.shuttletracker.feature.etas.utils.buildStopsWithEtas import edu.rpi.shuttletracker.feature.etas.utils.etaMinutesFromNow -/** - * The route picker + list of stops, each showing a preview of its soonest live etas. Has no - * Scaffold/TopAppBar of its own so the caller controls that chrome. - * */ +/** Route picker and stop ETA previews without screen-level chrome. */ @Composable fun StopEtaList( routes: Map, diff --git a/app/src/main/java/edu/rpi/shuttletracker/feature/etas/components/StopEtaSheet.kt b/app/src/main/java/edu/rpi/shuttletracker/feature/etas/components/StopEtaSheet.kt index 0acd36ff..3590e267 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/feature/etas/components/StopEtaSheet.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/feature/etas/components/StopEtaSheet.kt @@ -26,10 +26,7 @@ import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle -/** - * Full-detail list of every vehicle's live eta for one stop, opened by tapping a row in - * [StopEtaList]. - * */ +/** Shows every live vehicle relevant to the selected stop. */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun StopEtaSheet( diff --git a/app/src/main/java/edu/rpi/shuttletracker/feature/etas/utils/EtaUtils.kt b/app/src/main/java/edu/rpi/shuttletracker/feature/etas/utils/EtaUtils.kt index 6c6f4f2a..ea3c6911 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/feature/etas/utils/EtaUtils.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/feature/etas/utils/EtaUtils.kt @@ -7,7 +7,6 @@ import java.time.Duration import java.time.Instant import java.time.OffsetDateTime -/** One vehicle's live ETA at a particular stop - one entry in [StopWithEtas.etas]. */ data class VehicleEta( val vehicleId: String, val vehicleName: String, @@ -15,7 +14,7 @@ data class VehicleEta( val etaInstant: Instant, ) -/** A stop plus every vehicle currently approaching it, soonest first. The list item the ETAs tab shows. */ +/** A stop and its approaching vehicles, in display order. */ data class StopWithEtas( val stopKey: String, val stop: Stop, @@ -23,21 +22,13 @@ data class StopWithEtas( val etas: List, ) -/** - * Real routes the ETAs tab shows. Hardcoded so extra or test route entries from the API never - * show up as stops, filter options, or eta chips here - a vehicle on a route outside this list is - * excluded even if it happens to report an eta for a stop a visible route also serves. - * */ +/** Public routes shown in ETA filters; unexpected API routes stay hidden. */ val ETA_VISIBLE_ROUTES = listOf("NORTH", "WEST") -/** How long a just-passed arrival remains visible in the ETA tab. */ +/** Grace period before a passed arrival disappears from the ETA list. */ internal const val ETA_PAST_GRACE_PERIOD_MINUTES = 1L -/** - * Inverts each vehicle's [Vehicle.stopTimes] (stop key -> eta) into a per-stop view, one entry per - * stop across [ETA_VISIBLE_ROUTES] (or just [routeFilter] if given), each carrying its own sorted - * list of upcoming vehicle etas. - * */ +/** Builds the route-ordered stop list by inverting each vehicle's stop-to-ETA map. */ fun buildStopsWithEtas( routes: Map, vehicles: List, @@ -95,10 +86,10 @@ fun etaMinutesFromNow( now: Instant = Instant.now(), ): Long = Duration.between(now, etaInstant).toMinutes() -/** How long a vehicle keeps showing for a stop after its own eta has passed - etas can be a little stale. */ +/** Extra tolerance for stale ETAs in the selected stop's live vehicle list. */ private const val ETA_PAST_TOLERANCE_MINUTES = 2L -/** Vehicles relevant to [stopKey] right now: currently there, or with an eta that hasn't expired past [ETA_PAST_TOLERANCE_MINUTES]. */ +/** Vehicles currently at [stopKey] or still within its ETA tolerance. */ fun vehiclesForStop( vehicles: List, stopKey: String, diff --git a/app/src/main/java/edu/rpi/shuttletracker/feature/map/FakeAnnouncements.kt b/app/src/main/java/edu/rpi/shuttletracker/feature/map/FakeAnnouncements.kt index b0bd526e..c3068e58 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/feature/map/FakeAnnouncements.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/feature/map/FakeAnnouncements.kt @@ -4,11 +4,7 @@ import edu.rpi.shuttletracker.data.models.Announcement import edu.rpi.shuttletracker.data.models.AnnouncementType import java.time.Instant -/** - * Sample banners covering every severity and a Markdown link, for exercising the map banner UI - * when the shuttle API has nothing to show (e.g. summer break). Only reachable through the - * developer menu's "Simulate announcements" toggle. - * */ +/** Developer samples covering each banner severity and Markdown links. */ object FakeAnnouncements { fun sample(now: Instant = Instant.now()): List = listOf( 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 11341a1c..39e2c1ae 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 @@ -62,30 +62,18 @@ private val CampusBounds = private const val TILTED_DEGREES = 60f private const val TILT_ZOOM = 18f -// A pinch-zoom that isn't perfectly centered can nudge the target a little even though the user -// didn't mean to pan - only treat a gesture as a real pan (and drop out of follow mode) once it -// moves the target further than incidental zoom/rotate drift would. +// Ignore small target drift caused by zooming or rotating while following the user. private const val PAN_DETECTION_THRESHOLD_METERS = 20f private const val VEHICLE_FOCUS_ZOOM = 17f -/** - * 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, so the button never claims to be - * following a camera the user just took over. - * */ +/** Location button states: free camera, north-up follow, and tilted follow. */ private enum class LocationFollowMode { NotFollowing, Following, FollowingTilted, } -/** - * The actual Google Map: draws stops ([StopMarker]), route polylines, and vehicles - * ([VehicleMarker] - both real and, in dev mode, fake), plus the overlaid announcement strip, - * settings/map-type buttons, and a recenter FAB. Used by [MapsScreen]'s Map tab. - * */ +/** Draws routes, stops, vehicles, announcements, and map controls. */ @Composable internal fun ShuttleMap( uiState: MapsUiState, @@ -221,8 +209,6 @@ internal fun ShuttleMap( } } - // Kept in a separate loop over its own uiState field so developer-mode fake shuttles - // never mix with real vehicle data from the API. uiState.fakeVehicles.forEach { vehicle -> key(vehicle.id) { VehicleMarker( @@ -235,9 +221,7 @@ internal fun ShuttleMap( } } - // Announcements are status info and belong at the very top, first thing seen; settings - // and map-type are low-frequency controls that sit below in their own full-width row so - // they never collide structurally with the strip above them. + // Keep status information above the lower-priority map controls. Column( modifier = Modifier @@ -253,9 +237,7 @@ internal fun ShuttleMap( modifier = Modifier.fillMaxWidth(), ) - // A Box with independently-aligned children, not a Row with Arrangement.SpaceBetween - - // the chip can disappear/reappear on its own schedule, and SpaceBetween would shove the - // FAB stack over to the start edge whenever it's the Row's only remaining child. + // Independent alignment keeps controls fixed when the update chip disappears. Box(modifier = Modifier.fillMaxWidth()) { LastUpdatedChip( updatedAt = uiState.vehiclesUpdatedAt, @@ -313,7 +295,6 @@ internal fun ShuttleMap( ) } - // Schedule is reached via the bottom nav bar now, so Recenter is the map's only FAB. val (fabContainerColor, fabContentColor) = mapButtonColors() FloatingActionButton( onClick = handleClick@{ @@ -347,8 +328,7 @@ internal fun ShuttleMap( } } - // Already centered north-up: tilt into a 3D perspective, like the stock app's - // compass button does on a second tap. + // A second tap switches north-up following to a tilted view. LocationFollowMode.Following -> { coroutineScope.launch { cameraPositionState.animate( @@ -366,8 +346,7 @@ internal fun ShuttleMap( followMode = LocationFollowMode.FollowingTilted } - // Tilted: flatten back to north-up rather than dropping out of follow mode - - // only an actual map drag should do that. + // Flatten the view without leaving follow mode. LocationFollowMode.FollowingTilted -> { coroutineScope.launch { cameraPositionState.animate( diff --git a/app/src/main/java/edu/rpi/shuttletracker/feature/map/MapMarkers.kt b/app/src/main/java/edu/rpi/shuttletracker/feature/map/MapMarkers.kt index 4d76f8e3..35eeb8e5 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/feature/map/MapMarkers.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/feature/map/MapMarkers.kt @@ -44,7 +44,7 @@ import kotlinx.coroutines.launch import java.time.Duration import java.time.Instant -/** A stop's circle on the map. The actual [Marker] is invisible (`alpha = 0f`) - it only exists to catch taps and show the stop's name in an info window. */ +/** Draws a stop circle plus an invisible marker that handles taps and its info window. */ @Composable internal fun StopMarker( stop: Stop, @@ -75,13 +75,7 @@ internal fun StopMarker( ) } -/** - * A shuttle's marker, colored by [Vehicle.routeName] (falls back to the last known color for a - * while if the route briefly drops out, so the marker doesn't flash gray). Works for real vehicles - * and fake ones alike - both are just [Vehicle] instances. [selected] opens the info window - * automatically (used by the dev-mode shuttle inspector to jump to a specific vehicle), separate - * from the normal tap-to-open behavior. - * */ +/** Draws a shuttle, briefly retaining its route color if route data drops out. */ @Composable internal fun VehicleMarker( vehicle: Vehicle, @@ -162,7 +156,6 @@ internal fun VehicleMarker( ) } -/** A small round icon button that floats over the map (settings, map-type toggle, etc). */ @Composable internal fun MapActionButton( @DrawableRes icon: Int, @@ -186,14 +179,7 @@ internal fun MapActionButton( private val FLASH_WINDOW: Duration = Duration.ofSeconds(3) private val STALE_THRESHOLD: Duration = Duration.ofSeconds(30) -/** - * A translucent pill floating over the map opposite the [MapActionButton] stack - hidden almost - * all the time, and never shown at all as long as polling keeps succeeding. It only appears once - * [updatedAt] hasn't advanced in [STALE_THRESHOLD] (timed from first composition if a poll has - * never succeeded yet), and then, once a poll finally does succeed again, flashes "Updated" for - * [FLASH_WINDOW] before disappearing. A routine successful poll that was never stale never - * triggers the flash - only a *recovery* from a stale state does. - * */ +/** Shows stale status, then briefly confirms recovery after polling succeeds again. */ @Composable internal fun LastUpdatedChip( updatedAt: Instant?, @@ -248,11 +234,7 @@ internal fun LastUpdatedChip( } } -/** - * Shared colors for every button that floats over the map (the recenter FAB, [MapActionButton]). - * In dark mode this matches the bottom nav bar's lighter tone instead of the near-black - * background, so the buttons don't disappear against the map. - * */ +/** Shared colors keep floating map controls visible in both themes. */ @Composable internal fun mapButtonColors(): Pair = if (MaterialTheme.colorScheme.background.luminance() > 0.5f) { 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 48be75d3..955e3766 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 @@ -45,10 +45,7 @@ import edu.rpi.shuttletracker.feature.map.components.AnnouncementSheet import edu.rpi.shuttletracker.feature.schedule.ScheduleScreen import edu.rpi.shuttletracker.feature.schedule.ScheduleViewModel -/** - * Peer destinations of the live tracker experience. Switched with local state rather than a - * Navigation3 route since they share one Scaffold and bottom bar. - * */ +/** Home tabs share one scaffold, so they use local pager state instead of navigation routes. */ private enum class MainTab( @StringRes val labelRes: Int, @DrawableRes val iconRes: Int, @@ -58,13 +55,7 @@ private enum class MainTab( Schedule(R.string.schedule_title, R.drawable.ic_schedule), } -/** - * 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. All three pager pages stay - * composed so switching tabs preserves the live map and each tab's UI state. - * */ +/** Hosts the Map, ETA, and Schedule tabs while preserving each tab's UI state. */ @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3WindowSizeClassApi::class) @Composable fun MapsScreen( @@ -79,13 +70,11 @@ fun MapsScreen( var focusedVehicleId by remember { mutableStateOf(null) } val announcementsSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - // Compact is a phone in portrait; anything wider (a rotated phone, a foldable, a tablet) gets - // a side rail instead of a bottom bar so the bar doesn't waste all that horizontal space. + // Wider layouts move navigation to a side rail. val windowSizeClass = calculateWindowSizeClass(requireNotNull(LocalActivity.current)) val useNavigationRail = windowSizeClass.widthSizeClass != WindowWidthSizeClass.Compact - // Dark mode uses the same lighter tone as the map buttons (see mapButtonColors) so the nav - // chrome reads as one consistent piece instead of a different dark shade. + // Match dark navigation chrome to the map controls. val isDark = MaterialTheme.colorScheme.background.luminance() <= 0.5f val navContainerColor = if (isDark) MaterialTheme.colorScheme.surfaceVariant else null @@ -161,8 +150,7 @@ fun MapsScreen( .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. + // A rail already labels the selected tab. EtasScreen( routes = uiState.routes, vehicles = uiState.vehicles + uiState.fakeVehicles, @@ -194,9 +182,6 @@ fun MapsScreen( } } -/** - * Map content and announcement sheet. - * */ @OptIn(ExperimentalMaterial3Api::class) @Composable private fun MapTab( 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 041de9c2..97ab9082 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 @@ -34,12 +34,7 @@ import kotlinx.coroutines.launch import java.time.Instant import javax.inject.Inject -/** - * Backs [MapsScreen]'s Map tab: loads [Route]s once, polls vehicle locations/etas/velocities into - * [MapsUiState.vehicles] while [startVehiclePolling] is active, polls announcements separately - * (its own interval, so it never competes with the 5-second vehicle polling), and mirrors the - * user's map/theme/dev-mode preferences into UI state. - * */ +/** Owns shared route, vehicle, announcement, and preference state for the home tabs. */ @HiltViewModel class MapsViewModel @Inject @@ -105,9 +100,7 @@ class MapsViewModel 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 announcement/routes loads, which shouldn't affect it. + // Clear vehicle errors only when a live vehicle endpoint recovers. if (locationsResponse is NetworkResult.Success || etasResponse is NetworkResult.Success || velocitiesResponse is NetworkResult.Success @@ -144,10 +137,7 @@ class MapsViewModel clearError(MapRequest.Vehicles) } - /** - * Polls announcements on its own low-frequency interval, independent of vehicle polling, so a - * banner refresh never has to compete with the 5 second vehicle updates. - * */ + /** Polls announcements independently from frequent vehicle updates. */ fun startAnnouncementRefresh() { if (announcementsJob?.isActive == true) return @@ -155,7 +145,7 @@ class MapsViewModel shuttleRepository .observeAnnouncements(pollMs = ANNOUNCEMENT_POLL_MS) .onEach { result -> - // The dev menu's simulated banners take priority until it's turned back off. + // Simulated banners take priority while enabled. if (mapsUiState.value.simulateAnnouncements) return@onEach // A failed refresh must not clear announcements already on screen. @@ -177,12 +167,7 @@ class MapsViewModel clearError(MapRequest.Announcements) } - /** - * Ticks once a second, building fresh fake-shuttle positions from [buildFakeVehicles] and - * publishing them to [MapsUiState.fakeVehicles] - a separate field from - * [MapsUiState.vehicles] so fake and real vehicles never mix. Only started while developer - * options and the "fake shuttles" preference are both on (see [loadPreferences]). - * */ + /** Updates developer-mode shuttles once per second without mixing them into live data. */ private fun startFakeVehicles() { if (fakeVehiclesJob?.isActive == true) return @@ -272,7 +257,7 @@ class MapsViewModel if (simulateActive) { _mapsUiState.update { it.copy(announcements = FakeAnnouncements.sample().displayable()) } } else if (wasSimulating) { - // Get a fresh real fetch immediately rather than waiting for the next poll tick. + // Fetch real announcements immediately after simulation ends. stopAnnouncementRefresh() startAnnouncementRefresh() } @@ -308,7 +293,6 @@ class MapsViewModel updateMapType(next) } - /** On [NetworkResult.Success] calls [success]; on [NetworkResult.Failure] puts the error into UI state. */ private fun readApiResponse( response: NetworkResult, request: MapRequest, @@ -337,7 +321,6 @@ private enum class MapRequest { 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(), diff --git a/app/src/main/java/edu/rpi/shuttletracker/feature/map/components/AnnouncementBanner.kt b/app/src/main/java/edu/rpi/shuttletracker/feature/map/components/AnnouncementBanner.kt index 4ed37a99..8711be90 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/feature/map/components/AnnouncementBanner.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/feature/map/components/AnnouncementBanner.kt @@ -47,12 +47,7 @@ import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle -/** - * A summary of the most severe active announcement, colored to match its severity. Tapping it - * opens [AnnouncementSheet] with the full list; the chevron is a pure "there's more" cue, not a - * dismiss control - dismissal lives per-card in the sheet, where it's a deliberate action rather - * than an easy-to-mis-tap icon on a compact row. - * */ +/** Shows the highest-priority announcement and opens the full list when tapped. */ @Composable fun AnnouncementStrip( announcements: List, @@ -112,11 +107,7 @@ fun AnnouncementStrip( } } -/** - * Full-detail list of every active announcement, opened from [AnnouncementStrip]. - * - * @param updatedAt when the list was last refreshed from the API; omitted while simulated. - * */ +/** Shows all active announcements; [updatedAt] is omitted for simulated data. */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun AnnouncementSheet( diff --git a/app/src/main/java/edu/rpi/shuttletracker/feature/map/components/DeveloperVehicleView.kt b/app/src/main/java/edu/rpi/shuttletracker/feature/map/components/DeveloperVehicleView.kt index 4cd374d0..589c2af9 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/feature/map/components/DeveloperVehicleView.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/feature/map/components/DeveloperVehicleView.kt @@ -27,12 +27,7 @@ import androidx.compose.ui.unit.dp import edu.rpi.shuttletracker.R import edu.rpi.shuttletracker.data.models.Vehicle -/** - * Dev-mode-only panel listing every currently known [Vehicle] (real and fake), for inspecting - * live shuttle data without needing a debugger - see issue #137. [onZoomToVehicle] both pans the - * map camera to that vehicle and opens its marker's info window (wired up by the caller, since - * both the camera and the markers live in [edu.rpi.shuttletracker.feature.map.MapContent]). - * */ +/** Developer panel for inspecting vehicles and selecting one on the map. */ @Composable internal fun DeveloperVehicleView( vehicles: List, diff --git a/app/src/main/java/edu/rpi/shuttletracker/feature/map/components/SvgVehicleMarker.kt b/app/src/main/java/edu/rpi/shuttletracker/feature/map/components/SvgVehicleMarker.kt index 24756f04..3b296893 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/feature/map/components/SvgVehicleMarker.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/feature/map/components/SvgVehicleMarker.kt @@ -14,12 +14,8 @@ import com.google.android.gms.maps.model.BitmapDescriptor import com.google.android.gms.maps.model.BitmapDescriptorFactory import kotlin.math.roundToInt -// Google Maps markers need a Bitmap/BitmapDescriptor, not a Compose Painter, so a bus icon can't -// just be a vector drawable rendered normally - this draws the circle + bus glyph onto a Canvas by -// hand instead. getVehicleMarkerDescriptor() is the entry point (used by VehicleMarker); everything -// else here is a private implementation detail. +// Google Maps markers require a bitmap, so draw the bus icon directly onto a Canvas. -/** Draws a colored circle with a white bus icon on top, at [pxSize] pixels square. */ private fun buildVehicleMarkerBitmap( pxSize: Int, @ColorInt color: Int, @@ -76,10 +72,9 @@ private fun buildVehicleMarkerBitmap( return bitmap } -/** Avoids redrawing the same size+color bitmap on every recomposition/marker. */ +/** Reuses marker bitmaps by size and color. */ private object VehicleMarkerCache : LruCache(8) -/** The marker icon for a [edu.rpi.shuttletracker.data.models.Vehicle], cached by size and color. */ fun getVehicleMarkerDescriptor( context: Context, dpSize: Float, diff --git a/app/src/main/java/edu/rpi/shuttletracker/feature/map/utils/FakeShuttleUtils.kt b/app/src/main/java/edu/rpi/shuttletracker/feature/map/utils/FakeShuttleUtils.kt index e479d05e..fddfde0c 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/feature/map/utils/FakeShuttleUtils.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/feature/map/utils/FakeShuttleUtils.kt @@ -8,7 +8,7 @@ import java.util.Locale import kotlin.math.atan2 import kotlin.math.sqrt -/** A plain lat/lng pair, used instead of Android's `LatLng` so this file is unit-testable on the JVM. */ +/** Platform-independent coordinates keep this simulation JVM-testable. */ data class RoutePoint( val latitude: Double, val longitude: Double, @@ -17,27 +17,16 @@ data class RoutePoint( private const val FAKE_LOOP_DURATION_MS = 60_000L private const val FAKE_HEADING_LOOKAHEAD = 0.01 -/** - * Real routes to run fake shuttles on. Hardcoded rather than picked dynamically so each fake - * vehicle always follows a real, named route loop instead of whichever route happened to sort - * first. - * */ +/** Stable route names keep simulated vehicles predictable. */ private val FAKE_SHUTTLE_ROUTE_NAMES = listOf("NORTH", "WEST") -/** - * Flattens a route's raw coordinates into an ordered loop, deliberately avoiding the Android - * [com.google.android.gms.maps.model.LatLng] that [Route.latLng] returns so this stays usable - * from a plain JVM unit test. - * */ +/** Flattens route coordinates without depending on Android's `LatLng`. */ fun Route.toRoutePoints(): List = coordinates.flatMap { polyline -> polyline.mapNotNull { pair -> if (pair.size >= 2) RoutePoint(pair[0], pair[1]) else null } } -/** - * Walks the closed loop formed by [points] (implicitly connecting the last point back to the - * first) and returns the point [progress] of the way around, where progress wraps at 1.0. - * */ +/** Interpolates around a closed loop, wrapping [progress] at 1.0. */ fun interpolateAlongLoop( points: List, progress: Double, @@ -64,12 +53,7 @@ fun interpolateAlongLoop( return points.first() } -/** - * Builds one vehicle per [FAKE_SHUTTLE_ROUTE_NAMES] route present in [routes], each continuously - * looping that route's own coordinates, for developer-mode testing. Entirely separate from live - * vehicle data - the caller is responsible for keeping these out of [Vehicle] lists sourced from - * the API. - * */ +/** Builds one looping developer-mode vehicle for each configured route. */ fun buildFakeVehicles( routes: Map, elapsedMs: Long, @@ -86,7 +70,6 @@ fun buildFakeVehicles( Vehicle( id = "fake-shuttle-$routeName", - // Just the route name ("North"/"West") - the id already marks it as fake. name = routeName.lowercase(Locale.ROOT).replaceFirstChar { it.titlecase(Locale.ROOT) }, latitude = position.latitude, longitude = position.longitude, @@ -100,7 +83,7 @@ fun buildFakeVehicles( ) } -/** Synthesizes an eta for every stop on [route], based on how far the fake vehicle ([vehicleProgress] around [points]) still has to travel to reach it. */ +/** Estimates each stop ETA from the fake vehicle's remaining route distance. */ private fun buildFakeStopTimes( route: Route, points: List, @@ -119,7 +102,7 @@ private fun buildFakeStopTimes( stopKey to etaInstant.atOffset(ZoneOffset.UTC).toString() }.toMap() -/** How far around the loop (0.0-1.0, same convention as [interpolateAlongLoop]) the point nearest [target] sits. */ +/** Returns the loop progress nearest [target]. */ private fun progressOfPoint( points: List, target: RoutePoint, diff --git a/app/src/main/java/edu/rpi/shuttletracker/feature/map/utils/MessageSegment.kt b/app/src/main/java/edu/rpi/shuttletracker/feature/map/utils/MessageSegment.kt index 9a4ec75f..a950c3f3 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/feature/map/utils/MessageSegment.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/feature/map/utils/MessageSegment.kt @@ -1,6 +1,5 @@ package edu.rpi.shuttletracker.feature.map.utils -/** One piece of an announcement message, produced by [parseMessageSegments]: plain text or a link. */ sealed interface MessageSegment { data class PlainText( val text: String, @@ -14,10 +13,7 @@ sealed interface MessageSegment { private val MARKDOWN_LINK_REGEX = Regex("""\[([^\[\]]*)]\(([^()\s]+)\)""") -/** - * Splits a message on `[label](url)` Markdown links, leaving everything else as plain text. - * Malformed brackets/parens (unmatched, nested, empty) simply fail to match and pass through as text. - * */ +/** Splits Markdown links from plain text; malformed markup remains plain text. */ fun parseMessageSegments(message: String): List { val segments = mutableListOf() var lastIndex = 0 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 d995cdcf..72728630 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 @@ -17,7 +17,6 @@ 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, 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 7be5829c..6733e904 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 @@ -16,9 +16,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject -/** - * Backs [ScheduleScreen]. Loads the schedule once and exposes retryable error state. - * */ +/** Loads the schedule and exposes retryable error state. */ @HiltViewModel class ScheduleViewModel @Inject @@ -71,7 +69,6 @@ class ScheduleViewModel } } -/** Everything the Schedule tab needs to render. See [ScheduleViewModel] for how it's filled in. */ @Immutable data class ScheduleUiState( val schedule: Schedule? = 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 66cab0e4..1a21334f 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 @@ -65,10 +65,7 @@ import java.time.format.TextStyle import java.util.Locale import kotlin.text.lowercase -/** - * The full schedule picker + times list, filling whatever container hosts it. Has no - * Scaffold/TopAppBar of its own so callers control that chrome. - * */ +/** Schedule selectors and departure list without screen-level chrome. */ @Composable fun ScheduleContent( schedule: Schedule?, @@ -154,8 +151,7 @@ private fun ScheduleDetailsContent( } if (isWideLayout) { - // Wide enough that the two selectors don't need to compete for the same row - saves the - // vertical space the stacked layout would otherwise spend on a second row. + // Place selectors side by side when space allows. Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, 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 274ff871..b6a9b626 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 @@ -16,13 +16,11 @@ 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( val stopName: String, val time: String, ) -/** 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 vehicleName: String, @@ -30,7 +28,6 @@ data class TimeInfo( val stopTimes: List, ) -/** Every route with at least one scheduled departure on [day], sorted alphabetically. */ fun routesForDay( day: DayOfWeek, schedule: Schedule, @@ -47,9 +44,7 @@ fun routesForDay( return directions.sorted() } -/** - * Flattens, filters, and sorts upcoming departures for one route on a given day. - */ +/** Returns one route's departures in service-day order. */ fun consolidatedTimes( routeName: String, day: DayOfWeek, @@ -87,7 +82,7 @@ fun consolidatedTimes( return out.sortedBy { it.minutesOfDay } } -/** Every stop on [routeName], with its estimated time computed as [departureTime] plus that stop's [edu.rpi.shuttletracker.data.models.Stop.offset]. */ +/** Adds each stop's route offset to [departureTime]. */ fun buildStopTimesForDeparture( routeName: String, departureTime: String, @@ -107,7 +102,7 @@ fun buildStopTimesForDeparture( } } -/** The soonest scheduled arrival at [stopKey] on [day], across every route that serves it. An after-midnight departure (e.g. 12:30 AM) counts as later tonight, not earlier today. */ +/** Finds the soonest scheduled arrival, treating after-midnight service as later that night. */ fun nextScheduledArrival( stopKey: String, schedule: Schedule, @@ -140,12 +135,11 @@ fun nextScheduledArrival( return next } -/** Minutes since midnight, for sorting departures - after-midnight times (12-3am) sort last, as the next day's early service rather than the earliest. */ +/** Sort key that places midnight-to-3 AM service at the end of the service day. */ fun parseMinutesOfDay(timeText: String): Int? = parseLocalTime(timeText)?.let { time -> val minutes = time.hour * 60 + time.minute - // Moves after midnight departures at the end of the list if (time.hour in 0..3) { minutes + 24 * 60 } else { @@ -153,21 +147,15 @@ fun parseMinutesOfDay(timeText: String): Int? = } } -/** Parses a schedule time like "7:00 AM"; returns null instead of throwing on a bad value. */ +/** Parses schedule display times, returning null for invalid input. */ fun parseLocalTime(timeText: String): LocalTime? = runCatching { LocalTime.parse(timeText.trim(), TIME_FORMATTER) }.getOrNull() -/** Formats a time back to the schedule's display style, e.g. "7:00 AM". */ fun formatLocalTime(time: LocalTime): String = time.format(TIME_FORMATTER) -/** - * Which row of [times] to auto-scroll/expand to: the departure just before the next upcoming one, - * so the user sees "you just missed this one, next is this one" context. If every departure is - * still upcoming, that's row 0; if every departure has already happened, it's the last row (the - * most recent one) rather than looping back to the top of the morning schedule. - * */ +/** Chooses the previous departure so the next one remains visible with context. */ fun scrollIndexFor( times: List, nowMinutes: Int, diff --git a/app/src/main/java/edu/rpi/shuttletracker/feature/settings/SettingsContent.kt b/app/src/main/java/edu/rpi/shuttletracker/feature/settings/SettingsContent.kt index 5f5e58c3..3b879444 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/feature/settings/SettingsContent.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/feature/settings/SettingsContent.kt @@ -28,11 +28,7 @@ import edu.rpi.shuttletracker.core.ui.theme.ShuttleTrackerTheme import edu.rpi.shuttletracker.core.ui.theme.ThemeMode import edu.rpi.shuttletracker.feature.settings.components.SettingsItem -/** - * The list of settings rows, each built from [SettingsItem]. Stateless - [SettingsScreen] supplies - * the values and callbacks. The "Developer Options" row only shows once dev options have already - * been unlocked (see [edu.rpi.shuttletracker.feature.settings.about.AboutScreen]). - * */ +/** Stateless settings list; developer options appear only after they are unlocked. */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsContent( @@ -121,7 +117,6 @@ fun SettingsContent( } } -/** The System/Light/Dark segmented picker row at the top of Settings. */ @Composable fun ThemeModeSettingItem( themeMode: ThemeMode, diff --git a/app/src/main/java/edu/rpi/shuttletracker/feature/settings/SettingsScreen.kt b/app/src/main/java/edu/rpi/shuttletracker/feature/settings/SettingsScreen.kt index e18285e6..26e1b135 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/feature/settings/SettingsScreen.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/feature/settings/SettingsScreen.kt @@ -10,7 +10,6 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.launch -/** The Settings screen: wires [SettingsViewModel] state and actions into [SettingsContent]. */ @Composable fun SettingsScreen( onBack: () -> Unit, diff --git a/app/src/main/java/edu/rpi/shuttletracker/feature/settings/SettingsViewModel.kt b/app/src/main/java/edu/rpi/shuttletracker/feature/settings/SettingsViewModel.kt index 8e3143bf..f99447f0 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/feature/settings/SettingsViewModel.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/feature/settings/SettingsViewModel.kt @@ -12,12 +12,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject -/** - * Backs [SettingsScreen]. Unlike most ViewModels, [settingsUiState] has no local - * `MutableStateFlow` - it's derived straight from [UserPreferences] via `combine`, since this - * screen has no state of its own beyond what's already saved. Each `update*` function just writes - * through to [UserPreferences] and the UI updates automatically when that flow re-emits. - * */ +/** Exposes settings directly from [UserPreferences] and writes updates back to it. */ @HiltViewModel class SettingsViewModel @Inject 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 8bb11af5..f2f39b35 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 @@ -39,11 +39,7 @@ import edu.rpi.shuttletracker.R import edu.rpi.shuttletracker.core.ui.theme.shuttleTrackerColorScheme import edu.rpi.shuttletracker.feature.settings.components.SettingsItem -/** - * The About screen: repo/issues/privacy links, the libraries list, and version info. Tapping the - * version row 10 times calls [AboutViewModel.activateDevOptions], the classic Android "unlock - * developer options" gesture - after that, [SettingsScreen] shows a Developer Options entry. - * */ +/** Shows project details; tapping the version ten times unlocks developer options. */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun AboutScreen( diff --git a/app/src/main/java/edu/rpi/shuttletracker/feature/settings/about/AboutViewModel.kt b/app/src/main/java/edu/rpi/shuttletracker/feature/settings/about/AboutViewModel.kt index bd98b419..1c8ba84a 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/feature/settings/about/AboutViewModel.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/feature/settings/about/AboutViewModel.kt @@ -7,7 +7,6 @@ import edu.rpi.shuttletracker.data.local.preferences.UserPreferences import kotlinx.coroutines.launch import javax.inject.Inject -/** Backs [AboutScreen]. Just the one action - everything else on that screen is static/links. */ @HiltViewModel class AboutViewModel @Inject diff --git a/app/src/main/java/edu/rpi/shuttletracker/feature/settings/components/SettingsItem.kt b/app/src/main/java/edu/rpi/shuttletracker/feature/settings/components/SettingsItem.kt index 6e23bda3..4a7e6faa 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/feature/settings/components/SettingsItem.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/feature/settings/components/SettingsItem.kt @@ -16,17 +16,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp -/** - * One row in a settings list: an optional icon, a title/description, and either a click target or - * a trailing action (switch, etc). The one shared building block behind every settings screen. - * - * @param icon: Icon to show with the setting - * @param title: Title of the setting - * @param description: Any subtitle to show with the setting - * @param hasBottomSpacing: Adds bottom padding if true, else no padding - * @param onClick: What happens when the setting tile is clicked - * @param actions: any other composable such as switches to display with the setting - * */ +/** Shared settings row with optional icon, description, click action, and trailing content. */ @Composable fun SettingsItem( @DrawableRes icon: Int? = null, diff --git a/app/src/main/java/edu/rpi/shuttletracker/feature/settings/developerMenu/DevMenuScreen.kt b/app/src/main/java/edu/rpi/shuttletracker/feature/settings/developerMenu/DevMenuScreen.kt index 8fa97f79..ca07fcc9 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/feature/settings/developerMenu/DevMenuScreen.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/feature/settings/developerMenu/DevMenuScreen.kt @@ -26,13 +26,7 @@ import edu.rpi.shuttletracker.core.ui.theme.ShuttleTrackerTheme import edu.rpi.shuttletracker.feature.settings.components.SettingsItem import kotlinx.coroutines.launch -/** - * The developer options screen, reachable from Settings only after being unlocked via - * [edu.rpi.shuttletracker.feature.settings.about.AboutScreen]. Turning the top switch off here - * re-locks dev options and navigates back. To add a new dev toggle: add a preference (see - * [edu.rpi.shuttletracker.data.local.preferences.UserPreferences]), expose it from - * [DevMenuViewModel], and add a [SettingsItem] + `Switch` for it below. - * */ +/** Developer toggles unlocked from About; disabling the top switch locks this screen again. */ @Composable fun DevMenuScreen( onBack: () -> Unit, diff --git a/app/src/main/java/edu/rpi/shuttletracker/feature/settings/developerMenu/DevMenuViewModel.kt b/app/src/main/java/edu/rpi/shuttletracker/feature/settings/developerMenu/DevMenuViewModel.kt index b5e9aaea..f48dabe2 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/feature/settings/developerMenu/DevMenuViewModel.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/feature/settings/developerMenu/DevMenuViewModel.kt @@ -10,7 +10,6 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject -/** Backs [DevMenuScreen]. Same "mirror preferences directly" pattern as `SettingsViewModel`. */ @HiltViewModel class DevMenuViewModel @Inject diff --git a/app/src/main/java/edu/rpi/shuttletracker/feature/setup/Permission.kt b/app/src/main/java/edu/rpi/shuttletracker/feature/setup/Permission.kt index e80fe0e9..5aab55f9 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/feature/setup/Permission.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/feature/setup/Permission.kt @@ -6,11 +6,7 @@ import androidx.annotation.RequiresApi import androidx.annotation.StringRes import edu.rpi.shuttletracker.R -/** - * One permission group shown in the first-run setup flow. [permissions] can be more than one - * Android permission (e.g. fine + coarse location); [requiresAll] says whether every one of them - * must be granted to count as "granted", or just one. - * */ +/** A setup permission group; [requiresAll] controls whether every permission is required. */ sealed class Permission( @param:StringRes val nameRes: Int, @param:StringRes val descriptionRes: Int, diff --git a/app/src/main/java/edu/rpi/shuttletracker/feature/setup/SetupPage.kt b/app/src/main/java/edu/rpi/shuttletracker/feature/setup/SetupPage.kt index 0f08ec7f..6e2bcddd 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/feature/setup/SetupPage.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/feature/setup/SetupPage.kt @@ -3,7 +3,6 @@ package edu.rpi.shuttletracker.feature.setup import androidx.annotation.StringRes import edu.rpi.shuttletracker.R -/** One step of the first-run setup flow, in order: [About] -> [PrivacyPolicy] -> [Permissions]. */ enum class SetupPage( @param:StringRes val titleRes: Int, @param:StringRes val nextButtonRes: Int, diff --git a/app/src/main/java/edu/rpi/shuttletracker/feature/setup/SetupPageContent.kt b/app/src/main/java/edu/rpi/shuttletracker/feature/setup/SetupPageContent.kt index 851b365f..358dee16 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/feature/setup/SetupPageContent.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/feature/setup/SetupPageContent.kt @@ -26,7 +26,6 @@ import androidx.core.content.ContextCompat import edu.rpi.shuttletracker.R import edu.rpi.shuttletracker.feature.setup.components.PermissionItem -/** Renders whichever [SetupPage] the user is currently on. */ @Composable fun SetupPageContent(page: SetupPage) { when (page) { @@ -46,11 +45,7 @@ fun AboutPage() { } } -/** - * [R.array.privacy_page] is one paragraph per item, so each gets its own [Text] with real spacing - * instead of running together. The first item (title + effective date) is de-emphasized since - * [SetupPage]'s TopAppBar already shows "Privacy Policy" as the page title. - * */ +/** Renders each privacy-policy array item as a separate paragraph. */ @Composable fun PrivacyPolicyPage() { val paragraphs = stringArrayResource(R.array.privacy_page) @@ -87,7 +82,6 @@ fun PermissionsPage() { } } -/** One [Permission]'s row: tracks whether it's granted and launches the system permission dialog on tap. */ @Composable fun PermissionBox(permission: Permission) { val context = LocalContext.current diff --git a/app/src/main/java/edu/rpi/shuttletracker/feature/setup/SetupScreen.kt b/app/src/main/java/edu/rpi/shuttletracker/feature/setup/SetupScreen.kt index 92b53030..e54be546 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/feature/setup/SetupScreen.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/feature/setup/SetupScreen.kt @@ -30,11 +30,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import edu.rpi.shuttletracker.core.ui.theme.ShuttleTrackerTheme -/** - * The first-run flow: About -> Privacy Policy -> Permissions (see [SetupPage]). Calls - * [onSetupComplete] once [SetupScreenViewModel] marks the last page done, which is what tells - * [edu.rpi.shuttletracker.app.navigation.AppNavigation] to swap over to the map. - * */ +/** Runs the first-launch About, Privacy Policy, and Permissions flow. */ @Composable fun SetupScreen( onSetupComplete: () -> Unit, @@ -55,7 +51,6 @@ fun SetupScreen( ) } -/** Stateless setup UI: a card with the current page's content, a next/finish button, and back-button support. */ @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun SetupContent( diff --git a/app/src/main/java/edu/rpi/shuttletracker/feature/setup/SetupScreenViewModel.kt b/app/src/main/java/edu/rpi/shuttletracker/feature/setup/SetupScreenViewModel.kt index b85efa1e..2523de52 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/feature/setup/SetupScreenViewModel.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/feature/setup/SetupScreenViewModel.kt @@ -13,11 +13,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject -/** - * Backs [SetupScreen]. [completeCurrentPage] saves that page's acceptance (about/privacy policy) - * to [UserPreferences] and advances to the next [SetupPage], or - on the last page - marks setup - * complete and sets [SetupUiState.isComplete]. - * */ +/** Saves each accepted setup step and advances or completes the first-run flow. */ @HiltViewModel class SetupScreenViewModel @Inject diff --git a/app/src/main/java/edu/rpi/shuttletracker/feature/setup/components/PermissionItem.kt b/app/src/main/java/edu/rpi/shuttletracker/feature/setup/components/PermissionItem.kt index aa40f929..fd637a2b 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/feature/setup/components/PermissionItem.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/feature/setup/components/PermissionItem.kt @@ -14,7 +14,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import edu.rpi.shuttletracker.core.ui.theme.ShuttleTrackerTheme -/** A name/description row with a Grant button that becomes disabled and relabeled once granted. */ @Composable fun PermissionItem( name: String, 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 cefaed1a..fa450526 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/widget/EtaWidget.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/widget/EtaWidget.kt @@ -67,11 +67,7 @@ private val XXLARGE = DpSize(320.dp, 390.dp) private val WIDE = DpSize(450.dp, 250.dp) private val WIDE_TALL = DpSize(450.dp, 390.dp) -/** - * Home-screen widget with two views: all stops' soonest arrivals, or one configured stop's full - * live status. [EtaWidgetUpdater] fetches the data and saves it to Glance state; this class only - * renders whatever's already stored, it never touches the network itself. - * */ +/** Renders all-route or configured-stop data previously saved by [EtaWidgetUpdater]. */ class EtaWidget : GlanceAppWidget() { override val stateDefinition = PreferencesGlanceStateDefinition @@ -89,7 +85,6 @@ class EtaWidget : GlanceAppWidget() { } } -/** Backs the manifest `` entry; just points the framework at [EtaWidget]. */ class EtaWidgetReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = EtaWidget() @@ -137,7 +132,6 @@ private fun EtaWidgetContent() { .padding(8.dp), ) { WidgetHeader( - // Ignored in all-routes mode - WidgetHeader shows the route filter there instead. title = if (showingAllRoutes) { "" @@ -241,7 +235,6 @@ private fun ConfigurePrompt() { } } -/** Opens [EtaWidgetConfigureActivity] to change this widget instance's stop. */ @Composable private fun configureAction(): Action { val context = LocalContext.current @@ -282,7 +275,6 @@ private fun WidgetHeader( verticalAlignment = Alignment.CenterVertically, ) { if (showingAllRoutes) { - // All-routes mode has no stop to name, so the route filter goes here instead. Text( text = routeFilter?.lowercaseTitle() ?: context.getString(R.string.etas_route_all), maxLines = 1, diff --git a/app/src/main/java/edu/rpi/shuttletracker/widget/EtaWidgetActions.kt b/app/src/main/java/edu/rpi/shuttletracker/widget/EtaWidgetActions.kt index 919e7982..09f45919 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/widget/EtaWidgetActions.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/widget/EtaWidgetActions.kt @@ -8,7 +8,6 @@ import androidx.glance.appwidget.action.ActionCallback import androidx.glance.appwidget.state.updateAppWidgetState import edu.rpi.shuttletracker.feature.etas.utils.ETA_VISIBLE_ROUTES -/** Refetches live data and updates every widget instance. Bound to the widget's refresh icon. */ class RefreshAction : ActionCallback { override suspend fun onAction( context: Context, @@ -19,7 +18,7 @@ class RefreshAction : ActionCallback { } } -/** Cycles this instance's route filter through "every route" and each of [ETA_VISIBLE_ROUTES]. Just a display filter, no network call. */ +/** Cycles the local route filter without fetching data. */ class ToggleRouteFilterAction : ActionCallback { override suspend fun onAction( context: Context, @@ -41,7 +40,7 @@ class ToggleRouteFilterAction : ActionCallback { } } -/** Flips this instance between the all-routes view and its configured single-stop view. Just a display switch, no network call. */ +/** Switches between all routes and the configured stop without fetching data. */ class ToggleStopModeAction : ActionCallback { override suspend fun onAction( context: Context, diff --git a/app/src/main/java/edu/rpi/shuttletracker/widget/EtaWidgetConfigureActivity.kt b/app/src/main/java/edu/rpi/shuttletracker/widget/EtaWidgetConfigureActivity.kt index cab60888..7863d562 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/widget/EtaWidgetConfigureActivity.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/widget/EtaWidgetConfigureActivity.kt @@ -41,11 +41,7 @@ import edu.rpi.shuttletracker.feature.etas.utils.buildStopsWithEtas import kotlinx.coroutines.launch import javax.inject.Inject -/** - * Lets a user pick which stop one [EtaWidget] instance is configured to. Opens automatically when - * a widget is placed (`android:configure` in `res/xml/eta_widget_info.xml`) or edited, and can also - * be opened from inside the widget itself. - * */ +/** Selects the stop shown by one widget instance during setup or editing. */ @AndroidEntryPoint class EtaWidgetConfigureActivity : ComponentActivity() { @Inject diff --git a/app/src/main/java/edu/rpi/shuttletracker/widget/EtaWidgetRefreshWorker.kt b/app/src/main/java/edu/rpi/shuttletracker/widget/EtaWidgetRefreshWorker.kt index ed541744..fc8d30bc 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/widget/EtaWidgetRefreshWorker.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/widget/EtaWidgetRefreshWorker.kt @@ -15,7 +15,7 @@ import java.time.Duration private const val UNIQUE_WORK_NAME = "eta_widget_refresh" private const val UNIQUE_IMMEDIATE_WORK_NAME = "eta_widget_refresh_immediate" -/** Refreshes every widget instance every 15 minutes, WorkManager's minimum interval. [RefreshAction] covers shorter gaps. */ +/** Refreshes widgets at WorkManager's 15-minute minimum interval. */ class EtaWidgetRefreshWorker( context: Context, params: WorkerParameters, @@ -43,7 +43,7 @@ fun cancelEtaWidgetRefresh(context: Context) { WorkManager.getInstance(context).cancelUniqueWork(UNIQUE_WORK_NAME) } -/** Fires once immediately, so a newly placed widget doesn't sit empty until the next periodic tick. */ +/** Fills a new widget before its first periodic refresh. */ fun enqueueImmediateEtaWidgetRefresh(context: Context) { val request = OneTimeWorkRequestBuilder() diff --git a/app/src/main/java/edu/rpi/shuttletracker/widget/EtaWidgetTheme.kt b/app/src/main/java/edu/rpi/shuttletracker/widget/EtaWidgetTheme.kt index 2a593107..0b59434d 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/widget/EtaWidgetTheme.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/widget/EtaWidgetTheme.kt @@ -5,7 +5,7 @@ import androidx.glance.color.ColorProviders import edu.rpi.shuttletracker.core.ui.theme.shuttleTrackerColorScheme import androidx.glance.material3.ColorProviders as material3ColorProviders -/** The widget's light/dark colors, built from the app's own [shuttleTrackerColorScheme] instead of Glance's Material defaults. Dynamic color is left off so the widget stays visually stable. */ +/** Uses the app palette without dynamic color so widget colors stay stable. */ fun etaWidgetColors(context: Context): ColorProviders = material3ColorProviders( light = shuttleTrackerColorScheme(context, darkTheme = false, dynamicColor = false), 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 eb555b0b..ec7ec2b4 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/widget/EtaWidgetUpdater.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/widget/EtaWidgetUpdater.kt @@ -22,25 +22,19 @@ import edu.rpi.shuttletracker.feature.schedule.utils.RPI_ZONE_ID import edu.rpi.shuttletracker.feature.schedule.utils.nextScheduledArrival import kotlinx.coroutines.flow.first -/** Keys into each [EtaWidget] instance's [androidx.glance.state.PreferencesGlanceStateDefinition] state. */ +/** Preference keys stored separately for each widget instance. */ object EtaWidgetKeys { val SNAPSHOT_JSON = stringPreferencesKey("snapshot_json") val LAST_UPDATED_MILLIS = longPreferencesKey("last_updated_millis") val ROUTE_FILTER = stringPreferencesKey("route_filter") val LOAD_FAILED = booleanPreferencesKey("load_failed") - /** The stop key an instance's single-stop view targets, set by [EtaWidgetConfigureActivity]. */ val CONFIGURED_STOP = stringPreferencesKey("configured_stop") - /** Whether this instance shows the all-routes view instead of [CONFIGURED_STOP]. Defaults to `true`. */ val SHOWING_ALL_ROUTES = booleanPreferencesKey("showing_all_routes") } -/** - * Fetches routes + live vehicle etas and refreshes every placed [EtaWidget] instance's state - - * shared by [EtaWidgetRefreshWorker]'s periodic background refresh and the widget's own manual - * refresh button ([RefreshAction]), so both paths update state the same way. - * */ +/** Fetches one snapshot used by both periodic and manual widget refreshes. */ object EtaWidgetUpdater { private const val TAG = "EtaWidgetUpdater" @@ -63,7 +57,7 @@ object EtaWidgetUpdater { if (glanceIds.isNotEmpty()) EtaWidget().updateAll(context) } - /** Null only when the routes fetch itself fails - a vehicle-endpoint failure just yields fewer etas, not a hard error, since the widget has no room to show per-endpoint error detail anyway. */ + /** Route failure aborts refresh; vehicle endpoint failures produce a partial snapshot. */ private suspend fun fetchSnapshot( repository: ShuttleRepository, userPreferences: UserPreferences, @@ -96,7 +90,6 @@ object EtaWidgetUpdater { val vehicles = VehicleMerger.merge(locations = locations, velocities = velocities, etas = etas) val schedule = repository.getSchedule().dataOrNull() - // Same dev-mode fallback as the map and ETAs tab. val fakeShuttlesActive = userPreferences.getDevOptions().first() && userPreferences.getFakeShuttlesEnabled().first() val allVehicles = @@ -106,7 +99,6 @@ object EtaWidgetUpdater { vehicles } - // Every stop, so any instance can find its configured stop below. val stopDirectory = buildStopsWithEtas(routes, emptyList()) val perStop = diff --git a/app/src/main/java/edu/rpi/shuttletracker/widget/WidgetEntryPoint.kt b/app/src/main/java/edu/rpi/shuttletracker/widget/WidgetEntryPoint.kt index 5cf76fca..9c2ce64d 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/widget/WidgetEntryPoint.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/widget/WidgetEntryPoint.kt @@ -8,7 +8,7 @@ import dagger.hilt.components.SingletonComponent import edu.rpi.shuttletracker.data.local.preferences.UserPreferences import edu.rpi.shuttletracker.data.repository.ShuttleRepository -/** Lets classes that only get a plain [Context] (the refresh worker, Glance actions) reach [ShuttleRepository]/[UserPreferences] without full Hilt injection. */ +/** Exposes app dependencies to workers and Glance actions that only receive a [Context]. */ @EntryPoint @InstallIn(SingletonComponent::class) interface WidgetEntryPoint { 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 35c759d5..3a454286 100644 --- a/app/src/main/java/edu/rpi/shuttletracker/widget/WidgetSnapshot.kt +++ b/app/src/main/java/edu/rpi/shuttletracker/widget/WidgetSnapshot.kt @@ -10,32 +10,26 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import java.time.Instant -/** How many soonest-arriving stops the "all routes" view keeps - it only has room to show a handful. */ private const val MAX_STOPS = 8 -/** How many upcoming shuttles per stop the "all routes" view keeps. */ private const val MAX_ETAS_PER_STOP = 3 -/** How many vehicles the single-stop view keeps. */ private const val MAX_VEHICLES_PER_STOP = 6 private val json = Json { ignoreUnknownKeys = true } -/** One upcoming arrival - a serializable stand-in for [edu.rpi.shuttletracker.feature.etas.utils.VehicleEta]. */ @Serializable data class WidgetEtaSnapshot( val routeName: String?, val etaEpochMillis: Long, ) -/** One stop plus its soonest upcoming arrivals - the "all routes" view's stand-in for [StopWithEtas]. */ @Serializable data class WidgetStopSnapshot( val stopName: String, val etas: List, ) -/** One vehicle relevant to a single-stop view's target stop - see [edu.rpi.shuttletracker.feature.etas.utils.vehiclesForStop]. */ @Serializable data class WidgetVehicleSnapshot( val name: String, @@ -46,7 +40,6 @@ data class WidgetVehicleSnapshot( val etaEpochMillis: Long?, ) -/** Everything a single-stop widget instance needs to render one target stop. */ @Serializable data class SingleStopSnapshot( val stopName: String, @@ -54,11 +47,7 @@ data class SingleStopSnapshot( val nextScheduledEpochMillis: Long?, ) -/** - * Everything the widget shows, as of one successful fetch, stored as JSON in Glance state. - * [allRoutes] backs the all-routes view; [perStop] holds every stop's own single-stop view (keyed - * by stop key) so any instance can show whichever stop it's configured for. - * */ +/** Serializable data shared by all widget instances after one successful fetch. */ @Serializable data class WidgetSnapshot( val allRoutes: List = emptyList(), @@ -78,7 +67,7 @@ data class WidgetSnapshot( } } -/** Keeps only the stops with a live eta, soonest first, trimmed to what the widget has room to show. */ +/** Keeps the soonest live stops that fit in the all-routes widget. */ fun List.toWidgetStopSnapshots(): List = this .filter { it.etas.isNotEmpty() } @@ -97,7 +86,7 @@ fun List.toWidgetStopSnapshots(): List = ) } -/** Builds the single-stop view for [stopKey]: [vehicles] sorted at-stop-first then soonest eta, trimmed to [MAX_VEHICLES_PER_STOP]. [routesByName] resolves each vehicle's current stop key to a display name. */ +/** Builds a single-stop view with at-stop vehicles first, then by ETA. */ fun buildSingleStopSnapshot( stopKey: String, stopName: String, @@ -134,7 +123,7 @@ fun buildSingleStopSnapshot( ) } -/** [WidgetSnapshot.allRoutes] narrowed to one route, dropping stops left with no matching etas - `null` shows every route, as-is. */ +/** Filters the all-routes snapshot; null keeps every route. */ fun WidgetSnapshot.allRoutesForRoute(routeFilter: String?): List = if (routeFilter == null) { allRoutes diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3407b573..86ab7cd5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,12 +1,10 @@ Shubble - + 3 - - - + https://api-shuttles.rpi.edu/api/ https://wtg.github.io/Shuttle-Tracker-Android/privacy-policy/ https://github.com/wtg/Shuttle-Tracker-Android 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 88308618..4b5c0c9c 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 @@ -388,8 +388,7 @@ class MapsViewModelTest { assertThat(viewModel.mapsUiState.value.mapType).isEqualTo(MapType.HYBRID) } - // The fake vehicle ticker loops forever with delay(), so advanceUntilIdle() would hang while - // it's running; runCurrent()/advanceTimeBy() step the virtual clock by a bounded amount instead. + // The fake ticker never ends, so advance its virtual clock by bounded amounts. @Test fun `fake vehicles only start once both dev options and the fake shuttle toggle are on`() = 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 3188acb0..f9caceb0 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 @@ -129,7 +129,6 @@ class FakeShuttleUtilsTest { stops = listOf("quarter", "half"), stopDetails = mapOf( - // A quarter and half of the way around the square loop, respectively. "quarter" to Stop(coordinates = listOf(0.0, 1.0), offset = 0, name = "Quarter"), "half" to Stop(coordinates = listOf(1.0, 1.0), offset = 0, name = "Half"), ), @@ -156,8 +155,7 @@ class FakeShuttleUtilsTest { ) val now = Instant.parse("2026-07-19T12:00:00Z") - // The vehicle is a hair past the stop's own position (progress 0.0), so the stop is - // almost a full loop away, not slightly in the past. + // Just past progress 0, the same stop is almost a full loop away. val vehicle = buildFakeVehicles(mapOf("NORTH" to routeWithStops), elapsedMs = 100L, now = now).first() val etaStart = OffsetDateTime.parse(vehicle.stopTimes.getValue("start")).toInstant() diff --git a/build.gradle.kts b/build.gradle.kts index af047e11..bf1f1268 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -14,7 +14,7 @@ plugins { alias(libs.plugins.kotlin.serialization) apply false } -// pre commit hook to check kotlin style before commits +// Install the repository's Kotlin style check before commits. tasks.register("copyPreCommitHook") { description = "Copy pre-commit git hook from the scripts to the .git/hooks folder." group = "git hooks" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 83cae0db..13a8aa90 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -5,7 +5,7 @@ permalink: /architecture/ # Architecture -A quick map of the codebase so you know where to look add code. This app is written in Kotlin with [Jetpack Compose](https://developer.android.com/jetpack/compose) for the UI, [Hilt](https://developer.android.com/training/dependency-injection/hilt-android) for dependency injection, and follows an [MVVM](https://developer.android.com/topic/architecture) pattern (Model / View / ViewModel). It's a single-Activity app - everything you see is a Compose screen, not a separate Activity. +A quick map of the codebase so you know where to look and add code. This app is written in Kotlin with [Jetpack Compose](https://developer.android.com/jetpack/compose) for the UI, [Hilt](https://developer.android.com/training/dependency-injection/hilt-android) for dependency injection, and follows an [MVVM](https://developer.android.com/topic/architecture) pattern (Model / View / ViewModel). It's a single-Activity app - everything you see is a Compose screen, not a separate Activity. Everything lives under the package `edu.rpi.shuttletracker`, at `app/src/main/java/edu/rpi/shuttletracker/`. @@ -33,17 +33,17 @@ data/mapper/ turns the raw DTO into a data/models/ type ↓ data/repository/ ShuttleRepository wraps the result in a NetworkResult ↓ -feature//ViewModel.kt updates its UiState +feature// a ViewModel updates shared or screen-specific state ↓ -feature//Screen.kt Compose reads UiState and redraws +feature// Compose reads that state and redraws ``` **Handling a user action (screen → network):** ``` -feature//Screen.kt user taps something +feature// user taps something ↓ -feature//ViewModel.kt the tap calls a function on the ViewModel +feature// a callback reaches the owning ViewModel ↓ data/repository/ ShuttleRepository makes the call ↓ @@ -65,7 +65,7 @@ Everything below walks through each of those layers in more detail. This is the single source of truth every feature reads from. Nothing in `feature/` should talk to the network or DataStore directly - it goes through here. -- **`data/models/`** - plain Kotlin `data classes` shared everywhere: `Vehicle`, `Route`, `Stop`, `Schedule`, `Announcement`. No Android or network types leak in here. +- **`data/models/`** - app-level types shared everywhere: `Vehicle`, `Route`, `Stop`, `Schedule`, and `Announcement`. Network DTOs stay out of this package. - **`data/remote/`** - `ShuttleApi` (the Retrofit interface describing each HTTP endpoint) and `RetrofitShuttleRemoteDataSource`, which actually makes the calls. - **`data/mapper/`** - functions that turn raw network DTOs into the `data/models/` types above. - **`data/repository/`** - `ShuttleRepository` is the interface every feature depends on; `DefaultShuttleRepository` is the real implementation. Every response comes back wrapped in a `NetworkResult` (`Success` or `Failure`) so a dropped connection or a server error never crashes a screen - it just becomes a value the ViewModel can react to. @@ -75,19 +75,19 @@ Because `ShuttleRepository` and `UserPreferences` are interfaces, tests can swap ## `feature/` - one folder per screen -Every screen or self-contained flow gets its own folder under `feature/`, e.g. `feature/map/`, `feature/schedule/`, `feature/etas/`, `feature/settings/`, `feature/setup/`. Each one follows the same shape: +Every screen or self-contained flow gets its own folder under `feature/`, e.g. `feature/map/`, `feature/schedule/`, `feature/etas/`, `feature/settings/`, `feature/setup/`. A feature can contain: ``` feature// Screen.kt the composable that navigation points to - ViewModel.kt a @HiltViewModel holding that screen's state + ViewModel.kt an optional @HiltViewModel holding feature state components/ smaller composables used only by this feature utils/ plain Kotlin helper functions (no Compose/Android types) ``` -**`Screen.kt`** is a `@Composable` with a `viewModel: ViewModel = hiltViewModel()` default parameter. Hilt supplies the real ViewModel at runtime; tests construct the ViewModel by hand and pass it in instead. +Screen files contain the feature's `@Composable` entry point. Screens that own a ViewModel usually accept it as a defaulted `hiltViewModel()` parameter; simpler screens can receive state and callbacks from a parent instead. -**`ViewModel.kt`** owns a private `MutableStateFlow` and exposes it publicly as a read-only `StateFlow`. `XyzUiState` is a small `data class` at the bottom of the same file holding everything that screen needs to render (a list of vehicles, a loading flag, an error, etc). The View never mutates state directly - it calls a function on the ViewModel, and the ViewModel updates the flow, which Compose automatically re-renders from. +ViewModels expose read-only state, commonly a `StateFlow` backed by a private `MutableStateFlow` or derived from repository and preference flows. The UI never mutates that state directly: it calls a ViewModel function, then Compose redraws from the updated state. ```kotlin @HiltViewModel @@ -109,8 +109,8 @@ data class ExampleUiState(/* ... */) ### Current features -- **`feature/map/`** - the main screen: the Google Map itself, plus the bottom navigation bar that switches between the Map, ETAs, and Schedule tabs. -- **`feature/etas/`** - the ETAs tab: a list of stops with live arrival times, and a details sheet per stop. +- **`feature/map/`** - the home screen, Google Map, and navigation between the Map, ETAs, and Schedule tabs. `MapsViewModel` owns route and live vehicle state shared by Map and ETAs. +- **`feature/etas/`** - stateless ETA UI: a list of stops with live arrival times and a details sheet per stop. - **`feature/schedule/`** - the Schedule tab: the printed weekly schedule. - **`feature/settings/`** - the settings screen, plus `about/` and `developerMenu/` sub-screens. - **`feature/setup/`** - the first-run flow (permissions, privacy policy acceptance). @@ -133,7 +133,7 @@ Built with [Jetpack Glance](https://developer.android.com/jetpack/androidx/relea - **`WidgetSnapshot.kt`** - the data stored in each widget instance's state. - **`EtaWidgetTheme.kt`** - matches the widget's colors to the app's theme. -Like the map and ETAs tab, the widget shows fake shuttle data in dev mode when there's nothing live to show. +Like the map and ETAs tab, the widget includes simulated shuttles when both developer options and the fake-shuttles toggle are enabled. ## `core/` - shared building blocks diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 58c8d87d..e996db51 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -16,19 +16,13 @@ During setup: - Click Next on the setup pages unless you want to change default settings - Click Finish and allow the installation to complete (this may take some time) -### 1.2 Set up WSL (Windows only) -If you are on Windows, install Windows Subsystem for Linux (WSL): -https://docs.microsoft.com/en-us/windows/wsl/install +### 1.2 Install Git +Install [Git](https://git-scm.com/downloads) for your operating system. WSL is optional on Windows; Android Studio and Git work directly in Windows. -### 1.3 Install Git -Open your Ubuntu / WSL terminal and run: -```apt-get install git``` or if prompted, use: ```sudo apt-get install git``` - -Next, sign in to https://www.github.com, or sign up if you do not have an account. Make sure to remember the username you give, this will -be important for later. +Next, sign in to https://www.github.com, or sign up if you do not have an account. Remember the username you choose; you will use it below. Configure Git with your username and email: -``` +```shell git config --global user.name "your_username" git config --global user.email "your_emailid" ``` @@ -39,7 +33,9 @@ These commands will link your username and email to your computer, so GitHub kno ### 2.1 Clone the repository Run the following command in your terminal: -```git clone https://github.com/wtg/Shuttle-Tracker-Android.git``` +```shell +git clone https://github.com/wtg/Shuttle-Tracker-Android.git +``` The code will be downloaded into a folder named Shuttle-Tracker-Android. @@ -50,30 +46,32 @@ The code will be downloaded into a folder named Shuttle-Tracker-Android. ## 3. Google Maps API Key Setup ### 3.1 Generate a Google Maps API key -Get your own custom google maps API key. Here's a guide that details how to get one for yourself: [Here](https://developers.google.com/maps/documentation/javascript/get-api-key). +Create a key with the Maps SDK for Android enabled by following Google's [Maps SDK for Android key guide](https://developers.google.com/maps/documentation/android-sdk/get-api-key).
If you have trouble obtaining your debug SHA-1 this might help - Run this in a terminal (WSL users on Windows): +Run the Gradle signing report and copy the debug variant's SHA-1: - keytool -list -v \ - -keystore /mnt/c/Users//.android/debug.keystore \ - -alias androiddebugkey \ - -storepass android \ - -keypass android +```shell +./gradlew signingReport +``` + +On Windows without WSL, use `gradlew.bat signingReport`.
### 3.2 Insert your API key -Open the **local.properties** in your project level directory, and then add the following code. +Open `local.properties` in the project root and add: -```MAPS_API_KEY=YOUR_API_KEY``` +```properties +MAPS_API_KEY=YOUR_API_KEY +``` ## 4. Running the Virtual Android Device 1. In Android Studio, open the **Device Manager**, click the **+** button, and select **Create Virtual Device**. -2. Choose **Pixel 8**. When prompted to select a system image, choose **API level 34 (Android 14)** - specifically the image with the **Play Store** icon next to it, not a plain "Google APIs" image. The app uses Firebase push notifications, which need real Google Play Services to deliver correctly, and only the Play Store images include that. -3. Select the Pixel 8 emulator as your run configuration. +2. Choose a recent Pixel and an **API 34 or newer** system image with the **Play Store** icon. The Play Store image includes the Google Play services needed by Maps and Firebase. +3. Select the emulator as your run configuration. 4. Click **Run**. After completing these steps, the virtual Android device should launch and Google Maps should load correctly. @@ -82,25 +80,5 @@ Once that's all done, congrats. You are now ready to start development for the R If you're new to the codebase, [Architecture](ARCHITECTURE.md) is a good next read - it maps out where things live before you start changing code. ## 5. Troubleshooting (optional) -If you complete steps 1 through 4 and you are receiving the build tools is corrupted issue, use this link to Stack Overflow to solve the problem and put yourself back on track because of an error on Google's part: [Here](https://stackoverflow.com/a/68430992). - -If you have an error that looks like this, -Screenshot 2026-04-29 165126 - -This project is not compatible with higher versions of java. -Do not click project update recommended popup, it will fix the problem but then no longer be compatible with other branches/main project. - -If you have a higher version of JDK installed, manually set JAVA_HOME to be jdk-17(must be at least this). - -Can use the following commands to install and set JAVA_HOME to jdk-17(run these in the Ubuntu Terminal): -``` -sudo apt update -sudo apt install -y openjdk-17-jdk t -readlink -f $(which javac) -``` -You will see something like this: /usr/lib/jvm/java-17-openjdk-amd64/bin/javac and java home should be: /usr/lib/jvm/java-17-openjdk-amd64 -``` -export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 -export PATH=$JAVA_HOME/bin:$PATH -``` +The project targets JVM 17. If Gradle reports a Java version error, open **Settings → Build, Execution, Deployment → Build Tools → Gradle** in Android Studio and select a JDK 17-compatible Gradle JDK, then sync the project again.