Add route distance data source, workout target indication (#34)
Some checks failed
Build / build (push) Has been cancelled

* Add route distance data source, workout target indication

* Update target range drawing

* Fix powerbar is not redrawn in route progress mode

* Update changelog

* Change to v3 release action

* Test release action

* Update release parameters

* Test BASE_URL parameter

* Fix BASE_URL

* Replace BASE_URL on build

* Fix gradle task order

* Only replace base url in manifest as part of ci build

* Fix base url quotation
This commit is contained in:
timklge 2025-06-02 19:18:23 +02:00 committed by GitHub
parent 820fdb6ef9
commit 91c8eec977
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 379 additions and 121 deletions

View File

@ -27,6 +27,7 @@ jobs:
echo "KEYSTORE_PASSWORD=${{ secrets.KEYSTORE_PASSWORD }}" >> $GITHUB_ENV
echo "KEYSTORE_BASE64=${{ secrets.KEYSTORE_BASE64 }}" >> $GITHUB_ENV
echo "BUILD_NUMBER=${{ github.run_number }}" >> $GITHUB_ENV
echo "BASE_URL=${{ secrets.BASE_URL || 'https://github.com/timklge/karoo-powerbar/releases/latest/download' }}" >> $GITHUB_ENV
- uses: actions/checkout@v4
- name: set up JDK 17
@ -44,20 +45,22 @@ jobs:
- name: Build with Gradle
run: ./gradlew build
- name: Archive APK
uses: actions/upload-artifact@v4
with:
name: app-release.apk
path: app/build/outputs/apk/release/app-release.apk
- name: Create Release
id: create_release
uses: ncipollo/release-action@v1
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/')
with:
files: |
app/build/outputs/apk/release/app-release.apk
app/manifest.json
app/karoo-powerbar.png
powerbar_min.gif
powerbar0.png
powerbar2.png
name: ${{ github.ref_name }}
prerelease: false
generateReleaseNotes: true
artifacts: app/build/outputs/apk/release/app-release.apk, app/manifest.json, app/karoo-powerbar.png, powerbar_min.gif, powerbar0.png, powerbar2.png
draft: false
generate_release_notes: true
make_latest: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

1
.gitignore vendored
View File

@ -9,3 +9,4 @@
.cxx
local.properties
/app/release
app/manifest.json

View File

@ -1,4 +1,5 @@
import java.util.Base64
import com.android.build.gradle.tasks.ProcessApplicationManifest
plugins {
alias(libs.plugins.android.application)
@ -60,27 +61,36 @@ tasks.register("generateManifest") {
group = "build"
doLast {
val baseUrl = System.getenv("BASE_URL") ?: "https://github.com/timklge/karoo-powerbar/releases/latest/download"
val manifestFile = file("$projectDir/manifest.json")
val manifest = mapOf(
"label" to "Powerbar",
"packageName" to "de.timklge.karoopowerbar",
"iconUrl" to "https://github.com/timklge/karoo-powerbar/releases/latest/download/karoo-powerbar.png",
"latestApkUrl" to "https://github.com/timklge/karoo-powerbar/releases/latest/download/app-release.apk",
"iconUrl" to "$baseUrl/karoo-powerbar.png",
"latestApkUrl" to "$baseUrl/app-release.apk",
"latestVersion" to android.defaultConfig.versionName,
"latestVersionCode" to android.defaultConfig.versionCode,
"developer" to "github.com/timklge",
"description" to "Open-source extension that adds colored power or heart rate progress bars to the edges of the screen, similar to the LEDs on Wahoo computers",
"releaseNotes" to "* Replace dropdown popup with fullscreen dialog",
"releaseNotes" to "* Add route progress data source\n* Add workout target range indicator\n* Replace dropdown popup with fullscreen dialog",
"screenshotUrls" to listOf(
"https://github.com/timklge/karoo-powerbar/releases/latest/download/powerbar_min.gif",
"https://github.com/timklge/karoo-powerbar/releases/latest/download/powerbar0.png",
"https://github.com/timklge/karoo-powerbar/releases/latest/download/powerbar2.png",
"$baseUrl/powerbar_min.gif",
"$baseUrl/powerbar0.png",
"$baseUrl/powerbar2.png",
)
)
val gson = groovy.json.JsonBuilder(manifest).toPrettyString()
manifestFile.writeText(gson)
println("Generated manifest.json with version ${android.defaultConfig.versionName} (${android.defaultConfig.versionCode})")
if (System.getenv()["BASE_URL"] != null){
val androidManifestFile = file("$projectDir/src/main/AndroidManifest.xml")
var androidManifestContent = androidManifestFile.readText()
androidManifestContent = androidManifestContent.replace("\$BASE_URL\$", baseUrl)
androidManifestFile.writeText(androidManifestContent)
println("Replaced \$BASE_URL$ in AndroidManifest.xml")
}
}
}
@ -88,6 +98,11 @@ tasks.named("assemble") {
dependsOn("generateManifest")
}
tasks.withType<ProcessApplicationManifest>().configureEach {
if (name == "processDebugMainManifest" || name == "processReleaseMainManifest") {
dependsOn(tasks.named("generateManifest"))
}
}
dependencies {
implementation(libs.hammerhead.karoo.ext)
@ -102,4 +117,6 @@ dependencies {
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.cardview)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.mapbox.sdk.turf)
}

View File

@ -42,7 +42,7 @@
<!-- Provide Karoo System with information about delivery of your app -->
<meta-data
android:name="io.hammerhead.karooext.MANIFEST_URL"
android:value="https://github.com/timklge/karoo-powerbar/releases/latest/download/manifest.json" />
android:value="$BASE_URL$/manifest.json" />
</application>

View File

@ -18,6 +18,9 @@ class CustomProgressBar @JvmOverloads constructor(
var progress: Double? = 0.5
var location: PowerbarLocation = PowerbarLocation.BOTTOM
var label: String = ""
var minTarget: Double? = null
var maxTarget: Double? = null
var target: Double? = null
var showLabel: Boolean = true
var barBackground: Boolean = false
@ColorInt var progressColor: Int = 0xFF2b86e6.toInt()
@ -32,9 +35,35 @@ class CustomProgressBar @JvmOverloads constructor(
var barSize = CustomProgressBarBarSize.MEDIUM
set(value) {
field = value
targetZoneStrokePaint.strokeWidth = when(value){
CustomProgressBarBarSize.NONE, CustomProgressBarBarSize.SMALL -> 3f
CustomProgressBarBarSize.MEDIUM -> 6f
CustomProgressBarBarSize.LARGE -> 8f
}
targetIndicatorPaint.strokeWidth = when(value){
CustomProgressBarBarSize.NONE, CustomProgressBarBarSize.SMALL -> 6f
CustomProgressBarBarSize.MEDIUM -> 8f
CustomProgressBarBarSize.LARGE -> 10f
}
invalidate() // Redraw to apply new bar size
}
private val targetColor = 0xFF9933FF.toInt()
private val targetZoneFillPaint = Paint().apply {
isAntiAlias = true
style = Paint.Style.FILL
color = targetColor
alpha = 100 // Semi-transparent fill
}
private val targetZoneStrokePaint = Paint().apply {
isAntiAlias = true
strokeWidth = 6f
style = Paint.Style.STROKE
color = targetColor
}
private val linePaint = Paint().apply {
isAntiAlias = true
strokeWidth = 1f
@ -51,7 +80,7 @@ class CustomProgressBar @JvmOverloads constructor(
private val blurPaint = Paint().apply {
isAntiAlias = true
strokeWidth = 4f
strokeWidth = 6f
style = Paint.Style.STROKE
color = progressColor
maskFilter = BlurMaskFilter(3f, BlurMaskFilter.Blur.NORMAL)
@ -59,7 +88,7 @@ class CustomProgressBar @JvmOverloads constructor(
private val blurPaintHighlight = Paint().apply {
isAntiAlias = true
strokeWidth = 8f
strokeWidth = 10f
style = Paint.Style.FILL_AND_STROKE
color = ColorUtils.blendARGB(progressColor, 0xFFFFFF, 0.5f)
maskFilter = BlurMaskFilter(6f, BlurMaskFilter.Blur.NORMAL)
@ -81,19 +110,29 @@ class CustomProgressBar @JvmOverloads constructor(
color = Color.WHITE
strokeWidth = 3f
textSize = fontSize.fontSize
typeface = Typeface.create(Typeface.MONOSPACE, Typeface.BOLD);
typeface = Typeface.create(Typeface.MONOSPACE, Typeface.BOLD)
textAlign = Paint.Align.CENTER
}
private val targetIndicatorPaint = Paint().apply {
isAntiAlias = true
strokeWidth = 8f
style = Paint.Style.STROKE
}
override fun onDrawForeground(canvas: Canvas) {
super.onDrawForeground(canvas)
// Determine if the current progress is within the target range
val isTargetMet =
progress != null && minTarget != null && maxTarget != null && progress!! >= minTarget!! && progress!! <= maxTarget!!
linePaint.color = progressColor
lineStrokePaint.color = progressColor
blurPaint.color = progressColor
blurPaintHighlight.color = ColorUtils.blendARGB(progressColor, 0xFFFFFF, 0.5f)
when(location){
when (location) {
PowerbarLocation.TOP -> {
val rect = RectF(
1f,
@ -108,16 +147,60 @@ class CustomProgressBar @JvmOverloads constructor(
canvas.drawRect(0f, 15f, canvas.width.toFloat(), 15f + barSize.barHeight, backgroundPaint)
}
// Draw target zone fill behind the progress bar
if (minTarget != null && maxTarget != null) {
val minTargetX = (canvas.width * minTarget!!).toFloat()
val maxTargetX = (canvas.width * maxTarget!!).toFloat()
canvas.drawRoundRect(
minTargetX,
15f,
maxTargetX,
15f + barSize.barHeight,
2f,
2f,
targetZoneFillPaint
)
}
if (progress != null) {
canvas.drawRoundRect(rect, 2f, 2f, blurPaint)
canvas.drawRoundRect(rect, 2f, 2f, linePaint)
canvas.drawRoundRect(rect.right-4, rect.top, rect.right+4, rect.bottom, 2f, 2f, blurPaintHighlight)
}
}
// Draw label (if progress is not null and showLabel is true)
if (progress != null) {
// Draw target zone stroke after progress bar, before label
if (minTarget != null && maxTarget != null) {
val minTargetX = (canvas.width * minTarget!!).toFloat()
val maxTargetX = (canvas.width * maxTarget!!).toFloat()
// Draw stroked rounded rectangle for the target zone
canvas.drawRoundRect(
minTargetX,
15f,
maxTargetX,
15f + barSize.barHeight,
2f,
2f,
targetZoneStrokePaint
)
}
// Draw vertical target indicator line if target is present
if (target != null) {
val targetX = (canvas.width * target!!).toFloat()
targetIndicatorPaint.color = if (isTargetMet) Color.GREEN else Color.RED
canvas.drawLine(targetX, 15f, targetX, 15f + barSize.barHeight, targetIndicatorPaint)
}
if (showLabel){
lineStrokePaint.color = if (target != null){
if (isTargetMet) Color.GREEN else Color.RED
} else progressColor
blurPaint.color = lineStrokePaint.color
blurPaintHighlight.color = ColorUtils.blendARGB(lineStrokePaint.color, 0xFFFFFF, 0.5f)
val textBounds = textPaint.measureText(label)
val xOffset = (textBounds + 20).coerceAtLeast(10f) / 2f
val x = (rect.right - xOffset).coerceIn(0f..canvas.width-xOffset*2f)
@ -137,9 +220,10 @@ class CustomProgressBar @JvmOverloads constructor(
canvas.drawRoundRect(x, finalTextBoxTop, r, finalTextBoxBottom, 2f, 2f, blurPaint)
canvas.drawRoundRect(x, finalTextBoxTop, r, finalTextBoxBottom, 2f, 2f, lineStrokePaint)
canvas.drawText(label, x + xOffset, finalTextBaselineY, textPaint)
}
}
}
}
PowerbarLocation.BOTTOM -> {
val rect = RectF(
1f,
@ -155,6 +239,21 @@ class CustomProgressBar @JvmOverloads constructor(
canvas.drawRect(0f, canvas.height.toFloat() - barSize.barHeight, canvas.width.toFloat(), canvas.height.toFloat(), backgroundPaint)
}
// Draw target zone fill behind the progress bar
if (minTarget != null && maxTarget != null) {
val minTargetX = (canvas.width * minTarget!!).toFloat()
val maxTargetX = (canvas.width * maxTarget!!).toFloat()
canvas.drawRoundRect(
minTargetX,
canvas.height.toFloat() - barSize.barHeight,
maxTargetX,
canvas.height.toFloat(),
2f,
2f,
targetZoneFillPaint
)
}
if (progress != null) {
canvas.drawRoundRect(rect, 2f, 2f, blurPaint)
canvas.drawRoundRect(rect, 2f, 2f, linePaint)
@ -164,7 +263,37 @@ class CustomProgressBar @JvmOverloads constructor(
// Draw label (if progress is not null and showLabel is true)
if (progress != null) {
// Draw target zone stroke after progress bar, before label
if (minTarget != null && maxTarget != null) {
val minTargetX = (canvas.width * minTarget!!).toFloat()
val maxTargetX = (canvas.width * maxTarget!!).toFloat()
// Draw stroked rounded rectangle for the target zone
canvas.drawRoundRect(
minTargetX,
canvas.height.toFloat() - barSize.barHeight,
maxTargetX,
canvas.height.toFloat(),
2f,
2f,
targetZoneStrokePaint
)
}
// Draw vertical target indicator line if target is present
if (target != null) {
val targetX = (canvas.width * target!!).toFloat()
targetIndicatorPaint.color = if (isTargetMet) Color.GREEN else Color.RED
canvas.drawLine(targetX, canvas.height.toFloat() - barSize.barHeight, targetX, canvas.height.toFloat(), targetIndicatorPaint)
}
if (showLabel){
lineStrokePaint.color = if (target != null){
if (isTargetMet) Color.GREEN else Color.RED
} else progressColor
blurPaint.color = lineStrokePaint.color
blurPaintHighlight.color = ColorUtils.blendARGB(lineStrokePaint.color, 0xFFFFFF, 0.5f)
val textBounds = textPaint.measureText(label)
val xOffset = (textBounds + 20).coerceAtLeast(10f) / 2f
val x = (rect.right - xOffset).coerceIn(0f..canvas.width-xOffset*2f)

View File

@ -1,14 +1,18 @@
package de.timklge.karoopowerbar
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.OnNavigationState
import io.hammerhead.karooext.models.OnStreamState
import io.hammerhead.karooext.models.RideState
import io.hammerhead.karooext.models.StreamState
import io.hammerhead.karooext.models.UserProfile
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.transform
import kotlinx.serialization.json.Json
val jsonWithUnknownKeys = Json { ignoreUnknownKeys = true }
@ -35,6 +39,17 @@ fun KarooSystemService.streamRideState(): Flow<RideState> {
}
}
fun KarooSystemService.streamNavigationState(): Flow<OnNavigationState> {
return callbackFlow {
val listenerId = addConsumer { navigationState: OnNavigationState ->
trySendBlocking(navigationState)
}
awaitClose {
removeConsumer(listenerId)
}
}
}
fun KarooSystemService.streamUserProfile(): Flow<UserProfile> {
return callbackFlow {
val listenerId = addConsumer { userProfile: UserProfile ->
@ -45,3 +60,10 @@ fun KarooSystemService.streamUserProfile(): Flow<UserProfile> {
}
}
}
fun<T> Flow<T>.throttle(timeout: Long): Flow<T> = this
.conflate()
.transform {
emit(it)
delay(timeout)
}

View File

@ -17,10 +17,15 @@ import android.view.ViewGroup
import android.view.WindowInsets
import android.view.WindowManager
import androidx.annotation.ColorRes
import com.mapbox.geojson.LineString
import com.mapbox.turf.TurfConstants.UNIT_METERS
import com.mapbox.turf.TurfMeasurement
import de.timklge.karoopowerbar.KarooPowerbarExtension.Companion.TAG
import de.timklge.karoopowerbar.screens.SelectedSource
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.DataPoint
import io.hammerhead.karooext.models.DataType
import io.hammerhead.karooext.models.OnNavigationState
import io.hammerhead.karooext.models.StreamState
import io.hammerhead.karooext.models.UserProfile
import kotlinx.coroutines.CoroutineScope
@ -29,12 +34,15 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.math.roundToInt
fun remap(value: Double, fromMin: Double, fromMax: Double, toMin: Double, toMax: Double): Double {
fun remap(value: Double?, fromMin: Double, fromMax: Double, toMin: Double, toMax: Double): Double? {
if (value == null) return null
return (value - fromMin) * (toMax - toMin) / (fromMax - fromMin) + toMin
}
@ -50,6 +58,12 @@ class Window(
val powerbarBarSize: CustomProgressBarBarSize,
val powerbarFontSize: CustomProgressBarFontSize,
) {
companion object {
val FIELD_TARGET_VALUE_ID = "FIELD_WORKOUT_TARGET_VALUE_ID";
val FIELD_TARGET_MIN_ID = "FIELD_WORKOUT_TARGET_MIN_VALUE_ID";
val FIELD_TARGET_MAX_ID = "FIELD_WORKOUT_TARGET_MAX_VALUE_ID";
}
private val rootView: View
private var layoutParams: WindowManager.LayoutParams? = null
private val windowManager: WindowManager
@ -102,8 +116,6 @@ class Window(
private val karooSystem: KarooSystemService = KarooSystemService(context)
data class StreamData(val userProfile: UserProfile, val value: Double?, val settings: PowerbarSettings? = null)
private var serviceJob: Job? = null
@SuppressLint("UnspecifiedRegisterReceiverFlag")
@ -140,6 +152,7 @@ class Window(
SelectedSource.SPEED_3S -> streamSpeed(true)
SelectedSource.CADENCE -> streamCadence(false)
SelectedSource.CADENCE_3S -> streamCadence(true)
SelectedSource.ROUTE_PROGRESS -> streamRouteProgress()
else -> {}
}
}
@ -155,6 +168,55 @@ class Window(
}
}
private suspend fun streamRouteProgress() {
data class StreamData(
val userProfile: UserProfile,
val distanceToDestination: Double?,
val navigationState: OnNavigationState
)
var lastKnownRoutePolyline: String? = null
var lastKnownRouteLength: Double? = null
combine(karooSystem.streamUserProfile(), karooSystem.streamDataFlow(DataType.Type.DISTANCE_TO_DESTINATION), karooSystem.streamNavigationState()) { userProfile, distanceToDestination, navigationState ->
StreamData(userProfile, (distanceToDestination as? StreamState.Streaming)?.dataPoint?.values[DataType.Field.DISTANCE_TO_DESTINATION], navigationState)
}.distinctUntilChanged().throttle(5_000).collect { (userProfile, distanceToDestination, navigationState) ->
val state = navigationState.state
val routePolyline = when (state) {
is OnNavigationState.NavigationState.NavigatingRoute -> state.routePolyline
is OnNavigationState.NavigationState.NavigatingToDestination -> state.polyline
else -> null
}
if (routePolyline != lastKnownRoutePolyline) {
lastKnownRoutePolyline = routePolyline
lastKnownRouteLength = when (state){
is OnNavigationState.NavigationState.NavigatingRoute -> state.routeDistance
is OnNavigationState.NavigationState.NavigatingToDestination -> try {
TurfMeasurement.length(LineString.fromPolyline(state.polyline, 5), UNIT_METERS)
} catch (e: Exception) {
Log.e(TAG, "Failed to calculate route length", e)
null
}
else -> null
}
}
val routeLength = lastKnownRouteLength
val routeProgressMeters = routeLength?.let { routeLength - (distanceToDestination ?: 0.0) }?.coerceAtLeast(0.0)
val routeProgress = if (routeLength != null && routeProgressMeters != null) remap(routeProgressMeters, 0.0, routeLength, 0.0, 1.0) else null
val routeProgressInUserUnit = when (userProfile.preferredUnit.distance) {
UserProfile.PreferredUnit.UnitType.IMPERIAL -> routeProgressMeters?.times(0.000621371)?.roundToInt() // Miles
else -> routeProgressMeters?.times(0.001)?.roundToInt() // Kilometers
}
powerbar.progressColor = context.getColor(R.color.zone0)
powerbar.progress = routeProgress
powerbar.label = "$routeProgressInUserUnit"
powerbar.invalidate()
}
}
private suspend fun streamSpeed(smoothed: Boolean) {
val speedFlow = karooSystem.streamDataFlow(if(smoothed) DataType.Type.SMOOTHED_3S_AVERAGE_SPEED else DataType.Type.SPEED)
.map { (it as? StreamState.Streaming)?.dataPoint?.singleValue }
@ -162,12 +224,11 @@ class Window(
val settingsFlow = context.streamSettings()
karooSystem.streamUserProfile()
.distinctUntilChanged()
.combine(speedFlow) { userProfile, speed -> StreamData(userProfile, speed) }
.combine(settingsFlow) { streamData, settings -> streamData.copy(settings = settings) }
.distinctUntilChanged()
.collect { streamData ->
data class StreamData(val userProfile: UserProfile, val value: Double?, val settings: PowerbarSettings? = null)
combine(karooSystem.streamUserProfile(), speedFlow, settingsFlow) { userProfile, speed, settings ->
StreamData(userProfile, speed, settings)
}.distinctUntilChanged().throttle(1_000).collect { streamData ->
val valueMetersPerSecond = streamData.value
val value = when (streamData.userProfile.preferredUnit.distance){
UserProfile.PreferredUnit.UnitType.IMPERIAL -> valueMetersPerSecond?.times(2.23694)
@ -177,8 +238,7 @@ class Window(
if (value != null && valueMetersPerSecond != null) {
val minSpeed = streamData.settings?.minSpeed ?: PowerbarSettings.defaultMinSpeedMs
val maxSpeed = streamData.settings?.maxSpeed ?: PowerbarSettings.defaultMaxSpeedMs
val progress =
remap(valueMetersPerSecond, minSpeed.toDouble(), maxSpeed.toDouble(), 0.0, 1.0)
val progress = remap(valueMetersPerSecond, minSpeed.toDouble(), maxSpeed.toDouble(), 0.0, 1.0) ?: 0.0
@ColorRes val zoneColorRes = Zone.entries[(progress * Zone.entries.size).roundToInt().coerceIn(0..<Zone.entries.size)].colorResource
@ -203,46 +263,51 @@ class Window(
}
private suspend fun streamCadence(smoothed: Boolean) {
val speedFlow = karooSystem.streamDataFlow(if(smoothed) DataType.Type.SMOOTHED_3S_AVERAGE_CADENCE else DataType.Type.CADENCE)
val cadenceFlow = karooSystem.streamDataFlow(if(smoothed) DataType.Type.SMOOTHED_3S_AVERAGE_CADENCE else DataType.Type.CADENCE)
.map { (it as? StreamState.Streaming)?.dataPoint?.singleValue }
.distinctUntilChanged()
data class StreamData(val userProfile: UserProfile, val value: Double?, val settings: PowerbarSettings? = null, val cadenceTarget: DataPoint? = null)
val settingsFlow = context.streamSettings()
karooSystem.streamUserProfile()
val cadenceTargetFlow = karooSystem.streamDataFlow("TYPE_WORKOUT_CADENCE_TARGET_ID")
.map { (it as? StreamState.Streaming)?.dataPoint }
.distinctUntilChanged()
.combine(speedFlow) { userProfile, speed -> StreamData(userProfile, speed) }
.combine(settingsFlow) { streamData, settings -> streamData.copy(settings = settings) }
.distinctUntilChanged()
.collect { streamData ->
val value = streamData.value?.roundToInt()
if (value != null) {
val minCadence = streamData.settings?.minCadence ?: PowerbarSettings.defaultMinCadence
val maxCadence = streamData.settings?.maxCadence ?: PowerbarSettings.defaultMaxCadence
val progress =
remap(value.toDouble(), minCadence.toDouble(), maxCadence.toDouble(), 0.0, 1.0)
combine(karooSystem.streamUserProfile(), cadenceFlow, settingsFlow, cadenceTargetFlow) { userProfile, speed, settings, cadenceTarget ->
StreamData(userProfile, speed, settings, cadenceTarget)
}.distinctUntilChanged().throttle(1_000).collect { streamData ->
val value = streamData.value?.roundToInt()
@ColorRes val zoneColorRes = Zone.entries[(progress * Zone.entries.size).roundToInt().coerceIn(0..<Zone.entries.size)].colorResource
if (value != null) {
val minCadence = streamData.settings?.minCadence ?: PowerbarSettings.defaultMinCadence
val maxCadence = streamData.settings?.maxCadence ?: PowerbarSettings.defaultMaxCadence
val progress = remap(value.toDouble(), minCadence.toDouble(), maxCadence.toDouble(), 0.0, 1.0) ?: 0.0
powerbar.progressColor = if (streamData.settings?.useZoneColors == true) {
context.getColor(zoneColorRes)
} else {
context.getColor(R.color.zone0)
}
powerbar.progress = if (value > 0) progress else null
powerbar.label = "$value"
powerbar.minTarget = remap(streamData.cadenceTarget?.values[FIELD_TARGET_MIN_ID]?.toDouble(), minCadence.toDouble(), maxCadence.toDouble(), 0.0, 1.0)
powerbar.maxTarget = remap(streamData.cadenceTarget?.values[FIELD_TARGET_MAX_ID]?.toDouble(), minCadence.toDouble(), maxCadence.toDouble(), 0.0, 1.0)
powerbar.target = remap(streamData.cadenceTarget?.values[FIELD_TARGET_VALUE_ID]?.toDouble(), minCadence.toDouble(), maxCadence.toDouble(), 0.0, 1.0)
Log.d(TAG, "Cadence: $value min: $minCadence max: $maxCadence")
@ColorRes val zoneColorRes = Zone.entries[(progress * Zone.entries.size).roundToInt().coerceIn(0..<Zone.entries.size)].colorResource
powerbar.progressColor = if (streamData.settings?.useZoneColors == true) {
context.getColor(zoneColorRes)
} else {
powerbar.progressColor = context.getColor(R.color.zone0)
powerbar.progress = null
powerbar.label = "?"
Log.d(TAG, "Cadence: Unavailable")
context.getColor(R.color.zone0)
}
powerbar.invalidate()
powerbar.progress = if (value > 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 = null
powerbar.label = "?"
Log.d(TAG, "Cadence: Unavailable")
}
powerbar.invalidate()
}
}
private suspend fun streamHeartrate() {
@ -251,40 +316,46 @@ class Window(
.distinctUntilChanged()
val settingsFlow = context.streamSettings()
karooSystem.streamUserProfile()
val hrTargetFlow = karooSystem.streamDataFlow("TYPE_WORKOUT_HEART_RATE_TARGET_ID")
.map { (it as? StreamState.Streaming)?.dataPoint }
.distinctUntilChanged()
.combine(hrFlow) { userProfile, hr -> StreamData(userProfile, hr) }
.combine(settingsFlow) { streamData, settings -> streamData.copy(settings = settings) }
.distinctUntilChanged()
.collect { streamData ->
val value = streamData.value?.roundToInt()
if (value != null) {
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)
data class StreamData(val userProfile: UserProfile, val value: Double?, val settings: PowerbarSettings? = null, val heartrateTarget: DataPoint? = null)
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 = if (value > 0) progress else null
powerbar.label = "$value"
combine(karooSystem.streamUserProfile(), hrFlow, settingsFlow, hrTargetFlow) { userProfile, hr, settings, hrTarget ->
StreamData(userProfile, hr, settings, hrTarget)
}.distinctUntilChanged().throttle(1_000).collect { streamData ->
val value = streamData.value?.roundToInt()
Log.d(TAG, "Hr: $value min: $minHr max: $maxHr")
if (value != null) {
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.minTarget = remap(streamData.heartrateTarget?.values[FIELD_TARGET_MIN_ID]?.toDouble(), minHr.toDouble(), maxHr.toDouble(), 0.0, 1.0)
powerbar.maxTarget = remap(streamData.heartrateTarget?.values[FIELD_TARGET_MAX_ID]?.toDouble(), minHr.toDouble(), maxHr.toDouble(), 0.0, 1.0)
powerbar.target = remap(streamData.heartrateTarget?.values[FIELD_TARGET_VALUE_ID]?.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 {
powerbar.progressColor = context.getColor(R.color.zone0)
powerbar.progress = null
powerbar.label = "?"
Log.d(TAG, "Hr: Unavailable")
context.getColor(R.color.zone0)
}
powerbar.invalidate()
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 = null
powerbar.label = "?"
Log.d(TAG, "Hr: Unavailable")
}
powerbar.invalidate()
}
}
enum class PowerStreamSmoothing(val dataTypeId: String){
@ -300,39 +371,46 @@ class Window(
val settingsFlow = context.streamSettings()
karooSystem.streamUserProfile()
val powerTargetFlow = karooSystem.streamDataFlow("TYPE_WORKOUT_POWER_TARGET_ID") // TYPE_WORKOUT_HEART_RATE_TARGET_ID, TYPE_WORKOUT_CADENCE_TARGET_ID,
.map { (it as? StreamState.Streaming)?.dataPoint }
.distinctUntilChanged()
.combine(powerFlow) { userProfile, hr -> StreamData(userProfile, hr) }
.combine(settingsFlow) { streamData, settings -> streamData.copy(settings = settings) }
.distinctUntilChanged()
.collect { streamData ->
val value = streamData.value?.roundToInt()
if (value != null) {
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)
data class StreamData(val userProfile: UserProfile, val value: Double?, val settings: PowerbarSettings? = null, val powerTarget: DataPoint? = null)
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 = if (value > 0) progress else null
powerbar.label = "${value}W"
combine(karooSystem.streamUserProfile(), powerFlow, settingsFlow, powerTargetFlow) { userProfile, hr, settings, powerTarget ->
StreamData(userProfile, hr, settings, powerTarget)
}.distinctUntilChanged().throttle(1_000).collect { streamData ->
val value = streamData.value?.roundToInt()
Log.d(TAG, "Power: $value min: $minPower max: $maxPower")
if (value != null) {
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 + 30)
val progress = remap(value.toDouble(), minPower.toDouble(), maxPower.toDouble(), 0.0, 1.0)
powerbar.minTarget = remap(streamData.powerTarget?.values[FIELD_TARGET_MIN_ID]?.toDouble(), minPower.toDouble(), maxPower.toDouble(), 0.0, 1.0)
powerbar.maxTarget = remap(streamData.powerTarget?.values[FIELD_TARGET_MAX_ID]?.toDouble(), minPower.toDouble(), maxPower.toDouble(), 0.0, 1.0)
powerbar.target = remap(streamData.powerTarget?.values[FIELD_TARGET_VALUE_ID]?.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 {
powerbar.progressColor = context.getColor(R.color.zone0)
powerbar.progress = null
powerbar.label = "?"
Log.d(TAG, "Power: Unavailable")
context.getColor(R.color.zone0)
}
powerbar.invalidate()
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 = null
powerbar.label = "?"
Log.d(TAG, "Power: Unavailable")
}
powerbar.invalidate()
}
}
private var currentHideJob: Job? = null

View File

@ -87,7 +87,8 @@ 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)"),
ROUTE_PROGRESS("route_progress", "Route Progress");
fun isPower() = this == POWER || this == POWER_3S || this == POWER_10S
}

View File

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

View File

@ -14,6 +14,7 @@ lifecycleRuntimeKtx = "2.8.7"
navigationRuntimeKtx = "2.8.4"
navigationCompose = "2.8.4"
cardview = "1.0.0"
mapboxSdkTurf = "7.3.1"
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
@ -24,7 +25,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.2" }
hammerhead-karoo-ext = { group = "io.hammerhead", name = "karoo-ext", version = "1.1.5" }
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCore" }
@ -43,6 +44,7 @@ androidx-navigation-runtime-ktx = { group = "androidx.navigation", name = "navig
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
androidx-cardview = { group = "androidx.cardview", name = "cardview", version.ref = "cardview" }
mapbox-sdk-turf = { module = "com.mapbox.mapboxsdk:mapbox-sdk-turf", version.ref = "mapboxSdkTurf" }
[bundles]
androidx-lifeycle = ["androidx-lifecycle-runtime-compose", "androidx-lifecycle-viewmodel-compose"]

View File

@ -34,6 +34,11 @@ dependencyResolutionManagement {
password = gprKey
}
}
// mapbox
maven {
url = uri("https://api.mapbox.com/downloads/v2/releases/maven")
}
}
}