Compare commits

...

11 Commits

Author SHA1 Message Date
b72180ee27 Update release notes
Some checks failed
Build / build (push) Failing after 8m41s
2025-08-09 18:22:28 +02:00
timklge
97fadc742e
Add power balance source with center outward drawing mode (#52)
* Add power balance source with center outward drawing mode

* Add apk archive step
2025-08-09 18:20:31 +02:00
timklge
304a984110
Fix remaining distance bar shows label "null" if distance is unknown (#50) 2025-08-09 15:12:43 +02:00
timklge
f25ab5d7db
Also grow grade powerbar on descents, do not show if grade < 0.5 % (#51) 2025-08-09 15:12:30 +02:00
f99a82ec74 fix #46: Add screenshot, update changelog
All checks were successful
Build / build (push) Successful in 8m54s
2025-07-14 20:12:41 +02:00
timklge
2d7947b6a2
Fix grade color (#48)
* Set default gradient range to 0 - 15

* Fix grade color
2025-07-14 20:07:23 +02:00
timklge
31418b834d
fix #44: Add option to split powerbars (#47) 2025-07-13 17:02:35 +02:00
timklge
c202f20320
fix #42, fix #43: Add grade data source (#45) 2025-07-12 22:05:43 +02:00
timklge
1afeaae9e6
Update route progress datasource to show ridden distance, add route remaining datasource (#41)
All checks were successful
Build / build (push) Successful in 9m54s
2025-06-24 09:44:43 +02:00
timklge
b8c3356674
Rename "opaque background" to "solid background" (#40)
All checks were successful
Build / build (push) Successful in 4m49s
2025-06-02 20:03:02 +02:00
timklge
807623d04a
Swap top and bar bottom buttons in settings (#39) 2025-06-02 20:02:22 +02:00
12 changed files with 800 additions and 215 deletions

View File

@ -45,6 +45,12 @@ 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: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2

View File

@ -12,6 +12,7 @@ Compatible with Karoo 2 and Karoo 3 devices.
![Powerbar](powerbar0.png) ![Powerbar](powerbar0.png)
![Settings](powerbar1.png) ![Settings](powerbar1.png)
![Powerbar GIF](powerbar_min.gif) ![Powerbar GIF](powerbar_min.gif)
![Powerbar x4](powerbar2.png)
## Usage ## Usage
@ -25,6 +26,9 @@ to be displayed at the bottom or at the top of the screen:
- Average power over the last 10 seconds - Average power over the last 10 seconds
- Speed - Speed
- Cadence - Cadence
- Grade
- Route Progress (shows currently ridden distance)
- Remaining Route (shows remaining distance to the end of the route)
Subsequently, the bar(s) will be shown when riding. Bars are filled and colored according Subsequently, the bar(s) will be shown when riding. Bars are filled and colored according
to your current power output / heart rate zone as setup in your Karoo settings. Optionally, the actual data value can be displayed on top of the bar. to your current power output / heart rate zone as setup in your Karoo settings. Optionally, the actual data value can be displayed on top of the bar.

View File

@ -72,11 +72,13 @@ tasks.register("generateManifest") {
"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 "* Add route progress data source\n* Add workout target range indicator\n* Make bars transparent by default\n* Split size setting", "releaseNotes" to "* Add pedal balance data source\n* Add option to split bars\n* Add grade data source",
"screenshotUrls" to listOf( "screenshotUrls" to listOf(
"$baseUrl/powerbar_min.gif", "$baseUrl/powerbar_min.gif",
"$baseUrl/powerbar0.png", "$baseUrl/powerbar0.png",
"$baseUrl/powerbar2.png", "$baseUrl/powerbar2.png",
"$baseUrl/powerbar1.png",
"$baseUrl/powerbar3.png",
) )
) )

View File

@ -8,15 +8,33 @@ import android.graphics.Paint
import android.graphics.RectF import android.graphics.RectF
import android.graphics.Typeface import android.graphics.Typeface
import android.util.AttributeSet import android.util.AttributeSet
import android.util.Log
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 de.timklge.karoopowerbar.screens.SelectedSource
class CustomProgressBar @JvmOverloads constructor( class CustomView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null context: Context, attrs: AttributeSet? = null
) : View(context, attrs) { ) : View(context, attrs) {
var progressBars: Map<HorizontalPowerbarLocation, CustomProgressBar>? = null
override fun onDrawForeground(canvas: Canvas) {
super.onDrawForeground(canvas)
// Draw all progress bars
progressBars?.values?.forEach { progressBar ->
Log.d(KarooPowerbarExtension.TAG, "Drawing progress bar for source: ${progressBar.source} - location: ${progressBar.location} - horizontalLocation: ${progressBar.horizontalLocation}")
progressBar.onDrawForeground(canvas)
}
}
}
class CustomProgressBar(private val view: CustomView,
val source: SelectedSource,
val location: PowerbarLocation,
val horizontalLocation: HorizontalPowerbarLocation) {
var progress: Double? = 0.5 var progress: Double? = 0.5
var location: PowerbarLocation = PowerbarLocation.BOTTOM
var label: String = "" var label: String = ""
var minTarget: Double? = null var minTarget: Double? = null
var maxTarget: Double? = null var maxTarget: Double? = null
@ -24,12 +42,13 @@ class CustomProgressBar @JvmOverloads constructor(
var showLabel: Boolean = true var showLabel: Boolean = true
var barBackground: Boolean = false var barBackground: Boolean = false
@ColorInt var progressColor: Int = 0xFF2b86e6.toInt() @ColorInt var progressColor: Int = 0xFF2b86e6.toInt()
var drawMode: ProgressBarDrawMode = ProgressBarDrawMode.STANDARD
var fontSize = CustomProgressBarFontSize.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 view.invalidate() // Redraw to apply new font size
} }
var barSize = CustomProgressBarBarSize.MEDIUM var barSize = CustomProgressBarBarSize.MEDIUM
@ -45,7 +64,7 @@ class CustomProgressBar @JvmOverloads constructor(
CustomProgressBarBarSize.MEDIUM -> 8f CustomProgressBarBarSize.MEDIUM -> 8f
CustomProgressBarBarSize.LARGE -> 10f CustomProgressBarBarSize.LARGE -> 10f
} }
invalidate() // Redraw to apply new bar size view.invalidate() // Redraw to apply new bar size
} }
private val targetColor = 0xFF9933FF.toInt() private val targetColor = 0xFF9933FF.toInt()
@ -120,9 +139,7 @@ class CustomProgressBar @JvmOverloads constructor(
style = Paint.Style.STROKE style = Paint.Style.STROKE
} }
override fun onDrawForeground(canvas: Canvas) { fun onDrawForeground(canvas: Canvas) {
super.onDrawForeground(canvas)
// Determine if the current progress is within the target range // Determine if the current progress is within the target range
val isTargetMet = val isTargetMet =
progress != null && minTarget != null && maxTarget != null && progress!! >= minTarget!! && progress!! <= maxTarget!! progress != null && minTarget != null && maxTarget != null && progress!! >= minTarget!! && progress!! <= maxTarget!!
@ -132,25 +149,113 @@ class CustomProgressBar @JvmOverloads constructor(
blurPaint.color = progressColor blurPaint.color = progressColor
blurPaintHighlight.color = ColorUtils.blendARGB(progressColor, 0xFFFFFF, 0.5f) blurPaintHighlight.color = ColorUtils.blendARGB(progressColor, 0xFFFFFF, 0.5f)
val p = (progress ?: 0.0).coerceIn(0.0, 1.0)
val fullWidth = canvas.width.toFloat()
val halfWidth = fullWidth / 2f
// Calculate bar left and right positions based on draw mode
val (barLeft, barRight) = when (drawMode) {
ProgressBarDrawMode.STANDARD -> {
// Standard left-to-right progress bar
when (horizontalLocation) {
HorizontalPowerbarLocation.LEFT -> Pair(0f, (halfWidth * p).toFloat())
HorizontalPowerbarLocation.RIGHT -> Pair(fullWidth - (halfWidth * p).toFloat(), fullWidth)
HorizontalPowerbarLocation.FULL -> Pair(0f, (fullWidth * p).toFloat())
}
}
ProgressBarDrawMode.CENTER_OUT -> {
// Center-outward progress bar: 0.5 = invisible, <0.5 = extend left, >0.5 = extend right
when (horizontalLocation) {
HorizontalPowerbarLocation.LEFT -> {
val centerPoint = halfWidth / 2f // Center of left half
when {
p == 0.5 -> Pair(centerPoint, centerPoint) // Invisible at 0.5
p < 0.5 -> {
val leftExtent = centerPoint * (0.5 - p) * 2.0 // Map 0.0-0.5 to full left extension
Pair((centerPoint - leftExtent).toFloat(), centerPoint)
}
else -> {
val rightExtent = centerPoint * (p - 0.5) * 2.0 // Map 0.5-1.0 to full right extension
Pair(centerPoint, (centerPoint + rightExtent).toFloat())
}
}
}
HorizontalPowerbarLocation.RIGHT -> {
val centerPoint = halfWidth + (halfWidth / 2f) // Center of right half
when {
p == 0.5 -> Pair(centerPoint, centerPoint) // Invisible at 0.5
p < 0.5 -> {
val leftExtent = (halfWidth / 2f) * (0.5 - p) * 2.0 // Map 0.0-0.5 to full left extension
Pair((centerPoint - leftExtent).toFloat(), centerPoint)
}
else -> {
val rightExtent = (halfWidth / 2f) * (p - 0.5) * 2.0 // Map 0.5-1.0 to full right extension
Pair(centerPoint, (centerPoint + rightExtent).toFloat())
}
}
}
HorizontalPowerbarLocation.FULL -> {
val centerPoint = halfWidth // Center of full width
when {
p == 0.5 -> Pair(centerPoint, centerPoint) // Invisible at 0.5
p < 0.5 -> {
val leftExtent = halfWidth * (0.5 - p) * 2.0 // Map 0.0-0.5 to full left extension
Pair((centerPoint - leftExtent).toFloat(), centerPoint)
}
else -> {
val rightExtent = halfWidth * (p - 0.5) * 2.0 // Map 0.5-1.0 to full right extension
Pair(centerPoint, (centerPoint + rightExtent).toFloat())
}
}
}
}
}
}
val minTargetX = when (horizontalLocation) {
HorizontalPowerbarLocation.LEFT -> if (minTarget != null) (halfWidth * minTarget!!).toFloat() else 0f
HorizontalPowerbarLocation.RIGHT -> if (minTarget != null) halfWidth + (halfWidth * minTarget!!).toFloat() else 0f
HorizontalPowerbarLocation.FULL -> if (minTarget != null) (fullWidth * minTarget!!).toFloat() else 0f
}
val maxTargetX = when (horizontalLocation) {
HorizontalPowerbarLocation.LEFT -> if (maxTarget != null) (halfWidth * maxTarget!!).toFloat() else 0f
HorizontalPowerbarLocation.RIGHT -> if (maxTarget != null) halfWidth + (halfWidth * maxTarget!!).toFloat() else 0f
HorizontalPowerbarLocation.FULL -> if (maxTarget != null) (fullWidth * maxTarget!!).toFloat() else 0f
}
val targetX = when (horizontalLocation) {
HorizontalPowerbarLocation.LEFT -> if (target != null) (halfWidth * target!!).toFloat() else 0f
HorizontalPowerbarLocation.RIGHT -> if (target != null) halfWidth + (halfWidth * target!!).toFloat() else 0f
HorizontalPowerbarLocation.FULL -> if (target != null) (fullWidth * target!!).toFloat() else 0f
}
val backgroundLeft = when (horizontalLocation) {
HorizontalPowerbarLocation.LEFT -> 0f
HorizontalPowerbarLocation.RIGHT -> halfWidth
HorizontalPowerbarLocation.FULL -> 0f
}
val backgroundRight = when (horizontalLocation) {
HorizontalPowerbarLocation.LEFT -> halfWidth
HorizontalPowerbarLocation.RIGHT -> fullWidth
HorizontalPowerbarLocation.FULL -> fullWidth
}
when (location) { when (location) {
PowerbarLocation.TOP -> { PowerbarLocation.TOP -> {
val rect = RectF( val rect = RectF(
1f, barLeft,
15f, 15f,
((canvas.width.toDouble() - 1f) * (progress ?: 0.0).coerceIn(0.0, 1.0)).toFloat(), barRight,
15f + barSize.barHeight // barSize.barHeight will be 0f if NONE 15f + barSize.barHeight // barSize.barHeight will be 0f if NONE
) )
// Draw bar components only if barSize is not NONE // Draw bar components only if barSize is not NONE
if (barSize != CustomProgressBarBarSize.NONE) { if (barSize != CustomProgressBarBarSize.NONE) {
if (barBackground){ if (barBackground){
canvas.drawRect(0f, 15f, canvas.width.toFloat(), 15f + barSize.barHeight, backgroundPaint) canvas.drawRect(backgroundLeft, 15f, backgroundRight, 15f + barSize.barHeight, backgroundPaint)
} }
// Draw target zone fill behind the progress bar // Draw target zone fill behind the progress bar
if (minTarget != null && maxTarget != null) { if (minTarget != null && maxTarget != null) {
val minTargetX = (canvas.width * minTarget!!).toFloat()
val maxTargetX = (canvas.width * maxTarget!!).toFloat()
canvas.drawRoundRect( canvas.drawRoundRect(
minTargetX, minTargetX,
15f, 15f,
@ -172,8 +277,6 @@ class CustomProgressBar @JvmOverloads constructor(
if (progress != null) { if (progress != null) {
// Draw target zone stroke after progress bar, before label // Draw target zone stroke after progress bar, before label
if (minTarget != null && maxTarget != null) { 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 // Draw stroked rounded rectangle for the target zone
canvas.drawRoundRect( canvas.drawRoundRect(
minTargetX, minTargetX,
@ -188,7 +291,6 @@ class CustomProgressBar @JvmOverloads constructor(
// Draw vertical target indicator line if target is present // Draw vertical target indicator line if target is present
if (target != null) { if (target != null) {
val targetX = (canvas.width * target!!).toFloat()
targetIndicatorPaint.color = if (isTargetMet) Color.GREEN else Color.RED targetIndicatorPaint.color = if (isTargetMet) Color.GREEN else Color.RED
canvas.drawLine(targetX, 15f, targetX, 15f + barSize.barHeight, targetIndicatorPaint) canvas.drawLine(targetX, 15f, targetX, 15f + barSize.barHeight, targetIndicatorPaint)
} }
@ -203,7 +305,44 @@ class CustomProgressBar @JvmOverloads constructor(
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 x = (rect.right - xOffset).coerceIn(0f..canvas.width-xOffset*2f)
// Calculate label position based on draw mode
val x = when (drawMode) {
ProgressBarDrawMode.STANDARD -> {
// Original logic for standard mode
(if (horizontalLocation != HorizontalPowerbarLocation.RIGHT) rect.right - xOffset else rect.left - xOffset).coerceIn(backgroundLeft..backgroundRight-xOffset*2f)
}
ProgressBarDrawMode.CENTER_OUT -> {
// For center outward mode, position label at the edge of the bar
when {
p == 0.5 -> {
// When bar is invisible (at center), position at center
when (horizontalLocation) {
HorizontalPowerbarLocation.LEFT -> {
val centerPoint = halfWidth / 2f
(centerPoint - xOffset).coerceIn(backgroundLeft..backgroundRight-xOffset*2f)
}
HorizontalPowerbarLocation.RIGHT -> {
val centerPoint = halfWidth + (halfWidth / 2f)
(centerPoint - xOffset).coerceIn(backgroundLeft..backgroundRight-xOffset*2f)
}
HorizontalPowerbarLocation.FULL -> {
val centerPoint = halfWidth
(centerPoint - xOffset).coerceIn(backgroundLeft..backgroundRight-xOffset*2f)
}
}
}
p < 0.5 -> {
// Bar extends left from center, place label at left edge
(rect.left - xOffset).coerceIn(backgroundLeft..backgroundRight-xOffset*2f)
}
else -> {
// Bar extends right from center, place label at right edge
(rect.right - xOffset).coerceIn(backgroundLeft..backgroundRight-xOffset*2f)
}
}
}
}
val r = x + xOffset * 2 val r = x + xOffset * 2
val fm = textPaint.fontMetrics val fm = textPaint.fontMetrics
@ -226,9 +365,9 @@ class CustomProgressBar @JvmOverloads constructor(
PowerbarLocation.BOTTOM -> { PowerbarLocation.BOTTOM -> {
val rect = RectF( val rect = RectF(
1f, barLeft,
canvas.height.toFloat() - 1f - barSize.barHeight, // barSize.barHeight will be 0f if NONE 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(), barRight,
canvas.height.toFloat() canvas.height.toFloat()
) )
@ -236,13 +375,11 @@ class CustomProgressBar @JvmOverloads constructor(
if (barSize != CustomProgressBarBarSize.NONE) { if (barSize != CustomProgressBarBarSize.NONE) {
if (barBackground){ if (barBackground){
// Use barSize.barHeight for background top calculation // Use barSize.barHeight for background top calculation
canvas.drawRect(0f, canvas.height.toFloat() - barSize.barHeight, canvas.width.toFloat(), canvas.height.toFloat(), backgroundPaint) canvas.drawRect(backgroundLeft, canvas.height.toFloat() - barSize.barHeight, backgroundRight, canvas.height.toFloat(), backgroundPaint)
} }
// Draw target zone fill behind the progress bar // Draw target zone fill behind the progress bar
if (minTarget != null && maxTarget != null) { if (minTarget != null && maxTarget != null) {
val minTargetX = (canvas.width * minTarget!!).toFloat()
val maxTargetX = (canvas.width * maxTarget!!).toFloat()
canvas.drawRoundRect( canvas.drawRoundRect(
minTargetX, minTargetX,
canvas.height.toFloat() - barSize.barHeight, canvas.height.toFloat() - barSize.barHeight,
@ -265,8 +402,6 @@ class CustomProgressBar @JvmOverloads constructor(
if (progress != null) { if (progress != null) {
// Draw target zone stroke after progress bar, before label // Draw target zone stroke after progress bar, before label
if (minTarget != null && maxTarget != null) { 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 // Draw stroked rounded rectangle for the target zone
canvas.drawRoundRect( canvas.drawRoundRect(
minTargetX, minTargetX,
@ -281,7 +416,6 @@ class CustomProgressBar @JvmOverloads constructor(
// Draw vertical target indicator line if target is present // Draw vertical target indicator line if target is present
if (target != null) { if (target != null) {
val targetX = (canvas.width * target!!).toFloat()
targetIndicatorPaint.color = if (isTargetMet) Color.GREEN else Color.RED targetIndicatorPaint.color = if (isTargetMet) Color.GREEN else Color.RED
canvas.drawLine(targetX, canvas.height.toFloat() - barSize.barHeight, targetX, canvas.height.toFloat(), targetIndicatorPaint) canvas.drawLine(targetX, canvas.height.toFloat() - barSize.barHeight, targetX, canvas.height.toFloat(), targetIndicatorPaint)
} }
@ -296,7 +430,44 @@ class CustomProgressBar @JvmOverloads constructor(
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 x = (rect.right - xOffset).coerceIn(0f..canvas.width-xOffset*2f)
// Calculate label position based on draw mode
val x = when (drawMode) {
ProgressBarDrawMode.STANDARD -> {
// Original logic for standard mode
(if (horizontalLocation != HorizontalPowerbarLocation.RIGHT) rect.right - xOffset else rect.left - xOffset).coerceIn(backgroundLeft..backgroundRight-xOffset*2f)
}
ProgressBarDrawMode.CENTER_OUT -> {
// For center outward mode, position label at the edge of the bar
when {
p == 0.5 -> {
// When bar is invisible (at center), position at center
when (horizontalLocation) {
HorizontalPowerbarLocation.LEFT -> {
val centerPoint = halfWidth / 2f
(centerPoint - xOffset).coerceIn(backgroundLeft..backgroundRight-xOffset*2f)
}
HorizontalPowerbarLocation.RIGHT -> {
val centerPoint = halfWidth + (halfWidth / 2f)
(centerPoint - xOffset).coerceIn(backgroundLeft..backgroundRight-xOffset*2f)
}
HorizontalPowerbarLocation.FULL -> {
val centerPoint = halfWidth
(centerPoint - xOffset).coerceIn(backgroundLeft..backgroundRight-xOffset*2f)
}
}
}
p < 0.5 -> {
// Bar extends left from center, place label at left edge
(rect.left - xOffset).coerceIn(backgroundLeft..backgroundRight-xOffset*2f)
}
else -> {
// Bar extends right from center, place label at right edge
(rect.right - xOffset).coerceIn(backgroundLeft..backgroundRight-xOffset*2f)
}
}
}
}
val r = x + xOffset * 2 val r = x + xOffset * 2
// textDrawBaselineY calculation uses rect.top and barSize.barHeight. // textDrawBaselineY calculation uses rect.top and barSize.barHeight.
@ -315,4 +486,9 @@ class CustomProgressBar @JvmOverloads constructor(
} }
} }
} }
fun invalidate() {
// Invalidate the view to trigger a redraw
view.invalidate()
}
} }

View File

@ -52,19 +52,21 @@ class ForegroundService : Service() {
windows.forEach { it.close() } windows.forEach { it.close() }
windows.clear() windows.clear()
if (settings.source != SelectedSource.NONE && showBars) { if (showBars){
Window(this@ForegroundService, PowerbarLocation.BOTTOM, settings.showLabelOnBars, settings.barBackground, settings.barBarSize, settings.barFontSize).apply { if (settings.bottomBarSource != SelectedSource.NONE || settings.bottomBarLeftSource != SelectedSource.NONE || settings.bottomBarRightSource != SelectedSource.NONE) {
selectedSource = settings.source Window(this@ForegroundService, PowerbarLocation.BOTTOM, settings.showLabelOnBars, settings.barBackground, settings.barBarSize, settings.barFontSize,
windows.add(this) settings.splitBottomBar, settings.bottomBarSource, settings.bottomBarLeftSource, settings.bottomBarRightSource).apply {
open() windows.add(this)
open()
}
} }
}
if (settings.topBarSource != SelectedSource.NONE && showBars){ if (settings.topBarSource != SelectedSource.NONE || settings.topBarLeftSource != SelectedSource.NONE || settings.topBarRightSource != SelectedSource.NONE) {
Window(this@ForegroundService, PowerbarLocation.TOP, settings.showLabelOnBars, settings.barBackground, settings.barBarSize, settings.barFontSize).apply { Window(this@ForegroundService, PowerbarLocation.TOP, settings.showLabelOnBars, settings.barBackground, settings.barBarSize, settings.barFontSize,
selectedSource = settings.topBarSource settings.splitTopBar, settings.topBarSource, settings.topBarLeftSource, settings.topBarRightSource).apply {
open() open()
windows.add(this) windows.add(this)
}
} }
} }
} }

View File

@ -7,6 +7,7 @@ import de.timklge.karoopowerbar.screens.SelectedSource
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@ -15,8 +16,16 @@ val settingsKey = stringPreferencesKey("settings")
@Serializable @Serializable
data class PowerbarSettings( data class PowerbarSettings(
val source: SelectedSource = SelectedSource.POWER, @SerialName("source") val bottomBarSource: SelectedSource = SelectedSource.POWER,
val topBarSource: SelectedSource = SelectedSource.NONE, val topBarSource: SelectedSource = SelectedSource.NONE,
val splitTopBar: Boolean = false,
val splitBottomBar: Boolean = false,
val topBarLeftSource: SelectedSource = SelectedSource.NONE,
val topBarRightSource: SelectedSource = SelectedSource.NONE,
val bottomBarLeftSource: SelectedSource = SelectedSource.POWER,
val bottomBarRightSource: SelectedSource = SelectedSource.NONE,
val onlyShowWhileRiding: Boolean = true, val onlyShowWhileRiding: Boolean = true,
val showLabelOnBars: Boolean = true, val showLabelOnBars: Boolean = true,
val useZoneColors: Boolean = true, val useZoneColors: Boolean = true,
@ -29,7 +38,11 @@ data class PowerbarSettings(
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
val minPower: Int? = null, val maxPower: Int? = null, val minPower: Int? = null, val maxPower: Int? = null,
val minHr: Int? = null, val maxHr: Int? = null, val minHr: Int? = null, val maxHr: Int? = null,
val useCustomHrRange: Boolean = false, val useCustomPowerRange: Boolean = false val minGradient: Int? = defaultMinGradient, val maxGradient: Int? = defaultMaxGradient,
val useCustomGradientRange: Boolean = false,
val useCustomHrRange: Boolean = false,
val useCustomPowerRange: Boolean = false
){ ){
companion object { companion object {
val defaultSettings = Json.encodeToString(PowerbarSettings()) val defaultSettings = Json.encodeToString(PowerbarSettings())
@ -37,6 +50,8 @@ data class PowerbarSettings(
const val defaultMaxSpeedMs = 13.89f const val defaultMaxSpeedMs = 13.89f
const val defaultMinCadence = 50 const val defaultMinCadence = 50
const val defaultMaxCadence = 120 const val defaultMaxCadence = 120
const val defaultMinGradient = 0
const val defaultMaxGradient = 15
} }
} }

View File

@ -34,10 +34,11 @@ 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 java.util.Locale
import kotlin.math.absoluteValue
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? {
@ -50,6 +51,15 @@ enum class PowerbarLocation {
TOP, BOTTOM TOP, BOTTOM
} }
enum class HorizontalPowerbarLocation {
FULL, LEFT, RIGHT
}
enum class ProgressBarDrawMode {
STANDARD, // Normal left-to-right progress
CENTER_OUT // Progress extends outward from center (0.5 = invisible, <0.5 = left, >0.5 = right)
}
class Window( class Window(
private val context: Context, private val context: Context,
val powerbarLocation: PowerbarLocation = PowerbarLocation.BOTTOM, val powerbarLocation: PowerbarLocation = PowerbarLocation.BOTTOM,
@ -57,6 +67,10 @@ class Window(
val barBackground: Boolean, val barBackground: Boolean,
val powerbarBarSize: CustomProgressBarBarSize, val powerbarBarSize: CustomProgressBarBarSize,
val powerbarFontSize: CustomProgressBarFontSize, val powerbarFontSize: CustomProgressBarFontSize,
val splitBars: Boolean,
val selectedSource: SelectedSource = SelectedSource.NONE,
val selectedLeftSource: SelectedSource = SelectedSource.NONE,
val selectedRightSource: SelectedSource = SelectedSource.NONE
) { ) {
companion object { companion object {
val FIELD_TARGET_VALUE_ID = "FIELD_WORKOUT_TARGET_VALUE_ID"; val FIELD_TARGET_VALUE_ID = "FIELD_WORKOUT_TARGET_VALUE_ID";
@ -69,9 +83,8 @@ class Window(
private val windowManager: WindowManager private val windowManager: WindowManager
private val layoutInflater: LayoutInflater private val layoutInflater: LayoutInflater
private val powerbar: CustomProgressBar private val powerbars: MutableMap<HorizontalPowerbarLocation, CustomProgressBar> = mutableMapOf()
private val view: CustomView
var selectedSource: SelectedSource = SelectedSource.POWER
init { init {
layoutParams = WindowManager.LayoutParams( layoutParams = WindowManager.LayoutParams(
@ -84,8 +97,8 @@ class Window(
layoutInflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater layoutInflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
rootView = layoutInflater.inflate(R.layout.popup_window, null) rootView = layoutInflater.inflate(R.layout.popup_window, null)
powerbar = rootView.findViewById(R.id.progressBar) view = rootView.findViewById(R.id.customView)
powerbar.progress = null view.progressBars = powerbars
windowManager = context.getSystemService(WINDOW_SERVICE) as WindowManager windowManager = context.getSystemService(WINDOW_SERVICE) as WindowManager
val displayMetrics = DisplayMetrics() val displayMetrics = DisplayMetrics()
@ -116,45 +129,69 @@ class Window(
private val karooSystem: KarooSystemService = KarooSystemService(context) private val karooSystem: KarooSystemService = KarooSystemService(context)
private var serviceJob: Job? = null private var serviceJobs: MutableSet<Job> = mutableSetOf()
@SuppressLint("UnspecifiedRegisterReceiverFlag") @SuppressLint("UnspecifiedRegisterReceiverFlag")
suspend fun open() { suspend fun open() {
serviceJob = CoroutineScope(Dispatchers.Default).launch { val filter = IntentFilter("de.timklge.HIDE_POWERBAR")
val filter = IntentFilter("de.timklge.HIDE_POWERBAR") if (Build.VERSION.SDK_INT >= 33) {
if (Build.VERSION.SDK_INT >= 33) { context.registerReceiver(hideReceiver, filter, Context.RECEIVER_EXPORTED)
context.registerReceiver(hideReceiver, filter, Context.RECEIVER_EXPORTED) } else {
} else { context.registerReceiver(hideReceiver, filter)
context.registerReceiver(hideReceiver, filter) }
}
karooSystem.connect { connected -> karooSystem.connect { connected ->
Log.i(TAG, "Karoo system service connected: $connected") Log.i(TAG, "Karoo system service connected: $connected")
} }
powerbars.clear()
if (!splitBars) {
if (selectedSource != SelectedSource.NONE){
powerbars[HorizontalPowerbarLocation.FULL] = CustomProgressBar(view, selectedSource, powerbarLocation, HorizontalPowerbarLocation.FULL)
}
} else {
if (selectedLeftSource != SelectedSource.NONE) {
powerbars[HorizontalPowerbarLocation.LEFT] = CustomProgressBar(view, selectedLeftSource, powerbarLocation, HorizontalPowerbarLocation.LEFT)
}
if (selectedRightSource != SelectedSource.NONE) {
powerbars[HorizontalPowerbarLocation.RIGHT] = CustomProgressBar(view, selectedRightSource, powerbarLocation, HorizontalPowerbarLocation.RIGHT)
}
}
powerbars.values.forEach { powerbar ->
powerbar.progressColor = context.resources.getColor(R.color.zone7) powerbar.progressColor = context.resources.getColor(R.color.zone7)
powerbar.progress = null powerbar.progress = null
powerbar.location = powerbarLocation
powerbar.showLabel = showLabel powerbar.showLabel = showLabel
powerbar.barBackground = barBackground powerbar.barBackground = barBackground
powerbar.fontSize = powerbarFontSize powerbar.fontSize = powerbarFontSize
powerbar.barSize = powerbarBarSize powerbar.barSize = powerbarBarSize
powerbar.invalidate() powerbar.invalidate()
}
Log.i(TAG, "Streaming $selectedSource") Log.i(TAG, "Streaming $selectedSource")
when (selectedSource){ val selectedSources = powerbars.values.map { it.source }.toSet()
SelectedSource.POWER -> streamPower(PowerStreamSmoothing.RAW)
SelectedSource.POWER_3S -> streamPower(PowerStreamSmoothing.SMOOTHED_3S) selectedSources.forEach { selectedSource ->
SelectedSource.POWER_10S -> streamPower(PowerStreamSmoothing.SMOOTHED_10S) serviceJobs.add( CoroutineScope(Dispatchers.IO).launch {
SelectedSource.HEART_RATE -> streamHeartrate() Log.i(TAG, "Starting stream for $selectedSource")
SelectedSource.SPEED -> streamSpeed(false)
SelectedSource.SPEED_3S -> streamSpeed(true) when (selectedSource){
SelectedSource.CADENCE -> streamCadence(false) SelectedSource.POWER -> streamPower(SelectedSource.POWER, PowerStreamSmoothing.RAW)
SelectedSource.CADENCE_3S -> streamCadence(true) SelectedSource.POWER_3S -> streamPower(SelectedSource.POWER_3S, PowerStreamSmoothing.SMOOTHED_3S)
SelectedSource.ROUTE_PROGRESS -> streamRouteProgress() SelectedSource.POWER_10S -> streamPower(SelectedSource.POWER_10S, PowerStreamSmoothing.SMOOTHED_10S)
else -> {} SelectedSource.HEART_RATE -> streamHeartrate()
} SelectedSource.SPEED -> streamSpeed(SelectedSource.SPEED, false)
SelectedSource.SPEED_3S -> streamSpeed(SelectedSource.SPEED_3S, true)
SelectedSource.CADENCE -> streamCadence(SelectedSource.CADENCE, false)
SelectedSource.CADENCE_3S -> streamCadence(SelectedSource.CADENCE_3S, true)
SelectedSource.ROUTE_PROGRESS -> streamRouteProgress(SelectedSource.ROUTE_PROGRESS, ::getRouteProgress)
SelectedSource.REMAINING_ROUTE -> streamRouteProgress(SelectedSource.REMAINING_ROUTE, ::getRemainingRouteProgress)
SelectedSource.GRADE -> streamGrade()
SelectedSource.POWER_BALANCE -> streamBalance()
SelectedSource.NONE -> {}
}
})
} }
try { try {
@ -168,19 +205,98 @@ class Window(
} }
} }
private suspend fun streamRouteProgress() { private suspend fun streamBalance() {
data class StreamData(val powerBalanceLeft: Double?, val power: Double?)
karooSystem.streamDataFlow(DataType.Type.PEDAL_POWER_BALANCE)
.map {
val values = (it as? StreamState.Streaming)?.dataPoint?.values
StreamData(values?.get(DataType.Field.PEDAL_POWER_BALANCE_LEFT), values?.get(DataType.Field.POWER))
}
.distinctUntilChanged()
.throttle(1_000).collect { streamData ->
val powerBalanceLeft = streamData.powerBalanceLeft
val powerbarsWithBalanceSource = powerbars.values.filter { it.source == SelectedSource.POWER_BALANCE }
powerbarsWithBalanceSource.forEach { powerbar ->
powerbar.drawMode = ProgressBarDrawMode.CENTER_OUT
if (streamData.powerBalanceLeft != null) {
val value = remap(1.0 - (powerBalanceLeft ?: 0.5).coerceIn(0.0, 1.0), 0.4, 0.6, 0.0, 1.0)
val percentLeft = ((powerBalanceLeft ?: 0.5) * 100).roundToInt()
val percentDiffTo50 = (percentLeft - 50).absoluteValue
@ColorRes val zoneColorRes = Zone.entries[percentDiffTo50.toInt().coerceIn(0, Zone.entries.size-1)].colorResource
powerbar.progressColor = context.getColor(zoneColorRes)
powerbar.progress = value
val percentRight = 100 - percentLeft
powerbar.label = "${percentLeft}-${percentRight}"
Log.d(TAG, "Balance: $powerBalanceLeft power: ${streamData.power}")
} else {
powerbar.progressColor = context.getColor(R.color.zone0)
powerbar.progress = null
powerbar.label = "?"
Log.d(TAG, "Balance: Unavailable")
}
powerbar.invalidate()
}
}
}
data class BarProgress(
val progress: Double?,
val label: String?,
)
private fun getRouteProgress(userProfile: UserProfile, riddenDistance: Double?, routeEndAt: Double?, distanceToDestination: Double?): BarProgress {
val routeProgress = if (routeEndAt != null && riddenDistance != null) remap(riddenDistance, 0.0, routeEndAt, 0.0, 1.0) else null
val routeProgressInUserUnit = when (userProfile.preferredUnit.distance) {
UserProfile.PreferredUnit.UnitType.IMPERIAL -> riddenDistance?.times(0.000621371)?.roundToInt() // Miles
else -> riddenDistance?.times(0.001)?.roundToInt() // Kilometers
}
return BarProgress(routeProgress, routeProgressInUserUnit?.toString())
}
private fun getRemainingRouteProgress(userProfile: UserProfile, riddenDistance: Double?, routeEndAt: Double?, distanceToDestination: Double?): BarProgress {
val routeProgress = if (routeEndAt != null && riddenDistance != null) remap(riddenDistance, 0.0, routeEndAt, 0.0, 1.0) else null
val distanceToDestinationInUserUnit = when (userProfile.preferredUnit.distance) {
UserProfile.PreferredUnit.UnitType.IMPERIAL -> distanceToDestination?.times(0.000621371)?.roundToInt() // Miles
else -> distanceToDestination?.times(0.001)?.roundToInt() // Kilometers
}
return BarProgress(routeProgress, distanceToDestinationInUserUnit?.toString())
}
private suspend fun streamRouteProgress(
source: SelectedSource,
routeProgressProvider: (UserProfile, Double?, Double?, Double?) -> BarProgress
) {
data class StreamData( data class StreamData(
val userProfile: UserProfile, val userProfile: UserProfile,
val distanceToDestination: Double?, val distanceToDestination: Double?,
val navigationState: OnNavigationState val navigationState: OnNavigationState,
val riddenDistance: Double?
) )
var lastKnownRoutePolyline: String? = null var lastKnownRoutePolyline: String? = null
var lastKnownRouteLength: Double? = null var lastKnownRouteLength: Double? = null
combine(karooSystem.streamUserProfile(), karooSystem.streamDataFlow(DataType.Type.DISTANCE_TO_DESTINATION), karooSystem.streamNavigationState()) { userProfile, distanceToDestination, navigationState -> combine(karooSystem.streamUserProfile(), karooSystem.streamDataFlow(DataType.Type.DISTANCE_TO_DESTINATION), karooSystem.streamNavigationState(), karooSystem.streamDataFlow(DataType.Type.DISTANCE)) { userProfile, distanceToDestination, navigationState, riddenDistance ->
StreamData(userProfile, (distanceToDestination as? StreamState.Streaming)?.dataPoint?.values[DataType.Field.DISTANCE_TO_DESTINATION], navigationState) StreamData(
}.distinctUntilChanged().throttle(5_000).collect { (userProfile, distanceToDestination, navigationState) -> userProfile,
(distanceToDestination as? StreamState.Streaming)?.dataPoint?.values?.get(DataType.Field.DISTANCE_TO_DESTINATION),
navigationState,
(riddenDistance as? StreamState.Streaming)?.dataPoint?.values?.get(DataType.Field.DISTANCE)
)
}.distinctUntilChanged().throttle(5_000).collect { (userProfile, distanceToDestination, navigationState, riddenDistance) ->
val state = navigationState.state val state = navigationState.state
val routePolyline = when (state) { val routePolyline = when (state) {
is OnNavigationState.NavigationState.NavigatingRoute -> state.routePolyline is OnNavigationState.NavigationState.NavigatingRoute -> state.routePolyline
@ -202,22 +318,21 @@ class Window(
} }
} }
val routeLength = lastKnownRouteLength val routeEndAt = lastKnownRouteLength?.plus((distanceToDestination ?: 0.0))
val routeProgressMeters = routeLength?.let { routeLength - (distanceToDestination ?: 0.0) }?.coerceAtLeast(0.0) val barProgress = routeProgressProvider(userProfile, riddenDistance, routeEndAt, distanceToDestination)
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) val powerbarsWithRouteProgressSource = powerbars.values.filter { it.source == source }
powerbar.progress = routeProgress
powerbar.label = "$routeProgressInUserUnit" powerbarsWithRouteProgressSource.forEach { powerbar ->
powerbar.invalidate() powerbar.progressColor = context.getColor(R.color.zone0)
powerbar.progress = barProgress.progress
powerbar.label = barProgress.label ?: ""
powerbar.invalidate()
}
} }
} }
private suspend fun streamSpeed(smoothed: Boolean) { private suspend fun streamSpeed(source: SelectedSource, 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 }
.distinctUntilChanged() .distinctUntilChanged()
@ -235,34 +350,99 @@ class Window(
else -> valueMetersPerSecond?.times(3.6) else -> valueMetersPerSecond?.times(3.6)
}?.roundToInt() }?.roundToInt()
if (value != null && valueMetersPerSecond != null) { val powerbarsWithSpeedSource = powerbars.values.filter { it.source == source }
val minSpeed = streamData.settings?.minSpeed ?: PowerbarSettings.defaultMinSpeedMs powerbarsWithSpeedSource.forEach { powerbar ->
val maxSpeed = streamData.settings?.maxSpeed ?: PowerbarSettings.defaultMaxSpeedMs if (value != null) {
val progress = remap(valueMetersPerSecond, minSpeed.toDouble(), maxSpeed.toDouble(), 0.0, 1.0) ?: 0.0 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) ?: 0.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
powerbar.progressColor = if (streamData.settings?.useZoneColors == true) { powerbar.progressColor = if (streamData.settings?.useZoneColors == true) {
context.getColor(zoneColorRes) context.getColor(zoneColorRes)
} else {
context.getColor(R.color.zone0)
}
powerbar.progress = if (value > 0) progress else null
powerbar.label = "$value"
Log.d(TAG, "Speed: $value min: $minSpeed max: $maxSpeed")
} else { } else {
context.getColor(R.color.zone0) powerbar.progressColor = context.getColor(R.color.zone0)
} powerbar.progress = null
powerbar.progress = if (value > 0) progress else null powerbar.label = "?"
powerbar.label = "$value"
Log.d(TAG, "Speed: $value min: $minSpeed max: $maxSpeed") Log.d(TAG, "Speed: Unavailable")
}
powerbar.invalidate()
}
}
}
private suspend fun streamGrade() {
@ColorRes
fun getInclineIndicatorColor(percent: Float): Int? {
return when(percent) {
in -Float.MAX_VALUE..<-7.5f -> R.color.eleDarkBlue // Dark blue
in -7.5f..<-4.6f -> R.color.eleLightBlue // Light blue
in -4.6f..<-2f -> R.color.eleWhite // White
in -2f..<2f -> R.color.eleGray // Gray
in 2f..<4.6f -> R.color.eleDarkGreen // Dark green
in 4.6f..<7.5f -> R.color.eleLightGreen // Light green
in 7.5f..<12.5f -> R.color.eleYellow // Yellow
in 12.5f..<15.5f -> R.color.eleLightOrange // Light Orange
in 15.5f..<19.5f -> R.color.eleDarkOrange // Dark Orange
in 19.5f..<23.5f -> R.color.eleRed // Red
in 23.5f..Float.MAX_VALUE -> R.color.elePurple // Purple
else -> null
}
}
val gradeFlow = karooSystem.streamDataFlow(DataType.Type.ELEVATION_GRADE)
.map { (it as? StreamState.Streaming)?.dataPoint?.singleValue }
.distinctUntilChanged()
data class StreamData(val userProfile: UserProfile, val value: Double?, val settings: PowerbarSettings? = null)
val settingsFlow = context.streamSettings()
combine(karooSystem.streamUserProfile(), gradeFlow, settingsFlow) { userProfile, grade, settings ->
StreamData(userProfile, grade, settings)
}.distinctUntilChanged().throttle(1_000).collect { streamData ->
val value = streamData.value
val powerbarsWithGradeSource = powerbars.values.filter { it.source == SelectedSource.GRADE }
powerbarsWithGradeSource.forEach { powerbar ->
if (value != null) {
if (value.absoluteValue >= 0.5) {
val minGradient = streamData.settings?.minGradient ?: PowerbarSettings.defaultMinGradient
val maxGradient = streamData.settings?.maxGradient ?: PowerbarSettings.defaultMaxGradient
powerbar.progress = remap(value.absoluteValue, minGradient.toDouble(), maxGradient.toDouble(), 0.0, 1.0)
} else {
powerbar.progress = null
}
val colorRes = getInclineIndicatorColor(value.toFloat()) ?: R.color.zone0
powerbar.progressColor = context.getColor(colorRes)
powerbar.label = "${String.format(Locale.getDefault(), "%.1f", value)}%"
Log.d(TAG, "Grade: $value")
} else { } else {
powerbar.progressColor = context.getColor(R.color.zone0) powerbar.progressColor = context.getColor(R.color.zone0)
powerbar.progress = null powerbar.progress = null
powerbar.label = "?" powerbar.label = "?"
Log.d(TAG, "Speed: Unavailable") Log.d(TAG, "Grade: Unavailable")
} }
powerbar.invalidate() powerbar.invalidate()
} }
}
} }
private suspend fun streamCadence(smoothed: Boolean) { private suspend fun streamCadence(source: SelectedSource, smoothed: Boolean) {
val cadenceFlow = 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()
@ -278,35 +458,38 @@ class Window(
StreamData(userProfile, speed, settings, cadenceTarget) StreamData(userProfile, speed, settings, cadenceTarget)
}.distinctUntilChanged().throttle(1_000).collect { streamData -> }.distinctUntilChanged().throttle(1_000).collect { streamData ->
val value = streamData.value?.roundToInt() val value = streamData.value?.roundToInt()
val powerbarsWithCadenceSource = powerbars.values.filter { it.source == source }
if (value != null) { powerbarsWithCadenceSource.forEach { powerbar ->
val minCadence = streamData.settings?.minCadence ?: PowerbarSettings.defaultMinCadence if (value != null) {
val maxCadence = streamData.settings?.maxCadence ?: PowerbarSettings.defaultMaxCadence val minCadence = streamData.settings?.minCadence ?: PowerbarSettings.defaultMinCadence
val progress = remap(value.toDouble(), minCadence.toDouble(), maxCadence.toDouble(), 0.0, 1.0) ?: 0.0 val maxCadence = streamData.settings?.maxCadence ?: PowerbarSettings.defaultMaxCadence
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.minTarget = remap(streamData.cadenceTarget?.values?.get(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.maxTarget = remap(streamData.cadenceTarget?.values?.get(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) powerbar.target = remap(streamData.cadenceTarget?.values?.get(FIELD_TARGET_VALUE_ID)?.toDouble(), minCadence.toDouble(), maxCadence.toDouble(), 0.0, 1.0)
@ColorRes val zoneColorRes = Zone.entries[(progress * Zone.entries.size).roundToInt().coerceIn(0..<Zone.entries.size)].colorResource @ColorRes val zoneColorRes = Zone.entries[(progress * Zone.entries.size).roundToInt().coerceIn(0..<Zone.entries.size)].colorResource
powerbar.progressColor = if (streamData.settings?.useZoneColors == true) { powerbar.progressColor = if (streamData.settings?.useZoneColors == true) {
context.getColor(zoneColorRes) context.getColor(zoneColorRes)
} else {
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")
} else { } else {
context.getColor(R.color.zone0) powerbar.progressColor = context.getColor(R.color.zone0)
powerbar.progress = null
powerbar.label = "?"
Log.d(TAG, "Cadence: Unavailable")
} }
powerbar.progress = if (value > 0) progress else null powerbar.invalidate()
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()
} }
} }
@ -326,35 +509,38 @@ class Window(
StreamData(userProfile, hr, settings, hrTarget) StreamData(userProfile, hr, settings, hrTarget)
}.distinctUntilChanged().throttle(1_000).collect { streamData -> }.distinctUntilChanged().throttle(1_000).collect { streamData ->
val value = streamData.value?.roundToInt() val value = streamData.value?.roundToInt()
val powerbarsWithHrSource = powerbars.values.filter { it.source == SelectedSource.HEART_RATE }
if (value != null) { powerbarsWithHrSource.forEach { powerbar ->
val customMinHr = if (streamData.settings?.useCustomHrRange == true) streamData.settings.minHr else null if (value != null) {
val customMaxHr = if (streamData.settings?.useCustomHrRange == true) streamData.settings.maxHr else null val customMinHr = if (streamData.settings?.useCustomHrRange == true) streamData.settings.minHr else null
val minHr = customMinHr ?: streamData.userProfile.restingHr val customMaxHr = if (streamData.settings?.useCustomHrRange == true) streamData.settings.maxHr else null
val maxHr = customMaxHr ?: streamData.userProfile.maxHr val minHr = customMinHr ?: streamData.userProfile.restingHr
val progress = remap(value.toDouble(), minHr.toDouble(), maxHr.toDouble(), 0.0, 1.0) 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.minTarget = remap(streamData.heartrateTarget?.values?.get(FIELD_TARGET_MIN_ID), 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.maxTarget = remap(streamData.heartrateTarget?.values?.get(FIELD_TARGET_MAX_ID), 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.target = remap(streamData.heartrateTarget?.values?.get(FIELD_TARGET_VALUE_ID), minHr.toDouble(), maxHr.toDouble(), 0.0, 1.0)
powerbar.progressColor = if (streamData.settings?.useZoneColors == true) { powerbar.progressColor = if (streamData.settings?.useZoneColors == true) {
context.getColor(getZone(streamData.userProfile.heartRateZones, value)?.colorResource ?: R.color.zone7) context.getColor(getZone(streamData.userProfile.heartRateZones, value)?.colorResource ?: R.color.zone7)
} else {
context.getColor(R.color.zone0)
}
powerbar.progress = if (value > 0) progress else null
powerbar.label = "$value"
Log.d(TAG, "Hr: $value min: $minHr max: $maxHr")
} else { } else {
context.getColor(R.color.zone0) powerbar.progressColor = context.getColor(R.color.zone0)
powerbar.progress = null
powerbar.label = "?"
Log.d(TAG, "Hr: Unavailable")
} }
powerbar.progress = if (value > 0) progress else null powerbar.invalidate()
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()
} }
} }
@ -364,7 +550,7 @@ class Window(
SMOOTHED_10S(DataType.Type.SMOOTHED_10S_AVERAGE_POWER), SMOOTHED_10S(DataType.Type.SMOOTHED_10S_AVERAGE_POWER),
} }
private suspend fun streamPower(smoothed: PowerStreamSmoothing) { private suspend fun streamPower(source: SelectedSource, smoothed: PowerStreamSmoothing) {
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()
@ -381,35 +567,38 @@ class Window(
StreamData(userProfile, hr, settings, powerTarget) StreamData(userProfile, hr, settings, powerTarget)
}.distinctUntilChanged().throttle(1_000).collect { streamData -> }.distinctUntilChanged().throttle(1_000).collect { streamData ->
val value = streamData.value?.roundToInt() val value = streamData.value?.roundToInt()
val powerbarsWithPowerSource = powerbars.values.filter { it.source == source }
if (value != null) { powerbarsWithPowerSource.forEach { powerbar ->
val customMinPower = if (streamData.settings?.useCustomPowerRange == true) streamData.settings.minPower else null if (value != null) {
val customMaxPower = if (streamData.settings?.useCustomPowerRange == true) streamData.settings.maxPower else null val customMinPower = if (streamData.settings?.useCustomPowerRange == true) streamData.settings.minPower else null
val minPower = customMinPower ?: streamData.userProfile.powerZones.first().min val customMaxPower = if (streamData.settings?.useCustomPowerRange == true) streamData.settings.maxPower else null
val maxPower = customMaxPower ?: (streamData.userProfile.powerZones.last().min + 30) val minPower = customMinPower ?: streamData.userProfile.powerZones.first().min
val progress = remap(value.toDouble(), minPower.toDouble(), maxPower.toDouble(), 0.0, 1.0) 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.minTarget = remap(streamData.powerTarget?.values?.get(FIELD_TARGET_MIN_ID), 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.maxTarget = remap(streamData.powerTarget?.values?.get(FIELD_TARGET_MAX_ID), 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.target = remap(streamData.powerTarget?.values?.get(FIELD_TARGET_VALUE_ID), minPower.toDouble(), maxPower.toDouble(), 0.0, 1.0)
powerbar.progressColor = if (streamData.settings?.useZoneColors == true) { powerbar.progressColor = if (streamData.settings?.useZoneColors == true) {
context.getColor(getZone(streamData.userProfile.powerZones, value)?.colorResource ?: R.color.zone7) context.getColor(getZone(streamData.userProfile.powerZones, value)?.colorResource ?: R.color.zone7)
} else {
context.getColor(R.color.zone0)
}
powerbar.progress = if (value > 0) progress else null
powerbar.label = "${value}W"
Log.d(TAG, "Power: $value min: $minPower max: $maxPower")
} else { } else {
context.getColor(R.color.zone0) powerbar.progressColor = context.getColor(R.color.zone0)
powerbar.progress = null
powerbar.label = "?"
Log.d(TAG, "Power: Unavailable")
} }
powerbar.progress = if (value > 0) progress else null powerbar.invalidate()
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()
} }
} }
@ -422,7 +611,9 @@ class Window(
currentHideJob?.cancel() currentHideJob?.cancel()
currentHideJob = null currentHideJob = null
} }
serviceJob?.cancel() serviceJobs.forEach { job ->
job.cancel()
}
(context.getSystemService(WINDOW_SERVICE) as WindowManager).removeView(rootView) (context.getSystemService(WINDOW_SERVICE) as WindowManager).removeView(rootView)
rootView.invalidate() rootView.invalidate()
(rootView.parent as? ViewGroup)?.removeAllViews() (rootView.parent as? ViewGroup)?.removeAllViews()

View File

@ -59,7 +59,6 @@ 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.CustomProgressBarBarSize
import de.timklge.karoopowerbar.CustomProgressBarFontSize import de.timklge.karoopowerbar.CustomProgressBarFontSize
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
import de.timklge.karoopowerbar.R import de.timklge.karoopowerbar.R
@ -85,10 +84,13 @@ enum class SelectedSource(val id: String, val label: String) {
POWER_3S("power_3s", "Power (3 sec avg)"), POWER_3S("power_3s", "Power (3 sec avg)"),
POWER_10S("power_10s", "Power (10 sec avg)"), POWER_10S("power_10s", "Power (10 sec avg)"),
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"); GRADE("grade", "Grade"),
POWER_BALANCE("power_balance", "Power Balance"),
ROUTE_PROGRESS("route_progress", "Route Progress"),
REMAINING_ROUTE("route_progress_remaining", "Route Remaining");
fun isPower() = this == POWER || this == POWER_3S || this == POWER_10S fun isPower() = this == POWER || this == POWER_3S || this == POWER_10S
} }
@ -138,9 +140,22 @@ fun MainScreen(onFinish: () -> Unit) {
var bottomSelectedSource by remember { mutableStateOf(SelectedSource.POWER) } var bottomSelectedSource by remember { mutableStateOf(SelectedSource.POWER) }
var topSelectedSource by remember { mutableStateOf(SelectedSource.NONE) } var topSelectedSource by remember { mutableStateOf(SelectedSource.NONE) }
var splitTopBar by remember { mutableStateOf(false) }
var splitBottomBar by remember { mutableStateOf(false) }
var topSelectedSourceLeft by remember { mutableStateOf(SelectedSource.NONE) }
var topSelectedSourceRight by remember { mutableStateOf(SelectedSource.NONE) }
var bottomSelectedSourceLeft by remember { mutableStateOf(SelectedSource.NONE) }
var bottomSelectedSourceRight by remember { mutableStateOf(SelectedSource.NONE) }
var bottomBarDialogVisible by remember { mutableStateOf(false) } var bottomBarDialogVisible by remember { mutableStateOf(false) }
var topBarDialogVisible by remember { mutableStateOf(false) } var topBarDialogVisible by remember { mutableStateOf(false) }
var topBarLeftDialogVisible by remember { mutableStateOf(false) }
var topBarRightDialogVisible by remember { mutableStateOf(false) }
var bottomBarLeftDialogVisible by remember { mutableStateOf(false) }
var bottomBarRightDialogVisible by remember { mutableStateOf(false) }
var showAlerts by remember { mutableStateOf(false) } var showAlerts by remember { mutableStateOf(false) }
var givenPermissions by remember { mutableStateOf(false) } var givenPermissions by remember { mutableStateOf(false) }
@ -160,6 +175,8 @@ fun MainScreen(onFinish: () -> Unit) {
var customMaxPower by remember { mutableStateOf("") } var customMaxPower by remember { mutableStateOf("") }
var customMinHr by remember { mutableStateOf("") } var customMinHr by remember { mutableStateOf("") }
var customMaxHr by remember { mutableStateOf("") } var customMaxHr by remember { mutableStateOf("") }
var minGrade by remember { mutableStateOf("0") }
var maxGrade by remember { mutableStateOf("0") }
var useCustomPowerRange by remember { mutableStateOf(false) } var useCustomPowerRange by remember { mutableStateOf(false) }
var useCustomHrRange by remember { mutableStateOf(false) } var useCustomHrRange by remember { mutableStateOf(false) }
@ -177,7 +194,13 @@ fun MainScreen(onFinish: () -> Unit) {
val maxSpeedSetting = (maxSpeed.toIntOrNull()?.toFloat()?.div((if(isImperial) 2.23694f else 3.6f))) ?: PowerbarSettings.defaultMaxSpeedMs val maxSpeedSetting = (maxSpeed.toIntOrNull()?.toFloat()?.div((if(isImperial) 2.23694f else 3.6f))) ?: PowerbarSettings.defaultMaxSpeedMs
val newSettings = PowerbarSettings( val newSettings = PowerbarSettings(
source = bottomSelectedSource, topBarSource = topSelectedSource, bottomBarSource = bottomSelectedSource, topBarSource = topSelectedSource,
splitTopBar = splitTopBar,
splitBottomBar = splitBottomBar,
topBarLeftSource = topSelectedSourceLeft,
topBarRightSource = topSelectedSourceRight,
bottomBarLeftSource = bottomSelectedSourceLeft,
bottomBarRightSource = bottomSelectedSourceRight,
onlyShowWhileRiding = onlyShowWhileRiding, showLabelOnBars = showLabelOnBars, onlyShowWhileRiding = onlyShowWhileRiding, showLabelOnBars = showLabelOnBars,
barBackground = barBackground, barBackground = barBackground,
useZoneColors = colorBasedOnZones, useZoneColors = colorBasedOnZones,
@ -188,6 +211,8 @@ fun MainScreen(onFinish: () -> Unit) {
maxPower = customMaxPower.toIntOrNull(), maxPower = customMaxPower.toIntOrNull(),
minHr = customMinHr.toIntOrNull(), minHr = customMinHr.toIntOrNull(),
maxHr = customMaxHr.toIntOrNull(), maxHr = customMaxHr.toIntOrNull(),
minGradient = minGrade.toIntOrNull() ?: PowerbarSettings.defaultMinGradient,
maxGradient = maxGrade.toIntOrNull() ?: PowerbarSettings.defaultMaxGradient,
barBarSize = barBarSize, barBarSize = barBarSize,
barFontSize = barFontSize, barFontSize = barFontSize,
useCustomPowerRange = useCustomPowerRange, useCustomPowerRange = useCustomPowerRange,
@ -226,8 +251,14 @@ fun MainScreen(onFinish: () -> Unit) {
.combine(karooSystem.streamUserProfile()) { settings, profile -> settings to profile } .combine(karooSystem.streamUserProfile()) { settings, profile -> settings to profile }
.distinctUntilChanged() .distinctUntilChanged()
.collect { (settings, profile) -> .collect { (settings, profile) ->
bottomSelectedSource = settings.source bottomSelectedSource = settings.bottomBarSource
topSelectedSource = settings.topBarSource topSelectedSource = settings.topBarSource
splitTopBar = settings.splitTopBar
splitBottomBar = settings.splitBottomBar
topSelectedSourceLeft = settings.topBarLeftSource
topSelectedSourceRight = settings.topBarRightSource
bottomSelectedSourceLeft = settings.bottomBarLeftSource
bottomSelectedSourceRight = settings.bottomBarRightSource
onlyShowWhileRiding = settings.onlyShowWhileRiding onlyShowWhileRiding = settings.onlyShowWhileRiding
showLabelOnBars = settings.showLabelOnBars showLabelOnBars = settings.showLabelOnBars
colorBasedOnZones = settings.useZoneColors colorBasedOnZones = settings.useZoneColors
@ -243,6 +274,8 @@ fun MainScreen(onFinish: () -> Unit) {
customMaxPower = settings.maxPower?.toString() ?: "" customMaxPower = settings.maxPower?.toString() ?: ""
customMinHr = settings.minHr?.toString() ?: "" customMinHr = settings.minHr?.toString() ?: ""
customMaxHr = settings.maxHr?.toString() ?: "" customMaxHr = settings.maxHr?.toString() ?: ""
minGrade = settings.minGradient?.toString() ?: ""
maxGrade = settings.maxGradient?.toString() ?: ""
useCustomPowerRange = settings.useCustomPowerRange useCustomPowerRange = settings.useCustomPowerRange
useCustomHrRange = settings.useCustomHrRange useCustomHrRange = settings.useCustomHrRange
} }
@ -276,34 +309,66 @@ fun MainScreen(onFinish: () -> Unit) {
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(10.dp)) { .fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(10.dp)) {
FilledTonalButton(modifier = Modifier Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 10.dp)) {
.fillMaxWidth() Text("Top Bar", style = MaterialTheme.typography.titleMedium)
.height(60.dp), Spacer(modifier = Modifier.weight(1f))
onClick = { Text("Split")
bottomBarDialogVisible = true Spacer(modifier = Modifier.width(10.dp))
}) { Switch(checked = splitTopBar, onCheckedChange = {
Icon(Icons.Default.Build, contentDescription = "Select", modifier = Modifier.size(20.dp)) splitTopBar = it
Spacer(modifier = Modifier.width(5.dp))
Text("Bottom Bar: ${bottomSelectedSource.label}", modifier = Modifier.weight(1.0f))
}
if (bottomBarDialogVisible){
BarSelectDialog(bottomSelectedSource, onHide = { bottomBarDialogVisible = false }, onSelect = { selected ->
bottomSelectedSource = selected
coroutineScope.launch { updateSettings() } coroutineScope.launch { updateSettings() }
bottomBarDialogVisible = false
}) })
} }
FilledTonalButton(modifier = Modifier if (splitTopBar) {
.fillMaxWidth() FilledTonalButton(modifier = Modifier
.height(60.dp), .fillMaxWidth()
onClick = { .height(60.dp),
topBarDialogVisible = true onClick = {
}) { topBarLeftDialogVisible = true
Icon(Icons.Default.Build, contentDescription = "Select", modifier = Modifier.size(20.dp)) }) {
Spacer(modifier = Modifier.width(5.dp)) Icon(Icons.Default.Build, contentDescription = "Select", modifier = Modifier.size(20.dp))
Text("Top Bar: ${topSelectedSource.label}", modifier = Modifier.weight(1.0f)) Spacer(modifier = Modifier.width(5.dp))
Text("Top Bar (Left): ${topSelectedSourceLeft.label}", modifier = Modifier.weight(1.0f))
}
if (topBarLeftDialogVisible){
BarSelectDialog(topSelectedSourceLeft, onHide = { topBarLeftDialogVisible = false }, onSelect = { selected ->
topSelectedSourceLeft = selected
coroutineScope.launch { updateSettings() }
topBarLeftDialogVisible = false
})
}
FilledTonalButton(modifier = Modifier
.fillMaxWidth()
.height(60.dp),
onClick = {
topBarRightDialogVisible = true
}) {
Icon(Icons.Default.Build, contentDescription = "Select", modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.width(5.dp))
Text("Top Bar (Right): ${topSelectedSourceRight.label}", modifier = Modifier.weight(1.0f))
}
if (topBarRightDialogVisible){
BarSelectDialog(topSelectedSourceRight, onHide = { topBarRightDialogVisible = false }, onSelect = { selected ->
topSelectedSourceRight = selected
coroutineScope.launch { updateSettings() }
topBarRightDialogVisible = false
})
}
} else {
FilledTonalButton(modifier = Modifier
.fillMaxWidth()
.height(60.dp),
onClick = {
topBarDialogVisible = true
}) {
Icon(Icons.Default.Build, contentDescription = "Select", modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.width(5.dp))
Text("Top Bar: ${topSelectedSource.label}", modifier = Modifier.weight(1.0f))
}
} }
if (topBarDialogVisible){ if (topBarDialogVisible){
@ -314,6 +379,76 @@ fun MainScreen(onFinish: () -> Unit) {
}) })
} }
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 10.dp)) {
Text("Bottom Bar", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.weight(1f))
Text("Split")
Spacer(modifier = Modifier.width(10.dp))
Switch(checked = splitBottomBar, onCheckedChange = {
splitBottomBar = it
coroutineScope.launch { updateSettings() }
})
}
if (splitBottomBar) {
FilledTonalButton(modifier = Modifier
.fillMaxWidth()
.height(60.dp),
onClick = {
bottomBarLeftDialogVisible = true
}) {
Icon(Icons.Default.Build, contentDescription = "Select", modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.width(5.dp))
Text("Bottom Bar (Left): ${bottomSelectedSourceLeft.label}", modifier = Modifier.weight(1.0f))
}
if (bottomBarLeftDialogVisible){
BarSelectDialog(bottomSelectedSourceLeft, onHide = { bottomBarLeftDialogVisible = false }, onSelect = { selected ->
bottomSelectedSourceLeft = selected
coroutineScope.launch { updateSettings() }
bottomBarLeftDialogVisible = false
})
}
FilledTonalButton(modifier = Modifier
.fillMaxWidth()
.height(60.dp),
onClick = {
bottomBarRightDialogVisible = true
}) {
Icon(Icons.Default.Build, contentDescription = "Select", modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.width(5.dp))
Text("Bottom Bar (Right): ${bottomSelectedSourceRight.label}", modifier = Modifier.weight(1.0f))
}
if (bottomBarRightDialogVisible){
BarSelectDialog(bottomSelectedSourceRight, onHide = { bottomBarRightDialogVisible = false }, onSelect = { selected ->
bottomSelectedSourceRight = selected
coroutineScope.launch { updateSettings() }
bottomBarRightDialogVisible = false
})
}
} else {
FilledTonalButton(modifier = Modifier
.fillMaxWidth()
.height(60.dp),
onClick = {
bottomBarDialogVisible = true
}) {
Icon(Icons.Default.Build, contentDescription = "Select", modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.width(5.dp))
Text("Bottom Bar: ${bottomSelectedSource.label}", modifier = Modifier.weight(1.0f))
}
}
if (bottomBarDialogVisible){
BarSelectDialog(bottomSelectedSource, onHide = { bottomBarDialogVisible = false }, onSelect = { selected ->
bottomSelectedSource = selected
coroutineScope.launch { updateSettings() }
bottomBarDialogVisible = false
})
}
apply { apply {
val dropdownOptions = CustomProgressBarBarSize.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(barBarSize) { val dropdownInitialSelection by remember(barBarSize) {
@ -337,14 +472,17 @@ fun MainScreen(onFinish: () -> Unit) {
} }
if (topSelectedSource == SelectedSource.SPEED || topSelectedSource == SelectedSource.SPEED_3S || if (topSelectedSource == SelectedSource.SPEED || topSelectedSource == SelectedSource.SPEED_3S ||
bottomSelectedSource == SelectedSource.SPEED || bottomSelectedSource == SelectedSource.SPEED_3S){ bottomSelectedSource == SelectedSource.SPEED || bottomSelectedSource == SelectedSource.SPEED_3S ||
(splitTopBar && (topSelectedSourceLeft == SelectedSource.SPEED || topSelectedSourceLeft == SelectedSource.SPEED_3S || topSelectedSourceRight == SelectedSource.SPEED || topSelectedSourceRight == SelectedSource.SPEED_3S)) ||
(splitBottomBar && (bottomSelectedSourceLeft == SelectedSource.SPEED || bottomSelectedSourceLeft == SelectedSource.SPEED_3S || bottomSelectedSourceRight == SelectedSource.SPEED || bottomSelectedSourceRight == SelectedSource.SPEED_3S))
){
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(value = minSpeed, modifier = Modifier OutlinedTextField(value = minSpeed, modifier = Modifier
.weight(1f) .weight(1f)
.absolutePadding(right = 2.dp) .absolutePadding(right = 2.dp)
.onFocusEvent(::updateFocus), .onFocusEvent(::updateFocus),
onValueChange = { minSpeed = it }, onValueChange = { minSpeed = it.filter { c -> c.isDigit() } },
label = { Text("Min Speed") }, label = { Text("Min Speed") },
suffix = { Text(if (isImperial) "mph" else "kph") }, suffix = { Text(if (isImperial) "mph" else "kph") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
@ -355,7 +493,7 @@ fun MainScreen(onFinish: () -> Unit) {
.weight(1f) .weight(1f)
.absolutePadding(left = 2.dp) .absolutePadding(left = 2.dp)
.onFocusEvent(::updateFocus), .onFocusEvent(::updateFocus),
onValueChange = { maxSpeed = it }, onValueChange = { maxSpeed = it.filter { c -> c.isDigit() } },
label = { Text("Max Speed") }, label = { Text("Max Speed") },
suffix = { Text(if (isImperial) "mph" else "kph") }, suffix = { Text(if (isImperial) "mph" else "kph") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
@ -364,7 +502,10 @@ fun MainScreen(onFinish: () -> Unit) {
} }
} }
if (topSelectedSource.isPower() || bottomSelectedSource.isPower()){ if (topSelectedSource.isPower() || bottomSelectedSource.isPower() ||
(splitTopBar && (topSelectedSourceLeft.isPower() || topSelectedSourceRight.isPower())) ||
(splitBottomBar && (bottomSelectedSourceLeft.isPower() || bottomSelectedSourceRight.isPower()))
){
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = useCustomPowerRange, onCheckedChange = { Switch(checked = useCustomPowerRange, onCheckedChange = {
useCustomPowerRange = it useCustomPowerRange = it
@ -380,7 +521,7 @@ fun MainScreen(onFinish: () -> Unit) {
.weight(1f) .weight(1f)
.absolutePadding(right = 2.dp) .absolutePadding(right = 2.dp)
.onFocusEvent(::updateFocus), .onFocusEvent(::updateFocus),
onValueChange = { customMinPower = it }, onValueChange = { customMinPower = it.filter { c -> c.isDigit() } },
label = { Text("Min Power", fontSize = 12.sp) }, label = { Text("Min Power", fontSize = 12.sp) },
suffix = { Text("W") }, suffix = { Text("W") },
placeholder = { Text("$profileMinPower") }, placeholder = { Text("$profileMinPower") },
@ -392,7 +533,7 @@ fun MainScreen(onFinish: () -> Unit) {
.weight(1f) .weight(1f)
.absolutePadding(left = 2.dp) .absolutePadding(left = 2.dp)
.onFocusEvent(::updateFocus), .onFocusEvent(::updateFocus),
onValueChange = { customMaxPower = it }, onValueChange = { customMaxPower = it.filter { c -> c.isDigit() } },
label = { Text("Max Power", fontSize = 12.sp) }, label = { Text("Max Power", fontSize = 12.sp) },
suffix = { Text("W") }, suffix = { Text("W") },
placeholder = { Text("$profileMaxPower") }, placeholder = { Text("$profileMaxPower") },
@ -403,7 +544,10 @@ fun MainScreen(onFinish: () -> Unit) {
} }
} }
if (topSelectedSource == SelectedSource.HEART_RATE || bottomSelectedSource == SelectedSource.HEART_RATE){ if (topSelectedSource == SelectedSource.HEART_RATE || bottomSelectedSource == SelectedSource.HEART_RATE ||
(splitTopBar && (topSelectedSourceLeft == SelectedSource.HEART_RATE || topSelectedSourceRight == SelectedSource.HEART_RATE)) ||
(splitBottomBar && (bottomSelectedSourceLeft == SelectedSource.HEART_RATE || bottomSelectedSourceRight == SelectedSource.HEART_RATE))
){
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = useCustomHrRange, onCheckedChange = { Switch(checked = useCustomHrRange, onCheckedChange = {
useCustomHrRange = it useCustomHrRange = it
@ -419,7 +563,7 @@ fun MainScreen(onFinish: () -> Unit) {
.weight(1f) .weight(1f)
.absolutePadding(right = 2.dp) .absolutePadding(right = 2.dp)
.onFocusEvent(::updateFocus), .onFocusEvent(::updateFocus),
onValueChange = { customMinHr = it }, onValueChange = { customMinHr = it.filter { c -> c.isDigit() } },
label = { Text("Min Hr") }, label = { Text("Min Hr") },
suffix = { Text("bpm") }, suffix = { Text("bpm") },
placeholder = { if(profileRestHr > 0) Text("$profileRestHr") else Unit }, placeholder = { if(profileRestHr > 0) Text("$profileRestHr") else Unit },
@ -431,7 +575,7 @@ fun MainScreen(onFinish: () -> Unit) {
.weight(1f) .weight(1f)
.absolutePadding(left = 2.dp) .absolutePadding(left = 2.dp)
.onFocusEvent(::updateFocus), .onFocusEvent(::updateFocus),
onValueChange = { customMaxHr = it }, onValueChange = { customMaxHr = it.filter { c -> c.isDigit() } },
label = { Text("Max Hr") }, label = { Text("Max Hr") },
suffix = { Text("bpm") }, suffix = { Text("bpm") },
placeholder = { if(profileMaxHr > 0) Text("$profileMaxHr") else Unit }, placeholder = { if(profileMaxHr > 0) Text("$profileMaxHr") else Unit },
@ -443,14 +587,17 @@ fun MainScreen(onFinish: () -> Unit) {
} }
if (bottomSelectedSource == SelectedSource.CADENCE || topSelectedSource == SelectedSource.CADENCE || if (bottomSelectedSource == SelectedSource.CADENCE || topSelectedSource == SelectedSource.CADENCE ||
bottomSelectedSource == SelectedSource.CADENCE_3S || topSelectedSource == SelectedSource.CADENCE_3S){ bottomSelectedSource == SelectedSource.CADENCE_3S || topSelectedSource == SelectedSource.CADENCE_3S ||
(splitTopBar && (topSelectedSourceLeft == SelectedSource.CADENCE || topSelectedSourceLeft == SelectedSource.CADENCE_3S || topSelectedSourceRight == SelectedSource.CADENCE || topSelectedSourceRight == SelectedSource.CADENCE_3S)) ||
(splitBottomBar && (bottomSelectedSourceLeft == SelectedSource.CADENCE || bottomSelectedSourceLeft == SelectedSource.CADENCE_3S || bottomSelectedSourceRight == SelectedSource.CADENCE || bottomSelectedSourceRight == SelectedSource.CADENCE_3S))
){
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(value = minCadence, modifier = Modifier OutlinedTextField(value = minCadence, modifier = Modifier
.weight(1f) .weight(1f)
.absolutePadding(right = 2.dp) .absolutePadding(right = 2.dp)
.onFocusEvent(::updateFocus), .onFocusEvent(::updateFocus),
onValueChange = { minCadence = it }, onValueChange = { minCadence = it.filter { c -> c.isDigit() } },
label = { Text("Min Cadence") }, label = { Text("Min Cadence") },
suffix = { Text("rpm") }, suffix = { Text("rpm") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
@ -461,7 +608,7 @@ fun MainScreen(onFinish: () -> Unit) {
.weight(1f) .weight(1f)
.absolutePadding(left = 2.dp) .absolutePadding(left = 2.dp)
.onFocusEvent(::updateFocus), .onFocusEvent(::updateFocus),
onValueChange = { maxCadence = it }, onValueChange = { maxCadence = it.filter { c -> c.isDigit() } },
label = { Text("Min Cadence") }, label = { Text("Min Cadence") },
suffix = { Text("rpm") }, suffix = { Text("rpm") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
@ -470,6 +617,35 @@ fun MainScreen(onFinish: () -> Unit) {
} }
} }
if (topSelectedSource == SelectedSource.GRADE || bottomSelectedSource == SelectedSource.GRADE ||
(splitTopBar && (topSelectedSourceLeft == SelectedSource.GRADE || topSelectedSourceRight == SelectedSource.GRADE)) ||
(splitBottomBar && (bottomSelectedSourceLeft == SelectedSource.GRADE || bottomSelectedSourceRight == SelectedSource.GRADE))
){
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(value = minGrade, modifier = Modifier
.weight(1f)
.absolutePadding(right = 2.dp)
.onFocusEvent(::updateFocus),
onValueChange = { minGrade = it.filterIndexed { index, c -> c.isDigit() || (c == '-' && index == 0) } },
label = { Text("Min Grade") },
suffix = { Text("%") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true
)
OutlinedTextField(value = maxGrade, modifier = Modifier
.weight(1f)
.absolutePadding(left = 2.dp)
.onFocusEvent(::updateFocus),
onValueChange = { maxGrade = it.filterIndexed { index, c -> c.isDigit() || (c == '-' && index == 0) } },
label = { Text("Max Grade") },
suffix = { Text("%") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true
)
}
}
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = colorBasedOnZones, onCheckedChange = { Switch(checked = colorBasedOnZones, onCheckedChange = {
colorBasedOnZones = it colorBasedOnZones = it
@ -494,7 +670,7 @@ fun MainScreen(onFinish: () -> Unit) {
coroutineScope.launch { updateSettings() } coroutineScope.launch { updateSettings() }
}) })
Spacer(modifier = Modifier.width(10.dp)) Spacer(modifier = Modifier.width(10.dp))
Text("Opaque background") Text("Solid background")
} }
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {

View File

@ -4,8 +4,8 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="80dp"> android:layout_height="80dp">
<de.timklge.karoopowerbar.CustomProgressBar <de.timklge.karoopowerbar.CustomView
android:id="@+id/progressBar" android:id="@+id/customView"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="80dp" android:layout_height="80dp"
android:layout_gravity="center" /> android:layout_gravity="center" />

View File

@ -12,4 +12,17 @@
<color name="zone6">#FE581F</color> <color name="zone6">#FE581F</color>
<color name="zone7">#D60404</color> <color name="zone7">#D60404</color>
<color name="zone8">#B700A2</color> <color name="zone8">#B700A2</color>
<color name="eleDarkGreen">#079d78</color>
<color name="eleLightGreen">#58c597</color>
<color name="eleYellow">#e7e021</color>
<color name="eleLightOrange">#e59174</color>
<color name="eleDarkOrange">#e7693a</color>
<color name="eleRed">#c82425</color>
<color name="eleLightRed">#f05a28</color>
<color name="elePurple">#b222a3</color>
<color name="eleWhite">#ffffff</color>
<color name="eleGray">#9e9e9e</color>
<color name="eleLightBlue">#4fc3f7</color>
<color name="eleDarkBlue">#2D58AF</color>
</resources> </resources>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 46 KiB

BIN
powerbar3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB