mirror of
https://github.com/andwati/ledga.git
synced 2026-10-18 03:12:55 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user