timklge ee206a11c9
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
2025-03-02 15:18:07 +01:00

234 lines
11 KiB
Kotlin

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<Boolean?>(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))
}
}