From d7ab85f97de0c7d95df1899bd89ae042b6b50bf2 Mon Sep 17 00:00:00 2001 From: ngenohkevin Date: Mon, 8 Jun 2026 10:46:20 +0300 Subject: [PATCH] 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). --- app/build.gradle.kts | 4 +- .../ledga/app/data/db/dao/TransactionDao.kt | 39 +++ .../data/repository/TransactionRepository.kt | 6 + .../ledga/app/ui/activity/ActivityScreen.kt | 4 +- .../com/ledga/app/ui/people/PeopleScreen.kt | 251 ++++++++++++++++++ .../ledga/app/ui/people/PeopleViewModel.kt | 85 ++++++ .../ui/transactions/TransactionsViewModel.kt | 7 +- 7 files changed, 390 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/ledga/app/ui/people/PeopleScreen.kt create mode 100644 app/src/main/java/com/ledga/app/ui/people/PeopleViewModel.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ac5aacb..6819fd0 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 = 16 - versionName = "1.4.6" + versionCode = 17 + versionName = "1.4.7" 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 3b509c6..63ec07e 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,6 +189,45 @@ interface TransactionDao { @Query("SELECT * FROM transactions WHERE transactionCode = :code LIMIT 1") 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, + query: String, + minTotal: Double, + accountId: Long? = null, + ): Flow> + + /** 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, + accountId: Long? = null, + ): Flow> + /** Transactions at or above [threshold] — powers the "Large" filter. */ @Transaction @Query(""" 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 3191560..71e9c3f 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 @@ -151,6 +151,12 @@ class TransactionRepository @Inject constructor( fun getLargeTransactions(threshold: Double): Flow> = selectedAccount.flatMapLatest { transactionDao.getLargeTransactions(threshold, it) } + fun getPeopleByTypes(types: List, query: String, minTotal: Double): Flow> = + selectedAccount.flatMapLatest { transactionDao.getPeopleByTypes(types, query, minTotal, it) } + + fun getTransactionsForRecipient(name: String, types: List): Flow> = + selectedAccount.flatMapLatest { transactionDao.getTransactionsForRecipient(name, types, 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/activity/ActivityScreen.kt b/app/src/main/java/com/ledga/app/ui/activity/ActivityScreen.kt index 9e4e679..4fbc1e7 100644 --- a/app/src/main/java/com/ledga/app/ui/activity/ActivityScreen.kt +++ b/app/src/main/java/com/ledga/app/ui/activity/ActivityScreen.kt @@ -23,10 +23,11 @@ import androidx.compose.foundation.clickable import com.ledga.app.ui.components.v2.LedgaTopBar import com.ledga.app.ui.theme.LedgaText 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.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 @@ -51,6 +52,7 @@ fun ActivityScreen() { when (segment) { Segment.Transactions -> TransactionsScreen() + Segment.People -> PeopleScreen() Segment.Trends -> TrendsScreen() } } diff --git a/app/src/main/java/com/ledga/app/ui/people/PeopleScreen.kt b/app/src/main/java/com/ledga/app/ui/people/PeopleScreen.kt new file mode 100644 index 0000000..fe0c7db --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/people/PeopleScreen.kt @@ -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, + 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 { "?" } diff --git a/app/src/main/java/com/ledga/app/ui/people/PeopleViewModel.kt b/app/src/main/java/com/ledga/app/ui/people/PeopleViewModel.kt new file mode 100644 index 0000000..668938a --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/people/PeopleViewModel.kt @@ -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) { + 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 = emptyList(), + /** When non-null, the drill-down sheet shows this person's transactions. */ + val selectedPerson: String? = null, + val selectedPersonTransactions: List = 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(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 = 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 } +} 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 9b7b440..8cb68b2 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 @@ -65,14 +65,15 @@ class TransactionsViewModel @Inject constructor( val FILTERS = mapOf( "All" to emptyList(), + // 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), "Received" to listOf(TransactionType.RECEIVED), "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(), ) }