v1.4.6: large-transactions slider, heatmap fix, insight contrast

- 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.
This commit is contained in:
ngenohkevin
2026-06-08 09:40:41 +03:00
parent 0a80be72a2
commit 13a92d18f2
7 changed files with 194 additions and 96 deletions
+2 -2
View File
@@ -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"
}
@@ -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<List<TransactionWithCategory>>
@Transaction
@Query("SELECT * FROM transactions WHERE type = 'UNKNOWN' ORDER BY timestamp DESC")
fun getUnparsedTransactions(): Flow<List<TransactionWithCategory>>
/*
* 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
@@ -148,6 +148,9 @@ class TransactionRepository @Inject constructor(
fun getTransactionsByType(types: List<TransactionType>): Flow<List<TransactionWithCategory>> =
selectedAccount.flatMapLatest { transactionDao.getTransactionsByType(types, it) }
fun getLargeTransactions(threshold: Double): Flow<List<TransactionWithCategory>> =
selectedAccount.flatMapLatest { transactionDao.getLargeTransactions(threshold, it) }
fun getDailySpending(startTime: Long, endTime: Long): Flow<List<DailySpending>> =
selectedAccount.flatMapLatest { transactionDao.getDailySpending(startTime, endTime, it) }
@@ -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<Long, Double>,
@@ -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)),
)
}
@@ -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),
)
}
}
@@ -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<TransactionWithCategory>): DayTotals {
@@ -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<TransactionType>(),
"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<String>,
)
/** Current "large" threshold (shared with the large-transaction alert). */
val largeThreshold: StateFlow<Double> = 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
}