diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e7506d2..2fe63f2 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 = 6 - versionName = "1.1.2" + versionCode = 7 + versionName = "1.1.3" } signingConfigs { diff --git a/app/manifest.json b/app/manifest.json index 2def8fe..aa1632a 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.1.2", - "latestVersionCode": 6, + "latestVersion": "1.1.3", + "latestVersionCode": 7, "developer": "timklge", "description": "Provides headwind direction, wind speed and other weather data fields", - "releaseNotes": "Add hourly forecast and temperature datafields. Add setting to use absolute wind direction on headwind datafield." + "releaseNotes": "Adds hourly forecast. Shows error message in fields if weather data or gps are unavailable and remembers last known gps position." } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9e22dc0..15236d9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,13 +4,13 @@ diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png deleted file mode 100644 index 644d139..0000000 Binary files a/app/src/main/ic_launcher-playstore.png and /dev/null differ diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/DataStore.kt b/app/src/main/kotlin/de/timklge/karooheadwind/DataStore.kt new file mode 100644 index 0000000..a223ce8 --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/DataStore.kt @@ -0,0 +1,162 @@ +package de.timklge.karooheadwind + +import android.content.Context +import android.util.Log +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import de.timklge.karooheadwind.datatypes.GpsCoordinates +import de.timklge.karooheadwind.screens.HeadwindSettings +import de.timklge.karooheadwind.screens.HeadwindStats +import de.timklge.karooheadwind.screens.HeadwindWidgetSettings +import de.timklge.karooheadwind.screens.WindUnit +import io.hammerhead.karooext.KarooSystemService +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.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + + +val jsonWithUnknownKeys = Json { ignoreUnknownKeys = true } + +val settingsKey = stringPreferencesKey("settings") +val widgetSettingsKey = stringPreferencesKey("widgetSettings") +val currentDataKey = stringPreferencesKey("current") +val statsKey = stringPreferencesKey("stats") +val lastKnownPositionKey = stringPreferencesKey("lastKnownPosition") + +suspend fun saveSettings(context: Context, settings: HeadwindSettings) { + context.dataStore.edit { t -> + t[settingsKey] = Json.encodeToString(settings) + } +} + +suspend fun saveWidgetSettings(context: Context, settings: HeadwindWidgetSettings) { + context.dataStore.edit { t -> + t[widgetSettingsKey] = Json.encodeToString(settings) + } +} + +suspend fun saveStats(context: Context, stats: HeadwindStats) { + context.dataStore.edit { t -> + t[statsKey] = Json.encodeToString(stats) + } +} + +suspend fun saveCurrentData(context: Context, forecast: OpenMeteoCurrentWeatherResponse) { + context.dataStore.edit { t -> + t[currentDataKey] = Json.encodeToString(forecast) + } +} + +suspend fun saveLastKnownPosition(context: Context, gpsCoordinates: GpsCoordinates) { + Log.i(KarooHeadwindExtension.TAG, "Saving last known position: $gpsCoordinates") + + try { + context.dataStore.edit { t -> + t[lastKnownPositionKey] = Json.encodeToString(gpsCoordinates) + } + } catch(e: Throwable){ + Log.e(KarooHeadwindExtension.TAG, "Failed to save last known position", e) + } +} + + +fun Context.streamWidgetSettings(): Flow { + return dataStore.data.map { settingsJson -> + try { + if (settingsJson.contains(widgetSettingsKey)){ + jsonWithUnknownKeys.decodeFromString(settingsJson[widgetSettingsKey]!!) + } else { + jsonWithUnknownKeys.decodeFromString(HeadwindWidgetSettings.defaultWidgetSettings) + } + } catch(e: Throwable){ + Log.e(KarooHeadwindExtension.TAG, "Failed to read widget preferences", e) + jsonWithUnknownKeys.decodeFromString(HeadwindWidgetSettings.defaultWidgetSettings) + } + }.distinctUntilChanged() +} + +fun Context.streamSettings(karooSystemService: KarooSystemService): Flow { + return dataStore.data.map { settingsJson -> + try { + if (settingsJson.contains(settingsKey)){ + jsonWithUnknownKeys.decodeFromString(settingsJson[settingsKey]!!) + } else { + val defaultSettings = jsonWithUnknownKeys.decodeFromString(HeadwindSettings.defaultSettings) + + val preferredUnits = karooSystemService.streamUserProfile().first().preferredUnit + + defaultSettings.copy( + windUnit = if (preferredUnits.distance == UserProfile.PreferredUnit.UnitType.METRIC) WindUnit.KILOMETERS_PER_HOUR else WindUnit.MILES_PER_HOUR, + ) + } + } catch(e: Throwable){ + Log.e(KarooHeadwindExtension.TAG, "Failed to read preferences", e) + jsonWithUnknownKeys.decodeFromString(HeadwindSettings.defaultSettings) + } + }.distinctUntilChanged() +} + +fun Context.streamStats(): Flow { + return dataStore.data.map { statsJson -> + try { + jsonWithUnknownKeys.decodeFromString( + statsJson[statsKey] ?: HeadwindStats.defaultStats + ) + } catch(e: Throwable){ + Log.e(KarooHeadwindExtension.TAG, "Failed to read stats", e) + jsonWithUnknownKeys.decodeFromString(HeadwindStats.defaultStats) + } + }.distinctUntilChanged() +} + +suspend fun Context.getLastKnownPosition(): GpsCoordinates? { + val settingsJson = dataStore.data.first() + + try { + val lastKnownPositionString = settingsJson[lastKnownPositionKey] ?: return null + val lastKnownPosition = jsonWithUnknownKeys.decodeFromString( + lastKnownPositionString + ) + + return lastKnownPosition + } catch(e: Throwable){ + Log.e(KarooHeadwindExtension.TAG, "Failed to read last known position", e) + return null + } +} + +fun KarooSystemService.streamUserProfile(): Flow { + return callbackFlow { + val listenerId = addConsumer { userProfile: UserProfile -> + trySendBlocking(userProfile) + } + awaitClose { + removeConsumer(listenerId) + } + } +} + +fun Context.streamCurrentWeatherData(): Flow { + return dataStore.data.map { settingsJson -> + try { + val data = settingsJson[currentDataKey] + data?.let { d -> jsonWithUnknownKeys.decodeFromString(d) } + } catch (e: Throwable) { + Log.e(KarooHeadwindExtension.TAG, "Failed to read weather data", e) + null + } + }.distinctUntilChanged().map { response -> + if (response != null && response.current.time * 1000 >= System.currentTimeMillis() - (1000 * 60 * 60 * 12)){ + response + } else { + null + } + } +} diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/Extensions.kt b/app/src/main/kotlin/de/timklge/karooheadwind/Extensions.kt index 0f438ce..e030229 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/Extensions.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/Extensions.kt @@ -2,8 +2,25 @@ package de.timklge.karooheadwind import android.content.Context import android.util.Log +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.TextUnitType +import androidx.compose.ui.unit.dp import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.glance.GlanceModifier +import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi +import androidx.glance.appwidget.GlanceRemoteViews +import androidx.glance.appwidget.RemoteViewsCompositionResult +import androidx.glance.color.ColorProvider +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.padding +import androidx.glance.text.Text +import androidx.glance.text.TextAlign +import androidx.glance.text.TextStyle import de.timklge.karooheadwind.datatypes.GpsCoordinates import de.timklge.karooheadwind.screens.HeadwindSettings import de.timklge.karooheadwind.screens.HeadwindStats @@ -22,17 +39,17 @@ import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.scan import kotlinx.coroutines.flow.single import kotlinx.coroutines.flow.timeout @@ -44,37 +61,6 @@ import kotlin.math.abs import kotlin.math.absoluteValue import kotlin.time.Duration.Companion.seconds -val jsonWithUnknownKeys = Json { ignoreUnknownKeys = true } - -val settingsKey = stringPreferencesKey("settings") -val widgetSettingsKey = stringPreferencesKey("widgetSettings") -val currentDataKey = stringPreferencesKey("current") -val statsKey = stringPreferencesKey("stats") - -suspend fun saveSettings(context: Context, settings: HeadwindSettings) { - context.dataStore.edit { t -> - t[settingsKey] = Json.encodeToString(settings) - } -} - -suspend fun saveWidgetSettings(context: Context, settings: HeadwindWidgetSettings) { - context.dataStore.edit { t -> - t[widgetSettingsKey] = Json.encodeToString(settings) - } -} - -suspend fun saveStats(context: Context, stats: HeadwindStats) { - context.dataStore.edit { t -> - t[statsKey] = Json.encodeToString(stats) - } -} - -suspend fun saveCurrentData(context: Context, forecast: OpenMeteoCurrentWeatherResponse) { - context.dataStore.edit { t -> - t[currentDataKey] = Json.encodeToString(forecast) - } -} - fun KarooSystemService.streamDataFlow(dataTypeId: String): Flow { return callbackFlow { val listenerId = addConsumer(OnStreamState.StartStreaming(dataTypeId)) { event: OnStreamState -> @@ -86,189 +72,14 @@ fun KarooSystemService.streamDataFlow(dataTypeId: String): Flow { } } -fun Context.streamCurrentWeatherData(): Flow { - return dataStore.data.map { settingsJson -> - try { - val data = settingsJson[currentDataKey] - data?.let { d -> jsonWithUnknownKeys.decodeFromString(d) } - } catch (e: Throwable) { - Log.e(KarooHeadwindExtension.TAG, "Failed to read weather data", e) - null - } - }.filterNotNull().distinctUntilChanged().filter { it.current.time * 1000 >= System.currentTimeMillis() - (1000 * 60 * 60 * 12) } -} +fun Flow.throttle(timeout: Long): Flow = flow { + var lastEmissionTime = 0L -fun Context.streamWidgetSettings(): Flow { - return dataStore.data.map { settingsJson -> - try { - if (settingsJson.contains(widgetSettingsKey)){ - jsonWithUnknownKeys.decodeFromString(settingsJson[widgetSettingsKey]!!) - } else { - jsonWithUnknownKeys.decodeFromString(HeadwindWidgetSettings.defaultWidgetSettings) - } - } catch(e: Throwable){ - Log.e(KarooHeadwindExtension.TAG, "Failed to read widget preferences", e) - jsonWithUnknownKeys.decodeFromString(HeadwindWidgetSettings.defaultWidgetSettings) - } - }.distinctUntilChanged() -} - -fun Context.streamSettings(karooSystemService: KarooSystemService): Flow { - return dataStore.data.map { settingsJson -> - try { - if (settingsJson.contains(settingsKey)){ - jsonWithUnknownKeys.decodeFromString(settingsJson[settingsKey]!!) - } else { - val defaultSettings = jsonWithUnknownKeys.decodeFromString(HeadwindSettings.defaultSettings) - - val preferredUnits = karooSystemService.streamUserProfile().first().preferredUnit - - defaultSettings.copy( - windUnit = if (preferredUnits.distance == UserProfile.PreferredUnit.UnitType.METRIC) WindUnit.KILOMETERS_PER_HOUR else WindUnit.MILES_PER_HOUR, - ) - } - } catch(e: Throwable){ - Log.e(KarooHeadwindExtension.TAG, "Failed to read preferences", e) - jsonWithUnknownKeys.decodeFromString(HeadwindSettings.defaultSettings) - } - }.distinctUntilChanged() -} - -fun Context.streamStats(): Flow { - return dataStore.data.map { statsJson -> - try { - jsonWithUnknownKeys.decodeFromString( - statsJson[statsKey] ?: HeadwindStats.defaultStats - ) - } catch(e: Throwable){ - Log.e(KarooHeadwindExtension.TAG, "Failed to read stats", e) - jsonWithUnknownKeys.decodeFromString(HeadwindStats.defaultStats) - } - }.distinctUntilChanged() -} - -fun KarooSystemService.streamUserProfile(): Flow { - return callbackFlow { - val listenerId = addConsumer { userProfile: UserProfile -> - trySendBlocking(userProfile) - } - awaitClose { - removeConsumer(listenerId) + collect { value -> + val currentTime = System.currentTimeMillis() + if (currentTime - lastEmissionTime >= timeout) { + emit(value) + lastEmissionTime = currentTime } } -} - -@OptIn(FlowPreview::class) -suspend fun KarooSystemService.makeOpenMeteoHttpRequest(gpsCoordinates: 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¤t=surface_pressure,temperature_2m,relative_humidity_2m,precipitation,weather_code,cloud_cover,wind_speed_10m,wind_direction_10m,wind_gusts_10m&hourly=temperature_2m,precipitation_probability,precipitation,weather_code,wind_speed_10m,wind_direction_10m,wind_gusts_10m&timeformat=unixtime&past_hours=1&forecast_days=1&forecast_hours=12 - val url = "https://api.open-meteo.com/v1/forecast?latitude=${gpsCoordinates.lat}&longitude=${gpsCoordinates.lon}¤t=surface_pressure,temperature_2m,relative_humidity_2m,precipitation,weather_code,cloud_cover,wind_speed_10m,wind_direction_10m,wind_gusts_10m&hourly=temperature_2m,precipitation_probability,precipitation,weather_code,wind_speed_10m,wind_direction_10m,wind_gusts_10m&timeformat=unixtime&past_hours=0&forecast_days=1&forecast_hours=12&wind_speed_unit=${settings.windUnit.id}&precipitation_unit=${precipitationUnit.id}&temperature_unit=${temperatureUnit.id}" - - Log.d(KarooHeadwindExtension.TAG, "Http request to ${url}...") - - val listenerId = addConsumer( - OnHttpResponse.MakeHttpRequest( - "GET", - url, - waitForConnection = false, - ), - ) { event: OnHttpResponse -> - Log.d(KarooHeadwindExtension.TAG, "Http response event $event") - if (event.state is HttpResponseState.Complete){ - trySend(event.state as HttpResponseState.Complete) - close() - } - } - awaitClose { - removeConsumer(listenerId) - } - }.timeout(20.seconds).catch { e: Throwable -> - if (e is TimeoutCancellationException){ - emit(HttpResponseState.Complete(500, mapOf(), null, "Timeout")) - } else { - throw e - } - }.single() -} - -fun signedAngleDifference(angle1: Double, angle2: Double): Double { - val a1 = angle1 % 360 - val a2 = angle2 % 360 - var diff = abs(a1 - a2) - - val sign = if (a1 < a2) { - if (diff > 180.0) -1 else 1 - } else { - if (diff > 180.0) 1 else -1 - } - - if (diff > 180.0) { - diff = 360.0 - diff - } - - return sign * diff -} - -fun KarooSystemService.getRelativeHeadingFlow(context: Context): Flow { - val currentWeatherData = context.streamCurrentWeatherData() - - return getHeadingFlow() - .filter { it >= 0 } - .combine(currentWeatherData) { bearing, data -> bearing to data } - .map { (bearing, data) -> - val windBearing = data.current.windDirection + 180 - val diff = signedAngleDifference(bearing, windBearing) - Log.d(KarooHeadwindExtension.TAG, "Wind bearing: $bearing vs $windBearing => $diff") - - diff - } -} - -fun KarooSystemService.getHeadingFlow(): Flow { - // return flowOf(20.0) - - return streamDataFlow(DataType.Type.LOCATION) - .mapNotNull { (it as? StreamState.Streaming)?.dataPoint?.values } - .map { values -> - val heading = values[DataType.Field.LOC_BEARING] - Log.d(KarooHeadwindExtension.TAG, "Updated gps bearing: $heading") - heading ?: 0.0 - } - .distinctUntilChanged() - .scan(emptyList()) { acc, value -> /* Average over 3 values */ - val newAcc = acc + value - if (newAcc.size > 3) newAcc.drop(1) else newAcc - } - .map { it.average() } -} - -@OptIn(FlowPreview::class) -fun KarooSystemService.getGpsCoordinateFlow(context: Context): Flow { - // return flowOf(GpsCoordinates(52.5164069,13.3784)) - - return streamDataFlow(DataType.Type.LOCATION) - .mapNotNull { (it as? StreamState.Streaming)?.dataPoint?.values } - .mapNotNull { values -> - val lat = values[DataType.Field.LOC_LATITUDE] - val lon = values[DataType.Field.LOC_LONGITUDE] - - if (lat != null && lon != null){ - Log.d(KarooHeadwindExtension.TAG, "Updated gps coords: $lat $lon") - GpsCoordinates(lat, lon) - } else { - Log.e(KarooHeadwindExtension.TAG, "Missing gps values: $values") - null - } - } - .combine(context.streamSettings(this)) { gps, settings -> gps to settings } - .map { (gps, settings) -> - val rounded = gps.round(settings.roundLocationTo.km.toDouble()) - Log.d(KarooHeadwindExtension.TAG, "Round location to ${settings.roundLocationTo.km} - $rounded") - rounded - } - .distinctUntilChanged { old, new -> old.distanceTo(new).absoluteValue < 0.001 } - .debounce(Duration.ofSeconds(10)) } \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/HeadingFlow.kt b/app/src/main/kotlin/de/timklge/karooheadwind/HeadingFlow.kt new file mode 100644 index 0000000..7855fda --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/HeadingFlow.kt @@ -0,0 +1,145 @@ +package de.timklge.karooheadwind + +import android.content.Context +import android.util.Log +import de.timklge.karooheadwind.datatypes.GpsCoordinates +import io.hammerhead.karooext.KarooSystemService +import io.hammerhead.karooext.models.DataType +import io.hammerhead.karooext.models.StreamState +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.scan + + +sealed class HeadingResponse { + data object NoGps: HeadingResponse() + data object NoWeatherData: HeadingResponse() + data class Value(val diff: Double): HeadingResponse() +} + +fun KarooSystemService.getRelativeHeadingFlow(context: Context): Flow { + val currentWeatherData = context.streamCurrentWeatherData() + + return getHeadingFlow(context) + .combine(currentWeatherData) { bearing, data -> bearing to data } + .map { (bearing, data) -> + when { + bearing is HeadingResponse.Value && data != null -> { + val windBearing = data.current.windDirection + 180 + val diff = signedAngleDifference(bearing.diff, windBearing) + + Log.d(KarooHeadwindExtension.TAG, "Wind bearing: $bearing vs $windBearing => $diff") + + HeadingResponse.Value(diff) + } + bearing is HeadingResponse.NoGps -> HeadingResponse.NoGps + bearing is HeadingResponse.NoWeatherData || data == null -> HeadingResponse.NoWeatherData + else -> bearing + } + } +} + +fun KarooSystemService.getHeadingFlow(context: Context): Flow { + // return flowOf(HeadingResponse.Value(20.0)) + + return getGpsCoordinateFlow(context) + .map { coords -> + val heading = coords?.bearing + Log.d(KarooHeadwindExtension.TAG, "Updated gps bearing: $heading") + val headingValue = heading?.let { HeadingResponse.Value(it) } + + headingValue ?: HeadingResponse.NoGps + } + .distinctUntilChanged() + .scan(emptyList()) { acc, value -> /* Average over 3 values */ + if (value !is HeadingResponse.Value) return@scan listOf(value) + + val newAcc = acc + value + if (newAcc.size > 3) newAcc.drop(1) else newAcc + } + .map { data -> + Log.i(KarooHeadwindExtension.TAG, "Heading value: $data") + + if (data.isEmpty()) return@map HeadingResponse.NoGps + if (data.firstOrNull() !is HeadingResponse.Value) return@map data.first() + + val avgValues = data.mapNotNull { (it as? HeadingResponse.Value)?.diff } + + if (avgValues.isEmpty()) return@map HeadingResponse.NoGps + + val avg = avgValues.average() + + HeadingResponse.Value(avg) + } +} + +fun concatenate(vararg flows: Flow) = flow { + for (flow in flows) { + emitAll(flow) + } +} + +fun Flow.dropNullsIfNullEncountered(): Flow = flow { + var hadValue = false + + collect { value -> + if (!hadValue) { + emit(value) + if (value != null) hadValue = true + } else { + if (value != null) emit(value) + } + } +} + +suspend fun KarooSystemService.updateLastKnownGps(context: Context) { + while (true) { + getGpsCoordinateFlow(context) + .filterNotNull() + .throttle(60 * 1_000) // Only update last known gps position once every minute + .collect { gps -> + saveLastKnownPosition(context, gps) + } + delay(1_000) + } +} + +fun KarooSystemService.getGpsCoordinateFlow(context: Context): Flow { + // return flowOf(GpsCoordinates(52.5164069,13.3784)) + + val initialFlow = flow { + val lastKnownPosition = context.getLastKnownPosition() + if (lastKnownPosition != null) emit(lastKnownPosition) + } + + val gpsFlow = streamDataFlow(DataType.Type.LOCATION) + .map { (it as? StreamState.Streaming)?.dataPoint?.values } + .map { values -> + val lat = values?.get(DataType.Field.LOC_LATITUDE) + val lon = values?.get(DataType.Field.LOC_LONGITUDE) + val bearing = values?.get(DataType.Field.LOC_BEARING) + + if (lat != null && lon != null){ + // Log.d(KarooHeadwindExtension.TAG, "Updated gps coordinates: $lat $lon") + GpsCoordinates(lat, lon, bearing) + } else { + // Log.w(KarooHeadwindExtension.TAG, "Gps unavailable: $values") + null + } + } + + val concatenatedFlow = concatenate(initialFlow, gpsFlow) + + return concatenatedFlow + .combine(context.streamSettings(this)) { gps, settings -> gps to settings } + .map { (gps, settings) -> + gps?.round(settings.roundLocationTo.km.toDouble()) + } + .dropNullsIfNullEncountered() +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt b/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt index b37a53e..5c57f3e 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt @@ -23,25 +23,32 @@ import io.hammerhead.karooext.models.UserProfile import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.retry import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch +import kotlinx.coroutines.time.debounce +import java.time.Duration +import kotlin.math.absoluteValue import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes -class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.1.2") { +class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.1.3") { companion object { const val TAG = "karoo-headwind" } + lateinit var karooSystem: KarooSystemService + private var updateLastKnownGpsJob: Job? = null private var serviceJob: Job? = null override val types by lazy { @@ -62,15 +69,19 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.1.2") { ) } - data class StreamData(val settings: HeadwindSettings, val gps: GpsCoordinates, + data class StreamData(val settings: HeadwindSettings, val gps: GpsCoordinates?, val profile: UserProfile? = null) - @OptIn(ExperimentalCoroutinesApi::class) + @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) override fun onCreate() { super.onCreate() karooSystem = KarooSystemService(applicationContext) + updateLastKnownGpsJob = CoroutineScope(Dispatchers.IO).launch { + karooSystem.updateLastKnownGps(this@KarooHeadwindExtension) + } + serviceJob = CoroutineScope(Dispatchers.IO).launch { karooSystem.connect { connected -> if (connected) { @@ -80,7 +91,15 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.1.2") { val gpsFlow = karooSystem .getGpsCoordinateFlow(this@KarooHeadwindExtension) - .transformLatest { value: GpsCoordinates -> + .distinctUntilChanged { old, new -> + if (old != null && new != null) { + old.distanceTo(new).absoluteValue < 0.001 + } else { + old == new + } + } + .debounce(Duration.ofSeconds(5)) + .transformLatest { value: GpsCoordinates? -> while(true){ emit(value) delay(1.hours) @@ -101,6 +120,10 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.1.2") { HeadwindStats() } + if (gps == null){ + error("No GPS coordinates available") + } + val response = karooSystem.makeOpenMeteoHttpRequest(gps, settings, profile) if (response.error != null){ try { diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/OpenMeteo.kt b/app/src/main/kotlin/de/timklge/karooheadwind/OpenMeteo.kt new file mode 100644 index 0000000..a1add54 --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/OpenMeteo.kt @@ -0,0 +1,55 @@ +package de.timklge.karooheadwind + +import android.util.Log +import de.timklge.karooheadwind.datatypes.GpsCoordinates +import de.timklge.karooheadwind.screens.HeadwindSettings +import de.timklge.karooheadwind.screens.PrecipitationUnit +import de.timklge.karooheadwind.screens.TemperatureUnit +import io.hammerhead.karooext.KarooSystemService +import io.hammerhead.karooext.models.HttpResponseState +import io.hammerhead.karooext.models.OnHttpResponse +import io.hammerhead.karooext.models.UserProfile +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.single +import kotlinx.coroutines.flow.timeout +import kotlin.time.Duration.Companion.seconds + +@OptIn(FlowPreview::class) +suspend fun KarooSystemService.makeOpenMeteoHttpRequest(gpsCoordinates: 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¤t=surface_pressure,temperature_2m,relative_humidity_2m,precipitation,weather_code,cloud_cover,wind_speed_10m,wind_direction_10m,wind_gusts_10m&hourly=temperature_2m,precipitation_probability,precipitation,weather_code,wind_speed_10m,wind_direction_10m,wind_gusts_10m&timeformat=unixtime&past_hours=1&forecast_days=1&forecast_hours=12 + val url = "https://api.open-meteo.com/v1/forecast?latitude=${gpsCoordinates.lat}&longitude=${gpsCoordinates.lon}¤t=surface_pressure,temperature_2m,relative_humidity_2m,precipitation,weather_code,cloud_cover,wind_speed_10m,wind_direction_10m,wind_gusts_10m&hourly=temperature_2m,precipitation_probability,precipitation,weather_code,wind_speed_10m,wind_direction_10m,wind_gusts_10m&timeformat=unixtime&past_hours=0&forecast_days=1&forecast_hours=12&wind_speed_unit=${settings.windUnit.id}&precipitation_unit=${precipitationUnit.id}&temperature_unit=${temperatureUnit.id}" + + Log.d(KarooHeadwindExtension.TAG, "Http request to ${url}...") + + val listenerId = addConsumer( + OnHttpResponse.MakeHttpRequest( + "GET", + url, + waitForConnection = false, + ), + ) { event: OnHttpResponse -> + Log.d(KarooHeadwindExtension.TAG, "Http response event $event") + if (event.state is HttpResponseState.Complete){ + trySend(event.state as HttpResponseState.Complete) + close() + } + } + awaitClose { + removeConsumer(listenerId) + } + }.timeout(20.seconds).catch { e: Throwable -> + if (e is TimeoutCancellationException){ + emit(HttpResponseState.Complete(500, mapOf(), null, "Timeout")) + } else { + throw e + } + }.single() +} diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/Utils.kt b/app/src/main/kotlin/de/timklge/karooheadwind/Utils.kt new file mode 100644 index 0000000..512e795 --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/Utils.kt @@ -0,0 +1,21 @@ +package de.timklge.karooheadwind + +import kotlin.math.abs + +fun signedAngleDifference(angle1: Double, angle2: Double): Double { + val a1 = angle1 % 360 + val a2 = angle2 % 360 + var diff = abs(a1 - a2) + + val sign = if (a1 < a2) { + if (diff > 180.0) -1 else 1 + } else { + if (diff > 180.0) 1 else -1 + } + + if (diff > 180.0) { + diff = 360.0 - diff + } + + return sign * diff +} \ No newline at end of file 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 e59a2aa..ce93f2a 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/BaseDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/BaseDataType.kt @@ -12,6 +12,7 @@ import io.hammerhead.karooext.models.DataType import io.hammerhead.karooext.models.StreamState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch abstract class BaseDataType( @@ -25,10 +26,12 @@ abstract class BaseDataType( val job = CoroutineScope(Dispatchers.IO).launch { val currentWeatherData = applicationContext.streamCurrentWeatherData() - currentWeatherData.collect { data -> - val value = getValue(data) - Log.d(KarooHeadwindExtension.TAG, "$dataTypeId: $value") - emitter.onNext(StreamState.Streaming(DataPoint(dataTypeId, mapOf(DataType.Field.SINGLE to value)))) + currentWeatherData + .filterNotNull() + .collect { data -> + val value = getValue(data) + Log.d(KarooHeadwindExtension.TAG, "$dataTypeId: $value") + emitter.onNext(StreamState.Streaming(DataPoint(dataTypeId, mapOf(DataType.Field.SINGLE to value)))) } } emitter.setCancellable { 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 bcf2bc4..eb1a5c2 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){ +data class GpsCoordinates(val lat: Double, val lon: Double, val bearing: Double? = 0.0){ 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 832d154..832532c 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindDirectionDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindDirectionDataType.kt @@ -6,13 +6,13 @@ import android.util.Log import androidx.compose.ui.unit.DpSize import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi import androidx.glance.appwidget.GlanceRemoteViews +import de.timklge.karooheadwind.HeadingResponse import de.timklge.karooheadwind.KarooHeadwindExtension import de.timklge.karooheadwind.getRelativeHeadingFlow import de.timklge.karooheadwind.screens.HeadwindSettings import de.timklge.karooheadwind.screens.WindDirectionIndicatorSetting import de.timklge.karooheadwind.screens.WindDirectionIndicatorTextSetting import de.timklge.karooheadwind.streamCurrentWeatherData -import de.timklge.karooheadwind.streamDataFlow import de.timklge.karooheadwind.streamSettings import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.extension.DataTypeImpl @@ -30,8 +30,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.mapNotNull -import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.launch import kotlin.math.cos import kotlin.math.roundToInt @@ -47,7 +45,8 @@ class HeadwindDirectionDataType( val job = CoroutineScope(Dispatchers.IO).launch { karooSystem.getRelativeHeadingFlow(applicationContext) .collect { diff -> - emitter.onNext(StreamState.Streaming(DataPoint(dataTypeId, mapOf(DataType.Field.SINGLE to diff)))) + val value = (diff as? HeadingResponse.Value)?.diff ?: 0.0 + emitter.onNext(StreamState.Streaming(DataPoint(dataTypeId, mapOf(DataType.Field.SINGLE to value)))) } } emitter.setCancellable { @@ -55,7 +54,7 @@ class HeadwindDirectionDataType( } } - data class StreamData(val value: Double, val absoluteWindDirection: Double, val windSpeed: Double, val settings: HeadwindSettings) + data class StreamData(val headingResponse: HeadingResponse?, val absoluteWindDirection: Double?, val windSpeed: Double?, val settings: HeadwindSettings? = null) private fun previewFlow(): Flow { return flow { @@ -63,7 +62,7 @@ class HeadwindDirectionDataType( val bearing = (0..360).random().toDouble() val windSpeed = (-20..20).random() - emit(StreamData(bearing, bearing, windSpeed.toDouble(), HeadwindSettings())) + emit(StreamData(HeadingResponse.Value(bearing), bearing, windSpeed.toDouble(), HeadwindSettings())) delay(2_000) } } @@ -86,36 +85,47 @@ class HeadwindDirectionDataType( val flow = if (config.preview) { previewFlow() } else { - karooSystem.streamDataFlow(dataTypeId) - .mapNotNull { (it as? StreamState.Streaming)?.dataPoint?.singleValue } - .combine(context.streamCurrentWeatherData()) { value, data -> value to data } - .combine(context.streamSettings(karooSystem)) { (value, data), settings -> - StreamData(value, data.current.windDirection, data.current.windSpeed, settings) - } + karooSystem.getRelativeHeadingFlow(context) + .combine(context.streamCurrentWeatherData()) { headingResponse, data -> StreamData(headingResponse, data?.current?.windDirection, data?.current?.windSpeed) } + .combine(context.streamSettings(karooSystem)) { data, settings -> data.copy(settings = settings) } } val viewJob = CoroutineScope(Dispatchers.IO).launch { flow.collect { streamData -> - Log.d(KarooHeadwindExtension.TAG, "Updating headwind direction view") - val windSpeed = streamData.windSpeed - val windDirection = when (streamData.settings.windDirectionIndicatorSetting){ - WindDirectionIndicatorSetting.HEADWIND_DIRECTION -> streamData.value - WindDirectionIndicatorSetting.WIND_DIRECTION -> streamData.absoluteWindDirection + 180 - } - val text = when (streamData.settings.windDirectionIndicatorTextSetting) { - WindDirectionIndicatorTextSetting.HEADWIND_SPEED -> { - val headwindSpeed = cos( (windDirection + 180) * Math.PI / 180.0) * windSpeed - headwindSpeed.roundToInt().toString() - } - WindDirectionIndicatorTextSetting.WIND_SPEED -> windSpeed.roundToInt().toString() - WindDirectionIndicatorTextSetting.NONE -> "" + Log.d(KarooHeadwindExtension.TAG, "Updating headwind direction view") + + val value = (streamData.headingResponse as? HeadingResponse.Value)?.diff + if (value == null || streamData.absoluteWindDirection == null || streamData.settings == null || streamData.windSpeed == null){ + var headingResponse = streamData.headingResponse + + if (headingResponse is HeadingResponse.Value && (streamData.absoluteWindDirection == null || streamData.windSpeed == null)){ + headingResponse = HeadingResponse.NoWeatherData } - val result = glance.compose(context, DpSize.Unspecified) { - HeadwindDirection(baseBitmap, windDirection.roundToInt(), config.textSize, text) - } + emitter.updateView(getErrorWidget(glance, context, streamData.settings, headingResponse).remoteViews) - emitter.updateView(result.remoteViews) + return@collect + } + + val windSpeed = streamData.windSpeed + val windDirection = when (streamData.settings.windDirectionIndicatorSetting){ + WindDirectionIndicatorSetting.HEADWIND_DIRECTION -> value + WindDirectionIndicatorSetting.WIND_DIRECTION -> streamData.absoluteWindDirection + 180 + } + val text = when (streamData.settings.windDirectionIndicatorTextSetting) { + WindDirectionIndicatorTextSetting.HEADWIND_SPEED -> { + val headwindSpeed = cos( (windDirection + 180) * Math.PI / 180.0) * windSpeed + headwindSpeed.roundToInt().toString() + } + WindDirectionIndicatorTextSetting.WIND_SPEED -> windSpeed.roundToInt().toString() + WindDirectionIndicatorTextSetting.NONE -> "" + } + + val result = glance.compose(context, DpSize.Unspecified) { + HeadwindDirection(baseBitmap, windDirection.roundToInt(), config.textSize, text) + } + + emitter.updateView(result.remoteViews) } } emitter.setCancellable { 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 2af4a6e..f34a370 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindSpeedDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindSpeedDataType.kt @@ -1,6 +1,7 @@ package de.timklge.karooheadwind.datatypes import android.content.Context +import de.timklge.karooheadwind.HeadingResponse import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse import de.timklge.karooheadwind.getRelativeHeadingFlow import de.timklge.karooheadwind.screens.HeadwindSettings @@ -15,6 +16,7 @@ import io.hammerhead.karooext.models.StreamState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch import kotlin.math.cos @@ -22,16 +24,17 @@ class HeadwindSpeedDataType( private val karooSystem: KarooSystemService, private val context: Context) : DataTypeImpl("karoo-headwind", "headwindSpeed"){ - data class StreamData(val value: Double, val data: OpenMeteoCurrentWeatherResponse, val settings: HeadwindSettings) + data class StreamData(val headingResponse: HeadingResponse, val weatherResponse: OpenMeteoCurrentWeatherResponse?, val settings: HeadwindSettings) override fun startStream(emitter: Emitter) { 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) } + .filter { it.weatherResponse != null } .collect { streamData -> - val windSpeed = streamData.data.current.windSpeed - val windDirection = streamData.value + val windSpeed = streamData.weatherResponse?.current?.windSpeed ?: 0.0 + val windDirection = (streamData.headingResponse as? HeadingResponse.Value)?.diff ?: 0.0 val headwindSpeed = cos( (windDirection + 180) * Math.PI / 180.0) * windSpeed emitter.onNext(StreamState.Streaming(DataPoint(dataTypeId, mapOf(DataType.Field.SINGLE to headwindSpeed)))) diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/Views.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/Views.kt new file mode 100644 index 0000000..81d054d --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/Views.kt @@ -0,0 +1,47 @@ +package de.timklge.karooheadwind.datatypes + +import android.content.Context +import android.util.Log +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.TextUnitType +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceModifier +import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi +import androidx.glance.appwidget.GlanceRemoteViews +import androidx.glance.appwidget.RemoteViewsCompositionResult +import androidx.glance.color.ColorProvider +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.padding +import androidx.glance.text.Text +import androidx.glance.text.TextAlign +import androidx.glance.text.TextStyle +import de.timklge.karooheadwind.HeadingResponse +import de.timklge.karooheadwind.KarooHeadwindExtension +import de.timklge.karooheadwind.screens.HeadwindSettings + +@OptIn(ExperimentalGlanceRemoteViewsApi::class) +suspend fun getErrorWidget(glance: GlanceRemoteViews, context: Context, settings: HeadwindSettings?, headingResponse: HeadingResponse?): RemoteViewsCompositionResult { + return glance.compose(context, DpSize.Unspecified) { + Box(modifier = GlanceModifier.fillMaxSize().padding(5.dp), contentAlignment = Alignment.Center) { + val errorMessage = if (settings?.welcomeDialogAccepted == false) { + "Headwind app not set up" + } else if (headingResponse is HeadingResponse.NoGps){ + "No GPS signal" + } else { + "Weather data download failed" + } + + Log.d(KarooHeadwindExtension.TAG, "Error widget: $errorMessage") + + Text(text = errorMessage, style = TextStyle(fontSize = TextUnit(16f, TextUnitType.Sp), + textAlign = TextAlign.Center, + color = ColorProvider(Color.Black, Color.White) + ) + ) + } + } +} \ No newline at end of file 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 7b927b0..eab2beb 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherDataType.kt @@ -10,9 +10,11 @@ import androidx.glance.appwidget.GlanceRemoteViews import androidx.glance.layout.Alignment import androidx.glance.layout.Box import androidx.glance.layout.fillMaxSize +import de.timklge.karooheadwind.HeadingResponse import de.timklge.karooheadwind.KarooHeadwindExtension import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse 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 @@ -33,7 +35,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.launch import java.time.Instant import java.time.ZoneId @@ -58,8 +59,8 @@ class WeatherDataType( currentWeatherData .collect { data -> - Log.d(KarooHeadwindExtension.TAG, "Wind code: ${data.current.weatherCode}") - emitter.onNext(StreamState.Streaming(DataPoint(dataTypeId, mapOf(DataType.Field.SINGLE to data.current.weatherCode.toDouble())))) + Log.d(KarooHeadwindExtension.TAG, "Wind code: ${data?.current?.weatherCode}") + emitter.onNext(StreamState.Streaming(DataPoint(dataTypeId, mapOf(DataType.Field.SINGLE to (data?.current?.weatherCode?.toDouble() ?: 0.0))))) } } emitter.setCancellable { @@ -79,15 +80,23 @@ class WeatherDataType( de.timklge.karooheadwind.R.drawable.arrow_0 ) - data class StreamData(val data: OpenMeteoCurrentWeatherResponse, val settings: HeadwindSettings, - val profile: UserProfile? = null) + data class StreamData(val data: OpenMeteoCurrentWeatherResponse?, val settings: HeadwindSettings, + val profile: UserProfile? = null, val headingResponse: HeadingResponse? = null) val viewJob = CoroutineScope(Dispatchers.IO).launch { context.streamCurrentWeatherData() .combine(context.streamSettings(karooSystem)) { data, settings -> StreamData(data, settings) } .combine(karooSystem.streamUserProfile()) { data, profile -> data.copy(profile = profile) } - .collect { (data, settings, userProfile) -> + .combine(karooSystem.getHeadingFlow(context)) { data, heading -> data.copy(headingResponse = heading) } + .collect { (data, settings, userProfile, headingResponse) -> Log.d(KarooHeadwindExtension.TAG, "Updating weather view") + + if (data == null){ + emitter.updateView(getErrorWidget(glance, context, settings, headingResponse).remoteViews) + + return@collect + } + val interpretation = WeatherInterpretation.fromWeatherCode(data.current.weatherCode) val formattedTime = timeFormatter.format(Instant.ofEpochSecond(data.current.time)) 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 f30416b..7e9110b 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherForecastDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherForecastDataType.kt @@ -3,10 +3,6 @@ package de.timklge.karooheadwind.datatypes import android.content.Context import android.graphics.BitmapFactory import android.util.Log -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp @@ -25,12 +21,12 @@ import androidx.glance.layout.Row import androidx.glance.layout.Spacer import androidx.glance.layout.fillMaxHeight import androidx.glance.layout.fillMaxSize -import androidx.glance.layout.padding import androidx.glance.layout.width +import de.timklge.karooheadwind.HeadingResponse import de.timklge.karooheadwind.KarooHeadwindExtension import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse import de.timklge.karooheadwind.WeatherInterpretation -import de.timklge.karooheadwind.saveSettings +import de.timklge.karooheadwind.getHeadingFlow import de.timklge.karooheadwind.saveWidgetSettings import de.timklge.karooheadwind.screens.HeadwindSettings import de.timklge.karooheadwind.screens.HeadwindWidgetSettings @@ -55,7 +51,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.launch import java.time.Instant import java.time.ZoneId @@ -74,7 +69,7 @@ class CycleHoursAction : ActionCallback { val data = context.streamCurrentWeatherData().first() var hourOffset = currentSettings.currentForecastHourOffset + 3 - if (hourOffset >= data.forecastData.weatherCode.size) { + if (data == null || hourOffset >= data.forecastData.weatherCode.size) { hourOffset = 0 } @@ -101,8 +96,8 @@ class WeatherForecastDataType( currentWeatherData .collect { data -> - Log.d(KarooHeadwindExtension.TAG, "Wind code: ${data.current.weatherCode}") - emitter.onNext(StreamState.Streaming(DataPoint(dataTypeId, mapOf(DataType.Field.SINGLE to data.current.weatherCode.toDouble())))) + Log.d(KarooHeadwindExtension.TAG, "Wind code: ${data?.current?.weatherCode}") + emitter.onNext(StreamState.Streaming(DataPoint(dataTypeId, mapOf(DataType.Field.SINGLE to (data?.current?.weatherCode?.toDouble() ?: 0.0))))) } } emitter.setCancellable { @@ -122,16 +117,23 @@ class WeatherForecastDataType( de.timklge.karooheadwind.R.drawable.arrow_0 ) - data class StreamData(val data: OpenMeteoCurrentWeatherResponse, val settings: HeadwindSettings, - val widgetSettings: HeadwindWidgetSettings? = null, val profile: UserProfile? = null) + data class StreamData(val data: OpenMeteoCurrentWeatherResponse?, val settings: HeadwindSettings, + val widgetSettings: HeadwindWidgetSettings? = null, val profile: UserProfile? = null, val headingResponse: HeadingResponse? = null) val viewJob = CoroutineScope(Dispatchers.IO).launch { 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) } - .collect { (data, settings, widgetSettings, userProfile) -> - Log.d(KarooHeadwindExtension.TAG, "Updating weather view") + .combine(karooSystem.getHeadingFlow(context)) { data, headingResponse -> data.copy(headingResponse = headingResponse) } + .collect { (data, settings, widgetSettings, userProfile, headingResponse) -> + Log.d(KarooHeadwindExtension.TAG, "Updating weather forecast view") + + if (data == null){ + emitter.updateView(getErrorWidget(glance, context, settings, headingResponse).remoteViews) + + return@collect + } val result = glance.compose(context, DpSize.Unspecified) { Row(modifier = GlanceModifier.fillMaxSize().clickable(onClick = actionRunCallback()), horizontalAlignment = Alignment.Horizontal.CenterHorizontally) { diff --git a/app/src/main/res/drawable/launch_background.xml b/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..ce52b2c --- /dev/null +++ b/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/wind.png b/app/src/main/res/drawable/wind.png new file mode 100644 index 0000000..9461f5b Binary files /dev/null and b/app/src/main/res/drawable/wind.png differ diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index 036d09b..0000000 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml deleted file mode 100644 index 036d09b..0000000 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml deleted file mode 100644 index beab31f..0000000 --- a/app/src/main/res/values/ic_launcher_background.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - #000000 - \ No newline at end of file diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..285301a --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/extension_info.xml b/app/src/main/res/xml/extension_info.xml index 4694ea5..f8cf6a1 100644 --- a/app/src/main/res/xml/extension_info.xml +++ b/app/src/main/res/xml/extension_info.xml @@ -1,35 +1,35 @@