From ae17d4fcbafb11913bcbab53d9be94c886675937 Mon Sep 17 00:00:00 2001 From: Ashin Walpola Date: Wed, 3 Jun 2026 14:25:20 +0200 Subject: [PATCH] Initial Commit --- V2X_MicrOBU_Android_App_Requirements_v9.docx | Bin 0 -> 47520 bytes app/build.gradle.kts | 82 +++ app/proguard-rules.pro | 1 + app/src/main/AndroidManifest.xml | 71 ++ app/src/main/ic_launcher-playstore.png | Bin 0 -> 48868 bytes .../com/hawhamburg/micr0bu/MainActivity.kt | 275 ++++++++ .../hawhamburg/micr0bu/MicrOBUApplication.kt | 30 + .../hawhamburg/micr0bu/data/CsvExporter.kt | 71 ++ .../micr0bu/data/SensorRepository.kt | 104 +++ .../hawhamburg/micr0bu/data/TripRepository.kt | 93 +++ .../hawhamburg/micr0bu/data/db/AppDatabase.kt | 87 +++ .../micr0bu/data/db/DetectedEventEntity.kt | 50 ++ .../micr0bu/data/db/RecordedTripEntity.kt | 21 + .../hawhamburg/micr0bu/data/db/SessionDao.kt | 20 + .../micr0bu/data/db/SessionEntity.kt | 13 + .../com/hawhamburg/micr0bu/data/db/TripDao.kt | 40 ++ .../micr0bu/data/mqtt/CamBuilder.kt | 57 ++ .../micr0bu/data/mqtt/MqttConnectionState.kt | 8 + .../micr0bu/data/mqtt/MqttMessage.kt | 10 + .../micr0bu/data/mqtt/MqttPreferences.kt | 81 +++ .../micr0bu/data/mqtt/MqttRepository.kt | 245 +++++++ .../micr0bu/data/transport/TransportType.kt | 3 + .../data/transport/UsbNetworkDetector.kt | 82 +++ .../micr0bu/domain/detection/DetectedEvent.kt | 38 ++ .../domain/detection/DetectionConfig.kt | 58 ++ .../micr0bu/domain/detection/EventDetector.kt | 247 +++++++ .../micr0bu/domain/detection/RunningStats.kt | 66 ++ .../micr0bu/service/TripRecordingService.kt | 413 ++++++++++++ .../micr0bu/service/TripServiceBus.kt | 38 ++ .../micr0bu/ui/components/StatusTopBar.kt | 169 +++++ .../micr0bu/ui/navigation/AppNavigation.kt | 119 ++++ .../ui/screens/ConnectionSetupScreen.kt | 248 +++++++ .../micr0bu/ui/screens/DashboardScreen.kt | 434 ++++++++++++ .../micr0bu/ui/screens/MapScreen.kt | 138 ++++ .../ui/screens/MqttTopicViewerScreen.kt | 636 ++++++++++++++++++ .../micr0bu/ui/screens/RecordingScreen.kt | 234 +++++++ .../micr0bu/ui/screens/SensorScreen.kt | 118 ++++ .../micr0bu/ui/screens/SessionLogScreen.kt | 163 +++++ .../micr0bu/ui/screens/SettingsScreen.kt | 536 +++++++++++++++ .../micr0bu/ui/screens/TripHistoryScreen.kt | 179 +++++ .../micr0bu/ui/screens/TripReviewScreen.kt | 302 +++++++++ .../com/hawhamburg/micr0bu/ui/theme/Theme.kt | 60 ++ .../micr0bu/viewmodel/MqttViewModel.kt | 196 ++++++ .../micr0bu/viewmodel/SensorViewModel.kt | 293 ++++++++ .../viewmodel/TripRecordingViewModel.kt | 81 +++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 6 + app/src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 1592 bytes .../mipmap-hdpi/ic_launcher_foreground.webp | Bin 0 -> 2434 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 2966 bytes app/src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 936 bytes .../mipmap-mdpi/ic_launcher_foreground.webp | Bin 0 -> 1408 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 1738 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 2072 bytes .../mipmap-xhdpi/ic_launcher_foreground.webp | Bin 0 -> 3308 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 4338 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 3712 bytes .../mipmap-xxhdpi/ic_launcher_foreground.webp | Bin 0 -> 5708 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 6896 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 5078 bytes .../ic_launcher_foreground.webp | Bin 0 -> 8348 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 10480 bytes app/src/main/res/values-de/strings.xml | 216 ++++++ .../res/values/ic_launcher_background.xml | 4 + app/src/main/res/values/strings.xml | 217 ++++++ app/src/main/res/values/themes.xml | 10 + app/src/main/res/xml/file_paths.xml | 5 + app/src/main/res/xml/locales_config.xml | 5 + .../hawhamburg/micr0bu/EventDetectorTest.kt | 354 ++++++++++ build.gradle.kts | 7 + gradle.properties | 7 + gradle/libs.versions.toml | 57 ++ gradle/wrapper/gradle-wrapper.properties | 5 + settings.gradle.kts | 17 + 74 files changed, 7126 insertions(+) create mode 100644 V2X_MicrOBU_Android_App_Requirements_v9.docx create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/ic_launcher-playstore.png create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/MainActivity.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/MicrOBUApplication.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/data/CsvExporter.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/data/SensorRepository.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/data/TripRepository.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/data/db/AppDatabase.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/data/db/DetectedEventEntity.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/data/db/RecordedTripEntity.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/data/db/SessionDao.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/data/db/SessionEntity.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/data/db/TripDao.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/CamBuilder.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/MqttConnectionState.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/MqttMessage.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/MqttPreferences.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/MqttRepository.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/data/transport/TransportType.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/data/transport/UsbNetworkDetector.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/domain/detection/DetectedEvent.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/domain/detection/DetectionConfig.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/domain/detection/EventDetector.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/domain/detection/RunningStats.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/service/TripRecordingService.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/service/TripServiceBus.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/ui/components/StatusTopBar.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/ui/navigation/AppNavigation.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/ui/screens/ConnectionSetupScreen.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/ui/screens/DashboardScreen.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MapScreen.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/ui/screens/RecordingScreen.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/ui/screens/SensorScreen.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/ui/screens/SessionLogScreen.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/ui/screens/SettingsScreen.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/ui/screens/TripHistoryScreen.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/ui/screens/TripReviewScreen.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/ui/theme/Theme.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/viewmodel/MqttViewModel.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/viewmodel/SensorViewModel.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/viewmodel/TripRecordingViewModel.kt create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/values-de/strings.xml create mode 100644 app/src/main/res/values/ic_launcher_background.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/file_paths.xml create mode 100644 app/src/main/res/xml/locales_config.xml create mode 100644 app/src/test/java/com/hawhamburg/micr0bu/EventDetectorTest.kt create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 settings.gradle.kts diff --git a/V2X_MicrOBU_Android_App_Requirements_v9.docx b/V2X_MicrOBU_Android_App_Requirements_v9.docx new file mode 100644 index 0000000000000000000000000000000000000000..1babb2c992708b2d21a3e21c4e1652edafd4c1b1 GIT binary patch literal 47520 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

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

(< zos2={pSSe;pAnY8qKumIDoR92)21Avz%OA?mJ$FW&&&{HaVE zFinuy5<0h4$+_aF_;I(c8mx*Rx>yu|I;)}AtB4~&-AC5q&hW3&4k-cigZ0|`!HmHw z8a6d$SENC(Km;kWKHIcaD9;Coheir$pG-eJh)UEnnVMA5uXG|0H4Y$tR`KlERDND5 z3U*Mg@~Sbh$%UhXtShMb*t?+5oB(-b;`YeSHRVsGMCALES|KPy&x7C~0ElIunNhdJ zJ-*^6(t}Td+%tb{tC>>o7dS62(^ggFL+E6OOKtIAuNfK7JI4YY)VT1_N_DR;4P52a z?-Y!^jsLxCq#UP-P71#8j{m#W7LUVg?xTZlHT~_<=in>p|V(_N2cWvY{&-}p#>mps>6(ri1 zZX$8K1>(`O0`l6)JxV4DN?7&p963LGD&%zrh?gU5B;P$wkRSo-dNr$v6&E0a6a-J4 zLGlP2#7B2<$lbk|_ZBJ9-+}FESfdoQC(n*2FD3fEv48tq0piEGHsbFrH?^YbyR;ou zDK!(>3af3bIgLQHV%erG#80jO@fGMoX=`;I^`#&IL{VOC!blmY78}uS33d!vt;Fj z7pFuFP?A$*sWv$WBMEvGEI%(xN#E$x<|A{k3mQV*Z65NMFa1%c{68T98bVykL`bNr zVf2S}1Z5tppVidO0KiLf7>hu#|LHgSyp_mr^uq)Z*diarv*vvL)wY5XgFL&S39A#r z_)XD@1j+yo;vzbPMAUIHssO7i@Y%Oh=A$@200W_LZDokUzA^7T8n+Tj05C{%^5}C4 zGICTi1!);&Kt+t5k0Aj_TIap_Xvg?$W#~rOwl+Ynf9LOxOMWH=l2!l3PJS^)O#@Y! z){TmG@NkKmSOCdV=$CwX+P@2OYXhGMGhtN(DO>-hjp!>+RRE_b#?8rL&J5WFPUC>I z5&~3d8hL$o+ctpIsz{e8N}K77PM~i2&__@G1P2maG0hPY(@q)aq0`HTp{`7#1rb5a z36Vwf2{WI*{-drSm6|K2Svg3oY?$gvtH_cQXLpgE6GD(iM00EmMYK6)%6$huee(w# zq^VGCrkHlLaVbpRwrT3%Ph|HUIC^TCjE%W&EP&5-M<-B3^ThtwYDvA%l9f9^3n*IoG#5eE8M7@8A3U)wdtN z|C)5ac=h4E&ZCEKY&mI8i8ejRPKqs*VMqEey$RMLe{2kCl+b`y{o42IJhu0OAxnoR zw(Qp_MXMG{onn#S1*7%rl+-d3yH~L)V7e7dS%hE;3l7LWx=`37_xzWAucb4}& z?k*R2(0#7dg+xGrsj#NCt}vyGZMoEAzH@~iUF=HdxyvW6!Jx9Dl-e*2Tl<+WqEu*( z!PlOlcZJ^bGM9eC_x5~+LBi#eC}0(XGFvwOqLi{Q!(*WmbR-nVpgBv|mQ?8{1fv`% z`COibR;Hkp%FRO9dG7YT<9S|Vxx%X=Z@JYg&hmwoshFZF3qo9}6{VJDZg-hyoaQRu zxXk%}aE)8s>S|Yb-E$rkn_5a)QX-}%{v>5=*X>_oqa z5Db=b0wQ~C3BZ@~gk~JE&jog&;DW>SjcZWEgk{$Zl?r0e!X8FwXb;w00qH5tr3d|V1SA=RWS&j1&+6T0N;ATmQy@0(du0C&=^+maZxY1&o$2Xs`qjDasnvF z3dapzbFpXL;8geeM&F8!5`YC8)sx#Y5i!MF7OKy-z=*&cdWHw)7%`M1(NhT|09olt zXi5+WTL?fYqg*PdwL+@Wir5H<0^##ikSOJ}lu~NFkzk-aJ_T_=#uUj!7Gg_M%0wZy zKtxO#rgH%4a-b41IecnOYn_Rx8H6ef$AcGrADogN4?yJR98+xva*FAfwW9yPRaaBW zM>nf>jsCEpF|wjRzlu8-?i=ZYswJDk7~OuZ{Hpe=k!!yyeerILHHDG7jPk(6XJ0`# z9VGp9Fly3I2kN8meOE@=BcYAGBd<@W9akF}N8Bi5tSOAtkE4c-31fP6dDJkB#Qj0_ K?a=6u|Caz-L&zxr literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000000000000000000000000000000000000..d63f25230cd736732fdc5ef8752c424e9890dc32 GIT binary patch literal 1408 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..01bcbc77a1d33cf3301af5fcd74e2a4864fbae4b GIT binary patch literal 2072 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..2e3795c2f43d55296c24fd5ba1b1352a43c12069 GIT binary patch literal 4338 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000000000000000000000000000000000000..6610a1b7d284102c0b5000072ea6e6752a73c942 GIT binary patch literal 5708 zcmV-S7PIM6Nk&FQ761TOMM6+kP&iCC761S*L%~oGWvFf2hKc_FJH3q&p}=h$Wz2`K z;Qc5HMy)ao*yFL+n8X3T+o#y^`S_vh%`0OEex}j?xZ#{e{k#vsV%yfTZI7moBO8?- zFr%d|utv|ENRDOZL?szyCk(MiUC9Bt;4> zP&8Pf5S+&4FHs;xI(K)g-72SZci(e!cX!{eeslMBK)w?o<+g2Q%XUJWd!sSv_A6*4 z8kIrw1l8T$F7EE`u8r|FwCSg(Joj=A+PD!?t!+Ct?HMM!+el0{n-M+xF?r&wY3f#- zY}@}Pn8ySlSpQr9TmM`CTmS#yojt3DsUI`+RV1S1T&#p$VfY^Yzpnj_r zQBd(1AP|WXymSv39L7l`L2e38>m-Oog17COc1wgM#W=VKQd-WBMM7E)9lsPm1MEp{SW1LhjkE;I(W#LU zpwTQX$_zWx2}oN@D z$EC%jN-kOJDd0YF2IRO^n$&VsJf_rh$Qs4L&FVE_nFdol$3!p9M@JUK!~J50WLfy6 z>CeajnZ~Fj9o#dem6K ztaxb{Wl?9fRg9^C&XgLh1nfkZ%yL>MfI(UVN`@HK$m?a!1)!gw(=5|@s4_tU0@D39^-%L?A1*wc-+nRT&38)(*+yFaRa|5~9w*X96&!*GlrY0h3jxPah{Jx1$9NnuW@M(+iB;eHstrP&z z%BKPzNp^F%`eE|y!d607BV5p8G5~;+1u&pB&|^6!2Q8~{000ddQ-Km0c6qFNm23oyb0AN@%0FxjH zQv;rbhu2XM5|aTpQK?kz_*LoG0_iZGz&cI<0Au13j+G6p8KH#&)h*ec% zm0>jrxiNG20ZFzY(5!QS-W2R8u1totW_TN^Y{Vr4W*ZqJ99a!a^6_;Po2A?WOw-$l zFPSu(G#jL+B#=~5;Vl)GV~1(ga)#M(G}cJ*W3VTUX*TJK=~1U&9qdSc`m^^v5TtF12wf;mneIgOyO;Ss>;!rs*(n6 z#w;xh14)~P8eyjB$WT{)PE%Nk^m9wnrorjft$M^7-C8G9>(+8ZIFsD^D}<9%Bmik? z5W<>?*O2`aZbUx2A<(T_4p$)KoU-^-Sp9UsB&YHE;A9mWfqm?o^1oZsVzn%;JVrTX zaJjG|WA`nA9H-IhU~(M%u#vq3lz}|}K#8BApvFyL)L1$lR?1<;(WOF?VmAi&xle_0 zAN$k$X26ZeUJ6Hh*B^fMvRSGKC*pQ{{o#@F0z$}3Yz>pg5keLf5JEoU004gEcX5YM=Kx2zgEylPZ@XkOk_7&6wlu z@__voQBPy!0CHJ2K1-$R!Yp8%Eg8-Na@U9tkWV5<_uSBNIzo&99Io~!LI^3?XKzo# zHk1Sb=CTOWOF?qXcgQmXm~W96r`+Cg5i(B=j6OujLAN+dgd1GFz~$LMie9)Zl8l1c zz$?f%xSnMQxnasn9k(L;FmM(^&cZ|McI1o5ntgO~OIC$Jh@Ux~9S9lhIR+tfQ~qEK zsC|vx4B#2$D?GHAPm!;-!xM!Vieija5&!`K$tXY(FA9*vqD=wWtvs^Yx5)3DQ^Xzx%Ve-l7ZfRsBh5)*qrAbM1Jy)9}x078vrQw1w#JsQP24q z*=;jIDgsUGC~TyG#bQ=O3<4yqBr3I%;jvvmoF}qde|&0R2bh@09cujQE*%iE_}mw! zB7gU}%ar@CMTGp|VmfGf+;1KsWWTc+6glV%M*$e}g)h`a-tdL3Q^G8;+(ZOqttv|* zPDzG0KlopI(58dJ-J!)jlDWkI0Mx2^wON6O)rz#LrG!4SMs3P4NJ)bA#+V}#rFcom zG$K@A`28PL<`=^MSFJ1RD`Hq~nE+rdnXph2K%=Tik5Y0;NII0We}_IrzY)p*8cag+ zCn56CrCjrNI>9a&+yNp4~Cqh`FO5*^K^m7)(5PS1^5*pU;OTlia zAS6i9=PMyjK`aQ7(fVR(R#g%$DvQNneL*NiMJbCxTw=Al5g>|EmV=v@2*nULrf1$d zM^TLNa+iaTqa47Zc%Bo3F%G_Rh~w+A+6XUR4kFY`h=y1XDv?AYcG_u&*a*%hJMD7x z5*fRkHHO)0mmmN*+Ac={fL^=oq6;WCvQ|kw2#WX;*?|!9;fQvGkY|>-5&3Kk=T~G4 z0C)y@5&#^H5E}qs(r(>Il}186)TmE})D75if&X3SGUTa^SPd0Kr?OoBK+f;seSs`4 zvwI9PV!fIHZtJb50ss$gMJ-eiqs+P7iG0-4k9^^i9Nnnvp{+29f(X#&5=TgV$K}Wl z9}KYS6E~)Oz#hQVi0~`#&@)gCP%N|}q*x5$MN zmG2O5kK3&dA%qmXYM^7UO3x`Eq~J*n4bD#%Y#MN+1q*Covv~!iVBWkwe>3l}@`%w- z=AAKsr=FmsDv&s?`5X!L8K$FmIr1?USdV;$b#(a=A^-EBS1llmPKf;ID`dY1yrqEr zsdwTij9$*Zsh%Ti7?BAh} zkfJChnCd8sQe*^@N3M#BIc_M@;ObQ`nSp9dzj$l4RHgWjNUzYS}{E7UhBY==Vn0Ym^$`{e7- z|B0Oai#$M_8RmV2{LVarxg8--3aHWTpB9k)T!F*L3;e2$dmbUD!Ycnn$o+O{X&C&3 zkRRxJ)aEEwS+8b5fVLju_&{AmPyYFmOD?>ZmgJsCbffDP;4FmP4kJMA8U+-3RXGIi zb_Z%LI2T){+dM+Z@eQ~FA(z1*qe%fPl+7#GBWD#167k~Y;Nyr85HNvC)bOGblL!bJ zVcTt|YLweWxE_s))V%6&sCbC#cZ~n9{?O_yCVQV zkL|Y80RZT9jVIhs2#g`5V+4Rviw+$+oMi+xTHAE!(7~$&hKU?}<>2P+p&x-8<48jq zhMaENg~$ikSgYJP=q=}mX(BIoIVeTN@c_(3^rIi`Y$!t`8qt8L4~)t|w|S1z|Dr2y=@c9YV+{Fs)kyjMJK& zP;XU9FybT3W`vL%DW*6jU{)>Pm?#ygAcojUk1%Csou~_@F|*hd2q_&Ru^TD~r~XIT zT9OIELMP*g^m7&jNO~;11jTMDKNSJOjA%skdXhX=@ZQ#g43`GG+Hltt>F%$_$7GN$5NHYoE4Gq7G-QW(_I>VX)O0p~5?oxxe?R4=-LtPDWN#Bf^ zFHor9R+`AHH&N>a%XgFJGFTW&l5U<`F>V1lXp_8Ym-+@F7exLIdiGz!(l$@1vA4eb_@_YBei0Jsn-MCu~d2+8fEhF2C>9x%=+ zhuwvLAfysPHzPzHNJl0S(y)&SPUBEtoSZU)ci|3X9v@iZCxjRRPhkz-tH`IgZ8U0< z!xaehbIVd}7NM6!nUTk^+Sa*Q!Jtzjf#N^zaxLS z+N>j8((IsgWpI;nAnoNJS5#jr1#$rMzX zT~v$aJw8F@hDF)S1cF>9DNBNx<`RTDW2TuTOTw?zG*&3!F>I~X1yL$sm{9CVK*6QiY@jOy-W-QC zR2*+3otZ+&u-k|)8L-MIA(X+0S{YU-x>ZKh8`_3Nj<8aWWgBhQ8nFt>u3NiaEt|00 zfQOZM8`w8dDWF|96rNv|psFo6ZNR}=oDG~?D3(&i52cqZKO0xKS1^6WB7k|`&{wPfYVSj;Gr$DsT;bL>*wsZ*Q<#BL?n5;5EO=;8#LPkM#X;eyDE{nk_ zlC9Kd;0ntmiG;pm2ss@?AP@+WDz4)du#yZ1XIJYjaTT7VQ!4?3Gv$?JG^J*WFoL|KrONxdkL=y>UZH^Ito7RW zs;A^-w=xsLB4uV2N%F$6GMnMnN5D^z2BP{VzY#)+OTr=|CF18V{u4sT^6%(z1JV*q zzo%y6bClER4LL66qdsk?f1y}HxRw85+4wuU`j}BSJ z5uE8x;=+GUq~;5v_zJFwHf2p?ifWFUY|skRTyVYy$czL5To06*#0N!3M7s%DY)BgW zud97^z{o0L7Be8tPBs8XOfSvHX~Nnbu$kcJq@+M}@wsjniV)3>%&GsnnlFe(G=(Cg zCKbc1CR57!r0L<5c}!_gWkPEQfXO-$QPEdC*!$FvVxr4GcCh!R=R`$C_}E}ptx1jQ zP4U1y}lmTGj}~ z0KE9vUoAHOqSrl6a{>Ofcm0K$KNntp0Zilc%4$?Cs~b*SyPP&oNs^)-JaOpV&wuss zw--LX=Y^+G!Ik)@ANi3_zP0f6zdrEllfp2|EH15Fo16|fbu}^qj7FsOQ$zr?j2K+b zKnIgJ9nylV0y1jg`gKiom6UENPH79i<+!q&md} zDJ}3YNXaKjutJeAnW&8_4TS~*SzM_V1W7(hctAw7b~4!zixr|IlAtQei}2jmlStxf zSq+iN+9}}Sk;6ENB*@L6Nt8$=iu0*qrJSH(R;5pjNF<8!GII-(NX9wf;gXJ5f=DD7 y;GyBrU`T{8E^0&*CmoLg0+A@eO9v039NL5lYlXFO!2kQ-`rrEB`rrEh=k@>t^EI*n literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..fb5ce0ab454396d94a5cc58dd11d3bf1c73a7d02 GIT binary patch literal 6896 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..d27ca70a5720afdfa2a82206d540758f2be7c845 GIT binary patch literal 10480 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml new file mode 100644 index 0000000..17d616c --- /dev/null +++ b/app/src/main/res/values-de/strings.xml @@ -0,0 +1,216 @@ + + + + English + Deutsch + + + V2X Begleiter — Phase 02 + + + Dashboard + Sensoren + Messsitzung + Messsitzungen + V2X + Einstellungen + Verbindung + + + GNSS + IMU + OBU-Verbindung + GPS-Signal wird erfasst… + Berechtigung erforderlich + V2X-Tab öffnen zum Verbinden + Verbunden + MQTT verbunden + Verbindung zum Broker… + Broker nicht erreichbar — V2X-Einstellungen prüfen + Aufnahme + Messwerte + Wird initialisiert… + Luftdruck + Höhe + Richtung + Genauigkeit + + + Sensormonitor + Telefon-GNSS + Beschleunigungssensor + Gyroskop + Magnetometer + Barometer + Breitengrad + Längengrad + Höhe + Geschwindigkeit + Richtung + Genauigkeit + Luftdruck + Geschätzte Höhe + Warte auf GPS-Signal… + Wird initialisiert… + Sensor nicht verfügbar + + + Verbindungseinrichtung + Verbindung zur micrOBU-Hardware über Bluetooth (primär) oder USB-C (Fallback). + USB-C-Tethering ist der primäre Transport in Phase 02. OBU anschließen und Verbinden tippen. + Bluetooth + Bluetooth + Bluetooth-Verbindung ist für Phase 03 geplant und noch nicht implementiert. + Geräte suchen + Suche läuft… + USB-C (Primär) + Über USB-C verbinden (noch nicht implementiert) + Kabel erkannt + Kein Kabel + OBU-IP + Wird erkannt… + IP-Quelle + Automatisch erkannt + Manuell + ⚠ USB-C-Kabel anschließen, oder Auto-Erkennung in Einstellungen → Verbindung deaktivieren. + Verbinden + Mit OBU verbinden + Trennen + Ausgewählt + Verbunden (Stub) + ⚠ Bluetooth noch nicht implementiert. Dieser Bildschirm zeigt das Ziel-UI-Layout. + + + Messsitzung + Bereit zur Aufnahme + %1$d Messwerte erfasst + Aktive Datenströme + STOPP + REC + Telefon-GNSS + Beschleunigungssensor + Gyroskop + Magnetometer + Barometer + OBU / MQTT + + + Messsitzungen + %1$d Sitzung auf dem Gerät gespeichert + %1$d Sitzungen auf dem Gerät gespeichert + Sitzung #%1$d + CSV teilen + Auf Gerät speichern + Sitzung löschen + Noch keine Sitzungen + Gehe zu Aufnahme und starte eine Sitzung + Sitzung löschen? + Die am %1$s aufgezeichnete Sitzung wird dauerhaft vom Gerät entfernt. + Löschen + Abbrechen + Sitzung exportieren + + + GNSS-Standort + In Maps-App öffnen + Öffnet Ihre bevorzugte Karten-App + In App anzeigen + Aktuellen Standort auf einer In-App-Karte anzeigen + Noch kein GPS-Signal — gehen Sie ins Freie + Standortkarte + Aktueller Standort + + + Einstellungen + Sprache + Darstellung + Dunkles Design + Datenströme + Telefon-GNSS + Beschleunigungssensor + Gyroskop + Magnetometer + Barometer + Einheiten + Metrisch (m, km/h, hPa) + MQTT-Broker + Broker-IP + Port + Benutzername + Passwort + optional + Anzeigen + Ausblenden + Entwickler + Entwicklermodus + WLAN-OBU-Verbindung + Nur Entwicklermodus — noch nicht implementiert + Über + App-Version + 0.4.0 (Phase 02 — USB-C + DENM TX) + Verbindung + OBU per USB-C automatisch erkennen + OBU-IP (manuell) + Aktiver Transport + USB-C + WLAN + Broker-Einstellungen speichern + + + V2X-Monitor + Verbinden + Trennen + Automatisch scrollen + Noch keine Nachrichten + Mit der OBU verbinden und auf V2X-Verkehr warten + Noch keine Nachrichten zu diesem Thema + + + Zuletzt gesendete Nutzlast: + DENM-Übertragung + Test-DENM senden + DENM stoppen + Liegengebliebenes Fahrzeug (causeCode 94) + Mit OBU verbinden, um DENM-Auslösung zu aktivieren + DENM aktiv — OBU sendet über ITS-G5 + Sendet uca-denmctrl (retained) an v2x-uca/input/denmtrg + Letztes TX anzeigen + Ausblenden + + + USB-C + WLAN + BT + + + ⚠ OBU-stationType ≠ 2 (Radfahrer) — VRU-Erkennung an ausgerüsteten Kreuzungen ggf. beeinträchtigt + + Plattform + Android / Kotlin / Jetpack Compose + Projekt + MicrOBU — HAW Hamburg + + + Fahrten + + + Erkannte Ereignisse + Bremsen + Abbiegen + Anhalten + Ereigniserkennung + CSV-Sitzungsprotokoll + + + Fahrtenverlauf + %1$d Fahrt auf dem Gerät gespeichert + %1$d Fahrten auf dem Gerät gespeichert + Fahrt #%1$d + Fahrt löschen? + Die am %1$s aufgezeichnete Fahrt wird dauerhaft vom Gerät entfernt. + Noch keine Fahrten + Gehe zu Aufnahme und starte eine Sitzung + + + Fahrtanalyse + diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..a766863 --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #F8F8F8 + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..d85e526 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,217 @@ + + + + MicrOBU + V2X Companion — Phase 02 + + + English + Deutsch + + + Dashboard + Sensors + Record + Log + V2X + Settings + Connection + + + GNSS + IMU + OBU Connection + Acquiring fix… + Permission required + Tap V2X to connect + Connected + MQTT connected + Connecting to broker… + Broker unreachable — check V2X settings + Recording + samples + Initialising… + Pressure + Altitude + Heading + Accuracy + + + Sensor Monitor + Phone GNSS + Accelerometer + Gyroscope + Magnetometer + Barometer + Latitude + Longitude + Altitude + Speed + Heading + Accuracy + Pressure + Est. altitude + Waiting for location fix… + Initialising… + Sensor not present on this device + + + Connection Setup + Connect to the micrOBU hardware via Bluetooth (primary) or USB-C (fallback). + USB-C tethering is the primary transport in Phase 02. Plug the OBU in and tap Connect. + Bluetooth + Bluetooth + Bluetooth connection is planned for Phase 03 and is not yet implemented. + Scan for Devices + Scanning… + USB-C (Primary) + Connect via USB-C (not yet implemented) + Cable detected + No cable + OBU IP + Detecting… + IP source + Auto-detected + Manual override + ⚠ Connect the USB-C cable to detect the OBU, or disable auto-detect in Settings → Connection to use a manual IP. + Connect + Connect to OBU + Disconnect + Selected + Connected (stub) + ⚠ Bluetooth stack not yet implemented. This screen shows the target UI layout. + + + Recording Session + Ready to record + %1$d samples captured + Active Streams + STOP + REC + Phone GNSS + Accelerometer + Gyroscope + Magnetometer + Barometer + OBU / MQTT + + + Session Log + %1$d session stored on device + %1$d sessions stored on device + Session #%1$d + Share CSV + Save to device + Delete session + No sessions yet + Go to Record and start a session + Delete session? + This will permanently remove the session recorded on %1$s from the device. + Delete + Cancel + Export session + + + GNSS Location + Open in Maps App + Opens in your preferred maps application + View in App + Show current location on an in-app map + No GPS fix yet — move to an open area + Location Map + Current Location + + + Settings + Language + Appearance + Dark theme + Sensor Streams + Phone GNSS + Accelerometer + Gyroscope + Magnetometer + Barometer + Units + Metric (m, km/h, hPa) + MQTT Broker + Broker IP + Port + Username + Password + optional + Show + Hide + Developer + Developer mode + Wi-Fi OBU connection + Dev mode only — not implemented + About + App version + 0.4.0 (Phase 02 — USB-C + DENM TX) + Connection + Auto-detect OBU via USB-C + Manual OBU IP + Active transport + USB-C + Wi-Fi + Save Broker Settings + + + V2X Monitor + Connect + Disconnect + Auto-scroll to latest + No messages yet + Connect to the OBU and wait for V2X traffic + No messages on this topic yet + + + Last transmitted payload: + DENM Transmission + Send Test DENM + Stop DENM + Aftermarket Stationary Vehicle (causeCode 94) + Connect to the OBU to enable DENM triggering + DENM active — OBU broadcasting via ITS-G5 + Publishes uca-denmctrl (retained) to v2x-uca/input/denmtrg + Show last TX + Hide + + + USB-C + Wi-Fi + BT + + + ⚠ OBU stationType ≠ 2 (cyclist) — VRU detection may be impaired at equipped intersections + + Platform + Android / Kotlin / Jetpack Compose + Project + MicrOBU — HAW Hamburg + + + Trips + + + Detected Events + Braking + Turning + Stopping + Event Detection + CSV Session Log + + + Trip History + %1$d trip stored on device + %1$d trips stored on device + Trip #%1$d + Delete trip? + This will permanently remove the trip recorded on %1$s. + No trips yet + Go to Record and start a session + + + Trip Review + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..c4263b9 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..89c386c --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml new file mode 100644 index 0000000..cb544e7 --- /dev/null +++ b/app/src/main/res/xml/locales_config.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/test/java/com/hawhamburg/micr0bu/EventDetectorTest.kt b/app/src/test/java/com/hawhamburg/micr0bu/EventDetectorTest.kt new file mode 100644 index 0000000..d6456f4 --- /dev/null +++ b/app/src/test/java/com/hawhamburg/micr0bu/EventDetectorTest.kt @@ -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) -> Unit, + ) = runTest { + val events = mutableListOf() + 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) + } +} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..0a066db --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.ksp) apply false + alias(libs.plugins.hilt) apply false +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..bf2a159 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,7 @@ +org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError +android.suppressUnsupportedCompileSdk=36 +org.gradle.daemon=true +org.gradle.parallel=true +org.gradle.caching=true +android.useAndroidX=true +kotlin.code.style=official diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..35855cc --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,57 @@ +[versions] +agp = "8.7.3" +kotlin = "2.1.0" +ksp = "2.1.0-1.0.29" +hilt = "2.51.1" +junit = "4.13.2" +hiltNavigationCompose = "1.2.0" +datastore = "1.1.1" +pahoMqtt = "1.2.5" +room = "2.6.1" +appcompat = "1.7.0" +osmdroid = "6.1.18" +splashscreen = "1.0.1" +composeBom = "2024.12.01" +coreKtx = "1.15.0" +lifecycle = "2.8.7" +activityCompose = "1.9.3" +navigationCompose = "2.8.5" +playServicesLocation = "21.3.0" +coroutines = "1.9.0" + +[libraries] +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +compose-ui = { group = "androidx.compose.ui", name = "ui" } +compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +compose-material3 = { group = "androidx.compose.material3", name = "material3" } +compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } +navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } +play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" } +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } +kotlinx-coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-play-services", version.ref = "coroutines" } +hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } +hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } +hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" } +datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } +paho-mqtt = { group = "org.eclipse.paho", name = "org.eclipse.paho.client.mqttv3", version.ref = "pahoMqtt" } +room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } +room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } +appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +osmdroid = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "osmdroid" } +androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..1e2fbf0 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..1d4e70b --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,17 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "MicrOBU" +include(":app")