From 83ab18ea3f4be60825617fd8af357e655bda34fe Mon Sep 17 00:00:00 2001 From: Davide Ferrari Date: Fri, 16 Jan 2026 09:32:18 +0100 Subject: [PATCH] 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 * 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 * fix(weather): move icon to right-most position in weather column Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- CHANGELOG.md | 11 + .../com/matedroid/data/api/OpenMeteoApi.kt | 83 ++++ .../data/repository/WeatherRepository.kt | 343 ++++++++++++++++ .../java/com/matedroid/di/NetworkModule.kt | 17 + .../com/matedroid/ui/icons/CustomIcons.kt | 372 ++++++++++++++++++ .../ui/screens/drives/DriveDetailScreen.kt | 14 + .../ui/screens/drives/DriveDetailViewModel.kt | 46 ++- .../screens/drives/WeatherAlongTheWayCard.kt | 296 ++++++++++++++ 8 files changed, 1180 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/matedroid/data/api/OpenMeteoApi.kt create mode 100644 app/src/main/java/com/matedroid/data/repository/WeatherRepository.kt create mode 100644 app/src/main/java/com/matedroid/ui/screens/drives/WeatherAlongTheWayCard.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 512c8eb..18d50cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/src/main/java/com/matedroid/data/api/OpenMeteoApi.kt b/app/src/main/java/com/matedroid/data/api/OpenMeteoApi.kt new file mode 100644 index 0000000..8114586 --- /dev/null +++ b/app/src/main/java/com/matedroid/data/api/OpenMeteoApi.kt @@ -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? = null, + @Json(name = "temperature_2m") val temperature2m: List? = null, + @Json(name = "weather_code") val weatherCode: List? = 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 +} diff --git a/app/src/main/java/com/matedroid/data/repository/WeatherRepository.kt b/app/src/main/java/com/matedroid/data/repository/WeatherRepository.kt new file mode 100644 index 0000000..60556c7 --- /dev/null +++ b/app/src/main/java/com/matedroid/data/repository/WeatherRepository.kt @@ -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, + totalDistanceKm: Double + ): List { + 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 + ): List> { + val result = mutableListOf>() + 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>, + totalDistanceKm: Double + ): List> { + 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>, + intervalKm: Double, + totalDistanceKm: Double + ): List> { + val selected = mutableListOf>() + + // 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> + ): List { + val weatherPoints = mutableListOf() + + 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 + } +} diff --git a/app/src/main/java/com/matedroid/di/NetworkModule.kt b/app/src/main/java/com/matedroid/di/NetworkModule.kt index e68d12f..7b18460 100644 --- a/app/src/main/java/com/matedroid/di/NetworkModule.kt +++ b/app/src/main/java/com/matedroid/di/NetworkModule.kt @@ -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) + } } /** diff --git a/app/src/main/java/com/matedroid/ui/icons/CustomIcons.kt b/app/src/main/java/com/matedroid/ui/icons/CustomIcons.kt index dc32bd2..03c210d 100644 --- a/app/src/main/java/com/matedroid/ui/icons/CustomIcons.kt +++ b/app/src/main/java/com/matedroid/ui/icons/CustomIcons.kt @@ -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() + } } diff --git a/app/src/main/java/com/matedroid/ui/screens/drives/DriveDetailScreen.kt b/app/src/main/java/com/matedroid/ui/screens/drives/DriveDetailScreen.kt index 4531ede..7b98db6 100644 --- a/app/src/main/java/com/matedroid/ui/screens/drives/DriveDetailScreen.kt +++ b/app/src/main/java/com/matedroid/ui/screens/drives/DriveDetailScreen.kt @@ -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, + 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)) } } diff --git a/app/src/main/java/com/matedroid/ui/screens/drives/DriveDetailViewModel.kt b/app/src/main/java/com/matedroid/ui/screens/drives/DriveDetailViewModel.kt index 0212381..b4a7b5c 100644 --- a/app/src/main/java/com/matedroid/ui/screens/drives/DriveDetailViewModel.kt +++ b/app/src/main/java/com/matedroid/ui/screens/drives/DriveDetailViewModel.kt @@ -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 = 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) } } diff --git a/app/src/main/java/com/matedroid/ui/screens/drives/WeatherAlongTheWayCard.kt b/app/src/main/java/com/matedroid/ui/screens/drives/WeatherAlongTheWayCard.kt new file mode 100644 index 0000000..f7169ae --- /dev/null +++ b/app/src/main/java/com/matedroid/ui/screens/drives/WeatherAlongTheWayCard.kt @@ -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, + 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) + } +}