diff --git a/README.md b/README.md index 2aa8835..3d5c781 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,8 @@ If you are using a Karoo 2, you can use manual sideloading: After installing this app on your Karoo and opening it once from the main menu, you can add the following new data fields to your data pages: -- Headwind (graphical): Shows the headwind direction and speed as a circle with a triangular direction indicator. The speed is shown at the center in your set unit of measurement (default is kilometers per hour if you have set up metric units in your Karoo, otherwise miles per hour). Both direction and speed are relative to the current riding direction. -- Weather (graphical): Shows an icon indicating the current weather conditions (sunny, cloudy, ...). Additionally, current absolute wind direction, speed and wind gust speed are shown below the icon. +- Headwind (graphical, 1x1 field): Shows the headwind direction and speed as a circle with a triangular direction indicator. The speed is shown at the center in your set unit of measurement (default is kilometers per hour if you have set up metric units in your Karoo, otherwise miles per hour). Both direction and speed are relative to the current riding direction. +- Weather forecast (graphical, 2x1 field): Shows three columns indicating the current weather conditions (sunny, cloudy, ...), wind direction, precipitation and temperature forecasted for the next three hours. Tap on this widget to cycle through the 12 hour forecast. - Additionally, data fields that only show the current data value for headwind speed, humidity, cloud cover, absolute wind speed, absolute wind gust speed, absolute wind direction, rainfall and surface pressure can be added if desired. The app will automatically attempt to download weather data for your current approximate location from the [open-meteo.com](https://open-meteo.com) API once your device has acquired a GPS fix. The API service is free for non-commercial use. Your location is rounded to approximately two kilometers to maintain privacy. The data is updated when you ride more than two kilometers from the location where the weather data was downloaded or after one hour at the latest. If the app cannot connect to the weather service, it will retry the download every minute. Downloading weather data should work on Karoo 2 if you have a SIM card inserted or on Karoo 3 via your phone's internet connection if you have the Karoo companion app installed. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1405bc1..f035a1a 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 = 3 - versionName = "1.0.1" + versionCode = 4 + versionName = "1.1" } signingConfigs { diff --git a/app/manifest.json b/app/manifest.json index 3acd243..048792f 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.0.1", - "latestVersionCode": 3, + "latestVersion": "1.1", + "latestVersionCode": 4, "developer": "timklge", "description": "Provides headwind direction, wind speed and other weather data fields", - "releaseNotes": "Add wind indicator text setting and increase indicator font size. First build with new CI/CD pipeline." + "releaseNotes": "Add temperature datafield" } \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/Extensions.kt b/app/src/main/kotlin/de/timklge/karooheadwind/Extensions.kt index 62e1201..51f0981 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/Extensions.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/Extensions.kt @@ -7,7 +7,9 @@ import androidx.datastore.preferences.core.stringPreferencesKey import de.timklge.karooheadwind.datatypes.GpsCoordinates import de.timklge.karooheadwind.screens.HeadwindSettings import de.timklge.karooheadwind.screens.HeadwindStats +import de.timklge.karooheadwind.screens.HeadwindWidgetSettings import de.timklge.karooheadwind.screens.PrecipitationUnit +import de.timklge.karooheadwind.screens.TemperatureUnit import de.timklge.karooheadwind.screens.WindUnit import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.models.DataType @@ -28,6 +30,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.scan @@ -44,6 +47,7 @@ import kotlin.time.Duration.Companion.seconds val jsonWithUnknownKeys = Json { ignoreUnknownKeys = true } val settingsKey = stringPreferencesKey("settings") +val widgetSettingsKey = stringPreferencesKey("widgetSettings") val currentDataKey = stringPreferencesKey("current") val statsKey = stringPreferencesKey("stats") @@ -53,6 +57,12 @@ suspend fun saveSettings(context: Context, settings: HeadwindSettings) { } } +suspend fun saveWidgetSettings(context: Context, settings: HeadwindWidgetSettings) { + context.dataStore.edit { t -> + t[widgetSettingsKey] = Json.encodeToString(settings) + } +} + suspend fun saveStats(context: Context, stats: HeadwindStats) { context.dataStore.edit { t -> t[statsKey] = Json.encodeToString(stats) @@ -88,6 +98,21 @@ fun Context.streamCurrentWeatherData(): Flow { }.filterNotNull().distinctUntilChanged().filter { it.current.time * 1000 >= System.currentTimeMillis() - (1000 * 60 * 60 * 12) } } +fun Context.streamWidgetSettings(): Flow { + return dataStore.data.map { settingsJson -> + try { + if (settingsJson.contains(widgetSettingsKey)){ + jsonWithUnknownKeys.decodeFromString(settingsJson[widgetSettingsKey]!!) + } else { + jsonWithUnknownKeys.decodeFromString(HeadwindWidgetSettings.defaultWidgetSettings) + } + } catch(e: Throwable){ + Log.e(KarooHeadwindExtension.TAG, "Failed to read preferences", e) + jsonWithUnknownKeys.decodeFromString(HeadwindWidgetSettings.defaultWidgetSettings) + } + }.distinctUntilChanged() +} + fun Context.streamSettings(karooSystemService: KarooSystemService): Flow { return dataStore.data.map { settingsJson -> try { @@ -100,8 +125,9 @@ fun Context.streamSettings(karooSystemService: KarooSystemService): Flow { @OptIn(FlowPreview::class) suspend fun KarooSystemService.makeOpenMeteoHttpRequest(gpsCoordinates: GpsCoordinates, settings: HeadwindSettings): HttpResponseState.Complete { return callbackFlow { - // https://open-meteo.com/en/docs#current=temperature_2m,relative_humidity_2m,apparent_temperature,precipitation,weather_code,cloud_cover,wind_speed_10m,wind_direction_10m,wind_gusts_10m&hourly=&daily=&location_mode=csv_coordinates&timeformat=unixtime&forecast_days=3 - val url = "https://api.open-meteo.com/v1/forecast?latitude=${gpsCoordinates.lat}&longitude=${gpsCoordinates.lon}¤t=weather_code,temperature_2m,relative_humidity_2m,apparent_temperature,precipitation,cloud_cover,wind_speed_10m,wind_direction_10m,wind_gusts_10m,surface_pressure&timeformat=unixtime&wind_speed_unit=${settings.windUnit.id}&precipitation_unit=${settings.precipitationUnit.id}" + // https://api.open-meteo.com/v1/forecast?latitude=52.52&longitude=13.41¤t=surface_pressure,temperature_2m,relative_humidity_2m,precipitation,weather_code,cloud_cover,wind_speed_10m,wind_direction_10m,wind_gusts_10m&hourly=temperature_2m,precipitation_probability,precipitation,weather_code,wind_speed_10m,wind_direction_10m,wind_gusts_10m&timeformat=unixtime&past_hours=1&forecast_days=1&forecast_hours=12 + val url = "https://api.open-meteo.com/v1/forecast?latitude=${gpsCoordinates.lat}&longitude=${gpsCoordinates.lon}¤t=surface_pressure,temperature_2m,relative_humidity_2m,precipitation,weather_code,cloud_cover,wind_speed_10m,wind_direction_10m,wind_gusts_10m&hourly=temperature_2m,precipitation_probability,precipitation,weather_code,wind_speed_10m,wind_direction_10m,wind_gusts_10m&timeformat=unixtime&past_hours=0&forecast_days=1&forecast_hours=12&wind_speed_unit=${settings.windUnit.id}&precipitation_unit=${settings.precipitationUnit.id}&temperature_unit=${settings.temperatureUnit.id}" Log.d(KarooHeadwindExtension.TAG, "Http request to ${url}...") @@ -202,7 +228,7 @@ fun KarooSystemService.getRelativeHeadingFlow(context: Context): Flow { } fun KarooSystemService.getHeadingFlow(): Flow { - //return flowOf(20.0) + // return flowOf(20.0) return streamDataFlow(DataType.Type.LOCATION) .mapNotNull { (it as? StreamState.Streaming)?.dataPoint?.values } diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt b/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt index 9e3a495..72d7b51 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt @@ -10,7 +10,9 @@ import de.timklge.karooheadwind.datatypes.WindDirectionDataType import de.timklge.karooheadwind.datatypes.WindGustsDataType import de.timklge.karooheadwind.datatypes.HeadwindSpeedDataType import de.timklge.karooheadwind.datatypes.HeadwindDirectionDataType +import de.timklge.karooheadwind.datatypes.TemperatureDataType import de.timklge.karooheadwind.datatypes.WeatherDataType +import de.timklge.karooheadwind.datatypes.WeatherForecastDataType import de.timklge.karooheadwind.datatypes.WindSpeedDataType import de.timklge.karooheadwind.screens.HeadwindStats import io.hammerhead.karooext.KarooSystemService @@ -30,7 +32,7 @@ import kotlinx.coroutines.launch import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes -class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.0.1") { +class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.1") { companion object { const val TAG = "karoo-headwind" } @@ -44,11 +46,13 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.0.1") { HeadwindDirectionDataType(karooSystem, applicationContext), HeadwindSpeedDataType(karooSystem, applicationContext), WeatherDataType(karooSystem, applicationContext), + WeatherForecastDataType(karooSystem, applicationContext), HeadwindSpeedDataType(karooSystem, applicationContext), RelativeHumidityDataType(applicationContext), CloudCoverDataType(applicationContext), WindGustsDataType(applicationContext), WindSpeedDataType(applicationContext), + TemperatureDataType(applicationContext), WindDirectionDataType(karooSystem, applicationContext), PrecipitationDataType(applicationContext), SurfacePressureDataType(applicationContext) diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/OpenMeteoData.kt b/app/src/main/kotlin/de/timklge/karooheadwind/OpenMeteoData.kt index cbdf876..26c956f 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/OpenMeteoData.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/OpenMeteoData.kt @@ -17,6 +17,18 @@ data class OpenMeteoData( @SerialName("weather_code") val weatherCode: Int, ) +@Serializable +data class OpenMeteoForecastData( + @SerialName("time") val time: List, + @SerialName("temperature_2m") val temperature: List, + @SerialName("precipitation_probability") val precipitationProbability: List, + @SerialName("precipitation") val precipitation: List, + @SerialName("weather_code") val weatherCode: List, + @SerialName("wind_speed_10m") val windSpeed: List, + @SerialName("wind_direction_10m") val windDirection: List, + @SerialName("wind_gusts_10m") val windGusts: List, +) + enum class WeatherInterpretation { CLEAR, CLOUDY, RAINY, SNOWY, DRIZZLE, THUNDERSTORM, UNKNOWN; @@ -43,5 +55,6 @@ data class OpenMeteoCurrentWeatherResponse( val longitude: Double, val timezone: String, val elevation: Double, - @SerialName("utc_offset_seconds") val utfOffsetSeconds: Int + @SerialName("utc_offset_seconds") val utfOffsetSeconds: Int, + @SerialName("hourly") val forecastData: OpenMeteoForecastData ) \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TemperatureDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TemperatureDataType.kt new file mode 100644 index 0000000..ea69ab9 --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TemperatureDataType.kt @@ -0,0 +1,10 @@ +package de.timklge.karooheadwind.datatypes + +import android.content.Context +import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse + +class TemperatureDataType(context: Context) : BaseDataType(context, "temperature"){ + override fun getValue(data: OpenMeteoCurrentWeatherResponse): Double { + return data.current.temperature + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherDataType.kt index 673c0b0..1e47844 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherDataType.kt @@ -4,8 +4,12 @@ import android.content.Context import android.graphics.BitmapFactory import android.util.Log import androidx.compose.ui.unit.DpSize +import androidx.glance.GlanceModifier import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi import androidx.glance.appwidget.GlanceRemoteViews +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.fillMaxSize import de.timklge.karooheadwind.KarooHeadwindExtension import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse import de.timklge.karooheadwind.WeatherInterpretation @@ -27,6 +31,9 @@ import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.launch +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter import kotlin.math.roundToInt @OptIn(ExperimentalGlanceRemoteViewsApi::class) @@ -36,6 +43,10 @@ class WeatherDataType( ) : DataTypeImpl("karoo-headwind", "weather") { private val glance = GlanceRemoteViews() + companion object { + val timeFormatter = DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneId.systemDefault()) + } + // FIXME: Remove. Currently, the data field will permanently show "no sensor" if no data stream is provided override fun startStream(emitter: Emitter) { val job = CoroutineScope(Dispatchers.IO).launch { @@ -77,9 +88,24 @@ class WeatherDataType( .collect { (data, settings) -> Log.d(KarooHeadwindExtension.TAG, "Updating weather view") val interpretation = WeatherInterpretation.fromWeatherCode(data.current.weatherCode) + val formattedTime = timeFormatter.format(Instant.ofEpochSecond(data.current.time)) val result = glance.compose(context, DpSize.Unspecified) { - Weather(baseBitmap, interpretation, data.current.windDirection.roundToInt(), data.current.windSpeed.roundToInt(), data.current.windGusts.roundToInt()) + Box(modifier = GlanceModifier.fillMaxSize(), contentAlignment = Alignment.CenterEnd) { + Weather(baseBitmap, + current = interpretation, + windBearing = data.current.windDirection.roundToInt(), + windSpeed = data.current.windSpeed.roundToInt(), + windGusts = data.current.windGusts.roundToInt(), + windSpeedUnit = settings.windUnit, + precipitation = data.current.precipitation, + precipitationProbability = null, + precipitationUnit = settings.precipitationUnit, + temperature = data.current.temperature.roundToInt(), + temperatureUnit = settings.temperatureUnit, + timeLabel = formattedTime + ) + } } emitter.updateView(result.remoteViews) diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherForecastDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherForecastDataType.kt new file mode 100644 index 0000000..6b49633 --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherForecastDataType.kt @@ -0,0 +1,182 @@ +package de.timklge.karooheadwind.datatypes + +import android.content.Context +import android.graphics.BitmapFactory +import android.util.Log +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.action.ActionParameters +import androidx.glance.action.clickable +import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi +import androidx.glance.appwidget.GlanceRemoteViews +import androidx.glance.appwidget.action.ActionCallback +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.padding +import androidx.glance.layout.width +import de.timklge.karooheadwind.KarooHeadwindExtension +import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse +import de.timklge.karooheadwind.WeatherInterpretation +import de.timklge.karooheadwind.saveSettings +import de.timklge.karooheadwind.saveWidgetSettings +import de.timklge.karooheadwind.screens.HeadwindSettings +import de.timklge.karooheadwind.screens.HeadwindWidgetSettings +import de.timklge.karooheadwind.streamCurrentWeatherData +import de.timklge.karooheadwind.streamSettings +import de.timklge.karooheadwind.streamWidgetSettings +import io.hammerhead.karooext.KarooSystemService +import io.hammerhead.karooext.extension.DataTypeImpl +import io.hammerhead.karooext.internal.Emitter +import io.hammerhead.karooext.internal.ViewEmitter +import io.hammerhead.karooext.models.DataPoint +import io.hammerhead.karooext.models.DataType +import io.hammerhead.karooext.models.StreamState +import io.hammerhead.karooext.models.UpdateGraphicConfig +import io.hammerhead.karooext.models.ViewConfig +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.launch +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import kotlin.math.roundToInt + +class CycleHoursAction : ActionCallback { + override suspend fun onAction( + context: Context, + glanceId: GlanceId, + parameters: ActionParameters + ) { + Log.d(KarooHeadwindExtension.TAG, "Cycling hours") + + val currentSettings = context.streamWidgetSettings().first() + val data = context.streamCurrentWeatherData().first() + + var hourOffset = currentSettings.currentForecastHourOffset + 3 + if (hourOffset >= data.forecastData.weatherCode.size) { + hourOffset = 0 + } + + val newSettings = currentSettings.copy(currentForecastHourOffset = hourOffset) + saveWidgetSettings(context, newSettings) + } +} + +@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()) + } + + // FIXME: Remove. Currently, the data field will permanently show "no sensor" if no data stream is provided + override fun startStream(emitter: Emitter) { + val job = CoroutineScope(Dispatchers.IO).launch { + val currentWeatherData = applicationContext.streamCurrentWeatherData() + + currentWeatherData + .collect { data -> + Log.d(KarooHeadwindExtension.TAG, "Wind code: ${data.current.weatherCode}") + emitter.onNext(StreamState.Streaming(DataPoint(dataTypeId, mapOf(DataType.Field.SINGLE to data.current.weatherCode.toDouble())))) + } + } + emitter.setCancellable { + job.cancel() + } + } + + 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 + ) + + data class StreamData(val data: OpenMeteoCurrentWeatherResponse, val settings: HeadwindSettings, val widgetSettings: HeadwindWidgetSettings? = null) + + val viewJob = CoroutineScope(Dispatchers.IO).launch { + context.streamCurrentWeatherData() + .combine(context.streamSettings(karooSystem)) { data, settings -> StreamData(data, settings) } + .combine(context.streamWidgetSettings()) { data, widgetSettings -> data.copy(widgetSettings = widgetSettings) } + .onCompletion { + // Clear view on completion + val result = glance.compose(context, DpSize.Unspecified) { } + emitter.updateView(result.remoteViews) + } + .collect { (data, settings, widgetSettings) -> + Log.d(KarooHeadwindExtension.TAG, "Updating weather view") + + val result = glance.compose(context, DpSize.Unspecified) { + Row(modifier = GlanceModifier.fillMaxSize().clickable(onClick = actionRunCallback()), horizontalAlignment = Alignment.Horizontal.CenterHorizontally) { + val hourOffset = widgetSettings?.currentForecastHourOffset ?: 0 + + for (index in hourOffset..hourOffset + 2){ + if (index >= data.forecastData.weatherCode.size) { + break + } + + if (index > hourOffset) { + Spacer( + modifier = GlanceModifier.fillMaxHeight().background( + ColorProvider(Color.Black, Color.White) + ).width(1.dp) + ) + } + + val interpretation = WeatherInterpretation.fromWeatherCode(data.forecastData.weatherCode[index]) + val unixTime = data.forecastData.time[index] + val formattedTime = timeFormatter.format(Instant.ofEpochSecond(unixTime)) + + Weather(baseBitmap, + current = interpretation, + windBearing = data.forecastData.windDirection[index].roundToInt(), + windSpeed = data.forecastData.windSpeed[index].roundToInt(), + windGusts = data.forecastData.windGusts[index].roundToInt(), + windSpeedUnit = settings.windUnit, + precipitation = data.forecastData.precipitation[index], + precipitationProbability = data.forecastData.precipitationProbability[index], + precipitationUnit = settings.precipitationUnit, + temperature = data.forecastData.temperature[index].roundToInt(), + temperatureUnit = settings.temperatureUnit, + timeLabel = formattedTime + ) + } + } + } + + 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 3580f3d..ed724fa 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherView.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherView.kt @@ -10,22 +10,29 @@ import androidx.glance.ColorFilter import androidx.glance.GlanceModifier import androidx.glance.Image import androidx.glance.ImageProvider -import androidx.glance.LocalContext 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.fillMaxSize +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxHeight import androidx.glance.layout.height +import androidx.glance.layout.padding import androidx.glance.layout.width import androidx.glance.preview.ExperimentalGlancePreviewApi import androidx.glance.preview.Preview import androidx.glance.text.FontFamily +import androidx.glance.text.FontWeight import androidx.glance.text.Text +import androidx.glance.text.TextAlign import androidx.glance.text.TextStyle import de.timklge.karooheadwind.R import de.timklge.karooheadwind.WeatherInterpretation +import de.timklge.karooheadwind.screens.PrecipitationUnit +import de.timklge.karooheadwind.screens.TemperatureUnit +import de.timklge.karooheadwind.screens.WindUnit +import kotlin.math.ceil fun getWeatherIcon(interpretation: WeatherInterpretation): Int { return when (interpretation){ @@ -42,10 +49,15 @@ fun getWeatherIcon(interpretation: WeatherInterpretation): Int { @OptIn(ExperimentalGlancePreviewApi::class) @Preview(widthDp = 200, heightDp = 150) @Composable -fun Weather(baseBitmap: Bitmap, current: WeatherInterpretation, windBearing: Int, windSpeed: Int, windGusts: Int) { - Column(modifier = GlanceModifier.fillMaxSize(), horizontalAlignment = Alignment.End) { +fun Weather(baseBitmap: Bitmap, current: WeatherInterpretation, windBearing: Int, windSpeed: Int, windGusts: Int, windSpeedUnit: WindUnit, + precipitation: Double, precipitationProbability: Int?, precipitationUnit: PrecipitationUnit, + temperature: Int, temperatureUnit: TemperatureUnit, timeLabel: String? = null) { + + val fontSize = 14f + + Column(modifier = GlanceModifier.fillMaxHeight().padding(2.dp), horizontalAlignment = Alignment.End) { Row(modifier = GlanceModifier.defaultWeight(), horizontalAlignment = Alignment.End) { - val imageW = 70 + val imageW = 60 val imageH = (imageW * (280.0 / 400)).toInt() Image( modifier = GlanceModifier.height(imageH.dp).width(imageW.dp), @@ -56,7 +68,48 @@ fun Weather(baseBitmap: Bitmap, current: WeatherInterpretation, windBearing: Int ) } - Row(horizontalAlignment = Alignment.CenterHorizontally, verticalAlignment = Alignment.CenterVertically) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (timeLabel != null){ + Text( + text = timeLabel, + style = TextStyle(color = ColorProvider(Color.Black, Color.White), fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, fontSize = TextUnit(fontSize, TextUnitType.Sp)) + ) + + Spacer(modifier = GlanceModifier.width(5.dp)) + } + + Image( + modifier = GlanceModifier.height(20.dp).width(12.dp), + provider = ImageProvider(R.drawable.thermometer), + contentDescription = "Temperature", + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(ColorProvider(Color.Black, Color.White)) + ) + + Text( + text = "${temperature}${temperatureUnit.unitDisplay}", + style = TextStyle(color = ColorProvider(Color.Black, Color.White), fontFamily = FontFamily.Monospace, fontSize = TextUnit(fontSize, TextUnitType.Sp), textAlign = TextAlign.Center) + ) + } + + Row(verticalAlignment = Alignment.CenterVertically) { + /* Image( + modifier = GlanceModifier.height(20.dp).width(12.dp), + provider = ImageProvider(R.drawable.water_regular), + contentDescription = "Rain", + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(ColorProvider(Color.Black, Color.White)) + ) */ + + val precipitationProbabilityLabel = if (precipitationProbability != null) "${precipitationProbability}%," else "" + Text( + text = "${precipitationProbabilityLabel}${ceil(precipitation).toInt().coerceIn(0..9)}", + style = TextStyle(color = ColorProvider(Color.Black, Color.White), fontFamily = FontFamily.Monospace, fontSize = TextUnit(fontSize, TextUnitType.Sp)) + ) + + Spacer(modifier = GlanceModifier.width(5.dp)) + Image( modifier = GlanceModifier.height(20.dp).width(12.dp), provider = ImageProvider(getArrowBitmapByBearing(baseBitmap, windBearing + 180)), @@ -65,9 +118,10 @@ fun Weather(baseBitmap: Bitmap, current: WeatherInterpretation, windBearing: Int colorFilter = ColorFilter.tint(ColorProvider(Color.Black, Color.White)) ) + Text( - text = "$windSpeed,$windGusts", - style = TextStyle(color = ColorProvider(Color.Black, Color.White), fontFamily = FontFamily.Monospace, fontSize = TextUnit(18f, TextUnitType.Sp)) + text = "$windSpeed,${windGusts}", + style = TextStyle(color = ColorProvider(Color.Black, Color.White), fontFamily = FontFamily.Monospace, fontSize = TextUnit(fontSize, TextUnitType.Sp)) ) } } diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/screens/MainScreen.kt b/app/src/main/kotlin/de/timklge/karooheadwind/screens/MainScreen.kt index 1779251..2a945ed 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/screens/MainScreen.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/screens/MainScreen.kt @@ -55,9 +55,9 @@ enum class WindUnit(val id: String, val label: String, val unitDisplay: String){ KNOTS("kn", "Knots (kn)", "kn") } -enum class PrecipitationUnit(val id: String, val label: String){ - MILLIMETERS("mm", "Millimeters (mm)"), - INCH("inch", "Inch") +enum class PrecipitationUnit(val id: String, val label: String, val unitDisplay: String){ + MILLIMETERS("mm", "Millimeters (mm)", "mm"), + INCH("inch", "Inch", "in") } enum class WindDirectionIndicatorTextSetting(val id: String, val label: String){ @@ -66,10 +66,16 @@ enum class WindDirectionIndicatorTextSetting(val id: String, val label: String){ NONE("none", "None") } +enum class TemperatureUnit(val id: String, val label: String, val unitDisplay: String){ + CELSIUS("celsius", "Celsius (°C)", "°C"), + FAHRENHEIT("fahrenheit", "Fahrenheit (°F)", "°F") +} + @Serializable data class HeadwindSettings( val windUnit: WindUnit = WindUnit.KILOMETERS_PER_HOUR, val precipitationUnit: PrecipitationUnit = PrecipitationUnit.MILLIMETERS, + val temperatureUnit: TemperatureUnit = TemperatureUnit.CELSIUS, val welcomeDialogAccepted: Boolean = false, val windDirectionIndicatorTextSetting: WindDirectionIndicatorTextSetting = WindDirectionIndicatorTextSetting.HEADWIND_SPEED, ){ @@ -78,6 +84,15 @@ data class HeadwindSettings( } } +@Serializable +data class HeadwindWidgetSettings( + val currentForecastHourOffset: Int = 0 +){ + companion object { + val defaultWidgetSettings = Json.encodeToString(HeadwindWidgetSettings()) + } +} + @Serializable data class HeadwindStats( val lastSuccessfulWeatherRequest: Long? = null, @@ -99,6 +114,7 @@ fun MainScreen() { var selectedWindUnit by remember { mutableStateOf(WindUnit.KILOMETERS_PER_HOUR) } var selectedPrecipitationUnit by remember { mutableStateOf(PrecipitationUnit.MILLIMETERS) } + var selectedTemperatureUnit by remember { mutableStateOf(TemperatureUnit.CELSIUS) } var welcomeDialogVisible by remember { mutableStateOf(false) } var selectedWindDirectionIndicatorTextSetting by remember { mutableStateOf(WindDirectionIndicatorTextSetting.HEADWIND_SPEED) } @@ -113,6 +129,7 @@ fun MainScreen() { selectedPrecipitationUnit = settings.precipitationUnit welcomeDialogVisible = !settings.welcomeDialogAccepted selectedWindDirectionIndicatorTextSetting = settings.windDirectionIndicatorTextSetting + selectedTemperatureUnit = settings.temperatureUnit } } @@ -131,6 +148,14 @@ fun MainScreen() { .verticalScroll(rememberScrollState()) .fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(10.dp)) { + val windDirectionIndicatorTextSettingDropdownOptions = WindDirectionIndicatorTextSetting.entries.toList().map { unit -> DropdownOption(unit.id, unit.label) } + val windDirectionIndicatorTextSettingSelection by remember(selectedWindDirectionIndicatorTextSetting) { + mutableStateOf(windDirectionIndicatorTextSettingDropdownOptions.find { option -> option.id == selectedWindDirectionIndicatorTextSetting.id }!!) + } + Dropdown(label = "Text on headwind indicator", options = windDirectionIndicatorTextSettingDropdownOptions, selected = windDirectionIndicatorTextSettingSelection) { selectedOption -> + selectedWindDirectionIndicatorTextSetting = WindDirectionIndicatorTextSetting.entries.find { unit -> unit.id == selectedOption.id }!! + } + val windSpeedUnitDropdownOptions = WindUnit.entries.toList().map { unit -> DropdownOption(unit.id, unit.label) } val windSpeedUnitInitialSelection by remember(selectedWindUnit) { mutableStateOf(windSpeedUnitDropdownOptions.find { option -> option.id == selectedWindUnit.id }!!) @@ -147,18 +172,19 @@ fun MainScreen() { selectedPrecipitationUnit = PrecipitationUnit.entries.find { unit -> unit.id == selectedOption.id }!! } - val windDirectionIndicatorTextSettingDropdownOptions = WindDirectionIndicatorTextSetting.entries.toList().map { unit -> DropdownOption(unit.id, unit.label) } - val windDirectionIndicatorTextSettingSelection by remember(selectedWindDirectionIndicatorTextSetting) { - mutableStateOf(windDirectionIndicatorTextSettingDropdownOptions.find { option -> option.id == selectedWindDirectionIndicatorTextSetting.id }!!) + val temperatureUnitDropdownOptions = TemperatureUnit.entries.toList().map { unit -> DropdownOption(unit.id, unit.label) } + val temperatureUnitInitialSelection by remember(selectedTemperatureUnit) { + mutableStateOf(temperatureUnitDropdownOptions.find { option -> option.id == selectedTemperatureUnit.id }!!) } - Dropdown(label = "Text on headwind indicator", options = windDirectionIndicatorTextSettingDropdownOptions, selected = windDirectionIndicatorTextSettingSelection) { selectedOption -> - selectedWindDirectionIndicatorTextSetting = WindDirectionIndicatorTextSetting.entries.find { unit -> unit.id == selectedOption.id }!! + Dropdown(label = "Temperature Unit", options = temperatureUnitDropdownOptions, selected = temperatureUnitInitialSelection) { selectedOption -> + selectedTemperatureUnit = TemperatureUnit.entries.find { unit -> unit.id == selectedOption.id }!! } FilledTonalButton(modifier = Modifier .fillMaxWidth() .height(50.dp), onClick = { val newSettings = HeadwindSettings(windUnit = selectedWindUnit, precipitationUnit = selectedPrecipitationUnit, + temperatureUnit = selectedTemperatureUnit, welcomeDialogAccepted = true, windDirectionIndicatorTextSetting = selectedWindDirectionIndicatorTextSetting) coroutineScope.launch { @@ -210,7 +236,10 @@ fun MainScreen() { AlertDialog(onDismissRequest = { }, confirmButton = { Button(onClick = { coroutineScope.launch { - saveSettings(ctx, HeadwindSettings(windUnit = selectedWindUnit, precipitationUnit = selectedPrecipitationUnit, welcomeDialogAccepted = true)) + saveSettings(ctx, HeadwindSettings(windUnit = selectedWindUnit, + precipitationUnit = selectedPrecipitationUnit, + temperatureUnit = selectedTemperatureUnit, + welcomeDialogAccepted = true)) } }) { Text("OK") } }, text = { diff --git a/app/src/main/res/drawable/thermometer.png b/app/src/main/res/drawable/thermometer.png new file mode 100644 index 0000000..925d9f1 Binary files /dev/null and b/app/src/main/res/drawable/thermometer.png differ diff --git a/app/src/main/res/drawable/water_regular.png b/app/src/main/res/drawable/water_regular.png new file mode 100644 index 0000000..2059262 Binary files /dev/null and b/app/src/main/res/drawable/water_regular.png differ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0b11645..817ac64 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -19,6 +19,10 @@ Atmospheric pressure at surface in configured unit Weather Current weather conditions + Weather Forecast + Current hourly weather forecast Headwind speed Current headwind speed + Temperature + Current temperature in configured unit \ 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 94bda37..4694ea5 100644 --- a/app/src/main/res/xml/extension_info.xml +++ b/app/src/main/res/xml/extension_info.xml @@ -18,6 +18,13 @@ icon="@drawable/ic_launcher" typeId="weather" /> + + + + diff --git a/preview0.png b/preview0.png index d425bd8..fa8f21d 100644 Binary files a/preview0.png and b/preview0.png differ