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"