Compare commits
14 Commits
master
...
1.4.0-beta
| Author | SHA1 | Date | |
|---|---|---|---|
| b48138f196 | |||
| 2bb9c59bff | |||
| 6db3097e66 | |||
| da448b7407 | |||
| 76def03651 | |||
| cdafc7cedd | |||
| f020cc4e2f | |||
| bc1d03f053 | |||
| a54abf2667 | |||
| 7ffb6867b7 | |||
| dfedd1afe5 | |||
| 65e7ea4d14 | |||
| 3e1fa2169b | |||
| 72b9a0f57e |
21
.github/workflows/android.yml
vendored
21
.github/workflows/android.yml
vendored
@ -27,6 +27,7 @@ jobs:
|
||||
echo "KEYSTORE_PASSWORD=${{ secrets.KEYSTORE_PASSWORD }}" >> $GITHUB_ENV
|
||||
echo "KEYSTORE_BASE64=${{ secrets.KEYSTORE_BASE64 }}" >> $GITHUB_ENV
|
||||
echo "BUILD_NUMBER=${{ github.run_number }}" >> $GITHUB_ENV
|
||||
echo "BASE_URL=${{ secrets.BASE_URL || 'https://github.com/timklge/karoo-powerbar/releases/latest/download' }}" >> $GITHUB_ENV
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
- name: set up JDK 17
|
||||
@ -44,20 +45,22 @@ jobs:
|
||||
- name: Build with Gradle
|
||||
run: ./gradlew build
|
||||
|
||||
- name: Archive APK
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: app-release.apk
|
||||
path: app/build/outputs/apk/release/app-release.apk
|
||||
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: ncipollo/release-action@v1
|
||||
uses: softprops/action-gh-release@v2
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
files: |
|
||||
app/build/outputs/apk/release/app-release.apk
|
||||
app/manifest.json
|
||||
app/karoo-powerbar.png
|
||||
powerbar_min.gif
|
||||
powerbar0.png
|
||||
powerbar2.png
|
||||
name: ${{ github.ref_name }}
|
||||
prerelease: false
|
||||
generateReleaseNotes: true
|
||||
artifacts: app/build/outputs/apk/release/app-release.apk, app/manifest.json, app/karoo-powerbar.png, powerbar_min.gif, powerbar0.png, powerbar2.png
|
||||
draft: false
|
||||
generate_release_notes: true
|
||||
make_latest: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -9,3 +9,4 @@
|
||||
.cxx
|
||||
local.properties
|
||||
/app/release
|
||||
app/manifest.json
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import java.util.Base64
|
||||
import com.android.build.gradle.tasks.ProcessApplicationManifest
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
@ -60,27 +61,36 @@ tasks.register("generateManifest") {
|
||||
group = "build"
|
||||
|
||||
doLast {
|
||||
val baseUrl = System.getenv("BASE_URL") ?: "https://github.com/timklge/karoo-powerbar/releases/latest/download"
|
||||
val manifestFile = file("$projectDir/manifest.json")
|
||||
val manifest = mapOf(
|
||||
"label" to "Powerbar",
|
||||
"packageName" to "de.timklge.karoopowerbar",
|
||||
"iconUrl" to "https://github.com/timklge/karoo-powerbar/releases/latest/download/karoo-powerbar.png",
|
||||
"latestApkUrl" to "https://github.com/timklge/karoo-powerbar/releases/latest/download/app-release.apk",
|
||||
"iconUrl" to "$baseUrl/karoo-powerbar.png",
|
||||
"latestApkUrl" to "$baseUrl/app-release.apk",
|
||||
"latestVersion" to android.defaultConfig.versionName,
|
||||
"latestVersionCode" to android.defaultConfig.versionCode,
|
||||
"developer" to "github.com/timklge",
|
||||
"description" to "Open-source extension that adds colored power or heart rate progress bars to the edges of the screen, similar to the LEDs on Wahoo computers",
|
||||
"releaseNotes" to "* Replace dropdown popup with fullscreen dialog",
|
||||
"releaseNotes" to "* Add route progress data source\n* Add workout target range indicator\n* Replace dropdown popup with fullscreen dialog",
|
||||
"screenshotUrls" to listOf(
|
||||
"https://github.com/timklge/karoo-powerbar/releases/latest/download/powerbar_min.gif",
|
||||
"https://github.com/timklge/karoo-powerbar/releases/latest/download/powerbar0.png",
|
||||
"https://github.com/timklge/karoo-powerbar/releases/latest/download/powerbar2.png",
|
||||
"$baseUrl/powerbar_min.gif",
|
||||
"$baseUrl/powerbar0.png",
|
||||
"$baseUrl/powerbar2.png",
|
||||
)
|
||||
)
|
||||
|
||||
val gson = groovy.json.JsonBuilder(manifest).toPrettyString()
|
||||
manifestFile.writeText(gson)
|
||||
println("Generated manifest.json with version ${android.defaultConfig.versionName} (${android.defaultConfig.versionCode})")
|
||||
|
||||
if (System.getenv()["BASE_URL"] != null){
|
||||
val androidManifestFile = file("$projectDir/src/main/AndroidManifest.xml")
|
||||
var androidManifestContent = androidManifestFile.readText()
|
||||
androidManifestContent = androidManifestContent.replace("\$BASE_URL\$", baseUrl)
|
||||
androidManifestFile.writeText(androidManifestContent)
|
||||
println("Replaced \$BASE_URL$ in AndroidManifest.xml")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -88,6 +98,11 @@ tasks.named("assemble") {
|
||||
dependsOn("generateManifest")
|
||||
}
|
||||
|
||||
tasks.withType<ProcessApplicationManifest>().configureEach {
|
||||
if (name == "processDebugMainManifest" || name == "processReleaseMainManifest") {
|
||||
dependsOn(tasks.named("generateManifest"))
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.hammerhead.karoo.ext)
|
||||
@ -102,4 +117,6 @@ dependencies {
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.androidx.cardview)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.mapbox.sdk.turf)
|
||||
}
|
||||
|
||||
|
||||
@ -42,7 +42,7 @@
|
||||
<!-- Provide Karoo System with information about delivery of your app -->
|
||||
<meta-data
|
||||
android:name="io.hammerhead.karooext.MANIFEST_URL"
|
||||
android:value="https://github.com/timklge/karoo-powerbar/releases/latest/download/manifest.json" />
|
||||
android:value="$BASE_URL$/manifest.json" />
|
||||
</application>
|
||||
|
||||
|
||||
|
||||
@ -26,6 +26,9 @@ class CustomProgressBar @JvmOverloads constructor(
|
||||
var progress: Double? = 0.5
|
||||
var location: PowerbarLocation = PowerbarLocation.BOTTOM
|
||||
var label: String = ""
|
||||
var minTarget: Double? = null
|
||||
var maxTarget: Double? = null
|
||||
var target: Double? = null
|
||||
var showLabel: Boolean = true
|
||||
@ColorInt var progressColor: Int = 0xFF2b86e6.toInt()
|
||||
|
||||
@ -33,6 +36,32 @@ class CustomProgressBar @JvmOverloads constructor(
|
||||
set(value) {
|
||||
field = value
|
||||
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 {
|
||||
@ -81,13 +110,23 @@ class CustomProgressBar @JvmOverloads constructor(
|
||||
color = Color.WHITE
|
||||
strokeWidth = 3f
|
||||
textSize = size.fontSize
|
||||
typeface = Typeface.create(Typeface.MONOSPACE, Typeface.BOLD);
|
||||
typeface = Typeface.create(Typeface.MONOSPACE, Typeface.BOLD)
|
||||
textAlign = Paint.Align.CENTER
|
||||
}
|
||||
|
||||
private val targetIndicatorPaint = Paint().apply {
|
||||
isAntiAlias = true
|
||||
strokeWidth = 8f
|
||||
style = Paint.Style.STROKE
|
||||
}
|
||||
|
||||
override fun onDrawForeground(canvas: Canvas) {
|
||||
super.onDrawForeground(canvas)
|
||||
|
||||
// Determine if the current progress is within the target range
|
||||
val isTargetMet =
|
||||
progress != null && minTarget != null && maxTarget != null && progress!! >= minTarget!! && progress!! <= maxTarget!!
|
||||
|
||||
linePaint.color = progressColor
|
||||
lineStrokePaint.color = progressColor
|
||||
blurPaint.color = progressColor
|
||||
@ -95,75 +134,291 @@ class CustomProgressBar @JvmOverloads constructor(
|
||||
|
||||
when (location) {
|
||||
PowerbarLocation.TOP -> {
|
||||
val barTop = 15f
|
||||
val barBottom = barTop + size.barHeight
|
||||
val rect = RectF(
|
||||
1f,
|
||||
15f,
|
||||
((canvas.width.toDouble() - 1f) * (progress ?: 0.0).coerceIn(0.0, 1.0)).toFloat(),
|
||||
15f + size.barHeight
|
||||
barTop,
|
||||
((canvas.width.toDouble() - 1f) * (progress ?: 0.0).coerceIn(
|
||||
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) {
|
||||
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)
|
||||
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) {
|
||||
val textBounds = textPaint.measureText(label)
|
||||
val xOffset = (textBounds + 20).coerceAtLeast(10f) / 2f
|
||||
val yOffset = when(size){
|
||||
val textContent =
|
||||
label // Store original label, as textPaint.measureText can be slow
|
||||
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.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
|
||||
labelBoxTop = barTop - yOffsetOriginal
|
||||
labelBoxBottom =
|
||||
barBottom + yOffsetOriginal // Original calculation was based on rect.bottom which is barBottom
|
||||
textYPosition = barBottom + 6f // Original text Y
|
||||
}
|
||||
|
||||
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)
|
||||
lineStrokePaint.color = if (target != null){
|
||||
if (isTargetMet) Color.GREEN else Color.RED
|
||||
} else progressColor
|
||||
|
||||
canvas.drawText(label, x + xOffset, rect.top + size.barHeight + 6, textPaint)
|
||||
}
|
||||
}
|
||||
}
|
||||
PowerbarLocation.BOTTOM -> {
|
||||
val rect = RectF(
|
||||
1f,
|
||||
canvas.height.toFloat() - 1f - size.barHeight,
|
||||
((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,
|
||||
textBackgroundPaint
|
||||
)
|
||||
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) {
|
||||
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)
|
||||
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) {
|
||||
val textBounds = textPaint.measureText(label)
|
||||
val xOffset = (textBounds + 20).coerceAtLeast(10f) / 2f
|
||||
val yOffset = when(size){
|
||||
val textContent = label // Store original label
|
||||
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 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.MEDIUM -> size.fontSize / 2
|
||||
CustomProgressBarSize.LARGE -> size.fontSize / 2 - 5f
|
||||
}
|
||||
val x = (rect.right - xOffset).coerceIn(0f..canvas.width-xOffset*2f)
|
||||
val y = (rect.top - yOffset)
|
||||
val r = x + xOffset * 2
|
||||
val b = rect.bottom + 5
|
||||
labelBoxTop = barTop - yOffsetOriginal
|
||||
labelBoxBottom = barBottom + 5f // Original 'b' calculation
|
||||
textYPosition =
|
||||
barBottom - 1f // Original text Y (rect.top + size.barHeight -1)
|
||||
}
|
||||
|
||||
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)
|
||||
lineStrokePaint.color = if (target != null){
|
||||
if (isTargetMet) Color.GREEN else Color.RED
|
||||
} 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,14 +1,18 @@
|
||||
package de.timklge.karoopowerbar
|
||||
|
||||
import io.hammerhead.karooext.KarooSystemService
|
||||
import io.hammerhead.karooext.models.OnNavigationState
|
||||
import io.hammerhead.karooext.models.OnStreamState
|
||||
import io.hammerhead.karooext.models.RideState
|
||||
import io.hammerhead.karooext.models.StreamState
|
||||
import io.hammerhead.karooext.models.UserProfile
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.channels.trySendBlocking
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.conflate
|
||||
import kotlinx.coroutines.flow.transform
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
val jsonWithUnknownKeys = Json { ignoreUnknownKeys = true }
|
||||
@ -35,6 +39,17 @@ fun KarooSystemService.streamRideState(): Flow<RideState> {
|
||||
}
|
||||
}
|
||||
|
||||
fun KarooSystemService.streamNavigationState(): Flow<OnNavigationState> {
|
||||
return callbackFlow {
|
||||
val listenerId = addConsumer { navigationState: OnNavigationState ->
|
||||
trySendBlocking(navigationState)
|
||||
}
|
||||
awaitClose {
|
||||
removeConsumer(listenerId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun KarooSystemService.streamUserProfile(): Flow<UserProfile> {
|
||||
return callbackFlow {
|
||||
val listenerId = addConsumer { userProfile: UserProfile ->
|
||||
@ -45,3 +60,10 @@ fun KarooSystemService.streamUserProfile(): Flow<UserProfile> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun<T> Flow<T>.throttle(timeout: Long): Flow<T> = this
|
||||
.conflate()
|
||||
.transform {
|
||||
emit(it)
|
||||
delay(timeout)
|
||||
}
|
||||
@ -17,10 +17,15 @@ import android.view.ViewGroup
|
||||
import android.view.WindowInsets
|
||||
import android.view.WindowManager
|
||||
import androidx.annotation.ColorRes
|
||||
import com.mapbox.geojson.LineString
|
||||
import com.mapbox.turf.TurfConstants.UNIT_METERS
|
||||
import com.mapbox.turf.TurfMeasurement
|
||||
import de.timklge.karoopowerbar.KarooPowerbarExtension.Companion.TAG
|
||||
import de.timklge.karoopowerbar.screens.SelectedSource
|
||||
import io.hammerhead.karooext.KarooSystemService
|
||||
import io.hammerhead.karooext.models.DataPoint
|
||||
import io.hammerhead.karooext.models.DataType
|
||||
import io.hammerhead.karooext.models.OnNavigationState
|
||||
import io.hammerhead.karooext.models.StreamState
|
||||
import io.hammerhead.karooext.models.UserProfile
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@ -29,12 +34,15 @@ import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
fun remap(value: Double, fromMin: Double, fromMax: Double, toMin: Double, toMax: Double): Double {
|
||||
fun remap(value: Double?, fromMin: Double, fromMax: Double, toMin: Double, toMax: Double): Double? {
|
||||
if (value == null) return null
|
||||
|
||||
return (value - fromMin) * (toMax - toMin) / (fromMax - fromMin) + toMin
|
||||
}
|
||||
|
||||
@ -48,6 +56,12 @@ class Window(
|
||||
val showLabel: Boolean,
|
||||
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 var layoutParams: WindowManager.LayoutParams? = null
|
||||
private val windowManager: WindowManager
|
||||
@ -100,8 +114,6 @@ class Window(
|
||||
|
||||
private val karooSystem: KarooSystemService = KarooSystemService(context)
|
||||
|
||||
data class StreamData(val userProfile: UserProfile, val value: Double?, val settings: PowerbarSettings? = null)
|
||||
|
||||
private var serviceJob: Job? = null
|
||||
|
||||
@SuppressLint("UnspecifiedRegisterReceiverFlag")
|
||||
@ -136,6 +148,7 @@ class Window(
|
||||
SelectedSource.SPEED_3S -> streamSpeed(true)
|
||||
SelectedSource.CADENCE -> streamCadence(false)
|
||||
SelectedSource.CADENCE_3S -> streamCadence(true)
|
||||
SelectedSource.ROUTE_PROGRESS -> streamRouteProgress()
|
||||
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) {
|
||||
val speedFlow = karooSystem.streamDataFlow(if(smoothed) DataType.Type.SMOOTHED_3S_AVERAGE_SPEED else DataType.Type.SPEED)
|
||||
.map { (it as? StreamState.Streaming)?.dataPoint?.singleValue }
|
||||
@ -158,12 +220,11 @@ class Window(
|
||||
|
||||
val settingsFlow = context.streamSettings()
|
||||
|
||||
karooSystem.streamUserProfile()
|
||||
.distinctUntilChanged()
|
||||
.combine(speedFlow) { userProfile, speed -> StreamData(userProfile, speed) }
|
||||
.combine(settingsFlow) { streamData, settings -> streamData.copy(settings = settings) }
|
||||
.distinctUntilChanged()
|
||||
.collect { streamData ->
|
||||
data class StreamData(val userProfile: UserProfile, val value: Double?, val settings: PowerbarSettings? = null)
|
||||
|
||||
combine(karooSystem.streamUserProfile(), speedFlow, settingsFlow) { userProfile, speed, settings ->
|
||||
StreamData(userProfile, speed, settings)
|
||||
}.distinctUntilChanged().throttle(1_000).collect { streamData ->
|
||||
val valueMetersPerSecond = streamData.value
|
||||
val value = when (streamData.userProfile.preferredUnit.distance){
|
||||
UserProfile.PreferredUnit.UnitType.IMPERIAL -> valueMetersPerSecond?.times(2.23694)
|
||||
@ -173,8 +234,7 @@ class Window(
|
||||
if (value != null && valueMetersPerSecond != null) {
|
||||
val minSpeed = streamData.settings?.minSpeed ?: PowerbarSettings.defaultMinSpeedMs
|
||||
val maxSpeed = streamData.settings?.maxSpeed ?: PowerbarSettings.defaultMaxSpeedMs
|
||||
val progress =
|
||||
remap(valueMetersPerSecond, minSpeed.toDouble(), maxSpeed.toDouble(), 0.0, 1.0)
|
||||
val progress = remap(valueMetersPerSecond, minSpeed.toDouble(), maxSpeed.toDouble(), 0.0, 1.0) ?: 0.0
|
||||
|
||||
@ColorRes val zoneColorRes = Zone.entries[(progress * Zone.entries.size).roundToInt().coerceIn(0..<Zone.entries.size)].colorResource
|
||||
|
||||
@ -199,25 +259,30 @@ class Window(
|
||||
}
|
||||
|
||||
private suspend fun streamCadence(smoothed: Boolean) {
|
||||
val speedFlow = karooSystem.streamDataFlow(if(smoothed) DataType.Type.SMOOTHED_3S_AVERAGE_CADENCE else DataType.Type.CADENCE)
|
||||
val cadenceFlow = karooSystem.streamDataFlow(if(smoothed) DataType.Type.SMOOTHED_3S_AVERAGE_CADENCE else DataType.Type.CADENCE)
|
||||
.map { (it as? StreamState.Streaming)?.dataPoint?.singleValue }
|
||||
.distinctUntilChanged()
|
||||
|
||||
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()
|
||||
.combine(speedFlow) { userProfile, speed -> StreamData(userProfile, speed) }
|
||||
.combine(settingsFlow) { streamData, settings -> streamData.copy(settings = settings) }
|
||||
.distinctUntilChanged()
|
||||
.collect { streamData ->
|
||||
|
||||
combine(karooSystem.streamUserProfile(), cadenceFlow, settingsFlow, cadenceTargetFlow) { userProfile, speed, settings, cadenceTarget ->
|
||||
StreamData(userProfile, speed, settings, cadenceTarget)
|
||||
}.distinctUntilChanged().throttle(1_000).collect { streamData ->
|
||||
val value = streamData.value?.roundToInt()
|
||||
|
||||
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)
|
||||
val progress = remap(value.toDouble(), minCadence.toDouble(), maxCadence.toDouble(), 0.0, 1.0) ?: 0.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
|
||||
|
||||
@ -247,13 +312,15 @@ class Window(
|
||||
.distinctUntilChanged()
|
||||
|
||||
val settingsFlow = context.streamSettings()
|
||||
val hrTargetFlow = karooSystem.streamDataFlow("TYPE_WORKOUT_HEART_RATE_TARGET_ID")
|
||||
.map { (it as? StreamState.Streaming)?.dataPoint }
|
||||
.distinctUntilChanged()
|
||||
|
||||
karooSystem.streamUserProfile()
|
||||
.distinctUntilChanged()
|
||||
.combine(hrFlow) { userProfile, hr -> StreamData(userProfile, hr) }
|
||||
.combine(settingsFlow) { streamData, settings -> streamData.copy(settings = settings) }
|
||||
.distinctUntilChanged()
|
||||
.collect { streamData ->
|
||||
data class StreamData(val userProfile: UserProfile, val value: Double?, val settings: PowerbarSettings? = null, val heartrateTarget: DataPoint? = null)
|
||||
|
||||
combine(karooSystem.streamUserProfile(), hrFlow, settingsFlow, hrTargetFlow) { userProfile, hr, settings, hrTarget ->
|
||||
StreamData(userProfile, hr, settings, hrTarget)
|
||||
}.distinctUntilChanged().throttle(1_000).collect { streamData ->
|
||||
val value = streamData.value?.roundToInt()
|
||||
|
||||
if (value != null) {
|
||||
@ -263,6 +330,10 @@ class Window(
|
||||
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 {
|
||||
@ -296,21 +367,28 @@ class Window(
|
||||
|
||||
val settingsFlow = context.streamSettings()
|
||||
|
||||
karooSystem.streamUserProfile()
|
||||
val powerTargetFlow = karooSystem.streamDataFlow("TYPE_WORKOUT_POWER_TARGET_ID") // TYPE_WORKOUT_HEART_RATE_TARGET_ID, TYPE_WORKOUT_CADENCE_TARGET_ID,
|
||||
.map { (it as? StreamState.Streaming)?.dataPoint }
|
||||
.distinctUntilChanged()
|
||||
.combine(powerFlow) { userProfile, hr -> StreamData(userProfile, hr) }
|
||||
.combine(settingsFlow) { streamData, settings -> streamData.copy(settings = settings) }
|
||||
.distinctUntilChanged()
|
||||
.collect { streamData ->
|
||||
|
||||
data class StreamData(val userProfile: UserProfile, val value: Double?, val settings: PowerbarSettings? = null, val powerTarget: DataPoint? = null)
|
||||
|
||||
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()
|
||||
|
||||
if (value != null) {
|
||||
val customMinPower = if (streamData.settings?.useCustomPowerRange == true) streamData.settings.minPower else null
|
||||
val customMaxPower = if (streamData.settings?.useCustomPowerRange == true) streamData.settings.maxPower else null
|
||||
val minPower = customMinPower ?: streamData.userProfile.powerZones.first().min
|
||||
val maxPower = customMaxPower ?: (streamData.userProfile.powerZones.last().min + 50)
|
||||
val 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 {
|
||||
|
||||
@ -87,7 +87,8 @@ enum class SelectedSource(val id: String, val label: String) {
|
||||
SPEED("speed", "Speed"),
|
||||
SPEED_3S("speed_3s", "Speed (3 sec avg"),
|
||||
CADENCE("cadence", "Cadence"),
|
||||
CADENCE_3S("cadence_3s", "Cadence (3 sec avg)");
|
||||
CADENCE_3S("cadence_3s", "Cadence (3 sec avg)"),
|
||||
ROUTE_PROGRESS("route_progress", "Route Progress");
|
||||
|
||||
fun isPower() = this == POWER || this == POWER_3S || this == POWER_10S
|
||||
}
|
||||
|
||||
@ -2,12 +2,12 @@
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="40dp">
|
||||
android:layout_height="80dp">
|
||||
|
||||
<de.timklge.karoopowerbar.CustomProgressBar
|
||||
android:id="@+id/progressBar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="40dp"
|
||||
android:layout_height="80dp"
|
||||
android:layout_gravity="center" />
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
@ -14,6 +14,7 @@ lifecycleRuntimeKtx = "2.8.7"
|
||||
navigationRuntimeKtx = "2.8.4"
|
||||
navigationCompose = "2.8.4"
|
||||
cardview = "1.0.0"
|
||||
mapboxSdkTurf = "7.3.1"
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
@ -24,7 +25,7 @@ compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "
|
||||
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }
|
||||
androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||
color = { module = "com.maxkeppeler.sheets-compose-dialogs:color", version.ref = "color" }
|
||||
hammerhead-karoo-ext = { group = "io.hammerhead", name = "karoo-ext", version = "1.1.2" }
|
||||
hammerhead-karoo-ext = { group = "io.hammerhead", name = "karoo-ext", version = "1.1.5" }
|
||||
|
||||
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCore" }
|
||||
|
||||
@ -43,6 +44,7 @@ androidx-navigation-runtime-ktx = { group = "androidx.navigation", name = "navig
|
||||
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
|
||||
androidx-cardview = { group = "androidx.cardview", name = "cardview", version.ref = "cardview" }
|
||||
mapbox-sdk-turf = { module = "com.mapbox.mapboxsdk:mapbox-sdk-turf", version.ref = "mapboxSdkTurf" }
|
||||
|
||||
[bundles]
|
||||
androidx-lifeycle = ["androidx-lifecycle-runtime-compose", "androidx-lifecycle-viewmodel-compose"]
|
||||
|
||||
@ -34,6 +34,11 @@ dependencyResolutionManagement {
|
||||
password = gprKey
|
||||
}
|
||||
}
|
||||
|
||||
// mapbox
|
||||
maven {
|
||||
url = uri("https://api.mapbox.com/downloads/v2/releases/maven")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user