mirror of
https://github.com/andwati/ledga.git
synced 2026-10-18 03:12:55 +00:00
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:
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user