forked from fritob/Camper-Monitor
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:
@@ -3,12 +3,135 @@ package de.fritob.campermonitor
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.material3.Text
|
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() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
private lateinit var store: DeviceStore
|
||||||
|
private lateinit var bluetooth: BluetoothManager
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
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 |
Reference in New Issue
Block a user