From aaeb1204bf8a9756468f83191fb67ead8c84179b Mon Sep 17 00:00:00 2001 From: timklge <2026103+timklge@users.noreply.github.com> Date: Wed, 5 Mar 2025 00:00:14 +0100 Subject: [PATCH] fix #49: Add individual forecast fields (#59) --- app/build.gradle.kts | 4 +- app/manifest.json | 6 +- .../timklge/karooheadwind/HeadwindSettings.kt | 1 + .../karooheadwind/KarooHeadwindExtension.kt | 14 +- .../datatypes/ForecastDataType.kt | 359 ++++++++++++++++++ .../datatypes/GraphicalForecast.kt | 110 ++++++ .../PrecipitationForecastDataType.kt | 107 ++++++ .../datatypes/TemperatureForecastDataType.kt | 105 +++++ .../datatypes/WeatherForecastDataType.kt | 287 ++------------ .../karooheadwind/datatypes/WeatherView.kt | 4 +- .../datatypes/WindForecastDataType.kt | 120 ++++++ .../karooheadwind/screens/SettingsScreen.kt | 15 +- .../karooheadwind/screens/WeatherScreen.kt | 3 +- app/src/main/res/values/strings.xml | 8 + app/src/main/res/xml/extension_info.xml | 28 ++ 15 files changed, 906 insertions(+), 265 deletions(-) create mode 100644 app/src/main/kotlin/de/timklge/karooheadwind/datatypes/ForecastDataType.kt create mode 100644 app/src/main/kotlin/de/timklge/karooheadwind/datatypes/GraphicalForecast.kt create mode 100644 app/src/main/kotlin/de/timklge/karooheadwind/datatypes/PrecipitationForecastDataType.kt create mode 100644 app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TemperatureForecastDataType.kt create mode 100644 app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindForecastDataType.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cbdf96b..e11c884 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -15,8 +15,8 @@ android { applicationId = "de.timklge.karooheadwind" minSdk = 26 targetSdk = 35 - versionCode = 14 - versionName = "1.3" + versionCode = 15 + versionName = "1.3.1" } signingConfigs { diff --git a/app/manifest.json b/app/manifest.json index be27c83..2362ab8 100644 --- a/app/manifest.json +++ b/app/manifest.json @@ -3,9 +3,9 @@ "packageName": "de.timklge.karooheadwind", "iconUrl": "https://github.com/timklge/karoo-headwind/releases/latest/download/karoo-headwind.png", "latestApkUrl": "https://github.com/timklge/karoo-headwind/releases/latest/download/app-release.apk", - "latestVersion": "1.3", - "latestVersionCode": 14, + "latestVersion": "1.3.1", + "latestVersionCode": 15, "developer": "timklge", "description": "Provides headwind direction, wind speed and other weather data fields", - "releaseNotes": "* Forecast weather along route in fixed intervals if route is loaded\n* Show current weather in app menu" + "releaseNotes": "* Forecast weather along route in fixed intervals if route is loaded\n* Show current weather in app menu\n*Add individual forecast fields" } \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/HeadwindSettings.kt b/app/src/main/kotlin/de/timklge/karooheadwind/HeadwindSettings.kt index fbf826f..c37f9eb 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/HeadwindSettings.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/HeadwindSettings.kt @@ -69,6 +69,7 @@ data class HeadwindSettings( val forecastedKmPerHour: Int = 20, val forecastedMilesPerHour: Int = 12, val lastUpdateRequested: Long? = null, + val showDistanceInForecast: Boolean = true, ){ companion object { val defaultSettings = Json.encodeToString(HeadwindSettings()) diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt b/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt index 7f8b202..64dbe8d 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt @@ -7,18 +7,22 @@ import com.mapbox.turf.TurfConstants import com.mapbox.turf.TurfMeasurement import de.timklge.karooheadwind.datatypes.CloudCoverDataType import de.timklge.karooheadwind.datatypes.GpsCoordinates +import de.timklge.karooheadwind.datatypes.GraphicalForecastDataType import de.timklge.karooheadwind.datatypes.HeadwindDirectionDataType import de.timklge.karooheadwind.datatypes.HeadwindSpeedDataType import de.timklge.karooheadwind.datatypes.PrecipitationDataType +import de.timklge.karooheadwind.datatypes.PrecipitationForecastDataType import de.timklge.karooheadwind.datatypes.RelativeHumidityDataType import de.timklge.karooheadwind.datatypes.SealevelPressureDataType import de.timklge.karooheadwind.datatypes.SurfacePressureDataType import de.timklge.karooheadwind.datatypes.TailwindAndRideSpeedDataType import de.timklge.karooheadwind.datatypes.TemperatureDataType +import de.timklge.karooheadwind.datatypes.TemperatureForecastDataType import de.timklge.karooheadwind.datatypes.UserWindSpeedDataType import de.timklge.karooheadwind.datatypes.WeatherDataType import de.timklge.karooheadwind.datatypes.WeatherForecastDataType import de.timklge.karooheadwind.datatypes.WindDirectionDataType +import de.timklge.karooheadwind.datatypes.WindForecastDataType import de.timklge.karooheadwind.datatypes.WindGustsDataType import de.timklge.karooheadwind.datatypes.WindSpeedDataType import io.hammerhead.karooext.KarooSystemService @@ -50,7 +54,7 @@ import kotlin.math.roundToInt import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes -class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.3") { +class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.3.1") { companion object { const val TAG = "karoo-headwind" } @@ -66,7 +70,7 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.3") { TailwindAndRideSpeedDataType(karooSystem, applicationContext), HeadwindSpeedDataType(karooSystem, applicationContext), WeatherDataType(karooSystem, applicationContext), - WeatherForecastDataType(karooSystem, applicationContext), + WeatherForecastDataType(karooSystem), HeadwindSpeedDataType(karooSystem, applicationContext), RelativeHumidityDataType(applicationContext), CloudCoverDataType(applicationContext), @@ -77,7 +81,11 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.3") { PrecipitationDataType(applicationContext), SurfacePressureDataType(applicationContext), SealevelPressureDataType(applicationContext), - UserWindSpeedDataType(karooSystem, applicationContext) + UserWindSpeedDataType(karooSystem, applicationContext), + TemperatureForecastDataType(karooSystem), + PrecipitationForecastDataType(karooSystem), + WindForecastDataType(karooSystem), + GraphicalForecastDataType(karooSystem) ) } diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/ForecastDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/ForecastDataType.kt new file mode 100644 index 0000000..3f0901e --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/ForecastDataType.kt @@ -0,0 +1,359 @@ +package de.timklge.karooheadwind.datatypes + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.util.Log +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceModifier +import androidx.glance.action.clickable +import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi +import androidx.glance.appwidget.GlanceRemoteViews +import androidx.glance.appwidget.action.actionRunCallback +import androidx.glance.background +import androidx.glance.color.ColorProvider +import androidx.glance.layout.Alignment +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxHeight +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.width +import de.timklge.karooheadwind.HeadingResponse +import de.timklge.karooheadwind.HeadwindSettings +import de.timklge.karooheadwind.HeadwindWidgetSettings +import de.timklge.karooheadwind.KarooHeadwindExtension +import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse +import de.timklge.karooheadwind.OpenMeteoData +import de.timklge.karooheadwind.OpenMeteoForecastData +import de.timklge.karooheadwind.R +import de.timklge.karooheadwind.TemperatureUnit +import de.timklge.karooheadwind.UpcomingRoute +import de.timklge.karooheadwind.WeatherDataResponse +import de.timklge.karooheadwind.WeatherInterpretation +import de.timklge.karooheadwind.getHeadingFlow +import de.timklge.karooheadwind.streamCurrentWeatherData +import de.timklge.karooheadwind.streamSettings +import de.timklge.karooheadwind.streamUpcomingRoute +import de.timklge.karooheadwind.streamUserProfile +import de.timklge.karooheadwind.streamWidgetSettings +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.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.roundToInt + +abstract class ForecastDataType(private val karooSystem: KarooSystemService, typeId: String) : DataTypeImpl("karoo-headwind", typeId) { + @Composable + abstract 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) + + @OptIn(ExperimentalGlanceRemoteViewsApi::class) + private val glance = GlanceRemoteViews() + + companion object { + val timeFormatter = DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneId.systemDefault()) + } + + data class StreamData(val data: List?, val settings: SettingsAndProfile, + val widgetSettings: HeadwindWidgetSettings? = null, val profile: UserProfile? = null, + val headingResponse: HeadingResponse? = null, val upcomingRoute: UpcomingRoute? = null) + + data class SettingsAndProfile(val settings: HeadwindSettings, val isImperial: Boolean) + + 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 forecastTimes = (0..<12).map { timeAtFullHour + it * 60 * 60 } + val forecastTemperatures = (0..<12).map { 20.0 + (-20..20).random() } + val forecastPrecipitationPropability = (0..<12).map { (0..100).random() } + val forecastPrecipitation = (0..<12).map { 0.0 + (0..10).random() } + val forecastWeatherCodes = + (0..<12).map { WeatherInterpretation.getKnownWeatherCodes().random() } + val forecastWindSpeed = (0..<12).map { 0.0 + (0..10).random() } + val forecastWindDirection = (0..<12).map { 0.0 + (0..360).random() } + val forecastWindGusts = (0..<12).map { 0.0 + (0..10).random() } + val weatherData = OpenMeteoCurrentWeatherResponse( + OpenMeteoData( + Instant.now().epochSecond, + 0, + 20.0, + 50, + 3.0, + 0, + 1013.25, + 980.0, + 15.0, + 30.0, + 30.0, + WeatherInterpretation.getKnownWeatherCodes().random() + ), + 0.0, 0.0, "Europe/Berlin", 30.0, 0, + + OpenMeteoForecastData( + forecastTimes, + forecastTemperatures, + forecastPrecipitationPropability, + forecastPrecipitation, + forecastWeatherCodes, + forecastWindSpeed, + forecastWindDirection, + forecastWindGusts + ) + ) + + val distancePerHour = + settingsAndProfile?.settings?.getForecastMetersPerHour(settingsAndProfile.isImperial) + ?.toDouble() ?: 0.0 + val gpsCoords = + GpsCoordinates(0.0, 0.0, distanceAlongRoute = index * distancePerHour) + + WeatherDataResponse(weatherData, gpsCoords) + } + + + emit( + StreamData( + data, + SettingsAndProfile( + HeadwindSettings(), + settingsAndProfile?.isImperial == 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 baseBitmap = BitmapFactory.decodeResource( + context.resources, + R.drawable.arrow_0 + ) + + val settingsAndProfileStream = context.streamSettings(karooSystem).combine(karooSystem.streamUserProfile()) { settings, userProfile -> + SettingsAndProfile(settings = settings, isImperial = userProfile.preferredUnit.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL) + } + + val dataFlow = if (config.preview){ + previewFlow(settingsAndProfileStream) + } else { + combine( + context.streamCurrentWeatherData(), + settingsAndProfileStream, + context.streamWidgetSettings(), + karooSystem.getHeadingFlow(context), + karooSystem.streamUpcomingRoute() + ) { weatherData, settings, widgetSettings, heading, upcomingRoute -> + StreamData( + data = weatherData, + settings = settings, + widgetSettings = widgetSettings, + headingResponse = heading, + upcomingRoute = upcomingRoute + ) + } + } + + val viewJob = CoroutineScope(Dispatchers.IO).launch { + emitter.onNext(ShowCustomStreamState("", null)) + + dataFlow.collect { (allData, settingsAndProfile, widgetSettings, userProfile, headingResponse, upcomingRoute) -> + Log.d(KarooHeadwindExtension.TAG, "Updating weather forecast view") + + if (allData == null){ + emitter.updateView( + getErrorWidget( + glance, + context, + settingsAndProfile.settings, + headingResponse + ).remoteViews) + + return@collect + } + + val result = glance.compose(context, DpSize.Unspecified) { + var modifier = GlanceModifier.fillMaxSize() + + if (!config.preview) modifier = modifier.clickable(onClick = actionRunCallback()) + + Row(modifier = modifier, horizontalAlignment = Alignment.Horizontal.Start) { + val hourOffset = widgetSettings?.currentForecastHourOffset ?: 0 + val positionOffset = if (allData.size == 1) 0 else hourOffset + + var previousDate: String? = let { + val unixTime = + allData.getOrNull(positionOffset)?.data?.forecastData?.time?.getOrNull( + hourOffset + ) + val formattedDate = unixTime?.let { + getShortDateFormatter().format( + Instant.ofEpochSecond(unixTime) + ) + } + + formattedDate + } + + for (baseIndex in hourOffset..hourOffset + 2) { + val positionIndex = if (allData.size == 1) 0 else baseIndex + + if (allData.getOrNull(positionIndex) == null) break + if (baseIndex >= (allData.getOrNull(positionOffset)?.data?.forecastData?.weatherCode?.size + ?: 0) + ) { + break + } + + val data = allData.getOrNull(positionIndex)?.data + val distanceAlongRoute = + allData.getOrNull(positionIndex)?.requestedPosition?.distanceAlongRoute + val position = + allData.getOrNull(positionIndex)?.requestedPosition?.let { + "${ + (it.distanceAlongRoute?.div(1000.0))?.toInt() + } at ${it.lat}, ${it.lon}" + } + + if (baseIndex > hourOffset) { + Spacer( + modifier = GlanceModifier.fillMaxHeight().background( + ColorProvider(Color.Black, Color.White) + ).width(1.dp) + ) + } + + Log.d( + KarooHeadwindExtension.TAG, + "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 = data.current.windSpeed.roundToInt(), + windGusts = data.current.windGusts.roundToInt(), + precipitation = data.current.precipitation, + precipitationProbability = null, + temperature = data.current.temperature.roundToInt(), + temperatureUnit = if (userProfile?.preferredUnit?.temperature != UserProfile.PreferredUnit.UnitType.IMPERIAL) TemperatureUnit.CELSIUS else TemperatureUnit.FAHRENHEIT, + timeLabel = formattedTime, + dateLabel = if (hasNewDate) formattedDate else null, + distance = null, + isImperial = userProfile?.preferredUnit?.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL + ) + + previousDate = formattedDate + } else { + val interpretation = WeatherInterpretation.fromWeatherCode( + data?.forecastData?.weatherCode?.get(baseIndex) ?: 0 + ) + val unixTime = data?.forecastData?.time?.get(baseIndex) ?: 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 = data?.forecastData?.windDirection?.get(baseIndex) + ?.roundToInt() ?: 0, + windSpeed = data?.forecastData?.windSpeed?.get(baseIndex) + ?.roundToInt() ?: 0, + windGusts = data?.forecastData?.windGusts?.get(baseIndex) + ?.roundToInt() ?: 0, + precipitation = data?.forecastData?.precipitation?.get(baseIndex) + ?: 0.0, + precipitationProbability = data?.forecastData?.precipitationProbability?.get( + baseIndex + ) ?: 0, + temperature = data?.forecastData?.temperature?.get(baseIndex) + ?.roundToInt() ?: 0, + temperatureUnit = if (userProfile?.preferredUnit?.temperature != UserProfile.PreferredUnit.UnitType.IMPERIAL) TemperatureUnit.CELSIUS else TemperatureUnit.FAHRENHEIT, + timeLabel = formattedTime, + dateLabel = if (hasNewDate) formattedDate else null, + distance = if (settingsAndProfile.settings.showDistanceInForecast) distanceFromCurrent else null, + isImperial = settingsAndProfile.isImperial + ) + + previousDate = formattedDate + } + } + } + } + + 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/GraphicalForecast.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/GraphicalForecast.kt new file mode 100644 index 0000000..9755ae7 --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/GraphicalForecast.kt @@ -0,0 +1,110 @@ +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.layout.wrapContentWidth +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.WeatherInterpretation +import io.hammerhead.karooext.KarooSystemService +import kotlin.math.absoluteValue + +@Composable +fun GraphicalForecast( + current: WeatherInterpretation, + 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().wrapContentWidth(), horizontalAlignment = rowAlignment, verticalAlignment = Alignment.CenterVertically) { + Image( + modifier = GlanceModifier.defaultWeight().wrapContentWidth().padding(1.dp), + provider = ImageProvider(getWeatherIcon(current)), + contentDescription = "Current weather information", + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(ColorProvider(Color.Black, Color.White)) + ) + } + + 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) + ) + ) + } + } + } + + 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) + ) + ) + } + } +} + +class GraphicalForecastDataType(karooSystem: KarooSystemService) : ForecastDataType(karooSystem, "graphicalForecast") { + @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 + ) { + GraphicalForecast( + current = current, + distance = distance, + timeLabel = timeLabel, + isImperial = isImperial + ) + } +} \ 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 new file mode 100644 index 0000000..9ea3f5b --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/PrecipitationForecastDataType.kt @@ -0,0 +1,107 @@ +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.WeatherInterpretation +import io.hammerhead.karooext.KarooSystemService +import kotlin.math.absoluteValue +import kotlin.math.roundToInt + +@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 = if (precipitation > 0) precipitation.toString() else "" + 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" + } 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) + ) + ) + } + } + } + + 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) + ) + ) + } + } +} + +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 + ) { + PrecipitationForecast( + precipitation = precipitation.roundToInt().coerceAtLeast(0), + 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 new file mode 100644 index 0000000..bf85e7f --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TemperatureForecastDataType.kt @@ -0,0 +1,105 @@ +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.WeatherInterpretation +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" + } 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) + ) + ) + } + } + } + + 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) + ) + ) + } + } +} + +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 + ) { + TemperatureForecast( + temperature = temperature, + temperatureUnit = temperatureUnit, + distance = distance, + timeLabel = timeLabel, + isImperial = isImperial + ) + } +} + diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherForecastDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherForecastDataType.kt index 918aee7..77dbd81 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherForecastDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherForecastDataType.kt @@ -1,262 +1,43 @@ 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.unit.DpSize -import androidx.compose.ui.unit.dp -import androidx.glance.GlanceModifier -import androidx.glance.action.clickable -import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi -import androidx.glance.appwidget.GlanceRemoteViews -import androidx.glance.appwidget.action.actionRunCallback -import androidx.glance.background -import androidx.glance.color.ColorProvider -import androidx.glance.layout.Alignment -import androidx.glance.layout.Row -import androidx.glance.layout.Spacer -import androidx.glance.layout.fillMaxHeight -import androidx.glance.layout.fillMaxSize -import androidx.glance.layout.width -import de.timklge.karooheadwind.HeadingResponse -import de.timklge.karooheadwind.HeadwindSettings -import de.timklge.karooheadwind.HeadwindWidgetSettings -import de.timklge.karooheadwind.KarooHeadwindExtension -import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse -import de.timklge.karooheadwind.OpenMeteoData -import de.timklge.karooheadwind.OpenMeteoForecastData +import android.graphics.Bitmap +import androidx.compose.runtime.Composable import de.timklge.karooheadwind.TemperatureUnit -import de.timklge.karooheadwind.UpcomingRoute -import de.timklge.karooheadwind.WeatherDataResponse import de.timklge.karooheadwind.WeatherInterpretation -import de.timklge.karooheadwind.getHeadingFlow -import de.timklge.karooheadwind.streamCurrentWeatherData -import de.timklge.karooheadwind.streamSettings -import de.timklge.karooheadwind.streamUpcomingRoute -import de.timklge.karooheadwind.streamUserProfile -import de.timklge.karooheadwind.streamWidgetSettings 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.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.format.FormatStyle -import java.time.temporal.ChronoUnit -import kotlin.math.roundToInt -@OptIn(ExperimentalGlanceRemoteViewsApi::class) -class WeatherForecastDataType( - private val karooSystem: KarooSystemService, - private val applicationContext: Context -) : DataTypeImpl("karoo-headwind", "weatherForecast") { - private val glance = GlanceRemoteViews() - - companion object { - val timeFormatter = DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneId.systemDefault()) - } - - data class StreamData(val data: List?, val settings: SettingsAndProfile, - val widgetSettings: HeadwindWidgetSettings? = null, val profile: UserProfile? = null, - val headingResponse: HeadingResponse? = null, val upcomingRoute: UpcomingRoute? = null) - - data class SettingsAndProfile(val settings: HeadwindSettings, val isImperial: Boolean) - - 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 forecastTimes = (0..<12).map { timeAtFullHour + it * 60 * 60 } - val forecastTemperatures = (0..<12).map { 20.0 + (-20..20).random() } - val forecastPrecipitationPropability = (0..<12).map { (0..100).random() } - val forecastPrecipitation = (0..<12).map { 0.0 + (0..10).random() } - val forecastWeatherCodes = (0..<12).map { WeatherInterpretation.getKnownWeatherCodes().random() } - val forecastWindSpeed = (0..<12).map { 0.0 + (0..10).random() } - val forecastWindDirection = (0..<12).map { 0.0 + (0..360).random() } - val forecastWindGusts = (0..<12).map { 0.0 + (0..10).random() } - val weatherData = OpenMeteoCurrentWeatherResponse( - OpenMeteoData(Instant.now().epochSecond, 0, 20.0, 50, 3.0, 0, 1013.25, 980.0, 15.0, 30.0, 30.0, WeatherInterpretation.getKnownWeatherCodes().random()), - 0.0, 0.0, "Europe/Berlin", 30.0, 0, - - OpenMeteoForecastData(forecastTimes, forecastTemperatures, forecastPrecipitationPropability, - forecastPrecipitation, forecastWeatherCodes, forecastWindSpeed, forecastWindDirection, - forecastWindGusts) - ) - - val distancePerHour = settingsAndProfile?.settings?.getForecastMetersPerHour(settingsAndProfile.isImperial)?.toDouble() ?: 0.0 - val gpsCoords = GpsCoordinates(0.0, 0.0, distanceAlongRoute = index * distancePerHour) - - WeatherDataResponse(weatherData, gpsCoords) - } - - - emit( - StreamData(data, SettingsAndProfile(HeadwindSettings(), settingsAndProfile?.isImperial == true)) - ) - - delay(5_000) - } - } - - 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 baseBitmap = BitmapFactory.decodeResource( - context.resources, - de.timklge.karooheadwind.R.drawable.arrow_0 +class WeatherForecastDataType(karooSystem: KarooSystemService) : ForecastDataType(karooSystem, "weatherForecast") { + @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 + ) { + Weather( + arrowBitmap = arrowBitmap, + current = current, + windBearing = windBearing, + windSpeed = windSpeed, + windGusts = windGusts, + precipitation = precipitation, + precipitationProbability = precipitationProbability, + temperature = temperature, + temperatureUnit = temperatureUnit, + timeLabel = timeLabel, + dateLabel = dateLabel, + distance = distance, + isImperial = isImperial ) - - val settingsAndProfileStream = context.streamSettings(karooSystem).combine(karooSystem.streamUserProfile()) { settings, userProfile -> - SettingsAndProfile(settings = settings, isImperial = userProfile.preferredUnit.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL) - } - - val dataFlow = if (config.preview){ - previewFlow(settingsAndProfileStream) - } else { - combine(context.streamCurrentWeatherData(), - settingsAndProfileStream, - context.streamWidgetSettings(), - karooSystem.getHeadingFlow(context), - karooSystem.streamUpcomingRoute()) { weatherData, settings, widgetSettings, heading, upcomingRoute -> - StreamData(data = weatherData, settings = settings, widgetSettings = widgetSettings, headingResponse = heading, upcomingRoute = upcomingRoute) - } - } - - val viewJob = CoroutineScope(Dispatchers.IO).launch { - emitter.onNext(ShowCustomStreamState("", null)) - - dataFlow.collect { (allData, settingsAndProfile, widgetSettings, userProfile, headingResponse, upcomingRoute) -> - Log.d(KarooHeadwindExtension.TAG, "Updating weather forecast view") - - if (allData == null){ - emitter.updateView(getErrorWidget(glance, context, settingsAndProfile.settings, headingResponse).remoteViews) - - return@collect - } - - val result = glance.compose(context, DpSize.Unspecified) { - var modifier = GlanceModifier.fillMaxSize() - - if (!config.preview) modifier = modifier.clickable(onClick = actionRunCallback()) - - Row(modifier = modifier, horizontalAlignment = Alignment.Horizontal.Start) { - val hourOffset = widgetSettings?.currentForecastHourOffset ?: 0 - val positionOffset = if (allData.size == 1) 0 else hourOffset - - var previousDate: String? = let { - val unixTime = allData.getOrNull(positionOffset)?.data?.forecastData?.time?.getOrNull(hourOffset) - val formattedDate = unixTime?.let { getShortDateFormatter().format(Instant.ofEpochSecond(unixTime)) } - - formattedDate - } - - for (baseIndex in hourOffset..hourOffset + 2){ - val positionIndex = if (allData.size == 1) 0 else baseIndex - - if (allData.getOrNull(positionIndex) == null) break - if (baseIndex >= (allData.getOrNull(positionOffset)?.data?.forecastData?.weatherCode?.size ?: 0)) { - break - } - - val data = allData.getOrNull(positionIndex)?.data - val distanceAlongRoute = allData.getOrNull(positionIndex)?.requestedPosition?.distanceAlongRoute - val position = allData.getOrNull(positionIndex)?.requestedPosition?.let { "${(it.distanceAlongRoute?.div(1000.0))?.toInt()} at ${it.lat}, ${it.lon}" } - - if (baseIndex > hourOffset) { - Spacer( - modifier = GlanceModifier.fillMaxHeight().background( - ColorProvider(Color.Black, Color.White) - ).width(1.dp) - ) - } - - Log.d(KarooHeadwindExtension.TAG, "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 - - Weather( - baseBitmap, - current = interpretation, - windBearing = data.current.windDirection.roundToInt(), - windSpeed = data.current.windSpeed.roundToInt(), - windGusts = data.current.windGusts.roundToInt(), - precipitation = data.current.precipitation, - precipitationProbability = null, - temperature = data.current.temperature.roundToInt(), - temperatureUnit = if (userProfile?.preferredUnit?.temperature != UserProfile.PreferredUnit.UnitType.IMPERIAL) TemperatureUnit.CELSIUS else TemperatureUnit.FAHRENHEIT, - timeLabel = formattedTime, - dateLabel = if (hasNewDate) formattedDate else null, - isImperial = userProfile?.preferredUnit?.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL - ) - - previousDate = formattedDate - } else { - val interpretation = WeatherInterpretation.fromWeatherCode(data?.forecastData?.weatherCode?.get(baseIndex) ?: 0) - val unixTime = data?.forecastData?.time?.get(baseIndex) ?: 0 - val formattedTime = timeFormatter.format(Instant.ofEpochSecond(unixTime)) - val formattedDate = getShortDateFormatter().format(Instant.ofEpochSecond(unixTime)) - val hasNewDate = formattedDate != previousDate || baseIndex == 0 - - Weather( - baseBitmap, - current = interpretation, - windBearing = data?.forecastData?.windDirection?.get(baseIndex)?.roundToInt() ?: 0, - windSpeed = data?.forecastData?.windSpeed?.get(baseIndex)?.roundToInt() ?: 0, - windGusts = data?.forecastData?.windGusts?.get(baseIndex)?.roundToInt() ?: 0, - precipitation = data?.forecastData?.precipitation?.get(baseIndex) ?: 0.0, - precipitationProbability = data?.forecastData?.precipitationProbability?.get(baseIndex) ?: 0, - temperature = data?.forecastData?.temperature?.get(baseIndex)?.roundToInt() ?: 0, - temperatureUnit = if (userProfile?.preferredUnit?.temperature != UserProfile.PreferredUnit.UnitType.IMPERIAL) TemperatureUnit.CELSIUS else TemperatureUnit.FAHRENHEIT, - timeLabel = formattedTime, - dateLabel = if (hasNewDate) formattedDate else null, - distance = distanceFromCurrent, - isImperial = settingsAndProfile.isImperial - ) - - previousDate = formattedDate - } - } - } - } - - 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/WeatherView.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherView.kt index 5e6226e..1d9abdb 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherView.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherView.kt @@ -60,7 +60,7 @@ fun getWeatherIcon(interpretation: WeatherInterpretation): Int { @OptIn(ExperimentalGlancePreviewApi::class) @Composable fun Weather( - baseBitmap: Bitmap, + arrowBitmap: Bitmap, current: WeatherInterpretation, windBearing: Int, windSpeed: Int, @@ -172,7 +172,7 @@ fun Weather( Image( modifier = if (singleDisplay) GlanceModifier.height(20.dp).width(16.dp) else GlanceModifier.height(16.dp).width(12.dp).padding(1.dp), - provider = ImageProvider(getArrowBitmapByBearing(baseBitmap, windBearing + 180)), + provider = ImageProvider(getArrowBitmapByBearing(arrowBitmap, windBearing + 180)), contentDescription = "Current wind direction", contentScale = ContentScale.Fit, colorFilter = ColorFilter.tint(ColorProvider(Color.Black, Color.White)) diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindForecastDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindForecastDataType.kt new file mode 100644 index 0000000..55711ef --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindForecastDataType.kt @@ -0,0 +1,120 @@ +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.height +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.WeatherInterpretation +import io.hammerhead.karooext.KarooSystemService +import kotlin.math.absoluteValue +import kotlin.math.roundToInt + +@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) + ) + ) + } + } + } + + 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) + ) + ) + } + } +} + +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 + ) { + 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/SettingsScreen.kt b/app/src/main/kotlin/de/timklge/karooheadwind/screens/SettingsScreen.kt index 9371da0..d7ab37d 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/screens/SettingsScreen.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/screens/SettingsScreen.kt @@ -4,13 +4,16 @@ import android.util.Log import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -21,6 +24,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.KeyboardType @@ -62,6 +66,7 @@ fun SettingsScreen(onFinish: () -> Unit) { var selectedRoundLocationSetting by remember { mutableStateOf(RoundLocationSetting.KM_3) } var forecastKmPerHour by remember { mutableStateOf("20") } var forecastMilesPerHour by remember { mutableStateOf("12") } + var showDistanceInForecast by remember { mutableStateOf(true) } val profile by karooSystem.streamUserProfile().collectAsStateWithLifecycle(null) @@ -73,6 +78,7 @@ fun SettingsScreen(onFinish: () -> Unit) { selectedRoundLocationSetting = settings.roundLocationTo forecastKmPerHour = settings.forecastedKmPerHour.toString() forecastMilesPerHour = settings.forecastedMilesPerHour.toString() + showDistanceInForecast = settings.showDistanceInForecast } } @@ -98,7 +104,8 @@ fun SettingsScreen(onFinish: () -> Unit) { windDirectionIndicatorTextSetting = selectedWindDirectionIndicatorTextSetting, roundLocationTo = selectedRoundLocationSetting, forecastedMilesPerHour = forecastMilesPerHour.toIntOrNull()?.coerceIn(3, 30) ?: 12, - forecastedKmPerHour = forecastKmPerHour.toIntOrNull()?.coerceIn(5, 50) ?: 20 + forecastedKmPerHour = forecastKmPerHour.toIntOrNull()?.coerceIn(5, 50) ?: 20, + showDistanceInForecast = showDistanceInForecast ) saveSettings(ctx, newSettings) @@ -205,6 +212,12 @@ fun SettingsScreen(onFinish: () -> Unit) { ) } + Row(verticalAlignment = Alignment.CenterVertically) { + Switch(checked = showDistanceInForecast, onCheckedChange = { showDistanceInForecast = it}) + Spacer(modifier = Modifier.width(10.dp)) + Text("Show Distance in Forecast") + } + if (!karooConnected) { Text( modifier = Modifier.padding(5.dp), diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/screens/WeatherScreen.kt b/app/src/main/kotlin/de/timklge/karooheadwind/screens/WeatherScreen.kt index 88ed319..d069886 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/screens/WeatherScreen.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/screens/WeatherScreen.kt @@ -29,6 +29,7 @@ import de.timklge.karooheadwind.KarooHeadwindExtension import de.timklge.karooheadwind.R import de.timklge.karooheadwind.TemperatureUnit import de.timklge.karooheadwind.WeatherInterpretation +import de.timklge.karooheadwind.datatypes.ForecastDataType import de.timklge.karooheadwind.datatypes.WeatherDataType.Companion.timeFormatter import de.timklge.karooheadwind.datatypes.WeatherForecastDataType import de.timklge.karooheadwind.datatypes.getShortDateFormatter @@ -200,7 +201,7 @@ fun WeatherScreen(onFinish: () -> Unit) { val interpretation = WeatherInterpretation.fromWeatherCode(data?.forecastData?.weatherCode?.get(index) ?: 0) val unixTime = data?.forecastData?.time?.get(index) ?: 0 - val formattedForecastTime = WeatherForecastDataType.timeFormatter.format(Instant.ofEpochSecond(unixTime)) + val formattedForecastTime = ForecastDataType.timeFormatter.format(Instant.ofEpochSecond(unixTime)) val formattedForecastDate = getShortDateFormatter().format(Instant.ofEpochSecond(unixTime)) WeatherWidget( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0162395..bc26a7e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -25,10 +25,18 @@ Current weather conditions Weather Forecast Current hourly weather forecast + Temperature Forecast + Current hourly temperature forecast + Wind Forecast + Current hourly wind forecast + Precipitation Forecast + Current hourly precipitation forecast Headwind speed Current headwind speed Temperature Current temperature in configured unit Current headwind or wind speed based on user setting Set wind speed + Graphical Forecast + Current graphical weather forecast \ No newline at end of file diff --git a/app/src/main/res/xml/extension_info.xml b/app/src/main/res/xml/extension_info.xml index 3173920..eb4e075 100644 --- a/app/src/main/res/xml/extension_info.xml +++ b/app/src/main/res/xml/extension_info.xml @@ -32,6 +32,34 @@ icon="@drawable/wind" typeId="weatherForecast" /> + + + + + + + +