mirror of
https://github.com/vide/matedroid.git
synced 2026-01-20 00:03:17 +08:00
feat: add drive detail screen with map and improve dashboard
- Add DriveDetailScreen with OpenStreetMap route visualization - Show drive statistics: speed, power, battery, elevation, temperature - Add interactive charts for speed, power, battery, and elevation profiles - Fix DriveModels to match API response structure (drive_details field) - Add small location map to dashboard Location card - Make dashboard quick link cards fill horizontal space equally - Add osmdroid dependency for OpenStreetMap integration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -92,6 +92,9 @@ dependencies {
|
||||
// Charts
|
||||
implementation(libs.vico.compose.m3)
|
||||
|
||||
// Maps
|
||||
implementation(libs.osmdroid)
|
||||
|
||||
// Testing
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.coroutines.test)
|
||||
|
||||
@@ -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<DrivePosition>? = null
|
||||
@Json(name = "drive_details") val positions: List<DrivePosition>? = null
|
||||
) {
|
||||
val id: Int get() = driveId
|
||||
val distance: Double? get() = odometerDetails?.distance
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<DrivePosition>) {
|
||||
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<StatItem>
|
||||
) {
|
||||
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<DrivePosition>, 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<DrivePosition>) {
|
||||
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<DrivePosition>) {
|
||||
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<DrivePosition>) {
|
||||
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<Float>,
|
||||
color: Color,
|
||||
unit: String,
|
||||
showZeroLine: Boolean = false,
|
||||
fixedMinMax: Pair<Float, Float>? = 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"
|
||||
}
|
||||
@@ -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<DriveDetailUiState> = _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<Int>): Pair<Int, Int> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<DriveData>,
|
||||
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)
|
||||
)
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user