From 13a92d18f2e0b63e0649eda64c9eaf2ddb296538 Mon Sep 17 00:00:00 2001 From: ngenohkevin Date: Mon, 8 Jun 2026 09:40:41 +0300 Subject: [PATCH] v1.4.6: large-transactions slider, heatmap fix, insight contrast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New 'Large' tab on Transactions with a slider (Ksh 1K–100K) to set the threshold live — drag for bigger/smaller. Shares the large-transaction alert threshold, so it's now adjustable from the UI for the first time. - Spending heatmap was blank for non-UTC users: getDailySpending bucketed by UTC midnight while the grid looked up local-midnight keys (3h off in EAT → every cell zero). Now buckets by local day. Also: cells size to fit so the 24-week year view no longer overflows, and the legend shows the real 5-step alpha ramp. - Insights 'All clear' empty-state body text was onSurface on a light card (invisible in dark mode) — now derives ink from the card like the rest. --- app/build.gradle.kts | 4 +- .../ledga/app/data/db/dao/TransactionDao.kt | 20 +- .../data/repository/TransactionRepository.kt | 3 + .../app/ui/components/v2/HeatmapCalendar.kt | 171 +++++++++--------- .../ledga/app/ui/insights/InsightsScreen.kt | 4 +- .../app/ui/transactions/TransactionsScreen.kt | 54 ++++++ .../ui/transactions/TransactionsViewModel.kt | 34 +++- 7 files changed, 194 insertions(+), 96 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8ba23c0..ac5aacb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -27,8 +27,8 @@ android { applicationId = "com.ledga.app" minSdk = 26 targetSdk = 34 - versionCode = 15 - versionName = "1.4.5" + versionCode = 16 + versionName = "1.4.6" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/com/ledga/app/data/db/dao/TransactionDao.kt b/app/src/main/java/com/ledga/app/data/db/dao/TransactionDao.kt index 087c2ae..3b509c6 100644 --- a/app/src/main/java/com/ledga/app/data/db/dao/TransactionDao.kt +++ b/app/src/main/java/com/ledga/app/data/db/dao/TransactionDao.kt @@ -189,12 +189,30 @@ interface TransactionDao { @Query("SELECT * FROM transactions WHERE transactionCode = :code LIMIT 1") suspend fun getTransactionByCode(code: String): TransactionEntity? + /** Transactions at or above [threshold] — powers the "Large" filter. */ + @Transaction + @Query(""" + SELECT * FROM transactions + WHERE amount >= :threshold + AND (:accountId IS NULL OR accountId = :accountId) + ORDER BY timestamp DESC + """) + fun getLargeTransactions(threshold: Double, accountId: Long? = null): Flow> + @Transaction @Query("SELECT * FROM transactions WHERE type = 'UNKNOWN' ORDER BY timestamp DESC") fun getUnparsedTransactions(): Flow> + /* + * dayTimestamp is the epoch-ms of LOCAL midnight for each transaction's + * day, so it matches the device-local day keys the heatmap builds with + * Calendar. A naive (timestamp/86400000)*86400000 buckets by UTC midnight, + * which is offset from local midnight (e.g. +3h in EAT) — the heatmap keys + * then never match and every cell reads zero. + */ @Query(""" - SELECT (timestamp / 86400000) * 86400000 as dayTimestamp, SUM(amount) as totalAmount + SELECT CAST(strftime('%s', timestamp / 1000, 'unixepoch', 'localtime', 'start of day', 'utc') AS INTEGER) * 1000 as dayTimestamp, + SUM(amount) as totalAmount FROM transactions WHERE direction = 'OUTFLOW' AND timestamp BETWEEN :startTime AND :endTime diff --git a/app/src/main/java/com/ledga/app/data/repository/TransactionRepository.kt b/app/src/main/java/com/ledga/app/data/repository/TransactionRepository.kt index 6f733dd..3191560 100644 --- a/app/src/main/java/com/ledga/app/data/repository/TransactionRepository.kt +++ b/app/src/main/java/com/ledga/app/data/repository/TransactionRepository.kt @@ -148,6 +148,9 @@ class TransactionRepository @Inject constructor( fun getTransactionsByType(types: List): Flow> = selectedAccount.flatMapLatest { transactionDao.getTransactionsByType(types, it) } + fun getLargeTransactions(threshold: Double): Flow> = + selectedAccount.flatMapLatest { transactionDao.getLargeTransactions(threshold, it) } + fun getDailySpending(startTime: Long, endTime: Long): Flow> = selectedAccount.flatMapLatest { transactionDao.getDailySpending(startTime, endTime, it) } diff --git a/app/src/main/java/com/ledga/app/ui/components/v2/HeatmapCalendar.kt b/app/src/main/java/com/ledga/app/ui/components/v2/HeatmapCalendar.kt index f4ae1a8..643fa8e 100644 --- a/app/src/main/java/com/ledga/app/ui/components/v2/HeatmapCalendar.kt +++ b/app/src/main/java/com/ledga/app/ui/components/v2/HeatmapCalendar.kt @@ -3,10 +3,10 @@ package com.ledga.app.ui.components.v2 import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row 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.shape.RoundedCornerShape @@ -17,6 +17,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.unit.Dp import androidx.compose.ui.unit.dp import com.ledga.app.ui.theme.LedgaAccent import com.ledga.app.ui.theme.LedgaText @@ -25,14 +26,18 @@ import java.util.Calendar /** * GitHub-style heatmap calendar — one cell per day, columns are weeks. * - * Spec from LEDGA_REDESIGN.md §4.4: "low ▢▢▢▢ high" intensity legend - * using --accent at 8/24/56/88% opacity (plus an empty step at ~6% for - * days with zero spend). + * [dailyAmounts] maps a day-start timestamp (epoch ms at LOCAL midnight) to a + * total spend amount for that day. The DAO buckets by local midnight too, so + * the keys line up. Days that don't appear render as empty cells. * - * [dailyAmounts] maps a day-start timestamp (epoch ms, normalized to - * 00:00:00) to a total spend amount for that day. Days that don't appear - * in the map render as empty cells. + * Cell size adapts to the available width so wide ranges (e.g. a 24-week year + * view) never overflow the card and clip the earliest weeks. */ + +// Alpha ramp shared by cells + legend so they always agree. Index 0 = empty. +private val HEATMAP_ALPHAS = listOf(0.06f, 0.18f, 0.34f, 0.60f, 0.92f) +private val CELL_GAP = 4.dp + @Composable fun HeatmapCalendar( dailyAmounts: Map, @@ -62,55 +67,62 @@ fun HeatmapCalendar( modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp), ) { - // Day-of-week labels row + cell grid as one block, with the day labels on the left - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.Top, - ) { - // Day labels column — only show M/W/F to avoid clutter. - Column( - modifier = Modifier.padding(end = 4.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + // Reserve the day-label column (~16dp) + the gap before the grid, + // then split the rest across the week columns so they always fit. + val labelReserve = 20.dp + val cellSize: Dp = ((maxWidth - labelReserve - CELL_GAP * (weeks - 1)) / weeks) + .coerceIn(6.dp, 14.dp) + + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.Top, ) { - listOf("M", "", "W", "", "F", "", "").forEach { letter -> - Box( - modifier = Modifier.size(width = 12.dp, height = 14.dp), - contentAlignment = Alignment.Center, - ) { - if (letter.isNotEmpty()) { - Text( - text = letter, - style = LedgaText.Caption, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + // Day labels column — only show M/W/F to avoid clutter. + Column( + modifier = Modifier.padding(end = 4.dp), + verticalArrangement = Arrangement.spacedBy(CELL_GAP), + ) { + listOf("M", "", "W", "", "F", "", "").forEach { letter -> + Box( + modifier = Modifier.size(width = 12.dp, height = cellSize), + contentAlignment = Alignment.Center, + ) { + if (letter.isNotEmpty()) { + Text( + text = letter, + style = LedgaText.Caption, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } } - } - // Week columns - Row( - modifier = Modifier.weight(1f), - horizontalArrangement = Arrangement.spacedBy(4.dp, alignment = Alignment.End), - ) { - val cellsPerWeek = 7 - repeat(weeks) { weekIndex -> - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - repeat(cellsPerWeek) { dayIndex -> - val cellDay = Calendar.getInstance().apply { - timeInMillis = firstDay.timeInMillis - add(Calendar.WEEK_OF_YEAR, weekIndex) - add(Calendar.DAY_OF_YEAR, dayIndex) + // Week columns + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(CELL_GAP, alignment = Alignment.End), + ) { + repeat(weeks) { weekIndex -> + Column( + verticalArrangement = Arrangement.spacedBy(CELL_GAP), + ) { + repeat(7) { dayIndex -> + val cellDay = Calendar.getInstance().apply { + timeInMillis = firstDay.timeInMillis + add(Calendar.WEEK_OF_YEAR, weekIndex) + add(Calendar.DAY_OF_YEAR, dayIndex) + } + val isFuture = cellDay.timeInMillis > today.timeInMillis + val amount = dailyAmounts[cellDay.timeInMillis] ?: 0.0 + HeatmapCell( + amount = amount, + maxAmount = maxAmount, + isFuture = isFuture, + cellSize = cellSize, + ) } - val isFuture = cellDay.timeInMillis > today.timeInMillis - val amount = dailyAmounts[cellDay.timeInMillis] ?: 0.0 - HeatmapCell( - amount = amount, - maxAmount = maxAmount, - isFuture = isFuture, - ) } } } @@ -132,8 +144,13 @@ fun HeatmapCalendar( modifier = Modifier.padding(horizontal = 6.dp), horizontalArrangement = Arrangement.spacedBy(3.dp), ) { - listOf(0.0, 0.2, 0.5, 0.8, 1.0).forEach { intensity -> - LegendCell(intensity) + HEATMAP_ALPHAS.forEach { alpha -> + Box( + modifier = Modifier + .size(10.dp) + .clip(RoundedCornerShape(2.dp)) + .background(LedgaAccent.copy(alpha = alpha)), + ) } } Text( @@ -145,43 +162,27 @@ fun HeatmapCalendar( } } -@Composable -private fun HeatmapCell(amount: Double, maxAmount: Double, isFuture: Boolean) { - val color = when { - isFuture -> Color.Transparent - amount <= 0.0 -> LedgaAccent.copy(alpha = 0.06f) - else -> { - val intensity = (amount / maxAmount).coerceIn(0.0, 1.0) - val alpha = when { - intensity < 0.25 -> 0.18f - intensity < 0.50 -> 0.34f - intensity < 0.75 -> 0.60f - else -> 0.92f - } - LedgaAccent.copy(alpha = alpha) - } +/** Map a spend amount to one of the non-empty alpha steps (index 1..4). */ +private fun alphaFor(amount: Double, maxAmount: Double): Float { + if (amount <= 0.0) return HEATMAP_ALPHAS[0] + val intensity = (amount / maxAmount).coerceIn(0.0, 1.0) + val step = when { + intensity < 0.25 -> 1 + intensity < 0.50 -> 2 + intensity < 0.75 -> 3 + else -> 4 } + return HEATMAP_ALPHAS[step] +} + +@Composable +private fun HeatmapCell(amount: Double, maxAmount: Double, isFuture: Boolean, cellSize: Dp) { + val color = if (isFuture) Color.Transparent + else LedgaAccent.copy(alpha = alphaFor(amount, maxAmount)) Box( modifier = Modifier - .size(14.dp) + .size(cellSize) .clip(RoundedCornerShape(3.dp)) .background(color), ) } - -@Composable -private fun LegendCell(intensity: Double) { - val alpha = when { - intensity <= 0.0 -> 0.06f - intensity < 0.25 -> 0.18f - intensity < 0.50 -> 0.34f - intensity < 0.75 -> 0.60f - else -> 0.92f - } - Box( - modifier = Modifier - .size(10.dp) - .clip(RoundedCornerShape(2.dp)) - .background(LedgaAccent.copy(alpha = alpha)), - ) -} diff --git a/app/src/main/java/com/ledga/app/ui/insights/InsightsScreen.kt b/app/src/main/java/com/ledga/app/ui/insights/InsightsScreen.kt index a5a3b23..84739a8 100644 --- a/app/src/main/java/com/ledga/app/ui/insights/InsightsScreen.kt +++ b/app/src/main/java/com/ledga/app/ui/insights/InsightsScreen.kt @@ -205,7 +205,9 @@ private fun EmptyState() { text = "Ledga checks your activity every day and surfaces anything " + "worth your attention here. Quiet means things look healthy.", style = LedgaText.BodyM, - color = MaterialTheme.colorScheme.onSurface, + // Fixed light-green tonal card — derive ink from it, not the theme + // surface (onSurface is near-white in dark mode → invisible here). + color = onTonal(LedgaAccentSoft), ) } } diff --git a/app/src/main/java/com/ledga/app/ui/transactions/TransactionsScreen.kt b/app/src/main/java/com/ledga/app/ui/transactions/TransactionsScreen.kt index eec2173..64b09e2 100644 --- a/app/src/main/java/com/ledga/app/ui/transactions/TransactionsScreen.kt +++ b/app/src/main/java/com/ledga/app/ui/transactions/TransactionsScreen.kt @@ -14,11 +14,15 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -30,6 +34,7 @@ import com.ledga.app.ui.components.v2.BentoCard import com.ledga.app.ui.components.v2.LedgaChip import com.ledga.app.ui.components.v2.LedgaSearchField import com.ledga.app.ui.components.v2.TransactionRowV2 +import com.ledga.app.ui.theme.LedgaAccent import com.ledga.app.ui.theme.LedgaInflow import com.ledga.app.ui.theme.LedgaText import com.ledga.app.ui.theme.Space @@ -105,6 +110,16 @@ fun TransactionsScreen( } } + // When the Large tab is active, a slider sets the "large" threshold — + // drag right for only the biggest transactions, left to include smaller. + if (state.activeFilter == TransactionsViewModel.LARGE_FILTER) { + val threshold by viewModel.largeThreshold.collectAsState() + LargeThresholdSlider( + threshold = threshold, + onThresholdChange = { viewModel.setLargeThreshold(it) }, + ) + } + Spacer(modifier = Modifier.height(Space.s5)) LazyColumn( @@ -210,6 +225,45 @@ private fun DayGroupCard( } } +/** + * Slider that picks the "large" threshold (Ksh 1K–100K, in 1K steps). Drags + * update the label live; the list re-filters once on release to avoid a DB + * query on every tick. Resets to the stored value if it changes elsewhere. + */ +@Composable +private fun LargeThresholdSlider( + threshold: Double, + onThresholdChange: (Double) -> Unit, +) { + var sliderValue by remember(threshold) { mutableStateOf(threshold.toFloat()) } + Column(modifier = Modifier.padding(top = Space.s4)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "Transactions of at least", + style = LedgaText.Caption, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + Text( + text = CurrencyFormatter.formatKshCompact(sliderValue.toDouble()), + style = LedgaText.TitleS, + color = MaterialTheme.colorScheme.onSurface, + ) + } + Slider( + value = sliderValue, + onValueChange = { sliderValue = it }, + onValueChangeFinished = { onThresholdChange(sliderValue.toDouble()) }, + valueRange = 1_000f..100_000f, + steps = 98, // 1K increments across 1K–100K + colors = SliderDefaults.colors( + thumbColor = LedgaAccent, + activeTrackColor = LedgaAccent, + ), + ) + } +} + private data class DayTotals(val inflow: Double, val outflow: Double) private fun computeDayTotals(transactions: List): DayTotals { diff --git a/app/src/main/java/com/ledga/app/ui/transactions/TransactionsViewModel.kt b/app/src/main/java/com/ledga/app/ui/transactions/TransactionsViewModel.kt index 0551d74..9b7b440 100644 --- a/app/src/main/java/com/ledga/app/ui/transactions/TransactionsViewModel.kt +++ b/app/src/main/java/com/ledga/app/ui/transactions/TransactionsViewModel.kt @@ -10,6 +10,7 @@ import com.ledga.app.data.parser.TransactionType import com.ledga.app.data.repository.AccountsRepository import com.ledga.app.data.repository.CategoryRepository import com.ledga.app.data.repository.GoalsRepository +import com.ledga.app.data.repository.SettingsRepository import com.ledga.app.data.repository.TransactionRepository import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf @@ -52,12 +53,16 @@ class TransactionsViewModel @Inject constructor( private val categoryRepository: CategoryRepository, private val accountsRepository: AccountsRepository, private val goalsRepository: GoalsRepository, + private val settingsRepository: SettingsRepository, ) : ViewModel() { private val _searchQuery = MutableStateFlow("") private val _activeFilter = MutableStateFlow("All") companion object { + /** Amount-based filter (not a transaction type) — handled specially. */ + const val LARGE_FILTER = "Large" + val FILTERS = mapOf( "All" to emptyList(), "Sent" to listOf(TransactionType.SEND, TransactionType.MPESA_GLOBAL), @@ -65,20 +70,27 @@ class TransactionsViewModel @Inject constructor( "Bills" to listOf(TransactionType.PAY_BILL), "Goods" to listOf(TransactionType.BUY_GOODS), "Withdraw" to listOf(TransactionType.WITHDRAW_AGENT, TransactionType.WITHDRAW_ATM), + // Amount threshold, not a type — resolved against the user's + // large-transaction setting (default Ksh5,000, configurable). + LARGE_FILTER to emptyList(), ) } private val transactions = combine( _searchQuery.debounce(300), - _activeFilter - ) { query, filter -> - Pair(query, filter) - }.flatMapLatest { (query, filter) -> - val types = FILTERS[filter] ?: emptyList() + _activeFilter, + settingsRepository.getLargeTransactionThreshold(), + ) { query, filter, threshold -> + Triple(query, filter, threshold) + }.flatMapLatest { (query, filter, threshold) -> when { query.isNotBlank() -> transactionRepository.searchTransactions(query) - types.isNotEmpty() -> transactionRepository.getTransactionsByType(types) - else -> transactionRepository.getRecentTransactions(200) + filter == LARGE_FILTER -> transactionRepository.getLargeTransactions(threshold) + else -> { + val types = FILTERS[filter] ?: emptyList() + if (types.isNotEmpty()) transactionRepository.getTransactionsByType(types) + else transactionRepository.getRecentTransactions(200) + } } } @@ -131,6 +143,10 @@ class TransactionsViewModel @Inject constructor( val ownFragments: List, ) + /** Current "large" threshold (shared with the large-transaction alert). */ + val largeThreshold: StateFlow = settingsRepository.getLargeTransactionThreshold() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 5000.0) + fun setSearchQuery(query: String) { _searchQuery.value = query } @@ -139,6 +155,10 @@ class TransactionsViewModel @Inject constructor( _activeFilter.value = filter } + fun setLargeThreshold(amount: Double) { + viewModelScope.launch { settingsRepository.setLargeTransactionThreshold(amount) } + } + fun selectTransaction(twc: TransactionWithCategory) { _selectedTransaction.value = twc }