fix #98: Show temperature, precipitation, temperature forecasts as line graphs
All checks were successful
Build / build (push) Successful in 6m20s

This commit is contained in:
Tim Kluge 2025-05-30 19:51:27 +02:00
parent abf2757d08
commit cc51a485af
6 changed files with 694 additions and 357 deletions

View File

@ -294,41 +294,10 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ
"Distance along route ${positionIndex}: $position"
)
val distanceFromCurrent =
upcomingRoute?.distanceAlongRoute?.let { currentDistanceAlongRoute ->
val distanceFromCurrent = upcomingRoute?.distanceAlongRoute?.let { currentDistanceAlongRoute ->
distanceAlongRoute?.minus(currentDistanceAlongRoute)
}
val isCurrent = baseIndex == 0 && positionIndex == 0
if (isCurrent && data?.current != null) {
val interpretation = WeatherInterpretation.fromWeatherCode(data.current.weatherCode)
val unixTime = data.current.time
val formattedTime =
timeFormatter.format(Instant.ofEpochSecond(unixTime))
val formattedDate =
getShortDateFormatter().format(Instant.ofEpochSecond(unixTime))
val hasNewDate = formattedDate != previousDate || baseIndex == 0
RenderWidget(
arrowBitmap = baseBitmap,
current = interpretation,
windBearing = data.current.windDirection.roundToInt(),
windSpeed = msInUserUnit(data.current.windSpeed, settingsAndProfile.isImperial).roundToInt(),
windGusts = msInUserUnit(data.current.windGusts, settingsAndProfile.isImperial).roundToInt(),
precipitation = millimetersInUserUnit(data.current.precipitation, settingsAndProfile.isImperial),
precipitationProbability = null,
temperature = celciusInUserUnit(data.current.temperature, settingsAndProfile.isImperialTemperature).roundToInt(),
temperatureUnit = if (settingsAndProfile.isImperialTemperature) TemperatureUnit.FAHRENHEIT else TemperatureUnit.CELSIUS,
timeLabel = formattedTime,
dateLabel = if (hasNewDate) formattedDate else null,
distance = null,
isImperial = settingsAndProfile.isImperial,
isNight = data.current.isNight
)
previousDate = formattedDate
} else {
val weatherData = data?.forecasts?.getOrNull(baseIndex)
val interpretation = WeatherInterpretation.fromWeatherCode(weatherData?.weatherCode ?: 0)
val unixTime = data?.forecasts?.getOrNull(baseIndex)?.time ?: 0
@ -357,7 +326,6 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ
}
}
}
}
emitter.updateView(result.remoteViews)
}

View File

@ -0,0 +1,285 @@
package de.timklge.karooheadwind.datatypes
import android.content.Context
import android.util.Log
import androidx.compose.ui.unit.DpSize
import androidx.glance.GlanceModifier
import androidx.glance.Image
import androidx.glance.ImageProvider
import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi
import androidx.glance.appwidget.GlanceRemoteViews
import androidx.glance.layout.Box
import androidx.glance.layout.fillMaxSize
import de.timklge.karooheadwind.HeadingResponse
import de.timklge.karooheadwind.HeadwindSettings
import de.timklge.karooheadwind.HeadwindWidgetSettings
import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.UpcomingRoute
import de.timklge.karooheadwind.WeatherDataProvider
import de.timklge.karooheadwind.getHeadingFlow
import de.timklge.karooheadwind.screens.LineGraphBuilder
import de.timklge.karooheadwind.streamCurrentForecastWeatherData
import de.timklge.karooheadwind.streamDatatypeIsVisible
import de.timklge.karooheadwind.streamSettings
import de.timklge.karooheadwind.streamUpcomingRoute
import de.timklge.karooheadwind.streamUserProfile
import de.timklge.karooheadwind.streamWidgetSettings
import de.timklge.karooheadwind.throttle
import de.timklge.karooheadwind.weatherprovider.WeatherData
import de.timklge.karooheadwind.weatherprovider.WeatherDataForLocation
import de.timklge.karooheadwind.weatherprovider.WeatherDataResponse
import de.timklge.karooheadwind.weatherprovider.WeatherInterpretation
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.extension.DataTypeImpl
import io.hammerhead.karooext.internal.ViewEmitter
import io.hammerhead.karooext.models.ShowCustomStreamState
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.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
import kotlin.math.abs
import kotlin.math.ceil
import kotlin.math.floor
abstract class LineGraphForecastDataType(private val karooSystem: KarooSystemService, typeId: String) : DataTypeImpl("karoo-headwind", typeId) {
@OptIn(ExperimentalGlanceRemoteViewsApi::class)
private val glance = GlanceRemoteViews()
companion object {
val timeFormatter = DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneId.systemDefault())
}
data class StreamData(val data: WeatherDataResponse?, val settings: SettingsAndProfile,
val widgetSettings: HeadwindWidgetSettings? = null,
val headingResponse: HeadingResponse? = null, val upcomingRoute: UpcomingRoute? = null, val isVisible: Boolean)
data class SettingsAndProfile(val settings: HeadwindSettings, val isImperial: Boolean, val isImperialTemperature: Boolean)
data class LineData(val time: Instant? = null, val x: Float? = null, val weatherData: WeatherData)
abstract fun getLineData(lineData: List<LineData>, isImperial: Boolean): Set<LineGraphBuilder.Line>
private fun previewFlow(settingsAndProfileStream: Flow<SettingsAndProfile>): Flow<StreamData> =
flow {
val settingsAndProfile = settingsAndProfileStream.firstOrNull()
while (true) {
val data = (0..<10).map { index ->
val timeAtFullHour = Instant.now().truncatedTo(ChronoUnit.HOURS).epochSecond
val weatherData = (0..<12).map {
val forecastTime = timeAtFullHour + it * 60 * 60
val forecastTemperature = 20.0 + (-20..20).random()
val forecastPrecipitation = 0.0 + (0..10).random()
val forecastPrecipitationProbability = (0..100).random()
val forecastWeatherCode = WeatherInterpretation.getKnownWeatherCodes().random()
val forecastWindSpeed = 0.0 + (0..10).random()
val forecastWindDirection = 0.0 + (0..360).random()
val forecastWindGusts = 0.0 + (0..10).random()
WeatherData(
time = forecastTime,
temperature = forecastTemperature,
relativeHumidity = 20,
precipitation = forecastPrecipitation,
cloudCover = 3.0,
sealevelPressure = 1013.25,
surfacePressure = 1013.25,
precipitationProbability = forecastPrecipitationProbability.toDouble(),
windSpeed = forecastWindSpeed,
windDirection = forecastWindDirection,
windGusts = forecastWindGusts,
weatherCode = forecastWeatherCode,
isForecast = true,
isNight = it < 2
)
}
val distancePerHour =
settingsAndProfile?.settings?.getForecastMetersPerHour(settingsAndProfile.isImperial)
?.toDouble() ?: 0.0
WeatherDataForLocation(
current = WeatherData(
time = timeAtFullHour,
temperature = 20.0,
relativeHumidity = 20,
precipitation = 0.0,
cloudCover = 3.0,
sealevelPressure = 1013.25,
surfacePressure = 1013.25,
windSpeed = 5.0,
windDirection = 180.0,
windGusts = 10.0,
weatherCode = WeatherInterpretation.getKnownWeatherCodes().random(),
isForecast = false,
isNight = false
),
coords = GpsCoordinates(0.0, 0.0, distanceAlongRoute = index * distancePerHour),
timezone = "UTC",
elevation = null,
forecasts = weatherData
)
}
emit(
StreamData(
WeatherDataResponse(provider = WeatherDataProvider.OPEN_METEO, data = data),
SettingsAndProfile(
HeadwindSettings(),
settingsAndProfile?.isImperial == true,
settingsAndProfile?.isImperialTemperature == true
),
isVisible = true
)
)
delay(5_000)
}
}
@OptIn(ExperimentalGlanceRemoteViewsApi::class)
override fun startView(context: Context, config: ViewConfig, emitter: ViewEmitter) {
Log.d(KarooHeadwindExtension.TAG, "Starting weather forecast view with $emitter")
val configJob = CoroutineScope(Dispatchers.IO).launch {
emitter.onNext(UpdateGraphicConfig(showHeader = false))
awaitCancellation()
}
val settingsAndProfileStream = context.streamSettings(karooSystem).combine(karooSystem.streamUserProfile()) { settings, userProfile ->
SettingsAndProfile(settings = settings, isImperial = userProfile.preferredUnit.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL,
isImperialTemperature = userProfile.preferredUnit.temperature == UserProfile.PreferredUnit.UnitType.IMPERIAL)
}
val dataFlow = if (config.preview){
previewFlow(settingsAndProfileStream)
} else {
combine(
context.streamCurrentForecastWeatherData(),
settingsAndProfileStream,
context.streamWidgetSettings(),
karooSystem.getHeadingFlow(context).throttle(3 * 60_000L),
karooSystem.streamUpcomingRoute().distinctUntilChanged { old, new ->
val oldDistance = old?.distanceAlongRoute
val newDistance = new?.distanceAlongRoute
if (oldDistance == null && newDistance == null) return@distinctUntilChanged true
if (oldDistance == null || newDistance == null) return@distinctUntilChanged false
abs(oldDistance - newDistance) < 1_000
},
karooSystem.streamDatatypeIsVisible(dataTypeId)
) { data ->
val weatherData = data[0] as WeatherDataResponse?
val settings = data[1] as SettingsAndProfile
val widgetSettings = data[2] as HeadwindWidgetSettings?
val heading = data[3] as HeadingResponse?
val upcomingRoute = data[4] as UpcomingRoute?
val isVisible = data[5] as Boolean
StreamData(
data = weatherData,
settings = settings,
widgetSettings = widgetSettings,
headingResponse = heading,
upcomingRoute = upcomingRoute,
isVisible = isVisible
)
}
}
val viewJob = CoroutineScope(Dispatchers.IO).launch {
emitter.onNext(ShowCustomStreamState("", null))
dataFlow.filter { it.isVisible }.collect { (allData, settingsAndProfile, widgetSettings, headingResponse, upcomingRoute) ->
Log.d(KarooHeadwindExtension.TAG, "Updating weather forecast view")
if (allData?.data.isNullOrEmpty()){
emitter.updateView(
getErrorWidget(
glance,
context,
settingsAndProfile.settings,
headingResponse
).remoteViews)
return@collect
}
val result = glance.compose(context, DpSize.Unspecified) {
val data = buildList {
for(i in 0..12){
val locationData = allData?.data?.getOrNull(i) ?: allData?.data?.lastOrNull()
val data = locationData?.forecasts?.getOrNull(i)
if (data == null) {
Log.w(KarooHeadwindExtension.TAG, "No weather data available for forecast index $i")
continue
}
val time = Instant.ofEpochSecond(data.time)
add(LineData(
time = time,
x = locationData.coords.distanceAlongRoute?.toFloat(),
weatherData = data,
))
}
}
val pointData = getLineData(data, settingsAndProfile.isImperialTemperature)
val bitmap = LineGraphBuilder(context).drawLineGraph(config.viewSize.first, config.viewSize.second, config.gridSize.first, config.gridSize.second, pointData) { x ->
val addedHours = x / 60.0
val startTime = data.firstOrNull()?.time
val time = startTime?.plus(floor(addedHours * 60).toLong(), ChronoUnit.MINUTES)
val timeLabel = timeFormatter.format(time)
val beforeData = data.getOrNull(floor(x / 60.0).toInt().coerceAtLeast(0))
val afterData = data.getOrNull(ceil(x / 60.0).toInt())
if (beforeData?.x != null || afterData?.x != null) {
val start = (beforeData?.x ?: afterData?.x) ?: 0.0f
val end = (afterData?.x ?: beforeData?.x) ?: 0.0f
val distance = start + (end - start) * ((x / 60) - floor(x / 60))
val distanceLabel = if (settingsAndProfile.isImperial) {
"${(distance * 0.000621371).toInt()}mi"
} else {
"${(distance / 1000).toInt()}km"
}
return@drawLineGraph distanceLabel
} else {
timeLabel
}
}
Box(modifier = GlanceModifier.fillMaxSize()){
Image(ImageProvider(bitmap), "Forecast", modifier = GlanceModifier.fillMaxSize())
}
}
emitter.updateView(result.remoteViews)
}
}
emitter.setCancellable {
Log.d(
KarooHeadwindExtension.TAG,
"Stopping headwind weather forecast view with $emitter"
)
configJob.cancel()
viewJob.cancel()
}
}
}

View File

@ -1,108 +1,27 @@
package de.timklge.karooheadwind.datatypes
import android.graphics.Bitmap
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.TextUnitType
import androidx.compose.ui.unit.dp
import androidx.glance.GlanceModifier
import androidx.glance.color.ColorProvider
import androidx.glance.layout.Alignment
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.fillMaxHeight
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.padding
import androidx.glance.layout.width
import androidx.glance.text.FontFamily
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import de.timklge.karooheadwind.TemperatureUnit
import de.timklge.karooheadwind.weatherprovider.WeatherInterpretation
import de.timklge.karooheadwind.screens.LineGraphBuilder
import io.hammerhead.karooext.KarooSystemService
import kotlin.math.absoluteValue
import kotlin.math.ceil
@Composable
fun PrecipitationForecast(
precipitation: Int,
precipitationProbability: Int?,
distance: Double? = null,
timeLabel: String? = null,
rowAlignment: Alignment.Horizontal = Alignment.Horizontal.CenterHorizontally,
isImperial: Boolean?,
) {
Column(modifier = GlanceModifier.fillMaxHeight().padding(1.dp).width(86.dp), horizontalAlignment = rowAlignment) {
Row(modifier = GlanceModifier.defaultWeight().fillMaxWidth(), horizontalAlignment = rowAlignment, verticalAlignment = Alignment.CenterVertically) {
val precipitationProbabilityText = if (precipitationProbability != null) "${precipitationProbability}% " else ""
val precipitationText = precipitation.toString()
Text(
text = "${precipitationProbabilityText}${precipitationText}",
style = TextStyle(color = ColorProvider(Color.Black, Color.White), fontFamily = FontFamily.Monospace, fontSize = TextUnit(24f, TextUnitType.Sp), textAlign = TextAlign.Center)
)
}
if (distance != null && isImperial != null){
val distanceInUserUnit = (distance / (if(!isImperial) 1000.0 else 1609.34)).toInt()
val label = "${distanceInUserUnit.absoluteValue}${if(!isImperial) "km" else "mi"}"
val text = if(distanceInUserUnit > 0){
"In $label"
class PrecipitationForecastDataType(karooSystem: KarooSystemService) : LineGraphForecastDataType(karooSystem, "precipitationForecast") {
override fun getLineData(lineData: List<LineData>, isImperial: Boolean): Set<LineGraphBuilder.Line> {
val precipitationPoints = lineData.map { data ->
if (isImperial) { // Convert mm to inches
data.weatherData.precipitation * 0.0393701 // Convert mm to inches
} else {
"$label ago"
}
if (distanceInUserUnit != 0){
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = text,
style = TextStyle(
color = ColorProvider(Color.Black, Color.White),
fontFamily = FontFamily.Monospace,
fontSize = TextUnit(18f, TextUnitType.Sp)
)
)
}
data.weatherData.precipitation
}
}
if (timeLabel != null){
Text(
text = timeLabel,
style = TextStyle(color = ColorProvider(Color.Black, Color.White), fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace, fontSize = TextUnit(18f, TextUnitType.Sp)
return setOf(
LineGraphBuilder.Line(
dataPoints = precipitationPoints.mapIndexed { index, value ->
LineGraphBuilder.DataPoint(index.toFloat(), value.toFloat())
},
color = android.graphics.Color.BLUE,
label = if (!isImperial) "mm" else "in",
)
)
}
}
}
class PrecipitationForecastDataType(karooSystem: KarooSystemService) : ForecastDataType(karooSystem, "precipitationForecast") {
@Composable
override fun RenderWidget(
arrowBitmap: Bitmap,
current: WeatherInterpretation,
windBearing: Int,
windSpeed: Int,
windGusts: Int,
precipitation: Double,
precipitationProbability: Int?,
temperature: Int,
temperatureUnit: TemperatureUnit,
timeLabel: String,
dateLabel: String?,
distance: Double?,
isImperial: Boolean,
isNight: Boolean,
) {
PrecipitationForecast(
precipitation = ceil(precipitation).toInt(),
precipitationProbability = precipitationProbability,
distance = distance,
timeLabel = timeLabel,
isImperial = isImperial,
)
}
}

View File

@ -1,106 +1,28 @@
package de.timklge.karooheadwind.datatypes
import android.graphics.Bitmap
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.TextUnitType
import androidx.compose.ui.unit.dp
import androidx.glance.GlanceModifier
import androidx.glance.color.ColorProvider
import androidx.glance.layout.Alignment
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.fillMaxHeight
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.padding
import androidx.glance.layout.width
import androidx.glance.text.FontFamily
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import de.timklge.karooheadwind.TemperatureUnit
import de.timklge.karooheadwind.weatherprovider.WeatherInterpretation
import de.timklge.karooheadwind.screens.LineGraphBuilder
import io.hammerhead.karooext.KarooSystemService
import kotlin.math.absoluteValue
@Composable
fun TemperatureForecast(
temperature: Int,
temperatureUnit: TemperatureUnit,
distance: Double? = null,
timeLabel: String? = null,
rowAlignment: Alignment.Horizontal = Alignment.Horizontal.CenterHorizontally,
isImperial: Boolean?
) {
Column(modifier = GlanceModifier.fillMaxHeight().padding(1.dp).width(86.dp), horizontalAlignment = rowAlignment) {
Row(modifier = GlanceModifier.defaultWeight().fillMaxWidth(), horizontalAlignment = rowAlignment, verticalAlignment = Alignment.CenterVertically) {
Text(
text = "${temperature}${temperatureUnit.unitDisplay}",
style = TextStyle(color = ColorProvider(Color.Black, Color.White), fontFamily = FontFamily.Monospace, fontSize = TextUnit(28f, TextUnitType.Sp), textAlign = TextAlign.Center)
)
}
if (distance != null && isImperial != null){
val distanceInUserUnit = (distance / (if(!isImperial) 1000.0 else 1609.34)).toInt()
val label = "${distanceInUserUnit.absoluteValue}${if(!isImperial) "km" else "mi"}"
val text = if(distanceInUserUnit > 0){
"In $label"
class TemperatureForecastDataType(karooSystem: KarooSystemService) : LineGraphForecastDataType(karooSystem, "temperatureForecast") {
override fun getLineData(lineData: List<LineData>, isImperial: Boolean): Set<LineGraphBuilder.Line> {
val linePoints = lineData.map { data ->
if (isImperial) {
data.weatherData.temperature * 9 / 5 + 32 // Convert Celsius to Fahrenheit
} else {
"$label ago"
}
if (distanceInUserUnit != 0){
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = text,
style = TextStyle(
color = ColorProvider(Color.Black, Color.White),
fontFamily = FontFamily.Monospace,
fontSize = TextUnit(18f, TextUnitType.Sp)
)
)
}
data.weatherData.temperature // Keep Celsius
}
}
if (timeLabel != null){
Text(
text = timeLabel,
style = TextStyle(color = ColorProvider(Color.Black, Color.White), fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace, fontSize = TextUnit(18f, TextUnitType.Sp)
return setOf(
LineGraphBuilder.Line(
dataPoints = linePoints.mapIndexed { index, value ->
LineGraphBuilder.DataPoint(index.toFloat(), value.toFloat())
},
color = android.graphics.Color.RED,
label = if (!isImperial) "°C" else "°F",
)
)
}
}
}
class TemperatureForecastDataType(karooSystem: KarooSystemService) : ForecastDataType(karooSystem, "temperatureForecast") {
@Composable
override fun RenderWidget(
arrowBitmap: Bitmap,
current: WeatherInterpretation,
windBearing: Int,
windSpeed: Int,
windGusts: Int,
precipitation: Double,
precipitationProbability: Int?,
temperature: Int,
temperatureUnit: TemperatureUnit,
timeLabel: String,
dateLabel: String?,
distance: Double?,
isImperial: Boolean,
isNight: Boolean
) {
TemperatureForecast(
temperature = temperature,
temperatureUnit = temperatureUnit,
distance = distance,
timeLabel = timeLabel,
isImperial = isImperial,
)
}
}

View File

@ -1,119 +1,42 @@
package de.timklge.karooheadwind.datatypes
import android.graphics.Bitmap
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.TextUnitType
import androidx.compose.ui.unit.dp
import androidx.glance.ColorFilter
import androidx.glance.GlanceModifier
import androidx.glance.Image
import androidx.glance.ImageProvider
import androidx.glance.color.ColorProvider
import androidx.glance.layout.Alignment
import androidx.glance.layout.Column
import androidx.glance.layout.ContentScale
import androidx.glance.layout.Row
import androidx.glance.layout.fillMaxHeight
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.padding
import androidx.glance.layout.width
import androidx.glance.text.FontFamily
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import de.timklge.karooheadwind.TemperatureUnit
import de.timklge.karooheadwind.weatherprovider.WeatherInterpretation
import de.timklge.karooheadwind.screens.LineGraphBuilder
import io.hammerhead.karooext.KarooSystemService
import kotlin.math.absoluteValue
@Composable
fun WindForecast(
arrowBitmap: Bitmap,
windBearing: Int,
windSpeed: Int,
gustSpeed: Int,
distance: Double? = null,
timeLabel: String? = null,
rowAlignment: Alignment.Horizontal = Alignment.Horizontal.CenterHorizontally,
isImperial: Boolean?
) {
Column(modifier = GlanceModifier.fillMaxHeight().padding(1.dp).width(86.dp), horizontalAlignment = rowAlignment) {
Image(
modifier = GlanceModifier.defaultWeight().fillMaxWidth(),
provider = ImageProvider(getArrowBitmapByBearing(arrowBitmap, windBearing + 180)),
contentDescription = "Current wind direction",
contentScale = ContentScale.Fit,
colorFilter = ColorFilter.tint(ColorProvider(Color.Black, Color.White))
)
Text(
text = "${windSpeed}-${gustSpeed}",
style = TextStyle(color = ColorProvider(Color.Black, Color.White), fontFamily = FontFamily.Monospace, fontSize = TextUnit(18f, TextUnitType.Sp), textAlign = TextAlign.Center)
)
if (distance != null && isImperial != null){
val distanceInUserUnit = (distance / (if(!isImperial) 1000.0 else 1609.34)).toInt()
val label = "${distanceInUserUnit.absoluteValue}${if(!isImperial) "km" else "mi"}"
val text = if(distanceInUserUnit > 0){
"In $label"
} else {
"$label ago"
}
if (distanceInUserUnit != 0){
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = text,
style = TextStyle(
color = ColorProvider(Color.Black, Color.White),
fontFamily = FontFamily.Monospace,
fontSize = TextUnit(18f, TextUnitType.Sp)
)
)
}
class WindForecastDataType(karooSystem: KarooSystemService) : LineGraphForecastDataType(karooSystem, "windForecast") {
override fun getLineData(lineData: List<LineData>, isImperial: Boolean): Set<LineGraphBuilder.Line> {
val windPoints = lineData.map { data ->
if (isImperial) { // Convert m/s to mph
data.weatherData.windSpeed * 2.23694 // Convert m/s to mph
} else { // Convert m/s to km/h
data.weatherData.windSpeed * 3.6 // Convert m/s to km/h
}
}
if (timeLabel != null){
Text(
text = timeLabel,
style = TextStyle(color = ColorProvider(Color.Black, Color.White), fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace, fontSize = TextUnit(18f, TextUnitType.Sp)
val gustPoints = lineData.map { data ->
if (isImperial) { // Convert m/s to mph
data.weatherData.windGusts * 2.23694 // Convert m/s to mph
} else { // Convert m/s to km/h
data.weatherData.windGusts * 3.6 // Convert m/s to km/h
}
}
return setOf(
LineGraphBuilder.Line(
dataPoints = windPoints.mapIndexed { index, value ->
LineGraphBuilder.DataPoint(index.toFloat(), value.toFloat())
},
color = android.graphics.Color.GRAY,
label = "Wind" // if (!isImperial) "Wind km/h" else "Wind mph",
),
LineGraphBuilder.Line(
dataPoints = gustPoints.mapIndexed { index, value ->
LineGraphBuilder.DataPoint(index.toFloat(), value.toFloat())
},
color = android.graphics.Color.DKGRAY,
label = "Gust" // if (!isImperial) "Gust km/h" else "Gust mph",
)
)
}
}
}
class WindForecastDataType(karooSystem: KarooSystemService) : ForecastDataType(karooSystem, "windForecast") {
@Composable
override fun RenderWidget(
arrowBitmap: Bitmap,
current: WeatherInterpretation,
windBearing: Int,
windSpeed: Int,
windGusts: Int,
precipitation: Double,
precipitationProbability: Int?,
temperature: Int,
temperatureUnit: TemperatureUnit,
timeLabel: String,
dateLabel: String?,
distance: Double?,
isImperial: Boolean,
isNight: Boolean
) {
WindForecast(
arrowBitmap = arrowBitmap,
windBearing = windBearing,
windSpeed = windSpeed,
gustSpeed = windGusts,
distance = distance,
timeLabel = timeLabel,
isImperial = isImperial,
)
}
}

View File

@ -0,0 +1,320 @@
package de.timklge.karooheadwind.screens
import android.content.Context
import android.content.res.Configuration
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Paint.Align
import android.graphics.Path
import androidx.annotation.ColorInt
import kotlin.math.abs
class LineGraphBuilder(val context: Context) {
data class DataPoint(val x: Float, val y: Float)
data class AxisLabel(val x: Float, val label: String)
data class Line(
val dataPoints: List<DataPoint>,
@ColorInt val color: Int,
val label: String? = null,
)
private fun isNightMode(): Boolean {
val nightModeFlags = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
return nightModeFlags == Configuration.UI_MODE_NIGHT_YES
}
fun drawLineGraph(width: Int, height: Int, gridWidth: Int, gridHeight: Int, lines: Set<Line>, labelProvider: ((Float) -> String)): Bitmap {
val isNightMode = isNightMode()
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val backgroundColor = if (isNightMode) Color.BLACK else Color.WHITE
val primaryTextColor = if (isNightMode) Color.WHITE else Color.BLACK
val secondaryTextColor = if (isNightMode) Color.LTGRAY else Color.DKGRAY // For axes
canvas.drawColor(backgroundColor)
if (lines.isEmpty() || lines.all { it.dataPoints.isEmpty() }) {
val emptyPaint = Paint().apply {
color = primaryTextColor
textSize = 20f
textAlign = Align.CENTER
isAntiAlias = true
}
canvas.drawText("No data to display", width / 2f, height / 2f, emptyPaint)
return bitmap
}
val marginTop = 10f
val marginBottom = 40f // Increased from 30f
val marginRight = 20f // Increased from 5f
var dataMinX = Float.MAX_VALUE
var dataMaxX = Float.MIN_VALUE
var dataMinY = Float.MAX_VALUE
var dataMaxY = Float.MIN_VALUE
var hasData = false
lines.forEach { line ->
if (line.dataPoints.isNotEmpty()) hasData = true
line.dataPoints.forEach { point ->
dataMinX = minOf(dataMinX, point.x)
dataMaxX = maxOf(dataMaxX, point.x)
dataMinY = minOf(dataMinY, point.y)
dataMaxY = maxOf(dataMaxY, point.y)
}
}
if (!hasData) {
val emptyPaint = Paint().apply {
color = primaryTextColor
textSize = 40f
textAlign = Align.CENTER
isAntiAlias = true
}
canvas.drawText("No data points", width / 2f, height / 2f, emptyPaint)
return bitmap
}
// Dynamically calculate marginLeft based on Y-axis label widths
val yAxisLabelPaint = Paint().apply {
textSize = 28f // Matches textPaint for Y-axis labels
isAntiAlias = true
}
var maxLabelWidth = 0f
val yLabelStrings = mutableListOf<String>()
val numYTicksForCalc = 2 // As used later for drawing Y-axis ticks
// Determine Y-axis label strings (mirrors logic from where labels are drawn)
if (kotlin.math.abs(dataMaxY - dataMinY) < 0.0001f) {
yLabelStrings.add(String.format(java.util.Locale.getDefault(), "%.0f", dataMinY))
} else {
for (i in 0..numYTicksForCalc) {
val value = dataMinY + ((dataMaxY - dataMinY) / numYTicksForCalc) * i
yLabelStrings.add(String.format(java.util.Locale.getDefault(), "%.0f", value))
}
}
for (labelStr in yLabelStrings) {
maxLabelWidth = kotlin.math.max(maxLabelWidth, yAxisLabelPaint.measureText(labelStr))
}
val yAxisTextRightToAxisGap = 15f // Current gap used: graphLeft - 15f
val canvasEdgePadding = 5f // Desired padding from the canvas edge
val dynamicMarginLeft = maxLabelWidth + yAxisTextRightToAxisGap + canvasEdgePadding
val marginLeft = dynamicMarginLeft
val graphWidth = width - marginLeft - marginRight
val graphHeight = height - marginTop - marginBottom
val graphLeft = marginLeft
val graphTop = marginTop
val graphBottom = height - marginBottom
// Legend properties
val legendTextSize = 22f // Increased from 18f
val legendTextColor = primaryTextColor
val legendPadding = 5f
val legendEntryHeight = 30f // Increased from 25f
val legendColorBoxSize = 24f // Increased from 20f
val legendTextMargin = 5f
var effectiveMinX = dataMinX
var effectiveMaxX = dataMaxX
var effectiveMinY = dataMinY
var effectiveMaxY = dataMaxY
if (dataMinX == dataMaxX) {
effectiveMinX -= 1f
effectiveMaxX += 1f
} else {
val paddingX = (dataMaxX - dataMinX) * 0.05f
if (paddingX > 0.0001f) {
effectiveMinX -= paddingX
effectiveMaxX += paddingX
} else {
effectiveMinX -= 1f
effectiveMaxX += 1f
}
}
if (dataMinY == dataMaxY) {
effectiveMinY -= 1f
effectiveMaxY += 1f
} else {
val paddingY = (dataMaxY - dataMinY) * 0.05f
if (paddingY > 0.0001f) {
effectiveMinY -= paddingY
effectiveMaxY += paddingY
} else {
effectiveMinY -= 1f
effectiveMaxY += 1f
}
}
val rangeX = if (abs(effectiveMaxX - effectiveMinX) < 0.0001f) 1f else (effectiveMaxX - effectiveMinX)
val rangeY = if (abs(effectiveMaxY - effectiveMinY) < 0.0001f) 1f else (effectiveMaxY - effectiveMinY)
fun mapX(originalX: Float): Float {
return graphLeft + ((originalX - effectiveMinX) / rangeX) * graphWidth
}
fun mapY(originalY: Float): Float {
return graphBottom - ((originalY - effectiveMinY) / rangeY) * graphHeight
}
val axisPaint = Paint().apply {
color = secondaryTextColor
strokeWidth = 3f
isAntiAlias = true
}
canvas.drawLine(graphLeft, graphBottom, graphLeft + graphWidth, graphBottom, axisPaint)
canvas.drawLine(graphLeft, graphTop, graphLeft, graphBottom, axisPaint)
val linePaint = Paint().apply {
strokeWidth = 6f
style = Paint.Style.STROKE
isAntiAlias = true
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
}
val textPaint = Paint().apply {
color = primaryTextColor
textSize = 28f
isAntiAlias = true
}
for (line in lines) {
if (line.dataPoints.isEmpty()) continue
linePaint.color = line.color
val path = Path()
val firstPoint = line.dataPoints.first()
path.moveTo(mapX(firstPoint.x), mapY(firstPoint.y))
canvas.drawCircle(mapX(firstPoint.x), mapY(firstPoint.y), 8f, linePaint.apply { style = Paint.Style.FILL })
linePaint.style = Paint.Style.STROKE
for (i in 1 until line.dataPoints.size) {
val point = line.dataPoints[i]
path.lineTo(mapX(point.x), mapY(point.y))
canvas.drawCircle(mapX(point.x), mapY(point.y), 8f, linePaint.apply { style = Paint.Style.FILL })
linePaint.style = Paint.Style.STROKE
}
canvas.drawPath(path, linePaint)
}
textPaint.textAlign = Align.RIGHT
val numYTicks = if (gridWidth > 15) 2 else 1
if (abs(dataMaxY - dataMinY) > 0.0001f) {
for (i in 0..numYTicks) {
val value = dataMinY + ((dataMaxY - dataMinY) / numYTicks) * i
val yPos = mapY(value)
if (yPos >= graphTop - 5f && yPos <= graphBottom + 5f) {
canvas.drawLine(graphLeft - 5f, yPos, graphLeft + 5f, yPos, axisPaint)
canvas.drawText(String.format(java.util.Locale.getDefault(), "%.0f", value), graphLeft - 15f, yPos + (textPaint.textSize / 3), textPaint) // Adjusted horizontal offset from -10f
}
}
} else {
val yPos = mapY(dataMinY)
canvas.drawLine(graphLeft - 5f, yPos, graphLeft + 5f, yPos, axisPaint)
canvas.drawText(String.format(java.util.Locale.getDefault(), "%.0f", dataMinY), graphLeft - 15f, yPos + (textPaint.textSize / 3), textPaint) // Adjusted horizontal offset from -10f
}
textPaint.textAlign = Align.CENTER
val numXTicks = if (gridHeight > 15) 3 else 1
if (abs(dataMaxX - dataMinX) > 0.0001f) {
for (i in 0..numXTicks) {
val value = dataMinX + ((dataMaxX - dataMinX) / numXTicks) * i
val xPos = mapX(value)
if (xPos >= graphLeft - 5f && xPos <= graphLeft + graphWidth + 5f) {
canvas.drawLine(xPos, graphBottom - 5f, xPos, graphBottom + 5f, axisPaint)
canvas.drawText(labelProvider(xPos), xPos, graphBottom + 30f, textPaint)
}
}
} else {
val xPos = mapX(dataMinX)
canvas.drawLine(xPos, graphBottom - 5f, xPos, graphBottom + 5f, axisPaint)
canvas.drawText(labelProvider(xPos), xPos, graphBottom + 30f, textPaint)
}
textPaint.textAlign = Align.CENTER
textPaint.color = primaryTextColor // Ensure textPaint color is reset before drawing legend
// Draw Legend
val legendPaint = Paint().apply {
textSize = legendTextSize
color = legendTextColor
isAntiAlias = true
textAlign = Align.LEFT // Important for measuring text width correctly
}
val legendColorPaint = Paint().apply {
style = Paint.Style.FILL
isAntiAlias = true
}
val legendItems = lines.filter { it.label != null }
if (legendItems.isNotEmpty()) {
var maxLegendLabelWidth = 0f
for (item in legendItems) {
maxLegendLabelWidth = kotlin.math.max(maxLegendLabelWidth, legendPaint.measureText(item.label!!))
}
val legendContentActualLeft = (width - marginRight - legendPadding - legendColorBoxSize - legendTextMargin - maxLegendLabelWidth)
val legendContentActualRight = (width - marginRight - legendPadding) // Right edge of the color box
val legendContentActualTop = graphTop + legendPadding // Top edge of the first color box
val legendContentActualBottom = legendContentActualTop + (legendItems.size - 1) * legendEntryHeight + legendColorBoxSize // Bottom edge of the last color box
val legendBgPaint = Paint().apply {
color = if (isNightMode) {
Color.argb(200, 0, 0, 0)
} else {
Color.argb(200, 255, 255, 255)
}
style = Paint.Style.FILL
isAntiAlias = true
}
canvas.drawRoundRect(
legendContentActualLeft,
legendContentActualTop,
legendContentActualRight,
legendContentActualBottom,
5f,
5f,
legendBgPaint
)
}
var currentLegendY = graphTop + legendPadding
for (line in legendItems) { // Use the filtered list, was: lines.filter { it.label != null }
// Draw color box
legendColorPaint.color = line.color
canvas.drawRect(
width - marginRight - legendPadding - legendColorBoxSize, // left
currentLegendY, // top
width - marginRight - legendPadding, // right
currentLegendY + legendColorBoxSize, // bottom
legendColorPaint
)
// Draw label text
canvas.drawText(
line.label!!,
width - marginRight - legendPadding - legendColorBoxSize - legendTextMargin - legendPaint.measureText(line.label), // x: Align text to the left of the color box
currentLegendY + legendColorBoxSize / 2 + legendTextSize / 3, // y: Vertically center text with color box
legendPaint
)
currentLegendY += legendEntryHeight
}
return bitmap
}
}