diff --git a/V2X_MicrOBU_Android_App_Requirements_v9.docx b/V2X_MicrOBU_Android_App_Requirements_v9.docx
new file mode 100644
index 0000000..1babb2c
Binary files /dev/null and b/V2X_MicrOBU_Android_App_Requirements_v9.docx differ
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..9a3ad3f
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,82 @@
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.kotlin.compose)
+ alias(libs.plugins.ksp)
+ alias(libs.plugins.hilt)
+}
+
+android {
+ namespace = "com.hawhamburg.micr0bu"
+ compileSdk = 36
+
+ defaultConfig {
+ applicationId = "com.hawhamburg.micr0bu"
+ minSdk = 29
+ targetSdk = 36
+ versionCode = 1
+ versionName = "0.1.0"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+
+ buildFeatures {
+ compose = true
+ }
+}
+
+dependencies {
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.lifecycle.viewmodel.compose)
+ implementation(libs.androidx.activity.compose)
+
+ val composeBom = platform(libs.compose.bom)
+ implementation(composeBom)
+ implementation(libs.compose.ui)
+ implementation(libs.compose.ui.graphics)
+ implementation(libs.compose.ui.tooling.preview)
+ implementation(libs.compose.material3)
+ implementation(libs.compose.material.icons.extended)
+
+ implementation(libs.navigation.compose)
+ implementation(libs.play.services.location)
+ implementation(libs.kotlinx.coroutines.android)
+ implementation(libs.kotlinx.coroutines.play.services)
+
+ implementation(libs.hilt.android)
+ ksp(libs.hilt.compiler)
+ implementation(libs.hilt.navigation.compose)
+ implementation(libs.datastore.preferences)
+ implementation(libs.paho.mqtt)
+
+ implementation(libs.room.runtime)
+ implementation(libs.room.ktx)
+ ksp(libs.room.compiler)
+ implementation(libs.appcompat)
+ implementation(libs.osmdroid)
+ implementation(libs.androidx.core.splashscreen)
+
+ debugImplementation(libs.compose.ui.tooling)
+
+ // ── Unit tests (JVM, no emulator required) ────────────────────────────────
+ testImplementation(libs.junit)
+ testImplementation(libs.kotlinx.coroutines.test)
+}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 0000000..fb164d6
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1 @@
+# Add project specific ProGuard rules here.
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..19837d2
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png
new file mode 100644
index 0000000..c5e359e
Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ
diff --git a/app/src/main/java/com/hawhamburg/micr0bu/MainActivity.kt b/app/src/main/java/com/hawhamburg/micr0bu/MainActivity.kt
new file mode 100644
index 0000000..5508c2b
--- /dev/null
+++ b/app/src/main/java/com/hawhamburg/micr0bu/MainActivity.kt
@@ -0,0 +1,275 @@
+package com.hawhamburg.micr0bu
+
+import android.Manifest
+import android.os.Build
+import android.os.Bundle
+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
+import androidx.appcompat.app.AppCompatActivity
+import androidx.activity.SystemBarStyle
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.activity.viewModels
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Scaffold
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalView
+import androidx.core.view.WindowCompat
+import androidx.navigation.NavType
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import androidx.navigation.navArgument
+import com.hawhamburg.micr0bu.ui.components.StatusTopBar
+import com.hawhamburg.micr0bu.ui.navigation.BottomNavBar
+import com.hawhamburg.micr0bu.ui.navigation.Screen
+import com.hawhamburg.micr0bu.ui.screens.ConnectionSetupScreen
+import com.hawhamburg.micr0bu.ui.screens.DashboardScreen
+import com.hawhamburg.micr0bu.ui.screens.MqttTopicViewerScreen
+import com.hawhamburg.micr0bu.ui.screens.RecordingScreen
+import com.hawhamburg.micr0bu.ui.screens.SensorScreen
+import com.hawhamburg.micr0bu.ui.screens.SessionLogScreen
+import com.hawhamburg.micr0bu.ui.screens.MapScreen
+import com.hawhamburg.micr0bu.ui.screens.TripHistoryScreen
+import com.hawhamburg.micr0bu.ui.screens.TripReviewScreen
+import com.hawhamburg.micr0bu.ui.screens.AboutSettingsScreen
+import com.hawhamburg.micr0bu.ui.screens.AppearanceSettingsScreen
+import com.hawhamburg.micr0bu.ui.screens.ConnectionSettingsScreen
+import com.hawhamburg.micr0bu.ui.screens.DeveloperSettingsScreen
+import com.hawhamburg.micr0bu.ui.screens.LanguageSettingsScreen
+import com.hawhamburg.micr0bu.ui.screens.MqttSettingsScreen
+import com.hawhamburg.micr0bu.ui.screens.SensorsSettingsScreen
+import com.hawhamburg.micr0bu.ui.screens.SettingsScreen
+import com.hawhamburg.micr0bu.ui.screens.UnitsSettingsScreen
+import com.hawhamburg.micr0bu.ui.theme.MicrOBUTheme
+import com.hawhamburg.micr0bu.viewmodel.MqttViewModel
+import com.hawhamburg.micr0bu.viewmodel.SensorViewModel
+import com.hawhamburg.micr0bu.viewmodel.TripRecordingViewModel
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class MainActivity : AppCompatActivity() {
+
+ private val viewModel: SensorViewModel by viewModels()
+ private val mqttViewModel: MqttViewModel by viewModels()
+ private val tripViewModel: TripRecordingViewModel by viewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ installSplashScreen()
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge(
+ statusBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT),
+ navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT),
+ )
+
+ setContent {
+ val state by viewModel.state.collectAsState()
+ val mqttPrefs by mqttViewModel.mqttPrefs.collectAsState()
+ val mqttConnectionState by mqttViewModel.connectionState.collectAsState()
+ val activeTransport by mqttViewModel.activeTransport.collectAsState()
+ val usbConnected by mqttViewModel.usbConnected.collectAsState()
+ val obuStationTypeWarning by mqttViewModel.obuStationTypeWarning.collectAsState()
+ val tripServiceState by tripViewModel.serviceState.collectAsState()
+
+ MicrOBUTheme(darkTheme = state.darkTheme) {
+ val view = LocalView.current
+ SideEffect {
+ WindowCompat.getInsetsController(window, view).apply {
+ isAppearanceLightStatusBars = !state.darkTheme
+ isAppearanceLightNavigationBars = !state.darkTheme
+ }
+ }
+ val navController = rememberNavController()
+
+ val locationLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestMultiplePermissions()
+ ) { permissions ->
+ val granted = permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
+ permissions[Manifest.permission.ACCESS_COARSE_LOCATION] == true
+ if (granted) viewModel.onLocationPermissionGranted()
+ }
+
+ // Request POST_NOTIFICATIONS on Android 13+
+ val notificationLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) { /* ignored — notification permission is best-effort */ }
+
+ LaunchedEffect(Unit) {
+ viewModel.startImuStreams()
+ locationLauncher.launch(
+ arrayOf(
+ Manifest.permission.ACCESS_FINE_LOCATION,
+ Manifest.permission.ACCESS_COARSE_LOCATION,
+ )
+ )
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ notificationLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
+ }
+ }
+
+ Scaffold(
+ topBar = { StatusTopBar(state, mqttConnectionState) },
+ bottomBar = { BottomNavBar(navController) },
+ ) { innerPadding ->
+ NavHost(
+ navController = navController,
+ startDestination = Screen.Dashboard.route,
+ modifier = Modifier.padding(innerPadding),
+ ) {
+ composable(Screen.Dashboard.route) {
+ DashboardScreen(
+ state = state,
+ mqttConnectionState = mqttConnectionState,
+ activeTransport = activeTransport,
+ usbCableConnected = usbConnected,
+ obuStationTypeWarning = obuStationTypeWarning,
+ onNavigateToConnection = { navController.navigate(Screen.Connection.route) },
+ onNavigateToSensors = {
+ navController.navigate(Screen.Sensors.route) {
+ popUpTo(Screen.Dashboard.route) { saveState = true }
+ launchSingleTop = true
+ restoreState = true
+ }
+ },
+ onNavigateToMap = { navController.navigate(Screen.Map.route) },
+ )
+ }
+ composable(Screen.Sensors.route) {
+ SensorScreen(state = state)
+ }
+ composable(Screen.Record.route) {
+ RecordingScreen(
+ state = state,
+ mqttConnectionState = mqttConnectionState,
+ tripServiceState = tripServiceState,
+ onToggleRecording = {
+ // Start / stop both CSV recording and the event detection service
+ viewModel.toggleRecording()
+ tripViewModel.toggleRecording()
+ },
+ onOpenSessionLog = {
+ navController.navigate(Screen.Log.route)
+ },
+ )
+ }
+ composable(Screen.Log.route) {
+ SessionLogScreen(
+ state = state,
+ onDeleteSession = viewModel::deleteSession,
+ )
+ }
+
+ // ── Phase A: Trip History ─────────────────────────────
+ composable(Screen.TripHistory.route) {
+ TripHistoryScreen(
+ viewModel = tripViewModel,
+ onOpenTrip = { tripId ->
+ navController.navigate(Screen.TripReview.route(tripId))
+ },
+ )
+ }
+ composable(
+ route = Screen.TripReview.route,
+ arguments = listOf(
+ navArgument("tripId") { type = NavType.LongType }
+ ),
+ ) { backStackEntry ->
+ val tripId = backStackEntry.arguments
+ ?.getLong("tripId", -1L) ?: -1L
+ if (tripId < 0L) return@composable
+
+ val trips by tripViewModel.trips.collectAsState(initial = emptyList())
+ val trip = trips.firstOrNull { it.id == tripId }
+
+ if (trip != null) {
+ TripReviewScreen(
+ trip = trip,
+ viewModel = tripViewModel,
+ )
+ }
+ }
+
+ composable(Screen.MqttViewer.route) {
+ MqttTopicViewerScreen(viewModel = mqttViewModel)
+ }
+ composable(Screen.Settings.route) {
+ SettingsScreen(
+ onOpenLanguage = { navController.navigate(Screen.SettingsLanguage.route) },
+ onOpenAppearance = { navController.navigate(Screen.SettingsAppearance.route) },
+ onOpenSensors = { navController.navigate(Screen.SettingsSensors.route) },
+ onOpenUnits = { navController.navigate(Screen.SettingsUnits.route) },
+ onOpenConnection = { navController.navigate(Screen.SettingsConnection.route) },
+ onOpenMqtt = { navController.navigate(Screen.SettingsMqtt.route) },
+ onOpenDeveloper = { navController.navigate(Screen.SettingsDeveloper.route) },
+ onOpenAbout = { navController.navigate(Screen.SettingsAbout.route) },
+ )
+ }
+ composable(Screen.SettingsConnection.route) {
+ ConnectionSettingsScreen(
+ mqttPrefs = mqttPrefs,
+ onMqttPrefsChange = mqttViewModel::updatePrefs,
+ onBack = { navController.popBackStack() },
+ )
+ }
+ composable(Screen.SettingsLanguage.route) {
+ LanguageSettingsScreen(onBack = { navController.popBackStack() })
+ }
+ composable(Screen.SettingsAppearance.route) {
+ AppearanceSettingsScreen(
+ darkTheme = state.darkTheme,
+ onDarkTheme = viewModel::setDarkTheme,
+ onBack = { navController.popBackStack() },
+ )
+ }
+ composable(Screen.SettingsSensors.route) {
+ SensorsSettingsScreen(
+ state = state,
+ onAccelEnabled = viewModel::setAccelEnabled,
+ onGyroEnabled = viewModel::setGyroEnabled,
+ onMagnetEnabled = viewModel::setMagnetEnabled,
+ onBarometerEnabled = viewModel::setBarometerEnabled,
+ onGnssEnabled = viewModel::setGnssEnabled,
+ onBack = { navController.popBackStack() },
+ )
+ }
+ composable(Screen.SettingsUnits.route) {
+ UnitsSettingsScreen(
+ useMetric = state.useMetric,
+ onUseMetric = viewModel::setUseMetric,
+ onBack = { navController.popBackStack() },
+ )
+ }
+ composable(Screen.SettingsMqtt.route) {
+ MqttSettingsScreen(
+ mqttPrefs = mqttPrefs,
+ onMqttPrefsChange = mqttViewModel::updatePrefs,
+ onBack = { navController.popBackStack() },
+ )
+ }
+ composable(Screen.SettingsDeveloper.route) {
+ DeveloperSettingsScreen(
+ state = state,
+ onDeveloperMode = viewModel::setDeveloperMode,
+ onBack = { navController.popBackStack() },
+ )
+ }
+ composable(Screen.SettingsAbout.route) {
+ AboutSettingsScreen(onBack = { navController.popBackStack() })
+ }
+ composable(Screen.Connection.route) {
+ ConnectionSetupScreen()
+ }
+ composable(Screen.Map.route) {
+ MapScreen(gnss = state.gnss)
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/hawhamburg/micr0bu/MicrOBUApplication.kt b/app/src/main/java/com/hawhamburg/micr0bu/MicrOBUApplication.kt
new file mode 100644
index 0000000..b1b1d8c
--- /dev/null
+++ b/app/src/main/java/com/hawhamburg/micr0bu/MicrOBUApplication.kt
@@ -0,0 +1,30 @@
+package com.hawhamburg.micr0bu
+
+import android.app.Application
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import dagger.hilt.android.HiltAndroidApp
+
+@HiltAndroidApp
+class MicrOBUApplication : Application() {
+
+ override fun onCreate() {
+ super.onCreate()
+ createNotificationChannels()
+ }
+
+ private fun createNotificationChannels() {
+ val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
+
+ // Phase A: trip recording foreground service channel
+ nm.createNotificationChannel(
+ NotificationChannel(
+ "trip_recording",
+ "Trip Recording",
+ NotificationManager.IMPORTANCE_LOW,
+ ).apply {
+ description = "Shown while a bike trip is being recorded"
+ }
+ )
+ }
+}
diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/CsvExporter.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/CsvExporter.kt
new file mode 100644
index 0000000..dab3130
--- /dev/null
+++ b/app/src/main/java/com/hawhamburg/micr0bu/data/CsvExporter.kt
@@ -0,0 +1,71 @@
+package com.hawhamburg.micr0bu.data
+
+import android.content.Context
+import android.content.Intent
+import androidx.core.content.FileProvider
+import com.hawhamburg.micr0bu.viewmodel.RecordingSession
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+private val iso = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
+private val human = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
+
+fun sessionFileName(session: RecordingSession): String =
+ "micr0bu_session_${human.format(Date(session.startTime))
+ .replace(" ", "_").replace(":", "-")}.csv"
+
+/**
+ * Returns the full CSV content for a session.
+ * If stream-to-file data exists (v0.2.0+ recording) the real per-sample rows are returned.
+ * Falls back to a metadata-only summary for older sessions.
+ */
+fun readSessionCsvContent(context: Context, session: RecordingSession): String {
+ val dataFile = File(File(context.filesDir, "sessions"), "${session.id}.csv")
+ return if (dataFile.exists()) dataFile.readText() else buildSessionCsv(session)
+}
+
+fun shareSessionCsv(context: Context, session: RecordingSession) {
+ val fileName = sessionFileName(session)
+ val content = readSessionCsvContent(context, session)
+
+ // Write to cache so FileProvider can serve it via ACTION_SEND
+ val file = File(context.cacheDir, fileName).also { it.writeText(content) }
+
+ val uri = FileProvider.getUriForFile(
+ context,
+ "${context.packageName}.fileprovider",
+ file,
+ )
+
+ val intent = Intent(Intent.ACTION_SEND).apply {
+ type = "text/csv"
+ putExtra(Intent.EXTRA_STREAM, uri)
+ putExtra(Intent.EXTRA_SUBJECT, "MicrOBU Session Export — $fileName")
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ context.startActivity(Intent.createChooser(intent, "Export session"))
+}
+
+/**
+ * Metadata-only CSV fallback — used for sessions recorded before stream-to-file was implemented,
+ * and by [readSessionCsvContent] when no data file is present on disk.
+ */
+fun buildSessionCsv(s: RecordingSession): String = buildString {
+ appendLine("# MicrOBU Session Export")
+ appendLine("# Generated by MicrOBU v0.2.0 — HAW Hamburg / Project MicrOBU")
+ appendLine("# Session ID,${s.id}")
+ appendLine("# Start,${iso.format(Date(s.startTime))}")
+ appendLine("# End,${iso.format(Date(s.endTime))}")
+ appendLine("# Duration (s),${s.durationSeconds}")
+ appendLine("# Samples captured,${s.sampleCount}")
+ appendLine("# NOTE: This session was recorded before stream-to-file logging was enabled.")
+ appendLine()
+ appendLine("field,value,unit,timestamp_iso")
+ appendLine("session_id,${s.id},,${iso.format(Date(s.startTime))}")
+ appendLine("start_time,${s.startTime},ms_epoch,${iso.format(Date(s.startTime))}")
+ appendLine("end_time,${s.endTime},ms_epoch,${iso.format(Date(s.endTime))}")
+ appendLine("duration,${s.durationSeconds},s,${iso.format(Date(s.endTime))}")
+ appendLine("sample_count,${s.sampleCount},samples,${iso.format(Date(s.endTime))}")
+}
diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/SensorRepository.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/SensorRepository.kt
new file mode 100644
index 0000000..9aa67a6
--- /dev/null
+++ b/app/src/main/java/com/hawhamburg/micr0bu/data/SensorRepository.kt
@@ -0,0 +1,104 @@
+package com.hawhamburg.micr0bu.data
+
+import android.content.Context
+import android.hardware.Sensor
+import android.hardware.SensorEvent
+import android.hardware.SensorEventListener
+import android.hardware.SensorManager
+import android.location.Location
+import com.google.android.gms.location.LocationCallback
+import com.google.android.gms.location.LocationRequest
+import com.google.android.gms.location.LocationResult
+import com.google.android.gms.location.LocationServices
+import com.google.android.gms.location.Priority
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+
+data class ImuReading(
+ val x: Float,
+ val y: Float,
+ val z: Float,
+ val timestamp: Long = System.currentTimeMillis()
+)
+
+data class GnssReading(
+ val latitude: Double,
+ val longitude: Double,
+ val altitude: Double,
+ val speedMs: Float,
+ val bearingDeg: Float,
+ val accuracyM: Float,
+ val timestamp: Long = System.currentTimeMillis()
+)
+
+class SensorRepository(private val context: Context) {
+
+ private val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
+ private val fusedLocation = LocationServices.getFusedLocationProviderClient(context)
+
+ fun accelerometerFlow(): Flow = sensorFlow(Sensor.TYPE_ACCELEROMETER)
+
+ fun gyroscopeFlow(): Flow = sensorFlow(Sensor.TYPE_GYROSCOPE)
+
+ fun magnetometerFlow(): Flow = sensorFlow(Sensor.TYPE_MAGNETIC_FIELD)
+
+ fun barometerFlow(): Flow = callbackFlow {
+ val sensor = sensorManager.getDefaultSensor(Sensor.TYPE_PRESSURE)
+ if (sensor == null) {
+ close()
+ return@callbackFlow
+ }
+ val listener = object : SensorEventListener {
+ override fun onSensorChanged(event: SensorEvent) {
+ trySend(event.values[0])
+ }
+ override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {}
+ }
+ sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_NORMAL)
+ awaitClose { sensorManager.unregisterListener(listener) }
+ }
+
+ @Suppress("MissingPermission")
+ fun gnssFlow(): Flow = callbackFlow {
+ val request = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 1000L)
+ .setMinUpdateIntervalMillis(500L)
+ .build()
+ val callback = object : LocationCallback() {
+ override fun onLocationResult(result: LocationResult) {
+ result.lastLocation?.let { loc ->
+ trySend(loc.toReading())
+ }
+ }
+ }
+ fusedLocation.requestLocationUpdates(request, callback, context.mainLooper)
+ awaitClose { fusedLocation.removeLocationUpdates(callback) }
+ }
+
+ fun hasSensor(type: Int): Boolean = sensorManager.getDefaultSensor(type) != null
+
+ private fun sensorFlow(type: Int): Flow = callbackFlow {
+ val sensor = sensorManager.getDefaultSensor(type)
+ if (sensor == null) {
+ close()
+ return@callbackFlow
+ }
+ val listener = object : SensorEventListener {
+ override fun onSensorChanged(event: SensorEvent) {
+ trySend(ImuReading(event.values[0], event.values[1], event.values[2]))
+ }
+ override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {}
+ }
+ sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_UI)
+ awaitClose { sensorManager.unregisterListener(listener) }
+ }
+}
+
+private fun Location.toReading() = GnssReading(
+ latitude = latitude,
+ longitude = longitude,
+ altitude = altitude,
+ speedMs = speed,
+ bearingDeg = bearing,
+ accuracyM = accuracy
+)
diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/TripRepository.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/TripRepository.kt
new file mode 100644
index 0000000..c4106bf
--- /dev/null
+++ b/app/src/main/java/com/hawhamburg/micr0bu/data/TripRepository.kt
@@ -0,0 +1,93 @@
+package com.hawhamburg.micr0bu.data
+
+import com.hawhamburg.micr0bu.data.db.AppDatabase
+import com.hawhamburg.micr0bu.data.db.DetectedEventEntity
+import com.hawhamburg.micr0bu.data.db.RecordedTripEntity
+import com.hawhamburg.micr0bu.domain.detection.DetectedEvent
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * Repository that abstracts Room access for trips and detected events.
+ *
+ * All suspend functions are safe to call from a coroutine running on any
+ * dispatcher; Room executes the actual SQL on its own I/O thread pool.
+ */
+class TripRepository(db: AppDatabase) {
+
+ private val dao = db.tripDao()
+
+ // ── Trips ─────────────────────────────────────────────────────────────────
+
+ /**
+ * Inserts a new trip row and returns the auto-generated [RecordedTripEntity.id].
+ * Call this at the start of a recording session.
+ */
+ suspend fun insertTrip(
+ startTime: Long,
+ endTime: Long = startTime,
+ distanceMetres: Float = 0f,
+ eventCount: Int = 0,
+ gpsTrackJson: String = "[]",
+ ): Long = dao.insertTrip(
+ RecordedTripEntity(
+ startTime = startTime,
+ endTime = endTime,
+ distanceMetres = distanceMetres,
+ eventCount = eventCount,
+ gpsTrackJson = gpsTrackJson,
+ )
+ )
+
+ /**
+ * Finalises a trip row with duration, distance, event count, and the GPS
+ * polyline. Call this when the user stops recording.
+ */
+ suspend fun finaliseTrip(
+ tripId: Long,
+ endTime: Long,
+ distanceMetres: Float,
+ eventCount: Int,
+ gpsTrackJson: String,
+ ) {
+ val existing = dao.getTripById(tripId) ?: return
+ dao.updateTrip(
+ existing.copy(
+ endTime = endTime,
+ distanceMetres = distanceMetres,
+ eventCount = eventCount,
+ gpsTrackJson = gpsTrackJson,
+ )
+ )
+ }
+
+ /** Emits all trips ordered by startTime DESC, updating whenever the DB changes. */
+ fun getAllTrips(): Flow> = dao.getAllTrips()
+
+ suspend fun deleteTrip(tripId: Long) = dao.deleteTripById(tripId)
+
+ // ── Events ────────────────────────────────────────────────────────────────
+
+ /**
+ * Persists a domain [DetectedEvent] for the given [tripId].
+ * Converts the domain model to the Room entity.
+ */
+ suspend fun insertEvent(tripId: Long, event: DetectedEvent) =
+ dao.insertEvent(
+ DetectedEventEntity(
+ tripId = tripId,
+ timestamp = event.timestamp,
+ type = event.type.name,
+ confidence = event.confidence.name,
+ latitude = event.latitude,
+ longitude = event.longitude,
+ speedMps = event.speedMps.toFloat(),
+ peakAccelMagnitude = event.peakAccelMagnitude.toFloat(),
+ peakGyroMagnitude = event.peakGyroMagnitude.toFloat(),
+ durationMs = event.durationMs,
+ )
+ )
+
+ /** Emits events for [tripId] ordered by timestamp, updating whenever the DB changes. */
+ fun getEventsForTrip(tripId: Long): Flow> =
+ dao.getEventsForTrip(tripId)
+}
diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/db/AppDatabase.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/db/AppDatabase.kt
new file mode 100644
index 0000000..69c9608
--- /dev/null
+++ b/app/src/main/java/com/hawhamburg/micr0bu/data/db/AppDatabase.kt
@@ -0,0 +1,87 @@
+package com.hawhamburg.micr0bu.data.db
+
+import android.content.Context
+import androidx.room.Database
+import androidx.room.Room
+import androidx.room.RoomDatabase
+import androidx.room.migration.Migration
+import androidx.sqlite.db.SupportSQLiteDatabase
+
+@Database(
+ entities = [
+ SessionEntity::class,
+ RecordedTripEntity::class,
+ DetectedEventEntity::class,
+ ],
+ version = 2,
+ exportSchema = false,
+)
+abstract class AppDatabase : RoomDatabase() {
+
+ abstract fun sessionDao(): SessionDao
+ abstract fun tripDao(): TripDao
+
+ companion object {
+
+ @Volatile
+ private var INSTANCE: AppDatabase? = null
+
+ fun getInstance(context: Context): AppDatabase =
+ INSTANCE ?: synchronized(this) {
+ Room.databaseBuilder(
+ context.applicationContext,
+ AppDatabase::class.java,
+ "micr0bu.db",
+ )
+ .addMigrations(MIGRATION_1_2)
+ .build()
+ .also { INSTANCE = it }
+ }
+
+ // ── Migrations ────────────────────────────────────────────────────────
+
+ /**
+ * Adds the `trips` and `detected_events` tables introduced in Phase A.
+ * The existing `sessions` table is left untouched.
+ */
+ private val MIGRATION_1_2 = object : Migration(1, 2) {
+ override fun migrate(database: SupportSQLiteDatabase) {
+ database.execSQL(
+ """
+ CREATE TABLE IF NOT EXISTS `trips` (
+ `id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ `startTime` INTEGER NOT NULL,
+ `endTime` INTEGER NOT NULL,
+ `distanceMetres` REAL NOT NULL,
+ `eventCount` INTEGER NOT NULL,
+ `gpsTrackJson` TEXT NOT NULL
+ )
+ """.trimIndent()
+ )
+ database.execSQL(
+ """
+ CREATE TABLE IF NOT EXISTS `detected_events` (
+ `id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ `tripId` INTEGER NOT NULL,
+ `timestamp` INTEGER NOT NULL,
+ `type` TEXT NOT NULL,
+ `confidence` TEXT NOT NULL,
+ `latitude` REAL NOT NULL,
+ `longitude` REAL NOT NULL,
+ `speedMps` REAL NOT NULL,
+ `peakAccelMagnitude` REAL NOT NULL,
+ `peakGyroMagnitude` REAL NOT NULL,
+ `durationMs` INTEGER NOT NULL,
+ FOREIGN KEY(`tripId`) REFERENCES `trips`(`id`)
+ ON UPDATE NO ACTION ON DELETE CASCADE
+ )
+ """.trimIndent()
+ )
+ database.execSQL(
+ "CREATE INDEX IF NOT EXISTS `index_detected_events_tripId` " +
+ "ON `detected_events` (`tripId`)"
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/db/DetectedEventEntity.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/db/DetectedEventEntity.kt
new file mode 100644
index 0000000..49bbfcc
--- /dev/null
+++ b/app/src/main/java/com/hawhamburg/micr0bu/data/db/DetectedEventEntity.kt
@@ -0,0 +1,50 @@
+package com.hawhamburg.micr0bu.data.db
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.PrimaryKey
+
+/**
+ * One detected cyclist event (braking / turning / stopping) linked to a
+ * [RecordedTripEntity] via the [tripId] foreign key.
+ *
+ * [type] and [confidence] are stored as the enum name strings so that the
+ * database remains human-readable.
+ */
+@Entity(
+ tableName = "detected_events",
+ foreignKeys = [
+ ForeignKey(
+ entity = RecordedTripEntity::class,
+ parentColumns = ["id"],
+ childColumns = ["tripId"],
+ onDelete = ForeignKey.CASCADE,
+ )
+ ],
+)
+data class DetectedEventEntity(
+ @PrimaryKey(autoGenerate = true)
+ val id: Long = 0,
+
+ @ColumnInfo(index = true)
+ val tripId: Long,
+
+ /** Wall-clock epoch ms of the first qualifying sensor frame. */
+ val timestamp: Long,
+
+ /** EventType.name — one of BRAKING, TURNING, STOPPING. */
+ val type: String,
+
+ /** Confidence.name — one of HIGH, MEDIUM, LOW. */
+ val confidence: String,
+
+ val latitude: Double,
+ val longitude: Double,
+ val speedMps: Float,
+ val peakAccelMagnitude: Float,
+ val peakGyroMagnitude: Float,
+
+ /** Duration from first qualifying frame to emission (ms). */
+ val durationMs: Long,
+)
diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/db/RecordedTripEntity.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/db/RecordedTripEntity.kt
new file mode 100644
index 0000000..125cc57
--- /dev/null
+++ b/app/src/main/java/com/hawhamburg/micr0bu/data/db/RecordedTripEntity.kt
@@ -0,0 +1,21 @@
+package com.hawhamburg.micr0bu.data.db
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+/**
+ * One recorded bike trip stored in the local Room database.
+ *
+ * [gpsTrackJson] is a compact JSON array of GPS points:
+ * `[{"lat":53.5,"lon":10.0,"ts":1717000000000}, ...]`
+ */
+@Entity(tableName = "trips")
+data class RecordedTripEntity(
+ @PrimaryKey(autoGenerate = true)
+ val id: Long = 0,
+ val startTime: Long,
+ val endTime: Long,
+ val distanceMetres: Float,
+ val eventCount: Int,
+ val gpsTrackJson: String,
+)
diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/db/SessionDao.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/db/SessionDao.kt
new file mode 100644
index 0000000..4d8b923
--- /dev/null
+++ b/app/src/main/java/com/hawhamburg/micr0bu/data/db/SessionDao.kt
@@ -0,0 +1,20 @@
+package com.hawhamburg.micr0bu.data.db
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface SessionDao {
+ @Query("SELECT * FROM sessions ORDER BY startTime DESC")
+ fun getAllSessions(): Flow>
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertSession(session: SessionEntity)
+
+ @Query("DELETE FROM sessions WHERE id = :id")
+ suspend fun deleteById(id: String)
+}
diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/db/SessionEntity.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/db/SessionEntity.kt
new file mode 100644
index 0000000..8ab7df5
--- /dev/null
+++ b/app/src/main/java/com/hawhamburg/micr0bu/data/db/SessionEntity.kt
@@ -0,0 +1,13 @@
+package com.hawhamburg.micr0bu.data.db
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+@Entity(tableName = "sessions")
+data class SessionEntity(
+ @PrimaryKey val id: String,
+ val startTime: Long,
+ val endTime: Long,
+ val durationSeconds: Long,
+ val sampleCount: Int,
+)
diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/db/TripDao.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/db/TripDao.kt
new file mode 100644
index 0000000..a6f7365
--- /dev/null
+++ b/app/src/main/java/com/hawhamburg/micr0bu/data/db/TripDao.kt
@@ -0,0 +1,40 @@
+package com.hawhamburg.micr0bu.data.db
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Update
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface TripDao {
+
+ // ── Trips ─────────────────────────────────────────────────────────────────
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertTrip(trip: RecordedTripEntity): Long
+
+ @Update
+ suspend fun updateTrip(trip: RecordedTripEntity)
+
+ @Query("SELECT * FROM trips ORDER BY startTime DESC")
+ fun getAllTrips(): Flow>
+
+ @Query("SELECT * FROM trips WHERE id = :id")
+ suspend fun getTripById(id: Long): RecordedTripEntity?
+
+ @Query("DELETE FROM trips WHERE id = :id")
+ suspend fun deleteTripById(id: Long)
+
+ // ── Events ────────────────────────────────────────────────────────────────
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertEvent(event: DetectedEventEntity)
+
+ @Query("SELECT * FROM detected_events WHERE tripId = :tripId ORDER BY timestamp ASC")
+ fun getEventsForTrip(tripId: Long): Flow>
+
+ @Query("SELECT COUNT(*) FROM detected_events WHERE tripId = :tripId")
+ suspend fun getEventCountForTrip(tripId: Long): Int
+}
diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/CamBuilder.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/CamBuilder.kt
new file mode 100644
index 0000000..f06f98c
--- /dev/null
+++ b/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/CamBuilder.kt
@@ -0,0 +1,57 @@
+package com.hawhamburg.micr0bu.data.mqtt
+
+import com.hawhamburg.micr0bu.data.GnssReading
+import com.hawhamburg.micr0bu.data.ImuReading
+import org.json.JSONArray
+import org.json.JSONObject
+import kotlin.math.PI
+
+/**
+ * MQTT topic used to inject a CAM message into the OBU.
+ *
+ * TODO: Confirm this topic with consider it GmbH / HAW Hamburg before production use.
+ * The MQTT API v6 documentation only covers *received/processed* output topics
+ * (v2x-uca/output/json/…). No inbound CAM injection topic is currently documented.
+ * "v2x/tx/cam" is a reasonable candidate based on the naming convention; verify with
+ * the OBU firmware team before using in production.
+ */
+const val CAM_TX_TOPIC = "v2x/tx/cam"
+
+/**
+ * Builds a CAM JSON payload for test transmission.
+ *
+ * The schema mirrors the processed RX format on [v2x-uca/output/json/cam] per MQTT API v6 §2.2.3,
+ * adapted for outbound injection with phone sensor data.
+ */
+object CamBuilder {
+
+ fun build(
+ gnss: GnssReading,
+ gyro: ImuReading?,
+ stationId: Long,
+ ): String {
+ // Gyroscope Z axis (rad/s) → yaw rate (deg/s)
+ val yawRateDps = gyro?.z?.let { it.toDouble() * (180.0 / PI) } ?: 0.0
+
+ return JSONObject().apply {
+ put("type", "v2x-cam")
+ put("stationId", stationId)
+ put("stationType", "cyclist") // ETSI stationType = 2 (cyclist) mapped to string label
+ put("position", JSONObject().apply {
+ put("type", "Point")
+ // GeoJSON coordinate order: [longitude, latitude, altitude]
+ put("coordinates", JSONArray().apply {
+ put(gnss.longitude)
+ put(gnss.latitude)
+ put(gnss.altitude)
+ })
+ })
+ put("heading_deg", gnss.bearingDeg.toDouble())
+ put("speed_mps", gnss.speedMs.toDouble())
+ put("yawRate_dps", yawRateDps)
+ put("vehicleRole", "default")
+ put("pathHistory", JSONArray())
+ put("positionError_stdDev", gnss.accuracyM.toDouble())
+ }.toString()
+ }
+}
diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/MqttConnectionState.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/MqttConnectionState.kt
new file mode 100644
index 0000000..4ea0713
--- /dev/null
+++ b/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/MqttConnectionState.kt
@@ -0,0 +1,8 @@
+package com.hawhamburg.micr0bu.data.mqtt
+
+enum class MqttConnectionState {
+ DISCONNECTED,
+ CONNECTING,
+ CONNECTED,
+ ERROR,
+}
diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/MqttMessage.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/MqttMessage.kt
new file mode 100644
index 0000000..39af617
--- /dev/null
+++ b/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/MqttMessage.kt
@@ -0,0 +1,10 @@
+package com.hawhamburg.micr0bu.data.mqtt
+
+enum class MessageDirection { RX, TX }
+
+data class MqttMessage(
+ val topic: String,
+ val payload: String,
+ val timestamp: Long,
+ val direction: MessageDirection = MessageDirection.RX,
+)
diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/MqttPreferences.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/MqttPreferences.kt
new file mode 100644
index 0000000..bfcb769
--- /dev/null
+++ b/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/MqttPreferences.kt
@@ -0,0 +1,81 @@
+package com.hawhamburg.micr0bu.data.mqtt
+
+import android.content.Context
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.intPreferencesKey
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import javax.inject.Inject
+import javax.inject.Singleton
+
+private val Context.mqttDataStore by preferencesDataStore(name = "mqtt_prefs")
+
+data class MqttPrefs(
+ // ── Wi-Fi / direct MQTT broker ──────────────────────────────────────────
+ val brokerIp: String = DEFAULT_IP,
+ val brokerPort: Int = DEFAULT_PORT,
+ val username: String = "",
+ val password: String = "",
+
+ // ── USB-C transport ──────────────────────────────────────────────────────
+ /** When true, the detected gateway IP on the 192.168.42.x USB interface is used as broker IP. */
+ val usbAutoDetect: Boolean = true,
+ /** Manual IP override — used when [usbAutoDetect] is false or detection fails. */
+ val usbIpOverride: String = DEFAULT_USB_IP,
+ /**
+ * Active transport: "USB_C" (default) or "WIFI".
+ * Stored as a string so we can add more transports later without a migration.
+ */
+ val activeTransport: String = TRANSPORT_USB_C,
+) {
+ companion object {
+ const val DEFAULT_IP = "192.168.3.202"
+ const val DEFAULT_PORT = 1883
+ const val DEFAULT_USB_IP = "192.168.42.1"
+ const val TRANSPORT_USB_C = "USB_C"
+ const val TRANSPORT_WIFI = "WIFI"
+ }
+}
+
+@Singleton
+class MqttPreferences @Inject constructor(
+ @ApplicationContext private val context: Context,
+) {
+ private object Keys {
+ val BROKER_IP = stringPreferencesKey("broker_ip")
+ val BROKER_PORT = intPreferencesKey("broker_port")
+ val USERNAME = stringPreferencesKey("username")
+ val PASSWORD = stringPreferencesKey("password")
+ val USB_AUTO_DETECT = booleanPreferencesKey("usb_auto_detect")
+ val USB_IP_OVERRIDE = stringPreferencesKey("usb_ip_override")
+ val ACTIVE_TRANSPORT = stringPreferencesKey("active_transport")
+ }
+
+ val prefsFlow: Flow = context.mqttDataStore.data.map { prefs ->
+ MqttPrefs(
+ brokerIp = prefs[Keys.BROKER_IP] ?: MqttPrefs.DEFAULT_IP,
+ brokerPort = prefs[Keys.BROKER_PORT] ?: MqttPrefs.DEFAULT_PORT,
+ username = prefs[Keys.USERNAME] ?: "",
+ password = prefs[Keys.PASSWORD] ?: "",
+ usbAutoDetect = prefs[Keys.USB_AUTO_DETECT] ?: true,
+ usbIpOverride = prefs[Keys.USB_IP_OVERRIDE] ?: MqttPrefs.DEFAULT_USB_IP,
+ activeTransport = prefs[Keys.ACTIVE_TRANSPORT] ?: MqttPrefs.TRANSPORT_USB_C,
+ )
+ }
+
+ suspend fun update(p: MqttPrefs) {
+ context.mqttDataStore.edit { prefs ->
+ prefs[Keys.BROKER_IP] = p.brokerIp
+ prefs[Keys.BROKER_PORT] = p.brokerPort
+ prefs[Keys.USERNAME] = p.username
+ prefs[Keys.PASSWORD] = p.password
+ prefs[Keys.USB_AUTO_DETECT] = p.usbAutoDetect
+ prefs[Keys.USB_IP_OVERRIDE] = p.usbIpOverride
+ prefs[Keys.ACTIVE_TRANSPORT] = p.activeTransport
+ }
+ }
+}
diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/MqttRepository.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/MqttRepository.kt
new file mode 100644
index 0000000..0a63766
--- /dev/null
+++ b/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/MqttRepository.kt
@@ -0,0 +1,245 @@
+package com.hawhamburg.micr0bu.data.mqtt
+
+import android.content.Context
+import com.hawhamburg.micr0bu.data.transport.TransportType
+import com.hawhamburg.micr0bu.data.transport.UsbNetworkDetector
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import kotlin.coroutines.resumeWithException
+import kotlinx.coroutines.suspendCancellableCoroutine
+import org.eclipse.paho.client.mqttv3.IMqttActionListener
+import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken
+import org.eclipse.paho.client.mqttv3.IMqttToken
+import org.eclipse.paho.client.mqttv3.MqttAsyncClient
+import org.eclipse.paho.client.mqttv3.MqttCallback
+import org.eclipse.paho.client.mqttv3.MqttConnectOptions
+import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence
+import java.util.UUID
+import javax.inject.Inject
+import javax.inject.Singleton
+import org.eclipse.paho.client.mqttv3.MqttMessage as PahoMqttMessage
+
+private val SUBSCRIBED_TOPICS = listOf(
+ "sys/state/heartbeat",
+ "sys/state/cellular",
+ "v2x/rx/obu_gnss",
+ "v2x-uca/output/json/cam",
+ "v2x-uca/output/json/denm",
+ "v2x-uca/output/json/spat",
+ "v2x-uca/output/json/map",
+ "v2x-uca/output/json/cpm",
+)
+
+@Singleton
+class MqttRepository @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val prefs: MqttPreferences,
+ private val usbDetector: UsbNetworkDetector,
+) {
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private val clientId = "micr0bu-android-${UUID.randomUUID()}"
+
+ private val _connectionState = MutableStateFlow(MqttConnectionState.DISCONNECTED)
+ val connectionState: StateFlow = _connectionState.asStateFlow()
+
+ // extraBufferCapacity absorbs bursts (e.g. spat at 2 Hz, cam live) without blocking Paho's thread
+ private val _messages = MutableSharedFlow(
+ replay = 0,
+ extraBufferCapacity = 512,
+ )
+ val messages: SharedFlow = _messages.asSharedFlow()
+
+ /** Active transport derived from persisted prefs. */
+ val activeTransport: StateFlow = prefs.prefsFlow
+ .map { p ->
+ if (p.activeTransport == MqttPrefs.TRANSPORT_USB_C) TransportType.USB_C
+ else TransportType.WIFI
+ }
+ .stateIn(scope, SharingStarted.Eagerly, TransportType.USB_C)
+
+ @Volatile private var activeClient: MqttAsyncClient? = null
+ private var connectJob: Job? = null
+
+ // ── Public API ────────────────────────────────────────────────────────────
+
+ fun connect() {
+ connectJob?.cancel()
+ connectJob = scope.launch { connectWithRetry() }
+ }
+
+ fun disconnect() {
+ connectJob?.cancel()
+ connectJob = null
+ scope.launch {
+ runCatching { activeClient?.disconnectForcibly(0, 200) }
+ runCatching { activeClient?.close() }
+ activeClient = null
+ _connectionState.value = MqttConnectionState.DISCONNECTED
+ }
+ }
+
+ /**
+ * Publish a message to [topic]. No-op when not connected.
+ * Uses QoS 0 (fire-and-forget) by default — suitable for test CAM frames.
+ */
+ fun publish(topic: String, payload: String, qos: Int = 0) {
+ val client = activeClient ?: return
+ if (!client.isConnected) return
+ scope.launch {
+ runCatching {
+ val msg = PahoMqttMessage(payload.toByteArray(Charsets.UTF_8))
+ msg.qos = qos
+ client.publish(topic, msg)
+ }
+ }
+ }
+
+ /**
+ * Publish a retained message to [topic] at QoS 1. No-op when not connected.
+ * The retain flag ensures the OBU's Use Case app receives it on (re)connect.
+ * Required for DENM control messages on v2x-uca/input/denmtrg.
+ */
+ fun publishRetained(topic: String, payload: String, qos: Int = 1) {
+ val client = activeClient ?: return
+ if (!client.isConnected) return
+ scope.launch {
+ runCatching {
+ val msg = PahoMqttMessage(payload.toByteArray(Charsets.UTF_8))
+ msg.qos = qos
+ msg.isRetained = true
+ client.publish(topic, msg)
+ }
+ }
+ }
+
+ // ── Internal ─────────────────────────────────────────────────────────────
+
+ private suspend fun connectWithRetry() {
+ var attempt = 0
+ while (true) {
+ try {
+ _connectionState.value = MqttConnectionState.CONNECTING
+ doConnect()
+ // doConnect suspends until the connection drops; reaching here means it dropped
+ _connectionState.value = MqttConnectionState.ERROR
+ } catch (e: kotlinx.coroutines.CancellationException) {
+ throw e
+ } catch (_: Exception) {
+ _connectionState.value = MqttConnectionState.ERROR
+ }
+ // Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s (capped)
+ val backoffMs = minOf(1_000L * (1L shl minOf(attempt, 4)), 30_000L)
+ attempt++
+ delay(backoffMs)
+ }
+ }
+
+ private suspend fun doConnect() {
+ val currentPrefs = prefs.prefsFlow.first()
+ val isUsbC = currentPrefs.activeTransport == MqttPrefs.TRANSPORT_USB_C
+ val usbNet = usbDetector.usbNetwork.value // non-null only when cable is plugged in
+ val usbActive = isUsbC && usbNet != null // true = USB-C mode AND cable detected
+
+ // Resolve broker IP:
+ // • USB-C + cable present → OBU gateway IP on 192.168.42.x (auto or manual)
+ // • USB-C + no cable → fall back to the Wi-Fi broker IP so dev workflow still works
+ // • Wi-Fi transport → configured broker IP
+ val brokerIp: String = when {
+ usbActive && currentPrefs.usbAutoDetect ->
+ usbDetector.detectedGatewayIp.value ?: currentPrefs.usbIpOverride
+ usbActive ->
+ currentPrefs.usbIpOverride
+ else ->
+ // Wi-Fi transport, or USB-C selected but no cable → use Wi-Fi broker IP
+ currentPrefs.brokerIp
+ }
+
+ val serverUri = "tcp://$brokerIp:${currentPrefs.brokerPort}"
+
+ // Create fresh client every attempt; MemoryPersistence is fine at QoS 0
+ runCatching { activeClient?.disconnectForcibly(0, 200) }
+ runCatching { activeClient?.close() }
+
+ val client = MqttAsyncClient(serverUri, clientId, MemoryPersistence())
+ activeClient = client
+
+ // Signal that connection was lost — set before connect so no window is missed
+ val lostSignal = kotlinx.coroutines.CompletableDeferred()
+
+ client.setCallback(object : MqttCallback {
+ override fun connectionLost(cause: Throwable?) { lostSignal.complete(cause) }
+
+ override fun messageArrived(topic: String, message: PahoMqttMessage) {
+ _messages.tryEmit(
+ MqttMessage(
+ topic = topic,
+ payload = message.payload.toString(Charsets.UTF_8),
+ timestamp = System.currentTimeMillis(),
+ )
+ )
+ }
+
+ override fun deliveryComplete(token: IMqttDeliveryToken) {}
+ })
+
+ val options = MqttConnectOptions().apply {
+ connectionTimeout = 10
+ keepAliveInterval = 30
+ isCleanSession = true
+ isAutomaticReconnect = false // we handle reconnect ourselves
+
+ if (currentPrefs.username.isNotBlank()) {
+ userName = currentPrefs.username
+ password = currentPrefs.password.toCharArray()
+ }
+
+ // Bind socket to the USB tethering interface only when the cable is present.
+ // Without this, traffic would be routed over Wi-Fi even in USB-C mode.
+ if (usbActive) {
+ socketFactory = usbNet!!.socketFactory
+ }
+ }
+
+ // Connect (bridge callback → coroutine)
+ suspendCancellableCoroutine { cont ->
+ client.connect(options, null, object : IMqttActionListener {
+ override fun onSuccess(token: IMqttToken) = cont.resume(Unit) {}
+ override fun onFailure(token: IMqttToken, ex: Throwable) =
+ cont.resumeWithException(ex)
+ })
+ cont.invokeOnCancellation { runCatching { client.disconnectForcibly(0, 200) } }
+ }
+
+ // Subscribe to all topics at QoS 0 (fire-and-forget delivery)
+ val topicsArr = SUBSCRIBED_TOPICS.toTypedArray()
+ val qosArr = IntArray(topicsArr.size) { 0 }
+ suspendCancellableCoroutine { cont ->
+ client.subscribe(topicsArr, qosArr, null, object : IMqttActionListener {
+ override fun onSuccess(token: IMqttToken) = cont.resume(Unit) {}
+ override fun onFailure(token: IMqttToken, ex: Throwable) =
+ cont.resumeWithException(ex)
+ })
+ }
+
+ _connectionState.value = MqttConnectionState.CONNECTED
+
+ // Suspend here until the broker drops the connection
+ val cause = lostSignal.await()
+ throw Exception(cause?.message ?: "Connection lost", cause)
+ }
+}
diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/transport/TransportType.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/transport/TransportType.kt
new file mode 100644
index 0000000..c3a76e2
--- /dev/null
+++ b/app/src/main/java/com/hawhamburg/micr0bu/data/transport/TransportType.kt
@@ -0,0 +1,3 @@
+package com.hawhamburg.micr0bu.data.transport
+
+enum class TransportType { USB_C, WIFI, BLUETOOTH }
diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/transport/UsbNetworkDetector.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/transport/UsbNetworkDetector.kt
new file mode 100644
index 0000000..15e1f02
--- /dev/null
+++ b/app/src/main/java/com/hawhamburg/micr0bu/data/transport/UsbNetworkDetector.kt
@@ -0,0 +1,82 @@
+package com.hawhamburg.micr0bu.data.transport
+
+import android.content.Context
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkRequest
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Watches all Android network interfaces and identifies the OBU USB-C tethering link
+ * by looking for a non-WiFi / non-cellular interface with a 192.168.42.x address.
+ *
+ * The OBU acts as a USB NCM/tethering host:
+ * - Phone address : 192.168.42.129 (DHCP)
+ * - OBU (gateway) : typically 192.168.42.1 or as reported by the default route
+ *
+ * Once detected, [usbNetwork] holds the Android [Network] object whose [Network.socketFactory]
+ * is passed to Paho's [MqttConnectOptions] to bind all MQTT sockets to that interface.
+ */
+@Singleton
+class UsbNetworkDetector @Inject constructor(
+ @ApplicationContext private val context: Context,
+) {
+ private val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+
+ private val _usbNetwork = MutableStateFlow(null)
+ /** Non-null when a 192.168.42.x USB tethering network is active. */
+ val usbNetwork: StateFlow = _usbNetwork.asStateFlow()
+
+ private val _detectedGatewayIp = MutableStateFlow(null)
+ /**
+ * IP of the default gateway on the USB interface — almost always the OBU's IP.
+ * Falls back to .1 if no default route is reported.
+ */
+ val detectedGatewayIp: StateFlow = _detectedGatewayIp.asStateFlow()
+
+ private val callback = object : ConnectivityManager.NetworkCallback() {
+ override fun onAvailable(network: Network) = checkAndSet(network)
+ override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) =
+ checkAndSet(network)
+ override fun onLost(network: Network) {
+ if (_usbNetwork.value == network) {
+ _usbNetwork.value = null
+ _detectedGatewayIp.value = null
+ }
+ }
+ }
+
+ init {
+ // Register for ALL networks — we inspect each one to find the USB tethering link.
+ cm.registerNetworkCallback(NetworkRequest.Builder().build(), callback)
+ }
+
+ private fun checkAndSet(network: Network) {
+ val caps = cm.getNetworkCapabilities(network) ?: return
+ val lp = cm.getLinkProperties(network) ?: return
+
+ // Ignore WiFi and cellular — USB tethering shows up as Ethernet or an untyped transport
+ if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) return
+ if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) return
+
+ // Find a link address in the 192.168.42.x subnet
+ val usbAddr = lp.linkAddresses.firstOrNull { la ->
+ la.address.hostAddress?.startsWith("192.168.42.") == true
+ } ?: return
+
+ _usbNetwork.value = network
+
+ // Prefer the default-route gateway; fall back to subnet.1
+ val gateway = lp.routes
+ .firstOrNull { it.isDefaultRoute && it.gateway != null }
+ ?.gateway?.hostAddress
+ val phoneIp = usbAddr.address.hostAddress ?: return
+ _detectedGatewayIp.value = gateway ?: "${phoneIp.substringBeforeLast('.')}.1"
+ }
+}
diff --git a/app/src/main/java/com/hawhamburg/micr0bu/domain/detection/DetectedEvent.kt b/app/src/main/java/com/hawhamburg/micr0bu/domain/detection/DetectedEvent.kt
new file mode 100644
index 0000000..0032524
--- /dev/null
+++ b/app/src/main/java/com/hawhamburg/micr0bu/domain/detection/DetectedEvent.kt
@@ -0,0 +1,38 @@
+package com.hawhamburg.micr0bu.domain.detection
+
+/** The class of cyclist manoeuvre that was detected. */
+enum class EventType { BRAKING, TURNING, STOPPING }
+
+/** Confidence level assigned to a detected event. */
+enum class Confidence { HIGH, MEDIUM, LOW }
+
+/**
+ * A single cyclist event produced by [EventDetector].
+ *
+ * This is a pure domain model — no Android or Room imports.
+ */
+data class DetectedEvent(
+ val type: EventType,
+ val confidence: Confidence,
+
+ /** Wall-clock milliseconds (epoch) of the first qualifying frame. */
+ val timestamp: Long,
+
+ /** GPS latitude at the event location. */
+ val latitude: Double,
+
+ /** GPS longitude at the event location. */
+ val longitude: Double,
+
+ /** GPS speed at the moment of emission (m/s). */
+ val speedMps: Double,
+
+ /** Peak accelerometer magnitude observed during the event window (m/s²). */
+ val peakAccelMagnitude: Double,
+
+ /** Peak gyroscope magnitude observed during the event window (rad/s). */
+ val peakGyroMagnitude: Double,
+
+ /** Duration from first qualifying frame to emission (ms). */
+ val durationMs: Long,
+)
diff --git a/app/src/main/java/com/hawhamburg/micr0bu/domain/detection/DetectionConfig.kt b/app/src/main/java/com/hawhamburg/micr0bu/domain/detection/DetectionConfig.kt
new file mode 100644
index 0000000..50c8fcd
--- /dev/null
+++ b/app/src/main/java/com/hawhamburg/micr0bu/domain/detection/DetectionConfig.kt
@@ -0,0 +1,58 @@
+package com.hawhamburg.micr0bu.domain.detection
+
+/**
+ * All detection thresholds in one place.
+ *
+ * Pass a custom instance to [EventDetector] to tune behaviour without
+ * recompiling. The defaults match the Phase A specification.
+ */
+data class DetectionConfig(
+
+ // ── Sliding window ────────────────────────────────────────────────────────
+ /** Number of sensor frames in the statistics window. 50 ≈ 1 s at 50 Hz. */
+ val windowSize: Int = 50,
+
+ // ── BRAKING ───────────────────────────────────────────────────────────────
+ /**
+ * Minimum speed drop (m/s) from the reference speed at braking onset for
+ * a frame to qualify as a braking frame.
+ */
+ val brakingSpeedDropThreshold: Double = 0.5,
+
+ /** Minimum accel std-dev (m/s²) required for a frame to count as braking. */
+ val brakingAccelStdDevThreshold: Double = 1.2,
+
+ /** Consecutive braking frames required before an event is emitted. */
+ val brakingSustainedFrames: Int = 15,
+
+ /**
+ * Peak speed-drop rate (m/s per GPS update ≈ m/s²) above which the braking
+ * confidence is upgraded from MEDIUM to HIGH.
+ */
+ val brakingHighConfidenceRate: Double = 1.5,
+
+ // ── TURNING ───────────────────────────────────────────────────────────────
+ /** Minimum gyro mean (rad/s) required for a frame to qualify as turning. */
+ val turningGyroMeanThreshold: Double = 0.4,
+
+ /** Bearing-change rate (°/s) that must be exceeded when speed is above the
+ * minimum threshold for a HIGH-confidence turning confirmation. */
+ val turningBearingChangeThreshold: Double = 10.0,
+
+ /** GPS speed (m/s) above which the bearing-change criterion is enforced. */
+ val turningMinSpeedThreshold: Double = 2.0,
+
+ /** Consecutive turning frames required before an event is emitted. */
+ val turningSustainedFrames: Int = 20,
+
+ // ── STOPPING ─────────────────────────────────────────────────────────────
+ /** GPS speed (m/s) below which a frame is considered a potential stop. */
+ val stoppingSpeedThreshold: Double = 0.5,
+
+ /** Consecutive stop frames required (> this value) before an event is emitted.
+ * At 50 Hz, 100 frames ≈ 2 s. */
+ val stoppingFrames: Int = 100,
+
+ /** Maximum accel std-dev (m/s²) allowed for a frame to count as stationary. */
+ val stoppingAccelStdDevThreshold: Double = 0.15,
+)
diff --git a/app/src/main/java/com/hawhamburg/micr0bu/domain/detection/EventDetector.kt b/app/src/main/java/com/hawhamburg/micr0bu/domain/detection/EventDetector.kt
new file mode 100644
index 0000000..871ffe0
--- /dev/null
+++ b/app/src/main/java/com/hawhamburg/micr0bu/domain/detection/EventDetector.kt
@@ -0,0 +1,247 @@
+package com.hawhamburg.micr0bu.domain.detection
+
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlin.math.abs
+
+/**
+ * Pure-Kotlin event detector for cyclist manoeuvres.
+ *
+ * **No Android imports** — runs on the JVM in unit tests without an emulator.
+ *
+ * Feed one sample at a time via [processSample]. Detected events are emitted
+ * to [events] (a hot [SharedFlow]). Debounce is implemented with
+ * consecutive-frame counters, not timers.
+ *
+ * GPS updates at 1 Hz whilst sensors fire at ~50 Hz. [speedMps] and
+ * [bearingChangeDegPerSec] should be the values from the last known GPS fix;
+ * the detector compares speed against a *reference speed at braking onset*
+ * rather than a per-frame delta, so the condition remains true across the 50
+ * sensor frames that fall between consecutive 1-Hz GPS updates.
+ */
+class EventDetector(private val config: DetectionConfig = DetectionConfig()) {
+
+ // ── Statistics windows ────────────────────────────────────────────────────
+
+ private val accelStats = RunningStats(config.windowSize)
+ private val gyroStats = RunningStats(config.windowSize)
+
+ // ── Events flow ───────────────────────────────────────────────────────────
+
+ private val _events = MutableSharedFlow(extraBufferCapacity = 64)
+ val events: SharedFlow = _events.asSharedFlow()
+
+ // ── Braking state ─────────────────────────────────────────────────────────
+
+ private var brakingFrames = 0
+ private var brakingOnsetSpeed = 0.0 // reference speed when braking started
+ private var brakingStartTime = 0L
+ private var peakBrakingDrop = 0.0 // peak speed drop observed during this window
+ private var peakAccelBraking = 0.0
+
+ // ── Turning state ─────────────────────────────────────────────────────────
+
+ private var turningFrames = 0
+ private var turningStartTime = 0L
+ private var turningBothAgreed = false
+ private var peakGyroTurning = 0.0
+
+ // ── Stopping state ────────────────────────────────────────────────────────
+
+ private var stoppingFrames = 0
+ private var stoppingStartTime = 0L
+
+ // ── Previous-frame tracking ───────────────────────────────────────────────
+
+ private var prevSpeedMps = 0.0
+
+ // ── Last known location ───────────────────────────────────────────────────
+
+ private var lastLat = 0.0
+ private var lastLon = 0.0
+
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Process one sensor frame.
+ *
+ * @param accelMagnitude orientation-independent accelerometer magnitude
+ * (√(x²+y²+z²) − 9.81, i.e. net dynamic acceleration in m/s²).
+ * @param gyroMagnitude orientation-independent gyroscope magnitude (√(x²+y²+z²), rad/s).
+ * @param speedMps GPS speed at the last known fix (m/s).
+ * @param bearingChangeDegPerSec |Δbearing| / Δt from the last two GPS fixes (°/s).
+ * @param latitude GPS latitude at the last known fix.
+ * @param longitude GPS longitude at the last known fix.
+ * @param timestamp Wall-clock epoch ms for this frame (defaults to now).
+ */
+ fun processSample(
+ accelMagnitude: Double,
+ gyroMagnitude: Double,
+ speedMps: Double,
+ bearingChangeDegPerSec: Double,
+ latitude: Double,
+ longitude: Double,
+ timestamp: Long = System.currentTimeMillis(),
+ ) {
+ lastLat = latitude
+ lastLon = longitude
+
+ accelStats.add(accelMagnitude)
+ gyroStats.add(gyroMagnitude)
+
+ detectBraking(accelMagnitude, speedMps, timestamp)
+ detectTurning(gyroMagnitude, speedMps, bearingChangeDegPerSec, timestamp)
+ detectStopping(speedMps, timestamp)
+
+ prevSpeedMps = speedMps
+ }
+
+ // ── BRAKING ───────────────────────────────────────────────────────────────
+
+ private fun detectBraking(accelMagnitude: Double, speedMps: Double, timestamp: Long) {
+ // Compute the drop against the reference speed captured at onset.
+ // On the very first frame of a potential braking episode we compare against
+ // prevSpeedMps; on subsequent frames we compare against brakingOnsetSpeed.
+ val referenceSpeed = if (brakingFrames == 0) prevSpeedMps else brakingOnsetSpeed
+ val speedDrop = referenceSpeed - speedMps
+
+ val isBrakingFrame =
+ speedDrop > config.brakingSpeedDropThreshold &&
+ accelStats.stdDev() > config.brakingAccelStdDevThreshold
+
+ if (isBrakingFrame) {
+ if (brakingFrames == 0) {
+ brakingOnsetSpeed = prevSpeedMps
+ brakingStartTime = timestamp
+ peakBrakingDrop = 0.0
+ peakAccelBraking = 0.0
+ }
+ brakingFrames++
+ peakBrakingDrop = maxOf(peakBrakingDrop, speedDrop)
+ peakAccelBraking = maxOf(peakAccelBraking, accelMagnitude)
+
+ if (brakingFrames == config.brakingSustainedFrames) {
+ val confidence =
+ if (peakBrakingDrop > config.brakingHighConfidenceRate) Confidence.HIGH
+ else Confidence.MEDIUM
+
+ _events.tryEmit(
+ DetectedEvent(
+ type = EventType.BRAKING,
+ confidence = confidence,
+ timestamp = brakingStartTime,
+ latitude = lastLat,
+ longitude = lastLon,
+ speedMps = speedMps,
+ peakAccelMagnitude = peakAccelBraking,
+ peakGyroMagnitude = gyroStats.mean(),
+ durationMs = timestamp - brakingStartTime,
+ )
+ )
+ // Debounce: reset and require another full window to re-trigger.
+ brakingFrames = 0
+ }
+ } else {
+ brakingFrames = 0
+ }
+ }
+
+ // ── TURNING ───────────────────────────────────────────────────────────────
+
+ private fun detectTurning(
+ gyroMagnitude: Double,
+ speedMps: Double,
+ bearingChangeDegPerSec: Double,
+ timestamp: Long,
+ ) {
+ val gyroAbove = gyroStats.mean() > config.turningGyroMeanThreshold
+ val speedAbove = speedMps > config.turningMinSpeedThreshold
+ val bearingAbove = bearingChangeDegPerSec > config.turningBearingChangeThreshold
+
+ // A frame qualifies if gyro is elevated AND either:
+ // • the cyclist is moving fast enough and bearing confirms the turn, or
+ // • the cyclist is moving slowly (gyro alone is sufficient).
+ val isTurningFrame = gyroAbove && (!speedAbove || bearingAbove)
+
+ if (isTurningFrame) {
+ if (turningFrames == 0) {
+ turningStartTime = timestamp
+ peakGyroTurning = 0.0
+ turningBothAgreed = false
+ }
+ turningFrames++
+ peakGyroTurning = maxOf(peakGyroTurning, gyroMagnitude)
+ if (gyroAbove && speedAbove && bearingAbove) turningBothAgreed = true
+
+ if (turningFrames == config.turningSustainedFrames) {
+ val confidence = if (turningBothAgreed) Confidence.HIGH else Confidence.LOW
+
+ _events.tryEmit(
+ DetectedEvent(
+ type = EventType.TURNING,
+ confidence = confidence,
+ timestamp = turningStartTime,
+ latitude = lastLat,
+ longitude = lastLon,
+ speedMps = speedMps,
+ peakAccelMagnitude = accelStats.mean(),
+ peakGyroMagnitude = peakGyroTurning,
+ durationMs = timestamp - turningStartTime,
+ )
+ )
+ turningFrames = 0
+ }
+ } else {
+ turningFrames = 0
+ }
+ }
+
+ // ── STOPPING ─────────────────────────────────────────────────────────────
+
+ private fun detectStopping(speedMps: Double, timestamp: Long) {
+ val isStoppingFrame =
+ speedMps < config.stoppingSpeedThreshold &&
+ accelStats.stdDev() < config.stoppingAccelStdDevThreshold
+
+ if (isStoppingFrame) {
+ if (stoppingFrames == 0) {
+ stoppingStartTime = timestamp
+ }
+ stoppingFrames++
+
+ // Emit exactly once when the threshold is first crossed.
+ if (stoppingFrames == config.stoppingFrames + 1) {
+ _events.tryEmit(
+ DetectedEvent(
+ type = EventType.STOPPING,
+ confidence = Confidence.HIGH,
+ timestamp = stoppingStartTime,
+ latitude = lastLat,
+ longitude = lastLon,
+ speedMps = speedMps,
+ peakAccelMagnitude = accelStats.mean(),
+ peakGyroMagnitude = gyroStats.mean(),
+ durationMs = timestamp - stoppingStartTime,
+ )
+ )
+ }
+ } else {
+ stoppingFrames = 0
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /** Resets all running state (e.g. between trips). */
+ fun reset() {
+ accelStats.reset()
+ gyroStats.reset()
+ brakingFrames = 0
+ turningFrames = 0
+ stoppingFrames = 0
+ prevSpeedMps = 0.0
+ lastLat = 0.0
+ lastLon = 0.0
+ }
+}
diff --git a/app/src/main/java/com/hawhamburg/micr0bu/domain/detection/RunningStats.kt b/app/src/main/java/com/hawhamburg/micr0bu/domain/detection/RunningStats.kt
new file mode 100644
index 0000000..c6231ab
--- /dev/null
+++ b/app/src/main/java/com/hawhamburg/micr0bu/domain/detection/RunningStats.kt
@@ -0,0 +1,66 @@
+package com.hawhamburg.micr0bu.domain.detection
+
+import kotlin.math.max
+import kotlin.math.sqrt
+
+/**
+ * O(1) sliding-window statistics over the last [windowSize] doubles.
+ *
+ * Maintains a circular buffer and updates sum / sum-of-squares accumulators
+ * incrementally, so every add() call is O(1) regardless of window size.
+ *
+ * No Android dependencies — fully JVM-testable.
+ */
+class RunningStats(val windowSize: Int = 50) {
+
+ private val buffer = DoubleArray(windowSize)
+ private var head = 0 // next write index in the circular buffer
+ private var count = 0 // number of samples added so far (capped at windowSize)
+ private var sum = 0.0
+ private var sumSq = 0.0
+
+ /** Adds [value] to the window, evicting the oldest sample when full. */
+ fun add(value: Double) {
+ if (count == windowSize) {
+ // Evict the oldest value
+ val oldest = buffer[head]
+ sum -= oldest
+ sumSq -= oldest * oldest
+ } else {
+ count++
+ }
+ buffer[head] = value
+ sum += value
+ sumSq += value * value
+ head = (head + 1) % windowSize
+ }
+
+ /** Arithmetic mean of the current window. Returns 0 if the window is empty. */
+ fun mean(): Double = if (count == 0) 0.0 else sum / count
+
+ /**
+ * Population variance of the current window.
+ * Uses the Welford/accumulator identity: Var = E[x²] - E[x]²
+ * Clamped to ≥ 0 to avoid tiny negative floats from rounding errors.
+ */
+ fun variance(): Double {
+ if (count < 2) return 0.0
+ val m = mean()
+ return max(0.0, (sumSq / count) - (m * m))
+ }
+
+ /** Population standard deviation of the current window. */
+ fun stdDev(): Double = sqrt(variance())
+
+ /** Number of samples currently in the window (up to [windowSize]). */
+ fun size(): Int = count
+
+ /** Clears the window, resetting all accumulators. */
+ fun reset() {
+ buffer.fill(0.0)
+ head = 0
+ count = 0
+ sum = 0.0
+ sumSq = 0.0
+ }
+}
diff --git a/app/src/main/java/com/hawhamburg/micr0bu/service/TripRecordingService.kt b/app/src/main/java/com/hawhamburg/micr0bu/service/TripRecordingService.kt
new file mode 100644
index 0000000..d1025a6
--- /dev/null
+++ b/app/src/main/java/com/hawhamburg/micr0bu/service/TripRecordingService.kt
@@ -0,0 +1,413 @@
+package com.hawhamburg.micr0bu.service
+
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Intent
+import android.hardware.Sensor
+import android.hardware.SensorEvent
+import android.hardware.SensorEventListener
+import android.hardware.SensorManager
+import android.location.Location
+import android.os.IBinder
+import android.os.Looper
+import androidx.core.app.NotificationCompat
+import com.google.android.gms.location.FusedLocationProviderClient
+import com.google.android.gms.location.LocationCallback
+import com.google.android.gms.location.LocationRequest
+import com.google.android.gms.location.LocationResult
+import com.google.android.gms.location.LocationServices
+import com.google.android.gms.location.Priority
+import com.hawhamburg.micr0bu.MainActivity
+import com.hawhamburg.micr0bu.R
+import com.hawhamburg.micr0bu.data.TripRepository
+import com.hawhamburg.micr0bu.data.db.AppDatabase
+import com.hawhamburg.micr0bu.domain.detection.DetectionConfig
+import com.hawhamburg.micr0bu.domain.detection.EventDetector
+import com.hawhamburg.micr0bu.domain.detection.EventType
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlin.math.abs
+import kotlin.math.sqrt
+
+/**
+ * Foreground service that runs event detection while the screen is off.
+ *
+ * Start with [ACTION_START], stop with [ACTION_STOP].
+ *
+ * Sensor pipeline:
+ * • Accelerometer + Gyroscope at [SensorManager.SENSOR_DELAY_GAME] (~50 Hz)
+ * • FusedLocationProviderClient at 1 Hz
+ * • Accel/gyro magnitudes + last GPS state → [EventDetector] → Room via [TripRepository]
+ */
+class TripRecordingService : Service() {
+
+ // ── Constants ─────────────────────────────────────────────────────────────
+
+ companion object {
+ const val ACTION_START = "com.hawhamburg.micr0bu.TRIP_START"
+ const val ACTION_STOP = "com.hawhamburg.micr0bu.TRIP_STOP"
+
+ private const val NOTIFICATION_ID = 9001
+ private const val CHANNEL_ID = "trip_recording"
+ private const val CHANNEL_NAME = "Trip Recording"
+ private const val GPS_INTERVAL_MS = 1_000L
+ private const val GRAVITY = 9.81
+ }
+
+ // ── Service scope ─────────────────────────────────────────────────────────
+
+ private val serviceJob = SupervisorJob()
+ private val serviceScope = CoroutineScope(Dispatchers.Default + serviceJob)
+ private var timerJob: Job? = null
+
+ // ── Dependencies ──────────────────────────────────────────────────────────
+
+ private lateinit var sensorManager: SensorManager
+ private lateinit var fusedLocation: FusedLocationProviderClient
+ private lateinit var repository: TripRepository
+ private val detector = EventDetector(
+ DetectionConfig(
+ brakingSpeedDropThreshold = 1.0,
+ brakingAccelStdDevThreshold = 1.8,
+ brakingSustainedFrames = 25,
+ turningGyroMeanThreshold = 0.6,
+ turningBearingChangeThreshold = 15.0,
+ turningSustainedFrames = 30,
+ stoppingSpeedThreshold = 0.3,
+ stoppingFrames = 150,
+ stoppingAccelStdDevThreshold = 0.10,
+ )
+ )
+
+ // ── Sensor fusion state ───────────────────────────────────────────────────
+
+ // Latest accel xyz (updated at ~50 Hz)
+ @Volatile private var ax = 0f
+ @Volatile private var ay = 0f
+ @Volatile private var az = 0f
+
+ // Latest gyro xyz (updated at ~50 Hz)
+ @Volatile private var gx = 0f
+ @Volatile private var gy = 0f
+ @Volatile private var gz = 0f
+
+ // Latest GPS state (updated at 1 Hz)
+ @Volatile private var currentLat = 0.0
+ @Volatile private var currentLon = 0.0
+ @Volatile private var currentSpeedMps = 0f
+ @Volatile private var currentBearing = 0f
+ @Volatile private var previousBearing = 0f
+ @Volatile private var bearingChangeDps = 0.0 // °/s
+
+ // ── Trip accounting ───────────────────────────────────────────────────────
+
+ private var currentTripId: Long = -1L
+ private var distanceMetres = 0f
+ private var prevLat = Double.NaN
+ private var prevLon = Double.NaN
+
+ // GPS track for map replay: list of JSON objects built as a StringBuilder
+ private val gpsTrackBuilder = StringBuilder("[")
+ private var gpsPointCount = 0
+
+ // Event counts
+ private var brakingCount = 0
+ private var turningCount = 0
+ private var stoppingCount = 0
+
+ // ── SensorEventListener ───────────────────────────────────────────────────
+
+ private val sensorListener = object : SensorEventListener {
+ override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit
+
+ override fun onSensorChanged(event: SensorEvent) {
+ when (event.sensor.type) {
+ Sensor.TYPE_ACCELEROMETER -> {
+ ax = event.values[0]
+ ay = event.values[1]
+ az = event.values[2]
+ }
+ Sensor.TYPE_GYROSCOPE -> {
+ gx = event.values[0]
+ gy = event.values[1]
+ gz = event.values[2]
+ }
+ }
+
+ // Compute orientation-independent magnitudes and feed the detector
+ val accelMag = sqrt((ax * ax + ay * ay + az * az).toDouble()) - GRAVITY
+ val gyroMag = sqrt((gx * gx + gy * gy + gz * gz).toDouble())
+
+ detector.processSample(
+ accelMagnitude = accelMag,
+ gyroMagnitude = gyroMag,
+ speedMps = currentSpeedMps.toDouble(),
+ bearingChangeDegPerSec = bearingChangeDps,
+ latitude = currentLat,
+ longitude = currentLon,
+ timestamp = System.currentTimeMillis(),
+ )
+ }
+ }
+
+ // ── LocationCallback ─────────────────────────────────────────────────────
+
+ private val locationCallback = object : LocationCallback() {
+ override fun onLocationResult(result: LocationResult) {
+ val loc = result.lastLocation ?: return
+
+ // Update bearing-change rate before overwriting previous bearing
+ val newBearing = loc.bearing
+ bearingChangeDps = normaliseBearingDiff(previousBearing, newBearing)
+ previousBearing = currentBearing
+ currentBearing = newBearing
+
+ currentLat = loc.latitude
+ currentLon = loc.longitude
+ currentSpeedMps = loc.speed
+
+ TripServiceBus.update { copy(currentSpeedMs = loc.speed) }
+
+ accumulateDistance(loc)
+ appendGpsPoint(loc)
+ }
+ }
+
+ // ── Lifecycle ─────────────────────────────────────────────────────────────
+
+ override fun onCreate() {
+ super.onCreate()
+ sensorManager = getSystemService(SENSOR_SERVICE) as SensorManager
+ fusedLocation = LocationServices.getFusedLocationProviderClient(this)
+ repository = TripRepository(AppDatabase.getInstance(applicationContext))
+ createNotificationChannel()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ when (intent?.action) {
+ ACTION_START -> startTrip()
+ ACTION_STOP -> stopTrip()
+ }
+ return START_NOT_STICKY
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onDestroy() {
+ serviceJob.cancel()
+ super.onDestroy()
+ }
+
+ // ── Trip control ─────────────────────────────────────────────────────────
+
+ private fun startTrip() {
+ detector.reset()
+ brakingCount = 0
+ turningCount = 0
+ stoppingCount = 0
+ distanceMetres = 0f
+ prevLat = Double.NaN
+ prevLon = Double.NaN
+ gpsTrackBuilder.clear()
+ gpsTrackBuilder.append("[")
+ gpsPointCount = 0
+
+ val startTime = System.currentTimeMillis()
+
+ // Insert placeholder trip row and get the auto-generated ID
+ serviceScope.launch {
+ currentTripId = repository.insertTrip(startTime = startTime)
+ TripServiceBus.update {
+ copy(
+ isRecording = true,
+ currentTripId = currentTripId,
+ elapsedSeconds = 0L,
+ brakingCount = 0,
+ turningCount = 0,
+ stoppingCount = 0,
+ currentSpeedMs = 0f,
+ )
+ }
+ }
+
+ // Collect detector events and persist them
+ serviceScope.launch {
+ detector.events.collect { event ->
+ if (currentTripId < 0) return@collect
+ repository.insertEvent(currentTripId, event)
+ when (event.type) {
+ EventType.BRAKING -> brakingCount++
+ EventType.TURNING -> turningCount++
+ EventType.STOPPING -> stoppingCount++
+ }
+ TripServiceBus.update {
+ copy(
+ brakingCount = this@TripRecordingService.brakingCount,
+ turningCount = this@TripRecordingService.turningCount,
+ stoppingCount = this@TripRecordingService.stoppingCount,
+ )
+ }
+ updateNotification()
+ }
+ }
+
+ // Elapsed-time ticker
+ timerJob = serviceScope.launch {
+ var elapsed = 0L
+ while (true) {
+ delay(1_000)
+ elapsed++
+ TripServiceBus.update { copy(elapsedSeconds = elapsed) }
+ updateNotification()
+ }
+ }
+
+ // Register sensors
+ registerSensors()
+ requestLocationUpdates()
+
+ // Start foreground ASAP (within 5 s required by Android)
+ startForeground(NOTIFICATION_ID, buildNotification(0L, 0))
+ }
+
+ private fun stopTrip() {
+ timerJob?.cancel()
+ unregisterSensors()
+ removeLocationUpdates()
+
+ val endTime = System.currentTimeMillis()
+ val totalEvents = brakingCount + turningCount + stoppingCount
+
+ // Close GPS track JSON
+ gpsTrackBuilder.append("]")
+ val gpsJson = gpsTrackBuilder.toString()
+
+ serviceScope.launch {
+ if (currentTripId >= 0) {
+ repository.finaliseTrip(
+ tripId = currentTripId,
+ endTime = endTime,
+ distanceMetres = distanceMetres,
+ eventCount = totalEvents,
+ gpsTrackJson = gpsJson,
+ )
+ }
+ currentTripId = -1L
+ TripServiceBus.reset()
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ stopSelf()
+ }
+ }
+
+ // ── Sensors ───────────────────────────────────────────────────────────────
+
+ private fun registerSensors() {
+ listOf(Sensor.TYPE_ACCELEROMETER, Sensor.TYPE_GYROSCOPE).forEach { type ->
+ sensorManager.getDefaultSensor(type)?.let { sensor ->
+ sensorManager.registerListener(
+ sensorListener, sensor, SensorManager.SENSOR_DELAY_GAME
+ )
+ }
+ }
+ }
+
+ private fun unregisterSensors() =
+ sensorManager.unregisterListener(sensorListener)
+
+ // ── Location ─────────────────────────────────────────────────────────────
+
+ @Suppress("MissingPermission")
+ private fun requestLocationUpdates() {
+ val request = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, GPS_INTERVAL_MS)
+ .setMinUpdateIntervalMillis(GPS_INTERVAL_MS / 2)
+ .build()
+ fusedLocation.requestLocationUpdates(request, locationCallback, Looper.getMainLooper())
+ }
+
+ private fun removeLocationUpdates() =
+ fusedLocation.removeLocationUpdates(locationCallback)
+
+ // ── GPS track helpers ─────────────────────────────────────────────────────
+
+ private fun accumulateDistance(loc: Location) {
+ val pLat = prevLat
+ val pLon = prevLon
+ if (!pLat.isNaN() && !pLon.isNaN()) {
+ val results = FloatArray(1)
+ Location.distanceBetween(pLat, pLon, loc.latitude, loc.longitude, results)
+ distanceMetres += results[0]
+ }
+ prevLat = loc.latitude
+ prevLon = loc.longitude
+ }
+
+ private fun appendGpsPoint(loc: Location) {
+ if (gpsPointCount > 0) gpsTrackBuilder.append(",")
+ gpsTrackBuilder.append(
+ """{"lat":${loc.latitude},"lon":${loc.longitude},"ts":${loc.time}}"""
+ )
+ gpsPointCount++
+ }
+
+ private fun normaliseBearingDiff(a: Float, b: Float): Double {
+ // Returns absolute bearing change in °/s (GPS updates at ~1 Hz)
+ var diff = (b - a).toDouble()
+ while (diff > 180) diff -= 360
+ while (diff < -180) diff += 360
+ return abs(diff) // per second because GPS is 1 Hz
+ }
+
+ // ── Notification ─────────────────────────────────────────────────────────
+
+ private fun createNotificationChannel() {
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ CHANNEL_NAME,
+ NotificationManager.IMPORTANCE_LOW,
+ ).apply {
+ description = "Shown while a trip is being recorded"
+ }
+ (getSystemService(NOTIFICATION_SERVICE) as NotificationManager)
+ .createNotificationChannel(channel)
+ }
+
+ private fun buildNotification(elapsedSeconds: Long, eventCount: Int) =
+ NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle("Recording trip")
+ .setContentText(
+ "⏱ ${formatElapsed(elapsedSeconds)} · " +
+ "🚨 $brakingCount 🔄 $turningCount 🛑 $stoppingCount"
+ )
+ .setSmallIcon(R.mipmap.ic_launcher_foreground)
+ .setOngoing(true)
+ .setOnlyAlertOnce(true)
+ .setContentIntent(
+ PendingIntent.getActivity(
+ this, 0,
+ Intent(this, MainActivity::class.java),
+ PendingIntent.FLAG_IMMUTABLE,
+ )
+ )
+ .build()
+
+ private fun updateNotification() {
+ val elapsed = TripServiceBus.state.value.elapsedSeconds
+ val total = brakingCount + turningCount + stoppingCount
+ val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
+ nm.notify(NOTIFICATION_ID, buildNotification(elapsed, total))
+ }
+
+ private fun formatElapsed(seconds: Long): String {
+ val h = seconds / 3600
+ val m = (seconds % 3600) / 60
+ val s = seconds % 60
+ return if (h > 0) "%d:%02d:%02d".format(h, m, s)
+ else "%02d:%02d".format(m, s)
+ }
+}
diff --git a/app/src/main/java/com/hawhamburg/micr0bu/service/TripServiceBus.kt b/app/src/main/java/com/hawhamburg/micr0bu/service/TripServiceBus.kt
new file mode 100644
index 0000000..0667e04
--- /dev/null
+++ b/app/src/main/java/com/hawhamburg/micr0bu/service/TripServiceBus.kt
@@ -0,0 +1,38 @@
+package com.hawhamburg.micr0bu.service
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+/**
+ * Process-level singleton that bridges [TripRecordingService] (writes) and
+ * [TripRecordingViewModel] (reads) without requiring a bound service.
+ *
+ * The service updates [state] on every relevant change; the ViewModel collects
+ * it as a cold StateFlow.
+ */
+object TripServiceBus {
+
+ data class State(
+ val isRecording: Boolean = false,
+ val currentTripId: Long = -1L,
+ val elapsedSeconds: Long = 0L,
+ val brakingCount: Int = 0,
+ val turningCount: Int = 0,
+ val stoppingCount: Int = 0,
+ val currentSpeedMs: Float = 0f,
+ )
+
+ private val _state = MutableStateFlow(State())
+ val state: StateFlow = _state.asStateFlow()
+
+ /** Called exclusively from [TripRecordingService]. */
+ internal fun update(transform: State.() -> State) {
+ _state.value = _state.value.transform()
+ }
+
+ /** Resets to idle — called when recording stops. */
+ internal fun reset() {
+ _state.value = State()
+ }
+}
diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/components/StatusTopBar.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/components/StatusTopBar.kt
new file mode 100644
index 0000000..79c40b3
--- /dev/null
+++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/components/StatusTopBar.kt
@@ -0,0 +1,169 @@
+package com.hawhamburg.micr0bu.ui.components
+
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.GpsFixed
+import androidx.compose.material.icons.filled.GpsOff
+import androidx.compose.material.icons.filled.Sensors
+import androidx.compose.material.icons.filled.SensorsOff
+import androidx.compose.material.icons.filled.Wifi
+import androidx.compose.material.icons.filled.WifiOff
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+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.graphics.vector.ImageVector
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
+import com.hawhamburg.micr0bu.viewmodel.SensorUiState
+
+private val GreenActive = Color(0xFF4CAF50)
+private val AmberAcquiring = Color(0xFFFFC107)
+private val ErrorRed = Color(0xFFFF5252)
+private val GrayInactive = Color(0xFF555E6A)
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun StatusTopBar(state: SensorUiState, mqttConnectionState: MqttConnectionState) {
+ TopAppBar(
+ title = {
+ Text(
+ text = "MicrOBU",
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ },
+ actions = {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(end = 12.dp),
+ ) {
+ if (state.isRecording) {
+ RecordingPulse()
+ Spacer(Modifier.width(10.dp))
+ }
+
+ StatusIcon(
+ activeIcon = Icons.Default.GpsFixed,
+ inactiveIcon = Icons.Default.GpsOff,
+ tint = when {
+ state.gnss != null -> GreenActive
+ state.locationPermissionGranted -> AmberAcquiring
+ else -> GrayInactive
+ },
+ description = "GNSS",
+ )
+ Spacer(Modifier.width(8.dp))
+
+ StatusIcon(
+ activeIcon = Icons.Default.Sensors,
+ inactiveIcon = Icons.Default.SensorsOff,
+ tint = if (state.accel != null) GreenActive else GrayInactive,
+ description = "IMU",
+ )
+ Spacer(Modifier.width(8.dp))
+
+ ObuStatusIcon(mqttConnectionState)
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surface,
+ ),
+ )
+}
+
+@Composable
+private fun ObuStatusIcon(state: MqttConnectionState) {
+ val icon = when (state) {
+ MqttConnectionState.CONNECTED,
+ MqttConnectionState.CONNECTING -> Icons.Default.Wifi
+ MqttConnectionState.ERROR,
+ MqttConnectionState.DISCONNECTED -> Icons.Default.WifiOff
+ }
+ val tint = when (state) {
+ MqttConnectionState.CONNECTED -> GreenActive
+ MqttConnectionState.CONNECTING -> AmberAcquiring
+ MqttConnectionState.ERROR -> ErrorRed
+ MqttConnectionState.DISCONNECTED -> GrayInactive
+ }
+
+ // Pulse the icon while connecting so it's visually distinct from static states
+ val alpha = if (state == MqttConnectionState.CONNECTING) {
+ val transition = rememberInfiniteTransition(label = "obuConnect")
+ transition.animateFloat(
+ initialValue = 1f,
+ targetValue = 0.3f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(800, easing = LinearEasing),
+ repeatMode = RepeatMode.Reverse,
+ ),
+ label = "obuAlpha",
+ ).value
+ } else {
+ 1f
+ }
+
+ Icon(
+ imageVector = icon,
+ contentDescription = "OBU",
+ tint = tint.copy(alpha = alpha),
+ modifier = Modifier.size(20.dp),
+ )
+}
+
+@Composable
+private fun StatusIcon(
+ activeIcon: ImageVector,
+ inactiveIcon: ImageVector,
+ tint: Color,
+ description: String,
+) {
+ Icon(
+ imageVector = if (tint != GrayInactive) activeIcon else inactiveIcon,
+ contentDescription = description,
+ tint = tint,
+ modifier = Modifier.size(20.dp),
+ )
+}
+
+@Composable
+private fun RecordingPulse() {
+ val transition = rememberInfiniteTransition(label = "rec")
+ val alpha by transition.animateFloat(
+ initialValue = 1f,
+ targetValue = 0.15f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(600, easing = LinearEasing),
+ repeatMode = RepeatMode.Reverse,
+ ),
+ label = "recAlpha",
+ )
+ Box(
+ modifier = Modifier
+ .size(10.dp)
+ .clip(CircleShape)
+ .background(Color(0xFFFF5252).copy(alpha = alpha)),
+ )
+}
diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/navigation/AppNavigation.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/navigation/AppNavigation.kt
new file mode 100644
index 0000000..f2b7201
--- /dev/null
+++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/navigation/AppNavigation.kt
@@ -0,0 +1,119 @@
+package com.hawhamburg.micr0bu.ui.navigation
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Dashboard
+import androidx.compose.material.icons.filled.FiberManualRecord
+import androidx.compose.material.icons.filled.History
+import androidx.compose.material.icons.filled.Router
+import androidx.compose.material.icons.filled.Sensors
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material3.Icon
+import androidx.compose.material3.NavigationBar
+import androidx.compose.material3.NavigationBarItem
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.Modifier
+import androidx.navigation.NavController
+import androidx.navigation.compose.currentBackStackEntryAsState
+import com.hawhamburg.micr0bu.R
+
+sealed class Screen(val route: String, val labelRes: Int) {
+ data object Dashboard : Screen("dashboard", R.string.nav_dashboard)
+ data object Sensors : Screen("sensors", R.string.nav_sensors)
+ data object Record : Screen("record", R.string.nav_record)
+ data object Log : Screen("log", R.string.nav_log)
+ data object Settings : Screen("settings", R.string.nav_settings)
+ data object Connection : Screen("connection", R.string.nav_connection)
+ data object Map : Screen("map", R.string.map_title)
+ data object MqttViewer : Screen("mqtt_viewer", R.string.nav_v2x)
+
+ // Phase A — Trip Recording
+ data object TripHistory : Screen("trip_history", R.string.nav_trips)
+ data object TripReview : Screen("trip_review/{tripId}", R.string.trip_review_title) {
+ fun route(tripId: Long) = "trip_review/$tripId"
+ }
+
+ data object SettingsLanguage : Screen("settings/language", R.string.settings_language)
+ data object SettingsAppearance : Screen("settings/appearance", R.string.settings_appearance)
+ data object SettingsSensors : Screen("settings/sensors", R.string.settings_sensors)
+ data object SettingsUnits : Screen("settings/units", R.string.settings_units)
+ data object SettingsConnection : Screen("settings/connection", R.string.settings_connection)
+ data object SettingsMqtt : Screen("settings/mqtt", R.string.settings_mqtt_section)
+ data object SettingsDeveloper : Screen("settings/developer", R.string.settings_developer)
+ data object SettingsAbout : Screen("settings/about", R.string.settings_about)
+}
+
+private val bottomNavItems = listOf(
+ Screen.Dashboard,
+ Screen.Sensors,
+ Screen.Record,
+ Screen.TripHistory,
+ Screen.MqttViewer,
+ Screen.Settings,
+)
+
+@Composable
+fun BottomNavBar(navController: NavController) {
+ val backStackEntry by navController.currentBackStackEntryAsState()
+ val currentRoute = backStackEntry?.destination?.route
+
+ NavigationBar {
+ bottomNavItems.forEach { screen ->
+ NavigationBarItem(
+ selected = currentRoute == screen.route,
+ onClick = {
+ navController.navigate(screen.route) {
+ popUpTo(Screen.Dashboard.route) { saveState = true }
+ launchSingleTop = true
+ restoreState = true
+ }
+ },
+ icon = {
+ Icon(
+ imageVector = when (screen) {
+ Screen.Dashboard -> Icons.Default.Dashboard
+ Screen.Sensors -> Icons.Default.Sensors
+ Screen.Record -> Icons.Default.FiberManualRecord
+ Screen.TripHistory -> Icons.Default.History
+ Screen.MqttViewer -> Icons.Default.Router
+ Screen.Settings -> Icons.Default.Settings
+ else -> Icons.Default.Dashboard
+ },
+ contentDescription = stringResource(screen.labelRes),
+ )
+ },
+ label = { AutoSizeNavLabel(stringResource(screen.labelRes)) },
+ )
+ }
+ }
+}
+
+// Shrinks font until the label fits on one line — prevents wrapping on longer German labels.
+@Composable
+private fun AutoSizeNavLabel(text: String) {
+ var fontSize by remember(text) { mutableStateOf(12.sp) }
+ var readyToDraw by remember(text) { mutableStateOf(false) }
+ Text(
+ text = text,
+ fontSize = fontSize,
+ maxLines = 1,
+ softWrap = false,
+ overflow = TextOverflow.Clip,
+ modifier = Modifier.drawWithContent { if (readyToDraw) drawContent() },
+ onTextLayout = { result ->
+ if (result.hasVisualOverflow && fontSize > 8.sp) {
+ fontSize *= 0.85f
+ } else {
+ readyToDraw = true
+ }
+ },
+ )
+}
diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/ConnectionSetupScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/ConnectionSetupScreen.kt
new file mode 100644
index 0000000..7d1858f
--- /dev/null
+++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/ConnectionSetupScreen.kt
@@ -0,0 +1,248 @@
+package com.hawhamburg.micr0bu.ui.screens
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.filled.Link
+import androidx.compose.material.icons.filled.LinkOff
+import androidx.compose.material.icons.filled.Usb
+import androidx.compose.material.icons.filled.Warning
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.hawhamburg.micr0bu.R
+import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
+import com.hawhamburg.micr0bu.data.transport.TransportType
+import com.hawhamburg.micr0bu.viewmodel.MqttViewModel
+
+private val UsbGreen = Color(0xFF4CAF50)
+private val UsbGreenBg = Color(0xFF0D2B1A)
+private val UsbAmber = Color(0xFFFFC107)
+private val UsbAmberBg = Color(0xFF2B2000)
+private val UsbGray = Color(0xFF8B949E)
+private val UsbGrayBg = Color(0xFF1C1C1C)
+
+@Composable
+fun ConnectionSetupScreen(
+ viewModel: MqttViewModel = hiltViewModel(),
+ modifier: Modifier = Modifier,
+) {
+ val connectionState by viewModel.connectionState.collectAsState()
+ val usbConnected by viewModel.usbConnected.collectAsState()
+ val detectedObuIp by viewModel.detectedObuIp.collectAsState()
+ val activeTransport by viewModel.activeTransport.collectAsState()
+ val mqttPrefs by viewModel.mqttPrefs.collectAsState()
+
+ val isConnected = connectionState == MqttConnectionState.CONNECTED
+ val isConnecting = connectionState == MqttConnectionState.CONNECTING
+ val isUsbMode = activeTransport == TransportType.USB_C
+
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Text(
+ stringResource(R.string.conn_title),
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold,
+ )
+ Text(
+ stringResource(R.string.conn_phase2_desc),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ // ── USB-C Primary Card ─────────────────────────────────────────────
+ val usbContainerColor = when {
+ usbConnected && isConnected -> UsbGreenBg
+ usbConnected && isConnecting -> UsbAmberBg
+ else -> UsbGrayBg
+ }
+ Card(
+ colors = CardDefaults.cardColors(containerColor = usbContainerColor),
+ shape = RoundedCornerShape(12.dp),
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+
+ // Card title row
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Icon(
+ Icons.Default.Usb,
+ contentDescription = null,
+ tint = if (usbConnected) UsbGreen else UsbGray,
+ modifier = Modifier.size(20.dp),
+ )
+ Text(
+ stringResource(R.string.conn_usb_title),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = if (usbConnected) UsbGreen else UsbGray,
+ )
+ Spacer(Modifier.weight(1f))
+ // Cable state pill
+ Surface(
+ color = if (usbConnected) UsbGreen.copy(alpha = 0.2f)
+ else UsbGray.copy(alpha = 0.2f),
+ shape = RoundedCornerShape(50),
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Icon(
+ if (usbConnected) Icons.Default.CheckCircle else Icons.Default.Warning,
+ null,
+ tint = if (usbConnected) UsbGreen else UsbAmber,
+ modifier = Modifier.size(12.dp),
+ )
+ Text(
+ text = if (usbConnected) stringResource(R.string.conn_usb_cable_ok)
+ else stringResource(R.string.conn_usb_cable_missing),
+ style = MaterialTheme.typography.labelSmall,
+ color = if (usbConnected) UsbGreen else UsbAmber,
+ fontWeight = FontWeight.SemiBold,
+ )
+ }
+ }
+ }
+
+ Spacer(Modifier.height(12.dp))
+
+ // OBU IP row
+ InfoRow(
+ label = stringResource(R.string.conn_usb_obu_ip),
+ value = when {
+ mqttPrefs.usbAutoDetect && detectedObuIp != null -> detectedObuIp!!
+ mqttPrefs.usbAutoDetect -> stringResource(R.string.conn_usb_detecting)
+ else -> mqttPrefs.usbIpOverride
+ },
+ )
+ Spacer(Modifier.height(4.dp))
+ InfoRow(
+ label = stringResource(R.string.conn_usb_mode),
+ value = if (mqttPrefs.usbAutoDetect) stringResource(R.string.conn_usb_auto)
+ else stringResource(R.string.conn_usb_manual),
+ )
+
+ Spacer(Modifier.height(12.dp))
+ HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f))
+ Spacer(Modifier.height(12.dp))
+
+ // MQTT status row
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ val (mqttLabel, mqttColor) = when (connectionState) {
+ MqttConnectionState.CONNECTED -> stringResource(R.string.dash_mqtt_connected) to UsbGreen
+ MqttConnectionState.CONNECTING -> stringResource(R.string.dash_mqtt_connecting) to UsbAmber
+ MqttConnectionState.ERROR -> stringResource(R.string.dash_mqtt_error) to Color(0xFFFF5252)
+ MqttConnectionState.DISCONNECTED -> stringResource(R.string.dash_tap_to_connect) to UsbGray
+ }
+ Icon(
+ if (isConnected) Icons.Default.Link else Icons.Default.LinkOff,
+ null,
+ tint = mqttColor,
+ modifier = Modifier.size(14.dp),
+ )
+ Text(mqttLabel, style = MaterialTheme.typography.bodySmall, color = mqttColor)
+ }
+
+ Spacer(Modifier.height(12.dp))
+
+ // Connect / Disconnect button
+ if (isConnected || isConnecting) {
+ OutlinedButton(
+ onClick = { viewModel.disconnect() },
+ modifier = Modifier.fillMaxWidth(),
+ colors = ButtonDefaults.outlinedButtonColors(
+ contentColor = Color(0xFFFF5252),
+ ),
+ ) {
+ Icon(Icons.Default.LinkOff, null, modifier = Modifier.size(16.dp))
+ Spacer(Modifier.width(6.dp))
+ Text(stringResource(R.string.conn_disconnect))
+ }
+ } else {
+ Button(
+ onClick = { viewModel.connect() },
+ modifier = Modifier.fillMaxWidth(),
+ enabled = !isUsbMode || usbConnected || !mqttPrefs.usbAutoDetect,
+ ) {
+ Icon(Icons.Default.Link, null, modifier = Modifier.size(16.dp))
+ Spacer(Modifier.width(6.dp))
+ Text(stringResource(R.string.conn_connect_mqtt))
+ }
+ }
+
+ if (!usbConnected && mqttPrefs.usbAutoDetect) {
+ Spacer(Modifier.height(8.dp))
+ Text(
+ stringResource(R.string.conn_usb_no_cable_hint),
+ style = MaterialTheme.typography.bodySmall,
+ color = UsbAmber.copy(alpha = 0.8f),
+ )
+ }
+ }
+ }
+
+ }
+}
+
+@Composable
+private fun InfoRow(label: String, value: String) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ label,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
+ )
+ Text(
+ value,
+ style = MaterialTheme.typography.bodySmall,
+ fontFamily = FontFamily.Monospace,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+}
diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/DashboardScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/DashboardScreen.kt
new file mode 100644
index 0000000..57468ba
--- /dev/null
+++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/DashboardScreen.kt
@@ -0,0 +1,434 @@
+package com.hawhamburg.micr0bu.ui.screens
+
+import android.content.Intent
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Launch
+import androidx.compose.material.icons.filled.Bluetooth
+import androidx.compose.material.icons.filled.BluetoothDisabled
+import androidx.compose.material.icons.filled.Usb
+import androidx.compose.material.icons.filled.Warning
+import androidx.compose.material.icons.filled.Wifi
+import androidx.compose.material.icons.filled.WifiOff
+import androidx.compose.material.icons.filled.FiberManualRecord
+import androidx.compose.material.icons.filled.GpsFixed
+import androidx.compose.material.icons.filled.GpsOff
+import androidx.compose.material.icons.filled.Map
+import androidx.compose.material.icons.filled.Sensors
+import androidx.compose.material.icons.filled.SensorsOff
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.core.net.toUri
+import com.hawhamburg.micr0bu.R
+import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
+import com.hawhamburg.micr0bu.data.transport.TransportType
+import com.hawhamburg.micr0bu.viewmodel.SensorUiState
+import kotlin.math.sqrt
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun DashboardScreen(
+ state: SensorUiState,
+ mqttConnectionState: MqttConnectionState,
+ activeTransport: TransportType = TransportType.USB_C,
+ usbCableConnected: Boolean = false,
+ obuStationTypeWarning: Boolean = false,
+ onNavigateToConnection: () -> Unit,
+ onNavigateToSensors: () -> Unit,
+ onNavigateToMap: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val context = LocalContext.current
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ val gnssSheetVisible = remember { mutableStateOf(false) }
+
+ // GNSS location bottom sheet
+ if (gnssSheetVisible.value) {
+ ModalBottomSheet(
+ onDismissRequest = { gnssSheetVisible.value = false },
+ sheetState = sheetState,
+ ) {
+ Text(
+ text = stringResource(R.string.gnss_sheet_title),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
+ )
+ state.gnss?.let { gnss ->
+ Text(
+ text = "%.5f°, %.5f°".format(gnss.latitude, gnss.longitude),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp),
+ )
+ }
+
+ HorizontalDivider()
+
+ // Open in external maps app — Android routes to user's default/preferred app
+ ListItem(
+ headlineContent = { Text(stringResource(R.string.gnss_open_maps)) },
+ supportingContent = { Text(stringResource(R.string.gnss_open_maps_sub)) },
+ leadingContent = {
+ Icon(Icons.AutoMirrored.Filled.Launch, null, tint = MaterialTheme.colorScheme.primary)
+ },
+ modifier = Modifier.clickable(enabled = state.gnss != null) {
+ state.gnss?.let { gnss ->
+ val uri =
+ "geo:${gnss.latitude},${gnss.longitude}?q=${gnss.latitude},${gnss.longitude}".toUri()
+ context.startActivity(Intent(Intent.ACTION_VIEW, uri))
+ }
+ gnssSheetVisible.value = false
+ },
+ )
+
+ HorizontalDivider()
+
+ // In-app OSMDroid map
+ ListItem(
+ headlineContent = { Text(stringResource(R.string.gnss_view_inapp)) },
+ supportingContent = { Text(stringResource(R.string.gnss_view_inapp_sub)) },
+ leadingContent = {
+ Icon(Icons.Default.Map, null, tint = MaterialTheme.colorScheme.primary)
+ },
+ modifier = Modifier.clickable {
+ gnssSheetVisible.value = false
+ onNavigateToMap()
+ },
+ )
+
+ Spacer(Modifier.height(24.dp))
+ }
+ }
+
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.app_subtitle),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ // Recording banner
+ if (state.isRecording) {
+ androidx.compose.material3.Card(
+ colors = androidx.compose.material3.CardDefaults.cardColors(
+ containerColor = Color(0xFF3A1010)
+ ),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Row(
+ modifier = Modifier.padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Icon(
+ Icons.Default.FiberManualRecord, null,
+ tint = Color(0xFFFF5252),
+ modifier = Modifier.size(14.dp),
+ )
+ Text(
+ "${stringResource(R.string.dash_recording)} ${formatDuration(state.recordingDurationSeconds)}",
+ style = MaterialTheme.typography.labelLarge,
+ color = Color(0xFFFF5252),
+ fontWeight = FontWeight.SemiBold,
+ )
+ Spacer(Modifier.weight(1f))
+ Text(
+ "${state.recordingSampleCount} ${stringResource(R.string.dash_samples)}",
+ style = MaterialTheme.typography.bodySmall,
+ color = Color(0xFFFF9090),
+ )
+ }
+ }
+ }
+
+ // ── stationType warning banner ────────────────────────────────────
+ if (obuStationTypeWarning) {
+ androidx.compose.material3.Card(
+ colors = androidx.compose.material3.CardDefaults.cardColors(
+ containerColor = Color(0xFF3A2000)
+ ),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Row(
+ modifier = Modifier.padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Icon(
+ Icons.Default.Warning, null,
+ tint = Color(0xFFFFC107),
+ modifier = Modifier.size(16.dp),
+ )
+ Text(
+ stringResource(R.string.dash_station_type_warning),
+ style = MaterialTheme.typography.bodySmall,
+ color = Color(0xFFFFC107),
+ )
+ }
+ }
+ }
+
+ // GNSS card — tapping opens the bottom sheet
+ StatusCard(
+ title = stringResource(R.string.dash_gnss),
+ active = state.gnss != null,
+ activeIcon = Icons.Default.GpsFixed,
+ inactiveIcon = Icons.Default.GpsOff,
+ summary = state.gnss?.let {
+ "%.5f, %.5f | %.1f m/s".format(it.latitude, it.longitude, it.speedMs)
+ } ?: if (state.locationPermissionGranted) stringResource(R.string.dash_acquiring_fix)
+ else stringResource(R.string.dash_permission_required),
+ onClick = { gnssSheetVisible.value = true },
+ )
+
+ // IMU card — tapping navigates to Sensors screen
+ StatusCard(
+ title = stringResource(R.string.dash_imu),
+ active = state.accel != null,
+ activeIcon = Icons.Default.Sensors,
+ inactiveIcon = Icons.Default.SensorsOff,
+ summary = state.accel?.let {
+ val mag = sqrt(it.x * it.x + it.y * it.y + it.z * it.z)
+ "Accel magnitude: %.2f m/s²".format(mag)
+ } ?: stringResource(R.string.dash_initialising),
+ onClick = onNavigateToSensors,
+ )
+
+ val mqttConnected = mqttConnectionState == MqttConnectionState.CONNECTED
+ val transportIcon = when (activeTransport) {
+ TransportType.USB_C -> Icons.Default.Usb
+ TransportType.WIFI -> Icons.Default.Wifi
+ TransportType.BLUETOOTH -> Icons.Default.Bluetooth
+ }
+ val transportInactiveIcon = when (activeTransport) {
+ TransportType.USB_C -> Icons.Default.Usb
+ TransportType.WIFI -> Icons.Default.WifiOff
+ TransportType.BLUETOOTH -> Icons.Default.BluetoothDisabled
+ }
+
+ // OBU connection card with transport chip row
+ androidx.compose.material3.Card(
+ modifier = Modifier.fillMaxWidth().clickable { onNavigateToConnection() },
+ colors = androidx.compose.material3.CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant,
+ ),
+ ) {
+ Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ val tint = if (mqttConnected) Color(0xFF4CAF50)
+ else MaterialTheme.colorScheme.onSurfaceVariant
+ Icon(
+ if (mqttConnected) transportIcon else transportInactiveIcon,
+ null,
+ tint = tint,
+ modifier = Modifier.size(28.dp),
+ )
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ stringResource(R.string.dash_obu_connection),
+ style = MaterialTheme.typography.labelLarge,
+ color = tint,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Text(
+ text = when (mqttConnectionState) {
+ MqttConnectionState.CONNECTED -> stringResource(R.string.dash_mqtt_connected)
+ MqttConnectionState.CONNECTING -> stringResource(R.string.dash_mqtt_connecting)
+ MqttConnectionState.ERROR -> stringResource(R.string.dash_mqtt_error)
+ MqttConnectionState.DISCONNECTED -> stringResource(R.string.dash_tap_to_connect)
+ },
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ Spacer(Modifier.height(10.dp))
+ // Transport chip row — USB-C / Wi-Fi / BT with active highlighted
+ Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
+ TransportChip(
+ label = stringResource(R.string.dash_transport_usbc),
+ icon = Icons.Default.Usb,
+ active = activeTransport == TransportType.USB_C,
+ hasCable = usbCableConnected,
+ )
+ TransportChip(
+ label = stringResource(R.string.dash_transport_wifi),
+ icon = Icons.Default.Wifi,
+ active = activeTransport == TransportType.WIFI,
+ )
+ TransportChip(
+ label = stringResource(R.string.dash_transport_bt),
+ icon = Icons.Default.Bluetooth,
+ active = activeTransport == TransportType.BLUETOOTH,
+ dimmed = true, // Phase 03 — not yet available
+ )
+ }
+ }
+ }
+
+ Spacer(Modifier.height(4.dp))
+
+ if (state.pressureHpa != null)
+ QuickStatRow(stringResource(R.string.stat_pressure), "%.1f hPa".format(state.pressureHpa))
+ if (state.gnss != null) {
+ QuickStatRow(stringResource(R.string.stat_altitude), "%.1f m".format(state.gnss.altitude))
+ QuickStatRow(stringResource(R.string.stat_heading), "%.0f°".format(state.gnss.bearingDeg))
+ QuickStatRow(stringResource(R.string.stat_accuracy), "±%.1f m".format(state.gnss.accuracyM))
+ }
+ }
+}
+
+fun formatDuration(totalSeconds: Long): String {
+ val h = totalSeconds / 3600
+ val m = (totalSeconds % 3600) / 60
+ val s = totalSeconds % 60
+ return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%02d:%02d".format(m, s)
+}
+
+@Composable
+private fun StatusCard(
+ title: String,
+ active: Boolean,
+ activeIcon: ImageVector,
+ inactiveIcon: ImageVector,
+ summary: String,
+ badge: String? = null,
+ onClick: (() -> Unit)? = null,
+) {
+ val tint = if (active) Color(0xFF4CAF50) else MaterialTheme.colorScheme.onSurfaceVariant
+ val mod = if (onClick != null) Modifier.fillMaxWidth().clickable { onClick() }
+ else Modifier.fillMaxWidth()
+
+ androidx.compose.material3.Card(
+ modifier = mod,
+ colors = androidx.compose.material3.CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant
+ ),
+ ) {
+ Row(
+ modifier = Modifier.padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Icon(if (active) activeIcon else inactiveIcon, null, tint = tint, modifier = Modifier.size(28.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(title, style = MaterialTheme.typography.labelLarge, color = tint, fontWeight = FontWeight.SemiBold)
+ Text(summary, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ if (badge != null) {
+ androidx.compose.material3.Surface(
+ color = MaterialTheme.colorScheme.primaryContainer,
+ shape = androidx.compose.foundation.shape.RoundedCornerShape(50),
+ ) {
+ Text(
+ badge,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onPrimaryContainer,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
+ )
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Small pill showing a transport option (USB-C / Wi-Fi / BT).
+ * [active] → primary container colour; [dimmed] → grayed out (Phase 03 placeholder).
+ */
+@Composable
+private fun TransportChip(
+ label: String,
+ icon: androidx.compose.ui.graphics.vector.ImageVector,
+ active: Boolean,
+ hasCable: Boolean = false,
+ dimmed: Boolean = false,
+) {
+ val containerColor = when {
+ active -> MaterialTheme.colorScheme.primaryContainer
+ dimmed -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)
+ else -> MaterialTheme.colorScheme.surfaceVariant
+ }
+ val contentColor = when {
+ active -> MaterialTheme.colorScheme.onPrimaryContainer
+ dimmed -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.35f)
+ else -> MaterialTheme.colorScheme.onSurfaceVariant
+ }
+
+ androidx.compose.material3.Surface(
+ color = containerColor,
+ shape = androidx.compose.foundation.shape.RoundedCornerShape(50),
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Icon(icon, null, tint = contentColor, modifier = Modifier.size(12.dp))
+ Text(
+ label,
+ style = MaterialTheme.typography.labelSmall,
+ color = contentColor,
+ fontWeight = if (active) FontWeight.Bold else FontWeight.Normal,
+ )
+ // Cable-presence dot for USB-C
+ if (hasCable && !active) {
+ Icon(
+ Icons.Default.FiberManualRecord, null,
+ tint = Color(0xFF4CAF50),
+ modifier = Modifier.size(6.dp),
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun QuickStatRow(label: String, value: String) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Text(value, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
+ }
+}
diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MapScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MapScreen.kt
new file mode 100644
index 0000000..0f9a251
--- /dev/null
+++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MapScreen.kt
@@ -0,0 +1,138 @@
+package com.hawhamburg.micr0bu.ui.screens
+
+import android.content.Context
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.GpsOff
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import com.hawhamburg.micr0bu.R
+import com.hawhamburg.micr0bu.data.GnssReading
+import org.osmdroid.config.Configuration
+import org.osmdroid.tileprovider.tilesource.TileSourceFactory
+import org.osmdroid.util.GeoPoint
+import org.osmdroid.views.MapView
+import org.osmdroid.views.overlay.Marker
+
+@Composable
+fun MapScreen(gnss: GnssReading?, modifier: Modifier = Modifier) {
+ val context = LocalContext.current
+
+ if (gnss == null) {
+ NoFixPlaceholder(modifier)
+ return
+ }
+
+ val geoPoint = remember(gnss.latitude, gnss.longitude) {
+ GeoPoint(gnss.latitude, gnss.longitude)
+ }
+
+ // Hold a reference so we can manage the OSMDroid lifecycle
+ val mapViewRef = remember { mutableStateOf(null) }
+ val lifecycleOwner = LocalLifecycleOwner.current
+
+ DisposableEffect(lifecycleOwner) {
+ val observer = LifecycleEventObserver { _, event ->
+ when (event) {
+ Lifecycle.Event.ON_RESUME -> mapViewRef.value?.onResume()
+ Lifecycle.Event.ON_PAUSE -> mapViewRef.value?.onPause()
+ else -> {}
+ }
+ }
+ lifecycleOwner.lifecycle.addObserver(observer)
+ onDispose {
+ lifecycleOwner.lifecycle.removeObserver(observer)
+ mapViewRef.value?.onDetach()
+ }
+ }
+
+ Column(modifier = modifier.fillMaxSize()) {
+ // Coordinate strip at the top
+ Text(
+ text = "%.6f°, %.6f° · ±%.0f m".format(
+ gnss.latitude, gnss.longitude, gnss.accuracyM
+ ),
+ style = MaterialTheme.typography.labelMedium,
+ fontWeight = FontWeight.Medium,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ AndroidView(
+ factory = { ctx ->
+ initOsm(ctx)
+ MapView(ctx).apply {
+ setTileSource(TileSourceFactory.MAPNIK)
+ setMultiTouchControls(true)
+ controller.setZoom(17.0)
+ controller.setCenter(geoPoint)
+ mapViewRef.value = this
+ }
+ },
+ update = { mv ->
+ mv.overlays.clear()
+ val marker = Marker(mv).apply {
+ position = geoPoint
+ setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
+ title = stringLabel(context, gnss)
+ }
+ mv.overlays.add(marker)
+ mv.controller.animateTo(geoPoint)
+ mv.invalidate()
+ },
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+}
+
+@Composable
+private fun NoFixPlaceholder(modifier: Modifier) {
+ Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Icon(
+ Icons.Default.GpsOff,
+ contentDescription = null,
+ modifier = Modifier.size(56.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(Modifier.height(12.dp))
+ Text(
+ stringResource(R.string.gnss_no_fix),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+}
+
+private fun initOsm(context: Context) {
+ Configuration.getInstance().apply {
+ load(context, context.getSharedPreferences("osmdroid", Context.MODE_PRIVATE))
+ userAgentValue = context.packageName
+ }
+}
+
+private fun stringLabel(context: Context, gnss: GnssReading): String =
+ context.getString(R.string.map_location_label) +
+ "\n%.5f°, %.5f°".format(gnss.latitude, gnss.longitude)
diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt
new file mode 100644
index 0000000..13559b5
--- /dev/null
+++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt
@@ -0,0 +1,636 @@
+package com.hawhamburg.micr0bu.ui.screens
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.runtime.remember
+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.automirrored.filled.Send
+import androidx.compose.material.icons.filled.Circle
+import androidx.compose.material.icons.filled.Link
+import androidx.compose.material.icons.filled.LinkOff
+import androidx.compose.material.icons.filled.NotificationsActive
+import androidx.compose.material.icons.filled.VerticalAlignBottom
+import androidx.compose.material3.Badge
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+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.res.stringResource
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.hawhamburg.micr0bu.R
+import com.hawhamburg.micr0bu.data.mqtt.MessageDirection
+import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
+import com.hawhamburg.micr0bu.data.mqtt.MqttMessage
+import com.hawhamburg.micr0bu.viewmodel.MqttViewModel
+import org.json.JSONArray
+import org.json.JSONException
+import org.json.JSONObject
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+private val timeFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
+
+private val ConnectedGreen = Color(0xFF4CAF50)
+private val ConnectedGreenBg = Color(0xFF1B3A1B)
+private val ConnectingAmber = Color(0xFFFFC107)
+private val ConnectingAmberBg = Color(0xFF3A2D00)
+private val ErrorRed = Color(0xFFFF5252)
+private val ErrorRedBg = Color(0xFF3A1010)
+private val DisconnectedGray = Color(0xFF8B949E)
+private val DisconnectedGrayBg = Color(0xFF1C2733)
+private val DenmRed = Color(0xFFE53935)
+private val DenmRedBg = Color(0xFF3A0A0A)
+private val DenmAmber = Color(0xFFFFC107)
+
+@Composable
+fun MqttTopicViewerScreen(
+ viewModel: MqttViewModel = hiltViewModel(),
+) {
+ val connectionState by viewModel.connectionState.collectAsState()
+ val topicMessages by viewModel.topicMessages.collectAsState()
+ val selectedTopic by viewModel.selectedTopic.collectAsState()
+ val autoScroll by viewModel.autoScroll.collectAsState()
+ val denmActive by viewModel.denmActive.collectAsState()
+ val lastDenmPayload by viewModel.lastDenmPayload.collectAsState()
+
+ // Sort: sys/ topics first (heartbeat/health), then alphabetical
+ val sortedTopics = topicMessages.keys.sortedWith(
+ compareBy({ if (it.startsWith("sys/")) 0 else 1 }, { it })
+ )
+
+ BackHandler(enabled = selectedTopic != null) {
+ viewModel.selectTopic(null)
+ }
+
+ val isConnected = connectionState == MqttConnectionState.CONNECTED
+ val isConnecting = connectionState == MqttConnectionState.CONNECTING
+
+ Column(modifier = Modifier.fillMaxSize()) {
+
+ // ── Inline header — replaces the removed inner TopAppBar ──────────────
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 4.dp, end = 4.dp, top = 4.dp, bottom = 2.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (selectedTopic != null) {
+ IconButton(onClick = { viewModel.selectTopic(null) }) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
+ }
+ Text(
+ text = selectedTopic!!.substringAfterLast('/'),
+ style = MaterialTheme.typography.titleSmall,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f),
+ )
+ IconButton(
+ onClick = { viewModel.setAutoScroll(!autoScroll) },
+ colors = IconButtonDefaults.iconButtonColors(
+ contentColor = if (autoScroll) ConnectedGreen
+ else MaterialTheme.colorScheme.onSurfaceVariant,
+ ),
+ ) {
+ Icon(Icons.Default.VerticalAlignBottom,
+ contentDescription = stringResource(R.string.mqtt_auto_scroll))
+ }
+ } else {
+ Spacer(Modifier.weight(1f))
+ }
+
+ ConnectionChip(connectionState)
+ Spacer(Modifier.width(2.dp))
+ IconButton(
+ onClick = { if (isConnected || isConnecting) viewModel.disconnect() else viewModel.connect() },
+ colors = IconButtonDefaults.iconButtonColors(
+ contentColor = if (isConnected) ErrorRed else ConnectedGreen,
+ ),
+ ) {
+ Icon(
+ imageVector = if (isConnected || isConnecting) Icons.Default.LinkOff
+ else Icons.Default.Link,
+ contentDescription = if (isConnected || isConnecting)
+ stringResource(R.string.mqtt_disconnect)
+ else
+ stringResource(R.string.mqtt_connect),
+ )
+ }
+ }
+
+ HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f))
+
+ if (selectedTopic == null) {
+ TopicListPane(
+ topics = sortedTopics,
+ topicMessages = topicMessages,
+ connectionState = connectionState,
+ denmActive = denmActive,
+ lastDenmPayload = lastDenmPayload,
+ onSelectTopic = { viewModel.selectTopic(it) },
+ onSendDenm = { viewModel.sendDenm() },
+ onStopDenm = { viewModel.stopDenm() },
+ )
+ } else {
+ MessageDetailPane(
+ messages = topicMessages[selectedTopic] ?: emptyList(),
+ autoScroll = autoScroll,
+ )
+ }
+ }
+}
+
+// ── Topic list ────────────────────────────────────────────────────────────────
+
+@Composable
+private fun TopicListPane(
+ topics: List,
+ topicMessages: Map>,
+ connectionState: MqttConnectionState,
+ denmActive: Boolean,
+ lastDenmPayload: String?,
+ onSelectTopic: (String) -> Unit,
+ onSendDenm: () -> Unit,
+ onStopDenm: () -> Unit,
+) {
+ val isConnected = connectionState == MqttConnectionState.CONNECTED
+
+ Column(modifier = Modifier.fillMaxSize()) {
+
+ // ── DENM TX Control card ────────────────────────────────────────────
+ DenmTxCard(
+ isConnected = isConnected,
+ denmActive = denmActive,
+ lastDenmPayload = lastDenmPayload,
+ onSendDenm = onSendDenm,
+ onStopDenm = onStopDenm,
+ )
+
+ HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
+
+ // ── Topic rows ──────────────────────────────────────────────────────
+ if (topics.isEmpty()) {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(
+ stringResource(R.string.mqtt_no_topics),
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(Modifier.height(6.dp))
+ Text(
+ stringResource(R.string.mqtt_no_topics_hint),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
+ )
+ }
+ }
+ } else {
+ LazyColumn(modifier = Modifier.fillMaxSize()) {
+ items(topics, key = { it }) { topic ->
+ val msgs = topicMessages[topic] ?: emptyList()
+ val lastMsg = msgs.lastOrNull()
+ TopicRow(
+ topic = topic,
+ messageCount = msgs.size,
+ lastTimestamp = lastMsg?.timestamp,
+ onClick = { onSelectTopic(topic) },
+ )
+ HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun TopicRow(
+ topic: String,
+ messageCount: Int,
+ lastTimestamp: Long?,
+ onClick: () -> Unit,
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick)
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ val dotColor = if (topic.startsWith("sys/")) ConnectedGreen
+ else MaterialTheme.colorScheme.primary
+ Icon(
+ Icons.Default.Circle,
+ contentDescription = null,
+ tint = dotColor,
+ modifier = Modifier.size(8.dp),
+ )
+ Spacer(Modifier.width(12.dp))
+
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = topic,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Medium,
+ fontFamily = FontFamily.Monospace,
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ if (lastTimestamp != null) {
+ Text(
+ text = timeFormat.format(Date(lastTimestamp)),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+
+ Spacer(Modifier.width(8.dp))
+ Badge(
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
+ ) {
+ Text(
+ text = messageCount.toString(),
+ style = MaterialTheme.typography.labelSmall,
+ )
+ }
+ }
+}
+
+// ── Message detail ────────────────────────────────────────────────────────────
+
+@Composable
+private fun MessageDetailPane(
+ messages: List,
+ autoScroll: Boolean,
+) {
+ if (messages.isEmpty()) {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text(
+ stringResource(R.string.mqtt_no_messages),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ return
+ }
+
+ val reversed = remember(messages) { messages.reversed() }
+ val listState = rememberLazyListState()
+
+ LaunchedEffect(messages.size) {
+ if (autoScroll) listState.scrollToItem(0)
+ }
+
+ LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
+ items(reversed, key = { "${it.topic}-${it.timestamp}" }) { msg ->
+ MessageRow(msg)
+ HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f))
+ }
+ }
+}
+
+@Composable
+private fun MessageRow(msg: MqttMessage) {
+ // Show TX badge for all outbound messages; RX badge only on DENM topics
+ // (to avoid noisy badges on high-frequency CAM/SPAT traffic).
+ val showBadge = msg.direction == MessageDirection.TX ||
+ (msg.direction == MessageDirection.RX && msg.topic.contains("denm", ignoreCase = true))
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp, vertical = 8.dp),
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ Text(
+ text = timeFormat.format(Date(msg.timestamp)),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ fontFamily = FontFamily.Monospace,
+ )
+ if (showBadge) DirectionBadge(msg.direction)
+ }
+ Spacer(Modifier.height(4.dp))
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(6.dp))
+ .background(MaterialTheme.colorScheme.surfaceVariant),
+ ) {
+ Text(
+ text = prettyPrintJson(msg.payload),
+ style = MaterialTheme.typography.bodySmall.copy(
+ fontFamily = FontFamily.Monospace,
+ fontSize = 11.sp,
+ lineHeight = 16.sp,
+ ),
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.padding(8.dp),
+ )
+ }
+ }
+}
+
+// ── DENM TX card ──────────────────────────────────────────────────────────────
+
+@Composable
+private fun DenmTxCard(
+ isConnected: Boolean,
+ denmActive: Boolean,
+ lastDenmPayload: String?,
+ onSendDenm: () -> Unit,
+ onStopDenm: () -> Unit,
+) {
+ var showLastTx by rememberSaveable { mutableStateOf(false) }
+
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp, vertical = 8.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = if (denmActive) DenmRedBg
+ else MaterialTheme.colorScheme.surfaceVariant,
+ ),
+ ) {
+ Column(modifier = Modifier.padding(12.dp)) {
+
+ // Card header row
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Icon(
+ Icons.Default.NotificationsActive,
+ contentDescription = null,
+ tint = if (denmActive) DenmRed
+ else MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(16.dp),
+ )
+ Text(
+ stringResource(R.string.mqtt_denm_tx_title),
+ style = MaterialTheme.typography.labelLarge,
+ color = if (denmActive) DenmRed
+ else MaterialTheme.colorScheme.onSurface,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Spacer(Modifier.weight(1f))
+ if (lastDenmPayload != null) {
+ Text(
+ text = if (showLastTx) stringResource(R.string.mqtt_denm_hide)
+ else stringResource(R.string.mqtt_denm_show),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.clickable { showLastTx = !showLastTx },
+ )
+ }
+ }
+
+ Spacer(Modifier.height(6.dp))
+
+ // Use-case label
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ Surface(
+ color = if (denmActive) DenmRed.copy(alpha = 0.2f)
+ else MaterialTheme.colorScheme.outline.copy(alpha = 0.15f),
+ shape = RoundedCornerShape(4.dp),
+ ) {
+ Text(
+ "hln-sv",
+ style = MaterialTheme.typography.labelSmall,
+ color = if (denmActive) DenmRed
+ else MaterialTheme.colorScheme.onSurfaceVariant,
+ fontFamily = FontFamily.Monospace,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
+ )
+ }
+ Text(
+ stringResource(R.string.mqtt_denm_use_case_desc),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+
+ Spacer(Modifier.height(10.dp))
+
+ // Body
+ if (!isConnected) {
+ Text(
+ stringResource(R.string.mqtt_denm_not_connected),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ } else if (denmActive) {
+ // Active state — pulsing status + Stop button
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ modifier = Modifier.padding(bottom = 8.dp),
+ ) {
+ Icon(
+ Icons.Default.Circle,
+ contentDescription = null,
+ tint = DenmRed,
+ modifier = Modifier.size(8.dp),
+ )
+ Text(
+ stringResource(R.string.mqtt_denm_active),
+ style = MaterialTheme.typography.bodySmall,
+ color = DenmRed,
+ )
+ }
+ Button(
+ onClick = onStopDenm,
+ modifier = Modifier.fillMaxWidth(),
+ colors = ButtonDefaults.buttonColors(containerColor = DenmRed),
+ ) {
+ Text(stringResource(R.string.mqtt_stop_denm))
+ }
+ } else {
+ // Idle state — Send button + disabled Stop
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Button(
+ onClick = onSendDenm,
+ modifier = Modifier.weight(1f),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primary,
+ ),
+ ) {
+ Icon(Icons.AutoMirrored.Filled.Send, null, modifier = Modifier.size(14.dp))
+ Spacer(Modifier.width(4.dp))
+ Text(stringResource(R.string.mqtt_send_denm))
+ }
+ OutlinedButton(
+ onClick = {},
+ modifier = Modifier.weight(1f),
+ enabled = false,
+ ) {
+ Text(stringResource(R.string.mqtt_stop_denm))
+ }
+ }
+ Text(
+ stringResource(R.string.mqtt_denm_hint),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
+ )
+ }
+
+ // Collapsible last-TX payload
+ if (showLastTx && lastDenmPayload != null) {
+ Spacer(Modifier.height(8.dp))
+ HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f))
+ Spacer(Modifier.height(8.dp))
+ Text(
+ stringResource(R.string.mqtt_last_tx),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ fontFamily = FontFamily.Monospace,
+ )
+ Spacer(Modifier.height(4.dp))
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(4.dp))
+ .background(MaterialTheme.colorScheme.surface),
+ ) {
+ Text(
+ text = prettyPrintJson(lastDenmPayload),
+ style = MaterialTheme.typography.bodySmall.copy(
+ fontFamily = FontFamily.Monospace,
+ fontSize = 10.sp,
+ lineHeight = 14.sp,
+ ),
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.padding(8.dp),
+ )
+ }
+ }
+ }
+ }
+}
+
+// ── Direction badge ───────────────────────────────────────────────────────────
+
+@Composable
+private fun DirectionBadge(direction: MessageDirection) {
+ val (text, fg, bg) = when (direction) {
+ MessageDirection.TX -> Triple("TX", DenmAmber, Color(0xFF3A2D00))
+ MessageDirection.RX -> Triple("RX", ConnectedGreen, ConnectedGreenBg)
+ }
+ Surface(color = bg, shape = RoundedCornerShape(3.dp)) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.labelSmall,
+ color = fg,
+ fontWeight = FontWeight.Bold,
+ fontFamily = FontFamily.Monospace,
+ modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp),
+ )
+ }
+}
+
+// ── Connection status chip ────────────────────────────────────────────────────
+
+@Composable
+private fun ConnectionChip(state: MqttConnectionState) {
+ val (label, fg, bg) = when (state) {
+ MqttConnectionState.CONNECTED -> Triple("CONNECTED", ConnectedGreen, ConnectedGreenBg)
+ MqttConnectionState.CONNECTING -> Triple("CONNECTING…", ConnectingAmber, ConnectingAmberBg)
+ MqttConnectionState.ERROR -> Triple("ERROR", ErrorRed, ErrorRedBg)
+ MqttConnectionState.DISCONNECTED -> Triple("OFFLINE", DisconnectedGray, DisconnectedGrayBg)
+ }
+ val animatedBg by animateColorAsState(bg, animationSpec = tween(400), label = "chipBg")
+ val animatedFg by animateColorAsState(fg, animationSpec = tween(400), label = "chipFg")
+
+ Surface(
+ color = animatedBg,
+ shape = RoundedCornerShape(50),
+ modifier = Modifier.height(28.dp),
+ ) {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier.padding(horizontal = 10.dp),
+ ) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.labelSmall,
+ color = animatedFg,
+ fontWeight = FontWeight.SemiBold,
+ fontFamily = FontFamily.Monospace,
+ letterSpacing = 0.5.sp,
+ )
+ }
+ }
+}
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+private fun prettyPrintJson(raw: String): String {
+ val trimmed = raw.trim()
+ return try {
+ when {
+ trimmed.startsWith("{") -> JSONObject(trimmed).toString(2)
+ trimmed.startsWith("[") -> JSONArray(trimmed).toString(2)
+ else -> raw
+ }
+ } catch (_: JSONException) {
+ raw
+ }
+}
diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/RecordingScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/RecordingScreen.kt
new file mode 100644
index 0000000..56463e1
--- /dev/null
+++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/RecordingScreen.kt
@@ -0,0 +1,234 @@
+package com.hawhamburg.micr0bu.ui.screens
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.FiberManualRecord
+import androidx.compose.material.icons.filled.TableChart
+import androidx.compose.material.icons.filled.Stop
+import androidx.compose.material3.Button
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+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.unit.dp
+import com.hawhamburg.micr0bu.R
+import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
+import com.hawhamburg.micr0bu.service.TripServiceBus
+import com.hawhamburg.micr0bu.viewmodel.SensorUiState
+
+@Composable
+fun RecordingScreen(
+ state: SensorUiState,
+ mqttConnectionState: MqttConnectionState,
+ tripServiceState: TripServiceBus.State,
+ onToggleRecording: () -> Unit,
+ onOpenSessionLog: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Text(stringResource(R.string.rec_title), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
+
+ Spacer(Modifier.height(8.dp))
+
+ Text(
+ text = if (state.isRecording) formatDuration(state.recordingDurationSeconds) else "--:--",
+ style = MaterialTheme.typography.displayMedium,
+ fontFamily = FontFamily.Monospace,
+ fontWeight = FontWeight.Bold,
+ color = if (state.isRecording) Color(0xFFFF5252) else MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ )
+
+ Text(
+ text = if (state.isRecording)
+ stringResource(R.string.rec_samples_captured, state.recordingSampleCount)
+ else
+ stringResource(R.string.rec_ready),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ Spacer(Modifier.height(8.dp))
+
+ Button(
+ onClick = onToggleRecording,
+ shape = CircleShape,
+ modifier = Modifier.size(100.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = if (state.isRecording) Color(0xFF3A1010)
+ else MaterialTheme.colorScheme.primaryContainer,
+ ),
+ ) {
+ Icon(
+ imageVector = if (state.isRecording) Icons.Default.Stop else Icons.Default.FiberManualRecord,
+ contentDescription = if (state.isRecording) stringResource(R.string.rec_button_stop)
+ else stringResource(R.string.rec_button_rec),
+ tint = if (state.isRecording) Color(0xFFFF5252)
+ else MaterialTheme.colorScheme.onPrimaryContainer,
+ modifier = Modifier.size(40.dp),
+ )
+ }
+
+ Text(
+ text = if (state.isRecording) stringResource(R.string.rec_button_stop)
+ else stringResource(R.string.rec_button_rec),
+ style = MaterialTheme.typography.labelLarge,
+ fontWeight = FontWeight.Bold,
+ color = if (state.isRecording) Color(0xFFFF5252) else MaterialTheme.colorScheme.primary,
+ )
+
+ Spacer(Modifier.height(8.dp))
+
+ // ── Event Detection Counters ─────────────────────────────────────────
+ if (state.isRecording || tripServiceState.isRecording) {
+ Text(
+ stringResource(R.string.rec_events_detected),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier.align(Alignment.Start),
+ )
+
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer),
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ ) {
+ EventCountBadge(
+ label = stringResource(R.string.rec_event_braking),
+ count = tripServiceState.brakingCount,
+ color = Color(0xFFFF5252),
+ )
+ EventCountBadge(
+ label = stringResource(R.string.rec_event_turning),
+ count = tripServiceState.turningCount,
+ color = Color(0xFFFFB300),
+ )
+ EventCountBadge(
+ label = stringResource(R.string.rec_event_stopping),
+ count = tripServiceState.stoppingCount,
+ color = Color(0xFF42A5F5),
+ )
+ }
+ }
+
+ Spacer(Modifier.height(4.dp))
+ }
+
+ // ── CSV Session Log shortcut ─────────────────────────────────────────
+ if (!state.isRecording) {
+ OutlinedButton(
+ onClick = onOpenSessionLog,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Icon(
+ Icons.Default.TableChart,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ )
+ Spacer(Modifier.size(8.dp))
+ Text(stringResource(R.string.rec_open_session_log))
+ }
+ }
+
+ // ── Active Streams ────────────────────────────────────────────────────
+ Text(
+ stringResource(R.string.rec_active_streams),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier.align(Alignment.Start),
+ )
+
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
+ ) {
+ Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ StreamRow(stringResource(R.string.stream_gnss), state.gnssEnabled && state.gnss != null)
+ HorizontalDivider(color = MaterialTheme.colorScheme.outline)
+ StreamRow(stringResource(R.string.stream_accel), state.accelEnabled && state.accel != null)
+ HorizontalDivider(color = MaterialTheme.colorScheme.outline)
+ StreamRow(stringResource(R.string.stream_gyro), state.gyroEnabled && state.gyro != null)
+ HorizontalDivider(color = MaterialTheme.colorScheme.outline)
+ StreamRow(stringResource(R.string.stream_magnet), state.magnetEnabled && state.magnet != null)
+ HorizontalDivider(color = MaterialTheme.colorScheme.outline)
+ StreamRow(stringResource(R.string.stream_baro), state.barometerEnabled && state.pressureHpa != null)
+ HorizontalDivider(color = MaterialTheme.colorScheme.outline)
+ StreamRow(stringResource(R.string.stream_obu), mqttConnectionState == MqttConnectionState.CONNECTED)
+ HorizontalDivider(color = MaterialTheme.colorScheme.outline)
+ StreamRow(stringResource(R.string.rec_stream_event_detection), tripServiceState.isRecording)
+ }
+ }
+ }
+}
+
+@Composable
+private fun EventCountBadge(label: String, count: Int, color: Color) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(
+ text = count.toString(),
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold,
+ fontFamily = FontFamily.Monospace,
+ color = color,
+ )
+ Spacer(Modifier.height(2.dp))
+ Text(
+ text = label,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSecondaryContainer,
+ )
+ }
+}
+
+@Composable
+private fun StreamRow(label: String, active: Boolean) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp, horizontal = 4.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(label, style = MaterialTheme.typography.bodyMedium)
+ Text(
+ text = if (active) "● LIVE" else "○ OFF",
+ style = MaterialTheme.typography.labelMedium,
+ fontFamily = FontFamily.Monospace,
+ color = if (active) Color(0xFF4CAF50) else MaterialTheme.colorScheme.onSurfaceVariant,
+ fontWeight = FontWeight.SemiBold,
+ )
+ }
+}
diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/SensorScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/SensorScreen.kt
new file mode 100644
index 0000000..b73305e
--- /dev/null
+++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/SensorScreen.kt
@@ -0,0 +1,118 @@
+package com.hawhamburg.micr0bu.ui.screens
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.hawhamburg.micr0bu.R
+import com.hawhamburg.micr0bu.data.GnssReading
+import com.hawhamburg.micr0bu.data.ImuReading
+import com.hawhamburg.micr0bu.viewmodel.SensorUiState
+
+@Composable
+fun SensorScreen(state: SensorUiState, modifier: Modifier = Modifier) {
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text(stringResource(R.string.sensor_monitor_title), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
+
+ GnssCard(state.gnss)
+ ImuCard(stringResource(R.string.sensor_accel), "m/s²", state.accel)
+ ImuCard(stringResource(R.string.sensor_gyro), "rad/s", state.gyro, available = state.hasGyroscope)
+ ImuCard(stringResource(R.string.sensor_magnet), "µT", state.magnet, available = state.hasMagnetometer)
+ BarometerCard(state.pressureHpa, state.hasBarometer)
+ }
+}
+
+@Composable
+private fun GnssCard(gnss: GnssReading?) {
+ SensorCard(title = stringResource(R.string.sensor_gnss)) {
+ if (gnss == null) {
+ UnavailableRow(stringResource(R.string.sensor_waiting))
+ } else {
+ DataRow(stringResource(R.string.sensor_latitude), "%.7f°".format(gnss.latitude))
+ DataRow(stringResource(R.string.sensor_longitude), "%.7f°".format(gnss.longitude))
+ DataRow(stringResource(R.string.sensor_altitude), "%.1f m".format(gnss.altitude))
+ DataRow(stringResource(R.string.sensor_speed), "%.2f m/s (%.1f km/h)".format(gnss.speedMs, gnss.speedMs * 3.6f))
+ DataRow(stringResource(R.string.sensor_heading), "%.1f°".format(gnss.bearingDeg))
+ DataRow(stringResource(R.string.sensor_accuracy), "±%.1f m".format(gnss.accuracyM))
+ }
+ }
+}
+
+@Composable
+private fun ImuCard(title: String, unit: String, reading: ImuReading?, available: Boolean = true) {
+ SensorCard(title = title) {
+ when {
+ !available -> UnavailableRow(stringResource(R.string.sensor_not_present))
+ reading == null -> UnavailableRow(stringResource(R.string.sensor_initialising))
+ else -> {
+ DataRow("X", "%.4f $unit".format(reading.x))
+ DataRow("Y", "%.4f $unit".format(reading.y))
+ DataRow("Z", "%.4f $unit".format(reading.z))
+ }
+ }
+ }
+}
+
+@Composable
+private fun BarometerCard(pressureHpa: Float?, available: Boolean) {
+ SensorCard(title = stringResource(R.string.sensor_baro)) {
+ when {
+ !available -> UnavailableRow(stringResource(R.string.sensor_not_present))
+ pressureHpa == null -> UnavailableRow(stringResource(R.string.sensor_initialising))
+ else -> {
+ DataRow(stringResource(R.string.sensor_pressure), "%.2f hPa".format(pressureHpa))
+ val alt = 44330.0 * (1.0 - Math.pow((pressureHpa / 1013.25).toDouble(), 0.1903))
+ DataRow(stringResource(R.string.sensor_est_altitude), "%.1f m".format(alt))
+ }
+ }
+ }
+}
+
+@Composable
+private fun SensorCard(title: String, content: @Composable () -> Unit) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
+ ) {
+ Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ Text(title, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.primary)
+ HorizontalDivider(color = MaterialTheme.colorScheme.outline)
+ content()
+ }
+ }
+}
+
+@Composable
+private fun DataRow(label: String, value: String) {
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
+ Text(label, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Text(value, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurface)
+ }
+}
+
+@Composable
+private fun UnavailableRow(message: String) {
+ Text(message, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+}
diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/SessionLogScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/SessionLogScreen.kt
new file mode 100644
index 0000000..18403a1
--- /dev/null
+++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/SessionLogScreen.kt
@@ -0,0 +1,163 @@
+package com.hawhamburg.micr0bu.ui.screens
+
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Download
+import androidx.compose.material.icons.filled.FolderOpen
+import androidx.compose.material.icons.filled.Share
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.hawhamburg.micr0bu.R
+import com.hawhamburg.micr0bu.data.readSessionCsvContent
+import com.hawhamburg.micr0bu.data.sessionFileName
+import com.hawhamburg.micr0bu.data.shareSessionCsv
+import com.hawhamburg.micr0bu.viewmodel.RecordingSession
+import com.hawhamburg.micr0bu.viewmodel.SensorUiState
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+private val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
+
+@Composable
+fun SessionLogScreen(
+ state: SensorUiState,
+ onDeleteSession: (String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val context = LocalContext.current
+ var pendingDelete by remember { mutableStateOf(null) }
+ var pendingSave by remember { mutableStateOf(null) }
+
+ val saveLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.CreateDocument("text/csv")
+ ) { uri ->
+ uri ?: return@rememberLauncherForActivityResult
+ val session = pendingSave ?: return@rememberLauncherForActivityResult
+ context.contentResolver.openOutputStream(uri)?.use { it.write(readSessionCsvContent(context, session).toByteArray(Charsets.UTF_8)) }
+ pendingSave = null
+ }
+
+ pendingDelete?.let { session ->
+ AlertDialog(
+ onDismissRequest = { pendingDelete = null },
+ title = { Text(stringResource(R.string.log_delete_dialog_title)) },
+ text = { Text(stringResource(R.string.log_delete_dialog_text, sdf.format(Date(session.startTime)))) },
+ confirmButton = {
+ TextButton(onClick = { onDeleteSession(session.id); pendingDelete = null }) {
+ Text(stringResource(R.string.log_delete_confirm), color = MaterialTheme.colorScheme.error)
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { pendingDelete = null }) { Text(stringResource(R.string.log_cancel)) }
+ },
+ )
+ }
+
+ Column(modifier = modifier.fillMaxSize().padding(16.dp)) {
+ Text(stringResource(R.string.log_title), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
+ Text(
+ if (state.sessions.size == 1) stringResource(R.string.log_stored_one, state.sessions.size)
+ else stringResource(R.string.log_stored_other, state.sessions.size),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ Spacer(Modifier.height(12.dp))
+
+ if (state.sessions.isEmpty()) {
+ EmptyState()
+ } else {
+ LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ itemsIndexed(state.sessions, key = { _, s -> s.id }) { index, session ->
+ SessionCard(
+ session = session,
+ index = state.sessions.size - index,
+ onShare = { shareSessionCsv(context, session) },
+ onSaveToDevice = { pendingSave = session; saveLauncher.launch(sessionFileName(session)) },
+ onDelete = { pendingDelete = session },
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun SessionCard(
+ session: RecordingSession,
+ index: Int,
+ onShare: () -> Unit,
+ onSaveToDevice: () -> Unit,
+ onDelete: () -> Unit,
+) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
+ ) {
+ Row(modifier = Modifier.padding(start = 12.dp, top = 12.dp, bottom = 12.dp, end = 4.dp), verticalAlignment = Alignment.CenterVertically) {
+ Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ Text(stringResource(R.string.log_session_number, index), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.primary)
+ Text(sdf.format(Date(session.startTime)), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.padding(top = 4.dp)) {
+ StatChip("⏱ ${formatDuration(session.durationSeconds)}")
+ StatChip("📊 ${session.sampleCount} samples")
+ }
+ }
+ IconButton(onClick = onSaveToDevice) {
+ Icon(Icons.Default.Download, contentDescription = stringResource(R.string.log_save_cd), tint = MaterialTheme.colorScheme.secondary)
+ }
+ IconButton(onClick = onShare) {
+ Icon(Icons.Default.Share, contentDescription = stringResource(R.string.log_share_cd), tint = MaterialTheme.colorScheme.primary)
+ }
+ IconButton(onClick = onDelete) {
+ Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.log_delete_cd), tint = MaterialTheme.colorScheme.error)
+ }
+ }
+ }
+}
+
+@Composable
+private fun StatChip(text: String) {
+ Text(text, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+}
+
+@Composable
+private fun EmptyState() {
+ Column(modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
+ Icon(Icons.Default.FolderOpen, null, modifier = Modifier.size(64.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
+ Spacer(Modifier.height(12.dp))
+ Text(stringResource(R.string.log_empty_title), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Text(stringResource(R.string.log_empty_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+}
diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/SettingsScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/SettingsScreen.kt
new file mode 100644
index 0000000..84fc73f
--- /dev/null
+++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/SettingsScreen.kt
@@ -0,0 +1,536 @@
+package com.hawhamburg.micr0bu.ui.screens
+
+import androidx.appcompat.app.AppCompatDelegate
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.KeyboardArrowRight
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.unit.dp
+import androidx.core.os.LocaleListCompat
+import com.hawhamburg.micr0bu.R
+import com.hawhamburg.micr0bu.data.mqtt.MqttPrefs
+import com.hawhamburg.micr0bu.viewmodel.SensorUiState
+
+private data class LangOption(val tag: String, val flag: String, val labelRes: Int)
+
+private val languages = listOf(
+ LangOption("en", "🇬🇧", R.string.lang_english),
+ LangOption("de", "🇩🇪", R.string.lang_german),
+)
+
+// ── Top-level settings menu ─────────────────────────────────────────────────
+
+@Composable
+fun SettingsScreen(
+ onOpenLanguage: () -> Unit,
+ onOpenAppearance: () -> Unit,
+ onOpenSensors: () -> Unit,
+ onOpenUnits: () -> Unit,
+ onOpenConnection: () -> Unit,
+ onOpenMqtt: () -> Unit,
+ onOpenDeveloper: () -> Unit,
+ onOpenAbout: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text(
+ stringResource(R.string.settings_title),
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold,
+ )
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
+ ) {
+ Column {
+ MenuRow(stringResource(R.string.settings_language), onOpenLanguage)
+ RowDivider()
+ MenuRow(stringResource(R.string.settings_appearance), onOpenAppearance)
+ RowDivider()
+ MenuRow(stringResource(R.string.settings_sensors), onOpenSensors)
+ RowDivider()
+ MenuRow(stringResource(R.string.settings_units), onOpenUnits)
+ RowDivider()
+ MenuRow(stringResource(R.string.settings_connection), onOpenConnection)
+ RowDivider()
+ MenuRow(stringResource(R.string.settings_mqtt_section), onOpenMqtt)
+ RowDivider()
+ MenuRow(stringResource(R.string.settings_developer), onOpenDeveloper)
+ RowDivider()
+ MenuRow(stringResource(R.string.settings_about), onOpenAbout)
+ }
+ }
+ }
+}
+
+@Composable
+private fun MenuRow(label: String, onClick: () -> Unit) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick)
+ .padding(horizontal = 16.dp, vertical = 14.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(label, style = MaterialTheme.typography.bodyMedium)
+ Icon(
+ Icons.Default.KeyboardArrowRight,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+}
+
+// ── Sub-screen shell ────────────────────────────────────────────────────────
+
+@Composable
+private fun SubScreen(title: String, onBack: () -> Unit, content: @Composable () -> Unit) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(top = 4.dp),
+ ) {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
+ }
+ Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
+ }
+ content()
+ Spacer(Modifier.height(8.dp))
+ }
+}
+
+// ── Sub-screens ─────────────────────────────────────────────────────────────
+
+@Composable
+fun ConnectionSettingsScreen(
+ mqttPrefs: MqttPrefs,
+ onMqttPrefsChange: (MqttPrefs) -> Unit,
+ onBack: () -> Unit,
+) {
+ SubScreen(stringResource(R.string.settings_connection), onBack) {
+ SectionCard {
+ // Active transport selector
+ Text(
+ stringResource(R.string.settings_usb_transport),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 8.dp),
+ )
+ Spacer(Modifier.height(6.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ val isUsb = mqttPrefs.activeTransport == com.hawhamburg.micr0bu.data.mqtt.MqttPrefs.TRANSPORT_USB_C
+ OutlinedButton(
+ onClick = { onMqttPrefsChange(mqttPrefs.copy(activeTransport = com.hawhamburg.micr0bu.data.mqtt.MqttPrefs.TRANSPORT_USB_C)) },
+ modifier = Modifier.weight(1f),
+ colors = ButtonDefaults.outlinedButtonColors(
+ containerColor = if (isUsb) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
+ contentColor = if (isUsb) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
+ ),
+ ) { Text(stringResource(R.string.settings_transport_usbc), fontWeight = if (isUsb) FontWeight.Bold else FontWeight.Normal) }
+
+ OutlinedButton(
+ onClick = { onMqttPrefsChange(mqttPrefs.copy(activeTransport = com.hawhamburg.micr0bu.data.mqtt.MqttPrefs.TRANSPORT_WIFI)) },
+ modifier = Modifier.weight(1f),
+ colors = ButtonDefaults.outlinedButtonColors(
+ containerColor = if (!isUsb) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
+ contentColor = if (!isUsb) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
+ ),
+ ) { Text(stringResource(R.string.settings_transport_wifi), fontWeight = if (!isUsb) FontWeight.Bold else FontWeight.Normal) }
+ }
+ }
+
+ SectionCard {
+ // USB-C auto-detect toggle
+ SettingToggleRow(
+ label = stringResource(R.string.settings_usb_auto_detect),
+ checked = mqttPrefs.usbAutoDetect,
+ onCheckedChange = { onMqttPrefsChange(mqttPrefs.copy(usbAutoDetect = it)) },
+ )
+ Divider()
+ // Manual IP override
+ Spacer(Modifier.height(4.dp))
+ var usbIp by remember { mutableStateOf(mqttPrefs.usbIpOverride) }
+ MqttTextField(
+ label = stringResource(R.string.settings_usb_manual_ip),
+ value = usbIp,
+ placeholder = com.hawhamburg.micr0bu.data.mqtt.MqttPrefs.DEFAULT_USB_IP,
+ keyboardType = KeyboardType.Uri,
+ onValueChange = { usbIp = it },
+ onFocusLost = { onMqttPrefsChange(mqttPrefs.copy(usbIpOverride = usbIp)) },
+ )
+ Spacer(Modifier.height(4.dp))
+ }
+ }
+}
+
+@Composable
+fun LanguageSettingsScreen(onBack: () -> Unit) {
+ SubScreen(stringResource(R.string.settings_language), onBack) {
+ SectionCard { LanguageSection() }
+ }
+}
+
+@Composable
+fun AppearanceSettingsScreen(
+ darkTheme: Boolean,
+ onDarkTheme: (Boolean) -> Unit,
+ onBack: () -> Unit,
+) {
+ SubScreen(stringResource(R.string.settings_appearance), onBack) {
+ SectionCard {
+ SettingToggleRow(stringResource(R.string.settings_dark_theme), darkTheme, onDarkTheme)
+ }
+ }
+}
+
+@Composable
+fun SensorsSettingsScreen(
+ state: SensorUiState,
+ onAccelEnabled: (Boolean) -> Unit,
+ onGyroEnabled: (Boolean) -> Unit,
+ onMagnetEnabled: (Boolean) -> Unit,
+ onBarometerEnabled: (Boolean) -> Unit,
+ onGnssEnabled: (Boolean) -> Unit,
+ onBack: () -> Unit,
+) {
+ SubScreen(stringResource(R.string.settings_sensors), onBack) {
+ SectionCard {
+ SettingToggleRow(stringResource(R.string.settings_gnss), state.gnssEnabled, onGnssEnabled)
+ Divider()
+ SettingToggleRow(stringResource(R.string.settings_accel), state.accelEnabled, onAccelEnabled)
+ Divider()
+ SettingToggleRow(stringResource(R.string.settings_gyro), state.gyroEnabled, onGyroEnabled, enabled = state.hasGyroscope)
+ Divider()
+ SettingToggleRow(stringResource(R.string.settings_magnet), state.magnetEnabled, onMagnetEnabled, enabled = state.hasMagnetometer)
+ Divider()
+ SettingToggleRow(stringResource(R.string.settings_baro), state.barometerEnabled, onBarometerEnabled, enabled = state.hasBarometer)
+ }
+ }
+}
+
+@Composable
+fun UnitsSettingsScreen(
+ useMetric: Boolean,
+ onUseMetric: (Boolean) -> Unit,
+ onBack: () -> Unit,
+) {
+ SubScreen(stringResource(R.string.settings_units), onBack) {
+ SectionCard {
+ SettingToggleRow(stringResource(R.string.settings_metric), useMetric, onUseMetric)
+ }
+ }
+}
+
+@Composable
+fun MqttSettingsScreen(
+ mqttPrefs: MqttPrefs,
+ onMqttPrefsChange: (MqttPrefs) -> Unit,
+ onBack: () -> Unit,
+) {
+ SubScreen(stringResource(R.string.settings_mqtt_section), onBack) {
+ SectionCard {
+ MqttSettingsSection(prefs = mqttPrefs, onChange = onMqttPrefsChange)
+ }
+ }
+}
+
+@Composable
+fun DeveloperSettingsScreen(
+ state: SensorUiState,
+ onDeveloperMode: (Boolean) -> Unit,
+ onBack: () -> Unit,
+) {
+ SubScreen(stringResource(R.string.settings_developer), onBack) {
+ SectionCard {
+ SettingToggleRow(stringResource(R.string.settings_dev_mode), state.developerMode, onDeveloperMode)
+ if (state.developerMode) {
+ Divider()
+ DisabledRow(stringResource(R.string.settings_wifi), stringResource(R.string.settings_wifi_val))
+ }
+ }
+ }
+}
+
+@Composable
+fun AboutSettingsScreen(onBack: () -> Unit) {
+ SubScreen(stringResource(R.string.settings_about), onBack) {
+ SectionCard {
+ DisabledRow(stringResource(R.string.settings_app_version), stringResource(R.string.settings_app_version_val))
+ Divider()
+ DisabledRow(stringResource(R.string.settings_platform), stringResource(R.string.settings_platform_val))
+ Divider()
+ DisabledRow(stringResource(R.string.settings_project), stringResource(R.string.settings_project_val))
+ }
+ }
+}
+
+// ── Internal components ─────────────────────────────────────────────────────
+
+@Composable
+private fun SectionCard(content: @Composable () -> Unit) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
+ ) {
+ Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) {
+ content()
+ }
+ }
+}
+
+@Composable
+private fun MqttSettingsSection(prefs: MqttPrefs, onChange: (MqttPrefs) -> Unit) {
+ // Key each state to the corresponding saved pref so fields re-sync after an
+ // external save (e.g. via the Save button), while preserving in-progress edits.
+ var ip by remember(prefs.brokerIp) { mutableStateOf(prefs.brokerIp) }
+ var portStr by remember(prefs.brokerPort) { mutableStateOf(prefs.brokerPort.toString()) }
+ var username by remember(prefs.username) { mutableStateOf(prefs.username) }
+ var password by remember(prefs.password) { mutableStateOf(prefs.password) }
+ var passwordVisible by remember { mutableStateOf(false) }
+
+ Spacer(Modifier.height(4.dp))
+ MqttTextField(
+ label = stringResource(R.string.settings_broker_ip),
+ value = ip,
+ placeholder = "192.168.3.202",
+ keyboardType = KeyboardType.Uri,
+ onValueChange = { ip = it },
+ onFocusLost = { onChange(prefs.copy(brokerIp = ip)) },
+ )
+ Spacer(Modifier.height(8.dp))
+ MqttTextField(
+ label = stringResource(R.string.settings_port),
+ value = portStr,
+ placeholder = "1883",
+ keyboardType = KeyboardType.Number,
+ onValueChange = { portStr = it.filter { c -> c.isDigit() }.take(5) },
+ onFocusLost = {
+ val port = portStr.toIntOrNull() ?: 1883
+ portStr = port.toString()
+ onChange(prefs.copy(brokerPort = port))
+ },
+ )
+ Spacer(Modifier.height(8.dp))
+ MqttTextField(
+ label = stringResource(R.string.settings_username),
+ value = username,
+ placeholder = stringResource(R.string.settings_optional),
+ onValueChange = { username = it },
+ onFocusLost = { onChange(prefs.copy(username = username)) },
+ )
+ Spacer(Modifier.height(8.dp))
+ MqttTextField(
+ label = stringResource(R.string.settings_password),
+ value = password,
+ placeholder = stringResource(R.string.settings_optional),
+ visualTransformation = if (passwordVisible) VisualTransformation.None
+ else PasswordVisualTransformation(),
+ onValueChange = { password = it },
+ onFocusLost = { onChange(prefs.copy(password = password)) },
+ trailingLabel = if (passwordVisible) stringResource(R.string.settings_hide)
+ else stringResource(R.string.settings_show),
+ onTrailingClick = { passwordVisible = !passwordVisible },
+ )
+ Spacer(Modifier.height(12.dp))
+ // Explicit save button — ensures credentials are persisted even if focus-lost
+ // events are swallowed by the soft keyboard or fast back navigation.
+ Button(
+ onClick = {
+ val port = portStr.toIntOrNull() ?: 1883
+ portStr = port.toString()
+ onChange(
+ prefs.copy(
+ brokerIp = ip,
+ brokerPort = port,
+ username = username,
+ password = password,
+ )
+ )
+ },
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(stringResource(R.string.settings_save_broker))
+ }
+ Spacer(Modifier.height(4.dp))
+}
+
+@Composable
+private fun MqttTextField(
+ label: String,
+ value: String,
+ placeholder: String = "",
+ keyboardType: KeyboardType = KeyboardType.Text,
+ visualTransformation: VisualTransformation = VisualTransformation.None,
+ trailingLabel: String? = null,
+ onTrailingClick: (() -> Unit)? = null,
+ onFocusLost: () -> Unit = {},
+ onValueChange: (String) -> Unit,
+) {
+ Column {
+ Text(
+ label,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(Modifier.height(2.dp))
+ OutlinedTextField(
+ value = value,
+ onValueChange = onValueChange,
+ modifier = Modifier
+ .fillMaxWidth()
+ .onFocusChanged { if (!it.isFocused) onFocusLost() },
+ placeholder = {
+ Text(
+ placeholder,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
+ fontFamily = FontFamily.Monospace,
+ )
+ },
+ textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
+ visualTransformation = visualTransformation,
+ keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
+ singleLine = true,
+ trailingIcon = if (trailingLabel != null) {
+ {
+ Text(
+ trailingLabel,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(end = 8.dp)
+ .clickable { onTrailingClick?.invoke() },
+ )
+ }
+ } else null,
+ )
+ }
+}
+
+@Composable
+private fun LanguageSection() {
+ var currentTag by remember {
+ mutableStateOf(
+ AppCompatDelegate.getApplicationLocales()
+ .let { if (it.isEmpty) "en" else it[0]?.language ?: "en" }
+ )
+ }
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ languages.forEach { lang ->
+ val selected = currentTag == lang.tag
+ OutlinedButton(
+ onClick = {
+ currentTag = lang.tag
+ AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags(lang.tag))
+ },
+ modifier = Modifier.weight(1f),
+ colors = ButtonDefaults.outlinedButtonColors(
+ containerColor = if (selected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
+ contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
+ ),
+ ) {
+ Text(
+ "${lang.flag} ${stringResource(lang.labelRes)}",
+ fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun RowDivider() = HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f))
+
+@Composable
+private fun Divider() = HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f))
+
+@Composable
+private fun SettingToggleRow(
+ label: String,
+ checked: Boolean,
+ onCheckedChange: (Boolean) -> Unit,
+ enabled: Boolean = true,
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ label,
+ style = MaterialTheme.typography.bodyMedium,
+ color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Switch(checked = checked && enabled, onCheckedChange = onCheckedChange, enabled = enabled)
+ }
+}
+
+@Composable
+private fun DisabledRow(label: String, value: String) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Text(value, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface)
+ }
+}
diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/TripHistoryScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/TripHistoryScreen.kt
new file mode 100644
index 0000000..d54a4a1
--- /dev/null
+++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/TripHistoryScreen.kt
@@ -0,0 +1,179 @@
+package com.hawhamburg.micr0bu.ui.screens
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.DirectionsBike
+import androidx.compose.material.icons.filled.Map
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.hawhamburg.micr0bu.R
+import com.hawhamburg.micr0bu.data.db.RecordedTripEntity
+import com.hawhamburg.micr0bu.viewmodel.TripRecordingViewModel
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+private val tripSdf = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault())
+
+@Composable
+fun TripHistoryScreen(
+ viewModel: TripRecordingViewModel,
+ onOpenTrip: (Long) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val trips by viewModel.trips.collectAsState(initial = emptyList())
+ var pendingDelete by remember { mutableStateOf(null) }
+
+ pendingDelete?.let { trip ->
+ AlertDialog(
+ onDismissRequest = { pendingDelete = null },
+ title = { Text(stringResource(R.string.trip_delete_title)) },
+ text = { Text(stringResource(R.string.trip_delete_text, tripSdf.format(Date(trip.startTime)))) },
+ confirmButton = {
+ TextButton(onClick = { viewModel.deleteTrip(trip.id); pendingDelete = null }) {
+ Text(stringResource(R.string.log_delete_confirm), color = MaterialTheme.colorScheme.error)
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { pendingDelete = null }) {
+ Text(stringResource(R.string.log_cancel))
+ }
+ },
+ )
+ }
+
+ Column(modifier = modifier.fillMaxSize().padding(16.dp)) {
+ Text(
+ stringResource(R.string.trip_history_title),
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold,
+ )
+ Text(
+ if (trips.size == 1)
+ stringResource(R.string.trip_history_count_one, trips.size)
+ else
+ stringResource(R.string.trip_history_count_other, trips.size),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ Spacer(Modifier.height(12.dp))
+
+ if (trips.isEmpty()) {
+ TripEmptyState()
+ } else {
+ LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ itemsIndexed(trips, key = { _, t -> t.id }) { index, trip ->
+ TripCard(
+ trip = trip,
+ index = trips.size - index,
+ onOpen = { onOpenTrip(trip.id) },
+ onDelete = { pendingDelete = trip },
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun TripCard(
+ trip: RecordedTripEntity,
+ index: Int,
+ onOpen: () -> Unit,
+ onDelete: () -> Unit,
+) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onOpen),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
+ ) {
+ Row(
+ modifier = Modifier.padding(start = 12.dp, top = 12.dp, bottom = 12.dp, end = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ Text(
+ stringResource(R.string.trip_number, index),
+ style = MaterialTheme.typography.labelLarge,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ Text(
+ tripSdf.format(Date(trip.startTime)),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(Modifier.height(4.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ val durationSec = ((trip.endTime - trip.startTime) / 1000).coerceAtLeast(0)
+ TripStatChip("⏱ ${formatDuration(durationSec)}")
+ TripStatChip("📍 ${formatDistance(trip.distanceMetres)}")
+ TripStatChip("🚨 ${trip.eventCount} events")
+ }
+ }
+ IconButton(onClick = onOpen) {
+ Icon(Icons.Default.Map, contentDescription = stringResource(R.string.trip_review_title), tint = MaterialTheme.colorScheme.primary)
+ }
+ IconButton(onClick = onDelete) {
+ Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.log_delete_cd), tint = MaterialTheme.colorScheme.error)
+ }
+ }
+ }
+}
+
+@Composable
+private fun TripStatChip(text: String) {
+ Text(text, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+}
+
+@Composable
+private fun TripEmptyState() {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Icon(
+ Icons.Default.DirectionsBike, null,
+ modifier = Modifier.size(64.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(Modifier.height(12.dp))
+ Text(stringResource(R.string.trip_empty_title), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Text(stringResource(R.string.trip_empty_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ }
+}
+
+private fun formatDistance(metres: Float): String =
+ if (metres >= 1_000f) "%.1f km".format(metres / 1_000f)
+ else "%.0f m".format(metres)
diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/TripReviewScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/TripReviewScreen.kt
new file mode 100644
index 0000000..5759ffe
--- /dev/null
+++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/TripReviewScreen.kt
@@ -0,0 +1,302 @@
+package com.hawhamburg.micr0bu.ui.screens
+
+import android.content.Context
+import android.graphics.drawable.GradientDrawable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.BottomSheetDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import com.hawhamburg.micr0bu.data.db.DetectedEventEntity
+import com.hawhamburg.micr0bu.data.db.RecordedTripEntity
+import com.hawhamburg.micr0bu.viewmodel.TripRecordingViewModel
+import kotlinx.coroutines.launch
+import org.osmdroid.config.Configuration
+import org.osmdroid.tileprovider.tilesource.TileSourceFactory
+import org.osmdroid.util.BoundingBox
+import org.osmdroid.util.GeoPoint
+import org.osmdroid.views.MapView
+import org.osmdroid.views.overlay.Marker
+import org.osmdroid.views.overlay.Polyline
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun TripReviewScreen(
+ trip: RecordedTripEntity,
+ viewModel: TripRecordingViewModel,
+ modifier: Modifier = Modifier,
+) {
+ val context = LocalContext.current
+
+ // Initialize osmdroid configuration once per composition so the tile
+ // provider is ready before MapView is constructed in the factory block.
+ initOsmReview(context)
+
+ LaunchedEffect(trip.id) { viewModel.loadTripEvents(trip.id) }
+ val events by viewModel.selectedTripEvents.collectAsState()
+
+ val gpsPoints = remember(trip.gpsTrackJson) { parseGpsTrack(trip.gpsTrackJson) }
+
+ var selectedEvent by remember { mutableStateOf(null) }
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ val scope = rememberCoroutineScope()
+
+ val mapViewRef = remember { mutableStateOf(null) }
+ val lifecycleOwner = LocalLifecycleOwner.current
+
+ DisposableEffect(lifecycleOwner) {
+ val observer = LifecycleEventObserver { _, event ->
+ when (event) {
+ Lifecycle.Event.ON_RESUME -> mapViewRef.value?.onResume()
+ Lifecycle.Event.ON_PAUSE -> mapViewRef.value?.onPause()
+ else -> {}
+ }
+ }
+ lifecycleOwner.lifecycle.addObserver(observer)
+ onDispose {
+ lifecycleOwner.lifecycle.removeObserver(observer)
+ mapViewRef.value?.onDetach()
+ }
+ }
+
+ Column(modifier = modifier.fillMaxSize()) {
+
+ // ── Header strip ──────────────────────────────────────────────────────
+ Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
+ val durationSec = ((trip.endTime - trip.startTime) / 1000).coerceAtLeast(0)
+ Text(
+ "Trip · ${formatDuration(durationSec)} · ${formatDistanceReview(trip.distanceMetres)}",
+ style = MaterialTheme.typography.labelMedium,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(
+ "🚨 ${events.count { it.type == "BRAKING" }} " +
+ "🔄 ${events.count { it.type == "TURNING" }} " +
+ "🛑 ${events.count { it.type == "STOPPING" }}",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+
+ // ── Map ───────────────────────────────────────────────────────────────
+ // weight(1f) is required here — fillMaxSize() inside a Column receives
+ // an UNBOUNDED height constraint and resolves to 0 px (invisible map).
+ AndroidView(
+ factory = { ctx ->
+ MapView(ctx).apply {
+ setTileSource(TileSourceFactory.MAPNIK)
+ setMultiTouchControls(true)
+ mapViewRef.value = this
+ }
+ },
+ update = { mv ->
+ mv.overlays.clear()
+
+ // GPS polyline
+ if (gpsPoints.size >= 2) {
+ val polyline = Polyline(mv).apply {
+ setPoints(gpsPoints.map { GeoPoint(it.lat, it.lon) })
+ outlinePaint.color = Color(0xFF1565C0).toArgb()
+ outlinePaint.strokeWidth = 8f
+ }
+ mv.overlays.add(polyline)
+ }
+
+ // Event pins
+ events.forEach { event ->
+ val pinColor = when (event.type) {
+ "BRAKING" -> Color(0xFFFF5252)
+ "TURNING" -> Color(0xFFFFB300)
+ "STOPPING" -> Color(0xFF42A5F5)
+ else -> Color.Gray
+ }
+ val marker = Marker(mv).apply {
+ position = GeoPoint(event.latitude, event.longitude)
+ setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
+ title = "${event.type} (${event.confidence})"
+ setOnMarkerClickListener { _, _ ->
+ selectedEvent = event
+ scope.launch { sheetState.show() }
+ true
+ }
+ // Solid-circle pin in the event color
+ icon = GradientDrawable().apply {
+ shape = GradientDrawable.OVAL
+ setColor(pinColor.toArgb())
+ setSize(32, 32)
+ }
+ }
+ mv.overlays.add(marker)
+ }
+
+ // Auto-fit the camera to the track — deferred via post() so the
+ // MapView has been measured before zoomToBoundingBox is called.
+ // Calling it with width/height == 0 (before first layout) crashes osmdroid.
+ if (gpsPoints.isNotEmpty()) {
+ val geoPoints = gpsPoints.map { GeoPoint(it.lat, it.lon) }
+ mv.post {
+ if (mv.width > 0 && mv.height > 0) {
+ val box = BoundingBox.fromGeoPoints(geoPoints)
+ mv.zoomToBoundingBox(box.increaseByScale(1.15f), false)
+ } else {
+ mv.controller.setZoom(14.0)
+ }
+ mv.invalidate()
+ }
+ } else {
+ mv.post {
+ mv.controller.setZoom(14.0)
+ mv.invalidate()
+ }
+ }
+ },
+ modifier = Modifier.weight(1f),
+ )
+ }
+
+ // ── Event detail bottom sheet ─────────────────────────────────────────────
+ val ev = selectedEvent
+ if (ev != null) {
+ ModalBottomSheet(
+ onDismissRequest = { selectedEvent = null },
+ sheetState = sheetState,
+ dragHandle = { BottomSheetDefaults.DragHandle() },
+ ) {
+ EventDetailSheet(event = ev, onDismiss = {
+ scope.launch { sheetState.hide() }.invokeOnCompletion { selectedEvent = null }
+ })
+ }
+ }
+}
+
+// ── Event detail sheet content ────────────────────────────────────────────────
+
+@Composable
+private fun EventDetailSheet(event: DetectedEventEntity, onDismiss: () -> Unit) {
+ // Created here (not as a top-level static field) so it always uses the
+ // current locale even if the user changes it while the app is running.
+ val sdf = remember { SimpleDateFormat("HH:mm:ss", Locale.getDefault()) }
+
+ val accentColor = when (event.type) {
+ "BRAKING" -> Color(0xFFFF5252)
+ "TURNING" -> Color(0xFFFFB300)
+ "STOPPING" -> Color(0xFF42A5F5)
+ else -> MaterialTheme.colorScheme.primary
+ }
+
+ Column(modifier = Modifier.padding(horizontal = 20.dp).padding(bottom = 32.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
+ Text(
+ event.type.replaceFirstChar { it.titlecase() },
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold,
+ color = accentColor,
+ modifier = Modifier.weight(1f),
+ )
+ IconButton(onClick = onDismiss) {
+ Icon(Icons.Default.Close, contentDescription = "Close")
+ }
+ }
+
+ Text(
+ "Confidence: ${event.confidence}",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(
+ sdf.format(Date(event.timestamp)),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ Spacer(Modifier.height(12.dp))
+ HorizontalDivider()
+ Spacer(Modifier.height(12.dp))
+
+ EventDetailRow("Speed", "%.1f m/s".format(event.speedMps))
+ EventDetailRow("Peak accel", "%.2f m/s²".format(event.peakAccelMagnitude))
+ EventDetailRow("Peak gyro", "%.3f rad/s".format(event.peakGyroMagnitude))
+ EventDetailRow("Duration", "${event.durationMs} ms")
+ EventDetailRow("Location", "%.5f°, %.5f°".format(event.latitude, event.longitude))
+ }
+}
+
+@Composable
+private fun EventDetailRow(label: String, value: String) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Text(value, style = MaterialTheme.typography.bodyMedium, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Medium)
+ }
+}
+
+// ── GPS track parsing ─────────────────────────────────────────────────────────
+
+private data class GpsPoint(val lat: Double, val lon: Double)
+
+// Braces escaped (\{ \}) so they are treated as literals, not quantifiers.
+private val gpsPointRegex = Regex("""\{"lat":([-\d.E]+),"lon":([-\d.E]+),"ts":\d+\}""")
+
+private fun parseGpsTrack(json: String): List =
+ gpsPointRegex.findAll(json).mapNotNull { match ->
+ runCatching {
+ GpsPoint(
+ lat = match.groupValues[1].toDouble(),
+ lon = match.groupValues[2].toDouble(),
+ )
+ }.getOrNull()
+ }.toList()
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+private fun initOsmReview(context: Context) {
+ Configuration.getInstance().apply {
+ load(context, context.getSharedPreferences("osmdroid", Context.MODE_PRIVATE))
+ userAgentValue = context.packageName
+ }
+}
+
+private fun formatDistanceReview(metres: Float): String =
+ if (metres >= 1_000f) "%.1f km".format(metres / 1_000f)
+ else "%.0f m".format(metres)
diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/theme/Theme.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/theme/Theme.kt
new file mode 100644
index 0000000..4b77d3b
--- /dev/null
+++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/theme/Theme.kt
@@ -0,0 +1,60 @@
+package com.hawhamburg.micr0bu.ui.theme
+
+import android.os.Build
+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.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+
+private val DarkColors = darkColorScheme(
+ primary = Color(0xFF90CAF9),
+ onPrimary = Color(0xFF003258),
+ primaryContainer = Color(0xFF00487D),
+ onPrimaryContainer = Color(0xFFD1E4FF),
+ secondary = Color(0xFF4FC3F7),
+ background = Color(0xFF0D1117),
+ surface = Color(0xFF161B22),
+ surfaceVariant = Color(0xFF1C2733),
+ onBackground = Color(0xFFE6EDF3),
+ onSurface = Color(0xFFE6EDF3),
+ onSurfaceVariant = Color(0xFF8B949E),
+ error = Color(0xFFFF5252),
+ outline = Color(0xFF30363D),
+)
+
+private val LightColors = lightColorScheme(
+ primary = Color(0xFF1565C0),
+ onPrimary = Color.White,
+ primaryContainer = Color(0xFFD1E4FF),
+ onPrimaryContainer = Color(0xFF001D36),
+ secondary = Color(0xFF0288D1),
+ background = Color(0xFFF5F5F5),
+ surface = Color(0xFFFFFFFF),
+ surfaceVariant = Color(0xFFE8EAF6),
+ onBackground = Color(0xFF1A1C1E),
+ onSurface = Color(0xFF1A1C1E),
+ onSurfaceVariant = Color(0xFF44464F),
+ error = Color(0xFFB00020),
+ outline = Color(0xFFBDBEC7),
+)
+
+@Composable
+fun MicrOBUTheme(
+ darkTheme: Boolean = true,
+ content: @Composable () -> Unit
+) {
+ val context = LocalContext.current
+ val colorScheme = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+ } else {
+ if (darkTheme) DarkColors else LightColors
+ }
+ MaterialTheme(
+ colorScheme = colorScheme,
+ content = content
+ )
+}
diff --git a/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/MqttViewModel.kt b/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/MqttViewModel.kt
new file mode 100644
index 0000000..8fc1feb
--- /dev/null
+++ b/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/MqttViewModel.kt
@@ -0,0 +1,196 @@
+package com.hawhamburg.micr0bu.viewmodel
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.hawhamburg.micr0bu.data.mqtt.MessageDirection
+import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
+import com.hawhamburg.micr0bu.data.mqtt.MqttMessage
+import com.hawhamburg.micr0bu.data.mqtt.MqttPreferences
+import com.hawhamburg.micr0bu.data.mqtt.MqttPrefs
+import com.hawhamburg.micr0bu.data.mqtt.MqttRepository
+import com.hawhamburg.micr0bu.data.transport.TransportType
+import com.hawhamburg.micr0bu.data.transport.UsbNetworkDetector
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import org.json.JSONObject
+import javax.inject.Inject
+
+private const val MAX_MESSAGES_PER_TOPIC = 50
+
+private const val DENM_CTRL_TOPIC = "v2x-uca/input/denmtrg"
+private const val DENM_USE_CASE = "hln-sv"
+
+@HiltViewModel
+class MqttViewModel @Inject constructor(
+ private val repo: MqttRepository,
+ private val prefs: MqttPreferences,
+ private val usbDetector: UsbNetworkDetector,
+) : ViewModel() {
+
+ // ── MQTT connection & messages ────────────────────────────────────────────
+
+ val connectionState: StateFlow = repo.connectionState
+
+ private val _topicMessages = MutableStateFlow