From 4496ebcec493fc755c4278ba1710b45cdf8b1ffe Mon Sep 17 00:00:00 2001 From: Lukas Holzner Date: Sat, 23 May 2026 01:19:06 +0200 Subject: [PATCH] chore: init repo with devshell, gradle wrapper, and release workflow --- .env.example | 5 + .envrc | 7 + .gitea/workflows/release.yml | 46 + .gitignore | 18 + README.md | 21 + app/.gitignore | 1 + app/build.gradle.kts | 125 ++ app/proguard-rules.pro | 21 + .../com/example/ExampleInstrumentedTest.kt | 22 + app/src/main/AndroidManifest.xml | 29 + app/src/main/java/com/example/MainActivity.kt | 1114 +++++++++++++++++ .../main/java/com/example/data/api/MvgApi.kt | 74 ++ .../java/com/example/data/db/MvgDatabase.kt | 9 + .../java/com/example/data/db/SavedStop.kt | 13 + .../java/com/example/data/db/SavedStopDao.kt | 25 + .../example/data/repository/MvgRepository.kt | 51 + .../main/java/com/example/ui/theme/Color.kt | 30 + .../main/java/com/example/ui/theme/Theme.kt | 64 + .../main/java/com/example/ui/theme/Type.kt | 36 + .../com/example/ui/viewmodel/MvgViewModel.kt | 237 ++++ .../ui/viewmodel/MvgViewModelFactory.kt | 15 + .../res/drawable/ic_launcher_background.xml | 170 +++ .../res/drawable/ic_launcher_foreground.xml | 30 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 6 + app/src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 2096 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 4305 bytes app/src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 1485 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 2634 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 2854 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 5934 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 4360 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 8887 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 5743 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 11709 bytes app/src/main/res/values/colors.xml | 10 + app/src/main/res/values/strings.xml | 3 + app/src/main/res/values/themes.xml | 5 + app/src/main/res/xml/backup_rules.xml | 13 + .../main/res/xml/data_extraction_rules.xml | 19 + .../com/example/ExampleRobolectricTest.kt | 30 + .../test/java/com/example/ExampleUnitTest.kt | 16 + .../com/example/GreetingScreenshotTest.kt | 32 + app/src/test/screenshots/greeting.png | Bin 0 -> 2868 bytes build.gradle.kts | 8 + devbox.json | 6 + devbox.lock | 174 +++ flake.lock | 61 + flake.nix | 52 + gradle.properties | 26 + gradle/libs.versions.toml | 96 ++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43583 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 252 ++++ gradlew.bat | 94 ++ metadata.json | 6 + settings.gradle.kts | 27 + 57 files changed, 3112 insertions(+) create mode 100644 .env.example create mode 100644 .envrc create mode 100644 .gitea/workflows/release.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/.gitignore create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/androidTest/java/com/example/ExampleInstrumentedTest.kt create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/com/example/MainActivity.kt create mode 100644 app/src/main/java/com/example/data/api/MvgApi.kt create mode 100644 app/src/main/java/com/example/data/db/MvgDatabase.kt create mode 100644 app/src/main/java/com/example/data/db/SavedStop.kt create mode 100644 app/src/main/java/com/example/data/db/SavedStopDao.kt create mode 100644 app/src/main/java/com/example/data/repository/MvgRepository.kt create mode 100644 app/src/main/java/com/example/ui/theme/Color.kt create mode 100644 app/src/main/java/com/example/ui/theme/Theme.kt create mode 100644 app/src/main/java/com/example/ui/theme/Type.kt create mode 100644 app/src/main/java/com/example/ui/viewmodel/MvgViewModel.kt create mode 100644 app/src/main/java/com/example/ui/viewmodel/MvgViewModelFactory.kt create mode 100644 app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/backup_rules.xml create mode 100644 app/src/main/res/xml/data_extraction_rules.xml create mode 100644 app/src/test/java/com/example/ExampleRobolectricTest.kt create mode 100644 app/src/test/java/com/example/ExampleUnitTest.kt create mode 100644 app/src/test/java/com/example/GreetingScreenshotTest.kt create mode 100644 app/src/test/screenshots/greeting.png create mode 100644 build.gradle.kts create mode 100644 devbox.json create mode 100644 devbox.lock create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 metadata.json create mode 100644 settings.gradle.kts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1234e8c --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# GEMINI_API_KEY: Required for Gemini AI API calls. +# This is a placeholder key. +# AI Studio automatically injects this at runtime from user secrets. +# Users configure this via the Secrets panel in the AI Studio UI. +GEMINI_API_KEY=MY_GEMINI_API_KEY diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..84fc8e5 --- /dev/null +++ b/.envrc @@ -0,0 +1,7 @@ +# Automatically sets up your devbox environment whenever you cd into this +# directory via our direnv integration: + +eval "$(devbox generate direnv --print-envrc)" + +# check out https://www.jetpack.io/devbox/docs/ide_configuration/direnv/ +# for more details diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..bc2456e --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,46 @@ +name: Gitea Release + +on: + push: + tags: + - 'v*' + +jobs: + build-and-release: + runs-on: ubuntu-latest + permissions: + contents: write + releases: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: 'zulu' + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Create env file + run: cp .env.example .env + + - name: Build Release APK + run: ./gradlew assembleRelease + + - name: Rename APK + run: cp app/build/outputs/apk/release/app-release.apk munich-departures-${{ github.ref_name }}.apk + + - name: Create Gitea Release + uses: https://gitea.com/actions/gitea-release-action@v1 + with: + token: ${{ secrets.PAT || secrets.GITHUB_TOKEN }} + files: | + munich-departures-${{ github.ref_name }}.apk + name: Release ${{ github.ref_name }} + tag_name: ${{ github.ref_name }} + draft: false + prerelease: false + sha256sum: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2cbead9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +*.iml +.gradle +.kotlin +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties +.env +debug.keystore diff --git a/README.md b/README.md new file mode 100644 index 0000000..d5fad1c --- /dev/null +++ b/README.md @@ -0,0 +1,21 @@ +
+GHBanner +
+ +# Run and deploy your AI Studio app + +This contains everything you need to run your app locally. + +View your app in AI Studio: https://ai.studio/apps/9c59cfb4-12a7-42ef-ae43-53c4408c63ad + +## Run Locally + +**Prerequisites:** [Android Studio](https://developer.android.com/studio) + + +1. Open Android Studio +2. Select **Open** and choose the directory containing this project +3. Allow Android Studio to fix any incompatibilities as it imports the project. +4. Create a file named `.env` in the project directory and set `GEMINI_API_KEY` in that file to your Gemini API key (see `.env.example` for an example) +5. Remove this line from the app's `build.gradle.kts` file: `signingConfig = signingConfigs.getByName("debugConfig")` +6. Run the app on an emulator or physical device diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..ef64255 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,125 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.google.devtools.ksp) + alias(libs.plugins.roborazzi) + alias(libs.plugins.secrets) +} + +android { + namespace = "com.example" + compileSdk = 36 + + defaultConfig { + applicationId = "com.aistudio.munichdepartures.clnzs" + minSdk = 24 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + signingConfigs { + val keystorePath = System.getenv("KEYSTORE_PATH") ?: "${rootDir}/my-upload-key.jks" + val keystoreFile = file(keystorePath) + if (keystoreFile.exists()) { + create("release") { + storeFile = keystoreFile + storePassword = System.getenv("STORE_PASSWORD") + keyAlias = "upload" + keyPassword = System.getenv("KEY_PASSWORD") + } + } + create("debugConfig") { + storeFile = file("${rootDir}/debug.keystore") + storePassword = "android" + keyAlias = "androiddebugkey" + keyPassword = "android" + } + } + + buildTypes { + release { + isCrunchPngs = false + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + val releaseSigning = signingConfigs.findByName("release") + signingConfig = releaseSigning ?: signingConfigs.getByName("debugConfig") + } + debug { + signingConfig = signingConfigs.getByName("debugConfig") + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + buildFeatures { + compose = true + buildConfig = true + } + testOptions { unitTests { isIncludeAndroidResources = true } } +} + +// Configure the Secrets Gradle Plugin to use .env and .env.example files +// to match the convention used in Web projects. +secrets { + propertiesFileName = ".env" + defaultPropertiesFileName = ".env.example" +} + +// Some unused dependencies are commented out below instead of being removed. +// This makes it easy to add them back in the future if needed. +dependencies { + implementation(platform(libs.androidx.compose.bom)) + implementation(platform(libs.firebase.bom)) + // implementation(libs.accompanist.permissions) + implementation(libs.androidx.activity.compose) + // implementation(libs.androidx.camera.camera2) + // implementation(libs.androidx.camera.core) + // implementation(libs.androidx.camera.lifecycle) + // implementation(libs.androidx.camera.view) + implementation(libs.androidx.compose.material.icons.core) + // implementation(libs.androidx.compose.material.icons.extended) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.core.ktx) + // implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.viewmodel.compose) + // implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.room.ktx) + implementation(libs.androidx.room.runtime) + // implementation(libs.coil.compose) + implementation(libs.converter.moshi) + // implementation(libs.firebase.ai) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.logging.interceptor) + implementation(libs.moshi.kotlin) + implementation(libs.okhttp) + // implementation(libs.play.services.location) + implementation(libs.retrofit) + testImplementation(libs.androidx.compose.ui.test.junit4) + testImplementation(libs.androidx.core) + testImplementation(libs.androidx.junit) + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.robolectric) + testImplementation(libs.roborazzi) + testImplementation(libs.roborazzi.compose) + testImplementation(libs.roborazzi.junit.rule) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.runner) + debugImplementation(libs.androidx.compose.ui.test.manifest) + debugImplementation(libs.androidx.compose.ui.tooling) + "ksp"(libs.androidx.room.compiler) + "ksp"(libs.moshi.kotlin.codegen) +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..f1b4245 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/app/src/androidTest/java/com/example/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/example/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..6436111 --- /dev/null +++ b/app/src/androidTest/java/com/example/ExampleInstrumentedTest.kt @@ -0,0 +1,22 @@ +package com.example + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.example", appContext.packageName) + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..f4e5c65 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/example/MainActivity.kt b/app/src/main/java/com/example/MainActivity.kt new file mode 100644 index 0000000..2e53581 --- /dev/null +++ b/app/src/main/java/com/example/MainActivity.kt @@ -0,0 +1,1114 @@ +package com.example + +import android.os.Bundle +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.animation.* +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.room.Room +import com.example.data.api.MvgLocationDto +import com.example.data.db.MvgDatabase +import com.example.data.db.SavedStop +import com.example.data.repository.MvgRepository +import com.example.ui.theme.* +import com.example.ui.viewmodel.DepartureUiModel +import com.example.ui.viewmodel.DeparturesUiState +import com.example.ui.viewmodel.MvgViewModel +import com.example.ui.viewmodel.MvgViewModelFactory +import kotlinx.coroutines.delay +import java.text.SimpleDateFormat +import java.util.* + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + // Initialize Room Database + val db = Room.databaseBuilder( + applicationContext, + MvgDatabase::class.java, "mvg_departures_db" + ).fallbackToDestructiveMigration().build() + + val repository = MvgRepository(db.savedStopDao()) + val viewModelFactory = MvgViewModelFactory(repository) + val viewModel = ViewModelProvider(this, viewModelFactory)[MvgViewModel::class.java] + + setContent { + MyApplicationTheme { + Scaffold( + modifier = Modifier.fillMaxSize() + ) { innerPadding -> + Box(modifier = Modifier.padding(innerPadding)) { + MvgAppScreen(viewModel = viewModel) + } + } + } + } + } +} + +@Composable +fun MvgAppScreen(viewModel: MvgViewModel) { + var selectedTab by remember { mutableIntStateOf(0) } // 0: Live Board, 1: Settings + + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) { + when (selectedTab) { + 0 -> LiveBoardTab(viewModel = viewModel, onNavigateToSettings = { selectedTab = 1 }) + 1 -> SettingsTab(viewModel = viewModel, onNavigateBack = { selectedTab = 0 }) + } + } +} + +private fun filterFastestToReach( + departures: List, + nowMillis: Long +): List { + val result = mutableListOf() + val sorted = departures.sortedBy { it.realtimeTimeMillis } + for (dep in sorted) { + val existingIndex = result.indexOfFirst { existing -> + existing.line.equals(dep.line, ignoreCase = true) && + existing.destination.equals(dep.destination, ignoreCase = true) && + existing.transportType == dep.transportType && + existing.stopName != dep.stopName && + Math.abs(existing.realtimeTimeMillis - dep.realtimeTimeMillis) <= 600000L // 10 minutes + } + if (existingIndex == -1) { + result.add(dep) + } else { + val existing = result[existingIndex] + val depReachable = dep.isReachable(nowMillis) + val existingReachable = existing.isReachable(nowMillis) + if (depReachable && !existingReachable) { + result[existingIndex] = dep + } + } + } + return result +} + +@Composable +fun LiveBoardTab(viewModel: MvgViewModel, onNavigateToSettings: () -> Unit) { + val savedStops by viewModel.savedStops.collectAsStateWithLifecycle() + val departuresState by viewModel.departuresState.collectAsStateWithLifecycle() + val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle() + val hideUnreachable by viewModel.hideUnreachable.collectAsStateWithLifecycle() + val selectedStopFilter by viewModel.selectedStopFilter.collectAsStateWithLifecycle() + val lastUpdatedMillis by viewModel.lastUpdatedMillis.collectAsStateWithLifecycle() + + var currentTimeMillis by remember { mutableStateOf(System.currentTimeMillis()) } + + // Tick the clock every 2 seconds + LaunchedEffect(Unit) { + while (true) { + delay(2000) + currentTimeMillis = System.currentTimeMillis() + } + } + + Column(modifier = Modifier.fillMaxSize()) { + // Beautiful Elegant Dark Header Title Bar + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 24.dp, end = 24.dp, top = 24.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column { + Text( + text = "Departures", + style = MaterialTheme.typography.headlineMedium.copy( + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onBackground + ) + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "MUNICH LIVE TRANSIT", + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + letterSpacing = 1.5.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) + if (lastUpdatedMillis > 0) { + Spacer(modifier = Modifier.width(8.dp)) + val lastUpdateFormat = remember { java.text.SimpleDateFormat("HH:mm", java.util.Locale.GERMANY) } + Text( + text = "• Updated ${lastUpdateFormat.format(java.util.Date(lastUpdatedMillis))}", + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f) + ) + ) + } + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + // Elegant small Refresh button + Box( + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .background(Color(0xFF353439)) + .clickable { viewModel.refreshDepartures() } + .testTag("refresh_departures_button_fab"), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = "Refresh Board", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + } + + Spacer(modifier = Modifier.width(10.dp)) + + Box( + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .background(Color(0xFF353439)) + .clickable { onNavigateToSettings() }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = "Settings", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + } + } + } + + if (savedStops.isEmpty()) { + EmptySavedStopsState(onNavigateToSettings = onNavigateToSettings) + } else { + // Chip row filters + FilterChipsRow( + savedStops = savedStops, + selectedStopFilter = selectedStopFilter, + hideUnreachable = hideUnreachable, + onSelectStop = { viewModel.setSelectedStopFilter(it) }, + onToggleHideUnreachable = { viewModel.setHideUnreachable(it) } + ) + + // Render Departures List State + when (val state = departuresState) { + is DeparturesUiState.Loading -> { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + } + } + is DeparturesUiState.Error -> { + ErrorStateView(message = state.message, onRetry = { viewModel.refreshDepartures() }) + } + is DeparturesUiState.Success -> { + // Filter the departures based on user settings and remove duplicate rides across saved stops + val filteredDepartures = filterFastestToReach( + state.departures + .filter { departure -> + // 1. Filter by selected stop if active + selectedStopFilter == null || savedStops.find { it.name == departure.stopName }?.globalId == selectedStopFilter + } + .filter { departure -> + // 2. Filter out unreachable if toggle is active + !hideUnreachable || departure.isReachable(currentTimeMillis) + }, + currentTimeMillis + ).sortedBy { it.realtimeTimeMillis } + + if (filteredDepartures.isEmpty()) { + EmptyDeparturesState(hideUnreachable = hideUnreachable) + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + contentPadding = PaddingValues(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + items(filteredDepartures) { departure -> + DepartureRowCard(departure = departure, nowMillis = currentTimeMillis) + } + } + } + } + else -> { + // Initial state, call refresh + LaunchedEffect(Unit) { + viewModel.refreshDepartures() + } + } + } + } + } +} + +@Composable +fun HeaderCard(currentTimeMillis: Long, lastUpdatedMillis: Long, onRefresh: () -> Unit) { + val localTimeFormat = remember { SimpleDateFormat("HH:mm:ss", Locale.GERMANY) } + val lastUpdateFormat = remember { SimpleDateFormat("HH:mm", Locale.GERMANY) } + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + shape = RoundedCornerShape(24.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + // Pulse animation red dot + Box( + modifier = Modifier + .size(10.dp) + .clip(CircleShape) + .background(Color.Red) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "LIVE MÜNCHEN TIME", + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + letterSpacing = 1.2.sp, + color = MaterialTheme.colorScheme.primary + ) + ) + } + Spacer(modifier = Modifier.height(4.dp)) + // Large Clock + Text( + text = localTimeFormat.format(Date(currentTimeMillis)), + style = MaterialTheme.typography.displaySmall.copy( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.ExtraBold, + color = MaterialTheme.colorScheme.onSurface + ) + ) + + if (lastUpdatedMillis > 0) { + Text( + text = "Updated: ${lastUpdateFormat.format(Date(lastUpdatedMillis))}", + style = MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + ) + } + } + + // Refresh FAB-style circular card + FloatingActionButton( + onClick = onRefresh, + containerColor = Color(0xFF353439), + contentColor = MaterialTheme.colorScheme.primary, + shape = CircleShape, + modifier = Modifier.size(54.dp).testTag("refresh_departures_button_fab") + ) { + Icon(Icons.Default.Refresh, contentDescription = "Refresh Board", modifier = Modifier.size(26.dp)) + } + } + } +} + +@Composable +fun FilterChipsRow( + savedStops: List, + selectedStopFilter: String?, + hideUnreachable: Boolean, + onSelectStop: (String?) -> Unit, + onToggleHideUnreachable: (Boolean) -> Unit +) { + Column { + // Horizontal scrollable stops chips + LazyRowPaddingContent { + // "All Stops" Chip + InputChip( + selected = selectedStopFilter == null, + onClick = { onSelectStop(null) }, + label = { Text("🌍 All Stops") }, + modifier = Modifier.padding(end = 8.dp) + ) + + savedStops.forEach { stop -> + InputChip( + selected = selectedStopFilter == stop.globalId, + onClick = { onSelectStop(stop.globalId) }, + label = { Text(stop.name) }, + modifier = Modifier.padding(end = 8.dp) + ) + } + } + + // Catchable list toggles + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "Show catchable departures only", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onBackground + ) + Switch( + checked = hideUnreachable, + onCheckedChange = onToggleHideUnreachable, + modifier = Modifier.testTag("catchable_only_switch") + ) + } + Spacer(modifier = Modifier.height(6.dp)) + } +} + +@Composable +fun LazyRowPaddingContent(content: @Composable () -> Unit) { + androidx.compose.foundation.lazy.LazyRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + item { + content() + } + } +} + +@Composable +fun DepartureRowCard(departure: DepartureUiModel, nowMillis: Long) { + val minutes = departure.minutesRemaining(nowMillis) + val catchMinutes = departure.catchMinutesRemaining(nowMillis) + val isReachable = departure.isReachable(nowMillis) + + // Delay indicators + val isDelayed = departure.realtimeTimeMillis > departure.plannedTimeMillis + val delayMins = ((departure.realtimeTimeMillis - departure.plannedTimeMillis) / 60000).toInt() + + Card( + modifier = Modifier + .fillMaxWidth() + .testTag("departure_row_${departure.line}_${departure.destination}"), + shape = RoundedCornerShape(16.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.6f)), + colors = CardDefaults.cardColors( + containerColor = if (isReachable) MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.surface.copy(alpha = 0.4f) + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Live Munich Transit specific logos! + MvgTransitLogoBadge(transportType = departure.transportType, line = departure.label) + + Spacer(modifier = Modifier.width(14.dp)) + + // Body + Column(modifier = Modifier.weight(1f)) { + Text( + text = departure.destination, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + textDecoration = if (!isReachable) TextDecoration.LineThrough else TextDecoration.None + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (isReachable) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "at ${departure.stopName}", + style = MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "•", + style = MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "🚶 ${departure.stopWalkingTimeMinutes} min walk", + style = MaterialTheme.typography.bodySmall.copy( + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f) + ) + ) + } + + // Progress Bar: visual cue showing how close they are to needing to leave! + if (isReachable) { + Spacer(modifier = Modifier.height(8.dp)) + val progressValue = if (minutes > 0) { + (departure.stopWalkingTimeMinutes.toFloat() / minutes.toFloat()).coerceIn(0f, 1f) + } else 1f + + // Orange warning color as you approach need-to-leave time + val barColor = when { + catchMinutes in 1..2 -> Color(0xFFF59E0B) // amber + catchMinutes == 0 -> Color(0xFFEF4444) // red + else -> MaterialTheme.colorScheme.secondary // teal + } + + Column { + LinearProgressIndicator( + progress = { progressValue }, + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .clip(RoundedCornerShape(2.dp)), + color = barColor, + trackColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.1f) + ) + } + } + } + + Spacer(modifier = Modifier.width(12.dp)) + + // Time and catching statistics (Right aligned) + Column(horizontalAlignment = Alignment.End) { + // Large Live Departure Time + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = if (minutes == 0) "Now" else "$minutes min", + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.ExtraBold, + color = if (isReachable) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + ) + ) + + // Delay indicator Red badge + if (isDelayed && delayMins > 0) { + Spacer(modifier = Modifier.width(4.dp)) + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(Color(0xFFEF4444)) + .padding(horizontal = 4.dp, vertical = 2.dp) + ) { + Text( + text = "+$delayMins", + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 10.sp, + color = Color.White + ) + ) + } + } + } + + Spacer(modifier = Modifier.height(4.dp)) + + // Catch-up status chip + val infoText: String + val infoColor: Color + val infoTxtColor: Color + + when { + !isReachable -> { + infoText = "Too Late" + infoColor = Color.LightGray.copy(alpha = 0.3f) + infoTxtColor = Color.Gray + } + catchMinutes == 0 -> { + infoText = "Run Now!" + infoColor = Color(0xFFEF4444) // red + infoTxtColor = Color.White + } + catchMinutes in 1..2 -> { + infoText = "Leave in $catchMinutes min!" + infoColor = Color(0xFFF59E0B) // orange + infoTxtColor = Color.White + } + else -> { + infoText = "Leave in $catchMinutes min" + infoColor = Color(0xFF10B981) // emerald green + infoTxtColor = Color.White + } + } + + Box( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background(infoColor) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Text( + text = infoText, + style = MaterialTheme.typography.bodySmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 11.sp, + color = infoTxtColor + ) + ) + } + } + } + } +} + +@Composable +fun MvgTransitLogoBadge(transportType: String, line: String) { + // Official colors and layouts of Munich Public Transit (MVG) + // U-Bahn (METRO) -> Blue square with big white 'U' or 'Line name' + // S-Bahn (SUBURBAN) -> Green circle with S or 'Line name' + // TRAM -> Red square with T or 'Line name' + // BUS -> Teal square with 'Line name' or Haltestelle + + val badgeColor: Color + val isCircle: Boolean + val shapeToken: RoundedCornerShape + + when (transportType) { + "METRO" -> { + badgeColor = MvgBlue + isCircle = false + shapeToken = RoundedCornerShape(4.dp) + } + "SUBURBAN" -> { + badgeColor = MvgGreen + isCircle = true + shapeToken = RoundedCornerShape(50) // circle + } + "TRAM" -> { + badgeColor = MvgRed + isCircle = false + shapeToken = RoundedCornerShape(4.dp) + } + else -> { // "BUS" / Default + badgeColor = MvgTeal + isCircle = false + shapeToken = RoundedCornerShape(8.dp) + } + } + + Box( + modifier = Modifier + .size(46.dp) + .clip(shapeToken) + .background(badgeColor), + contentAlignment = Alignment.Center + ) { + Text( + text = line, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Black, + color = Color.White, + fontSize = if (line.length > 3) 12.sp else 16.sp + ), + textAlign = TextAlign.Center + ) + } +} + +@Composable +fun SettingsTab(viewModel: MvgViewModel, onNavigateBack: () -> Unit) { + val savedStops by viewModel.savedStops.collectAsStateWithLifecycle() + val stopSearchQuery by viewModel.stopSearchQuery.collectAsStateWithLifecycle() + val stopSearchResults by viewModel.stopSearchResults.collectAsStateWithLifecycle() + val isSearchingStops by viewModel.isSearchingStops.collectAsStateWithLifecycle() + + var showDialogToAdd by remember { mutableStateOf(null) } + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // App settings header + item { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + onClick = onNavigateBack, + modifier = Modifier.testTag("settings_back_button") + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back to Live Board", + tint = MaterialTheme.colorScheme.primary + ) + } + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "My Closest Stops", + style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onBackground + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Manage your home stations and configure walking times to filter reachable departures.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), + modifier = Modifier.padding(start = 8.dp) + ) + } + + // Current Saved Stops + if (savedStops.isEmpty()) { + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)) + ) { + Text( + text = "No saved stops. Add one below by searching!", + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.bodyMedium, + color = Color.Gray, + textAlign = TextAlign.Center + ) + } + } + } else { + items(savedStops) { stop -> + SavedStopSettingCard( + stop = stop, + onUpdateWalkTime = { newTime -> viewModel.updateWalkingTime(stop, newTime) }, + onDelete = { viewModel.deleteStop(stop) } + ) + } + } + + // Search Munich transit database section + item { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + Text( + text = "Add Stops Near You", + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onBackground + ) + Spacer(modifier = Modifier.height(8.dp)) + + // Search Bar Input + OutlinedTextField( + value = stopSearchQuery, + onValueChange = { viewModel.updateSearchQuery(it) }, + label = { Text("Search Munich Stop (e.g., Sendlinger Tor)") }, + modifier = Modifier + .fillMaxWidth() + .testTag("station_search_input"), + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + trailingIcon = { + if (stopSearchQuery.isNotEmpty()) { + IconButton(onClick = { viewModel.updateSearchQuery("") }) { + Icon(Icons.Default.Clear, contentDescription = "Clear") + } + } + }, + singleLine = true + ) + } + + // Search loading indicator + if (isSearchingStops) { + item { + Box(modifier = Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + } + } + } + + // Render Search Results + if (stopSearchQuery.length >= 3 && stopSearchResults.isEmpty() && !isSearchingStops) { + item { + Text( + text = "No stops found. Try typing a different Munich station name.", + style = MaterialTheme.typography.bodyMedium, + color = Color.Gray, + modifier = Modifier.padding(8.dp) + ) + } + } else { + items(stopSearchResults) { location -> + SearchResultStopRow(location = location, onSelect = { showDialogToAdd = location }) + } + } + } + + // Interactive Dialog to configure walking time + showDialogToAdd?.let { location -> + WalkingTimeSetupDialog( + location = location, + onDismiss = { showDialogToAdd = null }, + onConfirm = { minutes -> + viewModel.addStop( + name = location.name ?: "Unknown Stop", + globalId = location.id ?: "", + walkingTime = minutes + ) + showDialogToAdd = null + viewModel.updateSearchQuery("") // reset search + } + ) + } +} + +@Composable +fun SavedStopSettingCard( + stop: SavedStop, + onUpdateWalkTime: (Int) -> Unit, + onDelete: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .testTag("saved_stop_card_${stop.id}"), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + shape = RoundedCornerShape(16.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stop.name, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "ID: ${stop.globalId}", + style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.sp), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + } + + IconButton( + onClick = onDelete, + modifier = Modifier.testTag("delete_stop_button_${stop.id}") + ) { + Icon( + Icons.Default.Delete, + contentDescription = "Delete stop", + tint = MaterialTheme.colorScheme.error + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Adjust Walking Time Control Row (+ / - controls) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("🚶", fontSize = 18.sp) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "Walking time: ${stop.walkingTimeMinutes} min", + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Row(verticalAlignment = Alignment.CenterVertically) { + // Decrease Step + IconButton( + onClick = { if (stop.walkingTimeMinutes > 1) onUpdateWalkTime(stop.walkingTimeMinutes - 1) }, + enabled = stop.walkingTimeMinutes > 1, + modifier = Modifier + .size(36.dp) + .testTag("walk_decrease_${stop.id}") + ) { + Text("−", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), color = MaterialTheme.colorScheme.onSurfaceVariant) + } + + Spacer(modifier = Modifier.width(8.dp)) + + // Increase Step + IconButton( + onClick = { if (stop.walkingTimeMinutes < 60) onUpdateWalkTime(stop.walkingTimeMinutes + 1) }, + enabled = stop.walkingTimeMinutes < 60, + modifier = Modifier + .size(36.dp) + .testTag("walk_increase_${stop.id}") + ) { + Icon(Icons.Default.Add, contentDescription = "Increase") + } + } + } + } + } +} + +@Composable +fun SearchResultStopRow(location: MvgLocationDto, onSelect: () -> Unit) { + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { onSelect() } + .testTag("search_result_${location.id}"), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + shape = RoundedCornerShape(8.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) + ) { + Row( + modifier = Modifier.padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = location.name ?: "Unknown Stop", + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "ID: ${location.id}", + style = MaterialTheme.typography.bodySmall, + color = Color.Gray + ) + } + Icon(Icons.Default.AddCircle, contentDescription = "Add Stop", tint = MaterialTheme.colorScheme.primary) + } + } +} + +@Composable +fun WalkingTimeSetupDialog( + location: MvgLocationDto, + onDismiss: () -> Unit, + onConfirm: (Int) -> Unit +) { + var chosenTimeMinutes by remember { mutableFloatStateOf(5f) } + + Dialog(onDismissRequest = onDismiss) { + Card( + shape = RoundedCornerShape(16.dp), + modifier = Modifier.padding(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) + ) { + Column( + modifier = Modifier.padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Setup Walking Time", + style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold), + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "How long does it take you to walk to ${location.name}?", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = Color.Gray + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // Time specification slider + Text( + text = "${chosenTimeMinutes.toInt()} MINUTES WALKING TIME", + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.ExtraBold), + color = MaterialTheme.colorScheme.primary + ) + + Slider( + value = chosenTimeMinutes, + onValueChange = { chosenTimeMinutes = it }, + valueRange = 1f..45f, + steps = 44, + modifier = Modifier.testTag("setup_walk_slider") + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // Dismiss / Confirm buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = onDismiss, modifier = Modifier.testTag("dialog_dismiss_button")) { + Text("Cancel") + } + Spacer(modifier = Modifier.width(8.dp)) + Button( + onClick = { onConfirm(chosenTimeMinutes.toInt()) }, + modifier = Modifier.testTag("dialog_confirm_button") + ) { + Text("Save Stop") + } + } + } + } + } +} + +@Composable +fun EmptySavedStopsState(onNavigateToSettings: () -> Unit) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + "🚶", + fontSize = 54.sp, + modifier = Modifier.padding(bottom = 12.dp) + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Welcome to Munich Departures!", + style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold), + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Add your home stations and walk times to construct your live, personalized catch-board departure stream.", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = Color.Gray + ) + Spacer(modifier = Modifier.height(24.dp)) + Button(onClick = onNavigateToSettings, modifier = Modifier.testTag("go_to_settings_empty_btn")) { + Text("Go to Settings") + } + } + } +} + +@Composable +fun EmptyDeparturesState(hideUnreachable: Boolean) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + Icons.Default.Warning, + contentDescription = null, + modifier = Modifier.size(54.dp), + tint = Color.LightGray + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "No departures active", + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = if (hideUnreachable) { + "All next departures leave sooner than your walking time. Try disabling 'Show catchable departures only' to inspect them." + } else { + "No departures schedule found at these Munich transit stops right now." + }, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = Color.Gray + ) + } + } +} + +@Composable +fun ErrorStateView(message: String, onRetry: () -> Unit) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + Icons.Default.Warning, + contentDescription = null, + modifier = Modifier.size(54.dp), + tint = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "Connection Error", + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = Color.Gray + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = onRetry) { + Text("Retry Connection") + } + } + } +} diff --git a/app/src/main/java/com/example/data/api/MvgApi.kt b/app/src/main/java/com/example/data/api/MvgApi.kt new file mode 100644 index 0000000..7412d87 --- /dev/null +++ b/app/src/main/java/com/example/data/api/MvgApi.kt @@ -0,0 +1,74 @@ +package com.example.data.api + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.Query + +@JsonClass(generateAdapter = true) +data class MvgLocationDto( + @Json(name = "type") val type: String?, // "STATION", "POINTER", etc. + @Json(name = "latitude") val latitude: Double?, + @Json(name = "longitude") val longitude: Double?, + @Json(name = "globalId") val id: String?, // The globalId needed for departures! + @Json(name = "name") val name: String?, + @Json(name = "mvg") val mvg: Boolean?, + @Json(name = "mvv") val mvv: Boolean? +) + +@JsonClass(generateAdapter = true) +data class MvgDepartureDto( + @Json(name = "plannedDepartureTime") val plannedDepartureTime: Long?, + @Json(name = "realtimeDepartureTime") val realtimeDepartureTime: Long?, + @Json(name = "realtime") val realtime: Boolean?, + @Json(name = "line") val line: String?, + @Json(name = "destination") val destination: String?, + @Json(name = "transportType") val transportType: String?, // "METRO", "BUS", "TRAM", "SUBURBAN", "REGIONAL_TRAIN" + @Json(name = "label") val label: String?, + @Json(name = "sev") val sev: Boolean?, + @Json(name = "cancelled") val cancelled: Boolean? = false +) + +interface MvgApiService { + @GET("api/bgw-pt/v3/locations") + suspend fun searchLocations( + @Query("query") query: String, + @Query("locationTypes") locationTypes: String = "STATION", + @Header("User-Agent") userAgent: String = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + @Header("X-MVG-Authorization-Key") apiKey: String = "5kaY6p6F7uqSjnd98374sao234" + ): List + + @GET("api/bgw-pt/v3/departures") + suspend fun getDepartures( + @Query("globalId") globalId: String, + @Query("limit") limit: Int = 40, + @Header("User-Agent") userAgent: String = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + @Header("X-MVG-Authorization-Key") apiKey: String = "5kaY6p6F7uqSjnd98374sao234" + ): List +} + +object MvgApiClient { + private const val BASE_URL = "https://www.mvg.de/" + + private val okHttpClient by lazy { + OkHttpClient.Builder() + .addInterceptor(HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + }) + .build() + } + + val service: MvgApiService by lazy { + Retrofit.Builder() + .baseUrl(BASE_URL) + .client(okHttpClient) + .addConverterFactory(MoshiConverterFactory.create()) + .build() + .create(MvgApiService::class.java) + } +} diff --git a/app/src/main/java/com/example/data/db/MvgDatabase.kt b/app/src/main/java/com/example/data/db/MvgDatabase.kt new file mode 100644 index 0000000..4464ade --- /dev/null +++ b/app/src/main/java/com/example/data/db/MvgDatabase.kt @@ -0,0 +1,9 @@ +package com.example.data.db + +import androidx.room.Database +import androidx.room.RoomDatabase + +@Database(entities = [SavedStop::class], version = 1, exportSchema = false) +abstract class MvgDatabase : RoomDatabase() { + abstract fun savedStopDao(): SavedStopDao +} diff --git a/app/src/main/java/com/example/data/db/SavedStop.kt b/app/src/main/java/com/example/data/db/SavedStop.kt new file mode 100644 index 0000000..f1174b2 --- /dev/null +++ b/app/src/main/java/com/example/data/db/SavedStop.kt @@ -0,0 +1,13 @@ +package com.example.data.db + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "saved_stops") +data class SavedStop( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val name: String, // Stop Name (e.g. Johann-Clanze-Straße) + val globalId: String, // Official Stop ID (e.g. de:09162:150) + val walkingTimeMinutes: Int, // User's walking time to the stop + val isCustomOrder: Int = 0 // Custom list ordering +) diff --git a/app/src/main/java/com/example/data/db/SavedStopDao.kt b/app/src/main/java/com/example/data/db/SavedStopDao.kt new file mode 100644 index 0000000..6b4755a --- /dev/null +++ b/app/src/main/java/com/example/data/db/SavedStopDao.kt @@ -0,0 +1,25 @@ +package com.example.data.db + +import androidx.room.* +import kotlinx.coroutines.flow.Flow + +@Dao +interface SavedStopDao { + @Query("SELECT * FROM saved_stops ORDER BY isCustomOrder ASC, id ASC") + fun getAllSavedStops(): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertSavedStop(stop: SavedStop): Long + + @Update + suspend fun updateSavedStop(stop: SavedStop) + + @Delete + suspend fun deleteSavedStop(stop: SavedStop) + + @Query("DELETE FROM saved_stops WHERE id = :id") + suspend fun deleteById(id: Long) + + @Query("SELECT COUNT(*) FROM saved_stops") + suspend fun getCount(): Int +} diff --git a/app/src/main/java/com/example/data/repository/MvgRepository.kt b/app/src/main/java/com/example/data/repository/MvgRepository.kt new file mode 100644 index 0000000..c5b420d --- /dev/null +++ b/app/src/main/java/com/example/data/repository/MvgRepository.kt @@ -0,0 +1,51 @@ +package com.example.data.repository + +import com.example.data.api.MvgApiClient +import com.example.data.api.MvgDepartureDto +import com.example.data.api.MvgLocationDto +import com.example.data.db.SavedStop +import com.example.data.db.SavedStopDao +import kotlinx.coroutines.flow.Flow + +class MvgRepository(private val savedStopDao: SavedStopDao) { + + val allSavedStops: Flow> = savedStopDao.getAllSavedStops() + + suspend fun insertSavedStop(stop: SavedStop): Long { + return savedStopDao.insertSavedStop(stop) + } + + suspend fun updateSavedStop(stop: SavedStop) { + savedStopDao.updateSavedStop(stop) + } + + suspend fun deleteSavedStop(stop: SavedStop) { + savedStopDao.deleteSavedStop(stop) + } + + suspend fun deleteById(id: Long) { + savedStopDao.deleteById(id) + } + + suspend fun getSavedStopsCount(): Int { + return savedStopDao.getCount() + } + + suspend fun searchLocations(query: String): List { + return try { + MvgApiClient.service.searchLocations(query) + } catch (e: Exception) { + e.printStackTrace() + emptyList() + } + } + + suspend fun getDepartures(globalId: String): List { + return try { + MvgApiClient.service.getDepartures(globalId) + } catch (e: Exception) { + e.printStackTrace() + emptyList() + } + } +} diff --git a/app/src/main/java/com/example/ui/theme/Color.kt b/app/src/main/java/com/example/ui/theme/Color.kt new file mode 100644 index 0000000..5794330 --- /dev/null +++ b/app/src/main/java/com/example/ui/theme/Color.kt @@ -0,0 +1,30 @@ +package com.example.ui.theme + +import androidx.compose.ui.graphics.Color + +// Munich public transit inspired brand colors +val MvgBlue = Color(0xFF0F437D) // Official MVG U-Bahn Indigo Blue +val MvgTeal = Color(0xFF00827F) // Official MVG Bus Teal +val MvgRed = Color(0xFFD11119) // Official MVG Tram Red +val MvgGreen = Color(0xFF008F45) // S-Bahn Green +val TransitYellow = Color(0xFFFFCC00) // High-contrast warning/indicator yellow + +// Elegant Dark Design Theme Colors +val ElegantDarkBg = Color(0xFF1C1B1F) // #1C1B1F +val ElegantDarkSurface = Color(0xFF2B2930) // #2B2930 +val ElegantDarkBorder = Color(0xFF49454F) // #49454F +val ElegantDarkOnSurface = Color(0xFFE6E1E5) // #E6E1E5 +val ElegantDarkSubText = Color(0xFFCAC4D0) // #CAC4D0 +val ElegantDarkPurple = Color(0xFFD0BCFF) // #D0BCFF +val ElegantDarkPurpleContainer = Color(0xFF381E72) +val ElegantDarkActivePill = Color(0xFFE8DEF8) +val ElegantDarkOnActivePill = Color(0xFF1D192B) +val ElegantDarkButtonInactive = Color(0xFF353439) + +// Light theme color override tokens +val PrimaryLight = Color(0xFF0F437D) +val SecondaryLight = Color(0xFF00827F) +val TertiaryLight = Color(0xFFB45309) +val BackgroundLight = Color(0xFFF8FAFC) +val SurfaceLight = Color(0xFFFFFFFF) +val SurfaceCardLight = Color(0xFFEDF2F7) diff --git a/app/src/main/java/com/example/ui/theme/Theme.kt b/app/src/main/java/com/example/ui/theme/Theme.kt new file mode 100644 index 0000000..329d515 --- /dev/null +++ b/app/src/main/java/com/example/ui/theme/Theme.kt @@ -0,0 +1,64 @@ +package com.example.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +import androidx.compose.ui.graphics.Color + +private val DarkColorScheme = + darkColorScheme( + primary = ElegantDarkPurple, + primaryContainer = ElegantDarkPurpleContainer, + secondary = MvgTeal, + tertiary = MvgGreen, + background = ElegantDarkBg, + surface = ElegantDarkSurface, + onBackground = ElegantDarkOnSurface, + onSurface = ElegantDarkOnSurface, + surfaceVariant = ElegantDarkSurface, + onSurfaceVariant = ElegantDarkSubText, + outline = ElegantDarkBorder, + outlineVariant = ElegantDarkBorder + ) + +private val LightColorScheme = + lightColorScheme( + primary = PrimaryLight, + primaryContainer = Color(0xFFEFF6FF), + secondary = SecondaryLight, + tertiary = MvgGreen, + background = BackgroundLight, + surface = SurfaceLight, + onBackground = Color(0xFF0F172A), + onSurface = Color(0xFF0F172A), + surfaceVariant = SurfaceCardLight, + onSurfaceVariant = Color(0xFF1E293B) + ) + +@Composable +fun MyApplicationTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ (set to false by default for customized premium theme consistency) + dynamicColor: Boolean = false, + content: @Composable () -> Unit, +) { + val colorScheme = + when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme(colorScheme = colorScheme, typography = Typography, content = content) +} diff --git a/app/src/main/java/com/example/ui/theme/Type.kt b/app/src/main/java/com/example/ui/theme/Type.kt new file mode 100644 index 0000000..b631a6b --- /dev/null +++ b/app/src/main/java/com/example/ui/theme/Type.kt @@ -0,0 +1,36 @@ +package com.example.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = + Typography( + bodyLarge = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp, + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ + ) diff --git a/app/src/main/java/com/example/ui/viewmodel/MvgViewModel.kt b/app/src/main/java/com/example/ui/viewmodel/MvgViewModel.kt new file mode 100644 index 0000000..0886a0b --- /dev/null +++ b/app/src/main/java/com/example/ui/viewmodel/MvgViewModel.kt @@ -0,0 +1,237 @@ +package com.example.ui.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.data.api.MvgLocationDto +import com.example.data.db.SavedStop +import com.example.data.repository.MvgRepository +import kotlinx.coroutines.delay +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch + +sealed interface DeparturesUiState { + object Idle : DeparturesUiState + object Loading : DeparturesUiState + data class Success(val departures: List) : DeparturesUiState + data class Error(val message: String) : DeparturesUiState +} + +data class DepartureUiModel( + val line: String, + val destination: String, + val plannedTimeMillis: Long, + val realtimeTimeMillis: Long, + val realtime: Boolean, + val transportType: String, + val stopName: String, + val stopWalkingTimeMinutes: Int, + val label: String +) { + fun minutesRemaining(nowMillis: Long): Int { + val diff = realtimeTimeMillis - nowMillis + return (diff / 60000).toInt().coerceAtLeast(0) + } + + fun catchMinutesRemaining(nowMillis: Long): Int { + return minutesRemaining(nowMillis) - stopWalkingTimeMinutes + } + + fun isReachable(nowMillis: Long): Boolean { + return minutesRemaining(nowMillis) >= stopWalkingTimeMinutes + } +} + +class MvgViewModel(private val repository: MvgRepository) : ViewModel() { + + val savedStops: StateFlow> = repository.allSavedStops + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = emptyList() + ) + + private val _departuresState = MutableStateFlow(DeparturesUiState.Idle) + val departuresState: StateFlow = _departuresState.asStateFlow() + + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing: StateFlow = _isRefreshing.asStateFlow() + + private val _lastUpdatedMillis = MutableStateFlow(0L) + val lastUpdatedMillis: StateFlow = _lastUpdatedMillis.asStateFlow() + + // Filters and Display settings + private val _hideUnreachable = MutableStateFlow(false) + val hideUnreachable: StateFlow = _hideUnreachable.asStateFlow() + + private val _selectedStopFilter = MutableStateFlow(null) // null means show "All Saved Stops" + val selectedStopFilter: StateFlow = _selectedStopFilter.asStateFlow() + + // Stop Search + private val _stopSearchQuery = MutableStateFlow("") + val stopSearchQuery: StateFlow = _stopSearchQuery.asStateFlow() + + private val _stopSearchResults = MutableStateFlow>(emptyList()) + val stopSearchResults: StateFlow> = _stopSearchResults.asStateFlow() + + private val _isSearchingStops = MutableStateFlow(false) + val isSearchingStops: StateFlow = _isSearchingStops.asStateFlow() + + init { + viewModelScope.launch { + // Check if database needs pre-population + if (repository.getSavedStopsCount() == 0) { + repository.insertSavedStop( + SavedStop( + name = "Johann-Clanze-Straße", + globalId = "de:09162:1335", + walkingTimeMinutes = 3, + isCustomOrder = 0 + ) + ) + repository.insertSavedStop( + SavedStop( + name = "Harras", + globalId = "de:09162:1130", + walkingTimeMinutes = 12, + isCustomOrder = 1 + ) + ) + } + } + + // Auto-refresh departures whenever stops list changes + viewModelScope.launch { + savedStops.collect { stops -> + if (stops.isNotEmpty() && _departuresState.value is DeparturesUiState.Idle) { + refreshDepartures() + } + } + } + + // Auto-refresh departures every 30 seconds + viewModelScope.launch { + while (true) { + delay(30000) + if (savedStops.value.isNotEmpty()) { + refreshDepartures(quiet = true) + } + } + } + } + + fun setHideUnreachable(hide: Boolean) { + _hideUnreachable.value = hide + } + + fun setSelectedStopFilter(stopGlobalId: String?) { + _selectedStopFilter.value = stopGlobalId + } + + fun refreshDepartures(quiet: Boolean = false) { + val stops = savedStops.value + if (stops.isEmpty()) { + _departuresState.value = DeparturesUiState.Success(emptyList()) + return + } + + if (!quiet || _departuresState.value !is DeparturesUiState.Success) { + _departuresState.value = DeparturesUiState.Loading + } + viewModelScope.launch { + try { + val deferredList = stops.map { stop -> + async { + val dtoList = repository.getDepartures(stop.globalId) + dtoList.map { dto -> + // Use planned time as fallback if realtime is null + val fallbackTime = dto.realtimeDepartureTime ?: dto.plannedDepartureTime ?: System.currentTimeMillis() + DepartureUiModel( + line = dto.line ?: "Bus", + destination = dto.destination ?: "Unknown", + plannedTimeMillis = dto.plannedDepartureTime ?: fallbackTime, + realtimeTimeMillis = fallbackTime, + realtime = dto.realtime ?: false, + transportType = dto.transportType ?: "BUS", + stopName = stop.name, + stopWalkingTimeMinutes = stop.walkingTimeMinutes, + label = dto.label ?: dto.line ?: "" + ) + } + } + } + + val allResults = deferredList.awaitAll().flatten() + + _departuresState.value = DeparturesUiState.Success(allResults) + _lastUpdatedMillis.value = System.currentTimeMillis() + } catch (e: Exception) { + e.printStackTrace() + _departuresState.value = DeparturesUiState.Error(e.message ?: "Failed to retrieve departures") + } finally { + _isRefreshing.value = false + } + } + } + + fun triggerPullToRefresh() { + _isRefreshing.value = true + refreshDepartures() + } + + // Settings adjustments + fun addStop(name: String, globalId: String, walkingTime: Int) { + viewModelScope.launch { + repository.insertSavedStop( + SavedStop( + name = name, + globalId = globalId, + walkingTimeMinutes = walkingTime, + isCustomOrder = savedStops.value.size + ) + ) + refreshDepartures() + } + } + + fun deleteStop(stop: SavedStop) { + viewModelScope.launch { + repository.deleteSavedStop(stop) + refreshDepartures() + } + } + + fun updateWalkingTime(stop: SavedStop, newWalkingTime: Int) { + viewModelScope.launch { + repository.updateSavedStop(stop.copy(walkingTimeMinutes = newWalkingTime)) + refreshDepartures() + } + } + + // Stop Search operations + fun updateSearchQuery(query: String) { + _stopSearchQuery.value = query + if (query.length >= 3) { + performStopSearch(query) + } else { + _stopSearchResults.value = emptyList() + } + } + + private fun performStopSearch(query: String) { + _isSearchingStops.value = true + viewModelScope.launch { + try { + val results = repository.searchLocations(query) + // Filter to keep only STATION items for accuracy + val filtered = results.filter { it.type == "STATION" && !it.id.isNullOrEmpty() } + _stopSearchResults.value = filtered + } catch (e: Exception) { + e.printStackTrace() + } finally { + _isSearchingStops.value = false + } + } + } +} diff --git a/app/src/main/java/com/example/ui/viewmodel/MvgViewModelFactory.kt b/app/src/main/java/com/example/ui/viewmodel/MvgViewModelFactory.kt new file mode 100644 index 0000000..6cca2d3 --- /dev/null +++ b/app/src/main/java/com/example/ui/viewmodel/MvgViewModelFactory.kt @@ -0,0 +1,15 @@ +package com.example.ui.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.example.data.repository.MvgRepository + +class MvgViewModelFactory(private val repository: MvgRepository) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(MvgViewModel::class.java)) { + @Suppress("UNCHECKED_CAST") + return MvgViewModel(repository) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..7706ab9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..b3e26b4 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..b3e26b4 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..22a71398233a79426c21a628a54a21a66beaa9ff GIT binary patch literal 2096 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..97cbbb18f32fe50b17adf1b6f4c72172e889bba4 GIT binary patch literal 1485 zcmX|BX;Twf6z#AKiWCUQXfb2}MMX3!S_o5a2!bdsQ7jmdMRBAUC{ag6qXGiM5-@~K z2q8is5S9=UVi1x*QgzNBo2wtDy1Tkx_j~8vv)sOSy||cz;fhjGRG=-_=KUG1D9T>B zPW?Qu+|-s}MTRPTddMk`!^0(d5R7~K_AMM?_h{y~*c8~as@QqAiqqKqm6H+i*{XM# zD1Zk8>_s1}X&erZz}Azyw8Qiptmh-jULp7xGr6~5{Xi#d7`aG;Mnxk%+@g;I4C<;P z)3KHzC;{5&!Z3HZiLFytIL-cNNyje3U50t7#>nAv`Rc(AJ8#RwiUu9*qyK*(S}@;? zju`Iz*yzb2&SE^1Mtn!Fyy|$Rvbq^IyNq%e-x+=I{fvF0Poks{G z3~``UZr@?zR4wOVpdX`jouyGF;&yOJ#RVN#bqrkQ@|C1E1RC$t2Mhg&7_7zC9j*vn zUn&i^xyoMJ{}43yH7-41pcTF7Z_T3&@xrUV@%lIe z^;FsH$!OHJSq9I12^wbzuj(?WOsKNo~$fKs}%Z8zPRLGUUk+Y{k_ z&XAU7I2gQ-K6bvTl2RY_ zIY-mgG+>+4=_e#PKFmbJTYyoFy}(2&y_^@F%~;e)s5*{d0Ug-FU>+Q$Nt}#lf9^i& zqdC@m9CNbVFFCYw3>M)y51-_rnO53htiY;x)xu>gMb+`MWb?aB=;tuTvsDNR_l4bi z1zS&B;U!6=gMDy{IYx#Kwx1HTA*{im<3ih6_#0po`{yuFh_5iy7bfE`;h2w0xOJ1u zQWqvT_5KCBL*Jr!@AoupGPkUK+#>OSv#uI|Adm%<808 z3}YcJTEjJ3wKQzOg`rMpF zUvXum^oaLOdi+b7U`20!eI162TU%4kCq}_sEg7@H4@(ZFnlW|e!Cm@m_LODR?m1B? zu(pglYUzhouG3bON|QSO_rK9!{i-InQ7xV9rLCa?y*V2AKBmgH8|*8;d{Rmo5S`Z& hFesKyALPz;7^MGg=#d|pnbwOUu0ayHiBDOI@*ge>ER+BM literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..313d7c3353ae972251a80c5def194e24b84aeacf GIT binary patch literal 2634 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 W!-hf`z9-{e+)VET!{zUMD*k^Pf5K$| literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..1081450611a57e7b87fc832d3f8d5c2239d37663 GIT binary patch literal 4360 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 JO8@^q{{!Z`%7 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 EAEXfrY5)KL literal 0 HcmV?d00001 diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..ca1931b --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..1a6ee0d --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Munich Departures + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..839cd48 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +