Add touchable back button, autosave (#23)

This commit is contained in:
timklge 2025-03-06 22:40:15 +01:00 committed by GitHub
parent 116677e11e
commit c66162659b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 274 additions and 206 deletions

View File

@ -20,7 +20,7 @@ class MainActivity : ComponentActivity() {
setContent { setContent {
AppTheme { AppTheme {
MainScreen() MainScreen(::finish)
} }
} }

View File

@ -38,12 +38,6 @@ data class PowerbarSettings(
} }
} }
suspend fun saveSettings(context: Context, settings: PowerbarSettings) {
context.dataStore.edit { t ->
t[settingsKey] = Json.encodeToString(settings)
}
}
fun Context.streamSettings(): Flow<PowerbarSettings> { fun Context.streamSettings(): Flow<PowerbarSettings> {
return dataStore.data.map { settingsJson -> return dataStore.data.map { settingsJson ->
try { try {

View File

@ -2,8 +2,13 @@ package de.timklge.karoopowerbar.screens
import android.content.Intent import android.content.Intent
import android.provider.Settings import android.provider.Settings
import android.util.Log
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
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.Spacer import androidx.compose.foundation.layout.Spacer
@ -12,15 +17,13 @@ import androidx.compose.foundation.layout.fillMaxSize
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
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Build import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Done
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@ -30,6 +33,7 @@ import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
@ -39,15 +43,22 @@ import androidx.compose.runtime.rememberCoroutineScope
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.ui.focus.FocusState
import androidx.compose.ui.focus.onFocusEvent
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat.startActivity import androidx.core.content.ContextCompat.startActivity
import androidx.datastore.preferences.core.edit
import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.LifecycleResumeEffect
import de.timklge.karoopowerbar.CustomProgressBarSize import de.timklge.karoopowerbar.CustomProgressBarSize
import de.timklge.karoopowerbar.KarooPowerbarExtension
import de.timklge.karoopowerbar.PowerbarSettings import de.timklge.karoopowerbar.PowerbarSettings
import de.timklge.karoopowerbar.saveSettings import de.timklge.karoopowerbar.R
import de.timklge.karoopowerbar.dataStore
import de.timklge.karoopowerbar.settingsKey
import de.timklge.karoopowerbar.streamSettings import de.timklge.karoopowerbar.streamSettings
import de.timklge.karoopowerbar.streamUserProfile import de.timklge.karoopowerbar.streamUserProfile
import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.KarooSystemService
@ -56,6 +67,9 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlin.math.roundToInt import kotlin.math.roundToInt
enum class SelectedSource(val id: String, val label: String) { enum class SelectedSource(val id: String, val label: String) {
@ -74,7 +88,7 @@ enum class SelectedSource(val id: String, val label: String) {
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MainScreen() { fun MainScreen(onFinish: () -> Unit) {
var karooConnected by remember { mutableStateOf(false) } var karooConnected by remember { mutableStateOf(false) }
val ctx = LocalContext.current val ctx = LocalContext.current
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
@ -83,7 +97,6 @@ fun MainScreen() {
var bottomSelectedSource by remember { mutableStateOf(SelectedSource.POWER) } var bottomSelectedSource by remember { mutableStateOf(SelectedSource.POWER) }
var topSelectedSource by remember { mutableStateOf(SelectedSource.NONE) } var topSelectedSource by remember { mutableStateOf(SelectedSource.NONE) }
var savedDialogVisible by remember { mutableStateOf(false) }
var showAlerts by remember { mutableStateOf(false) } var showAlerts by remember { mutableStateOf(false) }
var givenPermissions by remember { mutableStateOf(false) } var givenPermissions by remember { mutableStateOf(false) }
@ -109,6 +122,46 @@ fun MainScreen() {
var profileMinPower by remember { mutableIntStateOf(0) } var profileMinPower by remember { mutableIntStateOf(0) }
var profileMaxPower by remember { mutableIntStateOf(0) } var profileMaxPower by remember { mutableIntStateOf(0) }
var anyFieldHasFocus by remember { mutableStateOf(false) }
suspend fun updateSettings(){
Log.d(KarooPowerbarExtension.TAG, "Saving settings")
val minSpeedSetting = (minSpeed.toIntOrNull()?.toFloat()?.div((if(isImperial) 2.23694f else 3.6f))) ?: PowerbarSettings.defaultMinSpeedMs
val maxSpeedSetting = (maxSpeed.toIntOrNull()?.toFloat()?.div((if(isImperial) 2.23694f else 3.6f))) ?: PowerbarSettings.defaultMaxSpeedMs
val newSettings = PowerbarSettings(
source = bottomSelectedSource, topBarSource = topSelectedSource,
onlyShowWhileRiding = onlyShowWhileRiding, showLabelOnBars = showLabelOnBars,
useZoneColors = colorBasedOnZones,
minCadence = minCadence.toIntOrNull() ?: PowerbarSettings.defaultMinCadence,
maxCadence = maxCadence.toIntOrNull() ?: PowerbarSettings.defaultMaxCadence,
minSpeed = minSpeedSetting, maxSpeed = maxSpeedSetting,
minPower = customMinPower.toIntOrNull(),
maxPower = customMaxPower.toIntOrNull(),
minHr = customMinHr.toIntOrNull(),
maxHr = customMaxHr.toIntOrNull(),
barSize = barSize,
useCustomPowerRange = useCustomPowerRange,
useCustomHrRange = useCustomHrRange,
)
ctx.dataStore.edit { t ->
t[settingsKey] = Json.encodeToString(newSettings)
}
}
fun updateFocus(focusState: FocusState){
val fieldGotFocus = focusState.isFocused
// Only save settings when truly losing focus (not because another field gained focus)
if (!fieldGotFocus && anyFieldHasFocus) {
anyFieldHasFocus = false
coroutineScope.launch { updateSettings() }
} else if (fieldGotFocus) {
anyFieldHasFocus = true
}
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
karooSystem.streamUserProfile().distinctUntilChanged().collect { profileData -> karooSystem.streamUserProfile().distinctUntilChanged().collect { profileData ->
isImperial = profileData.preferredUnit.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL isImperial = profileData.preferredUnit.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL
@ -118,7 +171,6 @@ fun MainScreen() {
profileMaxPower = profileData.powerZones.last().min + 50 profileMaxPower = profileData.powerZones.last().min + 50
} }
} }
LaunchedEffect(isImperial) { LaunchedEffect(isImperial) {
givenPermissions = Settings.canDrawOverlays(ctx) givenPermissions = Settings.canDrawOverlays(ctx)
@ -164,6 +216,7 @@ fun MainScreen() {
} }
Box(modifier = Modifier.fillMaxSize()){
Column(modifier = Modifier Column(modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.background)) { .background(MaterialTheme.colorScheme.background)) {
@ -180,6 +233,7 @@ fun MainScreen() {
} }
Dropdown(label = "Bottom Bar", options = dropdownOptions, selected = dropdownInitialSelection) { selectedOption -> Dropdown(label = "Bottom Bar", options = dropdownOptions, selected = dropdownInitialSelection) { selectedOption ->
bottomSelectedSource = SelectedSource.entries.find { unit -> unit.id == selectedOption.id }!! bottomSelectedSource = SelectedSource.entries.find { unit -> unit.id == selectedOption.id }!!
coroutineScope.launch { updateSettings() }
} }
} }
@ -190,6 +244,7 @@ fun MainScreen() {
} }
Dropdown(label = "Top Bar", options = dropdownOptions, selected = dropdownInitialSelection) { selectedOption -> Dropdown(label = "Top Bar", options = dropdownOptions, selected = dropdownInitialSelection) { selectedOption ->
topSelectedSource = SelectedSource.entries.find { unit -> unit.id == selectedOption.id }!! topSelectedSource = SelectedSource.entries.find { unit -> unit.id == selectedOption.id }!!
coroutineScope.launch { updateSettings() }
} }
} }
@ -200,6 +255,7 @@ fun MainScreen() {
} }
Dropdown(label = "Bar Size", options = dropdownOptions, selected = dropdownInitialSelection) { selectedOption -> Dropdown(label = "Bar Size", options = dropdownOptions, selected = dropdownInitialSelection) { selectedOption ->
barSize = CustomProgressBarSize.entries.find { unit -> unit.id == selectedOption.id }!! barSize = CustomProgressBarSize.entries.find { unit -> unit.id == selectedOption.id }!!
coroutineScope.launch { updateSettings() }
} }
} }
@ -209,7 +265,8 @@ fun MainScreen() {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(value = minSpeed, modifier = Modifier OutlinedTextField(value = minSpeed, modifier = Modifier
.weight(1f) .weight(1f)
.absolutePadding(right = 2.dp), .absolutePadding(right = 2.dp)
.onFocusEvent(::updateFocus),
onValueChange = { minSpeed = it }, onValueChange = { minSpeed = it },
label = { Text("Min Speed") }, label = { Text("Min Speed") },
suffix = { Text(if (isImperial) "mph" else "kph") }, suffix = { Text(if (isImperial) "mph" else "kph") },
@ -219,7 +276,8 @@ fun MainScreen() {
OutlinedTextField(value = maxSpeed, modifier = Modifier OutlinedTextField(value = maxSpeed, modifier = Modifier
.weight(1f) .weight(1f)
.absolutePadding(left = 2.dp), .absolutePadding(left = 2.dp)
.onFocusEvent(::updateFocus),
onValueChange = { maxSpeed = it }, onValueChange = { maxSpeed = it },
label = { Text("Max Speed") }, label = { Text("Max Speed") },
suffix = { Text(if (isImperial) "mph" else "kph") }, suffix = { Text(if (isImperial) "mph" else "kph") },
@ -231,7 +289,10 @@ fun MainScreen() {
if (topSelectedSource.isPower() || bottomSelectedSource.isPower()){ if (topSelectedSource.isPower() || bottomSelectedSource.isPower()){
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = useCustomPowerRange, onCheckedChange = { useCustomPowerRange = it}) Switch(checked = useCustomPowerRange, onCheckedChange = {
useCustomPowerRange = it
coroutineScope.launch { updateSettings() }
})
Spacer(modifier = Modifier.width(10.dp)) Spacer(modifier = Modifier.width(10.dp))
Text("Use custom power range") Text("Use custom power range")
} }
@ -240,7 +301,8 @@ fun MainScreen() {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(value = customMinPower, modifier = Modifier OutlinedTextField(value = customMinPower, modifier = Modifier
.weight(1f) .weight(1f)
.absolutePadding(right = 2.dp), .absolutePadding(right = 2.dp)
.onFocusEvent(::updateFocus),
onValueChange = { customMinPower = it }, onValueChange = { customMinPower = it },
label = { Text("Min Power", fontSize = 12.sp) }, label = { Text("Min Power", fontSize = 12.sp) },
suffix = { Text("W") }, suffix = { Text("W") },
@ -251,7 +313,8 @@ fun MainScreen() {
OutlinedTextField(value = customMaxPower, modifier = Modifier OutlinedTextField(value = customMaxPower, modifier = Modifier
.weight(1f) .weight(1f)
.absolutePadding(left = 2.dp), .absolutePadding(left = 2.dp)
.onFocusEvent(::updateFocus),
onValueChange = { customMaxPower = it }, onValueChange = { customMaxPower = it },
label = { Text("Max Power", fontSize = 12.sp) }, label = { Text("Max Power", fontSize = 12.sp) },
suffix = { Text("W") }, suffix = { Text("W") },
@ -265,7 +328,10 @@ fun MainScreen() {
if (topSelectedSource == SelectedSource.HEART_RATE || bottomSelectedSource == SelectedSource.HEART_RATE){ if (topSelectedSource == SelectedSource.HEART_RATE || bottomSelectedSource == SelectedSource.HEART_RATE){
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = useCustomHrRange, onCheckedChange = { useCustomHrRange = it}) Switch(checked = useCustomHrRange, onCheckedChange = {
useCustomHrRange = it
coroutineScope.launch { updateSettings() }
})
Spacer(modifier = Modifier.width(10.dp)) Spacer(modifier = Modifier.width(10.dp))
Text("Use custom HR range") Text("Use custom HR range")
} }
@ -274,7 +340,8 @@ fun MainScreen() {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(value = customMinHr, modifier = Modifier OutlinedTextField(value = customMinHr, modifier = Modifier
.weight(1f) .weight(1f)
.absolutePadding(right = 2.dp), .absolutePadding(right = 2.dp)
.onFocusEvent(::updateFocus),
onValueChange = { customMinHr = it }, onValueChange = { customMinHr = it },
label = { Text("Min Hr") }, label = { Text("Min Hr") },
suffix = { Text("bpm") }, suffix = { Text("bpm") },
@ -285,7 +352,8 @@ fun MainScreen() {
OutlinedTextField(value = customMaxHr, modifier = Modifier OutlinedTextField(value = customMaxHr, modifier = Modifier
.weight(1f) .weight(1f)
.absolutePadding(left = 2.dp), .absolutePadding(left = 2.dp)
.onFocusEvent(::updateFocus),
onValueChange = { customMaxHr = it }, onValueChange = { customMaxHr = it },
label = { Text("Max Hr") }, label = { Text("Max Hr") },
suffix = { Text("bpm") }, suffix = { Text("bpm") },
@ -303,7 +371,8 @@ fun MainScreen() {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(value = minCadence, modifier = Modifier OutlinedTextField(value = minCadence, modifier = Modifier
.weight(1f) .weight(1f)
.absolutePadding(right = 2.dp), .absolutePadding(right = 2.dp)
.onFocusEvent(::updateFocus),
onValueChange = { minCadence = it }, onValueChange = { minCadence = it },
label = { Text("Min Cadence") }, label = { Text("Min Cadence") },
suffix = { Text("rpm") }, suffix = { Text("rpm") },
@ -313,7 +382,8 @@ fun MainScreen() {
OutlinedTextField(value = maxCadence, modifier = Modifier OutlinedTextField(value = maxCadence, modifier = Modifier
.weight(1f) .weight(1f)
.absolutePadding(left = 2.dp), .absolutePadding(left = 2.dp)
.onFocusEvent(::updateFocus),
onValueChange = { maxCadence = it }, onValueChange = { maxCadence = it },
label = { Text("Min Cadence") }, label = { Text("Min Cadence") },
suffix = { Text("rpm") }, suffix = { Text("rpm") },
@ -324,54 +394,33 @@ fun MainScreen() {
} }
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = colorBasedOnZones, onCheckedChange = { colorBasedOnZones = it}) Switch(checked = colorBasedOnZones, onCheckedChange = {
colorBasedOnZones = it
coroutineScope.launch { updateSettings() }
})
Spacer(modifier = Modifier.width(10.dp)) Spacer(modifier = Modifier.width(10.dp))
Text("Color based on HR / power zones") Text("Color based on HR / power zones")
} }
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = showLabelOnBars, onCheckedChange = { showLabelOnBars = it}) Switch(checked = showLabelOnBars, onCheckedChange = {
showLabelOnBars = it
coroutineScope.launch { updateSettings() }
})
Spacer(modifier = Modifier.width(10.dp)) Spacer(modifier = Modifier.width(10.dp))
Text("Show value on bars") Text("Show value on bars")
} }
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = onlyShowWhileRiding, onCheckedChange = { onlyShowWhileRiding = it}) Switch(checked = onlyShowWhileRiding, onCheckedChange = {
onlyShowWhileRiding = it
coroutineScope.launch { updateSettings() }
})
Spacer(modifier = Modifier.width(10.dp)) Spacer(modifier = Modifier.width(10.dp))
Text("Only show while riding") Text("Only show while riding")
} }
FilledTonalButton(modifier = Modifier Spacer(modifier = Modifier.padding(30.dp))
.fillMaxWidth()
.height(50.dp), onClick = {
val minSpeedSetting = (minSpeed.toIntOrNull()?.toFloat()?.div((if(isImperial) 2.23694f else 3.6f))) ?: PowerbarSettings.defaultMinSpeedMs
val maxSpeedSetting = (maxSpeed.toIntOrNull()?.toFloat()?.div((if(isImperial) 2.23694f else 3.6f))) ?: PowerbarSettings.defaultMaxSpeedMs
val newSettings = PowerbarSettings(
source = bottomSelectedSource, topBarSource = topSelectedSource,
onlyShowWhileRiding = onlyShowWhileRiding, showLabelOnBars = showLabelOnBars,
useZoneColors = colorBasedOnZones,
minCadence = minCadence.toIntOrNull() ?: PowerbarSettings.defaultMinCadence,
maxCadence = maxCadence.toIntOrNull() ?: PowerbarSettings.defaultMaxCadence,
minSpeed = minSpeedSetting, maxSpeed = maxSpeedSetting,
minPower = customMinPower.toIntOrNull(),
maxPower = customMaxPower.toIntOrNull(),
minHr = customMinHr.toIntOrNull(),
maxHr = customMaxHr.toIntOrNull(),
barSize = barSize,
useCustomPowerRange = useCustomPowerRange,
useCustomHrRange = useCustomHrRange,
)
coroutineScope.launch {
saveSettings(ctx, newSettings)
savedDialogVisible = true
}
}) {
Icon(Icons.Default.Done, contentDescription = "Save")
Spacer(modifier = Modifier.width(5.dp))
Text("Save")
}
if (showAlerts){ if (showAlerts){
if(!karooConnected){ if(!karooConnected){
@ -396,12 +445,37 @@ fun MainScreen() {
} }
} }
if (savedDialogVisible){ Image(
AlertDialog(onDismissRequest = { savedDialogVisible = false }, painter = painterResource(id = R.drawable.back),
confirmButton = { Button(onClick = { contentDescription = "Back",
savedDialogVisible = false modifier = Modifier
}) { Text("OK") } }, .align(Alignment.BottomStart)
text = { Text("Settings saved successfully.") } .padding(bottom = 10.dp)
.size(54.dp)
.clickable {
onFinish()
}
) )
} }
DisposableEffect(Unit) {
onDispose {
runBlocking {
updateSettings()
}
}
}
DisposableEffect(Unit) {
onDispose {
karooSystem.disconnect()
}
}
BackHandler {
coroutineScope.launch {
updateSettings()
onFinish()
}
}
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB