Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Changed
- **Snappier throughout** — smoother list scrolling, lighter chart rendering, the Stats and Trips screens do their heavy work off the UI thread, and the dashboard stops polling for status while it's off screen (less background battery use). No change to how anything looks.
- **Large cost totals now use a thousands separator** (e.g. "1,234.56") consistently across the stats, mileage and trip screens, matching how distances and energy are already shown.

### Fixed
Expand Down
20 changes: 12 additions & 8 deletions app/src/main/java/com/matedroid/data/repository/StatsRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,11 @@ import com.matedroid.domain.model.GapRecord
import com.matedroid.domain.model.MaxDistanceBetweenChargesRecord
import com.matedroid.domain.model.StreakRecord
import com.matedroid.domain.model.YearFilter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton

Expand All @@ -41,17 +44,18 @@ class StatsRepository @Inject constructor(
/**
* Get complete stats for a car with the given year filter.
*/
suspend fun getStats(carId: Int, yearFilter: YearFilter): CarStats {
val quickStats = getQuickStats(carId, yearFilter)
val deepStats = getDeepStats(carId, yearFilter)
val syncProgress = syncManager.getProgressForCar(carId)
suspend fun getStats(carId: Int, yearFilter: YearFilter): CarStats = withContext(Dispatchers.IO) {
// Off the main thread, and the three groups are independent — run them in parallel.
val quickStats = async { getQuickStats(carId, yearFilter) }
val deepStats = async { getDeepStats(carId, yearFilter) }
val syncProgress = async { syncManager.getProgressForCar(carId) }

return CarStats(
CarStats(
carId = carId,
yearFilter = yearFilter,
quickStats = quickStats,
deepStats = deepStats,
syncProgress = syncProgress
quickStats = quickStats.await(),
deepStats = deepStats.await(),
syncProgress = syncProgress.await()
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.matedroid.data.local.ChargeSessionStateDataStore
import com.matedroid.data.local.SettingsDataStore
import com.matedroid.data.api.models.CarData
import com.matedroid.data.repository.ApiResult
import com.matedroid.data.repository.SentryEvent
import com.matedroid.data.repository.SentryStateRepository
Expand Down Expand Up @@ -156,7 +157,7 @@ class ChargingNotificationWorker @AssistedInject constructor(
// Check each car
for (car in cars) {
try {
checkCarStatus(car.carId)
checkCarStatus(car)
} catch (e: Exception) {
Log.e(TAG, "Error checking car ${car.carId}", e)
}
Expand All @@ -173,21 +174,9 @@ class ChargingNotificationWorker @AssistedInject constructor(
}
}

private suspend fun checkCarStatus(carId: Int) {
// Get car info and status
val carsResult = teslamateRepository.getCars()
val car = when (carsResult) {
is ApiResult.Success -> carsResult.data.find { it.carId == carId }
is ApiResult.Error -> {
Log.e(TAG, "Failed to fetch car info: ${carsResult.message}")
return
}
}

if (car == null) {
Log.e(TAG, "Car $carId not found")
return
}
private suspend fun checkCarStatus(car: CarData) {
// The car object is already in hand from doWork()'s getCars() — no refetch.
val carId = car.carId

val statusResult = teslamateRepository.getCarStatus(carId)
val statusData = when (statusResult) {
Expand Down
8 changes: 6 additions & 2 deletions app/src/main/java/com/matedroid/data/sync/SyncRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,10 @@ class SyncRepository @Inject constructor(
val total = unprocessedIds.size
log("Processing $total charge details for car $carId (batch size: $BATCH_SIZE)")

// Preload summaries once (they were just written by summary sync) instead of a
// per-charge DB read inside the batch loop, which was ~one query per charge.
val summariesById = chargeSummaryDao.getAllForCar(carId).associateBy { it.chargeId }

// Process in batches
unprocessedIds.chunked(BATCH_SIZE).forEachIndexed { batchIndex, batch ->
val processed = batchIndex * BATCH_SIZE
Expand All @@ -276,8 +280,8 @@ class SyncRepository @Inject constructor(
val aggregate = computeChargeAggregate(carId, result.data)
aggregates.add(aggregate)

// Get location from charge summary for geocoding
val summary = chargeSummaryDao.get(chargeId)
// Get location from charge summary for geocoding (preloaded above)
val summary = summariesById[chargeId]
if (summary != null && summary.latitude != 0.0 && summary.longitude != 0.0) {
batchLocations.add(summary.latitude to summary.longitude)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import com.matedroid.data.local.dao.ChargeSummaryDao
import com.matedroid.data.local.entity.ChargeSummary
import com.matedroid.util.haversineMeters
import kotlin.math.roundToInt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton

Expand Down Expand Up @@ -97,12 +99,14 @@ class ChargeComparisonRepository @Inject constructor(
carId: Int,
baseChargeId: Int,
radiusMeters: Double = DEFAULT_RADIUS_METERS
): ChargeComparison? {
): ChargeComparison? = withContext(Dispatchers.Default) {
// Loading the full history and running Haversine per row is CPU work — keep it off the
// caller's (main) thread. Room still runs the queries themselves on its own executor.
val summaries = chargeSummaryDao.getAllForCar(carId)
val baseSummary = summaries.firstOrNull { it.chargeId == baseChargeId } ?: return null
val baseSummary = summaries.firstOrNull { it.chargeId == baseChargeId } ?: return@withContext null

val dcIds = aggregateDao.getDcChargeIds(carId).toSet()
if (baseChargeId !in dcIds) return null
if (baseChargeId !in dcIds) return@withContext null

val aggregates = aggregateDao.getChargeAggregatesForCar(carId).associateBy { it.chargeId }

Expand Down Expand Up @@ -135,8 +139,8 @@ class ChargeComparisonRepository @Inject constructor(
.sortedByDescending { it.peakKw ?: -1 }
.toList()

if (others.isEmpty()) return null
return ChargeComparison(
if (others.isEmpty()) return@withContext null
ChargeComparison(
base = toComparable(baseSummary, 0.0),
others = others,
radiusMeters = radiusMeters
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import com.matedroid.util.haversineMeters
import com.matedroid.util.parseIsoDateTime
import java.time.temporal.ChronoUnit
import kotlin.math.abs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton

Expand Down Expand Up @@ -100,16 +102,18 @@ class DriveComparisonRepository @Inject constructor(
carId: Int,
baseDriveId: Int,
endpointRadiusMeters: Double = DEFAULT_ENDPOINT_RADIUS_METERS
): DriveComparison? {
): DriveComparison? = withContext(Dispatchers.Default) {
// Loading the full history and running Haversine per row is CPU work — keep it off the
// caller's (main) thread. Room still runs the queries themselves on its own executor.
val summaries = driveSummaryDao.getAllForCar(carId)
val baseSummary = summaries.firstOrNull { it.driveId == baseDriveId } ?: return null
val baseSummary = summaries.firstOrNull { it.driveId == baseDriveId } ?: return@withContext null

val aggregates = aggregateDao.getDriveAggregatesForCar(carId).associateBy { it.driveId }
val baseAgg = aggregates[baseDriveId] ?: return null
val baseStartLat = baseAgg.startLatitude ?: return null
val baseStartLon = baseAgg.startLongitude ?: return null
val baseEndLat = baseAgg.endLatitude ?: return null
val baseEndLon = baseAgg.endLongitude ?: return null
val baseAgg = aggregates[baseDriveId] ?: return@withContext null
val baseStartLat = baseAgg.startLatitude ?: return@withContext null
val baseStartLon = baseAgg.startLongitude ?: return@withContext null
val baseEndLat = baseAgg.endLatitude ?: return@withContext null
val baseEndLon = baseAgg.endLongitude ?: return@withContext null
val baseDistance = baseSummary.distance

fun toComparable(s: DriveSummary): ComparableDrive {
Expand Down Expand Up @@ -148,8 +152,8 @@ class DriveComparisonRepository @Inject constructor(
.map { toComparable(it) }
.sortedBy { it.efficiency ?: Double.MAX_VALUE }

if (others.isEmpty()) return null
return DriveComparison(
if (others.isEmpty()) return@withContext null
DriveComparison(
base = toComparable(baseSummary),
others = others,
endpointRadiusMeters = endpointRadiusMeters
Expand Down
52 changes: 31 additions & 21 deletions app/src/main/java/com/matedroid/domain/RouteSimplifier.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,31 +20,41 @@ object RouteSimplifier {
): List<T> {
if (points.size <= 2) return points

// Find the point with the maximum distance from the line start→end
var maxDist = 0.0
var maxIndex = 0
val start = points.first()
val end = points.last()
// Iterative Douglas-Peucker: mark points to keep in a bitset, using an explicit
// stack of index ranges. Same result as the classic recursion, but with no
// per-level list allocation and a bounded heap stack (no deep call recursion).
val keep = BooleanArray(points.size)
keep[0] = true
keep[points.size - 1] = true

for (i in 1 until points.size - 1) {
val d = perpendicularDistance(
lat(points[i]), lon(points[i]),
lat(start), lon(start),
lat(end), lon(end)
)
if (d > maxDist) {
maxDist = d
maxIndex = i
val stack = ArrayDeque<Int>()
stack.addLast(0)
stack.addLast(points.size - 1)
while (stack.isNotEmpty()) {
val end = stack.removeLast()
val start = stack.removeLast()

val ax = lat(points[start]); val ay = lon(points[start])
val bx = lat(points[end]); val by = lon(points[end])

var maxDist = 0.0
var maxIndex = -1
for (i in start + 1 until end) {
val d = perpendicularDistance(lat(points[i]), lon(points[i]), ax, ay, bx, by)
if (d > maxDist) {
maxDist = d
maxIndex = i
}
}
}

return if (maxDist > epsilon) {
val left = simplify(points.subList(0, maxIndex + 1), epsilon, lat, lon)
val right = simplify(points.subList(maxIndex, points.size), epsilon, lat, lon)
left.dropLast(1) + right
} else {
listOf(start, end)
if (maxIndex != -1 && maxDist > epsilon) {
keep[maxIndex] = true
stack.addLast(start); stack.addLast(maxIndex)
stack.addLast(maxIndex); stack.addLast(end)
}
}

return points.filterIndexed { i, _ -> keep[i] }
}

private fun perpendicularDistance(
Expand Down
17 changes: 14 additions & 3 deletions app/src/main/java/com/matedroid/domain/TripRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import com.matedroid.data.local.entity.SavedTripWithLegs
import com.matedroid.domain.model.Trip
import com.matedroid.util.parseIsoDateTime
import java.security.MessageDigest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.time.LocalDateTime
import java.time.temporal.ChronoUnit
import javax.inject.Inject
Expand Down Expand Up @@ -41,17 +43,26 @@ class TripRepository @Inject constructor(
private val tripDetector: TripDetector
) {

// Cars whose beta-era duplicate cleanup has already run this session (see cleanupDuplicateSavedTrips).
private val duplicatesCleanedForCar = mutableSetOf<Int>()

/** Returns all trips for a car (saved + newly auto-detected this call), newest first. */
suspend fun getTrips(carId: Int): List<Trip> {
suspend fun getTrips(carId: Int): List<Trip> = withContext(Dispatchers.Default) {
// Detection, duplicate healing (SHA-256 per trip) and trip building are CPU work — keep
// them off the caller's (main) thread. Room still runs the queries on its own executor.
val drives = driveSummaryDao.getAllChronological(carId)
val dcCharges = aggregateDao.getDcChargeSummaries(carId)
val allCharges = chargeSummaryDao.getAllForCar(carId)

autoPersistNewTrips(carId, drives, dcCharges)
cleanupDuplicateSavedTrips(carId)
// Beta-era duplicate healing only needs to run once per car per session, not every open.
// It's idempotent, so a redundant run (e.g. from a concurrent call) is harmless.
if (duplicatesCleanedForCar.add(carId)) {
cleanupDuplicateSavedTrips(carId)
}

val saved = savedTripDao.getAllWithLegs(carId)
return buildTripsFromSaved(saved, drives, allCharges)
buildTripsFromSaved(saved, drives, allCharges)
.sortedByDescending { it.startDate }
}

Expand Down
25 changes: 3 additions & 22 deletions app/src/main/java/com/matedroid/ui/components/ChartDrawUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -401,18 +401,12 @@ private fun axisLabelValues(chartData: ChartData, steps: Int = 4): Pair<List<Flo
* precision when the range is too small to differentiate integer values.
*/
fun DrawScope.drawYAxisLabels(
surfaceColor: Color,
textPaint: Paint,
chartData: ChartData,
unit: String,
height: Float
) {
drawContext.canvas.nativeCanvas.apply {
val textPaint = Paint().apply {
color = surfaceColor.copy(alpha = 0.7f).toArgb()
textSize = 26f
isAntiAlias = true
}

val (rawValues, format) = axisLabelValues(chartData)

for (i in rawValues.indices) {
Expand All @@ -429,21 +423,14 @@ fun DrawScope.drawYAxisLabels(
* Draws Y-axis labels for dual-axis chart (left or right side).
*/
fun DrawScope.drawDualYAxisLabels(
textPaint: Paint,
chartData: ChartData,
unit: String,
height: Float,
isLeft: Boolean,
color: Color,
width: Float = 0f
) {
drawContext.canvas.nativeCanvas.apply {
val textPaint = Paint().apply {
this.color = color.copy(alpha = 0.8f).toArgb()
textSize = 24f
isAntiAlias = true
textAlign = if (isLeft) Paint.Align.LEFT else Paint.Align.RIGHT
}

val (rawValues, format) = axisLabelValues(chartData)

for (i in rawValues.indices) {
Expand All @@ -461,19 +448,13 @@ fun DrawScope.drawDualYAxisLabels(
* Draws X-axis time labels at 5 positions: start (0%), 25%, 50%, 75%, end (100%).
*/
fun DrawScope.drawTimeLabels(
surfaceColor: Color,
textPaint: Paint,
timeLabels: List<String>,
width: Float,
chartHeight: Float,
timeLabelHeight: Float
) {
drawContext.canvas.nativeCanvas.apply {
val textPaint = Paint().apply {
color = surfaceColor.copy(alpha = 0.7f).toArgb()
textSize = 26f
isAntiAlias = true
}

val timeY = chartHeight + timeLabelHeight - 4f
val positions = listOf(0f, width * 0.25f, width * 0.5f, width * 0.75f, width)

Expand Down
Loading
Loading