diff --git a/CHANGELOG.md b/CHANGELOG.md index 384c3f2f..9680814b 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 317e4671..5958d55b 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 1d11d1d3..13abc5fe 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,198 @@ 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 = { + 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 new file mode 100644 index 00000000..db71243e --- /dev/null +++ b/app/src/main/java/com/matedroid/ui/screens/wherewasi/WhereWasIScreen.kt @@ -0,0 +1,523 @@ +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.automirrored.filled.KeyboardArrowRight +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 + address + state.location?.let { loc -> + Card( + colors = CardDefaults.cardColors(containerColor = palette.surface) + ) { + Column(modifier = Modifier.padding(16.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)) + } + 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( + localizedCountryName, + 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) + ) + } + } + } + + // Geofence name or full address (bigger) + val displayAddress = state.geofenceName + ?: loc.address + if (displayAddress != null) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = displayAddress, + style = MaterialTheme.typography.titleMedium, + color = palette.onSurface + ) + } + } + } + } + + // 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 */ } + } + + // Chevron hint for tappable cards + if (isClickable) { + Spacer(modifier = Modifier.height(8.dp)) + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = palette.onSurfaceVariant, + modifier = Modifier + .size(24.dp) + .align(Alignment.End) + ) + } + } + } + } + + // 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 00000000..ea9642c9 --- /dev/null +++ b/app/src/main/java/com/matedroid/ui/screens/wherewasi/WhereWasIViewModel.kt @@ -0,0 +1,364 @@ +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, + val geofenceName: 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 = drive.odometerDetails?.odometerEnd, + outsideTemp = nearest?.outsideTemp ?: drive.outsideTempAvg, + speed = nearest?.speed, + driveId = drive.driveId, + driveDistance = drive.distance, + units = units, + targetDateTime = _uiState.value.targetDateTime, + geofenceName = drive.endAddress ?: drive.startAddress + ) + + // 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, + geofenceName = charge.address + ) + + 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/java/com/matedroid/ui/theme/Color.kt b/app/src/main/java/com/matedroid/ui/theme/Color.kt index b49f8910..f8485893 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 4ed9db05..a85e4bca 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 ) diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index a926804a..fcfbdbea 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 9f0f637d..92aaa014 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 2f14e9b4..f6cbb2f0 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 58c3615f..d814891c 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