From a98fcb875aafde831964908a4139e08b48006faf Mon Sep 17 00:00:00 2001 From: timklge <2026103+timklge@users.noreply.github.com> Date: Fri, 17 Jan 2025 18:13:19 +0100 Subject: [PATCH] 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 --- README.md | 1 + .../karooheadwind/KarooHeadwindExtension.kt | 2 + .../datatypes/HeadwindDirectionView.kt | 72 ++++-- .../datatypes/TailwindAndRideSpeedDataType.kt | 215 ++++++++++++++++++ app/src/main/res/values/colors.xml | 9 + app/src/main/res/values/strings.xml | 4 +- app/src/main/res/xml/extension_info.xml | 7 + 7 files changed, 289 insertions(+), 21 deletions(-) create mode 100644 app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TailwindAndRideSpeedDataType.kt diff --git a/README.md b/README.md index ec3191e..a6ed69b 100644 --- a/README.md +++ b/README.md @@ -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: - 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. - 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. diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt b/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt index 5c57f3e..586a854 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt @@ -9,6 +9,7 @@ import de.timklge.karooheadwind.datatypes.SurfacePressureDataType import de.timklge.karooheadwind.datatypes.WindDirectionDataType import de.timklge.karooheadwind.datatypes.WindGustsDataType import de.timklge.karooheadwind.datatypes.HeadwindSpeedDataType +import de.timklge.karooheadwind.datatypes.TailwindAndRideSpeedDataType import de.timklge.karooheadwind.datatypes.HeadwindDirectionDataType import de.timklge.karooheadwind.datatypes.TemperatureDataType import de.timklge.karooheadwind.datatypes.WeatherDataType @@ -54,6 +55,7 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.1.3") { override val types by lazy { listOf( HeadwindDirectionDataType(karooSystem, applicationContext), + TailwindAndRideSpeedDataType(karooSystem, applicationContext), HeadwindSpeedDataType(karooSystem, applicationContext), WeatherDataType(karooSystem, applicationContext), WeatherForecastDataType(karooSystem, applicationContext), diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindDirectionView.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindDirectionView.kt index 209e519..e679aea 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindDirectionView.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindDirectionView.kt @@ -1,14 +1,11 @@ package de.timklge.karooheadwind.datatypes -import android.content.Context import android.graphics.Bitmap -import android.graphics.BitmapFactory import android.graphics.Canvas import android.graphics.Paint import android.util.Log import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.glance.ColorFilter @@ -16,27 +13,30 @@ import androidx.glance.GlanceModifier import androidx.glance.Image import androidx.glance.ImageProvider import androidx.glance.appwidget.background -import androidx.glance.background import androidx.glance.color.ColorProvider import androidx.glance.layout.Alignment import androidx.glance.layout.Box +import androidx.glance.layout.Column import androidx.glance.layout.ContentScale +import androidx.glance.layout.Row +import androidx.glance.layout.fillMaxHeight import androidx.glance.layout.fillMaxSize import androidx.glance.layout.padding +import androidx.glance.layout.width import androidx.glance.preview.ExperimentalGlancePreviewApi import androidx.glance.preview.Preview import androidx.glance.text.FontFamily +import androidx.glance.text.FontWeight import androidx.glance.text.Text import androidx.glance.text.TextStyle import de.timklge.karooheadwind.KarooHeadwindExtension +import de.timklge.karooheadwind.R import kotlin.math.roundToInt - data class BitmapWithBearing(val bitmap: Bitmap, val bearing: Int) val bitmapsByBearing = mutableMapOf() - fun getArrowBitmapByBearing(baseBitmap: Bitmap, bearing: Int): Bitmap { synchronized(bitmapsByBearing) { val bearingRounded = (((bearing + 360) / 10.0).roundToInt() * 10) % 360 @@ -71,7 +71,7 @@ fun getArrowBitmapByBearing(baseBitmap: Bitmap, bearing: Int): Bitmap { @OptIn(ExperimentalGlancePreviewApi::class) @Preview(widthDp = 200, heightDp = 150) @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( modifier = GlanceModifier.fillMaxSize().padding(5.dp), contentAlignment = Alignment( @@ -79,20 +79,52 @@ fun HeadwindDirection(baseBitmap: Bitmap, bearing: Int, fontSize: Int, overlayTe horizontal = Alignment.Horizontal.CenterHorizontally, ), ) { - Image( - modifier = GlanceModifier.fillMaxSize(), - provider = ImageProvider(getArrowBitmapByBearing(baseBitmap, bearing)), - contentDescription = "Relative wind direction indicator", - contentScale = ContentScale.Fit, - colorFilter = ColorFilter.tint(ColorProvider(Color.Black, Color.White)) - ) - if (overlayText.isNotEmpty()){ - Text( - overlayText, - style = TextStyle(ColorProvider(Color.Black, Color.White), 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) - ) + if (overlaySubText == null){ + Image( + modifier = GlanceModifier.fillMaxSize(), + provider = ImageProvider(getArrowBitmapByBearing(baseBitmap, bearing)), + contentDescription = "Relative wind direction indicator", + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(ColorProvider(dayColor, nightColor)) + ) + + Text( + overlayText, + 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) + ) + } + } + + } + } } } } \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TailwindAndRideSpeedDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TailwindAndRideSpeedDataType.kt new file mode 100644 index 0000000..2aa7fa0 --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TailwindAndRideSpeedDataType.kt @@ -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) { + 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 { + 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 { + 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() + } + } +} \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 8ddbd28..a5de843 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -4,4 +4,13 @@ #2600B3 #ffffff + #000000 + + #00ff00 + #ff9930 + #FF2424 + + #008000 + #BB4300 + #B30000 \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f180113..a870a54 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2,7 +2,9 @@ Headwind headwind Headwind - Current headwind direction and speed + Current headwind direction and headwind speed + Speed and tailwind + Current rider speed and tailwind Humidity Relative humidity in percent Cloud cover diff --git a/app/src/main/res/xml/extension_info.xml b/app/src/main/res/xml/extension_info.xml index f8cf6a1..c232717 100644 --- a/app/src/main/res/xml/extension_info.xml +++ b/app/src/main/res/xml/extension_info.xml @@ -11,6 +11,13 @@ icon="@drawable/wind" typeId="headwind" /> + +