Forecast weather along route in fixed intervals if route is loaded (#52)

* Prototype simplified weather forecast along route

* Reset last known route progression on route change

* Add forecast distance per hour setting

* Only decode response as gzip if gzipped

* Bump version to 1.3
This commit is contained in:
timklge 2025-03-02 12:46:40 +01:00 committed by GitHub
parent 548b81d7a4
commit de009454b8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 525 additions and 200 deletions

View File

@ -15,8 +15,8 @@ android {
applicationId = "de.timklge.karooheadwind"
minSdk = 26
targetSdk = 35
versionCode = 13
versionName = "1.2.5"
versionCode = 14
versionName = "1.3"
}
signingConfigs {
@ -55,6 +55,7 @@ android {
}
dependencies {
implementation(libs.mapbox.sdk.turf)
implementation(libs.hammerhead.karoo.ext)
implementation(libs.androidx.core.ktx)
implementation(libs.bundles.androidx.lifeycle)

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.2.5",
"latestVersionCode": 13,
"latestVersion": "1.3",
"latestVersionCode": 14,
"developer": "timklge",
"description": "Provides headwind direction, wind speed and other weather data fields",
"releaseNotes": "Include more directions in wind direction data field, do not use streams for secondary data fields to work around issue in current karoo release"
"releaseNotes": "Forecast weather along route in fixed intervals if route is loaded",
}

View File

@ -4,20 +4,32 @@ import android.content.Context
import android.util.Log
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.mapbox.geojson.LineString
import com.mapbox.turf.TurfConstants
import com.mapbox.turf.TurfMeasurement
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.WindUnit
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.DataType
import io.hammerhead.karooext.models.OnNavigationState
import io.hammerhead.karooext.models.StreamState
import io.hammerhead.karooext.models.UserProfile
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@ -26,7 +38,7 @@ val jsonWithUnknownKeys = Json { ignoreUnknownKeys = true }
val settingsKey = stringPreferencesKey("settings")
val widgetSettingsKey = stringPreferencesKey("widgetSettings")
val currentDataKey = stringPreferencesKey("current")
val currentDataKey = stringPreferencesKey("currentForecasts")
val statsKey = stringPreferencesKey("stats")
val lastKnownPositionKey = stringPreferencesKey("lastKnownPosition")
@ -48,7 +60,10 @@ suspend fun saveStats(context: Context, stats: HeadwindStats) {
}
}
suspend fun saveCurrentData(context: Context, forecast: OpenMeteoCurrentWeatherResponse) {
@Serializable
data class WeatherDataResponse(val data: OpenMeteoCurrentWeatherResponse, val requestedPosition: GpsCoordinates)
suspend fun saveCurrentData(context: Context, forecast: List<WeatherDataResponse>) {
context.dataStore.edit { t ->
t[currentDataKey] = Json.encodeToString(forecast)
}
@ -66,7 +81,6 @@ suspend fun saveLastKnownPosition(context: Context, gpsCoordinates: GpsCoordinat
}
}
fun Context.streamWidgetSettings(): Flow<HeadwindWidgetSettings> {
return dataStore.data.map { settingsJson ->
try {
@ -103,6 +117,45 @@ fun Context.streamSettings(karooSystemService: KarooSystemService): Flow<Headwin
}.distinctUntilChanged()
}
data class UpcomingRoute(val distanceAlongRoute: Double, val routePolyline: LineString, val routeLength: Double)
fun KarooSystemService.streamUpcomingRoute(): Flow<UpcomingRoute?> {
val distanceToDestinationStream = flow {
emit(null)
streamDataFlow(DataType.Type.DISTANCE_TO_DESTINATION)
.map { (it as? StreamState.Streaming)?.dataPoint?.singleValue }
.filter { it != 0.0 } // FIXME why is 0 sometimes emitted if no route is loaded?
.collect { emit(it) }
}
var lastKnownDistanceAlongRoute = 0.0
var lastKnownRoutePolyline: LineString? = null
val navigationStateStream = streamNavigationState()
.map { it.state as? OnNavigationState.NavigationState.NavigatingRoute }
.map { navigationState ->
navigationState?.let { LineString.fromPolyline(it.routePolyline, 5) }
}
.combine(distanceToDestinationStream) { routePolyline, distanceToDestination ->
if (routePolyline != null){
val length = TurfMeasurement.length(routePolyline, TurfConstants.UNIT_METERS)
if (routePolyline != lastKnownRoutePolyline){
lastKnownDistanceAlongRoute = 0.0
}
val distanceAlongRoute = distanceToDestination?.let { toDest -> length - toDest } ?: lastKnownDistanceAlongRoute
lastKnownDistanceAlongRoute = distanceAlongRoute
lastKnownRoutePolyline = routePolyline
UpcomingRoute(distanceAlongRoute, routePolyline, length)
} else {
null
}
}
return navigationStateStream
}
fun Context.streamStats(): Flow<HeadwindStats> {
return dataStore.data.map { statsJson ->
try {
@ -143,20 +196,18 @@ fun KarooSystemService.streamUserProfile(): Flow<UserProfile> {
}
}
fun Context.streamCurrentWeatherData(): Flow<OpenMeteoCurrentWeatherResponse?> {
fun Context.streamCurrentWeatherData(): Flow<List<WeatherDataResponse>> {
return dataStore.data.map { settingsJson ->
try {
val data = settingsJson[currentDataKey]
data?.let { d -> jsonWithUnknownKeys.decodeFromString<OpenMeteoCurrentWeatherResponse>(d) }
data?.let { d -> jsonWithUnknownKeys.decodeFromString<List<WeatherDataResponse>>(d) } ?: emptyList()
} catch (e: Throwable) {
Log.e(KarooHeadwindExtension.TAG, "Failed to read weather data", e)
null
emptyList()
}
}.distinctUntilChanged().map { response ->
if (response != null && response.current.time * 1000 >= System.currentTimeMillis() - (1000 * 60 * 60 * 12)){
response
} else {
null
response.filter { forecast ->
forecast.data.current.time * 1000 >= System.currentTimeMillis() - (1000 * 60 * 60 * 12)
}
}
}

View File

@ -33,6 +33,7 @@ import io.hammerhead.karooext.models.DataType
import io.hammerhead.karooext.models.HttpResponseState
import io.hammerhead.karooext.models.OnHttpResponse
import io.hammerhead.karooext.models.OnLocationChanged
import io.hammerhead.karooext.models.OnNavigationState
import io.hammerhead.karooext.models.OnStreamState
import io.hammerhead.karooext.models.StreamState
import io.hammerhead.karooext.models.UserProfile
@ -84,6 +85,17 @@ fun KarooSystemService.streamLocation(): Flow<OnLocationChanged> {
}
}
fun KarooSystemService.streamNavigationState(): Flow<OnNavigationState> {
return callbackFlow {
val listenerId = addConsumer { event: OnNavigationState ->
trySendBlocking(event)
}
awaitClose {
removeConsumer(listenerId)
}
}
}
fun<T> Flow<T>.throttle(timeout: Long): Flow<T> = flow {
var lastEmissionTime = 0L

View File

@ -4,6 +4,7 @@ import android.content.Context
import android.util.Log
import de.timklge.karooheadwind.datatypes.GpsCoordinates
import io.hammerhead.karooext.KarooSystemService
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
@ -12,6 +13,7 @@ import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
@ -28,8 +30,8 @@ fun KarooSystemService.getRelativeHeadingFlow(context: Context): Flow<HeadingRes
.combine(currentWeatherData) { bearing, data -> bearing to data }
.map { (bearing, data) ->
when {
bearing is HeadingResponse.Value && data != null -> {
val windBearing = data.current.windDirection + 180
bearing is HeadingResponse.Value && data.isNotEmpty() -> {
val windBearing = data.first().data.current.windDirection + 180
val diff = signedAngleDifference(bearing.diff, windBearing)
Log.d(KarooHeadwindExtension.TAG, "Wind bearing: Heading $bearing vs wind $windBearing => $diff")
@ -37,7 +39,7 @@ fun KarooSystemService.getRelativeHeadingFlow(context: Context): Flow<HeadingRes
HeadingResponse.Value(diff)
}
bearing is HeadingResponse.NoGps -> HeadingResponse.NoGps
bearing is HeadingResponse.NoWeatherData || data == null -> HeadingResponse.NoWeatherData
bearing is HeadingResponse.NoWeatherData || data.isEmpty() -> HeadingResponse.NoWeatherData
else -> bearing
}
}

View File

@ -1,14 +1,16 @@
package de.timklge.karooheadwind
import android.util.Log
import androidx.compose.ui.util.fastZip
import com.mapbox.geojson.LineString
import com.mapbox.turf.TurfConstants
import com.mapbox.turf.TurfMeasurement
import de.timklge.karooheadwind.datatypes.CloudCoverDataType
import de.timklge.karooheadwind.datatypes.GpsCoordinates
import de.timklge.karooheadwind.datatypes.HeadwindSpeedDataType
import de.timklge.karooheadwind.datatypes.PrecipitationDataType
import de.timklge.karooheadwind.datatypes.RelativeHumidityDataType
import de.timklge.karooheadwind.datatypes.SurfacePressureDataType
import de.timklge.karooheadwind.datatypes.WindDirectionDataType
import de.timklge.karooheadwind.datatypes.WindGustsDataType
import de.timklge.karooheadwind.datatypes.HeadwindSpeedDataType
import de.timklge.karooheadwind.datatypes.TailwindAndRideSpeedDataType
import de.timklge.karooheadwind.datatypes.HeadwindDirectionDataType
import de.timklge.karooheadwind.datatypes.SealevelPressureDataType
@ -16,6 +18,8 @@ import de.timklge.karooheadwind.datatypes.TemperatureDataType
import de.timklge.karooheadwind.datatypes.UserWindSpeedDataType
import de.timklge.karooheadwind.datatypes.WeatherDataType
import de.timklge.karooheadwind.datatypes.WeatherForecastDataType
import de.timklge.karooheadwind.datatypes.WindDirectionDataType
import de.timklge.karooheadwind.datatypes.WindGustsDataType
import de.timklge.karooheadwind.datatypes.WindSpeedDataType
import de.timklge.karooheadwind.screens.HeadwindSettings
import de.timklge.karooheadwind.screens.HeadwindStats
@ -31,6 +35,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
@ -38,17 +43,23 @@ import kotlinx.coroutines.flow.retry
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.time.debounce
import kotlinx.coroutines.withContext
import java.time.Duration
import java.time.LocalDateTime
import java.time.temporal.ChronoUnit
import java.util.Locale
import java.util.zip.GZIPInputStream
import kotlin.math.absoluteValue
import kotlin.math.roundToInt
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.2.5") {
class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.3") {
companion object {
const val TAG = "karoo-headwind"
}
lateinit var karooSystem: KarooSystemService
private lateinit var karooSystem: KarooSystemService
private var updateLastKnownGpsJob: Job? = null
private var serviceJob: Job? = null
@ -75,7 +86,7 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.2.5") {
}
data class StreamData(val settings: HeadwindSettings, val gps: GpsCoordinates?,
val profile: UserProfile? = null)
val profile: UserProfile? = null, val upcomingRoute: UpcomingRoute? = null)
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
override fun onCreate() {
@ -104,18 +115,26 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.2.5") {
}
}
.debounce(Duration.ofSeconds(5))
.transformLatest { value: GpsCoordinates? ->
var requestedGpsCoordinates: List<GpsCoordinates> = mutableListOf()
val settingsStream = streamSettings(karooSystem)
.filter { it.welcomeDialogAccepted }
data class StreamData(val settings: HeadwindSettings, val gps: GpsCoordinates?, val profile: UserProfile?, val upcomingRoute: UpcomingRoute?)
data class StreamDataIdentity(val settings: HeadwindSettings, val gpsLat: Double?, val gpsLon: Double?, val profile: UserProfile?, val routePolyline: LineString?)
combine(settingsStream, gpsFlow, karooSystem.streamUserProfile(), karooSystem.streamUpcomingRoute()) { settings, gps, profile, upcomingRoute ->
StreamData(settings, gps, profile, upcomingRoute)
}
.distinctUntilChangedBy { StreamDataIdentity(it.settings, it.gps?.lat, it.gps?.lon, it.profile, it.upcomingRoute?.routePolyline) }
.transformLatest { value ->
while(true){
emit(value)
delay(1.hours)
}
}
streamSettings(karooSystem)
.filter { it.welcomeDialogAccepted }
.combine(gpsFlow) { settings, gps -> StreamData(settings, gps) }
.combine(karooSystem.streamUserProfile()) { data, profile -> data.copy(profile = profile) }
.map { (settings, gps, profile) ->
.map { (settings: HeadwindSettings, gps, profile, upcomingRoute) ->
Log.d(TAG, "Acquired updated gps coordinates: $gps")
val lastKnownStats = try {
@ -129,7 +148,45 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.2.5") {
error("No GPS coordinates available")
}
val response = karooSystem.makeOpenMeteoHttpRequest(gps, settings, profile)
if (upcomingRoute != null){
val positionOnRoute = upcomingRoute.distanceAlongRoute
Log.i(TAG, "Position on route: ${positionOnRoute}m")
val distancePerHour = settings.getForecastMetersPerHour(profile?.preferredUnit?.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL).toDouble()
val msSinceFullHour = let {
val now = LocalDateTime.now()
val startOfHour = now.truncatedTo(ChronoUnit.HOURS)
ChronoUnit.MILLIS.between(startOfHour, now)
}
val msToNextFullHour = (1_000 * 60 * 60) - msSinceFullHour
val calculatedDistanceToNextFullHour = (msToNextFullHour / (1_000.0 * 60 * 60)) * distancePerHour
val distanceToNextFullHour = if (calculatedDistanceToNextFullHour > 5_000) calculatedDistanceToNextFullHour else distancePerHour
Log.d(TAG, "Minutes to next full hour: ${msToNextFullHour / 1000 / 60}, Distance to next full hour: ${(distanceToNextFullHour / 1000).roundToInt()}km (calculated: ${(calculatedDistanceToNextFullHour / 1000).roundToInt()}km)")
requestedGpsCoordinates = buildList {
add(gps)
var currentPosition = positionOnRoute + distanceToNextFullHour
var lastRequestedPosition = currentPosition
while (currentPosition < upcomingRoute.routeLength && size < 10){
val point = TurfMeasurement.along(upcomingRoute.routePolyline, currentPosition, TurfConstants.UNIT_METERS)
add(GpsCoordinates(point.latitude(), point.longitude(), distanceAlongRoute = currentPosition))
lastRequestedPosition = currentPosition
currentPosition += distancePerHour
}
if (upcomingRoute.routeLength > lastRequestedPosition + 5_000){
val point = TurfMeasurement.along(upcomingRoute.routePolyline, upcomingRoute.routeLength, TurfConstants.UNIT_METERS)
add(GpsCoordinates(point.latitude(), point.longitude(), distanceAlongRoute = upcomingRoute.routeLength))
}
}
} else {
requestedGpsCoordinates = mutableListOf(gps)
}
val response = karooSystem.makeOpenMeteoHttpRequest(requestedGpsCoordinates, settings, profile)
if (response.error != null){
try {
val stats = lastKnownStats.copy(failedWeatherRequest = System.currentTimeMillis())
@ -151,17 +208,37 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.2.5") {
}
response
}
.retry(Long.MAX_VALUE) { delay(1.minutes); true }
.collect { response ->
}.retry(Long.MAX_VALUE) { e ->
Log.w(TAG, "Failed to get weather data", e)
delay(1.minutes); true
}.collect { response ->
try {
val responseString = String(response.body ?: ByteArray(0))
val data = jsonWithUnknownKeys.decodeFromString<OpenMeteoCurrentWeatherResponse>(responseString)
val inputStream = java.io.ByteArrayInputStream(response.body ?: ByteArray(0))
val lowercaseHeaders = response.headers.map { (k: String, v: String) -> k.lowercase() to v.lowercase() }.toMap()
val isGzippedResponse = lowercaseHeaders["content-encoding"]?.contains("gzip") == true
val responseString = if(isGzippedResponse){
val gzipStream = withContext(Dispatchers.IO) { GZIPInputStream(inputStream) }
gzipStream.use { stream -> String(stream.readBytes()) }
} else {
inputStream.use { stream -> String(stream.readBytes()) }
}
if (requestedGpsCoordinates.size == 1){
val weatherData = jsonWithUnknownKeys.decodeFromString<OpenMeteoCurrentWeatherResponse>(responseString)
val data = WeatherDataResponse(weatherData, requestedGpsCoordinates.single())
saveCurrentData(applicationContext, data)
saveWidgetSettings(applicationContext, HeadwindWidgetSettings(currentForecastHourOffset = 0))
saveCurrentData(applicationContext, listOf(data))
Log.d(TAG, "Got updated weather info: $data")
} else {
val weatherData = jsonWithUnknownKeys.decodeFromString<List<OpenMeteoCurrentWeatherResponse>>(responseString)
val data = weatherData.fastZip(requestedGpsCoordinates) { weather, gps -> WeatherDataResponse(weather, gps) }
saveCurrentData(applicationContext, data)
Log.d(TAG, "Got updated weather info: $data")
}
saveWidgetSettings(applicationContext, HeadwindWidgetSettings(currentForecastHourOffset = 0))
} catch(e: Exception){
Log.e(TAG, "Failed to read current weather data", e)
}
@ -172,6 +249,10 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.2.5") {
override fun onDestroy() {
serviceJob?.cancel()
serviceJob = null
updateLastKnownGpsJob?.cancel()
updateLastKnownGpsJob = null
karooSystem.disconnect()
super.onDestroy()
}

View File

@ -16,16 +16,19 @@ import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.single
import kotlinx.coroutines.flow.timeout
import java.util.Locale
import kotlin.time.Duration.Companion.seconds
@OptIn(FlowPreview::class)
suspend fun KarooSystemService.makeOpenMeteoHttpRequest(gpsCoordinates: GpsCoordinates, settings: HeadwindSettings, profile: UserProfile?): HttpResponseState.Complete {
suspend fun KarooSystemService.makeOpenMeteoHttpRequest(gpsCoordinates: List<GpsCoordinates>, settings: HeadwindSettings, profile: UserProfile?): HttpResponseState.Complete {
val precipitationUnit = if (profile?.preferredUnit?.distance != UserProfile.PreferredUnit.UnitType.IMPERIAL) PrecipitationUnit.MILLIMETERS else PrecipitationUnit.INCH
val temperatureUnit = if (profile?.preferredUnit?.temperature != UserProfile.PreferredUnit.UnitType.IMPERIAL) TemperatureUnit.CELSIUS else TemperatureUnit.FAHRENHEIT
return callbackFlow {
// https://api.open-meteo.com/v1/forecast?latitude=52.52&longitude=13.41&current=surface_pressure,pressure_msl,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,pressure_msl,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=${precipitationUnit.id}&temperature_unit=${temperatureUnit.id}"
val lats = gpsCoordinates.joinToString(",") { String.format(Locale.US, "%.6f", it.lat) }
val lons = gpsCoordinates.joinToString(",") { String.format(Locale.US, "%.6f", it.lon) }
val url = "https://api.open-meteo.com/v1/forecast?latitude=${lats}&longitude=${lons}&current=surface_pressure,pressure_msl,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=${precipitationUnit.id}&temperature_unit=${temperatureUnit.id}"
Log.d(KarooHeadwindExtension.TAG, "Http request to ${url}...")
@ -34,18 +37,23 @@ suspend fun KarooSystemService.makeOpenMeteoHttpRequest(gpsCoordinates: GpsCoord
"GET",
url,
waitForConnection = false,
headers = mapOf("User-Agent" to KarooHeadwindExtension.TAG, "Accept-Encoding" to "gzip"),
),
) { event: OnHttpResponse ->
Log.d(KarooHeadwindExtension.TAG, "Http response event $event")
onEvent = { event: OnHttpResponse ->
if (event.state is HttpResponseState.Complete){
Log.d(KarooHeadwindExtension.TAG, "Http response received")
trySend(event.state as HttpResponseState.Complete)
close()
}
}
},
onError = { err ->
Log.d(KarooHeadwindExtension.TAG, "Http error: $err")
close(RuntimeException(err))
})
awaitClose {
removeConsumer(listenerId)
}
}.timeout(20.seconds).catch { e: Throwable ->
}.timeout(30.seconds).catch { e: Throwable ->
if (e is TimeoutCancellationException){
emit(HttpResponseState.Complete(500, mapOf(), null, "Timeout"))
} else {

View File

@ -29,9 +29,13 @@ abstract class BaseDataType(
currentWeatherData
.filterNotNull()
.collect { data ->
val value = getValue(data)
val value = data.firstOrNull()?.data?.let { w -> getValue(w) }
Log.d(KarooHeadwindExtension.TAG, "$dataTypeId: $value")
if (value != null){
emitter.onNext(StreamState.Streaming(DataPoint(dataTypeId, mapOf(DataType.Field.SINGLE to value))))
} else {
emitter.onNext(StreamState.NotAvailable)
}
}
}
emitter.setCancellable {

View File

@ -0,0 +1,37 @@
package de.timklge.karooheadwind.datatypes
import android.content.Context
import android.util.Log
import androidx.glance.GlanceId
import androidx.glance.action.ActionParameters
import androidx.glance.appwidget.action.ActionCallback
import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.saveWidgetSettings
import de.timklge.karooheadwind.streamCurrentWeatherData
import de.timklge.karooheadwind.streamWidgetSettings
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
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().firstOrNull()
var hourOffset = currentSettings.currentForecastHourOffset + 3
val requestedPositions = data?.size
val requestedHours = data?.firstOrNull()?.data?.forecastData?.weatherCode?.size
if (data == null || requestedHours == null || requestedPositions == null || hourOffset >= requestedHours || (requestedPositions > 1 && hourOffset >= requestedPositions)) {
hourOffset = 0
}
val newSettings = currentSettings.copy(currentForecastHourOffset = hourOffset)
saveWidgetSettings(context, newSettings)
}
}

View File

@ -9,7 +9,7 @@ import kotlin.math.sin
import kotlin.math.sqrt
@Serializable
data class GpsCoordinates(val lat: Double, val lon: Double, val bearing: Double? = 0.0){
data class GpsCoordinates(val lat: Double, val lon: Double, val bearing: Double? = 0.0, val distanceAlongRoute: Double? = null){
companion object {
private fun roundDegrees(degrees: Double, km: Double): Double {
val nkm = degrees * 111

View File

@ -44,7 +44,9 @@ class HeadwindDirectionDataType(
private fun streamValues(): Flow<Double> = flow {
karooSystem.getRelativeHeadingFlow(applicationContext)
.combine(applicationContext.streamCurrentWeatherData()) { headingResponse, data -> StreamData(headingResponse, data?.current?.windDirection, data?.current?.windSpeed) }
.combine(applicationContext.streamCurrentWeatherData()) { headingResponse, data ->
StreamData(headingResponse, data.firstOrNull()?.data?.current?.windDirection, data.firstOrNull()?.data?.current?.windSpeed)
}
.combine(applicationContext.streamSettings(karooSystem)) { data, settings -> data.copy(settings = settings) }
.collect { streamData ->
val value = (streamData.headingResponse as? HeadingResponse.Value)?.diff

View File

@ -30,7 +30,9 @@ class HeadwindSpeedDataType(
val job = CoroutineScope(Dispatchers.IO).launch {
karooSystem.getRelativeHeadingFlow(context)
.combine(context.streamCurrentWeatherData()) { value, data -> value to data }
.combine(context.streamSettings(karooSystem)) { (value, data), settings -> StreamData(value, data, settings) }
.combine(context.streamSettings(karooSystem)) { (value, data), settings ->
StreamData(value, data.firstOrNull()?.data, settings)
}
.filter { it.weatherResponse != null }
.collect { streamData ->
val windSpeed = streamData.weatherResponse?.current?.windSpeed ?: 0.0

View File

@ -115,7 +115,7 @@ class TailwindAndRideSpeedDataType(
karooSystem.getRelativeHeadingFlow(context)
.combine(context.streamCurrentWeatherData()) { value, data -> value to data }
.combine(context.streamSettings(karooSystem)) { (value, data), settings ->
StreamData(value, data?.current?.windDirection, data?.current?.windSpeed, settings)
StreamData(value, data.firstOrNull()?.data?.current?.windDirection, data.firstOrNull()?.data?.current?.windSpeed, settings)
}
.combine(karooSystem.streamUserProfile()) { streamData, userProfile ->
val isImperial = userProfile.preferredUnit.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL

View File

@ -34,7 +34,9 @@ class UserWindSpeedDataType(
fun streamValues(context: Context, karooSystem: KarooSystemService): Flow<Double> = flow {
karooSystem.getRelativeHeadingFlow(context)
.combine(context.streamCurrentWeatherData()) { value, data -> value to data }
.combine(context.streamSettings(karooSystem)) { (value, data), settings -> StreamData(value, data, settings) }
.combine(context.streamSettings(karooSystem)) { (value, data), settings ->
StreamData(value, data.firstOrNull()?.data, settings)
}
.filter { it.weatherResponse != null }
.collect { streamData ->
val windSpeed = streamData.weatherResponse?.current?.windSpeed ?: 0.0

View File

@ -17,7 +17,6 @@ import de.timklge.karooheadwind.OpenMeteoData
import de.timklge.karooheadwind.WeatherInterpretation
import de.timklge.karooheadwind.getHeadingFlow
import de.timklge.karooheadwind.screens.HeadwindSettings
import de.timklge.karooheadwind.screens.PrecipitationUnit
import de.timklge.karooheadwind.screens.TemperatureUnit
import de.timklge.karooheadwind.streamCurrentWeatherData
import de.timklge.karooheadwind.streamSettings
@ -58,6 +57,21 @@ class WeatherDataType(
val timeFormatter = DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneId.systemDefault())
}
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.firstOrNull()?.data?.current?.weatherCode}")
emitter.onNext(StreamState.Streaming(DataPoint(dataTypeId, mapOf(DataType.Field.SINGLE to (data.firstOrNull()?.data?.current?.weatherCode?.toDouble() ?: 0.0)))))
}
}
emitter.setCancellable {
job.cancel()
}
}
data class StreamData(val data: OpenMeteoCurrentWeatherResponse?, val settings: HeadwindSettings,
val profile: UserProfile? = null, val headingResponse: HeadingResponse? = null)
@ -87,14 +101,12 @@ class WeatherDataType(
de.timklge.karooheadwind.R.drawable.arrow_0
)
val dataFlow = if (config.preview){
previewFlow()
} else {
context.streamCurrentWeatherData()
.combine(context.streamSettings(karooSystem)) { data, settings -> StreamData(data, settings) }
.combine(karooSystem.streamUserProfile()) { data, profile -> data.copy(profile = profile) }
.combine(karooSystem.getHeadingFlow(context)) { data, heading -> data.copy(headingResponse = heading) }
combine(context.streamCurrentWeatherData(), context.streamSettings(karooSystem), karooSystem.streamUserProfile(), karooSystem.getHeadingFlow(context)) { data, settings, profile, heading ->
StreamData(data.firstOrNull()?.data, settings, profile, heading)
}
}
val viewJob = CoroutineScope(Dispatchers.IO).launch {
@ -116,25 +128,25 @@ class WeatherDataType(
val result = glance.compose(context, DpSize.Unspecified) {
Box(modifier = GlanceModifier.fillMaxSize(), contentAlignment = Alignment.CenterEnd) {
Weather(baseBitmap,
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 = if (userProfile?.preferredUnit?.distance != UserProfile.PreferredUnit.UnitType.IMPERIAL) PrecipitationUnit.MILLIMETERS else PrecipitationUnit.INCH,
temperature = data.current.temperature.roundToInt(),
temperatureUnit = if (userProfile?.preferredUnit?.temperature != UserProfile.PreferredUnit.UnitType.IMPERIAL) TemperatureUnit.CELSIUS else TemperatureUnit.FAHRENHEIT,
timeLabel = formattedTime,
dateLabel = formattedDate,
rowAlignment = when (config.alignment){
ViewConfig.Alignment.LEFT -> Alignment.Horizontal.Start
ViewConfig.Alignment.CENTER -> Alignment.Horizontal.CenterHorizontally
ViewConfig.Alignment.RIGHT -> Alignment.Horizontal.End
},
singleDisplay = true
dateLabel = formattedDate,
singleDisplay = true,
isImperial = userProfile?.preferredUnit?.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL
)
}
}

View File

@ -6,13 +6,10 @@ import android.util.Log
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.glance.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
@ -27,26 +24,22 @@ import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse
import de.timklge.karooheadwind.OpenMeteoData
import de.timklge.karooheadwind.OpenMeteoForecastData
import de.timklge.karooheadwind.UpcomingRoute
import de.timklge.karooheadwind.WeatherDataResponse
import de.timklge.karooheadwind.WeatherInterpretation
import de.timklge.karooheadwind.datatypes.WeatherDataType.StreamData
import de.timklge.karooheadwind.getHeadingFlow
import de.timklge.karooheadwind.saveWidgetSettings
import de.timklge.karooheadwind.screens.HeadwindSettings
import de.timklge.karooheadwind.screens.HeadwindWidgetSettings
import de.timklge.karooheadwind.screens.PrecipitationUnit
import de.timklge.karooheadwind.screens.TemperatureUnit
import de.timklge.karooheadwind.streamCurrentWeatherData
import de.timklge.karooheadwind.streamSettings
import de.timklge.karooheadwind.streamUpcomingRoute
import de.timklge.karooheadwind.streamUserProfile
import de.timklge.karooheadwind.streamWidgetSettings
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.extension.DataTypeImpl
import io.hammerhead.karooext.internal.Emitter
import io.hammerhead.karooext.internal.ViewEmitter
import io.hammerhead.karooext.models.DataPoint
import io.hammerhead.karooext.models.DataType
import io.hammerhead.karooext.models.ShowCustomStreamState
import io.hammerhead.karooext.models.StreamState
import io.hammerhead.karooext.models.UpdateGraphicConfig
import io.hammerhead.karooext.models.UserProfile
import io.hammerhead.karooext.models.ViewConfig
@ -56,7 +49,7 @@ import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import java.time.Instant
@ -66,27 +59,6 @@ import java.time.format.FormatStyle
import java.time.temporal.ChronoUnit
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 (data == null || hourOffset >= ((data.forecastData?.weatherCode?.size) ?: 0)) {
hourOffset = 0
}
val newSettings = currentSettings.copy(currentForecastHourOffset = hourOffset)
saveWidgetSettings(context, newSettings)
}
}
@OptIn(ExperimentalGlanceRemoteViewsApi::class)
class WeatherForecastDataType(
private val karooSystem: KarooSystemService,
@ -98,11 +70,17 @@ class WeatherForecastDataType(
val timeFormatter = DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneId.systemDefault())
}
data class StreamData(val data: OpenMeteoCurrentWeatherResponse?, val settings: HeadwindSettings,
val widgetSettings: HeadwindWidgetSettings? = null, val profile: UserProfile? = null, val headingResponse: HeadingResponse? = null)
data class StreamData(val data: List<WeatherDataResponse>?, val settings: SettingsAndProfile,
val widgetSettings: HeadwindWidgetSettings? = null, val profile: UserProfile? = null,
val headingResponse: HeadingResponse? = null, val upcomingRoute: UpcomingRoute? = null)
data class SettingsAndProfile(val settings: HeadwindSettings, val isImperial: Boolean)
private fun previewFlow(settingsAndProfileStream: Flow<SettingsAndProfile>): Flow<StreamData> = flow {
val settingsAndProfile = settingsAndProfileStream.firstOrNull()
private fun previewFlow(): Flow<StreamData> = flow {
while (true){
val data = (0..<10).map { index ->
val timeAtFullHour = Instant.now().truncatedTo(ChronoUnit.HOURS).epochSecond
val forecastTimes = (0..<12).map { timeAtFullHour + it * 60 * 60 }
val forecastTemperatures = (0..<12).map { 20.0 + (-20..20).random() }
@ -112,17 +90,24 @@ class WeatherForecastDataType(
val forecastWindSpeed = (0..<12).map { 0.0 + (0..10).random() }
val forecastWindDirection = (0..<12).map { 0.0 + (0..360).random() }
val forecastWindGusts = (0..<12).map { 0.0 + (0..10).random() }
emit(
StreamData(
OpenMeteoCurrentWeatherResponse(
val weatherData = OpenMeteoCurrentWeatherResponse(
OpenMeteoData(Instant.now().epochSecond, 0, 20.0, 50, 3.0, 0, 1013.25, 980.0, 15.0, 30.0, 30.0, WeatherInterpretation.getKnownWeatherCodes().random()),
0.0, 0.0, "Europe/Berlin", 30.0, 0,
OpenMeteoForecastData(forecastTimes, forecastTemperatures, forecastPrecipitationPropability,
forecastPrecipitation, forecastWeatherCodes, forecastWindSpeed, forecastWindDirection,
forecastWindGusts)
), HeadwindSettings())
)
val distancePerHour = settingsAndProfile?.settings?.getForecastMetersPerHour(settingsAndProfile.isImperial)?.toDouble() ?: 0.0
val gpsCoords = GpsCoordinates(0.0, 0.0, distanceAlongRoute = index * distancePerHour)
WeatherDataResponse(weatherData, gpsCoords)
}
emit(
StreamData(data, SettingsAndProfile(HeadwindSettings(), settingsAndProfile?.isImperial == true))
)
delay(5_000)
@ -141,24 +126,30 @@ class WeatherForecastDataType(
de.timklge.karooheadwind.R.drawable.arrow_0
)
val settingsAndProfileStream = context.streamSettings(karooSystem).combine(karooSystem.streamUserProfile()) { settings, userProfile ->
SettingsAndProfile(settings = settings, isImperial = userProfile.preferredUnit.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL)
}
val dataFlow = if (config.preview){
previewFlow()
previewFlow(settingsAndProfileStream)
} else {
context.streamCurrentWeatherData()
.combine(context.streamSettings(karooSystem)) { data, settings -> StreamData(data, settings) }
.combine(karooSystem.streamUserProfile()) { data, profile -> data.copy(profile = profile) }
.combine(context.streamWidgetSettings()) { data, widgetSettings -> data.copy(widgetSettings = widgetSettings) }
.combine(karooSystem.getHeadingFlow(context)) { data, headingResponse -> data.copy(headingResponse = headingResponse) }
combine(context.streamCurrentWeatherData(),
settingsAndProfileStream,
context.streamWidgetSettings(),
karooSystem.getHeadingFlow(context),
karooSystem.streamUpcomingRoute()) { weatherData, settings, widgetSettings, heading, upcomingRoute ->
StreamData(data = weatherData, settings = settings, widgetSettings = widgetSettings, headingResponse = heading, upcomingRoute = upcomingRoute)
}
}
val viewJob = CoroutineScope(Dispatchers.IO).launch {
emitter.onNext(ShowCustomStreamState("", null))
dataFlow.collect { (data, settings, widgetSettings, userProfile, headingResponse) ->
dataFlow.collect { (allData, settingsAndProfile, widgetSettings, userProfile, headingResponse, upcomingRoute) ->
Log.d(KarooHeadwindExtension.TAG, "Updating weather forecast view")
if (data == null){
emitter.updateView(getErrorWidget(glance, context, settings, headingResponse).remoteViews)
if (allData == null){
emitter.updateView(getErrorWidget(glance, context, settingsAndProfile.settings, headingResponse).remoteViews)
return@collect
}
@ -168,22 +159,32 @@ class WeatherForecastDataType(
if (!config.preview) modifier = modifier.clickable(onClick = actionRunCallback<CycleHoursAction>())
Row(modifier = modifier, horizontalAlignment = Alignment.Horizontal.CenterHorizontally) {
Row(modifier = modifier, horizontalAlignment = Alignment.Horizontal.Start) {
val hourOffset = widgetSettings?.currentForecastHourOffset ?: 0
val positionOffset = if (allData.size == 1) 0 else hourOffset
var previousDate: String? = let {
val unixTime = data.forecastData?.time?.getOrNull(hourOffset)
val unixTime = allData.getOrNull(positionOffset)?.data?.forecastData?.time?.getOrNull(hourOffset)
val formattedDate = unixTime?.let { Instant.ofEpochSecond(unixTime).atZone(ZoneId.systemDefault()).toLocalDate().format(DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)) }
formattedDate
}
for (index in hourOffset..hourOffset + 2){
if (index >= (data.forecastData?.weatherCode?.size ?: 0)) {
for (baseIndex in hourOffset..hourOffset + 2){
val positionIndex = if (allData.size == 1) 0 else baseIndex
if (allData.getOrNull(positionIndex) == null) break
if (baseIndex >= (allData.getOrNull(positionOffset)?.data?.forecastData?.weatherCode?.size ?: 0)) {
break
}
if (index > hourOffset) {
val data = allData.getOrNull(positionIndex)?.data
val distanceAlongRoute = allData.getOrNull(positionIndex)?.requestedPosition?.distanceAlongRoute
val position = allData.getOrNull(positionIndex)?.requestedPosition?.let { "${(it.distanceAlongRoute?.div(1000.0))?.toInt()} at ${it.lat}, ${it.lon}" }
Log.d(KarooHeadwindExtension.TAG, "Distance along route ${positionIndex}: $position")
if (baseIndex > hourOffset) {
Spacer(
modifier = GlanceModifier.fillMaxHeight().background(
ColorProvider(Color.Black, Color.White)
@ -191,28 +192,60 @@ class WeatherForecastDataType(
)
}
val interpretation = WeatherInterpretation.fromWeatherCode(data.forecastData?.weatherCode?.get(index) ?: 0)
val unixTime = data.forecastData?.time?.get(index) ?: 0
val distanceFromCurrent = upcomingRoute?.distanceAlongRoute?.let { currentDistanceAlongRoute ->
distanceAlongRoute?.minus(currentDistanceAlongRoute)
}
val isCurrent = baseIndex == 0 && positionIndex == 0
if (isCurrent && data?.current != null){
val interpretation = WeatherInterpretation.fromWeatherCode(data.current.weatherCode)
val unixTime = data.current.time
val formattedTime = timeFormatter.format(Instant.ofEpochSecond(unixTime))
val formattedDate = Instant.ofEpochSecond(unixTime).atZone(ZoneId.systemDefault()).toLocalDate().format(DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT))
val hasNewDate = formattedDate != previousDate || index == 0
val hasNewDate = formattedDate != previousDate || baseIndex == 0
Weather(baseBitmap,
Weather(
baseBitmap,
current = interpretation,
windBearing = data.forecastData?.windDirection?.get(index)?.roundToInt() ?: 0,
windSpeed = data.forecastData?.windSpeed?.get(index)?.roundToInt() ?: 0,
windGusts = data.forecastData?.windGusts?.get(index)?.roundToInt() ?: 0,
windSpeedUnit = settings.windUnit,
precipitation = data.forecastData?.precipitation?.get(index) ?: 0.0,
precipitationProbability = data.forecastData?.precipitationProbability?.get(index) ?: 0,
precipitationUnit = if (userProfile?.preferredUnit?.distance != UserProfile.PreferredUnit.UnitType.IMPERIAL) PrecipitationUnit.MILLIMETERS else PrecipitationUnit.INCH,
temperature = data.forecastData?.temperature?.get(index)?.roundToInt() ?: 0,
windBearing = data.current.windDirection.roundToInt(),
windSpeed = data.current.windSpeed.roundToInt(),
windGusts = data.current.windGusts.roundToInt(),
precipitation = data.current.precipitation,
precipitationProbability = null,
temperature = data.current.temperature.roundToInt(),
temperatureUnit = if (userProfile?.preferredUnit?.temperature != UserProfile.PreferredUnit.UnitType.IMPERIAL) TemperatureUnit.CELSIUS else TemperatureUnit.FAHRENHEIT,
timeLabel = formattedTime,
dateLabel = if (hasNewDate) formattedDate else null
dateLabel = if (hasNewDate) formattedDate else null,
isImperial = userProfile?.preferredUnit?.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL
)
previousDate = formattedDate
} else {
val interpretation = WeatherInterpretation.fromWeatherCode(data?.forecastData?.weatherCode?.get(baseIndex) ?: 0)
val unixTime = data?.forecastData?.time?.get(baseIndex) ?: 0
val formattedTime = timeFormatter.format(Instant.ofEpochSecond(unixTime))
val formattedDate = Instant.ofEpochSecond(unixTime).atZone(ZoneId.systemDefault()).toLocalDate().format(DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT))
val hasNewDate = formattedDate != previousDate || baseIndex == 0
Weather(
baseBitmap,
current = interpretation,
windBearing = data?.forecastData?.windDirection?.get(baseIndex)?.roundToInt() ?: 0,
windSpeed = data?.forecastData?.windSpeed?.get(baseIndex)?.roundToInt() ?: 0,
windGusts = data?.forecastData?.windGusts?.get(baseIndex)?.roundToInt() ?: 0,
precipitation = data?.forecastData?.precipitation?.get(baseIndex) ?: 0.0,
precipitationProbability = data?.forecastData?.precipitationProbability?.get(baseIndex) ?: 0,
temperature = data?.forecastData?.temperature?.get(baseIndex)?.roundToInt() ?: 0,
temperatureUnit = if (userProfile?.preferredUnit?.temperature != UserProfile.PreferredUnit.UnitType.IMPERIAL) TemperatureUnit.CELSIUS else TemperatureUnit.FAHRENHEIT,
timeLabel = formattedTime,
dateLabel = if (hasNewDate) formattedDate else null,
distance = distanceFromCurrent,
isImperial = settingsAndProfile.isImperial
)
previousDate = formattedDate
}
}
}
}

View File

@ -32,9 +32,8 @@ 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.absoluteValue
import kotlin.math.ceil
fun getWeatherIcon(interpretation: WeatherInterpretation): Int {
@ -52,10 +51,23 @@ 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, windSpeedUnit: WindUnit,
precipitation: Double, precipitationProbability: Int?, precipitationUnit: PrecipitationUnit,
temperature: Int, temperatureUnit: TemperatureUnit, timeLabel: String? = null, rowAlignment: Alignment.Horizontal = Alignment.Horizontal.CenterHorizontally,
dateLabel: String? = null, singleDisplay: Boolean = false) {
fun Weather(
baseBitmap: Bitmap,
current: WeatherInterpretation,
windBearing: Int,
windSpeed: Int,
windGusts: Int,
precipitation: Double,
precipitationProbability: Int?,
temperature: Int,
temperatureUnit: TemperatureUnit,
distance: Double? = null,
timeLabel: String? = null,
rowAlignment: Alignment.Horizontal = Alignment.Horizontal.CenterHorizontally,
dateLabel: String? = null,
singleDisplay: Boolean = false,
isImperial: Boolean?
) {
val fontSize = 14f
@ -83,6 +95,29 @@ fun Weather(baseBitmap: Bitmap, current: WeatherInterpretation, windBearing: Int
}
}
if (distance != null && !singleDisplay && isImperial != null){
val distanceInUserUnit = (distance / (if(!isImperial) 1000.0 else 1609.34)).toInt()
val label = "${distanceInUserUnit.absoluteValue}${if(!isImperial) "km" else "mi"}"
val text = if(distanceInUserUnit > 0){
"In $label"
} else {
"$label ago"
}
if (distanceInUserUnit != 0){
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = text,
style = TextStyle(
color = ColorProvider(Color.Black, Color.White),
fontFamily = FontFamily.Monospace,
fontSize = TextUnit(fontSize, TextUnitType.Sp)
)
)
}
}
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalAlignment = rowAlignment) {
if (timeLabel != null){
Text(

View File

@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ExitToApp
@ -20,18 +21,21 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.timklge.karooheadwind.datatypes.GpsCoordinates
@ -39,7 +43,9 @@ import de.timklge.karooheadwind.getGpsCoordinateFlow
import de.timklge.karooheadwind.saveSettings
import de.timklge.karooheadwind.streamSettings
import de.timklge.karooheadwind.streamStats
import de.timklge.karooheadwind.streamUserProfile
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.UserProfile
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
@ -90,11 +96,17 @@ data class HeadwindSettings(
val welcomeDialogAccepted: Boolean = false,
val windDirectionIndicatorTextSetting: WindDirectionIndicatorTextSetting = WindDirectionIndicatorTextSetting.HEADWIND_SPEED,
val windDirectionIndicatorSetting: WindDirectionIndicatorSetting = WindDirectionIndicatorSetting.HEADWIND_DIRECTION,
val roundLocationTo: RoundLocationSetting = RoundLocationSetting.KM_2
val roundLocationTo: RoundLocationSetting = RoundLocationSetting.KM_2,
val forecastedKmPerHour: Int = 20,
val forecastedMilesPerHour: Int = 12,
){
companion object {
val defaultSettings = Json.encodeToString(HeadwindSettings())
}
fun getForecastMetersPerHour(isImperial: Boolean): Int {
return if (isImperial) forecastedMilesPerHour * 1609 else forecastedKmPerHour * 1000
}
}
@Serializable
@ -130,8 +142,11 @@ fun MainScreen(onFinish: () -> Unit) {
var selectedWindDirectionIndicatorTextSetting by remember { mutableStateOf(WindDirectionIndicatorTextSetting.HEADWIND_SPEED) }
var selectedWindDirectionIndicatorSetting by remember { mutableStateOf(WindDirectionIndicatorSetting.HEADWIND_DIRECTION) }
var selectedRoundLocationSetting by remember { mutableStateOf(RoundLocationSetting.KM_2) }
var forecastKmPerHour by remember { mutableStateOf("20") }
var forecastMilesPerHour by remember { mutableStateOf("12") }
val stats by ctx.streamStats().collectAsState(HeadwindStats())
val profile by karooSystem.streamUserProfile().collectAsStateWithLifecycle(null)
val stats by ctx.streamStats().collectAsStateWithLifecycle(HeadwindStats())
val location by karooSystem.getGpsCoordinateFlow(ctx).collectAsStateWithLifecycle(null)
var savedDialogVisible by remember { mutableStateOf(false) }
@ -144,6 +159,8 @@ fun MainScreen(onFinish: () -> Unit) {
selectedWindDirectionIndicatorTextSetting = settings.windDirectionIndicatorTextSetting
selectedWindDirectionIndicatorSetting = settings.windDirectionIndicatorSetting
selectedRoundLocationSetting = settings.roundLocationTo
forecastKmPerHour = settings.forecastedKmPerHour.toString()
forecastMilesPerHour = settings.forecastedMilesPerHour.toString()
}
}
@ -194,6 +211,24 @@ fun MainScreen(onFinish: () -> Unit) {
selectedRoundLocationSetting = RoundLocationSetting.entries.find { unit -> unit.id == selectedOption.id }!!
}
if (profile?.preferredUnit?.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL){
OutlinedTextField(value = forecastMilesPerHour, modifier = Modifier.fillMaxWidth(),
onValueChange = { forecastMilesPerHour = it },
label = { Text("Forecast Distance per Hour") },
suffix = { Text("mi") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true
)
} else {
OutlinedTextField(value = forecastKmPerHour, modifier = Modifier.fillMaxWidth(),
onValueChange = { forecastKmPerHour = it },
label = { Text("Forecast Distance per Hour") },
suffix = { Text("km") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true
)
}
FilledTonalButton(modifier = Modifier
.fillMaxWidth()
@ -202,7 +237,9 @@ fun MainScreen(onFinish: () -> Unit) {
welcomeDialogAccepted = true,
windDirectionIndicatorSetting = selectedWindDirectionIndicatorSetting,
windDirectionIndicatorTextSetting = selectedWindDirectionIndicatorTextSetting,
roundLocationTo = selectedRoundLocationSetting)
roundLocationTo = selectedRoundLocationSetting,
forecastedMilesPerHour = forecastMilesPerHour.toIntOrNull()?.coerceIn(3, 30) ?: 12,
forecastedKmPerHour = forecastKmPerHour.toIntOrNull()?.coerceIn(5, 50) ?: 20)
coroutineScope.launch {
saveSettings(ctx, newSettings)

View File

@ -1,6 +1,6 @@
[versions]
agp = "8.5.2"
datastorePreferences = "1.1.1"
datastorePreferences = "1.1.2"
kotlin = "2.0.0"
androidxCore = "1.15.0"
@ -9,8 +9,8 @@ androidxActivity = "1.9.3"
androidxComposeUi = "1.7.6"
androidxComposeMaterial = "1.3.1"
glance = "1.1.1"
kotlinxDatetime = "0.6.1"
kotlinxSerializationJson = "1.7.3"
kotlinxSerializationJson = "1.8.0"
mapboxSdkTurf = "7.3.1"
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
@ -37,6 +37,7 @@ androidx-glance-appwidget = { group = "androidx.glance", name = "glance-appwidge
androidx-glance-appwidget-preview = { group = "androidx.glance", name = "glance-appwidget-preview", version.ref = "glance" }
androidx-glance-preview = { group = "androidx.glance", name = "glance-preview", version.ref = "glance" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
mapbox-sdk-turf = { module = "com.mapbox.mapboxsdk:mapbox-sdk-turf", version.ref = "mapboxSdkTurf" }
[bundles]
androidx-lifeycle = ["androidx-lifecycle-runtime-compose", "androidx-lifecycle-viewmodel-compose"]

View File

@ -34,6 +34,11 @@ dependencyResolutionManagement {
password = gprKey
}
}
// mapbox
maven {
url = uri("https://api.mapbox.com/downloads/v2/releases/maven")
}
}
}