parent
c259c24adb
commit
be701e4057
@ -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.
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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"
|
||||
}
|
||||
@ -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}¤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}...")
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
)
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 = {
|
||||
|
||||
BIN
app/src/main/res/drawable/thermometer.png
Normal file
BIN
app/src/main/res/drawable/thermometer.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
app/src/main/res/drawable/water_regular.png
Normal file
BIN
app/src/main/res/drawable/water_regular.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@ -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>
|
||||
@ -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>
|
||||
|
||||
BIN
preview0.png
BIN
preview0.png
Binary file not shown.
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 41 KiB |
Loading…
x
Reference in New Issue
Block a user