Compare commits

...

14 Commits

Author SHA1 Message Date
b48138f196 Fix base url quotation
All checks were successful
Build / build (push) Successful in 4m42s
2025-05-29 15:17:06 +02:00
2bb9c59bff Only replace base url in manifest as part of ci build
All checks were successful
Build / build (push) Successful in 4m34s
2025-05-28 17:37:58 +02:00
6db3097e66 Fix gradle task order
All checks were successful
Build / build (push) Successful in 8m21s
2025-05-28 17:33:37 +02:00
da448b7407 Replace BASE_URL on build
All checks were successful
Build / build (push) Successful in 8m14s
2025-05-28 17:14:51 +02:00
76def03651 Fix BASE_URL
All checks were successful
Build / build (push) Successful in 6m55s
2025-05-28 16:50:58 +02:00
cdafc7cedd Test BASE_URL parameter
All checks were successful
Build / build (push) Successful in 7m10s
2025-05-28 16:35:37 +02:00
f020cc4e2f Update release parameters 2025-05-28 16:31:07 +02:00
bc1d03f053 Test release action
All checks were successful
Build / build (push) Successful in 4m36s
2025-05-28 16:08:38 +02:00
a54abf2667 Change to v3 release action
All checks were successful
Build / build (push) Successful in 7m16s
2025-05-28 15:23:07 +02:00
7ffb6867b7 Merge branch 'master' into feat/workout-range-distance 2025-05-28 15:15:43 +02:00
dfedd1afe5 Update changelog 2025-05-28 15:15:33 +02:00
65e7ea4d14 Fix powerbar is not redrawn in route progress mode 2025-05-27 22:35:38 +02:00
3e1fa2169b Update target range drawing 2025-05-27 22:29:42 +02:00
72b9a0f57e Add route distance data source, workout target indication 2025-05-26 22:56:37 +02:00
11 changed files with 541 additions and 157 deletions

View File

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

1
.gitignore vendored
View File

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

View File

@ -1,4 +1,5 @@
import java.util.Base64 import java.util.Base64
import com.android.build.gradle.tasks.ProcessApplicationManifest
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
@ -60,27 +61,36 @@ tasks.register("generateManifest") {
group = "build" group = "build"
doLast { doLast {
val baseUrl = System.getenv("BASE_URL") ?: "https://github.com/timklge/karoo-powerbar/releases/latest/download"
val manifestFile = file("$projectDir/manifest.json") val manifestFile = file("$projectDir/manifest.json")
val manifest = mapOf( val manifest = mapOf(
"label" to "Powerbar", "label" to "Powerbar",
"packageName" to "de.timklge.karoopowerbar", "packageName" to "de.timklge.karoopowerbar",
"iconUrl" to "https://github.com/timklge/karoo-powerbar/releases/latest/download/karoo-powerbar.png", "iconUrl" to "$baseUrl/karoo-powerbar.png",
"latestApkUrl" to "https://github.com/timklge/karoo-powerbar/releases/latest/download/app-release.apk", "latestApkUrl" to "$baseUrl/app-release.apk",
"latestVersion" to android.defaultConfig.versionName, "latestVersion" to android.defaultConfig.versionName,
"latestVersionCode" to android.defaultConfig.versionCode, "latestVersionCode" to android.defaultConfig.versionCode,
"developer" to "github.com/timklge", "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", "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( "screenshotUrls" to listOf(
"https://github.com/timklge/karoo-powerbar/releases/latest/download/powerbar_min.gif", "$baseUrl/powerbar_min.gif",
"https://github.com/timklge/karoo-powerbar/releases/latest/download/powerbar0.png", "$baseUrl/powerbar0.png",
"https://github.com/timklge/karoo-powerbar/releases/latest/download/powerbar2.png", "$baseUrl/powerbar2.png",
) )
) )
val gson = groovy.json.JsonBuilder(manifest).toPrettyString() val gson = groovy.json.JsonBuilder(manifest).toPrettyString()
manifestFile.writeText(gson) manifestFile.writeText(gson)
println("Generated manifest.json with version ${android.defaultConfig.versionName} (${android.defaultConfig.versionCode})") 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") dependsOn("generateManifest")
} }
tasks.withType<ProcessApplicationManifest>().configureEach {
if (name == "processDebugMainManifest" || name == "processReleaseMainManifest") {
dependsOn(tasks.named("generateManifest"))
}
}
dependencies { dependencies {
implementation(libs.hammerhead.karoo.ext) implementation(libs.hammerhead.karoo.ext)
@ -102,4 +117,6 @@ dependencies {
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.cardview) implementation(libs.androidx.cardview)
implementation(libs.androidx.lifecycle.runtime.ktx) 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 --> <!-- Provide Karoo System with information about delivery of your app -->
<meta-data <meta-data
android:name="io.hammerhead.karooext.MANIFEST_URL" 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> </application>

View File

@ -26,6 +26,9 @@ class CustomProgressBar @JvmOverloads constructor(
var progress: Double? = 0.5 var progress: Double? = 0.5
var location: PowerbarLocation = PowerbarLocation.BOTTOM var location: PowerbarLocation = PowerbarLocation.BOTTOM
var label: String = "" var label: String = ""
var minTarget: Double? = null
var maxTarget: Double? = null
var target: Double? = null
var showLabel: Boolean = true var showLabel: Boolean = true
@ColorInt var progressColor: Int = 0xFF2b86e6.toInt() @ColorInt var progressColor: Int = 0xFF2b86e6.toInt()
@ -33,6 +36,32 @@ class CustomProgressBar @JvmOverloads constructor(
set(value) { set(value) {
field = value field = value
textPaint.textSize = value.fontSize textPaint.textSize = value.fontSize
targetZoneStrokePaint.strokeWidth = when(value){
CustomProgressBarSize.SMALL -> 3f
CustomProgressBarSize.MEDIUM -> 6f
CustomProgressBarSize.LARGE -> 8f
}
targetIndicatorPaint.strokeWidth = when(value){
CustomProgressBarSize.SMALL -> 6f
CustomProgressBarSize.MEDIUM -> 8f
CustomProgressBarSize.LARGE -> 10f
}
}
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 { private val linePaint = Paint().apply {
@ -81,13 +110,23 @@ class CustomProgressBar @JvmOverloads constructor(
color = Color.WHITE color = Color.WHITE
strokeWidth = 3f strokeWidth = 3f
textSize = size.fontSize textSize = size.fontSize
typeface = Typeface.create(Typeface.MONOSPACE, Typeface.BOLD); typeface = Typeface.create(Typeface.MONOSPACE, Typeface.BOLD)
textAlign = Paint.Align.CENTER textAlign = Paint.Align.CENTER
} }
private val targetIndicatorPaint = Paint().apply {
isAntiAlias = true
strokeWidth = 8f
style = Paint.Style.STROKE
}
override fun onDrawForeground(canvas: Canvas) { override fun onDrawForeground(canvas: Canvas) {
super.onDrawForeground(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 linePaint.color = progressColor
lineStrokePaint.color = progressColor lineStrokePaint.color = progressColor
blurPaint.color = progressColor blurPaint.color = progressColor
@ -95,75 +134,291 @@ class CustomProgressBar @JvmOverloads constructor(
when (location) { when (location) {
PowerbarLocation.TOP -> { PowerbarLocation.TOP -> {
val barTop = 15f
val barBottom = barTop + size.barHeight
val rect = RectF( val rect = RectF(
1f, 1f,
15f, barTop,
((canvas.width.toDouble() - 1f) * (progress ?: 0.0).coerceIn(0.0, 1.0)).toFloat(), ((canvas.width.toDouble() - 1f) * (progress ?: 0.0).coerceIn(
15f + size.barHeight 0.0,
1.0
)).toFloat(),
barBottom
) )
canvas.drawRect(0f, 15f, canvas.width.toFloat(), 15f + size.barHeight, backgroundPaint) canvas.drawRect(0f, barTop, canvas.width.toFloat(), barBottom, 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,
barTop,
maxTargetX,
barBottom,
2f,
2f,
targetZoneFillPaint
)
}
if (progress != null) { if (progress != null) {
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
)
// 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,
barTop,
maxTargetX,
barBottom,
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, barTop, targetX, barBottom, targetIndicatorPaint)
}
if (showLabel) { if (showLabel) {
val textBounds = textPaint.measureText(label) val textContent =
val xOffset = (textBounds + 20).coerceAtLeast(10f) / 2f label // Store original label, as textPaint.measureText can be slow
val yOffset = when(size){ val measuredTextWidth = textPaint.measureText(textContent)
val labelBoxWidth = (measuredTextWidth + 20).coerceAtLeast(10f)
val labelBoxHeight = size.fontSize + 10f // Consistent height with padding
// Calculate horizontal position for the label box (centered around progress end, clamped)
val labelBoxLeft = (rect.right - labelBoxWidth / 2f).coerceIn(
0f,
canvas.width - labelBoxWidth
)
var labelBoxTop: Float
var labelBoxBottom: Float
var textYPosition: Float
if (target != null) { // If workout target is present, move label BELOW the bar
val labelPadding = 5f // Padding between bar and label box
labelBoxTop = barBottom + labelPadding
labelBoxBottom = labelBoxTop + labelBoxHeight
// Vertically center text in the new box
val labelBoxCenterY = labelBoxTop + labelBoxHeight / 2f
textYPosition =
labelBoxCenterY - (textPaint.ascent() + textPaint.descent()) / 2f
} else { // Original position for TOP
val yOffsetOriginal = when (size) {
CustomProgressBarSize.SMALL -> (size.fontSize - size.barHeight) / 2 + 2f CustomProgressBarSize.SMALL -> (size.fontSize - size.barHeight) / 2 + 2f
CustomProgressBarSize.MEDIUM, CustomProgressBarSize.LARGE -> (size.fontSize - size.barHeight) / 2 CustomProgressBarSize.MEDIUM, CustomProgressBarSize.LARGE -> (size.fontSize - size.barHeight) / 2
} }
val x = (rect.right - xOffset).coerceIn(0f..canvas.width-xOffset*2f) labelBoxTop = barTop - yOffsetOriginal
val y = rect.top - yOffset labelBoxBottom =
val r = x + xOffset * 2 barBottom + yOffsetOriginal // Original calculation was based on rect.bottom which is barBottom
val b = rect.bottom + yOffset textYPosition = barBottom + 6f // Original text Y
}
canvas.drawRoundRect(x, y, r, b, 2f, 2f, textBackgroundPaint) lineStrokePaint.color = if (target != null){
canvas.drawRoundRect(x, y, r, b, 2f, 2f, blurPaint) if (isTargetMet) Color.GREEN else Color.RED
canvas.drawRoundRect(x, y, r, b, 2f, 2f, lineStrokePaint) } else progressColor
canvas.drawText(label, x + xOffset, rect.top + size.barHeight + 6, textPaint) canvas.drawRoundRect(
} labelBoxLeft,
} labelBoxTop,
} labelBoxLeft + labelBoxWidth,
PowerbarLocation.BOTTOM -> { labelBoxBottom,
val rect = RectF( 2f,
1f, 2f,
canvas.height.toFloat() - 1f - size.barHeight, textBackgroundPaint
((canvas.width.toDouble() - 1f) * (progress ?: 0.0).coerceIn(0.0, 1.0)).toFloat(), )
canvas.height.toFloat() canvas.drawRoundRect(
labelBoxLeft,
labelBoxTop,
labelBoxLeft + labelBoxWidth,
labelBoxBottom,
2f,
2f,
blurPaint
)
canvas.drawRoundRect(
labelBoxLeft,
labelBoxTop,
labelBoxLeft + labelBoxWidth,
labelBoxBottom,
2f,
2f,
lineStrokePaint
) )
canvas.drawRect(0f, canvas.height.toFloat() - size.barHeight, canvas.width.toFloat(), canvas.height.toFloat(), backgroundPaint) canvas.drawText(
textContent,
labelBoxLeft + labelBoxWidth / 2f,
textYPosition,
textPaint
)
}
}
}
PowerbarLocation.BOTTOM -> {
val barTop = canvas.height.toFloat() - 1f - size.barHeight
val barBottom = canvas.height.toFloat()
val rect = RectF(
1f,
barTop,
((canvas.width.toDouble() - 1f) * (progress ?: 0.0).coerceIn(
0.0,
1.0
)).toFloat(),
barBottom
)
canvas.drawRect(0f, barTop, canvas.width.toFloat(), barBottom, 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,
barTop,
maxTargetX,
barBottom,
2f,
2f,
targetZoneFillPaint
)
}
if (progress != null) { if (progress != null) {
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
)
// 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,
barTop,
maxTargetX,
barBottom,
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, barTop, targetX, barBottom, targetIndicatorPaint)
}
if (showLabel) { if (showLabel) {
val textBounds = textPaint.measureText(label) val textContent = label // Store original label
val xOffset = (textBounds + 20).coerceAtLeast(10f) / 2f val measuredTextWidth = textPaint.measureText(textContent)
val yOffset = when(size){ val labelBoxWidth = (measuredTextWidth + 20).coerceAtLeast(10f)
val labelBoxHeight = size.fontSize + 10f // Consistent height with padding
// Calculate horizontal position for the label box (centered around progress end, clamped)
val labelBoxLeft = (rect.right - labelBoxWidth / 2f).coerceIn(
0f,
canvas.width - labelBoxWidth
)
var labelBoxTop: Float
var labelBoxBottom: Float
var textYPosition: Float
if (target != null) { // If workout target is present, move label ABOVE the bar
val labelPadding = 5f // Padding between bar and label box
labelBoxBottom = barTop - labelPadding
labelBoxTop = labelBoxBottom - labelBoxHeight
// Vertically center text in the new box
val labelBoxCenterY = labelBoxTop + labelBoxHeight / 2f
textYPosition =
labelBoxCenterY - (textPaint.ascent() + textPaint.descent()) / 2f
} else { // Original position for BOTTOM
val yOffsetOriginal = when (size) {
CustomProgressBarSize.SMALL -> size.fontSize / 2 + 2f CustomProgressBarSize.SMALL -> size.fontSize / 2 + 2f
CustomProgressBarSize.MEDIUM -> size.fontSize / 2 CustomProgressBarSize.MEDIUM -> size.fontSize / 2
CustomProgressBarSize.LARGE -> size.fontSize / 2 - 5f CustomProgressBarSize.LARGE -> size.fontSize / 2 - 5f
} }
val x = (rect.right - xOffset).coerceIn(0f..canvas.width-xOffset*2f) labelBoxTop = barTop - yOffsetOriginal
val y = (rect.top - yOffset) labelBoxBottom = barBottom + 5f // Original 'b' calculation
val r = x + xOffset * 2 textYPosition =
val b = rect.bottom + 5 barBottom - 1f // Original text Y (rect.top + size.barHeight -1)
}
canvas.drawRoundRect(x, y, r, b, 2f, 2f, textBackgroundPaint) lineStrokePaint.color = if (target != null){
canvas.drawRoundRect(x, y, r, b, 2f, 2f, blurPaint) if (isTargetMet) Color.GREEN else Color.RED
canvas.drawRoundRect(x, y, r, b, 2f, 2f, lineStrokePaint) } else progressColor
canvas.drawText(label, x + xOffset, rect.top + size.barHeight - 1, textPaint) canvas.drawRoundRect(
labelBoxLeft,
labelBoxTop,
labelBoxLeft + labelBoxWidth,
labelBoxBottom,
2f,
2f,
textBackgroundPaint
)
canvas.drawRoundRect(
labelBoxLeft,
labelBoxTop,
labelBoxLeft + labelBoxWidth,
labelBoxBottom,
2f,
2f,
blurPaint
)
canvas.drawRoundRect(
labelBoxLeft,
labelBoxTop,
labelBoxLeft + labelBoxWidth,
labelBoxBottom,
2f,
2f,
lineStrokePaint
)
canvas.drawText(
textContent,
labelBoxLeft + labelBoxWidth / 2f,
textYPosition,
textPaint
)
} }
} }
} }

View File

@ -1,14 +1,18 @@
package de.timklge.karoopowerbar package de.timklge.karoopowerbar
import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.OnNavigationState
import io.hammerhead.karooext.models.OnStreamState import io.hammerhead.karooext.models.OnStreamState
import io.hammerhead.karooext.models.RideState import io.hammerhead.karooext.models.RideState
import io.hammerhead.karooext.models.StreamState import io.hammerhead.karooext.models.StreamState
import io.hammerhead.karooext.models.UserProfile import io.hammerhead.karooext.models.UserProfile
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.transform
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
val jsonWithUnknownKeys = Json { ignoreUnknownKeys = true } 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> { fun KarooSystemService.streamUserProfile(): Flow<UserProfile> {
return callbackFlow { return callbackFlow {
val listenerId = addConsumer { userProfile: UserProfile -> 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.WindowInsets
import android.view.WindowManager import android.view.WindowManager
import androidx.annotation.ColorRes 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.KarooPowerbarExtension.Companion.TAG
import de.timklge.karoopowerbar.screens.SelectedSource import de.timklge.karoopowerbar.screens.SelectedSource
import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.DataPoint
import io.hammerhead.karooext.models.DataType import io.hammerhead.karooext.models.DataType
import io.hammerhead.karooext.models.OnNavigationState
import io.hammerhead.karooext.models.StreamState import io.hammerhead.karooext.models.StreamState
import io.hammerhead.karooext.models.UserProfile import io.hammerhead.karooext.models.UserProfile
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -29,12 +34,15 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlin.math.roundToInt 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 return (value - fromMin) * (toMax - toMin) / (fromMax - fromMin) + toMin
} }
@ -48,6 +56,12 @@ class Window(
val showLabel: Boolean, val showLabel: Boolean,
val powerbarSize: CustomProgressBarSize val powerbarSize: CustomProgressBarSize
) { ) {
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 val rootView: View
private var layoutParams: WindowManager.LayoutParams? = null private var layoutParams: WindowManager.LayoutParams? = null
private val windowManager: WindowManager private val windowManager: WindowManager
@ -100,8 +114,6 @@ class Window(
private val karooSystem: KarooSystemService = KarooSystemService(context) private val karooSystem: KarooSystemService = KarooSystemService(context)
data class StreamData(val userProfile: UserProfile, val value: Double?, val settings: PowerbarSettings? = null)
private var serviceJob: Job? = null private var serviceJob: Job? = null
@SuppressLint("UnspecifiedRegisterReceiverFlag") @SuppressLint("UnspecifiedRegisterReceiverFlag")
@ -136,6 +148,7 @@ class Window(
SelectedSource.SPEED_3S -> streamSpeed(true) SelectedSource.SPEED_3S -> streamSpeed(true)
SelectedSource.CADENCE -> streamCadence(false) SelectedSource.CADENCE -> streamCadence(false)
SelectedSource.CADENCE_3S -> streamCadence(true) SelectedSource.CADENCE_3S -> streamCadence(true)
SelectedSource.ROUTE_PROGRESS -> streamRouteProgress()
else -> {} else -> {}
} }
} }
@ -151,6 +164,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) { private suspend fun streamSpeed(smoothed: Boolean) {
val speedFlow = karooSystem.streamDataFlow(if(smoothed) DataType.Type.SMOOTHED_3S_AVERAGE_SPEED else DataType.Type.SPEED) val speedFlow = karooSystem.streamDataFlow(if(smoothed) DataType.Type.SMOOTHED_3S_AVERAGE_SPEED else DataType.Type.SPEED)
.map { (it as? StreamState.Streaming)?.dataPoint?.singleValue } .map { (it as? StreamState.Streaming)?.dataPoint?.singleValue }
@ -158,12 +220,11 @@ class Window(
val settingsFlow = context.streamSettings() val settingsFlow = context.streamSettings()
karooSystem.streamUserProfile() data class StreamData(val userProfile: UserProfile, val value: Double?, val settings: PowerbarSettings? = null)
.distinctUntilChanged()
.combine(speedFlow) { userProfile, speed -> StreamData(userProfile, speed) } combine(karooSystem.streamUserProfile(), speedFlow, settingsFlow) { userProfile, speed, settings ->
.combine(settingsFlow) { streamData, settings -> streamData.copy(settings = settings) } StreamData(userProfile, speed, settings)
.distinctUntilChanged() }.distinctUntilChanged().throttle(1_000).collect { streamData ->
.collect { streamData ->
val valueMetersPerSecond = streamData.value val valueMetersPerSecond = streamData.value
val value = when (streamData.userProfile.preferredUnit.distance){ val value = when (streamData.userProfile.preferredUnit.distance){
UserProfile.PreferredUnit.UnitType.IMPERIAL -> valueMetersPerSecond?.times(2.23694) UserProfile.PreferredUnit.UnitType.IMPERIAL -> valueMetersPerSecond?.times(2.23694)
@ -173,8 +234,7 @@ class Window(
if (value != null && valueMetersPerSecond != null) { if (value != null && valueMetersPerSecond != null) {
val minSpeed = streamData.settings?.minSpeed ?: PowerbarSettings.defaultMinSpeedMs val minSpeed = streamData.settings?.minSpeed ?: PowerbarSettings.defaultMinSpeedMs
val maxSpeed = streamData.settings?.maxSpeed ?: PowerbarSettings.defaultMaxSpeedMs val maxSpeed = streamData.settings?.maxSpeed ?: PowerbarSettings.defaultMaxSpeedMs
val progress = val progress = remap(valueMetersPerSecond, minSpeed.toDouble(), maxSpeed.toDouble(), 0.0, 1.0) ?: 0.0
remap(valueMetersPerSecond, minSpeed.toDouble(), maxSpeed.toDouble(), 0.0, 1.0)
@ColorRes val zoneColorRes = Zone.entries[(progress * Zone.entries.size).roundToInt().coerceIn(0..<Zone.entries.size)].colorResource @ColorRes val zoneColorRes = Zone.entries[(progress * Zone.entries.size).roundToInt().coerceIn(0..<Zone.entries.size)].colorResource
@ -199,25 +259,30 @@ class Window(
} }
private suspend fun streamCadence(smoothed: Boolean) { 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 } .map { (it as? StreamState.Streaming)?.dataPoint?.singleValue }
.distinctUntilChanged() .distinctUntilChanged()
val settingsFlow = context.streamSettings() data class StreamData(val userProfile: UserProfile, val value: Double?, val settings: PowerbarSettings? = null, val cadenceTarget: DataPoint? = null)
karooSystem.streamUserProfile() val settingsFlow = context.streamSettings()
val cadenceTargetFlow = karooSystem.streamDataFlow("TYPE_WORKOUT_CADENCE_TARGET_ID")
.map { (it as? StreamState.Streaming)?.dataPoint }
.distinctUntilChanged() .distinctUntilChanged()
.combine(speedFlow) { userProfile, speed -> StreamData(userProfile, speed) }
.combine(settingsFlow) { streamData, settings -> streamData.copy(settings = settings) } combine(karooSystem.streamUserProfile(), cadenceFlow, settingsFlow, cadenceTargetFlow) { userProfile, speed, settings, cadenceTarget ->
.distinctUntilChanged() StreamData(userProfile, speed, settings, cadenceTarget)
.collect { streamData -> }.distinctUntilChanged().throttle(1_000).collect { streamData ->
val value = streamData.value?.roundToInt() val value = streamData.value?.roundToInt()
if (value != null) { if (value != null) {
val minCadence = streamData.settings?.minCadence ?: PowerbarSettings.defaultMinCadence val minCadence = streamData.settings?.minCadence ?: PowerbarSettings.defaultMinCadence
val maxCadence = streamData.settings?.maxCadence ?: PowerbarSettings.defaultMaxCadence val maxCadence = streamData.settings?.maxCadence ?: PowerbarSettings.defaultMaxCadence
val progress = val progress = remap(value.toDouble(), minCadence.toDouble(), maxCadence.toDouble(), 0.0, 1.0) ?: 0.0
remap(value.toDouble(), minCadence.toDouble(), maxCadence.toDouble(), 0.0, 1.0)
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)
@ColorRes val zoneColorRes = Zone.entries[(progress * Zone.entries.size).roundToInt().coerceIn(0..<Zone.entries.size)].colorResource @ColorRes val zoneColorRes = Zone.entries[(progress * Zone.entries.size).roundToInt().coerceIn(0..<Zone.entries.size)].colorResource
@ -247,13 +312,15 @@ class Window(
.distinctUntilChanged() .distinctUntilChanged()
val settingsFlow = context.streamSettings() val settingsFlow = context.streamSettings()
val hrTargetFlow = karooSystem.streamDataFlow("TYPE_WORKOUT_HEART_RATE_TARGET_ID")
.map { (it as? StreamState.Streaming)?.dataPoint }
.distinctUntilChanged()
karooSystem.streamUserProfile() data class StreamData(val userProfile: UserProfile, val value: Double?, val settings: PowerbarSettings? = null, val heartrateTarget: DataPoint? = null)
.distinctUntilChanged()
.combine(hrFlow) { userProfile, hr -> StreamData(userProfile, hr) } combine(karooSystem.streamUserProfile(), hrFlow, settingsFlow, hrTargetFlow) { userProfile, hr, settings, hrTarget ->
.combine(settingsFlow) { streamData, settings -> streamData.copy(settings = settings) } StreamData(userProfile, hr, settings, hrTarget)
.distinctUntilChanged() }.distinctUntilChanged().throttle(1_000).collect { streamData ->
.collect { streamData ->
val value = streamData.value?.roundToInt() val value = streamData.value?.roundToInt()
if (value != null) { if (value != null) {
@ -263,6 +330,10 @@ class Window(
val maxHr = customMaxHr ?: streamData.userProfile.maxHr val maxHr = customMaxHr ?: streamData.userProfile.maxHr
val progress = remap(value.toDouble(), minHr.toDouble(), maxHr.toDouble(), 0.0, 1.0) 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) { powerbar.progressColor = if (streamData.settings?.useZoneColors == true) {
context.getColor(getZone(streamData.userProfile.heartRateZones, value)?.colorResource ?: R.color.zone7) context.getColor(getZone(streamData.userProfile.heartRateZones, value)?.colorResource ?: R.color.zone7)
} else { } else {
@ -296,21 +367,28 @@ class Window(
val settingsFlow = context.streamSettings() 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() .distinctUntilChanged()
.combine(powerFlow) { userProfile, hr -> StreamData(userProfile, hr) }
.combine(settingsFlow) { streamData, settings -> streamData.copy(settings = settings) } data class StreamData(val userProfile: UserProfile, val value: Double?, val settings: PowerbarSettings? = null, val powerTarget: DataPoint? = null)
.distinctUntilChanged()
.collect { streamData -> 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() val value = streamData.value?.roundToInt()
if (value != null) { if (value != null) {
val customMinPower = if (streamData.settings?.useCustomPowerRange == true) streamData.settings.minPower else 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 customMaxPower = if (streamData.settings?.useCustomPowerRange == true) streamData.settings.maxPower else null
val minPower = customMinPower ?: streamData.userProfile.powerZones.first().min val minPower = customMinPower ?: streamData.userProfile.powerZones.first().min
val maxPower = customMaxPower ?: (streamData.userProfile.powerZones.last().min + 50) val maxPower = customMaxPower ?: (streamData.userProfile.powerZones.last().min + 30)
val progress = remap(value.toDouble(), minPower.toDouble(), maxPower.toDouble(), 0.0, 1.0) 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) { powerbar.progressColor = if (streamData.settings?.useZoneColors == true) {
context.getColor(getZone(streamData.userProfile.powerZones, value)?.colorResource ?: R.color.zone7) context.getColor(getZone(streamData.userProfile.powerZones, value)?.colorResource ?: R.color.zone7)
} else { } else {

View File

@ -87,7 +87,8 @@ enum class SelectedSource(val id: String, val label: String) {
SPEED("speed", "Speed"), SPEED("speed", "Speed"),
SPEED_3S("speed_3s", "Speed (3 sec avg"), SPEED_3S("speed_3s", "Speed (3 sec avg"),
CADENCE("cadence", "Cadence"), 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 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" <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="40dp"> android:layout_height="80dp">
<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="40dp" android:layout_height="80dp"
android:layout_gravity="center" /> android:layout_gravity="center" />
</FrameLayout> </FrameLayout>

View File

@ -14,6 +14,7 @@ lifecycleRuntimeKtx = "2.8.7"
navigationRuntimeKtx = "2.8.4" navigationRuntimeKtx = "2.8.4"
navigationCompose = "2.8.4" navigationCompose = "2.8.4"
cardview = "1.0.0" cardview = "1.0.0"
mapboxSdkTurf = "7.3.1"
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } 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-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }
androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
color = { module = "com.maxkeppeler.sheets-compose-dialogs:color", version.ref = "color" } 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" } 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" } 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" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
androidx-cardview = { group = "androidx.cardview", name = "cardview", version.ref = "cardview" } androidx-cardview = { group = "androidx.cardview", name = "cardview", version.ref = "cardview" }
mapbox-sdk-turf = { module = "com.mapbox.mapboxsdk:mapbox-sdk-turf", version.ref = "mapboxSdkTurf" }
[bundles] [bundles]
androidx-lifeycle = ["androidx-lifecycle-runtime-compose", "androidx-lifecycle-viewmodel-compose"] androidx-lifeycle = ["androidx-lifecycle-runtime-compose", "androidx-lifecycle-viewmodel-compose"]

View File

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