Display value on top of bar

This commit is contained in:
Tim Kluge 2024-12-09 21:11:05 +01:00
parent bc782e7f90
commit b3547f6051
15 changed files with 148 additions and 51 deletions

View File

@ -22,7 +22,7 @@ to be displayed at the bottom or at the top of the screen:
- Average power over the last 10 seconds - Average power over the last 10 seconds
Subsequently, the bar(s) will be shown when riding. Bars are filled and colored according Subsequently, the bar(s) will be shown when riding. Bars are filled and colored according
to your current power output / heart rate zone as setup in your Karoo settings. to your current power output / heart rate zone as setup in your Karoo settings. Optionally, the actual data value can be displayed on top of the bar.
## Installation ## Installation

View File

@ -13,8 +13,8 @@ android {
applicationId = "de.timklge.karoopowerbar" applicationId = "de.timklge.karoopowerbar"
minSdk = 26 minSdk = 26
targetSdk = 33 targetSdk = 33
versionCode = 4 versionCode = 5
versionName = "1.1.1" versionName = "1.2.0"
} }
buildTypes { buildTypes {

View File

@ -3,8 +3,8 @@
"packageName": "de.timklge.karoopowerbar", "packageName": "de.timklge.karoopowerbar",
"iconUrl": "https://github.com/timklge/karoo-powerbar/releases/latest/download/karoo-powerbar.png", "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", "latestApkUrl": "https://github.com/timklge/karoo-powerbar/releases/latest/download/app-release.apk",
"latestVersion": "1.1.1", "latestVersion": "1.2.0",
"latestVersionCode": 4, "latestVersionCode": 5,
"developer": "timklge", "developer": "timklge",
"description": "Adds a colored power bar to the bottom of the screen", "description": "Adds a colored power bar to the bottom of the screen",
"releaseNotes": "Add options to add secondary power bar and to hide bar when not riding. Fix manually set up power/hr zones." "releaseNotes": "Add options to add secondary power bar and to hide bar when not riding. Fix manually set up power/hr zones."

View File

@ -6,6 +6,7 @@ import android.graphics.Canvas
import android.graphics.Color import android.graphics.Color
import android.graphics.Paint import android.graphics.Paint
import android.graphics.RectF import android.graphics.RectF
import android.graphics.Typeface
import android.util.AttributeSet import android.util.AttributeSet
import android.view.View import android.view.View
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
@ -16,71 +17,135 @@ class CustomProgressBar @JvmOverloads constructor(
) : View(context, attrs) { ) : View(context, attrs) {
var progress: Double = 0.5 var progress: Double = 0.5
var location: PowerbarLocation = PowerbarLocation.BOTTOM var location: PowerbarLocation = PowerbarLocation.BOTTOM
var label: String = ""
var showLabel: Boolean = true
@ColorInt var progressColor: Int = 0xFF2b86e6.toInt() @ColorInt var progressColor: Int = 0xFF2b86e6.toInt()
val fontSize = 40f
private val linePaint = Paint().apply {
isAntiAlias = true
strokeWidth = 1f
style = Paint.Style.FILL_AND_STROKE
color = progressColor
}
private val lineStrokePaint = Paint().apply {
isAntiAlias = true
strokeWidth = 4f
style = Paint.Style.STROKE
color = progressColor
}
private val blurPaint = Paint().apply {
isAntiAlias = true
strokeWidth = 4f
style = Paint.Style.STROKE
color = progressColor
maskFilter = BlurMaskFilter(3f, BlurMaskFilter.Blur.NORMAL)
}
private val blurPaintHighlight = Paint().apply {
isAntiAlias = true
strokeWidth = 8f
style = Paint.Style.FILL_AND_STROKE
color = ColorUtils.blendARGB(progressColor, 0xFFFFFF, 0.5f)
maskFilter = BlurMaskFilter(6f, BlurMaskFilter.Blur.NORMAL)
}
private val backgroundPaint = Paint().apply {
style = Paint.Style.FILL
color = Color.argb(1.0f, 0f, 0f, 0f)
strokeWidth = 2f
}
private val textBackgroundPaint = Paint().apply {
style = Paint.Style.FILL
color = Color.argb(0.8f, 0f, 0f, 0f)
strokeWidth = 2f
}
private val textPaint = Paint().apply {
color = Color.WHITE
strokeWidth = 3f
textSize = fontSize
typeface = Typeface.create(Typeface.MONOSPACE, Typeface.BOLD);
textAlign = Paint.Align.CENTER
}
override fun onDrawForeground(canvas: Canvas) { override fun onDrawForeground(canvas: Canvas) {
super.onDrawForeground(canvas) super.onDrawForeground(canvas)
val linePaint = Paint().apply { linePaint.color = progressColor
isAntiAlias = true lineStrokePaint.color = progressColor
strokeWidth = 1f blurPaint.color = progressColor
style = Paint.Style.FILL_AND_STROKE blurPaintHighlight.color = ColorUtils.blendARGB(progressColor, 0xFFFFFF, 0.5f)
color = progressColor
}
val blurPaint = Paint().apply {
isAntiAlias = true
strokeWidth = 2f
style = Paint.Style.STROKE
color = progressColor
maskFilter = BlurMaskFilter(3f, BlurMaskFilter.Blur.NORMAL)
}
val blurPaintHighlight = Paint().apply {
isAntiAlias = true
strokeWidth = 4f
style = Paint.Style.FILL_AND_STROKE
color = ColorUtils.blendARGB(progressColor, 0xFFFFFF, 0.5f)
maskFilter = BlurMaskFilter(3f, BlurMaskFilter.Blur.NORMAL)
}
val background = Paint().apply {
style = Paint.Style.FILL_AND_STROKE
color = Color.argb(1.0f, 0f, 0f, 0f)
strokeWidth = 2f
}
when(location){ when(location){
PowerbarLocation.TOP -> { PowerbarLocation.TOP -> {
val rect = RectF( val rect = RectF(
1f, 1f,
1f, 15f,
((canvas.width.toDouble() - 1f) * progress.coerceIn(0.0, 1.0)).toFloat(), ((canvas.width.toDouble() - 1f) * progress.coerceIn(0.0, 1.0)).toFloat(),
canvas.height.toFloat() - 1f - 4f 15f + 20f
) )
canvas.drawRoundRect(0f, 2f, canvas.width.toFloat(), canvas.height.toFloat() - 4f, 2f, 2f, background) canvas.drawRoundRect(0f, 15f, canvas.width.toFloat(), 15f + 20f, 2f, 2f, backgroundPaint)
if (progress > 0.0) { if (progress > 0.0) {
canvas.drawRoundRect(rect, 2f, 2f, blurPaint) canvas.drawRoundRect(rect, 2f, 2f, blurPaint)
canvas.drawRoundRect(rect, 2f, 2f, linePaint) canvas.drawRoundRect(rect, 2f, 2f, linePaint)
canvas.drawRoundRect(rect.right-4, rect.top, rect.right+4, rect.bottom, 2f, 2f, blurPaintHighlight) canvas.drawRoundRect(rect.right-4, rect.top, rect.right+4, rect.bottom, 2f, 2f, blurPaintHighlight)
if (showLabel){
val textBounds = textPaint.measureText(label)
val xOffset = (textBounds + 20).coerceAtLeast(10f) / 2f
val yOffset = (fontSize - 15f) / 2
val x = (rect.right - xOffset).coerceIn(0f..canvas.width-xOffset*2f)
val y = rect.top - yOffset
val r = x + xOffset * 2
val b = rect.bottom + yOffset
canvas.drawRoundRect(x, y, r, b, 2f, 2f, textBackgroundPaint)
canvas.drawRoundRect(x, y, r, b, 2f, 2f, blurPaint)
canvas.drawRoundRect(x, y, r, b, 2f, 2f, lineStrokePaint)
canvas.drawText(label, x + xOffset, rect.top + 23, textPaint)
}
} }
} }
PowerbarLocation.BOTTOM -> { PowerbarLocation.BOTTOM -> {
val rect = RectF( val rect = RectF(
1f, 1f,
1f + 4f, canvas.height.toFloat() - 1f - 20f,
((canvas.width.toDouble() - 1f) * progress.coerceIn(0.0, 1.0)).toFloat(), ((canvas.width.toDouble() - 1f) * progress.coerceIn(0.0, 1.0)).toFloat(),
canvas.height.toFloat() - 1f canvas.height.toFloat() - 1f
) )
canvas.drawRoundRect(0f, 2f + 4f, canvas.width.toFloat(), canvas.height.toFloat(), 2f, 2f, background) canvas.drawRoundRect(0f, canvas.height.toFloat() - 20f, canvas.width.toFloat(), canvas.height.toFloat(), 2f, 2f, backgroundPaint)
if (progress > 0.0) { if (progress > 0.0) {
canvas.drawRoundRect(rect, 2f, 2f, blurPaint) canvas.drawRoundRect(rect, 2f, 2f, blurPaint)
canvas.drawRoundRect(rect, 2f, 2f, linePaint) canvas.drawRoundRect(rect, 2f, 2f, linePaint)
canvas.drawRoundRect(rect.right-4, rect.top, rect.right+4, rect.bottom, 2f, 2f, blurPaintHighlight) canvas.drawRoundRect(rect.right-4, rect.top, rect.right+4, rect.bottom, 2f, 2f, blurPaintHighlight)
if (showLabel){
val textBounds = textPaint.measureText(label)
val xOffset = (textBounds + 20).coerceAtLeast(10f) / 2f
val yOffset = (fontSize + 0f) / 2
val x = (rect.right - xOffset).coerceIn(0f..canvas.width-xOffset*2f)
val y = (rect.top - yOffset)
val r = x + xOffset * 2
val b = rect.bottom + 5
canvas.drawRoundRect(x, y, r, b, 2f, 2f, textBackgroundPaint)
canvas.drawRoundRect(x, y, r, b, 2f, 2f, blurPaint)
canvas.drawRoundRect(x, y, r, b, 2f, 2f, lineStrokePaint)
canvas.drawText(label, x + xOffset, rect.top + 16, textPaint)
}
} }
} }
} }

View File

@ -28,7 +28,8 @@ val settingsKey = stringPreferencesKey("settings")
data class PowerbarSettings( data class PowerbarSettings(
val source: SelectedSource = SelectedSource.POWER, val source: SelectedSource = SelectedSource.POWER,
val topBarSource: SelectedSource = SelectedSource.NONE, val topBarSource: SelectedSource = SelectedSource.NONE,
val onlyShowWhileRiding: Boolean = true val onlyShowWhileRiding: Boolean = true,
val showLabelOnBars: Boolean = true
){ ){
companion object { companion object {
val defaultSettings = Json.encodeToString(PowerbarSettings()) val defaultSettings = Json.encodeToString(PowerbarSettings())

View File

@ -53,7 +53,7 @@ class ForegroundService : Service() {
windows.clear() windows.clear()
if (settings.source != SelectedSource.NONE && showBars) { if (settings.source != SelectedSource.NONE && showBars) {
Window(this@ForegroundService, PowerbarLocation.BOTTOM).apply { Window(this@ForegroundService, PowerbarLocation.BOTTOM, settings.showLabelOnBars).apply {
selectedSource = settings.source selectedSource = settings.source
windows.add(this) windows.add(this)
open() open()
@ -61,7 +61,7 @@ class ForegroundService : Service() {
} }
if (settings.topBarSource != SelectedSource.NONE && showBars){ if (settings.topBarSource != SelectedSource.NONE && showBars){
Window(this@ForegroundService, PowerbarLocation.TOP).apply { Window(this@ForegroundService, PowerbarLocation.TOP, settings.showLabelOnBars).apply {
selectedSource = settings.topBarSource selectedSource = settings.topBarSource
open() open()
windows.add(this) windows.add(this)

View File

@ -10,7 +10,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class KarooPowerbarExtension : KarooExtension("karoo-powerbar", "1.1.1") { class KarooPowerbarExtension : KarooExtension("karoo-powerbar", "1.2.0") {
companion object { companion object {
const val TAG = "karoo-powerbar" const val TAG = "karoo-powerbar"

View File

@ -39,7 +39,8 @@ enum class PowerbarLocation {
class Window( class Window(
private val context: Context, private val context: Context,
val powerbarLocation: PowerbarLocation = PowerbarLocation.BOTTOM val powerbarLocation: PowerbarLocation = PowerbarLocation.BOTTOM,
val showLabel: Boolean
) { ) {
private val rootView: View private val rootView: View
private var layoutParams: WindowManager.LayoutParams? = null private var layoutParams: WindowManager.LayoutParams? = null
@ -83,7 +84,7 @@ class Window(
PowerbarLocation.BOTTOM -> Gravity.BOTTOM PowerbarLocation.BOTTOM -> Gravity.BOTTOM
} }
if (powerbarLocation == PowerbarLocation.TOP) { if (powerbarLocation == PowerbarLocation.TOP) {
layoutParams?.y = 10 layoutParams?.y = 0
} else { } else {
layoutParams?.y = 0 layoutParams?.y = 0
} }
@ -105,6 +106,8 @@ class Window(
powerbar.progressColor = context.resources.getColor(R.color.zone7) powerbar.progressColor = context.resources.getColor(R.color.zone7)
powerbar.progress = 0.0 powerbar.progress = 0.0
powerbar.location = powerbarLocation
powerbar.showLabel = showLabel
powerbar.invalidate() powerbar.invalidate()
Log.i(TAG, "Streaming $selectedSource") Log.i(TAG, "Streaming $selectedSource")
@ -142,7 +145,7 @@ class Window(
.collect { streamData -> .collect { streamData ->
val value = streamData.value.roundToInt() val value = streamData.value.roundToInt()
val color = context.getColor( val color = context.getColor(
streamData.userProfile.getZone(streamData.userProfile.heartRateZones, value)?.colorResource getZone(streamData.userProfile.heartRateZones, value)?.colorResource
?: R.color.zone7 ?: R.color.zone7
) )
val minHr = streamData.userProfile.restingHr val minHr = streamData.userProfile.restingHr
@ -152,6 +155,7 @@ class Window(
powerbar.progressColor = color powerbar.progressColor = color
powerbar.progress = progress powerbar.progress = progress
powerbar.label = "$value"
powerbar.invalidate() powerbar.invalidate()
Log.d(TAG, "Hr: $value min: $minHr max: $maxHr") Log.d(TAG, "Hr: $value min: $minHr max: $maxHr")
@ -177,7 +181,7 @@ class Window(
.collect { streamData -> .collect { streamData ->
val value = streamData.value.roundToInt() val value = streamData.value.roundToInt()
val color = context.getColor( val color = context.getColor(
streamData.userProfile.getZone(streamData.userProfile.powerZones, value)?.colorResource getZone(streamData.userProfile.powerZones, value)?.colorResource
?: R.color.zone7 ?: R.color.zone7
) )
val minPower = streamData.userProfile.powerZones.first().min val minPower = streamData.userProfile.powerZones.first().min
@ -187,9 +191,10 @@ class Window(
powerbar.progressColor = color powerbar.progressColor = color
powerbar.progress = progress powerbar.progress = progress
powerbar.label = "${value}W"
powerbar.invalidate() powerbar.invalidate()
Log.d(TAG, "Power: ${value} min: $minPower max: $maxPower") Log.d(TAG, "Power: $value min: $minPower max: $maxPower")
} }
} }

View File

@ -26,7 +26,7 @@ val zones = mapOf(
9 to listOf(Zone.Zone0, Zone.Zone1, Zone.Zone2, Zone.Zone3, Zone.Zone4, Zone.Zone5, Zone.Zone6, Zone.Zone7, Zone.Zone8) 9 to listOf(Zone.Zone0, Zone.Zone1, Zone.Zone2, Zone.Zone3, Zone.Zone4, Zone.Zone5, Zone.Zone6, Zone.Zone7, Zone.Zone8)
) )
fun UserProfile.getZone(userZones: List<UserProfile.Zone>, value: Int): Zone? { fun getZone(userZones: List<UserProfile.Zone>, value: Int): Zone? {
val zoneList = zones[userZones.size] ?: return null val zoneList = zones[userZones.size] ?: return null
userZones.forEachIndexed { index, zone -> userZones.forEachIndexed { index, zone ->

View File

@ -28,6 +28,7 @@ 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.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -41,8 +42,10 @@ import androidx.core.content.ContextCompat.startActivity
import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.LifecycleResumeEffect
import de.timklge.karoopowerbar.PowerbarSettings import de.timklge.karoopowerbar.PowerbarSettings
import de.timklge.karoopowerbar.saveSettings import de.timklge.karoopowerbar.saveSettings
import de.timklge.karoopowerbar.streamRideState
import de.timklge.karoopowerbar.streamSettings import de.timklge.karoopowerbar.streamSettings
import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.RideState
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -62,6 +65,8 @@ fun MainScreen() {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val karooSystem = remember { KarooSystemService(ctx) } val karooSystem = remember { KarooSystemService(ctx) }
val rideState: RideState by karooSystem.streamRideState().collectAsState(RideState.Idle)
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) }
@ -70,6 +75,7 @@ fun MainScreen() {
var givenPermissions by remember { mutableStateOf(false) } var givenPermissions by remember { mutableStateOf(false) }
var onlyShowWhileRiding by remember { mutableStateOf(false) } var onlyShowWhileRiding by remember { mutableStateOf(false) }
var showLabelOnBars by remember { mutableStateOf(true) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
givenPermissions = Settings.canDrawOverlays(ctx) givenPermissions = Settings.canDrawOverlays(ctx)
@ -78,6 +84,7 @@ fun MainScreen() {
bottomSelectedSource = settings.source bottomSelectedSource = settings.source
topSelectedSource = settings.topBarSource topSelectedSource = settings.topBarSource
onlyShowWhileRiding = settings.onlyShowWhileRiding onlyShowWhileRiding = settings.onlyShowWhileRiding
showLabelOnBars = settings.showLabelOnBars
} }
} }
@ -128,6 +135,12 @@ fun MainScreen() {
} }
} }
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = showLabelOnBars, onCheckedChange = { showLabelOnBars = it})
Spacer(modifier = Modifier.width(10.dp))
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})
Spacer(modifier = Modifier.width(10.dp)) Spacer(modifier = Modifier.width(10.dp))
@ -137,7 +150,10 @@ fun MainScreen() {
FilledTonalButton(modifier = Modifier FilledTonalButton(modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(50.dp), onClick = { .height(50.dp), onClick = {
val newSettings = PowerbarSettings(source = bottomSelectedSource, topBarSource = topSelectedSource, onlyShowWhileRiding = onlyShowWhileRiding) val newSettings = PowerbarSettings(
source = bottomSelectedSource, topBarSource = topSelectedSource,
onlyShowWhileRiding = onlyShowWhileRiding, showLabelOnBars = showLabelOnBars
)
coroutineScope.launch { coroutineScope.launch {
saveSettings(ctx, newSettings) saveSettings(ctx, newSettings)
@ -149,6 +165,16 @@ fun MainScreen() {
Text("Save") Text("Save")
} }
if (onlyShowWhileRiding && karooConnected) {
val hardwareName = karooSystem.hardwareType?.name ?: "unknown device"
val state = when (rideState) {
RideState.Idle -> "No ride started"
is RideState.Paused -> "Ride is paused"
RideState.Recording -> "Currently riding"
}
Text(modifier = Modifier.padding(5.dp), text = "Running on $hardwareName. $state.")
}
if (showAlerts){ if (showAlerts){
if(!karooConnected){ if(!karooConnected){
Text(modifier = Modifier.padding(5.dp), text = "Could not read device status. Is your Karoo updated?") Text(modifier = Modifier.padding(5.dp), text = "Could not read device status. Is your Karoo updated?")

View File

@ -2,12 +2,12 @@
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="14dp"> android:layout_height="40dp">
<de.timklge.karoopowerbar.CustomProgressBar <de.timklge.karoopowerbar.CustomProgressBar
android:id="@+id/progressBar" android:id="@+id/progressBar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="14dp" android:layout_height="40dp"
android:layout_gravity="center" /> android:layout_gravity="center" />
</FrameLayout> </FrameLayout>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 47 KiB

BIN
powerbar2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 MiB

After

Width:  |  Height:  |  Size: 173 KiB