Skip to content
Closed
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
36 changes: 21 additions & 15 deletions app/src/main/java/com/matedroid/ui/screens/charges/ChargesScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package com.matedroid.ui.screens.charges

import android.content.Intent
import android.net.Uri
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
Expand Down Expand Up @@ -61,6 +63,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
Expand Down Expand Up @@ -127,12 +130,12 @@ fun ChargesScreen(
.fillMaxSize()
.padding(padding)
) {
if (uiState.isLoading && !uiState.isRefreshing) {
if (uiState.isLoading && uiState.charges.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
CircularProgressIndicator(color = palette.accent)
}
} else {
ChargesContent(
Expand All @@ -152,8 +155,8 @@ fun ChargesScreen(
onChargeTypeFilterSelected = { viewModel.setChargeTypeFilter(it) },
onChargeClick = { chargeId, scrollIndex, scrollOffset ->
viewModel.saveScrollPosition(scrollIndex, scrollOffset)
onNavigateToChargeDetail(chargeId)
}
onNavigateToChargeDetail(chargeId) },
isLoading = uiState.isLoading
)
}
}
Expand All @@ -174,6 +177,7 @@ private fun ChargesContent(
selectedChargeTypeFilter: ChargeTypeFilter,
initialScrollPosition: Int,
initialScrollOffset: Int,
isLoading: Boolean,
palette: CarColorPalette,
onDateFilterSelected: (DateFilter) -> Unit,
onChargeTypeFilterSelected: (ChargeTypeFilter) -> Unit,
Expand All @@ -184,10 +188,19 @@ private fun ChargesContent(
initialFirstVisibleItemIndex = initialScrollPosition,
initialFirstVisibleItemScrollOffset = initialScrollOffset
)

// Animation of opacity: 0.6f when loading, 1f when ready.
// The "tween" of 300ms makes the change not abrupt.
val contentAlpha by animateFloatAsState(
targetValue = if (isLoading) 0.6f else 1f,
animationSpec = tween(durationMillis = 300),
label = "chargesContentAlpha"
)
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
// Applying the opacity here to make the entire list dim when loading
modifier = Modifier
.fillMaxSize()
.graphicsLayer(alpha = contentAlpha),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Expand All @@ -198,20 +211,16 @@ private fun ChargesContent(
onFilterSelected = onDateFilterSelected
)
}

item {
ChargeTypeFilterChips(
selectedFilter = selectedChargeTypeFilter,
palette = palette,
onFilterSelected = onChargeTypeFilterSelected
)
}

item {
SummaryCard(summary = summary, currencySymbol = currencySymbol, palette = palette)
}

// Charges charts (daily/weekly/monthly based on date range) - swipeable
if (chartData.isNotEmpty()) {
item {
ChargesChartsPager(
Expand All @@ -222,7 +231,6 @@ private fun ChargesContent(
)
}
}

item {
Spacer(modifier = Modifier.height(8.dp))
Text(
Expand All @@ -231,8 +239,8 @@ private fun ChargesContent(
fontWeight = FontWeight.Bold
)
}

if (charges.isEmpty()) {
// only show the empty message if there's no data and we're not loading
if (charges.isEmpty() && !isLoading) {
item {
Card(
modifier = Modifier.fillMaxWidth(),
Expand All @@ -258,8 +266,6 @@ private fun ChargesContent(
items(charges, key = { it.chargeId }) { charge ->
ChargeItem(
charge = charge,
// Show DC badge if in dcChargeIds, AC otherwise
// Will be correct once sync has processed charge details
isDcCharge = charge.chargeId in dcChargeIds,
currencySymbol = currencySymbol,
onEditCost = if (teslamateBaseUrl.isNotBlank()) {
Expand Down
121 changes: 89 additions & 32 deletions app/src/main/java/com/matedroid/ui/screens/charges/ChargesViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.YearMonth
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
Expand Down Expand Up @@ -48,6 +49,9 @@ data class ChargeChartData(
val label: String,
val count: Int,
val totalEnergy: Double,
//val energyAc: Double,
// val energyDc: Double,
val hasDc: Boolean,
val totalCost: Double,
val sortKey: Long // For sorting (epoch day, week number, or year-month)
)
Expand Down Expand Up @@ -125,8 +129,16 @@ class ChargesViewModel @Inject constructor(

fun setDateFilter(filter: DateFilter) {
val endDate = LocalDate.now()
val startDate = filter.days?.let { endDate.minusDays(it) }
_uiState.update { it.copy(startDate = startDate, endDate = if (filter.days != null) endDate else null, selectedFilter = filter) }
val startDate = filter.days?.let { days ->
if (days > 0) endDate.minusDays(days - 1) else endDate
}
_uiState.update { it.copy(
selectedFilter = filter,
startDate = startDate,
endDate = if (filter.days != null) endDate else null,
scrollPosition = 0,
scrollOffset = 0
)}
loadCharges(startDate, if (filter.days != null) endDate else null)
}

Expand All @@ -151,7 +163,11 @@ class ChargesViewModel @Inject constructor(
} else {
filter
}
_uiState.update { it.copy(chargeTypeFilter = newFilter) }
_uiState.update { it.copy(
chargeTypeFilter = newFilter,
scrollPosition = 0,
scrollOffset = 0
)}
applyFiltersAndUpdateState()
}

Expand Down Expand Up @@ -251,7 +267,7 @@ class ChargesViewModel @Inject constructor(

// Calculate summary and chart data from filtered charges
val summary = calculateSummary(chargesForStats)
val chartData = calculateChartData(chargesForStats, granularity)
val chartData = calculateChartData(chargesForStats, granularity, state.startDate)

_uiState.update {
it.copy(
Expand All @@ -274,51 +290,92 @@ class ChargesViewModel @Inject constructor(
}
}

private fun calculateChartData(charges: List<ChargeData>, granularity: ChartGranularity): List<ChargeChartData> {
private fun calculateChartData(charges: List<ChargeData>, granularity: ChartGranularity, startDate: LocalDate?): List<ChargeChartData> {
if (charges.isEmpty()) return emptyList()

val formatter = DateTimeFormatter.ISO_DATE_TIME
val weekFields = WeekFields.of(Locale.getDefault())

return charges
.mapNotNull { charge ->
// Group the charges by day
val chargesByDay = charges.mapNotNull { charge ->
charge.startDate?.let {
try {
// Use of localdatetime to support the full ISO format
val date = LocalDateTime.parse(it, formatter).toLocalDate()
date.toEpochDay() to charge
} catch (e: Exception) { null }
}
}.groupBy({ it.first }, { it.second })

return if (granularity == ChartGranularity.DAILY) {
// DAILY ranges (today, last 7 and last 30 days
// If not startDate (All Time), get the first trip, or today
val start = startDate ?: (chargesByDay.keys.minOrNull()?.let { LocalDate.ofEpochDay(it) } ?: LocalDate.now())
val end = LocalDate.now()
val result = mutableListOf<ChargeChartData>()
var current = start
while (!current.isAfter(end)) {
val key = current.toEpochDay()
val itemsInDay = chargesByDay[key] ?: emptyList()
result.add(
createChargeChartPoint(
label = current.format(DateTimeFormatter.ofPattern("d/M")),
sortKey = key,
charges = itemsInDay,
dcChargeIds = _uiState.value.dcChargeIds
)
)
current = current.plusDays(1)
}
result
} else {
// WEEKLY and MONTHLY ranges
charges.mapNotNull { charge ->
charge.startDate?.let { dateStr ->
try {
val date = LocalDate.parse(dateStr, formatter)
val date = LocalDateTime.parse(dateStr, formatter).toLocalDate()
val (label, sortKey) = when (granularity) {
ChartGranularity.DAILY -> {
val dayLabel = date.format(DateTimeFormatter.ofPattern("d/M"))
dayLabel to date.toEpochDay()
}
ChartGranularity.WEEKLY -> {
val weekOfYear = date.get(weekFields.weekOfWeekBasedYear())
val year = date.get(weekFields.weekBasedYear())
"W$weekOfYear" to (year * 100L + weekOfYear)
val firstDay = date.with(weekFields.dayOfWeek(), 1)
"W${date.get(weekFields.weekOfYear())}" to firstDay.toEpochDay()
}
ChartGranularity.MONTHLY -> {
val yearMonth = YearMonth.of(date.year, date.month)
yearMonth.format(DateTimeFormatter.ofPattern("MMM yy")) to (date.year * 12L + date.monthValue)
else -> { // MONTHLY
date.format(DateTimeFormatter.ofPattern("MMM yy")) to YearMonth.from(date).atDay(1).toEpochDay()
}
}
Triple(label, sortKey, charge)
} catch (e: Exception) {
null
}
} catch (e: Exception) { null }
}
}
.groupBy { Pair(it.first, it.second) }
.map { (key, chargesInPeriod) ->
ChargeChartData(
label = key.first,
count = chargesInPeriod.size,
totalEnergy = chargesInPeriod.sumOf { it.third.chargeEnergyAdded ?: 0.0 },
totalCost = chargesInPeriod.sumOf { it.third.cost ?: 0.0 },
sortKey = key.second
)
}
.sortedBy { it.sortKey }
.groupBy { it.first to it.second }
.map { (key, list) ->
createChargeChartPoint(
key.first,
key.second,
list.map { it.third },
dcChargeIds = _uiState.value.dcChargeIds
)
}
.sortedBy { it.sortKey }
}
}

// Helper function to centralize chart data creation
private fun createChargeChartPoint(label: String, sortKey: Long, charges: List<ChargeData>, dcChargeIds: Set<Int>): ChargeChartData {
//val dcCharges = charges.filter { it.chargeId in dcChargeIds }
//val energyDc = dcCharges.sumOf { it.chargeEnergyAdded ?: 0.0 }
//val energyTotal = charges.sumOf { it.chargeEnergyAdded ?: 0.0 }
return ChargeChartData(
label = label,
count = charges.size,
totalEnergy = charges.sumOf { it.chargeEnergyAdded ?: 0.0 },
//energyAc = energyTotal - energyDc,
//energyDc = energyDc,
totalCost = charges.sumOf { it.cost ?: 0.0 },
sortKey = sortKey,
hasDc = charges.any { it.chargeId in dcChargeIds }
)
}
private fun calculateSummary(charges: List<ChargeData>): ChargesSummary {
if (charges.isEmpty()) return ChargesSummary()

Expand Down
Loading