Compare commits

...

24 Commits

Author SHA1 Message Date
4f7a508740 Update changelog
Some checks failed
Build / build (push) Failing after 7m31s
2025-08-20 14:08:15 +02:00
timklge
fe6065a90b
Fix gear powerbar refresh (#64) 2025-08-20 14:07:22 +02:00
timklge
f26986900a
Fix pedal balance bar is inverted (#63)
Some checks failed
Build / build (push) Failing after 5m15s
2025-08-18 18:01:00 +02:00
timklge
f0495768fb
Fix smoothing options for pedal balance source (#62) 2025-08-18 18:00:50 +02:00
659250fb78 Abbreviate custom speed label in german translation 2025-08-18 17:55:23 +02:00
timklge
c762e961ba
Update available data sources in README 2025-08-16 20:06:49 +02:00
timklge
2a02a8e649
Add 3 second, 10 second moving averages for power balance data source (#61)
Some checks failed
Build / build (push) Failing after 7m15s
2025-08-16 19:59:12 +02:00
timklge
4c5b6aac15
Add german localization (#60) 2025-08-16 19:56:34 +02:00
timklge
b038641326
Add gear data sources (#58)
* Add gear data sources

* Respect zone color setting
2025-08-16 19:28:40 +02:00
timklge
53d7fb660c
Display permission notification at the top of the settings menu if it has not been granted (#59) 2025-08-16 19:27:50 +02:00
fa0f3c931e Update changleog
Some checks failed
Build / build (push) Failing after 7m14s
2025-08-15 22:25:58 +02:00
timklge
d30bcb7abe
Show zero value on bars to indicate sensor availability to the user (#56) 2025-08-15 22:25:43 +02:00
timklge
34c87f68ab
Fix pedal balance is multiplied by 100, update balance colors (#55) 2025-08-15 22:21:47 +02:00
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
16 changed files with 1109 additions and 294 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
@ -20,11 +21,15 @@ it on top of other apps (i. e. the karoo ride app). You can select one of the fo
to be displayed at the bottom or at the top of the screen: to be displayed at the bottom or at the top of the screen:
- Power - Power
- Heart rate - Heart Rate
- Average power over the last 3 seconds - Power (Instant, 3s, 10s)
- 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)
- Power Balance (Instant, 3s, 10s)
- Gears
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 "* Fix gear bar refresh\n* Add german localization\n* Add gear data sources\n* Show zero value on bars to indicate sensor availability\n* Fix pedal balance values\n* Add pedal balance data source\n* Add option to split bars",
"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

@ -3,17 +3,17 @@ package de.timklge.karoopowerbar
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
enum class CustomProgressBarSize(val id: String, val label: String, val fontSize: Float, val barHeight: Float) { enum class CustomProgressBarSize(val id: String, val labelResId: Int, val fontSize: Float, val barHeight: Float) {
SMALL("small", "Small", 35f, 10f), SMALL("small", R.string.size_small, 35f, 10f),
MEDIUM("medium", "Medium", 40f, 15f), MEDIUM("medium", R.string.size_medium, 40f, 15f),
LARGE("large", "Large", 60f, 25f), LARGE("large", R.string.size_large, 60f, 25f),
} }
@Serializable @Serializable
enum class CustomProgressBarFontSize(val id: String, val label: String, val fontSize: Float) { enum class CustomProgressBarFontSize(val id: String, val labelResId: Int, val fontSize: Float) {
SMALL("small", "Small", 35f), SMALL("small", R.string.size_small, 35f),
MEDIUM("medium", "Medium", 40f), MEDIUM("medium", R.string.size_medium, 40f),
LARGE("large", "Large", 60f); LARGE("large", R.string.size_large, 60f);
companion object { companion object {
fun fromSize(size: CustomProgressBarSize): CustomProgressBarFontSize { fun fromSize(size: CustomProgressBarSize): CustomProgressBarFontSize {
@ -27,11 +27,11 @@ enum class CustomProgressBarFontSize(val id: String, val label: String, val font
} }
@Serializable @Serializable
enum class CustomProgressBarBarSize(val id: String, val label: String, val barHeight: Float) { enum class CustomProgressBarBarSize(val id: String, val labelResId: Int, val barHeight: Float) {
NONE("none", "None", 0f), NONE("none", R.string.size_none, 0f),
SMALL("small", "Small", 10f), SMALL("small", R.string.size_small, 10f),
MEDIUM("medium", "Medium", 15f), MEDIUM("medium", R.string.size_medium, 15f),
LARGE("large", "Large", 25f); LARGE("large", R.string.size_large, 25f);
companion object { companion object {
fun fromSize(size: CustomProgressBarSize): CustomProgressBarBarSize { fun fromSize(size: CustomProgressBarSize): CustomProgressBarBarSize {

View File

@ -52,17 +52,18 @@ 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,
settings.splitBottomBar, settings.bottomBarSource, settings.bottomBarLeftSource, settings.bottomBarRightSource).apply {
windows.add(this) windows.add(this)
open() 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)
} }
@ -70,6 +71,7 @@ class ForegroundService : Service() {
} }
} }
} }
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
return super.onStartCommand(intent, flags, startId) return super.onStartCommand(intent, flags, startId)
@ -77,7 +79,7 @@ class ForegroundService : Service() {
private fun setupForeground() { private fun setupForeground() {
val channelId = "de.timklge.karoopowerbar" val channelId = "de.timklge.karoopowerbar"
val channelName = "Background Service" val channelName = getString(R.string.notification_channel_name)
val chan = NotificationChannel( val chan = NotificationChannel(
channelId, channelId,
channelName, channelName,
@ -86,14 +88,13 @@ class ForegroundService : Service() {
val manager = val manager =
checkNotNull(getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager?) checkNotNull(getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager?)
manager.createNotificationChannel(chan)
val notificationBuilder: NotificationCompat.Builder = manager.createNotificationChannel(chan)
NotificationCompat.Builder(this, channelId) val notificationBuilder = NotificationCompat.Builder(this, channelId)
val notification: Notification = notificationBuilder.setOngoing(true) val notification = notificationBuilder.setOngoing(true)
.setContentTitle("Powerbar service running")
.setContentText("Displaying on top of other apps")
.setSmallIcon(R.drawable.bar) .setSmallIcon(R.drawable.bar)
.setContentTitle(getString(R.string.app_name))
.setContentText(getString(R.string.notification_text))
.setPriority(NotificationManager.IMPORTANCE_MIN) .setPriority(NotificationManager.IMPORTANCE_MIN)
.setCategory(Notification.CATEGORY_SERVICE) .setCategory(Notification.CATEGORY_SERVICE)
.build() .build()

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,11 +129,10 @@ 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)
@ -132,29 +144,58 @@ class Window(
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")
val selectedSources = powerbars.values.map { it.source }.toSet()
selectedSources.forEach { selectedSource ->
serviceJobs.add( CoroutineScope(Dispatchers.IO).launch {
Log.i(TAG, "Starting stream for $selectedSource")
when (selectedSource){ when (selectedSource){
SelectedSource.POWER -> streamPower(PowerStreamSmoothing.RAW) SelectedSource.POWER -> streamPower(SelectedSource.POWER, PowerStreamSmoothing.RAW)
SelectedSource.POWER_3S -> streamPower(PowerStreamSmoothing.SMOOTHED_3S) SelectedSource.POWER_3S -> streamPower(SelectedSource.POWER_3S, PowerStreamSmoothing.SMOOTHED_3S)
SelectedSource.POWER_10S -> streamPower(PowerStreamSmoothing.SMOOTHED_10S) SelectedSource.POWER_10S -> streamPower(SelectedSource.POWER_10S, PowerStreamSmoothing.SMOOTHED_10S)
SelectedSource.HEART_RATE -> streamHeartrate() SelectedSource.HEART_RATE -> streamHeartrate()
SelectedSource.SPEED -> streamSpeed(false) SelectedSource.SPEED -> streamSpeed(SelectedSource.SPEED, false)
SelectedSource.SPEED_3S -> streamSpeed(true) SelectedSource.SPEED_3S -> streamSpeed(SelectedSource.SPEED_3S, true)
SelectedSource.CADENCE -> streamCadence(false) SelectedSource.CADENCE -> streamCadence(SelectedSource.CADENCE, false)
SelectedSource.CADENCE_3S -> streamCadence(true) SelectedSource.CADENCE_3S -> streamCadence(SelectedSource.CADENCE_3S, true)
SelectedSource.ROUTE_PROGRESS -> streamRouteProgress() SelectedSource.ROUTE_PROGRESS -> streamRouteProgress(SelectedSource.ROUTE_PROGRESS, ::getRouteProgress)
else -> {} SelectedSource.REMAINING_ROUTE -> streamRouteProgress(SelectedSource.REMAINING_ROUTE, ::getRemainingRouteProgress)
SelectedSource.GRADE -> streamGrade()
SelectedSource.POWER_BALANCE -> streamBalance(PedalBalanceSmoothing.RAW, SelectedSource.POWER_BALANCE)
SelectedSource.POWER_BALANCE_3S -> streamBalance(PedalBalanceSmoothing.SMOOTHED_3S, SelectedSource.POWER_BALANCE_3S)
SelectedSource.POWER_BALANCE_10S -> streamBalance(PedalBalanceSmoothing.SMOOTHED_10S, SelectedSource.POWER_BALANCE_10S)
SelectedSource.FRONT_GEAR -> streamGears(Gears.FRONT)
SelectedSource.REAR_GEAR -> streamGears(Gears.REAR)
SelectedSource.NONE -> {}
} }
})
} }
try { try {
@ -168,19 +209,103 @@ class Window(
} }
} }
private suspend fun streamRouteProgress() { private suspend fun streamBalance(smoothing: PedalBalanceSmoothing, selectedSource: SelectedSource) {
data class StreamData(val powerBalanceLeft: Double?, val power: Double?)
karooSystem.streamDataFlow(smoothing.dataTypeId)
.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 }
powerbarsWithBalanceSource.forEach { powerbar ->
powerbar.drawMode = ProgressBarDrawMode.CENTER_OUT
if (streamData.powerBalanceLeft != null) {
val value = remap((powerBalanceLeft ?: 50.0).coerceIn(0.0, 100.0), 40.0, 60.0, 100.0, 0.0)
val percentLeft = (powerBalanceLeft ?: 50.0).roundToInt()
@ColorRes val zoneColorRes = if (percentLeft < 50) {
R.color.zone0
} else if (percentLeft == 50) {
R.color.zone1
} else {
R.color.zone7
}
powerbar.progressColor = context.getColor(zoneColorRes)
powerbar.progress = value?.div(100.0)
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 +327,74 @@ 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
}
val powerbarsWithRouteProgressSource = powerbars.values.filter { it.source == source }
powerbarsWithRouteProgressSource.forEach { powerbar ->
powerbar.progressColor = context.getColor(R.color.zone0) powerbar.progressColor = context.getColor(R.color.zone0)
powerbar.progress = routeProgress powerbar.progress = barProgress.progress
powerbar.label = "$routeProgressInUserUnit" powerbar.label = barProgress.label ?: ""
powerbar.invalidate() powerbar.invalidate()
} }
} }
}
private suspend fun streamSpeed(smoothed: Boolean) { enum class Gears(val prefix: String, val dataTypeId: String, val numberFieldId: String, val maxFieldId: String) {
FRONT("F", DataType.Type.SHIFTING_FRONT_GEAR, DataType.Field.SHIFTING_FRONT_GEAR, DataType.Field.SHIFTING_FRONT_GEAR_MAX),
REAR("R", DataType.Type.SHIFTING_REAR_GEAR, DataType.Field.SHIFTING_REAR_GEAR, DataType.Field.SHIFTING_REAR_GEAR_MAX)
}
private suspend fun streamGears(gears: Gears) {
data class GearsState(val currentGear: Int?, val maxGear: Int?, val colorize: Boolean)
data class StreamState(val settings: PowerbarSettings, val streamState: io.hammerhead.karooext.models.StreamState?)
val gearsSource = when (gears) {
Gears.FRONT -> SelectedSource.FRONT_GEAR
Gears.REAR -> SelectedSource.REAR_GEAR
}
combine(context.streamSettings(), karooSystem.streamDataFlow(gears.dataTypeId)) { settings, streamState -> StreamState(settings, streamState) }
.map { (settings, streamState) ->
val valueMap = (streamState as? io.hammerhead.karooext.models.StreamState.Streaming)?.dataPoint?.values
valueMap?.let {
GearsState(valueMap[gears.numberFieldId]?.toInt(), valueMap[gears.maxFieldId]?.toInt(), settings.useZoneColors)
}
// if (gears == Gears.FRONT) GearsState(1, 2, settings.useZoneColors) else GearsState(6, 12, settings.useZoneColors)
}
.distinctUntilChanged().collect { gearState ->
val powerbarsWithGearsSource = powerbars.values.filter { it.source == gearsSource }
powerbarsWithGearsSource.forEach { powerbar ->
if (gearState?.currentGear != null) {
val currentGear = gearState.currentGear
val maxGear = gearState.maxGear ?: gearState.currentGear
val progress = remap(currentGear.toDouble(), 1.0, maxGear.toDouble(), 0.0, 1.0)
powerbar.progressColor = if (gearState.colorize) {
progress?.let { context.getColor(getZone(progress).colorResource) } ?: context.getColor(R.color.zone0)
} else {
context.getColor(R.color.zone0)
}
powerbar.progress = progress
powerbar.label = "${gears.prefix}${currentGear}"
Log.d(TAG, "Gears ${gears.name}: $currentGear/$maxGear")
} else {
powerbar.progressColor = context.getColor(R.color.zone0)
powerbar.progress = null
powerbar.label = "?"
Log.d(TAG, "Gears ${gears.name}: Unavailable")
}
powerbar.invalidate()
}
}
}
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,7 +412,9 @@ 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 }
powerbarsWithSpeedSource.forEach { powerbar ->
if (value != null) {
val minSpeed = streamData.settings?.minSpeed ?: PowerbarSettings.defaultMinSpeedMs val minSpeed = streamData.settings?.minSpeed ?: PowerbarSettings.defaultMinSpeedMs
val maxSpeed = streamData.settings?.maxSpeed ?: PowerbarSettings.defaultMaxSpeedMs val maxSpeed = streamData.settings?.maxSpeed ?: PowerbarSettings.defaultMaxSpeedMs
val progress = remap(valueMetersPerSecond, minSpeed.toDouble(), maxSpeed.toDouble(), 0.0, 1.0) ?: 0.0 val progress = remap(valueMetersPerSecond, minSpeed.toDouble(), maxSpeed.toDouble(), 0.0, 1.0) ?: 0.0
@ -247,7 +426,7 @@ class Window(
} else { } else {
context.getColor(R.color.zone0) context.getColor(R.color.zone0)
} }
powerbar.progress = if (value > 0) progress else null powerbar.progress = progress
powerbar.label = "$value" powerbar.label = "$value"
Log.d(TAG, "Speed: $value min: $minSpeed max: $maxSpeed") Log.d(TAG, "Speed: $value min: $minSpeed max: $maxSpeed")
@ -261,8 +440,67 @@ class Window(
powerbar.invalidate() powerbar.invalidate()
} }
} }
}
private suspend fun streamCadence(smoothed: Boolean) { 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) {
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)
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 {
powerbar.progressColor = context.getColor(R.color.zone0)
powerbar.progress = null
powerbar.label = "?"
Log.d(TAG, "Grade: Unavailable")
}
powerbar.invalidate()
}
}
}
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,15 +516,17 @@ 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 }
powerbarsWithCadenceSource.forEach { powerbar ->
if (value != null) { if (value != null) {
val minCadence = streamData.settings?.minCadence ?: PowerbarSettings.defaultMinCadence val minCadence = streamData.settings?.minCadence ?: PowerbarSettings.defaultMinCadence
val maxCadence = streamData.settings?.maxCadence ?: PowerbarSettings.defaultMaxCadence val maxCadence = streamData.settings?.maxCadence ?: PowerbarSettings.defaultMaxCadence
val progress = remap(value.toDouble(), minCadence.toDouble(), maxCadence.toDouble(), 0.0, 1.0) ?: 0.0 val progress = remap(value.toDouble(), minCadence.toDouble(), maxCadence.toDouble(), 0.0, 1.0) ?: 0.0
powerbar.minTarget = remap(streamData.cadenceTarget?.values[FIELD_TARGET_MIN_ID]?.toDouble(), minCadence.toDouble(), maxCadence.toDouble(), 0.0, 1.0) powerbar.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
@ -295,7 +535,7 @@ class Window(
} else { } else {
context.getColor(R.color.zone0) context.getColor(R.color.zone0)
} }
powerbar.progress = if (value > 0) progress else null powerbar.progress = progress
powerbar.label = "$value" powerbar.label = "$value"
Log.d(TAG, "Cadence: $value min: $minCadence max: $maxCadence") Log.d(TAG, "Cadence: $value min: $minCadence max: $maxCadence")
@ -309,6 +549,7 @@ class Window(
powerbar.invalidate() powerbar.invalidate()
} }
} }
}
private suspend fun streamHeartrate() { private suspend fun streamHeartrate() {
val hrFlow = karooSystem.streamDataFlow(DataType.Type.HEART_RATE) val hrFlow = karooSystem.streamDataFlow(DataType.Type.HEART_RATE)
@ -326,7 +567,9 @@ 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 }
powerbarsWithHrSource.forEach { powerbar ->
if (value != null) { if (value != null) {
val customMinHr = if (streamData.settings?.useCustomHrRange == true) streamData.settings.minHr else 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 customMaxHr = if (streamData.settings?.useCustomHrRange == true) streamData.settings.maxHr else null
@ -334,16 +577,16 @@ class Window(
val maxHr = customMaxHr ?: streamData.userProfile.maxHr val maxHr = customMaxHr ?: streamData.userProfile.maxHr
val progress = remap(value.toDouble(), minHr.toDouble(), maxHr.toDouble(), 0.0, 1.0) val progress = remap(value.toDouble(), minHr.toDouble(), maxHr.toDouble(), 0.0, 1.0)
powerbar.minTarget = remap(streamData.heartrateTarget?.values[FIELD_TARGET_MIN_ID]?.toDouble(), minHr.toDouble(), maxHr.toDouble(), 0.0, 1.0) powerbar.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 { } else {
context.getColor(R.color.zone0) context.getColor(R.color.zone0)
} }
powerbar.progress = if (value > 0) progress else null powerbar.progress = progress
powerbar.label = "$value" powerbar.label = "$value"
Log.d(TAG, "Hr: $value min: $minHr max: $maxHr") Log.d(TAG, "Hr: $value min: $minHr max: $maxHr")
@ -357,6 +600,7 @@ class Window(
powerbar.invalidate() powerbar.invalidate()
} }
} }
}
enum class PowerStreamSmoothing(val dataTypeId: String){ enum class PowerStreamSmoothing(val dataTypeId: String){
RAW(DataType.Type.POWER), RAW(DataType.Type.POWER),
@ -364,7 +608,13 @@ class Window(
SMOOTHED_10S(DataType.Type.SMOOTHED_10S_AVERAGE_POWER), SMOOTHED_10S(DataType.Type.SMOOTHED_10S_AVERAGE_POWER),
} }
private suspend fun streamPower(smoothed: PowerStreamSmoothing) { enum class PedalBalanceSmoothing(val dataTypeId: String){
RAW(DataType.Type.PEDAL_POWER_BALANCE),
SMOOTHED_3S(DataType.Type.SMOOTHED_3S_AVERAGE_PEDAL_POWER_BALANCE),
SMOOTHED_10S(DataType.Type.SMOOTHED_10S_AVERAGE_PEDAL_POWER_BALANCE),
}
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,7 +631,9 @@ 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 }
powerbarsWithPowerSource.forEach { powerbar ->
if (value != null) { if (value != null) {
val customMinPower = if (streamData.settings?.useCustomPowerRange == true) streamData.settings.minPower else null val customMinPower = if (streamData.settings?.useCustomPowerRange == true) streamData.settings.minPower else null
val customMaxPower = if (streamData.settings?.useCustomPowerRange == true) streamData.settings.maxPower else null val customMaxPower = if (streamData.settings?.useCustomPowerRange == true) streamData.settings.maxPower else null
@ -389,16 +641,16 @@ class Window(
val maxPower = customMaxPower ?: (streamData.userProfile.powerZones.last().min + 30) val maxPower = customMaxPower ?: (streamData.userProfile.powerZones.last().min + 30)
val progress = remap(value.toDouble(), minPower.toDouble(), maxPower.toDouble(), 0.0, 1.0) val progress = remap(value.toDouble(), minPower.toDouble(), maxPower.toDouble(), 0.0, 1.0)
powerbar.minTarget = remap(streamData.powerTarget?.values[FIELD_TARGET_MIN_ID]?.toDouble(), minPower.toDouble(), maxPower.toDouble(), 0.0, 1.0) powerbar.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 { } else {
context.getColor(R.color.zone0) context.getColor(R.color.zone0)
} }
powerbar.progress = if (value > 0) progress else null powerbar.progress = progress
powerbar.label = "${value}W" powerbar.label = "${value}W"
Log.d(TAG, "Power: $value min: $minPower max: $maxPower") Log.d(TAG, "Power: $value min: $minPower max: $maxPower")
@ -412,6 +664,7 @@ class Window(
powerbar.invalidate() powerbar.invalidate()
} }
} }
}
private var currentHideJob: Job? = null private var currentHideJob: Job? = null
@ -422,7 +675,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

@ -37,3 +37,10 @@ fun getZone(userZones: List<UserProfile.Zone>, value: Int): Zone? {
return null return null
} }
val zoneList = listOf(Zone.Zone0, Zone.Zone1, Zone.Zone2, Zone.Zone3, Zone.Zone4, Zone.Zone5, Zone.Zone6, Zone.Zone7, Zone.Zone8)
fun getZone(progress: Double): Zone {
val index = (progress * zoneList.size).toInt().coerceIn(0, zoneList.size - 1)
return zoneList[index]
}

View File

@ -50,6 +50,7 @@ import androidx.compose.ui.focus.FocusState
import androidx.compose.ui.focus.onFocusEvent import androidx.compose.ui.focus.onFocusEvent
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@ -59,7 +60,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
@ -78,17 +78,24 @@ import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlin.math.roundToInt import kotlin.math.roundToInt
enum class SelectedSource(val id: String, val label: String) { enum class SelectedSource(val id: String, val labelResId: Int) {
NONE("none", "None"), NONE("none", R.string.source_none),
HEART_RATE("hr", "Heart Rate"), HEART_RATE("hr", R.string.source_heart_rate),
POWER("power", "Power"), POWER("power", R.string.source_power),
POWER_3S("power_3s", "Power (3 sec avg)"), POWER_3S("power_3s", R.string.source_power_3s),
POWER_10S("power_10s", "Power (10 sec avg)"), POWER_10S("power_10s", R.string.source_power_10s),
SPEED("speed", "Speed"), SPEED("speed", R.string.source_speed),
SPEED_3S("speed_3s", "Speed (3 sec avg"), SPEED_3S("speed_3s", R.string.source_speed_3s),
CADENCE("cadence", "Cadence"), CADENCE("cadence", R.string.source_cadence),
CADENCE_3S("cadence_3s", "Cadence (3 sec avg)"), CADENCE_3S("cadence_3s", R.string.source_cadence_3s),
ROUTE_PROGRESS("route_progress", "Route Progress"); GRADE("grade", R.string.source_grade),
POWER_BALANCE("power_balance", R.string.source_power_balance),
POWER_BALANCE_3S("power_balance_3s", R.string.source_power_balance_3s),
POWER_BALANCE_10S("power_balance_10s", R.string.source_power_balance_10s),
ROUTE_PROGRESS("route_progress", R.string.source_route_progress),
REMAINING_ROUTE("route_progress_remaining", R.string.source_route_remaining),
FRONT_GEAR("front_gear", R.string.source_front_gear),
REAR_GEAR("rear_gear", R.string.source_rear_gear);
fun isPower() = this == POWER || this == POWER_3S || this == POWER_10S fun isPower() = this == POWER || this == POWER_3S || this == POWER_10S
} }
@ -117,7 +124,7 @@ fun BarSelectDialog(currentSelectedSource: SelectedSource, onHide: () -> Unit, o
onSelect(pattern) onSelect(pattern)
}) })
Text( Text(
text = pattern.label, text = stringResource(pattern.labelResId),
modifier = Modifier.padding(start = 10.dp) modifier = Modifier.padding(start = 10.dp)
) )
} }
@ -138,9 +145,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 +180,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 +199,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 +216,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 +256,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 +279,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
} }
@ -270,40 +308,93 @@ fun MainScreen(onFinish: () -> Unit) {
Column(modifier = Modifier Column(modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.background)) { .background(MaterialTheme.colorScheme.background)) {
TopAppBar(title = { Text("Powerbar") }) TopAppBar(title = { Text(stringResource(R.string.powerbar_title)) })
Column(modifier = Modifier Column(modifier = Modifier
.padding(5.dp) .padding(5.dp)
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(10.dp)) { .fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(10.dp)) {
if (showAlerts){
if(!karooConnected){
Text(modifier = Modifier.padding(5.dp), text = stringResource(R.string.karoo_connection_error))
}
if (!givenPermissions) {
Text(modifier = Modifier.padding(5.dp), text = stringResource(R.string.permission_not_granted))
FilledTonalButton(modifier = Modifier
.fillMaxWidth()
.height(50.dp), onClick = {
val myIntent = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION)
startActivity(ctx, myIntent, null)
}) {
Icon(Icons.Default.Build, contentDescription = stringResource(R.string.content_desc_give_permission))
Spacer(modifier = Modifier.width(5.dp))
Text(stringResource(R.string.give_permission))
}
}
}
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 10.dp)) {
Text(stringResource(R.string.top_bar), style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.weight(1f))
Text(stringResource(R.string.split))
Spacer(modifier = Modifier.width(10.dp))
Switch(checked = splitTopBar, onCheckedChange = {
splitTopBar = it
coroutineScope.launch { updateSettings() }
})
}
if (splitTopBar) {
FilledTonalButton(modifier = Modifier FilledTonalButton(modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(60.dp), .height(60.dp),
onClick = { onClick = {
bottomBarDialogVisible = true topBarLeftDialogVisible = true
}) { }) {
Icon(Icons.Default.Build, contentDescription = "Select", modifier = Modifier.size(20.dp)) Icon(Icons.Default.Build, contentDescription = stringResource(R.string.content_desc_select), modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.width(5.dp)) Spacer(modifier = Modifier.width(5.dp))
Text("Bottom Bar: ${bottomSelectedSource.label}", modifier = Modifier.weight(1.0f)) Text(stringResource(R.string.top_bar_left, stringResource(topSelectedSourceLeft.labelResId)), modifier = Modifier.weight(1.0f))
} }
if (bottomBarDialogVisible){ if (topBarLeftDialogVisible){
BarSelectDialog(bottomSelectedSource, onHide = { bottomBarDialogVisible = false }, onSelect = { selected -> BarSelectDialog(topSelectedSourceLeft, onHide = { topBarLeftDialogVisible = false }, onSelect = { selected ->
bottomSelectedSource = selected topSelectedSourceLeft = selected
coroutineScope.launch { updateSettings() } coroutineScope.launch { updateSettings() }
bottomBarDialogVisible = false topBarLeftDialogVisible = false
}) })
} }
FilledTonalButton(modifier = Modifier
.fillMaxWidth()
.height(60.dp),
onClick = {
topBarRightDialogVisible = true
}) {
Icon(Icons.Default.Build, contentDescription = stringResource(R.string.content_desc_select), modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.width(5.dp))
Text(stringResource(R.string.top_bar_right, stringResource(topSelectedSourceRight.labelResId)), 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 FilledTonalButton(modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(60.dp), .height(60.dp),
onClick = { onClick = {
topBarDialogVisible = true topBarDialogVisible = true
}) { }) {
Icon(Icons.Default.Build, contentDescription = "Select", modifier = Modifier.size(20.dp)) Icon(Icons.Default.Build, contentDescription = stringResource(R.string.content_desc_select), modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.width(5.dp)) Spacer(modifier = Modifier.width(5.dp))
Text("Top Bar: ${topSelectedSource.label}", modifier = Modifier.weight(1.0f)) Text(stringResource(R.string.top_bar_single, stringResource(topSelectedSource.labelResId)), modifier = Modifier.weight(1.0f))
}
} }
if (topBarDialogVisible){ if (topBarDialogVisible){
@ -314,39 +405,112 @@ fun MainScreen(onFinish: () -> Unit) {
}) })
} }
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 10.dp)) {
Text(stringResource(R.string.bottom_bar), style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.weight(1f))
Text(stringResource(R.string.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 = stringResource(R.string.content_desc_select), modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.width(5.dp))
Text(stringResource(R.string.bottom_bar_left, stringResource(bottomSelectedSourceLeft.labelResId)), 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 = stringResource(R.string.content_desc_select), modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.width(5.dp))
Text(stringResource(R.string.bottom_bar_right, stringResource(bottomSelectedSourceRight.labelResId)), 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 = stringResource(R.string.content_desc_select), modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.width(5.dp))
Text(stringResource(R.string.bottom_bar_single, stringResource(bottomSelectedSource.labelResId)), 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, stringResource(unit.labelResId)) }
val dropdownInitialSelection by remember(barBarSize) { val dropdownInitialSelection by remember(barBarSize) {
mutableStateOf(dropdownOptions.find { option -> option.id == barBarSize.id }!!) mutableStateOf(dropdownOptions.find { option -> option.id == barBarSize.id }!!)
} }
Dropdown(label = "Bar Size", options = dropdownOptions, selected = dropdownInitialSelection) { selectedOption -> Dropdown(label = stringResource(R.string.bar_size), options = dropdownOptions, selected = dropdownInitialSelection) { selectedOption ->
barBarSize = CustomProgressBarBarSize.entries.find { unit -> unit.id == selectedOption.id }!! barBarSize = CustomProgressBarBarSize.entries.find { unit -> unit.id == selectedOption.id }!!
coroutineScope.launch { updateSettings() } coroutineScope.launch { updateSettings() }
} }
} }
apply { apply {
val dropdownOptions = CustomProgressBarFontSize.entries.toList().map { unit -> DropdownOption(unit.id, unit.label) } val dropdownOptions = CustomProgressBarFontSize.entries.toList().map { unit -> DropdownOption(unit.id, stringResource(unit.labelResId)) }
val dropdownInitialSelection by remember(barFontSize) { val dropdownInitialSelection by remember(barFontSize) {
mutableStateOf(dropdownOptions.find { option -> option.id == barFontSize.id }!!) mutableStateOf(dropdownOptions.find { option -> option.id == barFontSize.id }!!)
} }
Dropdown(label = "Text Size", options = dropdownOptions, selected = dropdownInitialSelection) { selectedOption -> Dropdown(label = stringResource(R.string.text_size), options = dropdownOptions, selected = dropdownInitialSelection) { selectedOption ->
barFontSize = CustomProgressBarFontSize.entries.find { unit -> unit.id == selectedOption.id }!! barFontSize = CustomProgressBarFontSize.entries.find { unit -> unit.id == selectedOption.id }!!
coroutineScope.launch { updateSettings() } coroutineScope.launch { updateSettings() }
} }
} }
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(stringResource(R.string.min_speed)) },
suffix = { Text(if (isImperial) "mph" else "kph") }, suffix = { Text(stringResource(if (isImperial) R.string.unit_mph else R.string.unit_kph)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true singleLine = true
) )
@ -355,23 +519,26 @@ 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(stringResource(R.string.max_speed)) },
suffix = { Text(if (isImperial) "mph" else "kph") }, suffix = { Text(stringResource(if (isImperial) R.string.unit_mph else R.string.unit_kph)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true singleLine = true
) )
} }
} }
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
coroutineScope.launch { updateSettings() } coroutineScope.launch { updateSettings() }
}) })
Spacer(modifier = Modifier.width(10.dp)) Spacer(modifier = Modifier.width(10.dp))
Text("Use custom power range") Text(stringResource(R.string.use_custom_power_range))
} }
if(useCustomPowerRange){ if(useCustomPowerRange){
@ -380,9 +547,9 @@ 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(stringResource(R.string.min_power), fontSize = 12.sp) },
suffix = { Text("W") }, suffix = { Text(stringResource(R.string.unit_watts)) },
placeholder = { Text("$profileMinPower") }, placeholder = { Text("$profileMinPower") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true singleLine = true
@ -392,9 +559,9 @@ 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(stringResource(R.string.max_power), fontSize = 12.sp) },
suffix = { Text("W") }, suffix = { Text(stringResource(R.string.unit_watts)) },
placeholder = { Text("$profileMaxPower") }, placeholder = { Text("$profileMaxPower") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true singleLine = true
@ -403,14 +570,17 @@ 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
coroutineScope.launch { updateSettings() } coroutineScope.launch { updateSettings() }
}) })
Spacer(modifier = Modifier.width(10.dp)) Spacer(modifier = Modifier.width(10.dp))
Text("Use custom HR range") Text(stringResource(R.string.use_custom_hr_range))
} }
if (useCustomHrRange){ if (useCustomHrRange){
@ -419,9 +589,9 @@ 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(stringResource(R.string.min_hr)) },
suffix = { Text("bpm") }, suffix = { Text(stringResource(R.string.unit_bpm)) },
placeholder = { if(profileRestHr > 0) Text("$profileRestHr") else Unit }, placeholder = { if(profileRestHr > 0) Text("$profileRestHr") else Unit },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true singleLine = true
@ -431,9 +601,9 @@ 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(stringResource(R.string.max_hr)) },
suffix = { Text("bpm") }, suffix = { Text(stringResource(R.string.unit_bpm)) },
placeholder = { if(profileMaxHr > 0) Text("$profileMaxHr") else Unit }, placeholder = { if(profileMaxHr > 0) Text("$profileMaxHr") else Unit },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true singleLine = true
@ -443,16 +613,19 @@ 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(stringResource(R.string.min_cadence)) },
suffix = { Text("rpm") }, suffix = { Text(stringResource(R.string.unit_rpm)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true singleLine = true
) )
@ -461,9 +634,38 @@ 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(stringResource(R.string.max_cadence)) },
suffix = { Text("rpm") }, suffix = { Text(stringResource(R.string.unit_rpm)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true
)
}
}
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(stringResource(R.string.min_grade)) },
suffix = { Text(stringResource(R.string.unit_percent)) },
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(stringResource(R.string.max_grade)) },
suffix = { Text(stringResource(R.string.unit_percent)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true singleLine = true
) )
@ -476,7 +678,7 @@ fun MainScreen(onFinish: () -> Unit) {
coroutineScope.launch { updateSettings() } coroutineScope.launch { updateSettings() }
}) })
Spacer(modifier = Modifier.width(10.dp)) Spacer(modifier = Modifier.width(10.dp))
Text("Color based on HR / power zones") Text(stringResource(R.string.color_based_on_zones))
} }
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@ -485,7 +687,7 @@ fun MainScreen(onFinish: () -> Unit) {
coroutineScope.launch { updateSettings() } coroutineScope.launch { updateSettings() }
}) })
Spacer(modifier = Modifier.width(10.dp)) Spacer(modifier = Modifier.width(10.dp))
Text("Show value on bars") Text(stringResource(R.string.show_value_on_bars))
} }
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@ -494,7 +696,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(stringResource(R.string.solid_background))
} }
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@ -503,37 +705,16 @@ fun MainScreen(onFinish: () -> Unit) {
coroutineScope.launch { updateSettings() } coroutineScope.launch { updateSettings() }
}) })
Spacer(modifier = Modifier.width(10.dp)) Spacer(modifier = Modifier.width(10.dp))
Text("Only show while riding") Text(stringResource(R.string.only_show_while_riding))
} }
Spacer(modifier = Modifier.padding(30.dp)) Spacer(modifier = Modifier.padding(30.dp))
if (showAlerts){
if(!karooConnected){
Text(modifier = Modifier.padding(5.dp), text = "Could not read device status. Is your Karoo updated?")
}
if (!givenPermissions) {
Text(modifier = Modifier.padding(5.dp), text = "You have not given permissions to show the power bar overlay. Please do so.")
FilledTonalButton(modifier = Modifier
.fillMaxWidth()
.height(50.dp), onClick = {
val myIntent = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION)
startActivity(ctx, myIntent, null)
}) {
Icon(Icons.Default.Build, contentDescription = "Give permission")
Spacer(modifier = Modifier.width(5.dp))
Text("Give permission")
}
}
}
} }
} }
Image( Image(
painter = painterResource(id = R.drawable.back), painter = painterResource(id = R.drawable.back),
contentDescription = "Back", contentDescription = stringResource(R.string.content_desc_back),
modifier = Modifier modifier = Modifier
.align(Alignment.BottomStart) .align(Alignment.BottomStart)
.padding(bottom = 10.dp) .padding(bottom = 10.dp)

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

@ -0,0 +1,79 @@
<resources>
<string name="app_name">Powerbar</string>
<string name="extension_name">Powerbar</string>
<!-- Main Screen Strings -->
<string name="powerbar_title">Powerbar</string>
<string name="karoo_connection_error">Gerätestatus konnte nicht gelesen werden. Ist Ihr Karoo aktualisiert?</string>
<string name="permission_not_granted">Sie haben die Berechtigung zum Anzeigen der Powerbar nicht erteilt. Bitte tun Sie dies.</string>
<string name="give_permission">Berechtigung erteilen</string>
<string name="top_bar">Oben</string>
<string name="bottom_bar">Unten</string>
<string name="split">Teilen</string>
<string name="top_bar_left">Oben (Links): %s</string>
<string name="top_bar_right">Oben (Rechts): %s</string>
<string name="top_bar_single">Oben: %s</string>
<string name="bottom_bar_left">Unten (Links): %s</string>
<string name="bottom_bar_right">Unten (Rechts): %s</string>
<string name="bottom_bar_single">Unten: %s</string>
<string name="bar_size">Balkengröße</string>
<string name="text_size">Textgröße</string>
<string name="min_speed">Min. Geschw.</string>
<string name="max_speed">Max. Geschw.</string>
<string name="use_custom_power_range">Eigenen Leistungsbereich verwenden</string>
<string name="min_power">Min. Leistung</string>
<string name="max_power">Max. Leistung</string>
<string name="use_custom_hr_range">Eigenen HF-Bereich verwenden</string>
<string name="min_hr">Min. HF</string>
<string name="max_hr">Max. HF</string>
<string name="min_cadence">Min. Trittfrequenz</string>
<string name="max_cadence">Max. Trittfrequenz</string>
<string name="min_grade">Min. Steigung</string>
<string name="max_grade">Max. Steigung</string>
<string name="color_based_on_zones">Farbe basierend auf HF-/Leistungszonen</string>
<string name="show_value_on_bars">Werte auf Balken anzeigen</string>
<string name="solid_background">Fester Hintergrund</string>
<string name="only_show_while_riding">Nur während der Fahrt anzeigen</string>
<!-- Data Source Labels -->
<string name="source_none">Keine</string>
<string name="source_heart_rate">Herzfrequenz</string>
<string name="source_power">Leistung</string>
<string name="source_power_3s">Leistung (3 Sek. Ø)</string>
<string name="source_power_10s">Leistung (10 Sek. Ø)</string>
<string name="source_speed">Geschwindigkeit</string>
<string name="source_speed_3s">Geschwindigkeit (3 Sek. Ø)</string>
<string name="source_cadence">Trittfrequenz</string>
<string name="source_cadence_3s">Trittfrequenz (3 Sek. Ø)</string>
<string name="source_grade">Steigung</string>
<string name="source_power_balance">Leistungsbalance</string>
<string name="source_power_balance_3s">Leistungsbalance (3 Sek. Ø)</string>
<string name="source_power_balance_10s">Leistungsbalance (10 Sek. Ø)</string>
<string name="source_route_progress">Routenfortschritt</string>
<string name="source_route_remaining">Verbleibende Route</string>
<string name="source_front_gear">Vorderer Gang</string>
<string name="source_rear_gear">Hinterer Gang</string>
<!-- Units -->
<string name="unit_mph">mph</string>
<string name="unit_kph">km/h</string>
<string name="unit_watts">W</string>
<string name="unit_bpm">S/min</string>
<string name="unit_rpm">U/min</string>
<string name="unit_percent">%</string>
<!-- Content Descriptions -->
<string name="content_desc_give_permission">Berechtigung erteilen</string>
<string name="content_desc_select">Auswählen</string>
<string name="content_desc_back">Zurück</string>
<!-- Notification -->
<string name="notification_text">Anzeige über anderen Apps</string>
<string name="notification_channel_name">Hintergrunddienst</string>
<!-- Size Options -->
<string name="size_none">Keine</string>
<string name="size_small">Klein</string>
<string name="size_medium">Mittel</string>
<string name="size_large">Groß</string>
</resources>

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>

View File

@ -1,4 +1,79 @@
<resources> <resources>
<string name="app_name">Powerbar</string> <string name="app_name">Powerbar</string>
<string name="extension_name">Powerbar</string> <string name="extension_name">Powerbar</string>
<!-- Main Screen Strings -->
<string name="powerbar_title">Powerbar</string>
<string name="karoo_connection_error">Could not read device status. Is your Karoo updated?</string>
<string name="permission_not_granted">You have not granted the permission to show the power bar overlay. Please do so.</string>
<string name="give_permission">Give permission</string>
<string name="top_bar">Top Bar</string>
<string name="bottom_bar">Bottom Bar</string>
<string name="split">Split</string>
<string name="top_bar_left">Top Bar (Left): %s</string>
<string name="top_bar_right">Top Bar (Right): %s</string>
<string name="top_bar_single">Top Bar: %s</string>
<string name="bottom_bar_left">Bottom Bar (Left): %s</string>
<string name="bottom_bar_right">Bottom Bar (Right): %s</string>
<string name="bottom_bar_single">Bottom Bar: %s</string>
<string name="bar_size">Bar Size</string>
<string name="text_size">Text Size</string>
<string name="min_speed">Min Speed</string>
<string name="max_speed">Max Speed</string>
<string name="use_custom_power_range">Use custom power range</string>
<string name="min_power">Min Power</string>
<string name="max_power">Max Power</string>
<string name="use_custom_hr_range">Use custom HR range</string>
<string name="min_hr">Min Hr</string>
<string name="max_hr">Max Hr</string>
<string name="min_cadence">Min Cadence</string>
<string name="max_cadence">Max Cadence</string>
<string name="min_grade">Min Grade</string>
<string name="max_grade">Max Grade</string>
<string name="color_based_on_zones">Color based on HR / power zones</string>
<string name="show_value_on_bars">Show value on bars</string>
<string name="solid_background">Solid background</string>
<string name="only_show_while_riding">Only show while riding</string>
<!-- Data Source Labels -->
<string name="source_none">None</string>
<string name="source_heart_rate">Heart Rate</string>
<string name="source_power">Power</string>
<string name="source_power_3s">Power (3 sec avg)</string>
<string name="source_power_10s">Power (10 sec avg)</string>
<string name="source_speed">Speed</string>
<string name="source_speed_3s">Speed (3 sec avg)</string>
<string name="source_cadence">Cadence</string>
<string name="source_cadence_3s">Cadence (3 sec avg)</string>
<string name="source_grade">Grade</string>
<string name="source_power_balance">Power Balance</string>
<string name="source_power_balance_3s">Power Balance (3 sec avg)</string>
<string name="source_power_balance_10s">Power Balance (10 sec avg)</string>
<string name="source_route_progress">Route Progress</string>
<string name="source_route_remaining">Route Remaining</string>
<string name="source_front_gear">Front Gear</string>
<string name="source_rear_gear">Rear Gear</string>
<!-- Units -->
<string name="unit_mph">mph</string>
<string name="unit_kph">kph</string>
<string name="unit_watts">W</string>
<string name="unit_bpm">bpm</string>
<string name="unit_rpm">rpm</string>
<string name="unit_percent">%</string>
<!-- Content Descriptions -->
<string name="content_desc_give_permission">Give permission</string>
<string name="content_desc_select">Select</string>
<string name="content_desc_back">Back</string>
<!-- Notification -->
<string name="notification_text">Displaying on top of other apps</string>
<string name="notification_channel_name">Background Service</string>
<!-- Size Options -->
<string name="size_none">None</string>
<string name="size_small">Small</string>
<string name="size_medium">Medium</string>
<string name="size_large">Large</string>
</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