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:
+40
-14
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user