fix #13, fix #3: Add hourly forecast widget

This commit is contained in:
Tim Kluge 2024-12-19 20:26:57 +01:00
parent c259c24adb
commit be701e4057
16 changed files with 394 additions and 32 deletions

View File

@ -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: 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. - 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 (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. - 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. - 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. 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.

View File

@ -15,8 +15,8 @@ android {
applicationId = "de.timklge.karooheadwind" applicationId = "de.timklge.karooheadwind"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 3 versionCode = 4
versionName = "1.0.1" versionName = "1.1"
} }
signingConfigs { signingConfigs {

View File

@ -3,9 +3,9 @@
"packageName": "de.timklge.karooheadwind", "packageName": "de.timklge.karooheadwind",
"iconUrl": "https://github.com/timklge/karoo-headwind/releases/latest/download/karoo-headwind.png", "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", "latestApkUrl": "https://github.com/timklge/karoo-headwind/releases/latest/download/app-release.apk",
"latestVersion": "1.0.1", "latestVersion": "1.1",
"latestVersionCode": 3, "latestVersionCode": 4,
"developer": "timklge", "developer": "timklge",
"description": "Provides headwind direction, wind speed and other weather data fields", "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"
} }

View File

@ -7,7 +7,9 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import de.timklge.karooheadwind.datatypes.GpsCoordinates import de.timklge.karooheadwind.datatypes.GpsCoordinates
import de.timklge.karooheadwind.screens.HeadwindSettings import de.timklge.karooheadwind.screens.HeadwindSettings
import de.timklge.karooheadwind.screens.HeadwindStats import de.timklge.karooheadwind.screens.HeadwindStats
import de.timklge.karooheadwind.screens.HeadwindWidgetSettings
import de.timklge.karooheadwind.screens.PrecipitationUnit import de.timklge.karooheadwind.screens.PrecipitationUnit
import de.timklge.karooheadwind.screens.TemperatureUnit
import de.timklge.karooheadwind.screens.WindUnit import de.timklge.karooheadwind.screens.WindUnit
import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.DataType import io.hammerhead.karooext.models.DataType
@ -28,6 +30,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.scan import kotlinx.coroutines.flow.scan
@ -44,6 +47,7 @@ import kotlin.time.Duration.Companion.seconds
val jsonWithUnknownKeys = Json { ignoreUnknownKeys = true } val jsonWithUnknownKeys = Json { ignoreUnknownKeys = true }
val settingsKey = stringPreferencesKey("settings") val settingsKey = stringPreferencesKey("settings")
val widgetSettingsKey = stringPreferencesKey("widgetSettings")
val currentDataKey = stringPreferencesKey("current") val currentDataKey = stringPreferencesKey("current")
val statsKey = stringPreferencesKey("stats") 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) { suspend fun saveStats(context: Context, stats: HeadwindStats) {
context.dataStore.edit { t -> context.dataStore.edit { t ->
t[statsKey] = Json.encodeToString(stats) t[statsKey] = Json.encodeToString(stats)
@ -88,6 +98,21 @@ fun Context.streamCurrentWeatherData(): Flow<OpenMeteoCurrentWeatherResponse> {
}.filterNotNull().distinctUntilChanged().filter { it.current.time * 1000 >= System.currentTimeMillis() - (1000 * 60 * 60 * 12) } }.filterNotNull().distinctUntilChanged().filter { it.current.time * 1000 >= System.currentTimeMillis() - (1000 * 60 * 60 * 12) }
} }
fun Context.streamWidgetSettings(): Flow<HeadwindWidgetSettings> {
return dataStore.data.map { settingsJson ->
try {
if (settingsJson.contains(widgetSettingsKey)){
jsonWithUnknownKeys.decodeFromString<HeadwindWidgetSettings>(settingsJson[widgetSettingsKey]!!)
} else {
jsonWithUnknownKeys.decodeFromString<HeadwindWidgetSettings>(HeadwindWidgetSettings.defaultWidgetSettings)
}
} catch(e: Throwable){
Log.e(KarooHeadwindExtension.TAG, "Failed to read preferences", e)
jsonWithUnknownKeys.decodeFromString<HeadwindWidgetSettings>(HeadwindWidgetSettings.defaultWidgetSettings)
}
}.distinctUntilChanged()
}
fun Context.streamSettings(karooSystemService: KarooSystemService): Flow<HeadwindSettings> { fun Context.streamSettings(karooSystemService: KarooSystemService): Flow<HeadwindSettings> {
return dataStore.data.map { settingsJson -> return dataStore.data.map { settingsJson ->
try { try {
@ -100,8 +125,9 @@ fun Context.streamSettings(karooSystemService: KarooSystemService): Flow<Headwin
val preferredMetric = preferredUnits.distance == UserProfile.PreferredUnit.UnitType.METRIC val preferredMetric = preferredUnits.distance == UserProfile.PreferredUnit.UnitType.METRIC
defaultSettings.copy( defaultSettings.copy(
windUnit = if (preferredMetric) WindUnit.KILOMETERS_PER_HOUR else WindUnit.MILES_PER_HOUR, windUnit = if (preferredUnits.distance == UserProfile.PreferredUnit.UnitType.METRIC) WindUnit.KILOMETERS_PER_HOUR else WindUnit.MILES_PER_HOUR,
precipitationUnit = if (preferredMetric) PrecipitationUnit.MILLIMETERS else PrecipitationUnit.INCH precipitationUnit = if (preferredMetric) PrecipitationUnit.MILLIMETERS else PrecipitationUnit.INCH,
temperatureUnit = if (preferredUnits.temperature == UserProfile.PreferredUnit.UnitType.METRIC) TemperatureUnit.CELSIUS else TemperatureUnit.FAHRENHEIT
) )
} }
} catch(e: Throwable){ } catch(e: Throwable){
@ -138,8 +164,8 @@ fun KarooSystemService.streamUserProfile(): Flow<UserProfile> {
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
suspend fun KarooSystemService.makeOpenMeteoHttpRequest(gpsCoordinates: GpsCoordinates, settings: HeadwindSettings): HttpResponseState.Complete { suspend fun KarooSystemService.makeOpenMeteoHttpRequest(gpsCoordinates: GpsCoordinates, settings: HeadwindSettings): HttpResponseState.Complete {
return callbackFlow { 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 // https://api.open-meteo.com/v1/forecast?latitude=52.52&longitude=13.41&current=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}&current=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}" val url = "https://api.open-meteo.com/v1/forecast?latitude=${gpsCoordinates.lat}&longitude=${gpsCoordinates.lon}&current=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}...") Log.d(KarooHeadwindExtension.TAG, "Http request to ${url}...")

View File

@ -10,7 +10,9 @@ import de.timklge.karooheadwind.datatypes.WindDirectionDataType
import de.timklge.karooheadwind.datatypes.WindGustsDataType import de.timklge.karooheadwind.datatypes.WindGustsDataType
import de.timklge.karooheadwind.datatypes.HeadwindSpeedDataType import de.timklge.karooheadwind.datatypes.HeadwindSpeedDataType
import de.timklge.karooheadwind.datatypes.HeadwindDirectionDataType import de.timklge.karooheadwind.datatypes.HeadwindDirectionDataType
import de.timklge.karooheadwind.datatypes.TemperatureDataType
import de.timklge.karooheadwind.datatypes.WeatherDataType import de.timklge.karooheadwind.datatypes.WeatherDataType
import de.timklge.karooheadwind.datatypes.WeatherForecastDataType
import de.timklge.karooheadwind.datatypes.WindSpeedDataType import de.timklge.karooheadwind.datatypes.WindSpeedDataType
import de.timklge.karooheadwind.screens.HeadwindStats import de.timklge.karooheadwind.screens.HeadwindStats
import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.KarooSystemService
@ -30,7 +32,7 @@ import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.minutes
class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.0.1") { class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.1") {
companion object { companion object {
const val TAG = "karoo-headwind" const val TAG = "karoo-headwind"
} }
@ -44,11 +46,13 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.0.1") {
HeadwindDirectionDataType(karooSystem, applicationContext), HeadwindDirectionDataType(karooSystem, applicationContext),
HeadwindSpeedDataType(karooSystem, applicationContext), HeadwindSpeedDataType(karooSystem, applicationContext),
WeatherDataType(karooSystem, applicationContext), WeatherDataType(karooSystem, applicationContext),
WeatherForecastDataType(karooSystem, applicationContext),
HeadwindSpeedDataType(karooSystem, applicationContext), HeadwindSpeedDataType(karooSystem, applicationContext),
RelativeHumidityDataType(applicationContext), RelativeHumidityDataType(applicationContext),
CloudCoverDataType(applicationContext), CloudCoverDataType(applicationContext),
WindGustsDataType(applicationContext), WindGustsDataType(applicationContext),
WindSpeedDataType(applicationContext), WindSpeedDataType(applicationContext),
TemperatureDataType(applicationContext),
WindDirectionDataType(karooSystem, applicationContext), WindDirectionDataType(karooSystem, applicationContext),
PrecipitationDataType(applicationContext), PrecipitationDataType(applicationContext),
SurfacePressureDataType(applicationContext) SurfacePressureDataType(applicationContext)

View File

@ -17,6 +17,18 @@ data class OpenMeteoData(
@SerialName("weather_code") val weatherCode: Int, @SerialName("weather_code") val weatherCode: Int,
) )
@Serializable
data class OpenMeteoForecastData(
@SerialName("time") val time: List<Long>,
@SerialName("temperature_2m") val temperature: List<Double>,
@SerialName("precipitation_probability") val precipitationProbability: List<Int>,
@SerialName("precipitation") val precipitation: List<Double>,
@SerialName("weather_code") val weatherCode: List<Int>,
@SerialName("wind_speed_10m") val windSpeed: List<Double>,
@SerialName("wind_direction_10m") val windDirection: List<Double>,
@SerialName("wind_gusts_10m") val windGusts: List<Double>,
)
enum class WeatherInterpretation { enum class WeatherInterpretation {
CLEAR, CLOUDY, RAINY, SNOWY, DRIZZLE, THUNDERSTORM, UNKNOWN; CLEAR, CLOUDY, RAINY, SNOWY, DRIZZLE, THUNDERSTORM, UNKNOWN;
@ -43,5 +55,6 @@ data class OpenMeteoCurrentWeatherResponse(
val longitude: Double, val longitude: Double,
val timezone: String, val timezone: String,
val elevation: Double, val elevation: Double,
@SerialName("utc_offset_seconds") val utfOffsetSeconds: Int @SerialName("utc_offset_seconds") val utfOffsetSeconds: Int,
@SerialName("hourly") val forecastData: OpenMeteoForecastData
) )

View File

@ -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
}
}

View File

@ -4,8 +4,12 @@ import android.content.Context
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.util.Log import android.util.Log
import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.DpSize
import androidx.glance.GlanceModifier
import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi
import androidx.glance.appwidget.GlanceRemoteViews 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.KarooHeadwindExtension
import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse
import de.timklge.karooheadwind.WeatherInterpretation import de.timklge.karooheadwind.WeatherInterpretation
@ -27,6 +31,9 @@ import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import kotlin.math.roundToInt import kotlin.math.roundToInt
@OptIn(ExperimentalGlanceRemoteViewsApi::class) @OptIn(ExperimentalGlanceRemoteViewsApi::class)
@ -36,6 +43,10 @@ class WeatherDataType(
) : DataTypeImpl("karoo-headwind", "weather") { ) : DataTypeImpl("karoo-headwind", "weather") {
private val glance = GlanceRemoteViews() 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 // FIXME: Remove. Currently, the data field will permanently show "no sensor" if no data stream is provided
override fun startStream(emitter: Emitter<StreamState>) { override fun startStream(emitter: Emitter<StreamState>) {
val job = CoroutineScope(Dispatchers.IO).launch { val job = CoroutineScope(Dispatchers.IO).launch {
@ -77,9 +88,24 @@ class WeatherDataType(
.collect { (data, settings) -> .collect { (data, settings) ->
Log.d(KarooHeadwindExtension.TAG, "Updating weather view") Log.d(KarooHeadwindExtension.TAG, "Updating weather view")
val interpretation = WeatherInterpretation.fromWeatherCode(data.current.weatherCode) val interpretation = WeatherInterpretation.fromWeatherCode(data.current.weatherCode)
val formattedTime = timeFormatter.format(Instant.ofEpochSecond(data.current.time))
val result = glance.compose(context, DpSize.Unspecified) { 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) emitter.updateView(result.remoteViews)

View File

@ -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<StreamState>) {
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<CycleHoursAction>()), 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()
}
}
}

View File

@ -10,22 +10,29 @@ import androidx.glance.ColorFilter
import androidx.glance.GlanceModifier import androidx.glance.GlanceModifier
import androidx.glance.Image import androidx.glance.Image
import androidx.glance.ImageProvider import androidx.glance.ImageProvider
import androidx.glance.LocalContext
import androidx.glance.color.ColorProvider import androidx.glance.color.ColorProvider
import androidx.glance.layout.Alignment import androidx.glance.layout.Alignment
import androidx.glance.layout.Column import androidx.glance.layout.Column
import androidx.glance.layout.ContentScale import androidx.glance.layout.ContentScale
import androidx.glance.layout.Row 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.height
import androidx.glance.layout.padding
import androidx.glance.layout.width import androidx.glance.layout.width
import androidx.glance.preview.ExperimentalGlancePreviewApi import androidx.glance.preview.ExperimentalGlancePreviewApi
import androidx.glance.preview.Preview import androidx.glance.preview.Preview
import androidx.glance.text.FontFamily import androidx.glance.text.FontFamily
import androidx.glance.text.FontWeight
import androidx.glance.text.Text import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle import androidx.glance.text.TextStyle
import de.timklge.karooheadwind.R import de.timklge.karooheadwind.R
import de.timklge.karooheadwind.WeatherInterpretation 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 { fun getWeatherIcon(interpretation: WeatherInterpretation): Int {
return when (interpretation){ return when (interpretation){
@ -42,10 +49,15 @@ fun getWeatherIcon(interpretation: WeatherInterpretation): Int {
@OptIn(ExperimentalGlancePreviewApi::class) @OptIn(ExperimentalGlancePreviewApi::class)
@Preview(widthDp = 200, heightDp = 150) @Preview(widthDp = 200, heightDp = 150)
@Composable @Composable
fun Weather(baseBitmap: Bitmap, current: WeatherInterpretation, windBearing: Int, windSpeed: Int, windGusts: Int) { fun Weather(baseBitmap: Bitmap, current: WeatherInterpretation, windBearing: Int, windSpeed: Int, windGusts: Int, windSpeedUnit: WindUnit,
Column(modifier = GlanceModifier.fillMaxSize(), horizontalAlignment = Alignment.End) { 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) { Row(modifier = GlanceModifier.defaultWeight(), horizontalAlignment = Alignment.End) {
val imageW = 70 val imageW = 60
val imageH = (imageW * (280.0 / 400)).toInt() val imageH = (imageW * (280.0 / 400)).toInt()
Image( Image(
modifier = GlanceModifier.height(imageH.dp).width(imageW.dp), 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( Image(
modifier = GlanceModifier.height(20.dp).width(12.dp), modifier = GlanceModifier.height(20.dp).width(12.dp),
provider = ImageProvider(getArrowBitmapByBearing(baseBitmap, windBearing + 180)), 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)) colorFilter = ColorFilter.tint(ColorProvider(Color.Black, Color.White))
) )
Text( Text(
text = "$windSpeed,$windGusts", text = "$windSpeed,${windGusts}",
style = TextStyle(color = ColorProvider(Color.Black, Color.White), fontFamily = FontFamily.Monospace, fontSize = TextUnit(18f, TextUnitType.Sp)) style = TextStyle(color = ColorProvider(Color.Black, Color.White), fontFamily = FontFamily.Monospace, fontSize = TextUnit(fontSize, TextUnitType.Sp))
) )
} }
} }

View File

@ -55,9 +55,9 @@ enum class WindUnit(val id: String, val label: String, val unitDisplay: String){
KNOTS("kn", "Knots (kn)", "kn") KNOTS("kn", "Knots (kn)", "kn")
} }
enum class PrecipitationUnit(val id: String, val label: String){ enum class PrecipitationUnit(val id: String, val label: String, val unitDisplay: String){
MILLIMETERS("mm", "Millimeters (mm)"), MILLIMETERS("mm", "Millimeters (mm)", "mm"),
INCH("inch", "Inch") INCH("inch", "Inch", "in")
} }
enum class WindDirectionIndicatorTextSetting(val id: String, val label: String){ 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") 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 @Serializable
data class HeadwindSettings( data class HeadwindSettings(
val windUnit: WindUnit = WindUnit.KILOMETERS_PER_HOUR, val windUnit: WindUnit = WindUnit.KILOMETERS_PER_HOUR,
val precipitationUnit: PrecipitationUnit = PrecipitationUnit.MILLIMETERS, val precipitationUnit: PrecipitationUnit = PrecipitationUnit.MILLIMETERS,
val temperatureUnit: TemperatureUnit = TemperatureUnit.CELSIUS,
val welcomeDialogAccepted: Boolean = false, val welcomeDialogAccepted: Boolean = false,
val windDirectionIndicatorTextSetting: WindDirectionIndicatorTextSetting = WindDirectionIndicatorTextSetting.HEADWIND_SPEED, 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 @Serializable
data class HeadwindStats( data class HeadwindStats(
val lastSuccessfulWeatherRequest: Long? = null, val lastSuccessfulWeatherRequest: Long? = null,
@ -99,6 +114,7 @@ fun MainScreen() {
var selectedWindUnit by remember { mutableStateOf(WindUnit.KILOMETERS_PER_HOUR) } var selectedWindUnit by remember { mutableStateOf(WindUnit.KILOMETERS_PER_HOUR) }
var selectedPrecipitationUnit by remember { mutableStateOf(PrecipitationUnit.MILLIMETERS) } var selectedPrecipitationUnit by remember { mutableStateOf(PrecipitationUnit.MILLIMETERS) }
var selectedTemperatureUnit by remember { mutableStateOf(TemperatureUnit.CELSIUS) }
var welcomeDialogVisible by remember { mutableStateOf(false) } var welcomeDialogVisible by remember { mutableStateOf(false) }
var selectedWindDirectionIndicatorTextSetting by remember { mutableStateOf(WindDirectionIndicatorTextSetting.HEADWIND_SPEED) } var selectedWindDirectionIndicatorTextSetting by remember { mutableStateOf(WindDirectionIndicatorTextSetting.HEADWIND_SPEED) }
@ -113,6 +129,7 @@ fun MainScreen() {
selectedPrecipitationUnit = settings.precipitationUnit selectedPrecipitationUnit = settings.precipitationUnit
welcomeDialogVisible = !settings.welcomeDialogAccepted welcomeDialogVisible = !settings.welcomeDialogAccepted
selectedWindDirectionIndicatorTextSetting = settings.windDirectionIndicatorTextSetting selectedWindDirectionIndicatorTextSetting = settings.windDirectionIndicatorTextSetting
selectedTemperatureUnit = settings.temperatureUnit
} }
} }
@ -131,6 +148,14 @@ fun MainScreen() {
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(10.dp)) { .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 windSpeedUnitDropdownOptions = WindUnit.entries.toList().map { unit -> DropdownOption(unit.id, unit.label) }
val windSpeedUnitInitialSelection by remember(selectedWindUnit) { val windSpeedUnitInitialSelection by remember(selectedWindUnit) {
mutableStateOf(windSpeedUnitDropdownOptions.find { option -> option.id == selectedWindUnit.id }!!) mutableStateOf(windSpeedUnitDropdownOptions.find { option -> option.id == selectedWindUnit.id }!!)
@ -147,18 +172,19 @@ fun MainScreen() {
selectedPrecipitationUnit = PrecipitationUnit.entries.find { unit -> unit.id == selectedOption.id }!! selectedPrecipitationUnit = PrecipitationUnit.entries.find { unit -> unit.id == selectedOption.id }!!
} }
val windDirectionIndicatorTextSettingDropdownOptions = WindDirectionIndicatorTextSetting.entries.toList().map { unit -> DropdownOption(unit.id, unit.label) } val temperatureUnitDropdownOptions = TemperatureUnit.entries.toList().map { unit -> DropdownOption(unit.id, unit.label) }
val windDirectionIndicatorTextSettingSelection by remember(selectedWindDirectionIndicatorTextSetting) { val temperatureUnitInitialSelection by remember(selectedTemperatureUnit) {
mutableStateOf(windDirectionIndicatorTextSettingDropdownOptions.find { option -> option.id == selectedWindDirectionIndicatorTextSetting.id }!!) mutableStateOf(temperatureUnitDropdownOptions.find { option -> option.id == selectedTemperatureUnit.id }!!)
} }
Dropdown(label = "Text on headwind indicator", options = windDirectionIndicatorTextSettingDropdownOptions, selected = windDirectionIndicatorTextSettingSelection) { selectedOption -> Dropdown(label = "Temperature Unit", options = temperatureUnitDropdownOptions, selected = temperatureUnitInitialSelection) { selectedOption ->
selectedWindDirectionIndicatorTextSetting = WindDirectionIndicatorTextSetting.entries.find { unit -> unit.id == selectedOption.id }!! selectedTemperatureUnit = TemperatureUnit.entries.find { unit -> unit.id == selectedOption.id }!!
} }
FilledTonalButton(modifier = Modifier FilledTonalButton(modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(50.dp), onClick = { .height(50.dp), onClick = {
val newSettings = HeadwindSettings(windUnit = selectedWindUnit, precipitationUnit = selectedPrecipitationUnit, val newSettings = HeadwindSettings(windUnit = selectedWindUnit, precipitationUnit = selectedPrecipitationUnit,
temperatureUnit = selectedTemperatureUnit,
welcomeDialogAccepted = true, windDirectionIndicatorTextSetting = selectedWindDirectionIndicatorTextSetting) welcomeDialogAccepted = true, windDirectionIndicatorTextSetting = selectedWindDirectionIndicatorTextSetting)
coroutineScope.launch { coroutineScope.launch {
@ -210,7 +236,10 @@ fun MainScreen() {
AlertDialog(onDismissRequest = { }, AlertDialog(onDismissRequest = { },
confirmButton = { Button(onClick = { confirmButton = { Button(onClick = {
coroutineScope.launch { 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("OK") } },
text = { text = {

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -19,6 +19,10 @@
<string name="surfacePressure_description">Atmospheric pressure at surface in configured unit</string> <string name="surfacePressure_description">Atmospheric pressure at surface in configured unit</string>
<string name="weather">Weather</string> <string name="weather">Weather</string>
<string name="weather_description">Current weather conditions</string> <string name="weather_description">Current weather conditions</string>
<string name="weather_forecast">Weather Forecast</string>
<string name="weather_forecast_description">Current hourly weather forecast</string>
<string name="headwind_speed">Headwind speed</string> <string name="headwind_speed">Headwind speed</string>
<string name="headwind_speed_description">Current headwind speed</string> <string name="headwind_speed_description">Current headwind speed</string>
<string name="temperature">Temperature</string>
<string name="temperature_description">Current temperature in configured unit</string>
</resources> </resources>

View File

@ -18,6 +18,13 @@
icon="@drawable/ic_launcher" icon="@drawable/ic_launcher"
typeId="weather" /> typeId="weather" />
<DataType
description="@string/weather_forecast_description"
displayName="@string/weather_forecast"
graphical="true"
icon="@drawable/ic_launcher"
typeId="weatherForecast" />
<DataType <DataType
description="@string/headwind_speed_description" description="@string/headwind_speed_description"
displayName="@string/headwind_speed" displayName="@string/headwind_speed"
@ -73,4 +80,11 @@
graphical="false" graphical="false"
icon="@drawable/ic_cloud" icon="@drawable/ic_cloud"
typeId="surfacePressure" /> typeId="surfacePressure" />
<DataType
description="@string/temperature_description"
displayName="@string/temperature"
graphical="false"
icon="@drawable/thermometer"
typeId="temperature" />
</ExtensionInfo> </ExtensionInfo>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 41 KiB