From ee206a11c92261f60679a8db580b79b7be551589 Mon Sep 17 00:00:00 2001 From: timklge <2026103+timklge@users.noreply.github.com> Date: Sun, 2 Mar 2025 15:18:07 +0100 Subject: [PATCH] Add weather view to main menu (#54) * Add initial weather widget to main menu * Always forecast position with calculated distance * Fix manifest * Replace exit button with back button --- README.md | 2 + app/manifest.json | 2 +- .../de/timklge/karooheadwind/DataStore.kt | 11 +- .../de/timklge/karooheadwind/Extensions.kt | 52 --- .../de/timklge/karooheadwind/HeadingFlow.kt | 5 +- .../timklge/karooheadwind/HeadwindSettings.kt | 78 +++++ .../karooheadwind/KarooHeadwindExtension.kt | 47 ++- .../de/timklge/karooheadwind/OpenMeteo.kt | 3 - .../datatypes/HeadwindDirectionDataType.kt | 7 +- .../datatypes/HeadwindDirectionView.kt | 1 - .../datatypes/HeadwindSpeedDataType.kt | 2 +- .../datatypes/TailwindAndRideSpeedDataType.kt | 6 +- .../datatypes/UserWindSpeedDataType.kt | 4 +- .../timklge/karooheadwind/datatypes/Views.kt | 2 +- .../datatypes/WeatherDataType.kt | 7 +- .../datatypes/WeatherForecastDataType.kt | 6 +- .../karooheadwind/datatypes/WeatherView.kt | 2 +- .../datatypes/WindDirectionDataType.kt | 1 - .../timklge/karooheadwind/screens/Dropdown.kt | 1 - .../karooheadwind/screens/MainScreen.kt | 331 ++++-------------- .../karooheadwind/screens/SettingsScreen.kt | 217 ++++++++++++ .../karooheadwind/screens/WeatherScreen.kt | 234 +++++++++++++ .../karooheadwind/screens/WeatherWidget.kt | 168 +++++++++ app/src/main/res/drawable/back.png | Bin 0 -> 28042 bytes 24 files changed, 815 insertions(+), 374 deletions(-) create mode 100644 app/src/main/kotlin/de/timklge/karooheadwind/HeadwindSettings.kt create mode 100644 app/src/main/kotlin/de/timklge/karooheadwind/screens/SettingsScreen.kt create mode 100644 app/src/main/kotlin/de/timklge/karooheadwind/screens/WeatherScreen.kt create mode 100644 app/src/main/kotlin/de/timklge/karooheadwind/screens/WeatherWidget.kt create mode 100644 app/src/main/res/drawable/back.png diff --git a/README.md b/README.md index 160617b..e826031 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ This extension for Karoo devices adds a graphical data field that shows the curr Compatible with Karoo 2 and Karoo 3 devices. +Buy Me A Coffee + ![Settings](preview0.png) ![Field setup](preview1.png) ![Data page](preview2.png) diff --git a/app/manifest.json b/app/manifest.json index eea0770..be27c83 100644 --- a/app/manifest.json +++ b/app/manifest.json @@ -7,5 +7,5 @@ "latestVersionCode": 14, "developer": "timklge", "description": "Provides headwind direction, wind speed and other weather data fields", - "releaseNotes": "Forecast weather along route in fixed intervals if route is loaded", + "releaseNotes": "* Forecast weather along route in fixed intervals if route is loaded\n* Show current weather in app menu" } \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/DataStore.kt b/app/src/main/kotlin/de/timklge/karooheadwind/DataStore.kt index a3aec3f..d1aab68 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/DataStore.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/DataStore.kt @@ -8,10 +8,6 @@ import com.mapbox.geojson.LineString import com.mapbox.turf.TurfConstants import com.mapbox.turf.TurfMeasurement import de.timklge.karooheadwind.datatypes.GpsCoordinates -import de.timklge.karooheadwind.screens.HeadwindSettings -import de.timklge.karooheadwind.screens.HeadwindStats -import de.timklge.karooheadwind.screens.HeadwindWidgetSettings -import de.timklge.karooheadwind.screens.WindUnit import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.models.DataType import io.hammerhead.karooext.models.OnNavigationState @@ -21,16 +17,13 @@ import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -102,7 +95,8 @@ fun Context.streamSettings(karooSystemService: KarooSystemService): Flow(settingsJson[settingsKey]!!) } else { - val defaultSettings = jsonWithUnknownKeys.decodeFromString(HeadwindSettings.defaultSettings) + val defaultSettings = jsonWithUnknownKeys.decodeFromString( + HeadwindSettings.defaultSettings) val preferredUnits = karooSystemService.streamUserProfile().first().preferredUnit @@ -138,6 +132,7 @@ fun KarooSystemService.streamUpcomingRoute(): Flow { navigationState?.let { LineString.fromPolyline(it.routePolyline, 5) } } .combine(distanceToDestinationStream) { routePolyline, distanceToDestination -> + Log.d(KarooHeadwindExtension.TAG, "Route polyline size: ${routePolyline?.coordinates()?.size}, distance to destination: $distanceToDestination") if (routePolyline != null){ val length = TurfMeasurement.length(routePolyline, TurfConstants.UNIT_METERS) if (routePolyline != lastKnownRoutePolyline){ diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/Extensions.kt b/app/src/main/kotlin/de/timklge/karooheadwind/Extensions.kt index a6f398c..53359bd 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/Extensions.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/Extensions.kt @@ -1,67 +1,15 @@ package de.timklge.karooheadwind -import android.content.Context -import android.util.Log -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.DpSize -import androidx.compose.ui.unit.TextUnit -import androidx.compose.ui.unit.TextUnitType -import androidx.compose.ui.unit.dp -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.glance.GlanceModifier -import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi -import androidx.glance.appwidget.GlanceRemoteViews -import androidx.glance.appwidget.RemoteViewsCompositionResult -import androidx.glance.color.ColorProvider -import androidx.glance.layout.Alignment -import androidx.glance.layout.Box -import androidx.glance.layout.fillMaxSize -import androidx.glance.layout.padding -import androidx.glance.text.Text -import androidx.glance.text.TextAlign -import androidx.glance.text.TextStyle -import de.timklge.karooheadwind.datatypes.GpsCoordinates -import de.timklge.karooheadwind.screens.HeadwindSettings -import de.timklge.karooheadwind.screens.HeadwindStats -import de.timklge.karooheadwind.screens.HeadwindWidgetSettings -import de.timklge.karooheadwind.screens.PrecipitationUnit -import de.timklge.karooheadwind.screens.TemperatureUnit -import de.timklge.karooheadwind.screens.WindUnit import io.hammerhead.karooext.KarooSystemService -import io.hammerhead.karooext.models.DataType -import io.hammerhead.karooext.models.HttpResponseState -import io.hammerhead.karooext.models.OnHttpResponse import io.hammerhead.karooext.models.OnLocationChanged import io.hammerhead.karooext.models.OnNavigationState import io.hammerhead.karooext.models.OnStreamState import io.hammerhead.karooext.models.StreamState -import io.hammerhead.karooext.models.UserProfile -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.trySendBlocking -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.emitAll -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.scan -import kotlinx.coroutines.flow.single -import kotlinx.coroutines.flow.timeout -import kotlinx.coroutines.time.debounce -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import java.time.Duration -import kotlin.math.abs -import kotlin.math.absoluteValue -import kotlin.time.Duration.Companion.seconds fun KarooSystemService.streamDataFlow(dataTypeId: String): Flow { return callbackFlow { diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/HeadingFlow.kt b/app/src/main/kotlin/de/timklge/karooheadwind/HeadingFlow.kt index 7b1c750..50f9885 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/HeadingFlow.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/HeadingFlow.kt @@ -91,7 +91,10 @@ suspend fun KarooSystemService.updateLastKnownGps(context: Context) { } fun KarooSystemService.getGpsCoordinateFlow(context: Context): Flow { - // return flowOf(GpsCoordinates(52.5164069,13.3784)) + /* return flow { + emit(GpsCoordinates(52.5164069,13.3784)) + awaitCancellation() + } */ val initialFlow = flow { val lastKnownPosition = context.getLastKnownPosition() diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/HeadwindSettings.kt b/app/src/main/kotlin/de/timklge/karooheadwind/HeadwindSettings.kt new file mode 100644 index 0000000..8864399 --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/HeadwindSettings.kt @@ -0,0 +1,78 @@ +package de.timklge.karooheadwind + +import de.timklge.karooheadwind.datatypes.GpsCoordinates +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +enum class WindUnit(val id: String, val label: String, val unitDisplay: String){ + KILOMETERS_PER_HOUR("kmh", "Kilometers (km/h)", "km/h"), + METERS_PER_SECOND("ms", "Meters (m/s)", "m/s"), + MILES_PER_HOUR("mph", "Miles (mph)", "mph"), + KNOTS("kn", "Knots (kn)", "kn") +} + +enum class PrecipitationUnit(val id: String, val label: String, val unitDisplay: String){ + MILLIMETERS("mm", "Millimeters (mm)", "mm"), + INCH("inch", "Inch", "in") +} + +enum class WindDirectionIndicatorTextSetting(val id: String, val label: String){ + HEADWIND_SPEED("headwind-speed", "Headwind speed"), + WIND_SPEED("absolute-wind-speed", "Absolute wind speed"), + NONE("none", "None") +} + +enum class WindDirectionIndicatorSetting(val id: String, val label: String){ + HEADWIND_DIRECTION("headwind-direction", "Headwind"), + WIND_DIRECTION("wind-direction", "Absolute wind direction"), +} + +enum class TemperatureUnit(val id: String, val label: String, val unitDisplay: String){ + CELSIUS("celsius", "Celsius (°C)", "°C"), + FAHRENHEIT("fahrenheit", "Fahrenheit (°F)", "°F") +} + +enum class RoundLocationSetting(val id: String, val label: String, val km: Int){ + KM_1("1 km", "1 km", 1), + KM_2("2 km", "2 km", 2), + KM_5("5 km", "5 km", 5) +} + +@Serializable +data class HeadwindWidgetSettings( + val currentForecastHourOffset: Int = 0 +){ + companion object { + val defaultWidgetSettings = Json.encodeToString(HeadwindWidgetSettings()) + } +} + +@Serializable +data class HeadwindStats( + val lastSuccessfulWeatherRequest: Long? = null, + val lastSuccessfulWeatherPosition: GpsCoordinates? = null, + val failedWeatherRequest: Long? = null, +){ + companion object { + val defaultStats = Json.encodeToString(HeadwindStats()) + } +} + +@Serializable +data class HeadwindSettings( + val windUnit: WindUnit = WindUnit.KILOMETERS_PER_HOUR, + val welcomeDialogAccepted: Boolean = false, + val windDirectionIndicatorTextSetting: WindDirectionIndicatorTextSetting = WindDirectionIndicatorTextSetting.HEADWIND_SPEED, + val windDirectionIndicatorSetting: WindDirectionIndicatorSetting = WindDirectionIndicatorSetting.HEADWIND_DIRECTION, + val roundLocationTo: RoundLocationSetting = RoundLocationSetting.KM_2, + val forecastedKmPerHour: Int = 20, + val forecastedMilesPerHour: Int = 12, +){ + companion object { + val defaultSettings = Json.encodeToString(HeadwindSettings()) + } + + fun getForecastMetersPerHour(isImperial: Boolean): Int { + return if (isImperial) forecastedMilesPerHour * 1609 else forecastedKmPerHour * 1000 + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt b/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt index cc0207c..fbd8cbb 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt @@ -7,13 +7,13 @@ import com.mapbox.turf.TurfConstants import com.mapbox.turf.TurfMeasurement import de.timklge.karooheadwind.datatypes.CloudCoverDataType import de.timklge.karooheadwind.datatypes.GpsCoordinates +import de.timklge.karooheadwind.datatypes.HeadwindDirectionDataType import de.timklge.karooheadwind.datatypes.HeadwindSpeedDataType import de.timklge.karooheadwind.datatypes.PrecipitationDataType import de.timklge.karooheadwind.datatypes.RelativeHumidityDataType +import de.timklge.karooheadwind.datatypes.SealevelPressureDataType import de.timklge.karooheadwind.datatypes.SurfacePressureDataType import de.timklge.karooheadwind.datatypes.TailwindAndRideSpeedDataType -import de.timklge.karooheadwind.datatypes.HeadwindDirectionDataType -import de.timklge.karooheadwind.datatypes.SealevelPressureDataType import de.timklge.karooheadwind.datatypes.TemperatureDataType import de.timklge.karooheadwind.datatypes.UserWindSpeedDataType import de.timklge.karooheadwind.datatypes.WeatherDataType @@ -21,9 +21,6 @@ import de.timklge.karooheadwind.datatypes.WeatherForecastDataType import de.timklge.karooheadwind.datatypes.WindDirectionDataType import de.timklge.karooheadwind.datatypes.WindGustsDataType import de.timklge.karooheadwind.datatypes.WindSpeedDataType -import de.timklge.karooheadwind.screens.HeadwindSettings -import de.timklge.karooheadwind.screens.HeadwindStats -import de.timklge.karooheadwind.screens.HeadwindWidgetSettings import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.extension.KarooExtension import io.hammerhead.karooext.models.UserProfile @@ -47,7 +44,6 @@ import kotlinx.coroutines.withContext import java.time.Duration import java.time.LocalDateTime import java.time.temporal.ChronoUnit -import java.util.Locale import java.util.zip.GZIPInputStream import kotlin.math.absoluteValue import kotlin.math.roundToInt @@ -159,27 +155,46 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.3") { ChronoUnit.MILLIS.between(startOfHour, now) } val msToNextFullHour = (1_000 * 60 * 60) - msSinceFullHour - val calculatedDistanceToNextFullHour = (msToNextFullHour / (1_000.0 * 60 * 60)) * distancePerHour - val distanceToNextFullHour = if (calculatedDistanceToNextFullHour > 5_000) calculatedDistanceToNextFullHour else distancePerHour + val calculatedDistanceToNextFullHour = ((msToNextFullHour / (1_000.0 * 60 * 60)) * distancePerHour).coerceIn(0.0, distancePerHour) - Log.d(TAG, "Minutes to next full hour: ${msToNextFullHour / 1000 / 60}, Distance to next full hour: ${(distanceToNextFullHour / 1000).roundToInt()}km (calculated: ${(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 { add(gps) - var currentPosition = positionOnRoute + distanceToNextFullHour + var currentPosition = positionOnRoute + calculatedDistanceToNextFullHour var lastRequestedPosition = currentPosition - while (currentPosition < upcomingRoute.routeLength && size < 10){ - val point = TurfMeasurement.along(upcomingRoute.routePolyline, currentPosition, TurfConstants.UNIT_METERS) - add(GpsCoordinates(point.latitude(), point.longitude(), distanceAlongRoute = currentPosition)) + while (currentPosition < upcomingRoute.routeLength && size < 10) { + val point = TurfMeasurement.along( + upcomingRoute.routePolyline, + currentPosition, + TurfConstants.UNIT_METERS + ) + add( + GpsCoordinates( + point.latitude(), + point.longitude(), + distanceAlongRoute = currentPosition + ) + ) lastRequestedPosition = currentPosition currentPosition += distancePerHour } - if (upcomingRoute.routeLength > lastRequestedPosition + 5_000){ - val point = TurfMeasurement.along(upcomingRoute.routePolyline, upcomingRoute.routeLength, TurfConstants.UNIT_METERS) - add(GpsCoordinates(point.latitude(), point.longitude(), distanceAlongRoute = upcomingRoute.routeLength)) + if (upcomingRoute.routeLength > lastRequestedPosition + 5_000) { + val point = TurfMeasurement.along( + upcomingRoute.routePolyline, + upcomingRoute.routeLength, + TurfConstants.UNIT_METERS + ) + add( + GpsCoordinates( + point.latitude(), + point.longitude(), + distanceAlongRoute = upcomingRoute.routeLength + ) + ) } } } else { diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/OpenMeteo.kt b/app/src/main/kotlin/de/timklge/karooheadwind/OpenMeteo.kt index efd98dc..b4c4914 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/OpenMeteo.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/OpenMeteo.kt @@ -2,9 +2,6 @@ package de.timklge.karooheadwind import android.util.Log import de.timklge.karooheadwind.datatypes.GpsCoordinates -import de.timklge.karooheadwind.screens.HeadwindSettings -import de.timklge.karooheadwind.screens.PrecipitationUnit -import de.timklge.karooheadwind.screens.TemperatureUnit import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.models.HttpResponseState import io.hammerhead.karooext.models.OnHttpResponse diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindDirectionDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindDirectionDataType.kt index 1b5ed9d..ece3670 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindDirectionDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindDirectionDataType.kt @@ -7,12 +7,11 @@ 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.WindDirectionIndicatorSetting import de.timklge.karooheadwind.getRelativeHeadingFlow -import de.timklge.karooheadwind.screens.HeadwindSettings -import de.timklge.karooheadwind.screens.WindDirectionIndicatorSetting import de.timklge.karooheadwind.streamCurrentWeatherData -import de.timklge.karooheadwind.streamDataFlow import de.timklge.karooheadwind.streamSettings import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.extension.DataTypeImpl @@ -30,8 +29,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.launch import kotlin.math.roundToInt diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindDirectionView.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindDirectionView.kt index b529dde..b0c8898 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindDirectionView.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindDirectionView.kt @@ -43,7 +43,6 @@ fun getArrowBitmapByBearing(baseBitmap: Bitmap, bearing: Int): Bitmap { canvas.save() canvas.scale((bitmap.width / baseBitmap.width.toFloat()), (bitmap.height / baseBitmap.height.toFloat()), (bitmap.width / 2).toFloat(), (bitmap.height / 2).toFloat()) - Log.d(KarooHeadwindExtension.TAG, "Drawing arrow at $bearingRounded") canvas.rotate(bearingRounded.toFloat(), (bitmap.width / 2).toFloat(), (bitmap.height / 2).toFloat()) canvas.drawBitmap(baseBitmap, ((bitmap.width - baseBitmap.width) / 2).toFloat(), ((bitmap.height - baseBitmap.height) / 2).toFloat(), paint) canvas.restore() diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindSpeedDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindSpeedDataType.kt index baf67bb..6e19dbb 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindSpeedDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindSpeedDataType.kt @@ -2,9 +2,9 @@ package de.timklge.karooheadwind.datatypes import android.content.Context import de.timklge.karooheadwind.HeadingResponse +import de.timklge.karooheadwind.HeadwindSettings import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse import de.timklge.karooheadwind.getRelativeHeadingFlow -import de.timklge.karooheadwind.screens.HeadwindSettings import de.timklge.karooheadwind.streamCurrentWeatherData import de.timklge.karooheadwind.streamSettings import io.hammerhead.karooext.KarooSystemService diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TailwindAndRideSpeedDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TailwindAndRideSpeedDataType.kt index fdd814b..4247cf8 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TailwindAndRideSpeedDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TailwindAndRideSpeedDataType.kt @@ -12,12 +12,12 @@ import androidx.core.graphics.ColorUtils import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi import androidx.glance.appwidget.GlanceRemoteViews import de.timklge.karooheadwind.HeadingResponse +import de.timklge.karooheadwind.HeadwindSettings import de.timklge.karooheadwind.KarooHeadwindExtension import de.timklge.karooheadwind.R +import de.timklge.karooheadwind.WindDirectionIndicatorSetting +import de.timklge.karooheadwind.WindDirectionIndicatorTextSetting import de.timklge.karooheadwind.getRelativeHeadingFlow -import de.timklge.karooheadwind.screens.HeadwindSettings -import de.timklge.karooheadwind.screens.WindDirectionIndicatorSetting -import de.timklge.karooheadwind.screens.WindDirectionIndicatorTextSetting import de.timklge.karooheadwind.streamCurrentWeatherData import de.timklge.karooheadwind.streamDataFlow import de.timklge.karooheadwind.streamSettings diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/UserWindSpeedDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/UserWindSpeedDataType.kt index d4be500..062cf7d 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/UserWindSpeedDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/UserWindSpeedDataType.kt @@ -2,10 +2,10 @@ package de.timklge.karooheadwind.datatypes import android.content.Context import de.timklge.karooheadwind.HeadingResponse +import de.timklge.karooheadwind.HeadwindSettings import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse +import de.timklge.karooheadwind.WindDirectionIndicatorTextSetting import de.timklge.karooheadwind.getRelativeHeadingFlow -import de.timklge.karooheadwind.screens.HeadwindSettings -import de.timklge.karooheadwind.screens.WindDirectionIndicatorTextSetting import de.timklge.karooheadwind.streamCurrentWeatherData import de.timklge.karooheadwind.streamSettings import io.hammerhead.karooext.KarooSystemService diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/Views.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/Views.kt index f1d3dec..a20a6a6 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/Views.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/Views.kt @@ -20,8 +20,8 @@ import androidx.glance.text.Text import androidx.glance.text.TextAlign import androidx.glance.text.TextStyle import de.timklge.karooheadwind.HeadingResponse +import de.timklge.karooheadwind.HeadwindSettings import de.timklge.karooheadwind.KarooHeadwindExtension -import de.timklge.karooheadwind.screens.HeadwindSettings @OptIn(ExperimentalGlanceRemoteViewsApi::class) suspend fun getErrorWidget(glance: GlanceRemoteViews, context: Context, settings: HeadwindSettings?, headingResponse: HeadingResponse?): RemoteViewsCompositionResult { diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherDataType.kt index 46bd897..f85dbdc 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherDataType.kt @@ -11,13 +11,13 @@ 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.OpenMeteoCurrentWeatherResponse import de.timklge.karooheadwind.OpenMeteoData +import de.timklge.karooheadwind.TemperatureUnit import de.timklge.karooheadwind.WeatherInterpretation import de.timklge.karooheadwind.getHeadingFlow -import de.timklge.karooheadwind.screens.HeadwindSettings -import de.timklge.karooheadwind.screens.TemperatureUnit import de.timklge.karooheadwind.streamCurrentWeatherData import de.timklge.karooheadwind.streamSettings import de.timklge.karooheadwind.streamUserProfile @@ -83,7 +83,8 @@ class WeatherDataType( 0.0, 0.0, "Europe/Berlin", 30.0, 0, null - ), HeadwindSettings())) + ), HeadwindSettings() + )) delay(5_000) } diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherForecastDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherForecastDataType.kt index 11a1431..d31d1f8 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherForecastDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherForecastDataType.kt @@ -20,17 +20,17 @@ import androidx.glance.layout.fillMaxHeight import androidx.glance.layout.fillMaxSize import androidx.glance.layout.width import de.timklge.karooheadwind.HeadingResponse +import de.timklge.karooheadwind.HeadwindSettings +import de.timklge.karooheadwind.HeadwindWidgetSettings import de.timklge.karooheadwind.KarooHeadwindExtension import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse import de.timklge.karooheadwind.OpenMeteoData import de.timklge.karooheadwind.OpenMeteoForecastData +import de.timklge.karooheadwind.TemperatureUnit import de.timklge.karooheadwind.UpcomingRoute import de.timklge.karooheadwind.WeatherDataResponse import de.timklge.karooheadwind.WeatherInterpretation import de.timklge.karooheadwind.getHeadingFlow -import de.timklge.karooheadwind.screens.HeadwindSettings -import de.timklge.karooheadwind.screens.HeadwindWidgetSettings -import de.timklge.karooheadwind.screens.TemperatureUnit import de.timklge.karooheadwind.streamCurrentWeatherData import de.timklge.karooheadwind.streamSettings import de.timklge.karooheadwind.streamUpcomingRoute diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherView.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherView.kt index 05a990f..95e3435 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherView.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherView.kt @@ -31,8 +31,8 @@ import androidx.glance.text.Text import androidx.glance.text.TextAlign import androidx.glance.text.TextStyle import de.timklge.karooheadwind.R +import de.timklge.karooheadwind.TemperatureUnit import de.timklge.karooheadwind.WeatherInterpretation -import de.timklge.karooheadwind.screens.TemperatureUnit import kotlin.math.absoluteValue import kotlin.math.ceil diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindDirectionDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindDirectionDataType.kt index f074192..5b35103 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindDirectionDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindDirectionDataType.kt @@ -32,7 +32,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.mapNotNull -import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.launch import kotlin.math.roundToInt diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/screens/Dropdown.kt b/app/src/main/kotlin/de/timklge/karooheadwind/screens/Dropdown.kt index 7dbe519..936771e 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/screens/Dropdown.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/screens/Dropdown.kt @@ -9,7 +9,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MenuAnchorType import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text -import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/screens/MainScreen.kt b/app/src/main/kotlin/de/timklge/karooheadwind/screens/MainScreen.kt index 93af9e0..f306277 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/screens/MainScreen.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/screens/MainScreen.kt @@ -1,135 +1,44 @@ package de.timklge.karooheadwind.screens +import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ExitToApp -import androidx.compose.material.icons.filled.Done import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import de.timklge.karooheadwind.datatypes.GpsCoordinates -import de.timklge.karooheadwind.getGpsCoordinateFlow +import de.timklge.karooheadwind.HeadwindSettings +import de.timklge.karooheadwind.R import de.timklge.karooheadwind.saveSettings import de.timklge.karooheadwind.streamSettings -import de.timklge.karooheadwind.streamStats -import de.timklge.karooheadwind.streamUserProfile import io.hammerhead.karooext.KarooSystemService -import io.hammerhead.karooext.models.UserProfile import kotlinx.coroutines.launch -import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import java.time.Instant -import java.time.LocalDateTime -import java.time.ZoneOffset -import java.time.temporal.ChronoUnit -import kotlin.math.roundToInt -enum class WindUnit(val id: String, val label: String, val unitDisplay: String){ - KILOMETERS_PER_HOUR("kmh", "Kilometers (km/h)", "km/h"), - METERS_PER_SECOND("ms", "Meters (m/s)", "m/s"), - MILES_PER_HOUR("mph", "Miles (mph)", "mph"), - KNOTS("kn", "Knots (kn)", "kn") -} - -enum class PrecipitationUnit(val id: String, val label: String, val unitDisplay: String){ - MILLIMETERS("mm", "Millimeters (mm)", "mm"), - INCH("inch", "Inch", "in") -} - -enum class WindDirectionIndicatorTextSetting(val id: String, val label: String){ - HEADWIND_SPEED("headwind-speed", "Headwind speed"), - WIND_SPEED("absolute-wind-speed", "Absolute wind speed"), - NONE("none", "None") -} - -enum class WindDirectionIndicatorSetting(val id: String, val label: String){ - HEADWIND_DIRECTION("headwind-direction", "Headwind"), - WIND_DIRECTION("wind-direction", "Absolute wind direction"), -} - -enum class TemperatureUnit(val id: String, val label: String, val unitDisplay: String){ - CELSIUS("celsius", "Celsius (°C)", "°C"), - FAHRENHEIT("fahrenheit", "Fahrenheit (°F)", "°F") -} - -enum class RoundLocationSetting(val id: String, val label: String, val km: Int){ - KM_1("1 km", "1 km", 1), - KM_2("2 km", "2 km", 2), - KM_5("5 km", "5 km", 5) -} - -@Serializable -data class HeadwindSettings( - val windUnit: WindUnit = WindUnit.KILOMETERS_PER_HOUR, - val welcomeDialogAccepted: Boolean = false, - val windDirectionIndicatorTextSetting: WindDirectionIndicatorTextSetting = WindDirectionIndicatorTextSetting.HEADWIND_SPEED, - val windDirectionIndicatorSetting: WindDirectionIndicatorSetting = WindDirectionIndicatorSetting.HEADWIND_DIRECTION, - val roundLocationTo: RoundLocationSetting = RoundLocationSetting.KM_2, - val forecastedKmPerHour: Int = 20, - val forecastedMilesPerHour: Int = 12, -){ - companion object { - val defaultSettings = Json.encodeToString(HeadwindSettings()) - } - - fun getForecastMetersPerHour(isImperial: Boolean): Int { - return if (isImperial) forecastedMilesPerHour * 1609 else forecastedKmPerHour * 1000 - } -} - -@Serializable -data class HeadwindWidgetSettings( - val currentForecastHourOffset: Int = 0 -){ - companion object { - val defaultWidgetSettings = Json.encodeToString(HeadwindWidgetSettings()) - } -} - -@Serializable -data class HeadwindStats( - val lastSuccessfulWeatherRequest: Long? = null, - val lastSuccessfulWeatherPosition: GpsCoordinates? = null, - val failedWeatherRequest: Long? = null, -){ - companion object { - val defaultStats = Json.encodeToString(HeadwindStats()) - } -} - -@OptIn(ExperimentalMaterial3Api::class) @Composable fun MainScreen(onFinish: () -> Unit) { var karooConnected by remember { mutableStateOf(false) } @@ -137,30 +46,20 @@ fun MainScreen(onFinish: () -> Unit) { val coroutineScope = rememberCoroutineScope() val karooSystem = remember { KarooSystemService(ctx) } - var selectedWindUnit by remember { mutableStateOf(WindUnit.KILOMETERS_PER_HOUR) } var welcomeDialogVisible by remember { mutableStateOf(false) } - var selectedWindDirectionIndicatorTextSetting by remember { mutableStateOf(WindDirectionIndicatorTextSetting.HEADWIND_SPEED) } - var selectedWindDirectionIndicatorSetting by remember { mutableStateOf(WindDirectionIndicatorSetting.HEADWIND_DIRECTION) } - var selectedRoundLocationSetting by remember { mutableStateOf(RoundLocationSetting.KM_2) } - var forecastKmPerHour by remember { mutableStateOf("20") } - var forecastMilesPerHour by remember { mutableStateOf("12") } + var tabIndex by remember { mutableIntStateOf(0) } - val profile by karooSystem.streamUserProfile().collectAsStateWithLifecycle(null) - val stats by ctx.streamStats().collectAsStateWithLifecycle(HeadwindStats()) - val location by karooSystem.getGpsCoordinateFlow(ctx).collectAsStateWithLifecycle(null) + val tabs = listOf("Weather", "Settings") - var savedDialogVisible by remember { mutableStateOf(false) } - var exitDialogVisible by remember { mutableStateOf(false) } + DisposableEffect(Unit) { + onDispose { + karooSystem.disconnect() + } + } LaunchedEffect(Unit) { ctx.streamSettings(karooSystem).collect { settings -> - selectedWindUnit = settings.windUnit welcomeDialogVisible = !settings.welcomeDialogAccepted - selectedWindDirectionIndicatorTextSetting = settings.windDirectionIndicatorTextSetting - selectedWindDirectionIndicatorSetting = settings.windDirectionIndicatorSetting - selectedRoundLocationSetting = settings.roundLocationTo - forecastKmPerHour = settings.forecastedKmPerHour.toString() - forecastMilesPerHour = settings.forecastedMilesPerHour.toString() } } @@ -170,168 +69,58 @@ fun MainScreen(onFinish: () -> Unit) { } } - Column(modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background)) { - TopAppBar(title = { Text("Headwind") }) + Box(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier - .padding(5.dp) - .verticalScroll(rememberScrollState()) - .fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(10.dp)) { + .fillMaxSize() + .background(MaterialTheme.colorScheme.background)) { - 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 windSpeedUnitDropdownOptions = WindUnit.entries.toList().map { unit -> DropdownOption(unit.id, unit.label) } - val windSpeedUnitInitialSelection by remember(selectedWindUnit) { - mutableStateOf(windSpeedUnitDropdownOptions.find { option -> option.id == selectedWindUnit.id }!!) - } - Dropdown(label = "Wind Speed Unit", options = windSpeedUnitDropdownOptions, selected = windSpeedUnitInitialSelection) { selectedOption -> - selectedWindUnit = WindUnit.entries.find { unit -> unit.id == selectedOption.id }!! - } - - val roundLocationDropdownOptions = RoundLocationSetting.entries.toList().map { unit -> DropdownOption(unit.id, unit.label) } - val roundLocationInitialSelection by remember(selectedRoundLocationSetting) { - mutableStateOf(roundLocationDropdownOptions.find { option -> option.id == selectedRoundLocationSetting.id }!!) - } - Dropdown(label = "Round Location", options = roundLocationDropdownOptions, selected = roundLocationInitialSelection) { selectedOption -> - selectedRoundLocationSetting = RoundLocationSetting.entries.find { unit -> unit.id == selectedOption.id }!! - } - - if (profile?.preferredUnit?.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL){ - OutlinedTextField(value = forecastMilesPerHour, modifier = Modifier.fillMaxWidth(), - onValueChange = { forecastMilesPerHour = it }, - label = { Text("Forecast Distance per Hour") }, - suffix = { Text("mi") }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - singleLine = true - ) - } else { - OutlinedTextField(value = forecastKmPerHour, modifier = Modifier.fillMaxWidth(), - onValueChange = { forecastKmPerHour = it }, - label = { Text("Forecast Distance per Hour") }, - suffix = { Text("km") }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - singleLine = true - ) - } - - - FilledTonalButton(modifier = Modifier - .fillMaxWidth() - .height(50.dp), onClick = { - val newSettings = HeadwindSettings(windUnit = selectedWindUnit, - welcomeDialogAccepted = true, - windDirectionIndicatorSetting = selectedWindDirectionIndicatorSetting, - windDirectionIndicatorTextSetting = selectedWindDirectionIndicatorTextSetting, - roundLocationTo = selectedRoundLocationSetting, - forecastedMilesPerHour = forecastMilesPerHour.toIntOrNull()?.coerceIn(3, 30) ?: 12, - forecastedKmPerHour = forecastKmPerHour.toIntOrNull()?.coerceIn(5, 50) ?: 20) - - coroutineScope.launch { - saveSettings(ctx, newSettings) - savedDialogVisible = true + Column(modifier = Modifier.fillMaxWidth()) { + TabRow(selectedTabIndex = tabIndex) { + tabs.forEachIndexed { index, title -> + Tab(text = { Text(title) }, + selected = tabIndex == index, + onClick = { tabIndex = index } + ) } - }) { - Icon(Icons.Default.Done, contentDescription = "Save") - Spacer(modifier = Modifier.width(5.dp)) - Text("Save") - } - - if (!karooConnected){ - Text(modifier = Modifier.padding(5.dp), text = "Could not read device status. Is your Karoo updated?") - } - - val lastPosition = location?.let { l -> stats.lastSuccessfulWeatherPosition?.distanceTo(l) } - val lastPositionDistanceStr = lastPosition?.let { dist -> " (${dist.roundToInt()} km away)" } ?: "" - - if (stats.failedWeatherRequest != null && (stats.lastSuccessfulWeatherRequest == null || stats.failedWeatherRequest!! > stats.lastSuccessfulWeatherRequest!!)){ - val successfulTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(stats.lastSuccessfulWeatherRequest ?: 0), ZoneOffset.systemDefault()).toLocalTime().truncatedTo( - ChronoUnit.SECONDS) - val successfulDate = LocalDateTime.ofInstant(Instant.ofEpochMilli(stats.lastSuccessfulWeatherRequest ?: 0), ZoneOffset.systemDefault()).toLocalDate() - val lastTryTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(stats.failedWeatherRequest ?: 0), ZoneOffset.systemDefault()).toLocalTime().truncatedTo( - ChronoUnit.SECONDS) - val lastTryDate = LocalDateTime.ofInstant(Instant.ofEpochMilli(stats.failedWeatherRequest ?: 0), ZoneOffset.systemDefault()).toLocalDate() - - val successStr = if(lastPosition != null) " Last data received at $successfulDate ${successfulTime}${lastPositionDistanceStr}." else "" - Text(modifier = Modifier.padding(5.dp), text = "Failed to update weather data; last try at $lastTryDate ${lastTryTime}.${successStr}") - } else if(stats.lastSuccessfulWeatherRequest != null){ - val localDate = LocalDateTime.ofInstant(Instant.ofEpochMilli(stats.lastSuccessfulWeatherRequest ?: 0), ZoneOffset.systemDefault()).toLocalDate() - val localTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(stats.lastSuccessfulWeatherRequest ?: 0), ZoneOffset.systemDefault()).toLocalTime().truncatedTo( - ChronoUnit.SECONDS) - - Text(modifier = Modifier.padding(5.dp), text = "Last weather data received at $localDate ${localTime}${lastPositionDistanceStr}") - } else { - Text(modifier = Modifier.padding(5.dp), text = "No weather data received yet, waiting for GPS fix...") - } - - FilledTonalButton(modifier = Modifier - .fillMaxWidth() - .height(50.dp), onClick = { - exitDialogVisible = true - }) { - Icon(Icons.AutoMirrored.Default.ExitToApp, contentDescription = "Exit") - Spacer(modifier = Modifier.width(5.dp)) - Text("Exit") + } + when (tabIndex) { + 0 -> WeatherScreen(onFinish) + 1 -> SettingsScreen(onFinish) + } } } - if (exitDialogVisible) { - AlertDialog(onDismissRequest = { exitDialogVisible = false }, + if (welcomeDialogVisible){ + AlertDialog(onDismissRequest = { }, confirmButton = { Button(onClick = { - onFinish() - }) { Text("Yes") } }, - dismissButton = { Button(onClick = { - exitDialogVisible = false - }) { Text("No") } }, - text = { Text("Do you really want to exit?") } + coroutineScope.launch { + saveSettings(ctx, HeadwindSettings(welcomeDialogAccepted = true)) + } + }) { Text("OK") } }, + text = { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + Text("Welcome to karoo-headwind!") + + Spacer(Modifier.padding(10.dp)) + + Text("You can add headwind direction and other fields to your data pages in your profile settings.") + + Spacer(Modifier.padding(10.dp)) + + Text("Please note that this app periodically fetches data from the Open-Meteo API to know the current weather at your approximate location.") + } + } ) } - } - if (savedDialogVisible){ - AlertDialog(onDismissRequest = { savedDialogVisible = false }, - confirmButton = { Button(onClick = { - savedDialogVisible = false - }) { Text("OK") } }, - text = { Text("Settings saved successfully.") } - ) - } - - if (welcomeDialogVisible){ - AlertDialog(onDismissRequest = { }, - confirmButton = { Button(onClick = { - coroutineScope.launch { - saveSettings(ctx, HeadwindSettings(windUnit = selectedWindUnit, - welcomeDialogAccepted = true)) - } - }) { Text("OK") } }, - text = { - Column(modifier = Modifier.verticalScroll(rememberScrollState())) { - Text("Welcome to karoo-headwind!") - - Spacer(Modifier.padding(10.dp)) - - Text("You can add headwind direction and other fields to your data pages in your profile settings.") - - Spacer(Modifier.padding(10.dp)) - - Text("Please note that this app periodically fetches data from the Open-Meteo API to know the current weather at your approximate location.") - } - } + Image( + painter = painterResource(id = R.drawable.back), + contentDescription = "Back", + modifier = Modifier + .align(Alignment.BottomStart) + .padding(bottom = 10.dp) + .size(54.dp) + .clickable { onFinish() } ) } } \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/screens/SettingsScreen.kt b/app/src/main/kotlin/de/timklge/karooheadwind/screens/SettingsScreen.kt new file mode 100644 index 0000000..5ea81ad --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/screens/SettingsScreen.kt @@ -0,0 +1,217 @@ +package de.timklge.karooheadwind.screens + +import android.util.Log +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.timklge.karooheadwind.HeadwindSettings +import de.timklge.karooheadwind.KarooHeadwindExtension +import de.timklge.karooheadwind.RoundLocationSetting +import de.timklge.karooheadwind.WindDirectionIndicatorSetting +import de.timklge.karooheadwind.WindDirectionIndicatorTextSetting +import de.timklge.karooheadwind.WindUnit +import de.timklge.karooheadwind.saveSettings +import de.timklge.karooheadwind.streamSettings +import de.timklge.karooheadwind.streamUserProfile +import io.hammerhead.karooext.KarooSystemService +import io.hammerhead.karooext.models.UserProfile +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking + +@Composable +fun SettingsScreen(onFinish: () -> Unit) { + var karooConnected by remember { mutableStateOf(false) } + val ctx = LocalContext.current + val coroutineScope = rememberCoroutineScope() + val karooSystem = remember { KarooSystemService(ctx) } + + var selectedWindUnit by remember { mutableStateOf(WindUnit.KILOMETERS_PER_HOUR) } + var selectedWindDirectionIndicatorTextSetting by remember { + mutableStateOf( + WindDirectionIndicatorTextSetting.HEADWIND_SPEED + ) + } + var selectedWindDirectionIndicatorSetting by remember { + mutableStateOf( + WindDirectionIndicatorSetting.HEADWIND_DIRECTION + ) + } + var selectedRoundLocationSetting by remember { mutableStateOf(RoundLocationSetting.KM_2) } + var forecastKmPerHour by remember { mutableStateOf("20") } + var forecastMilesPerHour by remember { mutableStateOf("12") } + + val profile by karooSystem.streamUserProfile().collectAsStateWithLifecycle(null) + + LaunchedEffect(Unit) { + ctx.streamSettings(karooSystem).collect { settings -> + selectedWindUnit = settings.windUnit + selectedWindDirectionIndicatorTextSetting = settings.windDirectionIndicatorTextSetting + selectedWindDirectionIndicatorSetting = settings.windDirectionIndicatorSetting + selectedRoundLocationSetting = settings.roundLocationTo + forecastKmPerHour = settings.forecastedKmPerHour.toString() + forecastMilesPerHour = settings.forecastedMilesPerHour.toString() + } + } + + LaunchedEffect(Unit) { + karooSystem.connect { connected -> + karooConnected = connected + } + } + + DisposableEffect(Unit) { + onDispose { + karooSystem.disconnect() + } + } + + suspend fun updateSettings(){ + Log.d(KarooHeadwindExtension.TAG, "Saving settings") + + val newSettings = HeadwindSettings( + windUnit = selectedWindUnit, + welcomeDialogAccepted = true, + windDirectionIndicatorSetting = selectedWindDirectionIndicatorSetting, + windDirectionIndicatorTextSetting = selectedWindDirectionIndicatorTextSetting, + roundLocationTo = selectedRoundLocationSetting, + forecastedMilesPerHour = forecastMilesPerHour.toIntOrNull()?.coerceIn(3, 30) ?: 12, + forecastedKmPerHour = forecastKmPerHour.toIntOrNull()?.coerceIn(5, 50) ?: 20 + ) + + saveSettings(ctx, newSettings) + } + + DisposableEffect(Unit) { + onDispose { + runBlocking { + updateSettings() + } + } + } + + BackHandler { + coroutineScope.launch { + updateSettings() + onFinish() + } + } + + Column( + modifier = Modifier + .padding(5.dp) + .verticalScroll(rememberScrollState()) + .fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + + 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 windSpeedUnitDropdownOptions = + WindUnit.entries.toList().map { unit -> DropdownOption(unit.id, unit.label) } + val windSpeedUnitInitialSelection by remember(selectedWindUnit) { + mutableStateOf(windSpeedUnitDropdownOptions.find { option -> option.id == selectedWindUnit.id }!!) + } + Dropdown( + label = "Wind Speed Unit", + options = windSpeedUnitDropdownOptions, + selected = windSpeedUnitInitialSelection + ) { selectedOption -> + selectedWindUnit = WindUnit.entries.find { unit -> unit.id == selectedOption.id }!! + } + + val roundLocationDropdownOptions = RoundLocationSetting.entries.toList() + .map { unit -> DropdownOption(unit.id, unit.label) } + val roundLocationInitialSelection by remember(selectedRoundLocationSetting) { + mutableStateOf(roundLocationDropdownOptions.find { option -> option.id == selectedRoundLocationSetting.id }!!) + } + Dropdown( + label = "Round Location", + options = roundLocationDropdownOptions, + selected = roundLocationInitialSelection + ) { selectedOption -> + selectedRoundLocationSetting = + RoundLocationSetting.entries.find { unit -> unit.id == selectedOption.id }!! + } + + if (profile?.preferredUnit?.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL) { + OutlinedTextField( + value = forecastMilesPerHour, modifier = Modifier.fillMaxWidth(), + onValueChange = { forecastMilesPerHour = it }, + label = { Text("Forecast Distance per Hour") }, + suffix = { Text("mi") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true + ) + } else { + OutlinedTextField( + value = forecastKmPerHour, modifier = Modifier.fillMaxWidth(), + onValueChange = { forecastKmPerHour = it }, + label = { Text("Forecast Distance per Hour") }, + suffix = { Text("km") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true + ) + } + + if (!karooConnected) { + Text( + modifier = Modifier.padding(5.dp), + text = "Could not read device status. Is your Karoo updated?" + ) + } + + Spacer(modifier = Modifier.padding(30.dp)) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/screens/WeatherScreen.kt b/app/src/main/kotlin/de/timklge/karooheadwind/screens/WeatherScreen.kt new file mode 100644 index 0000000..207fbfc --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/screens/WeatherScreen.kt @@ -0,0 +1,234 @@ +package de.timklge.karooheadwind.screens + +import android.graphics.BitmapFactory +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.timklge.karooheadwind.HeadwindStats +import de.timklge.karooheadwind.KarooHeadwindExtension +import de.timklge.karooheadwind.PrecipitationUnit +import de.timklge.karooheadwind.R +import de.timklge.karooheadwind.TemperatureUnit +import de.timklge.karooheadwind.WeatherInterpretation +import de.timklge.karooheadwind.datatypes.WeatherDataType.Companion.timeFormatter +import de.timklge.karooheadwind.datatypes.WeatherForecastDataType +import de.timklge.karooheadwind.getGpsCoordinateFlow +import de.timklge.karooheadwind.streamCurrentWeatherData +import de.timklge.karooheadwind.streamStats +import de.timklge.karooheadwind.streamUpcomingRoute +import de.timklge.karooheadwind.streamUserProfile +import io.hammerhead.karooext.KarooSystemService +import io.hammerhead.karooext.models.UserProfile +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.temporal.ChronoUnit +import kotlin.math.roundToInt + +@Composable +fun WeatherScreen(onFinish: () -> Unit) { + var karooConnected by remember { mutableStateOf(null) } + val ctx = LocalContext.current + val karooSystem = remember { KarooSystemService(ctx) } + + val profile by karooSystem.streamUserProfile().collectAsStateWithLifecycle(null) + val stats by ctx.streamStats().collectAsStateWithLifecycle(HeadwindStats()) + val location by karooSystem.getGpsCoordinateFlow(ctx).collectAsStateWithLifecycle(null) + val weatherData by ctx.streamCurrentWeatherData().collectAsStateWithLifecycle(emptyList()) + + val baseBitmap = BitmapFactory.decodeResource( + ctx.resources, + R.drawable.arrow_0 + ) + + LaunchedEffect(Unit) { + karooSystem.connect { connected -> + karooConnected = connected + } + } + + DisposableEffect(Unit) { + onDispose { + karooSystem.disconnect() + } + } + + Column(modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(5.dp)) { + if (karooConnected == false) { + Text( + modifier = Modifier.padding(5.dp), + text = "Could not read device status. Is your Karoo updated?" + ) + } + + val currentWeatherData = weatherData.firstOrNull()?.data + val requestedWeatherPosition = weatherData.firstOrNull()?.requestedPosition + + val formattedTime = currentWeatherData?.let { timeFormatter.format(Instant.ofEpochSecond(currentWeatherData.current.time)) } + val formattedDate = currentWeatherData?.let { Instant.ofEpochSecond(currentWeatherData.current.time).atZone(ZoneId.systemDefault()).toLocalDate().format( + DateTimeFormatter.ofLocalizedDate( + FormatStyle.SHORT)) + } + + if (karooConnected == true && currentWeatherData != null) { + WeatherWidget( + dateLabel = formattedDate, + timeLabel = formattedTime, + baseBitmap = baseBitmap, + current = WeatherInterpretation.fromWeatherCode(currentWeatherData.current.weatherCode), + windBearing = currentWeatherData.current.windDirection.roundToInt(), + windSpeed = currentWeatherData.current.windSpeed.roundToInt(), + windGusts = currentWeatherData.current.windGusts.roundToInt(), + precipitation = currentWeatherData.current.precipitation, + temperature = currentWeatherData.current.temperature.toInt(), + temperatureUnit = if(profile?.preferredUnit?.temperature == UserProfile.PreferredUnit.UnitType.METRIC) TemperatureUnit.CELSIUS else TemperatureUnit.FAHRENHEIT, + isImperial = profile?.preferredUnit?.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL, + precipitationUnit = if (profile?.preferredUnit?.distance != UserProfile.PreferredUnit.UnitType.IMPERIAL) PrecipitationUnit.MILLIMETERS else PrecipitationUnit.INCH, + distance = requestedWeatherPosition?.let { l -> location?.distanceTo(l)?.times(1000) }, + includeDistanceLabel = false, + ) + } + + val lastPosition = location?.let { l -> stats.lastSuccessfulWeatherPosition?.distanceTo(l) } + val lastPositionDistanceStr = + lastPosition?.let { dist -> " (${dist.roundToInt()} km away)" } ?: "" + + if (stats.failedWeatherRequest != null && (stats.lastSuccessfulWeatherRequest == null || stats.failedWeatherRequest!! > stats.lastSuccessfulWeatherRequest!!)) { + val successfulTime = LocalDateTime.ofInstant( + Instant.ofEpochMilli( + stats.lastSuccessfulWeatherRequest ?: 0 + ), ZoneOffset.systemDefault() + ).toLocalTime().truncatedTo( + ChronoUnit.SECONDS + ) + val successfulDate = LocalDateTime.ofInstant( + Instant.ofEpochMilli( + stats.lastSuccessfulWeatherRequest ?: 0 + ), ZoneOffset.systemDefault() + ).toLocalDate() + val lastTryTime = LocalDateTime.ofInstant( + Instant.ofEpochMilli(stats.failedWeatherRequest ?: 0), + ZoneOffset.systemDefault() + ).toLocalTime().truncatedTo( + ChronoUnit.SECONDS + ) + val lastTryDate = LocalDateTime.ofInstant( + Instant.ofEpochMilli(stats.failedWeatherRequest ?: 0), + ZoneOffset.systemDefault() + ).toLocalDate() + + val successStr = + if (lastPosition != null) " Last data received at $successfulDate ${successfulTime}${lastPositionDistanceStr}." else "" + Text( + modifier = Modifier.padding(5.dp), + text = "Failed to update weather data; last try at $lastTryDate ${lastTryTime}.${successStr}" + ) + } else if (stats.lastSuccessfulWeatherRequest != null) { + val localDate = LocalDateTime.ofInstant( + Instant.ofEpochMilli( + stats.lastSuccessfulWeatherRequest ?: 0 + ), ZoneOffset.systemDefault() + ).toLocalDate() + val localTime = LocalDateTime.ofInstant( + Instant.ofEpochMilli( + stats.lastSuccessfulWeatherRequest ?: 0 + ), ZoneOffset.systemDefault() + ).toLocalTime().truncatedTo( + ChronoUnit.SECONDS + ) + + Text( + modifier = Modifier.padding(5.dp), + text = "Last weather data received at $localDate ${localTime}${lastPositionDistanceStr}" + ) + } else { + Text( + modifier = Modifier.padding(5.dp), + text = "No weather data received yet, waiting for GPS fix..." + ) + } + + val upcomingRoute by karooSystem.streamUpcomingRoute().collectAsStateWithLifecycle(null) + + for (index in 1..12){ + val positionIndex = if (weatherData.size == 1) 0 else index + + if (weatherData.getOrNull(positionIndex) == null) break + if (index >= (weatherData.getOrNull(positionIndex)?.data?.forecastData?.weatherCode?.size ?: 0)) { + break + } + + val data = weatherData.getOrNull(positionIndex)?.data + val distanceAlongRoute = weatherData.getOrNull(positionIndex)?.requestedPosition?.distanceAlongRoute + val position = weatherData.getOrNull(positionIndex)?.requestedPosition?.let { "${(it.distanceAlongRoute?.div(1000.0))?.toInt()} at ${it.lat}, ${it.lon}" } + + Log.d(KarooHeadwindExtension.TAG, "Distance along route index ${positionIndex}: $position") + + if (index > 1) { + Spacer( + modifier = Modifier + .fillMaxWidth() + .background( + Color.Black + ) + .height(1.dp) + ) + } + + val distanceFromCurrent = upcomingRoute?.distanceAlongRoute?.let { currentDistanceAlongRoute -> + distanceAlongRoute?.minus(currentDistanceAlongRoute) + } + + val interpretation = WeatherInterpretation.fromWeatherCode(data?.forecastData?.weatherCode?.get(index) ?: 0) + val unixTime = data?.forecastData?.time?.get(index) ?: 0 + val formattedForecastTime = WeatherForecastDataType.timeFormatter.format(Instant.ofEpochSecond(unixTime)) + val formattedForecastDate = Instant.ofEpochSecond(unixTime).atZone(ZoneId.systemDefault()).toLocalDate().format(DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)) + + WeatherWidget( + baseBitmap, + current = interpretation, + windBearing = data?.forecastData?.windDirection?.get(index)?.roundToInt() ?: 0, + windSpeed = data?.forecastData?.windSpeed?.get(index)?.roundToInt() ?: 0, + windGusts = data?.forecastData?.windGusts?.get(index)?.roundToInt() ?: 0, + precipitation = data?.forecastData?.precipitation?.get(index) ?: 0.0, + precipitationProbability = data?.forecastData?.precipitationProbability?.get(index) ?: 0, + temperature = data?.forecastData?.temperature?.get(index)?.roundToInt() ?: 0, + temperatureUnit = if (profile?.preferredUnit?.temperature != UserProfile.PreferredUnit.UnitType.IMPERIAL) TemperatureUnit.CELSIUS else TemperatureUnit.FAHRENHEIT, + timeLabel = formattedForecastTime, + dateLabel = formattedForecastDate, + distance = distanceFromCurrent, + isImperial = profile?.preferredUnit?.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL, + precipitationUnit = if (profile?.preferredUnit?.distance != UserProfile.PreferredUnit.UnitType.IMPERIAL) PrecipitationUnit.MILLIMETERS else PrecipitationUnit.INCH, + includeDistanceLabel = true + ) + } + + Spacer(modifier = Modifier.padding(30.dp)) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/screens/WeatherWidget.kt b/app/src/main/kotlin/de/timklge/karooheadwind/screens/WeatherWidget.kt new file mode 100644 index 0000000..47f9b7b --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/screens/WeatherWidget.kt @@ -0,0 +1,168 @@ +package de.timklge.karooheadwind.screens + +import android.graphics.Bitmap +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import de.timklge.karooheadwind.PrecipitationUnit +import de.timklge.karooheadwind.R +import de.timklge.karooheadwind.TemperatureUnit +import de.timklge.karooheadwind.WeatherInterpretation +import de.timklge.karooheadwind.datatypes.getArrowBitmapByBearing +import de.timklge.karooheadwind.datatypes.getWeatherIcon +import kotlin.math.absoluteValue +import kotlin.math.ceil + +@Composable +fun WeatherWidget( + baseBitmap: Bitmap, + current: WeatherInterpretation, + windBearing: Int, + windSpeed: Int, + windGusts: Int, + precipitation: Double, + temperature: Int, + temperatureUnit: TemperatureUnit, + timeLabel: String? = null, + dateLabel: String? = null, + distance: Double? = null, + includeDistanceLabel: Boolean = false, + precipitationProbability: Int? = null, + precipitationUnit: PrecipitationUnit, + isImperial: Boolean +) { + Row( + modifier = Modifier.fillMaxWidth().padding(5.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column { + if (dateLabel != null) { + Text( + text = dateLabel, + style = TextStyle( + fontSize = 14.sp + ) + ) + } + + if (distance != null) { + val distanceInUserUnit = (distance / (if(!isImperial) 1000.0 else 1609.34)).toInt() + val label = "${distanceInUserUnit.absoluteValue}${if(!isImperial) "km" else "mi"}" + val text = if (includeDistanceLabel){ + if(distanceInUserUnit > 0){ + "In $label" + } else { + "$label ago" + } + } else { + label + } + + Text( + text = text, + style = TextStyle( + fontSize = 14.sp + ) + ) + } + + if (timeLabel != null) { + Text( + text = timeLabel, + style = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ) + ) + } + } + + // Weather icon (larger) + Icon( + painter = painterResource(id = getWeatherIcon(current)), + contentDescription = "Current weather", + modifier = Modifier.size(72.dp) + ) + + Column { + // Temperature (larger) + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = R.drawable.thermometer), + contentDescription = "Temperature", + modifier = Modifier.size(18.dp) + ) + + Spacer(modifier = Modifier.width(4.dp)) + + Text( + text = "${temperature}${temperatureUnit.unitDisplay}", + style = TextStyle( + fontSize = 24.sp, + fontWeight = FontWeight.Bold + ) + ) + } + + // Precipitation + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 4.dp) + ) { + val precipitationProbabilityLabel = + if (precipitationProbability != null) "${precipitationProbability}% " else "" + + Text( + text = "${precipitationProbabilityLabel}${ceil(precipitation).toInt()}${precipitationUnit.unitDisplay}", + style = TextStyle( + fontSize = 14.sp + ) + ) + } + + // Wind + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 4.dp) + ) { + Image( + bitmap = getArrowBitmapByBearing(baseBitmap, windBearing).asImageBitmap(), + colorFilter = ColorFilter.tint(Color.Black), + contentDescription = "Wind direction", + modifier = Modifier.size(16.dp) + ) + + Spacer(modifier = Modifier.width(4.dp)) + + Text( + text = "$windSpeed,$windGusts", + style = TextStyle( + fontSize = 14.sp + ) + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/back.png b/app/src/main/res/drawable/back.png new file mode 100644 index 0000000000000000000000000000000000000000..38d43cfe8554b8c2e760cbad3c7206c21064c879 GIT binary patch literal 28042 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 F{s#g`(+L0o literal 0 HcmV?d00001