Refactor unit conversion, show individual forecasts as line graphs, remove headwind indicator settings (#137)

* Refactor unit conversion

* ref #136: Fix cloud cover, surface level pressure, sealevel pressure and relative humidity are not included in forecast values

* Unit conversions

* Update pipeline

* Remove apk archival step

* fix #128: Remove absolute displayed wind speed, text on indicator settings, userwindSpeed datatype

* fix #98: Show temperature, precipitation, temperature forecasts as line graphs

* Add WindDirectionAndSpeedDataType

* Disable line graph forecast when using openweathermap

* Fix wind on main menu forecast display
This commit is contained in:
timklge 2025-06-04 23:02:15 +02:00 committed by GitHub
parent 188863727f
commit dae1369cd8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 1546 additions and 666 deletions

View File

@ -3,10 +3,10 @@ name: Build
on:
workflow_dispatch:
push:
branches: [ "master" ]
branches: [ "**" ]
tags: [ "*" ]
pull_request:
branches: [ "master" ]
branches: [ "**" ]
jobs:
build:
@ -19,13 +19,14 @@ jobs:
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
- name: Set up environment variables
run: |
echo "GPR_USER=${{ github.actor }}" >> $GITHUB_ENV
echo "GPR_KEY=${{ secrets.GITHUB_TOKEN }}" >> $GITHUB_ENV
echo "GPR_USER=${{ secrets.GHUB_USER || github.actor }}" >> $GITHUB_ENV
echo "GPR_KEY=${{ secrets.GHUB_TOKEN || secrets.GITHUB_TOKEN }}" >> $GITHUB_ENV
echo "KEY_ALIAS=${{ secrets.KEY_ALIAS }}" >> $GITHUB_ENV
echo "KEY_PASSWORD=${{ secrets.KEY_PASSWORD }}" >> $GITHUB_ENV
echo "KEYSTORE_PASSWORD=${{ secrets.KEYSTORE_PASSWORD }}" >> $GITHUB_ENV
echo "KEYSTORE_BASE64=${{ secrets.KEYSTORE_BASE64 }}" >> $GITHUB_ENV
echo "BUILD_NUMBER=${{ github.run_number }}" >> $GITHUB_ENV
echo "BASE_URL=${{ secrets.BASE_URL || 'https://github.com/timklge/karoo-headwind/releases/latest/download' }}" >> $GITHUB_ENV
- uses: actions/checkout@v4
- name: set up JDK 17
uses: actions/setup-java@v4
@ -34,25 +35,30 @@ jobs:
distribution: 'temurin'
cache: gradle
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Build with Gradle
run: ./gradlew build
- name: Archive APK
uses: actions/upload-artifact@v4
with:
name: app-release.apk
path: app/build/outputs/apk/release/app-release.apk
- name: Create Release
id: create_release
uses: ncipollo/release-action@v1
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/')
with:
name: ${{ github.ref_name }}
prerelease: false
generateReleaseNotes: true
artifacts: app/build/outputs/apk/release/app-release.apk, app/manifest.json, app/karoo-headwind.png, preview0.png, preview1.png, preview2.png, preview3.png
draft: false
generate_release_notes: true
make_latest: true
files: |
app/build/outputs/apk/release/app-release.apk
app/manifest.json
app/karoo-headwind.png
preview0.png
preview1.png
preview2.png
preview3.png
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

1
.gitignore vendored
View File

@ -9,3 +9,4 @@
.cxx
local.properties
/app/release
app/manifest.json

View File

@ -24,6 +24,8 @@ After installing this app on your Karoo and opening it once from the main menu,
- Headwind (graphical, 1x1 field): Shows the headwind direction and speed as a circle with a triangular direction indicator. The speed is shown at the center in your set unit of measurement (default is kilometers per hour if you have set up metric units in your Karoo, otherwise miles per hour). Both direction and speed are relative to the current riding direction by default, i. e., riding directly into a wind of 20 km/h will show a headwind speed of 20 km/h, while riding in the same direction will show -20 km/h. You can change this behavior in the app settings to show the absolute wind direction and speed instead.
- Tailwind with riding speed (graphical, 1x1 field): Shows an arrow indicating the current headwind direction next to a label reading your current speed and the speed of the tailwind. If you ride against a headwind of 5 mph, it will show "-5". If you ride in the same direction of a 5 mph wind, it will read "+5". Text and arrow are colored based on the tailwind speed, with red indicating a strong headwind and green indicating a strong tailwind.
- Tailwind (graphical, 1x1 field): Similar to the tailwind and riding speed field, but shows tailwind speed, wind speed and wind gust speed instead of riding speed.
- Wind direction and speed (graphical, 1x1 field): Similar to the tailwind data field, but shows the absolute wind speed and gust speed instead.
- Wind forecast / Temperature Forecast / Precipitation forecast (graphical, 2x1 field): Line graphs showing the forecasted wind speeds, temperature or precipitation for the next 12 hours if no route is loaded. If a route is loaded, forecasts along the route will be used instead of the current location.
- Weather forecast (graphical, 2x1 field): Shows three columns indicating the current weather conditions (sunny, cloudy, ...), wind direction, precipitation and temperature forecasted for the next three hours. Tap on this widget to cycle through the 12 hour forecast. If you have a route loaded, the forecast widget will show the forecasted weather along points of the route, with an estimated traveled distance per hour of 20 km / 12 miles by default.
- Current weather (graphical, 1x1 field): Shows current weather conditions (same as forecast widget, but only for the current time).
- Relative grade (numerical): Shows the relative grade. The relative grade is calculated by estimating the force of the headwind, and then calculating the gradient you would need to ride at to experience this resistance if there was no wind. Example: If you are riding on an actual gradient of 2 %, face a headwind of 18 km/h while riding at 29 km/h, the relative grade will be shown as 5.2 % (with 3.2 % added to the actual grade due to the headwind).
@ -53,10 +55,11 @@ This app uses Google Crashlytics for crash reporting to help improve stability a
## Extension Developers: Headwind Data Type
If the user has installed the headwind extension on his karoo, you can stream the headwind data type from other extensions via `karoo-ext`.
Use extension id `karoo-headwind` with datatype ids `headwind` and `userwindSpeed`.
Use data type id `TYPE_EXT::karoo-headwind::TYPE_ID` with `TYPE_ID` being one of `headwind`, `windDirection`, `headwindSpeed`, `windSpeed` etc.
- The `headwind` datatype contains a single field that either represents an error code or the wind direction. A `-1.0` indicates missing gps receiption, `-2.0` no weather data, `-3.0` that the headwind extension
has not been set up. Otherwise, the value is the wind direction in degrees; if the user has set the headwind indicator to depict the absolute wind direction, the field will contain the absolute wind direction; otherwise
it will contain the headwind direction.
- The `userwindSpeed` datatype contains a single field with the wind speed in the user's defined unit. If the user has set the headwind indicator to show the absolute wind speed,
this field will contain the absolute wind speed; otherwise it will contain the headwind speed.
- The `headwind` datatype contains a single field that either represents an error code or the *relative* wind direction. A `-1.0` indicates missing gps receiption, `-2.0` no weather data, `-3.0` that the headwind extension
has not been set up. Otherwise, the value is the headwind direction in degrees.
- The `windDirection` datatype contains a single field with the *absolute* wind direction in degrees (so 0 = North, 90 = East etc.)
- The `headwindSpeed` datatype contains a single field that contains the *relative* headwind speed in meters per second.
- The `windSpeed` datatype contains a single field that contains the *absolute* wind speed in meters per second.
- Other datatypes like `windGusts` etc. are also available, see [extension_info.xml](https://github.com/timklge/karoo-headwind/blob/master/app/src/main/res/xml/extension_info.xml)

View File

@ -1,3 +1,4 @@
import com.android.build.gradle.tasks.ProcessApplicationManifest
import java.util.Base64
plugins {
@ -60,29 +61,38 @@ tasks.register("generateManifest") {
group = "build"
doLast {
val baseUrl = System.getenv("BASE_URL") ?: "https://github.com/timklge/karoo-headwind/releases/latest/download"
val manifestFile = file("$projectDir/manifest.json")
val manifest = mapOf(
"label" to "Headwind",
"packageName" to "de.timklge.karooheadwind",
"iconUrl" to "https://github.com/timklge/karoo-headwind/releases/latest/download/karoo-headwind.png",
"latestApkUrl" to "https://github.com/timklge/karoo-headwind/releases/latest/download/app-release.apk",
"iconUrl" to "$baseUrl/karoo-headwind.png",
"latestApkUrl" to "$baseUrl/app-release.apk",
"latestVersion" to android.defaultConfig.versionName,
"latestVersionCode" to android.defaultConfig.versionCode,
"developer" to "github.com/timklge",
"description" to "Open-source extension that provides headwind direction, wind speed, forecast and other weather data fields.",
"releaseNotes" to "* Remove crashlytics\n" +
"releaseNotes" to "* Refactor unit conversions\n* Remove crashlytics\n" +
"* Reduce refresh rate on K2, add refresh rate setting\n" +
"screenshotUrls" to listOf(
"https://github.com/timklge/karoo-headwind/releases/latest/download/preview1.png",
"https://github.com/timklge/karoo-headwind/releases/latest/download/preview3.png",
"https://github.com/timklge/karoo-headwind/releases/latest/download/preview2.png",
"https://github.com/timklge/karoo-headwind/releases/latest/download/preview0.png",
"$baseUrl/preview1.png",
"$baseUrl/preview3.png",
"$baseUrl/preview2.png",
"$baseUrl/preview0.png",
)
)
val gson = groovy.json.JsonBuilder(manifest).toPrettyString()
manifestFile.writeText(gson)
println("Generated manifest.json with version ${android.defaultConfig.versionName} (${android.defaultConfig.versionCode})")
if (System.getenv()["BASE_URL"] != null){
val androidManifestFile = file("$projectDir/src/main/AndroidManifest.xml")
var androidManifestContent = androidManifestFile.readText()
androidManifestContent = androidManifestContent.replace("\$BASE_URL\$", baseUrl)
androidManifestFile.writeText(androidManifestContent)
println("Replaced \$BASE_URL$ in AndroidManifest.xml")
}
}
}
@ -90,6 +100,12 @@ tasks.named("assemble") {
dependsOn("generateManifest")
}
tasks.withType<ProcessApplicationManifest>().configureEach {
if (name == "processDebugMainManifest" || name == "processReleaseMainManifest") {
dependsOn(tasks.named("generateManifest"))
}
}
dependencies {
implementation(libs.mapbox.sdk.turf)
implementation(libs.hammerhead.karoo.ext)

View File

@ -32,6 +32,6 @@
<meta-data
android:name="io.hammerhead.karooext.MANIFEST_URL"
android:value="https://github.com/timklge/karoo-headwind/releases/latest/download/manifest.json" />
android:value="$BASE_URL$/manifest.json" />
</application>
</manifest>

View File

@ -210,6 +210,22 @@ fun Context.streamCurrentForecastWeatherData(): Flow<WeatherDataResponse?> {
}.distinctUntilChanged()
}
fun lerp(
start: Double,
end: Double,
factor: Double
): Double {
return start + (end - start) * factor
}
fun lerp(
start: Int,
end: Int,
factor: Double
): Int {
return (start + (end - start) * factor).toInt()
}
fun lerpNullable(
start: Double?,
end: Double?,
@ -266,12 +282,12 @@ fun lerpWeather(
return WeatherData(
time = (start.time + (end.time - start.time) * factor).toLong(),
temperature = start.temperature + (end.temperature - start.temperature) * factor,
relativeHumidity = lerpNullable(start.relativeHumidity, end.relativeHumidity, factor),
relativeHumidity = lerp(start.relativeHumidity, end.relativeHumidity, factor),
precipitation = start.precipitation + (end.precipitation - start.precipitation) * factor,
precipitationProbability = lerpNullable(start.precipitationProbability, end.precipitationProbability, factor),
cloudCover = lerpNullable(start.cloudCover, end.cloudCover, factor),
surfacePressure = lerpNullable(start.surfacePressure, end.surfacePressure, factor),
sealevelPressure = lerpNullable(start.sealevelPressure, end.sealevelPressure, factor),
cloudCover = lerp(start.cloudCover, end.cloudCover, factor),
surfacePressure = lerp(start.surfacePressure, end.surfacePressure, factor),
sealevelPressure = lerp(start.sealevelPressure, end.sealevelPressure, factor),
windSpeed = start.windSpeed + (end.windSpeed - start.windSpeed) * factor,
windDirection = lerpAngle(start.windDirection, end.windDirection, factor),
windGusts = start.windGusts + (end.windGusts - start.windGusts) * factor,

View File

@ -18,17 +18,6 @@ enum class PrecipitationUnit(val id: String, val label: String, val unitDisplay:
INCH("inch", "Inch", "in")
}
enum class WindDirectionIndicatorTextSetting(val id: String, val label: String){
HEADWIND_SPEED("headwind-speed", "Headwind speed"),
WIND_SPEED("absolute-wind-speed", "Absolute wind speed"),
NONE("none", "None")
}
enum class WindDirectionIndicatorSetting(val id: String, val label: String){
HEADWIND_DIRECTION("headwind-direction", "Headwind"),
WIND_DIRECTION("wind-direction", "Absolute wind direction"),
}
enum class TemperatureUnit(val id: String, val label: String, val unitDisplay: String){
CELSIUS("celsius", "Celsius (°C)", "°C"),
FAHRENHEIT("fahrenheit", "Fahrenheit (°F)", "°F")
@ -91,8 +80,6 @@ enum class RefreshRate(val id: String, val k2Ms: Long, val k3Ms: Long) {
@Serializable
data class HeadwindSettings(
val welcomeDialogAccepted: Boolean = false,
val windDirectionIndicatorTextSetting: WindDirectionIndicatorTextSetting = WindDirectionIndicatorTextSetting.HEADWIND_SPEED,
val windDirectionIndicatorSetting: WindDirectionIndicatorSetting = WindDirectionIndicatorSetting.HEADWIND_DIRECTION,
val roundLocationTo: RoundLocationSetting = RoundLocationSetting.KM_3,
val forecastedKmPerHour: Int = 20,
val forecastedMilesPerHour: Int = 12,

View File

@ -17,10 +17,9 @@ import de.timklge.karooheadwind.datatypes.RelativeHumidityDataType
import de.timklge.karooheadwind.datatypes.SealevelPressureDataType
import de.timklge.karooheadwind.datatypes.SurfacePressureDataType
import de.timklge.karooheadwind.datatypes.TailwindAndRideSpeedDataType
import de.timklge.karooheadwind.datatypes.TailwindDataType
import de.timklge.karooheadwind.datatypes.WindDirectionAndSpeedDataType
import de.timklge.karooheadwind.datatypes.TemperatureDataType
import de.timklge.karooheadwind.datatypes.TemperatureForecastDataType
import de.timklge.karooheadwind.datatypes.UserWindSpeedDataType
import de.timklge.karooheadwind.datatypes.WeatherDataType
import de.timklge.karooheadwind.datatypes.WeatherForecastDataType
import de.timklge.karooheadwind.datatypes.WindDirectionDataType
@ -78,15 +77,15 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", BuildConfig.VERS
WindSpeedDataType(karooSystem, applicationContext),
TemperatureDataType(karooSystem, applicationContext),
WindDirectionDataType(karooSystem, applicationContext),
WindDirectionAndSpeedDataType(karooSystem, applicationContext),
PrecipitationDataType(karooSystem, applicationContext),
SurfacePressureDataType(karooSystem, applicationContext),
SealevelPressureDataType(karooSystem, applicationContext),
UserWindSpeedDataType(karooSystem, applicationContext),
TemperatureForecastDataType(karooSystem),
PrecipitationForecastDataType(karooSystem),
WindForecastDataType(karooSystem),
GraphicalForecastDataType(karooSystem),
TailwindDataType(karooSystem, applicationContext),
WindDirectionAndSpeedDataType(karooSystem, applicationContext),
RelativeGradeDataType(karooSystem, applicationContext),
RelativeElevationGainDataType(karooSystem, applicationContext),
)
@ -169,7 +168,7 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", BuildConfig.VERS
Log.d(TAG, "Minutes to next full hour: ${msToNextFullHour / 1000 / 60}, Distance to next full hour: ${(calculatedDistanceToNextFullHour / 1000).roundToInt()}km")
requestedGpsCoordinates = buildList {
add(gps)
add(GpsCoordinates(gps.lat, gps.lon, gps.bearing, distanceAlongRoute = positionOnRoute))
var currentPosition = positionOnRoute + calculatedDistanceToNextFullHour
var lastRequestedPosition = positionOnRoute

View File

@ -4,16 +4,23 @@ import android.content.Context
import android.util.Log
import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.streamCurrentWeatherData
import de.timklge.karooheadwind.streamUserProfile
import de.timklge.karooheadwind.throttle
import de.timklge.karooheadwind.weatherprovider.WeatherData
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.StreamState
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.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.launch
@ -22,20 +29,25 @@ abstract class BaseDataType(
private val applicationContext: Context,
dataTypeId: String
) : DataTypeImpl("karoo-headwind", dataTypeId) {
abstract fun getValue(data: WeatherData): Double?
abstract fun getValue(data: WeatherData, userProfile: UserProfile): Double?
open fun getFormatDataType(): String? = null
override fun startStream(emitter: Emitter<StreamState>) {
Log.d(KarooHeadwindExtension.TAG, "start $dataTypeId stream")
val job = CoroutineScope(Dispatchers.IO).launch {
val currentWeatherData = applicationContext.streamCurrentWeatherData(karooSystemService)
data class StreamData(val weatherData: WeatherData, val userProfile: UserProfile)
val currentWeatherData = combine(applicationContext.streamCurrentWeatherData(karooSystemService).filterNotNull(), karooSystemService.streamUserProfile()) { weatherData, userProfile ->
StreamData(weatherData, userProfile)
}
val refreshRate = karooSystemService.getRefreshRateInMilliseconds(applicationContext)
currentWeatherData
.filterNotNull()
currentWeatherData.filterNotNull()
.throttle(refreshRate)
.collect { data ->
val value = getValue(data)
.collect { (data, userProfile) ->
val value = getValue(data, userProfile)
Log.d(KarooHeadwindExtension.TAG, "$dataTypeId: $value")
if (value != null) {
@ -50,4 +62,12 @@ abstract class BaseDataType(
job.cancel()
}
}
override fun startView(context: Context, config: ViewConfig, emitter: ViewEmitter) {
Log.d(KarooHeadwindExtension.TAG, "Starting $dataTypeId view with $emitter")
if (getFormatDataType() != null){
emitter.onNext(UpdateGraphicConfig(formatDataTypeId = getFormatDataType()))
}
}
}

View File

@ -3,9 +3,10 @@ package de.timklge.karooheadwind.datatypes
import android.content.Context
import de.timklge.karooheadwind.weatherprovider.WeatherData
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.UserProfile
class CloudCoverDataType(karooSystemService: KarooSystemService, context: Context) : BaseDataType(karooSystemService, context, "cloudCover"){
override fun getValue(data: WeatherData): Double? {
override fun getValue(data: WeatherData, userProfile: UserProfile): Double? {
return data.cloudCover
}
}

View File

@ -37,6 +37,9 @@ import de.timklge.karooheadwind.streamUpcomingRoute
import de.timklge.karooheadwind.streamUserProfile
import de.timklge.karooheadwind.streamWidgetSettings
import de.timklge.karooheadwind.throttle
import de.timklge.karooheadwind.util.celciusInUserUnit
import de.timklge.karooheadwind.util.millimetersInUserUnit
import de.timklge.karooheadwind.util.msInUserUnit
import de.timklge.karooheadwind.weatherprovider.WeatherData
import de.timklge.karooheadwind.weatherprovider.WeatherDataForLocation
import de.timklge.karooheadwind.weatherprovider.WeatherDataResponse
@ -116,7 +119,7 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ
WeatherData(
time = forecastTime,
temperature = forecastTemperature,
relativeHumidity = 20.0,
relativeHumidity = 20,
precipitation = forecastPrecipitation,
cloudCover = 3.0,
sealevelPressure = 1013.25,
@ -139,7 +142,7 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ
current = WeatherData(
time = timeAtFullHour,
temperature = 20.0,
relativeHumidity = 20.0,
relativeHumidity = 20,
precipitation = 0.0,
cloudCover = 3.0,
sealevelPressure = 1013.25,
@ -311,11 +314,11 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ
arrowBitmap = baseBitmap,
current = interpretation,
windBearing = data.current.windDirection.roundToInt(),
windSpeed = data.current.windSpeed.roundToInt(),
windGusts = data.current.windGusts.roundToInt(),
precipitation = data.current.precipitation,
windSpeed = msInUserUnit(data.current.windSpeed, settingsAndProfile.isImperial).roundToInt(),
windGusts = msInUserUnit(data.current.windGusts, settingsAndProfile.isImperial).roundToInt(),
precipitation = millimetersInUserUnit(data.current.precipitation, settingsAndProfile.isImperial),
precipitationProbability = null,
temperature = data.current.temperature.roundToInt(),
temperature = celciusInUserUnit(data.current.temperature, settingsAndProfile.isImperialTemperature).roundToInt(),
temperatureUnit = if (settingsAndProfile.isImperialTemperature) TemperatureUnit.FAHRENHEIT else TemperatureUnit.CELSIUS,
timeLabel = formattedTime,
dateLabel = if (hasNewDate) formattedDate else null,
@ -337,11 +340,11 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ
arrowBitmap = baseBitmap,
current = interpretation,
windBearing = weatherData?.windDirection?.roundToInt() ?: 0,
windSpeed = weatherData?.windSpeed?.roundToInt() ?: 0,
windGusts = weatherData?.windGusts?.roundToInt() ?: 0,
precipitation = weatherData?.precipitation ?: 0.0,
windSpeed = msInUserUnit(weatherData?.windSpeed ?: 0.0, settingsAndProfile.isImperial).roundToInt(),
windGusts = msInUserUnit(weatherData?.windGusts ?: 0.0, settingsAndProfile.isImperial).roundToInt(),
precipitation = millimetersInUserUnit(weatherData?.precipitation ?: 0.0, settingsAndProfile.isImperial),
precipitationProbability = weatherData?.precipitationProbability?.toInt(),
temperature = weatherData?.temperature?.roundToInt() ?: 0,
temperature = celciusInUserUnit(weatherData?.temperature ?: 0.0, settingsAndProfile.isImperialTemperature).roundToInt(),
temperatureUnit = if (settingsAndProfile.isImperialTemperature) TemperatureUnit.FAHRENHEIT else TemperatureUnit.CELSIUS,
timeLabel = formattedTime,
dateLabel = if (hasNewDate) formattedDate else null,

View File

@ -44,7 +44,6 @@ fun GraphicalForecast(
provider = ImageProvider(getWeatherIcon(current, isNight)),
contentDescription = "Current weather information",
contentScale = ContentScale.Fit,
colorFilter = ColorFilter.tint(ColorProvider(Color.Black, Color.White))
)
}

View File

@ -9,12 +9,14 @@ import androidx.glance.appwidget.GlanceRemoteViews
import de.timklge.karooheadwind.HeadingResponse
import de.timklge.karooheadwind.HeadwindSettings
import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.WindDirectionIndicatorSetting
import de.timklge.karooheadwind.getRelativeHeadingFlow
import de.timklge.karooheadwind.streamCurrentWeatherData
import de.timklge.karooheadwind.streamDatatypeIsVisible
import de.timklge.karooheadwind.streamSettings
import de.timklge.karooheadwind.streamUserProfile
import de.timklge.karooheadwind.throttle
import de.timklge.karooheadwind.util.msInUserUnit
import de.timklge.karooheadwind.weatherprovider.WeatherData
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.extension.DataTypeImpl
import io.hammerhead.karooext.internal.Emitter
@ -24,6 +26,7 @@ import io.hammerhead.karooext.models.DataType
import io.hammerhead.karooext.models.HardwareType
import io.hammerhead.karooext.models.StreamState
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
@ -36,6 +39,7 @@ import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlin.math.cos
import kotlin.math.roundToInt
@OptIn(ExperimentalGlanceRemoteViewsApi::class)
@ -63,7 +67,7 @@ class HeadwindDirectionDataType(
val value = (streamData.headingResponse as? HeadingResponse.Value)?.diff
var returnValue = 0.0
if (value == null || streamData.absoluteWindDirection == null || streamData.settings == null || streamData.windSpeed == null){
if (value == null || streamData.absoluteWindDirection == null || streamData.windSpeed == null){
var errorCode = 1.0
var headingResponse = streamData.headingResponse
@ -71,7 +75,7 @@ class HeadwindDirectionDataType(
headingResponse = HeadingResponse.NoWeatherData
}
if (streamData.settings?.welcomeDialogAccepted == false){
if (streamData.settings.welcomeDialogAccepted == false){
errorCode = ERROR_APP_NOT_SET_UP.toDouble()
} else if (headingResponse is HeadingResponse.NoGps){
errorCode = ERROR_NO_GPS.toDouble()
@ -81,10 +85,7 @@ class HeadwindDirectionDataType(
returnValue = errorCode
} else {
var windDirection = when (streamData.settings.windDirectionIndicatorSetting){
WindDirectionIndicatorSetting.HEADWIND_DIRECTION -> value
WindDirectionIndicatorSetting.WIND_DIRECTION -> streamData.absoluteWindDirection + 180
}
var windDirection = value
if (windDirection < 0) windDirection += 360
@ -106,7 +107,12 @@ class HeadwindDirectionDataType(
}
}
data class DirectionAndSpeed(val bearing: Double, val speed: Double?, val isVisible: Boolean)
data class DirectionAndSpeed(
val bearing: Double,
val speed: Double?,
val isVisible: Boolean,
val isImperial: Boolean
)
private fun previewFlow(): Flow<DirectionAndSpeed> {
return flow {
@ -114,7 +120,12 @@ class HeadwindDirectionDataType(
val bearing = (0..360).random().toDouble()
val windSpeed = (0..20).random()
emit(DirectionAndSpeed(bearing, windSpeed.toDouble(), true))
emit(DirectionAndSpeed(
bearing,
windSpeed.toDouble(),
true,
true
))
delay(2_000)
}
@ -141,11 +152,11 @@ class HeadwindDirectionDataType(
val directionFlow = streamValues()
val speedFlow = flow {
emit(0.0)
emitAll(UserWindSpeedDataType.streamValues(context, karooSystem))
emitAll(streamValues(context, karooSystem))
}
combine(directionFlow, speedFlow, karooSystem.streamDatatypeIsVisible(dataTypeId)) { direction, speed, isVisible ->
DirectionAndSpeed(direction, speed, isVisible)
combine(directionFlow, speedFlow, karooSystem.streamDatatypeIsVisible(dataTypeId), karooSystem.streamUserProfile()) { direction, speed, isVisible, profile ->
DirectionAndSpeed(direction, speed, isVisible, profile.preferredUnit.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL)
}
}
@ -162,14 +173,15 @@ class HeadwindDirectionDataType(
}
val windDirection = streamData.bearing
val windSpeed = streamData.speed
val windSpeed = streamData.speed ?: 0.0
val windSpeedUserUnit = msInUserUnit(windSpeed, streamData.isImperial)
val result = glance.compose(context, DpSize.Unspecified) {
HeadwindDirection(
baseBitmap,
windDirection.roundToInt(),
config.textSize,
windSpeed?.toInt()?.toString() ?: "",
windSpeedUserUnit.roundToInt().toString(),
preview = config.preview,
wideMode = false
)
@ -189,6 +201,26 @@ class HeadwindDirectionDataType(
const val ERROR_NO_GPS = -1
const val ERROR_NO_WEATHER_DATA = -2
const val ERROR_APP_NOT_SET_UP = -3
fun streamValues(context: Context, karooSystem: KarooSystemService): Flow<Double> = flow {
data class StreamData(
val headingResponse: HeadingResponse,
val weatherResponse: WeatherData?,
val settings: HeadwindSettings
)
combine(karooSystem.getRelativeHeadingFlow(context), context.streamCurrentWeatherData(karooSystem), context.streamSettings(karooSystem)) { headingResponse, weatherResponse, settings ->
StreamData(headingResponse, weatherResponse, settings)
}.filter { it.weatherResponse != null }
.collect { streamData ->
val windSpeed = streamData.weatherResponse?.windSpeed ?: 0.0
val windDirection = (streamData.headingResponse as? HeadingResponse.Value)?.diff ?: 0.0
val headwindSpeed = cos((windDirection + 180) * Math.PI / 180.0) * windSpeed
emit(headwindSpeed)
}
}
}
}

View File

@ -11,9 +11,12 @@ import de.timklge.karooheadwind.throttle
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.StreamState
import io.hammerhead.karooext.models.UpdateGraphicConfig
import io.hammerhead.karooext.models.ViewConfig
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine
@ -49,5 +52,9 @@ class HeadwindSpeedDataType(
job.cancel()
}
}
override fun startView(context: Context, config: ViewConfig, emitter: ViewEmitter) {
emitter.onNext(UpdateGraphicConfig(formatDataTypeId = DataType.Type.SPEED))
}
}

View File

@ -0,0 +1,297 @@
package de.timklge.karooheadwind.datatypes
import android.content.Context
import android.util.Log
import androidx.compose.ui.unit.DpSize
import androidx.glance.GlanceModifier
import androidx.glance.Image
import androidx.glance.ImageProvider
import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi
import androidx.glance.appwidget.GlanceRemoteViews
import androidx.glance.layout.Box
import androidx.glance.layout.fillMaxSize
import de.timklge.karooheadwind.HeadingResponse
import de.timklge.karooheadwind.HeadwindSettings
import de.timklge.karooheadwind.HeadwindWidgetSettings
import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.UpcomingRoute
import de.timklge.karooheadwind.WeatherDataProvider
import de.timklge.karooheadwind.getHeadingFlow
import de.timklge.karooheadwind.screens.LineGraphBuilder
import de.timklge.karooheadwind.streamCurrentForecastWeatherData
import de.timklge.karooheadwind.streamDatatypeIsVisible
import de.timklge.karooheadwind.streamSettings
import de.timklge.karooheadwind.streamUpcomingRoute
import de.timklge.karooheadwind.streamUserProfile
import de.timklge.karooheadwind.streamWidgetSettings
import de.timklge.karooheadwind.throttle
import de.timklge.karooheadwind.weatherprovider.WeatherData
import de.timklge.karooheadwind.weatherprovider.WeatherDataForLocation
import de.timklge.karooheadwind.weatherprovider.WeatherDataResponse
import de.timklge.karooheadwind.weatherprovider.WeatherInterpretation
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.distinctUntilChanged
import kotlinx.coroutines.flow.filter
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.abs
import kotlin.math.ceil
import kotlin.math.floor
abstract class LineGraphForecastDataType(private val karooSystem: KarooSystemService, typeId: String) : DataTypeImpl("karoo-headwind", typeId) {
@OptIn(ExperimentalGlanceRemoteViewsApi::class)
private val glance = GlanceRemoteViews()
companion object {
val timeFormatter = DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneId.of("UTC"))
}
data class StreamData(val data: WeatherDataResponse?, val settings: SettingsAndProfile,
val widgetSettings: HeadwindWidgetSettings? = null,
val headingResponse: HeadingResponse? = null, val upcomingRoute: UpcomingRoute? = null, val isVisible: Boolean)
data class SettingsAndProfile(val settings: HeadwindSettings, val isImperial: Boolean, val isImperialTemperature: Boolean)
data class LineData(val time: Instant? = null, val distance: Float? = null, val weatherData: WeatherData)
abstract fun getLineData(
lineData: List<LineData>,
isImperial: Boolean,
upcomingRoute: UpcomingRoute?
): Set<LineGraphBuilder.Line>
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 weatherData = (0..<12).map {
val forecastTime = timeAtFullHour + it * 60 * 60
val forecastTemperature = 20.0 + (-20..20).random()
val forecastPrecipitation = 0.0 + (0..10).random()
val forecastPrecipitationProbability = (0..100).random()
val forecastWeatherCode = WeatherInterpretation.getKnownWeatherCodes().random()
val forecastWindSpeed = 0.0 + (0..10).random()
val forecastWindDirection = 0.0 + (0..360).random()
val forecastWindGusts = 0.0 + (0..10).random()
WeatherData(
time = forecastTime,
temperature = forecastTemperature,
relativeHumidity = 20,
precipitation = forecastPrecipitation,
cloudCover = 3.0,
sealevelPressure = 1013.25,
surfacePressure = 1013.25,
precipitationProbability = forecastPrecipitationProbability.toDouble(),
windSpeed = forecastWindSpeed,
windDirection = forecastWindDirection,
windGusts = forecastWindGusts,
weatherCode = forecastWeatherCode,
isForecast = true,
isNight = it < 2
)
}
val distancePerHour =
settingsAndProfile?.settings?.getForecastMetersPerHour(settingsAndProfile.isImperial)
?.toDouble() ?: 0.0
WeatherDataForLocation(
current = WeatherData(
time = timeAtFullHour,
temperature = 20.0,
relativeHumidity = 20,
precipitation = 0.0,
cloudCover = 3.0,
sealevelPressure = 1013.25,
surfacePressure = 1013.25,
windSpeed = 5.0,
windDirection = 180.0,
windGusts = 10.0,
weatherCode = WeatherInterpretation.getKnownWeatherCodes().random(),
isForecast = false,
isNight = false
),
coords = GpsCoordinates(0.0, 0.0, distanceAlongRoute = index * distancePerHour),
timezone = "UTC",
elevation = null,
forecasts = weatherData
)
}
emit(
StreamData(
WeatherDataResponse(provider = WeatherDataProvider.OPEN_METEO, data = data),
SettingsAndProfile(
HeadwindSettings(),
settingsAndProfile?.isImperial == true,
settingsAndProfile?.isImperialTemperature == true
),
isVisible = 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 settingsAndProfileStream = context.streamSettings(karooSystem).combine(karooSystem.streamUserProfile()) { settings, userProfile ->
SettingsAndProfile(settings = settings, isImperial = userProfile.preferredUnit.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL,
isImperialTemperature = userProfile.preferredUnit.temperature == UserProfile.PreferredUnit.UnitType.IMPERIAL)
}
val dataFlow = if (config.preview){
previewFlow(settingsAndProfileStream)
} else {
combine(
context.streamCurrentForecastWeatherData(),
settingsAndProfileStream,
context.streamWidgetSettings(),
karooSystem.getHeadingFlow(context).throttle(3 * 60_000L),
karooSystem.streamUpcomingRoute().distinctUntilChanged { old, new ->
val oldDistance = old?.distanceAlongRoute
val newDistance = new?.distanceAlongRoute
if (oldDistance == null && newDistance == null) return@distinctUntilChanged true
if (oldDistance == null || newDistance == null) return@distinctUntilChanged false
abs(oldDistance - newDistance) < 1_000
},
karooSystem.streamDatatypeIsVisible(dataTypeId)
) { data ->
val weatherData = data[0] as WeatherDataResponse?
val settings = data[1] as SettingsAndProfile
val widgetSettings = data[2] as HeadwindWidgetSettings?
val heading = data[3] as HeadingResponse?
val upcomingRoute = data[4] as UpcomingRoute?
val isVisible = data[5] as Boolean
StreamData(
data = weatherData,
settings = settings,
widgetSettings = widgetSettings,
headingResponse = heading,
upcomingRoute = upcomingRoute,
isVisible = isVisible
)
}
}
val viewJob = CoroutineScope(Dispatchers.IO).launch {
emitter.onNext(ShowCustomStreamState("", null))
dataFlow.filter { it.isVisible }.collect { (allData, settingsAndProfile, widgetSettings, headingResponse, actualUpcomingRoute) ->
val upcomingRoute = if (allData?.provider == WeatherDataProvider.OPEN_METEO) actualUpcomingRoute else null
Log.d(KarooHeadwindExtension.TAG, "Updating weather forecast view")
if (allData?.data.isNullOrEmpty()){
emitter.updateView(
getErrorWidget(
glance,
context,
settingsAndProfile.settings,
headingResponse
).remoteViews)
return@collect
}
val result = glance.compose(context, DpSize.Unspecified) {
val data = buildList {
for(i in 0..<12){
val locationData = if (upcomingRoute != null){
allData?.data?.getOrNull(i)
} else {
allData?.data?.firstOrNull()
}
val data = if (i == 0){
locationData?.current
} else {
locationData?.forecasts?.getOrNull(i)
}
if (data == null) {
Log.w(KarooHeadwindExtension.TAG, "No weather data available for forecast index $i")
continue
}
val time = Instant.ofEpochSecond(data.time)
add(LineData(
time = time,
distance = locationData?.coords?.distanceAlongRoute?.toFloat(),
weatherData = data,
))
}
}
val pointData = getLineData(data, settingsAndProfile.isImperialTemperature, upcomingRoute)
val bitmap = LineGraphBuilder(context).drawLineGraph(config.viewSize.first, config.viewSize.second, config.gridSize.first, config.gridSize.second, pointData) { x ->
val startTime = data.firstOrNull()?.time
val time = startTime?.plus(floor(x).toLong(), ChronoUnit.HOURS)
val timeLabel = timeFormatter.format(time)
val beforeData = data.getOrNull(floor(x).toInt().coerceAtLeast(0))
val afterData = data.getOrNull(ceil(x).toInt().coerceAtMost(data.size - 1))
if (beforeData?.distance != null || afterData?.distance != null) {
val start = beforeData?.distance ?: 0.0f
val end = (afterData?.distance ?: upcomingRoute?.routeLength?.toFloat()) ?: 0.0f
val distance = start + (end - start) * (x - floor(x))
val distanceLabel = if (settingsAndProfile.isImperial) {
"${(distance * 0.000621371).toInt()}mi"
} else {
"${(distance / 1000).toInt()}km"
}
return@drawLineGraph distanceLabel
} else {
timeLabel
}
}
Box(modifier = GlanceModifier.fillMaxSize()){
Image(ImageProvider(bitmap), "Forecast", modifier = GlanceModifier.fillMaxSize())
}
}
emitter.updateView(result.remoteViews)
}
}
emitter.setCancellable {
Log.d(
KarooHeadwindExtension.TAG,
"Stopping headwind weather forecast view with $emitter"
)
configJob.cancel()
viewJob.cancel()
}
}
}

View File

@ -1,11 +1,13 @@
package de.timklge.karooheadwind.datatypes
import android.content.Context
import de.timklge.karooheadwind.util.millimetersInUserUnit
import de.timklge.karooheadwind.weatherprovider.WeatherData
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.UserProfile
class PrecipitationDataType(karooSystemService: KarooSystemService, context: Context) : BaseDataType(karooSystemService, context, "precipitation"){
override fun getValue(data: WeatherData): Double {
return data.precipitation
override fun getValue(data: WeatherData, userProfile: UserProfile): Double {
return millimetersInUserUnit(data.precipitation, userProfile.preferredUnit.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL)
}
}

View File

@ -1,108 +1,45 @@
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.weatherprovider.WeatherInterpretation
import de.timklge.karooheadwind.UpcomingRoute
import de.timklge.karooheadwind.screens.LineGraphBuilder
import io.hammerhead.karooext.KarooSystemService
import kotlin.math.absoluteValue
import kotlin.math.ceil
@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 = precipitation.toString()
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?,
class PrecipitationForecastDataType(karooSystem: KarooSystemService) : LineGraphForecastDataType(karooSystem, "precipitationForecast") {
override fun getLineData(
lineData: List<LineData>,
isImperial: Boolean,
isNight: Boolean,
) {
PrecipitationForecast(
precipitation = ceil(precipitation).toInt(),
precipitationProbability = precipitationProbability,
distance = distance,
timeLabel = timeLabel,
isImperial = isImperial,
upcomingRoute: UpcomingRoute?
): Set<LineGraphBuilder.Line> {
val precipitationPoints = lineData.map { data ->
if (isImperial) { // Convert mm to inches
data.weatherData.precipitation * 0.0393701 // Convert mm to inches
} else {
data.weatherData.precipitation
}
}
val precipitationPropagation = lineData.map { data ->
data.weatherData.precipitationProbability ?: 0.0
}
return setOf(
LineGraphBuilder.Line(
dataPoints = precipitationPoints.mapIndexed { index, value ->
LineGraphBuilder.DataPoint(index.toFloat(), value.toFloat())
},
color = android.graphics.Color.BLUE,
label = if (!isImperial) "mm" else "in",
),
LineGraphBuilder.Line(
dataPoints = precipitationPropagation.mapIndexed { index, value ->
LineGraphBuilder.DataPoint(index.toFloat(), value.toFloat())
},
color = android.graphics.Color.CYAN,
label = "%",
yAxis = LineGraphBuilder.YAxis.RIGHT
)
)
}
}

View File

@ -129,29 +129,8 @@ class RelativeGradeDataType(private val karooSystemService: KarooSystemService,
val refreshRate = karooSystemService.getRefreshRateInMilliseconds(context)
val windSpeedFlow = combine(context.streamSettings(karooSystemService), karooSystemService.streamUserProfile(), context.streamCurrentWeatherData(karooSystemService).filterNotNull()) { settings, profile, weatherData ->
val isOpenMeteo = settings.weatherProvider == WeatherDataProvider.OPEN_METEO
val profileIsImperial = profile.preferredUnit.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL
if (isOpenMeteo) {
if (profileIsImperial) { // OpenMeteo returns wind speed in mph
val windSpeedInMilesPerHour = weatherData.windSpeed
windSpeedInMilesPerHour * 0.44704
} else { // Wind speed reported by openmeteo is in km/h
val windSpeedInKmh = weatherData.windSpeed
windSpeedInKmh * 0.277778
}
} else {
if (profileIsImperial) { // OpenWeatherMap returns wind speed in mph
val windSpeedInMilesPerHour = weatherData.windSpeed
windSpeedInMilesPerHour * 0.44704
} else { // Wind speed reported by openweathermap is in m/s
weatherData.windSpeed
}
}
val windSpeedFlow = context.streamCurrentWeatherData(karooSystemService).filterNotNull().map { weatherData ->
weatherData.windSpeed
}
data class StreamValues(

View File

@ -3,9 +3,10 @@ package de.timklge.karooheadwind.datatypes
import android.content.Context
import de.timklge.karooheadwind.weatherprovider.WeatherData
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.UserProfile
class RelativeHumidityDataType(karooSystemService: KarooSystemService, context: Context) : BaseDataType(karooSystemService, context, "relativeHumidity"){
override fun getValue(data: WeatherData): Double? {
return data.relativeHumidity
override fun getValue(data: WeatherData, userProfile: UserProfile): Double? {
return data.relativeHumidity.toDouble()
}
}

View File

@ -3,9 +3,10 @@ package de.timklge.karooheadwind.datatypes
import android.content.Context
import de.timklge.karooheadwind.weatherprovider.WeatherData
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.UserProfile
class SealevelPressureDataType(karooSystemService: KarooSystemService, context: Context) : BaseDataType(karooSystemService, context, "sealevelPressure"){
override fun getValue(data: WeatherData): Double? {
override fun getValue(data: WeatherData, userProfile: UserProfile): Double? {
return data.sealevelPressure
}
}

View File

@ -3,9 +3,10 @@ package de.timklge.karooheadwind.datatypes
import android.content.Context
import de.timklge.karooheadwind.weatherprovider.WeatherData
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.UserProfile
class SurfacePressureDataType(karooSystemService: KarooSystemService, context: Context) : BaseDataType(karooSystemService, context, "surfacePressure"){
override fun getValue(data: WeatherData): Double? {
override fun getValue(data: WeatherData, userProfile: UserProfile): Double? {
return data.surfacePressure
}
}

View File

@ -15,8 +15,6 @@ import de.timklge.karooheadwind.HeadingResponse
import de.timklge.karooheadwind.HeadwindSettings
import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.R
import de.timklge.karooheadwind.WindDirectionIndicatorSetting
import de.timklge.karooheadwind.WindDirectionIndicatorTextSetting
import de.timklge.karooheadwind.datatypes.TailwindDataType.StreamData
import de.timklge.karooheadwind.getRelativeHeadingFlow
import de.timklge.karooheadwind.streamCurrentWeatherData
@ -25,6 +23,7 @@ import de.timklge.karooheadwind.streamDatatypeIsVisible
import de.timklge.karooheadwind.streamSettings
import de.timklge.karooheadwind.streamUserProfile
import de.timklge.karooheadwind.throttle
import de.timklge.karooheadwind.util.msInUserUnit
import de.timklge.karooheadwind.weatherprovider.WeatherData
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.extension.DataTypeImpl
@ -133,11 +132,7 @@ class TailwindAndRideSpeedDataType(
val absoluteWindDirection = weatherData?.windDirection
val windSpeed = weatherData?.windSpeed
val gustSpeed = weatherData?.windGusts
val rideSpeed = if (isImperial){
rideSpeedInMs * 2.23694
} else {
rideSpeedInMs * 3.6
}
val rideSpeed = rideSpeedInMs
StreamData(headingResponse, absoluteWindDirection, windSpeed, settings, rideSpeed = rideSpeed, isImperial = isImperial, gustSpeed = gustSpeed, isVisible = isVisible)
}
@ -164,47 +159,44 @@ class TailwindAndRideSpeedDataType(
}
val windSpeed = streamData.windSpeed
val windDirection = when (streamData.settings.windDirectionIndicatorSetting){
WindDirectionIndicatorSetting.HEADWIND_DIRECTION -> streamData.headingResponse.diff
WindDirectionIndicatorSetting.WIND_DIRECTION -> streamData.absoluteWindDirection + 180
}
val windDirection = streamData.headingResponse.diff
val text = streamData.rideSpeed?.let { String.format(Locale.current.platformLocale, "%.1f", it) } ?: ""
val rideSpeedInUserUnit = msInUserUnit(streamData.rideSpeed ?: 0.0, streamData.isImperial)
val text = String.format(Locale.current.platformLocale, "%.1f", rideSpeedInUserUnit)
val wideMode = config.gridSize.first == 60
val gustSpeedInUserUnit = msInUserUnit(streamData.gustSpeed ?: 0.0, streamData.isImperial)
val gustSpeedAddon = if (wideMode) {
"-${streamData.gustSpeed?.roundToInt() ?: 0}"
"-${gustSpeedInUserUnit.roundToInt()}"
} else {
""
}
val subtextWithSign = when (streamData.settings.windDirectionIndicatorTextSetting) {
WindDirectionIndicatorTextSetting.HEADWIND_SPEED -> {
val headwindSpeed = cos( (windDirection + 180) * Math.PI / 180.0) * windSpeed
headwindSpeed.roundToInt().toString()
val windSpeedUserUnit = msInUserUnit(windSpeed, streamData.isImperial)
val sign = if (headwindSpeed < 0) "+" else {
if (headwindSpeed > 0) "-" else ""
}
"$sign${headwindSpeed.roundToInt().absoluteValue} ${windSpeed.roundToInt()}${gustSpeedAddon}"
val subtextWithSign = let {
val headwindSpeed = cos( (windDirection + 180) * Math.PI / 180.0) * windSpeed
headwindSpeed.roundToInt().toString()
val sign = if (headwindSpeed < 0) "+" else {
if (headwindSpeed > 0) "-" else ""
}
WindDirectionIndicatorTextSetting.WIND_SPEED -> "${windSpeed.roundToInt()}${gustSpeedAddon}"
WindDirectionIndicatorTextSetting.NONE -> ""
val headwindSpeedUserUnit = msInUserUnit(headwindSpeed, streamData.isImperial)
"$sign${headwindSpeedUserUnit.roundToInt().absoluteValue} ${windSpeedUserUnit.roundToInt()}${gustSpeedAddon}"
}
var dayColor = Color(ContextCompat.getColor(context, R.color.black))
var nightColor = Color(ContextCompat.getColor(context, R.color.white))
if (streamData.settings.windDirectionIndicatorSetting == WindDirectionIndicatorSetting.HEADWIND_DIRECTION) {
val headwindSpeed = cos( (windDirection + 180) * Math.PI / 180.0) * windSpeed
val windSpeedInKmh = if (streamData.isImperial == true){
headwindSpeed / 2.23694 * 3.6
} else {
headwindSpeed
}
dayColor = interpolateWindColor(windSpeedInKmh, false, context)
nightColor = interpolateWindColor(windSpeedInKmh, true, context)
}
val headwindSpeed = cos( (windDirection + 180) * Math.PI / 180.0) * windSpeed
val windSpeedInKmh = headwindSpeed * 3.6
dayColor = interpolateWindColor(windSpeedInKmh, false, context)
nightColor = interpolateWindColor(windSpeedInKmh, true, context)
val result = glance.compose(context, DpSize.Unspecified) {
HeadwindDirection(

View File

@ -3,17 +3,13 @@ package de.timklge.karooheadwind.datatypes
import android.content.Context
import android.graphics.BitmapFactory
import android.util.Log
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.DpSize
import androidx.core.content.ContextCompat
import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi
import androidx.glance.appwidget.GlanceRemoteViews
import de.timklge.karooheadwind.HeadingResponse
import de.timklge.karooheadwind.HeadwindSettings
import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.R
import de.timklge.karooheadwind.WindDirectionIndicatorSetting
import de.timklge.karooheadwind.WindDirectionIndicatorTextSetting
import de.timklge.karooheadwind.getRelativeHeadingFlow
import de.timklge.karooheadwind.streamCurrentWeatherData
import de.timklge.karooheadwind.streamDataFlow
@ -21,6 +17,7 @@ import de.timklge.karooheadwind.streamDatatypeIsVisible
import de.timklge.karooheadwind.streamSettings
import de.timklge.karooheadwind.streamUserProfile
import de.timklge.karooheadwind.throttle
import de.timklge.karooheadwind.util.msInUserUnit
import de.timklge.karooheadwind.weatherprovider.WeatherData
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.extension.DataTypeImpl
@ -120,13 +117,8 @@ class TailwindDataType(
val absoluteWindDirection = weatherData?.windDirection
val windSpeed = weatherData?.windSpeed
val gustSpeed = weatherData?.windGusts
val rideSpeed = if (isImperial){
rideSpeedInMs * 2.23694
} else {
rideSpeedInMs * 3.6
}
StreamData(headingResponse, absoluteWindDirection, windSpeed, settings, rideSpeed = rideSpeed, isImperial = isImperial, gustSpeed = gustSpeed, isVisible = isVisible)
StreamData(headingResponse, absoluteWindDirection, windSpeed, settings, rideSpeed = rideSpeedInMs, isImperial = isImperial, gustSpeed = gustSpeed, isVisible = isVisible)
}
}
@ -151,41 +143,29 @@ class TailwindDataType(
}
val windSpeed = streamData.windSpeed
val windDirection = when (streamData.settings.windDirectionIndicatorSetting){
WindDirectionIndicatorSetting.HEADWIND_DIRECTION -> streamData.headingResponse.diff
WindDirectionIndicatorSetting.WIND_DIRECTION -> streamData.absoluteWindDirection + 180
}
val windDirection = streamData.headingResponse.diff
val mainText = when (streamData.settings.windDirectionIndicatorTextSetting) {
WindDirectionIndicatorTextSetting.HEADWIND_SPEED -> {
val headwindSpeed = cos( (windDirection + 180) * Math.PI / 180.0) * windSpeed
headwindSpeed.roundToInt().toString()
val sign = if (headwindSpeed < 0) "+" else {
if (headwindSpeed > 0) "-" else ""
}
"$sign${headwindSpeed.roundToInt().absoluteValue}"
}
WindDirectionIndicatorTextSetting.WIND_SPEED -> windSpeed.roundToInt().toString()
WindDirectionIndicatorTextSetting.NONE -> ""
}
val subtext = "${windSpeed.roundToInt()}-${streamData.gustSpeed?.roundToInt()}"
var dayColor = Color(ContextCompat.getColor(context, R.color.black))
var nightColor = Color(ContextCompat.getColor(context, R.color.white))
if (streamData.settings.windDirectionIndicatorSetting == WindDirectionIndicatorSetting.HEADWIND_DIRECTION) {
val mainText = let {
val headwindSpeed = cos( (windDirection + 180) * Math.PI / 180.0) * windSpeed
val windSpeedInKmh = if (streamData.isImperial){
headwindSpeed / 2.23694 * 3.6
} else {
headwindSpeed
headwindSpeed.roundToInt().toString()
val sign = if (headwindSpeed < 0) "+" else {
if (headwindSpeed > 0) "-" else ""
}
dayColor = interpolateWindColor(windSpeedInKmh, false, context)
nightColor = interpolateWindColor(windSpeedInKmh, true, context)
val headwindSpeedUserUnit = msInUserUnit(headwindSpeed, streamData.isImperial)
"$sign${headwindSpeedUserUnit.roundToInt().absoluteValue}"
}
val windSpeedUserUnit = msInUserUnit(windSpeed, streamData.isImperial)
val gustSpeedUserUnit = msInUserUnit(streamData.gustSpeed ?: 0.0, streamData.isImperial)
val subtext = "${windSpeedUserUnit.roundToInt()}-${gustSpeedUserUnit.roundToInt()}"
val headwindSpeed = cos( (windDirection + 180) * Math.PI / 180.0) * windSpeed
val windSpeedInKmh = headwindSpeed * 3.6
val result = glance.compose(context, DpSize.Unspecified) {
HeadwindDirection(
baseBitmap,
@ -193,8 +173,8 @@ class TailwindDataType(
config.textSize,
mainText,
subtext,
dayColor,
nightColor,
interpolateWindColor(windSpeedInKmh, false, context),
interpolateWindColor(windSpeedInKmh, true, context),
wideMode = config.gridSize.first == 60,
preview = config.preview,
)

View File

@ -3,9 +3,15 @@ package de.timklge.karooheadwind.datatypes
import android.content.Context
import de.timklge.karooheadwind.weatherprovider.WeatherData
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.DataType
import io.hammerhead.karooext.models.UserProfile
class TemperatureDataType(karooSystemService: KarooSystemService, context: Context) : BaseDataType(karooSystemService, context, "temperature"){
override fun getValue(data: WeatherData): Double {
override fun getValue(data: WeatherData, userProfile: UserProfile): Double {
return data.temperature
}
override fun getFormatDataType(): String? {
return DataType.Type.TEMPERATURE
}
}

View File

@ -1,106 +1,33 @@
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.weatherprovider.WeatherInterpretation
import de.timklge.karooheadwind.UpcomingRoute
import de.timklge.karooheadwind.screens.LineGraphBuilder
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?,
class TemperatureForecastDataType(karooSystem: KarooSystemService) : LineGraphForecastDataType(karooSystem, "temperatureForecast") {
override fun getLineData(
lineData: List<LineData>,
isImperial: Boolean,
isNight: Boolean
) {
TemperatureForecast(
temperature = temperature,
temperatureUnit = temperatureUnit,
distance = distance,
timeLabel = timeLabel,
isImperial = isImperial,
upcomingRoute: UpcomingRoute?
): Set<LineGraphBuilder.Line> {
val linePoints = lineData.map { data ->
if (isImperial) {
data.weatherData.temperature * 9 / 5 + 32 // Convert Celsius to Fahrenheit
} else {
data.weatherData.temperature // Keep Celsius
}
}
return setOf(
LineGraphBuilder.Line(
dataPoints = linePoints.mapIndexed { index, value ->
LineGraphBuilder.DataPoint(index.toFloat(), value.toFloat())
},
color = android.graphics.Color.RED,
label = if (!isImperial) "°C" else "°F",
)
)
}
}

View File

@ -1,75 +0,0 @@
package de.timklge.karooheadwind.datatypes
import android.content.Context
import de.timklge.karooheadwind.HeadingResponse
import de.timklge.karooheadwind.HeadwindSettings
import de.timklge.karooheadwind.weatherprovider.WeatherData
import de.timklge.karooheadwind.WindDirectionIndicatorTextSetting
import de.timklge.karooheadwind.getRelativeHeadingFlow
import de.timklge.karooheadwind.streamCurrentWeatherData
import de.timklge.karooheadwind.streamSettings
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.extension.DataTypeImpl
import io.hammerhead.karooext.internal.Emitter
import io.hammerhead.karooext.models.DataPoint
import io.hammerhead.karooext.models.DataType
import io.hammerhead.karooext.models.StreamState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlin.math.cos
class UserWindSpeedDataType(
private val karooSystem: KarooSystemService,
private val context: Context
) : DataTypeImpl("karoo-headwind", "userwindSpeed"){
data class StreamData(val headingResponse: HeadingResponse, val weatherResponse: WeatherData?, val settings: HeadwindSettings)
companion object {
fun streamValues(context: Context, karooSystem: KarooSystemService): Flow<Double> = flow {
karooSystem.getRelativeHeadingFlow(context)
.combine(context.streamCurrentWeatherData(karooSystem)) { value, data -> value to data }
.combine(context.streamSettings(karooSystem)) { (value, data), settings ->
StreamData(value, data, settings)
}
.filter { it.weatherResponse != null }
.collect { streamData ->
val windSpeed = streamData.weatherResponse?.windSpeed ?: 0.0
val windDirection = (streamData.headingResponse as? HeadingResponse.Value)?.diff ?: 0.0
if (streamData.settings.windDirectionIndicatorTextSetting == WindDirectionIndicatorTextSetting.HEADWIND_SPEED){
val headwindSpeed = cos((windDirection + 180) * Math.PI / 180.0) * windSpeed
emit(headwindSpeed)
} else {
emit(windSpeed)
}
}
}
}
override fun startStream(emitter: Emitter<StreamState>) {
val job = CoroutineScope(Dispatchers.IO).launch {
streamValues(context, karooSystem)
.collect { value ->
emitter.onNext(
StreamState.Streaming(
DataPoint(
dataTypeId,
mapOf(DataType.Field.SINGLE to value)
)
)
)
}
}
emitter.setCancellable {
job.cancel()
}
}
}

View File

@ -24,6 +24,9 @@ import de.timklge.karooheadwind.streamDatatypeIsVisible
import de.timklge.karooheadwind.streamSettings
import de.timklge.karooheadwind.streamUserProfile
import de.timklge.karooheadwind.throttle
import de.timklge.karooheadwind.util.celciusInUserUnit
import de.timklge.karooheadwind.util.millimetersInUserUnit
import de.timklge.karooheadwind.util.msInUserUnit
import de.timklge.karooheadwind.weatherprovider.WeatherData
import de.timklge.karooheadwind.weatherprovider.WeatherInterpretation
import io.hammerhead.karooext.KarooSystemService
@ -85,7 +88,7 @@ class WeatherDataType(
emit(StreamData(
WeatherData(
Instant.now().epochSecond, 0.0,
20.0, 50.0, 3.0, 0.0, 1013.25, 980.0, 15.0, 30.0, 30.0,
20, 50.0, 3.0, 0.0, 1013.25, 980.0, 15.0, 30.0, 30.0,
WeatherInterpretation.getKnownWeatherCodes().random(), isForecast = false,
isNight = listOf(true, false).random()
), HeadwindSettings(), isVisible = true))
@ -147,11 +150,11 @@ class WeatherDataType(
baseBitmap,
current = interpretation,
windBearing = data.windDirection.roundToInt(),
windSpeed = data.windSpeed.roundToInt(),
windGusts = data.windGusts.roundToInt(),
precipitation = data.precipitation,
windSpeed = msInUserUnit(data.windSpeed, userProfile?.preferredUnit?.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL).roundToInt(),
windGusts = msInUserUnit(data.windGusts, userProfile?.preferredUnit?.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL).roundToInt(),
precipitation = millimetersInUserUnit(data.precipitation, userProfile?.preferredUnit?.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL),
precipitationProbability = null,
temperature = data.temperature.roundToInt(),
temperature = celciusInUserUnit(data.temperature, userProfile?.preferredUnit?.temperature == UserProfile.PreferredUnit.UnitType.IMPERIAL).roundToInt(),
temperatureUnit = if (userProfile?.preferredUnit?.temperature != UserProfile.PreferredUnit.UnitType.IMPERIAL) TemperatureUnit.CELSIUS else TemperatureUnit.FAHRENHEIT,
timeLabel = formattedTime,
rowAlignment = when (config.alignment){

View File

@ -0,0 +1,164 @@
package de.timklge.karooheadwind.datatypes
import android.content.Context
import android.graphics.BitmapFactory
import android.util.Log
import androidx.compose.ui.unit.DpSize
import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi
import androidx.glance.appwidget.GlanceRemoteViews
import de.timklge.karooheadwind.HeadingResponse
import de.timklge.karooheadwind.HeadwindSettings
import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.R
import de.timklge.karooheadwind.getRelativeHeadingFlow
import de.timklge.karooheadwind.streamCurrentWeatherData
import de.timklge.karooheadwind.streamDatatypeIsVisible
import de.timklge.karooheadwind.streamSettings
import de.timklge.karooheadwind.streamUserProfile
import de.timklge.karooheadwind.throttle
import de.timklge.karooheadwind.util.msInUserUnit
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.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlin.math.absoluteValue
import kotlin.math.cos
import kotlin.math.roundToInt
class WindDirectionAndSpeedDataType(
private val karooSystem: KarooSystemService,
private val applicationContext: Context
) : DataTypeImpl("karoo-headwind", "windDirectionAndSpeed") {
@OptIn(ExperimentalGlanceRemoteViewsApi::class)
private val glance = GlanceRemoteViews()
data class StreamData(val headingResponse: HeadingResponse,
val absoluteWindDirection: Double?,
val windSpeed: Double?,
val settings: HeadwindSettings,
val gustSpeed: Double?,
val isImperial: Boolean,
val isVisible: Boolean)
private fun previewFlow(profileFlow: Flow<UserProfile>): Flow<StreamData> {
return flow {
val profile = profileFlow.first()
while (true) {
val bearing = (0..360).random().toDouble()
val windSpeed = (0..20).random()
val gustSpeed = windSpeed * ((10..40).random().toDouble() / 10)
val isImperial = profile.preferredUnit.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL
emit(StreamData(HeadingResponse.Value(bearing), bearing, windSpeed.toDouble(), HeadwindSettings(), gustSpeed = gustSpeed, isImperial = isImperial, isVisible = true))
delay(2_000)
}
}
}
@OptIn(ExperimentalGlanceRemoteViewsApi::class)
override fun startView(context: Context, config: ViewConfig, emitter: ViewEmitter) {
Log.d(KarooHeadwindExtension.TAG, "Starting headwind direction view with $emitter")
val baseBitmap = BitmapFactory.decodeResource(
context.resources,
R.drawable.arrow_0
)
val configJob = CoroutineScope(Dispatchers.IO).launch {
emitter.onNext(UpdateGraphicConfig(showHeader = false))
awaitCancellation()
}
val flow = if (config.preview) {
previewFlow(karooSystem.streamUserProfile())
} else {
combine(karooSystem.getRelativeHeadingFlow(context),
context.streamCurrentWeatherData(karooSystem),
context.streamSettings(karooSystem),
karooSystem.streamUserProfile(),
karooSystem.streamDatatypeIsVisible(dataTypeId)
) { headingResponse, weatherData, settings, userProfile, isVisible ->
val isImperial = userProfile.preferredUnit.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL
val absoluteWindDirection = weatherData?.windDirection
val windSpeed = weatherData?.windSpeed
val gustSpeed = weatherData?.windGusts
StreamData(headingResponse, absoluteWindDirection, windSpeed, settings, isImperial = isImperial, gustSpeed = gustSpeed, isVisible = isVisible)
}
}
val viewJob = CoroutineScope(Dispatchers.IO).launch {
emitter.onNext(ShowCustomStreamState("", null))
val refreshRate = karooSystem.getRefreshRateInMilliseconds(context)
flow.filter { it.isVisible }.throttle(refreshRate).collect { streamData ->
Log.d(KarooHeadwindExtension.TAG, "Updating wind speed and direction view")
val value = (streamData.headingResponse as? HeadingResponse.Value)?.diff
if (value == null || streamData.absoluteWindDirection == null || streamData.windSpeed == null){
var headingResponse = streamData.headingResponse
if (headingResponse is HeadingResponse.Value && (streamData.absoluteWindDirection == null || streamData.windSpeed == null)){
headingResponse = HeadingResponse.NoWeatherData
}
emitter.updateView(getErrorWidget(glance, context, streamData.settings, headingResponse).remoteViews)
return@collect
}
val windSpeed = streamData.windSpeed
val windDirection = streamData.headingResponse.diff
val windSpeedUserUnit = msInUserUnit(windSpeed, streamData.isImperial)
val gustSpeedUserUnit = msInUserUnit(streamData.gustSpeed ?: 0.0, streamData.isImperial)
val mainText = let {
"${windSpeedUserUnit.roundToInt().absoluteValue}"
}
val subtext = "Max ${gustSpeedUserUnit.roundToInt()}"
val headwindSpeed = cos( (windDirection + 180) * Math.PI / 180.0) * windSpeed
val windSpeedInKmh = headwindSpeed * 3.6
val result = glance.compose(context, DpSize.Unspecified) {
HeadwindDirection(
baseBitmap,
windDirection.roundToInt(),
config.textSize,
mainText,
subtext,
interpolateWindColor(windSpeedInKmh, false, context),
interpolateWindColor(windSpeedInKmh, true, context),
wideMode = config.gridSize.first == 60,
preview = config.preview,
)
}
emitter.updateView(result.remoteViews)
}
}
emitter.setCancellable {
Log.d(KarooHeadwindExtension.TAG, "Stopping wind speed and direction view with $emitter")
configJob.cancel()
viewJob.cancel()
}
}
}

View File

@ -26,6 +26,7 @@ import io.hammerhead.karooext.internal.ViewEmitter
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
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -51,7 +52,7 @@ class WindDirectionDataType(val karooSystem: KarooSystemService, context: Contex
)
}
override fun getValue(data: WeatherData): Double {
override fun getValue(data: WeatherData, userProfile: UserProfile): Double {
return data.windDirection
}

View File

@ -1,119 +1,116 @@
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.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.weatherprovider.WeatherInterpretation
import android.util.Log
import com.mapbox.turf.TurfConstants
import com.mapbox.turf.TurfMeasurement
import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.UpcomingRoute
import de.timklge.karooheadwind.lerpWeather
import de.timklge.karooheadwind.screens.LineGraphBuilder
import de.timklge.karooheadwind.util.signedAngleDifference
import io.hammerhead.karooext.KarooSystemService
import kotlin.math.absoluteValue
import kotlin.math.ceil
import kotlin.math.cos
import kotlin.math.floor
@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)
)
)
}
}
fun remap(value: Float, fromLow: Float, fromHigh: Float, toLow: Float, toHigh: Float): Float {
if (fromHigh == fromLow) return toLow
return toLow + (value - fromLow) / (fromHigh - fromLow) * (toHigh - toLow)
}
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?,
class WindForecastDataType(karooSystem: KarooSystemService) : LineGraphForecastDataType(karooSystem, "windForecast") {
override fun getLineData(
lineData: List<LineData>,
isImperial: Boolean,
isNight: Boolean
) {
WindForecast(
arrowBitmap = arrowBitmap,
windBearing = windBearing,
windSpeed = windSpeed,
gustSpeed = windGusts,
distance = distance,
timeLabel = timeLabel,
isImperial = isImperial,
)
upcomingRoute: UpcomingRoute?
): Set<LineGraphBuilder.Line> {
val windPoints = lineData.map { data ->
if (isImperial) { // Convert m/s to mph
data.weatherData.windSpeed * 2.23694 // Convert m/s to mph
} else { // Convert m/s to km/h
data.weatherData.windSpeed * 3.6 // Convert m/s to km/h
}
}
val gustPoints = lineData.map { data ->
if (isImperial) { // Convert m/s to mph
data.weatherData.windGusts * 2.23694 // Convert m/s to mph
} else { // Convert m/s to km/h
data.weatherData.windGusts * 3.6 // Convert m/s to km/h
}
}
val headwindPoints = try {
if (upcomingRoute != null){
(0..<HEADWIND_SAMPLE_COUNT).mapNotNull { i ->
val t = i / HEADWIND_SAMPLE_COUNT.toDouble()
val beforeLineData = lineData.getOrNull(floor(lineData.size * t).toInt()) ?: lineData.firstOrNull()
val afterLineData = lineData.getOrNull(ceil(lineData.size * t).toInt()) ?: lineData.lastOrNull()
if (beforeLineData?.weatherData == null || afterLineData?.weatherData == null || beforeLineData.distance == null
|| afterLineData.distance == null) return@mapNotNull null
val dt = remap(t.toFloat(),
floor(lineData.size * t).toFloat() / lineData.size,
ceil(lineData.size * t).toFloat() / lineData.size,
0.0f, 1.0f
).toDouble()
val interpolatedWeather = lerpWeather(beforeLineData.weatherData, afterLineData.weatherData, dt)
val beforeDistanceAlongRoute = beforeLineData.distance
val afterDistanceAlongRoute = afterLineData.distance
val distanceAlongRoute = (beforeDistanceAlongRoute + (afterDistanceAlongRoute - beforeDistanceAlongRoute) * dt).coerceIn(0.0, upcomingRoute.routeLength)
val coordsAlongRoute = TurfMeasurement.along(upcomingRoute.routePolyline, distanceAlongRoute, TurfConstants.UNIT_METERS)
val nextCoordsAlongRoute = TurfMeasurement.along(upcomingRoute.routePolyline, distanceAlongRoute + 5, TurfConstants.UNIT_METERS)
val bearingAlongRoute = TurfMeasurement.bearing(coordsAlongRoute, nextCoordsAlongRoute)
val windBearing = interpolatedWeather.windDirection + 180
val diff = signedAngleDifference(bearingAlongRoute, windBearing)
val headwindSpeed = cos( (diff + 180) * Math.PI / 180.0) * interpolatedWeather.windSpeed
val headwindSpeedInUserUnit = if (isImperial) {
headwindSpeed * 2.23694 // Convert m/s to mph
} else {
headwindSpeed * 3.6 // Convert m/s to km/h
}
LineGraphBuilder.DataPoint(i.toFloat() * (windPoints.size / HEADWIND_SAMPLE_COUNT.toFloat()), headwindSpeedInUserUnit.toFloat())
}
} else {
emptyList()
}
} catch(e: Exception) {
Log.e(KarooHeadwindExtension.TAG, "Error calculating headwind points", e)
emptyList()
}
return buildSet {
add(LineGraphBuilder.Line(
dataPoints = windPoints.mapIndexed { index, value ->
LineGraphBuilder.DataPoint(index.toFloat(), value.toFloat())
},
color = android.graphics.Color.GRAY,
label = "Wind" // if (!isImperial) "Wind km/h" else "Wind mph",
))
add(LineGraphBuilder.Line(
dataPoints = gustPoints.mapIndexed { index, value ->
LineGraphBuilder.DataPoint(index.toFloat(), value.toFloat())
},
color = android.graphics.Color.DKGRAY,
label = "Gust" // if (!isImperial) "Gust km/h" else "Gust mph",
))
if (headwindPoints.isNotEmpty()) {
add(LineGraphBuilder.Line(
dataPoints = headwindPoints,
color = android.graphics.Color.MAGENTA,
label = "Headwind", // if (!isImperial) "Headwind km/h" else "Headwind mph",
drawCircles = false
))
}
}
}
companion object {
const val HEADWIND_SAMPLE_COUNT = 30
}
}

View File

@ -3,9 +3,10 @@ package de.timklge.karooheadwind.datatypes
import android.content.Context
import de.timklge.karooheadwind.weatherprovider.WeatherData
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.UserProfile
class WindGustsDataType(karooSystemService: KarooSystemService, context: Context) : BaseDataType(karooSystemService, context, "windGusts"){
override fun getValue(data: WeatherData): Double {
override fun getValue(data: WeatherData, userProfile: UserProfile): Double {
return data.windGusts
}
}

View File

@ -3,9 +3,10 @@ package de.timklge.karooheadwind.datatypes
import android.content.Context
import de.timklge.karooheadwind.weatherprovider.WeatherData
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.UserProfile
class WindSpeedDataType(karooSystemService: KarooSystemService, context: Context) : BaseDataType(karooSystemService, context, "windSpeed"){
override fun getValue(data: WeatherData): Double {
override fun getValue(data: WeatherData, userProfile: UserProfile): Double {
return data.windSpeed
}
}

View File

@ -0,0 +1,597 @@
package de.timklge.karooheadwind.screens
import android.content.Context
import android.content.res.Configuration
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Paint.Align
import android.graphics.Path
import androidx.annotation.ColorInt
import kotlin.math.abs
import androidx.core.graphics.createBitmap
class LineGraphBuilder(val context: Context) {
enum class YAxis {
LEFT, RIGHT
}
data class DataPoint(val x: Float, val y: Float)
data class Line(
val dataPoints: List<DataPoint>,
@ColorInt val color: Int,
val label: String? = null,
val yAxis: YAxis = YAxis.LEFT, // Default to left Y-axis
val drawCircles: Boolean = true // Default to true
)
private fun isNightMode(): Boolean {
val nightModeFlags = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
return nightModeFlags == Configuration.UI_MODE_NIGHT_YES
}
fun drawLineGraph(
width: Int,
height: Int,
gridWidth: Int,
gridHeight: Int,
lines: Set<Line>,
labelProvider: ((Float) -> String)
): Bitmap {
val isNightMode = isNightMode()
val bitmap = createBitmap(width, height)
val canvas = Canvas(bitmap)
val backgroundColor = if (isNightMode) Color.BLACK else Color.WHITE
val primaryTextColor = if (isNightMode) Color.WHITE else Color.BLACK
val secondaryTextColor = if (isNightMode) Color.LTGRAY else Color.DKGRAY // For axes
canvas.drawColor(backgroundColor)
if (lines.isEmpty() || lines.all { it.dataPoints.isEmpty() }) {
val emptyPaint = Paint().apply {
color = primaryTextColor
textSize = 24f // Increased from 20f
textAlign = Align.CENTER
isAntiAlias = true
}
canvas.drawText("No data to display", width / 2f, height / 2f, emptyPaint)
return bitmap
}
val marginTop = 10f
val marginBottom = 40f // Increased from 30f
var marginRight = 20f // Increased from 5f // Made var
var dataMinX = Float.MAX_VALUE
var dataMaxX = Float.MIN_VALUE
var dataMinYLeft = Float.MAX_VALUE
var dataMaxYLeft = Float.MIN_VALUE
var dataMinYRight = Float.MAX_VALUE
var dataMaxYRight = Float.MIN_VALUE
var hasLeftYAxisData = false
var hasRightYAxisData = false
var hasData = false
lines.forEach { line ->
if (line.dataPoints.isNotEmpty()) {
hasData = true
if (line.yAxis == YAxis.LEFT) {
hasLeftYAxisData = true
line.dataPoints.forEach { point ->
dataMinX = minOf(dataMinX, point.x)
dataMaxX = maxOf(dataMaxX, point.x)
dataMinYLeft = minOf(dataMinYLeft, point.y)
dataMaxYLeft = maxOf(dataMaxYLeft, point.y)
}
} else { // YAxis.RIGHT
hasRightYAxisData = true
line.dataPoints.forEach { point ->
dataMinX = minOf(dataMinX, point.x)
dataMaxX = maxOf(dataMaxX, point.x)
dataMinYRight = minOf(dataMinYRight, point.y)
dataMaxYRight = maxOf(dataMaxYRight, point.y)
}
}
}
}
if (!hasData) {
val emptyPaint = Paint().apply {
color = primaryTextColor
textSize = 48f // Increased from 40f
textAlign = Align.CENTER
isAntiAlias = true
}
canvas.drawText("No data points", width / 2f, height / 2f, emptyPaint)
return bitmap
}
// Dynamically calculate marginLeft based on Y-axis label widths
val yAxisLabelPaint = Paint().apply {
textSize = 32f
isAntiAlias = true
}
var maxLabelWidthLeft = 0f
if (hasLeftYAxisData) {
val yLabelStringsLeft = mutableListOf<String>()
val numYTicksForCalc = 2 // As used later for drawing Y-axis ticks
// Determine Y-axis label strings (mirrors logic from where labels are drawn)
if (abs(dataMaxYLeft - dataMinYLeft) < 0.0001f) {
yLabelStringsLeft.add(
String.format(
java.util.Locale.getDefault(),
"%.0f",
dataMinYLeft
)
)
} else {
for (i in 0..numYTicksForCalc) {
val value =
dataMinYLeft + ((dataMaxYLeft - dataMinYLeft) / numYTicksForCalc) * i
yLabelStringsLeft.add(
String.format(
java.util.Locale.getDefault(),
"%.0f",
value
)
)
}
}
for (labelStr in yLabelStringsLeft) {
maxLabelWidthLeft =
kotlin.math.max(maxLabelWidthLeft, yAxisLabelPaint.measureText(labelStr))
}
}
val yAxisTextRightToAxisGap = 15f // Current gap used: graphLeft - 15f
val canvasEdgePadding = 5f // Desired padding from the canvas edge
val dynamicMarginLeft =
if (hasLeftYAxisData) maxLabelWidthLeft + yAxisTextRightToAxisGap + canvasEdgePadding else canvasEdgePadding
// Dynamically calculate marginRight based on Right Y-axis label widths
var maxLabelWidthRight = 0f
if (hasRightYAxisData) {
val yLabelStringsRight = mutableListOf<String>()
val numYTicksForCalc = 2 // As used later for drawing Y-axis ticks
if (abs(dataMaxYRight - dataMinYRight) < 0.0001f) {
yLabelStringsRight.add(
String.format(
java.util.Locale.getDefault(),
"%.0f",
dataMinYRight
)
)
} else {
for (i in 0..numYTicksForCalc) {
val value =
dataMinYRight + ((dataMaxYRight - dataMinYRight) / numYTicksForCalc) * i
yLabelStringsRight.add(
String.format(
java.util.Locale.getDefault(),
"%.0f",
value
)
)
}
}
for (labelStr in yLabelStringsRight) {
maxLabelWidthRight =
kotlin.math.max(maxLabelWidthRight, yAxisLabelPaint.measureText(labelStr))
}
val dynamicMarginRight =
maxLabelWidthRight + yAxisTextRightToAxisGap + canvasEdgePadding
marginRight = dynamicMarginRight // Update marginRight
}
val graphWidth = width - dynamicMarginLeft - marginRight
val graphHeight = height - marginTop - marginBottom
val graphLeft = dynamicMarginLeft
val graphTop = marginTop
val graphBottom = height - marginBottom
val graphRight = width - marginRight // Define graphRight for clarity
// Legend properties
val legendTextSize = 26f // Increased from 22f
val legendTextColor = primaryTextColor
val legendPadding = 5f
val legendEntryHeight = 30f // Increased from 25f
val legendColorBoxSize = 24f // Increased from 20f
val legendTextMargin = 5f
var effectiveMinX = dataMinX
var effectiveMaxX = dataMaxX
var effectiveMinYLeft = dataMinYLeft
var effectiveMaxYLeft = dataMaxYLeft
var effectiveMinYRight = dataMinYRight
var effectiveMaxYRight = dataMaxYRight
if (dataMinX == dataMaxX) {
effectiveMinX -= 1f
effectiveMaxX += 1f
} else {
val paddingX = (dataMaxX - dataMinX) * 0.05f
if (paddingX > 0.0001f) {
effectiveMinX -= paddingX
effectiveMaxX += paddingX
} else {
effectiveMinX -= 1f
effectiveMaxX += 1f
}
}
// Y-axis Left: Adjust effective range based on new rules
if (hasLeftYAxisData) {
// effectiveMinYLeft is dataMinYLeft, effectiveMaxYLeft is dataMaxYLeft at this point
if (abs(dataMaxYLeft - dataMinYLeft) < 0.0001f) { // All Y_Left values are equal
val commonValue = dataMinYLeft
if (commonValue >= 0f) {
effectiveMinYLeft = 0f
effectiveMaxYLeft =
if (commonValue == 0f) 1f else commonValue + kotlin.math.max(
abs(commonValue * 0.1f),
1f
)
} else { // commonValue < 0f
effectiveMaxYLeft = 0f
effectiveMinYLeft = commonValue - kotlin.math.max(abs(commonValue * 0.1f), 1f)
}
} else { // Y_Left values are not all equal, apply standard 5% padding
val paddingYLeft = (dataMaxYLeft - dataMinYLeft) * 0.05f
if (paddingYLeft > 0.0001f) {
effectiveMinYLeft -= paddingYLeft // equivalent to dataMinYLeft - padding
effectiveMaxYLeft += paddingYLeft // equivalent to dataMaxYLeft + padding
} else {
effectiveMinYLeft -= 1f
effectiveMaxYLeft += 1f
}
}
// Safety check: ensure min < max for left Y-axis
if (effectiveMinYLeft >= effectiveMaxYLeft) {
effectiveMaxYLeft = effectiveMinYLeft + 1f
}
}
// Y-axis Right: Adjust effective range based on new rules
if (hasRightYAxisData) {
// effectiveMinYRight is dataMinYRight, effectiveMaxYRight is dataMaxYRight at this point
if (abs(dataMaxYRight - dataMinYRight) < 0.0001f) { // All Y_Right values are equal
val commonValue = dataMinYRight
if (commonValue >= 0f) {
effectiveMinYRight = 0f
effectiveMaxYRight =
if (commonValue == 0f) 1f else commonValue + kotlin.math.max(
abs(commonValue * 0.1f),
1f
)
} else { // commonValue < 0f
effectiveMaxYRight = 0f
effectiveMinYRight = commonValue - kotlin.math.max(abs(commonValue * 0.1f), 1f)
}
} else { // Y_Right values are not all equal, apply standard 5% padding
val paddingYRight = (dataMaxYRight - dataMinYRight) * 0.05f
if (paddingYRight > 0.0001f) {
effectiveMinYRight -= paddingYRight
effectiveMaxYRight += paddingYRight
} else {
effectiveMinYRight -= 1f
effectiveMaxYRight += 1f
}
}
// Safety check: ensure min < max for right Y-axis
if (effectiveMinYRight >= effectiveMaxYRight) {
effectiveMaxYRight = effectiveMinYRight + 1f
}
}
val rangeX =
if (abs(effectiveMaxX - effectiveMinX) < 0.0001f) 1f else (effectiveMaxX - effectiveMinX)
val rangeYLeft =
if (!hasLeftYAxisData || abs(effectiveMaxYLeft - effectiveMinYLeft) < 0.0001f) 1f else (effectiveMaxYLeft - effectiveMinYLeft)
val rangeYRight =
if (!hasRightYAxisData || abs(effectiveMaxYRight - effectiveMinYRight) < 0.0001f) 1f else (effectiveMaxYRight - effectiveMinYRight)
fun mapX(originalX: Float): Float {
return graphLeft + ((originalX - effectiveMinX) / rangeX) * graphWidth
}
fun mapYLeft(originalY: Float): Float {
return graphBottom - ((originalY - effectiveMinYLeft) / rangeYLeft) * graphHeight
}
fun mapYRight(originalY: Float): Float {
return graphBottom - ((originalY - effectiveMinYRight) / rangeYRight) * graphHeight
}
val axisPaint = Paint().apply {
color = secondaryTextColor
strokeWidth = 3f
isAntiAlias = true
}
canvas.drawLine(
graphLeft,
graphBottom,
graphLeft + graphWidth,
graphBottom,
axisPaint
) // X-axis
if (hasLeftYAxisData) {
canvas.drawLine(graphLeft, graphTop, graphLeft, graphBottom, axisPaint) // Left Y-axis
}
if (hasRightYAxisData) {
canvas.drawLine(
graphRight, // Use graphRight for clarity and consistency
graphTop,
graphRight,
graphBottom,
axisPaint
) // Right Y-axis
}
// Grid line paint
val gridLinePaint = Paint().apply {
color = if (isNightMode) Color.DKGRAY else Color.LTGRAY // Faint color
strokeWidth = 1f
isAntiAlias = true
}
val linePaint = Paint().apply {
strokeWidth = 6f
style = Paint.Style.STROKE
isAntiAlias = true
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
}
val textPaint = Paint().apply {
color = primaryTextColor
textSize = 32f // Increased from 28f
isAntiAlias = true
}
for (line in lines) {
if (line.dataPoints.isEmpty()) continue
linePaint.color = line.color
val path = Path()
val firstPoint = line.dataPoints.first()
val mapY = if (line.yAxis == YAxis.LEFT) ::mapYLeft else ::mapYRight
path.moveTo(mapX(firstPoint.x), mapY(firstPoint.y))
if (line.drawCircles) {
canvas.drawCircle(
mapX(firstPoint.x),
mapY(firstPoint.y),
8f,
linePaint.apply { style = Paint.Style.FILL })
}
linePaint.style = Paint.Style.STROKE
for (i in 1 until line.dataPoints.size) {
val point = line.dataPoints[i]
path.lineTo(mapX(point.x), mapY(point.y))
if (line.drawCircles) {
canvas.drawCircle(
mapX(point.x),
mapY(point.y),
8f,
linePaint.apply { style = Paint.Style.FILL })
}
linePaint.style = Paint.Style.STROKE
}
canvas.drawPath(path, linePaint)
}
// Draw Left Y-axis ticks and labels
if (hasLeftYAxisData) {
textPaint.textAlign = Align.RIGHT
val numYTicks = if (gridWidth > 15) 2 else 1
if (abs(dataMaxYLeft - dataMinYLeft) > 0.0001f) {
for (i in 0..numYTicks) {
val value = dataMinYLeft + ((dataMaxYLeft - dataMinYLeft) / numYTicks) * i
val yPos = mapYLeft(value)
if (yPos >= graphTop - 5f && yPos <= graphBottom + 5f) {
canvas.drawLine(graphLeft - 5f, yPos, graphLeft + 5f, yPos, axisPaint)
// Draw faint horizontal grid line
canvas.drawLine(graphLeft, yPos, graphRight, yPos, gridLinePaint)
canvas.drawText(
String.format(java.util.Locale.getDefault(), "%.0f", value),
graphLeft - 15f,
yPos + (textPaint.textSize / 3),
textPaint
)
}
}
} else {
val yPos = mapYLeft(dataMinYLeft)
canvas.drawLine(graphLeft - 5f, yPos, graphLeft + 5f, yPos, axisPaint)
// Draw faint horizontal grid line
canvas.drawLine(graphLeft, yPos, graphRight, yPos, gridLinePaint)
canvas.drawText(
String.format(java.util.Locale.getDefault(), "%.0f", dataMinYLeft),
graphLeft - 15f,
yPos + (textPaint.textSize / 3),
textPaint
)
}
}
// Draw Right Y-axis ticks and labels
if (hasRightYAxisData) {
textPaint.textAlign = Align.LEFT
val numYTicks = if (gridWidth > 15) 2 else 1
if (abs(dataMaxYRight - dataMinYRight) > 0.0001f) {
for (i in 0..numYTicks) {
val value = dataMinYRight + ((dataMaxYRight - dataMinYRight) / numYTicks) * i
val yPos = mapYRight(value)
if (yPos >= graphTop - 5f && yPos <= graphBottom + 5f) {
canvas.drawLine(
graphRight - 5f,
yPos,
graphRight + 5f,
yPos,
axisPaint
)
// Draw faint horizontal grid line
canvas.drawLine(graphLeft, yPos, graphRight, yPos, gridLinePaint)
canvas.drawText(
String.format(java.util.Locale.getDefault(), "%.0f", value),
graphRight + 15f,
yPos + (textPaint.textSize / 3),
textPaint
)
}
}
} else {
val yPos = mapYRight(dataMinYRight)
canvas.drawLine(
graphRight - 5f,
yPos,
graphRight + 5f,
yPos,
axisPaint
)
// Draw faint horizontal grid line
canvas.drawLine(graphLeft, yPos, graphRight, yPos, gridLinePaint)
canvas.drawText(
String.format(java.util.Locale.getDefault(), "%.0f", dataMinYRight),
graphRight + 15f,
yPos + (textPaint.textSize / 3),
textPaint
)
}
}
// Draw Y zero line (solid, using axisPaint)
// This is drawn after faint grid lines from Y-ticks, so it will be on top.
// It will not be drawn if it coincides with the X-axis (graphBottom), as X-axis is already solid.
val yZeroLinePaint = axisPaint // Use the same paint as other axes for consistency
if (hasLeftYAxisData) {
// If left Y-axis has data and its range includes 0
if (effectiveMinYLeft <= 0f && effectiveMaxYLeft >= 0f) {
val yZeroPos = mapYLeft(0f)
// Draw if the zero position is within graph bounds (inclusive top)
// and not effectively the same as the X-axis (graphBottom).
if (yZeroPos in graphTop..graphBottom && abs(yZeroPos - graphBottom) > 0.1f) {
canvas.drawLine(graphLeft, yZeroPos, graphRight, yZeroPos, yZeroLinePaint)
}
}
} else if (hasRightYAxisData) {
// Else, if no left Y-axis data, but right Y-axis has data and its range includes 0
if (effectiveMinYRight <= 0f && effectiveMaxYRight >= 0f) {
val yZeroPos = mapYRight(0f)
// Draw if the zero position is within graph bounds (inclusive top)
// and not effectively the same as the X-axis (graphBottom).
if (yZeroPos in graphTop..graphBottom && abs(yZeroPos - graphBottom) > 0.1f) {
canvas.drawLine(graphLeft, yZeroPos, graphRight, yZeroPos, yZeroLinePaint)
}
}
}
textPaint.textAlign = Align.CENTER
val numXTicks = if (gridHeight > 15) 3 else 1
if (abs(dataMaxX - dataMinX) > 0.0001f) {
for (i in 0..numXTicks) {
val value = dataMinX + ((dataMaxX - dataMinX) / numXTicks) * i
val xPos = mapX(value)
if (xPos >= graphLeft - 5f && xPos <= graphLeft + graphWidth + 5f) {
canvas.drawLine(xPos, graphBottom - 5f, xPos, graphBottom + 5f, axisPaint)
canvas.drawText(labelProvider(value), xPos, graphBottom + 30f, textPaint)
}
}
} else {
val xPos = mapX(dataMinX)
canvas.drawLine(xPos, graphBottom - 5f, xPos, graphBottom + 5f, axisPaint)
canvas.drawText(labelProvider(dataMinX), xPos, graphBottom + 30f, textPaint)
}
textPaint.textAlign = Align.CENTER
textPaint.color = primaryTextColor // Ensure textPaint color is reset before drawing legend
// Draw Legend
val legendPaint = Paint().apply {
textSize = legendTextSize
color = legendTextColor
isAntiAlias = true
textAlign = Align.LEFT // Important for measuring text width correctly
}
val legendColorPaint = Paint().apply {
style = Paint.Style.FILL
isAntiAlias = true
}
val legendItems = lines.filter { it.label != null }
if (legendItems.isNotEmpty()) {
var maxLegendLabelWidth = 0f
for (item in legendItems) {
maxLegendLabelWidth =
kotlin.math.max(maxLegendLabelWidth, legendPaint.measureText(item.label!!))
}
val legendContentActualLeft =
(width - marginRight - legendPadding - legendColorBoxSize - legendTextMargin - maxLegendLabelWidth)
val legendContentActualRight =
(width - marginRight - legendPadding) // Right edge of the color box
val legendContentActualTop = graphTop + legendPadding // Top edge of the first color box
val legendContentActualBottom =
legendContentActualTop + (legendItems.size - 1) * legendEntryHeight + legendColorBoxSize // Bottom edge of the last color box
val legendBgPaint = Paint().apply {
color = if (isNightMode) {
Color.argb(210, 0, 0, 0)
} else {
Color.argb(210, 255, 255, 255)
}
style = Paint.Style.FILL
isAntiAlias = true
}
canvas.drawRoundRect(
legendContentActualLeft,
legendContentActualTop,
legendContentActualRight,
legendContentActualBottom,
5f,
5f,
legendBgPaint
)
}
var currentLegendY = graphTop + legendPadding
for (line in legendItems) {
// Draw color box
legendColorPaint.color = line.color
canvas.drawRect(
width - marginRight - legendPadding - legendColorBoxSize, // left
currentLegendY, // top
width - marginRight - legendPadding, // right
currentLegendY + legendColorBoxSize, // bottom
legendColorPaint
)
// Draw label text
canvas.drawText(
line.label!!,
width - marginRight - legendPadding - legendColorBoxSize - legendTextMargin - legendPaint.measureText(
line.label
), // x: Align text to the left of the color box
currentLegendY + legendColorBoxSize / 2 + legendTextSize / 3, // y: Vertically center text with color box
legendPaint
)
currentLegendY += legendEntryHeight
}
return bitmap
}
}

View File

@ -42,8 +42,6 @@ import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.RefreshRate
import de.timklge.karooheadwind.RoundLocationSetting
import de.timklge.karooheadwind.WeatherDataProvider
import de.timklge.karooheadwind.WindDirectionIndicatorSetting
import de.timklge.karooheadwind.WindDirectionIndicatorTextSetting
import de.timklge.karooheadwind.datatypes.GpsCoordinates
import de.timklge.karooheadwind.saveSettings
import de.timklge.karooheadwind.streamSettings
@ -64,16 +62,6 @@ fun SettingsScreen(onFinish: () -> Unit) {
val karooSystem = remember { KarooSystemService(ctx) }
var refreshRateSetting by remember { mutableStateOf(RefreshRate.STANDARD) }
var selectedWindDirectionIndicatorTextSetting by remember {
mutableStateOf(
WindDirectionIndicatorTextSetting.HEADWIND_SPEED
)
}
var selectedWindDirectionIndicatorSetting by remember {
mutableStateOf(
WindDirectionIndicatorSetting.HEADWIND_DIRECTION
)
}
var selectedRoundLocationSetting by remember { mutableStateOf(RoundLocationSetting.KM_3) }
var forecastKmPerHour by remember { mutableStateOf("20") }
@ -88,8 +76,6 @@ fun SettingsScreen(onFinish: () -> Unit) {
LaunchedEffect(Unit) {
ctx.streamSettings(karooSystem).collect { settings ->
selectedWindDirectionIndicatorTextSetting = settings.windDirectionIndicatorTextSetting
selectedWindDirectionIndicatorSetting = settings.windDirectionIndicatorSetting
selectedRoundLocationSetting = settings.roundLocationTo
forecastKmPerHour = settings.forecastedKmPerHour.toString()
forecastMilesPerHour = settings.forecastedMilesPerHour.toString()
@ -118,8 +104,6 @@ fun SettingsScreen(onFinish: () -> Unit) {
val newSettings = HeadwindSettings(
welcomeDialogAccepted = true,
windDirectionIndicatorSetting = selectedWindDirectionIndicatorSetting,
windDirectionIndicatorTextSetting = selectedWindDirectionIndicatorTextSetting,
roundLocationTo = selectedRoundLocationSetting,
forecastedMilesPerHour = forecastMilesPerHour.toIntOrNull()?.coerceIn(3, 30) ?: 12,
forecastedKmPerHour = forecastKmPerHour.toIntOrNull()?.coerceIn(5, 50) ?: 20,
@ -165,37 +149,6 @@ fun SettingsScreen(onFinish: () -> Unit) {
refreshRateSetting = RefreshRate.entries.find { unit -> unit.id == selectedOption.id }!!
}
val windDirectionIndicatorSettingDropdownOptions =
WindDirectionIndicatorSetting.entries.toList().map { unit -> DropdownOption(unit.id, unit.label) }
val windDirectionIndicatorSettingSelection by remember(selectedWindDirectionIndicatorSetting) {
mutableStateOf(windDirectionIndicatorSettingDropdownOptions.find { option -> option.id == selectedWindDirectionIndicatorSetting.id }!!)
}
Dropdown(
label = "Wind Direction Indicator",
options = windDirectionIndicatorSettingDropdownOptions,
selected = windDirectionIndicatorSettingSelection
) { selectedOption ->
selectedWindDirectionIndicatorSetting =
WindDirectionIndicatorSetting.entries.find { unit -> unit.id == selectedOption.id }!!
}
val windDirectionIndicatorTextSettingDropdownOptions =
WindDirectionIndicatorTextSetting.entries.toList()
.map { unit -> DropdownOption(unit.id, unit.label) }
val windDirectionIndicatorTextSettingSelection by remember(
selectedWindDirectionIndicatorTextSetting
) {
mutableStateOf(windDirectionIndicatorTextSettingDropdownOptions.find { option -> option.id == selectedWindDirectionIndicatorTextSetting.id }!!)
}
Dropdown(
label = "Text on Headwind Indicator",
options = windDirectionIndicatorTextSettingDropdownOptions,
selected = windDirectionIndicatorTextSettingSelection
) { selectedOption ->
selectedWindDirectionIndicatorTextSetting =
WindDirectionIndicatorTextSetting.entries.find { unit -> unit.id == selectedOption.id }!!
}
val roundLocationDropdownOptions = RoundLocationSetting.entries.toList()
.map { unit -> DropdownOption(unit.id, unit.label) }
val roundLocationInitialSelection by remember(selectedRoundLocationSetting) {

View File

@ -37,6 +37,9 @@ import de.timklge.karooheadwind.streamCurrentWeatherData
import de.timklge.karooheadwind.streamStats
import de.timklge.karooheadwind.streamUpcomingRoute
import de.timklge.karooheadwind.streamUserProfile
import de.timklge.karooheadwind.util.celciusInUserUnit
import de.timklge.karooheadwind.util.millimetersInUserUnit
import de.timklge.karooheadwind.util.msInUserUnit
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.UserProfile
import java.time.Instant
@ -110,10 +113,10 @@ fun WeatherScreen(onFinish: () -> Unit) {
baseBitmap = baseBitmap,
current = WeatherInterpretation.fromWeatherCode(currentWeatherData?.weatherCode),
windBearing = currentWeatherData?.windDirection?.roundToInt() ?: 0,
windSpeed = currentWeatherData?.windSpeed?.roundToInt() ?: 0,
windGusts = currentWeatherData?.windGusts?.roundToInt() ?: 0,
precipitation = currentWeatherData?.precipitation ?: 0.0,
temperature = currentWeatherData?.temperature?.toInt() ?: 0,
windSpeed = msInUserUnit(currentWeatherData?.windSpeed ?: 0.0, profile?.preferredUnit?.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL).roundToInt(),
windGusts = msInUserUnit(currentWeatherData?.windGusts ?: 0.0, profile?.preferredUnit?.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL).roundToInt(),
precipitation = millimetersInUserUnit(currentWeatherData?.precipitation ?: 0.0, profile?.preferredUnit?.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL),
temperature = celciusInUserUnit(currentWeatherData?.temperature ?: 0.0, profile?.preferredUnit?.temperature == UserProfile.PreferredUnit.UnitType.IMPERIAL).roundToInt(),
temperatureUnit = if(profile?.preferredUnit?.temperature == UserProfile.PreferredUnit.UnitType.METRIC) TemperatureUnit.CELSIUS else TemperatureUnit.FAHRENHEIT,
timeLabel = formattedTime,
dateLabel = formattedDate,
@ -230,10 +233,10 @@ fun WeatherScreen(onFinish: () -> Unit) {
baseBitmap,
current = interpretation,
windBearing = weatherData?.windDirection?.roundToInt() ?: 0,
windSpeed = weatherData?.windSpeed?.roundToInt() ?: 0,
windGusts = weatherData?.windGusts?.roundToInt() ?: 0,
precipitation = weatherData?.precipitation ?: 0.0,
temperature = weatherData?.temperature?.toInt() ?: 0,
windSpeed = msInUserUnit(weatherData?.windSpeed ?: 0.0, profile?.preferredUnit?.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL).roundToInt(),
windGusts = msInUserUnit(weatherData?.windGusts ?: 0.0, profile?.preferredUnit?.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL).roundToInt(),
precipitation = millimetersInUserUnit(weatherData?.precipitation ?: 0.0, profile?.preferredUnit?.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL),
temperature = celciusInUserUnit(weatherData?.temperature ?: 0.0, profile?.preferredUnit?.temperature == UserProfile.PreferredUnit.UnitType.IMPERIAL).roundToInt(),
temperatureUnit = if (profile?.preferredUnit?.temperature != UserProfile.PreferredUnit.UnitType.IMPERIAL) TemperatureUnit.CELSIUS else TemperatureUnit.FAHRENHEIT,
timeLabel = formattedForecastTime,
dateLabel = formattedForecastDate,

View File

@ -50,7 +50,7 @@ fun WeatherWidget(
isImperial: Boolean,
isNight: Boolean
) {
val fontSize = 20.sp
val fontSize = 18.sp
Row(
modifier = Modifier.fillMaxWidth().padding(5.dp),
@ -106,7 +106,6 @@ fun WeatherWidget(
)
Column(horizontalAlignment = Alignment.End) {
// Temperature (larger)
Row(
verticalAlignment = Alignment.CenterVertically
) {

View File

@ -0,0 +1,26 @@
package de.timklge.karooheadwind.util
fun celciusInUserUnit(celcius: Double, isImperial: Boolean): Double {
return if (isImperial) {
celcius * 9.0 / 5 + 32.0
} else {
celcius
}
}
fun millimetersInUserUnit(millimeters: Double, isImperial: Boolean): Double {
return if (isImperial) {
millimeters / 25.4
} else {
millimeters
}
}
// Returns the given speed value (m / s) in user unit (km/h or mph)
fun msInUserUnit(ms: Double, isImperial: Boolean): Double {
return if (isImperial) {
ms * 2.2369362920544
} else {
ms * 3.6
}
}

View File

@ -6,12 +6,12 @@ import kotlinx.serialization.Serializable
data class WeatherData(
val time: Long,
val temperature: Double,
val relativeHumidity: Double? = null,
val relativeHumidity: Int,
val precipitation: Double,
val precipitationProbability: Double? = null,
val cloudCover: Double? = null,
val sealevelPressure: Double? = null,
val surfacePressure: Double? = null,
val cloudCover: Double,
val sealevelPressure: Double,
val surfacePressure: Double,
val windSpeed: Double,
val windDirection: Double,
val windGusts: Double,

View File

@ -12,7 +12,7 @@ data class OpenMeteoWeatherData(
@SerialName("precipitation") val precipitation: Double,
@SerialName("cloud_cover") val cloudCover: Int,
@SerialName("surface_pressure") val surfacePressure: Double,
@SerialName("pressure_msl") val sealevelPressure: Double? = null,
@SerialName("pressure_msl") val sealevelPressure: Double,
@SerialName("wind_speed_10m") val windSpeed: Double,
@SerialName("wind_direction_10m") val windDirection: Double,
@SerialName("wind_gusts_10m") val windGusts: Double,
@ -21,7 +21,7 @@ data class OpenMeteoWeatherData(
) {
fun toWeatherData(): WeatherData = WeatherData(
temperature = temperature,
relativeHumidity = relativeHumidity.toDouble(),
relativeHumidity = relativeHumidity,
precipitation = precipitation,
cloudCover = cloudCover.toDouble(),
surfacePressure = surfacePressure,
@ -32,7 +32,7 @@ data class OpenMeteoWeatherData(
weatherCode = weatherCode,
time = time,
isForecast = false,
isNight = isDay == 0
isNight = isDay == 0,
)
}

View File

@ -14,7 +14,11 @@ data class OpenMeteoWeatherForecastData(
@SerialName("wind_speed_10m") val windSpeed: List<Double>,
@SerialName("wind_direction_10m") val windDirection: List<Double>,
@SerialName("wind_gusts_10m") val windGusts: List<Double>,
@SerialName("cloud_cover") val cloudCover: List<Double>,
@SerialName("surface_pressure") val surfacePressure: List<Double>,
@SerialName("pressure_msl") val sealevelPressure: List<Double>,
@SerialName("is_day") val isDay: List<Int>,
@SerialName("relative_humidity_2m") val relativeHumidity: List<Int>,
) {
fun toWeatherData(): List<WeatherData> {
return time.mapIndexed { index, t ->
@ -29,6 +33,10 @@ data class OpenMeteoWeatherForecastData(
isNight = isDay[index] == 0,
time = t,
isForecast = true,
cloudCover = cloudCover[index],
surfacePressure = surfacePressure[index],
sealevelPressure = sealevelPressure[index],
relativeHumidity = relativeHumidity[index],
)
}
}

View File

@ -3,10 +3,7 @@ package de.timklge.karooheadwind.weatherprovider.openmeteo
import android.util.Log
import de.timklge.karooheadwind.HeadwindSettings
import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.PrecipitationUnit
import de.timklge.karooheadwind.TemperatureUnit
import de.timklge.karooheadwind.WeatherDataProvider
import de.timklge.karooheadwind.WindUnit
import de.timklge.karooheadwind.datatypes.GpsCoordinates
import de.timklge.karooheadwind.jsonWithUnknownKeys
import de.timklge.karooheadwind.weatherprovider.WeatherDataResponse
@ -28,16 +25,12 @@ import kotlin.time.Duration.Companion.seconds
class OpenMeteoWeatherProvider : WeatherProvider {
@OptIn(FlowPreview::class)
private suspend fun makeOpenMeteoWeatherRequest(karooSystemService: KarooSystemService, 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
val windUnit = if (profile?.preferredUnit?.distance != UserProfile.PreferredUnit.UnitType.IMPERIAL) WindUnit.KILOMETERS_PER_HOUR else WindUnit.MILES_PER_HOUR
private suspend fun makeOpenMeteoWeatherRequest(karooSystemService: KarooSystemService, gpsCoordinates: List<GpsCoordinates>): HttpResponseState.Complete {
val response = callbackFlow {
// https://api.open-meteo.com/v1/forecast?latitude=52.52&longitude=13.41&current=is_day,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 lats = gpsCoordinates.joinToString(",") { String.format(Locale.US, "%.6f", it.lat) }
val lons = gpsCoordinates.joinToString(",") { String.format(Locale.US, "%.6f", it.lon) }
val url = "https://api.open-meteo.com/v1/forecast?latitude=${lats}&longitude=${lons}&current=is_day,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,is_day&timeformat=unixtime&past_hours=0&forecast_days=1&forecast_hours=12&wind_speed_unit=${windUnit.id}&precipitation_unit=${precipitationUnit.id}&temperature_unit=${temperatureUnit.id}"
val url = "https://api.open-meteo.com/v1/forecast?latitude=${lats}&longitude=${lons}&current=is_day,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,is_day,surface_pressure,pressure_msl,relative_humidity_2m,cloud_cover&timeformat=unixtime&past_hours=0&forecast_days=1&forecast_hours=12&wind_speed_unit=ms"
Log.d(KarooHeadwindExtension.TAG, "Http request to ${url}...")
@ -84,7 +77,7 @@ class OpenMeteoWeatherProvider : WeatherProvider {
settings: HeadwindSettings,
profile: UserProfile?
): WeatherDataResponse {
val openMeteoResponse = makeOpenMeteoWeatherRequest(karooSystem, coordinates, settings, profile)
val openMeteoResponse = makeOpenMeteoWeatherRequest(karooSystem, coordinates)
val responseBody = openMeteoResponse.body?.let { String(it) } ?: throw WeatherProviderException(500, "Null response from OpenMeteo")
val weatherData = if (coordinates.size == 1) {
@ -102,4 +95,4 @@ class OpenMeteoWeatherProvider : WeatherProvider {
return response
}
}
}

View File

@ -33,7 +33,7 @@ data class OpenWeatherMapForecastData(
return WeatherData(
temperature = temp,
relativeHumidity = humidity.toDouble(),
relativeHumidity = humidity,
precipitation = rain?.h1 ?: 0.0,
cloudCover = clouds.toDouble(),
surfacePressure = pressure.toDouble(),

View File

@ -23,7 +23,7 @@ data class OpenWeatherMapWeatherData(
fun toWeatherData(): WeatherData = WeatherData(
temperature = temp,
relativeHumidity = humidity.toDouble(),
relativeHumidity = humidity,
precipitation = rain?.h1 ?: 0.0,
cloudCover = clouds.toDouble(),
surfacePressure = pressure.toDouble(),

View File

@ -69,7 +69,7 @@ class OpenWeatherMapWeatherProvider(private val apiKey: String) : WeatherProvide
profile: UserProfile?
): WeatherDataResponse {
val response = makeOpenWeatherMapRequest(karooSystem, coordinates, apiKey, profile)
val response = makeOpenWeatherMapRequest(karooSystem, coordinates, apiKey)
val responseBody = response.body?.let { String(it) } ?: throw Exception("Null response from OpenWeatherMap")
val responses = mutableListOf<WeatherDataForLocation>()
@ -89,21 +89,15 @@ class OpenWeatherMapWeatherProvider(private val apiKey: String) : WeatherProvide
private suspend fun makeOpenWeatherMapRequest(
service: KarooSystemService,
coordinates: List<GpsCoordinates>,
apiKey: String,
profile: UserProfile?
apiKey: String
): HttpResponseState.Complete {
val response = callbackFlow {
// OpenWeatherMap only supports setting imperial or metric units for all measurements, not individually for distance / temperature
val unitsString = if (profile?.preferredUnit?.temperature == UserProfile.PreferredUnit.UnitType.IMPERIAL || profile?.preferredUnit?.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL) {
"imperial"
} else {
"metric"
}
val coordinate = coordinates.first()
// URL API 3.0 with onecall endpoint
val url = "https://api.openweathermap.org/data/3.0/onecall?lat=${coordinate.lat}&lon=${coordinate.lon}" +
"&appid=$apiKey&exclude=minutely,daily,alerts&units=${unitsString}"
"&appid=$apiKey&exclude=minutely,daily,alerts&units=metric"
Log.d(KarooHeadwindExtension.TAG, "Http request to OpenWeatherMap API 3.0: $url")

View File

@ -45,4 +45,6 @@
<string name="relativeGrade_description">Perceived grade in percent</string>
<string name="relativeElevationGain">Relative Elevation Gain</string>
<string name="relativeElevationGain_description">Perceived elevation gain in meters</string>
<string name="windDirectionAndSpeed">Wind direction and speed</string>
<string name="windDirectionAndSpeed_description">Current wind direction and wind speed</string>
</resources>

View File

@ -76,11 +76,11 @@
typeId="headwindSpeed" />
<DataType
description="@string/userwind_speed_description"
displayName="@string/userwind_speed"
description="@string/windDirectionAndSpeed_description"
displayName="@string/windDirectionAndSpeed"
graphical="false"
icon="@drawable/wind"
typeId="userwindSpeed" />
typeId="windDirectionAndSpeed" />
<DataType
description="@string/relativeHumidity_description"