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 d268c1a..f17540e 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 @@ -46,6 +46,9 @@ interface TransactionDao { @Query("SELECT * FROM transactions ORDER BY timestamp DESC LIMIT 1") fun getLatestTransaction(): Flow + @Query("SELECT * FROM transactions WHERE balance > 0 ORDER BY timestamp DESC LIMIT 1") + fun getLatestTransactionWithBalance(): Flow + @Query(""" SELECT categoryId, SUM(amount) as totalAmount FROM transactions diff --git a/app/src/main/java/com/ledga/app/data/db/entity/TransactionEntity.kt b/app/src/main/java/com/ledga/app/data/db/entity/TransactionEntity.kt index 9defdf9..0b18163 100644 --- a/app/src/main/java/com/ledga/app/data/db/entity/TransactionEntity.kt +++ b/app/src/main/java/com/ledga/app/data/db/entity/TransactionEntity.kt @@ -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", diff --git a/app/src/main/java/com/ledga/app/data/parser/MpesaSmsParser.kt b/app/src/main/java/com/ledga/app/data/parser/MpesaSmsParser.kt index 8982a88..8fe18fb 100644 --- a/app/src/main/java/com/ledga/app/data/parser/MpesaSmsParser.kt +++ b/app/src/main/java/com/ledga/app/data/parser/MpesaSmsParser.kt @@ -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) 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 efa5fd2..43dc492 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 @@ -51,6 +51,9 @@ class TransactionRepository @Inject constructor( fun getLatestTransaction(): Flow = transactionDao.getLatestTransaction() + fun getLatestTransactionWithBalance(): Flow = + transactionDao.getLatestTransactionWithBalance() + fun getSpendingByCategory(startTime: Long, endTime: Long): Flow> = 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 diff --git a/app/src/main/java/com/ledga/app/ui/components/CategoryIcon.kt b/app/src/main/java/com/ledga/app/ui/components/CategoryIcon.kt new file mode 100644 index 0000000..1bb2f4f --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/components/CategoryIcon.kt @@ -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 + } +} diff --git a/app/src/main/java/com/ledga/app/ui/components/TransactionCard.kt b/app/src/main/java/com/ledga/app/ui/components/TransactionCard.kt index cb1a471..b53124f 100644 --- a/app/src/main/java/com/ledga/app/ui/components/TransactionCard.kt +++ b/app/src/main/java/com/ledga/app/ui/components/TransactionCard.kt @@ -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)) diff --git a/app/src/main/java/com/ledga/app/ui/home/HomeScreen.kt b/app/src/main/java/com/ledga/app/ui/home/HomeScreen.kt index eb6b971..529a6cf 100644 --- a/app/src/main/java/com/ledga/app/ui/home/HomeScreen.kt +++ b/app/src/main/java/com/ledga/app/ui/home/HomeScreen.kt @@ -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( diff --git a/app/src/main/java/com/ledga/app/ui/home/HomeViewModel.kt b/app/src/main/java/com/ledga/app/ui/home/HomeViewModel.kt index 7f3c7ec..4e2ca57 100644 --- a/app/src/main/java/com/ledga/app/ui/home/HomeViewModel.kt +++ b/app/src/main/java/com/ledga/app/ui/home/HomeViewModel.kt @@ -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 = emptyList(), + val categoryBreakdown: List = emptyList(), val recentTransactions: List = 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 = 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()) diff --git a/app/src/main/java/com/ledga/app/util/DateUtils.kt b/app/src/main/java/com/ledga/app/util/DateUtils.kt index 72d4423..aa2a4a2 100644 --- a/app/src/main/java/com/ledga/app/util/DateUtils.kt +++ b/app/src/main/java/com/ledga/app/util/DateUtils.kt @@ -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() }