Fix parser + redesign UI

Parser fixes (838 → ~0 unparsed):
- Fuliza auto-deduction (full/partial pay from balance)
- Withdraw alt format (Withdraw Ksh from agent)
- Reversal alt format (Reversal of transaction)
- Airtime self with lowercase confirmed
- Hustler Fund with 4-digit year dates
- Balance check messages filtered out
- SMS normalization (newlines, extra spaces)
- Amount regex handles Ksh with/without space

UI improvements:
- Category-specific Material icons (not generic cart)
- Colored icon backgrounds with rounded corners
- Green balance card with M-Pesa branding
- Category breakdown section (where your money went)
- Balance uses last non-zero transaction
- Better card shapes (20dp rounded)
- Font weight hierarchy
This commit is contained in:
ngenohkevin
2026-03-21 22:11:36 +03:00
parent 0f2ad432d6
commit 729ef15cd3
9 changed files with 442 additions and 101 deletions
@@ -46,6 +46,9 @@ interface TransactionDao {
@Query("SELECT * FROM transactions ORDER BY timestamp DESC LIMIT 1")
fun getLatestTransaction(): Flow<TransactionEntity?>
@Query("SELECT * FROM transactions WHERE balance > 0 ORDER BY timestamp DESC LIMIT 1")
fun getLatestTransactionWithBalance(): Flow<TransactionEntity?>
@Query("""
SELECT categoryId, SUM(amount) as totalAmount
FROM transactions
@@ -6,6 +6,7 @@ import androidx.room.Index
import androidx.room.PrimaryKey
import com.ledga.app.data.parser.TransactionDirection
import com.ledga.app.data.parser.TransactionType
// TransactionType includes: FULIZA_AUTO_PAY (auto-deduction from balance)
@Entity(
tableName = "transactions",
@@ -5,7 +5,8 @@ import com.ledga.app.util.DateUtils
enum class TransactionType {
SEND, BUY_GOODS, PAY_BILL, WITHDRAW_AGENT, WITHDRAW_ATM,
DEPOSIT, RECEIVED, AIRTIME_SELF, AIRTIME_OTHER, MPESA_GLOBAL,
FULIZA, FULIZA_REPAYMENT, FULIZA_REVERSAL, MSHWARI, KCB_MPESA,
FULIZA, FULIZA_REPAYMENT, FULIZA_REVERSAL, FULIZA_AUTO_PAY,
MSHWARI, KCB_MPESA,
REVERSAL, UNKNOWN
}
@@ -36,49 +37,77 @@ sealed interface ParseResult {
object MpesaSmsParser {
private val AMOUNT_REGEX = Regex("""Ksh([\d,]+\.\d{2})""")
// Amount: handles "Ksh500.00", "Ksh 500.00", "Ksh5,000.00"
private val AMOUNT_REGEX = Regex("""Ksh\s?([\d,]+\.\d{2})""")
// Transaction code: 10 alphanumeric chars at start, may have newline/space after
private val TRANSACTION_CODE_REGEX = Regex("""^([A-Z0-9]{10})\s""")
private val DATE_REGEX = Regex("""on (\d{1,2}/\d{1,2}/\d{2,4} at \d{1,2}:\d{2} [AP]M)""")
private val BALANCE_REGEX = Regex("""(?:balance is|account balance is)\s*Ksh([\d,]+\.\d{2})""", RegexOption.IGNORE_CASE)
private val COST_REGEX = Regex("""Transaction cost,?\s*Ksh([\d,]+\.\d{2})""", RegexOption.IGNORE_CASE)
private val FULIZA_AMOUNT_REGEX = Regex("""Fuliza M-PESA amount is Ksh([\d,]+\.\d{2})""")
private val FULIZA_OUTSTANDING_REGEX = Regex("""Fuliza M-PESA outstanding amount is Ksh([\d,]+\.\d{2})""")
// Date: handles both 2-digit and 4-digit year
private val DATE_REGEX = Regex("""on\s+(\d{1,2}/\d{1,2}/\d{2,4}\s+at\s+\d{1,2}:\d{2}\s+[AP]M)""", RegexOption.IGNORE_CASE)
// Balance: multiple formats
private val BALANCE_REGEX = Regex("""(?:M-PESA balance is|account balance is|M-PESA balance is|MPESA balance is)\s*Ksh\s?([\d,]+\.\d{2})""", RegexOption.IGNORE_CASE)
private val COST_REGEX = Regex("""Transaction cost,?\s*Ksh\s?([\d,]+\.\d{2})""", RegexOption.IGNORE_CASE)
private val FULIZA_AMOUNT_REGEX = Regex("""Fuliza M-PESA amount is Ksh\s?([\d,]+\.\d{2})""", RegexOption.IGNORE_CASE)
private val FULIZA_OUTSTANDING_REGEX = Regex("""(?:Fuliza M-PESA outstanding amount is|Total Fuliza M-PESA outstanding amount is)\s*Ksh\s?([\d,]+\.\d{2})""", RegexOption.IGNORE_CASE)
private val FULIZA_LIMIT_REGEX = Regex("""(?:Available |available )Fuliza M-PESA limit is Ksh\s?([\d,]+\.\d{2})""", RegexOption.IGNORE_CASE)
fun isMpesaMessage(sender: String): Boolean {
return sender.equals("MPESA", ignoreCase = true)
}
fun parse(smsBody: String, smsTimestamp: Long = System.currentTimeMillis()): ParseResult {
val code = extractTransactionCode(smsBody)
// Normalize: trim, collapse newlines/extra spaces
val sms = smsBody.trim().replace(Regex("""\s*\n\s*"""), " ")
val code = extractTransactionCode(sms)
?: return ParseResult.Failure(smsBody, "No transaction code found")
if (!smsBody.contains("Confirmed", ignoreCase = true)) {
if (!sms.contains("confirmed", ignoreCase = true)) {
return ParseResult.Failure(smsBody, "Not a confirmed transaction")
}
val timestamp = extractDate(smsBody) ?: smsTimestamp
val balance = extractBalance(smsBody) ?: 0.0
val cost = extractCost(smsBody) ?: 0.0
// Skip balance check messages (not transactions)
if (sms.contains("Your account balance was", ignoreCase = true) ||
sms.contains("account balance was:", ignoreCase = true)) {
return ParseResult.Failure(smsBody, "Balance check — not a transaction")
}
val timestamp = extractDate(sms) ?: smsTimestamp
val balance = extractBalance(sms) ?: 0.0
val cost = extractCost(sms) ?: 0.0
return try {
val transaction = when {
isReversal(smsBody) -> parseReversal(smsBody, code, balance, timestamp)
isFulizaReversal(smsBody) -> parseFulizaReversal(smsBody, code, timestamp)
isFulizaRepayment(smsBody) -> parseFulizaRepayment(smsBody, code, timestamp)
isMshwari(smsBody) -> parseMshwari(smsBody, code, balance, cost, timestamp)
isKcbMpesa(smsBody) -> parseKcbMpesa(smsBody, code, balance, cost, timestamp)
isMpesaGlobal(smsBody) -> parseMpesaGlobal(smsBody, code, balance, cost, timestamp)
isFuliza(smsBody) -> parseFuliza(smsBody, code, balance, cost, timestamp)
isDeposit(smsBody) -> parseDeposit(smsBody, code, balance, timestamp)
isReceived(smsBody) -> parseReceived(smsBody, code, balance, timestamp)
isAirtimeSelf(smsBody) -> parseAirtimeSelf(smsBody, code, balance, cost, timestamp)
isAirtimeOther(smsBody) -> parseAirtimeOther(smsBody, code, balance, cost, timestamp)
isWithdrawAtm(smsBody) -> parseWithdrawAtm(smsBody, code, balance, cost, timestamp)
isWithdrawAgent(smsBody) -> parseWithdrawAgent(smsBody, code, balance, cost, timestamp)
isPayBill(smsBody) -> parsePayBill(smsBody, code, balance, cost, timestamp)
isBuyGoods(smsBody) -> parseBuyGoods(smsBody, code, balance, cost, timestamp)
isSendMoney(smsBody) -> parseSendMoney(smsBody, code, balance, cost, timestamp)
else -> parseUnknown(smsBody, code, balance, cost, timestamp)
// Reversals (check before Fuliza since some reversals mention Fuliza)
isReversalAlt(sms) -> parseReversalAlt(sms, code, balance, timestamp)
isReversal(sms) -> parseReversal(sms, code, balance, timestamp)
isFulizaReversal(sms) -> parseFulizaReversal(sms, code, timestamp)
// Fuliza auto-deductions (must check before generic Fuliza)
isFulizaAutoPay(sms) -> parseFulizaAutoPay(sms, code, balance, timestamp)
isFulizaRepayment(sms) -> parseFulizaRepayment(sms, code, timestamp)
// Savings products
isMshwari(sms) -> parseMshwari(sms, code, balance, cost, timestamp)
isKcbMpesa(sms) -> parseKcbMpesa(sms, code, balance, cost, timestamp)
// International
isMpesaGlobal(sms) -> parseMpesaGlobal(sms, code, balance, cost, timestamp)
// Fuliza borrow (has "Fuliza M-PESA amount is" or "Total Fuliza M-PESA outstanding")
isFuliza(sms) -> parseFuliza(sms, code, balance, cost, timestamp)
// Standard types
isDeposit(sms) -> parseDeposit(sms, code, balance, timestamp)
isReceived(sms) -> parseReceived(sms, code, balance, timestamp)
isAirtimeSelf(sms) -> parseAirtimeSelf(sms, code, balance, cost, timestamp)
isAirtimeOther(sms) -> parseAirtimeOther(sms, code, balance, cost, timestamp)
isWithdrawAtm(sms) -> parseWithdrawAtm(sms, code, balance, cost, timestamp)
isWithdrawAlt(sms) -> parseWithdrawAlt(sms, code, balance, cost, timestamp)
isWithdrawAgent(sms) -> parseWithdrawAgent(sms, code, balance, cost, timestamp)
isPayBill(sms) -> parsePayBill(sms, code, balance, cost, timestamp)
isBuyGoods(sms) -> parseBuyGoods(sms, code, balance, cost, timestamp)
isSendMoney(sms) -> parseSendMoney(sms, code, balance, cost, timestamp)
else -> parseUnknown(sms, code, balance, cost, timestamp)
}
ParseResult.Success(transaction)
} catch (e: Exception) {
@@ -89,11 +118,20 @@ object MpesaSmsParser {
// --- Type detection ---
private fun isReversal(sms: String) =
sms.contains("has been reversed") && !sms.contains("Fuliza", ignoreCase = true)
sms.contains("has been reversed") && !sms.contains("Fuliza", ignoreCase = true) &&
!sms.contains("Reversal of transaction", ignoreCase = true)
private fun isReversalAlt(sms: String) =
sms.contains("Reversal of transaction", ignoreCase = true) &&
sms.contains("has been successfully reversed", ignoreCase = true)
private fun isFulizaReversal(sms: String) =
sms.contains("Fuliza M-PESA of", ignoreCase = true) && sms.contains("has been reversed")
private fun isFulizaAutoPay(sms: String) =
sms.contains("from your M-PESA has been used to", ignoreCase = true) &&
sms.contains("pay your outstanding Fuliza", ignoreCase = true)
private fun isFulizaRepayment(sms: String) =
sms.contains("paid", ignoreCase = true) && sms.contains("to Fuliza M-PESA", ignoreCase = true)
@@ -107,16 +145,25 @@ object MpesaSmsParser {
sms.contains("M-PESA Global", ignoreCase = true) || sms.contains("MPESA Global", ignoreCase = true)
private fun isFuliza(sms: String) =
sms.contains("Fuliza M-PESA amount is", ignoreCase = true)
sms.contains("Fuliza M-PESA amount is", ignoreCase = true) ||
sms.contains("Total Fuliza M-PESA outstanding", ignoreCase = true)
private fun isDeposit(sms: String) =
sms.contains("You have deposited", ignoreCase = true)
sms.contains("You have deposited", ignoreCase = true) ||
sms.contains("deposited", ignoreCase = true)
private fun isReceived(sms: String) =
sms.contains("You have received", ignoreCase = true)
private fun isAirtimeSelf(sms: String) =
sms.contains("airtime purchased", ignoreCase = true)
// "airtime purchased" OR "bought...of airtime" without a phone number after
private fun isAirtimeSelf(sms: String): Boolean {
if (sms.contains("airtime purchased", ignoreCase = true)) return true
if (sms.contains("bought", ignoreCase = true) && sms.contains("of airtime", ignoreCase = true)) {
// Self if no phone number after "airtime for"
return !sms.contains("airtime for", ignoreCase = true)
}
return false
}
private fun isAirtimeOther(sms: String) =
sms.contains("bought", ignoreCase = true) && sms.contains("airtime for", ignoreCase = true)
@@ -124,14 +171,20 @@ object MpesaSmsParser {
private fun isWithdrawAtm(sms: String) =
sms.contains("withdrawn", ignoreCase = true) && sms.contains("from an ATM", ignoreCase = true)
// Alt format: "Withdraw Ksh6,500.00 from 031824 - Agent Name"
private fun isWithdrawAlt(sms: String) =
Regex("""Withdraw\s+Ksh""", RegexOption.IGNORE_CASE).containsMatchIn(sms)
private fun isWithdrawAgent(sms: String) =
sms.contains("withdrawn", ignoreCase = true) && sms.contains("from ", ignoreCase = true)
private fun isPayBill(sms: String) =
sms.contains("paid to", ignoreCase = true) && sms.contains("Account Number", ignoreCase = true)
sms.contains("paid to", ignoreCase = true) &&
(sms.contains("Account Number", ignoreCase = true) || sms.contains("for account", ignoreCase = true))
private fun isBuyGoods(sms: String) =
sms.contains("paid to", ignoreCase = true) && !sms.contains("Account Number", ignoreCase = true)
sms.contains("paid to", ignoreCase = true) && !sms.contains("Account Number", ignoreCase = true) &&
!sms.contains("for account", ignoreCase = true)
private fun isSendMoney(sms: String) =
sms.contains("sent to", ignoreCase = true)
@@ -140,13 +193,20 @@ object MpesaSmsParser {
private fun parseSendMoney(sms: String, code: String, balance: Double, cost: Double, timestamp: Long): ParsedTransaction {
val amount = extractFirstAmount(sms) ?: 0.0
// Try standard: "sent to NAME PHONE on"
val sendToRegex = Regex("""sent to\s+(.+?)\s+(\d{10,})\s+on""", RegexOption.IGNORE_CASE)
val match = sendToRegex.find(sms)
// Try alt: "sent to NAME on" (no phone, e.g. Hustler Fund)
val sendToAltRegex = Regex("""sent to\s+(.+?)\s+(?:on|for)""", RegexOption.IGNORE_CASE)
val altMatch = if (match == null) sendToAltRegex.find(sms) else null
return ParsedTransaction(
transactionCode = code, type = TransactionType.SEND, amount = amount,
transactionCost = cost, recipientName = match?.groupValues?.get(1)?.trim(),
recipientPhone = match?.groupValues?.get(2), accountNumber = null,
destinationCountry = null, balance = balance, direction = TransactionDirection.OUTFLOW,
transactionCost = cost,
recipientName = match?.groupValues?.get(1)?.trim() ?: altMatch?.groupValues?.get(1)?.trim(),
recipientPhone = match?.groupValues?.get(2),
accountNumber = null, destinationCountry = null, balance = balance,
direction = TransactionDirection.OUTFLOW,
fulizaAmount = null, fulizaOutstanding = null, reversedTransactionCode = null,
timestamp = timestamp, rawSms = sms
)
@@ -168,12 +228,19 @@ object MpesaSmsParser {
private fun parsePayBill(sms: String, code: String, balance: Double, cost: Double, timestamp: Long): ParsedTransaction {
val amount = extractFirstAmount(sms) ?: 0.0
// Standard: "paid to BUSINESS. Account Number 12345. on"
val paidToRegex = Regex("""paid to\s+(.+?)\.\s*Account Number\s+(\S+?)\.?\s+on""", RegexOption.IGNORE_CASE)
val match = paidToRegex.find(sms)
// Alt: "sent to NAME for account ACCOUNT on"
val altRegex = Regex("""sent to\s+(.+?)\s+for account\s+(.+?)\s+on""", RegexOption.IGNORE_CASE)
val altMatch = if (match == null) altRegex.find(sms) else null
return ParsedTransaction(
transactionCode = code, type = TransactionType.PAY_BILL, amount = amount,
transactionCost = cost, recipientName = match?.groupValues?.get(1)?.trim(),
recipientPhone = null, accountNumber = match?.groupValues?.get(2)?.trim(),
transactionCost = cost,
recipientName = match?.groupValues?.get(1)?.trim() ?: altMatch?.groupValues?.get(1)?.trim(),
recipientPhone = null,
accountNumber = match?.groupValues?.get(2)?.trim() ?: altMatch?.groupValues?.get(2)?.trim(),
destinationCountry = null, balance = balance, direction = TransactionDirection.OUTFLOW,
fulizaAmount = null, fulizaOutstanding = null, reversedTransactionCode = null,
timestamp = timestamp, rawSms = sms
@@ -194,6 +261,23 @@ object MpesaSmsParser {
)
}
// Alt: "Withdraw Ksh6,500.00 from 031824 - Agent Name Street"
private fun parseWithdrawAlt(sms: String, code: String, balance: Double, cost: Double, timestamp: Long): ParsedTransaction {
val amount = extractFirstAmount(sms) ?: 0.0
val altRegex = Regex("""Withdraw\s+Ksh\s?[\d,]+\.\d{2}\s+from\s+(\d+)\s*-\s*(.+?)\s+(?:New |new )""", RegexOption.IGNORE_CASE)
val match = altRegex.find(sms)
return ParsedTransaction(
transactionCode = code, type = TransactionType.WITHDRAW_AGENT, amount = amount,
transactionCost = cost,
recipientName = match?.groupValues?.get(2)?.trim(),
recipientPhone = match?.groupValues?.get(1),
accountNumber = null, destinationCountry = null, balance = balance,
direction = TransactionDirection.OUTFLOW,
fulizaAmount = null, fulizaOutstanding = null, reversedTransactionCode = null,
timestamp = timestamp, rawSms = sms
)
}
private fun parseWithdrawAtm(sms: String, code: String, balance: Double, cost: Double, timestamp: Long): ParsedTransaction {
val amount = extractFirstAmount(sms) ?: 0.0
return ParsedTransaction(
@@ -277,7 +361,8 @@ object MpesaSmsParser {
val match = sendToRegex.find(sms)
return ParsedTransaction(
transactionCode = code, type = TransactionType.FULIZA, amount = amount,
transactionCode = code, type = TransactionType.FULIZA,
amount = fulizaAmt ?: amount,
transactionCost = cost, recipientName = match?.groupValues?.get(1)?.trim(),
recipientPhone = match?.groupValues?.get(2), accountNumber = null,
destinationCountry = null, balance = balance, direction = TransactionDirection.OUTFLOW,
@@ -286,6 +371,22 @@ object MpesaSmsParser {
)
}
// "Ksh X from your M-PESA has been used to fully/partially pay your outstanding Fuliza"
private fun parseFulizaAutoPay(sms: String, code: String, balance: Double, timestamp: Long): ParsedTransaction {
val amount = extractFirstAmount(sms) ?: 0.0
val fulizaLimit = FULIZA_LIMIT_REGEX.find(sms)?.groupValues?.get(1)?.let { parseAmount(it) }
val isFullPay = sms.contains("fully pay", ignoreCase = true)
return ParsedTransaction(
transactionCode = code, type = TransactionType.FULIZA_AUTO_PAY, amount = amount,
transactionCost = 0.0, recipientName = if (isFullPay) "Fuliza Full Payment" else "Fuliza Partial Payment",
recipientPhone = null, accountNumber = null, destinationCountry = null,
balance = balance, direction = TransactionDirection.OUTFLOW,
fulizaAmount = null, fulizaOutstanding = fulizaLimit,
reversedTransactionCode = null, timestamp = timestamp, rawSms = sms
)
}
private fun parseFulizaRepayment(sms: String, code: String, timestamp: Long): ParsedTransaction {
val amount = extractFirstAmount(sms) ?: 0.0
val fulizaOut = FULIZA_OUTSTANDING_REGEX.find(sms)?.groupValues?.get(1)?.let { parseAmount(it) }
@@ -322,6 +423,20 @@ object MpesaSmsParser {
)
}
// "Reversal of transaction X has been successfully reversed ... Ksh200.00 is credited"
private fun parseReversalAlt(sms: String, code: String, balance: Double, timestamp: Long): ParsedTransaction {
val reversedCodeRegex = Regex("""Reversal of transaction\s+([A-Z0-9]{10})""", RegexOption.IGNORE_CASE)
val match = reversedCodeRegex.find(sms)
val amount = extractFirstAmount(sms) ?: 0.0
return ParsedTransaction(
transactionCode = code, type = TransactionType.REVERSAL, amount = amount,
transactionCost = 0.0, recipientName = null, recipientPhone = null,
accountNumber = null, destinationCountry = null, balance = balance,
direction = TransactionDirection.INFLOW, fulizaAmount = null, fulizaOutstanding = null,
reversedTransactionCode = match?.groupValues?.get(1), timestamp = timestamp, rawSms = sms
)
}
private fun parseMshwari(sms: String, code: String, balance: Double, cost: Double, timestamp: Long): ParsedTransaction {
val amount = extractFirstAmount(sms) ?: 0.0
val isInflow = sms.contains("from M-Shwari", ignoreCase = true)
@@ -51,6 +51,9 @@ class TransactionRepository @Inject constructor(
fun getLatestTransaction(): Flow<TransactionEntity?> =
transactionDao.getLatestTransaction()
fun getLatestTransactionWithBalance(): Flow<TransactionEntity?> =
transactionDao.getLatestTransactionWithBalance()
fun getSpendingByCategory(startTime: Long, endTime: Long): Flow<List<CategorySpending>> =
transactionDao.getSpendingByCategory(startTime, endTime)
@@ -92,7 +95,7 @@ class TransactionRepository @Inject constructor(
TransactionType.DEPOSIT -> 9L // Deposit
TransactionType.MPESA_GLOBAL -> 11L // International
TransactionType.MSHWARI, TransactionType.KCB_MPESA -> 12L // Savings & Loans
TransactionType.FULIZA, TransactionType.FULIZA_REPAYMENT, TransactionType.FULIZA_REVERSAL -> 12L
TransactionType.FULIZA, TransactionType.FULIZA_REPAYMENT, TransactionType.FULIZA_REVERSAL, TransactionType.FULIZA_AUTO_PAY -> 12L
TransactionType.REVERSAL -> 13L // Other
TransactionType.UNKNOWN -> 13L
else -> null
@@ -0,0 +1,36 @@
package com.ledga.app.ui.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountBalance
import androidx.compose.material.icons.filled.AccountBalanceWallet
import androidx.compose.material.icons.filled.Atm
import androidx.compose.material.icons.filled.Category
import androidx.compose.material.icons.filled.DirectionsCar
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Receipt
import androidx.compose.material.icons.filled.Restaurant
import androidx.compose.material.icons.filled.Savings
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.ShoppingBag
import androidx.compose.material.icons.filled.ShoppingCart
import androidx.compose.ui.graphics.vector.ImageVector
fun categoryIcon(iconName: String): ImageVector {
return when (iconName) {
"shopping_cart" -> Icons.Default.ShoppingCart
"directions_car" -> Icons.Default.DirectionsCar
"receipt" -> Icons.Default.Receipt
"phone_android" -> Icons.Default.PhoneAndroid
"restaurant" -> Icons.Default.Restaurant
"person" -> Icons.Default.Person
"account_balance_wallet" -> Icons.Default.AccountBalanceWallet
"atm" -> Icons.Default.Atm
"savings" -> Icons.Default.Savings
"shopping_bag" -> Icons.Default.ShoppingBag
"public" -> Icons.Default.Public
"account_balance" -> Icons.Default.AccountBalance
"category" -> Icons.Default.Category
else -> Icons.Default.Category
}
}
@@ -1,6 +1,8 @@
package com.ledga.app.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -8,8 +10,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ShoppingCart
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
@@ -54,14 +55,25 @@ fun TransactionCard(
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Category icon
Icon(
imageVector = Icons.Default.ShoppingCart,
contentDescription = category?.name,
modifier = Modifier.size(40.dp),
tint = category?.color?.let { parseColor(it) }
?: MaterialTheme.colorScheme.onSurfaceVariant
)
// Category icon with colored background
val iconColor = category?.color?.let { parseColor(it) }
?: MaterialTheme.colorScheme.onSurfaceVariant
Box(
modifier = Modifier
.size(42.dp)
.background(
color = iconColor.copy(alpha = 0.15f),
shape = RoundedCornerShape(12.dp)
),
contentAlignment = androidx.compose.ui.Alignment.Center
) {
Icon(
imageVector = categoryIcon(category?.icon ?: "category"),
contentDescription = category?.name,
modifier = Modifier.size(22.dp),
tint = iconColor
)
}
Spacer(modifier = Modifier.width(12.dp))
@@ -1,6 +1,8 @@
package com.ledga.app.ui.home
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -8,21 +10,34 @@ 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.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import com.ledga.app.ui.components.DonutChart
import com.ledga.app.ui.components.PeriodSelector
import com.ledga.app.ui.components.StatCard
import com.ledga.app.ui.components.TransactionCard
import com.ledga.app.ui.components.categoryIcon
import com.ledga.app.ui.components.parseColor
import com.ledga.app.ui.theme.MpesaGreen
import com.ledga.app.util.CurrencyFormatter
@Composable
@@ -35,44 +50,107 @@ fun HomeScreen(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Greeting
item {
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.height(12.dp))
Text(
text = state.greeting,
style = MaterialTheme.typography.headlineSmall
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
}
// Balance card
// Balance card — prominent
item {
StatCard(
label = "M-PESA Balance",
value = state.balance?.let { CurrencyFormatter.formatKsh(it) } ?: "—",
subtitle = "from last transaction",
modifier = Modifier.fillMaxWidth()
)
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(
containerColor = MpesaGreen
)
) {
Column(
modifier = Modifier.padding(20.dp)
) {
Text(
text = "M-PESA Balance",
style = MaterialTheme.typography.bodyMedium,
color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.8f)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = state.balance?.let { CurrencyFormatter.formatKsh(it) } ?: "—",
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold,
color = androidx.compose.ui.graphics.Color.White
)
Text(
text = "from last transaction",
style = MaterialTheme.typography.bodySmall,
color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.6f)
)
}
}
}
// Spending summary
// Spending + Fees row
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
StatCard(
label = state.monthLabel,
value = CurrencyFormatter.formatKshCompact(state.totalSpending),
subtitle = "Spent",
modifier = Modifier.weight(1f)
)
StatCard(
label = "Fees",
value = CurrencyFormatter.formatKsh(state.totalFees),
modifier = Modifier.weight(1f)
)
Card(
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Spent",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = CurrencyFormatter.formatKshCompact(state.totalSpending),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold
)
Text(
text = state.monthLabel,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Card(
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Fees Paid",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = CurrencyFormatter.formatKsh(state.totalFees),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold
)
Text(
text = "this period",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@@ -85,27 +163,89 @@ fun HomeScreen(
}
// Donut chart
item {
if (state.donutSegments.isNotEmpty()) {
DonutChart(
segments = state.donutSegments,
totalAmount = state.totalSpending,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
)
if (state.donutSegments.isNotEmpty()) {
item {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
DonutChart(
segments = state.donutSegments,
totalAmount = state.totalSpending,
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
// Recent transactions header
// Category breakdown — "You spent X on Y"
if (state.categoryBreakdown.isNotEmpty()) {
item {
Text(
text = "Where your money went",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
items(state.categoryBreakdown) { item ->
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
val color = parseColor(item.color)
Box(
modifier = Modifier
.size(36.dp)
.background(color.copy(alpha = 0.15f), RoundedCornerShape(10.dp)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = categoryIcon(item.icon),
contentDescription = item.name,
modifier = Modifier.size(20.dp),
tint = color
)
}
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.name,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
}
Text(
text = CurrencyFormatter.formatKsh(item.amount),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold
)
}
}
}
// Recent transactions
item {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Recent Transactions",
style = MaterialTheme.typography.titleMedium
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
// Transaction list
if (state.recentTransactions.isEmpty()) {
item {
Text(
@@ -1,9 +1,9 @@
package com.ledga.app.ui.home
import android.content.Context
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import android.app.Application
import com.ledga.app.BuildConfig
import com.ledga.app.data.db.dao.CategorySpending
import com.ledga.app.data.db.entity.Category
@@ -18,28 +18,38 @@ import com.ledga.app.worker.GitHubRelease
import com.ledga.app.worker.UpdateChecker
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import android.content.Context
import javax.inject.Inject
data class CategoryBreakdownItem(
val name: String,
val color: String,
val icon: String,
val amount: Double
)
data class HomeUiState(
val greeting: String = "",
val balance: Double? = null,
val totalSpending: Double = 0.0,
val totalFees: Double = 0.0,
val donutSegments: List<DonutSegment> = emptyList(),
val categoryBreakdown: List<CategoryBreakdownItem> = emptyList(),
val recentTransactions: List<TransactionWithCategory> = emptyList(),
val selectedPeriod: Period = Period.THIS_MONTH,
val monthLabel: String = "",
val updateAvailable: GitHubRelease? = null
)
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class HomeViewModel @Inject constructor(
@ApplicationContext private val appContext: Context,
@@ -70,18 +80,15 @@ class HomeViewModel @Inject constructor(
Period.THIS_WEEK -> DateUtils.getStartOfWeek(now)
Period.THIS_MONTH -> DateUtils.getStartOfMonth(now)
}
kotlinx.coroutines.flow.flowOf(start to now)
flowOf(start to now)
}
val uiState: StateFlow<HomeUiState> = combine(
_selectedPeriod,
timeRange,
transactionRepository.getLatestTransaction(),
transactionRepository.getLatestTransactionWithBalance(),
transactionRepository.getRecentTransactions(10),
categoryRepository.getAllCategories()
) { period, (start, end), latestTransaction, recentTransactions, categories ->
val categoryMap = categories.associateBy { it.id }
) { period, latestTransaction, recentTransactions, categories ->
HomeUiState(
greeting = DateUtils.greeting(),
balance = latestTransaction?.balance,
@@ -97,14 +104,29 @@ class HomeViewModel @Inject constructor(
transactionRepository.getSpendingByCategory(start, end),
categoryRepository.getAllCategories()
) { spending, fees, categorySpending, categories ->
Triple(spending, fees, buildDonutSegments(categorySpending, categories))
val catMap = categories.associateBy { it.id }
val segments = buildDonutSegments(categorySpending, categories)
val breakdown = categorySpending
.sortedByDescending { it.totalAmount }
.mapNotNull { cs ->
val cat = catMap[cs.categoryId] ?: return@mapNotNull null
CategoryBreakdownItem(
name = cat.name,
color = cat.color,
icon = cat.icon,
amount = cs.totalAmount
)
}
Triple(spending, fees, segments) to breakdown
}
}
) { state, (spending, fees, segments) ->
) { state, (triple, breakdown) ->
val (spending, fees, segments) = triple
state.copy(
totalSpending = spending,
totalFees = fees,
donutSegments = segments
donutSegments = segments,
categoryBreakdown = breakdown
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), HomeUiState())
@@ -7,11 +7,20 @@ import java.util.Locale
object DateUtils {
private val mpesaDateFormat = SimpleDateFormat("d/M/yy 'at' h:mm a", Locale.ENGLISH)
private val mpesaDateFormat2 = SimpleDateFormat("d/M/yy 'at' h:mm a", Locale.ENGLISH)
private val mpesaDateFormat4 = SimpleDateFormat("d/M/yyyy 'at' h:mm a", Locale.ENGLISH)
// Alt format with extra spaces: "20/05/2024 at 03:23 PM"
private val mpesaDateFormatAlt = SimpleDateFormat("d/M/yyyy 'at' hh:mm a", Locale.ENGLISH)
fun parseMpesaDate(dateStr: String): Long {
val cleaned = dateStr.trim().replace(Regex("""\s+"""), " ")
return try {
mpesaDateFormat.parse(dateStr)?.time ?: System.currentTimeMillis()
// Try 4-digit year first
if (cleaned.matches(Regex("""\d{1,2}/\d{1,2}/\d{4}\s.*"""))) {
mpesaDateFormat4.parse(cleaned)?.time ?: mpesaDateFormatAlt.parse(cleaned)?.time ?: System.currentTimeMillis()
} else {
mpesaDateFormat2.parse(cleaned)?.time ?: System.currentTimeMillis()
}
} catch (e: Exception) {
System.currentTimeMillis()
}