Compare commits

...

4 Commits

Author SHA1 Message Date
b176b91f66 Update changelog
All checks were successful
Build / build (push) Successful in 4m40s
2025-06-02 19:23:44 +02:00
timklge
91c8eec977
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
2025-06-02 19:18:23 +02:00
timklge
820fdb6ef9
Add separate settings for font size, bar size (#38)
* Add separate settings for font size, bar size

* Remove labelVerticalPadding
2025-06-02 17:18:49 +02:00
timklge
aa764bd0a8
Make bar background transparent by default (#37)
fix #35
2025-06-02 16:11:29 +02:00
14 changed files with 536 additions and 184 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* Make bars transparent by default\n* Split size setting",
"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

@ -11,14 +11,6 @@ import android.util.AttributeSet
import android.view.View import android.view.View
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.core.graphics.ColorUtils import androidx.core.graphics.ColorUtils
import kotlinx.serialization.Serializable
@Serializable
enum class CustomProgressBarSize(val id: String, val label: String, val fontSize: Float, val barHeight: Float) {
SMALL("small", "Small", 35f, 10f),
MEDIUM("medium", "Medium", 40f, 15f),
LARGE("large", "Large", 60f, 25f),
}
class CustomProgressBar @JvmOverloads constructor( class CustomProgressBar @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null context: Context, attrs: AttributeSet? = null
@ -26,15 +18,52 @@ 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
var barBackground: Boolean = false
@ColorInt var progressColor: Int = 0xFF2b86e6.toInt() @ColorInt var progressColor: Int = 0xFF2b86e6.toInt()
var size = CustomProgressBarSize.MEDIUM var fontSize = CustomProgressBarFontSize.MEDIUM
set(value) { set(value) {
field = value field = value
textPaint.textSize = value.fontSize textPaint.textSize = value.fontSize
invalidate() // Redraw to apply new font size
} }
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 { private val linePaint = Paint().apply {
isAntiAlias = true isAntiAlias = true
strokeWidth = 1f strokeWidth = 1f
@ -51,7 +80,7 @@ class CustomProgressBar @JvmOverloads constructor(
private val blurPaint = Paint().apply { private val blurPaint = Paint().apply {
isAntiAlias = true isAntiAlias = true
strokeWidth = 4f strokeWidth = 6f
style = Paint.Style.STROKE style = Paint.Style.STROKE
color = progressColor color = progressColor
maskFilter = BlurMaskFilter(3f, BlurMaskFilter.Blur.NORMAL) maskFilter = BlurMaskFilter(3f, BlurMaskFilter.Blur.NORMAL)
@ -59,7 +88,7 @@ class CustomProgressBar @JvmOverloads constructor(
private val blurPaintHighlight = Paint().apply { private val blurPaintHighlight = Paint().apply {
isAntiAlias = true isAntiAlias = true
strokeWidth = 8f strokeWidth = 10f
style = Paint.Style.FILL_AND_STROKE style = Paint.Style.FILL_AND_STROKE
color = ColorUtils.blendARGB(progressColor, 0xFFFFFF, 0.5f) color = ColorUtils.blendARGB(progressColor, 0xFFFFFF, 0.5f)
maskFilter = BlurMaskFilter(6f, BlurMaskFilter.Blur.NORMAL) maskFilter = BlurMaskFilter(6f, BlurMaskFilter.Blur.NORMAL)
@ -80,93 +109,210 @@ class CustomProgressBar @JvmOverloads constructor(
private val textPaint = Paint().apply { private val textPaint = Paint().apply {
color = Color.WHITE color = Color.WHITE
strokeWidth = 3f strokeWidth = 3f
textSize = size.fontSize textSize = fontSize.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
blurPaintHighlight.color = ColorUtils.blendARGB(progressColor, 0xFFFFFF, 0.5f) blurPaintHighlight.color = ColorUtils.blendARGB(progressColor, 0xFFFFFF, 0.5f)
when(location){ when (location) {
PowerbarLocation.TOP -> { PowerbarLocation.TOP -> {
val rect = RectF( val rect = RectF(
1f, 1f,
15f, 15f,
((canvas.width.toDouble() - 1f) * (progress ?: 0.0).coerceIn(0.0, 1.0)).toFloat(), ((canvas.width.toDouble() - 1f) * (progress ?: 0.0).coerceIn(0.0, 1.0)).toFloat(),
15f + size.barHeight 15f + barSize.barHeight // barSize.barHeight will be 0f if NONE
) )
canvas.drawRect(0f, 15f, canvas.width.toFloat(), 15f + size.barHeight, backgroundPaint) // Draw bar components only if barSize is not NONE
if (barSize != CustomProgressBarBarSize.NONE) {
if (barBackground){
canvas.drawRect(0f, 15f, canvas.width.toFloat(), 15f + barSize.barHeight, backgroundPaint)
}
if (progress != null) { // Draw target zone fill behind the progress bar
canvas.drawRoundRect(rect, 2f, 2f, blurPaint) if (minTarget != null && maxTarget != null) {
canvas.drawRoundRect(rect, 2f, 2f, linePaint) val minTargetX = (canvas.width * minTarget!!).toFloat()
val maxTargetX = (canvas.width * maxTarget!!).toFloat()
canvas.drawRoundRect(
minTargetX,
15f,
maxTargetX,
15f + barSize.barHeight,
2f,
2f,
targetZoneFillPaint
)
}
canvas.drawRoundRect(rect.right-4, rect.top, rect.right+4, rect.bottom, 2f, 2f, blurPaintHighlight) if (progress != null) {
canvas.drawRoundRect(rect, 2f, 2f, blurPaint)
if (showLabel){ canvas.drawRoundRect(rect, 2f, 2f, linePaint)
val textBounds = textPaint.measureText(label) canvas.drawRoundRect(rect.right-4, rect.top, rect.right+4, rect.bottom, 2f, 2f, blurPaintHighlight)
val xOffset = (textBounds + 20).coerceAtLeast(10f) / 2f
val yOffset = when(size){
CustomProgressBarSize.SMALL -> (size.fontSize - size.barHeight) / 2 + 2f
CustomProgressBarSize.MEDIUM, CustomProgressBarSize.LARGE -> (size.fontSize - size.barHeight) / 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 + size.barHeight + 6, textPaint)
} }
} }
// 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)
val r = x + xOffset * 2
val fm = textPaint.fontMetrics
// barCenterY calculation uses barSize.barHeight, which is 0f for NONE,
// correctly centering the label on the 15f line.
val barCenterY = rect.top + barSize.barHeight / 2f
val centeredTextBaselineY = barCenterY - (fm.ascent + fm.descent) / 2f
val calculatedTextBoxTop = centeredTextBaselineY + fm.ascent
val finalTextBoxTop = calculatedTextBoxTop.coerceAtLeast(0f)
val finalTextBaselineY = finalTextBoxTop - fm.ascent
val finalTextBoxBottom = finalTextBaselineY + fm.descent
canvas.drawRoundRect(x, finalTextBoxTop, r, finalTextBoxBottom, 2f, 2f, textBackgroundPaint)
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 -> { PowerbarLocation.BOTTOM -> {
val rect = RectF( val rect = RectF(
1f, 1f,
canvas.height.toFloat() - 1f - size.barHeight, canvas.height.toFloat() - 1f - barSize.barHeight, // barSize.barHeight will be 0f if NONE
((canvas.width.toDouble() - 1f) * (progress ?: 0.0).coerceIn(0.0, 1.0)).toFloat(), ((canvas.width.toDouble() - 1f) * (progress ?: 0.0).coerceIn(0.0, 1.0)).toFloat(),
canvas.height.toFloat() canvas.height.toFloat()
) )
canvas.drawRect(0f, canvas.height.toFloat() - size.barHeight, canvas.width.toFloat(), canvas.height.toFloat(), backgroundPaint) // Draw bar components only if barSize is not NONE
if (barSize != CustomProgressBarBarSize.NONE) {
if (barBackground){
// Use barSize.barHeight for background top calculation
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)
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) { if (progress != null) {
canvas.drawRoundRect(rect, 2f, 2f, blurPaint) // Draw target zone stroke after progress bar, before label
canvas.drawRoundRect(rect, 2f, 2f, linePaint) 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
)
}
canvas.drawRoundRect(rect.right-4, rect.top, rect.right+4, rect.bottom, 2f, 2f, blurPaintHighlight) // 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){ 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 textBounds = textPaint.measureText(label)
val xOffset = (textBounds + 20).coerceAtLeast(10f) / 2f val xOffset = (textBounds + 20).coerceAtLeast(10f) / 2f
val yOffset = when(size){
CustomProgressBarSize.SMALL -> size.fontSize / 2 + 2f
CustomProgressBarSize.MEDIUM -> size.fontSize / 2
CustomProgressBarSize.LARGE -> size.fontSize / 2 - 5f
}
val x = (rect.right - xOffset).coerceIn(0f..canvas.width-xOffset*2f) val x = (rect.right - xOffset).coerceIn(0f..canvas.width-xOffset*2f)
val y = (rect.top - yOffset)
val r = x + xOffset * 2 val r = x + xOffset * 2
val b = rect.bottom + 5
canvas.drawRoundRect(x, y, r, b, 2f, 2f, textBackgroundPaint) // textDrawBaselineY calculation uses rect.top and barSize.barHeight.
canvas.drawRoundRect(x, y, r, b, 2f, 2f, blurPaint) // If NONE, barSize.barHeight is 0f. rect.top becomes canvas.height - 1f.
canvas.drawRoundRect(x, y, r, b, 2f, 2f, lineStrokePaint) // So, baseline is (canvas.height - 1f) + 0f - 1f = canvas.height - 2f.
val textDrawBaselineY = rect.top + barSize.barHeight - 1f
val yBox = textDrawBaselineY + textPaint.ascent()
val bBox = textDrawBaselineY + textPaint.descent()
canvas.drawText(label, x + xOffset, rect.top + size.barHeight - 1, textPaint) canvas.drawRoundRect(x, yBox, r, bBox, 2f, 2f, textBackgroundPaint)
canvas.drawRoundRect(x, yBox, r, bBox, 2f, 2f, blurPaint)
canvas.drawRoundRect(x, yBox, r, bBox, 2f, 2f, lineStrokePaint)
canvas.drawText(label, x + xOffset, textDrawBaselineY, textPaint)
} }
} }
} }
} }
} }
} }

View File

@ -0,0 +1,45 @@
package de.timklge.karoopowerbar
import kotlinx.serialization.Serializable
@Serializable
enum class CustomProgressBarSize(val id: String, val label: String, val fontSize: Float, val barHeight: Float) {
SMALL("small", "Small", 35f, 10f),
MEDIUM("medium", "Medium", 40f, 15f),
LARGE("large", "Large", 60f, 25f),
}
@Serializable
enum class CustomProgressBarFontSize(val id: String, val label: String, val fontSize: Float) {
SMALL("small", "Small", 35f),
MEDIUM("medium", "Medium", 40f),
LARGE("large", "Large", 60f);
companion object {
fun fromSize(size: CustomProgressBarSize): CustomProgressBarFontSize {
return when (size) {
CustomProgressBarSize.SMALL -> SMALL
CustomProgressBarSize.MEDIUM -> MEDIUM
CustomProgressBarSize.LARGE -> LARGE
}
}
}
}
@Serializable
enum class CustomProgressBarBarSize(val id: String, val label: String, val barHeight: Float) {
NONE("none", "None", 0f),
SMALL("small", "Small", 10f),
MEDIUM("medium", "Medium", 15f),
LARGE("large", "Large", 25f);
companion object {
fun fromSize(size: CustomProgressBarSize): CustomProgressBarBarSize {
return when (size) {
CustomProgressBarSize.SMALL -> SMALL
CustomProgressBarSize.MEDIUM -> MEDIUM
CustomProgressBarSize.LARGE -> LARGE
}
}
}
}

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 ->
@ -44,4 +59,11 @@ fun KarooSystemService.streamUserProfile(): Flow<UserProfile> {
removeConsumer(listenerId) removeConsumer(listenerId)
} }
} }
} }
fun<T> Flow<T>.throttle(timeout: Long): Flow<T> = this
.conflate()
.transform {
emit(it)
delay(timeout)
}

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, settings.showLabelOnBars, settings.barSize).apply { Window(this@ForegroundService, PowerbarLocation.BOTTOM, settings.showLabelOnBars, settings.barBackground, settings.barBarSize, settings.barFontSize).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, settings.showLabelOnBars, settings.barSize).apply { Window(this@ForegroundService, PowerbarLocation.TOP, settings.showLabelOnBars, settings.barBackground, settings.barBarSize, settings.barFontSize).apply {
selectedSource = settings.topBarSource selectedSource = settings.topBarSource
open() open()
windows.add(this) windows.add(this)

View File

@ -2,7 +2,6 @@ package de.timklge.karoopowerbar
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import de.timklge.karoopowerbar.screens.SelectedSource import de.timklge.karoopowerbar.screens.SelectedSource
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -21,7 +20,10 @@ data class PowerbarSettings(
val onlyShowWhileRiding: Boolean = true, val onlyShowWhileRiding: Boolean = true,
val showLabelOnBars: Boolean = true, val showLabelOnBars: Boolean = true,
val useZoneColors: Boolean = true, val useZoneColors: Boolean = true,
val barBackground: Boolean = false,
val barSize: CustomProgressBarSize = CustomProgressBarSize.MEDIUM, val barSize: CustomProgressBarSize = CustomProgressBarSize.MEDIUM,
val barFontSize: CustomProgressBarFontSize = CustomProgressBarFontSize.fromSize(barSize),
val barBarSize: CustomProgressBarBarSize = CustomProgressBarBarSize.fromSize(barSize),
val minCadence: Int = defaultMinCadence, val maxCadence: Int = defaultMaxCadence, val minCadence: Int = defaultMinCadence, val maxCadence: Int = defaultMaxCadence,
val minSpeed: Float = defaultMinSpeedMs, val maxSpeed: Float = defaultMaxSpeedMs, // 50 km/h in m/s val minSpeed: Float = defaultMinSpeedMs, val maxSpeed: Float = defaultMaxSpeedMs, // 50 km/h in m/s

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
} }
@ -46,8 +54,16 @@ class Window(
private val context: Context, private val context: Context,
val powerbarLocation: PowerbarLocation = PowerbarLocation.BOTTOM, val powerbarLocation: PowerbarLocation = PowerbarLocation.BOTTOM,
val showLabel: Boolean, val showLabel: Boolean,
val powerbarSize: CustomProgressBarSize val barBackground: Boolean,
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 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 +116,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")
@ -122,7 +136,9 @@ class Window(
powerbar.progress = null powerbar.progress = null
powerbar.location = powerbarLocation powerbar.location = powerbarLocation
powerbar.showLabel = showLabel powerbar.showLabel = showLabel
powerbar.size = powerbarSize powerbar.barBackground = barBackground
powerbar.fontSize = powerbarFontSize
powerbar.barSize = powerbarBarSize
powerbar.invalidate() powerbar.invalidate()
Log.i(TAG, "Streaming $selectedSource") Log.i(TAG, "Streaming $selectedSource")
@ -136,6 +152,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 +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) { 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 +224,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 +238,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,46 +263,51 @@ 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()
data class StreamData(val userProfile: UserProfile, val value: Double?, val settings: PowerbarSettings? = null, val cadenceTarget: DataPoint? = null)
val settingsFlow = context.streamSettings() val settingsFlow = context.streamSettings()
val cadenceTargetFlow = karooSystem.streamDataFlow("TYPE_WORKOUT_CADENCE_TARGET_ID")
karooSystem.streamUserProfile() .map { (it as? StreamState.Streaming)?.dataPoint }
.distinctUntilChanged() .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) { combine(karooSystem.streamUserProfile(), cadenceFlow, settingsFlow, cadenceTargetFlow) { userProfile, speed, settings, cadenceTarget ->
val minCadence = streamData.settings?.minCadence ?: PowerbarSettings.defaultMinCadence StreamData(userProfile, speed, settings, cadenceTarget)
val maxCadence = streamData.settings?.maxCadence ?: PowerbarSettings.defaultMaxCadence }.distinctUntilChanged().throttle(1_000).collect { streamData ->
val progress = val value = streamData.value?.roundToInt()
remap(value.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 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) { powerbar.minTarget = remap(streamData.cadenceTarget?.values[FIELD_TARGET_MIN_ID]?.toDouble(), minCadence.toDouble(), maxCadence.toDouble(), 0.0, 1.0)
context.getColor(zoneColorRes) powerbar.maxTarget = remap(streamData.cadenceTarget?.values[FIELD_TARGET_MAX_ID]?.toDouble(), minCadence.toDouble(), maxCadence.toDouble(), 0.0, 1.0)
} else { powerbar.target = remap(streamData.cadenceTarget?.values[FIELD_TARGET_VALUE_ID]?.toDouble(), minCadence.toDouble(), maxCadence.toDouble(), 0.0, 1.0)
context.getColor(R.color.zone0)
}
powerbar.progress = if (value > 0) progress else null
powerbar.label = "$value"
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 { } else {
powerbar.progressColor = context.getColor(R.color.zone0) context.getColor(R.color.zone0)
powerbar.progress = null
powerbar.label = "?"
Log.d(TAG, "Cadence: Unavailable")
} }
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() { private suspend fun streamHeartrate() {
@ -247,40 +316,46 @@ class Window(
.distinctUntilChanged() .distinctUntilChanged()
val settingsFlow = context.streamSettings() val settingsFlow = context.streamSettings()
val hrTargetFlow = karooSystem.streamDataFlow("TYPE_WORKOUT_HEART_RATE_TARGET_ID")
karooSystem.streamUserProfile() .map { (it as? StreamState.Streaming)?.dataPoint }
.distinctUntilChanged() .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) { data class StreamData(val userProfile: UserProfile, val value: Double?, val settings: PowerbarSettings? = null, val heartrateTarget: DataPoint? = 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.progressColor = if (streamData.settings?.useZoneColors == true) { combine(karooSystem.streamUserProfile(), hrFlow, settingsFlow, hrTargetFlow) { userProfile, hr, settings, hrTarget ->
context.getColor(getZone(streamData.userProfile.heartRateZones, value)?.colorResource ?: R.color.zone7) StreamData(userProfile, hr, settings, hrTarget)
} else { }.distinctUntilChanged().throttle(1_000).collect { streamData ->
context.getColor(R.color.zone0) val value = streamData.value?.roundToInt()
}
powerbar.progress = if (value > 0) progress else null
powerbar.label = "$value"
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 { } else {
powerbar.progressColor = context.getColor(R.color.zone0) context.getColor(R.color.zone0)
powerbar.progress = null
powerbar.label = "?"
Log.d(TAG, "Hr: Unavailable")
} }
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){ enum class PowerStreamSmoothing(val dataTypeId: String){
@ -293,42 +368,49 @@ class Window(
val powerFlow = karooSystem.streamDataFlow(smoothed.dataTypeId) val powerFlow = karooSystem.streamDataFlow(smoothed.dataTypeId)
.map { (it as? StreamState.Streaming)?.dataPoint?.singleValue } .map { (it as? StreamState.Streaming)?.dataPoint?.singleValue }
.distinctUntilChanged() .distinctUntilChanged()
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) }
.distinctUntilChanged()
.collect { streamData ->
val value = streamData.value?.roundToInt()
if (value != null) { data class StreamData(val userProfile: UserProfile, val value: Double?, val settings: PowerbarSettings? = null, val powerTarget: DataPoint? = 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)
powerbar.progressColor = if (streamData.settings?.useZoneColors == true) { combine(karooSystem.streamUserProfile(), powerFlow, settingsFlow, powerTargetFlow) { userProfile, hr, settings, powerTarget ->
context.getColor(getZone(streamData.userProfile.powerZones, value)?.colorResource ?: R.color.zone7) StreamData(userProfile, hr, settings, powerTarget)
} else { }.distinctUntilChanged().throttle(1_000).collect { streamData ->
context.getColor(R.color.zone0) val value = streamData.value?.roundToInt()
}
powerbar.progress = if (value > 0) progress else null
powerbar.label = "${value}W"
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 { } else {
powerbar.progressColor = context.getColor(R.color.zone0) context.getColor(R.color.zone0)
powerbar.progress = null
powerbar.label = "?"
Log.d(TAG, "Power: Unavailable")
} }
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 private var currentHideJob: Job? = null

View File

@ -57,6 +57,8 @@ import androidx.compose.ui.window.Dialog
import androidx.core.content.ContextCompat.startActivity import androidx.core.content.ContextCompat.startActivity
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.LifecycleResumeEffect
import de.timklge.karoopowerbar.CustomProgressBarBarSize
import de.timklge.karoopowerbar.CustomProgressBarFontSize
import de.timklge.karoopowerbar.CustomProgressBarSize import de.timklge.karoopowerbar.CustomProgressBarSize
import de.timklge.karoopowerbar.KarooPowerbarExtension import de.timklge.karoopowerbar.KarooPowerbarExtension
import de.timklge.karoopowerbar.PowerbarSettings import de.timklge.karoopowerbar.PowerbarSettings
@ -66,8 +68,6 @@ import de.timklge.karoopowerbar.settingsKey
import de.timklge.karoopowerbar.streamSettings import de.timklge.karoopowerbar.streamSettings
import de.timklge.karoopowerbar.streamUserProfile import de.timklge.karoopowerbar.streamUserProfile
import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.HardwareType
import io.hammerhead.karooext.models.PlayBeepPattern
import io.hammerhead.karooext.models.UserProfile import io.hammerhead.karooext.models.UserProfile
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@ -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
} }
@ -146,7 +147,9 @@ fun MainScreen(onFinish: () -> Unit) {
var onlyShowWhileRiding by remember { mutableStateOf(false) } var onlyShowWhileRiding by remember { mutableStateOf(false) }
var colorBasedOnZones by remember { mutableStateOf(false) } var colorBasedOnZones by remember { mutableStateOf(false) }
var showLabelOnBars by remember { mutableStateOf(true) } var showLabelOnBars by remember { mutableStateOf(true) }
var barSize by remember { mutableStateOf(CustomProgressBarSize.MEDIUM) } var barBarSize by remember { mutableStateOf(CustomProgressBarBarSize.MEDIUM) }
var barFontSize by remember { mutableStateOf(CustomProgressBarFontSize.MEDIUM) }
var barBackground by remember { mutableStateOf(false) }
var minCadence by remember { mutableStateOf("0") } var minCadence by remember { mutableStateOf("0") }
var maxCadence by remember { mutableStateOf("0") } var maxCadence by remember { mutableStateOf("0") }
@ -176,6 +179,7 @@ fun MainScreen(onFinish: () -> Unit) {
val newSettings = PowerbarSettings( val newSettings = PowerbarSettings(
source = bottomSelectedSource, topBarSource = topSelectedSource, source = bottomSelectedSource, topBarSource = topSelectedSource,
onlyShowWhileRiding = onlyShowWhileRiding, showLabelOnBars = showLabelOnBars, onlyShowWhileRiding = onlyShowWhileRiding, showLabelOnBars = showLabelOnBars,
barBackground = barBackground,
useZoneColors = colorBasedOnZones, useZoneColors = colorBasedOnZones,
minCadence = minCadence.toIntOrNull() ?: PowerbarSettings.defaultMinCadence, minCadence = minCadence.toIntOrNull() ?: PowerbarSettings.defaultMinCadence,
maxCadence = maxCadence.toIntOrNull() ?: PowerbarSettings.defaultMaxCadence, maxCadence = maxCadence.toIntOrNull() ?: PowerbarSettings.defaultMaxCadence,
@ -184,7 +188,8 @@ fun MainScreen(onFinish: () -> Unit) {
maxPower = customMaxPower.toIntOrNull(), maxPower = customMaxPower.toIntOrNull(),
minHr = customMinHr.toIntOrNull(), minHr = customMinHr.toIntOrNull(),
maxHr = customMaxHr.toIntOrNull(), maxHr = customMaxHr.toIntOrNull(),
barSize = barSize, barBarSize = barBarSize,
barFontSize = barFontSize,
useCustomPowerRange = useCustomPowerRange, useCustomPowerRange = useCustomPowerRange,
useCustomHrRange = useCustomHrRange, useCustomHrRange = useCustomHrRange,
) )
@ -226,7 +231,9 @@ fun MainScreen(onFinish: () -> Unit) {
onlyShowWhileRiding = settings.onlyShowWhileRiding onlyShowWhileRiding = settings.onlyShowWhileRiding
showLabelOnBars = settings.showLabelOnBars showLabelOnBars = settings.showLabelOnBars
colorBasedOnZones = settings.useZoneColors colorBasedOnZones = settings.useZoneColors
barSize = settings.barSize barBarSize = settings.barBarSize
barFontSize = settings.barFontSize
barBackground = settings.barBackground
minCadence = settings.minCadence.toString() minCadence = settings.minCadence.toString()
maxCadence = settings.maxCadence.toString() maxCadence = settings.maxCadence.toString()
isImperial = profile.preferredUnit.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL isImperial = profile.preferredUnit.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL
@ -308,12 +315,23 @@ fun MainScreen(onFinish: () -> Unit) {
} }
apply { apply {
val dropdownOptions = CustomProgressBarSize.entries.toList().map { unit -> DropdownOption(unit.id, unit.label) } val dropdownOptions = CustomProgressBarBarSize.entries.toList().map { unit -> DropdownOption(unit.id, unit.label) }
val dropdownInitialSelection by remember(barSize) { val dropdownInitialSelection by remember(barBarSize) {
mutableStateOf(dropdownOptions.find { option -> option.id == barSize.id }!!) mutableStateOf(dropdownOptions.find { option -> option.id == barBarSize.id }!!)
} }
Dropdown(label = "Bar Size", options = dropdownOptions, selected = dropdownInitialSelection) { selectedOption -> Dropdown(label = "Bar Size", options = dropdownOptions, selected = dropdownInitialSelection) { selectedOption ->
barSize = CustomProgressBarSize.entries.find { unit -> unit.id == selectedOption.id }!! barBarSize = CustomProgressBarBarSize.entries.find { unit -> unit.id == selectedOption.id }!!
coroutineScope.launch { updateSettings() }
}
}
apply {
val dropdownOptions = CustomProgressBarFontSize.entries.toList().map { unit -> DropdownOption(unit.id, unit.label) }
val dropdownInitialSelection by remember(barFontSize) {
mutableStateOf(dropdownOptions.find { option -> option.id == barFontSize.id }!!)
}
Dropdown(label = "Text Size", options = dropdownOptions, selected = dropdownInitialSelection) { selectedOption ->
barFontSize = CustomProgressBarFontSize.entries.find { unit -> unit.id == selectedOption.id }!!
coroutineScope.launch { updateSettings() } coroutineScope.launch { updateSettings() }
} }
} }
@ -470,6 +488,15 @@ fun MainScreen(onFinish: () -> Unit) {
Text("Show value on bars") Text("Show value on bars")
} }
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = barBackground, onCheckedChange = {
barBackground = it
coroutineScope.launch { updateSettings() }
})
Spacer(modifier = Modifier.width(10.dp))
Text("Opaque background")
}
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = onlyShowWhileRiding, onCheckedChange = { Switch(checked = onlyShowWhileRiding, onCheckedChange = {
onlyShowWhileRiding = it onlyShowWhileRiding = it

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")
}
} }
} }