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:
Davide Ferrari
2026-01-16 09:32:18 +01:00
committed by GitHub
parent a6bd609269
commit 83ab18ea3f
8 changed files with 1180 additions and 2 deletions
+11
View File
@@ -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)
}
}