fix #22: Adds widget that shows current riding speed and tailwind (#25)

* Add tailwind with ride speed version of the headwind datafield

* Add error indication to tailwind speed widget
This commit is contained in:
timklge 2025-01-17 18:13:19 +01:00 committed by GitHub
parent 69626dce67
commit a98fcb875a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 289 additions and 21 deletions

View File

@ -34,6 +34,7 @@ If you are using a Karoo 2, you can use manual sideloading:
After installing this app on your Karoo and opening it once from the main menu, you can add the following new data fields to your data pages: After installing this app on your Karoo and opening it once from the main menu, you can add the following new data fields to your data pages:
- Headwind (graphical, 1x1 field): Shows the headwind direction and speed as a circle with a triangular direction indicator. The speed is shown at the center in your set unit of measurement (default is kilometers per hour if you have set up metric units in your Karoo, otherwise miles per hour). Both direction and speed are relative to the current riding direction by default, i. e., riding directly into a wind of 20 km/h will show a headwind speed of 20 km/h, while riding in the same direction will show -20 km/h. You can change this behavior in the app settings to show the absolute wind direction and speed instead. - Headwind (graphical, 1x1 field): Shows the headwind direction and speed as a circle with a triangular direction indicator. The speed is shown at the center in your set unit of measurement (default is kilometers per hour if you have set up metric units in your Karoo, otherwise miles per hour). Both direction and speed are relative to the current riding direction by default, i. e., riding directly into a wind of 20 km/h will show a headwind speed of 20 km/h, while riding in the same direction will show -20 km/h. You can change this behavior in the app settings to show the absolute wind direction and speed instead.
- Tailwind with riding speed (graphical, 1x1 field): Shows an arrow indicating the current headwind direction next to a label reading your current speed and the speed of the tailwind. If you ride against a headwind of 5 mph, it will show "-5". If you ride in the same direction of a 5 mph wind, it will read "+5". Text and arrow are colored based on the tailwind speed, with red indicating a strong headwind and green indicating a strong tailwind.
- Weather forecast (graphical, 2x1 field): Shows three columns indicating the current weather conditions (sunny, cloudy, ...), wind direction, precipitation and temperature forecasted for the next three hours. Tap on this widget to cycle through the 12 hour forecast. - Weather forecast (graphical, 2x1 field): Shows three columns indicating the current weather conditions (sunny, cloudy, ...), wind direction, precipitation and temperature forecasted for the next three hours. Tap on this widget to cycle through the 12 hour forecast.
- Additionally, data fields that only show the current data value for headwind speed, humidity, cloud cover, absolute wind speed, absolute wind gust speed, absolute wind direction, rainfall and surface pressure can be added if desired. - Additionally, data fields that only show the current data value for headwind speed, humidity, cloud cover, absolute wind speed, absolute wind gust speed, absolute wind direction, rainfall and surface pressure can be added if desired.

View File

@ -9,6 +9,7 @@ import de.timklge.karooheadwind.datatypes.SurfacePressureDataType
import de.timklge.karooheadwind.datatypes.WindDirectionDataType import de.timklge.karooheadwind.datatypes.WindDirectionDataType
import de.timklge.karooheadwind.datatypes.WindGustsDataType import de.timklge.karooheadwind.datatypes.WindGustsDataType
import de.timklge.karooheadwind.datatypes.HeadwindSpeedDataType import de.timklge.karooheadwind.datatypes.HeadwindSpeedDataType
import de.timklge.karooheadwind.datatypes.TailwindAndRideSpeedDataType
import de.timklge.karooheadwind.datatypes.HeadwindDirectionDataType import de.timklge.karooheadwind.datatypes.HeadwindDirectionDataType
import de.timklge.karooheadwind.datatypes.TemperatureDataType import de.timklge.karooheadwind.datatypes.TemperatureDataType
import de.timklge.karooheadwind.datatypes.WeatherDataType import de.timklge.karooheadwind.datatypes.WeatherDataType
@ -54,6 +55,7 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.1.3") {
override val types by lazy { override val types by lazy {
listOf( listOf(
HeadwindDirectionDataType(karooSystem, applicationContext), HeadwindDirectionDataType(karooSystem, applicationContext),
TailwindAndRideSpeedDataType(karooSystem, applicationContext),
HeadwindSpeedDataType(karooSystem, applicationContext), HeadwindSpeedDataType(karooSystem, applicationContext),
WeatherDataType(karooSystem, applicationContext), WeatherDataType(karooSystem, applicationContext),
WeatherForecastDataType(karooSystem, applicationContext), WeatherForecastDataType(karooSystem, applicationContext),

View File

@ -1,14 +1,11 @@
package de.timklge.karooheadwind.datatypes package de.timklge.karooheadwind.datatypes
import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.Paint import android.graphics.Paint
import android.util.Log import android.util.Log
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.glance.ColorFilter import androidx.glance.ColorFilter
@ -16,27 +13,30 @@ import androidx.glance.GlanceModifier
import androidx.glance.Image import androidx.glance.Image
import androidx.glance.ImageProvider import androidx.glance.ImageProvider
import androidx.glance.appwidget.background import androidx.glance.appwidget.background
import androidx.glance.background
import androidx.glance.color.ColorProvider import androidx.glance.color.ColorProvider
import androidx.glance.layout.Alignment import androidx.glance.layout.Alignment
import androidx.glance.layout.Box import androidx.glance.layout.Box
import androidx.glance.layout.Column
import androidx.glance.layout.ContentScale import androidx.glance.layout.ContentScale
import androidx.glance.layout.Row
import androidx.glance.layout.fillMaxHeight
import androidx.glance.layout.fillMaxSize import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.padding import androidx.glance.layout.padding
import androidx.glance.layout.width
import androidx.glance.preview.ExperimentalGlancePreviewApi import androidx.glance.preview.ExperimentalGlancePreviewApi
import androidx.glance.preview.Preview import androidx.glance.preview.Preview
import androidx.glance.text.FontFamily import androidx.glance.text.FontFamily
import androidx.glance.text.FontWeight
import androidx.glance.text.Text import androidx.glance.text.Text
import androidx.glance.text.TextStyle import androidx.glance.text.TextStyle
import de.timklge.karooheadwind.KarooHeadwindExtension import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.R
import kotlin.math.roundToInt import kotlin.math.roundToInt
data class BitmapWithBearing(val bitmap: Bitmap, val bearing: Int) data class BitmapWithBearing(val bitmap: Bitmap, val bearing: Int)
val bitmapsByBearing = mutableMapOf<BitmapWithBearing, Bitmap>() val bitmapsByBearing = mutableMapOf<BitmapWithBearing, Bitmap>()
fun getArrowBitmapByBearing(baseBitmap: Bitmap, bearing: Int): Bitmap { fun getArrowBitmapByBearing(baseBitmap: Bitmap, bearing: Int): Bitmap {
synchronized(bitmapsByBearing) { synchronized(bitmapsByBearing) {
val bearingRounded = (((bearing + 360) / 10.0).roundToInt() * 10) % 360 val bearingRounded = (((bearing + 360) / 10.0).roundToInt() * 10) % 360
@ -71,7 +71,7 @@ fun getArrowBitmapByBearing(baseBitmap: Bitmap, bearing: Int): Bitmap {
@OptIn(ExperimentalGlancePreviewApi::class) @OptIn(ExperimentalGlancePreviewApi::class)
@Preview(widthDp = 200, heightDp = 150) @Preview(widthDp = 200, heightDp = 150)
@Composable @Composable
fun HeadwindDirection(baseBitmap: Bitmap, bearing: Int, fontSize: Int, overlayText: String) { fun HeadwindDirection(baseBitmap: Bitmap, bearing: Int, fontSize: Int, overlayText: String, overlaySubText: String? = null, dayColor: Color = Color.Black, nightColor: Color = Color.White) {
Box( Box(
modifier = GlanceModifier.fillMaxSize().padding(5.dp), modifier = GlanceModifier.fillMaxSize().padding(5.dp),
contentAlignment = Alignment( contentAlignment = Alignment(
@ -79,20 +79,52 @@ fun HeadwindDirection(baseBitmap: Bitmap, bearing: Int, fontSize: Int, overlayTe
horizontal = Alignment.Horizontal.CenterHorizontally, horizontal = Alignment.Horizontal.CenterHorizontally,
), ),
) { ) {
if (overlayText.isNotEmpty()){
if (overlaySubText == null){
Image( Image(
modifier = GlanceModifier.fillMaxSize(), modifier = GlanceModifier.fillMaxSize(),
provider = ImageProvider(getArrowBitmapByBearing(baseBitmap, bearing)), provider = ImageProvider(getArrowBitmapByBearing(baseBitmap, bearing)),
contentDescription = "Relative wind direction indicator", contentDescription = "Relative wind direction indicator",
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
colorFilter = ColorFilter.tint(ColorProvider(Color.Black, Color.White)) colorFilter = ColorFilter.tint(ColorProvider(dayColor, nightColor))
) )
if (overlayText.isNotEmpty()){
Text( Text(
overlayText, overlayText,
style = TextStyle(ColorProvider(Color.Black, Color.White), fontSize = (0.6 * fontSize).sp, fontFamily = FontFamily.Monospace), style = TextStyle(ColorProvider(dayColor, nightColor), fontSize = (0.6 * fontSize).sp, fontFamily = FontFamily.Monospace),
modifier = GlanceModifier.background(Color(1f, 1f, 1f, 0.4f), Color(0f, 0f, 0f, 0.4f)).padding(1.dp)
)
} else {
Row(modifier = GlanceModifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
Column(modifier = GlanceModifier.defaultWeight()){
Image(
provider = ImageProvider(getArrowBitmapByBearing(baseBitmap, bearing)),
contentDescription = "Relative wind direction indicator",
contentScale = ContentScale.Fit,
colorFilter = ColorFilter.tint(ColorProvider(dayColor, nightColor))
)
}
Column(modifier = GlanceModifier.defaultWeight(),
horizontalAlignment = Alignment.Horizontal.CenterHorizontally) {
Text(
overlayText,
style = TextStyle(ColorProvider(dayColor, nightColor), fontSize = (0.7 * fontSize).sp, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold),
modifier = GlanceModifier.background(Color(1f, 1f, 1f, 0.4f), Color(0f, 0f, 0f, 0.4f)).padding(1.dp)
)
Row(){
Text(
overlaySubText,
style = TextStyle(ColorProvider(dayColor, nightColor), fontSize = (0.5 * fontSize).sp, fontFamily = FontFamily.Monospace),
modifier = GlanceModifier.background(Color(1f, 1f, 1f, 0.4f), Color(0f, 0f, 0f, 0.4f)).padding(1.dp) modifier = GlanceModifier.background(Color(1f, 1f, 1f, 0.4f), Color(0f, 0f, 0f, 0.4f)).padding(1.dp)
) )
} }
} }
}
}
}
}
} }

View File

@ -0,0 +1,215 @@
package de.timklge.karooheadwind.datatypes
import android.content.Context
import android.graphics.BitmapFactory
import android.util.Log
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.unit.DpSize
import androidx.core.content.ContextCompat
import androidx.core.graphics.ColorUtils
import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi
import androidx.glance.appwidget.GlanceRemoteViews
import de.timklge.karooheadwind.HeadingResponse
import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.R
import de.timklge.karooheadwind.getRelativeHeadingFlow
import de.timklge.karooheadwind.screens.HeadwindSettings
import de.timklge.karooheadwind.screens.WindDirectionIndicatorSetting
import de.timklge.karooheadwind.screens.WindDirectionIndicatorTextSetting
import de.timklge.karooheadwind.streamCurrentWeatherData
import de.timklge.karooheadwind.streamDataFlow
import de.timklge.karooheadwind.streamSettings
import de.timklge.karooheadwind.streamUserProfile
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.extension.DataTypeImpl
import io.hammerhead.karooext.internal.Emitter
import io.hammerhead.karooext.internal.ViewEmitter
import io.hammerhead.karooext.models.DataPoint
import io.hammerhead.karooext.models.DataType
import io.hammerhead.karooext.models.StreamState
import io.hammerhead.karooext.models.UpdateGraphicConfig
import io.hammerhead.karooext.models.UserProfile
import io.hammerhead.karooext.models.ViewConfig
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlin.math.absoluteValue
import kotlin.math.cos
import kotlin.math.roundToInt
fun interpolateColor(color1: Color, color2: Color, factor: Float): Color {
return Color(ColorUtils.blendARGB(color1.toArgb(), color2.toArgb(), factor))
}
fun interpolateWindColor(windSpeedInKmh: Double, night: Boolean, context: Context): Color {
val default = Color(ContextCompat.getColor(context, if(night) R.color.white else R.color.black))
val green = Color(ContextCompat.getColor(context, if(night) R.color.green else R.color.hGreen))
val red = Color(ContextCompat.getColor(context, if(night) R.color.red else R.color.hRed))
val orange = Color(ContextCompat.getColor(context, if(night) R.color.orange else R.color.hOrange))
return when {
windSpeedInKmh <= -15 -> green
windSpeedInKmh >= 30 -> red
windSpeedInKmh in -15.0..0.0 -> interpolateColor(green, default, (windSpeedInKmh + 15).toFloat() / 15)
windSpeedInKmh in 0.0..15.0 -> interpolateColor(default, orange, windSpeedInKmh.toFloat() / 15)
else -> interpolateColor(orange, red, (windSpeedInKmh - 15).toFloat() / 15)
}
}
@OptIn(ExperimentalGlanceRemoteViewsApi::class)
class TailwindAndRideSpeedDataType(
private val karooSystem: KarooSystemService,
private val applicationContext: Context
) : DataTypeImpl("karoo-headwind", "tailwind-and-ride-speed") {
private val glance = GlanceRemoteViews()
override fun startStream(emitter: Emitter<StreamState>) {
val job = CoroutineScope(Dispatchers.IO).launch {
karooSystem.getRelativeHeadingFlow(applicationContext)
.collect { diff ->
val value = (diff as? HeadingResponse.Value)?.diff ?: 0.0
emitter.onNext(StreamState.Streaming(DataPoint(dataTypeId, mapOf(DataType.Field.SINGLE to value))))
}
}
emitter.setCancellable {
job.cancel()
}
}
data class StreamData(val headingResponse: HeadingResponse,
val absoluteWindDirection: Double?,
val windSpeed: Double?,
val settings: HeadwindSettings?,
val rideSpeed: Double? = null,
val isImperial: Boolean? = null)
private fun previewFlow(): Flow<StreamData> {
return flow {
while (true) {
val bearing = (0..360).random().toDouble()
val windSpeed = (-20..20).random()
val rideSpeed = (10..40).random().toDouble()
emit(StreamData(HeadingResponse.Value(bearing), bearing, windSpeed.toDouble(), HeadwindSettings(), rideSpeed))
delay(2_000)
}
}
}
private fun streamSpeedInMs(): Flow<Double> {
return karooSystem.streamDataFlow(DataType.Type.SMOOTHED_3S_AVERAGE_SPEED)
.map { (it as? StreamState.Streaming)?.dataPoint?.singleValue ?: 0.0 }
}
override fun startView(context: Context, config: ViewConfig, emitter: ViewEmitter) {
Log.d(KarooHeadwindExtension.TAG, "Starting headwind direction view with $emitter")
val baseBitmap = BitmapFactory.decodeResource(
context.resources,
R.drawable.arrow_0
)
val configJob = CoroutineScope(Dispatchers.IO).launch {
emitter.onNext(UpdateGraphicConfig(showHeader = false))
awaitCancellation()
}
val flow = if (config.preview) {
previewFlow()
} else {
karooSystem.getRelativeHeadingFlow(context)
.combine(context.streamCurrentWeatherData()) { value, data -> value to data }
.combine(context.streamSettings(karooSystem)) { (value, data), settings ->
StreamData(value, data?.current?.windDirection, data?.current?.windSpeed, settings)
}
.combine(karooSystem.streamUserProfile()) { streamData, userProfile ->
val isImperial = userProfile.preferredUnit.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL
streamData.copy(isImperial = isImperial)
}
.combine(streamSpeedInMs()) { streamData, rideSpeedInMs ->
val rideSpeed = if (streamData.isImperial == true){
rideSpeedInMs * 2.23694
} else {
rideSpeedInMs * 3.6
}
streamData.copy(rideSpeed = rideSpeed)
}
}
val viewJob = CoroutineScope(Dispatchers.IO).launch {
flow.collect { streamData ->
Log.d(KarooHeadwindExtension.TAG, "Updating headwind direction view")
val value = (streamData.headingResponse as? HeadingResponse.Value)?.diff
if (value == null || streamData.absoluteWindDirection == null || streamData.settings == null || streamData.windSpeed == null){
var headingResponse = streamData.headingResponse
if (headingResponse is HeadingResponse.Value && (streamData.absoluteWindDirection == null || streamData.windSpeed == null)){
headingResponse = HeadingResponse.NoWeatherData
}
emitter.updateView(getErrorWidget(glance, context, streamData.settings, headingResponse).remoteViews)
return@collect
}
val windSpeed = streamData.windSpeed
val windDirection = when (streamData.settings.windDirectionIndicatorSetting){
WindDirectionIndicatorSetting.HEADWIND_DIRECTION -> streamData.headingResponse.diff
WindDirectionIndicatorSetting.WIND_DIRECTION -> streamData.absoluteWindDirection + 180
}
val text = streamData.rideSpeed?.let { String.format(Locale.current.platformLocale, "%.1f", it) } ?: ""
val subtextWithSign = when (streamData.settings.windDirectionIndicatorTextSetting) {
WindDirectionIndicatorTextSetting.HEADWIND_SPEED -> {
val headwindSpeed = cos( (windDirection + 180) * Math.PI / 180.0) * windSpeed
headwindSpeed.roundToInt().toString()
val sign = if (headwindSpeed < 0) "+" else {
if (headwindSpeed > 0) "-" else ""
}
"$sign${headwindSpeed.roundToInt().absoluteValue}"
}
WindDirectionIndicatorTextSetting.WIND_SPEED -> windSpeed.roundToInt().toString()
WindDirectionIndicatorTextSetting.NONE -> ""
}
var dayColor = Color(ContextCompat.getColor(context, R.color.black))
var nightColor = Color(ContextCompat.getColor(context, R.color.white))
if (streamData.settings.windDirectionIndicatorTextSetting == WindDirectionIndicatorTextSetting.HEADWIND_SPEED) {
val headwindSpeed = cos( (windDirection + 180) * Math.PI / 180.0) * windSpeed
val windSpeedInKmh = if (streamData.isImperial == true){
headwindSpeed / 2.23694 * 3.6
} else {
headwindSpeed
}
dayColor = interpolateWindColor(windSpeedInKmh, false, context)
nightColor = interpolateWindColor(windSpeedInKmh, true, context)
}
val result = glance.compose(context, DpSize.Unspecified) {
HeadwindDirection(baseBitmap, windDirection.roundToInt(), config.textSize, text, subtextWithSign,
dayColor, nightColor)
}
emitter.updateView(result.remoteViews)
}
}
emitter.setCancellable {
Log.d(KarooHeadwindExtension.TAG, "Stopping headwind view with $emitter")
configJob.cancel()
viewJob.cancel()
}
}
}

View File

@ -4,4 +4,13 @@
<color name="colorPrimaryDark">#2600B3</color> <color name="colorPrimaryDark">#2600B3</color>
<color name="white">#ffffff</color> <color name="white">#ffffff</color>
<color name="black">#000000</color>
<color name="green">#00ff00</color>
<color name="orange">#ff9930</color>
<color name="red">#FF2424</color>
<color name="hGreen">#008000</color>
<color name="hOrange">#BB4300</color>
<color name="hRed">#B30000</color>
</resources> </resources>

View File

@ -2,7 +2,9 @@
<string name="app_name">Headwind</string> <string name="app_name">Headwind</string>
<string name="extension_name">headwind</string> <string name="extension_name">headwind</string>
<string name="headwind">Headwind</string> <string name="headwind">Headwind</string>
<string name="headwind_description">Current headwind direction and speed</string> <string name="headwind_description">Current headwind direction and headwind speed</string>
<string name="tailwind_and_speed">Speed and tailwind</string>
<string name="tailwind_and_speed_description">Current rider speed and tailwind</string>
<string name="relativeHumidity">Humidity</string> <string name="relativeHumidity">Humidity</string>
<string name="relativeHumidity_description">Relative humidity in percent</string> <string name="relativeHumidity_description">Relative humidity in percent</string>
<string name="cloudCover">Cloud cover</string> <string name="cloudCover">Cloud cover</string>

View File

@ -11,6 +11,13 @@
icon="@drawable/wind" icon="@drawable/wind"
typeId="headwind" /> typeId="headwind" />
<DataType
description="@string/tailwind_and_speed_description"
displayName="@string/tailwind_and_speed"
graphical="true"
icon="@drawable/ic_launcher"
typeId="tailwind-and-ride-speed" />
<DataType <DataType
description="@string/weather_description" description="@string/weather_description"
displayName="@string/weather" displayName="@string/weather"