From 899240c567f0edfa60edfe0f4018b419d0820979 Mon Sep 17 00:00:00 2001 From: BiasF Date: Mon, 31 Aug 2026 11:33:45 +0200 Subject: [PATCH] =?UTF-8?q?Android:=20Oberfl=C3=A4che=20im=20Emulator=20du?= =?UTF-8?q?rchgeklickt=20und=20nachgebessert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vier Dinge, die erst am laufenden Bild auffielen. Die dynamischen Systemfarben ab Android 12 sind wieder raus. In dieser App trägt Farbe Bedeutung - grün heisst eben, orange knapp daneben, rot schief. Eine vom Hintergrundbild abgeleitete Akzentfarbe stand daneben und liess die App je nach Telefon anders aussehen als ihr eigenes Logo. Die Libelle sprengte ihren Rahmen. `aspectRatio` koppelt die Höhe an die Breite und hebelte damit die Deckelung aus; auf grossen Anzeigen schob sie sich über die Umschalter. Jetzt eine feste Höhe - der Kreis wird darin ohnehin passend gezeichnet. Der Ratschlag im Ausrichtungs-Assistenten blieb auf "Warte auf Messwerte" stehen, obwohl daneben schon Werte standen. Compose überspringt einen Aufruf, dessen Argument dieselbe Instanz ist, und der Assistent ist bewusst kein Compose-Zustand. Sein Stand wird jetzt bei jeder Messung in ein Wertobjekt gezogen. Und die Zahlen, die die Ansichten selbst rechnen, standen mit Punkt statt Komma - `String.format` ohne Sprachangabe nimmt die des Systems. Das läuft jetzt über eine gemeinsame Stelle, wie schon bei den Messwerten. Durchgeklickt: Dashboard, Gerätedetails mit Verlauf, Kühlbox-Steuerung, Libelle, Fahrzeugansicht, Einrichtung des Neigungsmessers, Ausrichtungs-Assistent, Einbaulage-Assistent und Fahrzeuge. Die Kühlbox-Anzeige greift dabei auch hier richtig: der Demo-Datensatz trägt den Fühlerplatzhalter, und die Box wird als einzonig geführt. Co-Authored-By: Claude Opus 5 --- .../ui/AlignmentAssistantScreen.kt | 54 ++++++++++++++----- .../campermonitor/ui/DeviceDetailScreen.kt | 45 +++++++++++----- .../de/fritob/campermonitor/ui/Format.kt | 14 +++++ .../campermonitor/ui/LevelSetupScreen.kt | 4 +- .../de/fritob/campermonitor/ui/LevelViews.kt | 15 ++++-- .../fritob/campermonitor/ui/ProfilesScreen.kt | 4 +- .../de/fritob/campermonitor/ui/Theme.kt | 24 ++++----- Android/env.sh | 2 +- 8 files changed, 111 insertions(+), 51 deletions(-) create mode 100644 Android/app/src/main/kotlin/de/fritob/campermonitor/ui/Format.kt diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/AlignmentAssistantScreen.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/AlignmentAssistantScreen.kt index 0679faf..c2c2024 100644 --- a/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/AlignmentAssistantScreen.kt +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/AlignmentAssistantScreen.kt @@ -115,7 +115,22 @@ fun AlignmentAssistantScreen( ) }, ) { padding -> - @Suppress("UNUSED_EXPRESSION") revision // liest den Zähler, damit neu gezeichnet wird + // Der Assistent ist bewusst kein Compose-Zustand - die Protokollschicht + // soll nichts von Compose wissen. Sein Stand wird deshalb bei jeder + // neuen Messung in ein eigenes Wertobjekt gezogen. Ihn direkt + // weiterzureichen genügt nicht: Compose überspringt einen Aufruf, dessen + // Argument dieselbe Instanz ist, und der Ratschlag blieb dann stehen. + val view = remember(revision) { + AssistantView( + advice = assistant.advice, + hasReachedTarget = assistant.hasReachedTarget, + trend = assistant.trend, + deviation = assistant.current?.deviation, + best = assistant.best, + improvementAtBest = assistant.improvementAtBest, + secondsSinceBest = assistant.secondsSinceBest, + ) + } Column( modifier = Modifier .fillMaxSize() @@ -142,17 +157,17 @@ fun AlignmentAssistantScreen( VehicleTiltView(state.pitch, state.roll) } - AdviceBanner(assistant) + AdviceBanner(view) 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)) + Reading("Gesamt", view.deviation, Modifier.weight(1f)) } - val gain = assistant.improvementAtBest - val seconds = assistant.secondsSinceBest - val best = assistant.best + val gain = view.improvementAtBest + val seconds = view.secondsSinceBest + val best = view.best if (gain != null && seconds != null && best != null) { Card(modifier = Modifier.fillMaxWidth()) { Column( @@ -168,9 +183,9 @@ fun AlignmentAssistantScreen( ) } Text( - "Vor %.0f Sekunden stand das Fahrzeug %.1f° flacher (%.1f° statt %.1f°)." - .format(seconds, gain, best.deviation, - assistant.current?.deviation ?: 0.0), + de("Vor %.0f Sekunden stand das Fahrzeug %.1f° flacher (%.1f° statt %.1f°).", + seconds, gain, best.deviation, + view.deviation ?: 0.0), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -222,13 +237,24 @@ private fun DisconnectedBanner() { } } +/** Der Stand des Assistenten als Wert – siehe die Begründung oben. */ +private data class AssistantView( + val advice: String, + val hasReachedTarget: Boolean, + val trend: AlignmentAssistant.Trend, + val deviation: Double?, + val best: AlignmentAssistant.Sample?, + val improvementAtBest: Double?, + val secondsSinceBest: Double?, +) + @Composable -private fun AdviceBanner(assistant: AlignmentAssistant) { - val reached = assistant.hasReachedTarget +private fun AdviceBanner(view: AssistantView) { + val reached = view.hasReachedTarget val icon: ImageVector = if (reached) { Icons.Filled.CheckCircle } else { - when (assistant.trend) { + when (view.trend) { AlignmentAssistant.Trend.IMPROVING -> Icons.Filled.TrendingDown AlignmentAssistant.Trend.WORSENING -> Icons.Filled.TrendingUp AlignmentAssistant.Trend.STEADY -> Icons.Filled.TrendingFlat @@ -255,7 +281,7 @@ private fun AdviceBanner(assistant: AlignmentAssistant) { modifier = Modifier.size(32.dp), ) Text( - assistant.advice, + view.advice, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(start = 12.dp), ) @@ -296,7 +322,7 @@ private fun WedgeCard(state: LevelState, trackWidth: Double?, wheelbase: Double? modifier = Modifier.weight(1f), ) Text( - "%.0f cm".format(wedge.heightInCentimetres), + de("%.0f cm", wedge.heightInCentimetres), style = MaterialTheme.typography.titleMedium, ) } diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/DeviceDetailScreen.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/DeviceDetailScreen.kt index 966f526..35c244e 100644 --- a/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/DeviceDetailScreen.kt +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/DeviceDetailScreen.kt @@ -48,6 +48,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.drawscope.Stroke @@ -58,6 +62,7 @@ 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.DeviceRole import de.fritob.campermonitor.protocol.DeviceTransport import de.fritob.campermonitor.protocol.FridgeZoneMode import de.fritob.campermonitor.protocol.LevelState @@ -138,13 +143,13 @@ fun DeviceDetailScreen( // MARK: Steuerung val fridge = bluetooth.fridgeStates[device.id] - if (current.role == de.fritob.campermonitor.protocol.DeviceRole.FRIDGE && + if (current.role == DeviceRole.FRIDGE && fridge != null && fridge.hasStatus ) { FridgeControls(current, fridge, bluetooth) } - if (current.role == de.fritob.campermonitor.protocol.DeviceRole.LEVELING) { + if (current.role == DeviceRole.LEVELING) { LevelSection( device = current, state = bluetooth.levelStates[device.id] ?: LevelState(), @@ -183,7 +188,7 @@ fun DeviceDetailScreen( if (snapshot != null && snapshot.cellVoltages.isNotEmpty()) { SectionHeader("Zellspannungen") snapshot.cellVoltages.forEachIndexed { index, voltage -> - LabeledRow("Zelle ${index + 1}") { Text("%.3f V".format(voltage)) } + LabeledRow("Zelle ${index + 1}") { Text(de("%.3f V", voltage)) } } } @@ -195,7 +200,7 @@ fun DeviceDetailScreen( if (snapshot != null && snapshot.temperatures.size > 1) { SectionHeader("Temperaturen") snapshot.temperatures.forEachIndexed { index, value -> - LabeledRow("Fühler ${index + 1}") { Text("%.0f °C".format(value)) } + LabeledRow("Fühler ${index + 1}") { Text(de("%.0f °C", value)) } } } @@ -214,7 +219,7 @@ fun DeviceDetailScreen( SectionHeader("Einstellungen") NameField(current, store) LabeledRow("Typ") { Text(current.role.title) } - if (current.role == de.fritob.campermonitor.protocol.DeviceRole.FRIDGE) { + if (current.role == DeviceRole.FRIDGE) { ZoneModePicker(current, store, bluetooth) } if (current.role.transport == DeviceTransport.ADVERTISEMENT) { @@ -347,17 +352,29 @@ private fun KeyPrompt(onOpenKey: () -> Unit) { @Composable private fun NameField(device: ConfiguredDevice, store: DeviceStore) { var name by remember(device.id) { mutableStateOf(device.name) } + + fun save() { + // Ein leeres Feld beim Tippen darf den Namen nicht löschen. + val trimmed = name.trim() + if (trimmed.isNotEmpty() && trimmed != device.name) { + store.update(device.copy(name = trimmed)) + } + } + 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)) - }, + onValueChange = { name = it }, label = { Text("Name") }, singleLine = true, - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { save() }), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + // Beim Abschluss der Eingabe und beim Verlassen des Feldes + // gesichert - bei jedem Tastendruck zu speichern hiesse, die ganze + // Geräteliste je Zeichen neu zu schreiben. + .onFocusChanged { if (!it.isFocused) save() }, ) } @@ -443,13 +460,13 @@ private fun HistoryChart(values: List, unit: String) { } Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { Text( - "%.2f %s".format(lowest, unit), + de("%.2f %s", lowest, unit), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f), ) Text( - "%.2f %s".format(highest, unit), + de("%.2f %s", highest, unit), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/Format.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/Format.kt new file mode 100644 index 0000000..2c1cd92 --- /dev/null +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/Format.kt @@ -0,0 +1,14 @@ +package de.fritob.campermonitor.ui + +import java.util.Locale + +/** + * Zahlen mit deutschem Trennzeichen. + * + * `String.format` ohne Sprachangabe nimmt die des Systems – dann steht in + * einer sonst deutschen Oberfläche plötzlich "1.58" statt "1,58", je nach + * Telefon verschieden. Die Messwerte selbst formatiert die Protokollschicht + * bereits so; hier gilt dasselbe für alles, was die Ansichten selbst rechnen. + */ +fun de(format: String, vararg args: Any?): String = + String.format(Locale.GERMANY, format, *args) diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/LevelSetupScreen.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/LevelSetupScreen.kt index 9513084..5a845ea 100644 --- a/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/LevelSetupScreen.kt +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/LevelSetupScreen.kt @@ -167,8 +167,8 @@ 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." + return de("Der Nullpunkt liegt bei %.1f° längs und %.1f° quer. ", pitchOffset, rollOffset) + + "Zum Neusetzen das Fahrzeug eben stellen und dann tippen." } if (state.isKnownUncalibrated) { return "Noch kein Nullpunkt gesetzt – die Anzeige zeigt die Lage des Sensors, " + diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/LevelViews.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/LevelViews.kt index ecd8795..6fa050e 100644 --- a/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/LevelViews.kt +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/LevelViews.kt @@ -6,7 +6,6 @@ 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 @@ -56,7 +55,15 @@ fun LevelBubble(pitch: Double?, roll: Double?, range: Double = 6.0) { val outline = MaterialTheme.colorScheme.outlineVariant val fill = MaterialTheme.colorScheme.surfaceVariant - Canvas(modifier = Modifier.fillMaxWidth().aspectRatio(1f)) { + Canvas( + // Feste Höhe statt `aspectRatio`: das würde die Höhe wieder an die + // Breite koppeln und die Deckelung aushebeln - die Libelle wuchs dann + // über ihren Platz hinaus und schob sich über die Umschalter. + // Der Kreis wird darin ohnehin mittig und passend gezeichnet. + modifier = Modifier + .fillMaxWidth() + .height(260.dp) + ) { val side = minOf(size.width, size.height) val radius = side / 2 val bubble = side * 0.16f @@ -156,7 +163,7 @@ private fun TiltedVehicle( Row(modifier = Modifier.fillMaxWidth()) { Text(title, style = MaterialTheme.typography.titleSmall, modifier = Modifier.weight(1f)) Text( - value?.let { "%.1f°".format(it) } ?: "–", + value?.let { de("%.1f°", it) } ?: "–", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, color = tint, @@ -197,7 +204,7 @@ fun Reading(title: String, value: Double?, modifier: Modifier = Modifier) { horizontalAlignment = Alignment.CenterHorizontally, ) { Text( - value?.let { "%.1f°".format(it) } ?: "–", + value?.let { de("%.1f°", it) } ?: "–", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, ) diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/ProfilesScreen.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/ProfilesScreen.kt index 24ecc88..afa638a 100644 --- a/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/ProfilesScreen.kt +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/ProfilesScreen.kt @@ -158,8 +158,8 @@ private fun measuresText(profile: Profile): String { 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)) } + track?.let { add(de("Spur %.2f m", it)) } + base?.let { add(de("Radstand %.2f m", it)) } } return " · " + parts.joinToString(", ") } diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/Theme.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/Theme.kt index 81a18a1..10d339e 100644 --- a/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/Theme.kt +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/ui/Theme.kt @@ -1,20 +1,20 @@ 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. + * bleiben. + * + * Bewusst **ohne** die dynamischen Systemfarben ab Android 12. In dieser App + * trägt Farbe Bedeutung: grün heisst eben, orange knapp daneben, rot schief. + * Eine vom Hintergrundbild abgeleitete Akzentfarbe stünde daneben und liesse + * die App je nach Telefon anders aussehen als ihr eigenes Logo. */ private val CamperGreen = Color(0xFF1F9E52) @@ -33,12 +33,8 @@ 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) + MaterialTheme( + colorScheme = if (darkTheme) DarkColors else LightColors, + content = content, + ) } diff --git a/Android/env.sh b/Android/env.sh index 836f4cd..514168e 100644 --- a/Android/env.sh +++ b/Android/env.sh @@ -3,4 +3,4 @@ export JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home export ANDROID_HOME="$HOME/Library/Android/sdk" export ANDROID_SDK_ROOT="$ANDROID_HOME" -export PATH="$JAVA_HOME/bin:/opt/homebrew/share/android-commandlinetools/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$PATH" +export PATH="$JAVA_HOME/bin:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$PATH"