timklge 1afeaae9e6
All checks were successful
Build / build (push) Successful in 9m54s
Update route progress datasource to show ridden distance, add route remaining datasource (#41)
2025-06-24 09:44:43 +02:00

567 lines
26 KiB
Kotlin

package de.timklge.karoopowerbar.screens
import android.content.Intent
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.clickable
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.Spacer
import androidx.compose.foundation.layout.absolutePadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Build
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
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.res.painterResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.core.content.ContextCompat.startActivity
import androidx.datastore.preferences.core.edit
import androidx.lifecycle.compose.LifecycleResumeEffect
import de.timklge.karoopowerbar.CustomProgressBarBarSize
import de.timklge.karoopowerbar.CustomProgressBarFontSize
import de.timklge.karoopowerbar.KarooPowerbarExtension
import de.timklge.karoopowerbar.PowerbarSettings
import de.timklge.karoopowerbar.R
import de.timklge.karoopowerbar.dataStore
import de.timklge.karoopowerbar.settingsKey
import de.timklge.karoopowerbar.streamSettings
import de.timklge.karoopowerbar.streamUserProfile
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.UserProfile
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlin.math.roundToInt
enum class SelectedSource(val id: String, val label: String) {
NONE("none", "None"),
HEART_RATE("hr", "Heart Rate"),
POWER("power", "Power"),
POWER_3S("power_3s", "Power (3 sec avg)"),
POWER_10S("power_10s", "Power (10 sec avg)"),
SPEED("speed", "Speed"),
SPEED_3S("speed_3s", "Speed (3 sec avg"),
CADENCE("cadence", "Cadence"),
CADENCE_3S("cadence_3s", "Cadence (3 sec avg)"),
ROUTE_PROGRESS("route_progress", "Route Progress"),
REMAINING_ROUTE("route_progress_remaining", "Route Remaining");
fun isPower() = this == POWER || this == POWER_3S || this == POWER_10S
}
@Composable
fun BarSelectDialog(currentSelectedSource: SelectedSource, onHide: () -> Unit, onSelect: (SelectedSource) -> Unit) {
Dialog(onDismissRequest = { onHide() }) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(10.dp),
shape = RoundedCornerShape(10.dp),
) {
Column(modifier = Modifier
.padding(5.dp)
.verticalScroll(rememberScrollState())
.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(10.dp)) {
SelectedSource.entries.forEach { pattern ->
Row(modifier = Modifier
.fillMaxWidth()
.clickable {
onSelect(pattern)
}, verticalAlignment = Alignment.CenterVertically) {
RadioButton(selected = currentSelectedSource == pattern, onClick = {
onSelect(pattern)
})
Text(
text = pattern.label,
modifier = Modifier.padding(start = 10.dp)
)
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen(onFinish: () -> Unit) {
var karooConnected by remember { mutableStateOf(false) }
val ctx = LocalContext.current
val coroutineScope = rememberCoroutineScope()
val karooSystem = remember { KarooSystemService(ctx) }
var bottomSelectedSource by remember { mutableStateOf(SelectedSource.POWER) }
var topSelectedSource by remember { mutableStateOf(SelectedSource.NONE) }
var bottomBarDialogVisible by remember { mutableStateOf(false) }
var topBarDialogVisible by remember { mutableStateOf(false) }
var showAlerts by remember { mutableStateOf(false) }
var givenPermissions by remember { mutableStateOf(false) }
var onlyShowWhileRiding by remember { mutableStateOf(false) }
var colorBasedOnZones by remember { mutableStateOf(false) }
var showLabelOnBars by remember { mutableStateOf(true) }
var barBarSize by remember { mutableStateOf(CustomProgressBarBarSize.MEDIUM) }
var barFontSize by remember { mutableStateOf(CustomProgressBarFontSize.MEDIUM) }
var barBackground by remember { mutableStateOf(false) }
var minCadence by remember { mutableStateOf("0") }
var maxCadence by remember { mutableStateOf("0") }
var minSpeed by remember { mutableStateOf("0") }
var maxSpeed by remember { mutableStateOf("0") }
var isImperial by remember { mutableStateOf(false) }
var customMinPower by remember { mutableStateOf("") }
var customMaxPower by remember { mutableStateOf("") }
var customMinHr by remember { mutableStateOf("") }
var customMaxHr by remember { mutableStateOf("") }
var useCustomPowerRange by remember { mutableStateOf(false) }
var useCustomHrRange by remember { mutableStateOf(false) }
var profileMaxHr by remember { mutableIntStateOf(0) }
var profileRestHr by remember { mutableIntStateOf(0) }
var profileMinPower 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,
barBackground = barBackground,
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(),
barBarSize = barBarSize,
barFontSize = barFontSize,
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) {
karooSystem.streamUserProfile().distinctUntilChanged().collect { profileData ->
isImperial = profileData.preferredUnit.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL
profileMaxHr = profileData.maxHr
profileRestHr = profileData.restingHr
profileMinPower = profileData.powerZones.first().min
profileMaxPower = profileData.powerZones.last().min + 50
}
}
LaunchedEffect(isImperial) {
givenPermissions = Settings.canDrawOverlays(ctx)
ctx.streamSettings()
.combine(karooSystem.streamUserProfile()) { settings, profile -> settings to profile }
.distinctUntilChanged()
.collect { (settings, profile) ->
bottomSelectedSource = settings.source
topSelectedSource = settings.topBarSource
onlyShowWhileRiding = settings.onlyShowWhileRiding
showLabelOnBars = settings.showLabelOnBars
colorBasedOnZones = settings.useZoneColors
barBarSize = settings.barBarSize
barFontSize = settings.barFontSize
barBackground = settings.barBackground
minCadence = settings.minCadence.toString()
maxCadence = settings.maxCadence.toString()
isImperial = profile.preferredUnit.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL
minSpeed = (if(isImperial) settings.minSpeed * 2.23694f else settings.minSpeed * 3.6f).roundToInt().toString()
maxSpeed = (if(isImperial) settings.maxSpeed * 2.23694f else settings.maxSpeed * 3.6f).roundToInt().toString()
customMinPower = settings.minPower?.toString() ?: ""
customMaxPower = settings.maxPower?.toString() ?: ""
customMinHr = settings.minHr?.toString() ?: ""
customMaxHr = settings.maxHr?.toString() ?: ""
useCustomPowerRange = settings.useCustomPowerRange
useCustomHrRange = settings.useCustomHrRange
}
}
LaunchedEffect(Unit) {
delay(1_000)
showAlerts = true
}
LaunchedEffect(Unit) {
karooSystem.connect { connected ->
karooConnected = connected
}
}
LifecycleResumeEffect(Unit) {
givenPermissions = Settings.canDrawOverlays(ctx)
onPauseOrDispose { }
}
Box(modifier = Modifier.fillMaxSize()){
Column(modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)) {
TopAppBar(title = { Text("Powerbar") })
Column(modifier = Modifier
.padding(5.dp)
.verticalScroll(rememberScrollState())
.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(10.dp)) {
FilledTonalButton(modifier = Modifier
.fillMaxWidth()
.height(60.dp),
onClick = {
topBarDialogVisible = true
}) {
Icon(Icons.Default.Build, contentDescription = "Select", modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.width(5.dp))
Text("Top Bar: ${topSelectedSource.label}", modifier = Modifier.weight(1.0f))
}
if (topBarDialogVisible){
BarSelectDialog(topSelectedSource, onHide = { topBarDialogVisible = false }, onSelect = { selected ->
topSelectedSource = selected
coroutineScope.launch { updateSettings() }
topBarDialogVisible = false
})
}
FilledTonalButton(modifier = Modifier
.fillMaxWidth()
.height(60.dp),
onClick = {
bottomBarDialogVisible = true
}) {
Icon(Icons.Default.Build, contentDescription = "Select", modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.width(5.dp))
Text("Bottom Bar: ${bottomSelectedSource.label}", modifier = Modifier.weight(1.0f))
}
if (bottomBarDialogVisible){
BarSelectDialog(bottomSelectedSource, onHide = { bottomBarDialogVisible = false }, onSelect = { selected ->
bottomSelectedSource = selected
coroutineScope.launch { updateSettings() }
bottomBarDialogVisible = false
})
}
apply {
val dropdownOptions = CustomProgressBarBarSize.entries.toList().map { unit -> DropdownOption(unit.id, unit.label) }
val dropdownInitialSelection by remember(barBarSize) {
mutableStateOf(dropdownOptions.find { option -> option.id == barBarSize.id }!!)
}
Dropdown(label = "Bar Size", options = dropdownOptions, selected = dropdownInitialSelection) { selectedOption ->
barBarSize = CustomProgressBarBarSize.entries.find { unit -> unit.id == selectedOption.id }!!
coroutineScope.launch { updateSettings() }
}
}
apply {
val dropdownOptions = CustomProgressBarFontSize.entries.toList().map { unit -> DropdownOption(unit.id, unit.label) }
val dropdownInitialSelection by remember(barFontSize) {
mutableStateOf(dropdownOptions.find { option -> option.id == barFontSize.id }!!)
}
Dropdown(label = "Text Size", options = dropdownOptions, selected = dropdownInitialSelection) { selectedOption ->
barFontSize = CustomProgressBarFontSize.entries.find { unit -> unit.id == selectedOption.id }!!
coroutineScope.launch { updateSettings() }
}
}
if (topSelectedSource == SelectedSource.SPEED || topSelectedSource == SelectedSource.SPEED_3S ||
bottomSelectedSource == SelectedSource.SPEED || bottomSelectedSource == SelectedSource.SPEED_3S){
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(value = minSpeed, modifier = Modifier
.weight(1f)
.absolutePadding(right = 2.dp)
.onFocusEvent(::updateFocus),
onValueChange = { minSpeed = it },
label = { Text("Min Speed") },
suffix = { Text(if (isImperial) "mph" else "kph") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true
)
OutlinedTextField(value = maxSpeed, modifier = Modifier
.weight(1f)
.absolutePadding(left = 2.dp)
.onFocusEvent(::updateFocus),
onValueChange = { maxSpeed = it },
label = { Text("Max Speed") },
suffix = { Text(if (isImperial) "mph" else "kph") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true
)
}
}
if (topSelectedSource.isPower() || bottomSelectedSource.isPower()){
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = useCustomPowerRange, onCheckedChange = {
useCustomPowerRange = it
coroutineScope.launch { updateSettings() }
})
Spacer(modifier = Modifier.width(10.dp))
Text("Use custom power range")
}
if(useCustomPowerRange){
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(value = customMinPower, modifier = Modifier
.weight(1f)
.absolutePadding(right = 2.dp)
.onFocusEvent(::updateFocus),
onValueChange = { customMinPower = it },
label = { Text("Min Power", fontSize = 12.sp) },
suffix = { Text("W") },
placeholder = { Text("$profileMinPower") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true
)
OutlinedTextField(value = customMaxPower, modifier = Modifier
.weight(1f)
.absolutePadding(left = 2.dp)
.onFocusEvent(::updateFocus),
onValueChange = { customMaxPower = it },
label = { Text("Max Power", fontSize = 12.sp) },
suffix = { Text("W") },
placeholder = { Text("$profileMaxPower") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true
)
}
}
}
if (topSelectedSource == SelectedSource.HEART_RATE || bottomSelectedSource == SelectedSource.HEART_RATE){
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = useCustomHrRange, onCheckedChange = {
useCustomHrRange = it
coroutineScope.launch { updateSettings() }
})
Spacer(modifier = Modifier.width(10.dp))
Text("Use custom HR range")
}
if (useCustomHrRange){
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(value = customMinHr, modifier = Modifier
.weight(1f)
.absolutePadding(right = 2.dp)
.onFocusEvent(::updateFocus),
onValueChange = { customMinHr = it },
label = { Text("Min Hr") },
suffix = { Text("bpm") },
placeholder = { if(profileRestHr > 0) Text("$profileRestHr") else Unit },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true
)
OutlinedTextField(value = customMaxHr, modifier = Modifier
.weight(1f)
.absolutePadding(left = 2.dp)
.onFocusEvent(::updateFocus),
onValueChange = { customMaxHr = it },
label = { Text("Max Hr") },
suffix = { Text("bpm") },
placeholder = { if(profileMaxHr > 0) Text("$profileMaxHr") else Unit },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true
)
}
}
}
if (bottomSelectedSource == SelectedSource.CADENCE || topSelectedSource == SelectedSource.CADENCE ||
bottomSelectedSource == SelectedSource.CADENCE_3S || topSelectedSource == SelectedSource.CADENCE_3S){
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(value = minCadence, modifier = Modifier
.weight(1f)
.absolutePadding(right = 2.dp)
.onFocusEvent(::updateFocus),
onValueChange = { minCadence = it },
label = { Text("Min Cadence") },
suffix = { Text("rpm") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true
)
OutlinedTextField(value = maxCadence, modifier = Modifier
.weight(1f)
.absolutePadding(left = 2.dp)
.onFocusEvent(::updateFocus),
onValueChange = { maxCadence = it },
label = { Text("Min Cadence") },
suffix = { Text("rpm") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true
)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = colorBasedOnZones, onCheckedChange = {
colorBasedOnZones = it
coroutineScope.launch { updateSettings() }
})
Spacer(modifier = Modifier.width(10.dp))
Text("Color based on HR / power zones")
}
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = showLabelOnBars, onCheckedChange = {
showLabelOnBars = it
coroutineScope.launch { updateSettings() }
})
Spacer(modifier = Modifier.width(10.dp))
Text("Show value on bars")
}
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = barBackground, onCheckedChange = {
barBackground = it
coroutineScope.launch { updateSettings() }
})
Spacer(modifier = Modifier.width(10.dp))
Text("Solid background")
}
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = onlyShowWhileRiding, onCheckedChange = {
onlyShowWhileRiding = it
coroutineScope.launch { updateSettings() }
})
Spacer(modifier = Modifier.width(10.dp))
Text("Only show while riding")
}
Spacer(modifier = Modifier.padding(30.dp))
if (showAlerts){
if(!karooConnected){
Text(modifier = Modifier.padding(5.dp), text = "Could not read device status. Is your Karoo updated?")
}
if (!givenPermissions) {
Text(modifier = Modifier.padding(5.dp), text = "You have not given permissions to show the power bar overlay. Please do so.")
FilledTonalButton(modifier = Modifier
.fillMaxWidth()
.height(50.dp), onClick = {
val myIntent = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION)
startActivity(ctx, myIntent, null)
}) {
Icon(Icons.Default.Build, contentDescription = "Give permission")
Spacer(modifier = Modifier.width(5.dp))
Text("Give permission")
}
}
}
}
}
Image(
painter = painterResource(id = R.drawable.back),
contentDescription = "Back",
modifier = Modifier
.align(Alignment.BottomStart)
.padding(bottom = 10.dp)
.size(54.dp)
.clickable {
onFinish()
}
)
}
DisposableEffect(Unit) {
onDispose {
runBlocking {
updateSettings()
}
}
}
DisposableEffect(Unit) {
onDispose {
karooSystem.disconnect()
}
}
BackHandler {
coroutineScope.launch {
updateSettings()
onFinish()
}
}
}