From 91c8eec977a631df5922d7e65df9aa1ae19be28b Mon Sep 17 00:00:00 2001 From: timklge <2026103+timklge@users.noreply.github.com> Date: Mon, 2 Jun 2025 19:18:23 +0200 Subject: [PATCH] Add route distance data source, workout target indication (#34) * 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 --- .github/workflows/android.yml | 21 +- .gitignore | 1 + app/build.gradle.kts | 31 +- app/src/main/AndroidManifest.xml | 2 +- .../karoopowerbar/CustomProgressBar.kt | 141 +++++++++- .../de/timklge/karoopowerbar/Extensions.kt | 24 +- .../kotlin/de/timklge/karoopowerbar/Window.kt | 264 ++++++++++++------ .../karoopowerbar/screens/MainScreen.kt | 3 +- app/src/main/res/layout/popup_window.xml | 4 +- gradle/libs.versions.toml | 4 +- settings.gradle.kts | 5 + 11 files changed, 379 insertions(+), 121 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 64a6073..3fc3c2c 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -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 }} diff --git a/.gitignore b/.gitignore index 43ebc8c..326c9a7 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ .cxx local.properties /app/release +app/manifest.json diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f000fec..aef1efa 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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().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) -} \ No newline at end of file + implementation(libs.mapbox.sdk.turf) +} + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e1c8813..bebd6e1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -42,7 +42,7 @@ + android:value="$BASE_URL$/manifest.json" /> diff --git a/app/src/main/kotlin/de/timklge/karoopowerbar/CustomProgressBar.kt b/app/src/main/kotlin/de/timklge/karoopowerbar/CustomProgressBar.kt index 99c60a9..9d29b7e 100644 --- a/app/src/main/kotlin/de/timklge/karoopowerbar/CustomProgressBar.kt +++ b/app/src/main/kotlin/de/timklge/karoopowerbar/CustomProgressBar.kt @@ -18,6 +18,9 @@ class CustomProgressBar @JvmOverloads constructor( var progress: Double? = 0.5 var location: PowerbarLocation = PowerbarLocation.BOTTOM var label: String = "" + var minTarget: Double? = null + var maxTarget: Double? = null + var target: Double? = null var showLabel: Boolean = true var barBackground: Boolean = false @ColorInt var progressColor: Int = 0xFF2b86e6.toInt() @@ -32,9 +35,35 @@ class CustomProgressBar @JvmOverloads constructor( var barSize = CustomProgressBarBarSize.MEDIUM set(value) { field = value + targetZoneStrokePaint.strokeWidth = when(value){ + CustomProgressBarBarSize.NONE, CustomProgressBarBarSize.SMALL -> 3f + CustomProgressBarBarSize.MEDIUM -> 6f + CustomProgressBarBarSize.LARGE -> 8f + } + targetIndicatorPaint.strokeWidth = when(value){ + CustomProgressBarBarSize.NONE, CustomProgressBarBarSize.SMALL -> 6f + CustomProgressBarBarSize.MEDIUM -> 8f + CustomProgressBarBarSize.LARGE -> 10f + } invalidate() // Redraw to apply new bar size } + private val targetColor = 0xFF9933FF.toInt() + + private val targetZoneFillPaint = Paint().apply { + isAntiAlias = true + style = Paint.Style.FILL + color = targetColor + alpha = 100 // Semi-transparent fill + } + + private val targetZoneStrokePaint = Paint().apply { + isAntiAlias = true + strokeWidth = 6f + style = Paint.Style.STROKE + color = targetColor + } + private val linePaint = Paint().apply { isAntiAlias = true strokeWidth = 1f @@ -51,7 +80,7 @@ class CustomProgressBar @JvmOverloads constructor( private val blurPaint = Paint().apply { isAntiAlias = true - strokeWidth = 4f + strokeWidth = 6f style = Paint.Style.STROKE color = progressColor maskFilter = BlurMaskFilter(3f, BlurMaskFilter.Blur.NORMAL) @@ -59,7 +88,7 @@ class CustomProgressBar @JvmOverloads constructor( private val blurPaintHighlight = Paint().apply { isAntiAlias = true - strokeWidth = 8f + strokeWidth = 10f style = Paint.Style.FILL_AND_STROKE color = ColorUtils.blendARGB(progressColor, 0xFFFFFF, 0.5f) maskFilter = BlurMaskFilter(6f, BlurMaskFilter.Blur.NORMAL) @@ -81,19 +110,29 @@ class CustomProgressBar @JvmOverloads constructor( color = Color.WHITE strokeWidth = 3f textSize = fontSize.fontSize - typeface = Typeface.create(Typeface.MONOSPACE, Typeface.BOLD); + typeface = Typeface.create(Typeface.MONOSPACE, Typeface.BOLD) textAlign = Paint.Align.CENTER } + private val targetIndicatorPaint = Paint().apply { + isAntiAlias = true + strokeWidth = 8f + style = Paint.Style.STROKE + } + override fun onDrawForeground(canvas: Canvas) { super.onDrawForeground(canvas) + // Determine if the current progress is within the target range + val isTargetMet = + progress != null && minTarget != null && maxTarget != null && progress!! >= minTarget!! && progress!! <= maxTarget!! + linePaint.color = progressColor lineStrokePaint.color = progressColor blurPaint.color = progressColor blurPaintHighlight.color = ColorUtils.blendARGB(progressColor, 0xFFFFFF, 0.5f) - when(location){ + when (location) { PowerbarLocation.TOP -> { val rect = RectF( 1f, @@ -108,16 +147,60 @@ class CustomProgressBar @JvmOverloads constructor( canvas.drawRect(0f, 15f, canvas.width.toFloat(), 15f + barSize.barHeight, backgroundPaint) } + // Draw target zone fill behind the progress bar + if (minTarget != null && maxTarget != null) { + val minTargetX = (canvas.width * minTarget!!).toFloat() + val maxTargetX = (canvas.width * maxTarget!!).toFloat() + canvas.drawRoundRect( + minTargetX, + 15f, + maxTargetX, + 15f + barSize.barHeight, + 2f, + 2f, + targetZoneFillPaint + ) + } + if (progress != null) { canvas.drawRoundRect(rect, 2f, 2f, blurPaint) canvas.drawRoundRect(rect, 2f, 2f, linePaint) canvas.drawRoundRect(rect.right-4, rect.top, rect.right+4, rect.bottom, 2f, 2f, blurPaintHighlight) } } - // Draw label (if progress is not null and showLabel is true) if (progress != null) { + // Draw target zone stroke after progress bar, before label + if (minTarget != null && maxTarget != null) { + val minTargetX = (canvas.width * minTarget!!).toFloat() + val maxTargetX = (canvas.width * maxTarget!!).toFloat() + // Draw stroked rounded rectangle for the target zone + canvas.drawRoundRect( + minTargetX, + 15f, + maxTargetX, + 15f + barSize.barHeight, + 2f, + 2f, + targetZoneStrokePaint + ) + } + + // Draw vertical target indicator line if target is present + if (target != null) { + val targetX = (canvas.width * target!!).toFloat() + targetIndicatorPaint.color = if (isTargetMet) Color.GREEN else Color.RED + canvas.drawLine(targetX, 15f, targetX, 15f + barSize.barHeight, targetIndicatorPaint) + } + if (showLabel){ + lineStrokePaint.color = if (target != null){ + if (isTargetMet) Color.GREEN else Color.RED + } else progressColor + + blurPaint.color = lineStrokePaint.color + blurPaintHighlight.color = ColorUtils.blendARGB(lineStrokePaint.color, 0xFFFFFF, 0.5f) + val textBounds = textPaint.measureText(label) val xOffset = (textBounds + 20).coerceAtLeast(10f) / 2f val x = (rect.right - xOffset).coerceIn(0f..canvas.width-xOffset*2f) @@ -137,9 +220,10 @@ class CustomProgressBar @JvmOverloads constructor( canvas.drawRoundRect(x, finalTextBoxTop, r, finalTextBoxBottom, 2f, 2f, blurPaint) canvas.drawRoundRect(x, finalTextBoxTop, r, finalTextBoxBottom, 2f, 2f, lineStrokePaint) canvas.drawText(label, x + xOffset, finalTextBaselineY, textPaint) + } } - } } + PowerbarLocation.BOTTOM -> { val rect = RectF( 1f, @@ -155,6 +239,21 @@ class CustomProgressBar @JvmOverloads constructor( canvas.drawRect(0f, canvas.height.toFloat() - barSize.barHeight, canvas.width.toFloat(), canvas.height.toFloat(), backgroundPaint) } + // Draw target zone fill behind the progress bar + if (minTarget != null && maxTarget != null) { + val minTargetX = (canvas.width * minTarget!!).toFloat() + val maxTargetX = (canvas.width * maxTarget!!).toFloat() + canvas.drawRoundRect( + minTargetX, + canvas.height.toFloat() - barSize.barHeight, + maxTargetX, + canvas.height.toFloat(), + 2f, + 2f, + targetZoneFillPaint + ) + } + if (progress != null) { canvas.drawRoundRect(rect, 2f, 2f, blurPaint) canvas.drawRoundRect(rect, 2f, 2f, linePaint) @@ -164,7 +263,37 @@ class CustomProgressBar @JvmOverloads constructor( // Draw label (if progress is not null and showLabel is true) if (progress != null) { + // Draw target zone stroke after progress bar, before label + if (minTarget != null && maxTarget != null) { + val minTargetX = (canvas.width * minTarget!!).toFloat() + val maxTargetX = (canvas.width * maxTarget!!).toFloat() + // Draw stroked rounded rectangle for the target zone + canvas.drawRoundRect( + minTargetX, + canvas.height.toFloat() - barSize.barHeight, + maxTargetX, + canvas.height.toFloat(), + 2f, + 2f, + targetZoneStrokePaint + ) + } + + // Draw vertical target indicator line if target is present + if (target != null) { + val targetX = (canvas.width * target!!).toFloat() + targetIndicatorPaint.color = if (isTargetMet) Color.GREEN else Color.RED + canvas.drawLine(targetX, canvas.height.toFloat() - barSize.barHeight, targetX, canvas.height.toFloat(), targetIndicatorPaint) + } + if (showLabel){ + lineStrokePaint.color = if (target != null){ + if (isTargetMet) Color.GREEN else Color.RED + } else progressColor + + blurPaint.color = lineStrokePaint.color + blurPaintHighlight.color = ColorUtils.blendARGB(lineStrokePaint.color, 0xFFFFFF, 0.5f) + val textBounds = textPaint.measureText(label) val xOffset = (textBounds + 20).coerceAtLeast(10f) / 2f val x = (rect.right - xOffset).coerceIn(0f..canvas.width-xOffset*2f) diff --git a/app/src/main/kotlin/de/timklge/karoopowerbar/Extensions.kt b/app/src/main/kotlin/de/timklge/karoopowerbar/Extensions.kt index b5eb8c0..2bfbe1d 100644 --- a/app/src/main/kotlin/de/timklge/karoopowerbar/Extensions.kt +++ b/app/src/main/kotlin/de/timklge/karoopowerbar/Extensions.kt @@ -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 { } } +fun KarooSystemService.streamNavigationState(): Flow { + return callbackFlow { + val listenerId = addConsumer { navigationState: OnNavigationState -> + trySendBlocking(navigationState) + } + awaitClose { + removeConsumer(listenerId) + } + } +} + fun KarooSystemService.streamUserProfile(): Flow { return callbackFlow { val listenerId = addConsumer { userProfile: UserProfile -> @@ -44,4 +59,11 @@ fun KarooSystemService.streamUserProfile(): Flow { removeConsumer(listenerId) } } -} \ No newline at end of file +} + +fun Flow.throttle(timeout: Long): Flow = this + .conflate() + .transform { + emit(it) + delay(timeout) + } \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karoopowerbar/Window.kt b/app/src/main/kotlin/de/timklge/karoopowerbar/Window.kt index f5d7e9b..33be186 100644 --- a/app/src/main/kotlin/de/timklge/karoopowerbar/Window.kt +++ b/app/src/main/kotlin/de/timklge/karoopowerbar/Window.kt @@ -17,10 +17,15 @@ import android.view.ViewGroup import android.view.WindowInsets import android.view.WindowManager import androidx.annotation.ColorRes +import com.mapbox.geojson.LineString +import com.mapbox.turf.TurfConstants.UNIT_METERS +import com.mapbox.turf.TurfMeasurement import de.timklge.karoopowerbar.KarooPowerbarExtension.Companion.TAG import de.timklge.karoopowerbar.screens.SelectedSource import io.hammerhead.karooext.KarooSystemService +import io.hammerhead.karooext.models.DataPoint import io.hammerhead.karooext.models.DataType +import io.hammerhead.karooext.models.OnNavigationState import io.hammerhead.karooext.models.StreamState import io.hammerhead.karooext.models.UserProfile import kotlinx.coroutines.CoroutineScope @@ -29,12 +34,15 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlin.math.roundToInt -fun remap(value: Double, fromMin: Double, fromMax: Double, toMin: Double, toMax: Double): Double { +fun remap(value: Double?, fromMin: Double, fromMax: Double, toMin: Double, toMax: Double): Double? { + if (value == null) return null + return (value - fromMin) * (toMax - toMin) / (fromMax - fromMin) + toMin } @@ -50,6 +58,12 @@ class Window( val powerbarBarSize: CustomProgressBarBarSize, val powerbarFontSize: CustomProgressBarFontSize, ) { + companion object { + val FIELD_TARGET_VALUE_ID = "FIELD_WORKOUT_TARGET_VALUE_ID"; + val FIELD_TARGET_MIN_ID = "FIELD_WORKOUT_TARGET_MIN_VALUE_ID"; + val FIELD_TARGET_MAX_ID = "FIELD_WORKOUT_TARGET_MAX_VALUE_ID"; + } + private val rootView: View private var layoutParams: WindowManager.LayoutParams? = null private val windowManager: WindowManager @@ -102,8 +116,6 @@ class Window( private val karooSystem: KarooSystemService = KarooSystemService(context) - data class StreamData(val userProfile: UserProfile, val value: Double?, val settings: PowerbarSettings? = null) - private var serviceJob: Job? = null @SuppressLint("UnspecifiedRegisterReceiverFlag") @@ -140,6 +152,7 @@ class Window( SelectedSource.SPEED_3S -> streamSpeed(true) SelectedSource.CADENCE -> streamCadence(false) SelectedSource.CADENCE_3S -> streamCadence(true) + SelectedSource.ROUTE_PROGRESS -> streamRouteProgress() else -> {} } } @@ -155,6 +168,55 @@ class Window( } } + private suspend fun streamRouteProgress() { + data class StreamData( + val userProfile: UserProfile, + val distanceToDestination: Double?, + val navigationState: OnNavigationState + ) + + var lastKnownRoutePolyline: String? = null + var lastKnownRouteLength: Double? = null + + combine(karooSystem.streamUserProfile(), karooSystem.streamDataFlow(DataType.Type.DISTANCE_TO_DESTINATION), karooSystem.streamNavigationState()) { userProfile, distanceToDestination, navigationState -> + StreamData(userProfile, (distanceToDestination as? StreamState.Streaming)?.dataPoint?.values[DataType.Field.DISTANCE_TO_DESTINATION], navigationState) + }.distinctUntilChanged().throttle(5_000).collect { (userProfile, distanceToDestination, navigationState) -> + val state = navigationState.state + val routePolyline = when (state) { + is OnNavigationState.NavigationState.NavigatingRoute -> state.routePolyline + is OnNavigationState.NavigationState.NavigatingToDestination -> state.polyline + else -> null + } + + if (routePolyline != lastKnownRoutePolyline) { + lastKnownRoutePolyline = routePolyline + lastKnownRouteLength = when (state){ + is OnNavigationState.NavigationState.NavigatingRoute -> state.routeDistance + is OnNavigationState.NavigationState.NavigatingToDestination -> try { + TurfMeasurement.length(LineString.fromPolyline(state.polyline, 5), UNIT_METERS) + } catch (e: Exception) { + Log.e(TAG, "Failed to calculate route length", e) + null + } + else -> null + } + } + + val routeLength = lastKnownRouteLength + val routeProgressMeters = routeLength?.let { routeLength - (distanceToDestination ?: 0.0) }?.coerceAtLeast(0.0) + val routeProgress = if (routeLength != null && routeProgressMeters != null) remap(routeProgressMeters, 0.0, routeLength, 0.0, 1.0) else null + val routeProgressInUserUnit = when (userProfile.preferredUnit.distance) { + UserProfile.PreferredUnit.UnitType.IMPERIAL -> routeProgressMeters?.times(0.000621371)?.roundToInt() // Miles + else -> routeProgressMeters?.times(0.001)?.roundToInt() // Kilometers + } + + powerbar.progressColor = context.getColor(R.color.zone0) + powerbar.progress = routeProgress + powerbar.label = "$routeProgressInUserUnit" + powerbar.invalidate() + } + } + private suspend fun streamSpeed(smoothed: Boolean) { val speedFlow = karooSystem.streamDataFlow(if(smoothed) DataType.Type.SMOOTHED_3S_AVERAGE_SPEED else DataType.Type.SPEED) .map { (it as? StreamState.Streaming)?.dataPoint?.singleValue } @@ -162,12 +224,11 @@ class Window( val settingsFlow = context.streamSettings() - karooSystem.streamUserProfile() - .distinctUntilChanged() - .combine(speedFlow) { userProfile, speed -> StreamData(userProfile, speed) } - .combine(settingsFlow) { streamData, settings -> streamData.copy(settings = settings) } - .distinctUntilChanged() - .collect { streamData -> + data class StreamData(val userProfile: UserProfile, val value: Double?, val settings: PowerbarSettings? = null) + + combine(karooSystem.streamUserProfile(), speedFlow, settingsFlow) { userProfile, speed, settings -> + StreamData(userProfile, speed, settings) + }.distinctUntilChanged().throttle(1_000).collect { streamData -> val valueMetersPerSecond = streamData.value val value = when (streamData.userProfile.preferredUnit.distance){ UserProfile.PreferredUnit.UnitType.IMPERIAL -> valueMetersPerSecond?.times(2.23694) @@ -177,8 +238,7 @@ class Window( if (value != null && valueMetersPerSecond != null) { val minSpeed = streamData.settings?.minSpeed ?: PowerbarSettings.defaultMinSpeedMs val maxSpeed = streamData.settings?.maxSpeed ?: PowerbarSettings.defaultMaxSpeedMs - val progress = - remap(valueMetersPerSecond, minSpeed.toDouble(), maxSpeed.toDouble(), 0.0, 1.0) + val progress = remap(valueMetersPerSecond, minSpeed.toDouble(), maxSpeed.toDouble(), 0.0, 1.0) ?: 0.0 @ColorRes val zoneColorRes = Zone.entries[(progress * Zone.entries.size).roundToInt().coerceIn(0.. StreamData(userProfile, speed) } - .combine(settingsFlow) { streamData, settings -> streamData.copy(settings = settings) } - .distinctUntilChanged() - .collect { streamData -> - val value = streamData.value?.roundToInt() - if (value != null) { - val minCadence = streamData.settings?.minCadence ?: PowerbarSettings.defaultMinCadence - val maxCadence = streamData.settings?.maxCadence ?: PowerbarSettings.defaultMaxCadence - val progress = - remap(value.toDouble(), minCadence.toDouble(), maxCadence.toDouble(), 0.0, 1.0) + combine(karooSystem.streamUserProfile(), cadenceFlow, settingsFlow, cadenceTargetFlow) { userProfile, speed, settings, cadenceTarget -> + StreamData(userProfile, speed, settings, cadenceTarget) + }.distinctUntilChanged().throttle(1_000).collect { streamData -> + val value = streamData.value?.roundToInt() - @ColorRes val zoneColorRes = Zone.entries[(progress * Zone.entries.size).roundToInt().coerceIn(0.. 0) progress else null - powerbar.label = "$value" + powerbar.minTarget = remap(streamData.cadenceTarget?.values[FIELD_TARGET_MIN_ID]?.toDouble(), minCadence.toDouble(), maxCadence.toDouble(), 0.0, 1.0) + powerbar.maxTarget = remap(streamData.cadenceTarget?.values[FIELD_TARGET_MAX_ID]?.toDouble(), minCadence.toDouble(), maxCadence.toDouble(), 0.0, 1.0) + powerbar.target = remap(streamData.cadenceTarget?.values[FIELD_TARGET_VALUE_ID]?.toDouble(), minCadence.toDouble(), maxCadence.toDouble(), 0.0, 1.0) - Log.d(TAG, "Cadence: $value min: $minCadence max: $maxCadence") + @ColorRes val zoneColorRes = Zone.entries[(progress * Zone.entries.size).roundToInt().coerceIn(0.. 0) progress else null + powerbar.label = "$value" + + Log.d(TAG, "Cadence: $value min: $minCadence max: $maxCadence") + } else { + powerbar.progressColor = context.getColor(R.color.zone0) + powerbar.progress = null + powerbar.label = "?" + + Log.d(TAG, "Cadence: Unavailable") } + powerbar.invalidate() + } } private suspend fun streamHeartrate() { @@ -251,40 +316,46 @@ class Window( .distinctUntilChanged() val settingsFlow = context.streamSettings() - - karooSystem.streamUserProfile() + val hrTargetFlow = karooSystem.streamDataFlow("TYPE_WORKOUT_HEART_RATE_TARGET_ID") + .map { (it as? StreamState.Streaming)?.dataPoint } .distinctUntilChanged() - .combine(hrFlow) { userProfile, hr -> StreamData(userProfile, hr) } - .combine(settingsFlow) { streamData, settings -> streamData.copy(settings = settings) } - .distinctUntilChanged() - .collect { streamData -> - val value = streamData.value?.roundToInt() - if (value != null) { - val customMinHr = if (streamData.settings?.useCustomHrRange == true) streamData.settings.minHr else null - val customMaxHr = if (streamData.settings?.useCustomHrRange == true) streamData.settings.maxHr else null - val minHr = customMinHr ?: streamData.userProfile.restingHr - val maxHr = customMaxHr ?: streamData.userProfile.maxHr - val progress = remap(value.toDouble(), minHr.toDouble(), maxHr.toDouble(), 0.0, 1.0) + data class StreamData(val userProfile: UserProfile, val value: Double?, val settings: PowerbarSettings? = null, val heartrateTarget: DataPoint? = null) - powerbar.progressColor = if (streamData.settings?.useZoneColors == true) { - context.getColor(getZone(streamData.userProfile.heartRateZones, value)?.colorResource ?: R.color.zone7) - } else { - context.getColor(R.color.zone0) - } - powerbar.progress = if (value > 0) progress else null - powerbar.label = "$value" + combine(karooSystem.streamUserProfile(), hrFlow, settingsFlow, hrTargetFlow) { userProfile, hr, settings, hrTarget -> + StreamData(userProfile, hr, settings, hrTarget) + }.distinctUntilChanged().throttle(1_000).collect { streamData -> + val value = streamData.value?.roundToInt() - Log.d(TAG, "Hr: $value min: $minHr max: $maxHr") + if (value != null) { + val customMinHr = if (streamData.settings?.useCustomHrRange == true) streamData.settings.minHr else null + val customMaxHr = if (streamData.settings?.useCustomHrRange == true) streamData.settings.maxHr else null + val minHr = customMinHr ?: streamData.userProfile.restingHr + val maxHr = customMaxHr ?: streamData.userProfile.maxHr + val progress = remap(value.toDouble(), minHr.toDouble(), maxHr.toDouble(), 0.0, 1.0) + + powerbar.minTarget = remap(streamData.heartrateTarget?.values[FIELD_TARGET_MIN_ID]?.toDouble(), minHr.toDouble(), maxHr.toDouble(), 0.0, 1.0) + powerbar.maxTarget = remap(streamData.heartrateTarget?.values[FIELD_TARGET_MAX_ID]?.toDouble(), minHr.toDouble(), maxHr.toDouble(), 0.0, 1.0) + powerbar.target = remap(streamData.heartrateTarget?.values[FIELD_TARGET_VALUE_ID]?.toDouble(), minHr.toDouble(), maxHr.toDouble(), 0.0, 1.0) + + powerbar.progressColor = if (streamData.settings?.useZoneColors == true) { + context.getColor(getZone(streamData.userProfile.heartRateZones, value)?.colorResource ?: R.color.zone7) } else { - powerbar.progressColor = context.getColor(R.color.zone0) - powerbar.progress = null - powerbar.label = "?" - - Log.d(TAG, "Hr: Unavailable") + context.getColor(R.color.zone0) } - powerbar.invalidate() + powerbar.progress = if (value > 0) progress else null + powerbar.label = "$value" + + Log.d(TAG, "Hr: $value min: $minHr max: $maxHr") + } else { + powerbar.progressColor = context.getColor(R.color.zone0) + powerbar.progress = null + powerbar.label = "?" + + Log.d(TAG, "Hr: Unavailable") } + powerbar.invalidate() + } } enum class PowerStreamSmoothing(val dataTypeId: String){ @@ -297,42 +368,49 @@ class Window( val powerFlow = karooSystem.streamDataFlow(smoothed.dataTypeId) .map { (it as? StreamState.Streaming)?.dataPoint?.singleValue } .distinctUntilChanged() - + val settingsFlow = context.streamSettings() - karooSystem.streamUserProfile() + val powerTargetFlow = karooSystem.streamDataFlow("TYPE_WORKOUT_POWER_TARGET_ID") // TYPE_WORKOUT_HEART_RATE_TARGET_ID, TYPE_WORKOUT_CADENCE_TARGET_ID, + .map { (it as? StreamState.Streaming)?.dataPoint } .distinctUntilChanged() - .combine(powerFlow) { userProfile, hr -> StreamData(userProfile, hr) } - .combine(settingsFlow) { streamData, settings -> streamData.copy(settings = settings) } - .distinctUntilChanged() - .collect { streamData -> - val value = streamData.value?.roundToInt() - if (value != null) { - val customMinPower = if (streamData.settings?.useCustomPowerRange == true) streamData.settings.minPower else null - val customMaxPower = if (streamData.settings?.useCustomPowerRange == true) streamData.settings.maxPower else null - val minPower = customMinPower ?: streamData.userProfile.powerZones.first().min - val maxPower = customMaxPower ?: (streamData.userProfile.powerZones.last().min + 50) - val progress = remap(value.toDouble(), minPower.toDouble(), maxPower.toDouble(), 0.0, 1.0) + data class StreamData(val userProfile: UserProfile, val value: Double?, val settings: PowerbarSettings? = null, val powerTarget: DataPoint? = null) - powerbar.progressColor = if (streamData.settings?.useZoneColors == true) { - context.getColor(getZone(streamData.userProfile.powerZones, value)?.colorResource ?: R.color.zone7) - } else { - context.getColor(R.color.zone0) - } - powerbar.progress = if (value > 0) progress else null - powerbar.label = "${value}W" + combine(karooSystem.streamUserProfile(), powerFlow, settingsFlow, powerTargetFlow) { userProfile, hr, settings, powerTarget -> + StreamData(userProfile, hr, settings, powerTarget) + }.distinctUntilChanged().throttle(1_000).collect { streamData -> + val value = streamData.value?.roundToInt() - Log.d(TAG, "Power: $value min: $minPower max: $maxPower") + if (value != null) { + val customMinPower = if (streamData.settings?.useCustomPowerRange == true) streamData.settings.minPower else null + val customMaxPower = if (streamData.settings?.useCustomPowerRange == true) streamData.settings.maxPower else null + val minPower = customMinPower ?: streamData.userProfile.powerZones.first().min + val maxPower = customMaxPower ?: (streamData.userProfile.powerZones.last().min + 30) + val progress = remap(value.toDouble(), minPower.toDouble(), maxPower.toDouble(), 0.0, 1.0) + + powerbar.minTarget = remap(streamData.powerTarget?.values[FIELD_TARGET_MIN_ID]?.toDouble(), minPower.toDouble(), maxPower.toDouble(), 0.0, 1.0) + powerbar.maxTarget = remap(streamData.powerTarget?.values[FIELD_TARGET_MAX_ID]?.toDouble(), minPower.toDouble(), maxPower.toDouble(), 0.0, 1.0) + powerbar.target = remap(streamData.powerTarget?.values[FIELD_TARGET_VALUE_ID]?.toDouble(), minPower.toDouble(), maxPower.toDouble(), 0.0, 1.0) + + powerbar.progressColor = if (streamData.settings?.useZoneColors == true) { + context.getColor(getZone(streamData.userProfile.powerZones, value)?.colorResource ?: R.color.zone7) } else { - powerbar.progressColor = context.getColor(R.color.zone0) - powerbar.progress = null - powerbar.label = "?" - - Log.d(TAG, "Power: Unavailable") + context.getColor(R.color.zone0) } - powerbar.invalidate() + powerbar.progress = if (value > 0) progress else null + powerbar.label = "${value}W" + + Log.d(TAG, "Power: $value min: $minPower max: $maxPower") + } else { + powerbar.progressColor = context.getColor(R.color.zone0) + powerbar.progress = null + powerbar.label = "?" + + Log.d(TAG, "Power: Unavailable") } + powerbar.invalidate() + } } private var currentHideJob: Job? = null diff --git a/app/src/main/kotlin/de/timklge/karoopowerbar/screens/MainScreen.kt b/app/src/main/kotlin/de/timklge/karoopowerbar/screens/MainScreen.kt index 6e2f128..2947f6c 100644 --- a/app/src/main/kotlin/de/timklge/karoopowerbar/screens/MainScreen.kt +++ b/app/src/main/kotlin/de/timklge/karoopowerbar/screens/MainScreen.kt @@ -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 } diff --git a/app/src/main/res/layout/popup_window.xml b/app/src/main/res/layout/popup_window.xml index 6acc92f..d5ff363 100644 --- a/app/src/main/res/layout/popup_window.xml +++ b/app/src/main/res/layout/popup_window.xml @@ -2,12 +2,12 @@ + android:layout_height="80dp"> diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 169a7a7..0596253 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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"] diff --git a/settings.gradle.kts b/settings.gradle.kts index d07d436..a5ec10a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -34,6 +34,11 @@ dependencyResolutionManagement { password = gprKey } } + + // mapbox + maven { + url = uri("https://api.mapbox.com/downloads/v2/releases/maven") + } } }