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 0000000..38d43cf Binary files /dev/null and b/app/src/main/res/drawable/back.png differ