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:
parent
548b81d7a4
commit
de009454b8
@ -15,8 +15,8 @@ android {
|
||||
applicationId = "de.timklge.karooheadwind"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 13
|
||||
versionName = "1.2.5"
|
||||
versionCode = 14
|
||||
versionName = "1.3"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
@ -55,6 +55,7 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.mapbox.sdk.turf)
|
||||
implementation(libs.hammerhead.karoo.ext)
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.bundles.androidx.lifeycle)
|
||||
|
||||
@ -3,9 +3,9 @@
|
||||
"packageName": "de.timklge.karooheadwind",
|
||||
"iconUrl": "https://github.com/timklge/karoo-headwind/releases/latest/download/karoo-headwind.png",
|
||||
"latestApkUrl": "https://github.com/timklge/karoo-headwind/releases/latest/download/app-release.apk",
|
||||
"latestVersion": "1.2.5",
|
||||
"latestVersionCode": 13,
|
||||
"latestVersion": "1.3",
|
||||
"latestVersionCode": 14,
|
||||
"developer": "timklge",
|
||||
"description": "Provides headwind direction, wind speed and other weather data fields",
|
||||
"releaseNotes": "Include more directions in wind direction data field, do not use streams for secondary data fields to work around issue in current karoo release"
|
||||
"releaseNotes": "Forecast weather along route in fixed intervals if route is loaded",
|
||||
}
|
||||
@ -4,20 +4,32 @@ import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import com.mapbox.geojson.LineString
|
||||
import com.mapbox.turf.TurfConstants
|
||||
import com.mapbox.turf.TurfMeasurement
|
||||
import de.timklge.karooheadwind.datatypes.GpsCoordinates
|
||||
import de.timklge.karooheadwind.screens.HeadwindSettings
|
||||
import de.timklge.karooheadwind.screens.HeadwindStats
|
||||
import de.timklge.karooheadwind.screens.HeadwindWidgetSettings
|
||||
import de.timklge.karooheadwind.screens.WindUnit
|
||||
import io.hammerhead.karooext.KarooSystemService
|
||||
import io.hammerhead.karooext.models.DataType
|
||||
import io.hammerhead.karooext.models.OnNavigationState
|
||||
import io.hammerhead.karooext.models.StreamState
|
||||
import io.hammerhead.karooext.models.UserProfile
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.channels.trySendBlocking
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@ -26,7 +38,7 @@ val jsonWithUnknownKeys = Json { ignoreUnknownKeys = true }
|
||||
|
||||
val settingsKey = stringPreferencesKey("settings")
|
||||
val widgetSettingsKey = stringPreferencesKey("widgetSettings")
|
||||
val currentDataKey = stringPreferencesKey("current")
|
||||
val currentDataKey = stringPreferencesKey("currentForecasts")
|
||||
val statsKey = stringPreferencesKey("stats")
|
||||
val lastKnownPositionKey = stringPreferencesKey("lastKnownPosition")
|
||||
|
||||
@ -48,7 +60,10 @@ suspend fun saveStats(context: Context, stats: HeadwindStats) {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveCurrentData(context: Context, forecast: OpenMeteoCurrentWeatherResponse) {
|
||||
@Serializable
|
||||
data class WeatherDataResponse(val data: OpenMeteoCurrentWeatherResponse, val requestedPosition: GpsCoordinates)
|
||||
|
||||
suspend fun saveCurrentData(context: Context, forecast: List<WeatherDataResponse>) {
|
||||
context.dataStore.edit { t ->
|
||||
t[currentDataKey] = Json.encodeToString(forecast)
|
||||
}
|
||||
@ -66,7 +81,6 @@ suspend fun saveLastKnownPosition(context: Context, gpsCoordinates: GpsCoordinat
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun Context.streamWidgetSettings(): Flow<HeadwindWidgetSettings> {
|
||||
return dataStore.data.map { settingsJson ->
|
||||
try {
|
||||
@ -103,6 +117,45 @@ fun Context.streamSettings(karooSystemService: KarooSystemService): Flow<Headwin
|
||||
}.distinctUntilChanged()
|
||||
}
|
||||
|
||||
data class UpcomingRoute(val distanceAlongRoute: Double, val routePolyline: LineString, val routeLength: Double)
|
||||
|
||||
fun KarooSystemService.streamUpcomingRoute(): Flow<UpcomingRoute?> {
|
||||
val distanceToDestinationStream = flow {
|
||||
emit(null)
|
||||
|
||||
streamDataFlow(DataType.Type.DISTANCE_TO_DESTINATION)
|
||||
.map { (it as? StreamState.Streaming)?.dataPoint?.singleValue }
|
||||
.filter { it != 0.0 } // FIXME why is 0 sometimes emitted if no route is loaded?
|
||||
.collect { emit(it) }
|
||||
}
|
||||
|
||||
var lastKnownDistanceAlongRoute = 0.0
|
||||
var lastKnownRoutePolyline: LineString? = null
|
||||
|
||||
val navigationStateStream = streamNavigationState()
|
||||
.map { it.state as? OnNavigationState.NavigationState.NavigatingRoute }
|
||||
.map { navigationState ->
|
||||
navigationState?.let { LineString.fromPolyline(it.routePolyline, 5) }
|
||||
}
|
||||
.combine(distanceToDestinationStream) { routePolyline, distanceToDestination ->
|
||||
if (routePolyline != null){
|
||||
val length = TurfMeasurement.length(routePolyline, TurfConstants.UNIT_METERS)
|
||||
if (routePolyline != lastKnownRoutePolyline){
|
||||
lastKnownDistanceAlongRoute = 0.0
|
||||
}
|
||||
val distanceAlongRoute = distanceToDestination?.let { toDest -> length - toDest } ?: lastKnownDistanceAlongRoute
|
||||
lastKnownDistanceAlongRoute = distanceAlongRoute
|
||||
lastKnownRoutePolyline = routePolyline
|
||||
|
||||
UpcomingRoute(distanceAlongRoute, routePolyline, length)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
return navigationStateStream
|
||||
}
|
||||
|
||||
fun Context.streamStats(): Flow<HeadwindStats> {
|
||||
return dataStore.data.map { statsJson ->
|
||||
try {
|
||||
@ -143,20 +196,18 @@ fun KarooSystemService.streamUserProfile(): Flow<UserProfile> {
|
||||
}
|
||||
}
|
||||
|
||||
fun Context.streamCurrentWeatherData(): Flow<OpenMeteoCurrentWeatherResponse?> {
|
||||
fun Context.streamCurrentWeatherData(): Flow<List<WeatherDataResponse>> {
|
||||
return dataStore.data.map { settingsJson ->
|
||||
try {
|
||||
val data = settingsJson[currentDataKey]
|
||||
data?.let { d -> jsonWithUnknownKeys.decodeFromString<OpenMeteoCurrentWeatherResponse>(d) }
|
||||
data?.let { d -> jsonWithUnknownKeys.decodeFromString<List<WeatherDataResponse>>(d) } ?: emptyList()
|
||||
} catch (e: Throwable) {
|
||||
Log.e(KarooHeadwindExtension.TAG, "Failed to read weather data", e)
|
||||
null
|
||||
emptyList()
|
||||
}
|
||||
}.distinctUntilChanged().map { response ->
|
||||
if (response != null && response.current.time * 1000 >= System.currentTimeMillis() - (1000 * 60 * 60 * 12)){
|
||||
response
|
||||
} else {
|
||||
null
|
||||
response.filter { forecast ->
|
||||
forecast.data.current.time * 1000 >= System.currentTimeMillis() - (1000 * 60 * 60 * 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -33,6 +33,7 @@ import io.hammerhead.karooext.models.DataType
|
||||
import io.hammerhead.karooext.models.HttpResponseState
|
||||
import io.hammerhead.karooext.models.OnHttpResponse
|
||||
import io.hammerhead.karooext.models.OnLocationChanged
|
||||
import io.hammerhead.karooext.models.OnNavigationState
|
||||
import io.hammerhead.karooext.models.OnStreamState
|
||||
import io.hammerhead.karooext.models.StreamState
|
||||
import io.hammerhead.karooext.models.UserProfile
|
||||
@ -84,6 +85,17 @@ fun KarooSystemService.streamLocation(): Flow<OnLocationChanged> {
|
||||
}
|
||||
}
|
||||
|
||||
fun KarooSystemService.streamNavigationState(): Flow<OnNavigationState> {
|
||||
return callbackFlow {
|
||||
val listenerId = addConsumer { event: OnNavigationState ->
|
||||
trySendBlocking(event)
|
||||
}
|
||||
awaitClose {
|
||||
removeConsumer(listenerId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun<T> Flow<T>.throttle(timeout: Long): Flow<T> = flow {
|
||||
var lastEmissionTime = 0L
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ import android.content.Context
|
||||
import android.util.Log
|
||||
import de.timklge.karooheadwind.datatypes.GpsCoordinates
|
||||
import io.hammerhead.karooext.KarooSystemService
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
@ -12,6 +13,7 @@ import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
|
||||
@ -28,8 +30,8 @@ fun KarooSystemService.getRelativeHeadingFlow(context: Context): Flow<HeadingRes
|
||||
.combine(currentWeatherData) { bearing, data -> bearing to data }
|
||||
.map { (bearing, data) ->
|
||||
when {
|
||||
bearing is HeadingResponse.Value && data != null -> {
|
||||
val windBearing = data.current.windDirection + 180
|
||||
bearing is HeadingResponse.Value && data.isNotEmpty() -> {
|
||||
val windBearing = data.first().data.current.windDirection + 180
|
||||
val diff = signedAngleDifference(bearing.diff, windBearing)
|
||||
|
||||
Log.d(KarooHeadwindExtension.TAG, "Wind bearing: Heading $bearing vs wind $windBearing => $diff")
|
||||
@ -37,7 +39,7 @@ fun KarooSystemService.getRelativeHeadingFlow(context: Context): Flow<HeadingRes
|
||||
HeadingResponse.Value(diff)
|
||||
}
|
||||
bearing is HeadingResponse.NoGps -> HeadingResponse.NoGps
|
||||
bearing is HeadingResponse.NoWeatherData || data == null -> HeadingResponse.NoWeatherData
|
||||
bearing is HeadingResponse.NoWeatherData || data.isEmpty() -> HeadingResponse.NoWeatherData
|
||||
else -> bearing
|
||||
}
|
||||
}
|
||||
@ -109,4 +111,4 @@ fun KarooSystemService.getGpsCoordinateFlow(context: Context): Flow<GpsCoordinat
|
||||
gps?.round(settings.roundLocationTo.km.toDouble())
|
||||
}
|
||||
.dropNullsIfNullEncountered()
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,14 +1,16 @@
|
||||
package de.timklge.karooheadwind
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.ui.util.fastZip
|
||||
import com.mapbox.geojson.LineString
|
||||
import com.mapbox.turf.TurfConstants
|
||||
import com.mapbox.turf.TurfMeasurement
|
||||
import de.timklge.karooheadwind.datatypes.CloudCoverDataType
|
||||
import de.timklge.karooheadwind.datatypes.GpsCoordinates
|
||||
import de.timklge.karooheadwind.datatypes.HeadwindSpeedDataType
|
||||
import de.timklge.karooheadwind.datatypes.PrecipitationDataType
|
||||
import de.timklge.karooheadwind.datatypes.RelativeHumidityDataType
|
||||
import de.timklge.karooheadwind.datatypes.SurfacePressureDataType
|
||||
import de.timklge.karooheadwind.datatypes.WindDirectionDataType
|
||||
import de.timklge.karooheadwind.datatypes.WindGustsDataType
|
||||
import de.timklge.karooheadwind.datatypes.HeadwindSpeedDataType
|
||||
import de.timklge.karooheadwind.datatypes.TailwindAndRideSpeedDataType
|
||||
import de.timklge.karooheadwind.datatypes.HeadwindDirectionDataType
|
||||
import de.timklge.karooheadwind.datatypes.SealevelPressureDataType
|
||||
@ -16,6 +18,8 @@ import de.timklge.karooheadwind.datatypes.TemperatureDataType
|
||||
import de.timklge.karooheadwind.datatypes.UserWindSpeedDataType
|
||||
import de.timklge.karooheadwind.datatypes.WeatherDataType
|
||||
import de.timklge.karooheadwind.datatypes.WeatherForecastDataType
|
||||
import de.timklge.karooheadwind.datatypes.WindDirectionDataType
|
||||
import de.timklge.karooheadwind.datatypes.WindGustsDataType
|
||||
import de.timklge.karooheadwind.datatypes.WindSpeedDataType
|
||||
import de.timklge.karooheadwind.screens.HeadwindSettings
|
||||
import de.timklge.karooheadwind.screens.HeadwindStats
|
||||
@ -31,6 +35,7 @@ import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
@ -38,17 +43,23 @@ import kotlinx.coroutines.flow.retry
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.time.debounce
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.time.Duration
|
||||
import java.time.LocalDateTime
|
||||
import java.time.temporal.ChronoUnit
|
||||
import java.util.Locale
|
||||
import java.util.zip.GZIPInputStream
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.2.5") {
|
||||
class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.3") {
|
||||
companion object {
|
||||
const val TAG = "karoo-headwind"
|
||||
}
|
||||
|
||||
lateinit var karooSystem: KarooSystemService
|
||||
private lateinit var karooSystem: KarooSystemService
|
||||
|
||||
private var updateLastKnownGpsJob: Job? = null
|
||||
private var serviceJob: Job? = null
|
||||
@ -75,7 +86,7 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.2.5") {
|
||||
}
|
||||
|
||||
data class StreamData(val settings: HeadwindSettings, val gps: GpsCoordinates?,
|
||||
val profile: UserProfile? = null)
|
||||
val profile: UserProfile? = null, val upcomingRoute: UpcomingRoute? = null)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
||||
override fun onCreate() {
|
||||
@ -104,74 +115,144 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.2.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 }
|
||||
.combine(gpsFlow) { settings, gps -> StreamData(settings, gps) }
|
||||
.combine(karooSystem.streamUserProfile()) { data, profile -> data.copy(profile = profile) }
|
||||
.map { (settings, gps, profile) ->
|
||||
Log.d(TAG, "Acquired updated gps coordinates: $gps")
|
||||
|
||||
val lastKnownStats = try {
|
||||
streamStats().first()
|
||||
} catch(e: Exception){
|
||||
Log.e(TAG, "Failed to read stats", e)
|
||||
HeadwindStats()
|
||||
}
|
||||
data class StreamData(val settings: HeadwindSettings, val gps: GpsCoordinates?, val profile: UserProfile?, val upcomingRoute: UpcomingRoute?)
|
||||
data class StreamDataIdentity(val settings: HeadwindSettings, val gpsLat: Double?, val gpsLon: Double?, val profile: UserProfile?, val routePolyline: LineString?)
|
||||
|
||||
if (gps == null){
|
||||
error("No GPS coordinates available")
|
||||
}
|
||||
|
||||
val response = karooSystem.makeOpenMeteoHttpRequest(gps, settings, profile)
|
||||
if (response.error != null){
|
||||
try {
|
||||
val stats = lastKnownStats.copy(failedWeatherRequest = System.currentTimeMillis())
|
||||
launch { saveStats(this@KarooHeadwindExtension, stats) }
|
||||
} catch(e: Exception){
|
||||
Log.e(TAG, "Failed to write stats", e)
|
||||
}
|
||||
error("HTTP request failed: ${response.error}")
|
||||
} else {
|
||||
try {
|
||||
val stats = lastKnownStats.copy(
|
||||
lastSuccessfulWeatherRequest = System.currentTimeMillis(),
|
||||
lastSuccessfulWeatherPosition = gps
|
||||
)
|
||||
launch { saveStats(this@KarooHeadwindExtension, stats) }
|
||||
} catch(e: Exception){
|
||||
Log.e(TAG, "Failed to write stats", e)
|
||||
}
|
||||
}
|
||||
|
||||
response
|
||||
combine(settingsStream, gpsFlow, karooSystem.streamUserProfile(), karooSystem.streamUpcomingRoute()) { settings, gps, profile, upcomingRoute ->
|
||||
StreamData(settings, gps, profile, upcomingRoute)
|
||||
}
|
||||
.distinctUntilChangedBy { StreamDataIdentity(it.settings, it.gps?.lat, it.gps?.lon, it.profile, it.upcomingRoute?.routePolyline) }
|
||||
.transformLatest { value ->
|
||||
while(true){
|
||||
emit(value)
|
||||
delay(1.hours)
|
||||
}
|
||||
.retry(Long.MAX_VALUE) { delay(1.minutes); true }
|
||||
.collect { response ->
|
||||
try {
|
||||
val responseString = String(response.body ?: ByteArray(0))
|
||||
val data = jsonWithUnknownKeys.decodeFromString<OpenMeteoCurrentWeatherResponse>(responseString)
|
||||
}
|
||||
.map { (settings: HeadwindSettings, gps, profile, upcomingRoute) ->
|
||||
Log.d(TAG, "Acquired updated gps coordinates: $gps")
|
||||
|
||||
saveCurrentData(applicationContext, data)
|
||||
saveWidgetSettings(applicationContext, HeadwindWidgetSettings(currentForecastHourOffset = 0))
|
||||
val lastKnownStats = try {
|
||||
streamStats().first()
|
||||
} catch(e: Exception){
|
||||
Log.e(TAG, "Failed to read stats", e)
|
||||
HeadwindStats()
|
||||
}
|
||||
|
||||
if (gps == null){
|
||||
error("No GPS coordinates available")
|
||||
}
|
||||
|
||||
if (upcomingRoute != null){
|
||||
val positionOnRoute = upcomingRoute.distanceAlongRoute
|
||||
Log.i(TAG, "Position on route: ${positionOnRoute}m")
|
||||
val distancePerHour = settings.getForecastMetersPerHour(profile?.preferredUnit?.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL).toDouble()
|
||||
val msSinceFullHour = let {
|
||||
val now = LocalDateTime.now()
|
||||
val startOfHour = now.truncatedTo(ChronoUnit.HOURS)
|
||||
|
||||
ChronoUnit.MILLIS.between(startOfHour, now)
|
||||
}
|
||||
val msToNextFullHour = (1_000 * 60 * 60) - msSinceFullHour
|
||||
val calculatedDistanceToNextFullHour = (msToNextFullHour / (1_000.0 * 60 * 60)) * distancePerHour
|
||||
val distanceToNextFullHour = if (calculatedDistanceToNextFullHour > 5_000) calculatedDistanceToNextFullHour else distancePerHour
|
||||
|
||||
Log.d(TAG, "Minutes to next full hour: ${msToNextFullHour / 1000 / 60}, Distance to next full hour: ${(distanceToNextFullHour / 1000).roundToInt()}km (calculated: ${(calculatedDistanceToNextFullHour / 1000).roundToInt()}km)")
|
||||
|
||||
requestedGpsCoordinates = buildList {
|
||||
add(gps)
|
||||
|
||||
var currentPosition = positionOnRoute + distanceToNextFullHour
|
||||
var lastRequestedPosition = currentPosition
|
||||
while (currentPosition < upcomingRoute.routeLength && size < 10){
|
||||
val point = TurfMeasurement.along(upcomingRoute.routePolyline, currentPosition, TurfConstants.UNIT_METERS)
|
||||
add(GpsCoordinates(point.latitude(), point.longitude(), distanceAlongRoute = currentPosition))
|
||||
|
||||
lastRequestedPosition = currentPosition
|
||||
currentPosition += distancePerHour
|
||||
}
|
||||
|
||||
if (upcomingRoute.routeLength > lastRequestedPosition + 5_000){
|
||||
val point = TurfMeasurement.along(upcomingRoute.routePolyline, upcomingRoute.routeLength, TurfConstants.UNIT_METERS)
|
||||
add(GpsCoordinates(point.latitude(), point.longitude(), distanceAlongRoute = upcomingRoute.routeLength))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
requestedGpsCoordinates = mutableListOf(gps)
|
||||
}
|
||||
|
||||
val response = karooSystem.makeOpenMeteoHttpRequest(requestedGpsCoordinates, settings, profile)
|
||||
if (response.error != null){
|
||||
try {
|
||||
val stats = lastKnownStats.copy(failedWeatherRequest = System.currentTimeMillis())
|
||||
launch { saveStats(this@KarooHeadwindExtension, stats) }
|
||||
} catch(e: Exception){
|
||||
Log.e(TAG, "Failed to write stats", e)
|
||||
}
|
||||
error("HTTP request failed: ${response.error}")
|
||||
} else {
|
||||
try {
|
||||
val stats = lastKnownStats.copy(
|
||||
lastSuccessfulWeatherRequest = System.currentTimeMillis(),
|
||||
lastSuccessfulWeatherPosition = gps
|
||||
)
|
||||
launch { saveStats(this@KarooHeadwindExtension, stats) }
|
||||
} catch(e: Exception){
|
||||
Log.e(TAG, "Failed to write stats", e)
|
||||
}
|
||||
}
|
||||
|
||||
response
|
||||
}.retry(Long.MAX_VALUE) { e ->
|
||||
Log.w(TAG, "Failed to get weather data", e)
|
||||
delay(1.minutes); true
|
||||
}.collect { response ->
|
||||
try {
|
||||
val inputStream = java.io.ByteArrayInputStream(response.body ?: ByteArray(0))
|
||||
val lowercaseHeaders = response.headers.map { (k: String, v: String) -> k.lowercase() to v.lowercase() }.toMap()
|
||||
val isGzippedResponse = lowercaseHeaders["content-encoding"]?.contains("gzip") == true
|
||||
val responseString = if(isGzippedResponse){
|
||||
val gzipStream = withContext(Dispatchers.IO) { GZIPInputStream(inputStream) }
|
||||
gzipStream.use { stream -> String(stream.readBytes()) }
|
||||
} else {
|
||||
inputStream.use { stream -> String(stream.readBytes()) }
|
||||
}
|
||||
if (requestedGpsCoordinates.size == 1){
|
||||
val weatherData = jsonWithUnknownKeys.decodeFromString<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")
|
||||
} catch(e: Exception){
|
||||
Log.e(TAG, "Failed to read current weather data", e)
|
||||
}
|
||||
|
||||
saveWidgetSettings(applicationContext, HeadwindWidgetSettings(currentForecastHourOffset = 0))
|
||||
} catch(e: Exception){
|
||||
Log.e(TAG, "Failed to read current weather data", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
serviceJob?.cancel()
|
||||
serviceJob = null
|
||||
|
||||
updateLastKnownGpsJob?.cancel()
|
||||
updateLastKnownGpsJob = null
|
||||
|
||||
karooSystem.disconnect()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@ -16,16 +16,19 @@ import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.single
|
||||
import kotlinx.coroutines.flow.timeout
|
||||
import java.util.Locale
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
suspend fun KarooSystemService.makeOpenMeteoHttpRequest(gpsCoordinates: GpsCoordinates, settings: HeadwindSettings, profile: UserProfile?): HttpResponseState.Complete {
|
||||
suspend fun KarooSystemService.makeOpenMeteoHttpRequest(gpsCoordinates: List<GpsCoordinates>, settings: HeadwindSettings, profile: UserProfile?): HttpResponseState.Complete {
|
||||
val precipitationUnit = if (profile?.preferredUnit?.distance != UserProfile.PreferredUnit.UnitType.IMPERIAL) PrecipitationUnit.MILLIMETERS else PrecipitationUnit.INCH
|
||||
val temperatureUnit = if (profile?.preferredUnit?.temperature != UserProfile.PreferredUnit.UnitType.IMPERIAL) TemperatureUnit.CELSIUS else TemperatureUnit.FAHRENHEIT
|
||||
|
||||
return callbackFlow {
|
||||
// https://api.open-meteo.com/v1/forecast?latitude=52.52&longitude=13.41¤t=surface_pressure,pressure_msl,temperature_2m,relative_humidity_2m,precipitation,weather_code,cloud_cover,wind_speed_10m,wind_direction_10m,wind_gusts_10m&hourly=temperature_2m,precipitation_probability,precipitation,weather_code,wind_speed_10m,wind_direction_10m,wind_gusts_10m&timeformat=unixtime&past_hours=1&forecast_days=1&forecast_hours=12
|
||||
val url = "https://api.open-meteo.com/v1/forecast?latitude=${gpsCoordinates.lat}&longitude=${gpsCoordinates.lon}¤t=surface_pressure,pressure_msl,temperature_2m,relative_humidity_2m,precipitation,weather_code,cloud_cover,wind_speed_10m,wind_direction_10m,wind_gusts_10m&hourly=temperature_2m,precipitation_probability,precipitation,weather_code,wind_speed_10m,wind_direction_10m,wind_gusts_10m&timeformat=unixtime&past_hours=0&forecast_days=1&forecast_hours=12&wind_speed_unit=${settings.windUnit.id}&precipitation_unit=${precipitationUnit.id}&temperature_unit=${temperatureUnit.id}"
|
||||
val lats = gpsCoordinates.joinToString(",") { String.format(Locale.US, "%.6f", it.lat) }
|
||||
val lons = gpsCoordinates.joinToString(",") { String.format(Locale.US, "%.6f", it.lon) }
|
||||
val url = "https://api.open-meteo.com/v1/forecast?latitude=${lats}&longitude=${lons}¤t=surface_pressure,pressure_msl,temperature_2m,relative_humidity_2m,precipitation,weather_code,cloud_cover,wind_speed_10m,wind_direction_10m,wind_gusts_10m&hourly=temperature_2m,precipitation_probability,precipitation,weather_code,wind_speed_10m,wind_direction_10m,wind_gusts_10m&timeformat=unixtime&past_hours=0&forecast_days=1&forecast_hours=12&wind_speed_unit=${settings.windUnit.id}&precipitation_unit=${precipitationUnit.id}&temperature_unit=${temperatureUnit.id}"
|
||||
|
||||
Log.d(KarooHeadwindExtension.TAG, "Http request to ${url}...")
|
||||
|
||||
@ -34,18 +37,23 @@ suspend fun KarooSystemService.makeOpenMeteoHttpRequest(gpsCoordinates: GpsCoord
|
||||
"GET",
|
||||
url,
|
||||
waitForConnection = false,
|
||||
headers = mapOf("User-Agent" to KarooHeadwindExtension.TAG, "Accept-Encoding" to "gzip"),
|
||||
),
|
||||
) { event: OnHttpResponse ->
|
||||
Log.d(KarooHeadwindExtension.TAG, "Http response event $event")
|
||||
onEvent = { event: OnHttpResponse ->
|
||||
if (event.state is HttpResponseState.Complete){
|
||||
Log.d(KarooHeadwindExtension.TAG, "Http response received")
|
||||
trySend(event.state as HttpResponseState.Complete)
|
||||
close()
|
||||
}
|
||||
}
|
||||
},
|
||||
onError = { err ->
|
||||
Log.d(KarooHeadwindExtension.TAG, "Http error: $err")
|
||||
close(RuntimeException(err))
|
||||
})
|
||||
awaitClose {
|
||||
removeConsumer(listenerId)
|
||||
}
|
||||
}.timeout(20.seconds).catch { e: Throwable ->
|
||||
}.timeout(30.seconds).catch { e: Throwable ->
|
||||
if (e is TimeoutCancellationException){
|
||||
emit(HttpResponseState.Complete(500, mapOf(), null, "Timeout"))
|
||||
} else {
|
||||
|
||||
@ -29,9 +29,13 @@ abstract class BaseDataType(
|
||||
currentWeatherData
|
||||
.filterNotNull()
|
||||
.collect { data ->
|
||||
val value = getValue(data)
|
||||
val value = data.firstOrNull()?.data?.let { w -> getValue(w) }
|
||||
Log.d(KarooHeadwindExtension.TAG, "$dataTypeId: $value")
|
||||
emitter.onNext(StreamState.Streaming(DataPoint(dataTypeId, mapOf(DataType.Field.SINGLE to value))))
|
||||
if (value != null){
|
||||
emitter.onNext(StreamState.Streaming(DataPoint(dataTypeId, mapOf(DataType.Field.SINGLE to value))))
|
||||
} else {
|
||||
emitter.onNext(StreamState.NotAvailable)
|
||||
}
|
||||
}
|
||||
}
|
||||
emitter.setCancellable {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -9,7 +9,7 @@ import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
@Serializable
|
||||
data class GpsCoordinates(val lat: Double, val lon: Double, val bearing: Double? = 0.0){
|
||||
data class GpsCoordinates(val lat: Double, val lon: Double, val bearing: Double? = 0.0, val distanceAlongRoute: Double? = null){
|
||||
companion object {
|
||||
private fun roundDegrees(degrees: Double, km: Double): Double {
|
||||
val nkm = degrees * 111
|
||||
|
||||
@ -44,7 +44,9 @@ class HeadwindDirectionDataType(
|
||||
|
||||
private fun streamValues(): Flow<Double> = flow {
|
||||
karooSystem.getRelativeHeadingFlow(applicationContext)
|
||||
.combine(applicationContext.streamCurrentWeatherData()) { headingResponse, data -> StreamData(headingResponse, data?.current?.windDirection, data?.current?.windSpeed) }
|
||||
.combine(applicationContext.streamCurrentWeatherData()) { headingResponse, data ->
|
||||
StreamData(headingResponse, data.firstOrNull()?.data?.current?.windDirection, data.firstOrNull()?.data?.current?.windSpeed)
|
||||
}
|
||||
.combine(applicationContext.streamSettings(karooSystem)) { data, settings -> data.copy(settings = settings) }
|
||||
.collect { streamData ->
|
||||
val value = (streamData.headingResponse as? HeadingResponse.Value)?.diff
|
||||
|
||||
@ -30,7 +30,9 @@ class HeadwindSpeedDataType(
|
||||
val job = CoroutineScope(Dispatchers.IO).launch {
|
||||
karooSystem.getRelativeHeadingFlow(context)
|
||||
.combine(context.streamCurrentWeatherData()) { value, data -> value to data }
|
||||
.combine(context.streamSettings(karooSystem)) { (value, data), settings -> StreamData(value, data, settings) }
|
||||
.combine(context.streamSettings(karooSystem)) { (value, data), settings ->
|
||||
StreamData(value, data.firstOrNull()?.data, settings)
|
||||
}
|
||||
.filter { it.weatherResponse != null }
|
||||
.collect { streamData ->
|
||||
val windSpeed = streamData.weatherResponse?.current?.windSpeed ?: 0.0
|
||||
|
||||
@ -115,7 +115,7 @@ class TailwindAndRideSpeedDataType(
|
||||
karooSystem.getRelativeHeadingFlow(context)
|
||||
.combine(context.streamCurrentWeatherData()) { value, data -> value to data }
|
||||
.combine(context.streamSettings(karooSystem)) { (value, data), settings ->
|
||||
StreamData(value, data?.current?.windDirection, data?.current?.windSpeed, settings)
|
||||
StreamData(value, data.firstOrNull()?.data?.current?.windDirection, data.firstOrNull()?.data?.current?.windSpeed, settings)
|
||||
}
|
||||
.combine(karooSystem.streamUserProfile()) { streamData, userProfile ->
|
||||
val isImperial = userProfile.preferredUnit.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL
|
||||
|
||||
@ -34,7 +34,9 @@ class UserWindSpeedDataType(
|
||||
fun streamValues(context: Context, karooSystem: KarooSystemService): Flow<Double> = flow {
|
||||
karooSystem.getRelativeHeadingFlow(context)
|
||||
.combine(context.streamCurrentWeatherData()) { value, data -> value to data }
|
||||
.combine(context.streamSettings(karooSystem)) { (value, data), settings -> StreamData(value, data, settings) }
|
||||
.combine(context.streamSettings(karooSystem)) { (value, data), settings ->
|
||||
StreamData(value, data.firstOrNull()?.data, settings)
|
||||
}
|
||||
.filter { it.weatherResponse != null }
|
||||
.collect { streamData ->
|
||||
val windSpeed = streamData.weatherResponse?.current?.windSpeed ?: 0.0
|
||||
|
||||
@ -17,7 +17,6 @@ import de.timklge.karooheadwind.OpenMeteoData
|
||||
import de.timklge.karooheadwind.WeatherInterpretation
|
||||
import de.timklge.karooheadwind.getHeadingFlow
|
||||
import de.timklge.karooheadwind.screens.HeadwindSettings
|
||||
import de.timklge.karooheadwind.screens.PrecipitationUnit
|
||||
import de.timklge.karooheadwind.screens.TemperatureUnit
|
||||
import de.timklge.karooheadwind.streamCurrentWeatherData
|
||||
import de.timklge.karooheadwind.streamSettings
|
||||
@ -58,6 +57,21 @@ class WeatherDataType(
|
||||
val timeFormatter = DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneId.systemDefault())
|
||||
}
|
||||
|
||||
override fun startStream(emitter: Emitter<StreamState>) {
|
||||
val job = CoroutineScope(Dispatchers.IO).launch {
|
||||
val currentWeatherData = applicationContext.streamCurrentWeatherData()
|
||||
|
||||
currentWeatherData
|
||||
.collect { data ->
|
||||
Log.d(KarooHeadwindExtension.TAG, "Wind code: ${data.firstOrNull()?.data?.current?.weatherCode}")
|
||||
emitter.onNext(StreamState.Streaming(DataPoint(dataTypeId, mapOf(DataType.Field.SINGLE to (data.firstOrNull()?.data?.current?.weatherCode?.toDouble() ?: 0.0)))))
|
||||
}
|
||||
}
|
||||
emitter.setCancellable {
|
||||
job.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
data class StreamData(val data: OpenMeteoCurrentWeatherResponse?, val settings: HeadwindSettings,
|
||||
val profile: UserProfile? = null, val headingResponse: HeadingResponse? = null)
|
||||
|
||||
@ -87,14 +101,12 @@ class WeatherDataType(
|
||||
de.timklge.karooheadwind.R.drawable.arrow_0
|
||||
)
|
||||
|
||||
|
||||
val dataFlow = if (config.preview){
|
||||
previewFlow()
|
||||
} else {
|
||||
context.streamCurrentWeatherData()
|
||||
.combine(context.streamSettings(karooSystem)) { data, settings -> StreamData(data, settings) }
|
||||
.combine(karooSystem.streamUserProfile()) { data, profile -> data.copy(profile = profile) }
|
||||
.combine(karooSystem.getHeadingFlow(context)) { data, heading -> data.copy(headingResponse = heading) }
|
||||
combine(context.streamCurrentWeatherData(), context.streamSettings(karooSystem), karooSystem.streamUserProfile(), karooSystem.getHeadingFlow(context)) { data, settings, profile, heading ->
|
||||
StreamData(data.firstOrNull()?.data, settings, profile, heading)
|
||||
}
|
||||
}
|
||||
|
||||
val viewJob = CoroutineScope(Dispatchers.IO).launch {
|
||||
@ -116,25 +128,25 @@ class WeatherDataType(
|
||||
|
||||
val result = glance.compose(context, DpSize.Unspecified) {
|
||||
Box(modifier = GlanceModifier.fillMaxSize(), contentAlignment = Alignment.CenterEnd) {
|
||||
Weather(baseBitmap,
|
||||
Weather(
|
||||
baseBitmap,
|
||||
current = interpretation,
|
||||
windBearing = data.current.windDirection.roundToInt(),
|
||||
windSpeed = data.current.windSpeed.roundToInt(),
|
||||
windGusts = data.current.windGusts.roundToInt(),
|
||||
windSpeedUnit = settings.windUnit,
|
||||
precipitation = data.current.precipitation,
|
||||
precipitationProbability = null,
|
||||
precipitationUnit = if (userProfile?.preferredUnit?.distance != UserProfile.PreferredUnit.UnitType.IMPERIAL) PrecipitationUnit.MILLIMETERS else PrecipitationUnit.INCH,
|
||||
temperature = data.current.temperature.roundToInt(),
|
||||
temperatureUnit = if (userProfile?.preferredUnit?.temperature != UserProfile.PreferredUnit.UnitType.IMPERIAL) TemperatureUnit.CELSIUS else TemperatureUnit.FAHRENHEIT,
|
||||
timeLabel = formattedTime,
|
||||
dateLabel = formattedDate,
|
||||
rowAlignment = when (config.alignment){
|
||||
ViewConfig.Alignment.LEFT -> Alignment.Horizontal.Start
|
||||
ViewConfig.Alignment.CENTER -> Alignment.Horizontal.CenterHorizontally
|
||||
ViewConfig.Alignment.RIGHT -> Alignment.Horizontal.End
|
||||
},
|
||||
singleDisplay = true
|
||||
dateLabel = formattedDate,
|
||||
singleDisplay = true,
|
||||
isImperial = userProfile?.preferredUnit?.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,13 +6,10 @@ import android.util.Log
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.action.ActionParameters
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi
|
||||
import androidx.glance.appwidget.GlanceRemoteViews
|
||||
import androidx.glance.appwidget.action.ActionCallback
|
||||
import androidx.glance.appwidget.action.actionRunCallback
|
||||
import androidx.glance.background
|
||||
import androidx.glance.color.ColorProvider
|
||||
@ -27,26 +24,22 @@ import de.timklge.karooheadwind.KarooHeadwindExtension
|
||||
import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse
|
||||
import de.timklge.karooheadwind.OpenMeteoData
|
||||
import de.timklge.karooheadwind.OpenMeteoForecastData
|
||||
import de.timklge.karooheadwind.UpcomingRoute
|
||||
import de.timklge.karooheadwind.WeatherDataResponse
|
||||
import de.timklge.karooheadwind.WeatherInterpretation
|
||||
import de.timklge.karooheadwind.datatypes.WeatherDataType.StreamData
|
||||
import de.timklge.karooheadwind.getHeadingFlow
|
||||
import de.timklge.karooheadwind.saveWidgetSettings
|
||||
import de.timklge.karooheadwind.screens.HeadwindSettings
|
||||
import de.timklge.karooheadwind.screens.HeadwindWidgetSettings
|
||||
import de.timklge.karooheadwind.screens.PrecipitationUnit
|
||||
import de.timklge.karooheadwind.screens.TemperatureUnit
|
||||
import de.timklge.karooheadwind.streamCurrentWeatherData
|
||||
import de.timklge.karooheadwind.streamSettings
|
||||
import de.timklge.karooheadwind.streamUpcomingRoute
|
||||
import de.timklge.karooheadwind.streamUserProfile
|
||||
import de.timklge.karooheadwind.streamWidgetSettings
|
||||
import io.hammerhead.karooext.KarooSystemService
|
||||
import io.hammerhead.karooext.extension.DataTypeImpl
|
||||
import io.hammerhead.karooext.internal.Emitter
|
||||
import io.hammerhead.karooext.internal.ViewEmitter
|
||||
import io.hammerhead.karooext.models.DataPoint
|
||||
import io.hammerhead.karooext.models.DataType
|
||||
import io.hammerhead.karooext.models.ShowCustomStreamState
|
||||
import io.hammerhead.karooext.models.StreamState
|
||||
import io.hammerhead.karooext.models.UpdateGraphicConfig
|
||||
import io.hammerhead.karooext.models.UserProfile
|
||||
import io.hammerhead.karooext.models.ViewConfig
|
||||
@ -56,7 +49,7 @@ import kotlinx.coroutines.awaitCancellation
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.Instant
|
||||
@ -66,27 +59,6 @@ import java.time.format.FormatStyle
|
||||
import java.time.temporal.ChronoUnit
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class CycleHoursAction : ActionCallback {
|
||||
override suspend fun onAction(
|
||||
context: Context,
|
||||
glanceId: GlanceId,
|
||||
parameters: ActionParameters
|
||||
) {
|
||||
Log.d(KarooHeadwindExtension.TAG, "Cycling hours")
|
||||
|
||||
val currentSettings = context.streamWidgetSettings().first()
|
||||
val data = context.streamCurrentWeatherData().first()
|
||||
|
||||
var hourOffset = currentSettings.currentForecastHourOffset + 3
|
||||
if (data == null || hourOffset >= ((data.forecastData?.weatherCode?.size) ?: 0)) {
|
||||
hourOffset = 0
|
||||
}
|
||||
|
||||
val newSettings = currentSettings.copy(currentForecastHourOffset = hourOffset)
|
||||
saveWidgetSettings(context, newSettings)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalGlanceRemoteViewsApi::class)
|
||||
class WeatherForecastDataType(
|
||||
private val karooSystem: KarooSystemService,
|
||||
@ -98,31 +70,44 @@ class WeatherForecastDataType(
|
||||
val timeFormatter = DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneId.systemDefault())
|
||||
}
|
||||
|
||||
data class StreamData(val data: OpenMeteoCurrentWeatherResponse?, val settings: HeadwindSettings,
|
||||
val widgetSettings: HeadwindWidgetSettings? = null, val profile: UserProfile? = null, val headingResponse: HeadingResponse? = null)
|
||||
data class StreamData(val data: List<WeatherDataResponse>?, val settings: SettingsAndProfile,
|
||||
val widgetSettings: HeadwindWidgetSettings? = null, val profile: UserProfile? = null,
|
||||
val headingResponse: HeadingResponse? = null, val upcomingRoute: UpcomingRoute? = null)
|
||||
|
||||
data class SettingsAndProfile(val settings: HeadwindSettings, val isImperial: Boolean)
|
||||
|
||||
private fun previewFlow(settingsAndProfileStream: Flow<SettingsAndProfile>): Flow<StreamData> = flow {
|
||||
val settingsAndProfile = settingsAndProfileStream.firstOrNull()
|
||||
|
||||
private fun previewFlow(): Flow<StreamData> = flow {
|
||||
while (true){
|
||||
val timeAtFullHour = Instant.now().truncatedTo(ChronoUnit.HOURS).epochSecond
|
||||
val forecastTimes = (0..<12).map { timeAtFullHour + it * 60 * 60 }
|
||||
val forecastTemperatures = (0..<12).map { 20.0 + (-20..20).random() }
|
||||
val forecastPrecipitationPropability = (0..<12).map { (0..100).random() }
|
||||
val forecastPrecipitation = (0..<12).map { 0.0 + (0..10).random() }
|
||||
val forecastWeatherCodes = (0..<12).map { WeatherInterpretation.getKnownWeatherCodes().random() }
|
||||
val forecastWindSpeed = (0..<12).map { 0.0 + (0..10).random() }
|
||||
val forecastWindDirection = (0..<12).map { 0.0 + (0..360).random() }
|
||||
val forecastWindGusts = (0..<12).map { 0.0 + (0..10).random() }
|
||||
val data = (0..<10).map { index ->
|
||||
val timeAtFullHour = Instant.now().truncatedTo(ChronoUnit.HOURS).epochSecond
|
||||
val forecastTimes = (0..<12).map { timeAtFullHour + it * 60 * 60 }
|
||||
val forecastTemperatures = (0..<12).map { 20.0 + (-20..20).random() }
|
||||
val forecastPrecipitationPropability = (0..<12).map { (0..100).random() }
|
||||
val forecastPrecipitation = (0..<12).map { 0.0 + (0..10).random() }
|
||||
val forecastWeatherCodes = (0..<12).map { WeatherInterpretation.getKnownWeatherCodes().random() }
|
||||
val forecastWindSpeed = (0..<12).map { 0.0 + (0..10).random() }
|
||||
val forecastWindDirection = (0..<12).map { 0.0 + (0..360).random() }
|
||||
val forecastWindGusts = (0..<12).map { 0.0 + (0..10).random() }
|
||||
val weatherData = OpenMeteoCurrentWeatherResponse(
|
||||
OpenMeteoData(Instant.now().epochSecond, 0, 20.0, 50, 3.0, 0, 1013.25, 980.0, 15.0, 30.0, 30.0, WeatherInterpretation.getKnownWeatherCodes().random()),
|
||||
0.0, 0.0, "Europe/Berlin", 30.0, 0,
|
||||
|
||||
OpenMeteoForecastData(forecastTimes, forecastTemperatures, forecastPrecipitationPropability,
|
||||
forecastPrecipitation, forecastWeatherCodes, forecastWindSpeed, forecastWindDirection,
|
||||
forecastWindGusts)
|
||||
)
|
||||
|
||||
val distancePerHour = settingsAndProfile?.settings?.getForecastMetersPerHour(settingsAndProfile.isImperial)?.toDouble() ?: 0.0
|
||||
val gpsCoords = GpsCoordinates(0.0, 0.0, distanceAlongRoute = index * distancePerHour)
|
||||
|
||||
WeatherDataResponse(weatherData, gpsCoords)
|
||||
}
|
||||
|
||||
|
||||
emit(
|
||||
StreamData(
|
||||
OpenMeteoCurrentWeatherResponse(
|
||||
OpenMeteoData(Instant.now().epochSecond, 0, 20.0, 50, 3.0, 0, 1013.25, 980.0, 15.0, 30.0, 30.0, WeatherInterpretation.getKnownWeatherCodes().random()),
|
||||
0.0, 0.0, "Europe/Berlin", 30.0, 0,
|
||||
|
||||
OpenMeteoForecastData(forecastTimes, forecastTemperatures, forecastPrecipitationPropability,
|
||||
forecastPrecipitation, forecastWeatherCodes, forecastWindSpeed, forecastWindDirection,
|
||||
forecastWindGusts)
|
||||
), HeadwindSettings())
|
||||
StreamData(data, SettingsAndProfile(HeadwindSettings(), settingsAndProfile?.isImperial == true))
|
||||
)
|
||||
|
||||
delay(5_000)
|
||||
@ -141,24 +126,30 @@ class WeatherForecastDataType(
|
||||
de.timklge.karooheadwind.R.drawable.arrow_0
|
||||
)
|
||||
|
||||
val settingsAndProfileStream = context.streamSettings(karooSystem).combine(karooSystem.streamUserProfile()) { settings, userProfile ->
|
||||
SettingsAndProfile(settings = settings, isImperial = userProfile.preferredUnit.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL)
|
||||
}
|
||||
|
||||
val dataFlow = if (config.preview){
|
||||
previewFlow()
|
||||
previewFlow(settingsAndProfileStream)
|
||||
} else {
|
||||
context.streamCurrentWeatherData()
|
||||
.combine(context.streamSettings(karooSystem)) { data, settings -> StreamData(data, settings) }
|
||||
.combine(karooSystem.streamUserProfile()) { data, profile -> data.copy(profile = profile) }
|
||||
.combine(context.streamWidgetSettings()) { data, widgetSettings -> data.copy(widgetSettings = widgetSettings) }
|
||||
.combine(karooSystem.getHeadingFlow(context)) { data, headingResponse -> data.copy(headingResponse = headingResponse) }
|
||||
combine(context.streamCurrentWeatherData(),
|
||||
settingsAndProfileStream,
|
||||
context.streamWidgetSettings(),
|
||||
karooSystem.getHeadingFlow(context),
|
||||
karooSystem.streamUpcomingRoute()) { weatherData, settings, widgetSettings, heading, upcomingRoute ->
|
||||
StreamData(data = weatherData, settings = settings, widgetSettings = widgetSettings, headingResponse = heading, upcomingRoute = upcomingRoute)
|
||||
}
|
||||
}
|
||||
|
||||
val viewJob = CoroutineScope(Dispatchers.IO).launch {
|
||||
emitter.onNext(ShowCustomStreamState("", null))
|
||||
|
||||
dataFlow.collect { (data, settings, widgetSettings, userProfile, headingResponse) ->
|
||||
dataFlow.collect { (allData, settingsAndProfile, widgetSettings, userProfile, headingResponse, upcomingRoute) ->
|
||||
Log.d(KarooHeadwindExtension.TAG, "Updating weather forecast view")
|
||||
|
||||
if (data == null){
|
||||
emitter.updateView(getErrorWidget(glance, context, settings, headingResponse).remoteViews)
|
||||
if (allData == null){
|
||||
emitter.updateView(getErrorWidget(glance, context, settingsAndProfile.settings, headingResponse).remoteViews)
|
||||
|
||||
return@collect
|
||||
}
|
||||
@ -168,22 +159,32 @@ class WeatherForecastDataType(
|
||||
|
||||
if (!config.preview) modifier = modifier.clickable(onClick = actionRunCallback<CycleHoursAction>())
|
||||
|
||||
Row(modifier = modifier, horizontalAlignment = Alignment.Horizontal.CenterHorizontally) {
|
||||
Row(modifier = modifier, horizontalAlignment = Alignment.Horizontal.Start) {
|
||||
val hourOffset = widgetSettings?.currentForecastHourOffset ?: 0
|
||||
val positionOffset = if (allData.size == 1) 0 else hourOffset
|
||||
|
||||
var previousDate: String? = let {
|
||||
val unixTime = data.forecastData?.time?.getOrNull(hourOffset)
|
||||
val unixTime = allData.getOrNull(positionOffset)?.data?.forecastData?.time?.getOrNull(hourOffset)
|
||||
val formattedDate = unixTime?.let { Instant.ofEpochSecond(unixTime).atZone(ZoneId.systemDefault()).toLocalDate().format(DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)) }
|
||||
|
||||
formattedDate
|
||||
}
|
||||
|
||||
for (index in hourOffset..hourOffset + 2){
|
||||
if (index >= (data.forecastData?.weatherCode?.size ?: 0)) {
|
||||
for (baseIndex in hourOffset..hourOffset + 2){
|
||||
val positionIndex = if (allData.size == 1) 0 else baseIndex
|
||||
|
||||
if (allData.getOrNull(positionIndex) == null) break
|
||||
if (baseIndex >= (allData.getOrNull(positionOffset)?.data?.forecastData?.weatherCode?.size ?: 0)) {
|
||||
break
|
||||
}
|
||||
|
||||
if (index > hourOffset) {
|
||||
val data = allData.getOrNull(positionIndex)?.data
|
||||
val distanceAlongRoute = allData.getOrNull(positionIndex)?.requestedPosition?.distanceAlongRoute
|
||||
val position = allData.getOrNull(positionIndex)?.requestedPosition?.let { "${(it.distanceAlongRoute?.div(1000.0))?.toInt()} at ${it.lat}, ${it.lon}" }
|
||||
|
||||
Log.d(KarooHeadwindExtension.TAG, "Distance along route ${positionIndex}: $position")
|
||||
|
||||
if (baseIndex > hourOffset) {
|
||||
Spacer(
|
||||
modifier = GlanceModifier.fillMaxHeight().background(
|
||||
ColorProvider(Color.Black, Color.White)
|
||||
@ -191,28 +192,60 @@ class WeatherForecastDataType(
|
||||
)
|
||||
}
|
||||
|
||||
val interpretation = WeatherInterpretation.fromWeatherCode(data.forecastData?.weatherCode?.get(index) ?: 0)
|
||||
val unixTime = data.forecastData?.time?.get(index) ?: 0
|
||||
val formattedTime = timeFormatter.format(Instant.ofEpochSecond(unixTime))
|
||||
val formattedDate = Instant.ofEpochSecond(unixTime).atZone(ZoneId.systemDefault()).toLocalDate().format(DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT))
|
||||
val hasNewDate = formattedDate != previousDate || index == 0
|
||||
val distanceFromCurrent = upcomingRoute?.distanceAlongRoute?.let { currentDistanceAlongRoute ->
|
||||
distanceAlongRoute?.minus(currentDistanceAlongRoute)
|
||||
}
|
||||
|
||||
Weather(baseBitmap,
|
||||
current = interpretation,
|
||||
windBearing = data.forecastData?.windDirection?.get(index)?.roundToInt() ?: 0,
|
||||
windSpeed = data.forecastData?.windSpeed?.get(index)?.roundToInt() ?: 0,
|
||||
windGusts = data.forecastData?.windGusts?.get(index)?.roundToInt() ?: 0,
|
||||
windSpeedUnit = settings.windUnit,
|
||||
precipitation = data.forecastData?.precipitation?.get(index) ?: 0.0,
|
||||
precipitationProbability = data.forecastData?.precipitationProbability?.get(index) ?: 0,
|
||||
precipitationUnit = if (userProfile?.preferredUnit?.distance != UserProfile.PreferredUnit.UnitType.IMPERIAL) PrecipitationUnit.MILLIMETERS else PrecipitationUnit.INCH,
|
||||
temperature = data.forecastData?.temperature?.get(index)?.roundToInt() ?: 0,
|
||||
temperatureUnit = if (userProfile?.preferredUnit?.temperature != UserProfile.PreferredUnit.UnitType.IMPERIAL) TemperatureUnit.CELSIUS else TemperatureUnit.FAHRENHEIT,
|
||||
timeLabel = formattedTime,
|
||||
dateLabel = if (hasNewDate) formattedDate else null
|
||||
)
|
||||
val isCurrent = baseIndex == 0 && positionIndex == 0
|
||||
|
||||
previousDate = formattedDate
|
||||
if (isCurrent && data?.current != null){
|
||||
val interpretation = WeatherInterpretation.fromWeatherCode(data.current.weatherCode)
|
||||
val unixTime = data.current.time
|
||||
val formattedTime = timeFormatter.format(Instant.ofEpochSecond(unixTime))
|
||||
val formattedDate = Instant.ofEpochSecond(unixTime).atZone(ZoneId.systemDefault()).toLocalDate().format(DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT))
|
||||
val hasNewDate = formattedDate != previousDate || baseIndex == 0
|
||||
|
||||
Weather(
|
||||
baseBitmap,
|
||||
current = interpretation,
|
||||
windBearing = data.current.windDirection.roundToInt(),
|
||||
windSpeed = data.current.windSpeed.roundToInt(),
|
||||
windGusts = data.current.windGusts.roundToInt(),
|
||||
precipitation = data.current.precipitation,
|
||||
precipitationProbability = null,
|
||||
temperature = data.current.temperature.roundToInt(),
|
||||
temperatureUnit = if (userProfile?.preferredUnit?.temperature != UserProfile.PreferredUnit.UnitType.IMPERIAL) TemperatureUnit.CELSIUS else TemperatureUnit.FAHRENHEIT,
|
||||
timeLabel = formattedTime,
|
||||
dateLabel = if (hasNewDate) formattedDate else null,
|
||||
isImperial = userProfile?.preferredUnit?.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL
|
||||
)
|
||||
|
||||
previousDate = formattedDate
|
||||
} else {
|
||||
val interpretation = WeatherInterpretation.fromWeatherCode(data?.forecastData?.weatherCode?.get(baseIndex) ?: 0)
|
||||
val unixTime = data?.forecastData?.time?.get(baseIndex) ?: 0
|
||||
val formattedTime = timeFormatter.format(Instant.ofEpochSecond(unixTime))
|
||||
val formattedDate = Instant.ofEpochSecond(unixTime).atZone(ZoneId.systemDefault()).toLocalDate().format(DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT))
|
||||
val hasNewDate = formattedDate != previousDate || baseIndex == 0
|
||||
|
||||
Weather(
|
||||
baseBitmap,
|
||||
current = interpretation,
|
||||
windBearing = data?.forecastData?.windDirection?.get(baseIndex)?.roundToInt() ?: 0,
|
||||
windSpeed = data?.forecastData?.windSpeed?.get(baseIndex)?.roundToInt() ?: 0,
|
||||
windGusts = data?.forecastData?.windGusts?.get(baseIndex)?.roundToInt() ?: 0,
|
||||
precipitation = data?.forecastData?.precipitation?.get(baseIndex) ?: 0.0,
|
||||
precipitationProbability = data?.forecastData?.precipitationProbability?.get(baseIndex) ?: 0,
|
||||
temperature = data?.forecastData?.temperature?.get(baseIndex)?.roundToInt() ?: 0,
|
||||
temperatureUnit = if (userProfile?.preferredUnit?.temperature != UserProfile.PreferredUnit.UnitType.IMPERIAL) TemperatureUnit.CELSIUS else TemperatureUnit.FAHRENHEIT,
|
||||
timeLabel = formattedTime,
|
||||
dateLabel = if (hasNewDate) formattedDate else null,
|
||||
distance = distanceFromCurrent,
|
||||
isImperial = settingsAndProfile.isImperial
|
||||
)
|
||||
|
||||
previousDate = formattedDate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -32,9 +32,8 @@ import androidx.glance.text.TextAlign
|
||||
import androidx.glance.text.TextStyle
|
||||
import de.timklge.karooheadwind.R
|
||||
import de.timklge.karooheadwind.WeatherInterpretation
|
||||
import de.timklge.karooheadwind.screens.PrecipitationUnit
|
||||
import de.timklge.karooheadwind.screens.TemperatureUnit
|
||||
import de.timklge.karooheadwind.screens.WindUnit
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.math.ceil
|
||||
|
||||
fun getWeatherIcon(interpretation: WeatherInterpretation): Int {
|
||||
@ -52,10 +51,23 @@ fun getWeatherIcon(interpretation: WeatherInterpretation): Int {
|
||||
@OptIn(ExperimentalGlancePreviewApi::class)
|
||||
@Preview(widthDp = 200, heightDp = 150)
|
||||
@Composable
|
||||
fun Weather(baseBitmap: Bitmap, current: WeatherInterpretation, windBearing: Int, windSpeed: Int, windGusts: Int, windSpeedUnit: WindUnit,
|
||||
precipitation: Double, precipitationProbability: Int?, precipitationUnit: PrecipitationUnit,
|
||||
temperature: Int, temperatureUnit: TemperatureUnit, timeLabel: String? = null, rowAlignment: Alignment.Horizontal = Alignment.Horizontal.CenterHorizontally,
|
||||
dateLabel: String? = null, singleDisplay: Boolean = false) {
|
||||
fun Weather(
|
||||
baseBitmap: Bitmap,
|
||||
current: WeatherInterpretation,
|
||||
windBearing: Int,
|
||||
windSpeed: Int,
|
||||
windGusts: Int,
|
||||
precipitation: Double,
|
||||
precipitationProbability: Int?,
|
||||
temperature: Int,
|
||||
temperatureUnit: TemperatureUnit,
|
||||
distance: Double? = null,
|
||||
timeLabel: String? = null,
|
||||
rowAlignment: Alignment.Horizontal = Alignment.Horizontal.CenterHorizontally,
|
||||
dateLabel: String? = null,
|
||||
singleDisplay: Boolean = false,
|
||||
isImperial: Boolean?
|
||||
) {
|
||||
|
||||
val fontSize = 14f
|
||||
|
||||
@ -83,6 +95,29 @@ fun Weather(baseBitmap: Bitmap, current: WeatherInterpretation, windBearing: Int
|
||||
}
|
||||
}
|
||||
|
||||
if (distance != null && !singleDisplay && isImperial != null){
|
||||
val distanceInUserUnit = (distance / (if(!isImperial) 1000.0 else 1609.34)).toInt()
|
||||
val label = "${distanceInUserUnit.absoluteValue}${if(!isImperial) "km" else "mi"}"
|
||||
val text = if(distanceInUserUnit > 0){
|
||||
"In $label"
|
||||
} else {
|
||||
"$label ago"
|
||||
}
|
||||
|
||||
if (distanceInUserUnit != 0){
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = text,
|
||||
style = TextStyle(
|
||||
color = ColorProvider(Color.Black, Color.White),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = TextUnit(fontSize, TextUnitType.Sp)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalAlignment = rowAlignment) {
|
||||
if (timeLabel != null){
|
||||
Text(
|
||||
|
||||
@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ExitToApp
|
||||
@ -20,18 +21,21 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.timklge.karooheadwind.datatypes.GpsCoordinates
|
||||
@ -39,7 +43,9 @@ import de.timklge.karooheadwind.getGpsCoordinateFlow
|
||||
import de.timklge.karooheadwind.saveSettings
|
||||
import de.timklge.karooheadwind.streamSettings
|
||||
import de.timklge.karooheadwind.streamStats
|
||||
import de.timklge.karooheadwind.streamUserProfile
|
||||
import io.hammerhead.karooext.KarooSystemService
|
||||
import io.hammerhead.karooext.models.UserProfile
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
@ -90,11 +96,17 @@ data class HeadwindSettings(
|
||||
val welcomeDialogAccepted: Boolean = false,
|
||||
val windDirectionIndicatorTextSetting: WindDirectionIndicatorTextSetting = WindDirectionIndicatorTextSetting.HEADWIND_SPEED,
|
||||
val windDirectionIndicatorSetting: WindDirectionIndicatorSetting = WindDirectionIndicatorSetting.HEADWIND_DIRECTION,
|
||||
val roundLocationTo: RoundLocationSetting = RoundLocationSetting.KM_2
|
||||
val roundLocationTo: RoundLocationSetting = RoundLocationSetting.KM_2,
|
||||
val forecastedKmPerHour: Int = 20,
|
||||
val forecastedMilesPerHour: Int = 12,
|
||||
){
|
||||
companion object {
|
||||
val defaultSettings = Json.encodeToString(HeadwindSettings())
|
||||
}
|
||||
|
||||
fun getForecastMetersPerHour(isImperial: Boolean): Int {
|
||||
return if (isImperial) forecastedMilesPerHour * 1609 else forecastedKmPerHour * 1000
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@ -130,8 +142,11 @@ fun MainScreen(onFinish: () -> Unit) {
|
||||
var selectedWindDirectionIndicatorTextSetting by remember { mutableStateOf(WindDirectionIndicatorTextSetting.HEADWIND_SPEED) }
|
||||
var selectedWindDirectionIndicatorSetting by remember { mutableStateOf(WindDirectionIndicatorSetting.HEADWIND_DIRECTION) }
|
||||
var selectedRoundLocationSetting by remember { mutableStateOf(RoundLocationSetting.KM_2) }
|
||||
var forecastKmPerHour by remember { mutableStateOf("20") }
|
||||
var forecastMilesPerHour by remember { mutableStateOf("12") }
|
||||
|
||||
val stats by ctx.streamStats().collectAsState(HeadwindStats())
|
||||
val profile by karooSystem.streamUserProfile().collectAsStateWithLifecycle(null)
|
||||
val stats by ctx.streamStats().collectAsStateWithLifecycle(HeadwindStats())
|
||||
val location by karooSystem.getGpsCoordinateFlow(ctx).collectAsStateWithLifecycle(null)
|
||||
|
||||
var savedDialogVisible by remember { mutableStateOf(false) }
|
||||
@ -144,6 +159,8 @@ fun MainScreen(onFinish: () -> Unit) {
|
||||
selectedWindDirectionIndicatorTextSetting = settings.windDirectionIndicatorTextSetting
|
||||
selectedWindDirectionIndicatorSetting = settings.windDirectionIndicatorSetting
|
||||
selectedRoundLocationSetting = settings.roundLocationTo
|
||||
forecastKmPerHour = settings.forecastedKmPerHour.toString()
|
||||
forecastMilesPerHour = settings.forecastedMilesPerHour.toString()
|
||||
}
|
||||
}
|
||||
|
||||
@ -194,6 +211,24 @@ fun MainScreen(onFinish: () -> Unit) {
|
||||
selectedRoundLocationSetting = RoundLocationSetting.entries.find { unit -> unit.id == selectedOption.id }!!
|
||||
}
|
||||
|
||||
if (profile?.preferredUnit?.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL){
|
||||
OutlinedTextField(value = forecastMilesPerHour, modifier = Modifier.fillMaxWidth(),
|
||||
onValueChange = { forecastMilesPerHour = it },
|
||||
label = { Text("Forecast Distance per Hour") },
|
||||
suffix = { Text("mi") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true
|
||||
)
|
||||
} else {
|
||||
OutlinedTextField(value = forecastKmPerHour, modifier = Modifier.fillMaxWidth(),
|
||||
onValueChange = { forecastKmPerHour = it },
|
||||
label = { Text("Forecast Distance per Hour") },
|
||||
suffix = { Text("km") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
FilledTonalButton(modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@ -202,7 +237,9 @@ fun MainScreen(onFinish: () -> Unit) {
|
||||
welcomeDialogAccepted = true,
|
||||
windDirectionIndicatorSetting = selectedWindDirectionIndicatorSetting,
|
||||
windDirectionIndicatorTextSetting = selectedWindDirectionIndicatorTextSetting,
|
||||
roundLocationTo = selectedRoundLocationSetting)
|
||||
roundLocationTo = selectedRoundLocationSetting,
|
||||
forecastedMilesPerHour = forecastMilesPerHour.toIntOrNull()?.coerceIn(3, 30) ?: 12,
|
||||
forecastedKmPerHour = forecastKmPerHour.toIntOrNull()?.coerceIn(5, 50) ?: 20)
|
||||
|
||||
coroutineScope.launch {
|
||||
saveSettings(ctx, newSettings)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[versions]
|
||||
agp = "8.5.2"
|
||||
datastorePreferences = "1.1.1"
|
||||
datastorePreferences = "1.1.2"
|
||||
kotlin = "2.0.0"
|
||||
|
||||
androidxCore = "1.15.0"
|
||||
@ -9,8 +9,8 @@ androidxActivity = "1.9.3"
|
||||
androidxComposeUi = "1.7.6"
|
||||
androidxComposeMaterial = "1.3.1"
|
||||
glance = "1.1.1"
|
||||
kotlinxDatetime = "0.6.1"
|
||||
kotlinxSerializationJson = "1.7.3"
|
||||
kotlinxSerializationJson = "1.8.0"
|
||||
mapboxSdkTurf = "7.3.1"
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
@ -37,6 +37,7 @@ androidx-glance-appwidget = { group = "androidx.glance", name = "glance-appwidge
|
||||
androidx-glance-appwidget-preview = { group = "androidx.glance", name = "glance-appwidget-preview", version.ref = "glance" }
|
||||
androidx-glance-preview = { group = "androidx.glance", name = "glance-preview", version.ref = "glance" }
|
||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
|
||||
mapbox-sdk-turf = { module = "com.mapbox.mapboxsdk:mapbox-sdk-turf", version.ref = "mapboxSdkTurf" }
|
||||
|
||||
[bundles]
|
||||
androidx-lifeycle = ["androidx-lifecycle-runtime-compose", "androidx-lifecycle-viewmodel-compose"]
|
||||
|
||||
@ -34,6 +34,11 @@ dependencyResolutionManagement {
|
||||
password = gprKey
|
||||
}
|
||||
}
|
||||
|
||||
// mapbox
|
||||
maven {
|
||||
url = uri("https://api.mapbox.com/downloads/v2/releases/maven")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user