Android: Oberfläche

Alle Ansichten der iOS-Fassung in Jetpack Compose: Dashboard mit Kacheln,
Gerätedetails samt Messwerten, Verlauf, Zellspannungen und Diagnose,
Kühlbox-Steuerung, Nivellierung mit Libelle und Fahrzeugansicht,
Ausrichtungs-Assistent, die beiden Einrichtungsassistenten, Fahrzeuge,
Gerät hinzufügen, Schlüsseleingabe und Einstellungen.

Mitgenommen ist auch, was wir uns in der Oberfläche erarbeitet haben. Die
Geräteliste beim Einrichten sortiert nach Fundreihenfolge, nicht nach
Signalstärke - danach zu sortieren macht sie unbenutzbar, weil sie
sekündlich springt. Empfangspegel und Alter der Werte erscheinen nur mit
eingeblendeter Diagnose. Der Victron-Schlüssel liegt auf einer eigenen
Seite und meldet sich nur, wenn er fehlt oder nicht passt. Einbaulage und
Nullpunkt liegen eine Ebene tiefer, damit ein Fehlgriff beim Ablesen nicht
den Nullpunkt verstellt. Der Ausrichtungs-Assistent verlangt eine stehende
Verbindung und sagt es, wenn sie abreisst.

Drei Dinge sind auf Android anders:

Die Berechtigungsabfrage ist eigenständige Arbeit. Bis Android 11 lief ein
BLE-Scan über die Standortfreigabe, seither gibt es eigene
Bluetooth-Rechte; beide Wege werden bedient, und die App erklärt vorher,
wozu sie fragt.

Die Fahrzeugauswahl sitzt als Titel mit Aufklappen in der Leiste statt als
runder Knopf in der Ecke - so ist es hier üblich.

Die Symbole sind Material-Entsprechungen der SF-Symbole. Ausgewählt ist
jeweils das, was dieselbe Sache meint, nicht das ähnlichst aussehende.

Der Verlauf ist selbst gezeichnet; für eine Linie mit Fläche lohnt keine
Diagrammbibliothek.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
BiasF
2026-08-31 10:43:32 +02:00
co-authored by Claude Opus 5
parent 866fce9711
commit fcca37bbd0
18 changed files with 3193 additions and 3 deletions
@@ -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 }
@@ -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<Discovery?>(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<Discovery> { 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
}
}
@@ -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,
)
}
}
}
}
}
@@ -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")
}
}
}
@@ -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),
)
}
}
@@ -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<Double>, 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,
)
}
}
}
@@ -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),
)
}
@@ -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
@@ -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."
}
@@ -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,
)
}
}
@@ -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<String>
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")
}
}
}
@@ -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<Profile?>(null) }
var pendingDeletion by remember { mutableStateOf<Profile?>(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") }
}
},
)
}
@@ -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<OrientationDetection.Reading?>(null) }
var result by remember { mutableStateOf<SensorOrientation?>(null) }
var failure by remember { mutableStateOf<OrientationDetection.Failure?>(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))
}
}
@@ -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()
}
}
@@ -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)
}
@@ -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,
)
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB