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 ->
@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,
)
}
@@ -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<Double>, 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,
)
@@ -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 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, " +
@@ -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,
)
@@ -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(", ")
}
@@ -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,
)
}
+1 -1
View File
@@ -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"