v1.4.7: People view (sent/received) + surface Large filter

- New 'People' segment on Activity: browse who you've sent money to or
  received from, aggregated by name with totals and counts. Search by name,
  filter by a minimum total with a slider, toggle Sent/Received, and tap a
  person to see every transaction with them.
- Moved the 'Large' filter chip to the front of the row so it's visible
  without scrolling (it was the last, off-screen chip).
This commit is contained in:
ngenohkevin
2026-06-08 10:46:20 +03:00
parent 57cce3a72c
commit d7ab85f97d
7 changed files with 390 additions and 6 deletions
+2 -2
View File
@@ -27,8 +27,8 @@ android {
applicationId = "com.ledga.app" applicationId = "com.ledga.app"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 16 versionCode = 17
versionName = "1.4.6" versionName = "1.4.7"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -189,6 +189,45 @@ interface TransactionDao {
@Query("SELECT * FROM transactions WHERE transactionCode = :code LIMIT 1") @Query("SELECT * FROM transactions WHERE transactionCode = :code LIMIT 1")
suspend fun getTransactionByCode(code: String): TransactionEntity? suspend fun getTransactionByCode(code: String): TransactionEntity?
/**
* People you've transacted with, aggregated by name: total + count.
* [types] selects the direction — SEND/MPESA_GLOBAL for "sent to",
* RECEIVED for "received from". Filtered by a name fragment and a minimum
* total. Powers the People view.
*/
@Query("""
SELECT recipientName as recipientName, SUM(amount) as totalAmount, COUNT(*) as transactionCount
FROM transactions
WHERE type IN (:types)
AND recipientName IS NOT NULL AND recipientName != ''
AND recipientName LIKE '%' || :query || '%' COLLATE NOCASE
AND (:accountId IS NULL OR accountId = :accountId)
GROUP BY recipientName
HAVING SUM(amount) >= :minTotal
ORDER BY totalAmount DESC
""")
fun getPeopleByTypes(
types: List<TransactionType>,
query: String,
minTotal: Double,
accountId: Long? = null,
): Flow<List<TopMerchant>>
/** A single person's transactions of the given types, newest first. */
@Transaction
@Query("""
SELECT * FROM transactions
WHERE type IN (:types)
AND recipientName = :name
AND (:accountId IS NULL OR accountId = :accountId)
ORDER BY timestamp DESC
""")
fun getTransactionsForRecipient(
name: String,
types: List<TransactionType>,
accountId: Long? = null,
): Flow<List<TransactionWithCategory>>
/** Transactions at or above [threshold] — powers the "Large" filter. */ /** Transactions at or above [threshold] — powers the "Large" filter. */
@Transaction @Transaction
@Query(""" @Query("""
@@ -151,6 +151,12 @@ class TransactionRepository @Inject constructor(
fun getLargeTransactions(threshold: Double): Flow<List<TransactionWithCategory>> = fun getLargeTransactions(threshold: Double): Flow<List<TransactionWithCategory>> =
selectedAccount.flatMapLatest { transactionDao.getLargeTransactions(threshold, it) } selectedAccount.flatMapLatest { transactionDao.getLargeTransactions(threshold, it) }
fun getPeopleByTypes(types: List<TransactionType>, query: String, minTotal: Double): Flow<List<TopMerchant>> =
selectedAccount.flatMapLatest { transactionDao.getPeopleByTypes(types, query, minTotal, it) }
fun getTransactionsForRecipient(name: String, types: List<TransactionType>): Flow<List<TransactionWithCategory>> =
selectedAccount.flatMapLatest { transactionDao.getTransactionsForRecipient(name, types, it) }
fun getDailySpending(startTime: Long, endTime: Long): Flow<List<DailySpending>> = fun getDailySpending(startTime: Long, endTime: Long): Flow<List<DailySpending>> =
selectedAccount.flatMapLatest { transactionDao.getDailySpending(startTime, endTime, it) } selectedAccount.flatMapLatest { transactionDao.getDailySpending(startTime, endTime, it) }
@@ -23,10 +23,11 @@ import androidx.compose.foundation.clickable
import com.ledga.app.ui.components.v2.LedgaTopBar import com.ledga.app.ui.components.v2.LedgaTopBar
import com.ledga.app.ui.theme.LedgaText import com.ledga.app.ui.theme.LedgaText
import com.ledga.app.ui.theme.Space import com.ledga.app.ui.theme.Space
import com.ledga.app.ui.people.PeopleScreen
import com.ledga.app.ui.transactions.TransactionsScreen import com.ledga.app.ui.transactions.TransactionsScreen
import com.ledga.app.ui.trends.TrendsScreen import com.ledga.app.ui.trends.TrendsScreen
private enum class Segment { Transactions, Trends } private enum class Segment { Transactions, People, Trends }
/** /**
* Activity tab — pairs the Transactions list and Trends analytics behind * Activity tab — pairs the Transactions list and Trends analytics behind
@@ -51,6 +52,7 @@ fun ActivityScreen() {
when (segment) { when (segment) {
Segment.Transactions -> TransactionsScreen() Segment.Transactions -> TransactionsScreen()
Segment.People -> PeopleScreen()
Segment.Trends -> TrendsScreen() Segment.Trends -> TrendsScreen()
} }
} }
@@ -0,0 +1,251 @@
package com.ledga.app.ui.people
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.ledga.app.data.db.dao.TopMerchant
import com.ledga.app.data.parser.TransactionDirection
import com.ledga.app.ui.components.parseColor
import com.ledga.app.ui.components.v2.Avatar
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
import com.ledga.app.util.CurrencyFormatter
@Composable
fun PeopleScreen(viewModel: PeopleViewModel = hiltViewModel()) {
val state by viewModel.uiState.collectAsState()
if (state.selectedPerson != null) {
PersonSheet(
name = state.selectedPerson!!,
modeLabel = state.mode.label,
transactions = state.selectedPersonTransactions,
onDismiss = { viewModel.clearSelection() },
)
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = Space.Screen),
) {
// Sent / Received toggle
Row(horizontalArrangement = Arrangement.spacedBy(Space.s3)) {
PeopleMode.entries.forEach { mode ->
LedgaChip(
label = mode.label,
selected = state.mode == mode,
onClick = { viewModel.setMode(mode) },
)
}
}
Spacer(modifier = Modifier.height(Space.s4))
LedgaSearchField(
value = state.query,
onValueChange = { viewModel.setQuery(it) },
placeholder = "Search people by name…",
)
MinTotalSlider(
minTotal = state.minTotal,
onChange = { viewModel.setMinTotal(it) },
)
Spacer(modifier = Modifier.height(Space.s4))
if (state.people.isEmpty()) {
BentoCard {
Text(
text = if (state.query.isNotBlank() || state.minTotal > 0)
"No people match these filters."
else "No ${state.mode.label.lowercase()} people yet.",
style = LedgaText.BodyM,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(Space.s4),
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 140.dp),
) {
item {
Text(
text = "${state.people.size} ${if (state.people.size == 1) "person" else "people"}",
style = LedgaText.Caption,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
items(state.people, key = { it.recipientName }) { person ->
PersonRow(
person = person,
isInflow = state.mode == PeopleMode.RECEIVED,
onClick = { viewModel.selectPerson(person.recipientName) },
)
}
}
}
}
}
@Composable
private fun PersonRow(person: TopMerchant, isInflow: Boolean, onClick: () -> Unit) {
BentoCard(onClick = onClick) {
Row(verticalAlignment = Alignment.CenterVertically) {
Avatar(
initials = initialsOf(person.recipientName),
color = LedgaAccent,
size = 40.dp,
)
Column(
modifier = Modifier
.weight(1f)
.padding(start = Space.s4),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = person.recipientName,
style = LedgaText.BodyL,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = "${person.transactionCount} ${if (person.transactionCount == 1) "transaction" else "transactions"}",
style = LedgaText.Caption,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
text = CurrencyFormatter.formatKshCompact(person.totalAmount),
style = LedgaText.TitleS,
color = if (isInflow) LedgaInflow else MaterialTheme.colorScheme.onSurface,
)
}
}
}
@Composable
private fun MinTotalSlider(minTotal: Double, onChange: (Double) -> Unit) {
var value by remember(minTotal) { mutableStateOf(minTotal.toFloat()) }
Column(modifier = Modifier.padding(top = Space.s4)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "Total at least",
style = LedgaText.Caption,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Text(
text = if (value <= 0f) "Any amount"
else CurrencyFormatter.formatKshCompact(value.toDouble()),
style = LedgaText.TitleS,
color = MaterialTheme.colorScheme.onSurface,
)
}
Slider(
value = value,
onValueChange = { value = it },
onValueChangeFinished = { onChange(value.toDouble()) },
valueRange = 0f..100_000f,
steps = 99, // 1K increments
colors = SliderDefaults.colors(
thumbColor = LedgaAccent,
activeTrackColor = LedgaAccent,
),
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PersonSheet(
name: String,
modeLabel: String,
transactions: List<com.ledga.app.data.db.entity.TransactionWithCategory>,
onDismiss: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val total = transactions.sumOf { it.transaction.amount }
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp)
.padding(bottom = 24.dp),
) {
Text(text = name, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text(
text = "${modeLabel.lowercase()} · ${CurrencyFormatter.formatKsh(total)} over ${transactions.size} " +
if (transactions.size == 1) "transaction" else "transactions",
style = LedgaText.BodyM,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(12.dp))
HorizontalDivider()
LazyColumn(
modifier = Modifier.fillMaxWidth(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 8.dp),
) {
items(transactions, key = { it.transaction.id }) { twc ->
TransactionRowV2(
recipient = twc.transaction.recipientName ?: name,
amount = twc.transaction.amount,
isInflow = twc.transaction.direction == TransactionDirection.INFLOW,
category = twc.category?.name,
color = twc.category?.color?.let(::parseColor)
?: androidx.compose.ui.graphics.Color.Gray,
icon = twc.category?.icon ?: "person",
balance = twc.transaction.balance,
timestamp = twc.transaction.timestamp,
onClick = {},
)
}
}
}
}
}
private fun initialsOf(name: String): String =
name.trim().split(Regex("\\s+"))
.mapNotNull { it.firstOrNull()?.toString() }
.take(2)
.joinToString("")
.uppercase()
.ifEmpty { "?" }
@@ -0,0 +1,85 @@
package com.ledga.app.ui.people
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.ledga.app.data.db.dao.TopMerchant
import com.ledga.app.data.db.entity.TransactionWithCategory
import com.ledga.app.data.parser.TransactionType
import com.ledga.app.data.repository.TransactionRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
/** Direction of money flow for the People view. */
enum class PeopleMode(val label: String, val types: List<TransactionType>) {
SENT("Sent to", listOf(TransactionType.SEND, TransactionType.MPESA_GLOBAL)),
RECEIVED("Received from", listOf(TransactionType.RECEIVED)),
}
data class PeopleUiState(
val mode: PeopleMode = PeopleMode.SENT,
val query: String = "",
val minTotal: Double = 0.0,
val people: List<TopMerchant> = emptyList(),
/** When non-null, the drill-down sheet shows this person's transactions. */
val selectedPerson: String? = null,
val selectedPersonTransactions: List<TransactionWithCategory> = emptyList(),
)
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
@HiltViewModel
class PeopleViewModel @Inject constructor(
private val transactionRepository: TransactionRepository,
) : ViewModel() {
private val _mode = MutableStateFlow(PeopleMode.SENT)
private val _query = MutableStateFlow("")
private val _minTotal = MutableStateFlow(0.0)
private val _selectedPerson = MutableStateFlow<String?>(null)
private val people = combine(
_mode,
_query.debounce(250),
_minTotal,
) { mode, q, min -> Triple(mode, q, min) }
.flatMapLatest { (mode, q, min) ->
transactionRepository.getPeopleByTypes(mode.types, q, min)
}
private val selectedTransactions = combine(_selectedPerson, _mode) { name, mode -> name to mode }
.flatMapLatest { (name, mode) ->
if (name == null) flowOf(emptyList())
else transactionRepository.getTransactionsForRecipient(name, mode.types)
}
val uiState: StateFlow<PeopleUiState> = combine(
combine(_mode, _query, _minTotal) { mode, q, min -> Triple(mode, q, min) },
people,
_selectedPerson,
selectedTransactions,
) { (mode, query, minTotal), people, selected, txns ->
PeopleUiState(
mode = mode,
query = query,
minTotal = minTotal,
people = people,
selectedPerson = selected,
selectedPersonTransactions = txns,
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PeopleUiState())
fun setMode(mode: PeopleMode) { _mode.value = mode }
fun setQuery(value: String) { _query.value = value }
fun setMinTotal(value: Double) { _minTotal.value = value }
fun selectPerson(name: String) { _selectedPerson.value = name }
fun clearSelection() { _selectedPerson.value = null }
}
@@ -65,14 +65,15 @@ class TransactionsViewModel @Inject constructor(
val FILTERS = mapOf( val FILTERS = mapOf(
"All" to emptyList<TransactionType>(), "All" to emptyList<TransactionType>(),
// Amount threshold, not a type — resolved against the user's
// large-transaction setting (default Ksh5,000). Kept near the front
// so it's visible without scrolling the chip row.
LARGE_FILTER to emptyList(),
"Sent" to listOf(TransactionType.SEND, TransactionType.MPESA_GLOBAL), "Sent" to listOf(TransactionType.SEND, TransactionType.MPESA_GLOBAL),
"Received" to listOf(TransactionType.RECEIVED), "Received" to listOf(TransactionType.RECEIVED),
"Bills" to listOf(TransactionType.PAY_BILL), "Bills" to listOf(TransactionType.PAY_BILL),
"Goods" to listOf(TransactionType.BUY_GOODS), "Goods" to listOf(TransactionType.BUY_GOODS),
"Withdraw" to listOf(TransactionType.WITHDRAW_AGENT, TransactionType.WITHDRAW_ATM), "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(),
) )
} }