diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/MainActivity.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/MainActivity.kt index 1d10601..93bf53a 100644 --- a/Android/app/src/main/kotlin/de/fritob/campermonitor/MainActivity.kt +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/MainActivity.kt @@ -3,12 +3,135 @@ package de.fritob.campermonitor import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text +import androidx.activity.enableEdgeToEdge +import androidx.compose.runtime.Composable +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import de.fritob.campermonitor.bluetooth.BluetoothManager +import de.fritob.campermonitor.store.DeviceStore +import de.fritob.campermonitor.ui.AddDeviceScreen +import de.fritob.campermonitor.ui.AlignmentAssistantScreen +import de.fritob.campermonitor.ui.CamperTheme +import de.fritob.campermonitor.ui.DashboardScreen +import de.fritob.campermonitor.ui.DeviceDetailScreen +import de.fritob.campermonitor.ui.LevelSetupScreen +import de.fritob.campermonitor.ui.ProfilesScreen +import de.fritob.campermonitor.ui.SensorSetupScreen +import de.fritob.campermonitor.ui.SettingsScreen +import de.fritob.campermonitor.ui.VictronKeyScreen +import de.fritob.campermonitor.ui.WithBluetoothPermission +import java.util.UUID class MainActivity : ComponentActivity() { + + private lateinit var store: DeviceStore + private lateinit var bluetooth: BluetoothManager + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContent { MaterialTheme { Text("Camper Monitor") } } + enableEdgeToEdge() + + store = DeviceStore(applicationContext) + bluetooth = BluetoothManager(applicationContext, store) + + setContent { + CamperTheme { + WithBluetoothPermission { + // Erst wenn die Rechte da sind, darf der Scan starten - + // ohne sie wirft Android beim Scannen. + androidx.compose.runtime.LaunchedEffect(Unit) { bluetooth.start() } + CamperNavigation(store, bluetooth) + } + } + } + } + + override fun onDestroy() { + super.onDestroy() + bluetooth.stopEverything() } } + +@Composable +private fun CamperNavigation(store: DeviceStore, bluetooth: BluetoothManager) { + val navController = rememberNavController() + + /** + * Der Weg vom Navigationsargument zurück zum aktuellen Gerät. Bewusst ohne + * Zwischenspeicher: nach einer Umbenennung soll der neue Stand gelten. + */ + fun device(id: String?) = store.devices.firstOrNull { it.id.toString() == id } + + NavHost(navController = navController, startDestination = "dashboard") { + composable("dashboard") { + DashboardScreen( + store = store, + bluetooth = bluetooth, + onOpenDevice = { navController.navigate("device/${it.id}") }, + onAddDevice = { navController.navigate("add") }, + onOpenProfiles = { navController.navigate("profiles") }, + onOpenSettings = { navController.navigate("settings") }, + ) + } + + composable("device/{id}") { entry -> + device(entry.arguments?.getString("id"))?.let { + DeviceDetailScreen( + device = it, + store = store, + bluetooth = bluetooth, + onBack = { navController.popBackStack() }, + onOpenKey = { navController.navigate("key/${it.id}") }, + onOpenLevelSetup = { navController.navigate("levelsetup/${it.id}") }, + onOpenAssistant = { navController.navigate("assistant/${it.id}") }, + ) + } + } + + composable("key/{id}") { entry -> + device(entry.arguments?.getString("id"))?.let { + VictronKeyScreen(it, store, bluetooth) { navController.popBackStack() } + } + } + + composable("levelsetup/{id}") { entry -> + device(entry.arguments?.getString("id"))?.let { + LevelSetupScreen( + device = it, + store = store, + bluetooth = bluetooth, + onBack = { navController.popBackStack() }, + onOpenSensorSetup = { navController.navigate("sensorsetup/${it.id}") }, + ) + } + } + + composable("sensorsetup/{id}") { entry -> + device(entry.arguments?.getString("id"))?.let { + SensorSetupScreen(it, store, bluetooth) { navController.popBackStack() } + } + } + + composable("assistant/{id}") { entry -> + device(entry.arguments?.getString("id"))?.let { + AlignmentAssistantScreen(it, store, bluetooth) { navController.popBackStack() } + } + } + + composable("add") { + AddDeviceScreen(store, bluetooth) { navController.popBackStack() } + } + + composable("profiles") { + ProfilesScreen(store, bluetooth) { navController.popBackStack() } + } + + composable("settings") { + SettingsScreen(store) { navController.popBackStack() } + } + } +} + +/** Kleine Hilfe, damit Bildschirme das Gerät über seine Kennung finden. */ +internal fun DeviceStore.device(id: UUID) = devices.firstOrNull { it.id == id } diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/AddDeviceScreen.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/AddDeviceScreen.kt new file mode 100644 index 0000000..f340351 --- /dev/null +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/AddDeviceScreen.kt @@ -0,0 +1,255 @@ +package de.fritob.campermonitor.ui + +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.text.font.FontFamily +import androidx.compose.ui.unit.dp +import de.fritob.campermonitor.bluetooth.BluetoothManager +import de.fritob.campermonitor.bluetooth.Discovery +import de.fritob.campermonitor.protocol.ConfiguredDevice +import de.fritob.campermonitor.protocol.DeviceRole +import de.fritob.campermonitor.store.DeviceStore + +/** + * Die Liste der gefundenen Geräte. + * + * Sortiert wird **nicht** nach Signalstärke, sondern nach der Reihenfolge des + * Auftauchens. Nach dem Pegel zu sortieren macht die Liste unbenutzbar: sie + * springt dann sekündlich, und man trifft das gesuchte Gerät nicht. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddDeviceScreen(store: DeviceStore, bluetooth: BluetoothManager, onBack: () -> Unit) { + var showAll by remember { mutableStateOf(false) } + var selected by remember { mutableStateOf(null) } + + DisposableEffect(Unit) { + bluetooth.setDiscovering(true) + onDispose { bluetooth.setDiscovering(false) } + } + + val alreadyAdded = store.activeDevices().map { it.address }.toSet() + val found = bluetooth.discoveries.values + .filter { showAll || it.looksLikeSupported || it.name != null } + .sortedWith( + compareByDescending { it.isVictron } + .thenByDescending { it.looksLikeSupported } + .thenBy { it.firstSeen } + ) + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Gerät für ${store.activeProfile?.name ?: "Camper"}") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") + } + }, + ) + }, + ) { padding -> + LazyColumn(modifier = Modifier.fillMaxSize().padding(padding)) { + item { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = !showAll, + onClick = { showAll = false }, + label = { Text("Passende") }, + ) + FilterChip( + selected = showAll, + onClick = { showAll = true }, + label = { Text("Alle") }, + ) + } + } + + items(found, key = { it.address }) { discovery -> + val added = discovery.address in alreadyAdded + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = !added) { selected = discovery } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + discovery.name ?: "Ohne Namen", + style = MaterialTheme.typography.bodyLarge, + color = if (added) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + Text( + buildString { + append(discovery.address) + discovery.victronRecord?.let { append(" · Victron $it") } + append(" · ${discovery.rssi} dBm") + }, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (added) { + Text("schon dabei", style = MaterialTheme.typography.labelMedium) + } + } + } + + item { + Text( + "Victron-Geräte werden automatisch erkannt. Damit sie hier " + + "auftauchen, muss „Instant Readout“ in VictronConnect aktiv " + + "sein. Das BMS meldet sich meist als „DL-…“. Kühlboxen tragen " + + "oft einen kryptischen Namen – findest du dein Gerät nicht, auf " + + "„Alle“ umschalten.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(16.dp), + ) + } + } + } + + selected?.let { discovery -> + ConfigureDeviceDialog( + discovery = discovery, + onDismiss = { selected = null }, + onAdd = { name, role -> + store.update( + ConfiguredDevice( + name = name, + role = role, + profileID = store.activeProfile?.id ?: return@ConfigureDeviceDialog, + address = discovery.address, + advertisedName = discovery.name, + ) + ) + bluetooth.refreshConfiguration() + selected = null + onBack() + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ConfigureDeviceDialog( + discovery: Discovery, + onDismiss: () -> Unit, + onAdd: (String, DeviceRole) -> Unit, +) { + // Ein Vorschlag, der meistens passt – geraten wird nur, was sich aus dem + // Advertisement ablesen lässt. + val suggestedRole = remember(discovery) { guessRole(discovery) } + var role by remember { mutableStateOf(suggestedRole) } + var name by remember { mutableStateOf(discovery.name ?: suggestedRole.title) } + var expanded by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Gerät einrichten") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Name") }, + singleLine = true, + ) + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + OutlinedTextField( + value = role.title, + onValueChange = {}, + readOnly = true, + label = { Text("Art") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, + modifier = Modifier.menuAnchor( + androidx.compose.material3.MenuAnchorType.PrimaryNotEditable, true + ), + ) + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DeviceRole.entries.forEach { candidate -> + DropdownMenuItem( + text = { Text(candidate.title) }, + onClick = { role = candidate; expanded = false }, + ) + } + } + } + Text( + discovery.address, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + confirmButton = { + Button(onClick = { onAdd(name.trim().ifEmpty { role.title }, role) }) { + Text("Hinzufügen") + } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Abbrechen") } }, + ) +} + +/** Was sich aus Namen und Advertisement ableiten lässt. */ +private fun guessRole(discovery: Discovery): DeviceRole { + val name = discovery.name?.lowercase() ?: "" + return when { + discovery.victronRecord == "SOLAR_CHARGER" -> DeviceRole.SOLAR_CHARGER + discovery.victronRecord == "BATTERY_MONITOR" -> DeviceRole.BATTERY_MONITOR + discovery.victronRecord == "DCDC_CONVERTER" -> DeviceRole.CHARGE_BOOSTER + discovery.victronRecord == "ORION_XS" -> DeviceRole.CHARGE_BOOSTER + name.contains("vanalign") -> DeviceRole.LEVELING + name.contains("alpicool") || name.contains("icecube") -> DeviceRole.FRIDGE + name.startsWith("dl-") || name.contains("daly") || name.contains("wattcycle") -> + DeviceRole.BMS + else -> DeviceRole.BMS + } +} diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/AlignmentAssistantScreen.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/AlignmentAssistantScreen.kt new file mode 100644 index 0000000..0679faf --- /dev/null +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/AlignmentAssistantScreen.kt @@ -0,0 +1,313 @@ +package de.fritob.campermonitor.ui + +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.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Flag +import androidx.compose.material.icons.filled.HourglassEmpty +import androidx.compose.material.icons.filled.PortableWifiOff +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.TrendingDown +import androidx.compose.material.icons.filled.TrendingFlat +import androidx.compose.material.icons.filled.TrendingUp +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.dp +import de.fritob.campermonitor.bluetooth.BluetoothManager +import de.fritob.campermonitor.protocol.AlignmentAssistant +import de.fritob.campermonitor.protocol.ConfiguredDevice +import de.fritob.campermonitor.protocol.DeviceLinkState +import de.fritob.campermonitor.protocol.LevelState +import de.fritob.campermonitor.protocol.LevelingWedge +import de.fritob.campermonitor.store.DeviceStore + +/** + * Der Ausrichtungs-Assistent: begleitet das Rangieren und sagt, ob es besser + * oder schlechter wird und wo es am besten stand. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AlignmentAssistantScreen( + device: ConfiguredDevice, + store: DeviceStore, + bluetooth: BluetoothManager, + onBack: () -> Unit, +) { + val state = bluetooth.levelStates[device.id] ?: LevelState() + val linkState = bluetooth.linkStates[device.id] + val profile = store.activeProfile + + val assistant = remember { AlignmentAssistant() } + // Zwingt die Ansicht zum Neuzeichnen, wenn sich der Verlauf ändert – der + // Assistent selbst ist bewusst kein Compose-Zustand, damit die Protokoll- + // schicht nichts von Compose wissen muss. + var revision by remember { mutableIntStateOf(0) } + var displayStyle by remember { mutableStateOf(0) } + var announced by remember { mutableStateOf(false) } + + val view = LocalView.current + + LaunchedEffect(state) { + val pitch = state.pitch + val roll = state.roll + if (pitch != null && roll != null) { + assistant.add(pitch, roll) + revision += 1 + // Einmal spürbar melden, wenn die Waage erreicht ist – man schaut + // beim Rangieren nicht dauernd aufs Display. + if (assistant.hasReachedTarget) { + if (!announced) { + announced = true + @Suppress("DEPRECATION") + view.performHapticFeedback(android.view.HapticFeedbackConstants.CONFIRM) + } + } else { + announced = false + } + } + } + + // Beim Rangieren schaut man immer wieder aufs Display; es darf dabei nicht + // dunkel werden. + DisposableEffect(Unit) { + view.keepScreenOn = true + onDispose { view.keepScreenOn = false } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Ausrichtungs-Assistent") }, + actions = { TextButton(onClick = onBack) { Text("Fertig") } }, + ) + }, + ) { padding -> + @Suppress("UNUSED_EXPRESSION") revision // liest den Zähler, damit neu gezeichnet wird + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + listOf("Libelle", "Fahrzeug").forEachIndexed { index, label -> + SegmentedButton( + selected = displayStyle == index, + onClick = { displayStyle = index }, + shape = SegmentedButtonDefaults.itemShape(index, 2), + ) { Text(label) } + } + } + + if (linkState != DeviceLinkState.Live) DisconnectedBanner() + + if (displayStyle == 0) { + LevelBubble(state.pitch, state.roll) + } else { + VehicleTiltView(state.pitch, state.roll) + } + + AdviceBanner(assistant) + + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { + Reading("Längs", state.pitch, Modifier.weight(1f)) + Reading("Quer", state.roll, Modifier.weight(1f)) + Reading("Gesamt", assistant.current?.deviation, Modifier.weight(1f)) + } + + val gain = assistant.improvementAtBest + val seconds = assistant.secondsSinceBest + val best = assistant.best + if (gain != null && seconds != null && best != null) { + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.Flag, contentDescription = null) + Text( + "Bester Punkt", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(start = 8.dp), + ) + } + Text( + "Vor %.0f Sekunden stand das Fahrzeug %.1f° flacher (%.1f° statt %.1f°)." + .format(seconds, gain, best.deviation, + assistant.current?.deviation ?: 0.0), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + WedgeCard(state, profile?.trackWidth, profile?.wheelbase) + + OutlinedButton( + onClick = { assistant.reset(); revision += 1; announced = false }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Filled.Refresh, contentDescription = null) + Text("Neu beginnen", modifier = Modifier.padding(start = 8.dp)) + } + } + } +} + +/** + * Reisst die Verbindung beim Rangieren ab, stehen die Zahlen still. Ohne + * Hinweis sähe das aus, als hinge die App – man rangiert dann nach einem Wert, + * der längst nicht mehr gilt. + */ +@Composable +private fun DisconnectedBanner() { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = Color(0x33E08600)), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Filled.PortableWifiOff, contentDescription = null, + tint = Color(0xFFE08600), modifier = Modifier.size(32.dp), + ) + Column(modifier = Modifier.padding(start = 12.dp)) { + Text("Nicht verbunden", style = MaterialTheme.typography.titleSmall) + Text( + "Die Anzeige steht still, bis der Neigungsmesser wieder da ist.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun AdviceBanner(assistant: AlignmentAssistant) { + val reached = assistant.hasReachedTarget + val icon: ImageVector = if (reached) { + Icons.Filled.CheckCircle + } else { + when (assistant.trend) { + AlignmentAssistant.Trend.IMPROVING -> Icons.Filled.TrendingDown + AlignmentAssistant.Trend.WORSENING -> Icons.Filled.TrendingUp + AlignmentAssistant.Trend.STEADY -> Icons.Filled.TrendingFlat + AlignmentAssistant.Trend.UNKNOWN -> Icons.Filled.HourglassEmpty + } + } + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = if (reached) { + Color(0x331F9E52) + } else { + MaterialTheme.colorScheme.surfaceContainer + }, + ), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + icon, contentDescription = null, + tint = if (reached) Color(0xFF1F9E52) else MaterialTheme.colorScheme.primary, + modifier = Modifier.size(32.dp), + ) + Text( + assistant.advice, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(start = 12.dp), + ) + } + } +} + +@Composable +private fun WedgeCard(state: LevelState, trackWidth: Double?, wheelbase: Double?) { + val across = state.roll?.let { LevelingWedge.across(it, trackWidth) } + val along = state.pitch?.let { LevelingWedge.along(it, wheelbase) } + + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text("Auffahrkeile", style = MaterialTheme.typography.titleSmall) + when { + trackWidth == null && wheelbase == null -> Text( + "Für die Keilhöhe fehlen Spurweite und Radstand. Beides lässt sich " + + "beim Fahrzeug hinterlegen.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + across == null && along == null -> Text( + "Keine Keile nötig.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + else -> { + listOfNotNull(across, along).forEach { wedge -> + Row(modifier = Modifier.fillMaxWidth()) { + Text( + wedge.side.text.replaceFirstChar { it.uppercase() }, + modifier = Modifier.weight(1f), + ) + Text( + "%.0f cm".format(wedge.heightInCentimetres), + style = MaterialTheme.typography.titleMedium, + ) + } + } + Text( + "Höhe unter die tieferstehende Seite, damit das Fahrzeug eben steht.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/DashboardScreen.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/DashboardScreen.kt new file mode 100644 index 0000000..f0ecda1 --- /dev/null +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/DashboardScreen.kt @@ -0,0 +1,152 @@ +package de.fritob.campermonitor.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import de.fritob.campermonitor.bluetooth.BluetoothManager +import de.fritob.campermonitor.protocol.ConfiguredDevice +import de.fritob.campermonitor.protocol.DeviceLinkState +import de.fritob.campermonitor.store.DeviceStore + +/** + * Die Startansicht: alle Geräte des gewählten Fahrzeugs als Kacheln. + * + * Anders als unter iOS sitzt die Fahrzeugauswahl nicht als runder Knopf in der + * Ecke, sondern als Titel mit Aufklapp-Pfeil – so ist es auf Android üblich. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DashboardScreen( + store: DeviceStore, + bluetooth: BluetoothManager, + onOpenDevice: (ConfiguredDevice) -> Unit, + onAddDevice: () -> Unit, + onOpenProfiles: () -> Unit, + onOpenSettings: () -> Unit, +) { + val devices = store.activeDevices() + + Scaffold( + topBar = { + TopAppBar( + title = { + TextButton(onClick = onOpenProfiles) { + Icon( + profileIcon(store.activeProfile?.symbol ?: "box_truck"), + contentDescription = null, + ) + Text( + store.activeProfile?.name ?: "Camper", + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(start = 8.dp), + ) + } + }, + actions = { + IconButton(onClick = onAddDevice) { + Icon(Icons.Filled.Add, contentDescription = "Gerät hinzufügen") + } + IconButton(onClick = onOpenSettings) { + Icon(Icons.Filled.Settings, contentDescription = "Einstellungen") + } + }, + ) + }, + ) { padding -> + if (devices.isEmpty()) { + EmptyDashboard( + modifier = Modifier.fillMaxSize().padding(padding), + statusText = bluetooth.bluetoothStatusText, + isReady = bluetooth.isBluetoothReady, + onAddDevice = onAddDevice, + ) + return@Scaffold + } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = 16.dp, end = 16.dp, + top = padding.calculateTopPadding() + 8.dp, + bottom = padding.calculateBottomPadding() + 24.dp, + ), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + if (!bluetooth.isBluetoothReady) { + item { + Text( + bluetooth.bluetoothStatusText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } + } + items(devices, key = { it.id }) { device -> + DeviceCard( + device = device, + snapshot = bluetooth.snapshots[device.id], + linkState = bluetooth.linkStates[device.id] ?: DeviceLinkState.Searching, + onClick = { onOpenDevice(device) }, + ) + } + } + } +} + +@Composable +private fun EmptyDashboard( + modifier: Modifier, + statusText: String, + isReady: Boolean, + onAddDevice: () -> Unit, +) { + Column( + modifier = modifier.padding(32.dp), + verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "Noch keine Geräte", + style = MaterialTheme.typography.headlineSmall, + ) + Text( + "Füge deinen Ladebooster, den Solarladeregler, die Batterie, die " + + "Kühlbox oder den Neigungsmesser hinzu.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + if (!isReady) { + Text( + statusText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + ) + } + TextButton(onClick = onAddDevice, modifier = Modifier.fillMaxWidth()) { + Text("Gerät hinzufügen") + } + } +} diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/DeviceCard.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/DeviceCard.kt new file mode 100644 index 0000000..8652b1c --- /dev/null +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/DeviceCard.kt @@ -0,0 +1,199 @@ +package de.fritob.campermonitor.ui + +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +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.draw.clip +import androidx.compose.ui.graphics.Color +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 de.fritob.campermonitor.protocol.ConfiguredDevice +import de.fritob.campermonitor.protocol.DeviceLinkState +import de.fritob.campermonitor.protocol.DeviceSnapshot +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable + +/** + * Kachel auf dem Dashboard: Hauptwert gross, darunter die wichtigsten + * Nebenwerte und der Verbindungszustand. + */ +@Composable +fun DeviceCard( + device: ConfiguredDevice, + snapshot: DeviceSnapshot?, + linkState: DeviceLinkState, + onClick: () -> Unit, +) { + val isStale = snapshot?.isStale() ?: true + + Card( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = roleIcon(device.role), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + Column(modifier = Modifier.padding(start = 8.dp).weight(1f)) { + Text(device.name, style = MaterialTheme.typography.titleMedium) + Text( + device.role.title, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + StatusDot(linkState, isStale) + } + + val primary = snapshot?.primaryMetric + if (snapshot != null && primary != null) { + Row(verticalAlignment = Alignment.Bottom) { + Text( + primary.formatted, + fontSize = 40.sp, + fontWeight = FontWeight.SemiBold, + color = if (isStale) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + Text( + primary.unit, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 4.dp, bottom = 6.dp), + ) + } + SecondaryValues(snapshot) + } else { + Text( + placeholderText(linkState), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 12.dp), + ) + } + + CardFooter(snapshot, linkState) + } + } +} + +@Composable +private fun SecondaryValues(snapshot: DeviceSnapshot) { + val others = snapshot.metrics + .filter { it.key != snapshot.primaryMetric?.key && it.value != null } + .take(3) + if (others.isEmpty()) return + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + others.forEach { metric -> + Column { + Text( + metric.formattedWithUnit, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + Text( + metric.label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun CardFooter(snapshot: DeviceSnapshot?, linkState: DeviceLinkState) { + val fault = snapshot?.fault + when { + fault != null -> Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Filled.Warning, contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(16.dp), + ) + Text( + fault, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + maxLines = 2, + modifier = Modifier.padding(start = 6.dp), + ) + } + + snapshot?.state != null -> { + // Bei "Aus" ist erst der Grund die eigentliche Information. + val reason = snapshot.offReasons.firstOrNull() + Text( + if (reason != null) "${snapshot.state} · $reason" else snapshot.state!!, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + ) + } + + linkState is DeviceLinkState.Failed -> Text( + linkState.message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.tertiary, + maxLines = 2, + ) + } +} + +private fun placeholderText(linkState: DeviceLinkState): String = when (linkState) { + is DeviceLinkState.NeedsKey -> "Verschlüsselungsschlüssel fehlt – im Detail eintragen." + is DeviceLinkState.Failed -> linkState.message + else -> "Warte auf Daten…" +} + +/** Kleiner Punkt, der Verbindungszustand und Aktualität zusammenfasst. */ +@Composable +fun StatusDot(linkState: DeviceLinkState, isStale: Boolean) { + val color = when (linkState) { + is DeviceLinkState.Live -> if (isStale) Color(0xFFE08600) else Color(0xFF1F9E52) + is DeviceLinkState.NeedsKey -> Color(0xFFE08600) + is DeviceLinkState.Failed -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + val label = if (linkState is DeviceLinkState.Live && isStale) "Veraltet" else linkState.label + + Row(verticalAlignment = Alignment.CenterVertically) { + Box(modifier = Modifier.size(8.dp).clip(CircleShape).background(color)) + Text( + label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 5.dp), + ) + } +} diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/DeviceDetailScreen.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/DeviceDetailScreen.kt new file mode 100644 index 0000000..966f526 --- /dev/null +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/DeviceDetailScreen.kt @@ -0,0 +1,664 @@ +package de.fritob.campermonitor.ui + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import androidx.compose.foundation.Canvas +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.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.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Key +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +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.Scaffold +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +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.geometry.Offset +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import de.fritob.campermonitor.bluetooth.BluetoothManager +import de.fritob.campermonitor.bluetooth.BmsDiagnostics +import de.fritob.campermonitor.protocol.ConfiguredDevice +import de.fritob.campermonitor.protocol.DeviceLinkState +import de.fritob.campermonitor.protocol.DeviceTransport +import de.fritob.campermonitor.protocol.FridgeZoneMode +import de.fritob.campermonitor.protocol.LevelState +import de.fritob.campermonitor.store.DeviceStore + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DeviceDetailScreen( + device: ConfiguredDevice, + store: DeviceStore, + bluetooth: BluetoothManager, + onBack: () -> Unit, + onOpenKey: () -> Unit, + onOpenLevelSetup: () -> Unit, + onOpenAssistant: () -> Unit, +) { + val current = store.devices.firstOrNull { it.id == device.id } ?: device + val snapshot = bluetooth.snapshots[device.id] + val linkState = bluetooth.linkStates[device.id] ?: DeviceLinkState.Searching + val isStale = snapshot?.isStale() ?: true + var showDelete by remember { mutableStateOf(false) } + + /** + * Im Alltag stören die technischen Angaben nur. Meldet ein Gerät aber einen + * Fehler oder fehlt der Schlüssel, sind sie genau das, was weiterhilft – + * dann werden sie unabhängig von der Einstellung gezeigt. + */ + val showsTechnicalDetails = store.showDiagnostics || + linkState is DeviceLinkState.Failed || linkState is DeviceLinkState.NeedsKey + + Scaffold( + topBar = { + TopAppBar( + title = { Text(current.name) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(bottom = 32.dp), + ) { + // MARK: Zustand + LabeledRow("Verbindung") { StatusDot(linkState, isStale) } + snapshot?.state?.let { LabeledRow("Zustand") { Text(it) } } + snapshot?.fault?.let { + Text( + it, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + } + snapshot?.offReasons?.forEach { + Text( + it, + color = MaterialTheme.colorScheme.tertiary, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 2.dp), + ) + } + // Der Empfangspegel hilft beim Suchen eines Geräts, im Alltag sagt + // er nichts – deshalb nur bei eingeblendeter Diagnose. + if (showsTechnicalDetails) { + snapshot?.rssi?.let { LabeledRow("Signal") { Text("$it dBm") } } + } + + // MARK: Schlüssel, falls er fehlt + if (needsKeyAttention(current, store, bluetooth, snapshot != null, linkState)) { + KeyPrompt(onOpenKey) + } + + // MARK: Steuerung + val fridge = bluetooth.fridgeStates[device.id] + if (current.role == de.fritob.campermonitor.protocol.DeviceRole.FRIDGE && + fridge != null && fridge.hasStatus + ) { + FridgeControls(current, fridge, bluetooth) + } + + if (current.role == de.fritob.campermonitor.protocol.DeviceRole.LEVELING) { + LevelSection( + device = current, + state = bluetooth.levelStates[device.id] ?: LevelState(), + isLive = linkState == DeviceLinkState.Live, + onOpenAssistant = onOpenAssistant, + onOpenSetup = onOpenLevelSetup, + ) + } + + // MARK: Messwerte + if (snapshot != null && snapshot.metrics.isNotEmpty()) { + SectionHeader("Messwerte") + snapshot.metrics.forEach { metric -> + LabeledRow(metric.label) { + Text( + metric.formattedWithUnit, + color = if (metric.value == null) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + } + } + } + + // MARK: Verlauf + val samples = bluetooth.history[device.id] + val primary = snapshot?.primaryMetric + if (samples != null && samples.size > 1 && primary != null) { + SectionHeader("Verlauf – ${primary.label}") + HistoryChart(samples.map { it.value }, primary.unit) + } + + // MARK: Zellen + if (snapshot != null && snapshot.cellVoltages.isNotEmpty()) { + SectionHeader("Zellspannungen") + snapshot.cellVoltages.forEachIndexed { index, voltage -> + LabeledRow("Zelle ${index + 1}") { Text("%.3f V".format(voltage)) } + } + } + + if (snapshot != null && snapshot.info.isNotEmpty()) { + SectionHeader("Gerät") + snapshot.info.forEach { LabeledRow(it.label) { Text(it.value) } } + } + + if (snapshot != null && snapshot.temperatures.size > 1) { + SectionHeader("Temperaturen") + snapshot.temperatures.forEachIndexed { index, value -> + LabeledRow("Fühler ${index + 1}") { Text("%.0f °C".format(value)) } + } + } + + // MARK: Diagnose + if (showsTechnicalDetails) { + if (current.role.transport == DeviceTransport.ADVERTISEMENT) { + bluetooth.diagnostics[device.id]?.let { VictronDiagnosticsSection(it) } + } else { + bluetooth.bmsDiagnostics[device.id]?.let { + BmsDiagnosticsSection(current, it) + } + } + } + + // MARK: Einstellungen + SectionHeader("Einstellungen") + NameField(current, store) + LabeledRow("Typ") { Text(current.role.title) } + if (current.role == de.fritob.campermonitor.protocol.DeviceRole.FRIDGE) { + ZoneModePicker(current, store, bluetooth) + } + if (current.role.transport == DeviceTransport.ADVERTISEMENT) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onOpenKey) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Verschlüsselung", modifier = Modifier.weight(1f)) + Text( + keyStatusText(current, store, bluetooth), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) + } + } + LabeledRow("Bluetooth-Adresse") { + Text( + current.address, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodySmall, + ) + } + TextButton( + onClick = { showDelete = true }, + modifier = Modifier.padding(horizontal = 8.dp), + ) { + Icon(Icons.Filled.Delete, contentDescription = null, + tint = MaterialTheme.colorScheme.error) + Text( + "Gerät entfernen", + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + } + + if (showDelete) { + AlertDialog( + onDismissRequest = { showDelete = false }, + title = { Text("Gerät entfernen?") }, + text = { Text("Die Einstellungen und der hinterlegte Schlüssel werden gelöscht.") }, + confirmButton = { + TextButton(onClick = { + store.remove(current) + bluetooth.refreshConfiguration() + showDelete = false + onBack() + }) { Text("Entfernen") } + }, + dismissButton = { + TextButton(onClick = { showDelete = false }) { Text("Abbrechen") } + }, + ) + } +} + +// MARK: - Bausteine + +@Composable +fun LabeledRow(label: String, content: @Composable () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, modifier = Modifier.weight(1f)) + content() + } +} + +/** + * Ohne passenden Schlüssel bleibt das Gerät stumm – das ist dann keine + * Nebensache, sondern das Einzige, was zu tun ist. + */ +private fun needsKeyAttention( + device: ConfiguredDevice, + store: DeviceStore, + bluetooth: BluetoothManager, + hasSnapshot: Boolean, + linkState: DeviceLinkState, +): Boolean { + if (device.role.transport != DeviceTransport.ADVERTISEMENT) return false + val entered = store.victronKey(device.id) + val expected = bluetooth.diagnostics[device.id]?.expectedKeyFirstByte + if (entered != null && expected != null && (entered[0].toInt() and 0xFF) != expected) return true + // Kommen Werte an, passt der Schlüssel offensichtlich. + if (hasSnapshot && linkState == DeviceLinkState.Live) return false + return entered == null +} + +private fun keyStatusText( + device: ConfiguredDevice, + store: DeviceStore, + bluetooth: BluetoothManager, +): String { + val entered = store.victronKey(device.id) ?: return "fehlt" + val expected = bluetooth.diagnostics[device.id]?.expectedKeyFirstByte ?: return "hinterlegt" + return if ((entered[0].toInt() and 0xFF) == expected) "hinterlegt" else "passt nicht" +} + +@Composable +private fun KeyPrompt(onOpenKey: () -> Unit) { + Card( + modifier = Modifier.fillMaxWidth().padding(16.dp).clickable(onClick = onOpenKey), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Filled.Key, contentDescription = null, + tint = MaterialTheme.colorScheme.tertiary) + Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) { + Text("Verschlüsselungsschlüssel eintragen") + Text( + "Victron-Geräte senden ihre Werte verschlüsselt. Ohne den Schlüssel " + + "aus VictronConnect bleibt die Anzeige leer.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) + } + } +} + +@Composable +private fun NameField(device: ConfiguredDevice, store: DeviceStore) { + var name by remember(device.id) { mutableStateOf(device.name) } + OutlinedTextField( + value = name, + onValueChange = { + name = it + // Ein leeres Feld beim Tippen darf den Namen nicht löschen. + val trimmed = it.trim() + if (trimmed.isNotEmpty()) store.update(device.copy(name = trimmed)) + }, + label = { Text("Name") }, + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ZoneModePicker( + device: ConfiguredDevice, + store: DeviceStore, + bluetooth: BluetoothManager, +) { + var expanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) { + OutlinedTextField( + value = device.fridgeZoneMode.title, + onValueChange = {}, + readOnly = true, + label = { Text("Kühlzonen") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(androidx.compose.material3.MenuAnchorType.PrimaryNotEditable, true), + ) + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + FridgeZoneMode.entries.forEach { mode -> + DropdownMenuItem( + text = { Text(mode.title) }, + onClick = { + val updated = device.copy(fridgeZoneMode = mode) + store.update(updated) + bluetooth.updateFridgeZoneMode(updated) + expanded = false + }, + ) + } + } + } +} + +/** + * Der Verlauf des Hauptwerts. + * + * Selbst gezeichnet statt mit einer Diagrammbibliothek – für eine Linie mit + * Fläche lohnt keine weitere Abhängigkeit. + */ +@Composable +private fun HistoryChart(values: List, unit: String) { + if (values.size < 2) return + val lowest = values.min() + val highest = values.max() + // Bei Spannungen zählt der Unterschied von Zehntelvolt, nicht die absolute + // Spannung – deshalb eng um die Messwerte zoomen. + val low = lowest - 0.05 + val high = if (highest - lowest < 0.01) highest + 0.05 else highest + 0.05 + val span = (high - low).coerceAtLeast(0.0001) + val lineColor = MaterialTheme.colorScheme.primary + + Column { + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(160.dp) + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + val stepX = size.width / (values.size - 1) + fun y(value: Double) = (size.height * (1 - (value - low) / span)).toFloat() + + val line = Path().apply { + moveTo(0f, y(values.first())) + values.forEachIndexed { index, value -> lineTo(index * stepX, y(value)) } + } + val area = Path().apply { + addPath(line) + lineTo(size.width, size.height) + lineTo(0f, size.height) + close() + } + drawPath(area, lineColor.copy(alpha = 0.15f)) + drawPath(line, lineColor, style = Stroke(width = 3f)) + } + Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { + Text( + "%.2f %s".format(lowest, unit), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + Text( + "%.2f %s".format(highest, unit), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun VictronDiagnosticsSection(info: de.fritob.campermonitor.bluetooth.VictronDiagnostics) { + SectionHeader("Diagnose") + LabeledRow("Datensatz") { Text(info.recordName) } + LabeledRow("Produkt-ID") { Text(info.productIDText, fontFamily = FontFamily.Monospace) } + Text("Rohdaten", modifier = Modifier.padding(start = 16.dp, top = 8.dp)) + Text( + info.rawHex, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp), + ) + Text( + "Diese Werte sendet das Gerät unverschlüsselt mit.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(16.dp), + ) +} + +@Composable +private fun BmsDiagnosticsSection(device: ConfiguredDevice, info: BmsDiagnostics) { + val context = LocalContext.current + var copied by remember { mutableStateOf(false) } + + SectionHeader("Diagnose") + LabeledRow("Erkanntes Protokoll") { Text(info.dialect) } + info.endpointPosition?.let { + LabeledRow("Verbindungsweg") { Text("${it.first} von ${it.second}") } + } + info.endpointLabel?.let { + Text("Aktueller Weg", modifier = Modifier.padding(start = 16.dp, top = 8.dp)) + Text( + it, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + LabeledRow("Verbunden") { Text(if (info.isConnected) "ja" else "nein") } + LabeledRow("Empfang abonniert") { Text(if (info.isNotifyActive) "ja" else "nein") } + info.isBound?.let { LabeledRow("Angemeldet") { Text(if (it) "ja" else "nein") } } + if (info.confirmedWrites > 0) { + LabeledRow("Schreibvorgänge bestätigt") { Text("${info.confirmedWrites}") } + } + info.lastWriteError?.let { + LabeledRow("Letzter Schreibfehler") { + Text(it, color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall) + } + } + LabeledRow("Gesendet / empfangen") { + Text("${info.sentFrames} Anfragen / ${info.receivedBytes} Byte") + } + HexBlock("Letzter Stellbefehl", info.lastCommandHex) + HexBlock("Letzte Antwort", info.lastResponseHex) + info.fridgePayloadHex?.let { + HexBlock("Statusdaten der Box (${it.split(" ").size} Byte)", it) + } + + OutlinedButton( + onClick = { + copyToClipboard(context, buildReport(device, info)) + copied = true + }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Icon(Icons.Filled.ContentCopy, contentDescription = null) + Text( + if (copied) "Diagnose kopiert" else "Diagnose kopieren", + modifier = Modifier.padding(start = 8.dp), + ) + } + + if (info.gattSummary.isNotEmpty()) { + SectionHeader("Bluetooth-Merkmale des Geräts") + Text( + info.gattSummary.joinToString("\n"), + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + HorizontalDivider(modifier = Modifier.padding(top = 16.dp)) +} + +@Composable +private fun HexBlock(title: String, hex: String?) { + if (hex == null) return + Text(title, modifier = Modifier.padding(start = 16.dp, top = 8.dp)) + Text( + hex, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp), + ) +} + +/** + * Alles auf einmal, zum Weitergeben. Einzeln abzutippen ist zuviel verlangt, + * und gerade der Merkmalsbaum ist zu lang dafür. + */ +private fun buildReport(device: ConfiguredDevice, info: BmsDiagnostics): String = buildList { + add("Gerät: ${device.name} (${device.role.title})") + add("Protokoll: ${info.dialect}") + add("Verbunden: ${if (info.isConnected) "ja" else "nein"}") + add("Empfang abonniert: ${if (info.isNotifyActive) "ja" else "nein"}") + info.endpointPosition?.let { add("Weg: ${it.first} von ${it.second}") } + info.endpointLabel?.let { add("Merkmal: $it") } + info.isBound?.let { add("Angemeldet: ${if (it) "ja" else "nein"}") } + add("Gesendet: ${info.sentFrames} · empfangen: ${info.receivedBytes} Byte" + + " · bestätigt: ${info.confirmedWrites}") + info.lastWriteError?.let { add("Schreibfehler: $it") } + info.lastCommandHex?.let { add("Letzter Stellbefehl: $it") } + info.lastResponseHex?.let { add("Letzte Antwort: $it") } + info.fridgePayloadHex?.let { add("Statusdaten: $it") } + if (info.gattSummary.isNotEmpty()) { + add("Merkmale:") + addAll(info.gattSummary) + } +}.joinToString("\n") + +private fun copyToClipboard(context: Context, text: String) { + val manager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + manager.setPrimaryClip(ClipData.newPlainText("Diagnose", text)) +} + +/** + * Die Nivellierung in der Geräteansicht: Anzeige, Assistent und der Weg zur + * Einrichtung. Einbaulage und Nullpunkt liegen bewusst eine Ebene tiefer – + * direkt unter der Libelle verstellte ein Fehlgriff beim Ablesen den Nullpunkt. + */ +@Composable +private fun LevelSection( + device: ConfiguredDevice, + state: LevelState, + isLive: Boolean, + onOpenAssistant: () -> Unit, + onOpenSetup: () -> Unit, +) { + var displayStyle by remember { mutableStateOf(0) } + + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + listOf("Libelle", "Fahrzeug").forEachIndexed { index, label -> + SegmentedButton( + selected = displayStyle == index, + onClick = { displayStyle = index }, + shape = SegmentedButtonDefaults.itemShape(index, 2), + ) { Text(label) } + } + } + if (displayStyle == 0) { + LevelBubble(state.pitch, state.roll) + } else { + VehicleTiltView(state.pitch, state.roll) + } + state.instruction?.let { + Text( + it, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.fillMaxWidth(), + ) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { + Reading("Längs", state.pitch, Modifier.weight(1f)) + Reading("Quer", state.roll, Modifier.weight(1f)) + } + + OutlinedButton( + onClick = onOpenAssistant, + // Der Assistent lebt von laufenden Messwerten. Ein einmal + // empfangener Wert genügt nicht: nach einem Verbindungsabbruch + // bliebe er stehen und die Ansicht sähe eingefroren aus. + enabled = isLive && state.hasReading, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Ausrichtungs-Assistent") + } + if (!isLive) { + Text( + "Der Assistent braucht laufende Messwerte. Der Neigungsmesser ist " + + "gerade nicht verbunden.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + OutlinedButton(onClick = onOpenSetup, modifier = Modifier.fillMaxWidth()) { + Icon(Icons.Filled.Tune, contentDescription = null) + Text("Neigungsmesser einrichten", modifier = Modifier.weight(1f).padding(start = 8.dp)) + Text( + if (state.isKnownUncalibrated) "Nullpunkt fehlt" else device.sensorOrientation.summary, + style = MaterialTheme.typography.labelSmall, + ) + } + } +} diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/FridgeControls.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/FridgeControls.kt new file mode 100644 index 0000000..860c598 --- /dev/null +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/FridgeControls.kt @@ -0,0 +1,156 @@ +package de.fritob.campermonitor.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Remove +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import de.fritob.campermonitor.bluetooth.BluetoothManager +import de.fritob.campermonitor.protocol.AlpicoolState +import de.fritob.campermonitor.protocol.ConfiguredDevice +import de.fritob.campermonitor.protocol.DeviceLinkState +import de.fritob.campermonitor.protocol.FridgeZone + +/** + * Bedienelemente einer Alpicool-Kühlbox. + * + * Alle Schalter folgen dem Gerät, nicht der Vermutung: nach jedem Stellbefehl + * fragt die Sitzung den Zustand neu ab, und die Ansicht zeigt, was zurückkam. + */ +@Composable +fun FridgeControls( + device: ConfiguredDevice, + state: AlpicoolState, + bluetooth: BluetoothManager, +) { + val isLinked = bluetooth.linkStates[device.id] == DeviceLinkState.Live + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + SectionHeader("Steuerung") + + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Eingeschaltet", modifier = Modifier.weight(1f)) + Switch( + checked = state.isPoweredOn, + onCheckedChange = { bluetooth.setFridgePower(it, device.id) }, + ) + } + + SingleChoiceSegmentedButtonRow( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + ) { + listOf("Max" to false, "Eco" to true).forEachIndexed { index, (label, eco) -> + SegmentedButton( + selected = state.isEco == eco, + onClick = { bluetooth.setFridgeEco(eco, device.id) }, + shape = SegmentedButtonDefaults.itemShape(index, 2), + enabled = state.isPoweredOn, + ) { Text(label) } + } + } + + TargetStepper( + title = if (state.isDualZone) "Soll links" else "Solltemperatur", + value = state.leftTarget, + state = state, + enabled = state.isPoweredOn, + onChange = { bluetooth.setFridgeTarget(it, FridgeZone.LEFT, device.id) }, + ) + + if (state.isDualZone) { + TargetStepper( + title = "Soll rechts", + value = state.rightTarget, + state = state, + enabled = state.isPoweredOn, + onChange = { bluetooth.setFridgeTarget(it, FridgeZone.RIGHT, device.id) }, + ) + } + + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Bedienfeld gesperrt", modifier = Modifier.weight(1f)) + Switch( + checked = state.isLocked, + onCheckedChange = { bluetooth.setFridgeLock(it, device.id) }, + ) + } + + Text( + // Ist die Box gerade nicht erreichbar, wird ein Befehl aufgehoben + // statt verworfen – das gehört gesagt, sonst sieht es aus, als + // hätte das Tippen nichts bewirkt. + if (isLinked) { + "Änderungen gehen direkt an die Box. Der angezeigte Stand kommt aus " + + "ihrer Antwort, nicht aus der Eingabe." + } else { + "Die Box ist gerade nicht verbunden. Die Änderung wird gemerkt und " + + "geht raus, sobald sie wieder erreichbar ist." + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + } +} + +@Composable +private fun TargetStepper( + title: String, + value: Int?, + state: AlpicoolState, + enabled: Boolean, + onChange: (Int) -> Unit, +) { + val range = state.targetRange + val current = value ?: range.first + + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(title, modifier = Modifier.weight(1f)) + Text( + value?.let { "$it ${state.unitSymbol}" } ?: "–", + style = MaterialTheme.typography.titleMedium, + ) + IconButton( + onClick = { onChange((current - 1).coerceIn(range.first, range.last)) }, + enabled = enabled && value != null && current > range.first, + ) { Icon(Icons.Filled.Remove, contentDescription = "kälter") } + IconButton( + onClick = { onChange((current + 1).coerceIn(range.first, range.last)) }, + enabled = enabled && value != null && current < range.last, + ) { Icon(Icons.Filled.Add, contentDescription = "wärmer") } + } +} + +@Composable +fun SectionHeader(title: String) { + Text( + title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 4.dp), + ) +} diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/Icons.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/Icons.kt new file mode 100644 index 0000000..ff54198 --- /dev/null +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/Icons.kt @@ -0,0 +1,53 @@ +package de.fritob.campermonitor.ui + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AcUnit +import androidx.compose.material.icons.filled.Agriculture +import androidx.compose.material.icons.filled.AirportShuttle +import androidx.compose.material.icons.filled.Battery5Bar +import androidx.compose.material.icons.filled.BatteryChargingFull +import androidx.compose.material.icons.filled.Bolt +import androidx.compose.material.icons.filled.Cabin +import androidx.compose.material.icons.filled.DirectionsBoat +import androidx.compose.material.icons.filled.DirectionsBus +import androidx.compose.material.icons.filled.DirectionsCar +import androidx.compose.material.icons.filled.LocalShipping +import androidx.compose.material.icons.filled.Landscape +import androidx.compose.material.icons.filled.Speed +import androidx.compose.material.icons.filled.Straighten +import androidx.compose.material.icons.filled.WbSunny +import androidx.compose.ui.graphics.vector.ImageVector +import de.fritob.campermonitor.protocol.DeviceRole + +/** + * Die Entsprechungen der SF-Symbole aus der iOS-Fassung. + * + * Material bringt keine deckungsgleichen Symbole mit; ausgewählt ist jeweils + * das, was dieselbe Sache meint – nicht das, was am ähnlichsten aussieht. + */ +fun roleIcon(role: DeviceRole): ImageVector = when (role) { + DeviceRole.CHARGE_BOOSTER -> Icons.Filled.Bolt + DeviceRole.SOLAR_CHARGER -> Icons.Filled.WbSunny + DeviceRole.BATTERY_MONITOR -> Icons.Filled.Speed + DeviceRole.BMS -> Icons.Filled.BatteryChargingFull + DeviceRole.FRIDGE -> Icons.Filled.AcUnit + DeviceRole.LEVELING -> Icons.Filled.Straighten +} + +/** Symbol eines Fahrzeugprofils. */ +fun profileIcon(symbol: String): ImageVector = when (symbol) { + "box_truck" -> Icons.Filled.LocalShipping + "pickup" -> Icons.Filled.Agriculture + "bus" -> Icons.Filled.DirectionsBus + "double_decker" -> Icons.Filled.AirportShuttle + "car" -> Icons.Filled.DirectionsCar + "car_side" -> Icons.Filled.DirectionsCar + "tent" -> Icons.Filled.Cabin + "sailboat" -> Icons.Filled.DirectionsBoat + "lodge" -> Icons.Filled.Cabin + "mountains" -> Icons.Filled.Landscape + else -> Icons.Filled.LocalShipping +} + +/** Für die Anzeige des Batteriestands in der Fahrzeugauswahl. */ +val batteryIcon: ImageVector = Icons.Filled.Battery5Bar diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/LevelSetupScreen.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/LevelSetupScreen.kt new file mode 100644 index 0000000..9513084 --- /dev/null +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/LevelSetupScreen.kt @@ -0,0 +1,180 @@ +package de.fritob.campermonitor.ui + +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.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.GpsFixed +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.SwapHoriz +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +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.graphics.Color +import androidx.compose.ui.unit.dp +import de.fritob.campermonitor.bluetooth.BluetoothManager +import de.fritob.campermonitor.protocol.ConfiguredDevice +import de.fritob.campermonitor.protocol.LevelState +import de.fritob.campermonitor.store.DeviceStore + +/** + * Einrichtung des Neigungsmessers: Einbaulage und Nullpunkt. + * + * Beides wird einmal eingestellt und danach kaum wieder angefasst. In der + * Geräteansicht standen die Knöpfe direkt unter der Libelle – ein Fehlgriff + * beim Ablesen verstellte dort den Nullpunkt. Deshalb liegen sie hier. + * + * Die Reihenfolge ist nicht beliebig: erst muss klar sein, welche Achse des + * Sensors welche des Fahrzeugs ist, sonst wird der Nullpunkt auf die falsche + * Achse gelegt. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LevelSetupScreen( + device: ConfiguredDevice, + store: DeviceStore, + bluetooth: BluetoothManager, + onBack: () -> Unit, + onOpenSensorSetup: () -> Unit, +) { + val state = bluetooth.levelStates[device.id] ?: LevelState() + val current = store.devices.firstOrNull { it.id == device.id } ?: device + var showResetConfirmation by remember { mutableStateOf(false) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Neigungsmesser einrichten") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { + Reading("Längs", state.pitch, Modifier.weight(1f)) + Reading("Quer", state.roll, Modifier.weight(1f)) + } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Schritt 1 – Einbaulage", style = MaterialTheme.typography.titleSmall) + OutlinedButton( + onClick = onOpenSensorSetup, + enabled = state.hasReading, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Filled.SwapHoriz, contentDescription = null) + Text( + "Einbaulage bestimmen", + modifier = Modifier.weight(1f).padding(start = 8.dp), + ) + Text( + current.sensorOrientation.summary, + style = MaterialTheme.typography.labelSmall, + ) + } + Text( + "Sitzt der Sensor quer oder verdreht im Fahrzeug, meldet er längs " + + "und quer vertauscht. Der Assistent klärt das durch zweimaliges " + + "Kippen. Danach den Nullpunkt setzen.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Schritt 2 – Nullpunkt", style = MaterialTheme.typography.titleSmall) + Button( + onClick = { bluetooth.calibrateLevel(device.id) }, + enabled = state.hasReading, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Filled.GpsFixed, contentDescription = null) + Text("Aktuelle Lage als eben übernehmen", modifier = Modifier.padding(start = 8.dp)) + } + if (!state.isKnownUncalibrated) { + OutlinedButton( + onClick = { showResetConfirmation = true }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Icon(Icons.Filled.Refresh, contentDescription = null) + Text("Nullpunkt verwerfen", modifier = Modifier.padding(start = 8.dp)) + } + } + Text( + calibrationHint(state), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + if (showResetConfirmation) { + AlertDialog( + onDismissRequest = { showResetConfirmation = false }, + title = { Text("Nullpunkt verwerfen?") }, + text = { Text("Die Anzeige zeigt danach wieder die Lage des Sensors.") }, + confirmButton = { + TextButton(onClick = { + bluetooth.resetLevelCalibration(device.id) + showResetConfirmation = false + }) { Text("Verwerfen", color = Color.Unspecified) } + }, + dismissButton = { + TextButton(onClick = { showResetConfirmation = false }) { Text("Abbrechen") } + }, + ) + } +} + +private fun calibrationHint(state: LevelState): String { + val pitchOffset = state.pitchOffset + val rollOffset = state.rollOffset + if (state.isCalibrated && pitchOffset != null && rollOffset != null) { + return "Der Nullpunkt liegt bei %.1f° längs und %.1f° quer. Zum Neusetzen das " + .format(pitchOffset, rollOffset) + "Fahrzeug eben stellen und dann tippen." + } + if (state.isKnownUncalibrated) { + return "Noch kein Nullpunkt gesetzt – die Anzeige zeigt die Lage des Sensors, " + + "nicht die des Fahrzeugs. Fahrzeug eben stellen, dann tippen." + } + // Ältere Firmware gibt die Offsets nicht heraus. + return "Zum Setzen das Fahrzeug eben stellen und dann tippen. Ob schon ein " + + "Nullpunkt gesetzt wurde, meldet dieses Gerät nicht zurück." +} diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/LevelViews.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/LevelViews.kt new file mode 100644 index 0000000..ecd8795 --- /dev/null +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/LevelViews.kt @@ -0,0 +1,210 @@ +package de.fritob.campermonitor.ui + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +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.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +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.draw.rotate +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import de.fritob.campermonitor.R +import de.fritob.campermonitor.protocol.LevelState +import kotlin.math.abs +import kotlin.math.max + +/** + * Grafische Libelle: eine Blase, die zeigt, wohin das Fahrzeug hängt. + * + * Die Blase wandert dorthin, wo das Fahrzeug **höher** steht – so, wie sich + * eine echte Wasserwaage verhält. Wer sie mittig haben will, muss also die + * Gegenseite anheben. + */ +@Composable +fun LevelBubble(pitch: Double?, roll: Double?, range: Double = 6.0) { + val deviation = when { + pitch != null && roll != null -> max(abs(pitch), abs(roll)) + pitch != null -> abs(pitch) + roll != null -> abs(roll) + else -> null + } + val isLevel = pitch != null && roll != null && + abs(pitch) <= LevelState.LEVEL_TOLERANCE && abs(roll) <= LevelState.LEVEL_TOLERANCE + + // Grün nur, wenn es wirklich eben ist – sonst sähe "schief" wie "eben" aus. + val bubbleColor = when { + deviation == null -> Color.Gray + deviation <= LevelState.LEVEL_TOLERANCE -> Color(0xFF1F9E52) + deviation <= 2 -> Color(0xFFE08600) + else -> Color(0xFFD03B2F) + } + val outline = MaterialTheme.colorScheme.outlineVariant + val fill = MaterialTheme.colorScheme.surfaceVariant + + Canvas(modifier = Modifier.fillMaxWidth().aspectRatio(1f)) { + val side = minOf(size.width, size.height) + val radius = side / 2 + val bubble = side * 0.16f + // Die Blase darf den Rand nicht verlassen, auch bei starker Neigung. + val travel = radius - bubble / 2 - 4 + val centre = Offset(size.width / 2, size.height / 2) + + fun clamped(value: Double?): Float = + ((value ?: 0.0).coerceIn(-range, range) / range).toFloat() + + drawCircle(color = fill, radius = radius, center = centre) + drawCircle(color = outline, radius = radius, center = centre, style = Stroke(1f)) + + // Ringe als echter Massstab: der innere markiert die Toleranz, der + // mittlere zwei Grad. Ohne Massstab sagt die Blasenlage nichts darüber, + // wie weit es noch ist. + val toleranceRadius = max( + (LevelState.LEVEL_TOLERANCE / range * travel).toFloat(), + bubble * 0.6f, + ) + drawCircle( + color = if (isLevel) Color(0xFF1F9E52) else outline, + radius = toleranceRadius, center = centre, + style = Stroke(if (isLevel) 2f else 1f), + ) + drawCircle( + color = outline, radius = (2.0 / range * travel).toFloat(), center = centre, + style = Stroke(1f), + ) + drawLine(outline, Offset(centre.x - travel, centre.y), Offset(centre.x + travel, centre.y)) + drawLine(outline, Offset(centre.x, centre.y - travel), Offset(centre.x, centre.y + travel)) + + // Positiver Pitch heisst: das Heck steht höher, die Blase wandert also + // nach oben – in der Ansicht nach hinten. + drawCircle( + color = bubbleColor.copy(alpha = if (deviation == null) 0.25f else 1f), + radius = bubble / 2, + center = Offset( + centre.x + clamped(roll) * travel, + centre.y - clamped(pitch) * travel, + ), + ) + } +} + +/** + * Die Neigung am Fahrzeug selbst: Seitenansicht für längs, Heckansicht für + * quer. Aus dem Ursprungsprojekt übernommen. + */ +@Composable +fun VehicleTiltView(pitch: Double?, roll: Double?) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + TiltedVehicle( + title = "Längs", + value = pitch, + // Positiver Pitch heisst Heck höher; das Bild zeigt nach rechts das + // Heck, also dreht die Front nach unten. + degrees = ((pitch ?: 0.0) * EXAGGERATION).toFloat(), + drawable = R.drawable.vehicle_side, + leftLabel = "Front", rightLabel = "Heck", + ) + TiltedVehicle( + title = "Quer", + value = roll, + degrees = (-(roll ?: 0.0) * EXAGGERATION).toFloat(), + drawable = R.drawable.vehicle_rear, + leftLabel = "links", rightLabel = "rechts", + ) + Text( + "Neigung ${EXAGGERATION.toInt()}-fach überhöht dargestellt – sonst wäre " + + "sie kaum zu erkennen. Die Gradzahlen sind echt.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** Ohne Überhöhung sind zwei Grad am Bild nicht zu sehen. */ +private const val EXAGGERATION = 3.0 + +@Composable +private fun TiltedVehicle( + title: String, + value: Double?, + degrees: Float, + drawable: Int, + leftLabel: String, + rightLabel: String, +) { + val tint = when { + value == null -> MaterialTheme.colorScheme.onSurfaceVariant + abs(value) <= LevelState.LEVEL_TOLERANCE -> Color(0xFF1F9E52) + abs(value) <= 2 -> Color(0xFFE08600) + else -> Color(0xFFD03B2F) + } + Column { + Row(modifier = Modifier.fillMaxWidth()) { + Text(title, style = MaterialTheme.typography.titleSmall, modifier = Modifier.weight(1f)) + Text( + value?.let { "%.1f°".format(it) } ?: "–", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = tint, + ) + } + Box( + modifier = Modifier.fillMaxWidth().height(120.dp), + contentAlignment = Alignment.Center, + ) { + Image( + painter = painterResource(drawable), + contentDescription = null, + colorFilter = ColorFilter.tint(tint), + modifier = Modifier.fillMaxWidth(0.8f).rotate(degrees), + ) + } + Row(modifier = Modifier.fillMaxWidth()) { + Text( + leftLabel, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + Text( + rightLabel, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** Ein Messwert mit Beschriftung, wie er unter der Libelle steht. */ +@Composable +fun Reading(title: String, value: Double?, modifier: Modifier = Modifier) { + Column( + modifier = modifier.padding(vertical = 4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + value?.let { "%.1f°".format(it) } ?: "–", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + ) + Text( + title, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/Permissions.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/Permissions.kt new file mode 100644 index 0000000..51776fd --- /dev/null +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/Permissions.kt @@ -0,0 +1,103 @@ +package de.fritob.campermonitor.ui + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +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.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +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.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat + +/** + * Welche Rechte für Bluetooth gebraucht werden. + * + * Mit Android 12 hat sich das Modell geändert: davor lief ein BLE-Scan über + * die Standortfreigabe, seither gibt es eigene Bluetooth-Rechte. Beide Wege + * müssen bedient werden, sonst startet die App auf der einen oder der anderen + * Version nicht. + */ +val bluetoothPermissions: Array + get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT) + } else { + arrayOf(Manifest.permission.ACCESS_FINE_LOCATION) + } + +fun hasBluetoothPermissions(context: Context): Boolean = + bluetoothPermissions.all { + ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED + } + +/** + * Zeigt [content], sobald die Rechte da sind – sonst eine Erklärung mit Knopf. + * + * Ohne Erklärung wirkt eine App, die beim ersten Start nach Bluetooth fragt, + * schnell übergriffig. Hier steht, wozu. + */ +@Composable +fun WithBluetoothPermission(content: @Composable () -> Unit) { + val context = LocalContext.current + var granted by remember { mutableStateOf(hasBluetoothPermissions(context)) } + var asked by remember { mutableStateOf(false) } + + val launcher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { result -> + granted = result.values.all { it } + asked = true + } + + if (granted) { + content() + return + } + + Column( + modifier = Modifier.fillMaxSize().padding(32.dp), + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "Bluetooth wird gebraucht", + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + ) + Text( + "Die App liest die Werte deiner Geräte über Bluetooth – Ladebooster, " + + "Solarladeregler, Batterie, Kühlbox und Neigungsmesser. Ohne die " + + "Freigabe bleibt sie leer.", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + if (asked) { + Text( + "Die Freigabe wurde abgelehnt. Sie lässt sich in den " + + "Android-Einstellungen unter „Apps → Camper Monitor → " + + "Berechtigungen“ nachholen.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + ) + } + Button(onClick = { launcher.launch(bluetoothPermissions) }) { + Text(if (asked) "Nochmal fragen" else "Bluetooth freigeben") + } + } +} diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/ProfilesScreen.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/ProfilesScreen.kt new file mode 100644 index 0000000..24ecc88 --- /dev/null +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/ProfilesScreen.kt @@ -0,0 +1,241 @@ +package de.fritob.campermonitor.ui + +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +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.unit.dp +import de.fritob.campermonitor.bluetooth.BluetoothManager +import de.fritob.campermonitor.protocol.Profile +import de.fritob.campermonitor.store.DeviceStore + +/** + * Die Fahrzeugverwaltung. + * + * Antippen wählt aus, der Stift öffnet Name, Symbol und Masse. Beides + * getrennt, weil die Auswahl der häufige Fall ist und das Bearbeiten der + * seltene – ein Wisch-Menü hatte unter iOS niemand gefunden. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProfilesScreen(store: DeviceStore, bluetooth: BluetoothManager, onBack: () -> Unit) { + var editing by remember { mutableStateOf(null) } + var pendingDeletion by remember { mutableStateOf(null) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Fahrzeuge") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") + } + }, + actions = { + IconButton(onClick = { + editing = store.addProfile("Camper ${store.profiles.size + 1}") + }) { + Icon(Icons.Filled.Add, contentDescription = "Fahrzeug hinzufügen") + } + }, + ) + }, + ) { padding -> + LazyColumn(modifier = Modifier.fillMaxSize().padding(padding)) { + items(store.profiles, key = { it.id }) { profile -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + store.selectProfile(profile.id) + bluetooth.refreshConfiguration() + } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(profileIcon(profile.symbol), contentDescription = null) + Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) { + Text(profile.name, style = MaterialTheme.typography.bodyLarge) + Text( + "${store.devices.count { it.profileID == profile.id }} Geräte" + + measuresText(profile), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (profile.id == store.activeProfile?.id) { + Icon( + Icons.Filled.Check, contentDescription = "ausgewählt", + tint = MaterialTheme.colorScheme.primary, + ) + } + IconButton(onClick = { editing = profile }) { + Icon(Icons.Filled.Edit, contentDescription = "Bearbeiten") + } + } + } + + item { + Text( + "Antippen wählt das Fahrzeug aus, der Stift öffnet Name, Symbol " + + "und Masse. Jedes Fahrzeug hat seine eigenen Geräte, und die " + + "App liest immer nur die des gewählten aus.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(16.dp), + ) + } + } + } + + editing?.let { profile -> + ProfileEditDialog( + profile = profile, + canDelete = store.profiles.size > 1, + onDismiss = { editing = null }, + onSave = { + store.updateProfile(it) + bluetooth.refreshConfiguration() + editing = null + }, + onDelete = { + pendingDeletion = profile + editing = null + }, + ) + } + + pendingDeletion?.let { profile -> + AlertDialog( + onDismissRequest = { pendingDeletion = null }, + title = { Text("Fahrzeug entfernen?") }, + text = { Text("Die Geräte dieses Fahrzeugs werden mit gelöscht.") }, + confirmButton = { + TextButton(onClick = { + store.removeProfile(profile) + bluetooth.refreshConfiguration() + pendingDeletion = null + }) { Text("Entfernen") } + }, + dismissButton = { + TextButton(onClick = { pendingDeletion = null }) { Text("Abbrechen") } + }, + ) + } +} + +private fun measuresText(profile: Profile): String { + val track = profile.trackWidth + val base = profile.wheelbase + if (track == null && base == null) return "" + val parts = buildList { + track?.let { add("Spur %.2f m".format(it)) } + base?.let { add("Radstand %.2f m".format(it)) } + } + return " · " + parts.joinToString(", ") +} + +@Composable +private fun ProfileEditDialog( + profile: Profile, + canDelete: Boolean, + onDismiss: () -> Unit, + onSave: (Profile) -> Unit, + onDelete: () -> Unit, +) { + var name by remember { mutableStateOf(profile.name) } + var symbol by remember { mutableStateOf(profile.symbol) } + var track by remember { mutableStateOf(profile.trackWidth?.toString() ?: "") } + var base by remember { mutableStateOf(profile.wheelbase?.toString() ?: "") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Fahrzeug") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Name") }, + singleLine = true, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Profile.SYMBOLS.take(5).forEach { candidate -> + FilterChip( + selected = symbol == candidate, + onClick = { symbol = candidate }, + label = { Icon(profileIcon(candidate), contentDescription = null) }, + ) + } + } + OutlinedTextField( + value = track, + onValueChange = { track = it }, + label = { Text("Spurweite in Metern") }, + singleLine = true, + ) + OutlinedTextField( + value = base, + onValueChange = { base = it }, + label = { Text("Radstand in Metern") }, + singleLine = true, + ) + Text( + "Beide Masse braucht nur der Keilrechner im " + + "Ausrichtungs-Assistenten. Ohne sie bleibt der Rest nutzbar.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + confirmButton = { + Button(onClick = { + onSave( + profile.copy( + name = name.trim().ifEmpty { profile.name }, + symbol = symbol, + trackWidth = track.replace(',', '.').toDoubleOrNull(), + wheelbase = base.replace(',', '.').toDoubleOrNull(), + ) + ) + }) { Text("Sichern") } + }, + dismissButton = { + Row { + if (canDelete) { + TextButton(onClick = onDelete) { Text("Entfernen") } + } + TextButton(onClick = onDismiss) { Text("Abbrechen") } + } + }, + ) +} diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/SensorSetupScreen.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/SensorSetupScreen.kt new file mode 100644 index 0000000..54f5d33 --- /dev/null +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/SensorSetupScreen.kt @@ -0,0 +1,247 @@ +package de.fritob.campermonitor.ui + +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.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.HorizontalRule +import androidx.compose.material.icons.filled.SouthEast +import androidx.compose.material.icons.filled.SouthWest +import androidx.compose.material.icons.filled.SwapHoriz +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +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.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import de.fritob.campermonitor.bluetooth.BluetoothManager +import de.fritob.campermonitor.protocol.ConfiguredDevice +import de.fritob.campermonitor.protocol.LevelState +import de.fritob.campermonitor.protocol.OrientationDetection +import de.fritob.campermonitor.protocol.SensorOrientation +import de.fritob.campermonitor.store.DeviceStore + +/** + * Führt durch die Einrichtung der Einbaulage des Neigungsmessers. + * + * Der Sensor kann quer, gedreht oder kopfüber sitzen. Statt die Lage aus einer + * Liste raten zu lassen, wird sie gemessen: zweimal kippen, einmal um jede + * Achse, und aus der Reaktion ergibt sich die Zuordnung. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SensorSetupScreen( + device: ConfiguredDevice, + store: DeviceStore, + bluetooth: BluetoothManager, + onBack: () -> Unit, +) { + val state = bluetooth.levelStates[device.id] ?: LevelState() + val live = remember(state) { + val pitch = state.rawPitch + val roll = state.rawRoll + if (pitch != null && roll != null) OrientationDetection.Reading(pitch, roll) else null + } + + var step by remember { mutableStateOf(Step.INTRO) } + /** Ruhelage, auf die beide Kippbewegungen bezogen werden. */ + var reference by remember { mutableStateOf(OrientationDetection.Reading(0.0, 0.0)) } + var noseChange by remember { mutableStateOf(null) } + var result by remember { mutableStateOf(null) } + var failure by remember { mutableStateOf(null) } + + Scaffold( + topBar = { TopAppBar(title = { Text("Einbaulage") }) }, + ) { padding -> + Column( + modifier = Modifier.fillMaxSize().padding(padding).padding(24.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + when (step) { + Step.INTRO -> { + InstructionCard( + icon = Icons.Filled.SwapHoriz, + title = "Einbaulage bestimmen", + text = "Sitzt der Sensor quer oder verdreht im Fahrzeug, meldet " + + "er die Neigung vertauscht. Um das zu klären, wird er gleich " + + "zweimal gekippt.\n\nBaue ihn dazu so ein oder halte ihn so, " + + "wie er später sitzen soll. Er muss nicht angeschraubt sein – " + + "nur die Ausrichtung muss stimmen.", + ) + LiveReadout(state) + Button( + onClick = { + reference = live ?: OrientationDetection.Reading(0.0, 0.0) + step = Step.TILT_NOSE + }, + enabled = live != null, + ) { Text("Los geht's") } + } + + Step.TILT_NOSE -> { + InstructionCard( + icon = Icons.Filled.SouthEast, + title = "Schritt 1 von 2: nach vorne kippen", + text = "Kippe den Sensor so, als würde das Fahrzeug vorne abwärts " + + "stehen – die Front also nach unten.\n\nDeutlich kippen, etwa " + + "eine Handbreit, und in dieser Lage halten. Dann weiter.", + ) + LiveReadout(state) + Button( + onClick = { + live?.let { noseChange = it - reference } + // Der Bezug bleibt die Ruhelage. Von der gekippten + // Lage aus zu messen wäre falsch: die zweite Messung + // enthielte dann das Zurückkippen aus der ersten, + // und beide Achsen schlügen aus. + step = Step.SETTLE + }, + enabled = live != null, + ) { Text("Weiter") } + } + + Step.SETTLE -> { + InstructionCard( + icon = Icons.Filled.HorizontalRule, + title = "Zurück in die Ruhelage", + text = "Stelle den Sensor wieder so hin wie am Anfang und lass ihn " + + "kurz ruhen.\n\nVon hier aus wird die zweite Bewegung gemessen.", + ) + LiveReadout(state) + Button( + onClick = { + reference = live ?: reference + step = Step.TILT_SIDE + }, + enabled = live != null, + ) { Text("Weiter") } + } + + Step.TILT_SIDE -> { + InstructionCard( + icon = Icons.Filled.SouthWest, + title = "Schritt 2 von 2: nach links kippen", + text = "Kippe den Sensor jetzt so, als würde das Fahrzeug nach " + + "links hängen – die linke Seite also nach unten.\n\nWieder " + + "deutlich kippen und in dieser Lage halten.", + ) + LiveReadout(state) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedButton(onClick = { step = Step.SETTLE }) { Text("Zurück") } + Button( + onClick = { + val nose = noseChange + val side = live?.minus(reference) + if (nose != null && side != null) { + when (val r = OrientationDetection.orientation(nose, side)) { + is OrientationDetection.Result.Success -> { + result = r.orientation + step = Step.DONE + } + is OrientationDetection.Result.Error -> { + failure = r.failure + step = Step.FAILED + } + } + } + }, + enabled = live != null, + ) { Text("Fertig") } + } + } + + Step.DONE -> { + val orientation = result ?: SensorOrientation.IDENTITY + InstructionCard( + icon = Icons.Filled.CheckCircle, + title = "Einbaulage erkannt", + text = "Ergebnis: ${orientation.summary}.\n\nDie Anzeige rechnet die " + + "Werte des Sensors ab jetzt auf die Achsen des Fahrzeugs um. " + + "Vergiss nicht, anschliessend im ebenen Stand den Nullpunkt " + + "zu setzen.", + ) + Button(onClick = { + val updated = device.copy(sensorOrientation = orientation) + store.update(updated) + bluetooth.updateSensorOrientation(updated) + onBack() + }) { Text("Übernehmen") } + } + + Step.FAILED -> { + InstructionCard( + icon = Icons.Filled.Warning, + title = "Das hat nicht geklappt", + text = failure?.message ?: "", + ) + Button(onClick = { step = Step.INTRO }) { Text("Nochmal versuchen") } + } + } + + TextButton(onClick = onBack) { Text("Abbrechen") } + } + } +} + +private enum class Step { INTRO, TILT_NOSE, SETTLE, TILT_SIDE, DONE, FAILED } + +@Composable +private fun InstructionCard(icon: ImageVector, title: String, text: String) { + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + icon, contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(44.dp), + ) + Text( + title, + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + ) + Text( + text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } +} + +/** + * Was der Sensor gerade meldet – ohne Umrechnung, denn die wird hier ja erst + * bestimmt. Deshalb heissen die Achsen A und B statt längs und quer. + */ +@Composable +private fun LiveReadout(state: LevelState) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { + Reading("Achse A", state.rawPitch, Modifier.weight(1f)) + Reading("Achse B", state.rawRoll, Modifier.weight(1f)) + } +} diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/SettingsScreen.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/SettingsScreen.kt new file mode 100644 index 0000000..36c2740 --- /dev/null +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/SettingsScreen.kt @@ -0,0 +1,105 @@ +package de.fritob.campermonitor.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +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.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +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.Modifier +import androidx.compose.ui.unit.dp +import de.fritob.campermonitor.store.DeviceStore + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen(store: DeviceStore, onBack: () -> Unit) { + // Der Speicher meldet Änderungen selbst; die lokale Kopie hält nur den + // Schalter in Bewegung, während geschrieben wird. + var showDiagnostics by remember { mutableStateOf(store.showDiagnostics) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Einstellungen") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()), + ) { + SettingRow( + title = "Diagnose einblenden", + subtitle = "Zeigt Protokoll, Verbindungsweg und Rohdaten in den " + + "Gerätedetails. Im Alltag stören sie; bei einem Fehler sind sie " + + "genau das, was weiterhilft.", + trailing = { + Switch( + checked = showDiagnostics, + onCheckedChange = { + showDiagnostics = it + store.showDiagnostics = it + }, + ) + }, + ) + + Text( + "Camper Monitor liest Victron-Geräte über ihr Advertisement mit " + + "(„Instant Readout“) und spricht Batterie, Kühlbox und " + + "Neigungsmesser direkt über Bluetooth an.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(16.dp), + ) + } + } +} + +@Composable +fun SettingRow( + title: String, + subtitle: String? = null, + onClick: (() -> Unit)? = null, + trailing: @Composable (() -> Unit)? = null, +) { + androidx.compose.foundation.layout.Row( + modifier = Modifier + .fillMaxWidth() + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.bodyLarge) + if (subtitle != null) { + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + trailing?.invoke() + } +} diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/Theme.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/Theme.kt new file mode 100644 index 0000000..81a18a1 --- /dev/null +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/Theme.kt @@ -0,0 +1,44 @@ +package de.fritob.campermonitor.ui + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext + +/** + * Das Grün der iOS-Fassung als Akzent – damit beide Apps als dieselbe erkennbar + * bleiben. Ab Android 12 gewinnt die Farbwelt des Systems, das ist dort die + * Erwartung. + */ +private val CamperGreen = Color(0xFF1F9E52) + +private val LightColors = lightColorScheme( + primary = CamperGreen, + secondary = CamperGreen, +) + +private val DarkColors = darkColorScheme( + primary = Color(0xFF54C77F), + secondary = Color(0xFF54C77F), +) + +@Composable +fun CamperTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit, +) { + val context = LocalContext.current + val colors = when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + darkTheme -> DarkColors + else -> LightColors + } + MaterialTheme(colorScheme = colors, content = content) +} diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/VictronKeyScreen.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/VictronKeyScreen.kt new file mode 100644 index 0000000..92d0a86 --- /dev/null +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/VictronKeyScreen.kt @@ -0,0 +1,145 @@ +package de.fritob.campermonitor.ui + +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.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +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.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import de.fritob.campermonitor.bluetooth.BluetoothManager +import de.fritob.campermonitor.protocol.ConfiguredDevice +import de.fritob.campermonitor.store.DeviceStore + +/** + * Eingabe des Victron-Verschlüsselungsschlüssels. + * + * Der Schlüssel wird einmal eingetragen und danach nie wieder angefasst – + * deshalb steht er hier und nicht in der Geräteübersicht. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VictronKeyScreen( + device: ConfiguredDevice, + store: DeviceStore, + bluetooth: BluetoothManager, + onBack: () -> Unit, +) { + var keyInput by remember { mutableStateOf(store.victronKeyText(device.id) ?: "") } + val diagnostics = bluetooth.diagnostics[device.id] + + val entered = remember(keyInput) { + keyInput.filter { !it.isWhitespace() }.take(2) + .let { if (it.length == 2) it.toIntOrNull(16) else null } + } + val expected = diagnostics?.expectedKeyFirstByte + val agree = if (expected != null && entered != null) expected == entered else null + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Verschlüsselung") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + OutlinedTextField( + value = keyInput, + onValueChange = { keyInput = it }, + label = { Text("32 Hex-Zeichen") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Button( + onClick = { + store.setVictronKey(keyInput, device.id) + bluetooth.refreshConfiguration() + }, + enabled = keyInput.filter { !it.isWhitespace() }.length == 32, + ) { + Text("Schlüssel speichern") + } + Text( + "In VictronConnect: Gerät öffnen → Zahnrad → ⋮ → Produkt-Info → " + + "„Instant Readout“ einschalten → Verschlüsselungsdaten anzeigen. " + + "Der Schlüssel ist 16 Byte lang (32 Hex-Zeichen).", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // Das erste Byte sendet das Gerät unverschlüsselt mit. Stimmt es + // nicht mit dem eingetragenen überein, gehört der Schlüssel zu + // einem anderen Victron-Gerät – der häufigste Fehler überhaupt. + if (expected != null) { + Text("Erstes Schlüsselbyte", style = MaterialTheme.typography.titleSmall) + Row(modifier = Modifier.fillMaxWidth()) { + Text("Gerät sendet", modifier = Modifier.weight(1f)) + Text( + "0x%02X".format(expected), + fontFamily = FontFamily.Monospace, + color = if (agree == false) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + } + Row(modifier = Modifier.fillMaxWidth()) { + Text("Eingetragen", modifier = Modifier.weight(1f)) + Text( + entered?.let { "0x%02X".format(it) } ?: "–", + fontFamily = FontFamily.Monospace, + color = if (agree == false) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + Text( + if (agree == false) { + "Die beiden Bytes müssen übereinstimmen. Tun sie das nicht, " + + "stammt der Schlüssel von einem anderen Victron-Gerät – in " + + "VictronConnect prüfen, ob wirklich dieses Gerät geöffnet war." + } else { + "Zum Vergleichen: dieses Byte sendet das Gerät unverschlüsselt mit." + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} diff --git a/Android/app/src/main/res/drawable-nodpi/vehicle_rear.png b/Android/app/src/main/res/drawable-nodpi/vehicle_rear.png new file mode 100644 index 0000000..a71ab5e Binary files /dev/null and b/Android/app/src/main/res/drawable-nodpi/vehicle_rear.png differ diff --git a/Android/app/src/main/res/drawable-nodpi/vehicle_side.png b/Android/app/src/main/res/drawable-nodpi/vehicle_side.png new file mode 100644 index 0000000..9f1c333 Binary files /dev/null and b/Android/app/src/main/res/drawable-nodpi/vehicle_side.png differ