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

View File

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

View File

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

View File

@ -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<OpenMeteoCurrentWeatherResponse> {
}.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> {
return dataStore.data.map { settingsJson ->
try {
@ -100,8 +125,9 @@ fun Context.streamSettings(karooSystemService: KarooSystemService): Flow<Headwin
val preferredMetric = preferredUnits.distance == UserProfile.PreferredUnit.UnitType.METRIC
defaultSettings.copy(
windUnit = if (preferredMetric) WindUnit.KILOMETERS_PER_HOUR else WindUnit.MILES_PER_HOUR,
precipitationUnit = if (preferredMetric) PrecipitationUnit.MILLIMETERS else PrecipitationUnit.INCH
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,
temperatureUnit = if (preferredUnits.temperature == UserProfile.PreferredUnit.UnitType.METRIC) TemperatureUnit.CELSIUS else TemperatureUnit.FAHRENHEIT
)
}
} catch(e: Throwable){
@ -138,8 +164,8 @@ fun KarooSystemService.streamUserProfile(): Flow<UserProfile> {
@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}&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}"
// 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=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<Double> {
}
fun KarooSystemService.getHeadingFlow(): Flow<Double> {
//return flowOf(20.0)
// return flowOf(20.0)
return streamDataFlow(DataType.Type.LOCATION)
.mapNotNull { (it as? StreamState.Streaming)?.dataPoint?.values }

View File

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

View File

@ -17,6 +17,18 @@ data class OpenMeteoData(
@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 {
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
)

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.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<StreamState>) {
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)

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.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))
)
}
}

View File

@ -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 = {

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="weather">Weather</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_description">Current headwind speed</string>
<string name="temperature">Temperature</string>
<string name="temperature_description">Current temperature in configured unit</string>
</resources>

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 41 KiB