fix #49: Add individual forecast fields (#59)

This commit is contained in:
timklge 2025-03-05 00:00:14 +01:00 committed by GitHub
parent 4cd6d27aa2
commit aaeb1204bf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 906 additions and 265 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 = 14 versionCode = 15
versionName = "1.3" versionName = "1.3.1"
} }
signingConfigs { signingConfigs {

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.3", "latestVersion": "1.3.1",
"latestVersionCode": 14, "latestVersionCode": 15,
"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": "* Forecast weather along route in fixed intervals if route is loaded\n* Show current weather in app menu" "releaseNotes": "* Forecast weather along route in fixed intervals if route is loaded\n* Show current weather in app menu\n*Add individual forecast fields"
} }

View File

@ -69,6 +69,7 @@ data class HeadwindSettings(
val forecastedKmPerHour: Int = 20, val forecastedKmPerHour: Int = 20,
val forecastedMilesPerHour: Int = 12, val forecastedMilesPerHour: Int = 12,
val lastUpdateRequested: Long? = null, val lastUpdateRequested: Long? = null,
val showDistanceInForecast: Boolean = true,
){ ){
companion object { companion object {
val defaultSettings = Json.encodeToString(HeadwindSettings()) val defaultSettings = Json.encodeToString(HeadwindSettings())

View File

@ -7,18 +7,22 @@ import com.mapbox.turf.TurfConstants
import com.mapbox.turf.TurfMeasurement 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.GraphicalForecastDataType
import de.timklge.karooheadwind.datatypes.HeadwindDirectionDataType import de.timklge.karooheadwind.datatypes.HeadwindDirectionDataType
import de.timklge.karooheadwind.datatypes.HeadwindSpeedDataType import de.timklge.karooheadwind.datatypes.HeadwindSpeedDataType
import de.timklge.karooheadwind.datatypes.PrecipitationDataType import de.timklge.karooheadwind.datatypes.PrecipitationDataType
import de.timklge.karooheadwind.datatypes.PrecipitationForecastDataType
import de.timklge.karooheadwind.datatypes.RelativeHumidityDataType import de.timklge.karooheadwind.datatypes.RelativeHumidityDataType
import de.timklge.karooheadwind.datatypes.SealevelPressureDataType import de.timklge.karooheadwind.datatypes.SealevelPressureDataType
import de.timklge.karooheadwind.datatypes.SurfacePressureDataType import de.timklge.karooheadwind.datatypes.SurfacePressureDataType
import de.timklge.karooheadwind.datatypes.TailwindAndRideSpeedDataType import de.timklge.karooheadwind.datatypes.TailwindAndRideSpeedDataType
import de.timklge.karooheadwind.datatypes.TemperatureDataType import de.timklge.karooheadwind.datatypes.TemperatureDataType
import de.timklge.karooheadwind.datatypes.TemperatureForecastDataType
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.WindDirectionDataType
import de.timklge.karooheadwind.datatypes.WindForecastDataType
import de.timklge.karooheadwind.datatypes.WindGustsDataType import de.timklge.karooheadwind.datatypes.WindGustsDataType
import de.timklge.karooheadwind.datatypes.WindSpeedDataType import de.timklge.karooheadwind.datatypes.WindSpeedDataType
import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.KarooSystemService
@ -50,7 +54,7 @@ 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.3") { class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.3.1") {
companion object { companion object {
const val TAG = "karoo-headwind" const val TAG = "karoo-headwind"
} }
@ -66,7 +70,7 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.3") {
TailwindAndRideSpeedDataType(karooSystem, applicationContext), TailwindAndRideSpeedDataType(karooSystem, applicationContext),
HeadwindSpeedDataType(karooSystem, applicationContext), HeadwindSpeedDataType(karooSystem, applicationContext),
WeatherDataType(karooSystem, applicationContext), WeatherDataType(karooSystem, applicationContext),
WeatherForecastDataType(karooSystem, applicationContext), WeatherForecastDataType(karooSystem),
HeadwindSpeedDataType(karooSystem, applicationContext), HeadwindSpeedDataType(karooSystem, applicationContext),
RelativeHumidityDataType(applicationContext), RelativeHumidityDataType(applicationContext),
CloudCoverDataType(applicationContext), CloudCoverDataType(applicationContext),
@ -77,7 +81,11 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.3") {
PrecipitationDataType(applicationContext), PrecipitationDataType(applicationContext),
SurfacePressureDataType(applicationContext), SurfacePressureDataType(applicationContext),
SealevelPressureDataType(applicationContext), SealevelPressureDataType(applicationContext),
UserWindSpeedDataType(karooSystem, applicationContext) UserWindSpeedDataType(karooSystem, applicationContext),
TemperatureForecastDataType(karooSystem),
PrecipitationForecastDataType(karooSystem),
WindForecastDataType(karooSystem),
GraphicalForecastDataType(karooSystem)
) )
} }

View File

@ -0,0 +1,359 @@
package de.timklge.karooheadwind.datatypes
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Log
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.glance.GlanceModifier
import androidx.glance.action.clickable
import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi
import androidx.glance.appwidget.GlanceRemoteViews
import androidx.glance.appwidget.action.actionRunCallback
import androidx.glance.background
import androidx.glance.color.ColorProvider
import androidx.glance.layout.Alignment
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxHeight
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.width
import de.timklge.karooheadwind.HeadingResponse
import de.timklge.karooheadwind.HeadwindSettings
import de.timklge.karooheadwind.HeadwindWidgetSettings
import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse
import de.timklge.karooheadwind.OpenMeteoData
import de.timklge.karooheadwind.OpenMeteoForecastData
import de.timklge.karooheadwind.R
import de.timklge.karooheadwind.TemperatureUnit
import de.timklge.karooheadwind.UpcomingRoute
import de.timklge.karooheadwind.WeatherDataResponse
import de.timklge.karooheadwind.WeatherInterpretation
import de.timklge.karooheadwind.getHeadingFlow
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.ViewEmitter
import io.hammerhead.karooext.models.ShowCustomStreamState
import io.hammerhead.karooext.models.UpdateGraphicConfig
import io.hammerhead.karooext.models.UserProfile
import io.hammerhead.karooext.models.ViewConfig
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
import kotlin.math.roundToInt
abstract class ForecastDataType(private val karooSystem: KarooSystemService, typeId: String) : DataTypeImpl("karoo-headwind", typeId) {
@Composable
abstract fun RenderWidget(arrowBitmap: Bitmap,
current: WeatherInterpretation,
windBearing: Int,
windSpeed: Int,
windGusts: Int,
precipitation: Double,
precipitationProbability: Int?,
temperature: Int,
temperatureUnit: TemperatureUnit,
timeLabel: String,
dateLabel: String?,
distance: Double?,
isImperial: Boolean)
@OptIn(ExperimentalGlanceRemoteViewsApi::class)
private val glance = GlanceRemoteViews()
companion object {
val timeFormatter = DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneId.systemDefault())
}
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()
while (true) {
val data = (0..<10).map { index ->
val timeAtFullHour = Instant.now().truncatedTo(ChronoUnit.HOURS).epochSecond
val forecastTimes = (0..<12).map { timeAtFullHour + it * 60 * 60 }
val forecastTemperatures = (0..<12).map { 20.0 + (-20..20).random() }
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(
data,
SettingsAndProfile(
HeadwindSettings(),
settingsAndProfile?.isImperial == true
)
)
)
delay(5_000)
}
}
@OptIn(ExperimentalGlanceRemoteViewsApi::class)
override fun startView(context: Context, config: ViewConfig, emitter: ViewEmitter) {
Log.d(KarooHeadwindExtension.TAG, "Starting weather forecast view with $emitter")
val configJob = CoroutineScope(Dispatchers.IO).launch {
emitter.onNext(UpdateGraphicConfig(showHeader = false))
awaitCancellation()
}
val baseBitmap = BitmapFactory.decodeResource(
context.resources,
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(settingsAndProfileStream)
} else {
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 { (allData, settingsAndProfile, widgetSettings, userProfile, headingResponse, upcomingRoute) ->
Log.d(KarooHeadwindExtension.TAG, "Updating weather forecast view")
if (allData == null){
emitter.updateView(
getErrorWidget(
glance,
context,
settingsAndProfile.settings,
headingResponse
).remoteViews)
return@collect
}
val result = glance.compose(context, DpSize.Unspecified) {
var modifier = GlanceModifier.fillMaxSize()
if (!config.preview) modifier = modifier.clickable(onClick = actionRunCallback<CycleHoursAction>())
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 =
allData.getOrNull(positionOffset)?.data?.forecastData?.time?.getOrNull(
hourOffset
)
val formattedDate = unixTime?.let {
getShortDateFormatter().format(
Instant.ofEpochSecond(unixTime)
)
}
formattedDate
}
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
}
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}"
}
if (baseIndex > hourOffset) {
Spacer(
modifier = GlanceModifier.fillMaxHeight().background(
ColorProvider(Color.Black, Color.White)
).width(1.dp)
)
}
Log.d(
KarooHeadwindExtension.TAG,
"Distance along route ${positionIndex}: $position"
)
val distanceFromCurrent =
upcomingRoute?.distanceAlongRoute?.let { currentDistanceAlongRoute ->
distanceAlongRoute?.minus(currentDistanceAlongRoute)
}
val isCurrent = baseIndex == 0 && positionIndex == 0
if (isCurrent && data?.current != null) {
val interpretation =
WeatherInterpretation.fromWeatherCode(data.current.weatherCode)
val unixTime = data.current.time
val formattedTime =
timeFormatter.format(Instant.ofEpochSecond(unixTime))
val formattedDate =
getShortDateFormatter().format(Instant.ofEpochSecond(unixTime))
val hasNewDate = formattedDate != previousDate || baseIndex == 0
RenderWidget(
arrowBitmap = 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,
distance = 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 =
getShortDateFormatter().format(Instant.ofEpochSecond(unixTime))
val hasNewDate = formattedDate != previousDate || baseIndex == 0
RenderWidget(
arrowBitmap = 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 = if (settingsAndProfile.settings.showDistanceInForecast) distanceFromCurrent else null,
isImperial = settingsAndProfile.isImperial
)
previousDate = formattedDate
}
}
}
}
emitter.updateView(result.remoteViews)
}
}
emitter.setCancellable {
Log.d(
KarooHeadwindExtension.TAG,
"Stopping headwind weather forecast view with $emitter"
)
configJob.cancel()
viewJob.cancel()
}
}
}

View File

@ -0,0 +1,110 @@
package de.timklge.karooheadwind.datatypes
import android.graphics.Bitmap
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.TextUnitType
import androidx.compose.ui.unit.dp
import androidx.glance.ColorFilter
import androidx.glance.GlanceModifier
import androidx.glance.Image
import androidx.glance.ImageProvider
import androidx.glance.color.ColorProvider
import androidx.glance.layout.Alignment
import androidx.glance.layout.Column
import androidx.glance.layout.ContentScale
import androidx.glance.layout.Row
import androidx.glance.layout.fillMaxHeight
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.padding
import androidx.glance.layout.width
import androidx.glance.layout.wrapContentWidth
import androidx.glance.text.FontFamily
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import de.timklge.karooheadwind.TemperatureUnit
import de.timklge.karooheadwind.WeatherInterpretation
import io.hammerhead.karooext.KarooSystemService
import kotlin.math.absoluteValue
@Composable
fun GraphicalForecast(
current: WeatherInterpretation,
distance: Double? = null,
timeLabel: String? = null,
rowAlignment: Alignment.Horizontal = Alignment.Horizontal.CenterHorizontally,
isImperial: Boolean?
) {
Column(modifier = GlanceModifier.fillMaxHeight().padding(1.dp).width(86.dp), horizontalAlignment = rowAlignment) {
Row(modifier = GlanceModifier.defaultWeight().wrapContentWidth(), horizontalAlignment = rowAlignment, verticalAlignment = Alignment.CenterVertically) {
Image(
modifier = GlanceModifier.defaultWeight().wrapContentWidth().padding(1.dp),
provider = ImageProvider(getWeatherIcon(current)),
contentDescription = "Current weather information",
contentScale = ContentScale.Fit,
colorFilter = ColorFilter.tint(ColorProvider(Color.Black, Color.White))
)
}
if (distance != null && 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(18f, TextUnitType.Sp)
)
)
}
}
}
if (timeLabel != null){
Text(
text = timeLabel,
style = TextStyle(color = ColorProvider(Color.Black, Color.White), fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace, fontSize = TextUnit(18f, TextUnitType.Sp)
)
)
}
}
}
class GraphicalForecastDataType(karooSystem: KarooSystemService) : ForecastDataType(karooSystem, "graphicalForecast") {
@Composable
override fun RenderWidget(
arrowBitmap: Bitmap,
current: WeatherInterpretation,
windBearing: Int,
windSpeed: Int,
windGusts: Int,
precipitation: Double,
precipitationProbability: Int?,
temperature: Int,
temperatureUnit: TemperatureUnit,
timeLabel: String,
dateLabel: String?,
distance: Double?,
isImperial: Boolean
) {
GraphicalForecast(
current = current,
distance = distance,
timeLabel = timeLabel,
isImperial = isImperial
)
}
}

View File

@ -0,0 +1,107 @@
package de.timklge.karooheadwind.datatypes
import android.graphics.Bitmap
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.TextUnitType
import androidx.compose.ui.unit.dp
import androidx.glance.GlanceModifier
import androidx.glance.color.ColorProvider
import androidx.glance.layout.Alignment
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.fillMaxHeight
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.padding
import androidx.glance.layout.width
import androidx.glance.text.FontFamily
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import de.timklge.karooheadwind.TemperatureUnit
import de.timklge.karooheadwind.WeatherInterpretation
import io.hammerhead.karooext.KarooSystemService
import kotlin.math.absoluteValue
import kotlin.math.roundToInt
@Composable
fun PrecipitationForecast(
precipitation: Int,
precipitationProbability: Int?,
distance: Double? = null,
timeLabel: String? = null,
rowAlignment: Alignment.Horizontal = Alignment.Horizontal.CenterHorizontally,
isImperial: Boolean?
) {
Column(modifier = GlanceModifier.fillMaxHeight().padding(1.dp).width(86.dp), horizontalAlignment = rowAlignment) {
Row(modifier = GlanceModifier.defaultWeight().fillMaxWidth(), horizontalAlignment = rowAlignment, verticalAlignment = Alignment.CenterVertically) {
val precipitationProbabilityText = if (precipitationProbability != null) "${precipitationProbability}% " else ""
val precipitationText = if (precipitation > 0) precipitation.toString() else ""
Text(
text = "${precipitationProbabilityText}${precipitationText}",
style = TextStyle(color = ColorProvider(Color.Black, Color.White), fontFamily = FontFamily.Monospace, fontSize = TextUnit(24f, TextUnitType.Sp), textAlign = TextAlign.Center)
)
}
if (distance != null && 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(18f, TextUnitType.Sp)
)
)
}
}
}
if (timeLabel != null){
Text(
text = timeLabel,
style = TextStyle(color = ColorProvider(Color.Black, Color.White), fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace, fontSize = TextUnit(18f, TextUnitType.Sp)
)
)
}
}
}
class PrecipitationForecastDataType(karooSystem: KarooSystemService) : ForecastDataType(karooSystem, "precipitationForecast") {
@Composable
override fun RenderWidget(
arrowBitmap: Bitmap,
current: WeatherInterpretation,
windBearing: Int,
windSpeed: Int,
windGusts: Int,
precipitation: Double,
precipitationProbability: Int?,
temperature: Int,
temperatureUnit: TemperatureUnit,
timeLabel: String,
dateLabel: String?,
distance: Double?,
isImperial: Boolean
) {
PrecipitationForecast(
precipitation = precipitation.roundToInt().coerceAtLeast(0),
precipitationProbability = precipitationProbability,
distance = distance,
timeLabel = timeLabel,
isImperial = isImperial
)
}
}

View File

@ -0,0 +1,105 @@
package de.timklge.karooheadwind.datatypes
import android.graphics.Bitmap
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.TextUnitType
import androidx.compose.ui.unit.dp
import androidx.glance.GlanceModifier
import androidx.glance.color.ColorProvider
import androidx.glance.layout.Alignment
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.fillMaxHeight
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.padding
import androidx.glance.layout.width
import androidx.glance.text.FontFamily
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import de.timklge.karooheadwind.TemperatureUnit
import de.timklge.karooheadwind.WeatherInterpretation
import io.hammerhead.karooext.KarooSystemService
import kotlin.math.absoluteValue
@Composable
fun TemperatureForecast(
temperature: Int,
temperatureUnit: TemperatureUnit,
distance: Double? = null,
timeLabel: String? = null,
rowAlignment: Alignment.Horizontal = Alignment.Horizontal.CenterHorizontally,
isImperial: Boolean?
) {
Column(modifier = GlanceModifier.fillMaxHeight().padding(1.dp).width(86.dp), horizontalAlignment = rowAlignment) {
Row(modifier = GlanceModifier.defaultWeight().fillMaxWidth(), horizontalAlignment = rowAlignment, verticalAlignment = Alignment.CenterVertically) {
Text(
text = "${temperature}${temperatureUnit.unitDisplay}",
style = TextStyle(color = ColorProvider(Color.Black, Color.White), fontFamily = FontFamily.Monospace, fontSize = TextUnit(28f, TextUnitType.Sp), textAlign = TextAlign.Center)
)
}
if (distance != null && 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(18f, TextUnitType.Sp)
)
)
}
}
}
if (timeLabel != null){
Text(
text = timeLabel,
style = TextStyle(color = ColorProvider(Color.Black, Color.White), fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace, fontSize = TextUnit(18f, TextUnitType.Sp)
)
)
}
}
}
class TemperatureForecastDataType(karooSystem: KarooSystemService) : ForecastDataType(karooSystem, "temperatureForecast") {
@Composable
override fun RenderWidget(
arrowBitmap: Bitmap,
current: WeatherInterpretation,
windBearing: Int,
windSpeed: Int,
windGusts: Int,
precipitation: Double,
precipitationProbability: Int?,
temperature: Int,
temperatureUnit: TemperatureUnit,
timeLabel: String,
dateLabel: String?,
distance: Double?,
isImperial: Boolean
) {
TemperatureForecast(
temperature = temperature,
temperatureUnit = temperatureUnit,
distance = distance,
timeLabel = timeLabel,
isImperial = isImperial
)
}
}

View File

@ -1,262 +1,43 @@
package de.timklge.karooheadwind.datatypes package de.timklge.karooheadwind.datatypes
import android.content.Context import android.graphics.Bitmap
import android.graphics.BitmapFactory import androidx.compose.runtime.Composable
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.GlanceModifier
import androidx.glance.action.clickable
import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi
import androidx.glance.appwidget.GlanceRemoteViews
import androidx.glance.appwidget.action.actionRunCallback
import androidx.glance.background
import androidx.glance.color.ColorProvider
import androidx.glance.layout.Alignment
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxHeight
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.width
import de.timklge.karooheadwind.HeadingResponse
import de.timklge.karooheadwind.HeadwindSettings
import de.timklge.karooheadwind.HeadwindWidgetSettings
import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse
import de.timklge.karooheadwind.OpenMeteoData
import de.timklge.karooheadwind.OpenMeteoForecastData
import de.timklge.karooheadwind.TemperatureUnit import de.timklge.karooheadwind.TemperatureUnit
import de.timklge.karooheadwind.UpcomingRoute
import de.timklge.karooheadwind.WeatherDataResponse
import de.timklge.karooheadwind.WeatherInterpretation import de.timklge.karooheadwind.WeatherInterpretation
import de.timklge.karooheadwind.getHeadingFlow
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.KarooSystemService
import io.hammerhead.karooext.extension.DataTypeImpl
import io.hammerhead.karooext.internal.ViewEmitter
import io.hammerhead.karooext.models.ShowCustomStreamState
import io.hammerhead.karooext.models.UpdateGraphicConfig
import io.hammerhead.karooext.models.UserProfile
import io.hammerhead.karooext.models.ViewConfig
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.time.temporal.ChronoUnit
import kotlin.math.roundToInt
@OptIn(ExperimentalGlanceRemoteViewsApi::class)
class WeatherForecastDataType(
private val karooSystem: KarooSystemService,
private val applicationContext: Context
) : DataTypeImpl("karoo-headwind", "weatherForecast") {
private val glance = GlanceRemoteViews()
companion object {
val timeFormatter = DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneId.systemDefault())
}
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()
while (true){
val data = (0..<10).map { index ->
val timeAtFullHour = Instant.now().truncatedTo(ChronoUnit.HOURS).epochSecond
val forecastTimes = (0..<12).map { timeAtFullHour + it * 60 * 60 }
val forecastTemperatures = (0..<12).map { 20.0 + (-20..20).random() }
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(data, SettingsAndProfile(HeadwindSettings(), settingsAndProfile?.isImperial == true))
)
delay(5_000)
}
}
override fun startView(context: Context, config: ViewConfig, emitter: ViewEmitter) {
Log.d(KarooHeadwindExtension.TAG, "Starting weather forecast view with $emitter")
val configJob = CoroutineScope(Dispatchers.IO).launch {
emitter.onNext(UpdateGraphicConfig(showHeader = false))
awaitCancellation()
}
val baseBitmap = BitmapFactory.decodeResource(
context.resources,
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(settingsAndProfileStream)
} else {
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 { (allData, settingsAndProfile, widgetSettings, userProfile, headingResponse, upcomingRoute) ->
Log.d(KarooHeadwindExtension.TAG, "Updating weather forecast view")
if (allData == null){
emitter.updateView(getErrorWidget(glance, context, settingsAndProfile.settings, headingResponse).remoteViews)
return@collect
}
val result = glance.compose(context, DpSize.Unspecified) {
var modifier = GlanceModifier.fillMaxSize()
if (!config.preview) modifier = modifier.clickable(onClick = actionRunCallback<CycleHoursAction>())
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 = allData.getOrNull(positionOffset)?.data?.forecastData?.time?.getOrNull(hourOffset)
val formattedDate = unixTime?.let { getShortDateFormatter().format(Instant.ofEpochSecond(unixTime)) }
formattedDate
}
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
}
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}" }
if (baseIndex > hourOffset) {
Spacer(
modifier = GlanceModifier.fillMaxHeight().background(
ColorProvider(Color.Black, Color.White)
).width(1.dp)
)
}
Log.d(KarooHeadwindExtension.TAG, "Distance along route ${positionIndex}: $position")
val distanceFromCurrent = upcomingRoute?.distanceAlongRoute?.let { currentDistanceAlongRoute ->
distanceAlongRoute?.minus(currentDistanceAlongRoute)
}
val isCurrent = baseIndex == 0 && positionIndex == 0
if (isCurrent && data?.current != null){
val interpretation = WeatherInterpretation.fromWeatherCode(data.current.weatherCode)
val unixTime = data.current.time
val formattedTime = timeFormatter.format(Instant.ofEpochSecond(unixTime))
val formattedDate = getShortDateFormatter().format(Instant.ofEpochSecond(unixTime))
val hasNewDate = formattedDate != previousDate || baseIndex == 0
class WeatherForecastDataType(karooSystem: KarooSystemService) : ForecastDataType(karooSystem, "weatherForecast") {
@Composable
override fun RenderWidget(
arrowBitmap: Bitmap,
current: WeatherInterpretation,
windBearing: Int,
windSpeed: Int,
windGusts: Int,
precipitation: Double,
precipitationProbability: Int?,
temperature: Int,
temperatureUnit: TemperatureUnit,
timeLabel: String,
dateLabel: String?,
distance: Double?,
isImperial: Boolean
) {
Weather( Weather(
baseBitmap, arrowBitmap = arrowBitmap,
current = interpretation, current = current,
windBearing = data.current.windDirection.roundToInt(), windBearing = windBearing,
windSpeed = data.current.windSpeed.roundToInt(), windSpeed = windSpeed,
windGusts = data.current.windGusts.roundToInt(), windGusts = windGusts,
precipitation = data.current.precipitation, precipitation = precipitation,
precipitationProbability = null, precipitationProbability = precipitationProbability,
temperature = data.current.temperature.roundToInt(), temperature = temperature,
temperatureUnit = if (userProfile?.preferredUnit?.temperature != UserProfile.PreferredUnit.UnitType.IMPERIAL) TemperatureUnit.CELSIUS else TemperatureUnit.FAHRENHEIT, temperatureUnit = temperatureUnit,
timeLabel = formattedTime, timeLabel = timeLabel,
dateLabel = if (hasNewDate) formattedDate else null, dateLabel = dateLabel,
isImperial = userProfile?.preferredUnit?.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL distance = distance,
isImperial = isImperial
) )
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 = getShortDateFormatter().format(Instant.ofEpochSecond(unixTime))
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
}
}
}
} }
emitter.updateView(result.remoteViews)
}
}
emitter.setCancellable {
Log.d(KarooHeadwindExtension.TAG, "Stopping headwind weather forecast view with $emitter")
configJob.cancel()
viewJob.cancel()
}
}
} }

View File

@ -60,7 +60,7 @@ fun getWeatherIcon(interpretation: WeatherInterpretation): Int {
@OptIn(ExperimentalGlancePreviewApi::class) @OptIn(ExperimentalGlancePreviewApi::class)
@Composable @Composable
fun Weather( fun Weather(
baseBitmap: Bitmap, arrowBitmap: Bitmap,
current: WeatherInterpretation, current: WeatherInterpretation,
windBearing: Int, windBearing: Int,
windSpeed: Int, windSpeed: Int,
@ -172,7 +172,7 @@ fun Weather(
Image( Image(
modifier = if (singleDisplay) GlanceModifier.height(20.dp).width(16.dp) else GlanceModifier.height(16.dp).width(12.dp).padding(1.dp), modifier = if (singleDisplay) GlanceModifier.height(20.dp).width(16.dp) else GlanceModifier.height(16.dp).width(12.dp).padding(1.dp),
provider = ImageProvider(getArrowBitmapByBearing(baseBitmap, windBearing + 180)), provider = ImageProvider(getArrowBitmapByBearing(arrowBitmap, windBearing + 180)),
contentDescription = "Current wind direction", contentDescription = "Current wind direction",
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
colorFilter = ColorFilter.tint(ColorProvider(Color.Black, Color.White)) colorFilter = ColorFilter.tint(ColorProvider(Color.Black, Color.White))

View File

@ -0,0 +1,120 @@
package de.timklge.karooheadwind.datatypes
import android.graphics.Bitmap
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.TextUnitType
import androidx.compose.ui.unit.dp
import androidx.glance.ColorFilter
import androidx.glance.GlanceModifier
import androidx.glance.Image
import androidx.glance.ImageProvider
import androidx.glance.color.ColorProvider
import androidx.glance.layout.Alignment
import androidx.glance.layout.Column
import androidx.glance.layout.ContentScale
import androidx.glance.layout.Row
import androidx.glance.layout.fillMaxHeight
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.layout.width
import androidx.glance.text.FontFamily
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import de.timklge.karooheadwind.TemperatureUnit
import de.timklge.karooheadwind.WeatherInterpretation
import io.hammerhead.karooext.KarooSystemService
import kotlin.math.absoluteValue
import kotlin.math.roundToInt
@Composable
fun WindForecast(
arrowBitmap: Bitmap,
windBearing: Int,
windSpeed: Int,
gustSpeed: Int,
distance: Double? = null,
timeLabel: String? = null,
rowAlignment: Alignment.Horizontal = Alignment.Horizontal.CenterHorizontally,
isImperial: Boolean?
) {
Column(modifier = GlanceModifier.fillMaxHeight().padding(1.dp).width(86.dp), horizontalAlignment = rowAlignment) {
Image(
modifier = GlanceModifier.defaultWeight().fillMaxWidth(),
provider = ImageProvider(getArrowBitmapByBearing(arrowBitmap, windBearing + 180)),
contentDescription = "Current wind direction",
contentScale = ContentScale.Fit,
colorFilter = ColorFilter.tint(ColorProvider(Color.Black, Color.White))
)
Text(
text = "${windSpeed}-${gustSpeed}",
style = TextStyle(color = ColorProvider(Color.Black, Color.White), fontFamily = FontFamily.Monospace, fontSize = TextUnit(18f, TextUnitType.Sp), textAlign = TextAlign.Center)
)
if (distance != null && 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(18f, TextUnitType.Sp)
)
)
}
}
}
if (timeLabel != null){
Text(
text = timeLabel,
style = TextStyle(color = ColorProvider(Color.Black, Color.White), fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace, fontSize = TextUnit(18f, TextUnitType.Sp)
)
)
}
}
}
class WindForecastDataType(karooSystem: KarooSystemService) : ForecastDataType(karooSystem, "windForecast") {
@Composable
override fun RenderWidget(
arrowBitmap: Bitmap,
current: WeatherInterpretation,
windBearing: Int,
windSpeed: Int,
windGusts: Int,
precipitation: Double,
precipitationProbability: Int?,
temperature: Int,
temperatureUnit: TemperatureUnit,
timeLabel: String,
dateLabel: String?,
distance: Double?,
isImperial: Boolean
) {
WindForecast(
arrowBitmap = arrowBitmap,
windBearing = windBearing,
windSpeed = windSpeed,
gustSpeed = windGusts,
distance = distance,
timeLabel = timeLabel,
isImperial = isImperial
)
}
}

View File

@ -4,13 +4,16 @@ import android.util.Log
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
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.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
@ -21,6 +24,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
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.text.input.KeyboardType
@ -62,6 +66,7 @@ fun SettingsScreen(onFinish: () -> Unit) {
var selectedRoundLocationSetting by remember { mutableStateOf(RoundLocationSetting.KM_3) } var selectedRoundLocationSetting by remember { mutableStateOf(RoundLocationSetting.KM_3) }
var forecastKmPerHour by remember { mutableStateOf("20") } var forecastKmPerHour by remember { mutableStateOf("20") }
var forecastMilesPerHour by remember { mutableStateOf("12") } var forecastMilesPerHour by remember { mutableStateOf("12") }
var showDistanceInForecast by remember { mutableStateOf(true) }
val profile by karooSystem.streamUserProfile().collectAsStateWithLifecycle(null) val profile by karooSystem.streamUserProfile().collectAsStateWithLifecycle(null)
@ -73,6 +78,7 @@ fun SettingsScreen(onFinish: () -> Unit) {
selectedRoundLocationSetting = settings.roundLocationTo selectedRoundLocationSetting = settings.roundLocationTo
forecastKmPerHour = settings.forecastedKmPerHour.toString() forecastKmPerHour = settings.forecastedKmPerHour.toString()
forecastMilesPerHour = settings.forecastedMilesPerHour.toString() forecastMilesPerHour = settings.forecastedMilesPerHour.toString()
showDistanceInForecast = settings.showDistanceInForecast
} }
} }
@ -98,7 +104,8 @@ fun SettingsScreen(onFinish: () -> Unit) {
windDirectionIndicatorTextSetting = selectedWindDirectionIndicatorTextSetting, windDirectionIndicatorTextSetting = selectedWindDirectionIndicatorTextSetting,
roundLocationTo = selectedRoundLocationSetting, roundLocationTo = selectedRoundLocationSetting,
forecastedMilesPerHour = forecastMilesPerHour.toIntOrNull()?.coerceIn(3, 30) ?: 12, forecastedMilesPerHour = forecastMilesPerHour.toIntOrNull()?.coerceIn(3, 30) ?: 12,
forecastedKmPerHour = forecastKmPerHour.toIntOrNull()?.coerceIn(5, 50) ?: 20 forecastedKmPerHour = forecastKmPerHour.toIntOrNull()?.coerceIn(5, 50) ?: 20,
showDistanceInForecast = showDistanceInForecast
) )
saveSettings(ctx, newSettings) saveSettings(ctx, newSettings)
@ -205,6 +212,12 @@ fun SettingsScreen(onFinish: () -> Unit) {
) )
} }
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = showDistanceInForecast, onCheckedChange = { showDistanceInForecast = it})
Spacer(modifier = Modifier.width(10.dp))
Text("Show Distance in Forecast")
}
if (!karooConnected) { if (!karooConnected) {
Text( Text(
modifier = Modifier.padding(5.dp), modifier = Modifier.padding(5.dp),

View File

@ -29,6 +29,7 @@ import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.R import de.timklge.karooheadwind.R
import de.timklge.karooheadwind.TemperatureUnit import de.timklge.karooheadwind.TemperatureUnit
import de.timklge.karooheadwind.WeatherInterpretation import de.timklge.karooheadwind.WeatherInterpretation
import de.timklge.karooheadwind.datatypes.ForecastDataType
import de.timklge.karooheadwind.datatypes.WeatherDataType.Companion.timeFormatter import de.timklge.karooheadwind.datatypes.WeatherDataType.Companion.timeFormatter
import de.timklge.karooheadwind.datatypes.WeatherForecastDataType import de.timklge.karooheadwind.datatypes.WeatherForecastDataType
import de.timklge.karooheadwind.datatypes.getShortDateFormatter import de.timklge.karooheadwind.datatypes.getShortDateFormatter
@ -200,7 +201,7 @@ fun WeatherScreen(onFinish: () -> Unit) {
val interpretation = WeatherInterpretation.fromWeatherCode(data?.forecastData?.weatherCode?.get(index) ?: 0) val interpretation = WeatherInterpretation.fromWeatherCode(data?.forecastData?.weatherCode?.get(index) ?: 0)
val unixTime = data?.forecastData?.time?.get(index) ?: 0 val unixTime = data?.forecastData?.time?.get(index) ?: 0
val formattedForecastTime = WeatherForecastDataType.timeFormatter.format(Instant.ofEpochSecond(unixTime)) val formattedForecastTime = ForecastDataType.timeFormatter.format(Instant.ofEpochSecond(unixTime))
val formattedForecastDate = getShortDateFormatter().format(Instant.ofEpochSecond(unixTime)) val formattedForecastDate = getShortDateFormatter().format(Instant.ofEpochSecond(unixTime))
WeatherWidget( WeatherWidget(

View File

@ -25,10 +25,18 @@
<string name="weather_description">Current weather conditions</string> <string name="weather_description">Current weather conditions</string>
<string name="weather_forecast">Weather Forecast</string> <string name="weather_forecast">Weather Forecast</string>
<string name="weather_forecast_description">Current hourly weather forecast</string> <string name="weather_forecast_description">Current hourly weather forecast</string>
<string name="temperature_forecast">Temperature Forecast</string>
<string name="temperature_forecast_description">Current hourly temperature forecast</string>
<string name="wind_forecast">Wind Forecast</string>
<string name="wind_forecast_description">Current hourly wind forecast</string>
<string name="precipitation_forecast">Precipitation Forecast</string>
<string name="precipitation_forecast_description">Current hourly precipitation forecast</string>
<string name="headwind_speed">Headwind speed</string> <string name="headwind_speed">Headwind speed</string>
<string name="headwind_speed_description">Current headwind speed</string> <string name="headwind_speed_description">Current headwind speed</string>
<string name="temperature">Temperature</string> <string name="temperature">Temperature</string>
<string name="temperature_description">Current temperature in configured unit</string> <string name="temperature_description">Current temperature in configured unit</string>
<string name="userwind_speed_description">Current headwind or wind speed based on user setting</string> <string name="userwind_speed_description">Current headwind or wind speed based on user setting</string>
<string name="userwind_speed">Set wind speed</string> <string name="userwind_speed">Set wind speed</string>
<string name="graphical_forecast">Graphical Forecast</string>
<string name="graphical_forecast_description">Current graphical weather forecast</string>
</resources> </resources>

View File

@ -32,6 +32,34 @@
icon="@drawable/wind" icon="@drawable/wind"
typeId="weatherForecast" /> typeId="weatherForecast" />
<DataType
description="@string/temperature_forecast_description"
displayName="@string/temperature_forecast"
graphical="true"
icon="@drawable/wind"
typeId="temperatureForecast" />
<DataType
description="@string/precipitation_forecast_description"
displayName="@string/precipitation_forecast"
graphical="true"
icon="@drawable/wind"
typeId="precipitationForecast" />
<DataType
description="@string/wind_forecast_description"
displayName="@string/wind_forecast"
graphical="true"
icon="@drawable/wind"
typeId="windForecast" />
<DataType
description="@string/graphical_forecast_description"
displayName="@string/graphical_forecast"
graphical="true"
icon="@drawable/wind"
typeId="graphicalForecast" />
<DataType <DataType
description="@string/headwind_speed_description" description="@string/headwind_speed_description"
displayName="@string/headwind_speed" displayName="@string/headwind_speed"