From de009454b838bb14d7fd28e3acd1c274e5d8aeaa Mon Sep 17 00:00:00 2001 From: timklge <2026103+timklge@users.noreply.github.com> Date: Sun, 2 Mar 2025 12:46:40 +0100 Subject: [PATCH] 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 --- app/build.gradle.kts | 5 +- app/manifest.json | 6 +- .../de/timklge/karooheadwind/DataStore.kt | 71 +++++- .../de/timklge/karooheadwind/Extensions.kt | 12 + .../de/timklge/karooheadwind/HeadingFlow.kt | 10 +- .../karooheadwind/KarooHeadwindExtension.kt | 197 ++++++++++++----- .../de/timklge/karooheadwind/OpenMeteo.kt | 20 +- .../karooheadwind/datatypes/BaseDataType.kt | 8 +- .../datatypes/CycleHoursAction.kt | 37 ++++ .../karooheadwind/datatypes/GpsCoordinates.kt | 2 +- .../datatypes/HeadwindDirectionDataType.kt | 4 +- .../datatypes/HeadwindSpeedDataType.kt | 4 +- .../datatypes/TailwindAndRideSpeedDataType.kt | 2 +- .../datatypes/UserWindSpeedDataType.kt | 4 +- .../datatypes/WeatherDataType.kt | 34 ++- .../datatypes/WeatherForecastDataType.kt | 207 ++++++++++-------- .../karooheadwind/datatypes/WeatherView.kt | 47 +++- .../karooheadwind/screens/MainScreen.kt | 43 +++- gradle/libs.versions.toml | 7 +- settings.gradle.kts | 5 + 20 files changed, 525 insertions(+), 200 deletions(-) create mode 100644 app/src/main/kotlin/de/timklge/karooheadwind/datatypes/CycleHoursAction.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fb7a69c..cbdf96b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -15,8 +15,8 @@ android { applicationId = "de.timklge.karooheadwind" minSdk = 26 targetSdk = 35 - versionCode = 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) diff --git a/app/manifest.json b/app/manifest.json index 18e5b70..eea0770 100644 --- a/app/manifest.json +++ b/app/manifest.json @@ -3,9 +3,9 @@ "packageName": "de.timklge.karooheadwind", "iconUrl": "https://github.com/timklge/karoo-headwind/releases/latest/download/karoo-headwind.png", "latestApkUrl": "https://github.com/timklge/karoo-headwind/releases/latest/download/app-release.apk", - "latestVersion": "1.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", } \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/DataStore.kt b/app/src/main/kotlin/de/timklge/karooheadwind/DataStore.kt index a223ce8..a3aec3f 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/DataStore.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/DataStore.kt @@ -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) { context.dataStore.edit { t -> t[currentDataKey] = Json.encodeToString(forecast) } @@ -66,7 +81,6 @@ suspend fun saveLastKnownPosition(context: Context, gpsCoordinates: GpsCoordinat } } - fun Context.streamWidgetSettings(): Flow { return dataStore.data.map { settingsJson -> try { @@ -103,6 +117,45 @@ fun Context.streamSettings(karooSystemService: KarooSystemService): Flow { + 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 { return dataStore.data.map { statsJson -> try { @@ -143,20 +196,18 @@ fun KarooSystemService.streamUserProfile(): Flow { } } -fun Context.streamCurrentWeatherData(): Flow { +fun Context.streamCurrentWeatherData(): Flow> { return dataStore.data.map { settingsJson -> try { val data = settingsJson[currentDataKey] - data?.let { d -> jsonWithUnknownKeys.decodeFromString(d) } + data?.let { d -> jsonWithUnknownKeys.decodeFromString>(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) } } } diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/Extensions.kt b/app/src/main/kotlin/de/timklge/karooheadwind/Extensions.kt index c06df2a..a6f398c 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/Extensions.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/Extensions.kt @@ -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 { } } +fun KarooSystemService.streamNavigationState(): Flow { + return callbackFlow { + val listenerId = addConsumer { event: OnNavigationState -> + trySendBlocking(event) + } + awaitClose { + removeConsumer(listenerId) + } + } +} + fun Flow.throttle(timeout: Long): Flow = flow { var lastEmissionTime = 0L diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/HeadingFlow.kt b/app/src/main/kotlin/de/timklge/karooheadwind/HeadingFlow.kt index 73120cc..7b1c750 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/HeadingFlow.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/HeadingFlow.kt @@ -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 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 HeadingResponse.NoGps - bearing is HeadingResponse.NoWeatherData || data == null -> HeadingResponse.NoWeatherData + bearing is HeadingResponse.NoWeatherData || data.isEmpty() -> HeadingResponse.NoWeatherData else -> bearing } } @@ -109,4 +111,4 @@ fun KarooSystemService.getGpsCoordinateFlow(context: Context): Flow - while(true){ - emit(value) - delay(1.hours) - } - } - streamSettings(karooSystem) + var requestedGpsCoordinates: List = mutableListOf() + + val settingsStream = 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) -> - Log.d(TAG, "Acquired updated gps coordinates: $gps") - val lastKnownStats = try { - streamStats().first() - } catch(e: Exception){ - Log.e(TAG, "Failed to read stats", e) - HeadwindStats() - } + 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?) - if (gps == null){ - error("No GPS coordinates available") - } - - val response = karooSystem.makeOpenMeteoHttpRequest(gps, settings, profile) - if (response.error != null){ - try { - val stats = lastKnownStats.copy(failedWeatherRequest = System.currentTimeMillis()) - launch { saveStats(this@KarooHeadwindExtension, stats) } - } catch(e: Exception){ - Log.e(TAG, "Failed to write stats", e) - } - error("HTTP request failed: ${response.error}") - } else { - try { - val stats = lastKnownStats.copy( - lastSuccessfulWeatherRequest = System.currentTimeMillis(), - lastSuccessfulWeatherPosition = gps - ) - launch { saveStats(this@KarooHeadwindExtension, stats) } - } catch(e: Exception){ - Log.e(TAG, "Failed to write stats", e) - } - } - - response + 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) } - .retry(Long.MAX_VALUE) { delay(1.minutes); true } - .collect { response -> - try { - val responseString = String(response.body ?: ByteArray(0)) - val data = jsonWithUnknownKeys.decodeFromString(responseString) + } + .map { (settings: HeadwindSettings, gps, profile, upcomingRoute) -> + Log.d(TAG, "Acquired updated gps coordinates: $gps") - saveCurrentData(applicationContext, data) - saveWidgetSettings(applicationContext, HeadwindWidgetSettings(currentForecastHourOffset = 0)) + val lastKnownStats = try { + streamStats().first() + } catch(e: Exception){ + Log.e(TAG, "Failed to read stats", e) + HeadwindStats() + } + + if (gps == null){ + error("No GPS coordinates available") + } + + 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()) + launch { saveStats(this@KarooHeadwindExtension, stats) } + } catch(e: Exception){ + Log.e(TAG, "Failed to write stats", e) + } + error("HTTP request failed: ${response.error}") + } else { + try { + val stats = lastKnownStats.copy( + lastSuccessfulWeatherRequest = System.currentTimeMillis(), + lastSuccessfulWeatherPosition = gps + ) + launch { saveStats(this@KarooHeadwindExtension, stats) } + } catch(e: Exception){ + Log.e(TAG, "Failed to write stats", e) + } + } + + response + }.retry(Long.MAX_VALUE) { e -> + Log.w(TAG, "Failed to get weather data", e) + delay(1.minutes); true + }.collect { response -> + try { + 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(responseString) + val data = WeatherDataResponse(weatherData, requestedGpsCoordinates.single()) + + saveCurrentData(applicationContext, listOf(data)) + + Log.d(TAG, "Got updated weather info: $data") + } else { + val weatherData = jsonWithUnknownKeys.decodeFromString>(responseString) + val data = weatherData.fastZip(requestedGpsCoordinates) { weather, gps -> WeatherDataResponse(weather, gps) } + + saveCurrentData(applicationContext, data) Log.d(TAG, "Got updated weather info: $data") - } catch(e: Exception){ - Log.e(TAG, "Failed to read current weather data", e) } + + saveWidgetSettings(applicationContext, HeadwindWidgetSettings(currentForecastHourOffset = 0)) + } catch(e: Exception){ + Log.e(TAG, "Failed to read current weather data", e) } + } } } override fun onDestroy() { serviceJob?.cancel() serviceJob = null + + updateLastKnownGpsJob?.cancel() + updateLastKnownGpsJob = null + karooSystem.disconnect() super.onDestroy() } diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/OpenMeteo.kt b/app/src/main/kotlin/de/timklge/karooheadwind/OpenMeteo.kt index a800908..efd98dc 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/OpenMeteo.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/OpenMeteo.kt @@ -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, 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¤t=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}¤t=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}¤t=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 { diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/BaseDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/BaseDataType.kt index ce93f2a..a9b575f 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/BaseDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/BaseDataType.kt @@ -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") - emitter.onNext(StreamState.Streaming(DataPoint(dataTypeId, mapOf(DataType.Field.SINGLE to value)))) + if (value != null){ + emitter.onNext(StreamState.Streaming(DataPoint(dataTypeId, mapOf(DataType.Field.SINGLE to value)))) + } else { + emitter.onNext(StreamState.NotAvailable) + } } } emitter.setCancellable { diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/CycleHoursAction.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/CycleHoursAction.kt new file mode 100644 index 0000000..4677dbb --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/CycleHoursAction.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/GpsCoordinates.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/GpsCoordinates.kt index eb1a5c2..d521385 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/GpsCoordinates.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/GpsCoordinates.kt @@ -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 diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindDirectionDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindDirectionDataType.kt index 1de55c4..1b5ed9d 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindDirectionDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindDirectionDataType.kt @@ -44,7 +44,9 @@ class HeadwindDirectionDataType( private fun streamValues(): Flow = 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 diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindSpeedDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindSpeedDataType.kt index 5f1d6c8..baf67bb 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindSpeedDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindSpeedDataType.kt @@ -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 diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TailwindAndRideSpeedDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TailwindAndRideSpeedDataType.kt index eaeaeb6..fdd814b 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TailwindAndRideSpeedDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TailwindAndRideSpeedDataType.kt @@ -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 diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/UserWindSpeedDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/UserWindSpeedDataType.kt index abca4b5..d4be500 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/UserWindSpeedDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/UserWindSpeedDataType.kt @@ -34,7 +34,9 @@ class UserWindSpeedDataType( fun streamValues(context: Context, karooSystem: KarooSystemService): Flow = 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 diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherDataType.kt index 94c9e95..46bd897 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherDataType.kt @@ -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) { + 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 ) } } diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherForecastDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherForecastDataType.kt index f9d43d1..11a1431 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherForecastDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherForecastDataType.kt @@ -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,31 +70,44 @@ 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?, 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): Flow = flow { + val settingsAndProfile = settingsAndProfileStream.firstOrNull() - private fun previewFlow(): Flow = flow { while (true){ - 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() } - val forecastPrecipitationPropability = (0..<12).map { (0..100).random() } - val forecastPrecipitation = (0..<12).map { 0.0 + (0..10).random() } - val forecastWeatherCodes = (0..<12).map { WeatherInterpretation.getKnownWeatherCodes().random() } - 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() } + 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() } + val forecastPrecipitationPropability = (0..<12).map { (0..100).random() } + val forecastPrecipitation = (0..<12).map { 0.0 + (0..10).random() } + val forecastWeatherCodes = (0..<12).map { WeatherInterpretation.getKnownWeatherCodes().random() } + 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() } + 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) + ) + + 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( - 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()) + 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()) - 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 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 distanceFromCurrent = upcomingRoute?.distanceAlongRoute?.let { currentDistanceAlongRoute -> + distanceAlongRoute?.minus(currentDistanceAlongRoute) + } - 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, - temperatureUnit = if (userProfile?.preferredUnit?.temperature != UserProfile.PreferredUnit.UnitType.IMPERIAL) TemperatureUnit.CELSIUS else TemperatureUnit.FAHRENHEIT, - timeLabel = formattedTime, - dateLabel = if (hasNewDate) formattedDate else null - ) + val isCurrent = baseIndex == 0 && positionIndex == 0 - previousDate = formattedDate + 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 || baseIndex == 0 + + Weather( + baseBitmap, + current = interpretation, + 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, + 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 + } } } } diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherView.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherView.kt index 9fa656f..05a990f 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherView.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherView.kt @@ -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( diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/screens/MainScreen.kt b/app/src/main/kotlin/de/timklge/karooheadwind/screens/MainScreen.kt index 4987536..93af9e0 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/screens/MainScreen.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/screens/MainScreen.kt @@ -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) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4996e51..7a1acf5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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"] diff --git a/settings.gradle.kts b/settings.gradle.kts index 8587a1e..5551273 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -34,6 +34,11 @@ dependencyResolutionManagement { password = gprKey } } + + // mapbox + maven { + url = uri("https://api.mapbox.com/downloads/v2/releases/maven") + } } }