Initial Commit
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
@@ -0,0 +1,71 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- Networking (MQTT / tile downloads) -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||
|
||||
<!-- Location -->
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
|
||||
<!-- Phase A: foreground service (trip recording) -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SENSOR" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||
|
||||
<!-- Notification permission required on Android 13+ -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!-- Keep CPU active while recording with screen off -->
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<uses-feature android:name="android.hardware.sensor.accelerometer" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.sensor.gyroscope" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.sensor.compass" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.sensor.barometer" android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".MicrOBUApplication"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:label="MicrOBU"
|
||||
android:theme="@style/Theme.MicrOBU"
|
||||
android:supportsRtl="true"
|
||||
android:localeConfig="@xml/locales_config">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.MicrOBU.Splash">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Phase A: Trip Recording foreground service.
|
||||
foregroundServiceType="location" covers the GPS requirement.
|
||||
Sensor access (accelerometer / gyroscope) does not have its own
|
||||
foregroundServiceType flag; FOREGROUND_SERVICE_SENSOR permission
|
||||
in the <uses-permission> block is sufficient for API 34+. -->
|
||||
<service
|
||||
android:name=".service.TripRecordingService"
|
||||
android:foregroundServiceType="location"
|
||||
android:exported="false" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
After Width: | Height: | Size: 48 KiB |
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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))}")
|
||||
}
|
||||
@@ -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<ImuReading> = sensorFlow(Sensor.TYPE_ACCELEROMETER)
|
||||
|
||||
fun gyroscopeFlow(): Flow<ImuReading> = sensorFlow(Sensor.TYPE_GYROSCOPE)
|
||||
|
||||
fun magnetometerFlow(): Flow<ImuReading> = sensorFlow(Sensor.TYPE_MAGNETIC_FIELD)
|
||||
|
||||
fun barometerFlow(): Flow<Float> = 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<GnssReading> = 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<ImuReading> = 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
|
||||
)
|
||||
@@ -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<List<RecordedTripEntity>> = 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<List<DetectedEventEntity>> =
|
||||
dao.getEventsForTrip(tripId)
|
||||
}
|
||||
@@ -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`)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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<List<SessionEntity>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertSession(session: SessionEntity)
|
||||
|
||||
@Query("DELETE FROM sessions WHERE id = :id")
|
||||
suspend fun deleteById(id: String)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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<List<RecordedTripEntity>>
|
||||
|
||||
@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<List<DetectedEventEntity>>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM detected_events WHERE tripId = :tripId")
|
||||
suspend fun getEventCountForTrip(tripId: Long): Int
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.hawhamburg.micr0bu.data.mqtt
|
||||
|
||||
enum class MqttConnectionState {
|
||||
DISCONNECTED,
|
||||
CONNECTING,
|
||||
CONNECTED,
|
||||
ERROR,
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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<MqttPrefs> = 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<MqttConnectionState> = _connectionState.asStateFlow()
|
||||
|
||||
// extraBufferCapacity absorbs bursts (e.g. spat at 2 Hz, cam live) without blocking Paho's thread
|
||||
private val _messages = MutableSharedFlow<MqttMessage>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = 512,
|
||||
)
|
||||
val messages: SharedFlow<MqttMessage> = _messages.asSharedFlow()
|
||||
|
||||
/** Active transport derived from persisted prefs. */
|
||||
val activeTransport: StateFlow<TransportType> = 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<Throwable?>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.hawhamburg.micr0bu.data.transport
|
||||
|
||||
enum class TransportType { USB_C, WIFI, BLUETOOTH }
|
||||
@@ -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<Network?>(null)
|
||||
/** Non-null when a 192.168.42.x USB tethering network is active. */
|
||||
val usbNetwork: StateFlow<Network?> = _usbNetwork.asStateFlow()
|
||||
|
||||
private val _detectedGatewayIp = MutableStateFlow<String?>(null)
|
||||
/**
|
||||
* IP of the default gateway on the USB interface — almost always the OBU's IP.
|
||||
* Falls back to <phoneSubnet>.1 if no default route is reported.
|
||||
*/
|
||||
val detectedGatewayIp: StateFlow<String?> = _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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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<DetectedEvent>(extraBufferCapacity = 64)
|
||||
val events: SharedFlow<DetectedEvent> = _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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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> = _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()
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<MapView?>(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)
|
||||
@@ -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<String>,
|
||||
topicMessages: Map<String, List<MqttMessage>>,
|
||||
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<MqttMessage>,
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<RecordingSession?>(null) }
|
||||
var pendingSave by remember { mutableStateOf<RecordingSession?>(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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<RecordedTripEntity?>(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)
|
||||
@@ -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<DetectedEventEntity?>(null) }
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val mapViewRef = remember { mutableStateOf<MapView?>(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<GpsPoint> =
|
||||
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)
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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<MqttConnectionState> = repo.connectionState
|
||||
|
||||
private val _topicMessages = MutableStateFlow<Map<String, List<MqttMessage>>>(emptyMap())
|
||||
val topicMessages: StateFlow<Map<String, List<MqttMessage>>> = _topicMessages.asStateFlow()
|
||||
|
||||
private val _selectedTopic = MutableStateFlow<String?>(null)
|
||||
val selectedTopic: StateFlow<String?> = _selectedTopic.asStateFlow()
|
||||
|
||||
private val _autoScroll = MutableStateFlow(true)
|
||||
val autoScroll: StateFlow<Boolean> = _autoScroll.asStateFlow()
|
||||
|
||||
// ── Transport & USB ───────────────────────────────────────────────────────
|
||||
|
||||
val activeTransport: StateFlow<TransportType> = repo.activeTransport
|
||||
|
||||
/** True when a 192.168.42.x USB-C tethering network is detected. */
|
||||
val usbConnected: StateFlow<Boolean> = usbDetector.usbNetwork
|
||||
.map { it != null }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
|
||||
|
||||
/** Auto-detected OBU gateway IP on the USB interface. */
|
||||
val detectedObuIp: StateFlow<String?> = usbDetector.detectedGatewayIp
|
||||
|
||||
// ── Prefs ─────────────────────────────────────────────────────────────────
|
||||
|
||||
val mqttPrefs: StateFlow<MqttPrefs> = prefs.prefsFlow.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.Eagerly,
|
||||
MqttPrefs(),
|
||||
)
|
||||
|
||||
// ── OBU identity (parsed from v2x/rx/obu_gnss own_info) ──────────────────
|
||||
|
||||
private val _obuStationType = MutableStateFlow<Int?>(null)
|
||||
/**
|
||||
* stationType from the OBU's own_info (v2x/rx/obu_gnss). Should be 2 (cyclist/VRU) per
|
||||
* ETSI EN 302 637-2 Table 1. Null until the first obu_gnss message arrives.
|
||||
*/
|
||||
val obuStationType: StateFlow<Int?> = _obuStationType.asStateFlow()
|
||||
|
||||
/**
|
||||
* True when the OBU has reported a stationType other than 2 (cyclist).
|
||||
* Triggers a persistent warning banner — an incorrect stationType means this OBU will
|
||||
* not be detected as a VRU at equipped intersections.
|
||||
*/
|
||||
val obuStationTypeWarning: StateFlow<Boolean> = _obuStationType
|
||||
.map { it != null && it != 2 }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
|
||||
|
||||
// ── DENM transmission ─────────────────────────────────────────────────────
|
||||
|
||||
private val _denmActive = MutableStateFlow(false)
|
||||
/** True while the DENM use case is actively broadcasting on the OBU. */
|
||||
val denmActive: StateFlow<Boolean> = _denmActive.asStateFlow()
|
||||
|
||||
private val _lastDenmPayload = MutableStateFlow<String?>(null)
|
||||
/** JSON string of the most recently transmitted DENM control message. */
|
||||
val lastDenmPayload: StateFlow<String?> = _lastDenmPayload.asStateFlow()
|
||||
|
||||
/** Tracks which use case is currently active so Stop uses the same usecase field. */
|
||||
private var activeDenmUseCase: String? = null
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
repo.messages.collect { msg ->
|
||||
// Route into per-topic message lists
|
||||
_topicMessages.update { current ->
|
||||
val updated = ((current[msg.topic] ?: emptyList()) + msg)
|
||||
.takeLast(MAX_MESSAGES_PER_TOPIC)
|
||||
current + (msg.topic to updated)
|
||||
}
|
||||
|
||||
// Check stationType from own_info — must be 2 (cyclist/VRU)
|
||||
if (msg.topic == "v2x/rx/obu_gnss") {
|
||||
runCatching {
|
||||
val stType = JSONObject(msg.payload)
|
||||
.optJSONObject("own_info")
|
||||
?.optInt("stationType", -1) ?: -1
|
||||
if (stType >= 0) _obuStationType.value = stType
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────────────────
|
||||
|
||||
fun connect() = repo.connect()
|
||||
fun disconnect() = repo.disconnect()
|
||||
|
||||
fun selectTopic(topic: String?) { _selectedTopic.value = topic }
|
||||
fun setAutoScroll(enabled: Boolean) { _autoScroll.value = enabled }
|
||||
|
||||
fun updatePrefs(newPrefs: MqttPrefs) {
|
||||
viewModelScope.launch { prefs.update(newPrefs) }
|
||||
}
|
||||
|
||||
// ── DENM actions ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Publish a uca-denmctrl activate message with the retain flag so the OBU's Use Case app
|
||||
* receives it on any (re)connect. Only one use case may be active at a time.
|
||||
*/
|
||||
fun sendDenm(useCase: String = DENM_USE_CASE) {
|
||||
if (_denmActive.value) return // prevent concurrent activations
|
||||
val payload = buildDenmPayload(useCase = useCase, active = true)
|
||||
_lastDenmPayload.value = payload
|
||||
_denmActive.value = true
|
||||
activeDenmUseCase = useCase
|
||||
emitTxMessage(DENM_CTRL_TOPIC, payload)
|
||||
repo.publishRetained(DENM_CTRL_TOPIC, payload)
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a uca-denmctrl deactivate message (retained).
|
||||
* Uses the same usecase string as the activate call so the OBU phases out the correct DENM.
|
||||
*/
|
||||
fun stopDenm() {
|
||||
if (!_denmActive.value) return
|
||||
val useCase = activeDenmUseCase ?: DENM_USE_CASE
|
||||
val payload = buildDenmPayload(useCase = useCase, active = false)
|
||||
_lastDenmPayload.value = payload
|
||||
_denmActive.value = false
|
||||
activeDenmUseCase = null
|
||||
emitTxMessage(DENM_CTRL_TOPIC, payload)
|
||||
repo.publishRetained(DENM_CTRL_TOPIC, payload)
|
||||
}
|
||||
|
||||
private fun buildDenmPayload(useCase: String, active: Boolean): String =
|
||||
"""{"type":"uca-denmctrl","active":$active,"usecase":"$useCase","params":{}}"""
|
||||
|
||||
/**
|
||||
* Inject a synthetic TX [MqttMessage] into the local topic map so outbound messages
|
||||
* appear in the V2X Monitor alongside received traffic.
|
||||
*/
|
||||
private fun emitTxMessage(topic: String, payload: String) {
|
||||
val msg = MqttMessage(
|
||||
topic = topic,
|
||||
payload = payload,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
direction = MessageDirection.TX,
|
||||
)
|
||||
_topicMessages.update { current ->
|
||||
val updated = ((current[topic] ?: emptyList()) + msg)
|
||||
.takeLast(MAX_MESSAGES_PER_TOPIC)
|
||||
current + (topic to updated)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
repo.disconnect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package com.hawhamburg.micr0bu.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import android.hardware.Sensor
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.hawhamburg.micr0bu.data.GnssReading
|
||||
import com.hawhamburg.micr0bu.data.ImuReading
|
||||
import com.hawhamburg.micr0bu.data.SensorRepository
|
||||
import com.hawhamburg.micr0bu.data.db.AppDatabase
|
||||
import com.hawhamburg.micr0bu.data.db.SessionEntity
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.BufferedWriter
|
||||
import java.io.File
|
||||
import java.io.FileWriter
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
|
||||
// UI model (no Room annotations)
|
||||
data class RecordingSession(
|
||||
val id: String,
|
||||
val startTime: Long,
|
||||
val endTime: Long,
|
||||
val durationSeconds: Long,
|
||||
val sampleCount: Int,
|
||||
)
|
||||
|
||||
data class SensorUiState(
|
||||
// Sensor readings
|
||||
val gnss: GnssReading? = null,
|
||||
val accel: ImuReading? = null,
|
||||
val gyro: ImuReading? = null,
|
||||
val magnet: ImuReading? = null,
|
||||
val pressureHpa: Float? = null,
|
||||
|
||||
// Permissions & availability
|
||||
val locationPermissionGranted: Boolean = false,
|
||||
val hasGyroscope: Boolean = true,
|
||||
val hasMagnetometer: Boolean = true,
|
||||
val hasBarometer: Boolean = true,
|
||||
|
||||
// OBU connection (stub)
|
||||
val obuConnected: Boolean = false,
|
||||
val obuDeviceName: String? = null,
|
||||
|
||||
// Recording
|
||||
val isRecording: Boolean = false,
|
||||
val recordingStartTime: Long? = null,
|
||||
val recordingDurationSeconds: Long = 0,
|
||||
val recordingSampleCount: Int = 0,
|
||||
val sessions: List<RecordingSession> = emptyList(),
|
||||
|
||||
// Settings
|
||||
val accelEnabled: Boolean = true,
|
||||
val gyroEnabled: Boolean = true,
|
||||
val magnetEnabled: Boolean = true,
|
||||
val barometerEnabled: Boolean = true,
|
||||
val gnssEnabled: Boolean = true,
|
||||
val developerMode: Boolean = true,
|
||||
val useMetric: Boolean = true,
|
||||
val darkTheme: Boolean = true,
|
||||
)
|
||||
|
||||
private fun SessionEntity.toUiModel() = RecordingSession(
|
||||
id = id,
|
||||
startTime = startTime,
|
||||
endTime = endTime,
|
||||
durationSeconds = durationSeconds,
|
||||
sampleCount = sampleCount,
|
||||
)
|
||||
|
||||
class SensorViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private val repo = SensorRepository(application)
|
||||
private val dao = AppDatabase.getInstance(application).sessionDao()
|
||||
|
||||
private val _state = MutableStateFlow(
|
||||
SensorUiState(
|
||||
hasGyroscope = repo.hasSensor(Sensor.TYPE_GYROSCOPE),
|
||||
hasMagnetometer = repo.hasSensor(Sensor.TYPE_MAGNETIC_FIELD),
|
||||
hasBarometer = repo.hasSensor(Sensor.TYPE_PRESSURE),
|
||||
)
|
||||
)
|
||||
val state: StateFlow<SensorUiState> = _state.asStateFlow()
|
||||
|
||||
private var recordingTimerJob: Job? = null
|
||||
private var recordingSessionId: String = ""
|
||||
private var csvWriter: BufferedWriter? = null
|
||||
|
||||
private val isoFmt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
dao.getAllSessions().catch { }.collect { entities ->
|
||||
_state.value = _state.value.copy(sessions = entities.map { it.toUiModel() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Permissions ───────────────────────────────────────────────────────────
|
||||
|
||||
fun onLocationPermissionGranted() {
|
||||
_state.value = _state.value.copy(locationPermissionGranted = true)
|
||||
startGnss()
|
||||
}
|
||||
|
||||
// ── Sensor streams ────────────────────────────────────────────────────────
|
||||
|
||||
fun startImuStreams() {
|
||||
viewModelScope.launch {
|
||||
repo.accelerometerFlow().catch { }.collect { r ->
|
||||
if (_state.value.accelEnabled) {
|
||||
_state.value = _state.value.copy(accel = r)
|
||||
if (_state.value.isRecording) {
|
||||
_state.value = _state.value.copy(
|
||||
recordingSampleCount = _state.value.recordingSampleCount + 1
|
||||
)
|
||||
writeImuRow("accel", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
repo.gyroscopeFlow().catch { }.collect { r ->
|
||||
if (_state.value.gyroEnabled) {
|
||||
_state.value = _state.value.copy(gyro = r)
|
||||
if (_state.value.isRecording) writeImuRow("gyro", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
repo.magnetometerFlow().catch { }.collect { r ->
|
||||
if (_state.value.magnetEnabled) {
|
||||
_state.value = _state.value.copy(magnet = r)
|
||||
if (_state.value.isRecording) writeImuRow("magnet", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
repo.barometerFlow().catch { }.collect { hpa ->
|
||||
if (_state.value.barometerEnabled) {
|
||||
_state.value = _state.value.copy(pressureHpa = hpa)
|
||||
if (_state.value.isRecording) writeBaroRow(hpa)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startGnss() {
|
||||
viewModelScope.launch {
|
||||
repo.gnssFlow().catch { }.collect { r ->
|
||||
if (_state.value.gnssEnabled) {
|
||||
_state.value = _state.value.copy(gnss = r)
|
||||
if (_state.value.isRecording) {
|
||||
_state.value = _state.value.copy(
|
||||
recordingSampleCount = _state.value.recordingSampleCount + 1
|
||||
)
|
||||
writeGnssRow(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Recording ─────────────────────────────────────────────────────────────
|
||||
|
||||
fun toggleRecording() {
|
||||
if (_state.value.isRecording) stopRecording() else startRecording()
|
||||
}
|
||||
|
||||
private fun startRecording() {
|
||||
recordingSessionId = UUID.randomUUID().toString()
|
||||
val startTime = System.currentTimeMillis()
|
||||
|
||||
// Open streaming CSV file
|
||||
val sessionsDir = File(getApplication<Application>().filesDir, "sessions")
|
||||
.also { it.mkdirs() }
|
||||
runCatching {
|
||||
csvWriter = BufferedWriter(FileWriter(File(sessionsDir, "$recordingSessionId.csv")))
|
||||
csvWriter?.apply {
|
||||
appendLine("# MicrOBU Session Export")
|
||||
appendLine("# Generated by MicrOBU v0.2.0 — HAW Hamburg / Project MicrOBU")
|
||||
appendLine("# Session ID,$recordingSessionId")
|
||||
appendLine("# Start,${isoFmt.format(Date(startTime))}")
|
||||
appendLine()
|
||||
// Columns: type | timestamp_ms | timestamp_iso |
|
||||
// lat | lon | alt_m | speed_ms | bearing_deg | accuracy_m |
|
||||
// x | y | z | pressure_hpa
|
||||
appendLine("type,timestamp_ms,timestamp_iso,lat,lon,alt_m,speed_ms,bearing_deg,accuracy_m,x,y,z,pressure_hpa")
|
||||
}
|
||||
}
|
||||
|
||||
_state.value = _state.value.copy(
|
||||
isRecording = true,
|
||||
recordingStartTime = startTime,
|
||||
recordingDurationSeconds = 0,
|
||||
recordingSampleCount = 0,
|
||||
)
|
||||
recordingTimerJob = viewModelScope.launch {
|
||||
while (true) {
|
||||
delay(1_000)
|
||||
_state.value = _state.value.copy(
|
||||
recordingDurationSeconds = _state.value.recordingDurationSeconds + 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopRecording() {
|
||||
recordingTimerJob?.cancel()
|
||||
val s = _state.value
|
||||
val endTime = System.currentTimeMillis()
|
||||
|
||||
// Append footer and close the CSV file
|
||||
runCatching {
|
||||
csvWriter?.apply {
|
||||
appendLine()
|
||||
appendLine("# End,${isoFmt.format(Date(endTime))}")
|
||||
appendLine("# Duration (s),${s.recordingDurationSeconds}")
|
||||
appendLine("# Samples captured,${s.recordingSampleCount}")
|
||||
flush()
|
||||
close()
|
||||
}
|
||||
}
|
||||
csvWriter = null
|
||||
|
||||
val entity = SessionEntity(
|
||||
id = recordingSessionId,
|
||||
startTime = s.recordingStartTime ?: endTime,
|
||||
endTime = endTime,
|
||||
durationSeconds = s.recordingDurationSeconds,
|
||||
sampleCount = s.recordingSampleCount,
|
||||
)
|
||||
viewModelScope.launch { dao.insertSession(entity) }
|
||||
_state.value = s.copy(isRecording = false)
|
||||
}
|
||||
|
||||
// ── CSV row writers ───────────────────────────────────────────────────────
|
||||
// Columns: type, timestamp_ms, timestamp_iso,
|
||||
// lat, lon, alt_m, speed_ms, bearing_deg, accuracy_m,
|
||||
// x, y, z, pressure_hpa (13 total)
|
||||
|
||||
private fun writeGnssRow(r: GnssReading) {
|
||||
val ts = r.timestamp
|
||||
csvWriter?.appendLine(
|
||||
"gnss,$ts,${isoFmt.format(Date(ts))},${r.latitude},${r.longitude},${r.altitude},${r.speedMs},${r.bearingDeg},${r.accuracyM},,,,"
|
||||
)
|
||||
}
|
||||
|
||||
private fun writeImuRow(type: String, r: ImuReading) {
|
||||
val ts = r.timestamp
|
||||
csvWriter?.appendLine(
|
||||
"$type,$ts,${isoFmt.format(Date(ts))},,,,,,,${r.x},${r.y},${r.z},"
|
||||
)
|
||||
}
|
||||
|
||||
private fun writeBaroRow(hpa: Float) {
|
||||
val ts = System.currentTimeMillis()
|
||||
csvWriter?.appendLine(
|
||||
"baro,$ts,${isoFmt.format(Date(ts))},,,,,,,,,,$hpa"
|
||||
)
|
||||
}
|
||||
|
||||
// ── Settings ──────────────────────────────────────────────────────────────
|
||||
|
||||
fun deleteSession(id: String) {
|
||||
viewModelScope.launch { dao.deleteById(id) }
|
||||
// Remove the associated CSV data file
|
||||
runCatching {
|
||||
File(File(getApplication<Application>().filesDir, "sessions"), "$id.csv").delete()
|
||||
}
|
||||
}
|
||||
|
||||
fun setAccelEnabled(v: Boolean) { _state.value = _state.value.copy(accelEnabled = v) }
|
||||
fun setGyroEnabled(v: Boolean) { _state.value = _state.value.copy(gyroEnabled = v) }
|
||||
fun setMagnetEnabled(v: Boolean) { _state.value = _state.value.copy(magnetEnabled = v) }
|
||||
fun setBarometerEnabled(v: Boolean) { _state.value = _state.value.copy(barometerEnabled = v) }
|
||||
fun setGnssEnabled(v: Boolean) {
|
||||
_state.value = _state.value.copy(gnssEnabled = v)
|
||||
if (!v) _state.value = _state.value.copy(gnss = null)
|
||||
}
|
||||
fun setDeveloperMode(v: Boolean) { _state.value = _state.value.copy(developerMode = v) }
|
||||
fun setUseMetric(v: Boolean) { _state.value = _state.value.copy(useMetric = v) }
|
||||
fun setDarkTheme(v: Boolean) { _state.value = _state.value.copy(darkTheme = v) }
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.hawhamburg.micr0bu.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Intent
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.hawhamburg.micr0bu.data.TripRepository
|
||||
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.service.TripRecordingService
|
||||
import com.hawhamburg.micr0bu.service.TripServiceBus
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* ViewModel for the Trip Recording feature (Phase A).
|
||||
*
|
||||
* Sends [ACTION_START] / [ACTION_STOP] intents to [TripRecordingService] and
|
||||
* exposes service state via [TripServiceBus]. Also provides trip history and
|
||||
* per-trip event lists for the History and Review screens.
|
||||
*/
|
||||
class TripRecordingViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private val repository = TripRepository(AppDatabase.getInstance(application))
|
||||
|
||||
// ── Service state (live during a recording) ───────────────────────────────
|
||||
|
||||
/** Live recording state forwarded from [TripServiceBus]. */
|
||||
val serviceState: StateFlow<TripServiceBus.State> = TripServiceBus.state
|
||||
|
||||
// ── Trip history ─────────────────────────────────────────────────────────
|
||||
|
||||
/** All recorded trips, newest first. */
|
||||
val trips: Flow<List<RecordedTripEntity>> = repository.getAllTrips()
|
||||
|
||||
// ── Trip review ───────────────────────────────────────────────────────────
|
||||
|
||||
private val _selectedTripEvents = MutableStateFlow<List<DetectedEventEntity>>(emptyList())
|
||||
val selectedTripEvents: StateFlow<List<DetectedEventEntity>> = _selectedTripEvents.asStateFlow()
|
||||
|
||||
/** Load events for [tripId] into [selectedTripEvents]. */
|
||||
fun loadTripEvents(tripId: Long) {
|
||||
viewModelScope.launch {
|
||||
repository.getEventsForTrip(tripId).collect { events ->
|
||||
_selectedTripEvents.value = events
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Recording control ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Starts the foreground recording service if not already running,
|
||||
* or stops it if a trip is already active.
|
||||
*/
|
||||
fun toggleRecording() {
|
||||
if (serviceState.value.isRecording) stopRecording() else startRecording()
|
||||
}
|
||||
|
||||
fun startRecording() {
|
||||
val intent = Intent(getApplication(), TripRecordingService::class.java)
|
||||
.apply { action = TripRecordingService.ACTION_START }
|
||||
getApplication<Application>().startForegroundService(intent)
|
||||
}
|
||||
|
||||
fun stopRecording() {
|
||||
val intent = Intent(getApplication(), TripRecordingService::class.java)
|
||||
.apply { action = TripRecordingService.ACTION_STOP }
|
||||
getApplication<Application>().startService(intent)
|
||||
}
|
||||
|
||||
// ── Trip management ───────────────────────────────────────────────────────
|
||||
|
||||
fun deleteTrip(tripId: Long) {
|
||||
viewModelScope.launch { repository.deleteTrip(tripId) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 936 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,216 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Language names — intentionally NOT translated so they always read in their own language -->
|
||||
<string name="lang_english">English</string>
|
||||
<string name="lang_german">Deutsch</string>
|
||||
|
||||
<!-- App -->
|
||||
<string name="app_subtitle">V2X Begleiter — Phase 02</string>
|
||||
|
||||
<!-- Navigation -->
|
||||
<string name="nav_dashboard">Dashboard</string>
|
||||
<string name="nav_sensors">Sensoren</string>
|
||||
<string name="nav_record">Messsitzung</string>
|
||||
<string name="nav_log">Messsitzungen</string>
|
||||
<string name="nav_v2x">V2X</string>
|
||||
<string name="nav_settings">Einstellungen</string>
|
||||
<string name="nav_connection">Verbindung</string>
|
||||
|
||||
<!-- Dashboard -->
|
||||
<string name="dash_gnss">GNSS</string>
|
||||
<string name="dash_imu">IMU</string>
|
||||
<string name="dash_obu_connection">OBU-Verbindung</string>
|
||||
<string name="dash_acquiring_fix">GPS-Signal wird erfasst…</string>
|
||||
<string name="dash_permission_required">Berechtigung erforderlich</string>
|
||||
<string name="dash_tap_to_connect">V2X-Tab öffnen zum Verbinden</string>
|
||||
<string name="dash_connected">Verbunden</string>
|
||||
<string name="dash_mqtt_connected">MQTT verbunden</string>
|
||||
<string name="dash_mqtt_connecting">Verbindung zum Broker…</string>
|
||||
<string name="dash_mqtt_error">Broker nicht erreichbar — V2X-Einstellungen prüfen</string>
|
||||
<string name="dash_recording">Aufnahme</string>
|
||||
<string name="dash_samples">Messwerte</string>
|
||||
<string name="dash_initialising">Wird initialisiert…</string>
|
||||
<string name="stat_pressure">Luftdruck</string>
|
||||
<string name="stat_altitude">Höhe</string>
|
||||
<string name="stat_heading">Richtung</string>
|
||||
<string name="stat_accuracy">Genauigkeit</string>
|
||||
|
||||
<!-- Sensor Screen -->
|
||||
<string name="sensor_monitor_title">Sensormonitor</string>
|
||||
<string name="sensor_gnss">Telefon-GNSS</string>
|
||||
<string name="sensor_accel">Beschleunigungssensor</string>
|
||||
<string name="sensor_gyro">Gyroskop</string>
|
||||
<string name="sensor_magnet">Magnetometer</string>
|
||||
<string name="sensor_baro">Barometer</string>
|
||||
<string name="sensor_latitude">Breitengrad</string>
|
||||
<string name="sensor_longitude">Längengrad</string>
|
||||
<string name="sensor_altitude">Höhe</string>
|
||||
<string name="sensor_speed">Geschwindigkeit</string>
|
||||
<string name="sensor_heading">Richtung</string>
|
||||
<string name="sensor_accuracy">Genauigkeit</string>
|
||||
<string name="sensor_pressure">Luftdruck</string>
|
||||
<string name="sensor_est_altitude">Geschätzte Höhe</string>
|
||||
<string name="sensor_waiting">Warte auf GPS-Signal…</string>
|
||||
<string name="sensor_initialising">Wird initialisiert…</string>
|
||||
<string name="sensor_not_present">Sensor nicht verfügbar</string>
|
||||
|
||||
<!-- Connection Setup -->
|
||||
<string name="conn_title">Verbindungseinrichtung</string>
|
||||
<string name="conn_desc">Verbindung zur micrOBU-Hardware über Bluetooth (primär) oder USB-C (Fallback).</string>
|
||||
<string name="conn_phase2_desc">USB-C-Tethering ist der primäre Transport in Phase 02. OBU anschließen und Verbinden tippen.</string>
|
||||
<string name="conn_bluetooth">Bluetooth</string>
|
||||
<string name="conn_bluetooth_phase3">Bluetooth</string>
|
||||
<string name="conn_bluetooth_phase3_desc">Bluetooth-Verbindung ist für Phase 03 geplant und noch nicht implementiert.</string>
|
||||
<string name="conn_scan">Geräte suchen</string>
|
||||
<string name="conn_scanning">Suche läuft…</string>
|
||||
<string name="conn_usb_title">USB-C (Primär)</string>
|
||||
<string name="conn_usb_stub">Über USB-C verbinden (noch nicht implementiert)</string>
|
||||
<string name="conn_usb_cable_ok">Kabel erkannt</string>
|
||||
<string name="conn_usb_cable_missing">Kein Kabel</string>
|
||||
<string name="conn_usb_obu_ip">OBU-IP</string>
|
||||
<string name="conn_usb_detecting">Wird erkannt…</string>
|
||||
<string name="conn_usb_mode">IP-Quelle</string>
|
||||
<string name="conn_usb_auto">Automatisch erkannt</string>
|
||||
<string name="conn_usb_manual">Manuell</string>
|
||||
<string name="conn_usb_no_cable_hint">⚠ USB-C-Kabel anschließen, oder Auto-Erkennung in Einstellungen → Verbindung deaktivieren.</string>
|
||||
<string name="conn_connect">Verbinden</string>
|
||||
<string name="conn_connect_mqtt">Mit OBU verbinden</string>
|
||||
<string name="conn_disconnect">Trennen</string>
|
||||
<string name="conn_selected">Ausgewählt</string>
|
||||
<string name="conn_connected_stub">Verbunden (Stub)</string>
|
||||
<string name="conn_stub_warning">⚠ Bluetooth noch nicht implementiert. Dieser Bildschirm zeigt das Ziel-UI-Layout.</string>
|
||||
|
||||
<!-- Recording -->
|
||||
<string name="rec_title">Messsitzung</string>
|
||||
<string name="rec_ready">Bereit zur Aufnahme</string>
|
||||
<string name="rec_samples_captured">%1$d Messwerte erfasst</string>
|
||||
<string name="rec_active_streams">Aktive Datenströme</string>
|
||||
<string name="rec_button_stop">STOPP</string>
|
||||
<string name="rec_button_rec">REC</string>
|
||||
<string name="stream_gnss">Telefon-GNSS</string>
|
||||
<string name="stream_accel">Beschleunigungssensor</string>
|
||||
<string name="stream_gyro">Gyroskop</string>
|
||||
<string name="stream_magnet">Magnetometer</string>
|
||||
<string name="stream_baro">Barometer</string>
|
||||
<string name="stream_obu">OBU / MQTT</string>
|
||||
|
||||
<!-- Session Log -->
|
||||
<string name="log_title">Messsitzungen</string>
|
||||
<string name="log_stored_one">%1$d Sitzung auf dem Gerät gespeichert</string>
|
||||
<string name="log_stored_other">%1$d Sitzungen auf dem Gerät gespeichert</string>
|
||||
<string name="log_session_number">Sitzung #%1$d</string>
|
||||
<string name="log_share_cd">CSV teilen</string>
|
||||
<string name="log_save_cd">Auf Gerät speichern</string>
|
||||
<string name="log_delete_cd">Sitzung löschen</string>
|
||||
<string name="log_empty_title">Noch keine Sitzungen</string>
|
||||
<string name="log_empty_desc">Gehe zu Aufnahme und starte eine Sitzung</string>
|
||||
<string name="log_delete_dialog_title">Sitzung löschen?</string>
|
||||
<string name="log_delete_dialog_text">Die am %1$s aufgezeichnete Sitzung wird dauerhaft vom Gerät entfernt.</string>
|
||||
<string name="log_delete_confirm">Löschen</string>
|
||||
<string name="log_cancel">Abbrechen</string>
|
||||
<string name="log_export_chooser">Sitzung exportieren</string>
|
||||
|
||||
<!-- GNSS bottom sheet & map -->
|
||||
<string name="gnss_sheet_title">GNSS-Standort</string>
|
||||
<string name="gnss_open_maps">In Maps-App öffnen</string>
|
||||
<string name="gnss_open_maps_sub">Öffnet Ihre bevorzugte Karten-App</string>
|
||||
<string name="gnss_view_inapp">In App anzeigen</string>
|
||||
<string name="gnss_view_inapp_sub">Aktuellen Standort auf einer In-App-Karte anzeigen</string>
|
||||
<string name="gnss_no_fix">Noch kein GPS-Signal — gehen Sie ins Freie</string>
|
||||
<string name="map_title">Standortkarte</string>
|
||||
<string name="map_location_label">Aktueller Standort</string>
|
||||
|
||||
<!-- Settings -->
|
||||
<string name="settings_title">Einstellungen</string>
|
||||
<string name="settings_language">Sprache</string>
|
||||
<string name="settings_appearance">Darstellung</string>
|
||||
<string name="settings_dark_theme">Dunkles Design</string>
|
||||
<string name="settings_sensors">Datenströme</string>
|
||||
<string name="settings_gnss">Telefon-GNSS</string>
|
||||
<string name="settings_accel">Beschleunigungssensor</string>
|
||||
<string name="settings_gyro">Gyroskop</string>
|
||||
<string name="settings_magnet">Magnetometer</string>
|
||||
<string name="settings_baro">Barometer</string>
|
||||
<string name="settings_units">Einheiten</string>
|
||||
<string name="settings_metric">Metrisch (m, km/h, hPa)</string>
|
||||
<string name="settings_mqtt_section">MQTT-Broker</string>
|
||||
<string name="settings_broker_ip">Broker-IP</string>
|
||||
<string name="settings_port">Port</string>
|
||||
<string name="settings_username">Benutzername</string>
|
||||
<string name="settings_password">Passwort</string>
|
||||
<string name="settings_optional">optional</string>
|
||||
<string name="settings_show">Anzeigen</string>
|
||||
<string name="settings_hide">Ausblenden</string>
|
||||
<string name="settings_developer">Entwickler</string>
|
||||
<string name="settings_dev_mode">Entwicklermodus</string>
|
||||
<string name="settings_wifi">WLAN-OBU-Verbindung</string>
|
||||
<string name="settings_wifi_val">Nur Entwicklermodus — noch nicht implementiert</string>
|
||||
<string name="settings_about">Über</string>
|
||||
<string name="settings_app_version">App-Version</string>
|
||||
<string name="settings_app_version_val">0.4.0 (Phase 02 — USB-C + DENM TX)</string>
|
||||
<string name="settings_connection">Verbindung</string>
|
||||
<string name="settings_usb_auto_detect">OBU per USB-C automatisch erkennen</string>
|
||||
<string name="settings_usb_manual_ip">OBU-IP (manuell)</string>
|
||||
<string name="settings_usb_transport">Aktiver Transport</string>
|
||||
<string name="settings_transport_usbc">USB-C</string>
|
||||
<string name="settings_transport_wifi">WLAN</string>
|
||||
<string name="settings_save_broker">Broker-Einstellungen speichern</string>
|
||||
|
||||
<!-- MQTT Topic Viewer -->
|
||||
<string name="mqtt_viewer_title">V2X-Monitor</string>
|
||||
<string name="mqtt_connect">Verbinden</string>
|
||||
<string name="mqtt_disconnect">Trennen</string>
|
||||
<string name="mqtt_auto_scroll">Automatisch scrollen</string>
|
||||
<string name="mqtt_no_topics">Noch keine Nachrichten</string>
|
||||
<string name="mqtt_no_topics_hint">Mit der OBU verbinden und auf V2X-Verkehr warten</string>
|
||||
<string name="mqtt_no_messages">Noch keine Nachrichten zu diesem Thema</string>
|
||||
|
||||
<!-- DENM TX -->
|
||||
<string name="mqtt_last_tx">Zuletzt gesendete Nutzlast:</string>
|
||||
<string name="mqtt_denm_tx_title">DENM-Übertragung</string>
|
||||
<string name="mqtt_send_denm">Test-DENM senden</string>
|
||||
<string name="mqtt_stop_denm">DENM stoppen</string>
|
||||
<string name="mqtt_denm_use_case_desc">Liegengebliebenes Fahrzeug (causeCode 94)</string>
|
||||
<string name="mqtt_denm_not_connected">Mit OBU verbinden, um DENM-Auslösung zu aktivieren</string>
|
||||
<string name="mqtt_denm_active">DENM aktiv — OBU sendet über ITS-G5</string>
|
||||
<string name="mqtt_denm_hint">Sendet uca-denmctrl (retained) an v2x-uca/input/denmtrg</string>
|
||||
<string name="mqtt_denm_show">Letztes TX anzeigen</string>
|
||||
<string name="mqtt_denm_hide">Ausblenden</string>
|
||||
|
||||
<!-- Dashboard transport -->
|
||||
<string name="dash_transport_usbc">USB-C</string>
|
||||
<string name="dash_transport_wifi">WLAN</string>
|
||||
<string name="dash_transport_bt">BT</string>
|
||||
|
||||
<!-- OBU stationType warning -->
|
||||
<string name="dash_station_type_warning">⚠ OBU-stationType ≠ 2 (Radfahrer) — VRU-Erkennung an ausgerüsteten Kreuzungen ggf. beeinträchtigt</string>
|
||||
|
||||
<string name="settings_platform">Plattform</string>
|
||||
<string name="settings_platform_val">Android / Kotlin / Jetpack Compose</string>
|
||||
<string name="settings_project">Projekt</string>
|
||||
<string name="settings_project_val">MicrOBU — HAW Hamburg</string>
|
||||
|
||||
<!-- Phase A: Trips (bottom nav) -->
|
||||
<string name="nav_trips">Fahrten</string>
|
||||
|
||||
<!-- Phase A: Recording screen event counters -->
|
||||
<string name="rec_events_detected">Erkannte Ereignisse</string>
|
||||
<string name="rec_event_braking">Bremsen</string>
|
||||
<string name="rec_event_turning">Abbiegen</string>
|
||||
<string name="rec_event_stopping">Anhalten</string>
|
||||
<string name="rec_stream_event_detection">Ereigniserkennung</string>
|
||||
<string name="rec_open_session_log">CSV-Sitzungsprotokoll</string>
|
||||
|
||||
<!-- Phase A: Trip History screen -->
|
||||
<string name="trip_history_title">Fahrtenverlauf</string>
|
||||
<string name="trip_history_count_one">%1$d Fahrt auf dem Gerät gespeichert</string>
|
||||
<string name="trip_history_count_other">%1$d Fahrten auf dem Gerät gespeichert</string>
|
||||
<string name="trip_number">Fahrt #%1$d</string>
|
||||
<string name="trip_delete_title">Fahrt löschen?</string>
|
||||
<string name="trip_delete_text">Die am %1$s aufgezeichnete Fahrt wird dauerhaft vom Gerät entfernt.</string>
|
||||
<string name="trip_empty_title">Noch keine Fahrten</string>
|
||||
<string name="trip_empty_desc">Gehe zu Aufnahme und starte eine Sitzung</string>
|
||||
|
||||
<!-- Phase A: Trip Review screen -->
|
||||
<string name="trip_review_title">Fahrtanalyse</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#F8F8F8</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,217 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- App -->
|
||||
<string name="app_name">MicrOBU</string>
|
||||
<string name="app_subtitle">V2X Companion — Phase 02</string>
|
||||
|
||||
<!-- Language names (always shown in their own language) -->
|
||||
<string name="lang_english">English</string>
|
||||
<string name="lang_german">Deutsch</string>
|
||||
|
||||
<!-- Navigation -->
|
||||
<string name="nav_dashboard">Dashboard</string>
|
||||
<string name="nav_sensors">Sensors</string>
|
||||
<string name="nav_record">Record</string>
|
||||
<string name="nav_log">Log</string>
|
||||
<string name="nav_v2x">V2X</string>
|
||||
<string name="nav_settings">Settings</string>
|
||||
<string name="nav_connection">Connection</string>
|
||||
|
||||
<!-- Dashboard -->
|
||||
<string name="dash_gnss">GNSS</string>
|
||||
<string name="dash_imu">IMU</string>
|
||||
<string name="dash_obu_connection">OBU Connection</string>
|
||||
<string name="dash_acquiring_fix">Acquiring fix…</string>
|
||||
<string name="dash_permission_required">Permission required</string>
|
||||
<string name="dash_tap_to_connect">Tap V2X to connect</string>
|
||||
<string name="dash_connected">Connected</string>
|
||||
<string name="dash_mqtt_connected">MQTT connected</string>
|
||||
<string name="dash_mqtt_connecting">Connecting to broker…</string>
|
||||
<string name="dash_mqtt_error">Broker unreachable — check V2X settings</string>
|
||||
<string name="dash_recording">Recording</string>
|
||||
<string name="dash_samples">samples</string>
|
||||
<string name="dash_initialising">Initialising…</string>
|
||||
<string name="stat_pressure">Pressure</string>
|
||||
<string name="stat_altitude">Altitude</string>
|
||||
<string name="stat_heading">Heading</string>
|
||||
<string name="stat_accuracy">Accuracy</string>
|
||||
|
||||
<!-- Sensor Screen -->
|
||||
<string name="sensor_monitor_title">Sensor Monitor</string>
|
||||
<string name="sensor_gnss">Phone GNSS</string>
|
||||
<string name="sensor_accel">Accelerometer</string>
|
||||
<string name="sensor_gyro">Gyroscope</string>
|
||||
<string name="sensor_magnet">Magnetometer</string>
|
||||
<string name="sensor_baro">Barometer</string>
|
||||
<string name="sensor_latitude">Latitude</string>
|
||||
<string name="sensor_longitude">Longitude</string>
|
||||
<string name="sensor_altitude">Altitude</string>
|
||||
<string name="sensor_speed">Speed</string>
|
||||
<string name="sensor_heading">Heading</string>
|
||||
<string name="sensor_accuracy">Accuracy</string>
|
||||
<string name="sensor_pressure">Pressure</string>
|
||||
<string name="sensor_est_altitude">Est. altitude</string>
|
||||
<string name="sensor_waiting">Waiting for location fix…</string>
|
||||
<string name="sensor_initialising">Initialising…</string>
|
||||
<string name="sensor_not_present">Sensor not present on this device</string>
|
||||
|
||||
<!-- Connection Setup -->
|
||||
<string name="conn_title">Connection Setup</string>
|
||||
<string name="conn_desc">Connect to the micrOBU hardware via Bluetooth (primary) or USB-C (fallback).</string>
|
||||
<string name="conn_phase2_desc">USB-C tethering is the primary transport in Phase 02. Plug the OBU in and tap Connect.</string>
|
||||
<string name="conn_bluetooth">Bluetooth</string>
|
||||
<string name="conn_bluetooth_phase3">Bluetooth</string>
|
||||
<string name="conn_bluetooth_phase3_desc">Bluetooth connection is planned for Phase 03 and is not yet implemented.</string>
|
||||
<string name="conn_scan">Scan for Devices</string>
|
||||
<string name="conn_scanning">Scanning…</string>
|
||||
<string name="conn_usb_title">USB-C (Primary)</string>
|
||||
<string name="conn_usb_stub">Connect via USB-C (not yet implemented)</string>
|
||||
<string name="conn_usb_cable_ok">Cable detected</string>
|
||||
<string name="conn_usb_cable_missing">No cable</string>
|
||||
<string name="conn_usb_obu_ip">OBU IP</string>
|
||||
<string name="conn_usb_detecting">Detecting…</string>
|
||||
<string name="conn_usb_mode">IP source</string>
|
||||
<string name="conn_usb_auto">Auto-detected</string>
|
||||
<string name="conn_usb_manual">Manual override</string>
|
||||
<string name="conn_usb_no_cable_hint">⚠ Connect the USB-C cable to detect the OBU, or disable auto-detect in Settings → Connection to use a manual IP.</string>
|
||||
<string name="conn_connect">Connect</string>
|
||||
<string name="conn_connect_mqtt">Connect to OBU</string>
|
||||
<string name="conn_disconnect">Disconnect</string>
|
||||
<string name="conn_selected">Selected</string>
|
||||
<string name="conn_connected_stub">Connected (stub)</string>
|
||||
<string name="conn_stub_warning">⚠ Bluetooth stack not yet implemented. This screen shows the target UI layout.</string>
|
||||
|
||||
<!-- Recording -->
|
||||
<string name="rec_title">Recording Session</string>
|
||||
<string name="rec_ready">Ready to record</string>
|
||||
<string name="rec_samples_captured">%1$d samples captured</string>
|
||||
<string name="rec_active_streams">Active Streams</string>
|
||||
<string name="rec_button_stop">STOP</string>
|
||||
<string name="rec_button_rec">REC</string>
|
||||
<string name="stream_gnss">Phone GNSS</string>
|
||||
<string name="stream_accel">Accelerometer</string>
|
||||
<string name="stream_gyro">Gyroscope</string>
|
||||
<string name="stream_magnet">Magnetometer</string>
|
||||
<string name="stream_baro">Barometer</string>
|
||||
<string name="stream_obu">OBU / MQTT</string>
|
||||
|
||||
<!-- Session Log -->
|
||||
<string name="log_title">Session Log</string>
|
||||
<string name="log_stored_one">%1$d session stored on device</string>
|
||||
<string name="log_stored_other">%1$d sessions stored on device</string>
|
||||
<string name="log_session_number">Session #%1$d</string>
|
||||
<string name="log_share_cd">Share CSV</string>
|
||||
<string name="log_save_cd">Save to device</string>
|
||||
<string name="log_delete_cd">Delete session</string>
|
||||
<string name="log_empty_title">No sessions yet</string>
|
||||
<string name="log_empty_desc">Go to Record and start a session</string>
|
||||
<string name="log_delete_dialog_title">Delete session?</string>
|
||||
<string name="log_delete_dialog_text">This will permanently remove the session recorded on %1$s from the device.</string>
|
||||
<string name="log_delete_confirm">Delete</string>
|
||||
<string name="log_cancel">Cancel</string>
|
||||
<string name="log_export_chooser">Export session</string>
|
||||
|
||||
<!-- GNSS bottom sheet & map -->
|
||||
<string name="gnss_sheet_title">GNSS Location</string>
|
||||
<string name="gnss_open_maps">Open in Maps App</string>
|
||||
<string name="gnss_open_maps_sub">Opens in your preferred maps application</string>
|
||||
<string name="gnss_view_inapp">View in App</string>
|
||||
<string name="gnss_view_inapp_sub">Show current location on an in-app map</string>
|
||||
<string name="gnss_no_fix">No GPS fix yet — move to an open area</string>
|
||||
<string name="map_title">Location Map</string>
|
||||
<string name="map_location_label">Current Location</string>
|
||||
|
||||
<!-- Settings -->
|
||||
<string name="settings_title">Settings</string>
|
||||
<string name="settings_language">Language</string>
|
||||
<string name="settings_appearance">Appearance</string>
|
||||
<string name="settings_dark_theme">Dark theme</string>
|
||||
<string name="settings_sensors">Sensor Streams</string>
|
||||
<string name="settings_gnss">Phone GNSS</string>
|
||||
<string name="settings_accel">Accelerometer</string>
|
||||
<string name="settings_gyro">Gyroscope</string>
|
||||
<string name="settings_magnet">Magnetometer</string>
|
||||
<string name="settings_baro">Barometer</string>
|
||||
<string name="settings_units">Units</string>
|
||||
<string name="settings_metric">Metric (m, km/h, hPa)</string>
|
||||
<string name="settings_mqtt_section">MQTT Broker</string>
|
||||
<string name="settings_broker_ip">Broker IP</string>
|
||||
<string name="settings_port">Port</string>
|
||||
<string name="settings_username">Username</string>
|
||||
<string name="settings_password">Password</string>
|
||||
<string name="settings_optional">optional</string>
|
||||
<string name="settings_show">Show</string>
|
||||
<string name="settings_hide">Hide</string>
|
||||
<string name="settings_developer">Developer</string>
|
||||
<string name="settings_dev_mode">Developer mode</string>
|
||||
<string name="settings_wifi">Wi-Fi OBU connection</string>
|
||||
<string name="settings_wifi_val">Dev mode only — not implemented</string>
|
||||
<string name="settings_about">About</string>
|
||||
<string name="settings_app_version">App version</string>
|
||||
<string name="settings_app_version_val">0.4.0 (Phase 02 — USB-C + DENM TX)</string>
|
||||
<string name="settings_connection">Connection</string>
|
||||
<string name="settings_usb_auto_detect">Auto-detect OBU via USB-C</string>
|
||||
<string name="settings_usb_manual_ip">Manual OBU IP</string>
|
||||
<string name="settings_usb_transport">Active transport</string>
|
||||
<string name="settings_transport_usbc">USB-C</string>
|
||||
<string name="settings_transport_wifi">Wi-Fi</string>
|
||||
<string name="settings_save_broker">Save Broker Settings</string>
|
||||
|
||||
<!-- MQTT Topic Viewer -->
|
||||
<string name="mqtt_viewer_title">V2X Monitor</string>
|
||||
<string name="mqtt_connect">Connect</string>
|
||||
<string name="mqtt_disconnect">Disconnect</string>
|
||||
<string name="mqtt_auto_scroll">Auto-scroll to latest</string>
|
||||
<string name="mqtt_no_topics">No messages yet</string>
|
||||
<string name="mqtt_no_topics_hint">Connect to the OBU and wait for V2X traffic</string>
|
||||
<string name="mqtt_no_messages">No messages on this topic yet</string>
|
||||
|
||||
<!-- DENM TX -->
|
||||
<string name="mqtt_last_tx">Last transmitted payload:</string>
|
||||
<string name="mqtt_denm_tx_title">DENM Transmission</string>
|
||||
<string name="mqtt_send_denm">Send Test DENM</string>
|
||||
<string name="mqtt_stop_denm">Stop DENM</string>
|
||||
<string name="mqtt_denm_use_case_desc">Aftermarket Stationary Vehicle (causeCode 94)</string>
|
||||
<string name="mqtt_denm_not_connected">Connect to the OBU to enable DENM triggering</string>
|
||||
<string name="mqtt_denm_active">DENM active — OBU broadcasting via ITS-G5</string>
|
||||
<string name="mqtt_denm_hint">Publishes uca-denmctrl (retained) to v2x-uca/input/denmtrg</string>
|
||||
<string name="mqtt_denm_show">Show last TX</string>
|
||||
<string name="mqtt_denm_hide">Hide</string>
|
||||
|
||||
<!-- Dashboard transport -->
|
||||
<string name="dash_transport_usbc">USB-C</string>
|
||||
<string name="dash_transport_wifi">Wi-Fi</string>
|
||||
<string name="dash_transport_bt">BT</string>
|
||||
|
||||
<!-- OBU stationType warning -->
|
||||
<string name="dash_station_type_warning">⚠ OBU stationType ≠ 2 (cyclist) — VRU detection may be impaired at equipped intersections</string>
|
||||
|
||||
<string name="settings_platform">Platform</string>
|
||||
<string name="settings_platform_val">Android / Kotlin / Jetpack Compose</string>
|
||||
<string name="settings_project">Project</string>
|
||||
<string name="settings_project_val">MicrOBU — HAW Hamburg</string>
|
||||
|
||||
<!-- Phase A: Trip Recording (bottom nav) -->
|
||||
<string name="nav_trips">Trips</string>
|
||||
|
||||
<!-- Phase A: Recording screen event counters -->
|
||||
<string name="rec_events_detected">Detected Events</string>
|
||||
<string name="rec_event_braking">Braking</string>
|
||||
<string name="rec_event_turning">Turning</string>
|
||||
<string name="rec_event_stopping">Stopping</string>
|
||||
<string name="rec_stream_event_detection">Event Detection</string>
|
||||
<string name="rec_open_session_log">CSV Session Log</string>
|
||||
|
||||
<!-- Phase A: Trip History screen -->
|
||||
<string name="trip_history_title">Trip History</string>
|
||||
<string name="trip_history_count_one">%1$d trip stored on device</string>
|
||||
<string name="trip_history_count_other">%1$d trips stored on device</string>
|
||||
<string name="trip_number">Trip #%1$d</string>
|
||||
<string name="trip_delete_title">Delete trip?</string>
|
||||
<string name="trip_delete_text">This will permanently remove the trip recorded on %1$s.</string>
|
||||
<string name="trip_empty_title">No trips yet</string>
|
||||
<string name="trip_empty_desc">Go to Record and start a session</string>
|
||||
|
||||
<!-- Phase A: Trip Review screen -->
|
||||
<string name="trip_review_title">Trip Review</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.MicrOBU" parent="Theme.AppCompat.Light.NoActionBar" />
|
||||
|
||||
<style name="Theme.MicrOBU.Splash" parent="Theme.SplashScreen">
|
||||
<item name="windowSplashScreenBackground">#FFFFFF</item>
|
||||
<item name="windowSplashScreenAnimatedIcon">@mipmap/ic_launcher_foreground</item>
|
||||
<item name="postSplashScreenTheme">@style/Theme.MicrOBU</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<!-- Covers context.cacheDir — used for CSV exports -->
|
||||
<cache-path name="shared_csv" path="." />
|
||||
</paths>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<locale android:name="en" />
|
||||
<locale android:name="de" />
|
||||
</locale-config>
|
||||
@@ -0,0 +1,354 @@
|
||||
package com.hawhamburg.micr0bu
|
||||
|
||||
import com.hawhamburg.micr0bu.domain.detection.Confidence
|
||||
import com.hawhamburg.micr0bu.domain.detection.DetectedEvent
|
||||
import com.hawhamburg.micr0bu.domain.detection.DetectionConfig
|
||||
import com.hawhamburg.micr0bu.domain.detection.EventDetector
|
||||
import com.hawhamburg.micr0bu.domain.detection.EventType
|
||||
import com.hawhamburg.micr0bu.domain.detection.RunningStats
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Unit tests for [EventDetector] and [RunningStats] using synthetic sensor data.
|
||||
*
|
||||
* No Android emulator required — all production classes have zero Android imports.
|
||||
*
|
||||
* The test [config] uses a smaller window and fewer sustained frames than the
|
||||
* production defaults so tests run in milliseconds without generating thousands
|
||||
* of synthetic samples.
|
||||
*
|
||||
* Accel-std-dev notes
|
||||
* -------------------
|
||||
* A production threshold of 1.2 m/s² requires genuine variability in the window.
|
||||
* In the "hard brake" tests we alternate between high and low accel values
|
||||
* (e.g. 3.5 / 0.5), which yields std dev ≈ 1.5 with a 10-sample window.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class EventDetectorTest {
|
||||
|
||||
/** Tighter config so fewer frames are needed to trigger each event. */
|
||||
private val config = DetectionConfig(
|
||||
windowSize = 10,
|
||||
brakingSustainedFrames = 5,
|
||||
turningSustainedFrames = 8,
|
||||
stoppingFrames = 20,
|
||||
// Keep production thresholds for all signal values:
|
||||
brakingSpeedDropThreshold = 0.5,
|
||||
brakingAccelStdDevThreshold = 1.2,
|
||||
brakingHighConfidenceRate = 1.5,
|
||||
turningGyroMeanThreshold = 0.4,
|
||||
turningBearingChangeThreshold = 10.0,
|
||||
turningMinSpeedThreshold = 2.0,
|
||||
stoppingSpeedThreshold = 0.5,
|
||||
stoppingAccelStdDevThreshold = 0.15,
|
||||
)
|
||||
|
||||
private lateinit var detector: EventDetector
|
||||
|
||||
@Before fun setUp() { detector = EventDetector(config) }
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Runs [block] inside a coroutine that collects all emitted events. */
|
||||
private fun runCollecting(
|
||||
block: suspend kotlinx.coroutines.test.TestScope.(events: MutableList<DetectedEvent>) -> Unit,
|
||||
) = runTest {
|
||||
val events = mutableListOf<DetectedEvent>()
|
||||
val job = launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
detector.events.collect { events.add(it) }
|
||||
}
|
||||
block(events)
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
/**
|
||||
* Produces [n] frames with alternating accelMagnitude values of [hi] and [lo],
|
||||
* giving a population std dev of |hi - lo| / 2, which exceeds the production
|
||||
* threshold of 1.2 m/s² when hi=3.5 and lo=0.5 (std dev = 1.5).
|
||||
*/
|
||||
private fun alternatingAccelFrames(
|
||||
n: Int,
|
||||
hi: Double = 3.5,
|
||||
lo: Double = 0.5,
|
||||
speedMps: Double = 10.0,
|
||||
bearingChangeDps: Double = 0.0,
|
||||
timeOffset: Int = 0,
|
||||
) {
|
||||
repeat(n) { i ->
|
||||
detector.processSample(
|
||||
accelMagnitude = if (i % 2 == 0) hi else lo,
|
||||
gyroMagnitude = 0.05,
|
||||
speedMps = speedMps,
|
||||
bearingChangeDegPerSec = bearingChangeDps,
|
||||
latitude = 53.5,
|
||||
longitude = 10.0,
|
||||
timestamp = (timeOffset + i) * 20L,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Normal riding — no false triggers ───────────────────────────────────
|
||||
|
||||
@Test fun `normal riding produces no events`() = runCollecting { events ->
|
||||
// Steady 5 m/s, low gyro, very low accel variance (constant value → stdDev = 0)
|
||||
repeat(50) { i ->
|
||||
detector.processSample(
|
||||
accelMagnitude = 0.2,
|
||||
gyroMagnitude = 0.1,
|
||||
speedMps = 5.0,
|
||||
bearingChangeDegPerSec = 2.0,
|
||||
latitude = 53.5,
|
||||
longitude = 10.0,
|
||||
timestamp = i * 20L,
|
||||
)
|
||||
}
|
||||
assertTrue("No events expected during steady riding, got: $events", events.isEmpty())
|
||||
}
|
||||
|
||||
// ─── Hard brake ───────────────────────────────────────────────────────────
|
||||
|
||||
@Test fun `hard brake triggers BRAKING event`() = runCollecting { events ->
|
||||
// Phase 1: fill window at 10 m/s with constant accel (no std dev → no braking)
|
||||
repeat(config.windowSize) { i ->
|
||||
detector.processSample(1.0, 0.05, 10.0, 0.0, 53.5, 10.0, i * 20L)
|
||||
}
|
||||
// Phase 2: GPS drops to 4 m/s (drop = 6 m/s > 0.5 threshold).
|
||||
// Alternate hi/lo accel to exceed the std-dev threshold.
|
||||
alternatingAccelFrames(
|
||||
n = config.brakingSustainedFrames + 5,
|
||||
speedMps = 4.0,
|
||||
timeOffset = config.windowSize,
|
||||
)
|
||||
val braking = events.filter { it.type == EventType.BRAKING }
|
||||
assertTrue("Expected at least one BRAKING event, got: $events", braking.isNotEmpty())
|
||||
}
|
||||
|
||||
@Test fun `hard brake with large speed drop has HIGH confidence`() = runCollecting { events ->
|
||||
repeat(config.windowSize) { i ->
|
||||
detector.processSample(1.0, 0.05, 10.0, 0.0, 53.5, 10.0, i * 20L)
|
||||
}
|
||||
// Drop of 8 m/s > brakingHighConfidenceRate (1.5)
|
||||
alternatingAccelFrames(
|
||||
n = config.brakingSustainedFrames + 5,
|
||||
hi = 3.5,
|
||||
lo = 0.5,
|
||||
speedMps = 2.0, // drop from 10 → 8 m/s
|
||||
timeOffset = config.windowSize,
|
||||
)
|
||||
val braking = events.filter { it.type == EventType.BRAKING }
|
||||
assertTrue(braking.isNotEmpty())
|
||||
assertEquals(
|
||||
"Large speed drop should yield HIGH confidence",
|
||||
Confidence.HIGH,
|
||||
braking.first().confidence,
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun `moderate speed drop has MEDIUM confidence`() = runCollecting { events ->
|
||||
repeat(config.windowSize) { i ->
|
||||
detector.processSample(1.0, 0.05, 3.0, 0.0, 53.5, 10.0, i * 20L)
|
||||
}
|
||||
// Drop of 0.8 m/s — above speed-drop threshold (0.5) but below high-conf rate (1.5)
|
||||
alternatingAccelFrames(
|
||||
n = config.brakingSustainedFrames + 5,
|
||||
hi = 3.5,
|
||||
lo = 0.5,
|
||||
speedMps = 2.2, // drop = 0.8 m/s
|
||||
timeOffset = config.windowSize,
|
||||
)
|
||||
val braking = events.filter { it.type == EventType.BRAKING }
|
||||
assertTrue(braking.isNotEmpty())
|
||||
assertEquals(Confidence.MEDIUM, braking.first().confidence)
|
||||
}
|
||||
|
||||
// ─── Left turn ────────────────────────────────────────────────────────────
|
||||
|
||||
@Test fun `sustained high gyro above 2ms triggers TURNING event`() = runCollecting { events ->
|
||||
val total = config.windowSize + config.turningSustainedFrames + 4
|
||||
repeat(total) { i ->
|
||||
detector.processSample(
|
||||
accelMagnitude = 0.3,
|
||||
gyroMagnitude = 0.8, // mean → well above 0.4 threshold
|
||||
speedMps = 4.0, // above 2 m/s → bearing also checked
|
||||
bearingChangeDegPerSec = 15.0, // above 10 °/s → both signals agree
|
||||
latitude = 53.5,
|
||||
longitude = 10.0,
|
||||
timestamp = i * 20L,
|
||||
)
|
||||
}
|
||||
assertTrue("Expected TURNING event, got: $events", events.any { it.type == EventType.TURNING })
|
||||
}
|
||||
|
||||
@Test fun `turning with both signals agreeing gets HIGH confidence`() = runCollecting { events ->
|
||||
val total = config.windowSize + config.turningSustainedFrames + 4
|
||||
repeat(total) { i ->
|
||||
detector.processSample(0.3, 0.8, 4.0, 15.0, 53.5, 10.0, i * 20L)
|
||||
}
|
||||
val turning = events.filter { it.type == EventType.TURNING }
|
||||
assertTrue(turning.isNotEmpty())
|
||||
assertEquals(Confidence.HIGH, turning.first().confidence)
|
||||
}
|
||||
|
||||
@Test fun `turning at low speed with gyro only gets LOW confidence`() = runCollecting { events ->
|
||||
val total = config.windowSize + config.turningSustainedFrames + 4
|
||||
repeat(total) { i ->
|
||||
detector.processSample(
|
||||
accelMagnitude = 0.2,
|
||||
gyroMagnitude = 0.6, // above gyro threshold
|
||||
speedMps = 1.0, // below 2 m/s → bearing not enforced
|
||||
bearingChangeDegPerSec = 3.0, // below bearing threshold
|
||||
latitude = 53.5,
|
||||
longitude = 10.0,
|
||||
timestamp = i * 20L,
|
||||
)
|
||||
}
|
||||
val turning = events.filter { it.type == EventType.TURNING }
|
||||
assertTrue("Expected TURNING event at low speed, got: $events", turning.isNotEmpty())
|
||||
assertEquals("Low-speed turn should be LOW confidence", Confidence.LOW, turning.first().confidence)
|
||||
}
|
||||
|
||||
// ─── Full stop ────────────────────────────────────────────────────────────
|
||||
|
||||
@Test fun `full stop for required frames triggers STOPPING event`() = runCollecting { events ->
|
||||
val frames = config.stoppingFrames + 5
|
||||
repeat(frames) { i ->
|
||||
detector.processSample(
|
||||
accelMagnitude = 0.02, // constant → stdDev = 0 < 0.15
|
||||
gyroMagnitude = 0.01,
|
||||
speedMps = 0.1, // < 0.5 threshold
|
||||
bearingChangeDegPerSec = 0.0,
|
||||
latitude = 53.5,
|
||||
longitude = 10.0,
|
||||
timestamp = i * 20L,
|
||||
)
|
||||
}
|
||||
val stopping = events.filter { it.type == EventType.STOPPING }
|
||||
assertTrue("Expected STOPPING event, got: $events", stopping.isNotEmpty())
|
||||
assertEquals("STOPPING should always be HIGH confidence", Confidence.HIGH, stopping.first().confidence)
|
||||
}
|
||||
|
||||
@Test fun `STOPPING emitted exactly once per stop episode`() = runCollecting { events ->
|
||||
val frames = config.stoppingFrames + 30
|
||||
repeat(frames) { i ->
|
||||
detector.processSample(0.02, 0.01, 0.1, 0.0, 53.5, 10.0, i * 20L)
|
||||
}
|
||||
val stopping = events.filter { it.type == EventType.STOPPING }
|
||||
assertEquals("STOPPING must emit exactly once per episode", 1, stopping.size)
|
||||
}
|
||||
|
||||
// ─── Bag movement while stationary ───────────────────────────────────────
|
||||
|
||||
@Test fun `bag movement while stationary does not trigger BRAKING or TURNING`() = runCollecting { events ->
|
||||
// Speed stays at zero; occasional accel/gyro spikes from bag jostle
|
||||
repeat(50) { i ->
|
||||
val accel = if (i % 5 == 0) 1.8 else 0.3 // jitter but mean is below std-dev threshold
|
||||
val gyro = if (i % 7 == 0) 0.35 else 0.05 // occasional spike but mean stays < 0.4
|
||||
detector.processSample(
|
||||
accelMagnitude = accel,
|
||||
gyroMagnitude = gyro,
|
||||
speedMps = 0.0,
|
||||
bearingChangeDegPerSec = 1.0,
|
||||
latitude = 53.5,
|
||||
longitude = 10.0,
|
||||
timestamp = i * 20L,
|
||||
)
|
||||
}
|
||||
// speed = 0 → no speed drop possible → no BRAKING
|
||||
// gyro mean stays below 0.4 (only 1/7 frames spike to 0.35) → no TURNING
|
||||
val unwanted = events.filter { it.type == EventType.BRAKING || it.type == EventType.TURNING }
|
||||
assertTrue("Bag movement must not trigger BRAKING or TURNING, got: $events", unwanted.isEmpty())
|
||||
}
|
||||
|
||||
// ─── Starting from stationary ─────────────────────────────────────────────
|
||||
|
||||
@Test fun `starting from stationary does not produce false BRAKING event`() = runCollecting { events ->
|
||||
// Begin at rest then accelerate — speed only INCREASES
|
||||
repeat(10) { i ->
|
||||
detector.processSample(0.1, 0.05, 0.0, 0.0, 53.5, 10.0, i * 20L)
|
||||
}
|
||||
repeat(20) { i ->
|
||||
val speed = i * 0.3 // 0 → 5.7 m/s — monotonically increasing
|
||||
detector.processSample(0.8, 0.1, speed, 1.0, 53.5, 10.0, (10 + i) * 20L)
|
||||
}
|
||||
val braking = events.filter { it.type == EventType.BRAKING }
|
||||
assertTrue("Starting from stationary must not trigger BRAKING, got: $events", braking.isEmpty())
|
||||
}
|
||||
|
||||
@Test fun `stopping then re-accelerating then stopping triggers two STOPPING events`() = runCollecting { events ->
|
||||
val stopFrames = config.stoppingFrames + 5
|
||||
var t = 0
|
||||
|
||||
// First stop episode
|
||||
repeat(stopFrames) {
|
||||
detector.processSample(0.02, 0.01, 0.1, 0.0, 53.5, 10.0, t++ * 20L)
|
||||
}
|
||||
// Brief acceleration — resets the stopping counter
|
||||
repeat(5) {
|
||||
detector.processSample(0.5, 0.1, 5.0, 2.0, 53.5, 10.0, t++ * 20L)
|
||||
}
|
||||
// Second stop episode
|
||||
repeat(stopFrames) {
|
||||
detector.processSample(0.02, 0.01, 0.1, 0.0, 53.5, 10.0, t++ * 20L)
|
||||
}
|
||||
|
||||
val stopping = events.filter { it.type == EventType.STOPPING }
|
||||
assertEquals("Expected two STOPPING events (one per episode)", 2, stopping.size)
|
||||
}
|
||||
|
||||
// ─── RunningStats unit tests ──────────────────────────────────────────────
|
||||
|
||||
@Test fun `RunningStats mean and stdDev are correct for known sequence`() {
|
||||
val stats = RunningStats(windowSize = 4)
|
||||
// [2, 4, 4, 4] → mean = 3.5, variance = 0.75
|
||||
stats.add(2.0); stats.add(4.0); stats.add(4.0); stats.add(4.0)
|
||||
assertEquals(3.5, stats.mean(), 1e-9)
|
||||
assertEquals(0.75, stats.variance(), 1e-9)
|
||||
assertEquals(sqrt(0.75), stats.stdDev(), 1e-9)
|
||||
}
|
||||
|
||||
@Test fun `RunningStats evicts oldest value when window is full`() {
|
||||
val stats = RunningStats(windowSize = 3)
|
||||
stats.add(1.0); stats.add(2.0); stats.add(3.0)
|
||||
assertEquals(2.0, stats.mean(), 1e-9) // [1,2,3] → 2.0
|
||||
stats.add(10.0)
|
||||
assertEquals(5.0, stats.mean(), 1e-9) // [2,3,10] → 5.0
|
||||
}
|
||||
|
||||
@Test fun `RunningStats size grows correctly and caps at windowSize`() {
|
||||
val stats = RunningStats(windowSize = 5)
|
||||
assertEquals(0, stats.size())
|
||||
stats.add(1.0); assertEquals(1, stats.size())
|
||||
stats.add(1.0); assertEquals(2, stats.size())
|
||||
repeat(10) { stats.add(1.0) }
|
||||
assertEquals(5, stats.size())
|
||||
}
|
||||
|
||||
@Test fun `RunningStats reset clears all state`() {
|
||||
val stats = RunningStats(windowSize = 5)
|
||||
repeat(5) { stats.add(it.toDouble()) }
|
||||
stats.reset()
|
||||
assertEquals(0, stats.size())
|
||||
assertEquals(0.0, stats.mean(), 1e-9)
|
||||
assertEquals(0.0, stats.stdDev(), 1e-9)
|
||||
}
|
||||
|
||||
@Test fun `RunningStats variance is zero for constant sequence`() {
|
||||
val stats = RunningStats(windowSize = 10)
|
||||
repeat(10) { stats.add(3.14) }
|
||||
assertEquals(0.0, stats.variance(), 1e-9)
|
||||
}
|
||||
|
||||
@Test fun `RunningStats returns zero mean and stdDev for empty window`() {
|
||||
val stats = RunningStats(windowSize = 10)
|
||||
assertEquals(0.0, stats.mean(), 1e-9)
|
||||
assertEquals(0.0, stats.stdDev(), 1e-9)
|
||||
}
|
||||
}
|
||||