diff --git a/README.md b/README.md index d15c6a4..6c2dc52 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,9 @@ [![GitHub Downloads (specific asset, all releases)](https://img.shields.io/github/downloads/timklge/karoo-powerbar/app-release.apk)](https://github.com/timklge/karoo-powerbar/releases) ![GitHub License](https://img.shields.io/github/license/timklge/karoo-powerbar) -Simple karoo extension that shows an overlay power bar at the edge of the screen. For Karoo 2 and Karoo 3 devices. +Simple karoo extension that shows an overlay power bar at the edge of the screen, comparable to the +dedicated LEDs featured on Wahoo devices. +For Karoo 2 and Karoo 3 devices. ![Powerbar](powerbar0.png) ![Settings](powerbar1.png) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c039496..9bb4db1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -15,8 +15,8 @@ android { applicationId = "de.timklge.karoopowerbar" minSdk = 26 targetSdk = 33 - versionCode = 11 - versionName = "1.3.2" + versionCode = 12 + versionName = "1.3.3" } signingConfigs { diff --git a/app/manifest.json b/app/manifest.json index 6614bf7..552af9c 100644 --- a/app/manifest.json +++ b/app/manifest.json @@ -3,9 +3,9 @@ "packageName": "de.timklge.karoopowerbar", "iconUrl": "https://github.com/timklge/karoo-powerbar/releases/latest/download/karoo-powerbar.png", "latestApkUrl": "https://github.com/timklge/karoo-powerbar/releases/latest/download/app-release.apk", - "latestVersion": "1.3.2", - "latestVersionCode": 11, + "latestVersion": "1.3.3", + "latestVersionCode": 12, "developer": "timklge", "description": "Adds a colored power bar to the bottom of the screen", - "releaseNotes": "Add size setting, cadence and speed data sources with custom ranges" + "releaseNotes": "Adds option to set a custom range for power and heart rate bar" } \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karoopowerbar/CustomProgressBar.kt b/app/src/main/kotlin/de/timklge/karoopowerbar/CustomProgressBar.kt index 718a16a..c610dfc 100644 --- a/app/src/main/kotlin/de/timklge/karoopowerbar/CustomProgressBar.kt +++ b/app/src/main/kotlin/de/timklge/karoopowerbar/CustomProgressBar.kt @@ -23,8 +23,7 @@ enum class CustomProgressBarSize(val id: String, val label: String, val fontSize class CustomProgressBar @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : View(context, attrs) { - var progress: Double = 0.5 - var showValueIfNull: Boolean = false + var progress: Double? = 0.5 var location: PowerbarLocation = PowerbarLocation.BOTTOM var label: String = "" var showLabel: Boolean = true @@ -99,13 +98,13 @@ class CustomProgressBar @JvmOverloads constructor( val rect = RectF( 1f, 15f, - ((canvas.width.toDouble() - 1f) * progress.coerceIn(0.0, 1.0)).toFloat(), + ((canvas.width.toDouble() - 1f) * (progress ?: 0.0).coerceIn(0.0, 1.0)).toFloat(), 15f + size.barHeight ) canvas.drawRect(0f, 15f, canvas.width.toFloat(), 15f + size.barHeight, backgroundPaint) - if (progress > 0.0 || showValueIfNull) { + if (progress != null) { canvas.drawRoundRect(rect, 2f, 2f, blurPaint) canvas.drawRoundRect(rect, 2f, 2f, linePaint) @@ -135,13 +134,13 @@ class CustomProgressBar @JvmOverloads constructor( val rect = RectF( 1f, canvas.height.toFloat() - 1f - size.barHeight, - ((canvas.width.toDouble() - 1f) * progress.coerceIn(0.0, 1.0)).toFloat(), + ((canvas.width.toDouble() - 1f) * (progress ?: 0.0).coerceIn(0.0, 1.0)).toFloat(), canvas.height.toFloat() ) canvas.drawRect(0f, canvas.height.toFloat() - size.barHeight, canvas.width.toFloat(), canvas.height.toFloat(), backgroundPaint) - if (progress > 0.0 || showValueIfNull) { + if (progress != null) { canvas.drawRoundRect(rect, 2f, 2f, blurPaint) canvas.drawRoundRect(rect, 2f, 2f, linePaint) diff --git a/app/src/main/kotlin/de/timklge/karoopowerbar/KarooPowerbarExtension.kt b/app/src/main/kotlin/de/timklge/karoopowerbar/KarooPowerbarExtension.kt index bb33283..912d08a 100644 --- a/app/src/main/kotlin/de/timklge/karoopowerbar/KarooPowerbarExtension.kt +++ b/app/src/main/kotlin/de/timklge/karoopowerbar/KarooPowerbarExtension.kt @@ -10,7 +10,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch -class KarooPowerbarExtension : KarooExtension("karoo-powerbar", "1.3.2") { +class KarooPowerbarExtension : KarooExtension("karoo-powerbar", "1.3.3") { companion object { const val TAG = "karoo-powerbar" diff --git a/app/src/main/kotlin/de/timklge/karoopowerbar/Settings.kt b/app/src/main/kotlin/de/timklge/karoopowerbar/Settings.kt index b10f359..d8ba1c6 100644 --- a/app/src/main/kotlin/de/timklge/karoopowerbar/Settings.kt +++ b/app/src/main/kotlin/de/timklge/karoopowerbar/Settings.kt @@ -25,13 +25,16 @@ data class PowerbarSettings( val minCadence: Int = defaultMinCadence, val maxCadence: Int = defaultMaxCadence, val minSpeed: Float = defaultMinSpeedMs, val maxSpeed: Float = defaultMaxSpeedMs, // 50 km/h in m/s + val minPower: Int? = null, val maxPower: Int? = null, + val minHr: Int? = null, val maxHr: Int? = null, + val useCustomHrRange: Boolean = false, val useCustomPowerRange: Boolean = false ){ companion object { val defaultSettings = Json.encodeToString(PowerbarSettings()) - val defaultMinSpeedMs = 0f - val defaultMaxSpeedMs = 13.89f - val defaultMinCadence = 50 - val defaultMaxCadence = 120 + const val defaultMinSpeedMs = 0f + const val defaultMaxSpeedMs = 13.89f + const val defaultMinCadence = 50 + const val defaultMaxCadence = 120 } } diff --git a/app/src/main/kotlin/de/timklge/karoopowerbar/Window.kt b/app/src/main/kotlin/de/timklge/karoopowerbar/Window.kt index 961e40e..110c12b 100644 --- a/app/src/main/kotlin/de/timklge/karoopowerbar/Window.kt +++ b/app/src/main/kotlin/de/timklge/karoopowerbar/Window.kt @@ -69,7 +69,7 @@ class Window( layoutInflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater rootView = layoutInflater.inflate(R.layout.popup_window, null) powerbar = rootView.findViewById(R.id.progressBar) - powerbar.progress = 0.0 + powerbar.progress = null windowManager = context.getSystemService(WINDOW_SERVICE) as WindowManager val displayMetrics = DisplayMetrics() @@ -119,7 +119,7 @@ class Window( } powerbar.progressColor = context.resources.getColor(R.color.zone7) - powerbar.progress = 0.0 + powerbar.progress = null powerbar.location = powerbarLocation powerbar.showLabel = showLabel powerbar.size = powerbarSize @@ -175,7 +175,6 @@ class Window( val maxSpeed = streamData.settings?.maxSpeed ?: PowerbarSettings.defaultMaxSpeedMs val progress = remap(valueMetersPerSecond, minSpeed.toDouble(), maxSpeed.toDouble(), 0.0, 1.0) - powerbar.showValueIfNull = valueMetersPerSecond != 0.0 @ColorRes val zoneColorRes = Zone.entries[(progress * Zone.entries.size).roundToInt().coerceIn(0.. 0) progress else null powerbar.label = "$value" Log.d(TAG, "Speed: $value min: $minSpeed max: $maxSpeed") } else { powerbar.progressColor = context.getColor(R.color.zone0) - powerbar.progress = 0.0 - powerbar.showValueIfNull = false + powerbar.progress = null powerbar.label = "?" Log.d(TAG, "Speed: Unavailable") @@ -223,20 +221,18 @@ class Window( @ColorRes val zoneColorRes = Zone.entries[(progress * Zone.entries.size).roundToInt().coerceIn(0.. 0) progress else null powerbar.label = "$value" Log.d(TAG, "Cadence: $value min: $minCadence max: $maxCadence") } else { powerbar.progressColor = context.getColor(R.color.zone0) - powerbar.progress = 0.0 - powerbar.showValueIfNull = false + powerbar.progress = null powerbar.label = "?" Log.d(TAG, "Cadence: Unavailable") @@ -261,23 +257,24 @@ class Window( val value = streamData.value?.roundToInt() if (value != null) { - val minHr = streamData.userProfile.restingHr - val maxHr = streamData.userProfile.maxHr - val progress = - remap(value.toDouble(), minHr.toDouble(), maxHr.toDouble(), 0.0, 1.0) + val customMinHr = if (streamData.settings?.useCustomHrRange == true) streamData.settings.minHr else null + val customMaxHr = if (streamData.settings?.useCustomHrRange == true) streamData.settings.maxHr else null + val minHr = customMinHr ?: streamData.userProfile.restingHr + val maxHr = customMaxHr ?: streamData.userProfile.maxHr + val progress = remap(value.toDouble(), minHr.toDouble(), maxHr.toDouble(), 0.0, 1.0) powerbar.progressColor = if (streamData.settings?.useZoneColors == true) { context.getColor(getZone(streamData.userProfile.heartRateZones, value)?.colorResource ?: R.color.zone7) } else { context.getColor(R.color.zone0) } - powerbar.progress = progress + powerbar.progress = if (value > 0) progress else null powerbar.label = "$value" Log.d(TAG, "Hr: $value min: $minHr max: $maxHr") } else { powerbar.progressColor = context.getColor(R.color.zone0) - powerbar.progress = 0.0 + powerbar.progress = null powerbar.label = "?" Log.d(TAG, "Hr: Unavailable") @@ -308,23 +305,24 @@ class Window( val value = streamData.value?.roundToInt() if (value != null) { - val minPower = streamData.userProfile.powerZones.first().min - val maxPower = streamData.userProfile.powerZones.last().min + 50 - val progress = - remap(value.toDouble(), minPower.toDouble(), maxPower.toDouble(), 0.0, 1.0) + val customMinPower = if (streamData.settings?.useCustomPowerRange == true) streamData.settings.minPower else null + val customMaxPower = if (streamData.settings?.useCustomPowerRange == true) streamData.settings.maxPower else null + val minPower = customMinPower ?: streamData.userProfile.powerZones.first().min + val maxPower = customMaxPower ?: (streamData.userProfile.powerZones.last().min + 50) + val progress = remap(value.toDouble(), minPower.toDouble(), maxPower.toDouble(), 0.0, 1.0) powerbar.progressColor = if (streamData.settings?.useZoneColors == true) { context.getColor(getZone(streamData.userProfile.powerZones, value)?.colorResource ?: R.color.zone7) } else { context.getColor(R.color.zone0) } - powerbar.progress = progress + powerbar.progress = if (value > 0) progress else null powerbar.label = "${value}W" Log.d(TAG, "Power: $value min: $minPower max: $maxPower") } else { powerbar.progressColor = context.getColor(R.color.zone0) - powerbar.progress = 0.0 + powerbar.progress = null powerbar.label = "?" Log.d(TAG, "Power: Unavailable") diff --git a/app/src/main/kotlin/de/timklge/karoopowerbar/screens/MainScreen.kt b/app/src/main/kotlin/de/timklge/karoopowerbar/screens/MainScreen.kt index 132acc1..60b0405 100644 --- a/app/src/main/kotlin/de/timklge/karoopowerbar/screens/MainScreen.kt +++ b/app/src/main/kotlin/de/timklge/karoopowerbar/screens/MainScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable 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 @@ -41,6 +42,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat.startActivity import androidx.lifecycle.compose.LifecycleResumeEffect import de.timklge.karoopowerbar.CustomProgressBarSize @@ -65,7 +67,9 @@ enum class SelectedSource(val id: String, val label: String) { SPEED("speed", "Speed"), SPEED_3S("speed_3s", "Speed (3 sec avg"), CADENCE("cadence", "Cadence"), - CADENCE_3S("cadence_3s", "Cadence (3 sec avg)"), + CADENCE_3S("cadence_3s", "Cadence (3 sec avg)"); + + fun isPower() = this == POWER || this == POWER_3S || this == POWER_10S } @OptIn(ExperimentalMaterial3Api::class) @@ -93,10 +97,25 @@ fun MainScreen() { 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) } LaunchedEffect(Unit) { - karooSystem.streamUserProfile().distinctUntilChanged().collect { - isImperial = it.preferredUnit.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL + 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 } } @@ -118,6 +137,12 @@ fun MainScreen() { 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 } } @@ -182,7 +207,9 @@ fun MainScreen() { 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), + OutlinedTextField(value = minSpeed, modifier = Modifier + .weight(1f) + .absolutePadding(right = 2.dp), onValueChange = { minSpeed = it }, label = { Text("Min Speed") }, suffix = { Text(if (isImperial) "mph" else "kph") }, @@ -190,7 +217,9 @@ fun MainScreen() { singleLine = true ) - OutlinedTextField(value = maxSpeed, modifier = Modifier.weight(1f).absolutePadding(left = 2.dp), + OutlinedTextField(value = maxSpeed, modifier = Modifier + .weight(1f) + .absolutePadding(left = 2.dp), onValueChange = { maxSpeed = it }, label = { Text("Max Speed") }, suffix = { Text(if (isImperial) "mph" else "kph") }, @@ -200,11 +229,81 @@ fun MainScreen() { } } + if (topSelectedSource.isPower() || bottomSelectedSource.isPower()){ + Row(verticalAlignment = Alignment.CenterVertically) { + Switch(checked = useCustomPowerRange, onCheckedChange = { useCustomPowerRange = it}) + 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), + 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), + 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}) + 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), + 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), + 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), + OutlinedTextField(value = minCadence, modifier = Modifier + .weight(1f) + .absolutePadding(right = 2.dp), onValueChange = { minCadence = it }, label = { Text("Min Cadence") }, suffix = { Text("rpm") }, @@ -212,7 +311,9 @@ fun MainScreen() { singleLine = true ) - OutlinedTextField(value = maxCadence, modifier = Modifier.weight(1f).absolutePadding(left = 2.dp), + OutlinedTextField(value = maxCadence, modifier = Modifier + .weight(1f) + .absolutePadding(left = 2.dp), onValueChange = { maxCadence = it }, label = { Text("Min Cadence") }, suffix = { Text("rpm") }, @@ -253,7 +354,13 @@ fun MainScreen() { minCadence = minCadence.toIntOrNull() ?: PowerbarSettings.defaultMinCadence, maxCadence = maxCadence.toIntOrNull() ?: PowerbarSettings.defaultMaxCadence, minSpeed = minSpeedSetting, maxSpeed = maxSpeedSetting, - barSize = barSize + minPower = customMinPower.toIntOrNull(), + maxPower = customMaxPower.toIntOrNull(), + minHr = customMinHr.toIntOrNull(), + maxHr = customMaxHr.toIntOrNull(), + barSize = barSize, + useCustomPowerRange = useCustomPowerRange, + useCustomHrRange = useCustomHrRange, ) coroutineScope.launch { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7ac9548..169a7a7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,7 +9,6 @@ androidxLifecycle = "2.8.6" androidxActivity = "1.9.3" androidxComposeUi = "1.7.4" androidxComposeMaterial = "1.3.0" -glance = "1.1.1" kotlinxSerializationJson = "1.7.3" lifecycleRuntimeKtx = "2.8.7" navigationRuntimeKtx = "2.8.4" @@ -25,7 +24,7 @@ compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = " androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } color = { module = "com.maxkeppeler.sheets-compose-dialogs:color", version.ref = "color" } -hammerhead-karoo-ext = { group = "io.hammerhead", name = "karoo-ext", version = "1.1.1" } +hammerhead-karoo-ext = { group = "io.hammerhead", name = "karoo-ext", version = "1.1.2" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCore" }