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

* Prototype simplified weather forecast along route

* Reset last known route progression on route change

* Add forecast distance per hour setting

* Only decode response as gzip if gzipped

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

View File

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

View File

@ -3,9 +3,9 @@
"packageName": "de.timklge.karooheadwind", "packageName": "de.timklge.karooheadwind",
"iconUrl": "https://github.com/timklge/karoo-headwind/releases/latest/download/karoo-headwind.png", "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", "latestApkUrl": "https://github.com/timklge/karoo-headwind/releases/latest/download/app-release.apk",
"latestVersion": "1.2.5", "latestVersion": "1.3",
"latestVersionCode": 13, "latestVersionCode": 14,
"developer": "timklge", "developer": "timklge",
"description": "Provides headwind direction, wind speed and other weather data fields", "description": "Provides headwind direction, wind speed and other weather data fields",
"releaseNotes": "Include more directions in wind direction data field, do not use streams for secondary data fields to work around issue in current karoo release" "releaseNotes": "Forecast weather along route in fixed intervals if route is loaded",
} }

View File

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

View File

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

View File

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

View File

@ -1,14 +1,16 @@
package de.timklge.karooheadwind package de.timklge.karooheadwind
import android.util.Log import android.util.Log
import androidx.compose.ui.util.fastZip
import com.mapbox.geojson.LineString
import com.mapbox.turf.TurfConstants
import com.mapbox.turf.TurfMeasurement
import de.timklge.karooheadwind.datatypes.CloudCoverDataType import de.timklge.karooheadwind.datatypes.CloudCoverDataType
import de.timklge.karooheadwind.datatypes.GpsCoordinates import de.timklge.karooheadwind.datatypes.GpsCoordinates
import de.timklge.karooheadwind.datatypes.HeadwindSpeedDataType
import de.timklge.karooheadwind.datatypes.PrecipitationDataType import de.timklge.karooheadwind.datatypes.PrecipitationDataType
import de.timklge.karooheadwind.datatypes.RelativeHumidityDataType import de.timklge.karooheadwind.datatypes.RelativeHumidityDataType
import de.timklge.karooheadwind.datatypes.SurfacePressureDataType import de.timklge.karooheadwind.datatypes.SurfacePressureDataType
import de.timklge.karooheadwind.datatypes.WindDirectionDataType
import de.timklge.karooheadwind.datatypes.WindGustsDataType
import de.timklge.karooheadwind.datatypes.HeadwindSpeedDataType
import de.timklge.karooheadwind.datatypes.TailwindAndRideSpeedDataType import de.timklge.karooheadwind.datatypes.TailwindAndRideSpeedDataType
import de.timklge.karooheadwind.datatypes.HeadwindDirectionDataType import de.timklge.karooheadwind.datatypes.HeadwindDirectionDataType
import de.timklge.karooheadwind.datatypes.SealevelPressureDataType import de.timklge.karooheadwind.datatypes.SealevelPressureDataType
@ -16,6 +18,8 @@ import de.timklge.karooheadwind.datatypes.TemperatureDataType
import de.timklge.karooheadwind.datatypes.UserWindSpeedDataType import de.timklge.karooheadwind.datatypes.UserWindSpeedDataType
import de.timklge.karooheadwind.datatypes.WeatherDataType import de.timklge.karooheadwind.datatypes.WeatherDataType
import de.timklge.karooheadwind.datatypes.WeatherForecastDataType import de.timklge.karooheadwind.datatypes.WeatherForecastDataType
import de.timklge.karooheadwind.datatypes.WindDirectionDataType
import de.timklge.karooheadwind.datatypes.WindGustsDataType
import de.timklge.karooheadwind.datatypes.WindSpeedDataType import de.timklge.karooheadwind.datatypes.WindSpeedDataType
import de.timklge.karooheadwind.screens.HeadwindSettings import de.timklge.karooheadwind.screens.HeadwindSettings
import de.timklge.karooheadwind.screens.HeadwindStats import de.timklge.karooheadwind.screens.HeadwindStats
@ -31,6 +35,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@ -38,17 +43,23 @@ import kotlinx.coroutines.flow.retry
import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.time.debounce import kotlinx.coroutines.time.debounce
import kotlinx.coroutines.withContext
import java.time.Duration import java.time.Duration
import java.time.LocalDateTime
import java.time.temporal.ChronoUnit
import java.util.Locale
import java.util.zip.GZIPInputStream
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
import kotlin.math.roundToInt
import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.minutes
class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.2.5") { class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.3") {
companion object { companion object {
const val TAG = "karoo-headwind" const val TAG = "karoo-headwind"
} }
lateinit var karooSystem: KarooSystemService private lateinit var karooSystem: KarooSystemService
private var updateLastKnownGpsJob: Job? = null private var updateLastKnownGpsJob: Job? = null
private var serviceJob: Job? = null private var serviceJob: Job? = null
@ -75,7 +86,7 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.2.5") {
} }
data class StreamData(val settings: HeadwindSettings, val gps: GpsCoordinates?, data class StreamData(val settings: HeadwindSettings, val gps: GpsCoordinates?,
val profile: UserProfile? = null) val profile: UserProfile? = null, val upcomingRoute: UpcomingRoute? = null)
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
override fun onCreate() { override fun onCreate() {
@ -104,74 +115,144 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.2.5") {
} }
} }
.debounce(Duration.ofSeconds(5)) .debounce(Duration.ofSeconds(5))
.transformLatest { value: GpsCoordinates? ->
while(true){
emit(value)
delay(1.hours)
}
}
streamSettings(karooSystem) var requestedGpsCoordinates: List<GpsCoordinates> = mutableListOf()
val settingsStream = streamSettings(karooSystem)
.filter { it.welcomeDialogAccepted } .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 { data class StreamData(val settings: HeadwindSettings, val gps: GpsCoordinates?, val profile: UserProfile?, val upcomingRoute: UpcomingRoute?)
streamStats().first() data class StreamDataIdentity(val settings: HeadwindSettings, val gpsLat: Double?, val gpsLon: Double?, val profile: UserProfile?, val routePolyline: LineString?)
} catch(e: Exception){
Log.e(TAG, "Failed to read stats", e)
HeadwindStats()
}
if (gps == null){ combine(settingsStream, gpsFlow, karooSystem.streamUserProfile(), karooSystem.streamUpcomingRoute()) { settings, gps, profile, upcomingRoute ->
error("No GPS coordinates available") StreamData(settings, gps, profile, upcomingRoute)
} }
.distinctUntilChangedBy { StreamDataIdentity(it.settings, it.gps?.lat, it.gps?.lon, it.profile, it.upcomingRoute?.routePolyline) }
val response = karooSystem.makeOpenMeteoHttpRequest(gps, settings, profile) .transformLatest { value ->
if (response.error != null){ while(true){
try { emit(value)
val stats = lastKnownStats.copy(failedWeatherRequest = System.currentTimeMillis()) delay(1.hours)
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) { delay(1.minutes); true } }
.collect { response -> .map { (settings: HeadwindSettings, gps, profile, upcomingRoute) ->
try { Log.d(TAG, "Acquired updated gps coordinates: $gps")
val responseString = String(response.body ?: ByteArray(0))
val data = jsonWithUnknownKeys.decodeFromString<OpenMeteoCurrentWeatherResponse>(responseString)
saveCurrentData(applicationContext, data) val lastKnownStats = try {
saveWidgetSettings(applicationContext, HeadwindWidgetSettings(currentForecastHourOffset = 0)) 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<OpenMeteoCurrentWeatherResponse>(responseString)
val data = WeatherDataResponse(weatherData, requestedGpsCoordinates.single())
saveCurrentData(applicationContext, listOf(data))
Log.d(TAG, "Got updated weather info: $data")
} else {
val weatherData = jsonWithUnknownKeys.decodeFromString<List<OpenMeteoCurrentWeatherResponse>>(responseString)
val data = weatherData.fastZip(requestedGpsCoordinates) { weather, gps -> WeatherDataResponse(weather, gps) }
saveCurrentData(applicationContext, data)
Log.d(TAG, "Got updated weather info: $data") 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() { override fun onDestroy() {
serviceJob?.cancel() serviceJob?.cancel()
serviceJob = null serviceJob = null
updateLastKnownGpsJob?.cancel()
updateLastKnownGpsJob = null
karooSystem.disconnect() karooSystem.disconnect()
super.onDestroy() super.onDestroy()
} }

View File

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

View File

@ -29,9 +29,13 @@ abstract class BaseDataType(
currentWeatherData currentWeatherData
.filterNotNull() .filterNotNull()
.collect { data -> .collect { data ->
val value = getValue(data) val value = data.firstOrNull()?.data?.let { w -> getValue(w) }
Log.d(KarooHeadwindExtension.TAG, "$dataTypeId: $value") 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 { emitter.setCancellable {

View File

@ -0,0 +1,37 @@
package de.timklge.karooheadwind.datatypes
import android.content.Context
import android.util.Log
import androidx.glance.GlanceId
import androidx.glance.action.ActionParameters
import androidx.glance.appwidget.action.ActionCallback
import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.saveWidgetSettings
import de.timklge.karooheadwind.streamCurrentWeatherData
import de.timklge.karooheadwind.streamWidgetSettings
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
class CycleHoursAction : ActionCallback {
override suspend fun onAction(
context: Context,
glanceId: GlanceId,
parameters: ActionParameters
) {
Log.d(KarooHeadwindExtension.TAG, "Cycling hours")
val currentSettings = context.streamWidgetSettings().first()
val data = context.streamCurrentWeatherData().firstOrNull()
var hourOffset = currentSettings.currentForecastHourOffset + 3
val requestedPositions = data?.size
val requestedHours = data?.firstOrNull()?.data?.forecastData?.weatherCode?.size
if (data == null || requestedHours == null || requestedPositions == null || hourOffset >= requestedHours || (requestedPositions > 1 && hourOffset >= requestedPositions)) {
hourOffset = 0
}
val newSettings = currentSettings.copy(currentForecastHourOffset = hourOffset)
saveWidgetSettings(context, newSettings)
}
}

View File

@ -9,7 +9,7 @@ import kotlin.math.sin
import kotlin.math.sqrt import kotlin.math.sqrt
@Serializable @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 { companion object {
private fun roundDegrees(degrees: Double, km: Double): Double { private fun roundDegrees(degrees: Double, km: Double): Double {
val nkm = degrees * 111 val nkm = degrees * 111

View File

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

View File

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

View File

@ -115,7 +115,7 @@ class TailwindAndRideSpeedDataType(
karooSystem.getRelativeHeadingFlow(context) karooSystem.getRelativeHeadingFlow(context)
.combine(context.streamCurrentWeatherData()) { value, data -> value to data } .combine(context.streamCurrentWeatherData()) { value, data -> value to data }
.combine(context.streamSettings(karooSystem)) { (value, data), settings -> .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 -> .combine(karooSystem.streamUserProfile()) { streamData, userProfile ->
val isImperial = userProfile.preferredUnit.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL val isImperial = userProfile.preferredUnit.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL

View File

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

View File

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

View File

@ -6,13 +6,10 @@ import android.util.Log
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier import androidx.glance.GlanceModifier
import androidx.glance.action.ActionParameters
import androidx.glance.action.clickable import androidx.glance.action.clickable
import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi
import androidx.glance.appwidget.GlanceRemoteViews import androidx.glance.appwidget.GlanceRemoteViews
import androidx.glance.appwidget.action.ActionCallback
import androidx.glance.appwidget.action.actionRunCallback import androidx.glance.appwidget.action.actionRunCallback
import androidx.glance.background import androidx.glance.background
import androidx.glance.color.ColorProvider import androidx.glance.color.ColorProvider
@ -27,26 +24,22 @@ import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse
import de.timklge.karooheadwind.OpenMeteoData import de.timklge.karooheadwind.OpenMeteoData
import de.timklge.karooheadwind.OpenMeteoForecastData import de.timklge.karooheadwind.OpenMeteoForecastData
import de.timklge.karooheadwind.UpcomingRoute
import de.timklge.karooheadwind.WeatherDataResponse
import de.timklge.karooheadwind.WeatherInterpretation import de.timklge.karooheadwind.WeatherInterpretation
import de.timklge.karooheadwind.datatypes.WeatherDataType.StreamData
import de.timklge.karooheadwind.getHeadingFlow import de.timklge.karooheadwind.getHeadingFlow
import de.timklge.karooheadwind.saveWidgetSettings
import de.timklge.karooheadwind.screens.HeadwindSettings import de.timklge.karooheadwind.screens.HeadwindSettings
import de.timklge.karooheadwind.screens.HeadwindWidgetSettings import de.timklge.karooheadwind.screens.HeadwindWidgetSettings
import de.timklge.karooheadwind.screens.PrecipitationUnit
import de.timklge.karooheadwind.screens.TemperatureUnit import de.timklge.karooheadwind.screens.TemperatureUnit
import de.timklge.karooheadwind.streamCurrentWeatherData import de.timklge.karooheadwind.streamCurrentWeatherData
import de.timklge.karooheadwind.streamSettings import de.timklge.karooheadwind.streamSettings
import de.timklge.karooheadwind.streamUpcomingRoute
import de.timklge.karooheadwind.streamUserProfile import de.timklge.karooheadwind.streamUserProfile
import de.timklge.karooheadwind.streamWidgetSettings import de.timklge.karooheadwind.streamWidgetSettings
import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.extension.DataTypeImpl import io.hammerhead.karooext.extension.DataTypeImpl
import io.hammerhead.karooext.internal.Emitter
import io.hammerhead.karooext.internal.ViewEmitter 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.ShowCustomStreamState
import io.hammerhead.karooext.models.StreamState
import io.hammerhead.karooext.models.UpdateGraphicConfig import io.hammerhead.karooext.models.UpdateGraphicConfig
import io.hammerhead.karooext.models.UserProfile import io.hammerhead.karooext.models.UserProfile
import io.hammerhead.karooext.models.ViewConfig import io.hammerhead.karooext.models.ViewConfig
@ -56,7 +49,7 @@ import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.Instant import java.time.Instant
@ -66,27 +59,6 @@ import java.time.format.FormatStyle
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
import kotlin.math.roundToInt 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) @OptIn(ExperimentalGlanceRemoteViewsApi::class)
class WeatherForecastDataType( class WeatherForecastDataType(
private val karooSystem: KarooSystemService, private val karooSystem: KarooSystemService,
@ -98,31 +70,44 @@ class WeatherForecastDataType(
val timeFormatter = DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneId.systemDefault()) val timeFormatter = DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneId.systemDefault())
} }
data class StreamData(val data: OpenMeteoCurrentWeatherResponse?, val settings: HeadwindSettings, data class StreamData(val data: List<WeatherDataResponse>?, val settings: SettingsAndProfile,
val widgetSettings: HeadwindWidgetSettings? = null, val profile: UserProfile? = null, val headingResponse: HeadingResponse? = null) val widgetSettings: HeadwindWidgetSettings? = null, val profile: UserProfile? = null,
val headingResponse: HeadingResponse? = null, val upcomingRoute: UpcomingRoute? = null)
data class SettingsAndProfile(val settings: HeadwindSettings, val isImperial: Boolean)
private fun previewFlow(settingsAndProfileStream: Flow<SettingsAndProfile>): Flow<StreamData> = flow {
val settingsAndProfile = settingsAndProfileStream.firstOrNull()
private fun previewFlow(): Flow<StreamData> = flow {
while (true){ while (true){
val timeAtFullHour = Instant.now().truncatedTo(ChronoUnit.HOURS).epochSecond val data = (0..<10).map { index ->
val forecastTimes = (0..<12).map { timeAtFullHour + it * 60 * 60 } val timeAtFullHour = Instant.now().truncatedTo(ChronoUnit.HOURS).epochSecond
val forecastTemperatures = (0..<12).map { 20.0 + (-20..20).random() } val forecastTimes = (0..<12).map { timeAtFullHour + it * 60 * 60 }
val forecastPrecipitationPropability = (0..<12).map { (0..100).random() } val forecastTemperatures = (0..<12).map { 20.0 + (-20..20).random() }
val forecastPrecipitation = (0..<12).map { 0.0 + (0..10).random() } val forecastPrecipitationPropability = (0..<12).map { (0..100).random() }
val forecastWeatherCodes = (0..<12).map { WeatherInterpretation.getKnownWeatherCodes().random() } val forecastPrecipitation = (0..<12).map { 0.0 + (0..10).random() }
val forecastWindSpeed = (0..<12).map { 0.0 + (0..10).random() } val forecastWeatherCodes = (0..<12).map { WeatherInterpretation.getKnownWeatherCodes().random() }
val forecastWindDirection = (0..<12).map { 0.0 + (0..360).random() } val forecastWindSpeed = (0..<12).map { 0.0 + (0..10).random() }
val forecastWindGusts = (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( emit(
StreamData( StreamData(data, SettingsAndProfile(HeadwindSettings(), settingsAndProfile?.isImperial == true))
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())
) )
delay(5_000) delay(5_000)
@ -141,24 +126,30 @@ class WeatherForecastDataType(
de.timklge.karooheadwind.R.drawable.arrow_0 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){ val dataFlow = if (config.preview){
previewFlow() previewFlow(settingsAndProfileStream)
} else { } else {
context.streamCurrentWeatherData() combine(context.streamCurrentWeatherData(),
.combine(context.streamSettings(karooSystem)) { data, settings -> StreamData(data, settings) } settingsAndProfileStream,
.combine(karooSystem.streamUserProfile()) { data, profile -> data.copy(profile = profile) } context.streamWidgetSettings(),
.combine(context.streamWidgetSettings()) { data, widgetSettings -> data.copy(widgetSettings = widgetSettings) } karooSystem.getHeadingFlow(context),
.combine(karooSystem.getHeadingFlow(context)) { data, headingResponse -> data.copy(headingResponse = headingResponse) } karooSystem.streamUpcomingRoute()) { weatherData, settings, widgetSettings, heading, upcomingRoute ->
StreamData(data = weatherData, settings = settings, widgetSettings = widgetSettings, headingResponse = heading, upcomingRoute = upcomingRoute)
}
} }
val viewJob = CoroutineScope(Dispatchers.IO).launch { val viewJob = CoroutineScope(Dispatchers.IO).launch {
emitter.onNext(ShowCustomStreamState("", null)) 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") Log.d(KarooHeadwindExtension.TAG, "Updating weather forecast view")
if (data == null){ if (allData == null){
emitter.updateView(getErrorWidget(glance, context, settings, headingResponse).remoteViews) emitter.updateView(getErrorWidget(glance, context, settingsAndProfile.settings, headingResponse).remoteViews)
return@collect return@collect
} }
@ -168,22 +159,32 @@ class WeatherForecastDataType(
if (!config.preview) modifier = modifier.clickable(onClick = actionRunCallback<CycleHoursAction>()) if (!config.preview) modifier = modifier.clickable(onClick = actionRunCallback<CycleHoursAction>())
Row(modifier = modifier, horizontalAlignment = Alignment.Horizontal.CenterHorizontally) { Row(modifier = modifier, horizontalAlignment = Alignment.Horizontal.Start) {
val hourOffset = widgetSettings?.currentForecastHourOffset ?: 0 val hourOffset = widgetSettings?.currentForecastHourOffset ?: 0
val positionOffset = if (allData.size == 1) 0 else hourOffset
var previousDate: String? = let { 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)) } val formattedDate = unixTime?.let { Instant.ofEpochSecond(unixTime).atZone(ZoneId.systemDefault()).toLocalDate().format(DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)) }
formattedDate formattedDate
} }
for (index in hourOffset..hourOffset + 2){ for (baseIndex in hourOffset..hourOffset + 2){
if (index >= (data.forecastData?.weatherCode?.size ?: 0)) { 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 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( Spacer(
modifier = GlanceModifier.fillMaxHeight().background( modifier = GlanceModifier.fillMaxHeight().background(
ColorProvider(Color.Black, Color.White) ColorProvider(Color.Black, Color.White)
@ -191,28 +192,60 @@ class WeatherForecastDataType(
) )
} }
val interpretation = WeatherInterpretation.fromWeatherCode(data.forecastData?.weatherCode?.get(index) ?: 0) val distanceFromCurrent = upcomingRoute?.distanceAlongRoute?.let { currentDistanceAlongRoute ->
val unixTime = data.forecastData?.time?.get(index) ?: 0 distanceAlongRoute?.minus(currentDistanceAlongRoute)
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
Weather(baseBitmap, val isCurrent = baseIndex == 0 && positionIndex == 0
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
)
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
}
} }
} }
} }

View File

@ -32,9 +32,8 @@ import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle import androidx.glance.text.TextStyle
import de.timklge.karooheadwind.R import de.timklge.karooheadwind.R
import de.timklge.karooheadwind.WeatherInterpretation import de.timklge.karooheadwind.WeatherInterpretation
import de.timklge.karooheadwind.screens.PrecipitationUnit
import de.timklge.karooheadwind.screens.TemperatureUnit import de.timklge.karooheadwind.screens.TemperatureUnit
import de.timklge.karooheadwind.screens.WindUnit import kotlin.math.absoluteValue
import kotlin.math.ceil import kotlin.math.ceil
fun getWeatherIcon(interpretation: WeatherInterpretation): Int { fun getWeatherIcon(interpretation: WeatherInterpretation): Int {
@ -52,10 +51,23 @@ fun getWeatherIcon(interpretation: WeatherInterpretation): Int {
@OptIn(ExperimentalGlancePreviewApi::class) @OptIn(ExperimentalGlancePreviewApi::class)
@Preview(widthDp = 200, heightDp = 150) @Preview(widthDp = 200, heightDp = 150)
@Composable @Composable
fun Weather(baseBitmap: Bitmap, current: WeatherInterpretation, windBearing: Int, windSpeed: Int, windGusts: Int, windSpeedUnit: WindUnit, fun Weather(
precipitation: Double, precipitationProbability: Int?, precipitationUnit: PrecipitationUnit, baseBitmap: Bitmap,
temperature: Int, temperatureUnit: TemperatureUnit, timeLabel: String? = null, rowAlignment: Alignment.Horizontal = Alignment.Horizontal.CenterHorizontally, current: WeatherInterpretation,
dateLabel: String? = null, singleDisplay: Boolean = false) { 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 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) { Row(verticalAlignment = Alignment.CenterVertically, horizontalAlignment = rowAlignment) {
if (timeLabel != null){ if (timeLabel != null){
Text( Text(

View File

@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ExitToApp 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.FilledTonalButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.timklge.karooheadwind.datatypes.GpsCoordinates import de.timklge.karooheadwind.datatypes.GpsCoordinates
@ -39,7 +43,9 @@ import de.timklge.karooheadwind.getGpsCoordinateFlow
import de.timklge.karooheadwind.saveSettings import de.timklge.karooheadwind.saveSettings
import de.timklge.karooheadwind.streamSettings import de.timklge.karooheadwind.streamSettings
import de.timklge.karooheadwind.streamStats import de.timklge.karooheadwind.streamStats
import de.timklge.karooheadwind.streamUserProfile
import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.UserProfile
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
@ -90,11 +96,17 @@ data class HeadwindSettings(
val welcomeDialogAccepted: Boolean = false, val welcomeDialogAccepted: Boolean = false,
val windDirectionIndicatorTextSetting: WindDirectionIndicatorTextSetting = WindDirectionIndicatorTextSetting.HEADWIND_SPEED, val windDirectionIndicatorTextSetting: WindDirectionIndicatorTextSetting = WindDirectionIndicatorTextSetting.HEADWIND_SPEED,
val windDirectionIndicatorSetting: WindDirectionIndicatorSetting = WindDirectionIndicatorSetting.HEADWIND_DIRECTION, 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 { companion object {
val defaultSettings = Json.encodeToString(HeadwindSettings()) val defaultSettings = Json.encodeToString(HeadwindSettings())
} }
fun getForecastMetersPerHour(isImperial: Boolean): Int {
return if (isImperial) forecastedMilesPerHour * 1609 else forecastedKmPerHour * 1000
}
} }
@Serializable @Serializable
@ -130,8 +142,11 @@ fun MainScreen(onFinish: () -> Unit) {
var selectedWindDirectionIndicatorTextSetting by remember { mutableStateOf(WindDirectionIndicatorTextSetting.HEADWIND_SPEED) } var selectedWindDirectionIndicatorTextSetting by remember { mutableStateOf(WindDirectionIndicatorTextSetting.HEADWIND_SPEED) }
var selectedWindDirectionIndicatorSetting by remember { mutableStateOf(WindDirectionIndicatorSetting.HEADWIND_DIRECTION) } var selectedWindDirectionIndicatorSetting by remember { mutableStateOf(WindDirectionIndicatorSetting.HEADWIND_DIRECTION) }
var selectedRoundLocationSetting by remember { mutableStateOf(RoundLocationSetting.KM_2) } 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) val location by karooSystem.getGpsCoordinateFlow(ctx).collectAsStateWithLifecycle(null)
var savedDialogVisible by remember { mutableStateOf(false) } var savedDialogVisible by remember { mutableStateOf(false) }
@ -144,6 +159,8 @@ fun MainScreen(onFinish: () -> Unit) {
selectedWindDirectionIndicatorTextSetting = settings.windDirectionIndicatorTextSetting selectedWindDirectionIndicatorTextSetting = settings.windDirectionIndicatorTextSetting
selectedWindDirectionIndicatorSetting = settings.windDirectionIndicatorSetting selectedWindDirectionIndicatorSetting = settings.windDirectionIndicatorSetting
selectedRoundLocationSetting = settings.roundLocationTo 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 }!! 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 FilledTonalButton(modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -202,7 +237,9 @@ fun MainScreen(onFinish: () -> Unit) {
welcomeDialogAccepted = true, welcomeDialogAccepted = true,
windDirectionIndicatorSetting = selectedWindDirectionIndicatorSetting, windDirectionIndicatorSetting = selectedWindDirectionIndicatorSetting,
windDirectionIndicatorTextSetting = selectedWindDirectionIndicatorTextSetting, windDirectionIndicatorTextSetting = selectedWindDirectionIndicatorTextSetting,
roundLocationTo = selectedRoundLocationSetting) roundLocationTo = selectedRoundLocationSetting,
forecastedMilesPerHour = forecastMilesPerHour.toIntOrNull()?.coerceIn(3, 30) ?: 12,
forecastedKmPerHour = forecastKmPerHour.toIntOrNull()?.coerceIn(5, 50) ?: 20)
coroutineScope.launch { coroutineScope.launch {
saveSettings(ctx, newSettings) saveSettings(ctx, newSettings)

View File

@ -1,6 +1,6 @@
[versions] [versions]
agp = "8.5.2" agp = "8.5.2"
datastorePreferences = "1.1.1" datastorePreferences = "1.1.2"
kotlin = "2.0.0" kotlin = "2.0.0"
androidxCore = "1.15.0" androidxCore = "1.15.0"
@ -9,8 +9,8 @@ androidxActivity = "1.9.3"
androidxComposeUi = "1.7.6" androidxComposeUi = "1.7.6"
androidxComposeMaterial = "1.3.1" androidxComposeMaterial = "1.3.1"
glance = "1.1.1" glance = "1.1.1"
kotlinxDatetime = "0.6.1" kotlinxSerializationJson = "1.8.0"
kotlinxSerializationJson = "1.7.3" mapboxSdkTurf = "7.3.1"
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } 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-appwidget-preview = { group = "androidx.glance", name = "glance-appwidget-preview", version.ref = "glance" }
androidx-glance-preview = { group = "androidx.glance", name = "glance-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" } 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] [bundles]
androidx-lifeycle = ["androidx-lifecycle-runtime-compose", "androidx-lifecycle-viewmodel-compose"] androidx-lifeycle = ["androidx-lifecycle-runtime-compose", "androidx-lifecycle-viewmodel-compose"]

View File

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