Android: Oberfläche im Emulator durchgeklickt und nachgebessert

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 <noreply@anthropic.com>
This commit is contained in:
BiasF
2026-08-31 11:33:45 +02:00
co-authored by Claude Opus 5
parent 4c16cdd485
commit 899240c567
8 changed files with 111 additions and 51 deletions
@@ -115,7 +115,22 @@ fun AlignmentAssistantScreen(
) )
}, },
) { padding -> ) { 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( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -142,17 +157,17 @@ fun AlignmentAssistantScreen(
VehicleTiltView(state.pitch, state.roll) VehicleTiltView(state.pitch, state.roll)
} }
AdviceBanner(assistant) AdviceBanner(view)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
Reading("Längs", state.pitch, Modifier.weight(1f)) Reading("Längs", state.pitch, Modifier.weight(1f))
Reading("Quer", state.roll, 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 gain = view.improvementAtBest
val seconds = assistant.secondsSinceBest val seconds = view.secondsSinceBest
val best = assistant.best val best = view.best
if (gain != null && seconds != null && best != null) { if (gain != null && seconds != null && best != null) {
Card(modifier = Modifier.fillMaxWidth()) { Card(modifier = Modifier.fillMaxWidth()) {
Column( Column(
@@ -168,9 +183,9 @@ fun AlignmentAssistantScreen(
) )
} }
Text( Text(
"Vor %.0f Sekunden stand das Fahrzeug %.1f° flacher (%.1f° statt %.1f°)." de("Vor %.0f Sekunden stand das Fahrzeug %.1f° flacher (%.1f° statt %.1f°).",
.format(seconds, gain, best.deviation, seconds, gain, best.deviation,
assistant.current?.deviation ?: 0.0), view.deviation ?: 0.0),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, 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 @Composable
private fun AdviceBanner(assistant: AlignmentAssistant) { private fun AdviceBanner(view: AssistantView) {
val reached = assistant.hasReachedTarget val reached = view.hasReachedTarget
val icon: ImageVector = if (reached) { val icon: ImageVector = if (reached) {
Icons.Filled.CheckCircle Icons.Filled.CheckCircle
} else { } else {
when (assistant.trend) { when (view.trend) {
AlignmentAssistant.Trend.IMPROVING -> Icons.Filled.TrendingDown AlignmentAssistant.Trend.IMPROVING -> Icons.Filled.TrendingDown
AlignmentAssistant.Trend.WORSENING -> Icons.Filled.TrendingUp AlignmentAssistant.Trend.WORSENING -> Icons.Filled.TrendingUp
AlignmentAssistant.Trend.STEADY -> Icons.Filled.TrendingFlat AlignmentAssistant.Trend.STEADY -> Icons.Filled.TrendingFlat
@@ -255,7 +281,7 @@ private fun AdviceBanner(assistant: AlignmentAssistant) {
modifier = Modifier.size(32.dp), modifier = Modifier.size(32.dp),
) )
Text( Text(
assistant.advice, view.advice,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(start = 12.dp), modifier = Modifier.padding(start = 12.dp),
) )
@@ -296,7 +322,7 @@ private fun WedgeCard(state: LevelState, trackWidth: Double?, wheelbase: Double?
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
Text( Text(
"%.0f cm".format(wedge.heightInCentimetres), de("%.0f cm", wedge.heightInCentimetres),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
) )
} }
@@ -48,6 +48,10 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.geometry.Offset
import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke 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.bluetooth.BmsDiagnostics
import de.fritob.campermonitor.protocol.ConfiguredDevice import de.fritob.campermonitor.protocol.ConfiguredDevice
import de.fritob.campermonitor.protocol.DeviceLinkState import de.fritob.campermonitor.protocol.DeviceLinkState
import de.fritob.campermonitor.protocol.DeviceRole
import de.fritob.campermonitor.protocol.DeviceTransport import de.fritob.campermonitor.protocol.DeviceTransport
import de.fritob.campermonitor.protocol.FridgeZoneMode import de.fritob.campermonitor.protocol.FridgeZoneMode
import de.fritob.campermonitor.protocol.LevelState import de.fritob.campermonitor.protocol.LevelState
@@ -138,13 +143,13 @@ fun DeviceDetailScreen(
// MARK: Steuerung // MARK: Steuerung
val fridge = bluetooth.fridgeStates[device.id] val fridge = bluetooth.fridgeStates[device.id]
if (current.role == de.fritob.campermonitor.protocol.DeviceRole.FRIDGE && if (current.role == DeviceRole.FRIDGE &&
fridge != null && fridge.hasStatus fridge != null && fridge.hasStatus
) { ) {
FridgeControls(current, fridge, bluetooth) FridgeControls(current, fridge, bluetooth)
} }
if (current.role == de.fritob.campermonitor.protocol.DeviceRole.LEVELING) { if (current.role == DeviceRole.LEVELING) {
LevelSection( LevelSection(
device = current, device = current,
state = bluetooth.levelStates[device.id] ?: LevelState(), state = bluetooth.levelStates[device.id] ?: LevelState(),
@@ -183,7 +188,7 @@ fun DeviceDetailScreen(
if (snapshot != null && snapshot.cellVoltages.isNotEmpty()) { if (snapshot != null && snapshot.cellVoltages.isNotEmpty()) {
SectionHeader("Zellspannungen") SectionHeader("Zellspannungen")
snapshot.cellVoltages.forEachIndexed { index, voltage -> 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) { if (snapshot != null && snapshot.temperatures.size > 1) {
SectionHeader("Temperaturen") SectionHeader("Temperaturen")
snapshot.temperatures.forEachIndexed { index, value -> 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") SectionHeader("Einstellungen")
NameField(current, store) NameField(current, store)
LabeledRow("Typ") { Text(current.role.title) } LabeledRow("Typ") { Text(current.role.title) }
if (current.role == de.fritob.campermonitor.protocol.DeviceRole.FRIDGE) { if (current.role == DeviceRole.FRIDGE) {
ZoneModePicker(current, store, bluetooth) ZoneModePicker(current, store, bluetooth)
} }
if (current.role.transport == DeviceTransport.ADVERTISEMENT) { if (current.role.transport == DeviceTransport.ADVERTISEMENT) {
@@ -347,17 +352,29 @@ private fun KeyPrompt(onOpenKey: () -> Unit) {
@Composable @Composable
private fun NameField(device: ConfiguredDevice, store: DeviceStore) { private fun NameField(device: ConfiguredDevice, store: DeviceStore) {
var name by remember(device.id) { mutableStateOf(device.name) } 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( OutlinedTextField(
value = name, value = name,
onValueChange = { onValueChange = { name = it },
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") }, label = { Text("Name") },
singleLine = true, 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<Double>, unit: String) {
} }
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) {
Text( Text(
"%.2f %s".format(lowest, unit), de("%.2f %s", lowest, unit),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
Text( Text(
"%.2f %s".format(highest, unit), de("%.2f %s", highest, unit),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -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)
@@ -167,8 +167,8 @@ private fun calibrationHint(state: LevelState): String {
val pitchOffset = state.pitchOffset val pitchOffset = state.pitchOffset
val rollOffset = state.rollOffset val rollOffset = state.rollOffset
if (state.isCalibrated && pitchOffset != null && rollOffset != null) { if (state.isCalibrated && pitchOffset != null && rollOffset != null) {
return "Der Nullpunkt liegt bei %.1f° längs und %.1f° quer. Zum Neusetzen das " return de("Der Nullpunkt liegt bei %.1f° längs und %.1f° quer. ", pitchOffset, rollOffset) +
.format(pitchOffset, rollOffset) + "Fahrzeug eben stellen und dann tippen." "Zum Neusetzen das Fahrzeug eben stellen und dann tippen."
} }
if (state.isKnownUncalibrated) { if (state.isKnownUncalibrated) {
return "Noch kein Nullpunkt gesetzt die Anzeige zeigt die Lage des Sensors, " + return "Noch kein Nullpunkt gesetzt die Anzeige zeigt die Lage des Sensors, " +
@@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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 outline = MaterialTheme.colorScheme.outlineVariant
val fill = MaterialTheme.colorScheme.surfaceVariant 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 side = minOf(size.width, size.height)
val radius = side / 2 val radius = side / 2
val bubble = side * 0.16f val bubble = side * 0.16f
@@ -156,7 +163,7 @@ private fun TiltedVehicle(
Row(modifier = Modifier.fillMaxWidth()) { Row(modifier = Modifier.fillMaxWidth()) {
Text(title, style = MaterialTheme.typography.titleSmall, modifier = Modifier.weight(1f)) Text(title, style = MaterialTheme.typography.titleSmall, modifier = Modifier.weight(1f))
Text( Text(
value?.let { "%.1f°".format(it) } ?: "", value?.let { de("%.1f°", it) } ?: "",
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = tint, color = tint,
@@ -197,7 +204,7 @@ fun Reading(title: String, value: Double?, modifier: Modifier = Modifier) {
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Text( Text(
value?.let { "%.1f°".format(it) } ?: "", value?.let { de("%.1f°", it) } ?: "",
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
) )
@@ -158,8 +158,8 @@ private fun measuresText(profile: Profile): String {
val base = profile.wheelbase val base = profile.wheelbase
if (track == null && base == null) return "" if (track == null && base == null) return ""
val parts = buildList { val parts = buildList {
track?.let { add("Spur %.2f m".format(it)) } track?.let { add(de("Spur %.2f m", it)) }
base?.let { add("Radstand %.2f m".format(it)) } base?.let { add(de("Radstand %.2f m", it)) }
} }
return " · " + parts.joinToString(", ") return " · " + parts.joinToString(", ")
} }
@@ -1,20 +1,20 @@
package de.fritob.campermonitor.ui package de.fritob.campermonitor.ui
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color 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 * 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 * bleiben.
* Erwartung. *
* 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) private val CamperGreen = Color(0xFF1F9E52)
@@ -33,12 +33,8 @@ fun CamperTheme(
darkTheme: Boolean = isSystemInDarkTheme(), darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
val context = LocalContext.current MaterialTheme(
val colors = when { colorScheme = if (darkTheme) DarkColors else LightColors,
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> content = content,
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) )
darkTheme -> DarkColors
else -> LightColors
}
MaterialTheme(colorScheme = colors, content = content)
} }
+1 -1
View File
@@ -3,4 +3,4 @@
export JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home export JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home
export ANDROID_HOME="$HOME/Library/Android/sdk" export ANDROID_HOME="$HOME/Library/Android/sdk"
export ANDROID_SDK_ROOT="$ANDROID_HOME" 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"