mirror of
https://github.com/vide/matedroid.git
synced 2026-01-20 00:03:17 +08:00
feat(drives): add Weather Along the Way to drive details (#57)
* feat(drives): add Weather Along the Way to drive details Shows historical weather conditions along the drive route using the Open-Meteo API. Weather point frequency adapts to drive length: - Under 10 km: destination only - Under 30 km: start and end - Under 150 km: every 25 km - Over 150 km: every 35 km Displays time, distance, weather icon, and temperature in a table. Weather conditions: Clear, Partly Cloudy, Fog, Drizzle, Rain, Snow, Thunderstorm. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(weather): show "End" label for last weather point - Last weather point now displays "End" instead of distance - Intermediate points show distance in km or miles based on user setting - First point shows "Start" as before Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(weather): move icon to right-most position in weather column Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Drive Details**: Weather Along the Way - shows historical weather conditions along your drive route
|
||||
- Uses Open-Meteo API to fetch historical weather data for points along the route
|
||||
- Displays time, distance from start, weather icon, and temperature in a table
|
||||
- Weather point frequency adapts to drive length:
|
||||
- Under 10 km: shows weather at destination only
|
||||
- Under 30 km: shows weather at start and end
|
||||
- Under 150 km: shows weather every 25 km
|
||||
- Over 150 km: shows weather every 35 km
|
||||
- Weather icons for: Clear, Partly Cloudy, Fog, Drizzle, Rain, Snow, Thunderstorm
|
||||
|
||||
## [0.9.4] - 2026-01-14
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.matedroid.data.api
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* Open-Meteo Historical Weather API response models.
|
||||
*
|
||||
* WMO Weather interpretation codes (WW):
|
||||
* - 0: Clear sky
|
||||
* - 1, 2, 3: Mainly clear, partly cloudy, and overcast
|
||||
* - 45, 48: Fog and depositing rime fog
|
||||
* - 51, 53, 55: Drizzle: Light, moderate, and dense intensity
|
||||
* - 56, 57: Freezing Drizzle: Light and dense intensity
|
||||
* - 61, 63, 65: Rain: Slight, moderate and heavy intensity
|
||||
* - 66, 67: Freezing Rain: Light and heavy intensity
|
||||
* - 71, 73, 75: Snow fall: Slight, moderate, and heavy intensity
|
||||
* - 77: Snow grains
|
||||
* - 80, 81, 82: Rain showers: Slight, moderate, and violent
|
||||
* - 85, 86: Snow showers slight and heavy
|
||||
* - 95: Thunderstorm: Slight or moderate
|
||||
* - 96, 99: Thunderstorm with slight and heavy hail
|
||||
*/
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class OpenMeteoResponse(
|
||||
val latitude: Double? = null,
|
||||
val longitude: Double? = null,
|
||||
val elevation: Double? = null,
|
||||
@Json(name = "generationtime_ms") val generationTimeMs: Double? = null,
|
||||
@Json(name = "utc_offset_seconds") val utcOffsetSeconds: Int? = null,
|
||||
val timezone: String? = null,
|
||||
@Json(name = "timezone_abbreviation") val timezoneAbbreviation: String? = null,
|
||||
val hourly: OpenMeteoHourly? = null,
|
||||
@Json(name = "hourly_units") val hourlyUnits: OpenMeteoHourlyUnits? = null
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class OpenMeteoHourly(
|
||||
val time: List<String>? = null,
|
||||
@Json(name = "temperature_2m") val temperature2m: List<Double?>? = null,
|
||||
@Json(name = "weather_code") val weatherCode: List<Int?>? = null
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class OpenMeteoHourlyUnits(
|
||||
val time: String? = null,
|
||||
@Json(name = "temperature_2m") val temperature2m: String? = null,
|
||||
@Json(name = "weather_code") val weatherCode: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Open-Meteo Historical Weather API interface.
|
||||
* Documentation: https://open-meteo.com/en/docs/historical-weather-api
|
||||
*
|
||||
* The Archive API provides historical weather data for any location worldwide,
|
||||
* with data available from 1940 to present (with 2-5 day delay).
|
||||
*/
|
||||
interface OpenMeteoApi {
|
||||
|
||||
/**
|
||||
* Fetches historical weather data for a specific location and time range.
|
||||
*
|
||||
* @param latitude WGS84 latitude of the location
|
||||
* @param longitude WGS84 longitude of the location
|
||||
* @param startDate Start date in ISO8601 format (yyyy-MM-dd)
|
||||
* @param endDate End date in ISO8601 format (yyyy-MM-dd)
|
||||
* @param hourly Comma-separated list of hourly weather variables (e.g., "temperature_2m,weather_code")
|
||||
* @param timezone Timezone for the response (default: "auto" uses location timezone)
|
||||
* @return Historical weather data response
|
||||
*/
|
||||
@GET("v1/archive")
|
||||
suspend fun getHistoricalWeather(
|
||||
@Query("latitude") latitude: Double,
|
||||
@Query("longitude") longitude: Double,
|
||||
@Query("start_date") startDate: String,
|
||||
@Query("end_date") endDate: String,
|
||||
@Query("hourly") hourly: String = "temperature_2m,weather_code",
|
||||
@Query("timezone") timezone: String = "auto"
|
||||
): Response<OpenMeteoResponse>
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
package com.matedroid.data.repository
|
||||
|
||||
import android.util.Log
|
||||
import com.matedroid.data.api.OpenMeteoApi
|
||||
import com.matedroid.data.api.models.DrivePosition
|
||||
import java.time.LocalDateTime
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.DateTimeParseException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Represents a weather data point along a drive route.
|
||||
*
|
||||
* @property time The time of day as HH:mm string
|
||||
* @property distanceKm Distance from start of drive in kilometers
|
||||
* @property temperatureCelsius Temperature at this point in Celsius
|
||||
* @property weatherCode WMO weather interpretation code
|
||||
* @property weatherCondition Human-readable weather condition
|
||||
*/
|
||||
data class WeatherPoint(
|
||||
val time: String,
|
||||
val distanceKm: Double,
|
||||
val temperatureCelsius: Double,
|
||||
val weatherCode: Int,
|
||||
val weatherCondition: WeatherCondition
|
||||
)
|
||||
|
||||
/**
|
||||
* Weather conditions derived from WMO weather codes.
|
||||
*/
|
||||
enum class WeatherCondition {
|
||||
CLEAR, // Code 0
|
||||
PARTLY_CLOUDY, // Codes 1, 2, 3
|
||||
FOG, // Codes 45, 48
|
||||
DRIZZLE, // Codes 51, 53, 55, 56, 57
|
||||
RAIN, // Codes 61, 63, 65, 66, 67, 80, 81, 82
|
||||
SNOW, // Codes 71, 73, 75, 77, 85, 86
|
||||
THUNDERSTORM; // Codes 95, 96, 99
|
||||
|
||||
companion object {
|
||||
fun fromWmoCode(code: Int): WeatherCondition = when (code) {
|
||||
0 -> CLEAR
|
||||
1, 2, 3 -> PARTLY_CLOUDY
|
||||
45, 48 -> FOG
|
||||
51, 53, 55, 56, 57 -> DRIZZLE
|
||||
61, 63, 65, 66, 67, 80, 81, 82 -> RAIN
|
||||
71, 73, 75, 77, 85, 86 -> SNOW
|
||||
95, 96, 99 -> THUNDERSTORM
|
||||
else -> PARTLY_CLOUDY // Default for unknown codes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Repository for fetching weather data along a drive route.
|
||||
*/
|
||||
@Singleton
|
||||
class WeatherRepository @Inject constructor(
|
||||
private val openMeteoApi: OpenMeteoApi
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "WeatherRepository"
|
||||
|
||||
// Distance thresholds in kilometers for weather point selection
|
||||
private const val THRESHOLD_SINGLE_POINT = 10.0 // Under 10km: only end
|
||||
private const val THRESHOLD_TWO_POINTS = 30.0 // Under 30km: start and end
|
||||
private const val THRESHOLD_MEDIUM_DRIVE = 150.0 // Under 150km: every 25km
|
||||
private const val INTERVAL_MEDIUM = 25.0 // Interval for medium drives
|
||||
private const val INTERVAL_LONG = 35.0 // Interval for long drives (>150km)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches weather data for a drive based on its positions.
|
||||
*
|
||||
* The number and spacing of weather points depends on total distance:
|
||||
* - Under 10km: only the end point
|
||||
* - Under 30km: start and end points
|
||||
* - Under 150km: weather point every 25km
|
||||
* - Over 150km: weather point every 35km
|
||||
*
|
||||
* @param positions List of drive positions with coordinates and timestamps
|
||||
* @param totalDistanceKm Total drive distance in kilometers
|
||||
* @return List of weather points along the route, or empty list on failure
|
||||
*/
|
||||
suspend fun getWeatherAlongDrive(
|
||||
positions: List<DrivePosition>,
|
||||
totalDistanceKm: Double
|
||||
): List<WeatherPoint> {
|
||||
if (positions.isEmpty()) return emptyList()
|
||||
|
||||
// Filter positions with valid coordinates and timestamps
|
||||
val validPositions = positions.filter {
|
||||
it.latitude != null && it.longitude != null && it.date != null
|
||||
}
|
||||
|
||||
if (validPositions.isEmpty()) return emptyList()
|
||||
|
||||
// Calculate cumulative distances for each position
|
||||
val positionsWithDistance = calculateCumulativeDistances(validPositions)
|
||||
|
||||
// Select positions for weather queries based on total distance
|
||||
val selectedPositions = selectWeatherPositions(
|
||||
positionsWithDistance,
|
||||
totalDistanceKm
|
||||
)
|
||||
|
||||
if (selectedPositions.isEmpty()) return emptyList()
|
||||
|
||||
// Fetch weather for all selected positions
|
||||
return fetchWeatherForPositions(selectedPositions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates cumulative distance from start for each position.
|
||||
*/
|
||||
private fun calculateCumulativeDistances(
|
||||
positions: List<DrivePosition>
|
||||
): List<Pair<DrivePosition, Double>> {
|
||||
val result = mutableListOf<Pair<DrivePosition, Double>>()
|
||||
var cumulativeDistance = 0.0
|
||||
|
||||
positions.forEachIndexed { index, position ->
|
||||
if (index > 0) {
|
||||
val prevPosition = positions[index - 1]
|
||||
val segmentDistance = haversineDistance(
|
||||
prevPosition.latitude!!,
|
||||
prevPosition.longitude!!,
|
||||
position.latitude!!,
|
||||
position.longitude!!
|
||||
)
|
||||
cumulativeDistance += segmentDistance
|
||||
}
|
||||
result.add(Pair(position, cumulativeDistance))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects positions for weather queries based on total drive distance.
|
||||
*/
|
||||
private fun selectWeatherPositions(
|
||||
positionsWithDistance: List<Pair<DrivePosition, Double>>,
|
||||
totalDistanceKm: Double
|
||||
): List<Pair<DrivePosition, Double>> {
|
||||
if (positionsWithDistance.isEmpty()) return emptyList()
|
||||
|
||||
val first = positionsWithDistance.first()
|
||||
val last = positionsWithDistance.last()
|
||||
|
||||
return when {
|
||||
// Under 10km: only the end
|
||||
totalDistanceKm < THRESHOLD_SINGLE_POINT -> {
|
||||
listOf(last)
|
||||
}
|
||||
|
||||
// Under 30km: start and end
|
||||
totalDistanceKm < THRESHOLD_TWO_POINTS -> {
|
||||
listOf(first, last)
|
||||
}
|
||||
|
||||
// Medium or long drive: select at intervals
|
||||
else -> {
|
||||
val interval = if (totalDistanceKm <= THRESHOLD_MEDIUM_DRIVE) {
|
||||
INTERVAL_MEDIUM
|
||||
} else {
|
||||
INTERVAL_LONG
|
||||
}
|
||||
|
||||
selectAtIntervals(positionsWithDistance, interval, totalDistanceKm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects positions at regular distance intervals.
|
||||
* Always includes start and end positions.
|
||||
*/
|
||||
private fun selectAtIntervals(
|
||||
positionsWithDistance: List<Pair<DrivePosition, Double>>,
|
||||
intervalKm: Double,
|
||||
totalDistanceKm: Double
|
||||
): List<Pair<DrivePosition, Double>> {
|
||||
val selected = mutableListOf<Pair<DrivePosition, Double>>()
|
||||
|
||||
// Always include start
|
||||
selected.add(positionsWithDistance.first())
|
||||
|
||||
// Add intermediate points at intervals
|
||||
var nextTarget = intervalKm
|
||||
while (nextTarget < totalDistanceKm - intervalKm / 2) {
|
||||
// Find the position closest to the target distance
|
||||
val closest = positionsWithDistance.minByOrNull {
|
||||
kotlin.math.abs(it.second - nextTarget)
|
||||
}
|
||||
|
||||
if (closest != null && closest != selected.lastOrNull()) {
|
||||
selected.add(closest)
|
||||
}
|
||||
|
||||
nextTarget += intervalKm
|
||||
}
|
||||
|
||||
// Always include end if not already added
|
||||
val last = positionsWithDistance.last()
|
||||
if (selected.lastOrNull() != last) {
|
||||
selected.add(last)
|
||||
}
|
||||
|
||||
return selected
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches weather data from Open-Meteo for the selected positions.
|
||||
*/
|
||||
private suspend fun fetchWeatherForPositions(
|
||||
positions: List<Pair<DrivePosition, Double>>
|
||||
): List<WeatherPoint> {
|
||||
val weatherPoints = mutableListOf<WeatherPoint>()
|
||||
|
||||
for ((position, distanceKm) in positions) {
|
||||
try {
|
||||
val weather = fetchWeatherForPosition(position, distanceKm)
|
||||
if (weather != null) {
|
||||
weatherPoints.add(weather)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to fetch weather for position at ${distanceKm}km", e)
|
||||
// Continue with other positions even if one fails
|
||||
}
|
||||
}
|
||||
|
||||
return weatherPoints
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches weather for a single position from Open-Meteo.
|
||||
*/
|
||||
private suspend fun fetchWeatherForPosition(
|
||||
position: DrivePosition,
|
||||
distanceKm: Double
|
||||
): WeatherPoint? {
|
||||
val dateTime = parseDateTime(position.date!!) ?: return null
|
||||
val dateStr = dateTime.format(DateTimeFormatter.ISO_LOCAL_DATE)
|
||||
val hour = dateTime.hour
|
||||
|
||||
try {
|
||||
val response = openMeteoApi.getHistoricalWeather(
|
||||
latitude = position.latitude!!,
|
||||
longitude = position.longitude!!,
|
||||
startDate = dateStr,
|
||||
endDate = dateStr
|
||||
)
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
Log.w(TAG, "Weather API returned ${response.code()}: ${response.message()}")
|
||||
return null
|
||||
}
|
||||
|
||||
val body = response.body() ?: return null
|
||||
val hourly = body.hourly ?: return null
|
||||
|
||||
// Find the matching hour in the response
|
||||
val timeIndex = hourly.time?.indexOfFirst { timeStr ->
|
||||
try {
|
||||
val responseTime = LocalDateTime.parse(timeStr)
|
||||
responseTime.hour == hour
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
} ?: -1
|
||||
|
||||
if (timeIndex < 0) {
|
||||
Log.w(TAG, "Could not find matching hour $hour in weather response")
|
||||
return null
|
||||
}
|
||||
|
||||
val temperature = hourly.temperature2m?.getOrNull(timeIndex) ?: return null
|
||||
val weatherCode = hourly.weatherCode?.getOrNull(timeIndex) ?: 0
|
||||
|
||||
val timeStr = dateTime.format(DateTimeFormatter.ofPattern("HH:mm"))
|
||||
|
||||
return WeatherPoint(
|
||||
time = timeStr,
|
||||
distanceKm = distanceKm,
|
||||
temperatureCelsius = temperature,
|
||||
weatherCode = weatherCode,
|
||||
weatherCondition = WeatherCondition.fromWmoCode(weatherCode)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error fetching weather from Open-Meteo", e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a date string into LocalDateTime.
|
||||
* Supports both ISO 8601 with offset and without.
|
||||
*/
|
||||
private fun parseDateTime(dateStr: String): LocalDateTime? {
|
||||
return try {
|
||||
OffsetDateTime.parse(dateStr).toLocalDateTime()
|
||||
} catch (e: DateTimeParseException) {
|
||||
try {
|
||||
LocalDateTime.parse(dateStr.replace("Z", ""))
|
||||
} catch (e2: Exception) {
|
||||
Log.e(TAG, "Failed to parse date: $dateStr", e2)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the distance between two points using the Haversine formula.
|
||||
*
|
||||
* @return Distance in kilometers
|
||||
*/
|
||||
private fun haversineDistance(
|
||||
lat1: Double, lon1: Double,
|
||||
lat2: Double, lon2: Double
|
||||
): Double {
|
||||
val earthRadiusKm = 6371.0
|
||||
|
||||
val dLat = Math.toRadians(lat2 - lat1)
|
||||
val dLon = Math.toRadians(lon2 - lon1)
|
||||
|
||||
val a = sin(dLat / 2).pow(2) +
|
||||
cos(Math.toRadians(lat1)) *
|
||||
cos(Math.toRadians(lat2)) *
|
||||
sin(dLon / 2).pow(2)
|
||||
|
||||
val c = 2 * atan2(sqrt(a), sqrt(1 - a))
|
||||
|
||||
return earthRadiusKm * c
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.matedroid.di
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import com.matedroid.data.api.NominatimApi
|
||||
import com.matedroid.data.api.OpenMeteoApi
|
||||
import com.matedroid.data.api.TeslamateApi
|
||||
import com.matedroid.data.local.SettingsDataStore
|
||||
import com.squareup.moshi.Moshi
|
||||
@@ -58,6 +59,22 @@ object NetworkModule {
|
||||
.build()
|
||||
.create(NominatimApi::class.java)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOpenMeteoApi(moshi: Moshi): OpenMeteoApi {
|
||||
val okHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(15, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
return Retrofit.Builder()
|
||||
.baseUrl("https://archive-api.open-meteo.com/")
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(MoshiConverterFactory.create(moshi))
|
||||
.build()
|
||||
.create(OpenMeteoApi::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -197,4 +197,376 @@ object CustomIcons {
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
|
||||
// Weather icons from Material Symbols Outlined
|
||||
// Source: https://fonts.google.com/icons
|
||||
|
||||
/**
|
||||
* Clear/Sunny weather icon.
|
||||
* Material Symbol: sunny (light_mode)
|
||||
*/
|
||||
val WeatherSunny: ImageVector by lazy {
|
||||
ImageVector.Builder(
|
||||
name = "WeatherSunny",
|
||||
defaultWidth = 24.dp,
|
||||
defaultHeight = 24.dp,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f
|
||||
).apply {
|
||||
path(fill = SolidColor(Color.Black)) {
|
||||
// M480-280q-83 0-141.5-58.5T280-480q0-83 58.5-141.5T480-680q83 0 141.5 58.5T680-480q0 83-58.5 141.5T480-280Z
|
||||
// Sun circle
|
||||
moveTo(480f, 680f)
|
||||
quadToRelative(-83f, 0f, -141.5f, -58.5f)
|
||||
reflectiveQuadTo(280f, 480f)
|
||||
quadToRelative(0f, -83f, 58.5f, -141.5f)
|
||||
reflectiveQuadTo(480f, 280f)
|
||||
quadToRelative(83f, 0f, 141.5f, 58.5f)
|
||||
reflectiveQuadTo(680f, 480f)
|
||||
quadToRelative(0f, 83f, -58.5f, 141.5f)
|
||||
reflectiveQuadTo(480f, 680f)
|
||||
close()
|
||||
|
||||
// M440-760v-160h80v160h-80Z (top ray)
|
||||
moveTo(440f, 200f)
|
||||
verticalLineToRelative(-120f)
|
||||
horizontalLineToRelative(80f)
|
||||
verticalLineToRelative(120f)
|
||||
close()
|
||||
|
||||
// m0 720v-160h80v160h-80Z (bottom ray)
|
||||
moveTo(440f, 880f)
|
||||
verticalLineToRelative(-120f)
|
||||
horizontalLineToRelative(80f)
|
||||
verticalLineToRelative(120f)
|
||||
close()
|
||||
|
||||
// M760-440h160v-80H760v80Z (right ray)
|
||||
moveTo(760f, 520f)
|
||||
horizontalLineToRelative(120f)
|
||||
verticalLineToRelative(-80f)
|
||||
horizontalLineTo(760f)
|
||||
close()
|
||||
|
||||
// M40-440h160v-80H40v80Z (left ray)
|
||||
moveTo(80f, 520f)
|
||||
horizontalLineToRelative(-120f)
|
||||
verticalLineToRelative(-80f)
|
||||
horizontalLineToRelative(120f)
|
||||
close()
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Partly cloudy weather icon.
|
||||
* Material Symbol: partly_cloudy_day
|
||||
*/
|
||||
val WeatherPartlyCloudy: ImageVector by lazy {
|
||||
ImageVector.Builder(
|
||||
name = "WeatherPartlyCloudy",
|
||||
defaultWidth = 24.dp,
|
||||
defaultHeight = 24.dp,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f
|
||||
).apply {
|
||||
path(fill = SolidColor(Color.Black)) {
|
||||
// Cloud shape with partial sun
|
||||
// M260-160q-91 0-155.5-63T40-377q0-78 47-139t123-78q25-92 100-149t170-57q117 0 198.5 81.5T760-520q69 8 114.5 59.5T920-340q0 75-52.5 127.5T740-160H260Z
|
||||
moveTo(260f, 800f)
|
||||
quadToRelative(-91f, 0f, -155.5f, -63f)
|
||||
reflectiveQuadTo(40f, 583f)
|
||||
quadToRelative(0f, -78f, 47f, -139f)
|
||||
reflectiveQuadToRelative(123f, -78f)
|
||||
quadToRelative(25f, -92f, 100f, -149f)
|
||||
reflectiveQuadToRelative(170f, -57f)
|
||||
quadToRelative(117f, 0f, 198.5f, 81.5f)
|
||||
reflectiveQuadTo(760f, 440f)
|
||||
quadToRelative(69f, 8f, 114.5f, 59.5f)
|
||||
reflectiveQuadTo(920f, 620f)
|
||||
quadToRelative(0f, 75f, -52.5f, 127.5f)
|
||||
reflectiveQuadTo(740f, 800f)
|
||||
horizontalLineTo(260f)
|
||||
close()
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Foggy weather icon.
|
||||
* Material Symbol: foggy
|
||||
*/
|
||||
val WeatherFog: ImageVector by lazy {
|
||||
ImageVector.Builder(
|
||||
name = "WeatherFog",
|
||||
defaultWidth = 24.dp,
|
||||
defaultHeight = 24.dp,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f
|
||||
).apply {
|
||||
path(fill = SolidColor(Color.Black)) {
|
||||
// Fog lines
|
||||
// M160-200v-80h640v80H160Z (bottom line)
|
||||
moveTo(160f, 760f)
|
||||
verticalLineToRelative(-80f)
|
||||
horizontalLineToRelative(640f)
|
||||
verticalLineToRelative(80f)
|
||||
horizontalLineTo(160f)
|
||||
close()
|
||||
|
||||
// m0-160v-80h640v80H160Z (middle line)
|
||||
moveTo(160f, 600f)
|
||||
verticalLineToRelative(-80f)
|
||||
horizontalLineToRelative(640f)
|
||||
verticalLineToRelative(80f)
|
||||
horizontalLineTo(160f)
|
||||
close()
|
||||
|
||||
// m0-160v-80h640v80H160Z (top line)
|
||||
moveTo(160f, 440f)
|
||||
verticalLineToRelative(-80f)
|
||||
horizontalLineToRelative(640f)
|
||||
verticalLineToRelative(80f)
|
||||
horizontalLineTo(160f)
|
||||
close()
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Rainy weather icon.
|
||||
* Material Symbol: rainy
|
||||
*/
|
||||
val WeatherRain: ImageVector by lazy {
|
||||
ImageVector.Builder(
|
||||
name = "WeatherRain",
|
||||
defaultWidth = 24.dp,
|
||||
defaultHeight = 24.dp,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f
|
||||
).apply {
|
||||
path(fill = SolidColor(Color.Black)) {
|
||||
// Cloud with rain drops
|
||||
// M558-82 398-242l56-56 160 160-56 56Z (rain drop 1)
|
||||
moveTo(558f, 878f)
|
||||
lineToRelative(-160f, -160f)
|
||||
lineToRelative(56f, -56f)
|
||||
lineToRelative(160f, 160f)
|
||||
close()
|
||||
|
||||
// M368-82 208-242l56-56 160 160-56 56Z (rain drop 2)
|
||||
moveTo(368f, 878f)
|
||||
lineToRelative(-160f, -160f)
|
||||
lineToRelative(56f, -56f)
|
||||
lineToRelative(160f, 160f)
|
||||
close()
|
||||
|
||||
// M748-82 588-242l56-56 160 160-56 56Z (rain drop 3)
|
||||
moveTo(748f, 878f)
|
||||
lineToRelative(-160f, -160f)
|
||||
lineToRelative(56f, -56f)
|
||||
lineToRelative(160f, 160f)
|
||||
close()
|
||||
|
||||
// Cloud shape
|
||||
// M260-360q-91 0-155.5-63T40-577q0-78 47-139t123-78q25-92 100-149t170-57q117 0 198.5 81.5T760-720q69 8 114.5 59.5T920-540q0 75-52.5 127.5T740-360H260Z
|
||||
moveTo(260f, 600f)
|
||||
quadToRelative(-91f, 0f, -155.5f, -63f)
|
||||
reflectiveQuadTo(40f, 383f)
|
||||
quadToRelative(0f, -78f, 47f, -139f)
|
||||
reflectiveQuadToRelative(123f, -78f)
|
||||
quadToRelative(25f, -92f, 100f, -149f)
|
||||
reflectiveQuadToRelative(170f, -57f)
|
||||
quadToRelative(117f, 0f, 198.5f, 81.5f)
|
||||
reflectiveQuadTo(760f, 240f)
|
||||
quadToRelative(69f, 8f, 114.5f, 59.5f)
|
||||
reflectiveQuadTo(920f, 420f)
|
||||
quadToRelative(0f, 75f, -52.5f, 127.5f)
|
||||
reflectiveQuadTo(740f, 600f)
|
||||
horizontalLineTo(260f)
|
||||
close()
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Snowy weather icon.
|
||||
* Material Symbol: ac_unit (snowflake)
|
||||
*/
|
||||
val WeatherSnow: ImageVector by lazy {
|
||||
ImageVector.Builder(
|
||||
name = "WeatherSnow",
|
||||
defaultWidth = 24.dp,
|
||||
defaultHeight = 24.dp,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f
|
||||
).apply {
|
||||
path(fill = SolidColor(Color.Black)) {
|
||||
// Snowflake shape
|
||||
// M440-80v-166L310-116l-56-56 186-186v-82h-82L172-254l-56-56 130-130H80v-80h166L116-650l56-56 186 186h82v-82L254-788l56-56 130 130V-880h80v166l130-130 56 56-186 186v82h82l186-186 56 56-130 130h166v80H714l130 130-56 56-186-186h-82v82l186 186-56 56-130-130v166h-80Z
|
||||
moveTo(440f, 880f)
|
||||
verticalLineToRelative(-166f)
|
||||
lineTo(310f, 844f)
|
||||
lineToRelative(-56f, -56f)
|
||||
lineToRelative(186f, -186f)
|
||||
verticalLineToRelative(-82f)
|
||||
horizontalLineToRelative(-82f)
|
||||
lineTo(172f, 706f)
|
||||
lineToRelative(-56f, -56f)
|
||||
lineToRelative(130f, -130f)
|
||||
horizontalLineTo(80f)
|
||||
verticalLineToRelative(-80f)
|
||||
horizontalLineToRelative(166f)
|
||||
lineTo(116f, 310f)
|
||||
lineToRelative(56f, -56f)
|
||||
lineToRelative(186f, 186f)
|
||||
horizontalLineToRelative(82f)
|
||||
verticalLineToRelative(-82f)
|
||||
lineTo(254f, 172f)
|
||||
lineToRelative(56f, -56f)
|
||||
lineToRelative(130f, 130f)
|
||||
verticalLineTo(80f)
|
||||
horizontalLineToRelative(80f)
|
||||
verticalLineToRelative(166f)
|
||||
lineToRelative(130f, -130f)
|
||||
lineToRelative(56f, 56f)
|
||||
lineToRelative(-186f, 186f)
|
||||
verticalLineToRelative(82f)
|
||||
horizontalLineToRelative(82f)
|
||||
lineToRelative(186f, -186f)
|
||||
lineToRelative(56f, 56f)
|
||||
lineToRelative(-130f, 130f)
|
||||
horizontalLineToRelative(166f)
|
||||
verticalLineToRelative(80f)
|
||||
horizontalLineTo(714f)
|
||||
lineToRelative(130f, 130f)
|
||||
lineToRelative(-56f, 56f)
|
||||
lineToRelative(-186f, -186f)
|
||||
horizontalLineToRelative(-82f)
|
||||
verticalLineToRelative(82f)
|
||||
lineToRelative(186f, 186f)
|
||||
lineToRelative(-56f, 56f)
|
||||
lineToRelative(-130f, -130f)
|
||||
verticalLineToRelative(166f)
|
||||
horizontalLineToRelative(-80f)
|
||||
close()
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Thunderstorm weather icon.
|
||||
* Material Symbol: thunderstorm
|
||||
*/
|
||||
val WeatherThunderstorm: ImageVector by lazy {
|
||||
ImageVector.Builder(
|
||||
name = "WeatherThunderstorm",
|
||||
defaultWidth = 24.dp,
|
||||
defaultHeight = 24.dp,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f
|
||||
).apply {
|
||||
path(fill = SolidColor(Color.Black)) {
|
||||
// Lightning bolt with cloud
|
||||
// M480-80 360-280h120v-200h120L500-280H380l100-200H360L480-80Z (lightning bolt)
|
||||
moveTo(480f, 880f)
|
||||
lineTo(320f, 600f)
|
||||
horizontalLineToRelative(100f)
|
||||
verticalLineToRelative(-120f)
|
||||
horizontalLineToRelative(120f)
|
||||
lineTo(420f, 680f)
|
||||
horizontalLineToRelative(100f)
|
||||
close()
|
||||
|
||||
// Cloud shape above
|
||||
// M260-440q-91 0-155.5-63T40-657q0-78 47-139t123-78q25-92 100-149t170-57q117 0 198.5 81.5T760-800q69 8 114.5 59.5T920-620q0 75-52.5 127.5T740-440H260Z
|
||||
moveTo(260f, 520f)
|
||||
quadToRelative(-91f, 0f, -155.5f, -63f)
|
||||
reflectiveQuadTo(40f, 303f)
|
||||
quadToRelative(0f, -78f, 47f, -139f)
|
||||
reflectiveQuadToRelative(123f, -78f)
|
||||
quadToRelative(25f, -92f, 100f, -149f)
|
||||
reflectiveQuadToRelative(170f, -57f)
|
||||
quadToRelative(117f, 0f, 198.5f, 81.5f)
|
||||
reflectiveQuadTo(760f, 160f)
|
||||
quadToRelative(69f, 8f, 114.5f, 59.5f)
|
||||
reflectiveQuadTo(920f, 340f)
|
||||
quadToRelative(0f, 75f, -52.5f, 127.5f)
|
||||
reflectiveQuadTo(740f, 520f)
|
||||
horizontalLineTo(260f)
|
||||
close()
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Drizzle weather icon (light rain).
|
||||
* Material Symbol: grain (representing light precipitation)
|
||||
*/
|
||||
val WeatherDrizzle: ImageVector by lazy {
|
||||
ImageVector.Builder(
|
||||
name = "WeatherDrizzle",
|
||||
defaultWidth = 24.dp,
|
||||
defaultHeight = 24.dp,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f
|
||||
).apply {
|
||||
path(fill = SolidColor(Color.Black)) {
|
||||
// Cloud with light rain drops (simplified)
|
||||
// Cloud shape
|
||||
moveTo(260f, 600f)
|
||||
quadToRelative(-91f, 0f, -155.5f, -63f)
|
||||
reflectiveQuadTo(40f, 383f)
|
||||
quadToRelative(0f, -78f, 47f, -139f)
|
||||
reflectiveQuadToRelative(123f, -78f)
|
||||
quadToRelative(25f, -92f, 100f, -149f)
|
||||
reflectiveQuadToRelative(170f, -57f)
|
||||
quadToRelative(117f, 0f, 198.5f, 81.5f)
|
||||
reflectiveQuadTo(760f, 240f)
|
||||
quadToRelative(69f, 8f, 114.5f, 59.5f)
|
||||
reflectiveQuadTo(920f, 420f)
|
||||
quadToRelative(0f, 75f, -52.5f, 127.5f)
|
||||
reflectiveQuadTo(740f, 600f)
|
||||
horizontalLineTo(260f)
|
||||
close()
|
||||
|
||||
// Rain drops (dots)
|
||||
// Drop 1
|
||||
moveTo(300f, 720f)
|
||||
quadToRelative(-17f, 0f, -28.5f, -11.5f)
|
||||
reflectiveQuadTo(260f, 680f)
|
||||
quadToRelative(0f, -17f, 11.5f, -28.5f)
|
||||
reflectiveQuadTo(300f, 640f)
|
||||
quadToRelative(17f, 0f, 28.5f, 11.5f)
|
||||
reflectiveQuadTo(340f, 680f)
|
||||
quadToRelative(0f, 17f, -11.5f, 28.5f)
|
||||
reflectiveQuadTo(300f, 720f)
|
||||
close()
|
||||
|
||||
// Drop 2
|
||||
moveTo(480f, 800f)
|
||||
quadToRelative(-17f, 0f, -28.5f, -11.5f)
|
||||
reflectiveQuadTo(440f, 760f)
|
||||
quadToRelative(0f, -17f, 11.5f, -28.5f)
|
||||
reflectiveQuadTo(480f, 720f)
|
||||
quadToRelative(17f, 0f, 28.5f, 11.5f)
|
||||
reflectiveQuadTo(520f, 760f)
|
||||
quadToRelative(0f, 17f, -11.5f, 28.5f)
|
||||
reflectiveQuadTo(480f, 800f)
|
||||
close()
|
||||
|
||||
// Drop 3
|
||||
moveTo(660f, 720f)
|
||||
quadToRelative(-17f, 0f, -28.5f, -11.5f)
|
||||
reflectiveQuadTo(620f, 680f)
|
||||
quadToRelative(0f, -17f, 11.5f, -28.5f)
|
||||
reflectiveQuadTo(660f, 640f)
|
||||
quadToRelative(17f, 0f, 28.5f, 11.5f)
|
||||
reflectiveQuadTo(700f, 680f)
|
||||
quadToRelative(0f, 17f, -11.5f, 28.5f)
|
||||
reflectiveQuadTo(660f, 720f)
|
||||
close()
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.matedroid.data.api.models.DriveDetail
|
||||
import com.matedroid.data.api.models.DrivePosition
|
||||
import com.matedroid.data.api.models.Units
|
||||
import com.matedroid.data.repository.WeatherPoint
|
||||
import com.matedroid.domain.model.UnitFormatter
|
||||
import com.matedroid.ui.theme.CarColorPalettes
|
||||
import org.osmdroid.config.Configuration
|
||||
@@ -143,6 +144,8 @@ fun DriveDetailScreen(
|
||||
stats = uiState.stats,
|
||||
units = uiState.units,
|
||||
routeColor = palette.accent,
|
||||
weatherPoints = uiState.weatherPoints,
|
||||
isLoadingWeather = uiState.isLoadingWeather,
|
||||
modifier = Modifier.padding(padding)
|
||||
)
|
||||
}
|
||||
@@ -156,6 +159,8 @@ private fun DriveDetailContent(
|
||||
stats: DriveDetailStats?,
|
||||
units: Units?,
|
||||
routeColor: Color,
|
||||
weatherPoints: List<WeatherPoint>,
|
||||
isLoadingWeather: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
@@ -259,6 +264,15 @@ private fun DriveDetailContent(
|
||||
}
|
||||
}
|
||||
|
||||
// Weather along the way - shown when loading or has data
|
||||
if (isLoadingWeather || weatherPoints.isNotEmpty()) {
|
||||
WeatherAlongTheWayCard(
|
||||
weatherPoints = weatherPoints,
|
||||
units = units,
|
||||
isLoading = isLoadingWeather
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import com.matedroid.data.api.models.DrivePosition
|
||||
import com.matedroid.data.api.models.Units
|
||||
import com.matedroid.data.repository.ApiResult
|
||||
import com.matedroid.data.repository.TeslamateRepository
|
||||
import com.matedroid.data.repository.WeatherPoint
|
||||
import com.matedroid.data.repository.WeatherRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -20,7 +22,9 @@ data class DriveDetailUiState(
|
||||
val error: String? = null,
|
||||
val driveDetail: DriveDetail? = null,
|
||||
val units: Units? = null,
|
||||
val stats: DriveDetailStats? = null
|
||||
val stats: DriveDetailStats? = null,
|
||||
val weatherPoints: List<WeatherPoint> = emptyList(),
|
||||
val isLoadingWeather: Boolean = false
|
||||
)
|
||||
|
||||
data class DriveDetailStats(
|
||||
@@ -48,7 +52,8 @@ data class DriveDetailStats(
|
||||
|
||||
@HiltViewModel
|
||||
class DriveDetailViewModel @Inject constructor(
|
||||
private val repository: TeslamateRepository
|
||||
private val repository: TeslamateRepository,
|
||||
private val weatherRepository: WeatherRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(DriveDetailUiState())
|
||||
@@ -90,6 +95,9 @@ class DriveDetailViewModel @Inject constructor(
|
||||
error = null
|
||||
)
|
||||
}
|
||||
|
||||
// Fetch weather data in the background
|
||||
loadWeatherData(detail)
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
_uiState.update {
|
||||
@@ -103,6 +111,40 @@ class DriveDetailViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads weather data for the drive positions.
|
||||
* This runs in the background after the main drive detail is loaded.
|
||||
*/
|
||||
private fun loadWeatherData(detail: DriveDetail) {
|
||||
val positions = detail.positions
|
||||
val distance = detail.distance
|
||||
|
||||
if (positions.isNullOrEmpty() || distance == null || distance <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoadingWeather = true) }
|
||||
|
||||
try {
|
||||
val weatherPoints = weatherRepository.getWeatherAlongDrive(
|
||||
positions = positions,
|
||||
totalDistanceKm = distance
|
||||
)
|
||||
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
weatherPoints = weatherPoints,
|
||||
isLoadingWeather = false
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Weather loading failed silently - it's optional data
|
||||
_uiState.update { it.copy(isLoadingWeather = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_uiState.update { it.copy(error = null) }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
package com.matedroid.ui.screens.drives
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.matedroid.data.api.models.Units
|
||||
import com.matedroid.data.repository.WeatherCondition
|
||||
import com.matedroid.data.repository.WeatherPoint
|
||||
import com.matedroid.domain.model.UnitFormatter
|
||||
import com.matedroid.ui.icons.CustomIcons
|
||||
|
||||
/**
|
||||
* Displays weather conditions along the drive route in a table format.
|
||||
*
|
||||
* Table columns:
|
||||
* 1. Time (HH:mm)
|
||||
* 2. Distance from start
|
||||
* 3. Weather icon + temperature
|
||||
*
|
||||
* @param weatherPoints List of weather data points along the route
|
||||
* @param units Unit settings for formatting
|
||||
* @param isLoading Whether weather data is still loading
|
||||
*/
|
||||
@Composable
|
||||
fun WeatherAlongTheWayCard(
|
||||
weatherPoints: List<WeatherPoint>,
|
||||
units: Units?,
|
||||
isLoading: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
// Header
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CustomIcons.WeatherPartlyCloudy,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "Weather Along the Way",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
// Loading state
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(80.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = "Loading weather data...",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (weatherPoints.isEmpty()) {
|
||||
// Empty state
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(60.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Weather data unavailable",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Table header
|
||||
WeatherTableHeader()
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f)
|
||||
)
|
||||
|
||||
// Weather data rows
|
||||
weatherPoints.forEachIndexed { index, weatherPoint ->
|
||||
val isLastPoint = index == weatherPoints.size - 1
|
||||
WeatherTableRow(
|
||||
weatherPoint = weatherPoint,
|
||||
units = units,
|
||||
isLastPoint = isLastPoint
|
||||
)
|
||||
|
||||
if (!isLastPoint) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(vertical = 6.dp),
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeatherTableHeader() {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = "Time",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = "Distance",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Text(
|
||||
text = "Weather",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
|
||||
modifier = Modifier.weight(1.5f),
|
||||
textAlign = TextAlign.End
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeatherTableRow(
|
||||
weatherPoint: WeatherPoint,
|
||||
units: Units?,
|
||||
isLastPoint: Boolean
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
// Time column
|
||||
Text(
|
||||
text = weatherPoint.time,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
// Distance column
|
||||
Text(
|
||||
text = formatWeatherDistance(weatherPoint.distanceKm, units, isLastPoint),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
// Weather column (temperature + icon)
|
||||
Row(
|
||||
modifier = Modifier.weight(1.5f),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = UnitFormatter.formatTemperature(weatherPoint.temperatureCelsius, units),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Icon(
|
||||
imageVector = getWeatherIcon(weatherPoint.weatherCondition),
|
||||
contentDescription = getWeatherDescription(weatherPoint.weatherCondition),
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = getWeatherIconColor(weatherPoint.weatherCondition)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the appropriate weather icon for a given weather condition.
|
||||
*/
|
||||
private fun getWeatherIcon(condition: WeatherCondition): ImageVector {
|
||||
return when (condition) {
|
||||
WeatherCondition.CLEAR -> CustomIcons.WeatherSunny
|
||||
WeatherCondition.PARTLY_CLOUDY -> CustomIcons.WeatherPartlyCloudy
|
||||
WeatherCondition.FOG -> CustomIcons.WeatherFog
|
||||
WeatherCondition.DRIZZLE -> CustomIcons.WeatherDrizzle
|
||||
WeatherCondition.RAIN -> CustomIcons.WeatherRain
|
||||
WeatherCondition.SNOW -> CustomIcons.WeatherSnow
|
||||
WeatherCondition.THUNDERSTORM -> CustomIcons.WeatherThunderstorm
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the appropriate color for a weather icon.
|
||||
*/
|
||||
@Composable
|
||||
private fun getWeatherIconColor(condition: WeatherCondition): Color {
|
||||
return when (condition) {
|
||||
WeatherCondition.CLEAR -> Color(0xFFFFC107) // Amber/Yellow for sun
|
||||
WeatherCondition.PARTLY_CLOUDY -> Color(0xFF78909C) // Blue Grey
|
||||
WeatherCondition.FOG -> Color(0xFF90A4AE) // Light Grey
|
||||
WeatherCondition.DRIZZLE -> Color(0xFF64B5F6) // Light Blue
|
||||
WeatherCondition.RAIN -> Color(0xFF1E88E5) // Blue
|
||||
WeatherCondition.SNOW -> Color(0xFF42A5F5) // Light Blue
|
||||
WeatherCondition.THUNDERSTORM -> Color(0xFF7E57C2) // Purple
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a human-readable description for a weather condition.
|
||||
*/
|
||||
private fun getWeatherDescription(condition: WeatherCondition): String {
|
||||
return when (condition) {
|
||||
WeatherCondition.CLEAR -> "Clear sky"
|
||||
WeatherCondition.PARTLY_CLOUDY -> "Partly cloudy"
|
||||
WeatherCondition.FOG -> "Foggy"
|
||||
WeatherCondition.DRIZZLE -> "Light rain"
|
||||
WeatherCondition.RAIN -> "Rain"
|
||||
WeatherCondition.SNOW -> "Snow"
|
||||
WeatherCondition.THUNDERSTORM -> "Thunderstorm"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats distance for the weather table.
|
||||
* Shows "Start" for 0km, "End" for the last point, and formats with appropriate units otherwise.
|
||||
*/
|
||||
private fun formatWeatherDistance(distanceKm: Double, units: Units?, isLastPoint: Boolean): String {
|
||||
if (isLastPoint) {
|
||||
return "End"
|
||||
}
|
||||
|
||||
if (distanceKm < 0.1) {
|
||||
return "Start"
|
||||
}
|
||||
|
||||
val isImperial = units?.isImperial == true
|
||||
return if (isImperial) {
|
||||
val miles = distanceKm * 0.621371
|
||||
"%.1f mi".format(miles)
|
||||
} else {
|
||||
"%.1f km".format(distanceKm)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user