From d2130438c7e0a63fe7412dff8fef668a28bf24e9 Mon Sep 17 00:00:00 2001 From: Davide Ferrari Date: Fri, 20 Mar 2026 17:40:44 +0100 Subject: [PATCH 1/4] feat: add "Where was I that day?" feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new feature accessible from the dashboard Location card that lets users look up their car's position and activity at any past date/time. - Dashboard entrypoint: "Where was I that day?" row with AssistChip attached below the Location card with a HorizontalDivider - Material3 DatePicker → TimePicker dialog flow (past dates only) - New WhereWasIScreen with: map, location breadcrumb (country flag + name → tappable to CountriesVisited, region, city), car state card (driving/charging/parked with state-specific info), weather card - WhereWasIViewModel: queries drives/charges for the target day, determines state, interpolates position, reverse geocodes via Nominatim, fetches weather from Open-Meteo archive API - Shared info (odometer, outside temp) in consistent positions across all states for spatial memory - Tappable state card navigates to DriveDetail or ChargeDetail - All strings translated to EN/IT/ES/CA - Updated CHANGELOG.md with all unreleased features Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 6 + .../com/matedroid/ui/navigation/NavGraph.kt | 50 ++ .../ui/screens/dashboard/DashboardScreen.kt | 267 ++++++--- .../ui/screens/wherewasi/WhereWasIScreen.kt | 520 ++++++++++++++++++ .../screens/wherewasi/WhereWasIViewModel.kt | 361 ++++++++++++ app/src/main/res/values-ca/strings.xml | 13 + app/src/main/res/values-es/strings.xml | 13 + app/src/main/res/values-it/strings.xml | 13 + app/src/main/res/values/strings.xml | 24 + 9 files changed, 1200 insertions(+), 67 deletions(-) create mode 100644 app/src/main/java/com/matedroid/ui/screens/wherewasi/WhereWasIScreen.kt create mode 100644 app/src/main/java/com/matedroid/ui/screens/wherewasi/WhereWasIViewModel.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 384c3f2..9680814 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Where was I that day?**: New feature on the dashboard to look up the car's position and activity at any past date and time. Shows map, location breadcrumb (country/region/city), car state (driving/charging/parked), state-specific details, and weather. Tapping a driving or charging card navigates to the corresponding detail screen. +- **Line chart visual revamp**: Smooth cubic Bezier curves, gradient fills, dashed grid lines, vertical crosshair, glowing indicators, animated entrance, and theme-aware tooltips. +- **Battery heater overlay**: Grafana-style orange annotation bands on drive detail Power and Battery charts highlighting battery pre-heating periods. +- **Speed distribution histogram**: Drive details now include a speed histogram showing the percentage of time spent in each speed bucket (10 km/h or 5 mph). + ## [1.2.3] - 2026-03-08 ### Added diff --git a/app/src/main/java/com/matedroid/ui/navigation/NavGraph.kt b/app/src/main/java/com/matedroid/ui/navigation/NavGraph.kt index 317e467..5958d55 100644 --- a/app/src/main/java/com/matedroid/ui/navigation/NavGraph.kt +++ b/app/src/main/java/com/matedroid/ui/navigation/NavGraph.kt @@ -38,6 +38,7 @@ import java.net.URLDecoder import java.net.URLEncoder import java.nio.charset.StandardCharsets import com.matedroid.ui.screens.updates.SoftwareVersionsScreen +import com.matedroid.ui.screens.wherewasi.WhereWasIScreen import com.matedroid.domain.model.YearFilter import kotlinx.coroutines.launch @@ -154,6 +155,14 @@ sealed class Screen(val route: String) { return "stats/$carId/countries/$countryCode/regions?${params.joinToString("&")}" } } + data object WhereWasI : Screen("wherewasi/{carId}?timestamp={timestamp}&exteriorColor={exteriorColor}") { + fun createRoute(carId: Int, timestamp: String, exteriorColor: String? = null): String { + val encodedTimestamp = URLEncoder.encode(timestamp, StandardCharsets.UTF_8.toString()) + val params = mutableListOf("timestamp=$encodedTimestamp") + if (exteriorColor != null) params.add("exteriorColor=$exteriorColor") + return "wherewasi/$carId?${params.joinToString("&")}" + } + } } @Composable @@ -267,6 +276,9 @@ fun NavGraph( }, onNavigateToCurrentCharge = { carId, exteriorColor -> navController.navigate(Screen.CurrentCharge.createRoute(carId, exteriorColor)) + }, + onNavigateToWhereWasI = { carId, timestamp, exteriorColor -> + navController.navigate(Screen.WhereWasI.createRoute(carId, timestamp, exteriorColor)) } ) } @@ -567,5 +579,43 @@ fun NavGraph( onNavigateBack = { navController.popBackStack() } ) } + + composable( + route = Screen.WhereWasI.route, + arguments = listOf( + navArgument("carId") { type = NavType.IntType }, + navArgument("timestamp") { + type = NavType.StringType + defaultValue = "" + }, + navArgument("exteriorColor") { + type = NavType.StringType + nullable = true + defaultValue = null + } + ) + ) { backStackEntry -> + val carId = backStackEntry.arguments?.getInt("carId") ?: return@composable + val timestamp = backStackEntry.arguments?.getString("timestamp")?.let { + URLDecoder.decode(it, StandardCharsets.UTF_8.toString()) + } ?: return@composable + val exteriorColor = backStackEntry.arguments?.getString("exteriorColor") + + WhereWasIScreen( + carId = carId, + targetTimestamp = timestamp, + exteriorColor = exteriorColor, + onNavigateBack = { navController.popBackStack() }, + onNavigateToDriveDetail = { driveId -> + navController.navigate(Screen.DriveDetail.createRoute(carId, driveId, exteriorColor)) + }, + onNavigateToChargeDetail = { chargeId -> + navController.navigate(Screen.ChargeDetail.createRoute(carId, chargeId, exteriorColor)) + }, + onNavigateToCountriesVisited = { + navController.navigate(Screen.CountriesVisited.createRoute(carId, exteriorColor)) + } + ) + } } } diff --git a/app/src/main/java/com/matedroid/ui/screens/dashboard/DashboardScreen.kt b/app/src/main/java/com/matedroid/ui/screens/dashboard/DashboardScreen.kt index 1d11d1d..8c5cbfd 100644 --- a/app/src/main/java/com/matedroid/ui/screens/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/matedroid/ui/screens/dashboard/DashboardScreen.kt @@ -48,6 +48,8 @@ import androidx.compose.material.icons.filled.Circle import androidx.compose.material.icons.filled.Power import androidx.compose.material.icons.filled.Speed import androidx.compose.material.icons.filled.DriveEta +import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Terrain import androidx.compose.material.icons.filled.Thermostat import androidx.compose.material.icons.filled.Timeline @@ -55,7 +57,14 @@ import androidx.compose.material.icons.filled.WbSunny import com.matedroid.ui.icons.CustomIcons import androidx.compose.material.icons.filled.Timer import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.AssistChip import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.SelectableDates +import androidx.compose.material3.TimePicker +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberTimePickerState import androidx.compose.material3.PlainTooltip import androidx.compose.material3.TooltipBox import androidx.compose.material3.TooltipDefaults @@ -157,6 +166,7 @@ fun DashboardScreen( onNavigateToUpdates: (carId: Int, exteriorColor: String?) -> Unit = { _, _ -> }, onNavigateToStats: (carId: Int, exteriorColor: String?) -> Unit = { _, _ -> }, onNavigateToCurrentCharge: (carId: Int, exteriorColor: String?) -> Unit = { _, _ -> }, + onNavigateToWhereWasI: (carId: Int, timestamp: String, exteriorColor: String?) -> Unit = { _, _, _ -> }, viewModel: DashboardViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsState() @@ -258,6 +268,11 @@ fun DashboardScreen( }, onSaveCarImageOverride = { override -> viewModel.saveCarImageOverride(override) + }, + onNavigateToWhereWasI = { timestamp -> + uiState.selectedCarId?.let { carId -> + onNavigateToWhereWasI(carId, timestamp, uiState.selectedCarExterior?.exteriorColor) + } } ) } @@ -415,7 +430,8 @@ private fun DashboardContent( onNavigateToUpdates: () -> Unit = {}, onNavigateToStats: () -> Unit = {}, onNavigateToCurrentCharge: () -> Unit = {}, - onSaveCarImageOverride: (CarImageOverride?) -> Unit = {} + onSaveCarImageOverride: (CarImageOverride?) -> Unit = {}, + onNavigateToWhereWasI: (timestamp: String) -> Unit = {} ) { val isDarkTheme = isSystemInDarkTheme() val palette = CarColorPalettes.forExteriorColor(carExterior?.exteriorColor, isDarkTheme) @@ -471,7 +487,13 @@ private fun DashboardContent( // Location Section - show if we have coordinates if (status.latitude != null && status.longitude != null) { - LocationCard(status = status, units = units, resolvedAddress = resolvedAddress, palette = palette) + LocationCard( + status = status, + units = units, + resolvedAddress = resolvedAddress, + palette = palette, + onNavigateToWhereWasI = onNavigateToWhereWasI + ) } // Vehicle Info Card with navigation buttons @@ -1525,15 +1547,20 @@ private fun ChargingDetailsRow( } @Composable -private fun LocationCard(status: CarStatus, units: Units?, resolvedAddress: String? = null, palette: CarColorPalette) { +@OptIn(ExperimentalMaterial3Api::class) +private fun LocationCard( + status: CarStatus, + units: Units?, + resolvedAddress: String? = null, + palette: CarColorPalette, + onNavigateToWhereWasI: (timestamp: String) -> Unit = {} +) { val context = LocalContext.current val latitude = status.latitude val longitude = status.longitude val geofence = status.geofence val elevation = status.elevation - // Location text: geofence name if available, then resolved address, then coordinates - // Use takeIf to handle empty strings (API may return "" instead of null) val locationText = geofence?.takeIf { it.isNotBlank() } ?: resolvedAddress?.takeIf { it.isNotBlank() } ?: run { @@ -1552,83 +1579,189 @@ private fun LocationCard(status: CarStatus, units: Units?, resolvedAddress: Stri } } + // DateTimePicker state + var showDatePicker by remember { mutableStateOf(false) } + var showTimePicker by remember { mutableStateOf(false) } + val datePickerState = rememberDatePickerState( + selectableDates = object : SelectableDates { + override fun isSelectableDate(utcTimeMillis: Long): Boolean { + return utcTimeMillis <= System.currentTimeMillis() + } + } + ) + val timePickerState = rememberTimePickerState() + Card( - modifier = Modifier - .fillMaxWidth() - .clickable { openInMaps() }, - colors = CardDefaults.cardColors( - containerColor = palette.surface - ) + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = palette.surface) ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) { + Column(modifier = Modifier.fillMaxWidth()) { + // Location content (tappable to open maps) + Column( + modifier = Modifier + .fillMaxWidth() + .clickable { openInMaps() } + .padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top + ) { + Icon( + imageVector = Icons.Filled.LocationOn, + contentDescription = null, + tint = palette.accent + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.location), + style = MaterialTheme.typography.labelSmall, + color = palette.onSurfaceVariant + ) + Text( + text = locationText, + style = MaterialTheme.typography.titleMedium, + color = palette.onSurface + ) + } + + if (latitude != null && longitude != null) { + Spacer(modifier = Modifier.width(12.dp)) + SmallLocationMap( + latitude = latitude, + longitude = longitude, + onClick = { openInMaps() }, + modifier = Modifier + .width(140.dp) + .height(70.dp) + .clip(RoundedCornerShape(8.dp)) + ) + } + } + + if (elevation != null) { + Spacer(modifier = Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Filled.Terrain, + contentDescription = null, + tint = palette.accent + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = stringResource(R.string.elevation), + style = MaterialTheme.typography.labelSmall, + color = palette.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = UnitFormatter.formatElevation(elevation, units), + style = MaterialTheme.typography.bodyMedium, + color = palette.onSurface, + maxLines = 1, + softWrap = false + ) + } + } + } + + // Divider + "Where was I that day?" entrypoint + HorizontalDivider(color = palette.onSurfaceVariant.copy(alpha = 0.2f)) + Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.Top + modifier = Modifier + .fillMaxWidth() + .clickable { showDatePicker = true } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically ) { Icon( - imageVector = Icons.Filled.LocationOn, + imageVector = Icons.Filled.History, contentDescription = null, - tint = palette.accent + tint = palette.onSurfaceVariant, + modifier = Modifier.size(20.dp) ) Spacer(modifier = Modifier.width(12.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource(R.string.location), - style = MaterialTheme.typography.labelSmall, - color = palette.onSurfaceVariant - ) - Text( - text = locationText, - style = MaterialTheme.typography.titleMedium, - color = palette.onSurface - ) - } + Text( + text = stringResource(R.string.where_was_i_title), + style = MaterialTheme.typography.bodyMedium, + color = palette.onSurface, + modifier = Modifier.weight(1f) + ) + AssistChip( + onClick = { showDatePicker = true }, + label = { + Text( + text = stringResource(R.string.where_was_i_hint), + style = MaterialTheme.typography.labelSmall + ) + }, + leadingIcon = { + Icon( + Icons.Default.CalendarMonth, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + } + ) + } + } + } - // Small map showing car location - if (latitude != null && longitude != null) { - Spacer(modifier = Modifier.width(12.dp)) - SmallLocationMap( - latitude = latitude, - longitude = longitude, - onClick = { openInMaps() }, - modifier = Modifier - .width(140.dp) - .height(70.dp) - .clip(RoundedCornerShape(8.dp)) - ) + // Date picker dialog + if (showDatePicker) { + DatePickerDialog( + onDismissRequest = { showDatePicker = false }, + confirmButton = { + TextButton(onClick = { + showDatePicker = false + showTimePicker = true + }) { + Text(stringResource(R.string.where_was_i_go)) + } + }, + dismissButton = { + TextButton(onClick = { showDatePicker = false }) { + Text(stringResource(android.R.string.cancel)) } } + ) { + DatePicker(state = datePickerState) + } + } - // Elevation row - icon aligned with location icon, text aligned with location text - if (elevation != null) { - Spacer(modifier = Modifier.height(8.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = Icons.Filled.Terrain, - contentDescription = null, - tint = palette.accent - ) - Spacer(modifier = Modifier.width(12.dp)) - Text( - text = stringResource(R.string.elevation), - style = MaterialTheme.typography.labelSmall, - color = palette.onSurfaceVariant - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = UnitFormatter.formatElevation(elevation, units), - style = MaterialTheme.typography.bodyMedium, - color = palette.onSurface, - maxLines = 1, - softWrap = false + // Time picker dialog + if (showTimePicker) { + AlertDialog( + onDismissRequest = { showTimePicker = false }, + confirmButton = { + TextButton(onClick = { + showTimePicker = false + val selectedMillis = datePickerState.selectedDateMillis ?: return@TextButton + val selectedDate = java.time.Instant.ofEpochMilli(selectedMillis) + .atZone(java.time.ZoneId.systemDefault()) + .toLocalDate() + val timestamp = "%sT%02d:%02d:00Z".format( + selectedDate.format(java.time.format.DateTimeFormatter.ISO_LOCAL_DATE), + timePickerState.hour, + timePickerState.minute ) + onNavigateToWhereWasI(timestamp) + }) { + Text(stringResource(R.string.where_was_i_go)) + } + }, + dismissButton = { + TextButton(onClick = { showTimePicker = false }) { + Text(stringResource(android.R.string.cancel)) } + }, + title = { Text(stringResource(R.string.where_was_i_pick_time)) }, + text = { + TimePicker(state = timePickerState) } - } + ) } } diff --git a/app/src/main/java/com/matedroid/ui/screens/wherewasi/WhereWasIScreen.kt b/app/src/main/java/com/matedroid/ui/screens/wherewasi/WhereWasIScreen.kt new file mode 100644 index 0000000..e3cb9fa --- /dev/null +++ b/app/src/main/java/com/matedroid/ui/screens/wherewasi/WhereWasIScreen.kt @@ -0,0 +1,520 @@ +package com.matedroid.ui.screens.wherewasi + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +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.fillMaxSize +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.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.BatteryChargingFull +import androidx.compose.material.icons.filled.Bolt +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.DirectionsCar +import androidx.compose.material.icons.filled.EvStation +import androidx.compose.material.icons.filled.LocalParking +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.hilt.navigation.compose.hiltViewModel +import com.matedroid.R +import com.matedroid.data.repository.WeatherCondition +import com.matedroid.data.repository.countryCodeToFlag +import com.matedroid.domain.model.UnitFormatter +import com.matedroid.ui.icons.CustomIcons +import com.matedroid.ui.theme.CarColorPalettes +import org.osmdroid.config.Configuration +import org.osmdroid.tileprovider.tilesource.TileSourceFactory +import org.osmdroid.util.GeoPoint +import org.osmdroid.views.MapView + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WhereWasIScreen( + carId: Int, + targetTimestamp: String, + exteriorColor: String? = null, + onNavigateBack: () -> Unit, + onNavigateToDriveDetail: (driveId: Int) -> Unit, + onNavigateToChargeDetail: (chargeId: Int) -> Unit, + onNavigateToCountriesVisited: () -> Unit, + viewModel: WhereWasIViewModel = hiltViewModel() +) { + val isDarkTheme = isSystemInDarkTheme() + val palette = CarColorPalettes.forExteriorColor(exteriorColor, isDarkTheme) + val state by viewModel.uiState.collectAsState() + + LaunchedEffect(carId, targetTimestamp) { + viewModel.load(carId, targetTimestamp) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.where_was_i_screen_title)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + } + ) { paddingValues -> + when { + state.isLoading -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + state.error == "no_data" -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + Icons.Default.Schedule, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.where_was_i_no_data), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + state.targetDateTime?.let { dt -> + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = dt, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } + } + } + } + state.error != null -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center + ) { + Text( + text = state.error ?: "", + color = MaterialTheme.colorScheme.error + ) + } + } + else -> { + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // DateTime header + state.targetDateTime?.let { dt -> + Card( + colors = CardDefaults.cardColors(containerColor = palette.surface) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Schedule, + contentDescription = null, + tint = palette.accent + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = dt, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = palette.onSurface + ) + } + } + } + + // Map + val lat = state.latitude + val lon = state.longitude + if (lat != null && lon != null) { + val context = LocalContext.current + Card( + colors = CardDefaults.cardColors(containerColor = palette.surface), + modifier = Modifier.clickable { + val geoUri = Uri.parse("geo:$lat,$lon?q=$lat,$lon") + context.startActivity(Intent(Intent.ACTION_VIEW, geoUri)) + } + ) { + AndroidView( + factory = { ctx -> + Configuration.getInstance().userAgentValue = "MateDroid/1.0" + MapView(ctx).apply { + setTileSource(TileSourceFactory.MAPNIK) + setMultiTouchControls(false) + controller.setZoom(15.0) + controller.setCenter(GeoPoint(lat, lon)) + val marker = org.osmdroid.views.overlay.Marker(this) + marker.position = GeoPoint(lat, lon) + marker.setAnchor(org.osmdroid.views.overlay.Marker.ANCHOR_CENTER, org.osmdroid.views.overlay.Marker.ANCHOR_BOTTOM) + overlays.add(marker) + } + }, + modifier = Modifier + .fillMaxWidth() + .height(250.dp) + .clip(RoundedCornerShape(12.dp)) + ) + } + } + + // Location breadcrumb + state.location?.let { loc -> + Card( + colors = CardDefaults.cardColors(containerColor = palette.surface) + ) { + Column(modifier = Modifier.padding(16.dp)) { + // Country (tappable) + loc.countryCode?.let { code -> + val flag = countryCodeToFlag(code) + val countryName = loc.countryName ?: code + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { onNavigateToCountriesVisited() } + .padding(vertical = 4.dp) + ) { + Text(text = flag, fontSize = 20.sp) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = countryName, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = palette.accent, + modifier = Modifier.weight(1f) + ) + Icon( + Icons.Default.ChevronRight, + contentDescription = null, + tint = palette.onSurfaceVariant, + modifier = Modifier.size(20.dp) + ) + } + } + // Region + loc.regionName?.let { region -> + Text( + text = region, + style = MaterialTheme.typography.bodyMedium, + color = palette.onSurface, + modifier = Modifier.padding(start = 28.dp, top = 2.dp) + ) + } + // City / address + loc.city?.let { city -> + Text( + text = city, + style = MaterialTheme.typography.bodySmall, + color = palette.onSurfaceVariant, + modifier = Modifier.padding(start = 28.dp, top = 2.dp) + ) + } + loc.address?.let { addr -> + if (addr != loc.city) { + Text( + text = addr, + style = MaterialTheme.typography.bodySmall, + color = palette.onSurfaceVariant, + modifier = Modifier.padding(start = 28.dp, top = 2.dp) + ) + } + } + } + } + } + + // State card + state.carState?.let { carState -> + val isClickable = carState == CarActivityState.DRIVING || carState == CarActivityState.CHARGING + Card( + colors = CardDefaults.cardColors(containerColor = palette.surface), + modifier = if (isClickable) { + Modifier + .fillMaxWidth() + .clickable { + when (carState) { + CarActivityState.DRIVING -> state.driveId?.let { onNavigateToDriveDetail(it) } + CarActivityState.CHARGING -> state.chargeId?.let { onNavigateToChargeDetail(it) } + else -> {} + } + } + } else Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(16.dp)) { + // State icon + label (centered) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = stateIcon(carState), + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = palette.accent + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = stateLabel(carState), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = palette.onSurface + ) + } + + HorizontalDivider( + modifier = Modifier.padding(vertical = 12.dp), + color = palette.onSurfaceVariant.copy(alpha = 0.2f) + ) + + // Info rows — shared info first, then state-specific + // Row 1: Odometer | Outside Temp + Row(modifier = Modifier.fillMaxWidth()) { + InfoItem( + label = stringResource(R.string.mileage_title), + value = state.odometer?.let { + UnitFormatter.formatDistance(it, state.units) + } ?: "—", + palette = palette, + modifier = Modifier.weight(1f) + ) + InfoItem( + label = stringResource(R.string.outside_temp), + value = state.outsideTemp?.let { + UnitFormatter.formatTemperature(it, state.units) + } ?: "—", + palette = palette, + modifier = Modifier.weight(1f) + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Row 2-3: State-specific + when (carState) { + CarActivityState.DRIVING -> { + Row(modifier = Modifier.fillMaxWidth()) { + InfoItem( + label = stringResource(R.string.speed_profile).split(" ").first(), + value = state.speed?.let { "$it ${UnitFormatter.getSpeedUnit(state.units)}" } ?: "—", + palette = palette, + modifier = Modifier.weight(1f) + ) + InfoItem( + label = stringResource(R.string.distance), + value = state.driveDistance?.let { + UnitFormatter.formatDistance(it, state.units) + } ?: "—", + palette = palette, + modifier = Modifier.weight(1f) + ) + } + } + CarActivityState.CHARGING -> { + Row(modifier = Modifier.fillMaxWidth()) { + InfoItem( + label = stringResource(R.string.battery_level), + value = state.batteryLevel?.let { "$it%" } ?: "—", + palette = palette, + modifier = Modifier.weight(1f) + ) + InfoItem( + label = stringResource(R.string.power_profile).split(" ").first(), + value = state.chargerPower?.let { "$it kW" } ?: "—", + palette = palette, + modifier = Modifier.weight(1f) + ) + } + } + CarActivityState.PARKED -> { /* No additional rows */ } + } + + // Tap hint + if (isClickable) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.where_was_i_tap_details), + style = MaterialTheme.typography.labelSmall, + color = palette.accent, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + } + + // Weather card + if (state.weatherCondition != null && state.weatherTemperature != null) { + Card( + colors = CardDefaults.cardColors(containerColor = palette.surface) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = weatherIcon(state.weatherCondition!!), + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = weatherIconColor(state.weatherCondition!!) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = "%.1f\u00B0C".format(state.weatherTemperature), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = palette.onSurface + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = weatherDescription(state.weatherCondition!!), + style = MaterialTheme.typography.bodyMedium, + color = palette.onSurfaceVariant + ) + } + } + } + } + } + } + } +} + +@Composable +private fun InfoItem( + label: String, + value: String, + palette: com.matedroid.ui.theme.CarColorPalette, + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = palette.onSurfaceVariant + ) + Text( + text = value, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + color = palette.onSurface + ) + } +} + +@Composable +private fun stateLabel(state: CarActivityState): String = when (state) { + CarActivityState.DRIVING -> stringResource(R.string.where_was_i_driving) + CarActivityState.CHARGING -> stringResource(R.string.where_was_i_charging) + CarActivityState.PARKED -> stringResource(R.string.where_was_i_parked) +} + +private fun stateIcon(state: CarActivityState): ImageVector = when (state) { + CarActivityState.DRIVING -> Icons.Default.DirectionsCar + CarActivityState.CHARGING -> Icons.Default.EvStation + CarActivityState.PARKED -> Icons.Default.LocalParking +} + +private fun weatherIcon(condition: WeatherCondition): ImageVector = 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 +} + +private fun weatherIconColor(condition: WeatherCondition): Color = when (condition) { + WeatherCondition.CLEAR -> Color(0xFFFFC107) + WeatherCondition.PARTLY_CLOUDY -> Color(0xFF78909C) + WeatherCondition.FOG -> Color(0xFF90A4AE) + WeatherCondition.DRIZZLE -> Color(0xFF64B5F6) + WeatherCondition.RAIN -> Color(0xFF1E88E5) + WeatherCondition.SNOW -> Color(0xFF42A5F5) + WeatherCondition.THUNDERSTORM -> Color(0xFF7E57C2) +} + +@Composable +private fun weatherDescription(condition: WeatherCondition): String = when (condition) { + WeatherCondition.CLEAR -> stringResource(R.string.weather_clear) + WeatherCondition.PARTLY_CLOUDY -> stringResource(R.string.weather_partly_cloudy) + WeatherCondition.FOG -> stringResource(R.string.weather_fog) + WeatherCondition.DRIZZLE -> stringResource(R.string.weather_drizzle) + WeatherCondition.RAIN -> stringResource(R.string.weather_rain) + WeatherCondition.SNOW -> stringResource(R.string.weather_snow) + WeatherCondition.THUNDERSTORM -> stringResource(R.string.weather_thunderstorm) +} diff --git a/app/src/main/java/com/matedroid/ui/screens/wherewasi/WhereWasIViewModel.kt b/app/src/main/java/com/matedroid/ui/screens/wherewasi/WhereWasIViewModel.kt new file mode 100644 index 0000000..6fd580f --- /dev/null +++ b/app/src/main/java/com/matedroid/ui/screens/wherewasi/WhereWasIViewModel.kt @@ -0,0 +1,361 @@ +package com.matedroid.ui.screens.wherewasi + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.matedroid.data.api.OpenMeteoApi +import com.matedroid.data.api.models.ChargeData +import com.matedroid.data.api.models.DriveData +import com.matedroid.data.api.models.Units +import com.matedroid.data.repository.ApiResult +import com.matedroid.data.repository.GeocodedLocation +import com.matedroid.data.repository.GeocodingRepository +import com.matedroid.data.repository.TeslamateRepository +import com.matedroid.data.repository.WeatherCondition +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeParseException +import javax.inject.Inject + +enum class CarActivityState { DRIVING, CHARGING, PARKED } + +data class WhereWasIUiState( + val isLoading: Boolean = true, + val error: String? = null, + val carState: CarActivityState? = null, + val latitude: Double? = null, + val longitude: Double? = null, + val location: GeocodedLocation? = null, + val weatherCondition: WeatherCondition? = null, + val weatherTemperature: Double? = null, + val odometer: Double? = null, + val outsideTemp: Double? = null, + val units: Units? = null, + // Driving-specific + val driveId: Int? = null, + val speed: Int? = null, + val driveDistance: Double? = null, + // Charging-specific + val chargeId: Int? = null, + val batteryLevel: Int? = null, + val chargerPower: Int? = null, + // Display + val targetDateTime: String? = null +) + +@HiltViewModel +class WhereWasIViewModel @Inject constructor( + private val repository: TeslamateRepository, + private val geocodingRepository: GeocodingRepository, + private val openMeteoApi: OpenMeteoApi +) : ViewModel() { + + private val _uiState = MutableStateFlow(WhereWasIUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var loaded = false + + fun load(carId: Int, timestamp: String) { + if (loaded) return + loaded = true + + viewModelScope.launch { + try { + val targetTime = parseDateTime(timestamp) ?: run { + _uiState.value = WhereWasIUiState(isLoading = false, error = "Invalid date") + return@launch + } + + val displayFormatter = DateTimeFormatter.ofPattern("dd MMM yyyy, HH:mm") + _uiState.value = _uiState.value.copy(targetDateTime = targetTime.format(displayFormatter)) + + val dateStr = targetTime.format(DateTimeFormatter.ISO_LOCAL_DATE) + val dayStart = "${dateStr}T00:00:00Z" + val dayEnd = "${dateStr}T23:59:59Z" + + // Fetch drives, charges, and units in parallel + val drivesDeferred = async { repository.getDrives(carId, dayStart, dayEnd) } + val chargesDeferred = async { repository.getCharges(carId, dayStart, dayEnd) } + val statusDeferred = async { repository.getCarStatus(carId) } + + val drives = when (val r = drivesDeferred.await()) { + is ApiResult.Success -> r.data + is ApiResult.Error -> emptyList() + } + val charges = when (val r = chargesDeferred.await()) { + is ApiResult.Success -> r.data + is ApiResult.Error -> emptyList() + } + val units = when (val r = statusDeferred.await()) { + is ApiResult.Success -> r.data.units + is ApiResult.Error -> null + } + + // Determine state + val activeDrive = findActiveDrive(drives, targetTime) + val activeCharge = findActiveCharge(charges, targetTime) + + when { + activeDrive != null -> handleDriving(carId, activeDrive, targetTime, units) + activeCharge != null -> handleCharging(carId, activeCharge, targetTime, units) + else -> handleParked(carId, drives, charges, targetTime, units, dayStart) + } + } catch (e: Exception) { + Log.e(TAG, "Error loading where-was-i data", e) + _uiState.value = WhereWasIUiState(isLoading = false, error = e.message) + } + } + } + + private suspend fun handleDriving(carId: Int, drive: DriveData, targetTime: LocalDateTime, units: Units?) { + // Get drive detail for position interpolation + val detail = when (val r = repository.getDriveDetail(carId, drive.driveId)) { + is ApiResult.Success -> r.data + is ApiResult.Error -> null + } + + val positions = detail?.positions + val nearest = positions?.minByOrNull { pos -> + val posTime = parseDateTime(pos.date ?: "") ?: LocalDateTime.MAX + kotlin.math.abs(java.time.Duration.between(posTime, targetTime).seconds) + } + + val lat = nearest?.latitude ?: parseEndLatFromAddress(drive) + val lon = nearest?.longitude ?: parseEndLonFromAddress(drive) + + _uiState.value = WhereWasIUiState( + isLoading = false, + carState = CarActivityState.DRIVING, + latitude = lat, + longitude = lon, + odometer = nearest?.let { detail?.positions?.lastOrNull()?.let { p -> null } } ?: drive.odometerDetails?.odometerEnd, + outsideTemp = nearest?.outsideTemp?.toDouble() ?: drive.outsideTempAvg, + speed = nearest?.speed, + driveId = drive.driveId, + driveDistance = drive.distance, + units = units, + targetDateTime = _uiState.value.targetDateTime + ) + + // Fetch geocoding and weather in background + fetchGeocodingAndWeather(lat, lon, targetTime) + } + + private suspend fun handleCharging(carId: Int, charge: ChargeData, targetTime: LocalDateTime, units: Units?) { + val lat = charge.latitude + val lon = charge.longitude + + // Get charge detail for instant power and battery level + val detail = when (val r = repository.getChargeDetail(carId, charge.chargeId)) { + is ApiResult.Success -> r.data + is ApiResult.Error -> null + } + + val nearestPoint = detail?.chargePoints?.minByOrNull { point -> + val pointTime = parseDateTime(point.date ?: "") ?: LocalDateTime.MAX + kotlin.math.abs(java.time.Duration.between(pointTime, targetTime).seconds) + } + + _uiState.value = WhereWasIUiState( + isLoading = false, + carState = CarActivityState.CHARGING, + latitude = lat, + longitude = lon, + odometer = charge.odometer, + outsideTemp = nearestPoint?.outsideTemp ?: charge.outsideTempAvg, + batteryLevel = nearestPoint?.batteryLevel ?: charge.startBatteryLevel, + chargerPower = nearestPoint?.chargerPower, + chargeId = charge.chargeId, + units = units, + targetDateTime = _uiState.value.targetDateTime + ) + + fetchGeocodingAndWeather(lat, lon, targetTime) + } + + private suspend fun handleParked( + carId: Int, + drives: List, + charges: List, + targetTime: LocalDateTime, + units: Units?, + dayStart: String + ) { + // Find the nearest activity to determine position + data class ActivityEnd(val lat: Double?, val lon: Double?, val odometer: Double?, val temp: Double?, val endTime: LocalDateTime) + + val activityEnds = mutableListOf() + + // Drive end positions + for (drive in drives) { + val endTime = parseDateTime(drive.endDate ?: "") ?: continue + if (endTime <= targetTime) { + // Get last position from drive detail for lat/lon + val detail = when (val r = repository.getDriveDetail(carId, drive.driveId)) { + is ApiResult.Success -> r.data + is ApiResult.Error -> null + } + val lastPos = detail?.positions?.lastOrNull() + activityEnds.add(ActivityEnd( + lat = lastPos?.latitude, + lon = lastPos?.longitude, + odometer = drive.odometerDetails?.odometerEnd, + temp = drive.outsideTempAvg, + endTime = endTime + )) + } + } + + // Charge locations (already have lat/lon) + for (charge in charges) { + val endTime = parseDateTime(charge.endDate ?: "") ?: continue + if (endTime <= targetTime) { + activityEnds.add(ActivityEnd( + lat = charge.latitude, + lon = charge.longitude, + odometer = charge.odometer, + temp = charge.outsideTempAvg, + endTime = endTime + )) + } + } + + // Find the most recent activity end before target time + val nearest = activityEnds.maxByOrNull { it.endTime } + + // If nothing before target time on this day, try the first activity after + val fallback = if (nearest == null) { + val firstDriveAfter = drives.firstOrNull { d -> + val st = parseDateTime(d.startDate ?: "") + st != null && st > targetTime + } + if (firstDriveAfter != null) { + val detail = when (val r = repository.getDriveDetail(carId, firstDriveAfter.driveId)) { + is ApiResult.Success -> r.data + is ApiResult.Error -> null + } + val firstPos = detail?.positions?.firstOrNull() + ActivityEnd(firstPos?.latitude, firstPos?.longitude, firstDriveAfter.odometerDetails?.odometerStart, firstDriveAfter.outsideTempAvg, targetTime) + } else { + val firstChargeAfter = charges.firstOrNull { c -> + val st = parseDateTime(c.startDate ?: "") + st != null && st > targetTime + } + firstChargeAfter?.let { + ActivityEnd(it.latitude, it.longitude, it.odometer, it.outsideTempAvg, targetTime) + } + } + } else null + + val result = nearest ?: fallback + + if (result?.lat == null || result.lon == null) { + _uiState.value = WhereWasIUiState( + isLoading = false, + error = "no_data", + units = units, + targetDateTime = _uiState.value.targetDateTime + ) + return + } + + _uiState.value = WhereWasIUiState( + isLoading = false, + carState = CarActivityState.PARKED, + latitude = result.lat, + longitude = result.lon, + odometer = result.odometer, + outsideTemp = result.temp, + units = units, + targetDateTime = _uiState.value.targetDateTime + ) + + fetchGeocodingAndWeather(result.lat, result.lon, targetTime) + } + + private suspend fun fetchGeocodingAndWeather(lat: Double?, lon: Double?, targetTime: LocalDateTime) { + if (lat == null || lon == null) return + + // Reverse geocode + try { + val location = geocodingRepository.reverseGeocodeWithCountry(lat, lon) + _uiState.value = _uiState.value.copy(location = location) + } catch (e: Exception) { + Log.w(TAG, "Geocoding failed", e) + } + + // Fetch weather + try { + val dateStr = targetTime.format(DateTimeFormatter.ISO_LOCAL_DATE) + val response = openMeteoApi.getHistoricalWeather( + latitude = lat, + longitude = lon, + startDate = dateStr, + endDate = dateStr + ) + if (response.isSuccessful) { + val hourly = response.body()?.hourly + val hour = targetTime.hour + val timeIndex = hourly?.time?.indexOfFirst { timeStr -> + try { + LocalDateTime.parse(timeStr).hour == hour + } catch (e: Exception) { false } + } ?: -1 + + if (timeIndex >= 0) { + val temp = hourly?.temperature2m?.getOrNull(timeIndex) + val code = hourly?.weatherCode?.getOrNull(timeIndex) ?: 0 + _uiState.value = _uiState.value.copy( + weatherTemperature = temp, + weatherCondition = WeatherCondition.fromWmoCode(code) + ) + } + } + } catch (e: Exception) { + Log.w(TAG, "Weather fetch failed", e) + } + } + + private fun findActiveDrive(drives: List, targetTime: LocalDateTime): DriveData? { + return drives.firstOrNull { drive -> + val start = parseDateTime(drive.startDate ?: "") ?: return@firstOrNull false + val end = parseDateTime(drive.endDate ?: "") ?: return@firstOrNull false + !targetTime.isBefore(start) && !targetTime.isAfter(end) + } + } + + private fun findActiveCharge(charges: List, targetTime: LocalDateTime): ChargeData? { + return charges.firstOrNull { charge -> + val start = parseDateTime(charge.startDate ?: "") ?: return@firstOrNull false + val end = parseDateTime(charge.endDate ?: "") ?: return@firstOrNull false + !targetTime.isBefore(start) && !targetTime.isAfter(end) + } + } + + private fun parseDateTime(dateStr: String): LocalDateTime? { + if (dateStr.isBlank()) return null + return try { + OffsetDateTime.parse(dateStr).toLocalDateTime() + } catch (e: DateTimeParseException) { + try { + LocalDateTime.parse(dateStr.replace("Z", "")) + } catch (e2: Exception) { + null + } + } + } + + private fun parseEndLatFromAddress(drive: DriveData): Double? = null + private fun parseEndLonFromAddress(drive: DriveData): Double? = null + + companion object { + private const val TAG = "WhereWasIViewModel" + } +} diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index a926804..fcfbdbe 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -1082,4 +1082,17 @@ Mantingues-te informat MateDroid pot avisar-te sobre esdeveniments del mode sentinella, avisos de pressió dels pneumàtics i estat de càrrega. Activa les notificacions per estar al dia sobre el teu cotxe. Activar notificacions + + + On era aquell dia? + Tria una data + On era? + Conduint + Carregant + Aparcat + Anar! + Tria una data + Tria una hora + Toca per veure detalls + No hi ha dades disponibles per a aquesta data diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 9f0f637..92aaa01 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1082,4 +1082,17 @@ Mantente informado MateDroid puede notificarte sobre eventos del modo centinela, avisos de presión de neumáticos y estado de carga. Activa las notificaciones para estar al día sobre tu coche. Activar notificaciones + + + Donde estaba ese dia? + Elige una fecha + Donde estaba? + Conduciendo + Cargando + Aparcado + Ir! + Elige una fecha + Elige una hora + Toca para ver detalles + No hay datos disponibles para esta fecha diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 2f14e9b..f6cbb2f 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1082,4 +1082,17 @@ Resta informato MateDroid può avvisarti su eventi della modalità sentinella, avvisi di pressione pneumatici e stato di ricarica. Abilita le notifiche per restare aggiornato sulla tua auto. Abilita notifiche + + + Dove ero quel giorno? + Scegli una data + Dove ero? + In marcia + In ricarica + Parcheggiato + Vai! + Scegli una data + Scegli un orario + Tocca per dettagli + Nessun dato disponibile per questa data diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 58c3615..d814891 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1096,4 +1096,28 @@ MateDroid can notify you about sentry mode events, tyre pressure warnings, and charging status. Enable notifications to stay informed about your car. Enable notifications + + + + Where was I that day? + + Pick a date + + Where was I? + + Driving + + Charging + + Parked + + Go! + + Pick a date + + Pick a time + + Tap for details + + No data available for this date From 737b145643980f2458c32aaca93a4fc36627ca93 Mon Sep 17 00:00:00 2001 From: Davide Ferrari Date: Fri, 20 Mar 2026 17:52:16 +0100 Subject: [PATCH 2/4] fix: improve Where Was I screen based on feedback - Time picker dialog title now shows the chosen date - Location breadcrumb is now a single line: flag Country > Region > City with country tappable in accent color - Geofence name (from TeslaMate) displayed prominently below breadcrumb, falling back to Nominatim address if no geofence - Replace "Tap for details" text with standard > chevron hint - Add geofenceName to WhereWasIUiState, populated from drive/charge address Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ui/screens/dashboard/DashboardScreen.kt | 11 +- .../ui/screens/wherewasi/WhereWasIScreen.kt | 115 +++++++++--------- .../screens/wherewasi/WhereWasIViewModel.kt | 13 +- 3 files changed, 75 insertions(+), 64 deletions(-) diff --git a/app/src/main/java/com/matedroid/ui/screens/dashboard/DashboardScreen.kt b/app/src/main/java/com/matedroid/ui/screens/dashboard/DashboardScreen.kt index 8c5cbfd..13abc5f 100644 --- a/app/src/main/java/com/matedroid/ui/screens/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/matedroid/ui/screens/dashboard/DashboardScreen.kt @@ -1757,7 +1757,16 @@ private fun LocationCard( Text(stringResource(android.R.string.cancel)) } }, - title = { Text(stringResource(R.string.where_was_i_pick_time)) }, + title = { + val selectedMillis = datePickerState.selectedDateMillis + val dateText = if (selectedMillis != null) { + val date = java.time.Instant.ofEpochMilli(selectedMillis) + .atZone(java.time.ZoneId.systemDefault()) + .toLocalDate() + date.format(java.time.format.DateTimeFormatter.ofLocalizedDate(java.time.format.FormatStyle.MEDIUM)) + } else "" + Text(dateText) + }, text = { TimePicker(state = timePickerState) } diff --git a/app/src/main/java/com/matedroid/ui/screens/wherewasi/WhereWasIScreen.kt b/app/src/main/java/com/matedroid/ui/screens/wherewasi/WhereWasIScreen.kt index e3cb9fa..cc82d8a 100644 --- a/app/src/main/java/com/matedroid/ui/screens/wherewasi/WhereWasIScreen.kt +++ b/app/src/main/java/com/matedroid/ui/screens/wherewasi/WhereWasIScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.BatteryChargingFull import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.ChevronRight @@ -227,68 +228,65 @@ fun WhereWasIScreen( } } - // Location breadcrumb + // Location breadcrumb + address state.location?.let { loc -> Card( colors = CardDefaults.cardColors(containerColor = palette.surface) ) { Column(modifier = Modifier.padding(16.dp)) { - // Country (tappable) - loc.countryCode?.let { code -> - val flag = countryCodeToFlag(code) - val countryName = loc.countryName ?: code - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .clickable { onNavigateToCountriesVisited() } - .padding(vertical = 4.dp) - ) { - Text(text = flag, fontSize = 20.sp) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = countryName, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - color = palette.accent, - modifier = Modifier.weight(1f) - ) - Icon( - Icons.Default.ChevronRight, - contentDescription = null, - tint = palette.onSurfaceVariant, - modifier = Modifier.size(20.dp) - ) + // Breadcrumb line: flag Country > Region > City + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + loc.countryCode?.let { code -> + val flag = countryCodeToFlag(code) + Text(text = flag, fontSize = 16.sp) + Spacer(modifier = Modifier.width(4.dp)) } - } - // Region - loc.regionName?.let { region -> - Text( - text = region, - style = MaterialTheme.typography.bodyMedium, - color = palette.onSurface, - modifier = Modifier.padding(start = 28.dp, top = 2.dp) + val breadcrumbParts = listOfNotNull( + loc.countryName ?: loc.countryCode, + loc.regionName, + loc.city ) + breadcrumbParts.forEachIndexed { index, part -> + if (index == 0 && loc.countryCode != null) { + // Country is tappable + Text( + text = part, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = palette.accent, + modifier = Modifier.clickable { onNavigateToCountriesVisited() } + ) + } else { + Text( + text = part, + style = MaterialTheme.typography.bodyMedium, + color = palette.onSurfaceVariant + ) + } + if (index < breadcrumbParts.lastIndex) { + Text( + text = " > ", + style = MaterialTheme.typography.bodyMedium, + color = palette.onSurfaceVariant.copy(alpha = 0.5f) + ) + } + } } - // City / address - loc.city?.let { city -> + + // Geofence name or full address (bigger) + val displayAddress = state.geofenceName + ?: loc.address + if (displayAddress != null) { + Spacer(modifier = Modifier.height(8.dp)) Text( - text = city, - style = MaterialTheme.typography.bodySmall, - color = palette.onSurfaceVariant, - modifier = Modifier.padding(start = 28.dp, top = 2.dp) + text = displayAddress, + style = MaterialTheme.typography.titleMedium, + color = palette.onSurface ) } - loc.address?.let { addr -> - if (addr != loc.city) { - Text( - text = addr, - style = MaterialTheme.typography.bodySmall, - color = palette.onSurfaceVariant, - modifier = Modifier.padding(start = 28.dp, top = 2.dp) - ) - } - } } } } @@ -399,15 +397,16 @@ fun WhereWasIScreen( CarActivityState.PARKED -> { /* No additional rows */ } } - // Tap hint + // Chevron hint for tappable cards if (isClickable) { Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(R.string.where_was_i_tap_details), - style = MaterialTheme.typography.labelSmall, - color = palette.accent, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = palette.onSurfaceVariant, + modifier = Modifier + .size(24.dp) + .align(Alignment.End) ) } } diff --git a/app/src/main/java/com/matedroid/ui/screens/wherewasi/WhereWasIViewModel.kt b/app/src/main/java/com/matedroid/ui/screens/wherewasi/WhereWasIViewModel.kt index 6fd580f..ea9642c 100644 --- a/app/src/main/java/com/matedroid/ui/screens/wherewasi/WhereWasIViewModel.kt +++ b/app/src/main/java/com/matedroid/ui/screens/wherewasi/WhereWasIViewModel.kt @@ -47,7 +47,8 @@ data class WhereWasIUiState( val batteryLevel: Int? = null, val chargerPower: Int? = null, // Display - val targetDateTime: String? = null + val targetDateTime: String? = null, + val geofenceName: String? = null ) @HiltViewModel @@ -135,13 +136,14 @@ class WhereWasIViewModel @Inject constructor( carState = CarActivityState.DRIVING, latitude = lat, longitude = lon, - odometer = nearest?.let { detail?.positions?.lastOrNull()?.let { p -> null } } ?: drive.odometerDetails?.odometerEnd, - outsideTemp = nearest?.outsideTemp?.toDouble() ?: drive.outsideTempAvg, + odometer = drive.odometerDetails?.odometerEnd, + outsideTemp = nearest?.outsideTemp ?: drive.outsideTempAvg, speed = nearest?.speed, driveId = drive.driveId, driveDistance = drive.distance, units = units, - targetDateTime = _uiState.value.targetDateTime + targetDateTime = _uiState.value.targetDateTime, + geofenceName = drive.endAddress ?: drive.startAddress ) // Fetch geocoding and weather in background @@ -174,7 +176,8 @@ class WhereWasIViewModel @Inject constructor( chargerPower = nearestPoint?.chargerPower, chargeId = charge.chargeId, units = units, - targetDateTime = _uiState.value.targetDateTime + targetDateTime = _uiState.value.targetDateTime, + geofenceName = charge.address ) fetchGeocodingAndWeather(lat, lon, targetTime) From 63b4f363d286fa1d8b23b8901c0ae7d1917a91d2 Mon Sep 17 00:00:00 2001 From: Davide Ferrari Date: Fri, 20 Mar 2026 17:56:11 +0100 Subject: [PATCH 3/4] fix: use locale-aware country name in Where Was I breadcrumb Use Locale.getDisplayCountry() to show the country name in the device's current locale, matching the behavior of the Stats for Nerds countries visited page. Previously showed the Nominatim-provided name which is in the country's own language. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../com/matedroid/ui/screens/wherewasi/WhereWasIScreen.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/matedroid/ui/screens/wherewasi/WhereWasIScreen.kt b/app/src/main/java/com/matedroid/ui/screens/wherewasi/WhereWasIScreen.kt index cc82d8a..db71243 100644 --- a/app/src/main/java/com/matedroid/ui/screens/wherewasi/WhereWasIScreen.kt +++ b/app/src/main/java/com/matedroid/ui/screens/wherewasi/WhereWasIScreen.kt @@ -244,8 +244,12 @@ fun WhereWasIScreen( Text(text = flag, fontSize = 16.sp) Spacer(modifier = Modifier.width(4.dp)) } + val localizedCountryName = loc.countryCode?.let { code -> + java.util.Locale("", code).getDisplayCountry(java.util.Locale.getDefault()) + .takeIf { it.isNotBlank() && it != code } + } ?: loc.countryName ?: loc.countryCode val breadcrumbParts = listOfNotNull( - loc.countryName ?: loc.countryCode, + localizedCountryName, loc.regionName, loc.city ) From fb43f41f32deecb60653f9b5d4aef3a1c641d13c Mon Sep 17 00:00:00 2001 From: Davide Ferrari Date: Sat, 21 Mar 2026 10:31:38 +0100 Subject: [PATCH 4/4] fix: add missing Material3 color slots to app theme Add outline, outlineVariant, surfaceContainerHigh, and surfaceContainerHighest to both light and dark color schemes. Without these, Material3 components (DatePicker, DropdownMenu, etc.) fall back to auto-generated colors that clash with the app's neutral grey palette. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/src/main/java/com/matedroid/ui/theme/Color.kt | 8 ++++++++ app/src/main/java/com/matedroid/ui/theme/Theme.kt | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/app/src/main/java/com/matedroid/ui/theme/Color.kt b/app/src/main/java/com/matedroid/ui/theme/Color.kt index b49f891..f848589 100644 --- a/app/src/main/java/com/matedroid/ui/theme/Color.kt +++ b/app/src/main/java/com/matedroid/ui/theme/Color.kt @@ -28,6 +28,10 @@ val SurfaceLight = Color(0xFFFAFAFA) val OnSurfaceLight = Color(0xFF1C1B1F) val SurfaceVariantLight = Color(0xFFE8EAEC) val OnSurfaceVariantLight = Color(0xFF44474A) +val OutlineLight = Color(0xFF74777A) +val OutlineVariantLight = Color(0xFFC4C6C8) +val SurfaceContainerHighLight = Color(0xFFECEDEF) +val SurfaceContainerHighestLight = Color(0xFFE6E7E9) val ErrorLight = StatusError val OnErrorLight = Color.White @@ -48,6 +52,10 @@ val SurfaceDark = Color(0xFF1C1B1F) val OnSurfaceDark = Color(0xFFE6E1E5) val SurfaceVariantDark = Color(0xFF44474A) val OnSurfaceVariantDark = Color(0xFFCACCCE) +val OutlineDark = Color(0xFF8E9194) +val OutlineVariantDark = Color(0xFF44474A) +val SurfaceContainerHighDark = Color(0xFF2B2D31) +val SurfaceContainerHighestDark = Color(0xFF35373B) val ErrorDark = Color(0xFFFFB4AB) val OnErrorDark = Color(0xFF690005) diff --git a/app/src/main/java/com/matedroid/ui/theme/Theme.kt b/app/src/main/java/com/matedroid/ui/theme/Theme.kt index 4ed9db0..a85e4bc 100644 --- a/app/src/main/java/com/matedroid/ui/theme/Theme.kt +++ b/app/src/main/java/com/matedroid/ui/theme/Theme.kt @@ -23,6 +23,10 @@ private val DarkColorScheme = darkColorScheme( onSurface = OnSurfaceDark, surfaceVariant = SurfaceVariantDark, onSurfaceVariant = OnSurfaceVariantDark, + outline = OutlineDark, + outlineVariant = OutlineVariantDark, + surfaceContainerHigh = SurfaceContainerHighDark, + surfaceContainerHighest = SurfaceContainerHighestDark, error = ErrorDark, onError = OnErrorDark ) @@ -44,6 +48,10 @@ private val LightColorScheme = lightColorScheme( onSurface = OnSurfaceLight, surfaceVariant = SurfaceVariantLight, onSurfaceVariant = OnSurfaceVariantLight, + outline = OutlineLight, + outlineVariant = OutlineVariantLight, + surfaceContainerHigh = SurfaceContainerHighLight, + surfaceContainerHighest = SurfaceContainerHighestLight, error = ErrorLight, onError = OnErrorLight )