Compare commits

..

No commits in common. "1.5.5" and "master" have entirely different histories.

16 changed files with 299 additions and 1114 deletions

View File

@ -45,12 +45,6 @@ jobs:
- name: Build with Gradle
run: ./gradlew build
- name: Archive APK
uses: actions/upload-artifact@v4
with:
name: app-release.apk
path: app/build/outputs/apk/release/app-release.apk
- name: Create Release
id: create_release
uses: softprops/action-gh-release@v2

View File

@ -12,7 +12,6 @@ Compatible with Karoo 2 and Karoo 3 devices.
![Powerbar](powerbar0.png)
![Settings](powerbar1.png)
![Powerbar GIF](powerbar_min.gif)
![Powerbar x4](powerbar2.png)
## Usage
@ -21,15 +20,11 @@ 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:
- Power
- Heart Rate
- Power (Instant, 3s, 10s)
- Heart rate
- Average power over the last 3 seconds
- Average power over the last 10 seconds
- Speed
- 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
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,13 +72,11 @@ tasks.register("generateManifest") {
"latestVersionCode" to android.defaultConfig.versionCode,
"developer" to "github.com/timklge",
"description" to "Open-source extension that adds colored power or heart rate progress bars to the edges of the screen, similar to the LEDs on Wahoo computers",
"releaseNotes" to "* 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",
"releaseNotes" to "* Add route progress data source\n* Add workout target range indicator\n* Make bars transparent by default\n* Split size setting",
"screenshotUrls" to listOf(
"$baseUrl/powerbar_min.gif",
"$baseUrl/powerbar0.png",
"$baseUrl/powerbar2.png",
"$baseUrl/powerbar1.png",
"$baseUrl/powerbar3.png",
)
)

View File

@ -8,33 +8,15 @@ import android.graphics.Paint
import android.graphics.RectF
import android.graphics.Typeface
import android.util.AttributeSet
import android.util.Log
import android.view.View
import androidx.annotation.ColorInt
import androidx.core.graphics.ColorUtils
import de.timklge.karoopowerbar.screens.SelectedSource
class CustomView @JvmOverloads constructor(
class CustomProgressBar @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : 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 location: PowerbarLocation = PowerbarLocation.BOTTOM
var label: String = ""
var minTarget: Double? = null
var maxTarget: Double? = null
@ -42,13 +24,12 @@ class CustomProgressBar(private val view: CustomView,
var showLabel: Boolean = true
var barBackground: Boolean = false
@ColorInt var progressColor: Int = 0xFF2b86e6.toInt()
var drawMode: ProgressBarDrawMode = ProgressBarDrawMode.STANDARD
var fontSize = CustomProgressBarFontSize.MEDIUM
set(value) {
field = value
textPaint.textSize = value.fontSize
view.invalidate() // Redraw to apply new font size
invalidate() // Redraw to apply new font size
}
var barSize = CustomProgressBarBarSize.MEDIUM
@ -64,7 +45,7 @@ class CustomProgressBar(private val view: CustomView,
CustomProgressBarBarSize.MEDIUM -> 8f
CustomProgressBarBarSize.LARGE -> 10f
}
view.invalidate() // Redraw to apply new bar size
invalidate() // Redraw to apply new bar size
}
private val targetColor = 0xFF9933FF.toInt()
@ -139,7 +120,9 @@ class CustomProgressBar(private val view: CustomView,
style = Paint.Style.STROKE
}
fun onDrawForeground(canvas: Canvas) {
override fun onDrawForeground(canvas: Canvas) {
super.onDrawForeground(canvas)
// Determine if the current progress is within the target range
val isTargetMet =
progress != null && minTarget != null && maxTarget != null && progress!! >= minTarget!! && progress!! <= maxTarget!!
@ -149,113 +132,25 @@ class CustomProgressBar(private val view: CustomView,
blurPaint.color = progressColor
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) {
PowerbarLocation.TOP -> {
val rect = RectF(
barLeft,
1f,
15f,
barRight,
((canvas.width.toDouble() - 1f) * (progress ?: 0.0).coerceIn(0.0, 1.0)).toFloat(),
15f + barSize.barHeight // barSize.barHeight will be 0f if NONE
)
// Draw bar components only if barSize is not NONE
if (barSize != CustomProgressBarBarSize.NONE) {
if (barBackground){
canvas.drawRect(backgroundLeft, 15f, backgroundRight, 15f + barSize.barHeight, backgroundPaint)
canvas.drawRect(0f, 15f, canvas.width.toFloat(), 15f + barSize.barHeight, backgroundPaint)
}
// Draw target zone fill behind the progress bar
if (minTarget != null && maxTarget != null) {
val minTargetX = (canvas.width * minTarget!!).toFloat()
val maxTargetX = (canvas.width * maxTarget!!).toFloat()
canvas.drawRoundRect(
minTargetX,
15f,
@ -277,6 +172,8 @@ class CustomProgressBar(private val view: CustomView,
if (progress != null) {
// Draw target zone stroke after progress bar, before label
if (minTarget != null && maxTarget != null) {
val minTargetX = (canvas.width * minTarget!!).toFloat()
val maxTargetX = (canvas.width * maxTarget!!).toFloat()
// Draw stroked rounded rectangle for the target zone
canvas.drawRoundRect(
minTargetX,
@ -291,6 +188,7 @@ class CustomProgressBar(private val view: CustomView,
// Draw vertical target indicator line if target is present
if (target != null) {
val targetX = (canvas.width * target!!).toFloat()
targetIndicatorPaint.color = if (isTargetMet) Color.GREEN else Color.RED
canvas.drawLine(targetX, 15f, targetX, 15f + barSize.barHeight, targetIndicatorPaint)
}
@ -305,44 +203,7 @@ class CustomProgressBar(private val view: CustomView,
val textBounds = textPaint.measureText(label)
val xOffset = (textBounds + 20).coerceAtLeast(10f) / 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 x = (rect.right - xOffset).coerceIn(0f..canvas.width-xOffset*2f)
val r = x + xOffset * 2
val fm = textPaint.fontMetrics
@ -365,9 +226,9 @@ class CustomProgressBar(private val view: CustomView,
PowerbarLocation.BOTTOM -> {
val rect = RectF(
barLeft,
1f,
canvas.height.toFloat() - 1f - barSize.barHeight, // barSize.barHeight will be 0f if NONE
barRight,
((canvas.width.toDouble() - 1f) * (progress ?: 0.0).coerceIn(0.0, 1.0)).toFloat(),
canvas.height.toFloat()
)
@ -375,11 +236,13 @@ class CustomProgressBar(private val view: CustomView,
if (barSize != CustomProgressBarBarSize.NONE) {
if (barBackground){
// Use barSize.barHeight for background top calculation
canvas.drawRect(backgroundLeft, canvas.height.toFloat() - barSize.barHeight, backgroundRight, canvas.height.toFloat(), backgroundPaint)
canvas.drawRect(0f, canvas.height.toFloat() - barSize.barHeight, canvas.width.toFloat(), canvas.height.toFloat(), backgroundPaint)
}
// Draw target zone fill behind the progress bar
if (minTarget != null && maxTarget != null) {
val minTargetX = (canvas.width * minTarget!!).toFloat()
val maxTargetX = (canvas.width * maxTarget!!).toFloat()
canvas.drawRoundRect(
minTargetX,
canvas.height.toFloat() - barSize.barHeight,
@ -402,6 +265,8 @@ class CustomProgressBar(private val view: CustomView,
if (progress != null) {
// Draw target zone stroke after progress bar, before label
if (minTarget != null && maxTarget != null) {
val minTargetX = (canvas.width * minTarget!!).toFloat()
val maxTargetX = (canvas.width * maxTarget!!).toFloat()
// Draw stroked rounded rectangle for the target zone
canvas.drawRoundRect(
minTargetX,
@ -416,6 +281,7 @@ class CustomProgressBar(private val view: CustomView,
// Draw vertical target indicator line if target is present
if (target != null) {
val targetX = (canvas.width * target!!).toFloat()
targetIndicatorPaint.color = if (isTargetMet) Color.GREEN else Color.RED
canvas.drawLine(targetX, canvas.height.toFloat() - barSize.barHeight, targetX, canvas.height.toFloat(), targetIndicatorPaint)
}
@ -430,44 +296,7 @@ class CustomProgressBar(private val view: CustomView,
val textBounds = textPaint.measureText(label)
val xOffset = (textBounds + 20).coerceAtLeast(10f) / 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 x = (rect.right - xOffset).coerceIn(0f..canvas.width-xOffset*2f)
val r = x + xOffset * 2
// textDrawBaselineY calculation uses rect.top and barSize.barHeight.
@ -486,9 +315,4 @@ class CustomProgressBar(private val view: CustomView,
}
}
}
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
@Serializable
enum class CustomProgressBarSize(val id: String, val labelResId: Int, val fontSize: Float, val barHeight: Float) {
SMALL("small", R.string.size_small, 35f, 10f),
MEDIUM("medium", R.string.size_medium, 40f, 15f),
LARGE("large", R.string.size_large, 60f, 25f),
enum class CustomProgressBarSize(val id: String, val label: String, val fontSize: Float, val barHeight: Float) {
SMALL("small", "Small", 35f, 10f),
MEDIUM("medium", "Medium", 40f, 15f),
LARGE("large", "Large", 60f, 25f),
}
@Serializable
enum class CustomProgressBarFontSize(val id: String, val labelResId: Int, val fontSize: Float) {
SMALL("small", R.string.size_small, 35f),
MEDIUM("medium", R.string.size_medium, 40f),
LARGE("large", R.string.size_large, 60f);
enum class CustomProgressBarFontSize(val id: String, val label: String, val fontSize: Float) {
SMALL("small", "Small", 35f),
MEDIUM("medium", "Medium", 40f),
LARGE("large", "Large", 60f);
companion object {
fun fromSize(size: CustomProgressBarSize): CustomProgressBarFontSize {
@ -27,11 +27,11 @@ enum class CustomProgressBarFontSize(val id: String, val labelResId: Int, val fo
}
@Serializable
enum class CustomProgressBarBarSize(val id: String, val labelResId: Int, val barHeight: Float) {
NONE("none", R.string.size_none, 0f),
SMALL("small", R.string.size_small, 10f),
MEDIUM("medium", R.string.size_medium, 15f),
LARGE("large", R.string.size_large, 25f);
enum class CustomProgressBarBarSize(val id: String, val label: String, val barHeight: Float) {
NONE("none", "None", 0f),
SMALL("small", "Small", 10f),
MEDIUM("medium", "Medium", 15f),
LARGE("large", "Large", 25f);
companion object {
fun fromSize(size: CustomProgressBarSize): CustomProgressBarBarSize {

View File

@ -52,21 +52,19 @@ class ForegroundService : Service() {
windows.forEach { it.close() }
windows.clear()
if (showBars){
if (settings.bottomBarSource != SelectedSource.NONE || settings.bottomBarLeftSource != SelectedSource.NONE || settings.bottomBarRightSource != SelectedSource.NONE) {
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)
open()
}
if (settings.source != SelectedSource.NONE && showBars) {
Window(this@ForegroundService, PowerbarLocation.BOTTOM, settings.showLabelOnBars, settings.barBackground, settings.barBarSize, settings.barFontSize).apply {
selectedSource = settings.source
windows.add(this)
open()
}
}
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,
settings.splitTopBar, settings.topBarSource, settings.topBarLeftSource, settings.topBarRightSource).apply {
open()
windows.add(this)
}
if (settings.topBarSource != SelectedSource.NONE && showBars){
Window(this@ForegroundService, PowerbarLocation.TOP, settings.showLabelOnBars, settings.barBackground, settings.barBarSize, settings.barFontSize).apply {
selectedSource = settings.topBarSource
open()
windows.add(this)
}
}
}
@ -79,7 +77,7 @@ class ForegroundService : Service() {
private fun setupForeground() {
val channelId = "de.timklge.karoopowerbar"
val channelName = getString(R.string.notification_channel_name)
val channelName = "Background Service"
val chan = NotificationChannel(
channelId,
channelName,
@ -88,13 +86,14 @@ class ForegroundService : Service() {
val manager =
checkNotNull(getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager?)
manager.createNotificationChannel(chan)
val notificationBuilder = NotificationCompat.Builder(this, channelId)
val notification = notificationBuilder.setOngoing(true)
val notificationBuilder: NotificationCompat.Builder =
NotificationCompat.Builder(this, channelId)
val notification: Notification = notificationBuilder.setOngoing(true)
.setContentTitle("Powerbar service running")
.setContentText("Displaying on top of other apps")
.setSmallIcon(R.drawable.bar)
.setContentTitle(getString(R.string.app_name))
.setContentText(getString(R.string.notification_text))
.setPriority(NotificationManager.IMPORTANCE_MIN)
.setCategory(Notification.CATEGORY_SERVICE)
.build()

View File

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

View File

@ -34,11 +34,10 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.Locale
import kotlin.math.absoluteValue
import kotlin.math.roundToInt
fun remap(value: Double?, fromMin: Double, fromMax: Double, toMin: Double, toMax: Double): Double? {
@ -51,15 +50,6 @@ enum class PowerbarLocation {
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(
private val context: Context,
val powerbarLocation: PowerbarLocation = PowerbarLocation.BOTTOM,
@ -67,10 +57,6 @@ class Window(
val barBackground: Boolean,
val powerbarBarSize: CustomProgressBarBarSize,
val powerbarFontSize: CustomProgressBarFontSize,
val splitBars: Boolean,
val selectedSource: SelectedSource = SelectedSource.NONE,
val selectedLeftSource: SelectedSource = SelectedSource.NONE,
val selectedRightSource: SelectedSource = SelectedSource.NONE
) {
companion object {
val FIELD_TARGET_VALUE_ID = "FIELD_WORKOUT_TARGET_VALUE_ID";
@ -83,8 +69,9 @@ class Window(
private val windowManager: WindowManager
private val layoutInflater: LayoutInflater
private val powerbars: MutableMap<HorizontalPowerbarLocation, CustomProgressBar> = mutableMapOf()
private val view: CustomView
private val powerbar: CustomProgressBar
var selectedSource: SelectedSource = SelectedSource.POWER
init {
layoutParams = WindowManager.LayoutParams(
@ -97,8 +84,8 @@ class Window(
layoutInflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
rootView = layoutInflater.inflate(R.layout.popup_window, null)
view = rootView.findViewById(R.id.customView)
view.progressBars = powerbars
powerbar = rootView.findViewById(R.id.progressBar)
powerbar.progress = null
windowManager = context.getSystemService(WINDOW_SERVICE) as WindowManager
val displayMetrics = DisplayMetrics()
@ -129,73 +116,45 @@ class Window(
private val karooSystem: KarooSystemService = KarooSystemService(context)
private var serviceJobs: MutableSet<Job> = mutableSetOf()
private var serviceJob: Job? = null
@SuppressLint("UnspecifiedRegisterReceiverFlag")
suspend fun open() {
val filter = IntentFilter("de.timklge.HIDE_POWERBAR")
if (Build.VERSION.SDK_INT >= 33) {
context.registerReceiver(hideReceiver, filter, Context.RECEIVER_EXPORTED)
} else {
context.registerReceiver(hideReceiver, filter)
}
karooSystem.connect { 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)
serviceJob = CoroutineScope(Dispatchers.Default).launch {
val filter = IntentFilter("de.timklge.HIDE_POWERBAR")
if (Build.VERSION.SDK_INT >= 33) {
context.registerReceiver(hideReceiver, filter, Context.RECEIVER_EXPORTED)
} else {
context.registerReceiver(hideReceiver, filter)
}
karooSystem.connect { connected ->
Log.i(TAG, "Karoo system service connected: $connected")
}
} 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.progress = null
powerbar.location = powerbarLocation
powerbar.showLabel = showLabel
powerbar.barBackground = barBackground
powerbar.fontSize = powerbarFontSize
powerbar.barSize = powerbarBarSize
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){
SelectedSource.POWER -> streamPower(SelectedSource.POWER, PowerStreamSmoothing.RAW)
SelectedSource.POWER_3S -> streamPower(SelectedSource.POWER_3S, PowerStreamSmoothing.SMOOTHED_3S)
SelectedSource.POWER_10S -> streamPower(SelectedSource.POWER_10S, PowerStreamSmoothing.SMOOTHED_10S)
SelectedSource.HEART_RATE -> streamHeartrate()
SelectedSource.SPEED -> streamSpeed(SelectedSource.SPEED, false)
SelectedSource.SPEED_3S -> streamSpeed(SelectedSource.SPEED_3S, true)
SelectedSource.CADENCE -> streamCadence(SelectedSource.CADENCE, false)
SelectedSource.CADENCE_3S -> streamCadence(SelectedSource.CADENCE_3S, true)
SelectedSource.ROUTE_PROGRESS -> streamRouteProgress(SelectedSource.ROUTE_PROGRESS, ::getRouteProgress)
SelectedSource.REMAINING_ROUTE -> streamRouteProgress(SelectedSource.REMAINING_ROUTE, ::getRemainingRouteProgress)
SelectedSource.GRADE -> streamGrade()
SelectedSource.POWER_BALANCE -> streamBalance(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 -> {}
}
})
when (selectedSource){
SelectedSource.POWER -> streamPower(PowerStreamSmoothing.RAW)
SelectedSource.POWER_3S -> streamPower(PowerStreamSmoothing.SMOOTHED_3S)
SelectedSource.POWER_10S -> streamPower(PowerStreamSmoothing.SMOOTHED_10S)
SelectedSource.HEART_RATE -> streamHeartrate()
SelectedSource.SPEED -> streamSpeed(false)
SelectedSource.SPEED_3S -> streamSpeed(true)
SelectedSource.CADENCE -> streamCadence(false)
SelectedSource.CADENCE_3S -> streamCadence(true)
SelectedSource.ROUTE_PROGRESS -> streamRouteProgress()
else -> {}
}
}
try {
@ -209,103 +168,19 @@ class Window(
}
}
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
) {
private suspend fun streamRouteProgress() {
data class StreamData(
val userProfile: UserProfile,
val distanceToDestination: Double?,
val navigationState: OnNavigationState,
val riddenDistance: Double?
val navigationState: OnNavigationState
)
var lastKnownRoutePolyline: String? = null
var lastKnownRouteLength: Double? = null
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?.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) ->
combine(karooSystem.streamUserProfile(), karooSystem.streamDataFlow(DataType.Type.DISTANCE_TO_DESTINATION), karooSystem.streamNavigationState()) { userProfile, distanceToDestination, navigationState ->
StreamData(userProfile, (distanceToDestination as? StreamState.Streaming)?.dataPoint?.values[DataType.Field.DISTANCE_TO_DESTINATION], navigationState)
}.distinctUntilChanged().throttle(5_000).collect { (userProfile, distanceToDestination, navigationState) ->
val state = navigationState.state
val routePolyline = when (state) {
is OnNavigationState.NavigationState.NavigatingRoute -> state.routePolyline
@ -327,74 +202,22 @@ class Window(
}
}
val routeEndAt = lastKnownRouteLength?.plus((distanceToDestination ?: 0.0))
val barProgress = routeProgressProvider(userProfile, riddenDistance, routeEndAt, distanceToDestination)
val powerbarsWithRouteProgressSource = powerbars.values.filter { it.source == source }
powerbarsWithRouteProgressSource.forEach { powerbar ->
powerbar.progressColor = context.getColor(R.color.zone0)
powerbar.progress = barProgress.progress
powerbar.label = barProgress.label ?: ""
powerbar.invalidate()
val routeLength = lastKnownRouteLength
val routeProgressMeters = routeLength?.let { routeLength - (distanceToDestination ?: 0.0) }?.coerceAtLeast(0.0)
val routeProgress = if (routeLength != null && routeProgressMeters != null) remap(routeProgressMeters, 0.0, routeLength, 0.0, 1.0) else null
val routeProgressInUserUnit = when (userProfile.preferredUnit.distance) {
UserProfile.PreferredUnit.UnitType.IMPERIAL -> routeProgressMeters?.times(0.000621371)?.roundToInt() // Miles
else -> routeProgressMeters?.times(0.001)?.roundToInt() // Kilometers
}
powerbar.progressColor = context.getColor(R.color.zone0)
powerbar.progress = routeProgress
powerbar.label = "$routeProgressInUserUnit"
powerbar.invalidate()
}
}
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) {
private suspend fun streamSpeed(smoothed: Boolean) {
val speedFlow = karooSystem.streamDataFlow(if(smoothed) DataType.Type.SMOOTHED_3S_AVERAGE_SPEED else DataType.Type.SPEED)
.map { (it as? StreamState.Streaming)?.dataPoint?.singleValue }
.distinctUntilChanged()
@ -412,95 +235,34 @@ class Window(
else -> valueMetersPerSecond?.times(3.6)
}?.roundToInt()
val powerbarsWithSpeedSource = powerbars.values.filter { it.source == source }
powerbarsWithSpeedSource.forEach { powerbar ->
if (value != null) {
val minSpeed = streamData.settings?.minSpeed ?: PowerbarSettings.defaultMinSpeedMs
val maxSpeed = streamData.settings?.maxSpeed ?: PowerbarSettings.defaultMaxSpeedMs
val progress = remap(valueMetersPerSecond, minSpeed.toDouble(), maxSpeed.toDouble(), 0.0, 1.0) ?: 0.0
if (value != null && valueMetersPerSecond != null) {
val minSpeed = streamData.settings?.minSpeed ?: PowerbarSettings.defaultMinSpeedMs
val maxSpeed = streamData.settings?.maxSpeed ?: PowerbarSettings.defaultMaxSpeedMs
val progress = remap(valueMetersPerSecond, minSpeed.toDouble(), maxSpeed.toDouble(), 0.0, 1.0) ?: 0.0
@ColorRes val zoneColorRes = Zone.entries[(progress * Zone.entries.size).roundToInt().coerceIn(0..<Zone.entries.size)].colorResource
@ColorRes val zoneColorRes = Zone.entries[(progress * Zone.entries.size).roundToInt().coerceIn(0..<Zone.entries.size)].colorResource
powerbar.progressColor = if (streamData.settings?.useZoneColors == true) {
context.getColor(zoneColorRes)
} else {
context.getColor(R.color.zone0)
}
powerbar.progress = progress
powerbar.label = "$value"
Log.d(TAG, "Speed: $value min: $minSpeed max: $maxSpeed")
powerbar.progressColor = if (streamData.settings?.useZoneColors == true) {
context.getColor(zoneColorRes)
} else {
powerbar.progressColor = context.getColor(R.color.zone0)
powerbar.progress = null
powerbar.label = "?"
Log.d(TAG, "Speed: Unavailable")
context.getColor(R.color.zone0)
}
powerbar.invalidate()
}
}
}
powerbar.progress = if (value > 0) progress else null
powerbar.label = "$value"
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")
Log.d(TAG, "Speed: $value min: $minSpeed max: $maxSpeed")
} else {
powerbar.progressColor = context.getColor(R.color.zone0)
powerbar.progress = null
powerbar.label = "?"
Log.d(TAG, "Grade: Unavailable")
Log.d(TAG, "Speed: Unavailable")
}
powerbar.invalidate()
}
}
}
private suspend fun streamCadence(source: SelectedSource, smoothed: Boolean) {
private suspend fun streamCadence(smoothed: Boolean) {
val cadenceFlow = karooSystem.streamDataFlow(if(smoothed) DataType.Type.SMOOTHED_3S_AVERAGE_CADENCE else DataType.Type.CADENCE)
.map { (it as? StreamState.Streaming)?.dataPoint?.singleValue }
.distinctUntilChanged()
@ -516,38 +278,35 @@ class Window(
StreamData(userProfile, speed, settings, cadenceTarget)
}.distinctUntilChanged().throttle(1_000).collect { streamData ->
val value = streamData.value?.roundToInt()
val powerbarsWithCadenceSource = powerbars.values.filter { it.source == source }
powerbarsWithCadenceSource.forEach { powerbar ->
if (value != null) {
val minCadence = streamData.settings?.minCadence ?: PowerbarSettings.defaultMinCadence
val maxCadence = streamData.settings?.maxCadence ?: PowerbarSettings.defaultMaxCadence
val progress = remap(value.toDouble(), minCadence.toDouble(), maxCadence.toDouble(), 0.0, 1.0) ?: 0.0
if (value != null) {
val minCadence = streamData.settings?.minCadence ?: PowerbarSettings.defaultMinCadence
val maxCadence = streamData.settings?.maxCadence ?: PowerbarSettings.defaultMaxCadence
val progress = remap(value.toDouble(), minCadence.toDouble(), maxCadence.toDouble(), 0.0, 1.0) ?: 0.0
powerbar.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?.get(FIELD_TARGET_MAX_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)
powerbar.minTarget = remap(streamData.cadenceTarget?.values[FIELD_TARGET_MIN_ID]?.toDouble(), minCadence.toDouble(), maxCadence.toDouble(), 0.0, 1.0)
powerbar.maxTarget = remap(streamData.cadenceTarget?.values[FIELD_TARGET_MAX_ID]?.toDouble(), minCadence.toDouble(), maxCadence.toDouble(), 0.0, 1.0)
powerbar.target = remap(streamData.cadenceTarget?.values[FIELD_TARGET_VALUE_ID]?.toDouble(), minCadence.toDouble(), maxCadence.toDouble(), 0.0, 1.0)
@ColorRes val zoneColorRes = Zone.entries[(progress * Zone.entries.size).roundToInt().coerceIn(0..<Zone.entries.size)].colorResource
@ColorRes val zoneColorRes = Zone.entries[(progress * Zone.entries.size).roundToInt().coerceIn(0..<Zone.entries.size)].colorResource
powerbar.progressColor = if (streamData.settings?.useZoneColors == true) {
context.getColor(zoneColorRes)
} else {
context.getColor(R.color.zone0)
}
powerbar.progress = progress
powerbar.label = "$value"
Log.d(TAG, "Cadence: $value min: $minCadence max: $maxCadence")
powerbar.progressColor = if (streamData.settings?.useZoneColors == true) {
context.getColor(zoneColorRes)
} else {
powerbar.progressColor = context.getColor(R.color.zone0)
powerbar.progress = null
powerbar.label = "?"
Log.d(TAG, "Cadence: Unavailable")
context.getColor(R.color.zone0)
}
powerbar.invalidate()
powerbar.progress = if (value > 0) progress else null
powerbar.label = "$value"
Log.d(TAG, "Cadence: $value min: $minCadence max: $maxCadence")
} else {
powerbar.progressColor = context.getColor(R.color.zone0)
powerbar.progress = null
powerbar.label = "?"
Log.d(TAG, "Cadence: Unavailable")
}
powerbar.invalidate()
}
}
@ -567,38 +326,35 @@ class Window(
StreamData(userProfile, hr, settings, hrTarget)
}.distinctUntilChanged().throttle(1_000).collect { streamData ->
val value = streamData.value?.roundToInt()
val powerbarsWithHrSource = powerbars.values.filter { it.source == SelectedSource.HEART_RATE }
powerbarsWithHrSource.forEach { powerbar ->
if (value != null) {
val customMinHr = if (streamData.settings?.useCustomHrRange == true) streamData.settings.minHr else null
val customMaxHr = if (streamData.settings?.useCustomHrRange == true) streamData.settings.maxHr else null
val minHr = customMinHr ?: streamData.userProfile.restingHr
val maxHr = customMaxHr ?: streamData.userProfile.maxHr
val progress = remap(value.toDouble(), minHr.toDouble(), maxHr.toDouble(), 0.0, 1.0)
if (value != null) {
val customMinHr = if (streamData.settings?.useCustomHrRange == true) streamData.settings.minHr else null
val customMaxHr = if (streamData.settings?.useCustomHrRange == true) streamData.settings.maxHr else null
val minHr = customMinHr ?: streamData.userProfile.restingHr
val maxHr = customMaxHr ?: streamData.userProfile.maxHr
val progress = remap(value.toDouble(), minHr.toDouble(), maxHr.toDouble(), 0.0, 1.0)
powerbar.minTarget = remap(streamData.heartrateTarget?.values?.get(FIELD_TARGET_MIN_ID), 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?.get(FIELD_TARGET_VALUE_ID), minHr.toDouble(), maxHr.toDouble(), 0.0, 1.0)
powerbar.minTarget = remap(streamData.heartrateTarget?.values[FIELD_TARGET_MIN_ID]?.toDouble(), minHr.toDouble(), maxHr.toDouble(), 0.0, 1.0)
powerbar.maxTarget = remap(streamData.heartrateTarget?.values[FIELD_TARGET_MAX_ID]?.toDouble(), minHr.toDouble(), maxHr.toDouble(), 0.0, 1.0)
powerbar.target = remap(streamData.heartrateTarget?.values[FIELD_TARGET_VALUE_ID]?.toDouble(), minHr.toDouble(), maxHr.toDouble(), 0.0, 1.0)
powerbar.progressColor = if (streamData.settings?.useZoneColors == true) {
context.getColor(getZone(streamData.userProfile.heartRateZones, value)?.colorResource ?: R.color.zone7)
} else {
context.getColor(R.color.zone0)
}
powerbar.progress = progress
powerbar.label = "$value"
Log.d(TAG, "Hr: $value min: $minHr max: $maxHr")
powerbar.progressColor = if (streamData.settings?.useZoneColors == true) {
context.getColor(getZone(streamData.userProfile.heartRateZones, value)?.colorResource ?: R.color.zone7)
} else {
powerbar.progressColor = context.getColor(R.color.zone0)
powerbar.progress = null
powerbar.label = "?"
Log.d(TAG, "Hr: Unavailable")
context.getColor(R.color.zone0)
}
powerbar.invalidate()
powerbar.progress = if (value > 0) progress else null
powerbar.label = "$value"
Log.d(TAG, "Hr: $value min: $minHr max: $maxHr")
} else {
powerbar.progressColor = context.getColor(R.color.zone0)
powerbar.progress = null
powerbar.label = "?"
Log.d(TAG, "Hr: Unavailable")
}
powerbar.invalidate()
}
}
@ -608,13 +364,7 @@ class Window(
SMOOTHED_10S(DataType.Type.SMOOTHED_10S_AVERAGE_POWER),
}
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) {
private suspend fun streamPower(smoothed: PowerStreamSmoothing) {
val powerFlow = karooSystem.streamDataFlow(smoothed.dataTypeId)
.map { (it as? StreamState.Streaming)?.dataPoint?.singleValue }
.distinctUntilChanged()
@ -631,38 +381,35 @@ class Window(
StreamData(userProfile, hr, settings, powerTarget)
}.distinctUntilChanged().throttle(1_000).collect { streamData ->
val value = streamData.value?.roundToInt()
val powerbarsWithPowerSource = powerbars.values.filter { it.source == source }
powerbarsWithPowerSource.forEach { powerbar ->
if (value != null) {
val customMinPower = if (streamData.settings?.useCustomPowerRange == true) streamData.settings.minPower else null
val customMaxPower = if (streamData.settings?.useCustomPowerRange == true) streamData.settings.maxPower else null
val minPower = customMinPower ?: streamData.userProfile.powerZones.first().min
val maxPower = customMaxPower ?: (streamData.userProfile.powerZones.last().min + 30)
val progress = remap(value.toDouble(), minPower.toDouble(), maxPower.toDouble(), 0.0, 1.0)
if (value != null) {
val customMinPower = if (streamData.settings?.useCustomPowerRange == true) streamData.settings.minPower else null
val customMaxPower = if (streamData.settings?.useCustomPowerRange == true) streamData.settings.maxPower else null
val minPower = customMinPower ?: streamData.userProfile.powerZones.first().min
val maxPower = customMaxPower ?: (streamData.userProfile.powerZones.last().min + 30)
val progress = remap(value.toDouble(), minPower.toDouble(), maxPower.toDouble(), 0.0, 1.0)
powerbar.minTarget = remap(streamData.powerTarget?.values?.get(FIELD_TARGET_MIN_ID), 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?.get(FIELD_TARGET_VALUE_ID), minPower.toDouble(), maxPower.toDouble(), 0.0, 1.0)
powerbar.minTarget = remap(streamData.powerTarget?.values[FIELD_TARGET_MIN_ID]?.toDouble(), minPower.toDouble(), maxPower.toDouble(), 0.0, 1.0)
powerbar.maxTarget = remap(streamData.powerTarget?.values[FIELD_TARGET_MAX_ID]?.toDouble(), minPower.toDouble(), maxPower.toDouble(), 0.0, 1.0)
powerbar.target = remap(streamData.powerTarget?.values[FIELD_TARGET_VALUE_ID]?.toDouble(), minPower.toDouble(), maxPower.toDouble(), 0.0, 1.0)
powerbar.progressColor = if (streamData.settings?.useZoneColors == true) {
context.getColor(getZone(streamData.userProfile.powerZones, value)?.colorResource ?: R.color.zone7)
} else {
context.getColor(R.color.zone0)
}
powerbar.progress = progress
powerbar.label = "${value}W"
Log.d(TAG, "Power: $value min: $minPower max: $maxPower")
powerbar.progressColor = if (streamData.settings?.useZoneColors == true) {
context.getColor(getZone(streamData.userProfile.powerZones, value)?.colorResource ?: R.color.zone7)
} else {
powerbar.progressColor = context.getColor(R.color.zone0)
powerbar.progress = null
powerbar.label = "?"
Log.d(TAG, "Power: Unavailable")
context.getColor(R.color.zone0)
}
powerbar.invalidate()
powerbar.progress = if (value > 0) progress else null
powerbar.label = "${value}W"
Log.d(TAG, "Power: $value min: $minPower max: $maxPower")
} else {
powerbar.progressColor = context.getColor(R.color.zone0)
powerbar.progress = null
powerbar.label = "?"
Log.d(TAG, "Power: Unavailable")
}
powerbar.invalidate()
}
}
@ -675,9 +422,7 @@ class Window(
currentHideJob?.cancel()
currentHideJob = null
}
serviceJobs.forEach { job ->
job.cancel()
}
serviceJob?.cancel()
(context.getSystemService(WINDOW_SERVICE) as WindowManager).removeView(rootView)
rootView.invalidate()
(rootView.parent as? ViewGroup)?.removeAllViews()

View File

@ -37,10 +37,3 @@ fun getZone(userZones: List<UserProfile.Zone>, value: Int): Zone? {
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,7 +50,6 @@ import androidx.compose.ui.focus.FocusState
import androidx.compose.ui.focus.onFocusEvent
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@ -60,6 +59,7 @@ import androidx.datastore.preferences.core.edit
import androidx.lifecycle.compose.LifecycleResumeEffect
import de.timklge.karoopowerbar.CustomProgressBarBarSize
import de.timklge.karoopowerbar.CustomProgressBarFontSize
import de.timklge.karoopowerbar.CustomProgressBarSize
import de.timklge.karoopowerbar.KarooPowerbarExtension
import de.timklge.karoopowerbar.PowerbarSettings
import de.timklge.karoopowerbar.R
@ -78,24 +78,17 @@ import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlin.math.roundToInt
enum class SelectedSource(val id: String, val labelResId: Int) {
NONE("none", R.string.source_none),
HEART_RATE("hr", R.string.source_heart_rate),
POWER("power", R.string.source_power),
POWER_3S("power_3s", R.string.source_power_3s),
POWER_10S("power_10s", R.string.source_power_10s),
SPEED("speed", R.string.source_speed),
SPEED_3S("speed_3s", R.string.source_speed_3s),
CADENCE("cadence", R.string.source_cadence),
CADENCE_3S("cadence_3s", R.string.source_cadence_3s),
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);
enum class SelectedSource(val id: String, val label: String) {
NONE("none", "None"),
HEART_RATE("hr", "Heart Rate"),
POWER("power", "Power"),
POWER_3S("power_3s", "Power (3 sec avg)"),
POWER_10S("power_10s", "Power (10 sec avg)"),
SPEED("speed", "Speed"),
SPEED_3S("speed_3s", "Speed (3 sec avg"),
CADENCE("cadence", "Cadence"),
CADENCE_3S("cadence_3s", "Cadence (3 sec avg)"),
ROUTE_PROGRESS("route_progress", "Route Progress");
fun isPower() = this == POWER || this == POWER_3S || this == POWER_10S
}
@ -124,7 +117,7 @@ fun BarSelectDialog(currentSelectedSource: SelectedSource, onHide: () -> Unit, o
onSelect(pattern)
})
Text(
text = stringResource(pattern.labelResId),
text = pattern.label,
modifier = Modifier.padding(start = 10.dp)
)
}
@ -145,22 +138,9 @@ fun MainScreen(onFinish: () -> Unit) {
var bottomSelectedSource by remember { mutableStateOf(SelectedSource.POWER) }
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 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 givenPermissions by remember { mutableStateOf(false) }
@ -180,8 +160,6 @@ fun MainScreen(onFinish: () -> Unit) {
var customMaxPower by remember { mutableStateOf("") }
var customMinHr 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 useCustomHrRange by remember { mutableStateOf(false) }
@ -199,13 +177,7 @@ fun MainScreen(onFinish: () -> Unit) {
val maxSpeedSetting = (maxSpeed.toIntOrNull()?.toFloat()?.div((if(isImperial) 2.23694f else 3.6f))) ?: PowerbarSettings.defaultMaxSpeedMs
val newSettings = PowerbarSettings(
bottomBarSource = bottomSelectedSource, topBarSource = topSelectedSource,
splitTopBar = splitTopBar,
splitBottomBar = splitBottomBar,
topBarLeftSource = topSelectedSourceLeft,
topBarRightSource = topSelectedSourceRight,
bottomBarLeftSource = bottomSelectedSourceLeft,
bottomBarRightSource = bottomSelectedSourceRight,
source = bottomSelectedSource, topBarSource = topSelectedSource,
onlyShowWhileRiding = onlyShowWhileRiding, showLabelOnBars = showLabelOnBars,
barBackground = barBackground,
useZoneColors = colorBasedOnZones,
@ -216,8 +188,6 @@ fun MainScreen(onFinish: () -> Unit) {
maxPower = customMaxPower.toIntOrNull(),
minHr = customMinHr.toIntOrNull(),
maxHr = customMaxHr.toIntOrNull(),
minGradient = minGrade.toIntOrNull() ?: PowerbarSettings.defaultMinGradient,
maxGradient = maxGrade.toIntOrNull() ?: PowerbarSettings.defaultMaxGradient,
barBarSize = barBarSize,
barFontSize = barFontSize,
useCustomPowerRange = useCustomPowerRange,
@ -256,14 +226,8 @@ fun MainScreen(onFinish: () -> Unit) {
.combine(karooSystem.streamUserProfile()) { settings, profile -> settings to profile }
.distinctUntilChanged()
.collect { (settings, profile) ->
bottomSelectedSource = settings.bottomBarSource
bottomSelectedSource = settings.source
topSelectedSource = settings.topBarSource
splitTopBar = settings.splitTopBar
splitBottomBar = settings.splitBottomBar
topSelectedSourceLeft = settings.topBarLeftSource
topSelectedSourceRight = settings.topBarRightSource
bottomSelectedSourceLeft = settings.bottomBarLeftSource
bottomSelectedSourceRight = settings.bottomBarRightSource
onlyShowWhileRiding = settings.onlyShowWhileRiding
showLabelOnBars = settings.showLabelOnBars
colorBasedOnZones = settings.useZoneColors
@ -279,8 +243,6 @@ fun MainScreen(onFinish: () -> Unit) {
customMaxPower = settings.maxPower?.toString() ?: ""
customMinHr = settings.minHr?.toString() ?: ""
customMaxHr = settings.maxHr?.toString() ?: ""
minGrade = settings.minGradient?.toString() ?: ""
maxGrade = settings.maxGradient?.toString() ?: ""
useCustomPowerRange = settings.useCustomPowerRange
useCustomHrRange = settings.useCustomHrRange
}
@ -308,163 +270,21 @@ fun MainScreen(onFinish: () -> Unit) {
Column(modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)) {
TopAppBar(title = { Text(stringResource(R.string.powerbar_title)) })
TopAppBar(title = { Text("Powerbar") })
Column(modifier = Modifier
.padding(5.dp)
.verticalScroll(rememberScrollState())
.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
.fillMaxWidth()
.height(60.dp),
onClick = {
topBarLeftDialogVisible = 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_left, stringResource(topSelectedSourceLeft.labelResId)), modifier = Modifier.weight(1.0f))
}
if (topBarLeftDialogVisible){
BarSelectDialog(topSelectedSourceLeft, onHide = { topBarLeftDialogVisible = false }, onSelect = { selected ->
topSelectedSourceLeft = selected
coroutineScope.launch { updateSettings() }
topBarLeftDialogVisible = false
})
}
FilledTonalButton(modifier = Modifier
.fillMaxWidth()
.height(60.dp),
onClick = {
topBarRightDialogVisible = true
}) {
Icon(Icons.Default.Build, contentDescription = 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
.fillMaxWidth()
.height(60.dp),
onClick = {
topBarDialogVisible = 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_single, stringResource(topSelectedSource.labelResId)), modifier = Modifier.weight(1.0f))
}
}
if (topBarDialogVisible){
BarSelectDialog(topSelectedSource, onHide = { topBarDialogVisible = false }, onSelect = { selected ->
topSelectedSource = selected
coroutineScope.launch { updateSettings() }
topBarDialogVisible = false
})
}
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))
}
FilledTonalButton(modifier = Modifier
.fillMaxWidth()
.height(60.dp),
onClick = {
bottomBarDialogVisible = true
}) {
Icon(Icons.Default.Build, contentDescription = "Select", modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.width(5.dp))
Text("Bottom Bar: ${bottomSelectedSource.label}", modifier = Modifier.weight(1.0f))
}
if (bottomBarDialogVisible){
@ -475,42 +295,58 @@ fun MainScreen(onFinish: () -> Unit) {
})
}
FilledTonalButton(modifier = Modifier
.fillMaxWidth()
.height(60.dp),
onClick = {
topBarDialogVisible = true
}) {
Icon(Icons.Default.Build, contentDescription = "Select", modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.width(5.dp))
Text("Top Bar: ${topSelectedSource.label}", modifier = Modifier.weight(1.0f))
}
if (topBarDialogVisible){
BarSelectDialog(topSelectedSource, onHide = { topBarDialogVisible = false }, onSelect = { selected ->
topSelectedSource = selected
coroutineScope.launch { updateSettings() }
topBarDialogVisible = false
})
}
apply {
val dropdownOptions = CustomProgressBarBarSize.entries.toList().map { unit -> DropdownOption(unit.id, stringResource(unit.labelResId)) }
val dropdownOptions = CustomProgressBarBarSize.entries.toList().map { unit -> DropdownOption(unit.id, unit.label) }
val dropdownInitialSelection by remember(barBarSize) {
mutableStateOf(dropdownOptions.find { option -> option.id == barBarSize.id }!!)
}
Dropdown(label = stringResource(R.string.bar_size), options = dropdownOptions, selected = dropdownInitialSelection) { selectedOption ->
Dropdown(label = "Bar Size", options = dropdownOptions, selected = dropdownInitialSelection) { selectedOption ->
barBarSize = CustomProgressBarBarSize.entries.find { unit -> unit.id == selectedOption.id }!!
coroutineScope.launch { updateSettings() }
}
}
apply {
val dropdownOptions = CustomProgressBarFontSize.entries.toList().map { unit -> DropdownOption(unit.id, stringResource(unit.labelResId)) }
val dropdownOptions = CustomProgressBarFontSize.entries.toList().map { unit -> DropdownOption(unit.id, unit.label) }
val dropdownInitialSelection by remember(barFontSize) {
mutableStateOf(dropdownOptions.find { option -> option.id == barFontSize.id }!!)
}
Dropdown(label = stringResource(R.string.text_size), options = dropdownOptions, selected = dropdownInitialSelection) { selectedOption ->
Dropdown(label = "Text Size", options = dropdownOptions, selected = dropdownInitialSelection) { selectedOption ->
barFontSize = CustomProgressBarFontSize.entries.find { unit -> unit.id == selectedOption.id }!!
coroutineScope.launch { updateSettings() }
}
}
if (topSelectedSource == SelectedSource.SPEED || topSelectedSource == 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))
){
bottomSelectedSource == SelectedSource.SPEED || bottomSelectedSource == SelectedSource.SPEED_3S){
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(value = minSpeed, modifier = Modifier
.weight(1f)
.absolutePadding(right = 2.dp)
.onFocusEvent(::updateFocus),
onValueChange = { minSpeed = it.filter { c -> c.isDigit() } },
label = { Text(stringResource(R.string.min_speed)) },
suffix = { Text(stringResource(if (isImperial) R.string.unit_mph else R.string.unit_kph)) },
onValueChange = { minSpeed = it },
label = { Text("Min Speed") },
suffix = { Text(if (isImperial) "mph" else "kph") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true
)
@ -519,26 +355,23 @@ fun MainScreen(onFinish: () -> Unit) {
.weight(1f)
.absolutePadding(left = 2.dp)
.onFocusEvent(::updateFocus),
onValueChange = { maxSpeed = it.filter { c -> c.isDigit() } },
label = { Text(stringResource(R.string.max_speed)) },
suffix = { Text(stringResource(if (isImperial) R.string.unit_mph else R.string.unit_kph)) },
onValueChange = { maxSpeed = it },
label = { Text("Max Speed") },
suffix = { Text(if (isImperial) "mph" else "kph") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true
)
}
}
if (topSelectedSource.isPower() || bottomSelectedSource.isPower() ||
(splitTopBar && (topSelectedSourceLeft.isPower() || topSelectedSourceRight.isPower())) ||
(splitBottomBar && (bottomSelectedSourceLeft.isPower() || bottomSelectedSourceRight.isPower()))
){
if (topSelectedSource.isPower() || bottomSelectedSource.isPower()){
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = useCustomPowerRange, onCheckedChange = {
useCustomPowerRange = it
coroutineScope.launch { updateSettings() }
})
Spacer(modifier = Modifier.width(10.dp))
Text(stringResource(R.string.use_custom_power_range))
Text("Use custom power range")
}
if(useCustomPowerRange){
@ -547,9 +380,9 @@ fun MainScreen(onFinish: () -> Unit) {
.weight(1f)
.absolutePadding(right = 2.dp)
.onFocusEvent(::updateFocus),
onValueChange = { customMinPower = it.filter { c -> c.isDigit() } },
label = { Text(stringResource(R.string.min_power), fontSize = 12.sp) },
suffix = { Text(stringResource(R.string.unit_watts)) },
onValueChange = { customMinPower = it },
label = { Text("Min Power", fontSize = 12.sp) },
suffix = { Text("W") },
placeholder = { Text("$profileMinPower") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true
@ -559,9 +392,9 @@ fun MainScreen(onFinish: () -> Unit) {
.weight(1f)
.absolutePadding(left = 2.dp)
.onFocusEvent(::updateFocus),
onValueChange = { customMaxPower = it.filter { c -> c.isDigit() } },
label = { Text(stringResource(R.string.max_power), fontSize = 12.sp) },
suffix = { Text(stringResource(R.string.unit_watts)) },
onValueChange = { customMaxPower = it },
label = { Text("Max Power", fontSize = 12.sp) },
suffix = { Text("W") },
placeholder = { Text("$profileMaxPower") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true
@ -570,17 +403,14 @@ fun MainScreen(onFinish: () -> Unit) {
}
}
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))
){
if (topSelectedSource == SelectedSource.HEART_RATE || bottomSelectedSource == SelectedSource.HEART_RATE){
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = useCustomHrRange, onCheckedChange = {
useCustomHrRange = it
coroutineScope.launch { updateSettings() }
})
Spacer(modifier = Modifier.width(10.dp))
Text(stringResource(R.string.use_custom_hr_range))
Text("Use custom HR range")
}
if (useCustomHrRange){
@ -589,9 +419,9 @@ fun MainScreen(onFinish: () -> Unit) {
.weight(1f)
.absolutePadding(right = 2.dp)
.onFocusEvent(::updateFocus),
onValueChange = { customMinHr = it.filter { c -> c.isDigit() } },
label = { Text(stringResource(R.string.min_hr)) },
suffix = { Text(stringResource(R.string.unit_bpm)) },
onValueChange = { customMinHr = it },
label = { Text("Min Hr") },
suffix = { Text("bpm") },
placeholder = { if(profileRestHr > 0) Text("$profileRestHr") else Unit },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true
@ -601,9 +431,9 @@ fun MainScreen(onFinish: () -> Unit) {
.weight(1f)
.absolutePadding(left = 2.dp)
.onFocusEvent(::updateFocus),
onValueChange = { customMaxHr = it.filter { c -> c.isDigit() } },
label = { Text(stringResource(R.string.max_hr)) },
suffix = { Text(stringResource(R.string.unit_bpm)) },
onValueChange = { customMaxHr = it },
label = { Text("Max Hr") },
suffix = { Text("bpm") },
placeholder = { if(profileMaxHr > 0) Text("$profileMaxHr") else Unit },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true
@ -613,19 +443,16 @@ fun MainScreen(onFinish: () -> Unit) {
}
if (bottomSelectedSource == SelectedSource.CADENCE || topSelectedSource == SelectedSource.CADENCE ||
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))
){
bottomSelectedSource == SelectedSource.CADENCE_3S || topSelectedSource == SelectedSource.CADENCE_3S){
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(value = minCadence, modifier = Modifier
.weight(1f)
.absolutePadding(right = 2.dp)
.onFocusEvent(::updateFocus),
onValueChange = { minCadence = it.filter { c -> c.isDigit() } },
label = { Text(stringResource(R.string.min_cadence)) },
suffix = { Text(stringResource(R.string.unit_rpm)) },
onValueChange = { minCadence = it },
label = { Text("Min Cadence") },
suffix = { Text("rpm") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true
)
@ -634,38 +461,9 @@ fun MainScreen(onFinish: () -> Unit) {
.weight(1f)
.absolutePadding(left = 2.dp)
.onFocusEvent(::updateFocus),
onValueChange = { maxCadence = it.filter { c -> c.isDigit() } },
label = { Text(stringResource(R.string.max_cadence)) },
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)) },
onValueChange = { maxCadence = it },
label = { Text("Min Cadence") },
suffix = { Text("rpm") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true
)
@ -678,7 +476,7 @@ fun MainScreen(onFinish: () -> Unit) {
coroutineScope.launch { updateSettings() }
})
Spacer(modifier = Modifier.width(10.dp))
Text(stringResource(R.string.color_based_on_zones))
Text("Color based on HR / power zones")
}
Row(verticalAlignment = Alignment.CenterVertically) {
@ -687,7 +485,7 @@ fun MainScreen(onFinish: () -> Unit) {
coroutineScope.launch { updateSettings() }
})
Spacer(modifier = Modifier.width(10.dp))
Text(stringResource(R.string.show_value_on_bars))
Text("Show value on bars")
}
Row(verticalAlignment = Alignment.CenterVertically) {
@ -696,7 +494,7 @@ fun MainScreen(onFinish: () -> Unit) {
coroutineScope.launch { updateSettings() }
})
Spacer(modifier = Modifier.width(10.dp))
Text(stringResource(R.string.solid_background))
Text("Opaque background")
}
Row(verticalAlignment = Alignment.CenterVertically) {
@ -705,16 +503,37 @@ fun MainScreen(onFinish: () -> Unit) {
coroutineScope.launch { updateSettings() }
})
Spacer(modifier = Modifier.width(10.dp))
Text(stringResource(R.string.only_show_while_riding))
Text("Only show while riding")
}
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(
painter = painterResource(id = R.drawable.back),
contentDescription = stringResource(R.string.content_desc_back),
contentDescription = "Back",
modifier = Modifier
.align(Alignment.BottomStart)
.padding(bottom = 10.dp)

View File

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

View File

@ -1,79 +0,0 @@
<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,17 +12,4 @@
<color name="zone6">#FE581F</color>
<color name="zone7">#D60404</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>

View File

@ -1,79 +1,4 @@
<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">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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB