diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index e75a473..90b1e3f 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -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 }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 43ebc8c..4523740 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ .cxx local.properties /app/release +app/manifest.json \ No newline at end of file diff --git a/README.md b/README.md index ccc1f97..272e805 100644 --- a/README.md +++ b/README.md @@ -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) \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 466f95a..deb1185 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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().configureEach { + if (name == "processDebugMainManifest" || name == "processReleaseMainManifest") { + dependsOn(tasks.named("generateManifest")) + } +} + dependencies { implementation(libs.mapbox.sdk.turf) implementation(libs.hammerhead.karoo.ext) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 15236d9..c218a2c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -32,6 +32,6 @@ + android:value="$BASE_URL$/manifest.json" /> \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/DataStore.kt b/app/src/main/kotlin/de/timklge/karooheadwind/DataStore.kt index ef03232..99e2fe2 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/DataStore.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/DataStore.kt @@ -210,6 +210,22 @@ fun Context.streamCurrentForecastWeatherData(): Flow { }.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, diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/HeadwindSettings.kt b/app/src/main/kotlin/de/timklge/karooheadwind/HeadwindSettings.kt index f5cd6ec..b77dbf5 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/HeadwindSettings.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/HeadwindSettings.kt @@ -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, diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt b/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt index dbb222f..fac0c37 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt @@ -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 diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/BaseDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/BaseDataType.kt index d0d101e..d7d5627 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/BaseDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/BaseDataType.kt @@ -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) { 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())) + } + } } diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/CloudCoverDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/CloudCoverDataType.kt index f3ed860..d6369a3 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/CloudCoverDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/CloudCoverDataType.kt @@ -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 } } \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/ForecastDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/ForecastDataType.kt index 2bdbb4e..aac3f32 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/ForecastDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/ForecastDataType.kt @@ -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, diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/GraphicalForecast.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/GraphicalForecast.kt index 35b86c6..c2e77e3 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/GraphicalForecast.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/GraphicalForecast.kt @@ -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)) ) } diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindDirectionDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindDirectionDataType.kt index 9dae1db..a3f8693 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindDirectionDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindDirectionDataType.kt @@ -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 { 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 = 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) + } + } } } diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindSpeedDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindSpeedDataType.kt index 03abc3e..b0d834f 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindSpeedDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindSpeedDataType.kt @@ -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)) + } } diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/LineGraphForecastDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/LineGraphForecastDataType.kt new file mode 100644 index 0000000..2e34e3d --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/LineGraphForecastDataType.kt @@ -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, + isImperial: Boolean, + upcomingRoute: UpcomingRoute? + ): Set + + private fun previewFlow(settingsAndProfileStream: Flow): Flow = + 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() + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/PrecipitationDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/PrecipitationDataType.kt index c983104..c81b0d2 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/PrecipitationDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/PrecipitationDataType.kt @@ -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) } } \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/PrecipitationForecastDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/PrecipitationForecastDataType.kt index 85d57e0..e2c67ac 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/PrecipitationForecastDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/PrecipitationForecastDataType.kt @@ -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, isImperial: Boolean, - isNight: Boolean, - ) { - PrecipitationForecast( - precipitation = ceil(precipitation).toInt(), - precipitationProbability = precipitationProbability, - distance = distance, - timeLabel = timeLabel, - isImperial = isImperial, + upcomingRoute: UpcomingRoute? + ): Set { + 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 + ) ) } + } \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/RelativeGradeDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/RelativeGradeDataType.kt index 9dffc65..55a8df4 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/RelativeGradeDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/RelativeGradeDataType.kt @@ -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( diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/RelativeHumidityDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/RelativeHumidityDataType.kt index a665c67..b3bf0d3 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/RelativeHumidityDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/RelativeHumidityDataType.kt @@ -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() } } \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/SealevelPressureDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/SealevelPressureDataType.kt index 26d2ef9..a056017 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/SealevelPressureDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/SealevelPressureDataType.kt @@ -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 } } \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/SurfacePressureDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/SurfacePressureDataType.kt index 936f150..81b8ae9 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/SurfacePressureDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/SurfacePressureDataType.kt @@ -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 } } \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TailwindAndRideSpeedDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TailwindAndRideSpeedDataType.kt index 5b3dbe7..555345e 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TailwindAndRideSpeedDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TailwindAndRideSpeedDataType.kt @@ -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( diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TailwindDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TailwindDataType.kt index 6e41cbc..94f2039 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TailwindDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TailwindDataType.kt @@ -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, ) diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TemperatureDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TemperatureDataType.kt index b5f0124..b174df3 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TemperatureDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TemperatureDataType.kt @@ -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 + } } \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TemperatureForecastDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TemperatureForecastDataType.kt index 31bffea..c7255ac 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TemperatureForecastDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TemperatureForecastDataType.kt @@ -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, isImperial: Boolean, - isNight: Boolean - ) { - TemperatureForecast( - temperature = temperature, - temperatureUnit = temperatureUnit, - distance = distance, - timeLabel = timeLabel, - isImperial = isImperial, + upcomingRoute: UpcomingRoute? + ): Set { + 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", + ) ) } + } diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/UserWindSpeedDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/UserWindSpeedDataType.kt deleted file mode 100644 index b44ef80..0000000 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/UserWindSpeedDataType.kt +++ /dev/null @@ -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 = 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) { - 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() - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherDataType.kt index 22aff6e..ed54fbd 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherDataType.kt @@ -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){ diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindDirectionAndSpeedDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindDirectionAndSpeedDataType.kt new file mode 100644 index 0000000..7dc1fac --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindDirectionAndSpeedDataType.kt @@ -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): Flow { + 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() + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindDirectionDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindDirectionDataType.kt index e87d872..bceea04 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindDirectionDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindDirectionDataType.kt @@ -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 } diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindForecastDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindForecastDataType.kt index 36ce287..cf3330f 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindForecastDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindForecastDataType.kt @@ -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, isImperial: Boolean, - isNight: Boolean - ) { - WindForecast( - arrowBitmap = arrowBitmap, - windBearing = windBearing, - windSpeed = windSpeed, - gustSpeed = windGusts, - distance = distance, - timeLabel = timeLabel, - isImperial = isImperial, - ) + upcomingRoute: UpcomingRoute? + ): Set { + 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.. + 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 + } + } \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindGustsDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindGustsDataType.kt index 48e3043..565ca10 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindGustsDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindGustsDataType.kt @@ -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 } } \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindSpeedDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindSpeedDataType.kt index bc2705a..c086cc4 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindSpeedDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindSpeedDataType.kt @@ -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 } } \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/screens/LineGraph.kt b/app/src/main/kotlin/de/timklge/karooheadwind/screens/LineGraph.kt new file mode 100644 index 0000000..7c1427f --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/screens/LineGraph.kt @@ -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, + @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, + 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() + 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() + 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 + } +} diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/screens/SettingsScreen.kt b/app/src/main/kotlin/de/timklge/karooheadwind/screens/SettingsScreen.kt index df4b9ee..ec92b89 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/screens/SettingsScreen.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/screens/SettingsScreen.kt @@ -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) { diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/screens/WeatherScreen.kt b/app/src/main/kotlin/de/timklge/karooheadwind/screens/WeatherScreen.kt index 66c1449..6e2b158 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/screens/WeatherScreen.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/screens/WeatherScreen.kt @@ -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, diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/screens/WeatherWidget.kt b/app/src/main/kotlin/de/timklge/karooheadwind/screens/WeatherWidget.kt index 341f3c6..f7f99c2 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/screens/WeatherWidget.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/screens/WeatherWidget.kt @@ -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 ) { diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/util/Conversion.kt b/app/src/main/kotlin/de/timklge/karooheadwind/util/Conversion.kt new file mode 100644 index 0000000..cd32ed2 --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/util/Conversion.kt @@ -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 + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/WeatherData.kt b/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/WeatherData.kt index ede008e..db4ea4f 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/WeatherData.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/WeatherData.kt @@ -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, diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openmeteo/OpenMeteoWeatherData.kt b/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openmeteo/OpenMeteoWeatherData.kt index a1a4218..6cf5281 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openmeteo/OpenMeteoWeatherData.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openmeteo/OpenMeteoWeatherData.kt @@ -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, ) } diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openmeteo/OpenMeteoWeatherForecastData.kt b/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openmeteo/OpenMeteoWeatherForecastData.kt index 7c4c375..5a4d702 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openmeteo/OpenMeteoWeatherForecastData.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openmeteo/OpenMeteoWeatherForecastData.kt @@ -14,7 +14,11 @@ data class OpenMeteoWeatherForecastData( @SerialName("wind_speed_10m") val windSpeed: List, @SerialName("wind_direction_10m") val windDirection: List, @SerialName("wind_gusts_10m") val windGusts: List, + @SerialName("cloud_cover") val cloudCover: List, + @SerialName("surface_pressure") val surfacePressure: List, + @SerialName("pressure_msl") val sealevelPressure: List, @SerialName("is_day") val isDay: List, + @SerialName("relative_humidity_2m") val relativeHumidity: List, ) { fun toWeatherData(): List { 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], ) } } diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openmeteo/OpenMeteoWeatherProvider.kt b/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openmeteo/OpenMeteoWeatherProvider.kt index b8eff6b..f9f4a6f 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openmeteo/OpenMeteoWeatherProvider.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openmeteo/OpenMeteoWeatherProvider.kt @@ -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, 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): HttpResponseState.Complete { val response = callbackFlow { // https://api.open-meteo.com/v1/forecast?latitude=52.52&longitude=13.41¤t=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}¤t=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}¤t=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 } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openweathermap/OpenWeatherMapForecastData.kt b/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openweathermap/OpenWeatherMapForecastData.kt index e09b8d0..6bf45ed 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openweathermap/OpenWeatherMapForecastData.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openweathermap/OpenWeatherMapForecastData.kt @@ -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(), diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openweathermap/OpenWeatherMapWeatherData.kt b/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openweathermap/OpenWeatherMapWeatherData.kt index 6decf17..6c19fd2 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openweathermap/OpenWeatherMapWeatherData.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openweathermap/OpenWeatherMapWeatherData.kt @@ -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(), diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openweathermap/OpenWeatherMapWeatherProvider.kt b/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openweathermap/OpenWeatherMapWeatherProvider.kt index 18a4eb0..4f1bf16 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openweathermap/OpenWeatherMapWeatherProvider.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openweathermap/OpenWeatherMapWeatherProvider.kt @@ -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() @@ -89,21 +89,15 @@ class OpenWeatherMapWeatherProvider(private val apiKey: String) : WeatherProvide private suspend fun makeOpenWeatherMapRequest( service: KarooSystemService, coordinates: List, - 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") diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 66a1cb9..130d790 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -45,4 +45,6 @@ Perceived grade in percent Relative Elevation Gain Perceived elevation gain in meters + Wind direction and speed + Current wind direction and wind speed \ No newline at end of file diff --git a/app/src/main/res/xml/extension_info.xml b/app/src/main/res/xml/extension_info.xml index a701f6a..ce8c08b 100644 --- a/app/src/main/res/xml/extension_info.xml +++ b/app/src/main/res/xml/extension_info.xml @@ -76,11 +76,11 @@ typeId="headwindSpeed" /> + typeId="windDirectionAndSpeed" />