{
"content": "# BusWatch Implementation Plan\n\n> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.\n\n**Goal:** Build a Wear OS app showing real-time London bus arrivals for nearby stops using TfL API\n\n**Architecture:** MVVM with Repository pattern, Jetpack Compose for Wear OS, Hilt for DI, Retrofit for networking\n\n**Tech Stack:** Kotlin, Wear OS 3.0+, Jetpack Compose, Hilt, Retrofit, DataStore, Play Services Location\n\n---\n\n## Task 1: Android Wear OS Project Setup\n\n**Files:**\n- Create: `build.gradle.kts` (project root)\n- Create: `app/build.gradle.kts`\n- Create: `settings.gradle.kts`\n- Create: `gradle.properties`\n- Create: `app/src/main/AndroidManifest.xml`\n\n**Step 1: Create project-level build.gradle.kts**\n\n```kotlin\n// ABOUTME: Project-level build configuration for BusWatch Wear OS app\n// ABOUTME: Defines plugin versions and repositories for the entire project\nplugins {\n id(\"com.android.application\") version \"8.2.0\" apply false\n id(\"org.jetbrains.kotlin.android\") version \"1.9.20\" apply false\n id(\"com.google.dagger.hilt.android\") version \"2.48\" apply false\n id(\"org.jetbrains.kotlin.plugin.serialization\") version \"1.9.20\" apply false\n}\n```\n\n**Step 2: Create settings.gradle.kts**\n\n```kotlin\n// ABOUTME: Gradle settings defining plugin repositories and included modules\npluginManagement {\n repositories {\n google()\n mavenCentral()\n gradlePluginPortal()\n }\n}\n\ndependencyResolutionManagement {\n repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)\n repositories {\n google()\n mavenCentral()\n }\n}\n\nrootProject.name = \"BusWatch\"\ninclude(\":app\")\n```\n\n**Step 3: Create gradle.properties**\n\n```properties\norg.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8\nandroid.useAndroidX=true\nandroid.nonTransitiveRClass=true\nkotlin.code.style=official\n```\n\n**Step 4: Create app/build.gradle.kts**\n\n```kotlin\n// ABOUTME: App-level build configuration with dependencies and SDK versions\n// ABOUTME: Configures Wear OS app with Compose, Hilt, Retrofit, and required libraries\nplugins {\n id(\"com.android.application\")\n id(\"org.jetbrains.kotlin.android\")\n id(\"com.google.dagger.hilt.android\")\n id(\"org.jetbrains.kotlin.plugin.serialization\")\n kotlin(\"kapt\")\n}\n\nandroid {\n namespace = \"com.buswatch\"\n compileSdk = 34\n\n defaultConfig {\n applicationId = \"com.buswatch\"\n minSdk = 30\n targetSdk = 34\n versionCode = 1\n versionName = \"1.0.0\"\n\n testInstrumentationRunner = \"androidx.test.runner.AndroidJUnitRunner\"\n\n val apiKey = project.findProperty(\"tfl.api.key\") as String? ?: \"\"\n buildConfigField(\"String\", \"TFL_API_KEY\", \"\\\"$apiKey\\\"\")\n }\n\n buildTypes {\n release {\n isMinifyEnabled = false\n proguardFiles(\n getDefaultProguardFile(\"proguard-android-optimize.txt\"),\n \"proguard-rules.pro\"\n )\n }\n }\n\n compileOptions {\n sourceCompatibility = JavaVersion.VERSION_17\n targetCompatibility = JavaVersion.VERSION_17\n }\n\n kotlinOptions {\n jvmTarget = \"17\"\n }\n\n buildFeatures {\n compose = true\n buildConfig = true\n }\n\n composeOptions {\n kotlinCompilerExtensionVersion = \"1.5.4\"\n }\n\n packaging {\n resources {\n excludes += \"/META-INF/{AL2.0,LGPL2.1}\"\n }\n }\n}\n\ndependencies {\n // Wear OS\n implementation(\"androidx.wear:wear:1.3.0\")\n implementation(\"androidx.wear.compose:compose-material:1.2.1\")\n implementation(\"androidx.wear.compose:compose-foundation:1.2.1\")\n\n // Compose\n implementation(platform(\"androidx.compose:compose-bom:2023.10.01\"))\n implementation(\"androidx.compose.ui:ui\")\n implementation(\"androidx.compose.ui:ui-tooling-preview\")\n implementation(\"androidx.activity:activity-compose:1.8.1\")\n\n // Lifecycle\n implementation(\"androidx.lifecycle:lifecycle-runtime-ktx:2.6.2\")\n implementation(\"androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2\")\n\n // Hilt\n implementation(\"com.google.dagger:hilt-android:2.48\")\n kapt(\"com.google.dagger:hilt-android-compiler:2.48\")\n implementation(\"androidx.hilt:hilt-navigation-compose:1.1.0\")\n\n // Networking\n implementation(\"com.squareup.retrofit2:retrofit:2.9.0\")\n implementation(\"com.squareup.retrofit2:converter-gson:2.9.0\")\n implementation(\"com.squareup.okhttp3:okhttp:4.12.0\")\n implementation(\"com.squareup.okhttp3:logging-interceptor:4.12.0\")\n\n // Location\n implementation(\"com.google.android.gms:play-services-location:21.0.1\")\n\n // DataStore\n implementation(\"androidx.datastore:datastore-preferences:1.0.0\")\n\n // Serialization\n implementation(\"org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0\")\n\n // Logging\n implementation(\"com.jakewharton.timber:timber:5.0.1\")\n\n // Testing\n testImplementation(\"junit:junit:4.13.2\")\n testImplementation(\"io.mockk:mockk:1.13.8\")\n testImplementation(\"org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3\")\n testImplementation(\"app.cash.turbine:turbine:1.0.0\")\n androidTestImplementation(\"androidx.test.ext:junit:1.1.5\")\n androidTestImplementation(\"androidx.test.espresso:espresso-core:3.5.1\")\n androidTestImplementation(\"androidx.compose.ui:ui-test-junit4\")\n debugImplementation(\"androidx.compose.ui:ui-tooling\")\n debugImplementation(\"androidx.compose.ui:ui-test-manifest\")\n}\n\nkapt {\n correctErrorTypes = true\n}\n```\n\n**Step 5: Create AndroidManifest.xml**\n\n```xml\n<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n\n <uses-feature android:name=\"android.hardware.type.watch\" />\n\n <uses-permission android:name=\"android.permission.ACCESS_FINE_LOCATION\" />\n <uses-permission android:name=\"android.permission.ACCESS_COARSE_LOCATION\" />\n <uses-permission android:name=\"android.permission.INTERNET\" />\n <uses-permission android:name=\"android.permission.WAKE_LOCK\" />\n\n <application\n android:name=\".BusWatchApplication\"\n android:allowBackup=\"true\"\n android:icon=\"@mipmap/ic_launcher\"\n android:label=\"@string/app_name\"\n android:supportsRtl=\"true\"\n android:theme=\"@android:style/Theme.DeviceDefault\">\n\n <uses-library\n android:name=\"com.google.android.wearable\"\n android:required=\"true\" />\n\n <meta-data\n android:name=\"com.google.android.wearable.standalone\"\n android:value=\"true\" />\n\n <activity\n android:name=\".MainActivity\"\n android:exported=\"true\"\n android:theme=\"@android:style/Theme.DeviceDefault\">\n <intent-filter>\n <action android:name=\"android.intent.action.MAIN\" />\n <category android:name=\"android.intent.category.LAUNCHER\" />\n </intent-filter>\n </activity>\n </application>\n</manifest>\n```\n\n**Step 6: Create local.properties (for TfL API key)**\n\nIn root directory, create `local.properties`:\n```properties\ntfl.api.key=YOUR_TFL_API_KEY_HERE\n```\n\n**Step 7: Commit**\n\n```bash\ngit add build.gradle.kts settings.gradle.kts gradle.properties app/build.gradle.kts app/src/main/AndroidManifest.xml\ngit commit -m \"feat: initial Android Wear OS project setup\"\n```\n\n---\n\n## Task 2: Application Class and Hilt Setup\n\n**Files:**\n- Create: `app/src/main/java/com/buswatch/BusWatchApplication.kt`\n- Create: `app/src/main/res/values/strings.xml`\n- Create: `app/src/main/res/mipmap-hdpi/ic_launcher.png` (placeholder)\n\n**Step 1: Write BusWatchApplication class**\n\n```kotlin\n// ABOUTME: Application class initializing Timber logging and Hilt dependency injection\n// ABOUTME: Entry point for the BusWatch Wear OS application\npackage com.buswatch\n\nimport android.app.Application\nimport dagger.hilt.android.HiltAndroidApp\nimport timber.log.Timber\n\n@HiltAndroidApp\nclass BusWatchApplication : Application() {\n override fun onCreate() {\n super.onCreate()\n if (BuildConfig.DEBUG) {\n Timber.plant(Timber.DebugTree())\n }\n Timber.d(\"BusWatch application started\")\n }\n}\n```\n\n**Step 2: Create strings.xml**\n\n```xml\n<resources>\n <string name=\"app_name\">BusWatch</string>\n</resources>\n```\n\n**Step 3: Commit**\n\n```bash\ngit add app/src/main/java/com/buswatch/BusWatchApplication.kt app/src/main/res/values/strings.xml\ngit commit -m \"feat: add Application class with Hilt and Timber\"\n```\n\n---\n\n## Task 3: Domain Models\n\n**Files:**\n- Create: `app/src/main/java/com/buswatch/domain/model/BusStop.kt`\n- Create: `app/src/main/java/com/buswatch/domain/model/BusArrival.kt`\n- Create: `app/src/main/java/com/buswatch/domain/model/ArrivalType.kt`\n- Create: `app/src/test/java/com/buswatch/domain/model/BusStopTest.kt`\n\n**Step 1: Write failing test for BusStop**\n\n```kotlin\n// ABOUTME: Unit tests for BusStop domain model\npackage com.buswatch.domain.model\n\nimport org.junit.Assert.assertEquals\nimport org.junit.Test\n\nclass BusStopTest {\n @Test\n fun `BusStop creation with all properties`() {\n val stop = BusStop(\n id = \"490000001B\",\n code = \"BP\",\n name = \"Oxford Street\",\n latitude = 51.5074,\n longitude = -0.1278,\n routes = listOf(\"25\", \"73\", \"98\"),\n distanceMeters = 150\n )\n\n assertEquals(\"490000001B\", stop.id)\n assertEquals(\"BP\", stop.code)\n assertEquals(\"Oxford Street\", stop.name)\n assertEquals(150, stop.distanceMeters)\n assertEquals(3, stop.routes.size)\n }\n}\n```\n\n**Step 2: Run test to verify it fails**\n\nRun: `./gradlew test --tests BusStopTest`\nExpected: FAIL with \"Unresolved reference: BusStop\"\n\n**Step 3: Write BusStop model**\n\n```kotlin\n// ABOUTME: Domain model representing a London bus stop with location and routes\npackage com.buswatch.domain.model\n\ndata class BusStop(\n val id: String,\n val code: String,\n val name: String,\n val latitude: Double,\n val longitude: Double,\n val routes: List<String>,\n val distanceMeters: Int\n)\n```\n\n**Step 4: Write ArrivalType enum**\n\n```kotlin\n// ABOUTME: Enum representing bus arrival data source type\npackage com.buswatch.domain.model\n\nenum class ArrivalType {\n LIVE, // Real-time tracked data\n SCHEDULED // Timetable-based data\n}\n```\n\n**Step 5: Write BusArrival model**\n\n```kotlin\n// ABOUTME: Domain model representing a bus arrival with route, destination, and timing\npackage com.buswatch.domain.model\n\ndata class BusArrival(\n val route: String,\n val destinationShort: String,\n val minutesUntil: Int,\n val arrivalType: ArrivalType\n)\n```\n\n**Step 6: Run test to verify it passes**\n\nRun: `./gradlew test --tests BusStopTest`\nExpected: PASS\n\n**Step 7: Commit**\n\n```bash\ngit add app/src/main/java/com/buswatch/domain/model/ app/src/test/java/com/buswatch/domain/model/\ngit commit -m \"feat: add domain models for BusStop and BusArrival\"\n```\n\n---\n\n## Task 4: TfL API DTOs and Client\n\n**Files:**\n- Create: `app/src/main/java/com/buswatch/data/remote/dto/StopPointDto.kt`\n- Create: `app/src/main/java/com/buswatch/data/remote/dto/ArrivalDto.kt`\n- Create: `app/src/main/java/com/buswatch/data/remote/TfLApiService.kt`\n- Create: `app/src/main/java/com/buswatch/data/remote/di/NetworkModule.kt`\n\n**Step 1: Create StopPointDto**\n\n```kotlin\n// ABOUTME: Data transfer object for TfL API stop point response\npackage com.buswatch.data.remote.dto\n\nimport com.google.gson.annotations.SerializedName\n\ndata class StopPointDto(\n @SerializedName(\"id\") val id: String,\n @SerializedName(\"commonName\") val commonName: String,\n @SerializedName(\"indicator\") val indicator: String?,\n @SerializedName(\"lat\") val lat: Double,\n @SerializedName(\"lon\") val lon: Double,\n @SerializedName(\"lines\") val lines: List<LineDto>\n)\n\ndata class LineDto(\n @SerializedName(\"id\") val id: String,\n @SerializedName(\"name\") val name: String\n)\n```\n\n**Step 2: Create ArrivalDto**\n\n```kotlin\n// ABOUTME: Data transfer object for TfL API arrival prediction response\npackage com.buswatch.data.remote.dto\n\nimport com.google.gson.annotations.SerializedName\n\ndata class ArrivalDto(\n @SerializedName(\"lineId\") val lineId: String,\n @SerializedName(\"lineName\") val lineName: String,\n @SerializedName(\"destinationName\") val destinationName: String,\n @SerializedName(\"timeToStation\") val timeToStation: Int,\n @SerializedName(\"timing\") val timing: TimingDto?\n)\n\ndata class TimingDto(\n @SerializedName(\"source\") val source: String?\n)\n```\n\n**Step 3: Create TfLApiService interface**\n\n```kotlin\n// ABOUTME: Retrofit service interface for TfL API endpoints\npackage com.buswatch.data.remote\n\nimport com.buswatch.data.remote.dto.ArrivalDto\nimport com.buswatch.data.remote.dto.StopPointDto\nimport retrofit2.http.GET\nimport retrofit2.http.Path\nimport retrofit2.http.Query\n\ninterface TfLApiService {\n @GET(\"StopPoint\")\n suspend fun getNearbyStops(\n @Query(\"lat\") latitude: Double,\n @Query(\"lon\") longitude: Double,\n @Query(\"stopTypes\") stopTypes: String = \"NaptanPublicBusCoachTram\",\n @Query(\"radius\") radius: Int = 500\n ): List<StopPointDto>\n\n @GET(\"StopPoint/{stopId}/Arrivals\")\n suspend fun getArrivals(\n @Path(\"stopId\") stopId: String\n ): List<ArrivalDto>\n}\n```\n\n**Step 4: Create NetworkModule for Hilt**\n\n```kotlin\n// ABOUTME: Hilt module providing network dependencies (Retrofit, OkHttp, API service)\npackage com.buswatch.data.remote.di\n\nimport com.buswatch.BuildConfig\nimport com.buswatch.data.remote.TfLApiService\nimport dagger.Module\nimport dagger.Provides\nimport dagger.hilt.InstallIn\nimport dagger.hilt.components.SingletonComponent\nimport okhttp3.Interceptor\nimport okhttp3.OkHttpClient\nimport okhttp3.logging.HttpLoggingInterceptor\nimport retrofit2.Retrofit\nimport retrofit2.converter.gson.GsonConverterFactory\nimport java.util.concurrent.TimeUnit\nimport javax.inject.Singleton\n\n@Module\n@InstallIn(SingletonComponent::class)\nobject NetworkModule {\n\n @Provides\n @Singleton\n fun provideOkHttpClient(): OkHttpClient {\n val loggingInterceptor = HttpLoggingInterceptor().apply {\n level = if (BuildConfig.DEBUG) {\n HttpLoggingInterceptor.Level.BODY\n } else {\n HttpLoggingInterceptor.Level.NONE\n }\n }\n\n val apiKeyInterceptor = Interceptor { chain ->\n val originalRequest = chain.request()\n val urlWithApiKey = originalRequest.url.newBuilder()\n .addQueryParameter(\"app_key\", BuildConfig.TFL_API_KEY)\n .build()\n val requestWithApiKey = originalRequest.newBuilder()\n .url(urlWithApiKey)\n .build()\n chain.proceed(requestWithApiKey)\n }\n\n return OkHttpClient.Builder()\n .addInterceptor(apiKeyInterceptor)\n .addInterceptor(loggingInterceptor)\n .connectTimeout(30, TimeUnit.SECONDS)\n .readTimeout(30, TimeUnit.SECONDS)\n .writeTimeout(30, TimeUnit.SECONDS)\n .build()\n }\n\n @Provides\n @Singleton\n fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {\n return Retrofit.Builder()\n .baseUrl(\"https://api.tfl.gov.uk/\")\n .client(okHttpClient)\n .addConverterFactory(GsonConverterFactory.create())\n .build()\n }\n\n @Provides\n @Singleton\n fun provideTfLApiService(retrofit: Retrofit): TfLApiService {\n return retrofit.create(TfLApiService::class.java)\n }\n}\n```\n\n**Step 5: Commit**\n\n```bash\ngit add app/src/main/java/com/buswatch/data/remote/\ngit commit -m \"feat: add TfL API DTOs and Retrofit service\"\n```\n\n---\n\n## Task 5: Repositories - TfLRepository\n\n**Files:**\n- Create: `app/src/main/java/com/buswatch/data/repository/TfLRepository.kt`\n- Create: `app/src/main/java/com/buswatch/util/Result.kt`\n- Create: `app/src/test/java/com/buswatch/data/repository/TfLRepositoryTest.kt`\n\n**Step 1: Create Result sealed class**\n\n```kotlin\n// ABOUTME: Sealed class representing operation results with success or error states\npackage com.buswatch.util\n\nsealed class Result<out T> {\n data class Success<T>(val data: T) : Result<T>()\n data class Error(val message: String) : Result<Nothing>()\n}\n```\n\n**Step 2: Write failing test for TfLRepository**\n\n```kotlin\n// ABOUTME: Unit tests for TfLRepository with mocked API service\npackage com.buswatch.data.repository\n\nimport com.buswatch.data.remote.TfLApiService\nimport com.buswatch.data.remote.dto.ArrivalDto\nimport com.buswatch.data.remote.dto.LineDto\nimport com.buswatch.data.remote.dto.StopPointDto\nimport com.buswatch.data.remote.dto.TimingDto\nimport com.buswatch.domain.model.ArrivalType\nimport com.buswatch.util.Result\nimport io.mockk.coEvery\nimport io.mockk.mockk\nimport kotlinx.coroutines.test.runTest\nimport org.junit.Assert.assertEquals\nimport org.junit.Assert.assertTrue\nimport org.junit.Before\nimport org.junit.Test\n\nclass TfLRepositoryTest {\n\n private lateinit var apiService: TfLApiService\n private lateinit var repository: TfLRepository\n\n @Before\n fun setup() {\n apiService = mockk()\n repository = TfLRepository(apiService)\n }\n\n @Test\n fun `getNearbyStops returns success with stops`() = runTest {\n val mockStops = listOf(\n StopPointDto(\n id = \"490000001B\",\n commonName = \"Oxford Street\",\n indicator = \"BP\",\n lat = 51.5074,\n lon = -0.1278,\n lines = listOf(LineDto(\"25\", \"25\"))\n )\n )\n\n coEvery { apiService.getNearbyStops(any(), any(), any(), any()) } returns mockStops\n\n val result = repository.getNearbyStops(51.5074, -0.1278)\n\n assertTrue(result is Result.Success)\n val stops = (result as Result.Success).data\n assertEquals(1, stops.size)\n assertEquals(\"BP\", stops[0].code)\n }\n\n @Test\n fun `getArrivals returns success with live arrivals`() = runTest {\n val mockArrivals = listOf(\n ArrivalDto(\n lineId = \"25\",\n lineName = \"25\",\n destinationName = \"Ilford\",\n timeToStation = 180,\n timing = TimingDto(\"Estimated\")\n )\n )\n\n coEvery { apiService.getArrivals(any()) } returns mockArrivals\n\n val result = repository.getArrivals(\"490000001B\")\n\n assertTrue(result is Result.Success)\n val arrivals = (result as Result.Success).data\n assertEquals(1, arrivals.size)\n assertEquals(\"25\", arrivals[0].route)\n assertEquals(\"Ilf\", arrivals[0].destinationShort)\n assertEquals(3, arrivals[0].minutesUntil)\n assertEquals(ArrivalType.LIVE, arrivals[0].arrivalType)\n }\n}\n```\n\n**Step 3: Run test to verify it fails**\n\nRun: `./gradlew test --tests TfLRepositoryTest`\nExpected: FAIL with \"Unresolved reference: TfLRepository\"\n\n**Step 4: Write TfLRepository implementation**\n\n```kotlin\n// ABOUTME: Repository handling TfL API data fetching, transformation, and error handling\npackage com.buswatch.data.repository\n\nimport android.location.Location\nimport com.buswatch.data.remote.TfLApiService\nimport com.buswatch.domain.model.ArrivalType\nimport com.buswatch.domain.model.BusArrival\nimport com.buswatch.domain.model.BusStop\nimport com.buswatch.util.Result\nimport kotlinx.coroutines.delay\nimport timber.log.Timber\nimport javax.inject.Inject\nimport javax.inject.Singleton\n\n@Singleton\nclass TfLRepository @Inject constructor(\n private val apiService: TfLApiService\n) {\n suspend fun getNearbyStops(latitude: Double, longitude: Double): Result<List<BusStop>> {\n return executeWithRetry {\n val stops = apiService.getNearbyStops(latitude, longitude)\n\n val userLocation = Location(\"\").apply {\n this.latitude = latitude\n this.longitude = longitude\n }\n\n stops.map { dto ->\n val stopLocation = Location(\"\").apply {\n this.latitude = dto.lat\n this.longitude = dto.lon\n }\n val distance = userLocation.distanceTo(stopLocation).toInt()\n\n BusStop(\n id = dto.id,\n code = dto.indicator ?: \"N/A\",\n name = dto.commonName,\n latitude = dto.lat,\n longitude = dto.lon,\n routes = dto.lines.map { it.name },\n distanceMeters = distance\n )\n }.sortedBy { it.distanceMeters }\n }\n }\n\n suspend fun getArrivals(stopId: String): Result<List<BusArrival>> {\n return executeWithRetry {\n val arrivals = apiService.getArrivals(stopId)\n\n arrivals.map { dto ->\n val minutesUntil = (dto.timeToStation / 60).coerceAtLeast(0)\n val arrivalType = when (dto.timing?.source) {\n \"Estimated\" -> ArrivalType.LIVE\n else -> ArrivalType.SCHEDULED\n }\n val destinationShort = dto.destinationName.take(3)\n\n BusArrival(\n route = dto.lineName,\n destinationShort = destinationShort,\n minutesUntil = minutesUntil,\n arrivalType = arrivalType\n )\n }.sortedBy { it.minutesUntil }\n }\n }\n\n private suspend fun <T> executeWithRetry(\n maxRetries: Int = 3,\n initialDelay: Long = 1000,\n block: suspend () -> T\n ): Result<T> {\n var currentDelay = initialDelay\n repeat(maxRetries) { attempt ->\n try {\n val result = block()\n return Result.Success(result)\n } catch (e: Exception) {\n Timber.e(e, \"API call failed (attempt ${attempt + 1}/$maxRetries)\")\n if (attempt == maxRetries - 1) {\n return Result.Error(getErrorMessage(e))\n }\n delay(currentDelay)\n currentDelay *= 2\n }\n }\n return Result.Error(\"Unknown error\")\n }\n\n private fun getErrorMessage(exception: Exception): String {\n return when {\n exception is java.net.UnknownHostException -> \"No internet connection\"\n exception.message?.contains(\"429\") == true -> \"Service temporarily unavailable. Please try again in a moment.\"\n exception.message?.contains(\"404\") == true -> \"Bus stop not found\"\n exception.message?.contains(\"timeout\") == true -> \"Request timed out\"\n else -> \"Unable to load bus times. Please try again later.\"\n }\n }\n}\n```\n\n**Step 5: Run test to verify it passes**\n\nRun: `./gradlew test --tests TfLRepositoryTest`\nExpected: PASS\n\n**Step 6: Commit**\n\n```bash\ngit add app/src/main/java/com/buswatch/data/repository/TfLRepository.kt app/src/main/java/com/buswatch/util/Result.kt app/src/test/java/com/buswatch/data/repository/\ngit commit -m \"feat: add TfLRepository with retry logic\"\n```\n\n---\n\n## Task 6: LocationRepository\n\n**Files:**\n- Create: `app/src/main/java/com/buswatch/data/repository/LocationRepository.kt`\n- Create: `app/src/test/java/com/buswatch/data/repository/LocationRepositoryTest.kt`\n\n**Step 1: Write failing test for LocationRepository**\n\n```kotlin\n// ABOUTME: Unit tests for LocationRepository location acquisition logic\npackage com.buswatch.data.repository\n\nimport android.content.Context\nimport android.location.Location\nimport com.google.android.gms.location.FusedLocationProviderClient\nimport com.google.android.gms.tasks.OnFailureListener\nimport com.google.android.gms.tasks.OnSuccessListener\nimport com.google.android.gms.tasks.Task\nimport com.buswatch.util.Result\nimport io.mockk.every\nimport io.mockk.mockk\nimport io.mockk.slot\nimport kotlinx.coroutines.test.runTest\nimport org.junit.Assert.assertEquals\nimport org.junit.Assert.assertTrue\nimport org.junit.Before\nimport org.junit.Test\n\nclass LocationRepositoryTest {\n\n private lateinit var context: Context\n private lateinit var locationClient: FusedLocationProviderClient\n private lateinit var repository: LocationRepository\n\n @Before\n fun setup() {\n context = mockk(relaxed = true)\n locationClient = mockk()\n repository = LocationRepository(context, locationClient)\n }\n\n @Test\n fun `getCurrentLocation returns success with location`() = runTest {\n val mockLocation = mockk<Location> {\n every { latitude } returns 51.5074\n every { longitude } returns -0.1278\n }\n\n val taskMock = mockk<Task<Location>>()\n val successSlot = slot<OnSuccessListener<Location>>()\n\n every { locationClient.lastLocation } returns taskMock\n every { taskMock.addOnSuccessListener(capture(successSlot)) } answers {\n successSlot.captured.onSuccess(mockLocation)\n taskMock\n }\n every { taskMock.addOnFailureListener(any()) } returns taskMock\n\n val result = repository.getCurrentLocation()\n\n assertTrue(result is Result.Success)\n val location = (result as Result.Success).data\n assertEquals(51.5074, location.latitude, 0.0001)\n }\n}\n```\n\n**Step 2: Run test to verify it fails**\n\nRun: `./gradlew test --tests LocationRepositoryTest`\nExpected: FAIL\n\n**Step 3: Write LocationRepository implementation**\n\n```kotlin\n// ABOUTME: Repository managing device location acquisition using Play Services\npackage com.buswatch.data.repository\n\nimport android.Manifest\nimport android.content.Context\nimport android.content.pm.PackageManager\nimport android.location.Location\nimport androidx.core.content.ContextCompat\nimport com.google.android.gms.location.FusedLocationProviderClient\nimport com.buswatch.util.Result\nimport kotlinx.coroutines.suspendCancellableCoroutine\nimport timber.log.Timber\nimport javax.inject.Inject\nimport javax.inject.Singleton\nimport kotlin.coroutines.resume\n\n@Singleton\nclass LocationRepository @Inject constructor(\n private val context: Context,\n private val locationClient: FusedLocationProviderClient\n) {\n suspend fun getCurrentLocation(): Result<Location> {\n if (!hasLocationPermission()) {\n return Result.Error(\"Location permission required. Please enable in settings.\")\n }\n\n return suspendCancellableCoroutine { continuation ->\n try {\n locationClient.lastLocation\n .addOnSuccessListener { location ->\n if (location != null) {\n Timber.d(\"Location acquired: ${location.latitude}, ${location.longitude}\")\n continuation.resume(Result.Success(location))\n } else {\n Timber.w(\"Location is null\")\n continuation.resume(Result.Error(\"Unable to get location. Please ensure GPS is enabled.\"))\n }\n }\n .addOnFailureListener { exception ->\n Timber.e(exception, \"Failed to get location\")\n continuation.resume(Result.Error(\"Unable to get location. Please ensure GPS is enabled.\"))\n }\n } catch (e: SecurityException) {\n Timber.e(e, \"SecurityException getting location\")\n continuation.resume(Result.Error(\"Location permission required. Please enable in settings.\"))\n }\n }\n }\n\n private fun hasLocationPermission(): Boolean {\n return ContextCompat.checkSelfPermission(\n context,\n Manifest.permission.ACCESS_FINE_LOCATION\n ) == PackageManager.PERMISSION_GRANTED\n }\n}\n```\n\n**Step 4: Add LocationModule for Hilt**\n\nCreate: `app/src/main/java/com/buswatch/data/repository/di/LocationModule.kt`\n\n```kotlin\n// ABOUTME: Hilt module providing location-related dependencies\npackage com.buswatch.data.repository.di\n\nimport android.content.Context\nimport com.google.android.gms.location.FusedLocationProviderClient\nimport com.google.android.gms.location.LocationServices\nimport dagger.Module\nimport dagger.Provides\nimport dagger.hilt.InstallIn\nimport dagger.hilt.android.qualifiers.ApplicationContext\nimport dagger.hilt.components.SingletonComponent\nimport javax.inject.Singleton\n\n@Module\n@InstallIn(SingletonComponent::class)\nobject LocationModule {\n\n @Provides\n @Singleton\n fun provideFusedLocationProviderClient(\n @ApplicationContext context: Context\n ): FusedLocationProviderClient {\n return LocationServices.getFusedLocationProviderClient(context)\n }\n}\n```\n\n**Step 5: Run test to verify it passes**\n\nRun: `./gradlew test --tests LocationRepositoryTest`\nExpected: PASS\n\n**Step 6: Commit**\n\n```bash\ngit add app/src/main/java/com/buswatch/data/repository/LocationRepository.kt app/src/main/java/com/buswatch/data/repository/di/ app/src/test/java/com/buswatch/data/repository/LocationRepositoryTest.kt\ngit commit -m \"feat: add LocationRepository with permission handling\"\n```\n\n---\n\n## Task 7: DataStore for Preferences\n\n**Files:**\n- Create: `app/src/main/java/com/buswatch/data/local/PreferencesDataStore.kt`\n- Create: `app/src/main/java/com/buswatch/data/local/di/DataStoreModule.kt`\n\n**Step 1: Write PreferencesDataStore**\n\n```kotlin\n// ABOUTME: DataStore wrapper for storing last selected bus stop preferences\npackage com.buswatch.data.local\n\nimport android.content.Context\nimport androidx.datastore.core.DataStore\nimport androidx.datastore.preferences.core.Preferences\nimport androidx.datastore.preferences.core.doublePreferencesKey\nimport androidx.datastore.preferences.core.edit\nimport androidx.datastore.preferences.core.stringPreferencesKey\nimport androidx.datastore.preferences.preferencesDataStore\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.map\nimport javax.inject.Inject\nimport javax.inject.Singleton\n\nprivate val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = \"bus_watch_prefs\")\n\n@Singleton\nclass PreferencesDataStore @Inject constructor(\n private val context: Context\n) {\n private object Keys {\n val LAST_STOP_ID = stringPreferencesKey(\"last_stop_id\")\n val LAST_STOP_LAT = doublePreferencesKey(\"last_stop_lat\")\n val LAST_STOP_LON = doublePreferencesKey(\"last_stop_lon\")\n }\n\n suspend fun saveLastStop(stopId: String, latitude: Double, longitude: Double) {\n context.dataStore.edit { prefs ->\n prefs[Keys.LAST_STOP_ID] = stopId\n prefs[Keys.LAST_STOP_LAT] = latitude\n prefs[Keys.LAST_STOP_LON] = longitude\n }\n }\n\n fun getLastStop(): Flow<LastStop?> {\n return context.dataStore.data.map { prefs ->\n val stopId = prefs[Keys.LAST_STOP_ID]\n val lat = prefs[Keys.LAST_STOP_LAT]\n val lon = prefs[Keys.LAST_STOP_LON]\n\n if (stopId != null && lat != null && lon != null) {\n LastStop(stopId, lat, lon)\n } else {\n null\n }\n }\n }\n\n suspend fun clearLastStop() {\n context.dataStore.edit { prefs ->\n prefs.remove(Keys.LAST_STOP_ID)\n prefs.remove(Keys.LAST_STOP_LAT)\n prefs.remove(Keys.LAST_STOP_LON)\n }\n }\n}\n\ndata class LastStop(\n val stopId: String,\n val latitude: Double,\n val longitude: Double\n)\n```\n\n**Step 2: Create DataStoreModule**\n\n```kotlin\n// ABOUTME: Hilt module providing DataStore dependency\npackage com.buswatch.data.local.di\n\nimport android.content.Context\nimport com.buswatch.data.local.PreferencesDataStore\nimport dagger.Module\nimport dagger.Provides\nimport dagger.hilt.InstallIn\nimport dagger.hilt.android.qualifiers.ApplicationContext\nimport dagger.hilt.components.SingletonComponent\nimport javax.inject.Singleton\n\n@Module\n@InstallIn(SingletonComponent::class)\nobject DataStoreModule {\n\n @Provides\n @Singleton\n fun providePreferencesDataStore(\n @ApplicationContext context: Context\n ): PreferencesDataStore {\n return PreferencesDataStore(context)\n }\n}\n```\n\n**Step 3: Commit**\n\n```bash\ngit add app/src/main/java/com/buswatch/data/local/\ngit commit -m \"feat: add DataStore for last stop preferences\"\n```\n\n---\n\n## Task 8: UI State Classes\n\n**Files:**\n- Create: `app/src/main/java/com/buswatch/ui/state/UiState.kt`\n- Create: `app/src/main/java/com/buswatch/ui/state/StopListState.kt`\n- Create: `app/src/main/java/com/buswatch/ui/state/ArrivalState.kt`\n\n**Step 1: Create UiState sealed class**\n\n```kotlin\n// ABOUTME: Generic sealed class for representing UI loading, success, and error states\npackage com.buswatch.ui.state\n\nsealed class UiState<out T> {\n object Loading : UiState<Nothing>()\n data class Success<T>(val data: T) : UiState<T>()\n data class Error(val message: String, val canRetry: Boolean = true) : UiState<Nothing>()\n}\n```\n\n**Step 2: Create StopListState**\n\n```kotlin\n// ABOUTME: UI state for bus stop list screen\npackage com.buswatch.ui.state\n\nimport com.buswatch.domain.model.BusStop\n\ndata class StopListData(\n val stops: List<BusStop>\n)\n```\n\n**Step 3: Create ArrivalState**\n\n```kotlin\n// ABOUTME: UI state for arrival times screen\npackage com.buswatch.ui.state\n\nimport com.buswatch.domain.model.BusArrival\n\ndata class ArrivalData(\n val stopCode: String,\n val stopName: String,\n val arrivalsByRoute: Map<String, List<BusArrival>>\n)\n```\n\n**Step 4: Commit**\n\n```bash\ngit add app/src/main/java/com/buswatch/ui/state/\ngit commit -m \"feat: add UI state classes\"\n```\n\n---\n\n## Task 9: StopListViewModel\n\n**Files:**\n- Create: `app/src/main/java/com/buswatch/ui/viewmodel/StopListViewModel.kt`\n- Create: `app/src/test/java/com/buswatch/ui/viewmodel/StopListViewModelTest.kt`\n\n**Step 1: Write failing test for StopListViewModel**\n\n```kotlin\n// ABOUTME: Unit tests for StopListViewModel state management\npackage com.buswatch.ui.viewmodel\n\nimport android.location.Location\nimport com.buswatch.data.local.LastStop\nimport com.buswatch.data.local.PreferencesDataStore\nimport com.buswatch.data.repository.LocationRepository\nimport com.buswatch.data.repository.TfLRepository\nimport com.buswatch.domain.model.BusStop\nimport com.buswatch.ui.state.UiState\nimport com.buswatch.util.Result\nimport io.mockk.coEvery\nimport io.mockk.every\nimport io.mockk.mockk\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.ExperimentalCoroutinesApi\nimport kotlinx.coroutines.flow.flowOf\nimport kotlinx.coroutines.test.StandardTestDispatcher\nimport kotlinx.coroutines.test.resetMain\nimport kotlinx.coroutines.test.runTest\nimport kotlinx.coroutines.test.setMain\nimport org.junit.After\nimport org.junit.Assert.assertEquals\nimport org.junit.Assert.assertTrue\nimport org.junit.Before\nimport org.junit.Test\n\n@OptIn(ExperimentalCoroutinesApi::class)\nclass StopListViewModelTest {\n\n private lateinit var locationRepository: LocationRepository\n private lateinit var tflRepository: TfLRepository\n private lateinit var preferencesDataStore: PreferencesDataStore\n private lateinit var viewModel: StopListViewModel\n\n private val testDispatcher = StandardTestDispatcher()\n\n @Before\n fun setup() {\n Dispatchers.setMain(testDispatcher)\n locationRepository = mockk()\n tflRepository = mockk()\n preferencesDataStore = mockk()\n }\n\n @After\n fun tearDown() {\n Dispatchers.resetMain()\n }\n\n @Test\n fun `loadNearbyStops success shows stops`() = runTest {\n val mockLocation = mockk<Location> {\n every { latitude } returns 51.5074\n every { longitude } returns -0.1278\n }\n val mockStops = listOf(\n BusStop(\"1\", \"BP\", \"Oxford St\", 51.5074, -0.1278, listOf(\"25\"), 100)\n )\n\n coEvery { locationRepository.getCurrentLocation() } returns Result.Success(mockLocation)\n coEvery { tflRepository.getNearbyStops(any(), any()) } returns Result.Success(mockStops)\n every { preferencesDataStore.getLastStop() } returns flowOf(null)\n\n viewModel = StopListViewModel(locationRepository, tflRepository, preferencesDataStore)\n testDispatcher.scheduler.advanceUntilIdle()\n\n val state = viewModel.uiState.value\n assertTrue(state is UiState.Success)\n }\n}\n```\n\n**Step 2: Run test to verify it fails**\n\nRun: `./gradlew test --tests StopListViewModelTest`\nExpected: FAIL\n\n**Step 3: Write StopListViewModel implementation**\n\n```kotlin\n// ABOUTME: ViewModel managing stop list screen state and location-based stop fetching\npackage com.buswatch.ui.viewmodel\n\nimport android.location.Location\nimport androidx.lifecycle.ViewModel\nimport androidx.lifecycle.viewModelScope\nimport com.buswatch.data.local.PreferencesDataStore\nimport com.buswatch.data.repository.LocationRepository\nimport com.buswatch.data.repository.TfLRepository\nimport com.buswatch.domain.model.BusStop\nimport com.buswatch.ui.state.StopListData\nimport com.buswatch.ui.state.UiState\nimport com.buswatch.util.Result\nimport dagger.hilt.android.lifecycle.HiltViewModel\nimport kotlinx.coroutines.flow.MutableStateFlow\nimport kotlinx.coroutines.flow.StateFlow\nimport kotlinx.coroutines.flow.asStateFlow\nimport kotlinx.coroutines.flow.first\nimport kotlinx.coroutines.launch\nimport timber.log.Timber\nimport javax.inject.Inject\n\n@HiltViewModel\nclass StopListViewModel @Inject constructor(\n private val locationRepository: LocationRepository,\n private val tflRepository: TfLRepository,\n private val preferencesDataStore: PreferencesDataStore\n) : ViewModel() {\n\n private val _uiState = MutableStateFlow<UiState<StopListData>>(UiState.Loading)\n val uiState: StateFlow<UiState<StopListData>> = _uiState.asStateFlow()\n\n private var currentLocation: Location? = null\n\n init {\n loadNearbyStops()\n }\n\n fun loadNearbyStops() {\n viewModelScope.launch {\n _uiState.value = UiState.Loading\n\n when (val locationResult = locationRepository.getCurrentLocation()) {\n is Result.Success -> {\n currentLocation = locationResult.data\n fetchNearbyStops(locationResult.data.latitude, locationResult.data.longitude)\n }\n is Result.Error -> {\n _uiState.value = UiState.Error(locationResult.message, canRetry = false)\n }\n }\n }\n }\n\n private suspend fun fetchNearbyStops(latitude: Double, longitude: Double) {\n when (val stopsResult = tflRepository.getNearbyStops(latitude, longitude)) {\n is Result.Success -> {\n val stops = stopsResult.data.take(5)\n _uiState.value = UiState.Success(StopListData(stops))\n Timber.d(\"Loaded ${stops.size} nearby stops\")\n }\n is Result.Error -> {\n _uiState.value = UiState.Error(stopsResult.message, canRetry = true)\n }\n }\n }\n\n suspend fun saveSelectedStop(stop: BusStop) {\n preferencesDataStore.saveLastStop(stop.id, stop.latitude, stop.longitude)\n Timber.d(\"Saved last stop: ${stop.code}\")\n }\n\n suspend fun checkLastStop(): BusStop? {\n val lastStop = preferencesDataStore.getLastStop().first() ?: return null\n val currentLoc = currentLocation ?: return null\n\n val stopLocation = Location(\"\").apply {\n latitude = lastStop.latitude\n longitude = lastStop.longitude\n }\n\n val distance = currentLoc.distanceTo(stopLocation)\n\n return if (distance <= 500) {\n _uiState.value.let { state ->\n if (state is UiState.Success) {\n state.data.stops.find { it.id == lastStop.stopId }\n } else null\n }\n } else {\n null\n }\n }\n}\n```\n\n**Step 4: Run test to verify it passes**\n\nRun: `./gradlew test --tests StopListViewModelTest`\nExpected: PASS\n\n**Step 5: Commit**\n\n```bash\ngit add app/src/main/java/com/buswatch/ui/viewmodel/StopListViewModel.kt app/src/test/java/com/buswatch/ui/viewmodel/StopListViewModelTest.kt\ngit commit -m \"feat: add StopListViewModel with location handling\"\n```\n\n---\n\n## Task 10: ArrivalViewModel\n\n**Files:**\n- Create: `app/src/main/java/com/buswatch/ui/viewmodel/ArrivalViewModel.kt`\n- Create: `app/src/test/java/com/buswatch/ui/viewmodel/ArrivalViewModelTest.kt`\n\n**Step 1: Write failing test for ArrivalViewModel**\n\n```kotlin\n// ABOUTME: Unit tests for ArrivalViewModel refresh and state management\npackage com.buswatch.ui.viewmodel\n\nimport com.buswatch.data.repository.TfLRepository\nimport com.buswatch.domain.model.ArrivalType\nimport com.buswatch.domain.model.BusArrival\nimport com.buswatch.ui.state.UiState\nimport com.buswatch.util.Result\nimport io.mockk.coEvery\nimport io.mockk.mockk\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.ExperimentalCoroutinesApi\nimport kotlinx.coroutines.test.StandardTestDispatcher\nimport kotlinx.coroutines.test.resetMain\nimport kotlinx.coroutines.test.runTest\nimport kotlinx.coroutines.test.setMain\nimport org.junit.After\nimport org.junit.Assert.assertTrue\nimport org.junit.Before\nimport org.junit.Test\n\n@OptIn(ExperimentalCoroutinesApi::class)\nclass ArrivalViewModelTest {\n\n private lateinit var tflRepository: TfLRepository\n private lateinit var viewModel: ArrivalViewModel\n\n private val testDispatcher = StandardTestDispatcher()\n\n @Before\n fun setup() {\n Dispatchers.setMain(testDispatcher)\n tflRepository = mockk()\n }\n\n @After\n fun tearDown() {\n Dispatchers.resetMain()\n }\n\n @Test\n fun `loadArrivals success shows arrivals grouped by route`() = runTest {\n val mockArrivals = listOf(\n BusArrival(\"25\", \"Ilf\", 3, ArrivalType.LIVE),\n BusArrival(\"25\", \"Ilf\", 8, ArrivalType.LIVE)\n )\n\n coEvery { tflRepository.getArrivals(any()) } returns Result.Success(mockArrivals)\n\n viewModel = ArrivalViewModel(tflRepository)\n viewModel.loadArrivals(\"490000001B\", \"BP\", \"Oxford St\")\n testDispatcher.scheduler.advanceUntilIdle()\n\n val state = viewModel.uiState.value\n assertTrue(state is UiState.Success)\n }\n}\n```\n\n**Step 2: Run test to verify it fails**\n\nRun: `./gradlew test --tests ArrivalViewModelTest`\nExpected: FAIL\n\n**Step 3: Write ArrivalViewModel implementation**\n\n```kotlin\n// ABOUTME: ViewModel managing arrival screen state with auto-refresh logic\npackage com.buswatch.ui.viewmodel\n\nimport androidx.lifecycle.ViewModel\nimport androidx.lifecycle.viewModelScope\nimport com.buswatch.data.repository.TfLRepository\nimport com.buswatch.domain.model.BusArrival\nimport com.buswatch.ui.state.ArrivalData\nimport com.buswatch.ui.state.UiState\nimport com.buswatch.util.Result\nimport dagger.hilt.android.lifecycle.HiltViewModel\nimport kotlinx.coroutines.Job\nimport kotlinx.coroutines.delay\nimport kotlinx.coroutines.flow.MutableStateFlow\nimport kotlinx.coroutines.flow.StateFlow\nimport kotlinx.coroutines.flow.asStateFlow\nimport kotlinx.coroutines.launch\nimport timber.log.Timber\nimport javax.inject.Inject\n\n@HiltViewModel\nclass ArrivalViewModel @Inject constructor(\n private val tflRepository: TfLRepository\n) : ViewModel() {\n\n private val _uiState = MutableStateFlow<UiState<ArrivalData>>(UiState.Loading)\n val uiState: StateFlow<UiState<ArrivalData>> = _uiState.asStateFlow()\n\n private var refreshJob: Job? = null\n private var stopId: String = \"\"\n private var stopCode: String = \"\"\n private var stopName: String = \"\"\n private var lastActivityTime: Long = System.currentTimeMillis()\n\n fun loadArrivals(stopId: String, stopCode: String, stopName: String) {\n this.stopId = stopId\n this.stopCode = stopCode\n this.stopName = stopName\n fetchArrivals()\n startAutoRefresh()\n }\n\n fun onUserActivity() {\n lastActivityTime = System.currentTimeMillis()\n }\n\n private fun fetchArrivals() {\n viewModelScope.launch {\n _uiState.value = UiState.Loading\n\n when (val result = tflRepository.getArrivals(stopId)) {\n is Result.Success -> {\n if (result.data.isEmpty()) {\n _uiState.value = UiState.Error(\"No buses currently scheduled\", canRetry = false)\n } else {\n val groupedArrivals = result.data\n .groupBy { it.route }\n .mapValues { (_, arrivals) -> arrivals.take(2) }\n\n _uiState.value = UiState.Success(\n ArrivalData(stopCode, stopName, groupedArrivals)\n )\n Timber.d(\"Loaded arrivals for ${groupedArrivals.size} routes\")\n }\n }\n is Result.Error -> {\n _uiState.value = UiState.Error(result.message, canRetry = true)\n }\n }\n }\n }\n\n private fun startAutoRefresh() {\n refreshJob?.cancel()\n refreshJob = viewModelScope.launch {\n while (true) {\n delay(60_000) // 60 seconds\n\n val inactiveDuration = System.currentTimeMillis() - lastActivityTime\n if (inactiveDuration >= 300_000) { // 5 minutes\n Timber.d(\"Stopping auto-refresh due to inactivity\")\n break\n }\n\n fetchArrivals()\n }\n }\n }\n\n fun retry() {\n fetchArrivals()\n }\n\n override fun onCleared() {\n super.onCleared()\n refreshJob?.cancel()\n }\n}\n```\n\n**Step 4: Run test to verify it passes**\n\nRun: `./gradlew test --tests ArrivalViewModelTest`\nExpected: PASS\n\n**Step 5: Commit**\n\n```bash\ngit add app/src/main/java/com/buswatch/ui/viewmodel/ArrivalViewModel.kt app/src/test/java/com/buswatch/ui/viewmodel/ArrivalViewModelTest.kt\ngit commit -m \"feat: add ArrivalViewModel with auto-refresh\"\n```\n\n---\n\n## Task 11: Theme Setup\n\n**Files:**\n- Create: `app/src/main/java/com/buswatch/ui/theme/Color.kt`\n- Create: `app/src/main/java/com/buswatch/ui/theme/Theme.kt`\n- Create: `app/src/main/java/com/buswatch/ui/theme/Type.kt`\n\n**Step 1: Create Color.kt**\n\n```kotlin\n// ABOUTME: Color definitions for light and dark themes\npackage com.buswatch.ui.theme\n\nimport androidx.compose.ui.graphics.Color\n\n// Dark theme colors\nval DarkBackground = Color(0xFF000000)\nval DarkPrimary = Color(0xFF8AB4F8)\nval LiveGreen = Color(0xFF34A853)\nval ScheduledWhite = Color(0xFFFFFFFF)\n\n// Light theme colors\nval LightBackground = Color(0xFFFFFFFF)\nval LightPrimary = Color(0xFF1A73E8)\nval LiveGreenDark = Color(0xFF137333)\nval ScheduledBlack = Color(0xFF000000)\n```\n\n**Step 2: Create Type.kt**\n\n```kotlin\n// ABOUTME: Typography definitions for consistent text styling\npackage com.buswatch.ui.theme\n\nimport androidx.compose.ui.text.TextStyle\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.unit.sp\nimport androidx.wear.compose.material.Typography\n\nval BusWatchTypography = Typography(\n display1 = TextStyle(\n fontSize = 18.sp,\n fontWeight = FontWeight.Bold\n ),\n title1 = TextStyle(\n fontSize = 16.sp,\n fontWeight = FontWeight.Bold\n ),\n body1 = TextStyle(\n fontSize = 14.sp,\n fontWeight = FontWeight.Normal\n ),\n button = TextStyle(\n fontSize = 14.sp,\n fontWeight = FontWeight.Medium\n )\n)\n```\n\n**Step 3: Create Theme.kt**\n\n```kotlin\n// ABOUTME: Main theme composable managing light/dark theme colors\npackage com.buswatch.ui.theme\n\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.graphics.Color\nimport androidx.wear.compose.material.Colors\nimport androidx.wear.compose.material.MaterialTheme\n\n@Composable\nfun BusWatchTheme(\n content: @Composable () -> Unit\n) {\n val colors = Colors(\n primary = DarkPrimary,\n primaryVariant = DarkPrimary,\n secondary = LiveGreen,\n secondaryVariant = LiveGreen,\n background = DarkBackground,\n surface = DarkBackground,\n error = Color(0xFFCF6679),\n onPrimary = Color.Black,\n onSecondary = Color.Black,\n onBackground = Color.White,\n onSurface = Color.White,\n onError = Color.Black\n )\n\n MaterialTheme(\n colors = colors,\n typography = BusWatchTypography,\n content = content\n )\n}\n```\n\n**Step 4: Commit**\n\n```bash\ngit add app/src/main/java/com/buswatch/ui/theme/\ngit commit -m \"feat: add theme with color and typography\"\n```\n\n---\n\n## Task 12: StopListScreen UI\n\n**Files:**\n- Create: `app/src/main/java/com/buswatch/ui/screens/StopListScreen.kt`\n- Create: `app/src/main/java/com/buswatch/ui/components/LoadingScreen.kt`\n- Create: `app/src/main/java/com/buswatch/ui/components/ErrorScreen.kt`\n\n**Step 1: Create LoadingScreen component**\n\n```kotlin\n// ABOUTME: Reusable loading indicator component with message\npackage com.buswatch.ui.components\n\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.unit.dp\nimport androidx.wear.compose.material.CircularProgressIndicator\nimport androidx.wear.compose.material.Text\n\n@Composable\nfun LoadingScreen(message: String) {\n Box(\n modifier = Modifier.fillMaxSize(),\n contentAlignment = Alignment.Center\n ) {\n Column(horizontalAlignment = Alignment.CenterHorizontally) {\n CircularProgressIndicator(\n modifier = Modifier.size(48.dp)\n )\n Spacer(modifier = Modifier.height(16.dp))\n Text(text = message)\n }\n }\n}\n```\n\n**Step 2: Create ErrorScreen component**\n\n```kotlin\n// ABOUTME: Reusable error display component with optional retry\npackage com.buswatch.ui.components\n\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.text.style.TextAlign\nimport androidx.compose.ui.unit.dp\nimport androidx.wear.compose.material.Button\nimport androidx.wear.compose.material.Text\n\n@Composable\nfun ErrorScreen(\n message: String,\n canRetry: Boolean = true,\n onRetry: (() -> Unit)? = null\n) {\n Box(\n modifier = Modifier.fillMaxSize(),\n contentAlignment = Alignment.Center\n ) {\n Column(\n horizontalAlignment = Alignment.CenterHorizontally,\n modifier = Modifier.padding(16.dp)\n ) {\n Text(\n text = message,\n textAlign = TextAlign.Center\n )\n if (canRetry && onRetry != null) {\n Spacer(modifier = Modifier.height(16.dp))\n Button(onClick = onRetry) {\n Text(\"Retry\")\n }\n }\n }\n }\n}\n```\n\n**Step 3: Create StopListScreen**\n\n```kotlin\n// ABOUTME: Stop list screen displaying nearby bus stops in scrollable list\npackage com.buswatch.ui.screens\n\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.collectAsState\nimport androidx.compose.runtime.getValue\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.platform.LocalView\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.unit.dp\nimport androidx.hilt.navigation.compose.hiltViewModel\nimport androidx.wear.compose.material.Chip\nimport androidx.wear.compose.material.ChipDefaults\nimport androidx.wear.compose.material.ScalingLazyColumn\nimport androidx.wear.compose.material.Text\nimport androidx.wear.compose.material.items\nimport com.buswatch.domain.model.BusStop\nimport com.buswatch.ui.components.ErrorScreen\nimport com.buswatch.ui.components.LoadingScreen\nimport com.buswatch.ui.state.UiState\nimport com.buswatch.ui.viewmodel.StopListViewModel\nimport android.view.HapticFeedbackConstants\n\n@Composable\nfun StopListScreen(\n onStopSelected: (BusStop) -> Unit,\n viewModel: StopListViewModel = hiltViewModel()\n) {\n val uiState by viewModel.uiState.collectAsState()\n val view = LocalView.current\n\n when (val state = uiState) {\n is UiState.Loading -> {\n LoadingScreen(message = \"Getting your location...\")\n }\n is UiState.Error -> {\n ErrorScreen(\n message = state.message,\n canRetry = state.canRetry,\n onRetry = if (state.canRetry) {\n { viewModel.loadNearbyStops() }\n } else null\n )\n }\n is UiState.Success -> {\n ScalingLazyColumn(\n modifier = Modifier.fillMaxSize()\n ) {\n items(state.data.stops) { stop ->\n StopListItem(\n stop = stop,\n onClick = {\n view.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK)\n onStopSelected(stop)\n }\n )\n }\n }\n }\n }\n}\n\n@Composable\nfun StopListItem(\n stop: BusStop,\n onClick: () -> Unit\n) {\n Chip(\n onClick = onClick,\n label = {\n Column(modifier = Modifier.fillMaxWidth()) {\n Text(\n text = \"Stop ${stop.code}\",\n fontWeight = FontWeight.Bold\n )\n Text(text = stop.name)\n Spacer(modifier = Modifier.height(4.dp))\n Text(text = \"Routes: ${stop.routes.joinToString(\", \")}\")\n }\n },\n colors = ChipDefaults.primaryChipColors(),\n modifier = Modifier\n .fillMaxWidth()\n .padding(vertical = 4.dp, horizontal = 8.dp)\n )\n}\n```\n\n**Step 4: Commit**\n\n```bash\ngit add app/src/main/java/com/buswatch/ui/screens/StopListScreen.kt app/src/main/java/com/buswatch/ui/components/\ngit commit -m \"feat: add StopListScreen UI with loading and error states\"\n```\n\n---\n\n## Task 13: ArrivalScreen UI\n\n**Files:**\n- Create: `app/src/main/java/com/buswatch/ui/screens/ArrivalScreen.kt`\n\n**Step 1: Create ArrivalScreen**\n\n```kotlin\n// ABOUTME: Arrival screen displaying bus arrival times with auto-refresh\npackage com.buswatch.ui.screens\n\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.width\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.DisposableEffect\nimport androidx.compose.runtime.collectAsState\nimport androidx.compose.runtime.getValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.platform.LocalView\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.unit.dp\nimport androidx.hilt.navigation.compose.hiltViewModel\nimport androidx.wear.compose.material.Button\nimport androidx.wear.compose.material.ScalingLazyColumn\nimport androidx.wear.compose.material.Text\nimport com.buswatch.domain.model.ArrivalType\nimport com.buswatch.domain.model.BusArrival\nimport com.buswatch.ui.components.ErrorScreen\nimport com.buswatch.ui.components.LoadingScreen\nimport com.buswatch.ui.state.UiState\nimport com.buswatch.ui.theme.LiveGreen\nimport com.buswatch.ui.theme.ScheduledWhite\nimport com.buswatch.ui.viewmodel.ArrivalViewModel\nimport android.view.HapticFeedbackConstants\n\n@Composable\nfun ArrivalScreen(\n stopId: String,\n stopCode: String,\n stopName: String,\n onChangeStop: () -> Unit,\n viewModel: ArrivalViewModel = hiltViewModel()\n) {\n val uiState by viewModel.uiState.collectAsState()\n val view = LocalView.current\n\n DisposableEffect(stopId) {\n viewModel.loadArrivals(stopId, stopCode, stopName)\n onDispose { }\n }\n\n when (val state = uiState) {\n is UiState.Loading -> {\n LoadingScreen(message = \"Loading arrivals...\")\n }\n is UiState.Error -> {\n ErrorScreen(\n message = state.message,\n canRetry = state.canRetry,\n onRetry = if (state.canRetry) {\n { viewModel.retry() }\n } else null\n )\n }\n is UiState.Success -> {\n ScalingLazyColumn(\n modifier = Modifier\n .fillMaxSize()\n .padding(horizontal = 8.dp)\n ) {\n item {\n Column(\n modifier = Modifier\n .fillMaxWidth()\n .padding(vertical = 8.dp)\n ) {\n Text(\n text = \"Stop ${state.data.stopCode}\",\n fontWeight = FontWeight.Bold\n )\n Text(text = state.data.stopName)\n }\n }\n\n state.data.arrivalsByRoute.forEach { (route, arrivals) ->\n items(arrivals.size) { index ->\n ArrivalItem(\n arrival = arrivals[index],\n onClick = { viewModel.onUserActivity() }\n )\n }\n }\n\n item {\n Spacer(modifier = Modifier.height(8.dp))\n Button(\n onClick = {\n view.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK)\n onChangeStop()\n },\n modifier = Modifier.fillMaxWidth()\n ) {\n Text(\"Change Stop\")\n }\n Spacer(modifier = Modifier.height(16.dp))\n }\n }\n }\n }\n}\n\n@Composable\nfun ArrivalItem(\n arrival: BusArrival,\n onClick: () -> Unit\n) {\n Row(\n modifier = Modifier\n .fillMaxWidth()\n .padding(vertical = 4.dp),\n verticalAlignment = Alignment.CenterVertically\n ) {\n Text(\n text = arrival.route,\n fontWeight = FontWeight.Bold,\n modifier = Modifier.width(40.dp)\n )\n Text(text = \"\u2192\")\n Spacer(modifier = Modifier.width(4.dp))\n Text(text = arrival.destinationShort)\n Spacer(modifier = Modifier.weight(1f))\n\n val timeText = if (arrival.minutesUntil < 1) \"Due\" else \"${arrival.minutesUntil} min\"\n val timeColor = when (arrival.arrivalType) {\n ArrivalType.LIVE -> LiveGreen\n ArrivalType.SCHEDULED -> ScheduledWhite\n }\n\n Text(\n text = timeText,\n color = timeColor,\n fontWeight = FontWeight.Medium\n )\n }\n}\n```\n\n**Step 2: Commit**\n\n```bash\ngit add app/src/main/java/com/buswatch/ui/screens/ArrivalScreen.kt\ngit commit -m \"feat: add ArrivalScreen UI with color-coded times\"\n```\n\n---\n\n## Task 14: MainActivity and Navigation\n\n**Files:**\n- Create: `app/src/main/java/com/buswatch/MainActivity.kt`\n\n**Step 1: Write MainActivity**\n\n```kotlin\n// ABOUTME: Main activity managing navigation between stop list and arrival screens\npackage com.buswatch\n\nimport android.Manifest\nimport android.content.pm.PackageManager\nimport android.os.Bundle\nimport androidx.activity.ComponentActivity\nimport androidx.activity.compose.setContent\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.rememberCoroutineScope\nimport androidx.compose.runtime.setValue\nimport androidx.core.content.ContextCompat\nimport androidx.lifecycle.viewmodel.compose.viewModel\nimport com.buswatch.domain.model.BusStop\nimport com.buswatch.ui.screens.ArrivalScreen\nimport com.buswatch.ui.screens.StopListScreen\nimport com.buswatch.ui.theme.BusWatchTheme\nimport com.buswatch.ui.viewmodel.StopListViewModel\nimport dagger.hilt.android.AndroidEntryPoint\nimport kotlinx.coroutines.launch\nimport timber.log.Timber\n\n@AndroidEntryPoint\nclass MainActivity : ComponentActivity() {\n\n private val requestPermissionLauncher = registerForActivityResult(\n ActivityResultContracts.RequestPermission()\n ) { isGranted ->\n if (isGranted) {\n Timber.d(\"Location permission granted\")\n } else {\n Timber.w(\"Location permission denied\")\n }\n }\n\n override fun onCreate(savedInstanceState: Bundle?) {\n super.onCreate(savedInstanceState)\n\n checkLocationPermission()\n\n setContent {\n BusWatchTheme {\n var selectedStop by remember { mutableStateOf<BusStop?>(null) }\n val scope = rememberCoroutineScope()\n val stopListViewModel: StopListViewModel = viewModel()\n\n if (selectedStop == null) {\n StopListScreen(\n onStopSelected = { stop ->\n scope.launch {\n stopListViewModel.saveSelectedStop(stop)\n selectedStop = stop\n }\n }\n )\n } else {\n ArrivalScreen(\n stopId = selectedStop!!.id,\n stopCode = selectedStop!!.code,\n stopName = selectedStop!!.name,\n onChangeStop = {\n selectedStop = null\n }\n )\n }\n }\n }\n }\n\n private fun checkLocationPermission() {\n when {\n ContextCompat.checkSelfPermission(\n this,\n Manifest.permission.ACCESS_FINE_LOCATION\n ) == PackageManager.PERMISSION_GRANTED -> {\n Timber.d(\"Location permission already granted\")\n }\n else -> {\n requestPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION)\n }\n }\n }\n}\n```\n\n**Step 2: Commit**\n\n```bash\ngit add app/src/main/java/com/buswatch/MainActivity.kt\ngit commit -m \"feat: add MainActivity with navigation and permissions\"\n```\n\n---\n\n## Task 15: Build and Run\n\n**Step 1: Sync Gradle**\n\nRun: `./gradlew build`\nExpected: BUILD SUCCESSFUL\n\n**Step 2: Run unit tests**\n\nRun: `./gradlew test`\nExpected: All tests PASS\n\n**Step 3: Create a simple integration test**\n\nCreate: `app/src/androidTest/java/com/buswatch/MainActivityTest.kt`\n\n```kotlin\n// ABOUTME: Integration test verifying MainActivity launches correctly\npackage com.buswatch\n\nimport androidx.compose.ui.test.junit4.createAndroidComposeRule\nimport androidx.compose.ui.test.onNodeWithText\nimport androidx.test.ext.junit.runners.AndroidJUnit4\nimport org.junit.Rule\nimport org.junit.Test\nimport org.junit.runner.RunWith\n\n@RunWith(AndroidJUnit4::class)\nclass MainActivityTest {\n\n @get:Rule\n val composeTestRule = createAndroidComposeRule<MainActivity>()\n\n @Test\n fun mainActivity_launches() {\n composeTestRule.onNodeWithText(\"Getting your location...\").assertExists()\n }\n}\n```\n\n**Step 4: Commit**\n\n```bash\ngit add app/src/androidTest/java/com/buswatch/MainActivityTest.kt\ngit commit -m \"test: add MainActivity integration test\"\n```\n\n**Step 5: Final verification**\n\nRun: `./gradlew build test`\nExpected: BUILD SUCCESSFUL, all tests PASS\n\n---\n\n## Summary\n\nThis implementation plan covers:\n\u2705 Android Wear OS project setup with Gradle\n\u2705 Domain models (BusStop, BusArrival)\n\u2705 TfL API client with Retrofit\n\u2705 Repositories (TfLRepository, LocationRepository)\n\u2705 DataStore for preferences\n\u2705 ViewModels with state management\n\u2705 UI screens (StopList, Arrivals)\n\u2705 Theme and styling\n\u2705 MainActivity with navigation\n\u2705 Unit and integration tests\n\n**Next Steps:**\n1. Register for TfL API key at https://api-portal.tfl.gov.uk/\n2. Add key to `local.properties`\n3. Deploy to Wear OS device/emulator\n4. Test with real data\n\n**Future Enhancements:**\n- Add delay detection (orange color)\n- Improve error messages\n- Add app icon\n- Add more comprehensive E2E tests\n",
"file_path": "/var/home/a/Code/BusWatch/docs/plans/2025-11-19-buswatch-implementation.md"
}