Compare commits

...

3 Commits

Author SHA1 Message Date
timklge
ef5e980de3
Crop timespan to 6 hours in forecast views, use am / pm time format if appropriate (#144)
Some checks failed
Build / build (push) Has been cancelled
* Crop timespan to 6 hours in forecast views, use am / pm time format if appropriate

* Also crop timeframe in hourly forecast widget
2025-06-09 14:14:40 +02:00
timklge
4952d8fbf4
Remove tailwind, weather, temperature data fields (#143) 2025-06-09 12:18:20 +02:00
timklge
410441c3a6
Increase font size in line graph forecast datafields (#142) 2025-06-09 11:29:15 +02:00
18 changed files with 116 additions and 631 deletions

View File

@ -23,11 +23,9 @@ After installing this app on your Karoo and opening it once from the main menu,
- Headwind (graphical, 1x1 field): Shows the headwind direction and speed as a circle with a triangular direction indicator. The speed is shown at the center in your set unit of measurement (default is kilometers per hour if you have set up metric units in your Karoo, otherwise miles per hour). Both direction and speed are relative to the current riding direction by default, i. e., riding directly into a wind of 20 km/h will show a headwind speed of 20 km/h, while riding in the same direction will show -20 km/h. - 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. - Wind direction and speed (graphical, 1x1 field): Similar to the tailwind data field, but shows the absolute wind speed and gust speed instead.
- Wind forecast / Temperature Forecast / Precipitation forecast (graphical, 2x1 field): Line graphs showing the forecasted wind speeds, temperature or precipitation for the next 12 hours if no route is loaded. If a route is loaded, forecasts along the route will be used instead of the current location. - Wind forecast / Temperature Forecast / Precipitation forecast (graphical, 2x1 field): Line graphs showing the forecasted wind speeds, temperature or precipitation for the next 12 hours if no route is loaded. If a route is loaded, forecasts along the route will be used instead of the current location.
- Weather forecast (graphical, 2x1 field): Shows three columns indicating the current weather conditions (sunny, cloudy, ...), wind direction, precipitation and temperature forecasted for the next three hours. Tap on this widget to cycle through the 12 hour forecast. If you have a route loaded, the forecast widget will show the forecasted weather along points of the route, with an estimated traveled distance per hour of 20 km / 12 miles by default. - 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.
- Current weather (graphical, 1x1 field): Shows current weather conditions (same as forecast widget, but only for the current time).
- Relative grade (numerical): Shows the relative grade. The relative grade is calculated by estimating the force of the headwind, and then calculating the gradient you would need to ride at to experience this resistance if there was no wind. Example: If you are riding on an actual gradient of 2 %, face a headwind of 18 km/h while riding at 29 km/h, the relative grade will be shown as 5.2 % (with 3.2 % added to the actual grade due to the headwind). - 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.
- 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.

View File

@ -5,6 +5,7 @@ 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 kotlinx.coroutines.awaitCancellation
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
@ -90,7 +91,8 @@ 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()
} */ } */

View File

@ -6,7 +6,6 @@ import com.mapbox.turf.TurfConstants
import com.mapbox.turf.TurfMeasurement import com.mapbox.turf.TurfMeasurement
import de.timklge.karooheadwind.datatypes.CloudCoverDataType import de.timklge.karooheadwind.datatypes.CloudCoverDataType
import de.timklge.karooheadwind.datatypes.GpsCoordinates import de.timklge.karooheadwind.datatypes.GpsCoordinates
import de.timklge.karooheadwind.datatypes.GraphicalForecastDataType
import de.timklge.karooheadwind.datatypes.HeadwindDirectionDataType import de.timklge.karooheadwind.datatypes.HeadwindDirectionDataType
import de.timklge.karooheadwind.datatypes.HeadwindSpeedDataType import de.timklge.karooheadwind.datatypes.HeadwindSpeedDataType
import de.timklge.karooheadwind.datatypes.PrecipitationDataType import de.timklge.karooheadwind.datatypes.PrecipitationDataType
@ -17,11 +16,9 @@ import de.timklge.karooheadwind.datatypes.RelativeHumidityDataType
import de.timklge.karooheadwind.datatypes.SealevelPressureDataType import de.timklge.karooheadwind.datatypes.SealevelPressureDataType
import de.timklge.karooheadwind.datatypes.SurfacePressureDataType import de.timklge.karooheadwind.datatypes.SurfacePressureDataType
import de.timklge.karooheadwind.datatypes.TailwindAndRideSpeedDataType import de.timklge.karooheadwind.datatypes.TailwindAndRideSpeedDataType
import de.timklge.karooheadwind.datatypes.WindDirectionAndSpeedDataType
import de.timklge.karooheadwind.datatypes.TemperatureDataType
import de.timklge.karooheadwind.datatypes.TemperatureForecastDataType import de.timklge.karooheadwind.datatypes.TemperatureForecastDataType
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.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
@ -68,14 +65,12 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", BuildConfig.VERS
listOf( listOf(
HeadwindDirectionDataType(karooSystem, applicationContext), HeadwindDirectionDataType(karooSystem, applicationContext),
TailwindAndRideSpeedDataType(karooSystem, applicationContext), TailwindAndRideSpeedDataType(karooSystem, applicationContext),
WeatherDataType(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), WindDirectionAndSpeedDataType(karooSystem, applicationContext),
PrecipitationDataType(karooSystem, applicationContext), PrecipitationDataType(karooSystem, applicationContext),
@ -84,7 +79,6 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", BuildConfig.VERS
TemperatureForecastDataType(karooSystem), TemperatureForecastDataType(karooSystem),
PrecipitationForecastDataType(karooSystem), PrecipitationForecastDataType(karooSystem),
WindForecastDataType(karooSystem), WindForecastDataType(karooSystem),
GraphicalForecastDataType(karooSystem),
WindDirectionAndSpeedDataType(karooSystem, applicationContext), WindDirectionAndSpeedDataType(karooSystem, applicationContext),
RelativeGradeDataType(karooSystem, applicationContext), RelativeGradeDataType(karooSystem, applicationContext),
RelativeElevationGainDataType(karooSystem, applicationContext), RelativeElevationGainDataType(karooSystem, applicationContext),

View File

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

View File

@ -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
@ -89,10 +90,6 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ
@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)
@ -256,7 +253,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 +267,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 +301,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(
@ -332,7 +341,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

View File

@ -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
)
}
}

View File

@ -25,6 +25,7 @@ import de.timklge.karooheadwind.streamUpcomingRoute
import de.timklge.karooheadwind.streamUserProfile import de.timklge.karooheadwind.streamUserProfile
import de.timklge.karooheadwind.streamWidgetSettings import de.timklge.karooheadwind.streamWidgetSettings
import de.timklge.karooheadwind.throttle import de.timklge.karooheadwind.throttle
import de.timklge.karooheadwind.util.getTimeFormatter
import de.timklge.karooheadwind.weatherprovider.WeatherData import de.timklge.karooheadwind.weatherprovider.WeatherData
import de.timklge.karooheadwind.weatherprovider.WeatherDataForLocation import de.timklge.karooheadwind.weatherprovider.WeatherDataForLocation
import de.timklge.karooheadwind.weatherprovider.WeatherDataResponse import de.timklge.karooheadwind.weatherprovider.WeatherDataResponse
@ -49,7 +50,6 @@ 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.ceil import kotlin.math.ceil
@ -59,10 +59,6 @@ abstract class LineGraphForecastDataType(private val karooSystem: KarooSystemSer
@OptIn(ExperimentalGlanceRemoteViewsApi::class) @OptIn(ExperimentalGlanceRemoteViewsApi::class)
private val glance = GlanceRemoteViews() private val glance = GlanceRemoteViews()
companion object {
val timeFormatter = DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneId.of("UTC"))
}
data class StreamData(val data: WeatherDataResponse?, val settings: SettingsAndProfile, 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)
@ -82,7 +78,7 @@ abstract class LineGraphForecastDataType(private val karooSystem: KarooSystemSer
val settingsAndProfile = settingsAndProfileStream.firstOrNull() val settingsAndProfile = settingsAndProfileStream.firstOrNull()
while (true) { while (true) {
val data = (0..<10).map { index -> val data = (0..<12).map { index ->
val timeAtFullHour = Instant.now().truncatedTo(ChronoUnit.HOURS).epochSecond val timeAtFullHour = Instant.now().truncatedTo(ChronoUnit.HOURS).epochSecond
val weatherData = (0..<12).map { val weatherData = (0..<12).map {
@ -228,7 +224,13 @@ abstract class LineGraphForecastDataType(private val karooSystem: KarooSystemSer
val result = glance.compose(context, DpSize.Unspecified) { val result = glance.compose(context, DpSize.Unspecified) {
val data = buildList { val data = buildList {
for(i in 0..<12){ for(i in 0..<12){
val locationData = if (upcomingRoute != null){ val isRouteLoaded = if (config.preview){
true
} else {
upcomingRoute != null
}
val locationData = if (isRouteLoaded){
allData?.data?.getOrNull(i) allData?.data?.getOrNull(i)
} else { } else {
allData?.data?.firstOrNull() allData?.data?.firstOrNull()
@ -246,6 +248,11 @@ abstract class LineGraphForecastDataType(private val karooSystem: KarooSystemSer
val time = Instant.ofEpochSecond(data.time) 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( add(LineData(
time = time, time = time,
distance = locationData?.coords?.distanceAlongRoute?.toFloat(), distance = locationData?.coords?.distanceAlongRoute?.toFloat(),
@ -258,7 +265,7 @@ abstract class LineGraphForecastDataType(private val karooSystem: KarooSystemSer
val bitmap = LineGraphBuilder(context).drawLineGraph(config.viewSize.first, config.viewSize.second, config.gridSize.first, config.gridSize.second, pointData) { x -> val bitmap = LineGraphBuilder(context).drawLineGraph(config.viewSize.first, config.viewSize.second, config.gridSize.first, config.gridSize.second, pointData) { x ->
val startTime = data.firstOrNull()?.time val startTime = data.firstOrNull()?.time
val time = startTime?.plus(floor(x).toLong(), ChronoUnit.HOURS) val time = startTime?.plus(floor(x).toLong(), ChronoUnit.HOURS)
val timeLabel = timeFormatter.format(time) val timeLabel = getTimeFormatter(context).format(time?.atZone(ZoneId.systemDefault())?.toLocalTime())
val beforeData = data.getOrNull(floor(x).toInt().coerceAtLeast(0)) val beforeData = data.getOrNull(floor(x).toInt().coerceAtLeast(0))
val afterData = data.getOrNull(ceil(x).toInt().coerceAtMost(data.size - 1)) val afterData = data.getOrNull(ceil(x).toInt().coerceAtMost(data.size - 1))
@ -267,9 +274,9 @@ abstract class LineGraphForecastDataType(private val karooSystem: KarooSystemSer
val end = (afterData?.distance ?: upcomingRoute?.routeLength?.toFloat()) ?: 0.0f val end = (afterData?.distance ?: upcomingRoute?.routeLength?.toFloat()) ?: 0.0f
val distance = start + (end - start) * (x - floor(x)) val distance = start + (end - start) * (x - floor(x))
val distanceLabel = if (settingsAndProfile.isImperial) { val distanceLabel = if (settingsAndProfile.isImperial) {
"${(distance * 0.000621371).toInt()}mi" "${(distance * 0.000621371).toInt()}"
} else { } else {
"${(distance / 1000).toInt()}km" "${(distance / 1000).toInt()}"
} }
return@drawLineGraph distanceLabel return@drawLineGraph distanceLabel
} else { } else {

View File

@ -19,7 +19,7 @@ class PrecipitationForecastDataType(karooSystem: KarooSystemService) : LineGraph
} }
val precipitationPropagation = lineData.map { data -> val precipitationPropagation = lineData.map { data ->
data.weatherData.precipitationProbability ?: 0.0 (data.weatherData.precipitationProbability?.coerceAtMost(99.0)) ?: 0.0 // Max 99 % so that the label doesn't take up too much space
} }
return setOf( return setOf(

View File

@ -15,7 +15,6 @@ 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.datatypes.TailwindDataType.StreamData
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
@ -75,6 +74,17 @@ 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()

View File

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

View File

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

View File

@ -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, 5.0, 30.0, 10.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()
}
}
}

View File

@ -11,6 +11,7 @@ import android.graphics.Path
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import kotlin.math.abs import kotlin.math.abs
import androidx.core.graphics.createBitmap import androidx.core.graphics.createBitmap
import kotlin.math.roundToInt
class LineGraphBuilder(val context: Context) { class LineGraphBuilder(val context: Context) {
enum class YAxis { enum class YAxis {
@ -54,7 +55,7 @@ class LineGraphBuilder(val context: Context) {
if (lines.isEmpty() || lines.all { it.dataPoints.isEmpty() }) { if (lines.isEmpty() || lines.all { it.dataPoints.isEmpty() }) {
val emptyPaint = Paint().apply { val emptyPaint = Paint().apply {
color = primaryTextColor color = primaryTextColor
textSize = 24f // Increased from 20f textSize = 30f // Increased from 24f
textAlign = Align.CENTER textAlign = Align.CENTER
isAntiAlias = true isAntiAlias = true
} }
@ -63,8 +64,8 @@ class LineGraphBuilder(val context: Context) {
} }
val marginTop = 10f val marginTop = 10f
val marginBottom = 40f // Increased from 30f val marginBottom = 55f // Increased from 40f
var marginRight = 20f // Increased from 5f // Made var var marginRight = 25f // Increased from 20f // Made var, default updated
var dataMinX = Float.MAX_VALUE var dataMinX = Float.MAX_VALUE
var dataMaxX = Float.MIN_VALUE var dataMaxX = Float.MIN_VALUE
@ -101,7 +102,7 @@ class LineGraphBuilder(val context: Context) {
if (!hasData) { if (!hasData) {
val emptyPaint = Paint().apply { val emptyPaint = Paint().apply {
color = primaryTextColor color = primaryTextColor
textSize = 48f // Increased from 40f textSize = 60f // Increased from 48f
textAlign = Align.CENTER textAlign = Align.CENTER
isAntiAlias = true isAntiAlias = true
} }
@ -111,7 +112,7 @@ class LineGraphBuilder(val context: Context) {
// Dynamically calculate marginLeft based on Y-axis label widths // Dynamically calculate marginLeft based on Y-axis label widths
val yAxisLabelPaint = Paint().apply { val yAxisLabelPaint = Paint().apply {
textSize = 32f textSize = 40f // Increased from 32f
isAntiAlias = true isAntiAlias = true
} }
@ -120,26 +121,18 @@ class LineGraphBuilder(val context: Context) {
val yLabelStringsLeft = mutableListOf<String>() val yLabelStringsLeft = mutableListOf<String>()
val numYTicksForCalc = 2 // As used later for drawing Y-axis ticks 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) // Determine Y-axis label strings (mirrors logic from where labels are drawn)
if (abs(dataMaxYLeft - dataMinYLeft) < 0.0001f) { if (abs(dataMaxYLeft - dataMinYLeft) < 0.0001f) {
yLabelStringsLeft.add( yLabelStringsLeft.add(dataMinYLeft.roundToInt().toString())
String.format(
java.util.Locale.getDefault(),
"%.0f",
dataMinYLeft
)
)
} else { } else {
for (i in 0..numYTicksForCalc) { for (i in 0..numYTicksForCalc) {
val value = val value = dataMinYLeft + ((dataMaxYLeft - dataMinYLeft) / numYTicksForCalc) * i
dataMinYLeft + ((dataMaxYLeft - dataMinYLeft) / numYTicksForCalc) * i yLabelStringsLeft.add(value.roundToInt().toString())
yLabelStringsLeft.add(
String.format(
java.util.Locale.getDefault(),
"%.0f",
value
)
)
} }
} }
@ -149,8 +142,8 @@ class LineGraphBuilder(val context: Context) {
} }
} }
val yAxisTextRightToAxisGap = 15f // Current gap used: graphLeft - 15f val yAxisTextRightToAxisGap = 18f // Increased from 15f
val canvasEdgePadding = 5f // Desired padding from the canvas edge val canvasEdgePadding = 3f // Increased from 5f
val dynamicMarginLeft = val dynamicMarginLeft =
if (hasLeftYAxisData) maxLabelWidthLeft + yAxisTextRightToAxisGap + canvasEdgePadding else canvasEdgePadding if (hasLeftYAxisData) maxLabelWidthLeft + yAxisTextRightToAxisGap + canvasEdgePadding else canvasEdgePadding
@ -161,25 +154,18 @@ class LineGraphBuilder(val context: Context) {
val yLabelStringsRight = mutableListOf<String>() val yLabelStringsRight = mutableListOf<String>()
val numYTicksForCalc = 2 // As used later for drawing Y-axis ticks 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) { if (abs(dataMaxYRight - dataMinYRight) < 0.0001f) {
yLabelStringsRight.add( yLabelStringsRight.add(dataMinYRight.roundToInt().toString())
String.format(
java.util.Locale.getDefault(),
"%.0f",
dataMinYRight
)
)
} else { } else {
for (i in 0..numYTicksForCalc) { for (i in 0..numYTicksForCalc) {
val value = val value = dataMinYRight + ((dataMaxYRight - dataMinYRight) / numYTicksForCalc) * i
dataMinYRight + ((dataMaxYRight - dataMinYRight) / numYTicksForCalc) * i yLabelStringsRight.add(value.roundToInt().toString())
yLabelStringsRight.add(
String.format(
java.util.Locale.getDefault(),
"%.0f",
value
)
)
} }
} }
@ -199,12 +185,13 @@ class LineGraphBuilder(val context: Context) {
val graphBottom = height - marginBottom val graphBottom = height - marginBottom
val graphRight = width - marginRight // Define graphRight for clarity val graphRight = width - marginRight // Define graphRight for clarity
// Legend properties // Legend properties
val legendTextSize = 26f // Increased from 22f val legendTextSize = 25f
val legendTextColor = primaryTextColor val legendTextColor = primaryTextColor
val legendPadding = 5f val legendPadding = 5f
val legendEntryHeight = 30f // Increased from 25f val legendEntryHeight = 30f
val legendColorBoxSize = 24f // Increased from 20f val legendColorBoxSize = 24f
val legendTextMargin = 5f val legendTextMargin = 5f
var effectiveMinX = dataMinX var effectiveMinX = dataMinX
@ -353,7 +340,7 @@ class LineGraphBuilder(val context: Context) {
val textPaint = Paint().apply { val textPaint = Paint().apply {
color = primaryTextColor color = primaryTextColor
textSize = 32f // Increased from 28f textSize = 40f // Increased from 32f
isAntiAlias = true isAntiAlias = true
} }
@ -404,7 +391,7 @@ class LineGraphBuilder(val context: Context) {
// Draw faint horizontal grid line // Draw faint horizontal grid line
canvas.drawLine(graphLeft, yPos, graphRight, yPos, gridLinePaint) canvas.drawLine(graphLeft, yPos, graphRight, yPos, gridLinePaint)
canvas.drawText( canvas.drawText(
String.format(java.util.Locale.getDefault(), "%.0f", value), value.roundToInt().toString(),
graphLeft - 15f, graphLeft - 15f,
yPos + (textPaint.textSize / 3), yPos + (textPaint.textSize / 3),
textPaint textPaint
@ -417,7 +404,7 @@ class LineGraphBuilder(val context: Context) {
// Draw faint horizontal grid line // Draw faint horizontal grid line
canvas.drawLine(graphLeft, yPos, graphRight, yPos, gridLinePaint) canvas.drawLine(graphLeft, yPos, graphRight, yPos, gridLinePaint)
canvas.drawText( canvas.drawText(
String.format(java.util.Locale.getDefault(), "%.0f", dataMinYLeft), dataMinYLeft.roundToInt().toString(),
graphLeft - 15f, graphLeft - 15f,
yPos + (textPaint.textSize / 3), yPos + (textPaint.textSize / 3),
textPaint textPaint
@ -444,7 +431,7 @@ class LineGraphBuilder(val context: Context) {
// Draw faint horizontal grid line // Draw faint horizontal grid line
canvas.drawLine(graphLeft, yPos, graphRight, yPos, gridLinePaint) canvas.drawLine(graphLeft, yPos, graphRight, yPos, gridLinePaint)
canvas.drawText( canvas.drawText(
String.format(java.util.Locale.getDefault(), "%.0f", value), value.roundToInt().toString(),
graphRight + 15f, graphRight + 15f,
yPos + (textPaint.textSize / 3), yPos + (textPaint.textSize / 3),
textPaint textPaint
@ -463,7 +450,7 @@ class LineGraphBuilder(val context: Context) {
// Draw faint horizontal grid line // Draw faint horizontal grid line
canvas.drawLine(graphLeft, yPos, graphRight, yPos, gridLinePaint) canvas.drawLine(graphLeft, yPos, graphRight, yPos, gridLinePaint)
canvas.drawText( canvas.drawText(
String.format(java.util.Locale.getDefault(), "%.0f", dataMinYRight), dataMinYRight.roundToInt().toString(),
graphRight + 15f, graphRight + 15f,
yPos + (textPaint.textSize / 3), yPos + (textPaint.textSize / 3),
textPaint textPaint
@ -499,20 +486,20 @@ class LineGraphBuilder(val context: Context) {
} }
textPaint.textAlign = Align.CENTER textPaint.textAlign = Align.CENTER
val numXTicks = if (gridHeight > 15) 3 else 1 val numXTicks = if (gridHeight > 15) 2 else 1
if (abs(dataMaxX - dataMinX) > 0.0001f) { if (abs(dataMaxX - dataMinX) > 0.0001f) {
for (i in 0..numXTicks) { for (i in 0..numXTicks) {
val value = dataMinX + ((dataMaxX - dataMinX) / numXTicks) * i val value = dataMinX + ((dataMaxX - dataMinX) / numXTicks) * i
val xPos = mapX(value) val xPos = mapX(value)
if (xPos >= graphLeft - 5f && xPos <= graphLeft + graphWidth + 5f) { if (xPos >= graphLeft - 5f && xPos <= graphLeft + graphWidth + 5f) {
canvas.drawLine(xPos, graphBottom - 5f, xPos, graphBottom + 5f, axisPaint) canvas.drawLine(xPos, graphBottom - 5f, xPos, graphBottom + 5f, axisPaint)
canvas.drawText(labelProvider(value), xPos, graphBottom + 30f, textPaint) canvas.drawText(labelProvider(value), xPos, graphBottom + 40f, textPaint)
} }
} }
} else { } else {
val xPos = mapX(dataMinX) val xPos = mapX(dataMinX)
canvas.drawLine(xPos, graphBottom - 5f, xPos, graphBottom + 5f, axisPaint) canvas.drawLine(xPos, graphBottom - 5f, xPos, graphBottom + 5f, axisPaint)
canvas.drawText(labelProvider(dataMinX), xPos, graphBottom + 30f, textPaint) canvas.drawText(labelProvider(dataMinX), xPos, graphBottom + 40f, textPaint)
} }
textPaint.textAlign = Align.CENTER textPaint.textAlign = Align.CENTER

View File

@ -27,9 +27,6 @@ import de.timklge.karooheadwind.HeadwindStats
import de.timklge.karooheadwind.R import de.timklge.karooheadwind.R
import de.timklge.karooheadwind.ServiceStatusSingleton import de.timklge.karooheadwind.ServiceStatusSingleton
import de.timklge.karooheadwind.TemperatureUnit import de.timklge.karooheadwind.TemperatureUnit
import de.timklge.karooheadwind.weatherprovider.WeatherInterpretation
import de.timklge.karooheadwind.datatypes.ForecastDataType
import de.timklge.karooheadwind.datatypes.WeatherDataType.Companion.timeFormatter
import de.timklge.karooheadwind.datatypes.getShortDateFormatter import de.timklge.karooheadwind.datatypes.getShortDateFormatter
import de.timklge.karooheadwind.getGpsCoordinateFlow import de.timklge.karooheadwind.getGpsCoordinateFlow
import de.timklge.karooheadwind.streamCurrentForecastWeatherData import de.timklge.karooheadwind.streamCurrentForecastWeatherData
@ -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,7 +226,7 @@ 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(

View File

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

View File

@ -21,8 +21,6 @@
<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="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>
@ -33,14 +31,8 @@
<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">Headwind speed</string>
<string name="headwind_speed_description">Current headwind speed</string> <string name="headwind_speed_description">Current headwind speed</string>
<string name="temperature">Temperature</string>
<string name="temperature_description">Current temperature in configured unit</string>
<string name="userwind_speed_description">Current headwind or wind speed based on user setting</string> <string name="userwind_speed_description">Current headwind or wind speed based on user setting</string>
<string name="userwind_speed">Set wind speed</string> <string name="userwind_speed">Set wind speed</string>
<string name="graphical_forecast">Graphical Forecast</string>
<string name="graphical_forecast_description">Current graphical weather forecast</string>
<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>

View File

@ -12,13 +12,6 @@
icon="@drawable/wind" icon="@drawable/wind"
typeId="tailwind-and-ride-speed" /> typeId="tailwind-and-ride-speed" />
<DataType
description="@string/tailwind_description"
displayName="@string/tailwind"
graphical="true"
icon="@drawable/wind"
typeId="tailwind" />
<DataType <DataType
description="@string/headwind_description" description="@string/headwind_description"
displayName="@string/headwind" displayName="@string/headwind"
@ -27,11 +20,11 @@
typeId="headwind" /> typeId="headwind" />
<DataType <DataType
description="@string/weather_description" description="@string/windDirectionAndSpeed_description"
displayName="@string/weather" displayName="@string/windDirectionAndSpeed"
graphical="true" graphical="false"
icon="@drawable/wind" icon="@drawable/wind"
typeId="weather" /> typeId="windDirectionAndSpeed" />
<DataType <DataType
description="@string/weather_forecast_description" description="@string/weather_forecast_description"
@ -61,13 +54,6 @@
icon="@drawable/wind" icon="@drawable/wind"
typeId="windForecast" /> typeId="windForecast" />
<DataType
description="@string/graphical_forecast_description"
displayName="@string/graphical_forecast"
graphical="true"
icon="@drawable/wind"
typeId="graphicalForecast" />
<DataType <DataType
description="@string/headwind_speed_description" description="@string/headwind_speed_description"
displayName="@string/headwind_speed" displayName="@string/headwind_speed"
@ -75,13 +61,6 @@
icon="@drawable/wind" icon="@drawable/wind"
typeId="headwindSpeed" /> typeId="headwindSpeed" />
<DataType
description="@string/windDirectionAndSpeed_description"
displayName="@string/windDirectionAndSpeed"
graphical="false"
icon="@drawable/wind"
typeId="windDirectionAndSpeed" />
<DataType <DataType
description="@string/relativeHumidity_description" description="@string/relativeHumidity_description"
displayName="@string/relativeHumidity" displayName="@string/relativeHumidity"
@ -138,13 +117,6 @@
icon="@drawable/ic_cloud" icon="@drawable/ic_cloud"
typeId="sealevelPressure" /> typeId="sealevelPressure" />
<DataType
description="@string/temperature_description"
displayName="@string/temperature"
graphical="false"
icon="@drawable/thermometer"
typeId="temperature" />
<DataType <DataType
description="@string/relativeGrade_description" description="@string/relativeGrade_description"
displayName="@string/relativeGrade" displayName="@string/relativeGrade"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 37 KiB