mirror of
https://github.com/andwati/ledga.git
synced 2026-10-18 03:12:55 +00:00
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:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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<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. */
|
||||
@Transaction
|
||||
@Query("""
|
||||
|
||||
@@ -151,6 +151,12 @@ class TransactionRepository @Inject constructor(
|
||||
fun getLargeTransactions(threshold: Double): Flow<List<TransactionWithCategory>> =
|
||||
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>> =
|
||||
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.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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
"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),
|
||||
"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(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user