Compare commits

...

32 Commits

Author SHA1 Message Date
c8410ad0dc Remove empty space in forces datafield if no value is below zero
Some checks failed
Build / build (push) Successful in 5m27s
Comment on Fixed Issues/PRs on Release / comment-on-fixed (push) Failing after 6s
2025-09-01 18:53:52 +02:00
da8583bd3e Update changelog
All checks were successful
Build / build (push) Successful in 7m57s
2025-09-01 18:42:56 +02:00
timklge
b809a48631
Only update position if the estimated accuracy is within 500 meters (#172) 2025-08-30 12:16:44 +02:00
timklge
d56a220ae1
Open main extension menu when clicking on graphical headwind / tailwind data field (#173) 2025-08-27 20:55:30 +02:00
timklge
89cb2ec010
Add force distribution data field (#169)
* WIP forces data field

* Remove value label, increase font size

* Add forces datafield description to README
2025-08-27 00:30:31 +02:00
timklge
839b93a43b
Only increase relative elevation gain when relative grade is positive (#168) 2025-08-27 00:30:17 +02:00
1b450c00d9 Update release notes
Some checks failed
Build / build (push) Successful in 5m40s
Comment on Fixed Issues/PRs on Release / comment-on-fixed (push) Failing after 7s
2025-08-15 21:30:29 +02:00
timklge
e7d11c2000
Fix plain wind gust / wind speed datafields show values in meters per second (#167) 2025-08-15 21:28:48 +02:00
timklge
1fe7eb1a16
Fix headwind forecast field does not show preview (#165) 2025-08-15 21:20:56 +02:00
timklge
917770e45a
Fix metric openweathermap wind speed unit in README (#163) 2025-07-31 19:46:58 +02:00
1f27a33b64 Update changelog
Some checks failed
Build / build (push) Successful in 6m16s
Comment on Fixed Issues/PRs on Release / comment-on-fixed (push) Failing after 6s
2025-07-07 20:48:37 +02:00
timklge
109c002533
fix #156, fix #151: Split wind forecast datafield into headwind forecast and wind / gust forecast (#161) 2025-07-07 20:29:36 +02:00
Julien B.
5169048143
Add UV Index to current weather and forecast (#159) 2025-07-07 17:41:55 +02:00
timklge
be7ca192b2
Reintroduce circular headwind indicator data field with absolute wind speed (#160)
* fix #158: Add circular absolute wind direction datafield

* Move unittest package

* Rework circular indicator

* Shorten wind direction and speed label

* Update README
2025-07-07 17:40:45 +02:00
timklge
2378c944e6
Colorize headwind forecast line graph and the area beneath it (#155)
All checks were successful
Build / build (push) Successful in 5m19s
2025-06-12 23:32:26 +02:00
87a244ef27 Merge remote-tracking branch 'timklge/master'
All checks were successful
Build / build (push) Successful in 5m37s
2025-06-11 20:57:39 +02:00
04fb321a40 Update changelog 2025-06-11 20:56:45 +02:00
timklge
b6d0acd62d
Request first two and last location for openweathermap route forecast (#154) 2025-06-11 20:54:53 +02:00
timklge
533cb1e006
Show headwind line in wind forecast preview (#153) 2025-06-11 20:43:08 +02:00
timklge
1727e606ee
Scale number of y ticks in linegraph data fields (#150) 2025-06-11 20:17:02 +02:00
timklge
f35ffe52cc
Fix initial position is only retrieved on position change (#149) 2025-06-11 19:39:11 +02:00
Enderthor
5b163f6f7a
Add route forecast support for OpenWeatherMap (#145)
* Added openweathermap support

* Added openweathermap support

* Added openweathermap support

* Added openweathermap support

* Added openweathermap support

* Added openweathermap support

* timklge updates 20250308
Forecast in route (openweathermap)

* Forecast in route (openweathermap)

* Forecast in route (openweathermap)

* Update app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openweathermap/OpenWeatherMapWeatherProvider.kt

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-06-11 19:21:48 +02:00
302bdef429 Reintroduce temperature field
All checks were successful
Build / build (push) Successful in 5m36s
2025-06-09 19:16:10 +02:00
958c576a5e Update changelog
All checks were successful
Build / build (push) Successful in 5m29s
2025-06-09 14:18:42 +02:00
timklge
ef5e980de3
Crop timespan to 6 hours in forecast views, use am / pm time format if appropriate (#144)
Some checks failed
Build / build (push) Has been cancelled
* Crop timespan to 6 hours in forecast views, use am / pm time format if appropriate

* Also crop timeframe in hourly forecast widget
2025-06-09 14:14:40 +02:00
timklge
4952d8fbf4
Remove tailwind, weather, temperature data fields (#143) 2025-06-09 12:18:20 +02:00
timklge
410441c3a6
Increase font size in line graph forecast datafields (#142) 2025-06-09 11:29:15 +02:00
timklge
d1b6f2c525
Remove crashlytics references (#138)
All checks were successful
Build / build (push) Successful in 5m27s
2025-06-05 00:02:36 +02:00
31ca117fef Remove absolute setting hint 2025-06-04 23:51:06 +02:00
915f4cfacb Fix manifest
Some checks failed
Build / build (push) Failing after 5m32s
2025-06-04 23:41:25 +02:00
8028226fac Update screenshots, changelog
All checks were successful
Build / build (push) Successful in 5m21s
2025-06-04 23:15:10 +02:00
timklge
dae1369cd8
Refactor unit conversion, show individual forecasts as line graphs, remove headwind indicator settings (#137)
* Refactor unit conversion

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

* Unit conversions

* Update pipeline

* Remove apk archival step

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

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

* Add WindDirectionAndSpeedDataType

* Disable line graph forecast when using openweathermap

* Fix wind on main menu forecast display
2025-06-04 23:02:15 +02:00
66 changed files with 2509 additions and 1162 deletions

View File

@ -3,10 +3,10 @@ name: Build
on: on:
workflow_dispatch: workflow_dispatch:
push: push:
branches: [ "master" ] branches: [ "**" ]
tags: [ "*" ] tags: [ "*" ]
pull_request: pull_request:
branches: [ "master" ] branches: [ "**" ]
jobs: jobs:
build: build:
@ -19,13 +19,14 @@ jobs:
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV run: echo "RELEASE_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
- name: Set up environment variables - name: Set up environment variables
run: | run: |
echo "GPR_USER=${{ github.actor }}" >> $GITHUB_ENV echo "GPR_USER=${{ secrets.GHUB_USER || github.actor }}" >> $GITHUB_ENV
echo "GPR_KEY=${{ secrets.GITHUB_TOKEN }}" >> $GITHUB_ENV echo "GPR_KEY=${{ secrets.GHUB_TOKEN || secrets.GITHUB_TOKEN }}" >> $GITHUB_ENV
echo "KEY_ALIAS=${{ secrets.KEY_ALIAS }}" >> $GITHUB_ENV echo "KEY_ALIAS=${{ secrets.KEY_ALIAS }}" >> $GITHUB_ENV
echo "KEY_PASSWORD=${{ secrets.KEY_PASSWORD }}" >> $GITHUB_ENV echo "KEY_PASSWORD=${{ secrets.KEY_PASSWORD }}" >> $GITHUB_ENV
echo "KEYSTORE_PASSWORD=${{ secrets.KEYSTORE_PASSWORD }}" >> $GITHUB_ENV echo "KEYSTORE_PASSWORD=${{ secrets.KEYSTORE_PASSWORD }}" >> $GITHUB_ENV
echo "KEYSTORE_BASE64=${{ secrets.KEYSTORE_BASE64 }}" >> $GITHUB_ENV echo "KEYSTORE_BASE64=${{ secrets.KEYSTORE_BASE64 }}" >> $GITHUB_ENV
echo "BUILD_NUMBER=${{ github.run_number }}" >> $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 - uses: actions/checkout@v4
- name: set up JDK 17 - name: set up JDK 17
uses: actions/setup-java@v4 uses: actions/setup-java@v4
@ -34,25 +35,30 @@ jobs:
distribution: 'temurin' distribution: 'temurin'
cache: gradle cache: gradle
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Grant execute permission for gradlew - name: Grant execute permission for gradlew
run: chmod +x gradlew run: chmod +x gradlew
- name: Build with Gradle - name: Build with Gradle
run: ./gradlew build 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 - name: Create Release
id: create_release id: create_release
uses: ncipollo/release-action@v1 uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/') if: startsWith(github.ref, 'refs/tags/')
with: with:
name: ${{ github.ref_name }} name: ${{ github.ref_name }}
prerelease: false draft: false
generateReleaseNotes: true generate_release_notes: 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 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: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

54
.github/workflows/release_comment.yml vendored Normal file
View File

@ -0,0 +1,54 @@
name: Comment on Fixed Issues/PRs on Release
on:
push:
tags:
- '*'
workflow_dispatch:
inputs:
tag:
description: 'Tag to run the workflow for'
required: false
default: ''
jobs:
comment-on-fixed:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Fetch all history for all tags and branches
- name: Find closed issues/PRs and comment
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Use the input tag if provided, otherwise use the tag from the push event
if [ -n "${{ github.event.inputs.tag }}" ]; then
RELEASE_TAG="${{ github.event.inputs.tag }}"
else
RELEASE_TAG="${{ github.ref }}"
# Remove the 'refs/tags/' part to get the tag name
RELEASE_TAG="${RELEASE_TAG#refs/tags/}"
fi
# Get the previous tag. If there is no previous tag, this will be empty.
PREVIOUS_TAG=$(git tag --sort=-v:refname | grep -v "$RELEASE_TAG" | head -n 1)
# Get the commit range
if [ -z "$PREVIOUS_TAG" ]; then
# If there is no previous tag, get all commits up to the current tag
COMMIT_RANGE="$RELEASE_TAG"
else
COMMIT_RANGE="$PREVIOUS_TAG..$RELEASE_TAG"
fi
# Find the commits in this release
COMMITS=$(git log "$COMMIT_RANGE" --pretty=format:"%B")
# Extract issues/PRs closed (simple regex, can be improved)
echo "$COMMITS" | grep -oE "#[0-9]+" | sort -u | while read ISSUE; do
ISSUE_NUMBER="${ISSUE//#/}"
COMMENT="This issue/pr has been fixed in release ${RELEASE_TAG} :tada:"
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
done
shell: bash

1
.gitignore vendored
View File

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

View File

@ -21,19 +21,21 @@ This extension is available from the extension library on your Karoo device. Fin
After installing this app on your Karoo and opening it once from the main menu, you can add the following new data fields to your data pages: After installing this app on your Karoo and opening it once from the main menu, you can add the following new data fields to your data pages:
- 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. - 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.
- 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 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. The "circular" variant uses the same circular graphics as the headwind indicator instead.
- 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. - 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.
- Current weather (graphical, 1x1 field): Shows current weather conditions (same as forecast widget, but only for the current time). - Headwind forecast (graphical, 2x1 field): Shows the forecasted headwind speed if a route is loaded.
- 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. If placed in a 1x1 datafield, only the current weather conditions are shown.
- 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). - 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).
- Relative elevation gain (numerical): Shows the relative elegation gain. The relative elevation gain is calculated using the relative grade and is an estimation of how much climbing would have been equivalent to the headwind you faced during the ride. - Relative elevation gain (numerical): Shows the relative elegation gain. The relative elevation gain is calculated using the relative grade and is an estimation of how much climbing would have been equivalent to the headwind you faced during the ride.
- Resistance forces (graphical, 2x1 field): Shows a graphical representation of the different forces you have to overcome while riding, including gravity (actual gradient), rolling resistance (based on speed and weight), aerodynamic drag (based on speed) and wind resistance (based on headwind speed). The app reads your weight from your karoo user profile and uses rough estimates for CdA and Crr.
- Additionally, data fields that only show the current data value for headwind speed, humidity, cloud cover, absolute wind speed, absolute wind gust speed, absolute wind direction, rainfall and surface pressure can be added if desired. - Additionally, data fields that only show the current data value for headwind speed, humidity, cloud cover, absolute wind speed, absolute wind gust speed, absolute wind direction, rainfall and surface pressure can be added if desired.
The app can use OpenMeteo or OpenWeatherMap as providers for live weather data. The app can use OpenMeteo or OpenWeatherMap as providers for live weather data.
- OpenMeteo is the default provider and does not require any configuration. Wind speed will be reported in km/h if your karoo is set to metric units or mph if set to imperial. - OpenMeteo is the default provider and does not require any configuration. Wind speed will be reported in km/h if your karoo is set to metric units or mph if set to imperial.
- OpenWeatherMap can provide more accurate data for some locations. Forecasts along the loaded route are not available using OpenWeatherMap. OpenWeatherMap is free for personal use, but you need to register at https://openweathermap.org/home/sign_in and obtain a one call API key (e. g. by subscribing to "One Call API 3.0" from the [pricing page](https://openweathermap.org/price)). You can enter your API key in the app settings. Please note that it can take a few hours before OpenWeatherMap enables the key. You can check if your key is enabled by entering it in the app settings and pressing "Test API Key". Wind speed will be reported in meters per second if your Karoo is set to metric units and miles per hour if set to imperial. - OpenWeatherMap can provide more accurate data for some locations. Forecasts along the loaded route are not available using OpenWeatherMap. OpenWeatherMap is free for personal use, but you need to register at https://openweathermap.org/home/sign_in and obtain a one call API key (e. g. by subscribing to "One Call API 3.0" from the [pricing page](https://openweathermap.org/price)). You can enter your API key in the app settings. Please note that it can take a few hours before OpenWeatherMap enables the key. You can check if your key is enabled by entering it in the app settings and pressing "Test API Key". Wind speed will be reported in km/h if your Karoo is set to metric units and miles per hour if set to imperial.
The app will automatically attempt to download weather data from the selected data provider once your device has acquired a GPS fix. Your location is rounded to approximately three kilometers to maintain privacy. The app will automatically attempt to download weather data from the selected data provider once your device has acquired a GPS fix. Your location is rounded to approximately three kilometers to maintain privacy.
New weather data is downloaded when you ride more than three kilometers from the location where the weather data was downloaded for or after one hour at the latest. New weather data is downloaded when you ride more than three kilometers from the location where the weather data was downloaded for or after one hour at the latest.
@ -46,17 +48,14 @@ If the app cannot connect to the weather service, it will retry the download eve
- Interfaces with [openweathermap.org](https://openweathermap.org) - Interfaces with [openweathermap.org](https://openweathermap.org)
- Uses [karoo-ext](https://github.com/hammerheadnav/karoo-ext) (Apache2-licensed) - Uses [karoo-ext](https://github.com/hammerheadnav/karoo-ext) (Apache2-licensed)
## Crashlytics
This app uses Google Crashlytics for crash reporting to help improve stability and performance.
## Extension Developers: Headwind Data Type ## 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`. 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 - 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 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 has not been set up. Otherwise, the value is the headwind direction in degrees.
it will contain the headwind direction. - The `windDirection` datatype contains a single field with the *absolute* wind direction in degrees (so 0 = North, 90 = East etc.)
- 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, - The `headwindSpeed` datatype contains a single field that contains the *relative* headwind speed in meters per second.
this field will contain the absolute wind speed; otherwise it will contain the headwind speed. - The `windSpeed` datatype contains a single field that contains the *absolute* wind speed in meters per second.
- Other datatypes like `windGusts` etc. are also available, see [extension_info.xml](https://github.com/timklge/karoo-headwind/blob/master/app/src/main/res/xml/extension_info.xml)

View File

@ -1,3 +1,4 @@
import com.android.build.gradle.tasks.ProcessApplicationManifest
import java.util.Base64 import java.util.Base64
plugins { plugins {
@ -60,29 +61,37 @@ tasks.register("generateManifest") {
group = "build" group = "build"
doLast { doLast {
val baseUrl = System.getenv("BASE_URL") ?: "https://github.com/timklge/karoo-headwind/releases/latest/download"
val manifestFile = file("$projectDir/manifest.json") val manifestFile = file("$projectDir/manifest.json")
val manifest = mapOf( val manifest = mapOf(
"label" to "Headwind", "label" to "Headwind",
"packageName" to "de.timklge.karooheadwind", "packageName" to "de.timklge.karooheadwind",
"iconUrl" to "https://github.com/timklge/karoo-headwind/releases/latest/download/karoo-headwind.png", "iconUrl" to "$baseUrl/karoo-headwind.png",
"latestApkUrl" to "https://github.com/timklge/karoo-headwind/releases/latest/download/app-release.apk", "latestApkUrl" to "$baseUrl/app-release.apk",
"latestVersion" to android.defaultConfig.versionName, "latestVersion" to android.defaultConfig.versionName,
"latestVersionCode" to android.defaultConfig.versionCode, "latestVersionCode" to android.defaultConfig.versionCode,
"developer" to "github.com/timklge", "developer" to "github.com/timklge",
"description" to "Open-source extension that provides headwind direction, wind speed, forecast and other weather data fields.", "description" to "Open-source extension that provides headwind direction, wind speed, forecast and other weather data fields.",
"releaseNotes" to "* Remove crashlytics\n" + "releaseNotes" to "* Open main extension menu when clicking on graphical headwind / tailwind datafield\n* Only update position if the estimated accuracy is within 500 meters\n* Add force distribution datafield\n* Only increase relative elevation gain when relative grade is positive",
"* Reduce refresh rate on K2, add refresh rate setting\n" +
"screenshotUrls" to listOf( "screenshotUrls" to listOf(
"https://github.com/timklge/karoo-headwind/releases/latest/download/preview1.png", "$baseUrl/preview1.png",
"https://github.com/timklge/karoo-headwind/releases/latest/download/preview3.png", "$baseUrl/preview3.png",
"https://github.com/timklge/karoo-headwind/releases/latest/download/preview2.png", "$baseUrl/preview2.png",
"https://github.com/timklge/karoo-headwind/releases/latest/download/preview0.png", "$baseUrl/preview0.png",
) )
) )
val gson = groovy.json.JsonBuilder(manifest).toPrettyString() val gson = groovy.json.JsonBuilder(manifest).toPrettyString()
manifestFile.writeText(gson) manifestFile.writeText(gson)
println("Generated manifest.json with version ${android.defaultConfig.versionName} (${android.defaultConfig.versionCode})") 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 +99,12 @@ tasks.named("assemble") {
dependsOn("generateManifest") dependsOn("generateManifest")
} }
tasks.withType<ProcessApplicationManifest>().configureEach {
if (name == "processDebugMainManifest" || name == "processReleaseMainManifest") {
dependsOn(tasks.named("generateManifest"))
}
}
dependencies { dependencies {
implementation(libs.mapbox.sdk.turf) implementation(libs.mapbox.sdk.turf)
implementation(libs.hammerhead.karoo.ext) implementation(libs.hammerhead.karoo.ext)

View File

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

View File

@ -210,6 +210,22 @@ fun Context.streamCurrentForecastWeatherData(): Flow<WeatherDataResponse?> {
}.distinctUntilChanged() }.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( fun lerpNullable(
start: Double?, start: Double?,
end: Double?, end: Double?,
@ -266,21 +282,21 @@ fun lerpWeather(
return WeatherData( return WeatherData(
time = (start.time + (end.time - start.time) * factor).toLong(), time = (start.time + (end.time - start.time) * factor).toLong(),
temperature = start.temperature + (end.temperature - start.temperature) * factor, 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, precipitation = start.precipitation + (end.precipitation - start.precipitation) * factor,
precipitationProbability = lerpNullable(start.precipitationProbability, end.precipitationProbability, factor), precipitationProbability = lerpNullable(start.precipitationProbability, end.precipitationProbability, factor),
cloudCover = lerpNullable(start.cloudCover, end.cloudCover, factor), cloudCover = lerp(start.cloudCover, end.cloudCover, factor),
surfacePressure = lerpNullable(start.surfacePressure, end.surfacePressure, factor), surfacePressure = lerp(start.surfacePressure, end.surfacePressure, factor),
sealevelPressure = lerpNullable(start.sealevelPressure, end.sealevelPressure, factor), sealevelPressure = lerp(start.sealevelPressure, end.sealevelPressure, factor),
windSpeed = start.windSpeed + (end.windSpeed - start.windSpeed) * factor, windSpeed = start.windSpeed + (end.windSpeed - start.windSpeed) * factor,
windDirection = lerpAngle(start.windDirection, end.windDirection, factor), windDirection = lerpAngle(start.windDirection, end.windDirection, factor),
windGusts = start.windGusts + (end.windGusts - start.windGusts) * factor, windGusts = start.windGusts + (end.windGusts - start.windGusts) * factor,
weatherCode = closestWeatherData.weatherCode, weatherCode = closestWeatherData.weatherCode,
isForecast = closestWeatherData.isForecast, isForecast = closestWeatherData.isForecast,
isNight = closestWeatherData.isNight, isNight = closestWeatherData.isNight,
uvi = start.uvi + (end.uvi - start.uvi) * factor
) )
} }
fun lerpWeatherTime( fun lerpWeatherTime(
weatherData: List<WeatherData>?, weatherData: List<WeatherData>?,
currentWeatherData: WeatherData currentWeatherData: WeatherData

View File

@ -2,7 +2,6 @@ package de.timklge.karooheadwind
import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.ActiveRidePage import io.hammerhead.karooext.models.ActiveRidePage
import io.hammerhead.karooext.models.OnLocationChanged
import io.hammerhead.karooext.models.OnNavigationState import io.hammerhead.karooext.models.OnNavigationState
import io.hammerhead.karooext.models.OnStreamState import io.hammerhead.karooext.models.OnStreamState
import io.hammerhead.karooext.models.RideState import io.hammerhead.karooext.models.RideState
@ -14,7 +13,6 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.flow.transform import kotlinx.coroutines.flow.transform
fun KarooSystemService.streamDataFlow(dataTypeId: String): Flow<StreamState> { fun KarooSystemService.streamDataFlow(dataTypeId: String): Flow<StreamState> {
@ -28,17 +26,6 @@ fun KarooSystemService.streamDataFlow(dataTypeId: String): Flow<StreamState> {
} }
} }
fun KarooSystemService.streamLocation(): Flow<OnLocationChanged> {
return callbackFlow {
val listenerId = addConsumer { event: OnLocationChanged ->
trySendBlocking(event)
}
awaitClose {
removeConsumer(listenerId)
}
}
}
fun KarooSystemService.streamNavigationState(): Flow<OnNavigationState> { fun KarooSystemService.streamNavigationState(): Flow<OnNavigationState> {
return callbackFlow { return callbackFlow {
val listenerId = addConsumer { event: OnNavigationState -> val listenerId = addConsumer { event: OnNavigationState ->

View File

@ -5,15 +5,18 @@ import android.util.Log
import de.timklge.karooheadwind.datatypes.GpsCoordinates import de.timklge.karooheadwind.datatypes.GpsCoordinates
import de.timklge.karooheadwind.util.signedAngleDifference import de.timklge.karooheadwind.util.signedAngleDifference
import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.DataType
import io.hammerhead.karooext.models.StreamState
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
sealed class HeadingResponse { sealed class HeadingResponse {
data object NoGps: HeadingResponse() data object NoGps: HeadingResponse()
@ -90,19 +93,59 @@ suspend fun KarooSystemService.updateLastKnownGps(context: Context) {
fun KarooSystemService.getGpsCoordinateFlow(context: Context): Flow<GpsCoordinates?> { fun KarooSystemService.getGpsCoordinateFlow(context: Context): Flow<GpsCoordinates?> {
/* return flow { /* return flow {
emit(GpsCoordinates(52.5164069,13.3784)) // emit(GpsCoordinates(52.5164069,13.3784))
emit(GpsCoordinates(32.46,-111.524))
awaitCancellation() awaitCancellation()
} */ } */
val initialFlow = flow { val initialFlow = flow {
val lastKnownPosition = context.getLastKnownPosition() val lastKnownPosition = context.getLastKnownPosition()
emit(lastKnownPosition) if (lastKnownPosition == null) {
val initialState = streamDataFlow(DataType.Type.LOCATION).firstOrNull()?.let { it as? StreamState.Streaming }
initialState?.dataPoint?.let { dataPoint ->
val lat = dataPoint.values[DataType.Field.LOC_LATITUDE]
val lng = dataPoint.values[DataType.Field.LOC_LONGITUDE]
val orientation = dataPoint.values[DataType.Field.LOC_BEARING]
val accuracy = dataPoint.values[DataType.Field.LOC_ACCURACY]
if (lat != null && lng != null && accuracy != null && accuracy < 500) {
emit(GpsCoordinates(lat, lng, orientation))
Log.i(KarooHeadwindExtension.TAG, "No last known position found, fetched initial GPS position")
} else {
emit(null)
Log.w(KarooHeadwindExtension.TAG, "No last known position found, initial GPS position is unavailable")
}
} ?: run {
emit(null)
Log.w(KarooHeadwindExtension.TAG, "No last known position found, initial GPS position is unavailable")
}
} else {
emit(lastKnownPosition)
Log.i(KarooHeadwindExtension.TAG, "Using last known position: $lastKnownPosition")
}
} }
val gpsFlow = streamLocation() val gpsFlow = streamDataFlow(DataType.Type.LOCATION).mapNotNull { it as? StreamState.Streaming }
.filter { it.orientation != null } .mapNotNull { dataPoint ->
.map { GpsCoordinates(it.lat, it.lng, it.orientation) } val lat = dataPoint.dataPoint.values[DataType.Field.LOC_LATITUDE]
val lng = dataPoint.dataPoint.values[DataType.Field.LOC_LONGITUDE]
val orientation = dataPoint.dataPoint.values[DataType.Field.LOC_BEARING]
val accuracy = dataPoint.dataPoint.values[DataType.Field.LOC_ACCURACY]
Log.i(KarooHeadwindExtension.TAG, "Received GPS update: lat=$lat, lng=$lng, accuracy=$accuracy, orientation=$orientation")
if (lat != null && lng != null && accuracy != null && accuracy < 500) {
GpsCoordinates(lat, lng, orientation)
} else {
null
}
}
val concatenatedFlow = concatenate(initialFlow, gpsFlow) val concatenatedFlow = concatenate(initialFlow, gpsFlow)

View File

@ -18,17 +18,6 @@ enum class PrecipitationUnit(val id: String, val label: String, val unitDisplay:
INCH("inch", "Inch", "in") 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){ enum class TemperatureUnit(val id: String, val label: String, val unitDisplay: String){
CELSIUS("celsius", "Celsius (°C)", "°C"), CELSIUS("celsius", "Celsius (°C)", "°C"),
FAHRENHEIT("fahrenheit", "Fahrenheit (°F)", "°F") FAHRENHEIT("fahrenheit", "Fahrenheit (°F)", "°F")
@ -91,8 +80,6 @@ enum class RefreshRate(val id: String, val k2Ms: Long, val k3Ms: Long) {
@Serializable @Serializable
data class HeadwindSettings( data class HeadwindSettings(
val welcomeDialogAccepted: Boolean = false, val welcomeDialogAccepted: Boolean = false,
val windDirectionIndicatorTextSetting: WindDirectionIndicatorTextSetting = WindDirectionIndicatorTextSetting.HEADWIND_SPEED,
val windDirectionIndicatorSetting: WindDirectionIndicatorSetting = WindDirectionIndicatorSetting.HEADWIND_DIRECTION,
val roundLocationTo: RoundLocationSetting = RoundLocationSetting.KM_3, val roundLocationTo: RoundLocationSetting = RoundLocationSetting.KM_3,
val forecastedKmPerHour: Int = 20, val forecastedKmPerHour: Int = 20,
val forecastedMilesPerHour: Int = 12, val forecastedMilesPerHour: Int = 12,

View File

@ -6,23 +6,24 @@ import com.mapbox.turf.TurfConstants
import com.mapbox.turf.TurfMeasurement import com.mapbox.turf.TurfMeasurement
import de.timklge.karooheadwind.datatypes.CloudCoverDataType import de.timklge.karooheadwind.datatypes.CloudCoverDataType
import de.timklge.karooheadwind.datatypes.GpsCoordinates import de.timklge.karooheadwind.datatypes.GpsCoordinates
import de.timklge.karooheadwind.datatypes.GraphicalForecastDataType
import de.timklge.karooheadwind.datatypes.HeadwindDirectionDataType import de.timklge.karooheadwind.datatypes.HeadwindDirectionDataType
import de.timklge.karooheadwind.datatypes.HeadwindForecastDataType
import de.timklge.karooheadwind.datatypes.HeadwindSpeedDataType import de.timklge.karooheadwind.datatypes.HeadwindSpeedDataType
import de.timklge.karooheadwind.datatypes.PrecipitationDataType import de.timklge.karooheadwind.datatypes.PrecipitationDataType
import de.timklge.karooheadwind.datatypes.PrecipitationForecastDataType import de.timklge.karooheadwind.datatypes.PrecipitationForecastDataType
import de.timklge.karooheadwind.datatypes.RelativeElevationGainDataType import de.timklge.karooheadwind.datatypes.RelativeElevationGainDataType
import de.timklge.karooheadwind.datatypes.RelativeGradeDataType import de.timklge.karooheadwind.datatypes.RelativeGradeDataType
import de.timklge.karooheadwind.datatypes.RelativeHumidityDataType import de.timklge.karooheadwind.datatypes.RelativeHumidityDataType
import de.timklge.karooheadwind.datatypes.ResistanceForcesDataType
import de.timklge.karooheadwind.datatypes.SealevelPressureDataType import de.timklge.karooheadwind.datatypes.SealevelPressureDataType
import de.timklge.karooheadwind.datatypes.SurfacePressureDataType import de.timklge.karooheadwind.datatypes.SurfacePressureDataType
import de.timklge.karooheadwind.datatypes.TailwindAndRideSpeedDataType import de.timklge.karooheadwind.datatypes.TailwindAndRideSpeedDataType
import de.timklge.karooheadwind.datatypes.TailwindDataType
import de.timklge.karooheadwind.datatypes.TemperatureDataType import de.timklge.karooheadwind.datatypes.TemperatureDataType
import de.timklge.karooheadwind.datatypes.TemperatureForecastDataType import de.timklge.karooheadwind.datatypes.TemperatureForecastDataType
import de.timklge.karooheadwind.datatypes.UserWindSpeedDataType import de.timklge.karooheadwind.datatypes.UviDataType
import de.timklge.karooheadwind.datatypes.WeatherDataType
import de.timklge.karooheadwind.datatypes.WeatherForecastDataType import de.timklge.karooheadwind.datatypes.WeatherForecastDataType
import de.timklge.karooheadwind.datatypes.WindDirectionAndSpeedDataType
import de.timklge.karooheadwind.datatypes.WindDirectionAndSpeedDataTypeCircle
import de.timklge.karooheadwind.datatypes.WindDirectionDataType import de.timklge.karooheadwind.datatypes.WindDirectionDataType
import de.timklge.karooheadwind.datatypes.WindForecastDataType import de.timklge.karooheadwind.datatypes.WindForecastDataType
import de.timklge.karooheadwind.datatypes.WindGustsDataType import de.timklge.karooheadwind.datatypes.WindGustsDataType
@ -46,8 +47,6 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.retry import kotlinx.coroutines.flow.retry
import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.time.debounce
import java.time.Duration
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
@ -69,26 +68,28 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", BuildConfig.VERS
listOf( listOf(
HeadwindDirectionDataType(karooSystem, applicationContext), HeadwindDirectionDataType(karooSystem, applicationContext),
TailwindAndRideSpeedDataType(karooSystem, applicationContext), TailwindAndRideSpeedDataType(karooSystem, applicationContext),
WeatherDataType(karooSystem, applicationContext), WindDirectionAndSpeedDataTypeCircle(karooSystem, applicationContext),
WeatherForecastDataType(karooSystem), WeatherForecastDataType(karooSystem),
HeadwindSpeedDataType(karooSystem, applicationContext), HeadwindSpeedDataType(karooSystem, applicationContext),
RelativeHumidityDataType(karooSystem, applicationContext), RelativeHumidityDataType(karooSystem, applicationContext),
CloudCoverDataType(karooSystem, applicationContext), CloudCoverDataType(karooSystem, applicationContext),
WindGustsDataType(karooSystem, applicationContext), WindGustsDataType(karooSystem, applicationContext),
WindSpeedDataType(karooSystem, applicationContext), WindSpeedDataType(karooSystem, applicationContext),
TemperatureDataType(karooSystem, applicationContext),
WindDirectionDataType(karooSystem, applicationContext), WindDirectionDataType(karooSystem, applicationContext),
WindDirectionAndSpeedDataType(karooSystem, applicationContext),
PrecipitationDataType(karooSystem, applicationContext), PrecipitationDataType(karooSystem, applicationContext),
SurfacePressureDataType(karooSystem, applicationContext), SurfacePressureDataType(karooSystem, applicationContext),
SealevelPressureDataType(karooSystem, applicationContext), SealevelPressureDataType(karooSystem, applicationContext),
UserWindSpeedDataType(karooSystem, applicationContext),
TemperatureForecastDataType(karooSystem), TemperatureForecastDataType(karooSystem),
PrecipitationForecastDataType(karooSystem), PrecipitationForecastDataType(karooSystem),
WindForecastDataType(karooSystem), WindForecastDataType(karooSystem),
GraphicalForecastDataType(karooSystem), HeadwindForecastDataType(karooSystem),
TailwindDataType(karooSystem, applicationContext), WindDirectionAndSpeedDataType(karooSystem, applicationContext),
RelativeGradeDataType(karooSystem, applicationContext), RelativeGradeDataType(karooSystem, applicationContext),
RelativeElevationGainDataType(karooSystem, applicationContext), RelativeElevationGainDataType(karooSystem, applicationContext),
TemperatureDataType(karooSystem, applicationContext),
UviDataType(karooSystem, applicationContext),
ResistanceForcesDataType(karooSystem, applicationContext)
) )
} }
@ -120,7 +121,7 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", BuildConfig.VERS
old == new old == new
} }
} }
.debounce(Duration.ofSeconds(5)) .throttle(5_000L)
var requestedGpsCoordinates: List<GpsCoordinates> = emptyList() var requestedGpsCoordinates: List<GpsCoordinates> = emptyList()
@ -169,7 +170,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") Log.d(TAG, "Minutes to next full hour: ${msToNextFullHour / 1000 / 60}, Distance to next full hour: ${(calculatedDistanceToNextFullHour / 1000).roundToInt()}km")
requestedGpsCoordinates = buildList { requestedGpsCoordinates = buildList {
add(gps) add(GpsCoordinates(gps.lat, gps.lon, gps.bearing, distanceAlongRoute = positionOnRoute))
var currentPosition = positionOnRoute + calculatedDistanceToNextFullHour var currentPosition = positionOnRoute + calculatedDistanceToNextFullHour
var lastRequestedPosition = positionOnRoute var lastRequestedPosition = positionOnRoute
@ -239,7 +240,7 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", BuildConfig.VERS
response response
}.retry(Long.MAX_VALUE) { e -> }.retry(Long.MAX_VALUE) { e ->
Log.w(TAG, "Failed to get weather data", e) Log.w(TAG, "Failed to get weather data", e)
delay(1.minutes); true delay(2.minutes); true
}.collect { response -> }.collect { response ->
try { try {
saveCurrentData(applicationContext, response) saveCurrentData(applicationContext, response)

View File

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

View File

@ -3,9 +3,10 @@ package de.timklge.karooheadwind.datatypes
import android.content.Context import android.content.Context
import de.timklge.karooheadwind.weatherprovider.WeatherData import de.timklge.karooheadwind.weatherprovider.WeatherData
import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.UserProfile
class CloudCoverDataType(karooSystemService: KarooSystemService, context: Context) : BaseDataType(karooSystemService, context, "cloudCover"){ 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 return data.cloudCover
} }
} }

View File

@ -25,9 +25,9 @@ class CycleHoursAction : ActionCallback {
var hourOffset = currentSettings.currentForecastHourOffset + 3 var hourOffset = currentSettings.currentForecastHourOffset + 3
val requestedPositions = forecastData?.data?.size val requestedPositions = forecastData?.data?.size
val requestedHours = forecastData?.data?.firstOrNull()?.forecasts?.size val requestedHours = forecastData?.data?.firstOrNull()?.forecasts?.size?.coerceAtMost(6)
if (forecastData == null || requestedHours == null || requestedPositions == null || hourOffset >= requestedHours || (requestedPositions in 2..hourOffset)) { if (forecastData == null || requestedHours == null || requestedPositions == null || (requestedPositions == 1 && hourOffset >= requestedHours) || (requestedPositions in 2..hourOffset)) {
hourOffset = 0 hourOffset = 0
} }

View File

@ -37,6 +37,10 @@ import de.timklge.karooheadwind.streamUpcomingRoute
import de.timklge.karooheadwind.streamUserProfile import de.timklge.karooheadwind.streamUserProfile
import de.timklge.karooheadwind.streamWidgetSettings import de.timklge.karooheadwind.streamWidgetSettings
import de.timklge.karooheadwind.throttle import de.timklge.karooheadwind.throttle
import de.timklge.karooheadwind.util.celciusInUserUnit
import de.timklge.karooheadwind.util.getTimeFormatter
import de.timklge.karooheadwind.util.millimetersInUserUnit
import de.timklge.karooheadwind.util.msInUserUnit
import de.timklge.karooheadwind.weatherprovider.WeatherData import de.timklge.karooheadwind.weatherprovider.WeatherData
import de.timklge.karooheadwind.weatherprovider.WeatherDataForLocation import de.timklge.karooheadwind.weatherprovider.WeatherDataForLocation
import de.timklge.karooheadwind.weatherprovider.WeatherDataResponse import de.timklge.karooheadwind.weatherprovider.WeatherDataResponse
@ -61,35 +65,33 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.Instant import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.roundToInt import kotlin.math.roundToInt
abstract class ForecastDataType(private val karooSystem: KarooSystemService, typeId: String) : DataTypeImpl("karoo-headwind", typeId) { abstract class ForecastDataType(private val karooSystem: KarooSystemService, typeId: String) : DataTypeImpl("karoo-headwind", typeId) {
@Composable @Composable
abstract fun RenderWidget(arrowBitmap: Bitmap, abstract fun RenderWidget(
current: WeatherInterpretation, arrowBitmap: Bitmap,
windBearing: Int, current: WeatherInterpretation,
windSpeed: Int, windBearing: Int,
windGusts: Int, windSpeed: Int,
precipitation: Double, windGusts: Int,
precipitationProbability: Int?, precipitation: Double,
temperature: Int, precipitationProbability: Int?,
temperatureUnit: TemperatureUnit, temperature: Int,
timeLabel: String, temperatureUnit: TemperatureUnit,
dateLabel: String?, timeLabel: String,
distance: Double?, dateLabel: String?,
isImperial: Boolean, distance: Double?,
isNight: Boolean) isImperial: Boolean,
isNight: Boolean,
uvi: Double
)
@OptIn(ExperimentalGlanceRemoteViewsApi::class) @OptIn(ExperimentalGlanceRemoteViewsApi::class)
private val glance = GlanceRemoteViews() private val glance = GlanceRemoteViews()
companion object {
val timeFormatter = DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneId.systemDefault())
}
data class StreamData(val data: WeatherDataResponse?, val settings: SettingsAndProfile, data class StreamData(val data: WeatherDataResponse?, val settings: SettingsAndProfile,
val widgetSettings: HeadwindWidgetSettings? = null, val widgetSettings: HeadwindWidgetSettings? = null,
val headingResponse: HeadingResponse? = null, val upcomingRoute: UpcomingRoute? = null, val isVisible: Boolean) val headingResponse: HeadingResponse? = null, val upcomingRoute: UpcomingRoute? = null, val isVisible: Boolean)
@ -107,6 +109,7 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ
val weatherData = (0..<12).map { val weatherData = (0..<12).map {
val forecastTime = timeAtFullHour + it * 60 * 60 val forecastTime = timeAtFullHour + it * 60 * 60
val forecastTemperature = 20.0 + (-20..20).random() val forecastTemperature = 20.0 + (-20..20).random()
val forecastUvi = 0.0 + (0..12).random().toDouble()
val forecastPrecipitation = 0.0 + (0..10).random() val forecastPrecipitation = 0.0 + (0..10).random()
val forecastPrecipitationProbability = (0..100).random() val forecastPrecipitationProbability = (0..100).random()
val forecastWeatherCode = WeatherInterpretation.getKnownWeatherCodes().random() val forecastWeatherCode = WeatherInterpretation.getKnownWeatherCodes().random()
@ -116,7 +119,7 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ
WeatherData( WeatherData(
time = forecastTime, time = forecastTime,
temperature = forecastTemperature, temperature = forecastTemperature,
relativeHumidity = 20.0, relativeHumidity = 20,
precipitation = forecastPrecipitation, precipitation = forecastPrecipitation,
cloudCover = 3.0, cloudCover = 3.0,
sealevelPressure = 1013.25, sealevelPressure = 1013.25,
@ -127,7 +130,8 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ
windGusts = forecastWindGusts, windGusts = forecastWindGusts,
weatherCode = forecastWeatherCode, weatherCode = forecastWeatherCode,
isForecast = true, isForecast = true,
isNight = it < 2 isNight = it < 2,
uvi = forecastUvi
) )
} }
@ -139,7 +143,7 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ
current = WeatherData( current = WeatherData(
time = timeAtFullHour, time = timeAtFullHour,
temperature = 20.0, temperature = 20.0,
relativeHumidity = 20.0, relativeHumidity = 20,
precipitation = 0.0, precipitation = 0.0,
cloudCover = 3.0, cloudCover = 3.0,
sealevelPressure = 1013.25, sealevelPressure = 1013.25,
@ -149,7 +153,8 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ
windGusts = 10.0, windGusts = 10.0,
weatherCode = WeatherInterpretation.getKnownWeatherCodes().random(), weatherCode = WeatherInterpretation.getKnownWeatherCodes().random(),
isForecast = false, isForecast = false,
isNight = false isNight = false,
uvi = 2.0
), ),
coords = GpsCoordinates(0.0, 0.0, distanceAlongRoute = index * distancePerHour), coords = GpsCoordinates(0.0, 0.0, distanceAlongRoute = index * distancePerHour),
timezone = "UTC", timezone = "UTC",
@ -253,7 +258,7 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ
if (!config.preview) modifier = modifier.clickable(onClick = actionRunCallback<CycleHoursAction>()) if (!config.preview) modifier = modifier.clickable(onClick = actionRunCallback<CycleHoursAction>())
Row(modifier = modifier, horizontalAlignment = Alignment.Horizontal.Start) { Row(modifier = modifier, horizontalAlignment = Alignment.Horizontal.CenterHorizontally) {
val hourOffset = widgetSettings?.currentForecastHourOffset ?: 0 val hourOffset = widgetSettings?.currentForecastHourOffset ?: 0
val positionOffset = if (allData?.data?.size == 1) 0 else hourOffset val positionOffset = if (allData?.data?.size == 1) 0 else hourOffset
@ -267,6 +272,9 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ
} }
for (baseIndex in hourOffset..hourOffset + 2) { for (baseIndex in hourOffset..hourOffset + 2) {
// Only show first value if placed in a 1x1 grid cell
if (baseIndex > 0 && config.gridSize.first == 30) break
val positionIndex = if (allData?.data?.size == 1) 0 else baseIndex val positionIndex = if (allData?.data?.size == 1) 0 else baseIndex
if (allData?.data?.getOrNull(positionIndex) == null) break if (allData?.data?.getOrNull(positionIndex) == null) break
@ -298,30 +306,40 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ
val isCurrent = baseIndex == 0 && positionIndex == 0 val isCurrent = baseIndex == 0 && positionIndex == 0
val time = if (isCurrent && data?.current != null) {
Instant.ofEpochSecond(data.current.time)
} else {
Instant.ofEpochSecond(data?.forecasts?.getOrNull(baseIndex)?.time ?: 0)
}
if (time.isBefore(Instant.now().minus(1, ChronoUnit.HOURS)) || (upcomingRoute == null && time.isAfter(Instant.now().plus(6, ChronoUnit.HOURS)))) {
Log.d(KarooHeadwindExtension.TAG, "Skipping forecast data for time $time as it is in the past or too close to now")
continue
}
if (isCurrent && data?.current != null) { if (isCurrent && data?.current != null) {
val interpretation = WeatherInterpretation.fromWeatherCode(data.current.weatherCode) val interpretation = WeatherInterpretation.fromWeatherCode(data.current.weatherCode)
val unixTime = data.current.time val unixTime = data.current.time
val formattedTime = val formattedTime = getTimeFormatter(context).format(Instant.ofEpochSecond(unixTime).atZone(ZoneId.systemDefault()).toLocalTime())
timeFormatter.format(Instant.ofEpochSecond(unixTime)) val formattedDate = getShortDateFormatter().format(Instant.ofEpochSecond(unixTime).atZone(ZoneId.systemDefault()))
val formattedDate =
getShortDateFormatter().format(Instant.ofEpochSecond(unixTime))
val hasNewDate = formattedDate != previousDate || baseIndex == 0 val hasNewDate = formattedDate != previousDate || baseIndex == 0
RenderWidget( RenderWidget(
arrowBitmap = baseBitmap, arrowBitmap = baseBitmap,
current = interpretation, current = interpretation,
windBearing = data.current.windDirection.roundToInt(), windBearing = data.current.windDirection.roundToInt(),
windSpeed = data.current.windSpeed.roundToInt(), windSpeed = msInUserUnit(data.current.windSpeed, settingsAndProfile.isImperial).roundToInt(),
windGusts = data.current.windGusts.roundToInt(), windGusts = msInUserUnit(data.current.windGusts, settingsAndProfile.isImperial).roundToInt(),
precipitation = data.current.precipitation, precipitation = millimetersInUserUnit(data.current.precipitation, settingsAndProfile.isImperial),
precipitationProbability = null, precipitationProbability = null,
temperature = data.current.temperature.roundToInt(), temperature = celciusInUserUnit(data.current.temperature, settingsAndProfile.isImperialTemperature).roundToInt(),
temperatureUnit = if (settingsAndProfile.isImperialTemperature) TemperatureUnit.FAHRENHEIT else TemperatureUnit.CELSIUS, temperatureUnit = if (settingsAndProfile.isImperialTemperature) TemperatureUnit.FAHRENHEIT else TemperatureUnit.CELSIUS,
timeLabel = formattedTime, timeLabel = formattedTime,
dateLabel = if (hasNewDate) formattedDate else null, dateLabel = if (hasNewDate) formattedDate else null,
distance = null, distance = null,
isImperial = settingsAndProfile.isImperial, isImperial = settingsAndProfile.isImperial,
isNight = data.current.isNight isNight = data.current.isNight,
uvi = data.current.uvi
) )
previousDate = formattedDate previousDate = formattedDate
@ -329,7 +347,7 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ
val weatherData = data?.forecasts?.getOrNull(baseIndex) val weatherData = data?.forecasts?.getOrNull(baseIndex)
val interpretation = WeatherInterpretation.fromWeatherCode(weatherData?.weatherCode ?: 0) val interpretation = WeatherInterpretation.fromWeatherCode(weatherData?.weatherCode ?: 0)
val unixTime = data?.forecasts?.getOrNull(baseIndex)?.time ?: 0 val unixTime = data?.forecasts?.getOrNull(baseIndex)?.time ?: 0
val formattedTime = timeFormatter.format(Instant.ofEpochSecond(unixTime)) val formattedTime = getTimeFormatter(context).format(Instant.ofEpochSecond(unixTime).atZone(ZoneId.systemDefault()).toLocalTime())
val formattedDate = getShortDateFormatter().format(Instant.ofEpochSecond(unixTime)) val formattedDate = getShortDateFormatter().format(Instant.ofEpochSecond(unixTime))
val hasNewDate = formattedDate != previousDate || baseIndex == 0 val hasNewDate = formattedDate != previousDate || baseIndex == 0
@ -337,17 +355,18 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ
arrowBitmap = baseBitmap, arrowBitmap = baseBitmap,
current = interpretation, current = interpretation,
windBearing = weatherData?.windDirection?.roundToInt() ?: 0, windBearing = weatherData?.windDirection?.roundToInt() ?: 0,
windSpeed = weatherData?.windSpeed?.roundToInt() ?: 0, windSpeed = msInUserUnit(weatherData?.windSpeed ?: 0.0, settingsAndProfile.isImperial).roundToInt(),
windGusts = weatherData?.windGusts?.roundToInt() ?: 0, windGusts = msInUserUnit(weatherData?.windGusts ?: 0.0, settingsAndProfile.isImperial).roundToInt(),
precipitation = weatherData?.precipitation ?: 0.0, precipitation = millimetersInUserUnit(weatherData?.precipitation ?: 0.0, settingsAndProfile.isImperial),
precipitationProbability = weatherData?.precipitationProbability?.toInt(), 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, temperatureUnit = if (settingsAndProfile.isImperialTemperature) TemperatureUnit.FAHRENHEIT else TemperatureUnit.CELSIUS,
timeLabel = formattedTime, timeLabel = formattedTime,
dateLabel = if (hasNewDate) formattedDate else null, dateLabel = if (hasNewDate) formattedDate else null,
distance = if (settingsAndProfile.settings.showDistanceInForecast) distanceFromCurrent else null, distance = if (settingsAndProfile.settings.showDistanceInForecast) distanceFromCurrent else null,
isImperial = settingsAndProfile.isImperial, isImperial = settingsAndProfile.isImperial,
isNight = weatherData?.isNight == true isNight = weatherData?.isNight == true,
uvi = weatherData?.uvi ?: 0.0
) )
previousDate = formattedDate previousDate = formattedDate

View File

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

View File

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

View File

@ -67,7 +67,7 @@ fun HeadwindDirection(
val baseModifier = GlanceModifier.fillMaxSize().padding(5.dp).background(dayColor, nightColor).cornerRadius(10.dp) val baseModifier = GlanceModifier.fillMaxSize().padding(5.dp).background(dayColor, nightColor).cornerRadius(10.dp)
Box( Box(
modifier = baseModifier, // TODO if (!preview) baseModifier.clickable(actionStartActivity<MainActivity>()) else baseModifier, modifier = if (!preview) baseModifier.clickable(actionStartActivity<MainActivity>()) else baseModifier,
contentAlignment = Alignment( contentAlignment = Alignment(
vertical = Alignment.Vertical.CenterVertically, vertical = Alignment.Vertical.CenterVertically,
horizontal = Alignment.Horizontal.CenterHorizontally, horizontal = Alignment.Horizontal.CenterHorizontally,

View File

@ -0,0 +1,148 @@
package de.timklge.karooheadwind.datatypes
import android.content.Context
import android.util.Log
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.core.content.ContextCompat
import com.mapbox.turf.TurfConstants
import com.mapbox.turf.TurfMeasurement
import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.R
import de.timklge.karooheadwind.UpcomingRoute
import de.timklge.karooheadwind.lerpWeather
import de.timklge.karooheadwind.screens.LineGraphBuilder
import de.timklge.karooheadwind.screens.isNightMode
import de.timklge.karooheadwind.util.signedAngleDifference
import io.hammerhead.karooext.KarooSystemService
import kotlin.math.ceil
import kotlin.math.cos
import kotlin.math.floor
fun interpolateWindLineColor(windSpeedInKmh: Double, night: Boolean, context: Context): androidx.compose.ui.graphics.Color {
val default = Color(ContextCompat.getColor(context, R.color.gray))
val green = Color(ContextCompat.getColor(context, R.color.green))
val red = Color(ContextCompat.getColor(context, R.color.red))
val orange = Color(ContextCompat.getColor(context, R.color.orange))
return when {
windSpeedInKmh <= -10 -> green
windSpeedInKmh >= 15 -> red
windSpeedInKmh in -10.0..0.0 -> interpolateColor(green, default, -10.0, 0.0, windSpeedInKmh)
windSpeedInKmh in 0.0..10.0 -> interpolateColor(default, orange, 0.0, 10.0, windSpeedInKmh)
else -> interpolateColor(orange, red, 10.0, 15.0, windSpeedInKmh)
}
}
class HeadwindForecastDataType(karooSystem: KarooSystemService) : LineGraphForecastDataType(karooSystem, "headwindForecast") {
override fun getLineData(
lineData: List<LineData>,
isImperial: Boolean,
upcomingRoute: UpcomingRoute?,
isPreview: Boolean,
context: Context
): LineGraphForecastData {
if (upcomingRoute == null && !isPreview){
return LineGraphForecastData.Error("No route loaded")
}
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 headwindPoints = try {
(0..<HEADWIND_SAMPLE_COUNT).mapNotNull { i ->
val t = i / HEADWIND_SAMPLE_COUNT.toDouble()
if (isPreview) {
// Use a sine wave for headwind preview speed
val headwindSpeed = 10f * kotlin.math.sin(i * Math.PI * 2 / HEADWIND_SAMPLE_COUNT).toFloat()
return@mapNotNull LineGraphBuilder.DataPoint(x = i.toFloat() * (windPoints.size / HEADWIND_SAMPLE_COUNT.toFloat()),
y = headwindSpeed)
}
if (upcomingRoute == null) {
Log.e(KarooHeadwindExtension.TAG, "Upcoming route is null")
return@mapNotNull null
}
val beforeLineData = lineData.getOrNull(floor((lineData.size) * t).toInt().coerceAtLeast(0)) ?: lineData.firstOrNull()
val afterLineData = lineData.getOrNull(ceil((lineData.size) * t).toInt().coerceAtLeast(0)) ?: lineData.lastOrNull()
if (beforeLineData?.weatherData == null || afterLineData?.weatherData == null || beforeLineData.distance == null
|| afterLineData.distance == null || beforeLineData == afterLineData) 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 = try {
TurfMeasurement.along(upcomingRoute.routePolyline, distanceAlongRoute, TurfConstants.UNIT_METERS)
} catch(e: Exception) {
Log.e(KarooHeadwindExtension.TAG, "Error getting coordinates along route", e)
return@mapNotNull null
}
val nextCoordsAlongRoute = try {
TurfMeasurement.along(upcomingRoute.routePolyline, distanceAlongRoute + 5, TurfConstants.UNIT_METERS)
} catch(e: Exception) {
Log.e(KarooHeadwindExtension.TAG, "Error getting next coordinates along route", e)
return@mapNotNull null
}
val bearingAlongRoute = try {
TurfMeasurement.bearing(coordsAlongRoute, nextCoordsAlongRoute)
} catch(e: Exception) {
Log.e(KarooHeadwindExtension.TAG, "Error calculating bearing along route", e)
return@mapNotNull null
}
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(
x = i.toFloat() * (windPoints.size / HEADWIND_SAMPLE_COUNT.toFloat()),
y = headwindSpeedInUserUnit.toFloat()
)
}
} catch(e: Exception) {
Log.e(KarooHeadwindExtension.TAG, "Error calculating headwind points", e)
emptyList()
}
return LineGraphForecastData.LineData(buildSet {
if (headwindPoints.isNotEmpty()) {
add(LineGraphBuilder.Line(
dataPoints = headwindPoints,
color = android.graphics.Color.BLACK,
label = "Head", // if (!isImperial) "Headwind km/h" else "Headwind mph",
drawCircles = false,
colorFunc = { headwindSpeed ->
val headwindSpeedInKmh = headwindSpeed * 3.6 // Convert m/s to km/h
interpolateWindLineColor(headwindSpeedInKmh, isNightMode(context), context).toArgb()
},
alpha = 255
))
}
})
}
companion object {
const val HEADWIND_SAMPLE_COUNT = 70
}
}

View File

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

View File

@ -0,0 +1,335 @@
package de.timklge.karooheadwind.datatypes
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.util.Log
import androidx.compose.ui.unit.DpSize
import androidx.core.graphics.createBitmap
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.R
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.util.getTimeFormatter
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.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) {
sealed class LineGraphForecastData {
data class LineData(val data: Set<LineGraphBuilder.Line>) : LineGraphForecastData()
data class Error(val message: String) : LineGraphForecastData()
}
@OptIn(ExperimentalGlanceRemoteViewsApi::class)
private val glance = GlanceRemoteViews()
data class StreamData(val data: WeatherDataResponse?, val settings: SettingsAndProfile,
val widgetSettings: HeadwindWidgetSettings? = null,
val headingResponse: HeadingResponse? = null, val upcomingRoute: UpcomingRoute? = null, val isVisible: Boolean)
data class SettingsAndProfile(val settings: HeadwindSettings, val isImperial: Boolean, val isImperialTemperature: Boolean)
data class LineData(val time: Instant? = null, val distance: Float? = null, val weatherData: WeatherData)
abstract fun getLineData(
lineData: List<LineData>,
isImperial: Boolean,
upcomingRoute: UpcomingRoute?,
isPreview: Boolean,
context: Context
): LineGraphForecastData
private fun previewFlow(settingsAndProfileStream: Flow<SettingsAndProfile>): Flow<StreamData> =
flow {
val settingsAndProfile = settingsAndProfileStream.firstOrNull()
while (true) {
val data = (0..<12).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()
val forcastUvi = 0.0 + (0..12).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,
uvi = forcastUvi
)
}
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,
uvi = 2.0
),
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, _, headingResponse, upcomingRoute) ->
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 isRouteLoaded = if (config.preview){
true
} else {
upcomingRoute != null
}
val locationData = if (isRouteLoaded){
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)
if (time.isBefore(Instant.now().minus(1, ChronoUnit.HOURS)) || (locationData?.coords?.distanceAlongRoute == null && time.isAfter(Instant.now().plus(6, ChronoUnit.HOURS)))) {
Log.d(KarooHeadwindExtension.TAG, "Skipping forecast data for time $time as it is in the past or too close to now")
continue
}
add(LineData(
time = time,
distance = locationData?.coords?.distanceAlongRoute?.toFloat(),
weatherData = data,
))
}
}
val pointData = getLineData(
data,
settingsAndProfile.isImperialTemperature,
upcomingRoute,
config.preview,
context
)
when (pointData) {
is LineGraphForecastData.LineData -> {
val bitmap = LineGraphBuilder(context).drawLineGraph(config.viewSize.first, config.viewSize.second, config.gridSize.first, config.gridSize.second, pointData.data) { x ->
val startTime = data.firstOrNull()?.time
val time = startTime?.plus(floor(x).toLong(), ChronoUnit.HOURS)
val timeLabel = getTimeFormatter(context).format(time?.atZone(ZoneId.systemDefault())?.toLocalTime())
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()}"
} else {
"${(distance / 1000).toInt()}"
}
return@drawLineGraph distanceLabel
} else {
timeLabel
}
}
Box(modifier = GlanceModifier.fillMaxSize()){
Image(ImageProvider(bitmap), "Forecast", modifier = GlanceModifier.fillMaxSize())
}
}
is LineGraphForecastData.Error -> {
emitter.onNext(ShowCustomStreamState(pointData.message, null))
Box(modifier = GlanceModifier.fillMaxSize()){
}
}
}
}
emitter.updateView(result.remoteViews)
}
}
emitter.setCancellable {
Log.d(
KarooHeadwindExtension.TAG,
"Stopping headwind weather forecast view with $emitter"
)
configJob.cancel()
viewJob.cancel()
}
}
}

View File

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

View File

@ -1,108 +1,48 @@
package de.timklge.karooheadwind.datatypes package de.timklge.karooheadwind.datatypes
import android.graphics.Bitmap import android.content.Context
import androidx.compose.runtime.Composable import de.timklge.karooheadwind.UpcomingRoute
import androidx.compose.ui.graphics.Color import de.timklge.karooheadwind.screens.LineGraphBuilder
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 io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.KarooSystemService
import kotlin.math.absoluteValue
import kotlin.math.ceil
@Composable class PrecipitationForecastDataType(karooSystem: KarooSystemService) : LineGraphForecastDataType(karooSystem, "precipitationForecast") {
fun PrecipitationForecast( override fun getLineData(
precipitation: Int, lineData: List<LineData>,
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?,
isImperial: Boolean, isImperial: Boolean,
isNight: Boolean, upcomingRoute: UpcomingRoute?,
) { isPreview: Boolean,
PrecipitationForecast( context: Context
precipitation = ceil(precipitation).toInt(), ): LineGraphForecastData {
precipitationProbability = precipitationProbability, val precipitationPoints = lineData.map { data ->
distance = distance, if (isImperial) { // Convert mm to inches
timeLabel = timeLabel, data.weatherData.precipitation * 0.0393701 // Convert mm to inches
isImperial = isImperial, } else {
) data.weatherData.precipitation
}
}
val precipitationPropagation = lineData.map { data ->
(data.weatherData.precipitationProbability?.coerceAtMost(99.0)) ?: 0.0 // Max 99 % so that the label doesn't take up too much space
}
return LineGraphForecastData.LineData(setOf(
LineGraphBuilder.Line(
dataPoints = precipitationPoints.mapIndexed { index, value ->
LineGraphBuilder.DataPoint(index.toFloat(), value.toFloat())
},
color = android.graphics.Color.BLUE,
label = if (!isImperial) "mm" else "in",
),
LineGraphBuilder.Line(
dataPoints = precipitationPropagation.mapIndexed { index, value ->
LineGraphBuilder.DataPoint(index.toFloat(), value.toFloat())
},
color = android.graphics.Color.CYAN,
label = "%",
yAxis = LineGraphBuilder.YAxis.RIGHT
)
))
} }
} }

View File

@ -30,7 +30,7 @@ class RelativeElevationGainDataType(private val karooSystemService: KarooSystemS
val gradeDifferenceDueToWind = relativeGrade - actualGrade val gradeDifferenceDueToWind = relativeGrade - actualGrade
var intervalWindElevation = 0.0 var intervalWindElevation = 0.0
if (gradeDifferenceDueToWind > 0) { if (gradeDifferenceDueToWind > 0 && relativeGrade > 0) {
val distanceCovered = riderSpeed * deltaTime val distanceCovered = riderSpeed * deltaTime
intervalWindElevation = distanceCovered * gradeDifferenceDueToWind intervalWindElevation = distanceCovered * gradeDifferenceDueToWind
} }

View File

@ -4,11 +4,9 @@ import android.content.Context
import android.util.Log import android.util.Log
import de.timklge.karooheadwind.HeadingResponse import de.timklge.karooheadwind.HeadingResponse
import de.timklge.karooheadwind.KarooHeadwindExtension import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.WeatherDataProvider
import de.timklge.karooheadwind.getRelativeHeadingFlow import de.timklge.karooheadwind.getRelativeHeadingFlow
import de.timklge.karooheadwind.streamCurrentWeatherData import de.timklge.karooheadwind.streamCurrentWeatherData
import de.timklge.karooheadwind.streamDataFlow import de.timklge.karooheadwind.streamDataFlow
import de.timklge.karooheadwind.streamSettings
import de.timklge.karooheadwind.streamUserProfile import de.timklge.karooheadwind.streamUserProfile
import de.timklge.karooheadwind.throttle import de.timklge.karooheadwind.throttle
import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.KarooSystemService
@ -19,7 +17,6 @@ import io.hammerhead.karooext.models.DataPoint
import io.hammerhead.karooext.models.DataType import io.hammerhead.karooext.models.DataType
import io.hammerhead.karooext.models.StreamState import io.hammerhead.karooext.models.StreamState
import io.hammerhead.karooext.models.UpdateGraphicConfig import io.hammerhead.karooext.models.UpdateGraphicConfig
import io.hammerhead.karooext.models.UserProfile
import io.hammerhead.karooext.models.ViewConfig import io.hammerhead.karooext.models.ViewConfig
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -36,12 +33,88 @@ import kotlin.math.cos
class RelativeGradeDataType(private val karooSystemService: KarooSystemService, private val context: Context): DataTypeImpl("karoo-headwind", "relativeGrade") { class RelativeGradeDataType(private val karooSystemService: KarooSystemService, private val context: Context): DataTypeImpl("karoo-headwind", "relativeGrade") {
data class RelativeGradeResponse(val relativeGrade: Double, val actualGrade: Double, val riderSpeed: Double) data class RelativeGradeResponse(val relativeGrade: Double, val actualGrade: Double, val riderSpeed: Double)
data class ResistanceForces(
val airResistanceWithoutWind: Double,
val airResistanceWithWind: Double,
val rollingResistance: Double,
val gravitationalForce: Double
)
companion object { companion object {
// Default physical constants - adjust as needed // Default physical constants - adjust as needed
const val DEFAULT_GRAVITY = 9.80665 // Acceleration due to gravity (m/s^2) const val DEFAULT_GRAVITY = 9.80665 // Acceleration due to gravity (m/s^2)
const val DEFAULT_AIR_DENSITY = 1.225 // Air density at sea level, 15°C (kg/m^3) const val DEFAULT_AIR_DENSITY = 1.225 // Air density at sea level, 15°C (kg/m^3)
const val DEFAULT_CDA = 0.4 // Default coefficient of drag * frontal area (m^2). Varies significantly with rider position and equipment. const val DEFAULT_CDA = 0.4 // Default coefficient of drag * frontal area (m^2). Varies significantly with rider position and equipment.
const val DEFAULT_BIKE_WEIGHT = 10.0 // Default bike weight (kg). const val DEFAULT_BIKE_WEIGHT = 9.0 // Default bike weight (kg).
const val DEFAULT_CRR = 0.005 // Default coefficient of rolling resistance
/**
* Estimates the various resistance forces acting on a cyclist.
*
* @param actualGrade The current gradient of the road (unitless, e.g., 0.05 for 5%).
* @param riderSpeed The speed of the rider relative to the ground (m/s). Must be non-negative.
* @param windSpeed The speed of the wind relative to the ground (m/s). Must be non-negative.
* @param windDirectionDegrees The direction of the wind relative to the rider's direction
* of travel (degrees).
* 0 = direct headwind, 90 = crosswind right,
* 180 = direct tailwind, 270 = crosswind left.
* @param totalMass The combined mass of the rider and the bike (kg). Must be positive.
* @param cda The rider's coefficient of drag multiplied by their frontal area (m^2).
* Defaults to DEFAULT_CDA. Represents aerodynamic efficiency.
* @param crr The coefficient of rolling resistance. Defaults to DEFAULT_CRR.
* @param airDensity The density of the air (kg/m^3). Defaults to DEFAULT_AIR_DENSITY.
* @param g The acceleration due to gravity (m/s^2). Defaults to DEFAULT_GRAVITY.
* @return A [ResistanceForces] object containing the calculated forces, or null
* if input parameters are invalid.
*/
fun estimateResistanceForces(
actualGrade: Double,
riderSpeed: Double,
windSpeed: Double,
windDirectionDegrees: Double,
totalMass: Double,
cda: Double = DEFAULT_CDA,
crr: Double = DEFAULT_CRR,
airDensity: Double = DEFAULT_AIR_DENSITY,
g: Double = DEFAULT_GRAVITY
): ResistanceForces? {
// --- Input Validation ---
if (totalMass <= 0.0 || riderSpeed < 0.0 || windSpeed < 0.0 || g <= 0.0 || airDensity < 0.0 || cda < 0.0 || crr < 0.0) {
Log.w(KarooHeadwindExtension.TAG, "Warning: Invalid input parameters for force calculation.")
return null
}
// 1. Calculate wind component parallel to rider's direction
val windComponentParallel = windSpeed * cos(Math.toRadians(windDirectionDegrees))
// 2. Calculate effective air speed
val effectiveAirSpeed = riderSpeed + windComponentParallel
// 3. Calculate aerodynamic resistance factor
val aeroFactor = 0.5 * airDensity * cda
// 4. Calculate air resistance forces
// Drag Force = aeroFactor * speed^2 * sign(speed)
val airResistanceWithWind = aeroFactor * effectiveAirSpeed * abs(effectiveAirSpeed)
val airResistanceWithoutWind = aeroFactor * riderSpeed * abs(riderSpeed)
// 5. Calculate gravitational force (force due to slope)
// Decomposing the gravitational force along the slope
val gravitationalForce = totalMass * g * actualGrade
// 6. Calculate rolling resistance force
// This is simplified; in reality, it's perpendicular to the road surface.
// F_rolling = Crr * N = Crr * m * g * cos(arctan(grade))
// For small angles, cos(arctan(grade)) is close to 1, so we approximate.
val rollingResistance = totalMass * g * crr
return ResistanceForces(
airResistanceWithoutWind = airResistanceWithoutWind,
airResistanceWithWind = airResistanceWithWind,
rollingResistance = rollingResistance,
gravitationalForce = gravitationalForce
)
}
/** /**
* Estimates the "relative grade" experienced by a cyclist. * Estimates the "relative grade" experienced by a cyclist.
@ -76,42 +149,42 @@ class RelativeGradeDataType(private val karooSystemService: KarooSystemService,
airDensity: Double = DEFAULT_AIR_DENSITY, airDensity: Double = DEFAULT_AIR_DENSITY,
g: Double = DEFAULT_GRAVITY, g: Double = DEFAULT_GRAVITY,
): Double { ): Double {
// --- Input Validation --- val forces = estimateResistanceForces(
if (totalMass <= 0.0 || riderSpeed < 0.0 || windSpeed < 0.0 || g <= 0.0 || airDensity < 0.0 || cda < 0.0) { actualGrade,
Log.w(KarooHeadwindExtension.TAG, "Warning: Invalid input parameters. Mass/g must be positive; speeds, airDensity, Cda must be non-negative.") riderSpeed,
windSpeed,
windDirectionDegrees,
totalMass,
cda,
DEFAULT_CRR,
airDensity,
g
)
if (forces == null) {
Log.w(KarooHeadwindExtension.TAG, "Could not calculate forces for relative grade.")
return Double.NaN return Double.NaN
} }
if (riderSpeed == 0.0 && windSpeed == 0.0) { if (riderSpeed == 0.0 && windSpeed == 0.0) {
// If no movement and no wind, relative grade is just the actual grade // If no movement and no wind, relative grade is just the actual grade
return actualGrade return actualGrade
} }
// 1. Calculate the component of wind speed parallel to the rider's direction of travel. // The difference in force is purely from the wind.
// cos(0 rad) = 1 (headwind), cos(PI rad) = -1 (tailwind) // This difference in force, when equated to a change in gravitational force, gives the change in grade.
val windComponentParallel = windSpeed * cos(Math.toRadians(windDirectionDegrees)) // delta_F_air = F_air_with_wind - F_air_without_wind
// delta_F_air = m * g * delta_grade
// 2. Calculate the effective air speed the rider experiences. // delta_grade = delta_F_air / (m * g)
// This is rider speed + the parallel wind component. // relative_grade = actual_grade + delta_grade
val effectiveAirSpeed = riderSpeed + windComponentParallel val dragForceDifference = forces.airResistanceWithWind - forces.airResistanceWithoutWind
// 3. Calculate the aerodynamic resistance factor constant part.
val aeroFactor = 0.5 * airDensity * cda
// 4. Calculate the gravitational force component denominator.
val gravitationalFactor = totalMass * g val gravitationalFactor = totalMass * g
// 5. Calculate the difference in the aerodynamic drag force term between if (gravitationalFactor == 0.0) {
// the current situation (with wind) and the hypothetical no-wind situation. return actualGrade // Avoid division by zero
// Drag Force = aeroFactor * effectiveAirSpeed * abs(effectiveAirSpeed) }
// We use speed * abs(speed) to ensure drag always opposes relative air motion.
val dragForceDifference = aeroFactor * ( (effectiveAirSpeed * abs(effectiveAirSpeed)) - (riderSpeed * abs(riderSpeed)) )
// 6. Calculate the relative grade. return actualGrade + (dragForceDifference / gravitationalFactor)
// It's the actual grade plus the equivalent grade change caused by the wind.
// Equivalent Grade Change = Drag Force Difference / Gravitational Force Component
val relativeGrade = actualGrade + (dragForceDifference / gravitationalFactor)
return relativeGrade
} }
suspend fun streamRelativeGrade(karooSystemService: KarooSystemService, context: Context): Flow<RelativeGradeResponse> { suspend fun streamRelativeGrade(karooSystemService: KarooSystemService, context: Context): Flow<RelativeGradeResponse> {
@ -129,29 +202,8 @@ class RelativeGradeDataType(private val karooSystemService: KarooSystemService,
val refreshRate = karooSystemService.getRefreshRateInMilliseconds(context) val refreshRate = karooSystemService.getRefreshRateInMilliseconds(context)
val windSpeedFlow = combine(context.streamSettings(karooSystemService), karooSystemService.streamUserProfile(), context.streamCurrentWeatherData(karooSystemService).filterNotNull()) { settings, profile, weatherData -> val windSpeedFlow = context.streamCurrentWeatherData(karooSystemService).filterNotNull().map { weatherData ->
val isOpenMeteo = settings.weatherProvider == WeatherDataProvider.OPEN_METEO weatherData.windSpeed
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
}
}
} }
data class StreamValues( data class StreamValues(

View File

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

View File

@ -0,0 +1,185 @@
package de.timklge.karooheadwind.datatypes
import android.content.Context
import android.util.Log
import androidx.compose.ui.unit.DpSize
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 androidx.glance.GlanceModifier
import androidx.glance.Image
import de.timklge.karooheadwind.HeadingResponse
import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.datatypes.RelativeGradeDataType.Companion.DEFAULT_BIKE_WEIGHT
import de.timklge.karooheadwind.getRelativeHeadingFlow
import de.timklge.karooheadwind.screens.BarChartBuilder
import de.timklge.karooheadwind.streamCurrentWeatherData
import de.timklge.karooheadwind.streamDataFlow
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.internal.ViewEmitter
import io.hammerhead.karooext.models.DataType
import io.hammerhead.karooext.models.ShowCustomStreamState
import io.hammerhead.karooext.models.StreamState
import io.hammerhead.karooext.models.UpdateGraphicConfig
import io.hammerhead.karooext.models.UserProfile
import io.hammerhead.karooext.models.ViewConfig
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.filterIsInstance
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
class ResistanceForcesDataType(val karooSystem: KarooSystemService, context: Context) : BaseDataType(karooSystem, context, "forces"){
override fun getValue(data: WeatherData, userProfile: UserProfile): Double {
return data.windDirection
}
private fun previewFlow(): Flow<RelativeGradeDataType.ResistanceForces> {
return flow {
while (true) {
val withoutWind = (0..300).random().toDouble()
emit(RelativeGradeDataType.ResistanceForces(
withoutWind,
withoutWind + (0..200).random().toDouble(),
(0..50).random().toDouble(),
(-100..500).random().toDouble()
))
delay(1_000)
}
}
}
@OptIn(ExperimentalGlanceRemoteViewsApi::class)
override fun startView(context: Context, config: ViewConfig, emitter: ViewEmitter) {
val configJob = CoroutineScope(Dispatchers.IO).launch {
emitter.onNext(UpdateGraphicConfig(showHeader = false))
awaitCancellation()
}
val viewJob = CoroutineScope(Dispatchers.IO).launch {
emitter.onNext(ShowCustomStreamState("", null))
val flow = if (config.preview) {
previewFlow()
} else {
val relativeWindDirectionFlow = karooSystem.getRelativeHeadingFlow(context).filterIsInstance<HeadingResponse.Value>().map { it.diff + 180 }
val speedFlow = karooSystem.streamDataFlow(DataType.Type.SPEED).filterIsInstance<StreamState.Streaming>().map { it.dataPoint.singleValue ?: 0.0 }
val actualGradeFlow = karooSystem.streamDataFlow(DataType.Type.ELEVATION_GRADE).filterIsInstance<StreamState.Streaming>().map { it.dataPoint.singleValue }.filterNotNull().map { it / 100.0 } // Convert to decimal grade
val totalMassFlow = karooSystem.streamUserProfile().map {
if (it.weight in 30.0f..300.0f){
it.weight
} else {
Log.w(KarooHeadwindExtension.TAG, "Invalid rider weight ${it.weight} kg, defaulting to 70 kg")
70.0f // Default to 70 kg if weight is invalid
} + DEFAULT_BIKE_WEIGHT
}
val refreshRate = karooSystem.getRefreshRateInMilliseconds(context)
val windSpeedFlow = context.streamCurrentWeatherData(karooSystem).filterNotNull().map { weatherData ->
weatherData.windSpeed
}
data class StreamValues(
val relativeWindDirection: Double,
val speed: Double,
val windSpeed: Double,
val actualGrade: Double,
val totalMass: Double
)
combine(relativeWindDirectionFlow, speedFlow, windSpeedFlow, actualGradeFlow, totalMassFlow) { windDirection, speed, windSpeed, actualGrade, totalMass ->
StreamValues(windDirection, speed, windSpeed, actualGrade, totalMass)
}.distinctUntilChanged().throttle(refreshRate).map { (windDirection, speed, windSpeed, actualGrade, totalMass) ->
val resistanceForces = RelativeGradeDataType.estimateResistanceForces(
actualGrade = actualGrade,
riderSpeed = speed,
windSpeed = windSpeed,
windDirectionDegrees = windDirection,
totalMass = totalMass
)
Log.d(KarooHeadwindExtension.TAG, "Resistance Forces: $resistanceForces")
resistanceForces
}
}
val refreshRate = karooSystem.getRefreshRateInMilliseconds(context)
flow.throttle(refreshRate).collect { resistanceForces ->
if (resistanceForces != null) {
// Create bar chart data
val bars = listOf(
BarChartBuilder.BarData(
value = resistanceForces.airResistanceWithoutWind,
label = "Air",
smallLabel = "Air",
color = 0xFF4CAF50.toInt() // Green
),
BarChartBuilder.BarData(
value = resistanceForces.airResistanceWithWind - resistanceForces.airResistanceWithoutWind,
label = "Wind",
smallLabel = "Wind",
color = 0xFF2196F3.toInt() // Blue
),
BarChartBuilder.BarData(
value = resistanceForces.rollingResistance,
label = "Roll",
smallLabel = "R",
color = 0xFFFF9800.toInt() // Orange
),
BarChartBuilder.BarData(
value = resistanceForces.gravitationalForce,
label = "Gravity",
smallLabel = "G",
color = 0xFFF44336.toInt() // Red
)
)
// Draw bar chart
val bitmap = BarChartBuilder(context).drawBarChart(
width = config.viewSize.first,
height = config.viewSize.second,
bars = bars,
small = config.gridSize.first <= 30
)
// Use the correct ViewEmitter pattern with glance.compose
val glance = GlanceRemoteViews()
val result = glance.compose(context, DpSize.Unspecified) {
Box(modifier = GlanceModifier.fillMaxSize()) {
Image(
ImageProvider(bitmap),
"Resistance Forces Bar Chart",
modifier = GlanceModifier.fillMaxSize()
)
}
}
emitter.updateView(result.remoteViews)
} else {
// Display error message when no resistance forces data
emitter.onNext(ShowCustomStreamState("No resistance data", null))
}
}
}
emitter.setCancellable {
configJob.cancel()
viewJob.cancel()
}
}
}

View File

@ -3,9 +3,10 @@ package de.timklge.karooheadwind.datatypes
import android.content.Context import android.content.Context
import de.timklge.karooheadwind.weatherprovider.WeatherData import de.timklge.karooheadwind.weatherprovider.WeatherData
import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.UserProfile
class SealevelPressureDataType(karooSystemService: KarooSystemService, context: Context) : BaseDataType(karooSystemService, context, "sealevelPressure"){ 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 return data.sealevelPressure
} }
} }

View File

@ -3,9 +3,10 @@ package de.timklge.karooheadwind.datatypes
import android.content.Context import android.content.Context
import de.timklge.karooheadwind.weatherprovider.WeatherData import de.timklge.karooheadwind.weatherprovider.WeatherData
import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.UserProfile
class SurfacePressureDataType(karooSystemService: KarooSystemService, context: Context) : BaseDataType(karooSystemService, context, "surfacePressure"){ 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 return data.surfacePressure
} }
} }

View File

@ -15,16 +15,15 @@ import de.timklge.karooheadwind.HeadingResponse
import de.timklge.karooheadwind.HeadwindSettings import de.timklge.karooheadwind.HeadwindSettings
import de.timklge.karooheadwind.KarooHeadwindExtension import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.R 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.getRelativeHeadingFlow
import de.timklge.karooheadwind.screens.isNightMode
import de.timklge.karooheadwind.streamCurrentWeatherData import de.timklge.karooheadwind.streamCurrentWeatherData
import de.timklge.karooheadwind.streamDataFlow import de.timklge.karooheadwind.streamDataFlow
import de.timklge.karooheadwind.streamDatatypeIsVisible import de.timklge.karooheadwind.streamDatatypeIsVisible
import de.timklge.karooheadwind.streamSettings import de.timklge.karooheadwind.streamSettings
import de.timklge.karooheadwind.streamUserProfile import de.timklge.karooheadwind.streamUserProfile
import de.timklge.karooheadwind.throttle import de.timklge.karooheadwind.throttle
import de.timklge.karooheadwind.util.msInUserUnit
import de.timklge.karooheadwind.weatherprovider.WeatherData import de.timklge.karooheadwind.weatherprovider.WeatherData
import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.extension.DataTypeImpl import io.hammerhead.karooext.extension.DataTypeImpl
@ -76,14 +75,25 @@ class TailwindAndRideSpeedDataType(
) : DataTypeImpl("karoo-headwind", "tailwind-and-ride-speed") { ) : DataTypeImpl("karoo-headwind", "tailwind-and-ride-speed") {
private val glance = GlanceRemoteViews() private val glance = GlanceRemoteViews()
data class StreamData(
val headingResponse: HeadingResponse,
val absoluteWindDirection: Double?,
val windSpeed: Double?,
val settings: HeadwindSettings,
val rideSpeed: Double? = null,
val gustSpeed: Double? = null,
val isImperial: Boolean = false,
val isVisible: Boolean = true
)
private fun previewFlow(profileFlow: Flow<UserProfile>): Flow<StreamData> { private fun previewFlow(profileFlow: Flow<UserProfile>): Flow<StreamData> {
return flow { return flow {
val profile = profileFlow.first() val profile = profileFlow.first()
while (true) { while (true) {
val bearing = (0..360).random().toDouble() val bearing = (0..360).random().toDouble()
val windSpeed = (0..20).random() val windSpeed = (0..10).random()
val rideSpeed = (10..40).random().toDouble() val rideSpeed = (5..10).random().toDouble()
val gustSpeed = windSpeed * ((10..40).random().toDouble() / 10) val gustSpeed = windSpeed * ((10..40).random().toDouble() / 10)
val isImperial = profile.preferredUnit.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL val isImperial = profile.preferredUnit.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL
@ -133,11 +143,7 @@ class TailwindAndRideSpeedDataType(
val absoluteWindDirection = weatherData?.windDirection val absoluteWindDirection = weatherData?.windDirection
val windSpeed = weatherData?.windSpeed val windSpeed = weatherData?.windSpeed
val gustSpeed = weatherData?.windGusts val gustSpeed = weatherData?.windGusts
val rideSpeed = if (isImperial){ val rideSpeed = rideSpeedInMs
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 = rideSpeed, isImperial = isImperial, gustSpeed = gustSpeed, isVisible = isVisible)
} }
@ -164,48 +170,41 @@ class TailwindAndRideSpeedDataType(
} }
val windSpeed = streamData.windSpeed val windSpeed = streamData.windSpeed
val windDirection = when (streamData.settings.windDirectionIndicatorSetting){ val windDirection = streamData.headingResponse.diff
WindDirectionIndicatorSetting.HEADWIND_DIRECTION -> streamData.headingResponse.diff
WindDirectionIndicatorSetting.WIND_DIRECTION -> streamData.absoluteWindDirection + 180
}
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 wideMode = config.gridSize.first == 60
val gustSpeedInUserUnit = msInUserUnit(streamData.gustSpeed ?: 0.0, streamData.isImperial)
val gustSpeedAddon = if (wideMode) { val gustSpeedAddon = if (wideMode) {
"-${streamData.gustSpeed?.roundToInt() ?: 0}" "-${gustSpeedInUserUnit.roundToInt()}"
} else { } else {
"" ""
} }
val subtextWithSign = when (streamData.settings.windDirectionIndicatorTextSetting) { val windSpeedUserUnit = msInUserUnit(windSpeed, streamData.isImperial)
WindDirectionIndicatorTextSetting.HEADWIND_SPEED -> {
val headwindSpeed = cos( (windDirection + 180) * Math.PI / 180.0) * windSpeed
headwindSpeed.roundToInt().toString()
val sign = if (headwindSpeed < 0) "+" else { val subtextWithSign = let {
if (headwindSpeed > 0) "-" else ""
}
"$sign${headwindSpeed.roundToInt().absoluteValue} ${windSpeed.roundToInt()}${gustSpeedAddon}"
}
WindDirectionIndicatorTextSetting.WIND_SPEED -> "${windSpeed.roundToInt()}${gustSpeedAddon}"
WindDirectionIndicatorTextSetting.NONE -> ""
}
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 headwindSpeed = cos( (windDirection + 180) * Math.PI / 180.0) * windSpeed
val windSpeedInKmh = if (streamData.isImperial == true){ headwindSpeed.roundToInt().toString()
headwindSpeed / 2.23694 * 3.6
} else { val sign = if (headwindSpeed < 0) "+" else {
headwindSpeed if (headwindSpeed > 0) "-" else ""
} }
dayColor = interpolateWindColor(windSpeedInKmh, false, context)
nightColor = interpolateWindColor(windSpeedInKmh, true, context) val headwindSpeedUserUnit = msInUserUnit(headwindSpeed, streamData.isImperial)
"$sign${headwindSpeedUserUnit.roundToInt().absoluteValue} ${windSpeedUserUnit.roundToInt()}${gustSpeedAddon}"
} }
val headwindSpeed = cos( (windDirection + 180) * Math.PI / 180.0) * windSpeed
val windSpeedInKmh = headwindSpeed * 3.6
val dayColor = interpolateWindColor(windSpeedInKmh, false, context)
val nightColor = interpolateWindColor(windSpeedInKmh, true, context)
val result = glance.compose(context, DpSize.Unspecified) { val result = glance.compose(context, DpSize.Unspecified) {
HeadwindDirection( HeadwindDirection(
baseBitmap, baseBitmap,

View File

@ -3,9 +3,15 @@ package de.timklge.karooheadwind.datatypes
import android.content.Context import android.content.Context
import de.timklge.karooheadwind.weatherprovider.WeatherData import de.timklge.karooheadwind.weatherprovider.WeatherData
import io.hammerhead.karooext.KarooSystemService 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"){ 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 return data.temperature
} }
override fun getFormatDataType(): String? {
return DataType.Type.TEMPERATURE
}
} }

View File

@ -1,106 +1,36 @@
package de.timklge.karooheadwind.datatypes package de.timklge.karooheadwind.datatypes
import android.graphics.Bitmap import android.content.Context
import androidx.compose.runtime.Composable import de.timklge.karooheadwind.UpcomingRoute
import androidx.compose.ui.graphics.Color import de.timklge.karooheadwind.screens.LineGraphBuilder
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 io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.KarooSystemService
import kotlin.math.absoluteValue
@Composable class TemperatureForecastDataType(karooSystem: KarooSystemService) : LineGraphForecastDataType(karooSystem, "temperatureForecast") {
fun TemperatureForecast( override fun getLineData(
temperature: Int, lineData: List<LineData>,
temperatureUnit: TemperatureUnit,
distance: Double? = null,
timeLabel: String? = null,
rowAlignment: Alignment.Horizontal = Alignment.Horizontal.CenterHorizontally,
isImperial: Boolean?
) {
Column(modifier = GlanceModifier.fillMaxHeight().padding(1.dp).width(86.dp), horizontalAlignment = rowAlignment) {
Row(modifier = GlanceModifier.defaultWeight().fillMaxWidth(), horizontalAlignment = rowAlignment, verticalAlignment = Alignment.CenterVertically) {
Text(
text = "${temperature}${temperatureUnit.unitDisplay}",
style = TextStyle(color = ColorProvider(Color.Black, Color.White), fontFamily = FontFamily.Monospace, fontSize = TextUnit(28f, TextUnitType.Sp), textAlign = TextAlign.Center)
)
}
if (distance != null && isImperial != null){
val distanceInUserUnit = (distance / (if(!isImperial) 1000.0 else 1609.34)).toInt()
val label = "${distanceInUserUnit.absoluteValue}${if(!isImperial) "km" else "mi"}"
val text = if(distanceInUserUnit > 0){
"In $label"
} else {
"$label ago"
}
if (distanceInUserUnit != 0){
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = text,
style = TextStyle(
color = ColorProvider(Color.Black, Color.White),
fontFamily = FontFamily.Monospace,
fontSize = TextUnit(18f, TextUnitType.Sp)
)
)
}
}
}
if (timeLabel != null){
Text(
text = timeLabel,
style = TextStyle(color = ColorProvider(Color.Black, Color.White), fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace, fontSize = TextUnit(18f, TextUnitType.Sp)
)
)
}
}
}
class TemperatureForecastDataType(karooSystem: KarooSystemService) : ForecastDataType(karooSystem, "temperatureForecast") {
@Composable
override fun RenderWidget(
arrowBitmap: Bitmap,
current: WeatherInterpretation,
windBearing: Int,
windSpeed: Int,
windGusts: Int,
precipitation: Double,
precipitationProbability: Int?,
temperature: Int,
temperatureUnit: TemperatureUnit,
timeLabel: String,
dateLabel: String?,
distance: Double?,
isImperial: Boolean, isImperial: Boolean,
isNight: Boolean upcomingRoute: UpcomingRoute?,
) { isPreview: Boolean,
TemperatureForecast( context: Context
temperature = temperature, ): LineGraphForecastData {
temperatureUnit = temperatureUnit, val linePoints = lineData.map { data ->
distance = distance, if (isImperial) {
timeLabel = timeLabel, data.weatherData.temperature * 9 / 5 + 32 // Convert Celsius to Fahrenheit
isImperial = isImperial, } else {
) data.weatherData.temperature // Keep Celsius
}
}
return LineGraphForecastData.LineData(setOf(
LineGraphBuilder.Line(
dataPoints = linePoints.mapIndexed { index, value ->
LineGraphBuilder.DataPoint(index.toFloat(), value.toFloat())
},
color = android.graphics.Color.RED,
label = if (!isImperial) "°C" else "°F",
)
))
} }
} }

View File

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

View File

@ -0,0 +1,12 @@
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 UviDataType(karooSystemService: KarooSystemService, context: Context) : BaseDataType(karooSystemService, context, "uvi"){
override fun getValue(data: WeatherData, userProfile: UserProfile): Double {
return data.uvi
}
}

View File

@ -22,6 +22,10 @@ import androidx.glance.text.TextStyle
import de.timklge.karooheadwind.HeadingResponse import de.timklge.karooheadwind.HeadingResponse
import de.timklge.karooheadwind.HeadwindSettings import de.timklge.karooheadwind.HeadwindSettings
import de.timklge.karooheadwind.KarooHeadwindExtension import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.streamSettings
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.HardwareType
import kotlinx.coroutines.flow.first
@OptIn(ExperimentalGlanceRemoteViewsApi::class) @OptIn(ExperimentalGlanceRemoteViewsApi::class)
suspend fun getErrorWidget(glance: GlanceRemoteViews, context: Context, settings: HeadwindSettings?, headingResponse: HeadingResponse?): RemoteViewsCompositionResult { suspend fun getErrorWidget(glance: GlanceRemoteViews, context: Context, settings: HeadwindSettings?, headingResponse: HeadingResponse?): RemoteViewsCompositionResult {
@ -76,3 +80,14 @@ suspend fun getErrorWidget(glance: GlanceRemoteViews, context: Context, errorCod
} }
} }
} }
suspend fun KarooSystemService.getRefreshRateInMilliseconds(context: Context): Long {
val refreshRate = context.streamSettings(this).first().refreshRate
val isK2 = hardwareType == HardwareType.K2
return if (isK2){
refreshRate.k2Ms
} else {
refreshRate.k3Ms
}
}

View File

@ -1,179 +0,0 @@
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.GlanceModifier
import androidx.glance.action.actionStartActivity
import androidx.glance.action.clickable
import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi
import androidx.glance.appwidget.GlanceRemoteViews
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.fillMaxSize
import de.timklge.karooheadwind.HeadingResponse
import de.timklge.karooheadwind.HeadwindSettings
import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.MainActivity
import de.timklge.karooheadwind.R
import de.timklge.karooheadwind.TemperatureUnit
import de.timklge.karooheadwind.getHeadingFlow
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.weatherprovider.WeatherData
import de.timklge.karooheadwind.weatherprovider.WeatherInterpretation
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.extension.DataTypeImpl
import io.hammerhead.karooext.internal.Emitter
import io.hammerhead.karooext.internal.ViewEmitter
import io.hammerhead.karooext.models.DataPoint
import io.hammerhead.karooext.models.DataType
import io.hammerhead.karooext.models.ShowCustomStreamState
import io.hammerhead.karooext.models.StreamState
import io.hammerhead.karooext.models.UpdateGraphicConfig
import io.hammerhead.karooext.models.UserProfile
import io.hammerhead.karooext.models.ViewConfig
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.flow
import kotlinx.coroutines.launch
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import kotlin.math.roundToInt
@OptIn(ExperimentalGlanceRemoteViewsApi::class)
class WeatherDataType(
private val karooSystem: KarooSystemService,
private val applicationContext: Context
) : DataTypeImpl("karoo-headwind", "weather") {
private val glance = GlanceRemoteViews()
companion object {
val timeFormatter = DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneId.systemDefault())
}
override fun startStream(emitter: Emitter<StreamState>) {
val job = CoroutineScope(Dispatchers.IO).launch {
val currentWeatherData = applicationContext.streamCurrentWeatherData(karooSystem)
currentWeatherData.collect { data ->
Log.d(KarooHeadwindExtension.TAG, "Wind code: ${data?.weatherCode}")
emitter.onNext(StreamState.Streaming(DataPoint(dataTypeId, mapOf(DataType.Field.SINGLE to (data?.weatherCode?.toDouble() ?: 0.0)))))
}
}
emitter.setCancellable {
job.cancel()
}
}
data class StreamData(val data: WeatherData?, val settings: HeadwindSettings,
val profile: UserProfile? = null, val headingResponse: HeadingResponse? = null,
val isVisible: Boolean)
private fun previewFlow(): Flow<StreamData> = flow {
while (true){
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,
WeatherInterpretation.getKnownWeatherCodes().random(), isForecast = false,
isNight = listOf(true, false).random()
), HeadwindSettings(), isVisible = true))
delay(5_000)
}
}
override fun startView(context: Context, config: ViewConfig, emitter: ViewEmitter) {
Log.d(KarooHeadwindExtension.TAG, "Starting weather view with $emitter")
val configJob = CoroutineScope(Dispatchers.IO).launch {
emitter.onNext(UpdateGraphicConfig(showHeader = false))
awaitCancellation()
}
val baseBitmap = BitmapFactory.decodeResource(
context.resources,
R.drawable.arrow_0
)
val dataFlow = if (config.preview){
previewFlow()
} else {
combine(
context.streamCurrentWeatherData(karooSystem),
context.streamSettings(karooSystem),
karooSystem.streamUserProfile(),
karooSystem.getHeadingFlow(context),
karooSystem.streamDatatypeIsVisible(dataTypeId)
) { data, settings, profile, heading, isVisible ->
StreamData(data, settings, profile, heading, isVisible)
}
}
val viewJob = CoroutineScope(Dispatchers.IO).launch {
emitter.onNext(ShowCustomStreamState("", null))
val refreshRate = karooSystem.getRefreshRateInMilliseconds(context)
dataFlow.filter { it.isVisible }.throttle(refreshRate).collect { (data, settings, userProfile, headingResponse) ->
Log.d(KarooHeadwindExtension.TAG, "Updating weather view")
if (data == null){
emitter.updateView(getErrorWidget(glance, context, settings, headingResponse).remoteViews)
return@collect
}
val interpretation = WeatherInterpretation.fromWeatherCode(data.weatherCode)
val formattedTime = timeFormatter.format(Instant.ofEpochSecond(data.time))
val formattedDate = getShortDateFormatter().format(Instant.ofEpochSecond(data.time))
val result = glance.compose(context, DpSize.Unspecified) {
var modifier = GlanceModifier.fillMaxSize()
// TODO reenable once swipes are no longer interpreted as clicks if (!config.preview) modifier = modifier.clickable(onClick = actionStartActivity<MainActivity>())
Box(modifier = modifier, contentAlignment = Alignment.CenterEnd) {
Weather(
baseBitmap,
current = interpretation,
windBearing = data.windDirection.roundToInt(),
windSpeed = data.windSpeed.roundToInt(),
windGusts = data.windGusts.roundToInt(),
precipitation = data.precipitation,
precipitationProbability = null,
temperature = data.temperature.roundToInt(),
temperatureUnit = if (userProfile?.preferredUnit?.temperature != UserProfile.PreferredUnit.UnitType.IMPERIAL) TemperatureUnit.CELSIUS else TemperatureUnit.FAHRENHEIT,
timeLabel = formattedTime,
rowAlignment = when (config.alignment){
ViewConfig.Alignment.LEFT -> Alignment.Horizontal.Start
ViewConfig.Alignment.CENTER -> Alignment.Horizontal.CenterHorizontally
ViewConfig.Alignment.RIGHT -> Alignment.Horizontal.End
},
dateLabel = formattedDate,
singleDisplay = true,
isImperial = userProfile?.preferredUnit?.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL,
isNight = data.isNight,
)
}
}
emitter.updateView(result.remoteViews)
}
}
emitter.setCancellable {
Log.d(KarooHeadwindExtension.TAG, "Stopping headwind view with $emitter")
configJob.cancel()
viewJob.cancel()
}
}
}

View File

@ -23,6 +23,7 @@ class WeatherForecastDataType(karooSystem: KarooSystemService) : ForecastDataTyp
distance: Double?, distance: Double?,
isImperial: Boolean, isImperial: Boolean,
isNight: Boolean, isNight: Boolean,
uvi: Double,
) { ) {
Weather( Weather(
arrowBitmap = arrowBitmap, arrowBitmap = arrowBitmap,

View File

@ -3,31 +3,24 @@ package de.timklge.karooheadwind.datatypes
import android.content.Context import android.content.Context
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.util.Log import android.util.Log
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.DpSize
import androidx.core.content.ContextCompat
import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi
import androidx.glance.appwidget.GlanceRemoteViews import androidx.glance.appwidget.GlanceRemoteViews
import de.timklge.karooheadwind.HeadingResponse import de.timklge.karooheadwind.HeadingResponse
import de.timklge.karooheadwind.HeadwindSettings import de.timklge.karooheadwind.HeadwindSettings
import de.timklge.karooheadwind.KarooHeadwindExtension import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.R import de.timklge.karooheadwind.R
import de.timklge.karooheadwind.WindDirectionIndicatorSetting
import de.timklge.karooheadwind.WindDirectionIndicatorTextSetting
import de.timklge.karooheadwind.getRelativeHeadingFlow import de.timklge.karooheadwind.getRelativeHeadingFlow
import de.timklge.karooheadwind.streamCurrentWeatherData import de.timklge.karooheadwind.streamCurrentWeatherData
import de.timklge.karooheadwind.streamDataFlow
import de.timklge.karooheadwind.streamDatatypeIsVisible import de.timklge.karooheadwind.streamDatatypeIsVisible
import de.timklge.karooheadwind.streamSettings import de.timklge.karooheadwind.streamSettings
import de.timklge.karooheadwind.streamUserProfile import de.timklge.karooheadwind.streamUserProfile
import de.timklge.karooheadwind.throttle import de.timklge.karooheadwind.throttle
import de.timklge.karooheadwind.weatherprovider.WeatherData import de.timklge.karooheadwind.util.msInUserUnit
import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.extension.DataTypeImpl import io.hammerhead.karooext.extension.DataTypeImpl
import io.hammerhead.karooext.internal.ViewEmitter import io.hammerhead.karooext.internal.ViewEmitter
import io.hammerhead.karooext.models.DataType
import io.hammerhead.karooext.models.ShowCustomStreamState import io.hammerhead.karooext.models.ShowCustomStreamState
import io.hammerhead.karooext.models.StreamState
import io.hammerhead.karooext.models.UpdateGraphicConfig import io.hammerhead.karooext.models.UpdateGraphicConfig
import io.hammerhead.karooext.models.UserProfile import io.hammerhead.karooext.models.UserProfile
import io.hammerhead.karooext.models.ViewConfig import io.hammerhead.karooext.models.ViewConfig
@ -40,16 +33,15 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
import kotlin.math.cos import kotlin.math.cos
import kotlin.math.roundToInt import kotlin.math.roundToInt
class TailwindDataType( class WindDirectionAndSpeedDataType(
private val karooSystem: KarooSystemService, private val karooSystem: KarooSystemService,
private val applicationContext: Context private val applicationContext: Context
) : DataTypeImpl("karoo-headwind", "tailwind") { ) : DataTypeImpl("karoo-headwind", "windDirectionAndSpeed") {
@OptIn(ExperimentalGlanceRemoteViewsApi::class) @OptIn(ExperimentalGlanceRemoteViewsApi::class)
private val glance = GlanceRemoteViews() private val glance = GlanceRemoteViews()
@ -57,7 +49,6 @@ class TailwindDataType(
val absoluteWindDirection: Double?, val absoluteWindDirection: Double?,
val windSpeed: Double?, val windSpeed: Double?,
val settings: HeadwindSettings, val settings: HeadwindSettings,
val rideSpeed: Double?,
val gustSpeed: Double?, val gustSpeed: Double?,
val isImperial: Boolean, val isImperial: Boolean,
val isVisible: Boolean) val isVisible: Boolean)
@ -68,23 +59,17 @@ class TailwindDataType(
while (true) { while (true) {
val bearing = (0..360).random().toDouble() val bearing = (0..360).random().toDouble()
val windSpeed = (0..20).random() val windSpeed = (0..10).random()
val rideSpeed = (10..40).random().toDouble() val gustSpeed = windSpeed * ((10..20).random().toDouble() / 10)
val gustSpeed = windSpeed * ((10..40).random().toDouble() / 10)
val isImperial = profile.preferredUnit.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL val isImperial = profile.preferredUnit.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL
emit(StreamData(HeadingResponse.Value(bearing), bearing, windSpeed.toDouble(), HeadwindSettings(), rideSpeed, gustSpeed = gustSpeed, isImperial = isImperial, isVisible = true)) emit(StreamData(HeadingResponse.Value(bearing), bearing, windSpeed.toDouble(), HeadwindSettings(), gustSpeed = gustSpeed, isImperial = isImperial, isVisible = true))
delay(2_000) delay(2_000)
} }
} }
} }
private fun streamSpeedInMs(): Flow<Double> {
return karooSystem.streamDataFlow(DataType.Type.SMOOTHED_3S_AVERAGE_SPEED)
.map { (it as? StreamState.Streaming)?.dataPoint?.singleValue ?: 0.0 }
}
@OptIn(ExperimentalGlanceRemoteViewsApi::class) @OptIn(ExperimentalGlanceRemoteViewsApi::class)
override fun startView(context: Context, config: ViewConfig, emitter: ViewEmitter) { override fun startView(context: Context, config: ViewConfig, emitter: ViewEmitter) {
Log.d(KarooHeadwindExtension.TAG, "Starting headwind direction view with $emitter") Log.d(KarooHeadwindExtension.TAG, "Starting headwind direction view with $emitter")
@ -106,27 +91,14 @@ class TailwindDataType(
context.streamCurrentWeatherData(karooSystem), context.streamCurrentWeatherData(karooSystem),
context.streamSettings(karooSystem), context.streamSettings(karooSystem),
karooSystem.streamUserProfile(), karooSystem.streamUserProfile(),
streamSpeedInMs(),
karooSystem.streamDatatypeIsVisible(dataTypeId) karooSystem.streamDatatypeIsVisible(dataTypeId)
) { data -> ) { headingResponse, weatherData, settings, userProfile, isVisible ->
val headingResponse = data[0] as HeadingResponse
val weatherData = data[1] as? WeatherData
val settings = data[2] as HeadwindSettings
val userProfile = data[3] as UserProfile
val rideSpeedInMs = data[4] as Double
val isVisible = data[5] as Boolean
val isImperial = userProfile.preferredUnit.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL val isImperial = userProfile.preferredUnit.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL
val absoluteWindDirection = weatherData?.windDirection val absoluteWindDirection = weatherData?.windDirection
val windSpeed = weatherData?.windSpeed val windSpeed = weatherData?.windSpeed
val gustSpeed = weatherData?.windGusts 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, isImperial = isImperial, gustSpeed = gustSpeed, isVisible = isVisible)
} }
} }
@ -135,7 +107,7 @@ class TailwindDataType(
val refreshRate = karooSystem.getRefreshRateInMilliseconds(context) val refreshRate = karooSystem.getRefreshRateInMilliseconds(context)
flow.filter { it.isVisible }.throttle(refreshRate).collect { streamData -> flow.filter { it.isVisible }.throttle(refreshRate).collect { streamData ->
Log.d(KarooHeadwindExtension.TAG, "Updating tailwind direction view") Log.d(KarooHeadwindExtension.TAG, "Updating wind speed and direction view")
val value = (streamData.headingResponse as? HeadingResponse.Value)?.diff val value = (streamData.headingResponse as? HeadingResponse.Value)?.diff
if (value == null || streamData.absoluteWindDirection == null || streamData.windSpeed == null){ if (value == null || streamData.absoluteWindDirection == null || streamData.windSpeed == null){
@ -151,40 +123,20 @@ class TailwindDataType(
} }
val windSpeed = streamData.windSpeed val windSpeed = streamData.windSpeed
val windDirection = when (streamData.settings.windDirectionIndicatorSetting){ val windDirection = streamData.headingResponse.diff
WindDirectionIndicatorSetting.HEADWIND_DIRECTION -> streamData.headingResponse.diff
WindDirectionIndicatorSetting.WIND_DIRECTION -> streamData.absoluteWindDirection + 180 val windSpeedUserUnit = msInUserUnit(windSpeed, streamData.isImperial)
val gustSpeedUserUnit = msInUserUnit(streamData.gustSpeed ?: 0.0, streamData.isImperial)
val mainText = let {
"${windSpeedUserUnit.roundToInt().absoluteValue}"
} }
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 { val subtext = "Max ${gustSpeedUserUnit.roundToInt()}"
if (headwindSpeed > 0) "-" else ""
}
"$sign${headwindSpeed.roundToInt().absoluteValue}"
}
WindDirectionIndicatorTextSetting.WIND_SPEED -> windSpeed.roundToInt().toString()
WindDirectionIndicatorTextSetting.NONE -> ""
}
val subtext = "${windSpeed.roundToInt()}-${streamData.gustSpeed?.roundToInt()}" val headwindSpeed = cos( (windDirection + 180) * Math.PI / 180.0) * windSpeed
val windSpeedInKmh = headwindSpeed * 3.6
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){
headwindSpeed / 2.23694 * 3.6
} else {
headwindSpeed
}
dayColor = interpolateWindColor(windSpeedInKmh, false, context)
nightColor = interpolateWindColor(windSpeedInKmh, true, context)
}
val result = glance.compose(context, DpSize.Unspecified) { val result = glance.compose(context, DpSize.Unspecified) {
HeadwindDirection( HeadwindDirection(
@ -193,8 +145,8 @@ class TailwindDataType(
config.textSize, config.textSize,
mainText, mainText,
subtext, subtext,
dayColor, interpolateWindColor(windSpeedInKmh, false, context),
nightColor, interpolateWindColor(windSpeedInKmh, true, context),
wideMode = config.gridSize.first == 60, wideMode = config.gridSize.first == 60,
preview = config.preview, preview = config.preview,
) )
@ -204,7 +156,7 @@ class TailwindDataType(
} }
} }
emitter.setCancellable { emitter.setCancellable {
Log.d(KarooHeadwindExtension.TAG, "Stopping headwind view with $emitter") Log.d(KarooHeadwindExtension.TAG, "Stopping wind speed and direction view with $emitter")
configJob.cancel() configJob.cancel()
viewJob.cancel() viewJob.cancel()
} }

View File

@ -0,0 +1,145 @@
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.datatypes.WindDirectionAndSpeedDataType.StreamData
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
import io.hammerhead.karooext.internal.ViewEmitter
import io.hammerhead.karooext.models.DataPoint
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
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emitAll
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)
class WindDirectionAndSpeedDataTypeCircle(
private val karooSystem: KarooSystemService,
private val applicationContext: Context
) : DataTypeImpl("karoo-headwind", "windDirectionAndSpeedCircle") {
private val glance = GlanceRemoteViews()
data class StreamData(val headingResponse: HeadingResponse, val absoluteWindDirection: Double?, val windSpeed: Double?, val settings: HeadwindSettings)
private fun previewFlow(profileFlow: Flow<UserProfile>): Flow<de.timklge.karooheadwind.datatypes.WindDirectionAndSpeedDataType.StreamData> {
return flow {
val profile = profileFlow.first()
while (true) {
val bearing = (0..360).random().toDouble()
val windSpeed = (0..10).random()
val gustSpeed = windSpeed * ((10..20).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)
}
}
}
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,
de.timklge.karooheadwind.R.drawable.circle
)
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 {
val refreshRate = karooSystem.getRefreshRateInMilliseconds(context)
flow.filter { it.isVisible }.throttle(refreshRate).collect { streamData ->
Log.d(KarooHeadwindExtension.TAG, "Updating headwind 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 windSpeedUserUnit = msInUserUnit(windSpeed, streamData.isImperial)
val result = glance.compose(context, DpSize.Unspecified) {
HeadwindDirection(
baseBitmap,
value.roundToInt(),
config.textSize,
windSpeedUserUnit.roundToInt().toString(),
preview = config.preview,
wideMode = false
)
}
emitter.updateView(result.remoteViews)
}
}
emitter.setCancellable {
Log.d(KarooHeadwindExtension.TAG, "Stopping headwind view with $emitter")
configJob.cancel()
viewJob.cancel()
}
}
}

View File

@ -26,6 +26,7 @@ import io.hammerhead.karooext.internal.ViewEmitter
import io.hammerhead.karooext.models.ShowCustomStreamState import io.hammerhead.karooext.models.ShowCustomStreamState
import io.hammerhead.karooext.models.StreamState import io.hammerhead.karooext.models.StreamState
import io.hammerhead.karooext.models.UpdateGraphicConfig import io.hammerhead.karooext.models.UpdateGraphicConfig
import io.hammerhead.karooext.models.UserProfile
import io.hammerhead.karooext.models.ViewConfig import io.hammerhead.karooext.models.ViewConfig
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers 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 return data.windDirection
} }

View File

@ -1,119 +1,67 @@
package de.timklge.karooheadwind.datatypes package de.timklge.karooheadwind.datatypes
import android.graphics.Bitmap import android.content.Context
import androidx.compose.runtime.Composable import android.graphics.Color
import androidx.compose.ui.graphics.Color import android.util.Log
import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.unit.TextUnitType import com.mapbox.turf.TurfConstants
import androidx.compose.ui.unit.dp import com.mapbox.turf.TurfMeasurement
import androidx.glance.ColorFilter import de.timklge.karooheadwind.KarooHeadwindExtension
import androidx.glance.GlanceModifier import de.timklge.karooheadwind.UpcomingRoute
import androidx.glance.Image import de.timklge.karooheadwind.lerpWeather
import androidx.glance.ImageProvider import de.timklge.karooheadwind.screens.LineGraphBuilder
import androidx.glance.color.ColorProvider import de.timklge.karooheadwind.screens.isNightMode
import androidx.glance.layout.Alignment import de.timklge.karooheadwind.util.signedAngleDifference
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 io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.KarooSystemService
import kotlin.math.absoluteValue import kotlin.math.ceil
import kotlin.math.cos
import kotlin.math.floor
@Composable fun remap(value: Float, fromLow: Float, fromHigh: Float, toLow: Float, toHigh: Float): Float {
fun WindForecast( if (fromHigh == fromLow) return toLow
arrowBitmap: Bitmap, return toLow + (value - fromLow) / (fromHigh - fromLow) * (toHigh - toLow)
windBearing: Int,
windSpeed: Int,
gustSpeed: Int,
distance: Double? = null,
timeLabel: String? = null,
rowAlignment: Alignment.Horizontal = Alignment.Horizontal.CenterHorizontally,
isImperial: Boolean?
) {
Column(modifier = GlanceModifier.fillMaxHeight().padding(1.dp).width(86.dp), horizontalAlignment = rowAlignment) {
Image(
modifier = GlanceModifier.defaultWeight().fillMaxWidth(),
provider = ImageProvider(getArrowBitmapByBearing(arrowBitmap, windBearing + 180)),
contentDescription = "Current wind direction",
contentScale = ContentScale.Fit,
colorFilter = ColorFilter.tint(ColorProvider(Color.Black, Color.White))
)
Text(
text = "${windSpeed}-${gustSpeed}",
style = TextStyle(color = ColorProvider(Color.Black, Color.White), fontFamily = FontFamily.Monospace, fontSize = TextUnit(18f, TextUnitType.Sp), textAlign = TextAlign.Center)
)
if (distance != null && isImperial != null){
val distanceInUserUnit = (distance / (if(!isImperial) 1000.0 else 1609.34)).toInt()
val label = "${distanceInUserUnit.absoluteValue}${if(!isImperial) "km" else "mi"}"
val text = if(distanceInUserUnit > 0){
"In $label"
} else {
"$label ago"
}
if (distanceInUserUnit != 0){
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = text,
style = TextStyle(
color = ColorProvider(Color.Black, Color.White),
fontFamily = FontFamily.Monospace,
fontSize = TextUnit(18f, TextUnitType.Sp)
)
)
}
}
}
if (timeLabel != null){
Text(
text = timeLabel,
style = TextStyle(color = ColorProvider(Color.Black, Color.White), fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace, fontSize = TextUnit(18f, TextUnitType.Sp)
)
)
}
}
} }
class WindForecastDataType(karooSystem: KarooSystemService) : ForecastDataType(karooSystem, "windForecast") { class WindForecastDataType(karooSystem: KarooSystemService) : LineGraphForecastDataType(karooSystem, "windForecast") {
@Composable override fun getLineData(
override fun RenderWidget( lineData: List<LineData>,
arrowBitmap: Bitmap,
current: WeatherInterpretation,
windBearing: Int,
windSpeed: Int,
windGusts: Int,
precipitation: Double,
precipitationProbability: Int?,
temperature: Int,
temperatureUnit: TemperatureUnit,
timeLabel: String,
dateLabel: String?,
distance: Double?,
isImperial: Boolean, isImperial: Boolean,
isNight: Boolean upcomingRoute: UpcomingRoute?,
) { isPreview: Boolean,
WindForecast( context: Context
arrowBitmap = arrowBitmap, ): LineGraphForecastData {
windBearing = windBearing, val windPoints = lineData.map { data ->
windSpeed = windSpeed, if (isImperial) { // Convert m/s to mph
gustSpeed = windGusts, data.weatherData.windSpeed * 2.23694 // Convert m/s to mph
distance = distance, } else { // Convert m/s to km/h
timeLabel = timeLabel, data.weatherData.windSpeed * 3.6 // Convert m/s to km/h
isImperial = isImperial, }
) }
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
}
}
return LineGraphForecastData.LineData(buildSet {
add(LineGraphBuilder.Line(
dataPoints = gustPoints.mapIndexed { index, value ->
LineGraphBuilder.DataPoint(index.toFloat(), value.toFloat())
},
color = Color.DKGRAY,
label = "Gust" // if (!isImperial) "Gust km/h" else "Gust mph",
))
add(LineGraphBuilder.Line(
dataPoints = windPoints.mapIndexed { index, value ->
LineGraphBuilder.DataPoint(index.toFloat(), value.toFloat())
},
color = Color.GRAY,
label = "Wind" // if (!isImperial) "Wind km/h" else "Wind mph",
))
})
} }
} }

View File

@ -3,9 +3,15 @@ package de.timklge.karooheadwind.datatypes
import android.content.Context import android.content.Context
import de.timklge.karooheadwind.weatherprovider.WeatherData import de.timklge.karooheadwind.weatherprovider.WeatherData
import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.DataType
import io.hammerhead.karooext.models.UserProfile
class WindGustsDataType(karooSystemService: KarooSystemService, context: Context) : BaseDataType(karooSystemService, context, "windGusts"){ 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 return data.windGusts
} }
override fun getFormatDataType(): String {
return DataType.Type.SPEED
}
} }

View File

@ -3,9 +3,15 @@ package de.timklge.karooheadwind.datatypes
import android.content.Context import android.content.Context
import de.timklge.karooheadwind.weatherprovider.WeatherData import de.timklge.karooheadwind.weatherprovider.WeatherData
import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.DataType
import io.hammerhead.karooext.models.UserProfile
class WindSpeedDataType(karooSystemService: KarooSystemService, context: Context) : BaseDataType(karooSystemService, context, "windSpeed"){ 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 return data.windSpeed
} }
override fun getFormatDataType(): String {
return DataType.Type.SPEED
}
} }

View File

@ -0,0 +1,154 @@
package de.timklge.karooheadwind.screens
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import androidx.annotation.ColorInt
import androidx.core.graphics.createBitmap
import kotlin.math.abs
class BarChartBuilder(val context: Context) {
data class BarData(
val value: Double,
val label: String,
val smallLabel: String,
@ColorInt val color: Int
)
fun drawBarChart(
width: Int,
height: Int,
small: Boolean,
bars: List<BarData>
): Bitmap {
val bitmap = createBitmap(width, height)
val canvas = Canvas(bitmap)
val isNightMode = isNightMode(context)
val backgroundColor = if (isNightMode) Color.BLACK else Color.WHITE
val primaryTextColor = if (isNightMode) Color.WHITE else Color.BLACK
canvas.drawColor(backgroundColor)
if (bars.isEmpty()) {
val emptyPaint = Paint().apply {
color = primaryTextColor
textSize = 30f
textAlign = Paint.Align.CENTER
isAntiAlias = true
}
canvas.drawText("No data to display", width / 2f, height / 2f, emptyPaint)
return bitmap
}
val marginTop = 45f
val marginBottom = 45f
val marginLeft = 5f
val marginRight = 5f
// Find the maximum absolute value to determine scale
val maxValue = bars.maxOfOrNull { abs(it.value) } ?: 1.0
val minValue = bars.minOfOrNull { it.value } ?: 0.0
// Determine if we need to show negative values
val hasNegativeValues = minValue < 0
val chartWidth = width - marginLeft - marginRight
val chartHeight = height - marginTop - marginBottom
val chartLeft = marginLeft
val chartTop = marginTop
val chartBottom = if (hasNegativeValues) height - marginBottom else height - 5.0f
val zeroY = if (hasNegativeValues) {
chartTop + chartHeight * (maxValue / (maxValue - minValue)).toFloat()
} else {
chartBottom
}
// Calculate bar dimensions
val barSpacing = 10f
val totalSpacing = (bars.size - 1) * barSpacing
val barWidth = (chartWidth - totalSpacing) / bars.size
// Draw bars
val barPaint = Paint().apply {
isAntiAlias = true
style = Paint.Style.FILL
}
bars.forEachIndexed { index, bar ->
val barLeft = chartLeft + index * (barWidth + barSpacing)
val barRight = barLeft + barWidth
val barHeight = if (hasNegativeValues) {
(abs(bar.value) / (maxValue - minValue) * chartHeight).toFloat()
} else {
(bar.value / maxValue * chartHeight).toFloat()
}
val barTop = if (bar.value >= 0) {
zeroY - barHeight
} else {
zeroY
}
val barBottom = if (bar.value >= 0) {
zeroY
} else {
zeroY + barHeight
}
// Draw bar
barPaint.color = bar.color
val rect = RectF(barLeft, barTop, barRight, barBottom)
canvas.drawRect(rect, barPaint)
// Draw label where value used to be with increased font size
val labelPaint = Paint().apply {
color = primaryTextColor
textSize = 32f // Increased from 24f and 28f
textAlign = Paint.Align.CENTER
isAntiAlias = true
}
// Use smallLabel if small is true, otherwise use regular label
val labelToUse = if (small) bar.smallLabel else bar.label
val labelY = if (bar.value >= 0) {
barTop - 10f // Position above positive bars
} else {
barBottom + labelPaint.textSize + 10f // Position below negative bars
}
// Create semi-transparent background box for label
val backgroundPaint = Paint().apply {
color = if (isNightMode) Color.argb(200, 0, 0, 0) else Color.argb(200, 255, 255, 255)
style = Paint.Style.FILL
isAntiAlias = true
}
// Calculate text bounds for background box
val textBounds = android.graphics.Rect()
labelPaint.getTextBounds(labelToUse, 0, labelToUse.length, textBounds)
val padding = 8f
val boxLeft = barLeft + barWidth / 2f - textBounds.width() / 2f - padding
val boxRight = barLeft + barWidth / 2f + textBounds.width() / 2f + padding
val boxTop = labelY - textBounds.height() - padding / 2f
val boxBottom = labelY + padding / 2f
// Draw rounded rectangle background
val backgroundRect = RectF(boxLeft, boxTop, boxRight, boxBottom)
val cornerRadius = 6f
canvas.drawRoundRect(backgroundRect, cornerRadius, cornerRadius, backgroundPaint)
canvas.drawText(labelToUse, barLeft + barWidth / 2f, labelY, labelPaint)
}
return bitmap
}
}

View File

@ -0,0 +1,664 @@
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 androidx.core.graphics.createBitmap
import kotlin.math.abs
import kotlin.math.floor
import kotlin.math.roundToInt
fun isNightMode(context: Context): Boolean {
val nightModeFlags = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
return nightModeFlags == Configuration.UI_MODE_NIGHT_YES
}
class LineGraphBuilder(val context: Context) {
enum class YAxis {
LEFT, RIGHT
}
data class DataPoint(val x: Float, val y: Float) // color field removed
data class Line(
val dataPoints: List<DataPoint>, // DataPoint type is now the new one
@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
val colorFunc: ((Float) -> Int)? = null, // Optional color function for dynamic colors,
val alpha: Int = 80
)
fun drawLineGraph(
width: Int,
height: Int,
gridWidth: Int,
gridHeight: Int,
lines: Set<Line>,
labelProvider: ((Float) -> String)
): Bitmap {
val bitmap = createBitmap(width, height)
val canvas = Canvas(bitmap)
val isNightMode = isNightMode(context)
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 = 30f // Increased from 24f
textAlign = Align.CENTER
isAntiAlias = true
}
canvas.drawText("No data to display", width / 2f, height / 2f, emptyPaint)
return bitmap
}
val marginTop = 10f
val marginBottom = 55f // Increased from 40f
var marginRight = 25f // Increased from 20f // Made var, default updated
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 = 60f // Increased from 48f
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 = 40f // Increased from 32f
isAntiAlias = true
}
var maxLabelWidthLeft = 0f
if (hasLeftYAxisData) {
val yLabelStringsLeft = mutableListOf<String>()
val numYTicksForCalc = 2 // As used later for drawing Y-axis ticks
val minRange = numYTicksForCalc.toFloat()
if (dataMaxYLeft - dataMinYLeft < minRange) {
dataMaxYLeft += minRange - (dataMaxYLeft - dataMinYLeft)
}
// Determine Y-axis label strings (mirrors logic from where labels are drawn)
if (abs(dataMaxYLeft - dataMinYLeft) < 0.0001f) {
yLabelStringsLeft.add(dataMinYLeft.roundToInt().toString())
} else {
for (i in 0..numYTicksForCalc) {
val value = dataMinYLeft + ((dataMaxYLeft - dataMinYLeft) / numYTicksForCalc) * i
yLabelStringsLeft.add(value.roundToInt().toString())
}
}
for (labelStr in yLabelStringsLeft) {
maxLabelWidthLeft =
kotlin.math.max(maxLabelWidthLeft, yAxisLabelPaint.measureText(labelStr))
}
}
val yAxisTextRightToAxisGap = 18f // Increased from 15f
val canvasEdgePadding = 3f // Increased from 5f
val dynamicMarginLeft =
if (hasLeftYAxisData) maxLabelWidthLeft + yAxisTextRightToAxisGap + canvasEdgePadding else canvasEdgePadding
// Dynamically calculate marginRight based on Right Y-axis label widths
var maxLabelWidthRight = 0f
if (hasRightYAxisData) {
val yLabelStringsRight = mutableListOf<String>()
val numYTicksForCalc = 2 // As used later for drawing Y-axis ticks
// Adjust Y-axis range based on numYTicksForCalc.
val minRange = numYTicksForCalc.toFloat()
if (dataMaxYRight - dataMinYRight < minRange) {
dataMaxYRight += minRange - (dataMaxYRight - dataMinYRight)
}
if (abs(dataMaxYRight - dataMinYRight) < 0.0001f) {
yLabelStringsRight.add(dataMinYRight.roundToInt().toString())
} else {
for (i in 0..numYTicksForCalc) {
val value = dataMinYRight + ((dataMaxYRight - dataMinYRight) / numYTicksForCalc) * i
yLabelStringsRight.add(value.roundToInt().toString())
}
}
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 = 25f
val legendTextColor = primaryTextColor
val legendPadding = 5f
val legendEntryHeight = 30f
val legendColorBoxSize = 24f
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 floor(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 = 40f // Increased from 32f
isAntiAlias = true
}
for (line in lines) {
if (line.dataPoints.isEmpty()) continue
val mapY = if (line.yAxis == YAxis.LEFT) ::mapYLeft else ::mapYRight
// Draw area between line and X axis, colorized per segment (match line colorization)
val zeroY = mapY((if (line.yAxis == YAxis.LEFT) effectiveMinYLeft else effectiveMinYRight).coerceAtLeast(0f))
for (i in 1 until line.dataPoints.size) {
val prev = line.dataPoints[i - 1]
val curr = line.dataPoints[i]
if (line.colorFunc != null) {
val N = 4 // Number of sub-segments (tweak for smoothness/performance)
val adjustment = 0.5f // Pixel adjustment to help close seams
for (j in 0 until N) {
val t0 = j / N.toFloat()
val t1 = (j + 1) / N.toFloat()
val x0 = prev.x + (curr.x - prev.x) * t0
val y0 = prev.y + (curr.y - prev.y) * t0
val x1 = prev.x + (curr.x - prev.x) * t1
val y1 = prev.y + (curr.y - prev.y) * t1
val color0 = line.colorFunc.invoke(y0)
val mappedX0 = mapX(x0)
val mappedY0 = mapY(y0)
val mappedX1 = mapX(x1)
val mappedY1 = mapY(y1)
// Extend the right edge of internal sub-segments to create an overlap
val rightEdgeX = if (j < N - 1) mappedX1 + adjustment else mappedX1
val areaPath = Path().apply {
moveTo(mappedX0, mappedY0)
lineTo(rightEdgeX, mappedY1)
lineTo(rightEdgeX, zeroY)
lineTo(mappedX0, zeroY)
close()
}
val areaPaint = Paint().apply {
style = Paint.Style.FILL
color = color0
isAntiAlias = true
alpha = line.alpha
}
canvas.drawPath(areaPath, areaPaint)
}
} else {
val areaPath = Path().apply {
moveTo(mapX(prev.x), mapY(prev.y))
lineTo(mapX(curr.x), mapY(curr.y))
lineTo(mapX(curr.x), zeroY)
lineTo(mapX(prev.x), zeroY)
close()
}
val areaPaint = Paint().apply {
style = Paint.Style.FILL
color = line.color
isAntiAlias = true
alpha = line.alpha
}
canvas.drawPath(areaPath, areaPaint)
}
}
// Draw the line, colorized per segment (improved: split for colorFunc)
for (i in 1 until line.dataPoints.size) {
val prev = line.dataPoints[i - 1]
val curr = line.dataPoints[i]
if (line.colorFunc != null) {
val N = 4 // Number of sub-segments (tweak for smoothness/performance)
for (j in 0 until N) {
val t0 = j / N.toFloat()
val t1 = (j + 1) / N.toFloat()
val x0 = prev.x + (curr.x - prev.x) * t0
val y0 = prev.y + (curr.y - prev.y) * t0
val x1 = prev.x + (curr.x - prev.x) * t1
val y1 = prev.y + (curr.y - prev.y) * t1
val color0 = line.colorFunc.invoke(y0)
// Optionally, blend color0 and color1 for the segment, or just use color0
val segPaint = Paint(linePaint).apply {
color = color0
}
canvas.drawLine(
mapX(x0), mapY(y0),
mapX(x1), mapY(y1),
segPaint
)
}
} else {
val segPaint = Paint(linePaint).apply {
color = line.color
}
canvas.drawLine(
mapX(prev.x), mapY(prev.y),
mapX(curr.x), mapY(curr.y),
segPaint
)
}
}
// Draw circles if enabled
if (line.drawCircles) {
for (point in line.dataPoints) {
val circlePaint = Paint(linePaint).apply {
style = Paint.Style.FILL
color = line.colorFunc?.invoke(point.y) ?: line.color
}
canvas.drawCircle(mapX(point.x), mapY(point.y), 8f, circlePaint)
}
}
}
// Draw Left Y-axis ticks and labels
if (hasLeftYAxisData) {
textPaint.textAlign = Align.RIGHT
val numYTicks = if (gridHeight > 15) (gridHeight / 10) 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(
value.roundToInt().toString(),
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(
dataMinYLeft.roundToInt().toString(),
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(
value.roundToInt().toString(),
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(
dataMinYRight.roundToInt().toString(),
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) 2 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 + 40f, textPaint)
}
}
} else {
val xPos = mapX(dataMinX)
canvas.drawLine(xPos, graphBottom - 5f, xPos, graphBottom + 5f, axisPaint)
canvas.drawText(labelProvider(dataMinX), xPos, graphBottom + 40f, textPaint)
}
textPaint.textAlign = Align.CENTER
textPaint.color = primaryTextColor // Ensure textPaint color is reset before drawing legend
// Draw Legend
val legendPaint = Paint().apply {
textSize = legendTextSize
color = legendTextColor
isAntiAlias = true
textAlign = Align.LEFT // Important for measuring text width correctly
}
val legendColorPaint = Paint().apply {
style = Paint.Style.FILL
isAntiAlias = true
}
val legendItems = lines.filter { it.label != null }
if (legendItems.isNotEmpty()) {
var maxLegendLabelWidth = 0f
for (item in legendItems) {
maxLegendLabelWidth =
kotlin.math.max(maxLegendLabelWidth, legendPaint.measureText(item.label!!))
}
val legendContentActualLeft =
(width - marginRight - legendPadding - legendColorBoxSize - legendTextMargin - maxLegendLabelWidth)
val legendContentActualRight =
(width - marginRight - legendPadding) // Right edge of the color box
val legendContentActualTop = graphTop + legendPadding // Top edge of the first color box
val legendContentActualBottom =
legendContentActualTop + (legendItems.size - 1) * legendEntryHeight + legendColorBoxSize // Bottom edge of the last color box
val legendBgPaint = Paint().apply {
color = if (isNightMode) {
Color.argb(210, 0, 0, 0)
} else {
Color.argb(210, 255, 255, 255)
}
style = Paint.Style.FILL
isAntiAlias = true
}
canvas.drawRoundRect(
legendContentActualLeft,
legendContentActualTop,
legendContentActualRight,
legendContentActualBottom,
5f,
5f,
legendBgPaint
)
}
var currentLegendY = graphTop + legendPadding
for (line in legendItems) {
// Draw color box
legendColorPaint.color = line.color
canvas.drawRect(
width - marginRight - legendPadding - legendColorBoxSize, // left
currentLegendY, // top
width - marginRight - legendPadding, // right
currentLegendY + legendColorBoxSize, // bottom
legendColorPaint
)
// Draw label text
canvas.drawText(
line.label!!,
width - marginRight - legendPadding - legendColorBoxSize - legendTextMargin - legendPaint.measureText(
line.label
), // x: Align text to the left of the color box
currentLegendY + legendColorBoxSize / 2 + legendTextSize / 3, // y: Vertically center text with color box
legendPaint
)
currentLegendY += legendEntryHeight
}
return bitmap
}
}

View File

@ -139,7 +139,7 @@ fun MainScreen(close: () -> Unit) {
Spacer(Modifier.padding(10.dp)) Spacer(Modifier.padding(10.dp))
Text("Please note that this app periodically fetches weather data from OpenMeteo for your current location. Crashlytics is used for crash reporting to help improve stability.") Text("Please note that this app periodically fetches weather data from OpenMeteo for your current location.")
} }
} }
) )

View File

@ -42,8 +42,6 @@ import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.RefreshRate import de.timklge.karooheadwind.RefreshRate
import de.timklge.karooheadwind.RoundLocationSetting import de.timklge.karooheadwind.RoundLocationSetting
import de.timklge.karooheadwind.WeatherDataProvider 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.datatypes.GpsCoordinates
import de.timklge.karooheadwind.saveSettings import de.timklge.karooheadwind.saveSettings
import de.timklge.karooheadwind.streamSettings import de.timklge.karooheadwind.streamSettings
@ -64,16 +62,6 @@ fun SettingsScreen(onFinish: () -> Unit) {
val karooSystem = remember { KarooSystemService(ctx) } val karooSystem = remember { KarooSystemService(ctx) }
var refreshRateSetting by remember { mutableStateOf(RefreshRate.STANDARD) } 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 selectedRoundLocationSetting by remember { mutableStateOf(RoundLocationSetting.KM_3) }
var forecastKmPerHour by remember { mutableStateOf("20") } var forecastKmPerHour by remember { mutableStateOf("20") }
@ -88,8 +76,6 @@ fun SettingsScreen(onFinish: () -> Unit) {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
ctx.streamSettings(karooSystem).collect { settings -> ctx.streamSettings(karooSystem).collect { settings ->
selectedWindDirectionIndicatorTextSetting = settings.windDirectionIndicatorTextSetting
selectedWindDirectionIndicatorSetting = settings.windDirectionIndicatorSetting
selectedRoundLocationSetting = settings.roundLocationTo selectedRoundLocationSetting = settings.roundLocationTo
forecastKmPerHour = settings.forecastedKmPerHour.toString() forecastKmPerHour = settings.forecastedKmPerHour.toString()
forecastMilesPerHour = settings.forecastedMilesPerHour.toString() forecastMilesPerHour = settings.forecastedMilesPerHour.toString()
@ -118,8 +104,6 @@ fun SettingsScreen(onFinish: () -> Unit) {
val newSettings = HeadwindSettings( val newSettings = HeadwindSettings(
welcomeDialogAccepted = true, welcomeDialogAccepted = true,
windDirectionIndicatorSetting = selectedWindDirectionIndicatorSetting,
windDirectionIndicatorTextSetting = selectedWindDirectionIndicatorTextSetting,
roundLocationTo = selectedRoundLocationSetting, roundLocationTo = selectedRoundLocationSetting,
forecastedMilesPerHour = forecastMilesPerHour.toIntOrNull()?.coerceIn(3, 30) ?: 12, forecastedMilesPerHour = forecastMilesPerHour.toIntOrNull()?.coerceIn(3, 30) ?: 12,
forecastedKmPerHour = forecastKmPerHour.toIntOrNull()?.coerceIn(5, 50) ?: 20, forecastedKmPerHour = forecastKmPerHour.toIntOrNull()?.coerceIn(5, 50) ?: 20,
@ -165,37 +149,6 @@ fun SettingsScreen(onFinish: () -> Unit) {
refreshRateSetting = RefreshRate.entries.find { unit -> unit.id == selectedOption.id }!! 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() val roundLocationDropdownOptions = RoundLocationSetting.entries.toList()
.map { unit -> DropdownOption(unit.id, unit.label) } .map { unit -> DropdownOption(unit.id, unit.label) }
val roundLocationInitialSelection by remember(selectedRoundLocationSetting) { val roundLocationInitialSelection by remember(selectedRoundLocationSetting) {

View File

@ -27,9 +27,6 @@ import de.timklge.karooheadwind.HeadwindStats
import de.timklge.karooheadwind.R import de.timklge.karooheadwind.R
import de.timklge.karooheadwind.ServiceStatusSingleton import de.timklge.karooheadwind.ServiceStatusSingleton
import de.timklge.karooheadwind.TemperatureUnit import de.timklge.karooheadwind.TemperatureUnit
import de.timklge.karooheadwind.weatherprovider.WeatherInterpretation
import de.timklge.karooheadwind.datatypes.ForecastDataType
import de.timklge.karooheadwind.datatypes.WeatherDataType.Companion.timeFormatter
import de.timklge.karooheadwind.datatypes.getShortDateFormatter import de.timklge.karooheadwind.datatypes.getShortDateFormatter
import de.timklge.karooheadwind.getGpsCoordinateFlow import de.timklge.karooheadwind.getGpsCoordinateFlow
import de.timklge.karooheadwind.streamCurrentForecastWeatherData import de.timklge.karooheadwind.streamCurrentForecastWeatherData
@ -37,10 +34,16 @@ import de.timklge.karooheadwind.streamCurrentWeatherData
import de.timklge.karooheadwind.streamStats import de.timklge.karooheadwind.streamStats
import de.timklge.karooheadwind.streamUpcomingRoute import de.timklge.karooheadwind.streamUpcomingRoute
import de.timklge.karooheadwind.streamUserProfile import de.timklge.karooheadwind.streamUserProfile
import de.timklge.karooheadwind.util.celciusInUserUnit
import de.timklge.karooheadwind.util.getTimeFormatter
import de.timklge.karooheadwind.util.millimetersInUserUnit
import de.timklge.karooheadwind.util.msInUserUnit
import de.timklge.karooheadwind.weatherprovider.WeatherInterpretation
import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.UserProfile import io.hammerhead.karooext.models.UserProfile
import java.time.Instant import java.time.Instant
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZoneOffset import java.time.ZoneOffset
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
import kotlin.math.roundToInt import kotlin.math.roundToInt
@ -102,7 +105,7 @@ fun WeatherScreen(onFinish: () -> Unit) {
val requestedWeatherPosition = forecastData?.data?.firstOrNull()?.coords val requestedWeatherPosition = forecastData?.data?.firstOrNull()?.coords
val formattedTime = currentWeatherData?.let { timeFormatter.format(Instant.ofEpochSecond(it.time)) } val formattedTime = currentWeatherData?.let { getTimeFormatter(ctx).format(Instant.ofEpochSecond(it.time).atZone(ZoneId.systemDefault()).toLocalTime()) }
val formattedDate = currentWeatherData?.let { getShortDateFormatter().format(Instant.ofEpochSecond(it.time)) } val formattedDate = currentWeatherData?.let { getShortDateFormatter().format(Instant.ofEpochSecond(it.time)) }
if (karooConnected == true && currentWeatherData != null) { if (karooConnected == true && currentWeatherData != null) {
@ -110,10 +113,10 @@ fun WeatherScreen(onFinish: () -> Unit) {
baseBitmap = baseBitmap, baseBitmap = baseBitmap,
current = WeatherInterpretation.fromWeatherCode(currentWeatherData?.weatherCode), current = WeatherInterpretation.fromWeatherCode(currentWeatherData?.weatherCode),
windBearing = currentWeatherData?.windDirection?.roundToInt() ?: 0, windBearing = currentWeatherData?.windDirection?.roundToInt() ?: 0,
windSpeed = currentWeatherData?.windSpeed?.roundToInt() ?: 0, windSpeed = msInUserUnit(currentWeatherData?.windSpeed ?: 0.0, profile?.preferredUnit?.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL).roundToInt(),
windGusts = currentWeatherData?.windGusts?.roundToInt() ?: 0, windGusts = msInUserUnit(currentWeatherData?.windGusts ?: 0.0, profile?.preferredUnit?.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL).roundToInt(),
precipitation = currentWeatherData?.precipitation ?: 0.0, precipitation = millimetersInUserUnit(currentWeatherData?.precipitation ?: 0.0, profile?.preferredUnit?.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL),
temperature = currentWeatherData?.temperature?.toInt() ?: 0, 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, temperatureUnit = if(profile?.preferredUnit?.temperature == UserProfile.PreferredUnit.UnitType.METRIC) TemperatureUnit.CELSIUS else TemperatureUnit.FAHRENHEIT,
timeLabel = formattedTime, timeLabel = formattedTime,
dateLabel = formattedDate, dateLabel = formattedDate,
@ -223,17 +226,17 @@ fun WeatherScreen(onFinish: () -> Unit) {
val weatherData = data?.forecasts?.getOrNull(index) val weatherData = data?.forecasts?.getOrNull(index)
val interpretation = WeatherInterpretation.fromWeatherCode(weatherData?.weatherCode ?: 0) val interpretation = WeatherInterpretation.fromWeatherCode(weatherData?.weatherCode ?: 0)
val unixTime = weatherData?.time ?: 0 val unixTime = weatherData?.time ?: 0
val formattedForecastTime = ForecastDataType.timeFormatter.format(Instant.ofEpochSecond(unixTime)) val formattedForecastTime = getTimeFormatter(ctx).format(Instant.ofEpochSecond(unixTime).atZone(ZoneId.systemDefault()).toLocalTime())
val formattedForecastDate = getShortDateFormatter().format(Instant.ofEpochSecond(unixTime)) val formattedForecastDate = getShortDateFormatter().format(Instant.ofEpochSecond(unixTime))
WeatherWidget( WeatherWidget(
baseBitmap, baseBitmap,
current = interpretation, current = interpretation,
windBearing = weatherData?.windDirection?.roundToInt() ?: 0, windBearing = weatherData?.windDirection?.roundToInt() ?: 0,
windSpeed = weatherData?.windSpeed?.roundToInt() ?: 0, windSpeed = msInUserUnit(weatherData?.windSpeed ?: 0.0, profile?.preferredUnit?.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL).roundToInt(),
windGusts = weatherData?.windGusts?.roundToInt() ?: 0, windGusts = msInUserUnit(weatherData?.windGusts ?: 0.0, profile?.preferredUnit?.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL).roundToInt(),
precipitation = weatherData?.precipitation ?: 0.0, precipitation = millimetersInUserUnit(weatherData?.precipitation ?: 0.0, profile?.preferredUnit?.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL),
temperature = weatherData?.temperature?.toInt() ?: 0, 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, temperatureUnit = if (profile?.preferredUnit?.temperature != UserProfile.PreferredUnit.UnitType.IMPERIAL) TemperatureUnit.CELSIUS else TemperatureUnit.FAHRENHEIT,
timeLabel = formattedForecastTime, timeLabel = formattedForecastTime,
dateLabel = formattedForecastDate, dateLabel = formattedForecastDate,

View File

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

View File

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

View File

@ -0,0 +1,15 @@
package de.timklge.karooheadwind.util
import android.content.Context
import android.text.format.DateFormat
import java.time.format.DateTimeFormatter
fun getTimeFormatter(context: Context): DateTimeFormatter {
val is24HourFormat = DateFormat.is24HourFormat(context)
return if (is24HourFormat) {
DateTimeFormatter.ofPattern("HH:mm")
} else {
DateTimeFormatter.ofPattern("h a")
}
}

View File

@ -6,17 +6,18 @@ import kotlinx.serialization.Serializable
data class WeatherData( data class WeatherData(
val time: Long, val time: Long,
val temperature: Double, val temperature: Double,
val relativeHumidity: Double? = null, val relativeHumidity: Int,
val precipitation: Double, val precipitation: Double,
val precipitationProbability: Double? = null, val precipitationProbability: Double? = null,
val cloudCover: Double? = null, val cloudCover: Double,
val sealevelPressure: Double? = null, val sealevelPressure: Double,
val surfacePressure: Double? = null, val surfacePressure: Double,
val windSpeed: Double, val windSpeed: Double,
val windDirection: Double, val windDirection: Double,
val windGusts: Double, val windGusts: Double,
val weatherCode: Int, val weatherCode: Int,
val isForecast: Boolean, val isForecast: Boolean,
val isNight: Boolean val isNight: Boolean,
val uvi: Double,
) )

View File

@ -12,16 +12,17 @@ data class OpenMeteoWeatherData(
@SerialName("precipitation") val precipitation: Double, @SerialName("precipitation") val precipitation: Double,
@SerialName("cloud_cover") val cloudCover: Int, @SerialName("cloud_cover") val cloudCover: Int,
@SerialName("surface_pressure") val surfacePressure: Double, @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_speed_10m") val windSpeed: Double,
@SerialName("wind_direction_10m") val windDirection: Double, @SerialName("wind_direction_10m") val windDirection: Double,
@SerialName("wind_gusts_10m") val windGusts: Double, @SerialName("wind_gusts_10m") val windGusts: Double,
@SerialName("weather_code") val weatherCode: Int, @SerialName("weather_code") val weatherCode: Int,
@SerialName("is_day") val isDay: Int, @SerialName("is_day") val isDay: Int,
@SerialName("uv_index") val uvi: Double,
) { ) {
fun toWeatherData(): WeatherData = WeatherData( fun toWeatherData(): WeatherData = WeatherData(
temperature = temperature, temperature = temperature,
relativeHumidity = relativeHumidity.toDouble(), relativeHumidity = relativeHumidity,
precipitation = precipitation, precipitation = precipitation,
cloudCover = cloudCover.toDouble(), cloudCover = cloudCover.toDouble(),
surfacePressure = surfacePressure, surfacePressure = surfacePressure,
@ -32,7 +33,8 @@ data class OpenMeteoWeatherData(
weatherCode = weatherCode, weatherCode = weatherCode,
time = time, time = time,
isForecast = false, isForecast = false,
isNight = isDay == 0 isNight = isDay == 0,
uvi = uvi
) )
} }

View File

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

View File

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

View File

@ -20,7 +20,8 @@ data class OpenWeatherMapForecastData(
val pop: Double, val pop: Double,
val rain: Rain? = null, val rain: Rain? = null,
val snow: Snow? = null, val snow: Snow? = null,
val weather: List<Weather> val weather: List<Weather>,
val uvi: Double,
) { ) {
fun toWeatherData(currentWeatherData: OpenWeatherMapWeatherData): WeatherData { fun toWeatherData(currentWeatherData: OpenWeatherMapWeatherData): WeatherData {
val dtInstant = Instant.ofEpochSecond(dt) val dtInstant = Instant.ofEpochSecond(dt)
@ -32,8 +33,9 @@ data class OpenWeatherMapForecastData(
val sunsetTime = sunsetInstant.atZone(ZoneOffset.UTC).toLocalTime() val sunsetTime = sunsetInstant.atZone(ZoneOffset.UTC).toLocalTime()
return WeatherData( return WeatherData(
uvi = uvi,
temperature = temp, temperature = temp,
relativeHumidity = humidity.toDouble(), relativeHumidity = humidity,
precipitation = rain?.h1 ?: 0.0, precipitation = rain?.h1 ?: 0.0,
cloudCover = clouds.toDouble(), cloudCover = clouds.toDouble(),
surfacePressure = pressure.toDouble(), surfacePressure = pressure.toDouble(),

View File

@ -19,11 +19,13 @@ data class OpenWeatherMapWeatherData(
val wind_gust: Double? = null, val wind_gust: Double? = null,
val rain: Rain? = null, val rain: Rain? = null,
val snow: Snow? = null, val snow: Snow? = null,
val uvi: Double,
val weather: List<Weather>){ val weather: List<Weather>){
fun toWeatherData(): WeatherData = WeatherData( fun toWeatherData(): WeatherData = WeatherData(
uvi = uvi,
temperature = temp, temperature = temp,
relativeHumidity = humidity.toDouble(), relativeHumidity = humidity,
precipitation = rain?.h1 ?: 0.0, precipitation = rain?.h1 ?: 0.0,
cloudCover = clouds.toDouble(), cloudCover = clouds.toDouble(),
surfacePressure = pressure.toDouble(), surfacePressure = pressure.toDouble(),

View File

@ -6,7 +6,6 @@ import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.WeatherDataProvider import de.timklge.karooheadwind.WeatherDataProvider
import de.timklge.karooheadwind.datatypes.GpsCoordinates import de.timklge.karooheadwind.datatypes.GpsCoordinates
import de.timklge.karooheadwind.jsonWithUnknownKeys import de.timklge.karooheadwind.jsonWithUnknownKeys
import de.timklge.karooheadwind.weatherprovider.WeatherDataForLocation
import de.timklge.karooheadwind.weatherprovider.WeatherDataResponse import de.timklge.karooheadwind.weatherprovider.WeatherDataResponse
import de.timklge.karooheadwind.weatherprovider.WeatherProvider import de.timklge.karooheadwind.weatherprovider.WeatherProvider
import de.timklge.karooheadwind.weatherprovider.WeatherProviderException import de.timklge.karooheadwind.weatherprovider.WeatherProviderException
@ -17,12 +16,14 @@ import io.hammerhead.karooext.models.UserProfile
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.single import kotlinx.coroutines.flow.single
import kotlinx.coroutines.flow.timeout import kotlinx.coroutines.flow.timeout
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlin.math.absoluteValue
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@ -48,6 +49,8 @@ data class Snow(
class OpenWeatherMapWeatherProvider(private val apiKey: String) : WeatherProvider { class OpenWeatherMapWeatherProvider(private val apiKey: String) : WeatherProvider {
companion object { companion object {
private const val MAX_API_CALLS = 3
fun convertWeatherCodeToOpenMeteo(owmCode: Int): Int { fun convertWeatherCodeToOpenMeteo(owmCode: Int): Int {
// Mapping OpenWeatherMap to WMO OpenMeteo // Mapping OpenWeatherMap to WMO OpenMeteo
return when (owmCode) { return when (owmCode) {
@ -67,43 +70,68 @@ class OpenWeatherMapWeatherProvider(private val apiKey: String) : WeatherProvide
coordinates: List<GpsCoordinates>, coordinates: List<GpsCoordinates>,
settings: HeadwindSettings, settings: HeadwindSettings,
profile: UserProfile? profile: UserProfile?
): WeatherDataResponse { ): WeatherDataResponse = coroutineScope {
val selectedCoordinates = coordinates.take((MAX_API_CALLS - 1).coerceAtLeast(1)).toMutableList()
val response = makeOpenWeatherMapRequest(karooSystem, coordinates, apiKey, profile) if (coordinates.isNotEmpty() && !selectedCoordinates.contains(coordinates.last())){
val responseBody = response.body?.let { String(it) } ?: throw Exception("Null response from OpenWeatherMap") selectedCoordinates.add(coordinates.last())
}
val responses = mutableListOf<WeatherDataForLocation>() Log.d(KarooHeadwindExtension.TAG, "OpenWeatherMap: searching for ${selectedCoordinates.size} locations from ${coordinates.size} total")
selectedCoordinates.forEachIndexed { index, coord ->
Log.d(KarooHeadwindExtension.TAG, "Point #$index: ${coord.lat}, ${coord.lon}, distance: ${coord.distanceAlongRoute}")
}
val openWeatherMapWeatherDataForLocation = jsonWithUnknownKeys.decodeFromString<OpenWeatherMapWeatherDataForLocation>(responseBody) val weatherDataForSelectedLocations = buildList {
responses.add(openWeatherMapWeatherDataForLocation.toWeatherDataForLocation(null)) for (coordinate in selectedCoordinates){
val response = makeOpenWeatherMapRequest(karooSystem, coordinate, apiKey)
val responseBody = response.body?.let { String(it) }
?: throw WeatherProviderException(response.statusCode, "Null Response from OpenWeatherMap")
// FIXME Route forecast val weatherData = jsonWithUnknownKeys.decodeFromString<OpenWeatherMapWeatherDataForLocation>(responseBody)
return WeatherDataResponse( add(coordinate to weatherData)
}
}
val allLocationData = coordinates.map { originalCoord ->
val directMatch = weatherDataForSelectedLocations.find { it.first == originalCoord }
if (directMatch != null) {
directMatch.second.toWeatherDataForLocation(originalCoord.distanceAlongRoute)
} else {
val closestCoord = weatherDataForSelectedLocations.minByOrNull { (coord, _) ->
if (originalCoord.distanceAlongRoute != null && coord.distanceAlongRoute != null) {
(originalCoord.distanceAlongRoute - coord.distanceAlongRoute).absoluteValue
} else {
originalCoord.distanceTo(coord)
}
} ?: throw WeatherProviderException(500, "Error finding nearest coordinate")
closestCoord.second.toWeatherDataForLocation(originalCoord.distanceAlongRoute)
}
}
WeatherDataResponse(
provider = WeatherDataProvider.OPEN_WEATHER_MAP, provider = WeatherDataProvider.OPEN_WEATHER_MAP,
data = responses data = allLocationData
) )
} }
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
private suspend fun makeOpenWeatherMapRequest( private suspend fun makeOpenWeatherMapRequest(
service: KarooSystemService, service: KarooSystemService,
coordinates: List<GpsCoordinates>, coordinate: GpsCoordinates,
apiKey: String, apiKey: String
profile: UserProfile?
): HttpResponseState.Complete { ): HttpResponseState.Complete {
val response = callbackFlow { 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 // URL API 3.0 with onecall endpoint
val url = "https://api.openweathermap.org/data/3.0/onecall?lat=${coordinate.lat}&lon=${coordinate.lon}" + 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") Log.d(KarooHeadwindExtension.TAG, "Http request to OpenWeatherMap API 3.0: $url")

View File

@ -5,6 +5,7 @@
<color name="white">#ffffff</color> <color name="white">#ffffff</color>
<color name="black">#000000</color> <color name="black">#000000</color>
<color name="gray">#808080</color>
<color name="green">#00ff00</color> <color name="green">#00ff00</color>
<color name="orange">#ff9930</color> <color name="orange">#ff9930</color>

View File

@ -10,39 +10,43 @@
<string name="cloudCover">Cloud cover</string> <string name="cloudCover">Cloud cover</string>
<string name="cloudCover_description">Current cloud cover in percent</string> <string name="cloudCover_description">Current cloud cover in percent</string>
<string name="windSpeed">Wind speed</string> <string name="windSpeed">Wind speed</string>
<string name="windSpeed_description">Current wind speed in configured unit</string> <string name="windSpeed_description">Current absolute wind speed</string>
<string name="windGusts">Wind gusts</string> <string name="windGusts">Wind gusts</string>
<string name="windGusts_description">Current wind gust speed in configured unit</string> <string name="windGusts_description">Current wind gust speed in configured unit</string>
<string name="windDirection">Absolute wind direction</string> <string name="windDirection">Absolute wind direction</string>
<string name="windDirection_description">Current wind direction</string> <string name="windDirection_description">Current absolute wind direction</string>
<string name="precipitation">Rainfall</string> <string name="precipitation">Rainfall</string>
<string name="precipitation_description">Current precipitation (rainfall / snowfall)</string> <string name="precipitation_description">Current precipitation (rainfall / snowfall)</string>
<string name="surfacePressure">Surface pressure</string> <string name="surfacePressure">Surface pressure</string>
<string name="surfacePressure_description">Atmospheric pressure at surface in configured unit</string> <string name="surfacePressure_description">Atmospheric pressure at surface in configured unit</string>
<string name="sealevelPressure">Sealevel pressure</string> <string name="sealevelPressure">Sealevel pressure</string>
<string name="sealevelPressure_description">Atmospheric pressure at sea level in configured unit</string> <string name="sealevelPressure_description">Atmospheric pressure at sea level in configured unit</string>
<string name="weather">Weather</string>
<string name="weather_description">Current weather conditions</string>
<string name="weather_forecast">Weather Forecast</string> <string name="weather_forecast">Weather Forecast</string>
<string name="uvi">UV Index</string>
<string name="uvi_description">Current UV Index at current location</string>
<string name="weather_forecast_description">Current hourly weather forecast</string> <string name="weather_forecast_description">Current hourly weather forecast</string>
<string name="temperature_forecast">Temperature Forecast</string> <string name="temperature_forecast">Temperature Forecast</string>
<string name="temperature_forecast_description">Current hourly temperature forecast</string> <string name="temperature_forecast_description">Current hourly temperature forecast</string>
<string name="wind_forecast">Wind Forecast</string> <string name="wind_forecast">Wind Forecast</string>
<string name="wind_forecast_description">Current hourly wind forecast</string> <string name="wind_forecast_description">Current hourly wind forecast</string>
<string name="headwind_forecast">Headwind Forecast</string>
<string name="headwind_forecast_description">Current hourly headwind forecast for loaded route</string>
<string name="precipitation_forecast">Precipitation Forecast</string> <string name="precipitation_forecast">Precipitation Forecast</string>
<string name="precipitation_forecast_description">Current hourly precipitation forecast</string> <string name="precipitation_forecast_description">Current hourly precipitation forecast</string>
<string name="headwind_speed">Headwind speed</string>
<string name="headwind_speed_description">Current headwind speed</string>
<string name="temperature">Temperature</string> <string name="temperature">Temperature</string>
<string name="temperature_description">Current temperature in configured unit</string> <string name="temperature_description">Current temperature in configured unit</string>
<string name="headwind_speed">Headwind speed</string>
<string name="headwind_speed_description">Current headwind speed</string>
<string name="userwind_speed_description">Current headwind or wind speed based on user setting</string> <string name="userwind_speed_description">Current headwind or wind speed based on user setting</string>
<string name="userwind_speed">Set wind speed</string> <string name="userwind_speed">Set wind speed</string>
<string name="graphical_forecast">Graphical Forecast</string>
<string name="graphical_forecast_description">Current graphical weather forecast</string>
<string name="tailwind">Tailwind</string>
<string name="tailwind_description">Current tailwind, wind speed and gust speed</string>
<string name="relativeGrade">Relative Grade</string> <string name="relativeGrade">Relative Grade</string>
<string name="relativeGrade_description">Perceived grade in percent</string> <string name="relativeGrade_description">Perceived grade in percent</string>
<string name="relativeElevationGain">Relative Elevation Gain</string> <string name="relativeElevationGain">Relative Elevation Gain</string>
<string name="relativeElevationGain_description">Perceived elevation gain in meters</string> <string name="relativeElevationGain_description">Perceived elevation gain in meters</string>
<string name="windDirectionAndSpeed">Wind direction, speed</string>
<string name="windDirectionAndSpeed_description">Current wind direction and wind speed</string>
<string name="windDirectionAndSpeedCircle">Wind direction, speed (Circle)</string>
<string name="windDirectionAndSpeedCircle_description">Current wind direction and wind speed (Circle graphics)</string>
<string name="forces">Resistance forces</string>
<string name="forces_description">Current resistance forces (air, rolling, gradient)</string>
</resources> </resources>

View File

@ -12,13 +12,6 @@
icon="@drawable/wind" icon="@drawable/wind"
typeId="tailwind-and-ride-speed" /> typeId="tailwind-and-ride-speed" />
<DataType
description="@string/tailwind_description"
displayName="@string/tailwind"
graphical="true"
icon="@drawable/wind"
typeId="tailwind" />
<DataType <DataType
description="@string/headwind_description" description="@string/headwind_description"
displayName="@string/headwind" displayName="@string/headwind"
@ -27,11 +20,18 @@
typeId="headwind" /> typeId="headwind" />
<DataType <DataType
description="@string/weather_description" description="@string/windDirectionAndSpeed_description"
displayName="@string/weather" displayName="@string/windDirectionAndSpeed"
graphical="true" graphical="true"
icon="@drawable/wind" icon="@drawable/wind"
typeId="weather" /> typeId="windDirectionAndSpeed" />
<DataType
description="@string/windDirectionAndSpeedCircle_description"
displayName="@string/windDirectionAndSpeedCircle"
graphical="true"
icon="@drawable/wind"
typeId="windDirectionAndSpeedCircle" />
<DataType <DataType
description="@string/weather_forecast_description" description="@string/weather_forecast_description"
@ -62,11 +62,11 @@
typeId="windForecast" /> typeId="windForecast" />
<DataType <DataType
description="@string/graphical_forecast_description" description="@string/headwind_forecast_description"
displayName="@string/graphical_forecast" displayName="@string/headwind_forecast"
graphical="true" graphical="true"
icon="@drawable/wind" icon="@drawable/wind"
typeId="graphicalForecast" /> typeId="headwindForecast" />
<DataType <DataType
description="@string/headwind_speed_description" description="@string/headwind_speed_description"
@ -75,13 +75,6 @@
icon="@drawable/wind" icon="@drawable/wind"
typeId="headwindSpeed" /> typeId="headwindSpeed" />
<DataType
description="@string/userwind_speed_description"
displayName="@string/userwind_speed"
graphical="false"
icon="@drawable/wind"
typeId="userwindSpeed" />
<DataType <DataType
description="@string/relativeHumidity_description" description="@string/relativeHumidity_description"
displayName="@string/relativeHumidity" displayName="@string/relativeHumidity"
@ -145,6 +138,13 @@
icon="@drawable/thermometer" icon="@drawable/thermometer"
typeId="temperature" /> typeId="temperature" />
<DataType
description="@string/uvi_description"
displayName="@string/uvi"
graphical="false"
icon="@drawable/thermometer"
typeId="uvi" />
<DataType <DataType
description="@string/relativeGrade_description" description="@string/relativeGrade_description"
displayName="@string/relativeGrade" displayName="@string/relativeGrade"
@ -158,4 +158,11 @@
graphical="false" graphical="false"
icon="@drawable/wind" icon="@drawable/wind"
typeId="relativeElevationGain" /> typeId="relativeElevationGain" />
<DataType
description="@string/forces_description"
displayName="@string/forces"
graphical="true"
icon="@drawable/wind"
typeId="forces" />
</ExtensionInfo> </ExtensionInfo>

View File

@ -1,3 +1,5 @@
package de.timklge.karooheadwind.datatypes
import de.timklge.karooheadwind.datatypes.RelativeGradeDataType import de.timklge.karooheadwind.datatypes.RelativeGradeDataType
import org.junit.Test import org.junit.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 44 KiB