diff --git a/app/build.gradle.kts b/app/build.gradle.kts index efdfe21..7a05ead 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -92,6 +92,9 @@ dependencies { // Charts implementation(libs.vico.compose.m3) + // Maps + implementation(libs.osmdroid) + // Testing testImplementation(libs.junit) testImplementation(libs.coroutines.test) diff --git a/app/src/main/java/com/matedroid/data/api/models/DriveModels.kt b/app/src/main/java/com/matedroid/data/api/models/DriveModels.kt index eb7cae7..c6f2157 100644 --- a/app/src/main/java/com/matedroid/data/api/models/DriveModels.kt +++ b/app/src/main/java/com/matedroid/data/api/models/DriveModels.kt @@ -75,7 +75,19 @@ data class DriveRange( @JsonClass(generateAdapter = true) data class DriveDetailResponse( - @Json(name = "data") val data: DriveDetail? = null + @Json(name = "data") val data: DriveDetailData? = null +) + +@JsonClass(generateAdapter = true) +data class DriveDetailData( + @Json(name = "car") val car: DriveDetailCar? = null, + @Json(name = "drive") val drive: DriveDetail? = null +) + +@JsonClass(generateAdapter = true) +data class DriveDetailCar( + @Json(name = "car_id") val carId: Int? = null, + @Json(name = "car_name") val carName: String? = null ) @JsonClass(generateAdapter = true) @@ -99,7 +111,7 @@ data class DriveDetail( @Json(name = "inside_temp_avg") val insideTempAvg: Double? = null, @Json(name = "energy_consumed_net") val energyConsumedNet: Double? = null, @Json(name = "consumption_net") val consumptionNet: Double? = null, - @Json(name = "positions") val positions: List? = null + @Json(name = "drive_details") val positions: List? = null ) { val id: Int get() = driveId val distance: Double? get() = odometerDetails?.distance diff --git a/app/src/main/java/com/matedroid/data/repository/TeslamateRepository.kt b/app/src/main/java/com/matedroid/data/repository/TeslamateRepository.kt index d1fe99e..3cfc61c 100644 --- a/app/src/main/java/com/matedroid/data/repository/TeslamateRepository.kt +++ b/app/src/main/java/com/matedroid/data/repository/TeslamateRepository.kt @@ -151,7 +151,7 @@ class TeslamateRepository @Inject constructor( val api = getApi() ?: return ApiResult.Error("Server not configured") val response = api.getDriveDetail(carId, driveId) if (response.isSuccessful) { - val detail = response.body()?.data + val detail = response.body()?.data?.drive if (detail != null) { ApiResult.Success(detail) } else { 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 4d495c9..423fe71 100644 --- a/app/src/main/java/com/matedroid/ui/navigation/NavGraph.kt +++ b/app/src/main/java/com/matedroid/ui/navigation/NavGraph.kt @@ -12,6 +12,7 @@ import androidx.navigation.navArgument import com.matedroid.ui.screens.battery.BatteryScreen import com.matedroid.ui.screens.charges.ChargesScreen import com.matedroid.ui.screens.dashboard.DashboardScreen +import com.matedroid.ui.screens.drives.DriveDetailScreen import com.matedroid.ui.screens.drives.DrivesScreen import com.matedroid.ui.screens.mileage.MileageScreen import com.matedroid.ui.screens.settings.SettingsScreen @@ -115,6 +116,25 @@ fun NavGraph( val carId = backStackEntry.arguments?.getInt("carId") ?: return@composable DrivesScreen( carId = carId, + onNavigateBack = { navController.popBackStack() }, + onNavigateToDriveDetail = { driveId -> + navController.navigate(Screen.DriveDetail.createRoute(carId, driveId)) + } + ) + } + + composable( + route = Screen.DriveDetail.route, + arguments = listOf( + navArgument("carId") { type = NavType.IntType }, + navArgument("driveId") { type = NavType.IntType } + ) + ) { backStackEntry -> + val carId = backStackEntry.arguments?.getInt("carId") ?: return@composable + val driveId = backStackEntry.arguments?.getInt("driveId") ?: return@composable + DriveDetailScreen( + carId = carId, + driveId = driveId, onNavigateBack = { navController.popBackStack() } ) } 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 a49ff55..735eaba 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 @@ -1,9 +1,11 @@ package com.matedroid.ui.screens.dashboard +import android.graphics.Paint 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.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -12,6 +14,7 @@ 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.filled.Battery5Bar @@ -43,17 +46,27 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView import androidx.hilt.navigation.compose.hiltViewModel +import org.osmdroid.config.Configuration +import org.osmdroid.tileprovider.tilesource.TileSourceFactory +import org.osmdroid.util.GeoPoint +import org.osmdroid.views.MapView +import org.osmdroid.views.overlay.Marker import com.matedroid.data.api.models.BatteryDetails import com.matedroid.data.api.models.CarGeodata import com.matedroid.data.api.models.CarStatus @@ -512,6 +525,9 @@ private fun ChargingCard(status: CarStatus) { @Composable private fun LocationCard(geofence: String, status: CarStatus) { + val latitude = status.latitude + val longitude = status.longitude + Card(modifier = Modifier.fillMaxWidth()) { Row( modifier = Modifier @@ -525,7 +541,7 @@ private fun LocationCard(geofence: String, status: CarStatus) { tint = MaterialTheme.colorScheme.primary ) Spacer(modifier = Modifier.width(12.dp)) - Column { + Column(modifier = Modifier.weight(1f)) { Text( text = "Location", style = MaterialTheme.typography.labelSmall, @@ -536,10 +552,64 @@ private fun LocationCard(geofence: String, status: CarStatus) { style = MaterialTheme.typography.titleMedium ) } + + // Small map showing car location + if (latitude != null && longitude != null) { + Spacer(modifier = Modifier.width(12.dp)) + SmallLocationMap( + latitude = latitude, + longitude = longitude, + modifier = Modifier + .width(140.dp) + .height(70.dp) + .clip(RoundedCornerShape(8.dp)) + ) + } } } } +@Composable +private fun SmallLocationMap( + latitude: Double, + longitude: Double, + modifier: Modifier = Modifier +) { + val primaryColor = MaterialTheme.colorScheme.primary.toArgb() + + DisposableEffect(Unit) { + Configuration.getInstance().userAgentValue = "MateDroid/1.0" + onDispose { } + } + + AndroidView( + factory = { ctx -> + MapView(ctx).apply { + setTileSource(TileSourceFactory.MAPNIK) + setMultiTouchControls(false) + + // Disable all interactions for this small preview map + setBuiltInZoomControls(false) + isClickable = false + isFocusable = false + + val carLocation = GeoPoint(latitude, longitude) + controller.setZoom(15.0) + controller.setCenter(carLocation) + + // Add a marker for the car + val marker = Marker(this).apply { + position = carLocation + setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) + icon = ctx.getDrawable(android.R.drawable.ic_menu_mylocation) + } + overlays.add(marker) + } + }, + modifier = modifier + ) +} + @Composable private fun VehicleInfoCard(status: CarStatus, units: Units?) { val distanceUnit = UnitFormatter.getDistanceUnit(units) @@ -657,19 +727,22 @@ private fun QuickLinksRow( @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun QuickLinkItem( +private fun RowScope.QuickLinkItem( title: String, icon: ImageVector, onClick: () -> Unit ) { Card( onClick = onClick, + modifier = Modifier.weight(1f), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceVariant ) ) { Column( - modifier = Modifier.padding(12.dp), + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Icon( diff --git a/app/src/main/java/com/matedroid/ui/screens/drives/DriveDetailScreen.kt b/app/src/main/java/com/matedroid/ui/screens/drives/DriveDetailScreen.kt new file mode 100644 index 0000000..4bd1812 --- /dev/null +++ b/app/src/main/java/com/matedroid/ui/screens/drives/DriveDetailScreen.kt @@ -0,0 +1,725 @@ +package com.matedroid.ui.screens.drives + +import android.graphics.Color as AndroidColor +import android.graphics.Paint +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +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.DeviceThermostat +import androidx.compose.material.icons.filled.Landscape +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.Route +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material.icons.filled.Speed +import androidx.compose.material.icons.filled.TrendingDown +import androidx.compose.material.icons.filled.TrendingUp +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.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.hilt.navigation.compose.hiltViewModel +import com.matedroid.data.api.models.DriveDetail +import com.matedroid.data.api.models.DrivePosition +import com.matedroid.data.api.models.Units +import com.matedroid.domain.model.UnitFormatter +import org.osmdroid.config.Configuration +import org.osmdroid.tileprovider.tilesource.TileSourceFactory +import org.osmdroid.util.BoundingBox +import org.osmdroid.util.GeoPoint +import org.osmdroid.views.MapView +import org.osmdroid.views.overlay.Polyline +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeParseException + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DriveDetailScreen( + carId: Int, + driveId: Int, + onNavigateBack: () -> Unit, + viewModel: DriveDetailViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(carId, driveId) { + viewModel.loadDriveDetail(carId, driveId) + } + + LaunchedEffect(uiState.error) { + uiState.error?.let { error -> + snackbarHostState.showSnackbar(error) + viewModel.clearError() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Drive Details") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { padding -> + if (uiState.isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else { + uiState.driveDetail?.let { detail -> + DriveDetailContent( + detail = detail, + stats = uiState.stats, + units = uiState.units, + modifier = Modifier.padding(padding) + ) + } + } + } +} + +@Composable +private fun DriveDetailContent( + detail: DriveDetail, + stats: DriveDetailStats?, + units: Units?, + modifier: Modifier = Modifier +) { + val scrollState = rememberScrollState() + + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Route header card + RouteHeaderCard(detail = detail) + + // Map showing the route + if (!detail.positions.isNullOrEmpty()) { + DriveMapCard(positions = detail.positions) + } + + // Stats grid + stats?.let { s -> + // Speed section + StatsSectionCard( + title = "Speed", + icon = Icons.Default.Speed, + stats = listOf( + StatItem("Maximum", UnitFormatter.formatSpeed(s.speedMax.toDouble(), units)), + StatItem("Average", UnitFormatter.formatSpeed(s.speedAvg, units)), + StatItem("Avg (distance)", UnitFormatter.formatSpeed(s.avgSpeedFromDistance, units)) + ) + ) + + // Distance & Duration section + StatsSectionCard( + title = "Trip", + icon = Icons.Default.Route, + stats = listOf( + StatItem("Distance", UnitFormatter.formatDistance(s.distance, units)), + StatItem("Duration", formatDuration(s.durationMin)), + StatItem("Efficiency", UnitFormatter.formatEfficiency(s.efficiency, units)) + ) + ) + + // Battery section + StatsSectionCard( + title = "Battery", + icon = Icons.Default.BatteryChargingFull, + stats = listOf( + StatItem("Start", "${s.batteryStart}%"), + StatItem("End", "${s.batteryEnd}%"), + StatItem("Used", "${s.batteryUsed}%"), + StatItem("Energy", "%.2f kWh".format(s.energyUsed)) + ) + ) + + // Power section + StatsSectionCard( + title = "Power", + icon = Icons.Default.Bolt, + stats = listOf( + StatItem("Max (accel)", "${s.powerMax} kW"), + StatItem("Min (regen)", "${s.powerMin} kW"), + StatItem("Average", "%.1f kW".format(s.powerAvg)) + ) + ) + + // Elevation section + if (s.elevationMax > 0 || s.elevationMin > 0) { + StatsSectionCard( + title = "Elevation", + icon = Icons.Default.Landscape, + stats = listOf( + StatItem("Maximum", "${s.elevationMax} m"), + StatItem("Minimum", "${s.elevationMin} m"), + StatItem("Gain", "+${s.elevationGain} m"), + StatItem("Loss", "-${s.elevationLoss} m") + ) + ) + } + + // Temperature section + if (s.outsideTempAvg != null || s.insideTempAvg != null) { + StatsSectionCard( + title = "Temperature", + icon = Icons.Default.DeviceThermostat, + stats = listOfNotNull( + s.outsideTempAvg?.let { StatItem("Outside", UnitFormatter.formatTemperature(it, units)) }, + s.insideTempAvg?.let { StatItem("Inside", UnitFormatter.formatTemperature(it, units)) } + ) + ) + } + + // Charts + if (!detail.positions.isNullOrEmpty() && detail.positions.size > 2) { + SpeedChartCard(positions = detail.positions, units = units) + PowerChartCard(positions = detail.positions) + BatteryChartCard(positions = detail.positions) + if (detail.positions.any { it.elevation != null && it.elevation != 0 }) { + ElevationChartCard(positions = detail.positions) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@Composable +private fun RouteHeaderCard(detail: DriveDetail) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Start location + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.LocationOn, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = "From", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) + ) + Text( + text = detail.startAddress ?: "Unknown location", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + + HorizontalDivider( + modifier = Modifier.padding(start = 36.dp), + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.2f) + ) + + // End location + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.LocationOn, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.tertiary + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = "To", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) + ) + Text( + text = detail.endAddress ?: "Unknown location", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + + HorizontalDivider( + modifier = Modifier.padding(start = 36.dp), + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.2f) + ) + + // Date and time + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Schedule, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = formatDateTime(detail.startDate), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + detail.durationStr?.let { duration -> + Text( + text = "Duration: $duration", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) + ) + } + } + } + } + } +} + +@Composable +private fun DriveMapCard(positions: List) { + val context = LocalContext.current + val validPositions = positions.filter { it.latitude != null && it.longitude != null } + + if (validPositions.isEmpty()) return + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Route Map", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 12.dp) + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(250.dp) + .clip(RoundedCornerShape(8.dp)) + ) { + val primaryColor = MaterialTheme.colorScheme.primary.toArgb() + + DisposableEffect(Unit) { + Configuration.getInstance().userAgentValue = "MateDroid/1.0" + onDispose { } + } + + AndroidView( + factory = { ctx -> + MapView(ctx).apply { + setTileSource(TileSourceFactory.MAPNIK) + setMultiTouchControls(true) + + // Create polyline for the route + val geoPoints = validPositions.map { pos -> + GeoPoint(pos.latitude!!, pos.longitude!!) + } + + val polyline = Polyline().apply { + setPoints(geoPoints) + outlinePaint.color = primaryColor + outlinePaint.strokeWidth = 8f + outlinePaint.strokeCap = Paint.Cap.ROUND + outlinePaint.strokeJoin = Paint.Join.ROUND + } + overlays.add(polyline) + + // Calculate bounding box with padding + if (geoPoints.isNotEmpty()) { + val north = geoPoints.maxOf { it.latitude } + val south = geoPoints.minOf { it.latitude } + val east = geoPoints.maxOf { it.longitude } + val west = geoPoints.minOf { it.longitude } + + // Add some padding + val latPadding = (north - south) * 0.15 + val lonPadding = (east - west) * 0.15 + + val boundingBox = BoundingBox( + north + latPadding, + east + lonPadding, + south - latPadding, + west - lonPadding + ) + + post { + zoomToBoundingBox(boundingBox, false) + invalidate() + } + } + } + }, + modifier = Modifier.fillMaxSize() + ) + } + } + } +} + +data class StatItem(val label: String, val value: String) + +@Composable +private fun StatsSectionCard( + title: String, + icon: ImageVector, + stats: List +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(bottom = 12.dp) + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + } + + // Stats grid - 2 or more columns + val chunked = stats.chunked(2) + chunked.forEachIndexed { index, row -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + row.forEach { stat -> + StatItemView( + label = stat.label, + value = stat.value, + modifier = Modifier.weight(1f) + ) + } + // Fill empty space if odd number + if (row.size == 1) { + Spacer(modifier = Modifier.weight(1f)) + } + } + if (index < chunked.size - 1) { + Spacer(modifier = Modifier.height(8.dp)) + } + } + } + } +} + +@Composable +private fun StatItemView( + label: String, + value: String, + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + Text( + text = value, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold + ) + } +} + +@Composable +private fun SpeedChartCard(positions: List, units: Units?) { + val speeds = positions.mapNotNull { it.speed?.toFloat() } + if (speeds.size < 2) return + + ChartCard( + title = "Speed Profile", + icon = Icons.Default.Speed, + data = speeds, + color = MaterialTheme.colorScheme.primary, + unit = UnitFormatter.getSpeedUnit(units), + convertValue = { value -> + if (units?.isImperial == true) (value * 0.621371f) else value + } + ) +} + +@Composable +private fun PowerChartCard(positions: List) { + val powers = positions.mapNotNull { it.power?.toFloat() } + if (powers.size < 2) return + + ChartCard( + title = "Power Profile", + icon = Icons.Default.Bolt, + data = powers, + color = MaterialTheme.colorScheme.tertiary, + unit = "kW", + showZeroLine = true + ) +} + +@Composable +private fun BatteryChartCard(positions: List) { + val batteryLevels = positions.mapNotNull { it.batteryLevel?.toFloat() } + if (batteryLevels.size < 2) return + + ChartCard( + title = "Battery Level", + icon = Icons.Default.BatteryChargingFull, + data = batteryLevels, + color = MaterialTheme.colorScheme.secondary, + unit = "%", + fixedMinMax = Pair(0f, 100f) + ) +} + +@Composable +private fun ElevationChartCard(positions: List) { + val elevations = positions.mapNotNull { it.elevation?.toFloat() } + if (elevations.size < 2) return + + ChartCard( + title = "Elevation Profile", + icon = Icons.Default.Landscape, + data = elevations, + color = Color(0xFF8B4513), // Brown color for terrain + unit = "m" + ) +} + +@Composable +private fun ChartCard( + title: String, + icon: ImageVector, + data: List, + color: Color, + unit: String, + showZeroLine: Boolean = false, + fixedMinMax: Pair? = null, + convertValue: (Float) -> Float = { it } +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(bottom = 12.dp) + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = color + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + } + + val convertedData = data.map { convertValue(it) } + val minValue = fixedMinMax?.first ?: convertedData.minOrNull() ?: 0f + val maxValue = fixedMinMax?.second ?: convertedData.maxOrNull() ?: 1f + val range = (maxValue - minValue).coerceAtLeast(1f) + + val surfaceColor = MaterialTheme.colorScheme.onSurface + val gridColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + ) { + val width = size.width + val height = size.height + val stepX = width / (convertedData.size - 1).coerceAtLeast(1) + + // Draw grid lines + val gridLineCount = 4 + for (i in 0..gridLineCount) { + val y = height * i / gridLineCount + drawLine( + color = gridColor, + start = Offset(0f, y), + end = Offset(width, y), + strokeWidth = 1f + ) + } + + // Draw zero line if needed (for power chart) + if (showZeroLine && minValue < 0 && maxValue > 0) { + val zeroY = height * (1 - (0f - minValue) / range) + drawLine( + color = surfaceColor.copy(alpha = 0.5f), + start = Offset(0f, zeroY), + end = Offset(width, zeroY), + strokeWidth = 2f + ) + } + + // Draw the line chart + if (convertedData.size >= 2) { + for (i in 0 until convertedData.size - 1) { + val x1 = i * stepX + val x2 = (i + 1) * stepX + val y1 = height * (1 - (convertedData[i] - minValue) / range) + val y2 = height * (1 - (convertedData[i + 1] - minValue) / range) + + drawLine( + color = color, + start = Offset(x1, y1), + end = Offset(x2, y2), + strokeWidth = 2.5f + ) + } + } + + // Draw min/max labels + drawContext.canvas.nativeCanvas.apply { + val textPaint = Paint().apply { + this.color = surfaceColor.copy(alpha = 0.7f).toArgb() + textSize = 28f + isAntiAlias = true + } + + drawText( + "%.0f".format(maxValue) + " $unit", + 8f, + textPaint.textSize + 4f, + textPaint + ) + drawText( + "%.0f".format(minValue) + " $unit", + 8f, + height - 8f, + textPaint + ) + } + } + } + } +} + +private fun formatDateTime(dateStr: String?): String { + if (dateStr == null) return "Unknown" + return try { + val dateTime = try { + OffsetDateTime.parse(dateStr).toLocalDateTime() + } catch (e: DateTimeParseException) { + LocalDateTime.parse(dateStr.replace("Z", "")) + } + val formatter = DateTimeFormatter.ofPattern("EEEE, MMM d, yyyy 'at' HH:mm") + dateTime.format(formatter) + } catch (e: Exception) { + dateStr + } +} + +private fun formatDuration(minutes: Int): String { + val hours = minutes / 60 + val mins = minutes % 60 + return if (hours > 0) "${hours}h ${mins}m" else "${mins}m" +} diff --git a/app/src/main/java/com/matedroid/ui/screens/drives/DriveDetailViewModel.kt b/app/src/main/java/com/matedroid/ui/screens/drives/DriveDetailViewModel.kt new file mode 100644 index 0000000..0212381 --- /dev/null +++ b/app/src/main/java/com/matedroid/ui/screens/drives/DriveDetailViewModel.kt @@ -0,0 +1,184 @@ +package com.matedroid.ui.screens.drives + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.matedroid.data.api.models.DriveDetail +import com.matedroid.data.api.models.DrivePosition +import com.matedroid.data.api.models.Units +import com.matedroid.data.repository.ApiResult +import com.matedroid.data.repository.TeslamateRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class DriveDetailUiState( + val isLoading: Boolean = true, + val error: String? = null, + val driveDetail: DriveDetail? = null, + val units: Units? = null, + val stats: DriveDetailStats? = null +) + +data class DriveDetailStats( + val speedMax: Int, + val speedAvg: Double, + val speedMin: Int, + val powerMax: Int, + val powerMin: Int, + val powerAvg: Double, + val elevationMax: Int, + val elevationMin: Int, + val elevationGain: Int, + val elevationLoss: Int, + val batteryStart: Int, + val batteryEnd: Int, + val batteryUsed: Int, + val energyUsed: Double, + val efficiency: Double, + val distance: Double, + val durationMin: Int, + val avgSpeedFromDistance: Double, + val outsideTempAvg: Double?, + val insideTempAvg: Double? +) + +@HiltViewModel +class DriveDetailViewModel @Inject constructor( + private val repository: TeslamateRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(DriveDetailUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var carId: Int? = null + private var driveId: Int? = null + + fun loadDriveDetail(carId: Int, driveId: Int) { + if (this.carId == carId && this.driveId == driveId && _uiState.value.driveDetail != null) { + return // Already loaded + } + + this.carId = carId + this.driveId = driveId + + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, error = null) } + + // Fetch drive detail and units in parallel + val detailResult = repository.getDriveDetail(carId, driveId) + val statusResult = repository.getCarStatus(carId) + + val units = when (statusResult) { + is ApiResult.Success -> statusResult.data.units + is ApiResult.Error -> null + } + + when (detailResult) { + is ApiResult.Success -> { + val detail = detailResult.data + val stats = calculateStats(detail) + _uiState.update { + it.copy( + isLoading = false, + driveDetail = detail, + units = units, + stats = stats, + error = null + ) + } + } + is ApiResult.Error -> { + _uiState.update { + it.copy( + isLoading = false, + error = detailResult.message + ) + } + } + } + } + } + + fun clearError() { + _uiState.update { it.copy(error = null) } + } + + private fun calculateStats(detail: DriveDetail): DriveDetailStats { + val positions = detail.positions ?: emptyList() + + // Speed stats from positions + val speeds = positions.mapNotNull { it.speed } + val speedMax = speeds.maxOrNull() ?: detail.speedMax ?: 0 + val speedMin = speeds.filter { it > 0 }.minOrNull() ?: 0 + val speedAvg = if (speeds.isNotEmpty()) speeds.average() else detail.speedAvg ?: 0.0 + + // Power stats from positions + val powers = positions.mapNotNull { it.power } + val powerMax = powers.maxOrNull() ?: detail.powerMax ?: 0 + val powerMin = powers.minOrNull() ?: detail.powerMin ?: 0 + val powerAvg = if (powers.isNotEmpty()) powers.average() else 0.0 + + // Elevation stats + val elevations = positions.mapNotNull { it.elevation } + val elevationMax = elevations.maxOrNull() ?: 0 + val elevationMin = elevations.minOrNull() ?: 0 + val (elevationGain, elevationLoss) = calculateElevationChange(elevations) + + // Battery stats + val batteryLevels = positions.mapNotNull { it.batteryLevel } + val batteryStart = batteryLevels.firstOrNull() ?: detail.startBatteryLevel ?: 0 + val batteryEnd = batteryLevels.lastOrNull() ?: detail.endBatteryLevel ?: 0 + val batteryUsed = batteryStart - batteryEnd + + // Energy and efficiency + val distance = detail.distance ?: 0.0 + val energyUsed = detail.energyConsumedNet ?: 0.0 + val efficiency = if (distance > 0) (energyUsed * 1000) / distance else 0.0 + + // Duration and average speed from distance + val durationMin = detail.durationMin ?: 0 + val avgSpeedFromDistance = if (durationMin > 0) (distance / durationMin) * 60 else 0.0 + + return DriveDetailStats( + speedMax = speedMax, + speedAvg = speedAvg, + speedMin = speedMin, + powerMax = powerMax, + powerMin = powerMin, + powerAvg = powerAvg, + elevationMax = elevationMax, + elevationMin = elevationMin, + elevationGain = elevationGain, + elevationLoss = elevationLoss, + batteryStart = batteryStart, + batteryEnd = batteryEnd, + batteryUsed = batteryUsed, + energyUsed = energyUsed, + efficiency = efficiency, + distance = distance, + durationMin = durationMin, + avgSpeedFromDistance = avgSpeedFromDistance, + outsideTempAvg = detail.outsideTempAvg, + insideTempAvg = detail.insideTempAvg + ) + } + + private fun calculateElevationChange(elevations: List): Pair { + if (elevations.size < 2) return Pair(0, 0) + + var gain = 0 + var loss = 0 + + for (i in 1 until elevations.size) { + val diff = elevations[i] - elevations[i - 1] + if (diff > 0) gain += diff + else loss += -diff + } + + return Pair(gain, loss) + } +} diff --git a/app/src/main/java/com/matedroid/ui/screens/drives/DrivesScreen.kt b/app/src/main/java/com/matedroid/ui/screens/drives/DrivesScreen.kt index 94f23e9..b77c60a 100644 --- a/app/src/main/java/com/matedroid/ui/screens/drives/DrivesScreen.kt +++ b/app/src/main/java/com/matedroid/ui/screens/drives/DrivesScreen.kt @@ -1,5 +1,6 @@ package com.matedroid.ui.screens.drives +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -72,6 +73,7 @@ enum class DriveDateFilter(val label: String, val days: Long?) { fun DrivesScreen( carId: Int, onNavigateBack: () -> Unit, + onNavigateToDriveDetail: (driveId: Int) -> Unit, viewModel: DrivesViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsState() @@ -138,7 +140,8 @@ fun DrivesScreen( drives = uiState.drives, summary = uiState.summary, selectedFilter = selectedFilter, - onFilterSelected = { applyDateFilter(it) } + onFilterSelected = { applyDateFilter(it) }, + onDriveClick = onNavigateToDriveDetail ) } } @@ -151,7 +154,8 @@ private fun DrivesContent( drives: List, summary: DrivesSummary, selectedFilter: DriveDateFilter, - onFilterSelected: (DriveDateFilter) -> Unit + onFilterSelected: (DriveDateFilter) -> Unit, + onDriveClick: (driveId: Int) -> Unit ) { LazyColumn( modifier = Modifier.fillMaxSize(), @@ -202,7 +206,10 @@ private fun DrivesContent( } } else { items(drives, key = { it.id }) { drive -> - DriveItem(drive = drive) + DriveItem( + drive = drive, + onClick = { onDriveClick(drive.id) } + ) } } } @@ -322,9 +329,14 @@ private fun SummaryItem( } @Composable -private fun DriveItem(drive: DriveData) { +private fun DriveItem( + drive: DriveData, + onClick: () -> Unit +) { Card( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) ) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 275d214..f319a87 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,7 @@ moshi = "1.15.1" datastore = "1.1.1" securityCrypto = "1.1.0-alpha06" vico = "2.0.0-beta.2" +osmdroid = "6.1.18" junit = "4.13.2" junitExt = "1.2.1" espresso = "3.6.1" @@ -62,6 +63,9 @@ security-crypto = { group = "androidx.security", name = "security-crypto", versi # Charts vico-compose-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", version.ref = "vico" } +# Maps +osmdroid = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "osmdroid" } + # Testing junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitExt" }