2026-06-03 14:25:20 +02:00
|
|
|
package com.hawhamburg.micr0bu
|
|
|
|
|
|
|
|
|
|
import android.Manifest
|
2026-07-02 15:04:34 +02:00
|
|
|
import android.content.Intent
|
|
|
|
|
import android.net.Uri
|
2026-06-03 14:25:20 +02:00
|
|
|
import android.os.Build
|
|
|
|
|
import android.os.Bundle
|
2026-07-02 15:04:34 +02:00
|
|
|
import android.provider.Settings
|
2026-06-03 14:25:20 +02:00
|
|
|
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
|
2026-07-02 15:04:34 +02:00
|
|
|
import com.hawhamburg.micr0bu.ui.screens.UseCaseAlertsSettingsScreen
|
2026-06-03 14:25:20 +02:00
|
|
|
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()
|
2026-07-02 15:04:34 +02:00
|
|
|
val obuStationType by mqttViewModel.obuStationType.collectAsState()
|
2026-06-03 14:25:20 +02:00
|
|
|
val tripServiceState by tripViewModel.serviceState.collectAsState()
|
2026-07-02 15:04:34 +02:00
|
|
|
val showBatteryOptPrompt by tripViewModel.showBatteryOptPrompt.collectAsState()
|
|
|
|
|
val useCaseEnabledMap by mqttViewModel.useCaseEnabledMap.collectAsState()
|
2026-08-04 17:58:07 +02:00
|
|
|
val obuHardware by mqttViewModel.obuHardware.collectAsState()
|
2026-08-10 11:38:01 +02:00
|
|
|
val usbSerialState by mqttViewModel.usbSerialState.collectAsState()
|
2026-06-03 14:25:20 +02:00
|
|
|
|
|
|
|
|
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,
|
2026-08-04 17:58:07 +02:00
|
|
|
obuHardware = obuHardware,
|
2026-08-10 11:38:01 +02:00
|
|
|
usbSerialState = usbSerialState,
|
2026-06-03 14:25:20 +02:00
|
|
|
usbCableConnected = usbConnected,
|
|
|
|
|
obuStationTypeWarning = obuStationTypeWarning,
|
2026-07-02 15:04:34 +02:00
|
|
|
obuStationType = obuStationType,
|
2026-06-03 14:25:20 +02:00
|
|
|
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) },
|
2026-08-04 17:58:07 +02:00
|
|
|
onNavigateToRecord = {
|
|
|
|
|
navController.navigate(Screen.Record.route) {
|
|
|
|
|
popUpTo(Screen.Dashboard.route) { saveState = true }
|
|
|
|
|
launchSingleTop = true
|
|
|
|
|
restoreState = true
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-06-03 14:25:20 +02:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
composable(Screen.Sensors.route) {
|
|
|
|
|
SensorScreen(state = state)
|
|
|
|
|
}
|
|
|
|
|
composable(Screen.Record.route) {
|
|
|
|
|
RecordingScreen(
|
|
|
|
|
state = state,
|
|
|
|
|
mqttConnectionState = mqttConnectionState,
|
|
|
|
|
tripServiceState = tripServiceState,
|
2026-07-02 15:04:34 +02:00
|
|
|
showBatteryOptPrompt = showBatteryOptPrompt,
|
2026-06-03 14:25:20 +02:00
|
|
|
onToggleRecording = {
|
|
|
|
|
// Start / stop both CSV recording and the event detection service
|
|
|
|
|
viewModel.toggleRecording()
|
|
|
|
|
tripViewModel.toggleRecording()
|
|
|
|
|
},
|
|
|
|
|
onOpenSessionLog = {
|
|
|
|
|
navController.navigate(Screen.Log.route)
|
|
|
|
|
},
|
2026-07-02 15:04:34 +02:00
|
|
|
onOpenBatterySettings = {
|
|
|
|
|
startActivity(
|
|
|
|
|
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
|
|
|
|
data = Uri.parse("package:$packageName")
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
tripViewModel.recheckBatteryOpt()
|
|
|
|
|
},
|
|
|
|
|
onDismissBatteryOptPrompt = tripViewModel::dismissBatteryOptPrompt,
|
2026-06-03 14:25:20 +02:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
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) },
|
2026-07-02 15:04:34 +02:00
|
|
|
onOpenUseCaseAlerts = { navController.navigate(Screen.SettingsUseCaseAlerts.route) },
|
2026-06-03 14:25:20 +02:00
|
|
|
onOpenDeveloper = { navController.navigate(Screen.SettingsDeveloper.route) },
|
|
|
|
|
onOpenAbout = { navController.navigate(Screen.SettingsAbout.route) },
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-07-02 15:04:34 +02:00
|
|
|
composable(Screen.SettingsUseCaseAlerts.route) {
|
|
|
|
|
UseCaseAlertsSettingsScreen(
|
|
|
|
|
enabledMap = useCaseEnabledMap,
|
|
|
|
|
onToggle = mqttViewModel::setUseCaseEnabled,
|
|
|
|
|
onBack = { navController.popBackStack() },
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-06-03 14:25:20 +02:00
|
|
|
composable(Screen.SettingsConnection.route) {
|
|
|
|
|
ConnectionSettingsScreen(
|
|
|
|
|
mqttPrefs = mqttPrefs,
|
|
|
|
|
onMqttPrefsChange = mqttViewModel::updatePrefs,
|
2026-08-04 17:58:07 +02:00
|
|
|
obuHardware = obuHardware,
|
|
|
|
|
onObuHardwareChange = mqttViewModel::setObuHardware,
|
2026-06-03 14:25:20 +02:00
|
|
|
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) {
|
2026-08-10 11:38:01 +02:00
|
|
|
// Must be the Activity-scoped instance (same one MainActivity holds
|
|
|
|
|
// and MqttTopicViewerScreen is given below), not the default
|
|
|
|
|
// hiltViewModel() — that would create a separate instance scoped to
|
|
|
|
|
// this NavBackStackEntry, whose onCleared() (fired the moment you
|
|
|
|
|
// navigate away) would disconnect the shared UsbSerialTransport out
|
|
|
|
|
// from under every other screen still using it.
|
|
|
|
|
ConnectionSetupScreen(viewModel = mqttViewModel)
|
2026-06-03 14:25:20 +02:00
|
|
|
}
|
|
|
|
|
composable(Screen.Map.route) {
|
|
|
|
|
MapScreen(gnss = state.gnss)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|