Compare commits
32 Commits
1.5-beta-3
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| c8410ad0dc | |||
| da8583bd3e | |||
|
|
b809a48631 | ||
|
|
d56a220ae1 | ||
|
|
89cb2ec010 | ||
|
|
839b93a43b | ||
| 1b450c00d9 | |||
|
|
e7d11c2000 | ||
|
|
1fe7eb1a16 | ||
|
|
917770e45a | ||
| 1f27a33b64 | |||
|
|
109c002533 | ||
|
|
5169048143 | ||
|
|
be7ca192b2 | ||
|
|
2378c944e6 | ||
| 87a244ef27 | |||
| 04fb321a40 | |||
|
|
b6d0acd62d | ||
|
|
533cb1e006 | ||
|
|
1727e606ee | ||
|
|
f35ffe52cc | ||
|
|
5b163f6f7a | ||
| 302bdef429 | |||
| 958c576a5e | |||
|
|
ef5e980de3 | ||
|
|
4952d8fbf4 | ||
|
|
410441c3a6 | ||
|
|
d1b6f2c525 | ||
| 31ca117fef | |||
| 915f4cfacb | |||
| 8028226fac | |||
|
|
dae1369cd8 |
54
.github/workflows/release_comment.yml
vendored
Normal 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
|
||||||
29
README.md
@ -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)
|
||||||
|
|||||||
@ -72,8 +72,7 @@ tasks.register("generateManifest") {
|
|||||||
"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 "* Refactor unit conversions\n* 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(
|
||||||
"$baseUrl/preview1.png",
|
"$baseUrl/preview1.png",
|
||||||
"$baseUrl/preview3.png",
|
"$baseUrl/preview3.png",
|
||||||
|
|||||||
@ -294,9 +294,9 @@ fun lerpWeather(
|
|||||||
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
|
||||||
|
|||||||
@ -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 ->
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -38,6 +38,7 @@ 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.celciusInUserUnit
|
||||||
|
import de.timklge.karooheadwind.util.getTimeFormatter
|
||||||
import de.timklge.karooheadwind.util.millimetersInUserUnit
|
import de.timklge.karooheadwind.util.millimetersInUserUnit
|
||||||
import de.timklge.karooheadwind.util.msInUserUnit
|
import de.timklge.karooheadwind.util.msInUserUnit
|
||||||
import de.timklge.karooheadwind.weatherprovider.WeatherData
|
import de.timklge.karooheadwind.weatherprovider.WeatherData
|
||||||
@ -64,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)
|
||||||
@ -110,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()
|
||||||
@ -130,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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -152,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",
|
||||||
@ -256,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
|
||||||
|
|
||||||
@ -270,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
|
||||||
@ -301,13 +306,22 @@ 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(
|
||||||
@ -324,7 +338,8 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ
|
|||||||
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
|
||||||
@ -332,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
|
||||||
|
|
||||||
@ -350,7 +365,8 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ
|
|||||||
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
|
||||||
|
|||||||
@ -1,110 +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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -9,7 +9,6 @@ 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
|
||||||
@ -17,6 +16,7 @@ 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.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
|
||||||
@ -39,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)
|
||||||
@ -66,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.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
|
||||||
|
|
||||||
@ -120,7 +101,7 @@ class HeadwindDirectionDataType(
|
|||||||
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(
|
emit(DirectionAndSpeed(
|
||||||
bearing,
|
bearing,
|
||||||
@ -154,7 +135,7 @@ 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), karooSystem.streamUserProfile()) { direction, speed, isVisible, profile ->
|
combine(directionFlow, speedFlow, karooSystem.streamDatatypeIsVisible(dataTypeId), karooSystem.streamUserProfile()) { direction, speed, isVisible, profile ->
|
||||||
@ -203,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
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun KarooSystemService.getRefreshRateInMilliseconds(context: Context): Long {
|
fun streamValues(context: Context, karooSystem: KarooSystemService): Flow<Double> = flow {
|
||||||
val refreshRate = context.streamSettings(this).first().refreshRate
|
data class StreamData(
|
||||||
val isK2 = hardwareType == HardwareType.K2
|
val headingResponse: HeadingResponse,
|
||||||
|
val weatherResponse: WeatherData?,
|
||||||
|
val settings: HeadwindSettings
|
||||||
|
)
|
||||||
|
|
||||||
return if (isK2){
|
combine(karooSystem.getRelativeHeadingFlow(context), context.streamCurrentWeatherData(karooSystem), context.streamSettings(karooSystem)) { headingResponse, weatherResponse, settings ->
|
||||||
refreshRate.k2Ms
|
StreamData(headingResponse, weatherResponse, settings)
|
||||||
} else {
|
}.filter { it.weatherResponse != null }
|
||||||
refreshRate.k3Ms
|
.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
)
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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> {
|
||||||
|
|||||||
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,10 +15,8 @@ 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
|
||||||
@ -77,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
|
||||||
|
|
||||||
@ -161,10 +170,7 @@ 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 rideSpeedInUserUnit = msInUserUnit(streamData.rideSpeed ?: 0.0, streamData.isImperial)
|
val rideSpeedInUserUnit = msInUserUnit(streamData.rideSpeed ?: 0.0, streamData.isImperial)
|
||||||
val text = String.format(Locale.current.platformLocale, "%.1f", rideSpeedInUserUnit)
|
val text = String.format(Locale.current.platformLocale, "%.1f", rideSpeedInUserUnit)
|
||||||
@ -181,33 +187,24 @@ class TailwindAndRideSpeedDataType(
|
|||||||
|
|
||||||
val windSpeedUserUnit = msInUserUnit(windSpeed, streamData.isImperial)
|
val windSpeedUserUnit = msInUserUnit(windSpeed, streamData.isImperial)
|
||||||
|
|
||||||
val subtextWithSign = when (streamData.settings.windDirectionIndicatorTextSetting) {
|
val subtextWithSign = let {
|
||||||
WindDirectionIndicatorTextSetting.HEADWIND_SPEED -> {
|
|
||||||
val headwindSpeed = cos( (windDirection + 180) * Math.PI / 180.0) * windSpeed
|
|
||||||
headwindSpeed.roundToInt().toString()
|
|
||||||
|
|
||||||
val sign = if (headwindSpeed < 0) "+" else {
|
|
||||||
if (headwindSpeed > 0) "-" else ""
|
|
||||||
}
|
|
||||||
|
|
||||||
val headwindSpeedUserUnit = msInUserUnit(headwindSpeed, streamData.isImperial)
|
|
||||||
|
|
||||||
"$sign${headwindSpeedUserUnit.roundToInt().absoluteValue} ${windSpeedUserUnit.roundToInt()}${gustSpeedAddon}"
|
|
||||||
}
|
|
||||||
WindDirectionIndicatorTextSetting.WIND_SPEED -> "${windSpeedUserUnit.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 = headwindSpeed * 3.6
|
headwindSpeed.roundToInt().toString()
|
||||||
dayColor = interpolateWindColor(windSpeedInKmh, false, context)
|
|
||||||
nightColor = interpolateWindColor(windSpeedInKmh, true, context)
|
val sign = if (headwindSpeed < 0) "+" else {
|
||||||
|
if (headwindSpeed > 0) "-" else ""
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
|||||||
@ -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",
|
||||||
|
)
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 {
|
||||||
@ -75,4 +79,15 @@ 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -1,182 +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.util.celciusInUserUnit
|
|
||||||
import de.timklge.karooheadwind.util.millimetersInUserUnit
|
|
||||||
import de.timklge.karooheadwind.util.msInUserUnit
|
|
||||||
import de.timklge.karooheadwind.weatherprovider.WeatherData
|
|
||||||
import de.timklge.karooheadwind.weatherprovider.WeatherInterpretation
|
|
||||||
import io.hammerhead.karooext.KarooSystemService
|
|
||||||
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, 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 = msInUserUnit(data.windSpeed, userProfile?.preferredUnit?.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL).roundToInt(),
|
|
||||||
windGusts = msInUserUnit(data.windGusts, userProfile?.preferredUnit?.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL).roundToInt(),
|
|
||||||
precipitation = millimetersInUserUnit(data.precipitation, userProfile?.preferredUnit?.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL),
|
|
||||||
precipitationProbability = null,
|
|
||||||
temperature = celciusInUserUnit(data.temperature, userProfile?.preferredUnit?.temperature == UserProfile.PreferredUnit.UnitType.IMPERIAL).roundToInt(),
|
|
||||||
temperatureUnit = if (userProfile?.preferredUnit?.temperature != UserProfile.PreferredUnit.UnitType.IMPERIAL) TemperatureUnit.CELSIUS else TemperatureUnit.FAHRENHEIT,
|
|
||||||
timeLabel = formattedTime,
|
|
||||||
rowAlignment = when (config.alignment){
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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,
|
||||||
|
|||||||
@ -3,32 +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.util.msInUserUnit
|
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.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
|
||||||
@ -41,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()
|
||||||
|
|
||||||
@ -58,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)
|
||||||
@ -69,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")
|
||||||
@ -107,22 +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
|
||||||
|
|
||||||
StreamData(headingResponse, absoluteWindDirection, windSpeed, settings, rideSpeed = rideSpeedInMs, isImperial = isImperial, gustSpeed = gustSpeed, isVisible = isVisible)
|
StreamData(headingResponse, absoluteWindDirection, windSpeed, settings, isImperial = isImperial, gustSpeed = gustSpeed, isVisible = isVisible)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -131,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){
|
||||||
@ -147,43 +123,21 @@ 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 mainText = when (streamData.settings.windDirectionIndicatorTextSetting) {
|
|
||||||
WindDirectionIndicatorTextSetting.HEADWIND_SPEED -> {
|
|
||||||
val headwindSpeed = cos( (windDirection + 180) * Math.PI / 180.0) * windSpeed
|
|
||||||
headwindSpeed.roundToInt().toString()
|
|
||||||
|
|
||||||
val sign = if (headwindSpeed < 0) "+" else {
|
|
||||||
if (headwindSpeed > 0) "-" else ""
|
|
||||||
}
|
|
||||||
|
|
||||||
val headwindSpeedUserUnit = msInUserUnit(headwindSpeed, streamData.isImperial)
|
|
||||||
|
|
||||||
"$sign${headwindSpeedUserUnit.roundToInt().absoluteValue}"
|
|
||||||
}
|
|
||||||
WindDirectionIndicatorTextSetting.WIND_SPEED -> msInUserUnit(windSpeed, streamData.isImperial).roundToInt().toString()
|
|
||||||
WindDirectionIndicatorTextSetting.NONE -> ""
|
|
||||||
}
|
|
||||||
|
|
||||||
val windSpeedUserUnit = msInUserUnit(windSpeed, streamData.isImperial)
|
val windSpeedUserUnit = msInUserUnit(windSpeed, streamData.isImperial)
|
||||||
val gustSpeedUserUnit = msInUserUnit(streamData.gustSpeed ?: 0.0, streamData.isImperial)
|
val gustSpeedUserUnit = msInUserUnit(streamData.gustSpeed ?: 0.0, streamData.isImperial)
|
||||||
|
|
||||||
val subtext = "${windSpeedUserUnit.roundToInt()}-${gustSpeedUserUnit.roundToInt()}"
|
val mainText = let {
|
||||||
|
"${windSpeedUserUnit.roundToInt().absoluteValue}"
|
||||||
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 = headwindSpeed * 3.6
|
|
||||||
dayColor = interpolateWindColor(windSpeedInKmh, false, context)
|
|
||||||
nightColor = interpolateWindColor(windSpeedInKmh, true, context)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
val subtext = "Max ${gustSpeedUserUnit.roundToInt()}"
|
||||||
|
|
||||||
|
val headwindSpeed = cos( (windDirection + 180) * Math.PI / 180.0) * windSpeed
|
||||||
|
val windSpeedInKmh = headwindSpeed * 3.6
|
||||||
|
|
||||||
val result = glance.compose(context, DpSize.Unspecified) {
|
val result = glance.compose(context, DpSize.Unspecified) {
|
||||||
HeadwindDirection(
|
HeadwindDirection(
|
||||||
baseBitmap,
|
baseBitmap,
|
||||||
@ -191,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,
|
||||||
)
|
)
|
||||||
@ -202,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()
|
||||||
}
|
}
|
||||||
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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",
|
||||||
|
))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -3,10 +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
|
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, userProfile: UserProfile): Double {
|
override fun getValue(data: WeatherData, userProfile: UserProfile): Double {
|
||||||
return data.windGusts
|
return data.windGusts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun getFormatDataType(): String {
|
||||||
|
return DataType.Type.SPEED
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -3,10 +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
|
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, userProfile: UserProfile): Double {
|
override fun getValue(data: WeatherData, userProfile: UserProfile): Double {
|
||||||
return data.windSpeed
|
return data.windSpeed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun getFormatDataType(): String {
|
||||||
|
return DataType.Type.SPEED
|
||||||
|
}
|
||||||
}
|
}
|
||||||
154
app/src/main/kotlin/de/timklge/karooheadwind/screens/BarChart.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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
|
||||||
@ -38,12 +35,15 @@ 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.celciusInUserUnit
|
||||||
|
import de.timklge.karooheadwind.util.getTimeFormatter
|
||||||
import de.timklge.karooheadwind.util.millimetersInUserUnit
|
import de.timklge.karooheadwind.util.millimetersInUserUnit
|
||||||
import de.timklge.karooheadwind.util.msInUserUnit
|
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
|
||||||
@ -105,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) {
|
||||||
@ -226,15 +226,15 @@ 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 = msInUserUnit(currentWeatherData?.windSpeed ?: 0.0, profile?.preferredUnit?.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL).roundToInt(),
|
windSpeed = msInUserUnit(weatherData?.windSpeed ?: 0.0, profile?.preferredUnit?.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL).roundToInt(),
|
||||||
windGusts = msInUserUnit(currentWeatherData?.windGusts ?: 0.0, profile?.preferredUnit?.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL).roundToInt(),
|
windGusts = msInUserUnit(weatherData?.windGusts ?: 0.0, profile?.preferredUnit?.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL).roundToInt(),
|
||||||
precipitation = millimetersInUserUnit(weatherData?.precipitation ?: 0.0, profile?.preferredUnit?.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL),
|
precipitation = millimetersInUserUnit(weatherData?.precipitation ?: 0.0, profile?.preferredUnit?.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL),
|
||||||
temperature = celciusInUserUnit(weatherData?.temperature ?: 0.0, profile?.preferredUnit?.temperature == UserProfile.PreferredUnit.UnitType.IMPERIAL).roundToInt(),
|
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,
|
||||||
|
|||||||
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -17,6 +17,7 @@ data class WeatherData(
|
|||||||
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,7 @@ data class OpenMeteoWeatherData(
|
|||||||
@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,
|
||||||
@ -33,6 +34,7 @@ data class OpenMeteoWeatherData(
|
|||||||
time = time,
|
time = time,
|
||||||
isForecast = false,
|
isForecast = false,
|
||||||
isNight = isDay == 0,
|
isNight = isDay == 0,
|
||||||
|
uvi = uvi
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,7 @@ data class OpenMeteoWeatherForecastData(
|
|||||||
@SerialName("pressure_msl") val sealevelPressure: 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("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 ->
|
||||||
@ -37,6 +38,7 @@ data class OpenMeteoWeatherForecastData(
|
|||||||
surfacePressure = surfacePressure[index],
|
surfacePressure = surfacePressure[index],
|
||||||
sealevelPressure = sealevelPressure[index],
|
sealevelPressure = sealevelPressure[index],
|
||||||
relativeHumidity = relativeHumidity[index],
|
relativeHumidity = relativeHumidity[index],
|
||||||
|
uvi = uvi[index]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,7 +30,7 @@ class OpenMeteoWeatherProvider : WeatherProvider {
|
|||||||
// https://api.open-meteo.com/v1/forecast?latitude=52.52&longitude=13.41¤t=is_day,surface_pressure,pressure_msl,temperature_2m,relative_humidity_2m,precipitation,weather_code,cloud_cover,wind_speed_10m,wind_direction_10m,wind_gusts_10m&hourly=temperature_2m,precipitation_probability,precipitation,weather_code,wind_speed_10m,wind_direction_10m,wind_gusts_10m&timeformat=unixtime&past_hours=1&forecast_days=1&forecast_hours=12
|
// https://api.open-meteo.com/v1/forecast?latitude=52.52&longitude=13.41¤t=is_day,surface_pressure,pressure_msl,temperature_2m,relative_humidity_2m,precipitation,weather_code,cloud_cover,wind_speed_10m,wind_direction_10m,wind_gusts_10m&hourly=temperature_2m,precipitation_probability,precipitation,weather_code,wind_speed_10m,wind_direction_10m,wind_gusts_10m&timeformat=unixtime&past_hours=1&forecast_days=1&forecast_hours=12
|
||||||
val lats = gpsCoordinates.joinToString(",") { String.format(Locale.US, "%.6f", it.lat) }
|
val 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}¤t=is_day,surface_pressure,pressure_msl,temperature_2m,relative_humidity_2m,precipitation,weather_code,cloud_cover,wind_speed_10m,wind_direction_10m,wind_gusts_10m&hourly=temperature_2m,precipitation_probability,precipitation,weather_code,wind_speed_10m,wind_direction_10m,wind_gusts_10m,is_day,surface_pressure,pressure_msl,relative_humidity_2m,cloud_cover&timeformat=unixtime&past_hours=0&forecast_days=1&forecast_hours=12&wind_speed_unit=ms"
|
val url = "https://api.open-meteo.com/v1/forecast?latitude=${lats}&longitude=${lons}¤t=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}...")
|
||||||
|
|
||||||
|
|||||||
@ -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,6 +33,7 @@ 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,
|
relativeHumidity = humidity,
|
||||||
precipitation = rain?.h1 ?: 0.0,
|
precipitation = rain?.h1 ?: 0.0,
|
||||||
|
|||||||
@ -19,9 +19,11 @@ 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,
|
relativeHumidity = humidity,
|
||||||
precipitation = rain?.h1 ?: 0.0,
|
precipitation = rain?.h1 ?: 0.0,
|
||||||
|
|||||||
@ -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,37 +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)
|
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
|
||||||
): 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 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=metric"
|
"&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")
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
BIN
preview0.png
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 37 KiB |
BIN
preview1.png
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 56 KiB |
BIN
preview2.png
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 24 KiB |
BIN
preview3.png
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 44 KiB |