From cc51a485af2eb6c872982c62d341fab7e659e33b Mon Sep 17 00:00:00 2001 From: Tim Kluge Date: Fri, 30 May 2025 19:51:27 +0200 Subject: [PATCH] fix #98: Show temperature, precipitation, temperature forecasts as line graphs --- .../datatypes/ForecastDataType.kt | 88 ++--- .../datatypes/LineGraphForecastDataType.kt | 285 ++++++++++++++++ .../PrecipitationForecastDataType.kt | 111 +----- .../datatypes/TemperatureForecastDataType.kt | 108 +----- .../datatypes/WindForecastDataType.kt | 139 ++------ .../karooheadwind/screens/LineGraph.kt | 320 ++++++++++++++++++ 6 files changed, 694 insertions(+), 357 deletions(-) create mode 100644 app/src/main/kotlin/de/timklge/karooheadwind/datatypes/LineGraphForecastDataType.kt create mode 100644 app/src/main/kotlin/de/timklge/karooheadwind/screens/LineGraph.kt diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/ForecastDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/ForecastDataType.kt index aac3f32..6ded729 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/ForecastDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/ForecastDataType.kt @@ -294,67 +294,35 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ "Distance along route ${positionIndex}: $position" ) - 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 - 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 = weatherData?.windDirection?.roundToInt() ?: 0, - windSpeed = msInUserUnit(weatherData?.windSpeed ?: 0.0, settingsAndProfile.isImperial).roundToInt(), - windGusts = msInUserUnit(weatherData?.windGusts ?: 0.0, settingsAndProfile.isImperial).roundToInt(), - precipitation = millimetersInUserUnit(weatherData?.precipitation ?: 0.0, settingsAndProfile.isImperial), - precipitationProbability = weatherData?.precipitationProbability?.toInt(), - temperature = celciusInUserUnit(weatherData?.temperature ?: 0.0, settingsAndProfile.isImperialTemperature).roundToInt(), - temperatureUnit = if (settingsAndProfile.isImperialTemperature) TemperatureUnit.FAHRENHEIT else TemperatureUnit.CELSIUS, - timeLabel = formattedTime, - dateLabel = if (hasNewDate) formattedDate else null, - distance = if (settingsAndProfile.settings.showDistanceInForecast) distanceFromCurrent else null, - isImperial = settingsAndProfile.isImperial, - isNight = weatherData?.isNight == true - ) - - previousDate = formattedDate + val distanceFromCurrent = upcomingRoute?.distanceAlongRoute?.let { currentDistanceAlongRoute -> + distanceAlongRoute?.minus(currentDistanceAlongRoute) } + + val weatherData = data?.forecasts?.getOrNull(baseIndex) + val interpretation = WeatherInterpretation.fromWeatherCode(weatherData?.weatherCode ?: 0) + val unixTime = data?.forecasts?.getOrNull(baseIndex)?.time ?: 0 + 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 = weatherData?.windDirection?.roundToInt() ?: 0, + windSpeed = msInUserUnit(weatherData?.windSpeed ?: 0.0, settingsAndProfile.isImperial).roundToInt(), + windGusts = msInUserUnit(weatherData?.windGusts ?: 0.0, settingsAndProfile.isImperial).roundToInt(), + precipitation = millimetersInUserUnit(weatherData?.precipitation ?: 0.0, settingsAndProfile.isImperial), + precipitationProbability = weatherData?.precipitationProbability?.toInt(), + temperature = celciusInUserUnit(weatherData?.temperature ?: 0.0, settingsAndProfile.isImperialTemperature).roundToInt(), + temperatureUnit = if (settingsAndProfile.isImperialTemperature) TemperatureUnit.FAHRENHEIT else TemperatureUnit.CELSIUS, + timeLabel = formattedTime, + dateLabel = if (hasNewDate) formattedDate else null, + distance = if (settingsAndProfile.settings.showDistanceInForecast) distanceFromCurrent else null, + isImperial = settingsAndProfile.isImperial, + isNight = weatherData?.isNight == true + ) + + previousDate = formattedDate } } } diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/LineGraphForecastDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/LineGraphForecastDataType.kt new file mode 100644 index 0000000..7368f73 --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/LineGraphForecastDataType.kt @@ -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, isImperial: Boolean): Set + + private fun previewFlow(settingsAndProfileStream: Flow): Flow = + 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() + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/PrecipitationForecastDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/PrecipitationForecastDataType.kt index 85d57e0..8d289ae 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/PrecipitationForecastDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/PrecipitationForecastDataType.kt @@ -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, isImperial: Boolean): Set { + 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, ) } + } \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TemperatureForecastDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TemperatureForecastDataType.kt index 31bffea..f4333e4 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TemperatureForecastDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TemperatureForecastDataType.kt @@ -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, isImperial: Boolean): Set { + 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, ) } + } diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindForecastDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindForecastDataType.kt index 36ce287..a923623 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindForecastDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindForecastDataType.kt @@ -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, isImperial: Boolean): Set { + 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, ) } + } \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/screens/LineGraph.kt b/app/src/main/kotlin/de/timklge/karooheadwind/screens/LineGraph.kt new file mode 100644 index 0000000..f0e0e88 --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/screens/LineGraph.kt @@ -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, + @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, 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() + 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 + } +}