commit 4496ebcec493fc755c4278ba1710b45cdf8b1ffe Author: Lukas Holzner Date: Sat May 23 01:19:06 2026 +0200 chore: init repo with devshell, gradle wrapper, and release workflow 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 0000000..22a7139 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..390f9b5 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ 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 0000000..97cbbb1 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ 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 0000000..313d7c3 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..3ae3972 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..449a793 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ 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 0000000..1081450 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..cefa5e4 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..a351935 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..4046ce1 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ 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 @@ + + + +