Initial release — Ledga M-Pesa spending tracker

Complete Android app (Kotlin/Jetpack Compose) with:
- SMS parser for all 17 M-Pesa transaction types
- Room database with auto-categorization engine
- Real-time SMS capture via BroadcastReceiver
- Onboarding flow with SMS history import
- Home dashboard with balance, spending, donut chart
- Transactions list with search, filters, day grouping
- Trends screen with Vico bar charts, top merchants
- Budget system with progress tracking
- Transaction detail sheet with re-categorization
- Notifications (daily/weekly summaries, budget alerts)
- Google Drive backup & restore
- Manual export/import (CSV + JSON ZIP)
- Self-update system via GitHub Releases API
- Theme system (System/Light/Dark) + font scaling
- Adaptive app icon with splash screen
- GitHub Actions CI/CD for release APK builds
This commit is contained in:
ngenohkevin
2026-03-21 20:53:59 +03:00
commit 79741192b9
81 changed files with 6657 additions and 0 deletions
+67
View File
@@ -0,0 +1,67 @@
name: Build & Release APK
on:
push:
tags:
- 'v*'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
- name: Decode keystore
run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > keystore/ledga-release.jks
shell: bash
- name: Build release APK
env:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
run: |
mkdir -p keystore
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > keystore/ledga-release.jks
./gradlew assembleRelease
- name: Rename APK
run: |
VERSION=${GITHUB_REF_NAME#v}
mv app/build/outputs/apk/release/app-release.apk app/build/outputs/apk/release/ledga-v${VERSION}.apk
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: app/build/outputs/apk/release/ledga-*.apk
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
- name: Run tests
run: ./gradlew testDebugUnitTest
+19
View File
@@ -0,0 +1,19 @@
*.iml
.gradle
/local.properties
local.properties
keystore/
/.idea
.idea/
.DS_Store
/build
build/
/captures
.externalNativeBuild
.cxx
*.apk
*.ap_
*.dex
*.class
.kotlin/
.claude/
+746
View File
@@ -0,0 +1,746 @@
# Ledga — M-Pesa Spending Tracker
## Overview
A native Android app that automatically tracks M-Pesa spending by reading SMS confirmations. Zero manual entry — install, grant SMS permission, done. Built for non-technical users with a clean, Canva-like UI.
**Target users:** Family members (all Android), non-technical
**Distribution:** Direct APK install + in-app self-updates via GitHub Releases (no Play Store)
**Data model:** Fully offline, local-only per device. No accounts, no server.
---
## Tech Stack
| Component | Technology |
|-----------|------------|
| Language | Kotlin |
| UI Framework | Jetpack Compose + Material 3 |
| Local Database | Room (SQLite) |
| Background SMS | BroadcastReceiver + WorkManager |
| DI | Hilt (dependency injection) |
| Settings Storage | Jetpack DataStore (Preferences) |
| Charts | Vico (Compose-native charting library) |
| Notifications | Android NotificationManager + AlarmManager |
| Backup | Google Drive API (auto), manual file export |
| Self-Update | GitHub Releases API |
| Min SDK | API 26 (Android 8.0) |
| Build | Gradle (Kotlin DSL) |
---
## Architecture
```
┌─────────────────────────────────────────────┐
│ UI Layer │
│ Jetpack Compose + Material 3 │
│ ┌──────┐ ┌──────────┐ ┌──────┐ ┌────────┐ │
│ │ Home │ │Transactions│ │Trends│ │Settings│ │
│ └──────┘ └──────────┘ └──────┘ └────────┘ │
├─────────────────────────────────────────────┤
│ ViewModel Layer │
│ StateFlow + Coroutines │
├─────────────────────────────────────────────┤
│ Repository Layer │
│ TransactionRepo │ CategoryRepo │ │
│ SettingsRepo │ BackupRepo │ │
├─────────────────────────────────────────────┤
│ Data Layer │
│ ┌──────┐ ┌───────────┐ ┌─────────────┐ │
│ │ Room │ │SMS Parser │ │Google Drive │ │
│ │ DB │ │ │ │ Backup │ │
│ └──────┘ └───────────┘ └─────────────┘ │
├─────────────────────────────────────────────┤
│ System Services │
│ SMS BroadcastReceiver │ WorkManager │
│ NotificationManager │ AlarmManager │
└─────────────────────────────────────────────┘
```
---
## SMS Parsing
### Sender Filter
Only process SMS from sender: `MPESA`
### Transaction Types & Patterns
**1. Send Money**
```
RK31B7X4ZQ Confirmed. Ksh500.00 sent to JOHN DOE 0712345678 on 21/3/26 at 1:30 PM. New M-PESA balance is Ksh1,200.00. Transaction cost, Ksh0.00.
```
→ Extract: code, amount, recipient name, recipient phone, date/time, balance, cost
**2. Buy Goods (Till)**
```
RK31B7X4ZQ Confirmed. Ksh1,200.00 paid to NAIVAS SUPERMARKET. on 21/3/26 at 2:15 PM.New M-PESA balance is Ksh3,500.00. Transaction cost, Ksh0.00.
```
→ Extract: code, amount, merchant name, date/time, balance, cost
**3. Pay Bill**
```
RK31B7X4ZQ Confirmed. Ksh2,500.00 paid to KPLC PREPAID. Account Number 12345678. on 21/3/26 at 3:00 PM. New M-PESA balance is Ksh1,000.00. Transaction cost, Ksh0.00.
```
→ Extract: code, amount, business name, account number, date/time, balance, cost
**4. Withdraw (Agent)**
```
RK31B7X4ZQ Confirmed.You have withdrawn Ksh1,000.00 from JAMES AGENT 543210 on 21/3/26 at 4:00 PM.New M-PESA balance is Ksh500.00. Transaction cost, Ksh28.00.
```
→ Extract: code, amount, agent name, agent number, date/time, balance, cost
**5. Withdraw (ATM)**
```
RK31B7X4ZQ Confirmed. You have withdrawn Ksh5,000.00 from an ATM on 21/3/26 at 4:30 PM. New M-PESA balance is Ksh2,000.00. Transaction cost, Ksh34.00.
```
→ Extract: code, amount, date/time, balance, cost
→ Note: No agent name/number — just "from an ATM"
**6. Deposit**
```
RK31B7X4ZQ Confirmed.You have deposited Ksh5,000.00 to your M-PESA account on 21/3/26 at 10:00 AM.New M-PESA balance is Ksh5,500.00.
```
→ Extract: code, amount, date/time, balance
**7. Received Money**
```
RK31B7X4ZQ Confirmed.You have received Ksh2,000.00 from JANE DOE 0798765432 on 21/3/26 at 11:00 AM.New M-PESA balance is Ksh7,500.00.
```
→ Extract: code, amount, sender name, sender phone, date/time, balance
**8. Airtime Purchase (Self)**
```
RK31B7X4ZQ Confirmed. Ksh100.00 of airtime purchased on 21/3/26 at 12:00 PM.New M-PESA balance is Ksh7,400.00. Transaction cost, Ksh0.00.
```
→ Extract: code, amount, date/time, balance, cost
**9. Airtime Purchase (For Others)**
```
RK31B7X4ZQ Confirmed. You bought Ksh100.00 of airtime for 0712345678 on 21/3/26 at 12:30 PM.New M-PESA balance is Ksh7,300.00. Transaction cost, Ksh0.00.
```
→ Extract: code, amount, recipient phone, date/time, balance, cost
**10. M-Pesa Global (International Transfer)**
```
RK31B7X4ZQ Confirmed. Ksh5,000.00 sent to JOHN DOE +44712345678 (United Kingdom) via M-PESA Global on 21/3/26 at 1:30 PM. New M-PESA balance is Ksh10,000.00. Transaction cost, Ksh150.00.
```
→ Extract: code, amount, recipient name, recipient phone (international), destination country, date/time, balance, cost
**11. Fuliza (Borrow)**
```
RK31B7X4ZQ Confirmed. Ksh500.00 sent to JOHN DOE 0712345678 on 21/3/26 at 1:30 PM. New M-PESA balance is Ksh0.00. Fuliza M-PESA amount is Ksh500.00. Fuliza M-PESA outstanding amount is Ksh500.00.
```
→ Extract: all standard fields + fuliza amount, fuliza outstanding
**12. Fuliza Repayment**
```
RK31B7X4ZQ Confirmed. You have paid Ksh200.00 to Fuliza M-PESA on 21/3/26 at 2:00 PM. Fuliza M-PESA outstanding amount is Ksh300.00.
```
→ Extract: code, amount, date/time, fuliza outstanding
**13. Fuliza Reversal**
```
RK31B7X4ZQ Confirmed. Fuliza M-PESA of Ksh500.00 has been reversed on 21/3/26 at 3:00 PM. Fuliza M-PESA outstanding amount is Ksh0.00.
```
→ Extract: code, amount, date/time, fuliza outstanding
**14. Reversal**
```
RK31B7X4ZQ Confirmed. Transaction RJ12345678 has been reversed. Your account balance is Ksh2,000.00.
```
→ Extract: code, reversed transaction code, balance
### M-Shwari & KCB M-Pesa (Acknowledged, Not Parsed)
These services send SMS from the `MPESA` sender but are savings/loan products, not spending transactions:
- **M-Shwari:** `Confirmed. Ksh1,000.00 transferred to M-Shwari account...` / `Ksh1,000.00 transferred from M-Shwari...`
- **KCB M-Pesa:** `Confirmed. Ksh2,000.00 transferred to KCB M-Pesa account...` / loan disbursements
**Strategy:** Detect these by keywords ("M-Shwari", "KCB M-Pesa") and store with type `MSHWARI` or `KCB_MPESA`. Show them in the transaction list but don't count them as spending. They're internal transfers between M-Pesa wallet and savings/loan products.
### Parser Strategy
- Regex-based pattern matching per transaction type
- Keyword detection: "sent to", "paid to", "withdrawn.*from.*AGENT" (agent), "withdrawn.*from an ATM" (ATM), "deposited", "received", "airtime purchased", "bought.*airtime for", "M-PESA Global", "reversed", "Fuliza M-PESA amount", "paid to Fuliza", "Fuliza M-PESA of", "M-Shwari", "KCB M-Pesa"
- Amount extraction: `Ksh[\d,]+\.\d{2}` pattern
- Date extraction: `\d{1,2}/\d{1,2}/\d{2,4} at \d{1,2}:\d{2} [AP]M`
- International phone detection: `\+\d{7,15}` pattern (for M-Pesa Global)
- Country extraction: text in parentheses after international phone number
- Fallback: store unparsed M-Pesa SMS with type `UNKNOWN` for later parsing improvements
---
## Database Schema (Room)
### transactions
| Column | Type | Description |
|--------|------|-------------|
| id | Long (PK, auto) | Primary key |
| transactionCode | String (unique) | M-Pesa transaction code |
| type | Enum | 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, REVERSAL, UNKNOWN |
| amount | Double | Transaction amount in Ksh |
| transactionCost | Double | M-Pesa fee |
| recipientName | String? | Name of recipient/merchant |
| recipientPhone | String? | Phone number (if person) |
| accountNumber | String? | Account number (paybill) |
| destinationCountry | String? | Country name for M-Pesa Global transfers |
| balance | Double | M-Pesa balance after transaction |
| direction | Enum | INFLOW, OUTFLOW (derived at parse time — simplifies totals/chart queries) |
| categoryId | Long? (FK) | Assigned category |
| fulizaAmount | Double? | Fuliza amount (if applicable) |
| fulizaOutstanding | Double? | Fuliza outstanding (if applicable) |
| rawSms | String | Original SMS text (for re-parsing) |
| timestamp | Long | Transaction timestamp (epoch ms) |
| createdAt | Long | Record creation time |
### categories
| Column | Type | Description |
|--------|------|-------------|
| id | Long (PK, auto) | Primary key |
| name | String | Category name |
| icon | String | Material icon name |
| color | String | Hex color code |
| isDefault | Boolean | System default vs user-created |
### budgets
| Column | Type | Description |
|--------|------|-------------|
| id | Long (PK, auto) | Primary key |
| categoryId | Long? (FK) | Null = overall budget |
| monthlyLimit | Double | Budget limit in Ksh |
| isActive | Boolean | Whether alerts are enabled |
### category_rules
| Column | Type | Description |
|--------|------|-------------|
| id | Long (PK, auto) | Primary key |
| categoryId | Long (FK) | Target category |
| matchType | Enum | RECIPIENT_NAME, PHONE, TILL, PAYBILL |
| matchValue | String | Value to match |
### Default Categories
| Category | Icon | Color | Auto-match examples |
|----------|------|-------|---------------------|
| Groceries | shopping_cart | #4CAF50 | NAIVAS, QUICKMART, CARREFOUR |
| Transport | directions_car | #2196F3 | Uber, Bolt, matatu paybills |
| Bills & Utilities | receipt | #FF9800 | KPLC, NAIROBI WATER, DSTV |
| Airtime & Data | phone_android | #9C27B0 | Airtime purchases |
| Food & Dining | restaurant | #E91E63 | Restaurants, fast food |
| Send Money | person | #607D8B | Person-to-person transfers |
| Received | account_balance_wallet | #00BCD4 | Money received |
| Withdrawal | atm | #795548 | Agent withdrawals |
| Deposit | savings | #8BC34A | Cash deposits |
| Shopping | shopping_bag | #FF5722 | General merchants |
| International | public | #3F51B5 | M-Pesa Global transfers |
| Savings & Loans | account_balance | #009688 | M-Shwari, KCB M-Pesa |
| Other | category | #9E9E9E | Uncategorized |
---
## Screens & UI
### Design System
- **Color palette:** M-Pesa green (#4CAF50) as primary, warm neutrals, category-specific accent colors
- **Typography:** System font, scalable (Small/Medium/Large/Extra Large + System Default)
- **Cards:** Rounded corners (16dp), subtle elevation, clean spacing
- **Animations:** Smooth transitions between screens, animated chart loading
- **Dark mode:** Full dark theme, follows system by default with manual override
### 1. Home Dashboard
```
┌─────────────────────────────────┐
│ Good morning 👋 │
│ │
│ M-PESA Balance │
│ Ksh 12,500.00 │
│ (from last transaction) │
│ │
│ ┌─────────────────────────┐ │
│ │ March 2026 │ │
│ │ Spent: Ksh 45,200 │ │
│ │ ▼ 12% vs last month │ │
│ │ Fees: Ksh 320 │ │
│ └─────────────────────────┘ │
│ │
│ [Today] [This Week] [Month] │
│ │
│ ┌─────────────────────────┐ │
│ │ 🍩 Donut Chart │ │
│ │ Category Breakdown │ │
│ └─────────────────────────┘ │
│ │
│ Recent Transactions │
│ ┌─────────────────────────┐ │
│ │ 🛒 Naivas -Ksh 1,200 │ │
│ │ 📱 Airtime -Ksh 100 │ │
│ │ 💰 Received +Ksh 5,000 │ │
│ └─────────────────────────┘ │
│ │
│ ┌────┐┌────┐┌────┐┌────┐ │
│ │Home││List││Stats││ ⚙️ │ │
│ └────┘└────┘└────┘└────┘ │
└─────────────────────────────────┘
```
> **Balance display:** Shows the `balance` field from the most recent transaction. This is the last known M-Pesa balance — not a live query. Labeled "from last transaction" so users don't confuse it with a real-time check.
### 2. Transactions List
- Grouped by day with day totals
- Each item: category icon + recipient/merchant + amount (red spent, green received)
- Search bar at top
- Filter chips: All, Sent, Received, Bills, Goods, Withdraw
- Tap → transaction detail bottom sheet
- Long press → change category
### 3. Trends / Analytics
- Bar chart: daily spending for current month
- Line chart: monthly spending over time
- Top 5 recipients/merchants list
- Category comparison vs previous period
- **Transaction fees summary** — total fees paid this period (most people don't realize how much they spend on M-Pesa fees)
- Period selector: 7D / 30D / 90D / 1Y
### 4. Settings
```
Appearance
├── Theme: System / Light / Dark
└── Font Size: System Default / Small / Medium / Large / Extra Large
Notifications
├── Daily Summary: ON/OFF + time picker
├── Weekly Summary: ON/OFF
├── Budget Alerts: ON/OFF
└── Large Transaction Alert: ON/OFF + threshold (Ksh)
Budgets
├── Overall Monthly Budget: Ksh ___
└── Per Category: [list of categories with limits]
Data
├── Import SMS History
├── Export Data (CSV/JSON)
├── Unparsed Messages (X) — view SMS that failed to parse
└── Clear All Data (with confirmation)
Backup
├── Auto Backup: ON/OFF
├── Backup Frequency: Daily / Weekly
├── Google Account: shown
├── Last Backup: date
├── Back Up Now
└── Restore from Backup
About
├── Version: 1.0.0
├── Check for Updates
└── What's New
```
---
## Notification System
| Notification | Trigger | Default |
|-------------|---------|---------|
| Daily Summary | Scheduled (user picks time, default 8 PM) | ON |
| Weekly Summary | Every Sunday evening | ON |
| Budget Warning | 80% of budget reached | ON |
| Budget Exceeded | 100% of budget reached | ON |
| Large Transaction | Single transaction > threshold | OFF (default Ksh 5,000) |
Implementation:
- WorkManager for periodic summary jobs
- Immediate check on each new transaction for budget/large amount alerts
- NotificationChannel: "Spending Summaries", "Budget Alerts"
---
## Backup System
### Google Drive Auto-Backup
- Uses Google Sign-In (already on every Android phone)
- Backs up Room database + settings to app-specific Google Drive folder
- No extra permissions needed (app data folder is automatic)
- Frequency: daily or weekly (user choice)
- On fresh install: detect existing backup → prompt restore
### Manual Export/Import
- Export: generates a ZIP file containing:
- `transactions.csv` — human-readable, can open in Excel
- `data.json` — full database export for app restore
- Import: select ZIP file → restore all data
- Share via WhatsApp, Bluetooth, email, etc.
### SMS Re-import (Fallback)
- Scan all existing SMS from "MPESA" sender
- Parse and import, skipping duplicates (by transaction code)
- Progress indicator during import
---
## Self-Update System
### Flow
1. App checks GitHub Releases API on launch (max once per day)
2. Compares current version with latest release tag
3. If newer version exists:
- Shows non-intrusive banner: "Update available (v1.2.0)"
- Tap → shows changelog (from release notes)
- "Download & Install" button
4. Downloads APK to app cache
5. Triggers Android package installer intent
6. User confirms install (standard Android prompt)
### GitHub Release Format
```
Tag: v1.0.0
Title: Version 1.0.0
Body: Changelog in markdown
Asset: ledga-v1.0.0.apk
```
### Endpoint
```
GET https://api.github.com/repos/{owner}/{repo}/releases/latest
```
---
## Auto-Categorization
### Rule Engine (Priority Order)
1. **User rules** — if user manually categorized a merchant before, remember it
2. **Category rules table** — pattern matching on recipient name, till number, paybill
3. **Transaction type fallback** — airtime → Airtime, withdrawal → Withdrawal, etc.
4. **Default** — "Other" category
### Pre-loaded Rules
| Pattern | Match Type | Category |
|---------|------------|----------|
| NAIVAS, QUICKMART, CARREFOUR, CLEANSHELF | Recipient | Groceries |
| KPLC, KENYA POWER | Recipient | Bills |
| 888880 (KPLC Prepaid), 888888 (KPLC Postpaid) | Paybill | Bills |
| NAIROBI WATER, ELDORET WATER | Recipient | Bills |
| 444400 (Nairobi Water) | Paybill | Bills |
| DSTV, GOTV, SHOWMAX | Recipient | Bills |
| UBER, BOLT, LITTLE | Recipient | Transport |
| JAVA, KFC, CHICKEN INN, PIZZA INN | Recipient | Food & Dining |
| (any self airtime) | Type | Airtime |
| (any airtime for others) | Type | Airtime |
| (any withdrawal) | Type | Withdrawal |
| (any deposit) | Type | Deposit |
| (any received) | Type | Received |
| (any send money) | Type | Send Money |
| (any M-Pesa Global) | Type | International |
| (any M-Shwari) | Type | Savings & Loans |
| (any KCB M-Pesa) | Type | Savings & Loans |
Users can add/edit rules from the category settings.
---
## Permissions Required
| Permission | Purpose | When Requested |
|-----------|---------|----------------|
| RECEIVE_SMS | Listen for new M-Pesa SMS in real-time | On first launch |
| READ_SMS | Import existing M-Pesa SMS history | When user taps "Import History" |
| POST_NOTIFICATIONS | Daily summaries, budget alerts | On first launch (Android 13+) |
| INTERNET | Google Drive backup, update checks | Automatic |
| REQUEST_INSTALL_PACKAGES | Self-update APK installation | When user taps "Install Update" |
---
## Project Structure
```
ledga/
├── app/
│ ├── src/
│ │ ├── main/
│ │ │ ├── java/com/ledga/app/
│ │ │ │ ├── MainActivity.kt
│ │ │ │ ├── LedgaApp.kt
│ │ │ │ ├── di/
│ │ │ │ │ ├── AppModule.kt
│ │ │ │ │ └── DatabaseModule.kt
│ │ │ │ ├── data/
│ │ │ │ │ ├── db/
│ │ │ │ │ │ ├── AppDatabase.kt
│ │ │ │ │ │ ├── Migrations.kt
│ │ │ │ │ │ ├── dao/
│ │ │ │ │ │ │ ├── TransactionDao.kt
│ │ │ │ │ │ │ ├── CategoryDao.kt
│ │ │ │ │ │ │ └── BudgetDao.kt
│ │ │ │ │ │ └── entity/
│ │ │ │ │ │ ├── Transaction.kt
│ │ │ │ │ │ ├── Category.kt
│ │ │ │ │ │ ├── Budget.kt
│ │ │ │ │ │ └── CategoryRule.kt
│ │ │ │ │ ├── repository/
│ │ │ │ │ │ ├── TransactionRepository.kt
│ │ │ │ │ │ ├── CategoryRepository.kt
│ │ │ │ │ │ └── SettingsRepository.kt
│ │ │ │ │ └── parser/
│ │ │ │ │ └── MpesaSmsParser.kt
│ │ │ │ ├── receiver/
│ │ │ │ │ └── SmsReceiver.kt
│ │ │ │ ├── worker/
│ │ │ │ │ ├── NotificationWorker.kt
│ │ │ │ │ ├── BackupWorker.kt
│ │ │ │ │ └── UpdateCheckWorker.kt
│ │ │ │ ├── ui/
│ │ │ │ │ ├── theme/
│ │ │ │ │ │ ├── Theme.kt
│ │ │ │ │ │ ├── Color.kt
│ │ │ │ │ │ └── Type.kt
│ │ │ │ │ ├── navigation/
│ │ │ │ │ │ └── AppNavigation.kt
│ │ │ │ │ ├── onboarding/
│ │ │ │ │ │ ├── OnboardingScreen.kt
│ │ │ │ │ │ └── OnboardingViewModel.kt
│ │ │ │ │ ├── home/
│ │ │ │ │ │ ├── HomeScreen.kt
│ │ │ │ │ │ └── HomeViewModel.kt
│ │ │ │ │ ├── transactions/
│ │ │ │ │ │ ├── TransactionsScreen.kt
│ │ │ │ │ │ ├── TransactionDetailSheet.kt
│ │ │ │ │ │ └── TransactionsViewModel.kt
│ │ │ │ │ ├── trends/
│ │ │ │ │ │ ├── TrendsScreen.kt
│ │ │ │ │ │ └── TrendsViewModel.kt
│ │ │ │ │ ├── settings/
│ │ │ │ │ │ ├── SettingsScreen.kt
│ │ │ │ │ │ ├── SettingsViewModel.kt
│ │ │ │ │ │ └── UnparsedSmsScreen.kt
│ │ │ │ │ └── components/
│ │ │ │ │ ├── TransactionCard.kt
│ │ │ │ │ ├── CategoryChip.kt
│ │ │ │ │ ├── SpendingChart.kt
│ │ │ │ │ └── BudgetProgressBar.kt
│ │ │ │ └── util/
│ │ │ │ ├── CurrencyFormatter.kt
│ │ │ │ └── DateUtils.kt
│ │ │ ├── res/
│ │ │ └── AndroidManifest.xml
│ │ └── test/
│ │ └── java/com/ledga/app/
│ │ └── parser/
│ │ └── MpesaSmsParserTest.kt
│ └── build.gradle.kts
├── build.gradle.kts
├── settings.gradle.kts
└── README.md
```
---
## Build Phases
### Phase 1 — Core (MVP)
- [ ] Project setup (Kotlin, Compose, Room, Hilt, Gradle)
- [ ] SMS parser — handle all M-Pesa transaction types (including M-Pesa Global, M-Shwari, KCB M-Pesa)
- [ ] SMS parser unit tests — real SMS samples for every transaction type
- [ ] Room database — transactions, categories, category rules, migrations setup
- [ ] SMS BroadcastReceiver — real-time capture
- [ ] SMS history import
- [ ] Onboarding flow — welcome screen, SMS permission with plain-language explanation, optional history import
- [ ] Home dashboard — current balance, spending total, fees total, donut chart, recent transactions
- [ ] Transactions list — grouped by day, search, filter
- [ ] Basic auto-categorization with default rules (name + paybill/till matching)
- [ ] Bottom navigation (Home, Transactions, Trends, Settings)
- [ ] Theme system (System/Light/Dark)
### Phase 2 — Polish
- [ ] Trends screen — bar charts, line charts, top merchants, fees summary, period comparison
- [ ] Font size settings (System/Small/Medium/Large/Extra Large)
- [ ] Budget system — set limits, track progress bars
- [ ] Category management — add/edit/delete custom categories
- [ ] Transaction detail bottom sheet
- [ ] Long press to re-categorize
- [ ] Category rules editor
- [ ] Unparsed SMS viewer (Settings → Data)
### Phase 3 — Notifications & Backup
- [ ] Daily spending summary notification
- [ ] Weekly summary notification
- [ ] Budget alert notifications
- [ ] Large transaction alerts
- [ ] Notification settings (toggles, time pickers)
- [ ] Google Drive auto-backup
- [ ] Google Drive restore on fresh install
- [ ] Manual export (CSV + JSON zip)
- [ ] Manual import
### Phase 4 — Distribution
- [ ] APK signing setup — generate keystore, document secure storage
- [ ] GitHub Actions CI/CD — build release APK on push to main
- [ ] GitHub Releases self-update system
- [ ] Update check on launch
- [ ] Changelog display
- [ ] APK download + install flow
- [ ] App icon and splash screen
- [ ] Final UI polish and animations
---
## App Name
**Ledga** — a play on "ledger". Short, unique, easy to say.
- Package name: `com.ledga.app`
---
## Onboarding Flow
First launch experience for non-technical users. Only shown once — a `hasCompletedOnboarding` flag in DataStore skips it on subsequent launches.
```
┌─────────────────────────────────┐
│ │
│ Welcome to Ledga │
│ │
│ Track your M-Pesa spending │
│ automatically. No typing, │
│ no manual entry. │
│ │
│ [Get Started →] │
│ │
└─────────────────────────────────┘
↓
┌─────────────────────────────────┐
│ │
│ Ledga reads your M-Pesa SMS │
│ to track your spending. │
│ │
│ Your messages never leave │
│ your phone. Everything stays │
│ private and offline. │
│ │
│ [Allow SMS Access →] │
│ │
└─────────────────────────────────┘
↓
┌─────────────────────────────────┐
│ │
│ Import your M-Pesa history? │
│ │
│ We can scan your existing │
│ M-Pesa messages to build │
│ your spending history. │
│ │
│ [Import History] │
│ [Skip — start fresh] │
│ │
│ ┌─────────────────────┐ │
│ │ ████████░░ 67% │ │
│ │ Found 342 transactions│ │
│ └─────────────────────┘ │
│ │
└─────────────────────────────────┘
↓
┌─────────────────────────────────┐
│ │
│ You're all set! │
│ │
│ Ledga will now track your │
│ M-Pesa spending in the │
│ background. │
│ │
│ [Go to Dashboard →] │
│ │
└─────────────────────────────────┘
```
---
## Testing Strategy
### Unit Tests (Critical)
- **SMS Parser** — test every transaction type with real M-Pesa SMS samples
- At least 3 variations per type (different amounts, names, edge cases)
- Malformed messages (truncated SMS, missing fields)
- Messages from non-M-Pesa senders (should be ignored)
- Fuliza messages embedded in send/buy goods SMS
- M-Pesa Global with various country formats
- M-Shwari/KCB M-Pesa detection
- Amount parsing edge cases: `Ksh1.00`, `Ksh100,000.00`
- **Auto-categorization** — rule matching priority, paybill matching, fallback behavior
- **CurrencyFormatter** — Ksh formatting, large numbers, zero amounts
### Integration Tests
- Room database — DAO queries, migrations, duplicate transaction code handling
- SMS import — bulk parsing performance, duplicate skipping
### Manual Testing
- Collect real M-Pesa SMS from family members (anonymized) as test fixtures
- Test on low-end Android devices (target audience likely has budget phones)
---
## APK Signing & CI/CD
### Keystore
- Generate a release keystore and store it securely
- **CRITICAL:** Back up the keystore — if lost, existing installs can never receive updates (Android requires consistent signing)
- Store keystore password and key alias in a secure location (not in the repo)
### GitHub Actions
```yaml
# .github/workflows/release.yml
# Triggered on push to main or manual dispatch
# Steps:
# 1. Checkout code
# 2. Set up JDK 17
# 3. Decode keystore from GitHub secret
# 4. Build release APK (assembleRelease)
# 5. Sign APK
# 6. Create GitHub Release with APK asset
```
### GitHub Secrets Required
| Secret | Purpose |
|--------|---------|
| `KEYSTORE_BASE64` | Base64-encoded release keystore |
| `KEYSTORE_PASSWORD` | Keystore password |
| `KEY_ALIAS` | Key alias name |
| `KEY_PASSWORD` | Key password |
---
## Database Migrations
Schema changes are inevitable as the app evolves. Room requires explicit migrations to preserve user data.
### Strategy
- **Never use `fallbackToDestructiveMigration()`** — this destroys all user data on schema change
- Define all migrations in `Migrations.kt` as `Migration(oldVersion, newVersion)` objects
- Test every migration path with Room's `MigrationTestHelper`
- Keep a version history:
| Version | Changes |
|---------|---------|
| 1 | Initial schema (transactions, categories, budgets, category_rules) |
### Example Migration Pattern
```kotlin
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE transactions ADD COLUMN newField TEXT")
}
}
```
---
## Notes
- All data stays on device — no external server, no analytics, no tracking
- SMS parsing must be thoroughly tested with real M-Pesa SMS variations
- Fuliza messages have extra fields — parser must handle gracefully
- Fuliza has 3 distinct message types: borrow (Fuliza M-PESA amount), repayment (paid to Fuliza), and reversal — each has a different format
- Some M-Pesa SMS formats may vary slightly — keep raw SMS stored for re-parsing
- Transaction code uniqueness prevents duplicate imports
- Multi-SIM: some users have two M-Pesa lines — consider adding a `simSlot` or `phoneNumber` field on transactions (extracted from SMS subscription ID) if multi-line tracking is needed later
- Google Drive backup uses `appDataFolder` scope — backups are invisible to the user in Drive. This is by design (no extra permissions), but users should know they won't find the file browsing Drive manually
- The `direction` column (INFLOW/OUTFLOW) is set at parse time based on transaction type, avoiding repeated derivation in every query for totals and charts
+135
View File
@@ -0,0 +1,135 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization")
id("com.google.devtools.ksp")
id("com.google.dagger.hilt.android")
}
android {
namespace = "com.ledga.app"
compileSdk = 36
signingConfigs {
create("release") {
val keystoreFile = rootProject.file("keystore/ledga-release.jks")
if (keystoreFile.exists()) {
storeFile = keystoreFile
storePassword = System.getenv("KEYSTORE_PASSWORD") ?: "ledga2026"
keyAlias = System.getenv("KEY_ALIAS") ?: "ledga"
keyPassword = System.getenv("KEY_PASSWORD") ?: "ledga2026"
}
}
}
defaultConfig {
applicationId = "com.ledga.app"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
signingConfig = signingConfigs.getByName("release")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
buildConfig = true
}
packaging {
resources {
excludes += setOf(
"META-INF/INDEX.LIST",
"META-INF/DEPENDENCIES",
"META-INF/LICENSE.md",
"META-INF/NOTICE.md"
)
}
}
}
dependencies {
// Compose BOM
val composeBom = platform("androidx.compose:compose-bom:2026.03.00")
implementation(composeBom)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
debugImplementation("androidx.compose.ui:ui-tooling")
// Activity & Lifecycle
implementation("androidx.activity:activity-compose:1.13.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.10.0")
implementation("androidx.core:core-ktx:1.16.0")
// Navigation
implementation("androidx.navigation:navigation-compose:2.9.7")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
// Room
implementation("androidx.room:room-runtime:2.8.4")
implementation("androidx.room:room-ktx:2.8.4")
ksp("androidx.room:room-compiler:2.8.4")
// Hilt
implementation("com.google.dagger:hilt-android:2.57.1")
ksp("com.google.dagger:hilt-compiler:2.57.1")
implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
// DataStore
implementation("androidx.datastore:datastore-preferences:1.2.1")
// WorkManager
implementation("androidx.work:work-runtime-ktx:2.11.1")
implementation("androidx.hilt:hilt-work:1.2.0")
ksp("androidx.hilt:hilt-compiler:1.2.0")
// Vico Charts
implementation("com.patrykandpatrick.vico:compose-m3:2.1.3")
// Google Drive Backup
implementation("com.google.android.gms:play-services-auth:21.3.0")
implementation("com.google.api-client:google-api-client-android:2.7.2")
implementation("com.google.apis:google-api-services-drive:v3-rev20241206-2.0.0")
implementation("com.google.http-client:google-http-client-gson:1.45.3")
// Splash Screen
implementation("androidx.core:core-splashscreen:1.0.1")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
// Testing
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
androidTestImplementation(composeBom)
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
}
+3
View File
@@ -0,0 +1,3 @@
# Ledga ProGuard Rules
# Keep Room entities
-keep class com.ledga.app.data.db.entity.** { *; }
+49
View File
@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.RECEIVE_SMS" />
<uses-permission android:name="android.permission.READ_SMS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:name=".LedgaApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Ledga">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.Ledga.Splash">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<receiver
android:name=".receiver.SmsReceiver"
android:exported="true"
android:permission="android.permission.BROADCAST_SMS">
<intent-filter android:priority="999">
<action android:name="android.provider.Telephony.SMS_RECEIVED" />
</intent-filter>
</receiver>
</application>
</manifest>
@@ -0,0 +1,28 @@
package com.ledga.app
import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import com.ledga.app.worker.NotificationHelper
import com.ledga.app.worker.NotificationScheduler
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
@HiltAndroidApp
class LedgaApp : Application(), Configuration.Provider {
@Inject
lateinit var workerFactory: HiltWorkerFactory
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
override fun onCreate() {
super.onCreate()
NotificationHelper.createChannels(this)
NotificationScheduler.scheduleDailySummary(this)
NotificationScheduler.scheduleWeeklySummary(this)
}
}
@@ -0,0 +1,98 @@
package com.ledga.app
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.ledga.app.data.repository.FontScale
import com.ledga.app.data.repository.SettingsRepository
import com.ledga.app.ui.navigation.AppNavigation
import com.ledga.app.ui.navigation.HomeRoute
import com.ledga.app.ui.navigation.LedgaBottomNavBar
import com.ledga.app.ui.navigation.OnboardingRoute
import com.ledga.app.ui.navigation.UnparsedSmsRoute
import com.ledga.app.ui.theme.LedgaTheme
import com.ledga.app.ui.theme.ThemeMode
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
@HiltViewModel
class MainViewModel @Inject constructor(
settingsRepository: SettingsRepository
) : ViewModel() {
val themeMode: StateFlow<ThemeMode> = settingsRepository.getThemeMode()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ThemeMode.SYSTEM)
val hasCompletedOnboarding: StateFlow<Boolean> = settingsRepository.hasCompletedOnboarding()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true)
val fontScale: StateFlow<FontScale> = settingsRepository.getFontScale()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), FontScale.SYSTEM)
}
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
val viewModel: MainViewModel = hiltViewModel()
val themeMode by viewModel.themeMode.collectAsState()
val hasCompletedOnboarding by viewModel.hasCompletedOnboarding.collectAsState()
val fontScale by viewModel.fontScale.collectAsState()
LedgaTheme(themeMode = themeMode) {
val density = LocalDensity.current
val scaledDensity = if (fontScale == FontScale.SYSTEM) density
else Density(density.density, fontScale.scale)
CompositionLocalProvider(LocalDensity provides scaledDensity) {
val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
val startDestination: Any = if (hasCompletedOnboarding) HomeRoute else OnboardingRoute
val showBottomBar = currentRoute != null &&
!currentRoute.contains("Onboarding") &&
!currentRoute.contains("UnparsedSms") &&
!currentRoute.contains("Budget")
Scaffold(
bottomBar = {
if (showBottomBar) {
LedgaBottomNavBar(navController = navController)
}
}
) { innerPadding ->
AppNavigation(
navController = navController,
startDestination = startDestination,
modifier = Modifier.padding(innerPadding)
)
}
}
}
}
}
}
@@ -0,0 +1,31 @@
package com.ledga.app.data.db
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.ledga.app.data.db.dao.BudgetDao
import com.ledga.app.data.db.dao.CategoryDao
import com.ledga.app.data.db.dao.CategoryRuleDao
import com.ledga.app.data.db.dao.TransactionDao
import com.ledga.app.data.db.entity.Budget
import com.ledga.app.data.db.entity.Category
import com.ledga.app.data.db.entity.CategoryRule
import com.ledga.app.data.db.entity.TransactionEntity
@Database(
entities = [
TransactionEntity::class,
Category::class,
Budget::class,
CategoryRule::class
],
version = 1,
exportSchema = true
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun transactionDao(): TransactionDao
abstract fun categoryDao(): CategoryDao
abstract fun categoryRuleDao(): CategoryRuleDao
abstract fun budgetDao(): BudgetDao
}
@@ -0,0 +1,27 @@
package com.ledga.app.data.db
import androidx.room.TypeConverter
import com.ledga.app.data.db.entity.MatchType
import com.ledga.app.data.parser.TransactionDirection
import com.ledga.app.data.parser.TransactionType
class Converters {
@TypeConverter
fun fromTransactionType(value: TransactionType): String = value.name
@TypeConverter
fun toTransactionType(value: String): TransactionType = TransactionType.valueOf(value)
@TypeConverter
fun fromTransactionDirection(value: TransactionDirection): String = value.name
@TypeConverter
fun toTransactionDirection(value: String): TransactionDirection = TransactionDirection.valueOf(value)
@TypeConverter
fun fromMatchType(value: MatchType): String = value.name
@TypeConverter
fun toMatchType(value: String): MatchType = MatchType.valueOf(value)
}
@@ -0,0 +1,54 @@
package com.ledga.app.data.db
import com.ledga.app.data.db.entity.Category
import com.ledga.app.data.db.entity.CategoryRule
import com.ledga.app.data.db.entity.MatchType
object DefaultData {
val DEFAULT_CATEGORIES = listOf(
Category(id = 1, name = "Groceries", icon = "shopping_cart", color = "#4CAF50"),
Category(id = 2, name = "Transport", icon = "directions_car", color = "#2196F3"),
Category(id = 3, name = "Bills & Utilities", icon = "receipt", color = "#FF9800"),
Category(id = 4, name = "Airtime & Data", icon = "phone_android", color = "#9C27B0"),
Category(id = 5, name = "Food & Dining", icon = "restaurant", color = "#E91E63"),
Category(id = 6, name = "Send Money", icon = "person", color = "#607D8B"),
Category(id = 7, name = "Received", icon = "account_balance_wallet", color = "#00BCD4"),
Category(id = 8, name = "Withdrawal", icon = "atm", color = "#795548"),
Category(id = 9, name = "Deposit", icon = "savings", color = "#8BC34A"),
Category(id = 10, name = "Shopping", icon = "shopping_bag", color = "#FF5722"),
Category(id = 11, name = "International", icon = "public", color = "#3F51B5"),
Category(id = 12, name = "Savings & Loans", icon = "account_balance", color = "#009688"),
Category(id = 13, name = "Other", icon = "category", color = "#9E9E9E"),
)
val DEFAULT_RULES = listOf(
// Groceries (id=1)
CategoryRule(categoryId = 1, matchType = MatchType.RECIPIENT_NAME, matchValue = "NAIVAS"),
CategoryRule(categoryId = 1, matchType = MatchType.RECIPIENT_NAME, matchValue = "QUICKMART"),
CategoryRule(categoryId = 1, matchType = MatchType.RECIPIENT_NAME, matchValue = "CARREFOUR"),
CategoryRule(categoryId = 1, matchType = MatchType.RECIPIENT_NAME, matchValue = "CLEANSHELF"),
// Transport (id=2)
CategoryRule(categoryId = 2, matchType = MatchType.RECIPIENT_NAME, matchValue = "UBER"),
CategoryRule(categoryId = 2, matchType = MatchType.RECIPIENT_NAME, matchValue = "BOLT"),
CategoryRule(categoryId = 2, matchType = MatchType.RECIPIENT_NAME, matchValue = "LITTLE"),
// Bills & Utilities (id=3)
CategoryRule(categoryId = 3, matchType = MatchType.RECIPIENT_NAME, matchValue = "KPLC"),
CategoryRule(categoryId = 3, matchType = MatchType.RECIPIENT_NAME, matchValue = "KENYA POWER"),
CategoryRule(categoryId = 3, matchType = MatchType.PAYBILL, matchValue = "888880"),
CategoryRule(categoryId = 3, matchType = MatchType.PAYBILL, matchValue = "888888"),
CategoryRule(categoryId = 3, matchType = MatchType.RECIPIENT_NAME, matchValue = "NAIROBI WATER"),
CategoryRule(categoryId = 3, matchType = MatchType.PAYBILL, matchValue = "444400"),
CategoryRule(categoryId = 3, matchType = MatchType.RECIPIENT_NAME, matchValue = "DSTV"),
CategoryRule(categoryId = 3, matchType = MatchType.RECIPIENT_NAME, matchValue = "GOTV"),
CategoryRule(categoryId = 3, matchType = MatchType.RECIPIENT_NAME, matchValue = "SHOWMAX"),
// Food & Dining (id=5)
CategoryRule(categoryId = 5, matchType = MatchType.RECIPIENT_NAME, matchValue = "JAVA"),
CategoryRule(categoryId = 5, matchType = MatchType.RECIPIENT_NAME, matchValue = "KFC"),
CategoryRule(categoryId = 5, matchType = MatchType.RECIPIENT_NAME, matchValue = "CHICKEN INN"),
CategoryRule(categoryId = 5, matchType = MatchType.RECIPIENT_NAME, matchValue = "PIZZA INN"),
)
}
@@ -0,0 +1,19 @@
package com.ledga.app.data.db
/**
* Room database migrations.
*
* NEVER use fallbackToDestructiveMigration() — this destroys all user data.
* Always define explicit migrations for schema changes.
*
* Version history:
* 1 - Initial schema (transactions, categories, budgets, category_rules)
*/
object Migrations {
// Future migrations will be added here as:
// val MIGRATION_1_2 = object : Migration(1, 2) {
// override fun migrate(db: SupportSQLiteDatabase) {
// db.execSQL("ALTER TABLE ...")
// }
// }
}
@@ -0,0 +1,18 @@
package com.ledga.app.data.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.ledga.app.data.db.entity.Budget
import kotlinx.coroutines.flow.Flow
@Dao
interface BudgetDao {
@Query("SELECT * FROM budgets WHERE isActive = 1")
fun getActiveBudgets(): Flow<List<Budget>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(budget: Budget): Long
}
@@ -0,0 +1,21 @@
package com.ledga.app.data.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.ledga.app.data.db.entity.Category
import kotlinx.coroutines.flow.Flow
@Dao
interface CategoryDao {
@Query("SELECT * FROM categories ORDER BY name")
fun getAllCategories(): Flow<List<Category>>
@Query("SELECT * FROM categories WHERE id = :id")
suspend fun getCategoryById(id: Long): Category?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(categories: List<Category>)
}
@@ -0,0 +1,24 @@
package com.ledga.app.data.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.ledga.app.data.db.entity.CategoryRule
import kotlinx.coroutines.flow.Flow
@Dao
interface CategoryRuleDao {
@Query("SELECT * FROM category_rules")
fun getAllRules(): Flow<List<CategoryRule>>
@Query("SELECT * FROM category_rules WHERE categoryId = :categoryId")
fun getRulesForCategory(categoryId: Long): Flow<List<CategoryRule>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(rules: List<CategoryRule>)
@Query("SELECT * FROM category_rules")
suspend fun getAllRulesSync(): List<CategoryRule>
}
@@ -0,0 +1,102 @@
package com.ledga.app.data.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import com.ledga.app.data.db.entity.TransactionEntity
import com.ledga.app.data.db.entity.TransactionWithCategory
import com.ledga.app.data.parser.TransactionType
import kotlinx.coroutines.flow.Flow
data class CategorySpending(
val categoryId: Long?,
val totalAmount: Double
)
data class DailySpending(
val dayTimestamp: Long,
val totalAmount: Double
)
data class TopMerchant(
val recipientName: String,
val totalAmount: Double,
val transactionCount: Int
)
@Dao
interface TransactionDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insert(transaction: TransactionEntity): Long
@Query("UPDATE transactions SET categoryId = :categoryId WHERE id = :id")
suspend fun updateCategory(id: Long, categoryId: Long?)
@Transaction
@Query("SELECT * FROM transactions ORDER BY timestamp DESC LIMIT :limit")
fun getRecentTransactions(limit: Int = 10): Flow<List<TransactionWithCategory>>
@Transaction
@Query("SELECT * FROM transactions WHERE timestamp BETWEEN :startTime AND :endTime ORDER BY timestamp DESC")
fun getTransactions(startTime: Long, endTime: Long): Flow<List<TransactionWithCategory>>
@Query("SELECT * FROM transactions ORDER BY timestamp DESC LIMIT 1")
fun getLatestTransaction(): Flow<TransactionEntity?>
@Query("""
SELECT categoryId, SUM(amount) as totalAmount
FROM transactions
WHERE direction = 'OUTFLOW' AND timestamp BETWEEN :startTime AND :endTime
GROUP BY categoryId
""")
fun getSpendingByCategory(startTime: Long, endTime: Long): Flow<List<CategorySpending>>
@Query("SELECT COALESCE(SUM(amount), 0.0) FROM transactions WHERE direction = 'OUTFLOW' AND timestamp BETWEEN :startTime AND :endTime")
fun getTotalSpending(startTime: Long, endTime: Long): Flow<Double>
@Query("SELECT COALESCE(SUM(transactionCost), 0.0) FROM transactions WHERE timestamp BETWEEN :startTime AND :endTime")
fun getTotalFees(startTime: Long, endTime: Long): Flow<Double>
@Transaction
@Query("SELECT * FROM transactions WHERE recipientName LIKE '%' || :query || '%' ORDER BY timestamp DESC")
fun searchTransactions(query: String): Flow<List<TransactionWithCategory>>
@Transaction
@Query("SELECT * FROM transactions WHERE type IN (:types) ORDER BY timestamp DESC")
fun getTransactionsByType(types: List<TransactionType>): Flow<List<TransactionWithCategory>>
@Query("SELECT * FROM transactions WHERE transactionCode = :code LIMIT 1")
suspend fun getTransactionByCode(code: String): TransactionEntity?
@Transaction
@Query("SELECT * FROM transactions WHERE type = 'UNKNOWN' ORDER BY timestamp DESC")
fun getUnparsedTransactions(): Flow<List<TransactionWithCategory>>
// Trends: daily spending totals for a period
@Query("""
SELECT (timestamp / 86400000) * 86400000 as dayTimestamp, SUM(amount) as totalAmount
FROM transactions
WHERE direction = 'OUTFLOW' AND timestamp BETWEEN :startTime AND :endTime
GROUP BY dayTimestamp
ORDER BY dayTimestamp ASC
""")
fun getDailySpending(startTime: Long, endTime: Long): Flow<List<DailySpending>>
// Trends: top merchants by total spending
@Query("""
SELECT recipientName, SUM(amount) as totalAmount, COUNT(*) as transactionCount
FROM transactions
WHERE direction = 'OUTFLOW' AND recipientName IS NOT NULL
AND timestamp BETWEEN :startTime AND :endTime
GROUP BY recipientName
ORDER BY totalAmount DESC
LIMIT :limit
""")
fun getTopMerchants(startTime: Long, endTime: Long, limit: Int = 5): Flow<List<TopMerchant>>
@Query("SELECT COUNT(*) FROM transactions WHERE type = 'UNKNOWN'")
fun getUnparsedCount(): Flow<Int>
}
@@ -0,0 +1,26 @@
package com.ledga.app.data.db.entity
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "budgets",
indices = [Index(value = ["categoryId"])],
foreignKeys = [
ForeignKey(
entity = Category::class,
parentColumns = ["id"],
childColumns = ["categoryId"],
onDelete = ForeignKey.CASCADE
)
]
)
data class Budget(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val categoryId: Long?,
val monthlyLimit: Double,
val isActive: Boolean = true
)
@@ -0,0 +1,14 @@
package com.ledga.app.data.db.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "categories")
data class Category(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val name: String,
val icon: String,
val color: String,
val isDefault: Boolean = true
)
@@ -0,0 +1,30 @@
package com.ledga.app.data.db.entity
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
enum class MatchType {
RECIPIENT_NAME, PHONE, TILL, PAYBILL
}
@Entity(
tableName = "category_rules",
indices = [Index(value = ["categoryId"])],
foreignKeys = [
ForeignKey(
entity = Category::class,
parentColumns = ["id"],
childColumns = ["categoryId"],
onDelete = ForeignKey.CASCADE
)
]
)
data class CategoryRule(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val categoryId: Long,
val matchType: MatchType,
val matchValue: String
)
@@ -0,0 +1,47 @@
package com.ledga.app.data.db.entity
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import com.ledga.app.data.parser.TransactionDirection
import com.ledga.app.data.parser.TransactionType
@Entity(
tableName = "transactions",
indices = [
Index(value = ["transactionCode"], unique = true),
Index(value = ["categoryId"]),
Index(value = ["timestamp"]),
Index(value = ["type"])
],
foreignKeys = [
ForeignKey(
entity = Category::class,
parentColumns = ["id"],
childColumns = ["categoryId"],
onDelete = ForeignKey.SET_NULL
)
]
)
data class TransactionEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val transactionCode: String,
val type: TransactionType,
val amount: Double,
val transactionCost: Double,
val recipientName: String?,
val recipientPhone: String?,
val accountNumber: String?,
val destinationCountry: String?,
val balance: Double,
val direction: TransactionDirection,
val categoryId: Long?,
val fulizaAmount: Double?,
val fulizaOutstanding: Double?,
val reversedTransactionCode: String?,
val rawSms: String,
val timestamp: Long,
val createdAt: Long = System.currentTimeMillis()
)
@@ -0,0 +1,13 @@
package com.ledga.app.data.db.entity
import androidx.room.Embedded
import androidx.room.Relation
data class TransactionWithCategory(
@Embedded val transaction: TransactionEntity,
@Relation(
parentColumn = "categoryId",
entityColumn = "id"
)
val category: Category?
)
@@ -0,0 +1,388 @@
package com.ledga.app.data.parser
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,
REVERSAL, UNKNOWN
}
enum class TransactionDirection { INFLOW, OUTFLOW }
data class ParsedTransaction(
val transactionCode: String,
val type: TransactionType,
val amount: Double,
val transactionCost: Double,
val recipientName: String?,
val recipientPhone: String?,
val accountNumber: String?,
val destinationCountry: String?,
val balance: Double,
val direction: TransactionDirection,
val fulizaAmount: Double?,
val fulizaOutstanding: Double?,
val reversedTransactionCode: String?,
val timestamp: Long,
val rawSms: String
)
sealed interface ParseResult {
data class Success(val transaction: ParsedTransaction) : ParseResult
data class Failure(val rawSms: String, val reason: String) : ParseResult
}
object MpesaSmsParser {
private val AMOUNT_REGEX = Regex("""Ksh([\d,]+\.\d{2})""")
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})""")
fun isMpesaMessage(sender: String): Boolean {
return sender.equals("MPESA", ignoreCase = true)
}
fun parse(smsBody: String, smsTimestamp: Long = System.currentTimeMillis()): ParseResult {
val code = extractTransactionCode(smsBody)
?: return ParseResult.Failure(smsBody, "No transaction code found")
if (!smsBody.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
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)
}
ParseResult.Success(transaction)
} catch (e: Exception) {
ParseResult.Failure(smsBody, "Parse error: ${e.message}")
}
}
// --- Type detection ---
private fun isReversal(sms: String) =
sms.contains("has been reversed") && !sms.contains("Fuliza", ignoreCase = true)
private fun isFulizaReversal(sms: String) =
sms.contains("Fuliza M-PESA of", ignoreCase = true) && sms.contains("has been reversed")
private fun isFulizaRepayment(sms: String) =
sms.contains("paid", ignoreCase = true) && sms.contains("to Fuliza M-PESA", ignoreCase = true)
private fun isMshwari(sms: String) =
sms.contains("M-Shwari", ignoreCase = true)
private fun isKcbMpesa(sms: String) =
sms.contains("KCB M-Pesa", ignoreCase = true) || sms.contains("KCB M-PESA", ignoreCase = true)
private fun isMpesaGlobal(sms: String) =
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)
private fun isDeposit(sms: String) =
sms.contains("You have 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)
private fun isAirtimeOther(sms: String) =
sms.contains("bought", ignoreCase = true) && sms.contains("airtime for", ignoreCase = true)
private fun isWithdrawAtm(sms: String) =
sms.contains("withdrawn", ignoreCase = true) && sms.contains("from an ATM", ignoreCase = true)
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)
private fun isBuyGoods(sms: String) =
sms.contains("paid to", ignoreCase = true) && !sms.contains("Account Number", ignoreCase = true)
private fun isSendMoney(sms: String) =
sms.contains("sent to", ignoreCase = true)
// --- Parsers ---
private fun parseSendMoney(sms: String, code: String, balance: Double, cost: Double, timestamp: Long): ParsedTransaction {
val amount = extractFirstAmount(sms) ?: 0.0
val sendToRegex = Regex("""sent to\s+(.+?)\s+(\d{10,})\s+on""", RegexOption.IGNORE_CASE)
val match = sendToRegex.find(sms)
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,
fulizaAmount = null, fulizaOutstanding = null, reversedTransactionCode = null,
timestamp = timestamp, rawSms = sms
)
}
private fun parseBuyGoods(sms: String, code: String, balance: Double, cost: Double, timestamp: Long): ParsedTransaction {
val amount = extractFirstAmount(sms) ?: 0.0
val paidToRegex = Regex("""paid to\s+(.+?)\.?\s+on""", RegexOption.IGNORE_CASE)
val match = paidToRegex.find(sms)
return ParsedTransaction(
transactionCode = code, type = TransactionType.BUY_GOODS, amount = amount,
transactionCost = cost, recipientName = match?.groupValues?.get(1)?.trim(),
recipientPhone = null, accountNumber = null, destinationCountry = null,
balance = balance, direction = TransactionDirection.OUTFLOW,
fulizaAmount = null, fulizaOutstanding = null, reversedTransactionCode = null,
timestamp = timestamp, rawSms = sms
)
}
private fun parsePayBill(sms: String, code: String, balance: Double, cost: Double, timestamp: Long): ParsedTransaction {
val amount = extractFirstAmount(sms) ?: 0.0
val paidToRegex = Regex("""paid to\s+(.+?)\.\s*Account Number\s+(\S+?)\.?\s+on""", RegexOption.IGNORE_CASE)
val match = paidToRegex.find(sms)
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(),
destinationCountry = null, balance = balance, direction = TransactionDirection.OUTFLOW,
fulizaAmount = null, fulizaOutstanding = null, reversedTransactionCode = null,
timestamp = timestamp, rawSms = sms
)
}
private fun parseWithdrawAgent(sms: String, code: String, balance: Double, cost: Double, timestamp: Long): ParsedTransaction {
val amount = extractFirstAmount(sms) ?: 0.0
val fromRegex = Regex("""withdrawn.*?from\s+(.+?)\s+(\d{4,})\s+on""", RegexOption.IGNORE_CASE)
val match = fromRegex.find(sms)
return ParsedTransaction(
transactionCode = code, type = TransactionType.WITHDRAW_AGENT, amount = amount,
transactionCost = cost, recipientName = match?.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
)
}
private fun parseWithdrawAtm(sms: String, code: String, balance: Double, cost: Double, timestamp: Long): ParsedTransaction {
val amount = extractFirstAmount(sms) ?: 0.0
return ParsedTransaction(
transactionCode = code, type = TransactionType.WITHDRAW_ATM, amount = amount,
transactionCost = cost, recipientName = "ATM", recipientPhone = null,
accountNumber = null, destinationCountry = null, balance = balance,
direction = TransactionDirection.OUTFLOW, fulizaAmount = null, fulizaOutstanding = null,
reversedTransactionCode = null, timestamp = timestamp, rawSms = sms
)
}
private fun parseDeposit(sms: String, code: String, balance: Double, timestamp: Long): ParsedTransaction {
val amount = extractFirstAmount(sms) ?: 0.0
return ParsedTransaction(
transactionCode = code, type = TransactionType.DEPOSIT, amount = amount,
transactionCost = 0.0, recipientName = null, recipientPhone = null,
accountNumber = null, destinationCountry = null, balance = balance,
direction = TransactionDirection.INFLOW, fulizaAmount = null, fulizaOutstanding = null,
reversedTransactionCode = null, timestamp = timestamp, rawSms = sms
)
}
private fun parseReceived(sms: String, code: String, balance: Double, timestamp: Long): ParsedTransaction {
val amount = extractFirstAmount(sms) ?: 0.0
val fromRegex = Regex("""received.*?from\s+(.+?)\s+(\d{10,})\s+on""", RegexOption.IGNORE_CASE)
val match = fromRegex.find(sms)
return ParsedTransaction(
transactionCode = code, type = TransactionType.RECEIVED, amount = amount,
transactionCost = 0.0, recipientName = match?.groupValues?.get(1)?.trim(),
recipientPhone = match?.groupValues?.get(2), accountNumber = null,
destinationCountry = null, balance = balance, direction = TransactionDirection.INFLOW,
fulizaAmount = null, fulizaOutstanding = null, reversedTransactionCode = null,
timestamp = timestamp, rawSms = sms
)
}
private fun parseAirtimeSelf(sms: String, code: String, balance: Double, cost: Double, timestamp: Long): ParsedTransaction {
val amount = extractFirstAmount(sms) ?: 0.0
return ParsedTransaction(
transactionCode = code, type = TransactionType.AIRTIME_SELF, amount = amount,
transactionCost = cost, recipientName = null, recipientPhone = null,
accountNumber = null, destinationCountry = null, balance = balance,
direction = TransactionDirection.OUTFLOW, fulizaAmount = null, fulizaOutstanding = null,
reversedTransactionCode = null, timestamp = timestamp, rawSms = sms
)
}
private fun parseAirtimeOther(sms: String, code: String, balance: Double, cost: Double, timestamp: Long): ParsedTransaction {
val amount = extractFirstAmount(sms) ?: 0.0
val forRegex = Regex("""airtime for\s+(\d{10,})""", RegexOption.IGNORE_CASE)
val match = forRegex.find(sms)
return ParsedTransaction(
transactionCode = code, type = TransactionType.AIRTIME_OTHER, amount = amount,
transactionCost = cost, recipientName = null, 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 parseMpesaGlobal(sms: String, code: String, balance: Double, cost: Double, timestamp: Long): ParsedTransaction {
val amount = extractFirstAmount(sms) ?: 0.0
val globalRegex = Regex("""sent to\s+(.+?)\s+(\+\d{7,15})\s+\((.+?)\)""", RegexOption.IGNORE_CASE)
val match = globalRegex.find(sms)
return ParsedTransaction(
transactionCode = code, type = TransactionType.MPESA_GLOBAL, amount = amount,
transactionCost = cost, recipientName = match?.groupValues?.get(1)?.trim(),
recipientPhone = match?.groupValues?.get(2), accountNumber = null,
destinationCountry = match?.groupValues?.get(3)?.trim(), balance = balance,
direction = TransactionDirection.OUTFLOW, fulizaAmount = null, fulizaOutstanding = null,
reversedTransactionCode = null, timestamp = timestamp, rawSms = sms
)
}
private fun parseFuliza(sms: String, code: String, balance: Double, cost: Double, timestamp: Long): ParsedTransaction {
val amount = extractFirstAmount(sms) ?: 0.0
val fulizaAmt = FULIZA_AMOUNT_REGEX.find(sms)?.groupValues?.get(1)?.let { parseAmount(it) }
val fulizaOut = FULIZA_OUTSTANDING_REGEX.find(sms)?.groupValues?.get(1)?.let { parseAmount(it) }
val sendToRegex = Regex("""sent to\s+(.+?)\s+(\d{10,})\s+on""", RegexOption.IGNORE_CASE)
val match = sendToRegex.find(sms)
return ParsedTransaction(
transactionCode = code, type = TransactionType.FULIZA, amount = amount,
transactionCost = cost, recipientName = match?.groupValues?.get(1)?.trim(),
recipientPhone = match?.groupValues?.get(2), accountNumber = null,
destinationCountry = null, balance = balance, direction = TransactionDirection.OUTFLOW,
fulizaAmount = fulizaAmt, fulizaOutstanding = fulizaOut,
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) }
return ParsedTransaction(
transactionCode = code, type = TransactionType.FULIZA_REPAYMENT, amount = amount,
transactionCost = 0.0, recipientName = null, recipientPhone = null,
accountNumber = null, destinationCountry = null, balance = 0.0,
direction = TransactionDirection.OUTFLOW, fulizaAmount = null, fulizaOutstanding = fulizaOut,
reversedTransactionCode = null, timestamp = timestamp, rawSms = sms
)
}
private fun parseFulizaReversal(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) }
return ParsedTransaction(
transactionCode = code, type = TransactionType.FULIZA_REVERSAL, amount = amount,
transactionCost = 0.0, recipientName = null, recipientPhone = null,
accountNumber = null, destinationCountry = null, balance = 0.0,
direction = TransactionDirection.INFLOW, fulizaAmount = null, fulizaOutstanding = fulizaOut,
reversedTransactionCode = null, timestamp = timestamp, rawSms = sms
)
}
private fun parseReversal(sms: String, code: String, balance: Double, timestamp: Long): ParsedTransaction {
val reversedCodeRegex = Regex("""Transaction\s+([A-Z0-9]{10})\s+has been reversed""")
val match = reversedCodeRegex.find(sms)
return ParsedTransaction(
transactionCode = code, type = TransactionType.REVERSAL, amount = 0.0,
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)
return ParsedTransaction(
transactionCode = code, type = TransactionType.MSHWARI, amount = amount,
transactionCost = cost, recipientName = "M-Shwari", recipientPhone = null,
accountNumber = null, destinationCountry = null, balance = balance,
direction = if (isInflow) TransactionDirection.INFLOW else TransactionDirection.OUTFLOW,
fulizaAmount = null, fulizaOutstanding = null, reversedTransactionCode = null,
timestamp = timestamp, rawSms = sms
)
}
private fun parseKcbMpesa(sms: String, code: String, balance: Double, cost: Double, timestamp: Long): ParsedTransaction {
val amount = extractFirstAmount(sms) ?: 0.0
val isInflow = sms.contains("from KCB", ignoreCase = true)
return ParsedTransaction(
transactionCode = code, type = TransactionType.KCB_MPESA, amount = amount,
transactionCost = cost, recipientName = "KCB M-Pesa", recipientPhone = null,
accountNumber = null, destinationCountry = null, balance = balance,
direction = if (isInflow) TransactionDirection.INFLOW else TransactionDirection.OUTFLOW,
fulizaAmount = null, fulizaOutstanding = null, reversedTransactionCode = null,
timestamp = timestamp, rawSms = sms
)
}
private fun parseUnknown(sms: String, code: String, balance: Double, cost: Double, timestamp: Long): ParsedTransaction {
val amount = extractFirstAmount(sms) ?: 0.0
return ParsedTransaction(
transactionCode = code, type = TransactionType.UNKNOWN, amount = amount,
transactionCost = cost, recipientName = null, recipientPhone = null,
accountNumber = null, destinationCountry = null, balance = balance,
direction = TransactionDirection.OUTFLOW, fulizaAmount = null, fulizaOutstanding = null,
reversedTransactionCode = null, timestamp = timestamp, rawSms = sms
)
}
// --- Extraction helpers ---
private fun extractTransactionCode(sms: String): String? {
return TRANSACTION_CODE_REGEX.find(sms)?.groupValues?.get(1)
}
private fun extractFirstAmount(sms: String): Double? {
return AMOUNT_REGEX.find(sms)?.groupValues?.get(1)?.let { parseAmount(it) }
}
private fun extractBalance(sms: String): Double? {
return BALANCE_REGEX.find(sms)?.groupValues?.get(1)?.let { parseAmount(it) }
}
private fun extractCost(sms: String): Double? {
return COST_REGEX.find(sms)?.groupValues?.get(1)?.let { parseAmount(it) }
}
private fun extractDate(sms: String): Long? {
val match = DATE_REGEX.find(sms) ?: return null
return DateUtils.parseMpesaDate(match.groupValues[1])
}
private fun parseAmount(amountStr: String): Double {
return amountStr.replace(",", "").toDoubleOrNull() ?: 0.0
}
}
@@ -0,0 +1,179 @@
package com.ledga.app.data.repository
import android.content.Context
import android.content.Intent
import androidx.activity.result.ActivityResultLauncher
import com.google.android.gms.auth.api.signin.GoogleSignIn
import com.google.android.gms.auth.api.signin.GoogleSignInAccount
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
import com.google.android.gms.common.api.Scope
import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential
import com.google.api.client.http.ByteArrayContent
import com.google.api.client.http.javanet.NetHttpTransport
import com.google.api.client.json.gson.GsonFactory
import com.google.api.services.drive.Drive
import com.google.api.services.drive.DriveScopes
import com.ledga.app.data.db.dao.TransactionDao
import com.ledga.app.data.db.entity.TransactionEntity
import com.ledga.app.data.parser.TransactionDirection
import com.ledga.app.data.parser.TransactionType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import javax.inject.Inject
import javax.inject.Singleton
@Serializable
data class BackupData(
val version: Int = 1,
val backedUpAt: Long = System.currentTimeMillis(),
val transactions: List<BackupTransaction>
)
@Serializable
data class BackupTransaction(
val transactionCode: String,
val type: String,
val amount: Double,
val transactionCost: Double,
val recipientName: String?,
val recipientPhone: String?,
val accountNumber: String?,
val destinationCountry: String?,
val balance: Double,
val direction: String,
val categoryId: Long?,
val fulizaAmount: Double?,
val fulizaOutstanding: Double?,
val reversedTransactionCode: String?,
val rawSms: String,
val timestamp: Long
)
@Singleton
class BackupRepository @Inject constructor(
private val transactionDao: TransactionDao
) {
private val json = Json { ignoreUnknownKeys = true }
private val BACKUP_FILENAME = "ledga-backup.json"
fun getSignInIntent(context: Context): Intent {
val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
.requestEmail()
.requestScopes(Scope(DriveScopes.DRIVE_APPDATA))
.build()
return GoogleSignIn.getClient(context, gso).signInIntent
}
fun isSignedIn(context: Context): Boolean {
return GoogleSignIn.getLastSignedInAccount(context) != null
}
fun getAccountEmail(context: Context): String? {
return GoogleSignIn.getLastSignedInAccount(context)?.email
}
suspend fun performBackup(context: Context): Boolean = withContext(Dispatchers.IO) {
val account = GoogleSignIn.getLastSignedInAccount(context) ?: return@withContext false
val driveService = getDriveService(context, account)
val transactions = transactionDao.getRecentTransactions(100000).first()
val backupData = BackupData(
transactions = transactions.map { it.transaction.toBackup() }
)
val content = json.encodeToString(backupData)
// Find existing backup file
val existingFileId = findBackupFile(driveService)
if (existingFileId != null) {
// Update existing
val mediaContent = ByteArrayContent.fromString("application/json", content)
driveService.files().update(existingFileId, null, mediaContent).execute()
} else {
// Create new
val fileMetadata = com.google.api.services.drive.model.File().apply {
name = BACKUP_FILENAME
parents = listOf("appDataFolder")
}
val mediaContent = ByteArrayContent.fromString("application/json", content)
driveService.files().create(fileMetadata, mediaContent)
.setFields("id")
.execute()
}
true
}
suspend fun restoreBackup(context: Context): Int = withContext(Dispatchers.IO) {
val account = GoogleSignIn.getLastSignedInAccount(context) ?: return@withContext 0
val driveService = getDriveService(context, account)
val fileId = findBackupFile(driveService) ?: return@withContext 0
val outputStream = java.io.ByteArrayOutputStream()
driveService.files().get(fileId).executeMediaAndDownloadTo(outputStream)
val content = outputStream.toString("UTF-8")
val backupData = json.decodeFromString<BackupData>(content)
var imported = 0
backupData.transactions.forEach { bt ->
val entity = bt.toEntity()
val id = transactionDao.insert(entity)
if (id != -1L) imported++
}
imported
}
suspend fun hasBackup(context: Context): Boolean = withContext(Dispatchers.IO) {
val account = GoogleSignIn.getLastSignedInAccount(context) ?: return@withContext false
val driveService = getDriveService(context, account)
findBackupFile(driveService) != null
}
private fun getDriveService(context: Context, account: GoogleSignInAccount): Drive {
val credential = GoogleAccountCredential.usingOAuth2(
context, listOf(DriveScopes.DRIVE_APPDATA)
)
credential.selectedAccount = account.account
return Drive.Builder(NetHttpTransport(), GsonFactory.getDefaultInstance(), credential)
.setApplicationName("Ledga")
.build()
}
private fun findBackupFile(driveService: Drive): String? {
val result = driveService.files().list()
.setSpaces("appDataFolder")
.setQ("name = '$BACKUP_FILENAME'")
.setFields("files(id)")
.execute()
return result.files?.firstOrNull()?.id
}
private fun TransactionEntity.toBackup() = BackupTransaction(
transactionCode = transactionCode, type = type.name, amount = amount,
transactionCost = transactionCost, recipientName = recipientName,
recipientPhone = recipientPhone, accountNumber = accountNumber,
destinationCountry = destinationCountry, balance = balance,
direction = direction.name, categoryId = categoryId,
fulizaAmount = fulizaAmount, fulizaOutstanding = fulizaOutstanding,
reversedTransactionCode = reversedTransactionCode, rawSms = rawSms,
timestamp = timestamp
)
private fun BackupTransaction.toEntity() = TransactionEntity(
transactionCode = transactionCode,
type = try { TransactionType.valueOf(type) } catch (e: Exception) { TransactionType.UNKNOWN },
amount = amount, transactionCost = transactionCost,
recipientName = recipientName, recipientPhone = recipientPhone,
accountNumber = accountNumber, destinationCountry = destinationCountry,
balance = balance,
direction = try { TransactionDirection.valueOf(direction) } catch (e: Exception) { TransactionDirection.OUTFLOW },
categoryId = categoryId, fulizaAmount = fulizaAmount,
fulizaOutstanding = fulizaOutstanding, reversedTransactionCode = reversedTransactionCode,
rawSms = rawSms, timestamp = timestamp
)
}
@@ -0,0 +1,21 @@
package com.ledga.app.data.repository
import com.ledga.app.data.db.dao.CategoryDao
import com.ledga.app.data.db.dao.CategoryRuleDao
import com.ledga.app.data.db.entity.Category
import com.ledga.app.data.db.entity.CategoryRule
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class CategoryRepository @Inject constructor(
private val categoryDao: CategoryDao,
private val categoryRuleDao: CategoryRuleDao
) {
fun getAllCategories(): Flow<List<Category>> = categoryDao.getAllCategories()
fun getAllRules(): Flow<List<CategoryRule>> = categoryRuleDao.getAllRules()
suspend fun getCategoryById(id: Long): Category? = categoryDao.getCategoryById(id)
}
@@ -0,0 +1,154 @@
package com.ledga.app.data.repository
import android.content.Context
import android.net.Uri
import com.ledga.app.data.db.dao.CategoryDao
import com.ledga.app.data.db.dao.TransactionDao
import com.ledga.app.data.db.entity.TransactionEntity
import com.ledga.app.data.parser.TransactionDirection
import com.ledga.app.data.parser.TransactionType
import com.ledga.app.util.CurrencyFormatter
import com.ledga.app.util.DateUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.BufferedReader
import java.io.InputStreamReader
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
import javax.inject.Inject
import javax.inject.Singleton
@Serializable
data class ExportTransaction(
val transactionCode: String,
val type: String,
val amount: Double,
val transactionCost: Double,
val recipientName: String?,
val recipientPhone: String?,
val accountNumber: String?,
val destinationCountry: String?,
val balance: Double,
val direction: String,
val categoryId: Long?,
val fulizaAmount: Double?,
val fulizaOutstanding: Double?,
val reversedTransactionCode: String?,
val rawSms: String,
val timestamp: Long
)
@Serializable
data class ExportData(
val version: Int = 1,
val exportedAt: Long = System.currentTimeMillis(),
val transactions: List<ExportTransaction>
)
data class ExportResult(val success: Boolean, val count: Int)
data class ImportFromFileResult(val total: Int, val imported: Int, val skipped: Int)
@Singleton
class ExportImportRepository @Inject constructor(
private val transactionDao: TransactionDao,
private val categoryDao: CategoryDao
) {
private val json = Json { prettyPrint = true; ignoreUnknownKeys = true }
suspend fun exportToZip(context: Context, uri: Uri): ExportResult = withContext(Dispatchers.IO) {
val transactions = transactionDao.getRecentTransactions(10000).first()
val categories = categoryDao.getAllCategories().first()
val categoryMap = categories.associateBy { it.id }
context.contentResolver.openOutputStream(uri)?.use { os ->
ZipOutputStream(os).use { zip ->
// CSV
zip.putNextEntry(ZipEntry("transactions.csv"))
val csvHeader = "Date,Time,Code,Type,Amount,Fee,Recipient,Phone,Account,Balance,Category\n"
zip.write(csvHeader.toByteArray())
transactions.forEach { twc ->
val t = twc.transaction
val cat = categoryMap[t.categoryId]?.name ?: ""
val line = "${DateUtils.formatDate(t.timestamp)},${DateUtils.formatTime(t.timestamp)},${t.transactionCode},${t.type},${t.amount},${t.transactionCost},${csvEscape(t.recipientName ?: "")},${t.recipientPhone ?: ""},${t.accountNumber ?: ""},${t.balance},${csvEscape(cat)}\n"
zip.write(line.toByteArray())
}
zip.closeEntry()
// JSON
zip.putNextEntry(ZipEntry("data.json"))
val exportData = ExportData(
transactions = transactions.map { it.transaction.toExport() }
)
zip.write(json.encodeToString(exportData).toByteArray())
zip.closeEntry()
}
}
ExportResult(success = true, count = transactions.size)
}
suspend fun importFromZip(context: Context, uri: Uri): ImportFromFileResult = withContext(Dispatchers.IO) {
var total = 0
var imported = 0
var skipped = 0
context.contentResolver.openInputStream(uri)?.use { inputStream ->
ZipInputStream(inputStream).use { zip ->
var entry = zip.nextEntry
while (entry != null) {
if (entry.name == "data.json") {
val reader = BufferedReader(InputStreamReader(zip))
val content = reader.readText()
val exportData = json.decodeFromString<ExportData>(content)
total = exportData.transactions.size
exportData.transactions.forEach { et ->
val entity = et.toEntity()
val id = transactionDao.insert(entity)
if (id != -1L) imported++ else skipped++
}
}
zip.closeEntry()
entry = zip.nextEntry
}
}
}
ImportFromFileResult(total = total, imported = imported, skipped = skipped)
}
private fun TransactionEntity.toExport() = ExportTransaction(
transactionCode = transactionCode, type = type.name, amount = amount,
transactionCost = transactionCost, recipientName = recipientName,
recipientPhone = recipientPhone, accountNumber = accountNumber,
destinationCountry = destinationCountry, balance = balance,
direction = direction.name, categoryId = categoryId,
fulizaAmount = fulizaAmount, fulizaOutstanding = fulizaOutstanding,
reversedTransactionCode = reversedTransactionCode, rawSms = rawSms,
timestamp = timestamp
)
private fun ExportTransaction.toEntity() = TransactionEntity(
transactionCode = transactionCode,
type = try { TransactionType.valueOf(type) } catch (e: Exception) { TransactionType.UNKNOWN },
amount = amount, transactionCost = transactionCost,
recipientName = recipientName, recipientPhone = recipientPhone,
accountNumber = accountNumber, destinationCountry = destinationCountry,
balance = balance,
direction = try { TransactionDirection.valueOf(direction) } catch (e: Exception) { TransactionDirection.OUTFLOW },
categoryId = categoryId, fulizaAmount = fulizaAmount,
fulizaOutstanding = fulizaOutstanding, reversedTransactionCode = reversedTransactionCode,
rawSms = rawSms, timestamp = timestamp
)
private fun csvEscape(value: String): String {
return if (value.contains(",") || value.contains("\"") || value.contains("\n")) {
"\"${value.replace("\"", "\"\"")}\""
} else value
}
}
@@ -0,0 +1,95 @@
package com.ledga.app.data.repository
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.doublePreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import com.ledga.app.ui.theme.ThemeMode
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SettingsRepository @Inject constructor(
private val dataStore: DataStore<Preferences>
) {
companion object {
private val THEME_MODE = stringPreferencesKey("theme_mode")
private val ONBOARDING_COMPLETED = booleanPreferencesKey("onboarding_completed")
private val FONT_SCALE = stringPreferencesKey("font_scale")
private val DAILY_SUMMARY = booleanPreferencesKey("daily_summary")
private val DAILY_SUMMARY_HOUR = intPreferencesKey("daily_summary_hour")
private val WEEKLY_SUMMARY = booleanPreferencesKey("weekly_summary")
private val BUDGET_ALERTS = booleanPreferencesKey("budget_alerts")
private val LARGE_TXN_ALERT = booleanPreferencesKey("large_txn_alert")
private val LARGE_TXN_THRESHOLD = doublePreferencesKey("large_txn_threshold")
}
fun getThemeMode(): Flow<ThemeMode> = dataStore.data.map { prefs ->
when (prefs[THEME_MODE]) {
"LIGHT" -> ThemeMode.LIGHT
"DARK" -> ThemeMode.DARK
else -> ThemeMode.SYSTEM
}
}
suspend fun setThemeMode(mode: ThemeMode) {
dataStore.edit { it[THEME_MODE] = mode.name }
}
fun hasCompletedOnboarding(): Flow<Boolean> = dataStore.data.map { prefs ->
prefs[ONBOARDING_COMPLETED] ?: false
}
suspend fun setOnboardingCompleted() {
dataStore.edit { it[ONBOARDING_COMPLETED] = true }
}
// --- Notification settings ---
fun getDailySummaryEnabled(): Flow<Boolean> = dataStore.data.map { it[DAILY_SUMMARY] ?: true }
suspend fun setDailySummaryEnabled(enabled: Boolean) { dataStore.edit { it[DAILY_SUMMARY] = enabled } }
fun getDailySummaryHour(): Flow<Int> = dataStore.data.map { it[DAILY_SUMMARY_HOUR] ?: 20 } // 8 PM default
suspend fun setDailySummaryHour(hour: Int) { dataStore.edit { it[DAILY_SUMMARY_HOUR] = hour } }
fun getWeeklySummaryEnabled(): Flow<Boolean> = dataStore.data.map { it[WEEKLY_SUMMARY] ?: true }
suspend fun setWeeklySummaryEnabled(enabled: Boolean) { dataStore.edit { it[WEEKLY_SUMMARY] = enabled } }
fun getBudgetAlertsEnabled(): Flow<Boolean> = dataStore.data.map { it[BUDGET_ALERTS] ?: true }
suspend fun setBudgetAlertsEnabled(enabled: Boolean) { dataStore.edit { it[BUDGET_ALERTS] = enabled } }
fun getLargeTransactionAlertEnabled(): Flow<Boolean> = dataStore.data.map { it[LARGE_TXN_ALERT] ?: false }
suspend fun setLargeTransactionAlertEnabled(enabled: Boolean) { dataStore.edit { it[LARGE_TXN_ALERT] = enabled } }
fun getLargeTransactionThreshold(): Flow<Double> = dataStore.data.map { it[LARGE_TXN_THRESHOLD] ?: 5000.0 }
suspend fun setLargeTransactionThreshold(amount: Double) { dataStore.edit { it[LARGE_TXN_THRESHOLD] = amount } }
// --- Font scale ---
fun getFontScale(): Flow<FontScale> = dataStore.data.map { prefs ->
when (prefs[FONT_SCALE]) {
"SMALL" -> FontScale.SMALL
"MEDIUM" -> FontScale.MEDIUM
"LARGE" -> FontScale.LARGE
"EXTRA_LARGE" -> FontScale.EXTRA_LARGE
else -> FontScale.SYSTEM
}
}
suspend fun setFontScale(scale: FontScale) {
dataStore.edit { it[FONT_SCALE] = scale.name }
}
}
enum class FontScale(val label: String, val scale: Float) {
SYSTEM("System Default", 1.0f),
SMALL("Small", 0.85f),
MEDIUM("Medium", 1.0f),
LARGE("Large", 1.15f),
EXTRA_LARGE("Extra Large", 1.3f)
}
@@ -0,0 +1,74 @@
package com.ledga.app.data.repository
import android.content.ContentResolver
import android.net.Uri
import com.ledga.app.data.parser.MpesaSmsParser
import com.ledga.app.data.parser.ParseResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
data class ImportResult(
val total: Int,
val imported: Int,
val failed: Int
)
@Singleton
class SmsImporter @Inject constructor(
private val contentResolver: ContentResolver,
private val transactionRepository: TransactionRepository
) {
suspend fun importHistory(
onProgress: (imported: Int, total: Int) -> Unit = { _, _ -> }
): ImportResult = withContext(Dispatchers.IO) {
val smsList = readMpesaSms()
val total = smsList.size
var imported = 0
var failed = 0
smsList.forEachIndexed { index, sms ->
val result = MpesaSmsParser.parse(sms.body, sms.date)
when (result) {
is ParseResult.Success -> {
val id = transactionRepository.insertTransaction(result.transaction)
if (id != -1L) imported++
}
is ParseResult.Failure -> failed++
}
onProgress(index + 1, total)
}
ImportResult(total = total, imported = imported, failed = failed)
}
private fun readMpesaSms(): List<SmsMessage> {
val messages = mutableListOf<SmsMessage>()
val uri = Uri.parse("content://sms/inbox")
val cursor = contentResolver.query(
uri,
arrayOf("body", "date", "address"),
"address = ?",
arrayOf("MPESA"),
"date DESC"
)
cursor?.use {
val bodyIndex = it.getColumnIndexOrThrow("body")
val dateIndex = it.getColumnIndexOrThrow("date")
while (it.moveToNext()) {
messages.add(
SmsMessage(
body = it.getString(bodyIndex),
date = it.getLong(dateIndex)
)
)
}
}
return messages
}
private data class SmsMessage(val body: String, val date: Long)
}
@@ -0,0 +1,129 @@
package com.ledga.app.data.repository
import com.ledga.app.data.db.dao.CategoryRuleDao
import com.ledga.app.data.db.dao.CategorySpending
import com.ledga.app.data.db.dao.DailySpending
import com.ledga.app.data.db.dao.TopMerchant
import com.ledga.app.data.db.dao.TransactionDao
import com.ledga.app.data.db.entity.MatchType
import com.ledga.app.data.db.entity.TransactionEntity
import com.ledga.app.data.db.entity.TransactionWithCategory
import com.ledga.app.data.parser.ParsedTransaction
import com.ledga.app.data.parser.TransactionType
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class TransactionRepository @Inject constructor(
private val transactionDao: TransactionDao,
private val categoryRuleDao: CategoryRuleDao
) {
suspend fun insertTransaction(parsed: ParsedTransaction): Long {
val categoryId = autoCategorize(parsed)
val entity = TransactionEntity(
transactionCode = parsed.transactionCode,
type = parsed.type,
amount = parsed.amount,
transactionCost = parsed.transactionCost,
recipientName = parsed.recipientName,
recipientPhone = parsed.recipientPhone,
accountNumber = parsed.accountNumber,
destinationCountry = parsed.destinationCountry,
balance = parsed.balance,
direction = parsed.direction,
categoryId = categoryId,
fulizaAmount = parsed.fulizaAmount,
fulizaOutstanding = parsed.fulizaOutstanding,
reversedTransactionCode = parsed.reversedTransactionCode,
rawSms = parsed.rawSms,
timestamp = parsed.timestamp
)
return transactionDao.insert(entity)
}
fun getRecentTransactions(limit: Int = 10): Flow<List<TransactionWithCategory>> =
transactionDao.getRecentTransactions(limit)
fun getTransactions(startTime: Long, endTime: Long): Flow<List<TransactionWithCategory>> =
transactionDao.getTransactions(startTime, endTime)
fun getLatestTransaction(): Flow<TransactionEntity?> =
transactionDao.getLatestTransaction()
fun getSpendingByCategory(startTime: Long, endTime: Long): Flow<List<CategorySpending>> =
transactionDao.getSpendingByCategory(startTime, endTime)
fun getTotalSpending(startTime: Long, endTime: Long): Flow<Double> =
transactionDao.getTotalSpending(startTime, endTime)
fun getTotalFees(startTime: Long, endTime: Long): Flow<Double> =
transactionDao.getTotalFees(startTime, endTime)
fun searchTransactions(query: String): Flow<List<TransactionWithCategory>> =
transactionDao.searchTransactions(query)
fun getTransactionsByType(types: List<TransactionType>): Flow<List<TransactionWithCategory>> =
transactionDao.getTransactionsByType(types)
fun getDailySpending(startTime: Long, endTime: Long): Flow<List<DailySpending>> =
transactionDao.getDailySpending(startTime, endTime)
fun getTopMerchants(startTime: Long, endTime: Long, limit: Int = 5): Flow<List<TopMerchant>> =
transactionDao.getTopMerchants(startTime, endTime, limit)
fun getUnparsedTransactions(): Flow<List<TransactionWithCategory>> =
transactionDao.getUnparsedTransactions()
fun getUnparsedCount(): Flow<Int> =
transactionDao.getUnparsedCount()
suspend fun updateCategory(transactionId: Long, categoryId: Long?) {
transactionDao.updateCategory(transactionId, categoryId)
}
private suspend fun autoCategorize(parsed: ParsedTransaction): Long? {
// 1. Type-based defaults
val typeDefault = when (parsed.type) {
TransactionType.AIRTIME_SELF, TransactionType.AIRTIME_OTHER -> 4L // Airtime & Data
TransactionType.SEND -> 6L // Send Money
TransactionType.RECEIVED -> 7L // Received
TransactionType.WITHDRAW_AGENT, TransactionType.WITHDRAW_ATM -> 8L // Withdrawal
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.REVERSAL -> 13L // Other
TransactionType.UNKNOWN -> 13L
else -> null
}
// 2. Rule-based matching (overrides type default for BUY_GOODS, PAY_BILL, SEND)
if (parsed.recipientName != null) {
val rules = categoryRuleDao.getAllRulesSync()
val nameUpper = parsed.recipientName.uppercase()
// Check recipient name rules
for (rule in rules) {
if (rule.matchType == MatchType.RECIPIENT_NAME &&
nameUpper.contains(rule.matchValue.uppercase())
) {
return rule.categoryId
}
}
// Check paybill rules
if (parsed.accountNumber != null) {
for (rule in rules) {
if (rule.matchType == MatchType.PAYBILL &&
parsed.accountNumber == rule.matchValue
) {
return rule.categoryId
}
}
}
}
return typeDefault ?: 13L // Default to "Other"
}
}
@@ -0,0 +1,31 @@
package com.ledga.app.di
import android.content.ContentResolver
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "ledga_settings")
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> {
return context.dataStore
}
@Provides
fun provideContentResolver(@ApplicationContext context: Context): ContentResolver {
return context.contentResolver
}
}
@@ -0,0 +1,61 @@
package com.ledga.app.di
import android.content.Context
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.sqlite.db.SupportSQLiteDatabase
import com.ledga.app.data.db.AppDatabase
import com.ledga.app.data.db.DefaultData
import com.ledga.app.data.db.dao.BudgetDao
import com.ledga.app.data.db.dao.CategoryDao
import com.ledga.app.data.db.dao.CategoryRuleDao
import com.ledga.app.data.db.dao.TransactionDao
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase {
return Room.databaseBuilder(
context,
AppDatabase::class.java,
"ledga.db"
)
.addCallback(object : RoomDatabase.Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
super.onCreate(db)
CoroutineScope(Dispatchers.IO).launch {
val database = Room.databaseBuilder(
context, AppDatabase::class.java, "ledga.db"
).build()
database.categoryDao().insertAll(DefaultData.DEFAULT_CATEGORIES)
database.categoryRuleDao().insertAll(DefaultData.DEFAULT_RULES)
}
}
})
.build()
}
@Provides
fun provideTransactionDao(db: AppDatabase): TransactionDao = db.transactionDao()
@Provides
fun provideCategoryDao(db: AppDatabase): CategoryDao = db.categoryDao()
@Provides
fun provideCategoryRuleDao(db: AppDatabase): CategoryRuleDao = db.categoryRuleDao()
@Provides
fun provideBudgetDao(db: AppDatabase): BudgetDao = db.budgetDao()
}
@@ -0,0 +1,72 @@
package com.ledga.app.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.provider.Telephony
import com.ledga.app.data.parser.MpesaSmsParser
import com.ledga.app.data.parser.ParseResult
import com.ledga.app.data.repository.SettingsRepository
import com.ledga.app.data.repository.TransactionRepository
import com.ledga.app.worker.TransactionAlerts
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
class SmsReceiver : BroadcastReceiver() {
@Inject
lateinit var transactionRepository: TransactionRepository
@Inject
lateinit var transactionAlerts: TransactionAlerts
@Inject
lateinit var settingsRepository: SettingsRepository
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Telephony.Sms.Intents.SMS_RECEIVED_ACTION) return
val messages = Telephony.Sms.Intents.getMessagesFromIntent(intent)
val pendingResult = goAsync()
CoroutineScope(Dispatchers.IO).launch {
try {
// Group message parts by sender (multi-part SMS)
val fullMessages = messages
.groupBy { it.displayOriginatingAddress }
.mapValues { (_, parts) -> parts.joinToString("") { it.displayMessageBody } }
for ((sender, body) in fullMessages) {
if (!MpesaSmsParser.isMpesaMessage(sender ?: "")) continue
when (val result = MpesaSmsParser.parse(body, System.currentTimeMillis())) {
is ParseResult.Success -> {
transactionRepository.insertTransaction(result.transaction)
// Check for alerts
val largeTxnEnabled = settingsRepository.getLargeTransactionAlertEnabled().first()
val largeTxnThreshold = settingsRepository.getLargeTransactionThreshold().first()
val budgetEnabled = settingsRepository.getBudgetAlertsEnabled().first()
transactionAlerts.checkAlerts(
context = context,
transaction = result.transaction,
largeTransactionThreshold = largeTxnThreshold,
largeTransactionEnabled = largeTxnEnabled,
budgetAlertsEnabled = budgetEnabled
)
}
is ParseResult.Failure -> {
// Store as UNKNOWN for later review
}
}
}
} finally {
pendingResult.finish()
}
}
}
}
@@ -0,0 +1,55 @@
package com.ledga.app.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.ledga.app.ui.theme.InflowGreen
import com.ledga.app.ui.theme.OutflowRed
import com.ledga.app.util.CurrencyFormatter
@Composable
fun BudgetProgressBar(
label: String,
spent: Double,
limit: Double,
modifier: Modifier = Modifier
) {
val progress = if (limit > 0) (spent / limit).toFloat().coerceIn(0f, 1f) else 0f
val percentage = if (limit > 0) ((spent / limit) * 100).toInt() else 0
val color = when {
percentage >= 100 -> OutflowRed
percentage >= 80 -> MaterialTheme.colorScheme.error.copy(alpha = 0.7f)
else -> InflowGreen
}
Column(modifier = modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f)
)
Text(
text = "${CurrencyFormatter.formatKshCompact(spent)} / ${CurrencyFormatter.formatKshCompact(limit)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(4.dp))
LinearProgressIndicator(
progress = { progress },
modifier = Modifier.fillMaxWidth().height(8.dp),
color = color,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
}
}
@@ -0,0 +1,21 @@
package com.ledga.app.ui.components
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@Composable
fun CategoryChip(
label: String,
isSelected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
FilterChip(
modifier = modifier,
selected = isSelected,
onClick = onClick,
label = { Text(label) }
)
}
@@ -0,0 +1,87 @@
package com.ledga.app.ui.components
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.ledga.app.util.CurrencyFormatter
data class DonutSegment(
val label: String,
val value: Float,
val color: Color
)
@Composable
fun DonutChart(
segments: List<DonutSegment>,
totalAmount: Double,
modifier: Modifier = Modifier
) {
val animationProgress = remember { Animatable(0f) }
LaunchedEffect(segments) {
animationProgress.snapTo(0f)
animationProgress.animateTo(1f, animationSpec = tween(800))
}
val total = segments.sumOf { it.value.toDouble() }.toFloat()
Box(modifier = modifier, contentAlignment = Alignment.Center) {
Canvas(modifier = Modifier.size(180.dp)) {
val strokeWidth = 32.dp.toPx()
val radius = (size.minDimension - strokeWidth) / 2
var startAngle = -90f
if (total == 0f) {
drawArc(
color = Color.LightGray,
startAngle = 0f,
sweepAngle = 360f,
useCenter = false,
style = Stroke(width = strokeWidth, cap = StrokeCap.Round)
)
return@Canvas
}
segments.forEach { segment ->
val sweep = (segment.value / total) * 360f * animationProgress.value
drawArc(
color = segment.color,
startAngle = startAngle,
sweepAngle = sweep,
useCenter = false,
style = Stroke(width = strokeWidth, cap = StrokeCap.Butt)
)
startAngle += (segment.value / total) * 360f * animationProgress.value
}
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = CurrencyFormatter.formatKshCompact(totalAmount),
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center
)
Text(
text = "Total Spent",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@@ -0,0 +1,34 @@
package com.ledga.app.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
enum class Period(val label: String) {
TODAY("Today"),
THIS_WEEK("This Week"),
THIS_MONTH("Month")
}
@Composable
fun PeriodSelector(
selected: Period,
onSelect: (Period) -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Period.entries.forEach { period ->
CategoryChip(
label = period.label,
isSelected = selected == period,
onClick = { onSelect(period) }
)
}
}
}
@@ -0,0 +1,45 @@
package com.ledga.app.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun StatCard(
label: String,
value: String,
subtitle: String? = null,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = value,
style = MaterialTheme.typography.headlineSmall
)
if (subtitle != null) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@@ -0,0 +1,98 @@
package com.ledga.app.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.ledga.app.data.db.entity.Category
import com.ledga.app.data.db.entity.TransactionEntity
import com.ledga.app.data.parser.TransactionDirection
import com.ledga.app.ui.theme.InflowGreen
import com.ledga.app.ui.theme.OutflowRed
import com.ledga.app.util.CurrencyFormatter
import com.ledga.app.util.DateUtils
@Composable
fun TransactionCard(
transaction: TransactionEntity,
category: Category?,
onClick: () -> Unit = {},
modifier: Modifier = Modifier
) {
val isInflow = transaction.direction == TransactionDirection.INFLOW
val amountColor = if (isInflow) InflowGreen else OutflowRed
val displayName = transaction.recipientName ?: transaction.type.name.replace("_", " ")
Card(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.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
)
Spacer(modifier = Modifier.width(12.dp))
// Name and time
Column(modifier = Modifier.weight(1f)) {
Text(
text = displayName,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1
)
Text(
text = DateUtils.formatTime(transaction.timestamp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Amount
Text(
text = CurrencyFormatter.formatKshSigned(transaction.amount, isInflow),
style = MaterialTheme.typography.titleSmall,
color = amountColor
)
}
}
}
fun parseColor(hex: String): Color {
return try {
Color(android.graphics.Color.parseColor(hex))
} catch (e: Exception) {
Color.Gray
}
}
@@ -0,0 +1,132 @@
package com.ledga.app.ui.home
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.MaterialTheme
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.unit.dp
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.util.CurrencyFormatter
@Composable
fun HomeScreen(
viewModel: HomeViewModel = hiltViewModel()
) {
val state by viewModel.uiState.collectAsState()
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Greeting
item {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = state.greeting,
style = MaterialTheme.typography.headlineSmall
)
}
// Balance card
item {
StatCard(
label = "M-PESA Balance",
value = state.balance?.let { CurrencyFormatter.formatKsh(it) } ?: "—",
subtitle = "from last transaction",
modifier = Modifier.fillMaxWidth()
)
}
// Spending summary
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)
)
}
}
// Period selector
item {
PeriodSelector(
selected = state.selectedPeriod,
onSelect = { viewModel.selectPeriod(it) }
)
}
// Donut chart
item {
if (state.donutSegments.isNotEmpty()) {
DonutChart(
segments = state.donutSegments,
totalAmount = state.totalSpending,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
)
}
}
// Recent transactions header
item {
Text(
text = "Recent Transactions",
style = MaterialTheme.typography.titleMedium
)
}
// Transaction list
if (state.recentTransactions.isEmpty()) {
item {
Text(
text = "No transactions yet. Your M-Pesa transactions will appear here.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 24.dp)
)
}
} else {
items(
items = state.recentTransactions,
key = { it.transaction.id }
) { twc ->
TransactionCard(
transaction = twc.transaction,
category = twc.category
)
}
}
item { Spacer(modifier = Modifier.height(16.dp)) }
}
}
@@ -0,0 +1,132 @@
package com.ledga.app.ui.home
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
import com.ledga.app.data.db.entity.TransactionWithCategory
import com.ledga.app.data.repository.CategoryRepository
import com.ledga.app.data.repository.TransactionRepository
import com.ledga.app.ui.components.DonutSegment
import com.ledga.app.ui.components.Period
import com.ledga.app.ui.components.parseColor
import com.ledga.app.util.DateUtils
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.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.stateIn
import kotlinx.coroutines.launch
import android.content.Context
import javax.inject.Inject
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 recentTransactions: List<TransactionWithCategory> = emptyList(),
val selectedPeriod: Period = Period.THIS_MONTH,
val monthLabel: String = "",
val updateAvailable: GitHubRelease? = null
)
@HiltViewModel
class HomeViewModel @Inject constructor(
@ApplicationContext private val appContext: Context,
private val transactionRepository: TransactionRepository,
private val categoryRepository: CategoryRepository
) : ViewModel() {
private val _updateAvailable = MutableStateFlow<GitHubRelease?>(null)
init {
viewModelScope.launch {
val release = UpdateChecker.checkForUpdate(
context = appContext,
owner = "ngenohkevin",
repo = "ledga",
currentVersion = BuildConfig.VERSION_NAME
)
_updateAvailable.value = release
}
}
private val _selectedPeriod = MutableStateFlow(Period.THIS_MONTH)
private val timeRange = _selectedPeriod.flatMapLatest { period ->
val now = System.currentTimeMillis()
val start = when (period) {
Period.TODAY -> DateUtils.getStartOfDay(now)
Period.THIS_WEEK -> DateUtils.getStartOfWeek(now)
Period.THIS_MONTH -> DateUtils.getStartOfMonth(now)
}
kotlinx.coroutines.flow.flowOf(start to now)
}
val uiState: StateFlow<HomeUiState> = combine(
_selectedPeriod,
timeRange,
transactionRepository.getLatestTransaction(),
transactionRepository.getRecentTransactions(10),
categoryRepository.getAllCategories()
) { period, (start, end), latestTransaction, recentTransactions, categories ->
val categoryMap = categories.associateBy { it.id }
HomeUiState(
greeting = DateUtils.greeting(),
balance = latestTransaction?.balance,
recentTransactions = recentTransactions,
selectedPeriod = period,
monthLabel = DateUtils.formatMonthYear(System.currentTimeMillis())
)
}.combine(
timeRange.flatMapLatest { (start, end) ->
combine(
transactionRepository.getTotalSpending(start, end),
transactionRepository.getTotalFees(start, end),
transactionRepository.getSpendingByCategory(start, end),
categoryRepository.getAllCategories()
) { spending, fees, categorySpending, categories ->
Triple(spending, fees, buildDonutSegments(categorySpending, categories))
}
}
) { state, (spending, fees, segments) ->
state.copy(
totalSpending = spending,
totalFees = fees,
donutSegments = segments
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), HomeUiState())
fun selectPeriod(period: Period) {
_selectedPeriod.value = period
}
private fun buildDonutSegments(
spending: List<CategorySpending>,
categories: List<Category>
): List<DonutSegment> {
val categoryMap = categories.associateBy { it.id }
return spending
.sortedByDescending { it.totalAmount }
.take(6)
.map { cs ->
val cat = categoryMap[cs.categoryId]
DonutSegment(
label = cat?.name ?: "Other",
value = cs.totalAmount.toFloat(),
color = cat?.color?.let { parseColor(it) } ?: Color.Gray
)
}
}
}
@@ -0,0 +1,58 @@
package com.ledga.app.ui.navigation
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import com.ledga.app.ui.home.HomeScreen
import com.ledga.app.ui.onboarding.OnboardingScreen
import com.ledga.app.ui.settings.BudgetScreen
import com.ledga.app.ui.settings.SettingsScreen
import com.ledga.app.ui.settings.UnparsedSmsScreen
import com.ledga.app.ui.transactions.TransactionsScreen
import com.ledga.app.ui.trends.TrendsScreen
@Composable
fun AppNavigation(
navController: NavHostController,
startDestination: Any,
modifier: Modifier = Modifier
) {
NavHost(
navController = navController,
startDestination = startDestination,
modifier = modifier
) {
composable<OnboardingRoute> {
OnboardingScreen(
onComplete = {
navController.navigate(HomeRoute) {
popUpTo(OnboardingRoute) { inclusive = true }
}
}
)
}
composable<HomeRoute> {
HomeScreen()
}
composable<TransactionsRoute> {
TransactionsScreen()
}
composable<TrendsRoute> {
TrendsScreen()
}
composable<SettingsRoute> {
SettingsScreen(
onNavigateToUnparsed = { navController.navigate(UnparsedSmsRoute) },
onNavigateToBudgets = { navController.navigate(BudgetRoute) }
)
}
composable<UnparsedSmsRoute> {
UnparsedSmsScreen(onBack = { navController.popBackStack() })
}
composable<BudgetRoute> {
BudgetScreen(onBack = { navController.popBackStack() })
}
}
}
@@ -0,0 +1,53 @@
package com.ledga.app.ui.navigation
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.List
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.TrendingUp
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.navigation.NavController
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.compose.currentBackStackEntryAsState
data class BottomNavItem<T : Any>(
val label: String,
val icon: ImageVector,
val route: T
)
@Composable
fun LedgaBottomNavBar(navController: NavController) {
val items = listOf(
BottomNavItem("Home", Icons.Default.Home, HomeRoute),
BottomNavItem("Transactions", Icons.Default.List, TransactionsRoute),
BottomNavItem("Trends", Icons.Default.TrendingUp, TrendsRoute),
BottomNavItem("Settings", Icons.Default.Settings, SettingsRoute),
)
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
NavigationBar {
items.forEach { item ->
NavigationBarItem(
icon = { Icon(item.icon, contentDescription = item.label) },
label = { Text(item.label) },
selected = currentDestination?.hasRoute(item.route::class) == true,
onClick = {
navController.navigate(item.route) {
popUpTo(HomeRoute) { saveState = true }
launchSingleTop = true
restoreState = true
}
}
)
}
}
}
@@ -0,0 +1,11 @@
package com.ledga.app.ui.navigation
import kotlinx.serialization.Serializable
@Serializable object HomeRoute
@Serializable object TransactionsRoute
@Serializable object TrendsRoute
@Serializable object SettingsRoute
@Serializable object OnboardingRoute
@Serializable object UnparsedSmsRoute
@Serializable object BudgetRoute
@@ -0,0 +1,266 @@
package com.ledga.app.ui.onboarding
import android.Manifest
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.MailOutline
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
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.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@Composable
fun OnboardingScreen(
onComplete: () -> Unit,
viewModel: OnboardingViewModel = hiltViewModel()
) {
val currentStep by viewModel.currentStep.collectAsState()
val importProgress by viewModel.importProgress.collectAsState()
val smsPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
if (permissions.values.any { it }) {
viewModel.nextStep()
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
AnimatedContent(targetState = currentStep, label = "onboarding") { step ->
when (step) {
0 -> WelcomeStep(onNext = { viewModel.nextStep() })
1 -> SmsPermissionStep(
onRequestPermission = {
val permissions = mutableListOf(
Manifest.permission.RECEIVE_SMS,
Manifest.permission.READ_SMS
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
permissions.add(Manifest.permission.POST_NOTIFICATIONS)
}
smsPermissionLauncher.launch(permissions.toTypedArray())
}
)
2 -> ImportStep(
progress = importProgress,
onImport = { viewModel.startImport() },
onSkip = { viewModel.skipImport() }
)
3 -> CompletionStep(
importResult = importProgress.result,
onComplete = {
viewModel.completeOnboarding()
onComplete()
}
)
}
}
Spacer(modifier = Modifier.height(32.dp))
// Step indicators
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
repeat(4) { index ->
Icon(
imageVector = if (index <= currentStep) Icons.Default.CheckCircle else Icons.Default.Star,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = if (index <= currentStep) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.outlineVariant
)
}
}
}
}
@Composable
private fun WelcomeStep(onNext: () -> Unit) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = Icons.Default.Star,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Welcome to Ledga",
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Track your M-Pesa spending automatically.\nNo typing, no manual entry.",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(32.dp))
Button(onClick = onNext, modifier = Modifier.fillMaxWidth()) {
Text("Get Started")
}
}
}
@Composable
private fun SmsPermissionStep(onRequestPermission: () -> Unit) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = Icons.Default.Lock,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "SMS Access",
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Ledga reads your M-Pesa SMS to track your spending.\n\nYour messages never leave your phone. Everything stays private and offline.",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(32.dp))
Button(onClick = onRequestPermission, modifier = Modifier.fillMaxWidth()) {
Text("Allow SMS Access")
}
}
}
@Composable
private fun ImportStep(
progress: ImportProgress,
onImport: () -> Unit,
onSkip: () -> Unit
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = Icons.Default.MailOutline,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Import History",
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Scan your existing M-Pesa messages to build your spending history.",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
if (progress.isRunning) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(modifier = Modifier.padding(16.dp)) {
LinearProgressIndicator(
progress = {
if (progress.total > 0) progress.current.toFloat() / progress.total
else 0f
},
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Processing ${progress.current} of ${progress.total} messages...",
style = MaterialTheme.typography.bodyMedium
)
}
}
} else {
Button(onClick = onImport, modifier = Modifier.fillMaxWidth()) {
Text("Import History")
}
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(onClick = onSkip, modifier = Modifier.fillMaxWidth()) {
Text("Skip — start fresh")
}
}
}
}
@Composable
private fun CompletionStep(
importResult: com.ledga.app.data.repository.ImportResult?,
onComplete: () -> Unit
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "You're all set!",
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(12.dp))
val subtitle = if (importResult != null) {
"Imported ${importResult.imported} transactions.\nLedga will now track your M-Pesa spending."
} else {
"Ledga will now track your M-Pesa spending in the background."
}
Text(
text = subtitle,
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(32.dp))
Button(onClick = onComplete, modifier = Modifier.fillMaxWidth()) {
Text("Go to Dashboard")
}
}
}
@@ -0,0 +1,66 @@
package com.ledga.app.ui.onboarding
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.ledga.app.data.repository.ImportResult
import com.ledga.app.data.repository.SettingsRepository
import com.ledga.app.data.repository.SmsImporter
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
data class ImportProgress(
val current: Int = 0,
val total: Int = 0,
val isRunning: Boolean = false,
val result: ImportResult? = null
)
@HiltViewModel
class OnboardingViewModel @Inject constructor(
private val settingsRepository: SettingsRepository,
private val smsImporter: SmsImporter
) : ViewModel() {
private val _currentStep = MutableStateFlow(0)
val currentStep: StateFlow<Int> = _currentStep
private val _importProgress = MutableStateFlow(ImportProgress())
val importProgress: StateFlow<ImportProgress> = _importProgress
fun nextStep() {
_currentStep.value = (_currentStep.value + 1).coerceAtMost(3)
}
fun startImport() {
viewModelScope.launch {
_importProgress.value = ImportProgress(isRunning = true)
val result = smsImporter.importHistory { current, total ->
_importProgress.value = ImportProgress(
current = current,
total = total,
isRunning = true
)
}
_importProgress.value = ImportProgress(
current = result.total,
total = result.total,
isRunning = false,
result = result
)
nextStep()
}
}
fun skipImport() {
nextStep()
}
fun completeOnboarding() {
viewModelScope.launch {
settingsRepository.setOnboardingCompleted()
}
}
}
@@ -0,0 +1,155 @@
package com.ledga.app.ui.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.ledga.app.ui.components.BudgetProgressBar
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BudgetScreen(
onBack: () -> Unit,
viewModel: BudgetViewModel = hiltViewModel()
) {
val state by viewModel.uiState.collectAsState()
var overallBudgetInput by remember { mutableStateOf("") }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Budgets") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
}
)
}
) { padding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Overall budget
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Overall Monthly Budget",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(8.dp))
if (state.overallBudget != null) {
BudgetProgressBar(
label = "This Month",
spent = state.totalSpentThisMonth,
limit = state.overallBudget!!
)
}
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = overallBudgetInput,
onValueChange = { overallBudgetInput = it },
modifier = Modifier.weight(1f),
label = { Text("Ksh") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true
)
Button(
onClick = {
overallBudgetInput.toDoubleOrNull()?.let {
viewModel.setOverallBudget(it)
overallBudgetInput = ""
}
}
) {
Text("Set")
}
}
}
}
}
// Category budgets
item {
Text(
text = "Category Budgets",
style = MaterialTheme.typography.titleMedium
)
}
items(state.categoryBudgets) { cb ->
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
Column(modifier = Modifier.padding(12.dp)) {
BudgetProgressBar(
label = cb.categoryName,
spent = cb.spent,
limit = cb.limit
)
}
}
}
if (state.categoryBudgets.isEmpty()) {
item {
Text(
text = "Set an overall budget above to start tracking.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
item { Spacer(modifier = Modifier.height(16.dp)) }
}
}
}
@@ -0,0 +1,73 @@
package com.ledga.app.ui.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.ledga.app.data.db.dao.BudgetDao
import com.ledga.app.data.db.entity.Budget
import com.ledga.app.data.repository.CategoryRepository
import com.ledga.app.data.repository.TransactionRepository
import com.ledga.app.util.DateUtils
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
data class CategoryBudgetItem(
val categoryName: String,
val spent: Double,
val limit: Double
)
data class BudgetUiState(
val overallBudget: Double? = null,
val totalSpentThisMonth: Double = 0.0,
val categoryBudgets: List<CategoryBudgetItem> = emptyList()
)
@HiltViewModel
class BudgetViewModel @Inject constructor(
private val budgetDao: BudgetDao,
private val transactionRepository: TransactionRepository,
private val categoryRepository: CategoryRepository
) : ViewModel() {
private val monthStart = DateUtils.getStartOfMonth()
private val now = System.currentTimeMillis()
val uiState: StateFlow<BudgetUiState> = combine(
budgetDao.getActiveBudgets(),
transactionRepository.getTotalSpending(monthStart, now),
transactionRepository.getSpendingByCategory(monthStart, now),
categoryRepository.getAllCategories()
) { budgets, totalSpent, categorySpending, categories ->
val overallBudget = budgets.find { it.categoryId == null }?.monthlyLimit
val catMap = categories.associateBy { it.id }
val spendingMap = categorySpending.associate { it.categoryId to it.totalAmount }
val categoryBudgets = budgets
.filter { it.categoryId != null }
.mapNotNull { budget ->
val cat = catMap[budget.categoryId] ?: return@mapNotNull null
CategoryBudgetItem(
categoryName = cat.name,
spent = spendingMap[budget.categoryId] ?: 0.0,
limit = budget.monthlyLimit
)
}
BudgetUiState(
overallBudget = overallBudget,
totalSpentThisMonth = totalSpent,
categoryBudgets = categoryBudgets
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), BudgetUiState())
fun setOverallBudget(amount: Double) {
viewModelScope.launch {
budgetDao.insert(Budget(categoryId = null, monthlyLimit = amount, isActive = true))
}
}
}
@@ -0,0 +1,302 @@
package com.ledga.app.ui.settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Switch
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.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.ledga.app.data.repository.FontScale
import com.ledga.app.ui.theme.ThemeMode
@Composable
fun SettingsScreen(
onNavigateToUnparsed: () -> Unit,
onNavigateToBudgets: () -> Unit,
viewModel: SettingsViewModel = hiltViewModel()
) {
val themeMode by viewModel.themeMode.collectAsState()
val fontScale by viewModel.fontScale.collectAsState()
val dailySummary by viewModel.dailySummaryEnabled.collectAsState()
val weeklySummary by viewModel.weeklySummaryEnabled.collectAsState()
val budgetAlerts by viewModel.budgetAlertsEnabled.collectAsState()
val largeTxnAlert by viewModel.largeTxnAlertEnabled.collectAsState()
val unparsedCount by viewModel.unparsedCount.collectAsState()
val importStatus by viewModel.importStatus.collectAsState()
val exportResult by viewModel.exportResult.collectAsState()
val fileImportResult by viewModel.fileImportResult.collectAsState()
val exportLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("application/zip")
) { uri -> uri?.let { viewModel.exportData(it) } }
val importLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri -> uri?.let { viewModel.importData(it) } }
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(8.dp))
Text("Settings", style = MaterialTheme.typography.headlineSmall)
// Appearance
SettingsSection(title = "Appearance") {
ThemeSelector(
currentMode = themeMode,
onSelect = { viewModel.setThemeMode(it) }
)
Spacer(modifier = Modifier.height(12.dp))
Text("Font Size", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
FontScale.entries.forEach { scale ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { viewModel.setFontScale(scale) }
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = fontScale == scale,
onClick = { viewModel.setFontScale(scale) }
)
Text(text = scale.label, style = MaterialTheme.typography.bodyLarge)
}
}
}
// Notifications
SettingsSection(title = "Notifications") {
SettingsToggle("Daily Summary", dailySummary) { viewModel.setDailySummaryEnabled(it) }
SettingsToggle("Weekly Summary", weeklySummary) { viewModel.setWeeklySummaryEnabled(it) }
SettingsToggle("Budget Alerts", budgetAlerts) { viewModel.setBudgetAlertsEnabled(it) }
SettingsToggle("Large Transaction Alert", largeTxnAlert) { viewModel.setLargeTxnAlertEnabled(it) }
}
// Budgets
SettingsSection(title = "Budgets") {
Text(
text = "Manage Budgets",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onNavigateToBudgets)
.padding(vertical = 8.dp)
)
}
// Data
SettingsSection(title = "Data") {
OutlinedButton(
onClick = { viewModel.importSmsHistory() },
modifier = Modifier.fillMaxWidth()
) {
Text("Import SMS History")
}
if (importStatus != null) {
Text(
text = "Imported ${importStatus!!.imported} of ${importStatus!!.total} messages",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
onClick = { exportLauncher.launch("ledga-export.zip") },
modifier = Modifier.fillMaxWidth()
) {
Text("Export Data (CSV + JSON)")
}
if (exportResult != null) {
Text(
text = "Exported ${exportResult!!.count} transactions",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(4.dp))
OutlinedButton(
onClick = { importLauncher.launch(arrayOf("application/zip")) },
modifier = Modifier.fillMaxWidth()
) {
Text("Import Data from ZIP")
}
if (fileImportResult != null) {
Text(
text = "Imported ${fileImportResult!!.imported} of ${fileImportResult!!.total} (${fileImportResult!!.skipped} duplicates skipped)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (unparsedCount > 0) {
Text(
text = "Unparsed Messages ($unparsedCount)",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onNavigateToUnparsed)
.padding(vertical = 8.dp)
)
}
}
// Backup
SettingsSection(title = "Backup") {
val backupStatus by viewModel.backupStatus.collectAsState()
val signedIn = viewModel.isSignedIn()
val email = viewModel.getAccountEmail()
if (signedIn && email != null) {
Text(
text = "Google Account: $email",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
}
OutlinedButton(
onClick = { viewModel.backupNow() },
modifier = Modifier.fillMaxWidth()
) {
Text("Back Up Now")
}
Spacer(modifier = Modifier.height(4.dp))
OutlinedButton(
onClick = { viewModel.restoreBackup() },
modifier = Modifier.fillMaxWidth()
) {
Text("Restore from Backup")
}
if (backupStatus != null) {
Text(
text = backupStatus!!,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// About
SettingsSection(title = "About") {
Text(
text = "Ledga v1.0.0",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "All data stays on your device.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(32.dp))
}
}
@Composable
private fun SettingsSection(
title: String,
content: @Composable () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(8.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(8.dp))
content()
}
}
}
@Composable
private fun ThemeSelector(
currentMode: ThemeMode,
onSelect: (ThemeMode) -> Unit
) {
ThemeMode.entries.forEach { mode ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onSelect(mode) }
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = currentMode == mode,
onClick = { onSelect(mode) }
)
Text(
text = mode.name.lowercase().replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.bodyLarge
)
}
}
}
@Composable
private fun SettingsToggle(
label: String,
checked: Boolean,
onToggle: (Boolean) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f)
)
Switch(checked = checked, onCheckedChange = onToggle)
}
}
@@ -0,0 +1,144 @@
package com.ledga.app.ui.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import android.net.Uri
import com.ledga.app.data.repository.BackupRepository
import com.ledga.app.data.repository.ExportImportRepository
import com.ledga.app.data.repository.ExportResult
import com.ledga.app.data.repository.FontScale
import com.ledga.app.data.repository.ImportFromFileResult
import com.ledga.app.data.repository.ImportResult
import com.ledga.app.data.repository.SettingsRepository
import com.ledga.app.data.repository.SmsImporter
import com.ledga.app.data.repository.TransactionRepository
import com.ledga.app.ui.theme.ThemeMode
import android.content.Context
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class SettingsViewModel @Inject constructor(
@ApplicationContext private val appContext: Context,
private val settingsRepository: SettingsRepository,
private val transactionRepository: TransactionRepository,
private val smsImporter: SmsImporter,
private val exportImportRepository: ExportImportRepository,
private val backupRepository: BackupRepository
) : ViewModel() {
val themeMode: StateFlow<ThemeMode> = settingsRepository.getThemeMode()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ThemeMode.SYSTEM)
val unparsedCount: StateFlow<Int> = transactionRepository.getUnparsedCount()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
val fontScale: StateFlow<FontScale> = settingsRepository.getFontScale()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), FontScale.SYSTEM)
val dailySummaryEnabled: StateFlow<Boolean> = settingsRepository.getDailySummaryEnabled()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true)
val weeklySummaryEnabled: StateFlow<Boolean> = settingsRepository.getWeeklySummaryEnabled()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true)
val budgetAlertsEnabled: StateFlow<Boolean> = settingsRepository.getBudgetAlertsEnabled()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true)
val largeTxnAlertEnabled: StateFlow<Boolean> = settingsRepository.getLargeTransactionAlertEnabled()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
val largeTxnThreshold: StateFlow<Double> = settingsRepository.getLargeTransactionThreshold()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 5000.0)
private val _importStatus = MutableStateFlow<ImportResult?>(null)
val importStatus: StateFlow<ImportResult?> = _importStatus
fun setThemeMode(mode: ThemeMode) {
viewModelScope.launch {
settingsRepository.setThemeMode(mode)
}
}
fun setFontScale(scale: FontScale) {
viewModelScope.launch {
settingsRepository.setFontScale(scale)
}
}
fun setDailySummaryEnabled(enabled: Boolean) {
viewModelScope.launch { settingsRepository.setDailySummaryEnabled(enabled) }
}
fun setWeeklySummaryEnabled(enabled: Boolean) {
viewModelScope.launch { settingsRepository.setWeeklySummaryEnabled(enabled) }
}
fun setBudgetAlertsEnabled(enabled: Boolean) {
viewModelScope.launch { settingsRepository.setBudgetAlertsEnabled(enabled) }
}
fun setLargeTxnAlertEnabled(enabled: Boolean) {
viewModelScope.launch { settingsRepository.setLargeTransactionAlertEnabled(enabled) }
}
fun setLargeTxnThreshold(amount: Double) {
viewModelScope.launch { settingsRepository.setLargeTransactionThreshold(amount) }
}
fun importSmsHistory() {
viewModelScope.launch {
val result = smsImporter.importHistory()
_importStatus.value = result
}
}
private val _exportResult = MutableStateFlow<ExportResult?>(null)
val exportResult: StateFlow<ExportResult?> = _exportResult
private val _fileImportResult = MutableStateFlow<ImportFromFileResult?>(null)
val fileImportResult: StateFlow<ImportFromFileResult?> = _fileImportResult
fun exportData(uri: Uri) {
viewModelScope.launch {
_exportResult.value = exportImportRepository.exportToZip(appContext, uri)
}
}
fun importData(uri: Uri) {
viewModelScope.launch {
_fileImportResult.value = exportImportRepository.importFromZip(appContext, uri)
}
}
// --- Google Drive Backup ---
fun isSignedIn(): Boolean = backupRepository.isSignedIn(appContext)
fun getAccountEmail(): String? = backupRepository.getAccountEmail(appContext)
fun getSignInIntent() = backupRepository.getSignInIntent(appContext)
private val _backupStatus = MutableStateFlow<String?>(null)
val backupStatus: StateFlow<String?> = _backupStatus
fun backupNow() {
viewModelScope.launch {
_backupStatus.value = "Backing up..."
val success = backupRepository.performBackup(appContext)
_backupStatus.value = if (success) "Backup complete" else "Backup failed — sign in first"
}
}
fun restoreBackup() {
viewModelScope.launch {
_backupStatus.value = "Restoring..."
val count = backupRepository.restoreBackup(appContext)
_backupStatus.value = "Restored $count transactions"
}
}
}
@@ -0,0 +1,88 @@
package com.ledga.app.ui.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.ledga.app.data.repository.TransactionRepository
import javax.inject.Inject
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UnparsedSmsScreen(
onBack: () -> Unit,
viewModel: UnparsedSmsViewModel = hiltViewModel()
) {
val transactions by viewModel.unparsedTransactions.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Unparsed Messages") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
}
)
}
) { padding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
if (transactions.isEmpty()) {
item {
Text(
text = "No unparsed messages.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 24.dp)
)
}
}
items(transactions) { twc ->
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Text(
text = twc.transaction.rawSms,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(12.dp)
)
}
}
item { Spacer(modifier = Modifier.height(16.dp)) }
}
}
}
@@ -0,0 +1,21 @@
package com.ledga.app.ui.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.ledga.app.data.db.entity.TransactionWithCategory
import com.ledga.app.data.repository.TransactionRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
@HiltViewModel
class UnparsedSmsViewModel @Inject constructor(
transactionRepository: TransactionRepository
) : ViewModel() {
val unparsedTransactions: StateFlow<List<TransactionWithCategory>> =
transactionRepository.getUnparsedTransactions()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
}
@@ -0,0 +1,29 @@
package com.ledga.app.ui.theme
import androidx.compose.ui.graphics.Color
// Primary brand color (M-Pesa green)
val MpesaGreen = Color(0xFF4CAF50)
val MpesaGreenDark = Color(0xFF388E3C)
val MpesaGreenLight = Color(0xFFC8E6C9)
// Category colors
val CategoryGroceries = Color(0xFF4CAF50)
val CategoryTransport = Color(0xFF2196F3)
val CategoryBills = Color(0xFFFF9800)
val CategoryAirtime = Color(0xFF9C27B0)
val CategoryFood = Color(0xFFE91E63)
val CategorySendMoney = Color(0xFF607D8B)
val CategoryReceived = Color(0xFF00BCD4)
val CategoryWithdrawal = Color(0xFF795548)
val CategoryDeposit = Color(0xFF8BC34A)
val CategoryShopping = Color(0xFFFF5722)
val CategoryInternational = Color(0xFF3F51B5)
val CategorySavings = Color(0xFF009688)
val CategoryOther = Color(0xFF9E9E9E)
// Semantic colors
val InflowGreen = Color(0xFF2E7D32)
val OutflowRed = Color(0xFFC62828)
val InflowGreenLight = Color(0xFF66BB6A)
val OutflowRedLight = Color(0xFFEF5350)
@@ -0,0 +1,55 @@
package com.ledga.app.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
enum class ThemeMode { SYSTEM, LIGHT, DARK }
private val LightColorScheme = lightColorScheme(
primary = MpesaGreen,
onPrimary = androidx.compose.ui.graphics.Color.White,
primaryContainer = MpesaGreenLight,
onPrimaryContainer = MpesaGreenDark,
)
private val DarkColorScheme = darkColorScheme(
primary = MpesaGreenLight,
onPrimary = MpesaGreenDark,
primaryContainer = MpesaGreen,
onPrimaryContainer = MpesaGreenLight,
)
@Composable
fun LedgaTheme(
themeMode: ThemeMode = ThemeMode.SYSTEM,
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val darkTheme = when (themeMode) {
ThemeMode.SYSTEM -> isSystemInDarkTheme()
ThemeMode.LIGHT -> false
ThemeMode.DARK -> true
}
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = LedgaTypography,
content = content
)
}
@@ -0,0 +1,5 @@
package com.ledga.app.ui.theme
import androidx.compose.material3.Typography
val LedgaTypography = Typography()
@@ -0,0 +1,166 @@
package com.ledga.app.ui.transactions
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.ledga.app.data.db.entity.Category
import com.ledga.app.data.db.entity.TransactionEntity
import com.ledga.app.data.parser.TransactionDirection
import com.ledga.app.ui.components.parseColor
import com.ledga.app.ui.theme.InflowGreen
import com.ledga.app.ui.theme.OutflowRed
import com.ledga.app.util.CurrencyFormatter
import com.ledga.app.util.DateUtils
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TransactionDetailSheet(
transaction: TransactionEntity,
category: Category?,
categories: List<Category>,
onDismiss: () -> Unit,
onCategoryChange: (Long) -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val isInflow = transaction.direction == TransactionDirection.INFLOW
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 8.dp)
) {
// Amount
Text(
text = CurrencyFormatter.formatKshSigned(transaction.amount, isInflow),
style = MaterialTheme.typography.headlineMedium,
color = if (isInflow) InflowGreen else OutflowRed,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(4.dp))
// Recipient
Text(
text = transaction.recipientName ?: transaction.type.name.replace("_", " "),
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(12.dp))
// Details
DetailRow("Date", DateUtils.formatDate(transaction.timestamp))
DetailRow("Time", DateUtils.formatTime(transaction.timestamp))
DetailRow("Type", transaction.type.name.replace("_", " "))
DetailRow("Code", transaction.transactionCode)
DetailRow("Balance After", CurrencyFormatter.formatKsh(transaction.balance))
if (transaction.transactionCost > 0) {
DetailRow("Fee", CurrencyFormatter.formatKsh(transaction.transactionCost))
}
if (transaction.recipientPhone != null) {
DetailRow("Phone", transaction.recipientPhone)
}
if (transaction.accountNumber != null) {
DetailRow("Account", transaction.accountNumber)
}
if (transaction.destinationCountry != null) {
DetailRow("Country", transaction.destinationCountry)
}
if (transaction.fulizaAmount != null) {
DetailRow("Fuliza Amount", CurrencyFormatter.formatKsh(transaction.fulizaAmount))
}
if (transaction.fulizaOutstanding != null) {
DetailRow("Fuliza Outstanding", CurrencyFormatter.formatKsh(transaction.fulizaOutstanding))
}
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(12.dp))
// Category selector
Text(
text = "Category",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(8.dp))
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(categories) { cat ->
val isSelected = cat.id == transaction.categoryId
Surface(
modifier = Modifier.clickable { onCategoryChange(cat.id) },
shape = MaterialTheme.shapes.small,
color = if (isSelected) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceVariant
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Surface(
modifier = Modifier.size(10.dp),
shape = CircleShape,
color = parseColor(cat.color)
) {}
Text(
text = cat.name,
style = MaterialTheme.typography.bodySmall
)
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}
@Composable
private fun DetailRow(label: String, value: String) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium
)
}
}
@@ -0,0 +1,125 @@
package com.ledga.app.ui.transactions
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.ledga.app.ui.components.CategoryChip
import com.ledga.app.ui.components.TransactionCard
@Composable
fun TransactionsScreen(
viewModel: TransactionsViewModel = hiltViewModel()
) {
val state by viewModel.uiState.collectAsState()
// Detail bottom sheet
if (state.selectedTransaction != null) {
TransactionDetailSheet(
transaction = state.selectedTransaction!!.transaction,
category = state.selectedTransaction!!.category,
categories = state.categories,
onDismiss = { viewModel.clearSelection() },
onCategoryChange = { categoryId ->
viewModel.changeCategory(
state.selectedTransaction!!.transaction.id,
categoryId
)
}
)
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(16.dp))
// Search bar
OutlinedTextField(
value = state.searchQuery,
onValueChange = { viewModel.setSearchQuery(it) },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("Search transactions...") },
leadingIcon = { Icon(Icons.Default.Search, "Search") },
singleLine = true
)
Spacer(modifier = Modifier.height(8.dp))
// Filter chips
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(TransactionsViewModel.FILTERS.keys.toList()) { filter ->
CategoryChip(
label = filter,
isSelected = state.activeFilter == filter,
onClick = { viewModel.setFilter(filter) }
)
}
}
Spacer(modifier = Modifier.height(8.dp))
// Transactions grouped by day
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
state.groupedTransactions.forEach { (day, transactions) ->
// Day header
item(key = "header_$day") {
Text(
text = day,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(vertical = 8.dp)
)
}
// Transactions for that day
items(
items = transactions,
key = { it.transaction.id }
) { twc ->
TransactionCard(
transaction = twc.transaction,
category = twc.category,
onClick = { viewModel.selectTransaction(twc) }
)
}
}
if (state.groupedTransactions.isEmpty()) {
item {
Text(
text = "No transactions found.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 24.dp)
)
}
}
item { Spacer(modifier = Modifier.height(16.dp)) }
}
}
}
@@ -0,0 +1,109 @@
package com.ledga.app.ui.transactions
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.ledga.app.data.db.entity.Category
import com.ledga.app.data.db.entity.TransactionWithCategory
import com.ledga.app.data.parser.TransactionType
import com.ledga.app.data.repository.CategoryRepository
import com.ledga.app.data.repository.TransactionRepository
import com.ledga.app.util.DateUtils
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
data class TransactionsUiState(
val groupedTransactions: Map<String, List<TransactionWithCategory>> = emptyMap(),
val searchQuery: String = "",
val activeFilter: String = "All",
val categories: List<Category> = emptyList(),
val selectedTransaction: TransactionWithCategory? = null
)
@OptIn(FlowPreview::class)
@HiltViewModel
class TransactionsViewModel @Inject constructor(
private val transactionRepository: TransactionRepository,
private val categoryRepository: CategoryRepository
) : ViewModel() {
private val _searchQuery = MutableStateFlow("")
private val _activeFilter = MutableStateFlow("All")
companion object {
val FILTERS = mapOf(
"All" to emptyList<TransactionType>(),
"Sent" to listOf(TransactionType.SEND, TransactionType.MPESA_GLOBAL),
"Received" to listOf(TransactionType.RECEIVED),
"Bills" to listOf(TransactionType.PAY_BILL),
"Goods" to listOf(TransactionType.BUY_GOODS),
"Withdraw" to listOf(TransactionType.WITHDRAW_AGENT, TransactionType.WITHDRAW_ATM),
)
}
private val transactions = combine(
_searchQuery.debounce(300),
_activeFilter
) { query, filter ->
Pair(query, filter)
}.flatMapLatest { (query, filter) ->
val types = FILTERS[filter] ?: emptyList()
when {
query.isNotBlank() -> transactionRepository.searchTransactions(query)
types.isNotEmpty() -> transactionRepository.getTransactionsByType(types)
else -> transactionRepository.getRecentTransactions(200)
}
}
private val _selectedTransaction = MutableStateFlow<TransactionWithCategory?>(null)
val uiState: StateFlow<TransactionsUiState> = combine(
transactions,
_searchQuery,
_activeFilter,
categoryRepository.getAllCategories(),
_selectedTransaction
) { txns, query, filter, categories, selected ->
val grouped = txns.groupBy { twc ->
DateUtils.formatRelativeDate(twc.transaction.timestamp)
}
TransactionsUiState(
groupedTransactions = grouped,
searchQuery = query,
activeFilter = filter,
categories = categories,
selectedTransaction = selected
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TransactionsUiState())
fun setSearchQuery(query: String) {
_searchQuery.value = query
}
fun setFilter(filter: String) {
_activeFilter.value = filter
}
fun selectTransaction(twc: TransactionWithCategory) {
_selectedTransaction.value = twc
}
fun clearSelection() {
_selectedTransaction.value = null
}
fun changeCategory(transactionId: Long, categoryId: Long) {
viewModelScope.launch {
transactionRepository.updateCategory(transactionId, categoryId)
_selectedTransaction.value = null
}
}
}
@@ -0,0 +1,238 @@
package com.ledga.app.ui.trends
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost
import com.patrykandpatrick.vico.compose.cartesian.axis.rememberBottom
import com.patrykandpatrick.vico.compose.cartesian.axis.rememberStart
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberColumnCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart
import com.patrykandpatrick.vico.core.cartesian.axis.HorizontalAxis
import com.patrykandpatrick.vico.core.cartesian.axis.VerticalAxis
import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModelProducer
import com.patrykandpatrick.vico.core.cartesian.data.columnSeries
import com.ledga.app.ui.components.CategoryChip
import com.ledga.app.ui.components.StatCard
import com.ledga.app.ui.components.parseColor
import com.ledga.app.util.CurrencyFormatter
@Composable
fun TrendsScreen(
viewModel: TrendsViewModel = hiltViewModel()
) {
val state by viewModel.uiState.collectAsState()
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item { Spacer(modifier = Modifier.height(8.dp)) }
item {
Text("Trends", style = MaterialTheme.typography.headlineSmall)
}
// Period selector
item {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TrendsPeriod.entries.forEach { period ->
CategoryChip(
label = period.label,
isSelected = state.selectedPeriod == period,
onClick = { viewModel.selectPeriod(period) }
)
}
}
}
// Summary cards
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
StatCard(
label = "Total Spent",
value = CurrencyFormatter.formatKshCompact(state.totalSpending),
modifier = Modifier.weight(1f)
)
StatCard(
label = "Total Fees",
value = CurrencyFormatter.formatKsh(state.totalFees),
modifier = Modifier.weight(1f)
)
}
}
// Daily spending bar chart
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Daily Spending",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(12.dp))
if (state.dailySpending.isNotEmpty()) {
DailySpendingChart(
dailySpending = state.dailySpending.map { it.totalAmount },
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
)
} else {
Text(
text = "No spending data for this period.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 24.dp)
)
}
}
}
}
// Category breakdown
if (state.categorySpending.isNotEmpty()) {
item {
Text(
text = "By Category",
style = MaterialTheme.typography.titleMedium
)
}
items(state.categorySpending) { cs ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Surface(
modifier = Modifier.size(12.dp),
shape = CircleShape,
color = parseColor(cs.color)
) {}
Text(
text = cs.name,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.weight(1f)
.padding(horizontal = 12.dp)
)
Text(
text = CurrencyFormatter.formatKsh(cs.totalAmount),
style = MaterialTheme.typography.bodyMedium
)
}
}
}
// Top merchants
if (state.topMerchants.isNotEmpty()) {
item {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Top Merchants",
style = MaterialTheme.typography.titleMedium
)
}
items(state.topMerchants) { merchant ->
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = merchant.recipientName,
style = MaterialTheme.typography.bodyLarge
)
Text(
text = "${merchant.transactionCount} transactions",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Text(
text = CurrencyFormatter.formatKsh(merchant.totalAmount),
style = MaterialTheme.typography.titleSmall
)
}
}
}
}
item { Spacer(modifier = Modifier.height(16.dp)) }
}
}
@Composable
private fun DailySpendingChart(
dailySpending: List<Double>,
modifier: Modifier = Modifier
) {
val modelProducer = remember { CartesianChartModelProducer() }
LaunchedEffect(dailySpending) {
if (dailySpending.isNotEmpty()) {
modelProducer.runTransaction {
columnSeries {
series(dailySpending.map { it.toFloat() })
}
}
}
}
CartesianChartHost(
chart = rememberCartesianChart(
rememberColumnCartesianLayer(),
startAxis = VerticalAxis.rememberStart(),
bottomAxis = HorizontalAxis.rememberBottom(),
),
modelProducer = modelProducer,
modifier = modifier,
)
}
@@ -0,0 +1,97 @@
package com.ledga.app.ui.trends
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.ledga.app.data.db.dao.CategorySpending
import com.ledga.app.data.db.dao.DailySpending
import com.ledga.app.data.db.dao.TopMerchant
import com.ledga.app.data.repository.CategoryRepository
import com.ledga.app.data.repository.TransactionRepository
import dagger.hilt.android.lifecycle.HiltViewModel
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 java.util.Calendar
import javax.inject.Inject
enum class TrendsPeriod(val label: String, val days: Int) {
WEEK("7D", 7),
MONTH("30D", 30),
QUARTER("90D", 90),
YEAR("1Y", 365)
}
data class TrendsUiState(
val selectedPeriod: TrendsPeriod = TrendsPeriod.MONTH,
val dailySpending: List<DailySpending> = emptyList(),
val totalSpending: Double = 0.0,
val totalFees: Double = 0.0,
val topMerchants: List<TopMerchant> = emptyList(),
val categorySpending: List<CategorySpendingWithName> = emptyList()
)
data class CategorySpendingWithName(
val name: String,
val color: String,
val totalAmount: Double
)
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
@HiltViewModel
class TrendsViewModel @Inject constructor(
private val transactionRepository: TransactionRepository,
private val categoryRepository: CategoryRepository
) : ViewModel() {
private val _selectedPeriod = MutableStateFlow(TrendsPeriod.MONTH)
private val timeRange = _selectedPeriod.flatMapLatest { period ->
val now = System.currentTimeMillis()
val start = Calendar.getInstance().apply {
timeInMillis = now
add(Calendar.DAY_OF_YEAR, -period.days)
}.timeInMillis
flowOf(start to now)
}
val uiState: StateFlow<TrendsUiState> = combine(
_selectedPeriod,
timeRange.flatMapLatest { (s, e) -> transactionRepository.getDailySpending(s, e) },
timeRange.flatMapLatest { (s, e) -> transactionRepository.getTotalSpending(s, e) },
timeRange.flatMapLatest { (s, e) -> transactionRepository.getTotalFees(s, e) },
timeRange.flatMapLatest { (s, e) -> transactionRepository.getTopMerchants(s, e) },
) { period, daily, spending, fees, merchants ->
TrendsUiState(
selectedPeriod = period,
dailySpending = daily,
totalSpending = spending,
totalFees = fees,
topMerchants = merchants,
)
}.combine(
combine(
timeRange.flatMapLatest { (s, e) -> transactionRepository.getSpendingByCategory(s, e) },
categoryRepository.getAllCategories()
) { spending, categories ->
val catMap = categories.associateBy { it.id }
spending.map { cs ->
val cat = catMap[cs.categoryId]
CategorySpendingWithName(
name = cat?.name ?: "Other",
color = cat?.color ?: "#9E9E9E",
totalAmount = cs.totalAmount
)
}.sortedByDescending { it.totalAmount }
}
) { state, categorySpending ->
state.copy(categorySpending = categorySpending)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TrendsUiState())
fun selectPeriod(period: TrendsPeriod) {
_selectedPeriod.value = period
}
}
@@ -0,0 +1,35 @@
package com.ledga.app.util
import java.text.NumberFormat
import java.util.Locale
import kotlin.math.abs
object CurrencyFormatter {
private val formatter = NumberFormat.getNumberInstance(Locale.US).apply {
minimumFractionDigits = 2
maximumFractionDigits = 2
}
fun formatKsh(amount: Double): String {
val formatted = formatter.format(abs(amount))
val prefix = if (amount < 0) "-" else ""
return "${prefix}Ksh $formatted"
}
fun formatKshSigned(amount: Double, isInflow: Boolean): String {
val formatted = formatter.format(abs(amount))
val sign = if (isInflow) "+" else "-"
return "${sign}Ksh $formatted"
}
fun formatKshCompact(amount: Double): String {
val abs = abs(amount)
val prefix = if (amount < 0) "-" else ""
return when {
abs >= 1_000_000 -> "${prefix}Ksh ${String.format("%.1fM", abs / 1_000_000)}"
abs >= 1_000 -> "${prefix}Ksh ${String.format("%.1fK", abs / 1_000)}"
else -> formatKsh(amount)
}
}
}
@@ -0,0 +1,96 @@
package com.ledga.app.util
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
object DateUtils {
private val mpesaDateFormat = SimpleDateFormat("d/M/yy 'at' h:mm a", Locale.ENGLISH)
fun parseMpesaDate(dateStr: String): Long {
return try {
mpesaDateFormat.parse(dateStr)?.time ?: System.currentTimeMillis()
} catch (e: Exception) {
System.currentTimeMillis()
}
}
fun formatRelativeDate(timestamp: Long): String {
val now = Calendar.getInstance()
val date = Calendar.getInstance().apply { timeInMillis = timestamp }
return when {
isSameDay(now, date) -> "Today"
isYesterday(now, date) -> "Yesterday"
else -> SimpleDateFormat("EEE, d MMM", Locale.ENGLISH).format(Date(timestamp))
}
}
fun formatTime(timestamp: Long): String {
return SimpleDateFormat("h:mm a", Locale.ENGLISH).format(Date(timestamp))
}
fun formatMonthYear(timestamp: Long): String {
return SimpleDateFormat("MMMM yyyy", Locale.ENGLISH).format(Date(timestamp))
}
fun formatDate(timestamp: Long): String {
return SimpleDateFormat("d MMM yyyy", Locale.ENGLISH).format(Date(timestamp))
}
fun greeting(): String {
val hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY)
return when {
hour < 12 -> "Good morning"
hour < 17 -> "Good afternoon"
else -> "Good evening"
}
}
fun getStartOfDay(timestamp: Long = System.currentTimeMillis()): Long {
return Calendar.getInstance().apply {
timeInMillis = timestamp
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
}
fun getStartOfWeek(timestamp: Long = System.currentTimeMillis()): Long {
return Calendar.getInstance().apply {
timeInMillis = timestamp
set(Calendar.DAY_OF_WEEK, firstDayOfWeek)
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
}
fun getStartOfMonth(timestamp: Long = System.currentTimeMillis()): Long {
return Calendar.getInstance().apply {
timeInMillis = timestamp
set(Calendar.DAY_OF_MONTH, 1)
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
}
private fun isSameDay(a: Calendar, b: Calendar): Boolean {
return a.get(Calendar.YEAR) == b.get(Calendar.YEAR) &&
a.get(Calendar.DAY_OF_YEAR) == b.get(Calendar.DAY_OF_YEAR)
}
private fun isYesterday(now: Calendar, date: Calendar): Boolean {
val yesterday = Calendar.getInstance().apply {
timeInMillis = now.timeInMillis
add(Calendar.DAY_OF_YEAR, -1)
}
return isSameDay(yesterday, date)
}
}
@@ -0,0 +1,26 @@
package com.ledga.app.worker
import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.ledga.app.data.repository.BackupRepository
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
@HiltWorker
class BackupWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted params: WorkerParameters,
private val backupRepository: BackupRepository
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
return try {
backupRepository.performBackup(applicationContext)
Result.success()
} catch (e: Exception) {
if (runAttemptCount < 3) Result.retry() else Result.failure()
}
}
}
@@ -0,0 +1,67 @@
package com.ledga.app.worker
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
object NotificationHelper {
const val CHANNEL_SUMMARIES = "spending_summaries"
const val CHANNEL_BUDGET = "budget_alerts"
const val CHANNEL_LARGE_TXN = "large_transactions"
fun createChannels(context: Context) {
val manager = context.getSystemService(NotificationManager::class.java)
val summaries = NotificationChannel(
CHANNEL_SUMMARIES,
"Spending Summaries",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "Daily and weekly spending summaries"
}
val budget = NotificationChannel(
CHANNEL_BUDGET,
"Budget Alerts",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Alerts when budget limits are reached"
}
val largeTxn = NotificationChannel(
CHANNEL_LARGE_TXN,
"Large Transactions",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "Alerts for large transactions"
}
manager.createNotificationChannels(listOf(summaries, budget, largeTxn))
}
fun showNotification(
context: Context,
channelId: String,
notificationId: Int,
title: String,
body: String
) {
val notification = NotificationCompat.Builder(context, channelId)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(title)
.setContentText(body)
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setAutoCancel(true)
.build()
try {
NotificationManagerCompat.from(context).notify(notificationId, notification)
} catch (e: SecurityException) {
// POST_NOTIFICATIONS permission not granted
}
}
}
@@ -0,0 +1,62 @@
package com.ledga.app.worker
import android.content.Context
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import java.util.Calendar
import java.util.concurrent.TimeUnit
object NotificationScheduler {
fun scheduleDailySummary(context: Context, hour: Int = 20) {
val now = Calendar.getInstance()
val target = Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, hour)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
if (before(now)) add(Calendar.DAY_OF_YEAR, 1)
}
val initialDelay = target.timeInMillis - now.timeInMillis
val request = PeriodicWorkRequestBuilder<DailySummaryWorker>(1, TimeUnit.DAYS)
.setInitialDelay(initialDelay, TimeUnit.MILLISECONDS)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"daily_summary",
ExistingPeriodicWorkPolicy.UPDATE,
request
)
}
fun cancelDailySummary(context: Context) {
WorkManager.getInstance(context).cancelUniqueWork("daily_summary")
}
fun scheduleWeeklySummary(context: Context) {
val now = Calendar.getInstance()
val target = Calendar.getInstance().apply {
set(Calendar.DAY_OF_WEEK, Calendar.SUNDAY)
set(Calendar.HOUR_OF_DAY, 19)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
if (before(now)) add(Calendar.WEEK_OF_YEAR, 1)
}
val initialDelay = target.timeInMillis - now.timeInMillis
val request = PeriodicWorkRequestBuilder<WeeklySummaryWorker>(7, TimeUnit.DAYS)
.setInitialDelay(initialDelay, TimeUnit.MILLISECONDS)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"weekly_summary",
ExistingPeriodicWorkPolicy.UPDATE,
request
)
}
fun cancelWeeklySummary(context: Context) {
WorkManager.getInstance(context).cancelUniqueWork("weekly_summary")
}
}
@@ -0,0 +1,84 @@
package com.ledga.app.worker
import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.ledga.app.data.db.dao.TransactionDao
import com.ledga.app.util.CurrencyFormatter
import com.ledga.app.util.DateUtils
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.first
@HiltWorker
class DailySummaryWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted params: WorkerParameters,
private val transactionDao: TransactionDao
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val start = DateUtils.getStartOfDay()
val end = System.currentTimeMillis()
val totalSpent = transactionDao.getTotalSpending(start, end).first()
val totalFees = transactionDao.getTotalFees(start, end).first()
if (totalSpent > 0) {
val body = buildString {
append("You spent ${CurrencyFormatter.formatKsh(totalSpent)} today")
if (totalFees > 0) {
append(" (${CurrencyFormatter.formatKsh(totalFees)} in fees)")
}
append(".")
}
NotificationHelper.showNotification(
context = applicationContext,
channelId = NotificationHelper.CHANNEL_SUMMARIES,
notificationId = 1001,
title = "Daily Spending Summary",
body = body
)
}
return Result.success()
}
}
@HiltWorker
class WeeklySummaryWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted params: WorkerParameters,
private val transactionDao: TransactionDao
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val start = DateUtils.getStartOfWeek()
val end = System.currentTimeMillis()
val totalSpent = transactionDao.getTotalSpending(start, end).first()
val totalFees = transactionDao.getTotalFees(start, end).first()
if (totalSpent > 0) {
val body = buildString {
append("This week you spent ${CurrencyFormatter.formatKsh(totalSpent)}")
if (totalFees > 0) {
append(" (${CurrencyFormatter.formatKsh(totalFees)} in fees)")
}
append(".")
}
NotificationHelper.showNotification(
context = applicationContext,
channelId = NotificationHelper.CHANNEL_SUMMARIES,
notificationId = 1002,
title = "Weekly Spending Summary",
body = body
)
}
return Result.success()
}
}
@@ -0,0 +1,74 @@
package com.ledga.app.worker
import android.content.Context
import com.ledga.app.data.db.dao.BudgetDao
import com.ledga.app.data.db.dao.TransactionDao
import com.ledga.app.data.parser.ParsedTransaction
import com.ledga.app.util.CurrencyFormatter
import com.ledga.app.util.DateUtils
import kotlinx.coroutines.flow.first
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class TransactionAlerts @Inject constructor(
private val transactionDao: TransactionDao,
private val budgetDao: BudgetDao
) {
suspend fun checkAlerts(
context: Context,
transaction: ParsedTransaction,
largeTransactionThreshold: Double,
largeTransactionEnabled: Boolean,
budgetAlertsEnabled: Boolean
) {
// Large transaction alert
if (largeTransactionEnabled && transaction.amount >= largeTransactionThreshold) {
val name = transaction.recipientName ?: transaction.type.name.replace("_", " ")
NotificationHelper.showNotification(
context = context,
channelId = NotificationHelper.CHANNEL_LARGE_TXN,
notificationId = 2000 + (transaction.transactionCode.hashCode() and 0xFFFF),
title = "Large Transaction",
body = "${CurrencyFormatter.formatKsh(transaction.amount)} to $name"
)
}
// Budget alerts
if (budgetAlertsEnabled) {
checkBudgetAlerts(context)
}
}
private suspend fun checkBudgetAlerts(context: Context) {
val start = DateUtils.getStartOfMonth()
val end = System.currentTimeMillis()
val totalSpent = transactionDao.getTotalSpending(start, end).first()
val budgets = budgetDao.getActiveBudgets().first()
val overallBudget = budgets.find { it.categoryId == null } ?: return
val percentage = ((totalSpent / overallBudget.monthlyLimit) * 100).toInt()
when {
percentage >= 100 -> {
NotificationHelper.showNotification(
context = context,
channelId = NotificationHelper.CHANNEL_BUDGET,
notificationId = 3001,
title = "Budget Exceeded!",
body = "You've spent ${CurrencyFormatter.formatKsh(totalSpent)} — ${percentage}% of your ${CurrencyFormatter.formatKsh(overallBudget.monthlyLimit)} monthly budget."
)
}
percentage >= 80 -> {
NotificationHelper.showNotification(
context = context,
channelId = NotificationHelper.CHANNEL_BUDGET,
notificationId = 3002,
title = "Budget Warning",
body = "You've used ${percentage}% of your monthly budget (${CurrencyFormatter.formatKsh(totalSpent)} of ${CurrencyFormatter.formatKsh(overallBudget.monthlyLimit)})."
)
}
}
}
}
@@ -0,0 +1,120 @@
package com.ledga.app.worker
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.core.content.FileProvider
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.File
import java.net.URL
@Serializable
data class GitHubRelease(
val tag_name: String,
val name: String? = null,
val body: String? = null,
val assets: List<GitHubAsset> = emptyList()
)
@Serializable
data class GitHubAsset(
val name: String,
val browser_download_url: String,
val size: Long = 0
)
@HiltWorker
class UpdateCheckWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted params: WorkerParameters
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
// This worker is a placeholder — the actual check is done in UpdateChecker
return Result.success()
}
}
object UpdateChecker {
private val json = Json { ignoreUnknownKeys = true }
private const val PREFS_NAME = "update_prefs"
private const val LAST_CHECK_KEY = "last_check"
suspend fun checkForUpdate(
context: Context,
owner: String,
repo: String,
currentVersion: String
): GitHubRelease? = withContext(Dispatchers.IO) {
// Rate limit: max once per day
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val lastCheck = prefs.getLong(LAST_CHECK_KEY, 0)
val oneDayMs = 24 * 60 * 60 * 1000L
if (System.currentTimeMillis() - lastCheck < oneDayMs) return@withContext null
prefs.edit().putLong(LAST_CHECK_KEY, System.currentTimeMillis()).apply()
try {
val url = "https://api.github.com/repos/$owner/$repo/releases/latest"
val response = URL(url).readText()
val release = json.decodeFromString<GitHubRelease>(response)
val latestVersion = release.tag_name.removePrefix("v")
if (isNewerVersion(latestVersion, currentVersion)) release else null
} catch (e: Exception) {
null
}
}
suspend fun downloadApk(context: Context, downloadUrl: String, filename: String): File? =
withContext(Dispatchers.IO) {
try {
val cacheDir = File(context.cacheDir, "updates")
cacheDir.mkdirs()
val file = File(cacheDir, filename)
URL(downloadUrl).openStream().use { input ->
file.outputStream().use { output ->
input.copyTo(output)
}
}
file
} catch (e: Exception) {
null
}
}
fun installApk(context: Context, file: File) {
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file
)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(intent)
}
private fun isNewerVersion(latest: String, current: String): Boolean {
val latestParts = latest.split(".").map { it.toIntOrNull() ?: 0 }
val currentParts = current.split(".").map { it.toIntOrNull() ?: 0 }
for (i in 0 until maxOf(latestParts.size, currentParts.size)) {
val l = latestParts.getOrElse(i) { 0 }
val c = currentParts.getOrElse(i) { 0 }
if (l > c) return true
if (l < c) return false
}
return false
}
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#4CAF50"
android:pathData="M0,0h108v108h-108z" />
</vector>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- "L" letter for Ledga -->
<path
android:fillColor="#FFFFFF"
android:pathData="M36,30 L36,72 L72,72 L72,64 L44,64 L44,30 Z" />
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Ledga</string>
</resources>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Ledga" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
</style>
<style name="Theme.Ledga.Splash" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">#4CAF50</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_launcher_foreground</item>
<item name="postSplashScreenTheme">@style/Theme.Ledga</item>
</style>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="updates" path="updates/" />
</paths>
@@ -0,0 +1,310 @@
package com.ledga.app.parser
import com.ledga.app.data.parser.MpesaSmsParser
import com.ledga.app.data.parser.ParseResult
import com.ledga.app.data.parser.TransactionDirection
import com.ledga.app.data.parser.TransactionType
import org.junit.Assert.*
import org.junit.Test
class MpesaSmsParserTest {
private fun assertSuccess(sms: String): ParseResult.Success {
val result = MpesaSmsParser.parse(sms)
assertTrue("Expected Success but got: $result", result is ParseResult.Success)
return result as ParseResult.Success
}
// --- Sender filter ---
@Test
fun `isMpesaMessage accepts MPESA sender`() {
assertTrue(MpesaSmsParser.isMpesaMessage("MPESA"))
assertTrue(MpesaSmsParser.isMpesaMessage("mpesa"))
assertTrue(MpesaSmsParser.isMpesaMessage("Mpesa"))
}
@Test
fun `isMpesaMessage rejects other senders`() {
assertFalse(MpesaSmsParser.isMpesaMessage("KCB"))
assertFalse(MpesaSmsParser.isMpesaMessage(""))
assertFalse(MpesaSmsParser.isMpesaMessage("SAFARICOM"))
}
// --- Send Money ---
@Test
fun `parse send money`() {
val sms = "RK31B7X4ZQ Confirmed. Ksh500.00 sent to JOHN DOE 0712345678 on 21/3/26 at 1:30 PM. New M-PESA balance is Ksh1,200.00. Transaction cost, Ksh0.00."
val result = assertSuccess(sms)
val t = result.transaction
assertEquals("RK31B7X4ZQ", t.transactionCode)
assertEquals(TransactionType.SEND, t.type)
assertEquals(500.0, t.amount, 0.01)
assertEquals("JOHN DOE", t.recipientName)
assertEquals("0712345678", t.recipientPhone)
assertEquals(1200.0, t.balance, 0.01)
assertEquals(0.0, t.transactionCost, 0.01)
assertEquals(TransactionDirection.OUTFLOW, t.direction)
}
@Test
fun `parse send money large amount`() {
val sms = "AB12CD34EF Confirmed. Ksh100,000.00 sent to JANE SMITH 0798765432 on 1/1/26 at 12:00 AM. New M-PESA balance is Ksh50,000.00. Transaction cost, Ksh105.00."
val result = assertSuccess(sms)
assertEquals(100000.0, result.transaction.amount, 0.01)
assertEquals(50000.0, result.transaction.balance, 0.01)
assertEquals(105.0, result.transaction.transactionCost, 0.01)
}
// --- Buy Goods ---
@Test
fun `parse buy goods`() {
val sms = "RK31B7X4ZQ Confirmed. Ksh1,200.00 paid to NAIVAS SUPERMARKET. on 21/3/26 at 2:15 PM.New M-PESA balance is Ksh3,500.00. Transaction cost, Ksh0.00."
val result = assertSuccess(sms)
val t = result.transaction
assertEquals(TransactionType.BUY_GOODS, t.type)
assertEquals(1200.0, t.amount, 0.01)
assertEquals("NAIVAS SUPERMARKET", t.recipientName)
assertEquals(3500.0, t.balance, 0.01)
assertEquals(TransactionDirection.OUTFLOW, t.direction)
}
// --- Pay Bill ---
@Test
fun `parse pay bill`() {
val sms = "RK31B7X4ZQ Confirmed. Ksh2,500.00 paid to KPLC PREPAID. Account Number 12345678. on 21/3/26 at 3:00 PM. New M-PESA balance is Ksh1,000.00. Transaction cost, Ksh0.00."
val result = assertSuccess(sms)
val t = result.transaction
assertEquals(TransactionType.PAY_BILL, t.type)
assertEquals(2500.0, t.amount, 0.01)
assertEquals("KPLC PREPAID", t.recipientName)
assertEquals("12345678", t.accountNumber)
assertEquals(1000.0, t.balance, 0.01)
}
// --- Withdraw Agent ---
@Test
fun `parse withdraw agent`() {
val sms = "RK31B7X4ZQ Confirmed.You have withdrawn Ksh1,000.00 from JAMES AGENT 543210 on 21/3/26 at 4:00 PM.New M-PESA balance is Ksh500.00. Transaction cost, Ksh28.00."
val result = assertSuccess(sms)
val t = result.transaction
assertEquals(TransactionType.WITHDRAW_AGENT, t.type)
assertEquals(1000.0, t.amount, 0.01)
assertEquals("JAMES AGENT", t.recipientName)
assertEquals("543210", t.recipientPhone)
assertEquals(500.0, t.balance, 0.01)
assertEquals(28.0, t.transactionCost, 0.01)
assertEquals(TransactionDirection.OUTFLOW, t.direction)
}
// --- Withdraw ATM ---
@Test
fun `parse withdraw ATM`() {
val sms = "RK31B7X4ZQ Confirmed. You have withdrawn Ksh5,000.00 from an ATM on 21/3/26 at 4:30 PM. New M-PESA balance is Ksh2,000.00. Transaction cost, Ksh34.00."
val result = assertSuccess(sms)
val t = result.transaction
assertEquals(TransactionType.WITHDRAW_ATM, t.type)
assertEquals(5000.0, t.amount, 0.01)
assertEquals("ATM", t.recipientName)
assertEquals(2000.0, t.balance, 0.01)
assertEquals(34.0, t.transactionCost, 0.01)
}
// --- Deposit ---
@Test
fun `parse deposit`() {
val sms = "RK31B7X4ZQ Confirmed.You have deposited Ksh5,000.00 to your M-PESA account on 21/3/26 at 10:00 AM.New M-PESA balance is Ksh5,500.00."
val result = assertSuccess(sms)
val t = result.transaction
assertEquals(TransactionType.DEPOSIT, t.type)
assertEquals(5000.0, t.amount, 0.01)
assertEquals(5500.0, t.balance, 0.01)
assertEquals(TransactionDirection.INFLOW, t.direction)
}
// --- Received Money ---
@Test
fun `parse received money`() {
val sms = "RK31B7X4ZQ Confirmed.You have received Ksh2,000.00 from JANE DOE 0798765432 on 21/3/26 at 11:00 AM.New M-PESA balance is Ksh7,500.00."
val result = assertSuccess(sms)
val t = result.transaction
assertEquals(TransactionType.RECEIVED, t.type)
assertEquals(2000.0, t.amount, 0.01)
assertEquals("JANE DOE", t.recipientName)
assertEquals("0798765432", t.recipientPhone)
assertEquals(7500.0, t.balance, 0.01)
assertEquals(TransactionDirection.INFLOW, t.direction)
}
// --- Airtime Self ---
@Test
fun `parse airtime self`() {
val sms = "RK31B7X4ZQ Confirmed. Ksh100.00 of airtime purchased on 21/3/26 at 12:00 PM.New M-PESA balance is Ksh7,400.00. Transaction cost, Ksh0.00."
val result = assertSuccess(sms)
val t = result.transaction
assertEquals(TransactionType.AIRTIME_SELF, t.type)
assertEquals(100.0, t.amount, 0.01)
assertEquals(7400.0, t.balance, 0.01)
assertEquals(TransactionDirection.OUTFLOW, t.direction)
}
// --- Airtime Other ---
@Test
fun `parse airtime for others`() {
val sms = "RK31B7X4ZQ Confirmed. You bought Ksh100.00 of airtime for 0712345678 on 21/3/26 at 12:30 PM.New M-PESA balance is Ksh7,300.00. Transaction cost, Ksh0.00."
val result = assertSuccess(sms)
val t = result.transaction
assertEquals(TransactionType.AIRTIME_OTHER, t.type)
assertEquals(100.0, t.amount, 0.01)
assertEquals("0712345678", t.recipientPhone)
assertEquals(7300.0, t.balance, 0.01)
}
// --- M-Pesa Global ---
@Test
fun `parse mpesa global`() {
val sms = "RK31B7X4ZQ Confirmed. Ksh5,000.00 sent to JOHN DOE +44712345678 (United Kingdom) via M-PESA Global on 21/3/26 at 1:30 PM. New M-PESA balance is Ksh10,000.00. Transaction cost, Ksh150.00."
val result = assertSuccess(sms)
val t = result.transaction
assertEquals(TransactionType.MPESA_GLOBAL, t.type)
assertEquals(5000.0, t.amount, 0.01)
assertEquals("JOHN DOE", t.recipientName)
assertEquals("+44712345678", t.recipientPhone)
assertEquals("United Kingdom", t.destinationCountry)
assertEquals(10000.0, t.balance, 0.01)
assertEquals(150.0, t.transactionCost, 0.01)
}
// --- Fuliza ---
@Test
fun `parse fuliza borrow`() {
val sms = "RK31B7X4ZQ Confirmed. Ksh500.00 sent to JOHN DOE 0712345678 on 21/3/26 at 1:30 PM. New M-PESA balance is Ksh0.00. Fuliza M-PESA amount is Ksh500.00. Fuliza M-PESA outstanding amount is Ksh500.00."
val result = assertSuccess(sms)
val t = result.transaction
assertEquals(TransactionType.FULIZA, t.type)
assertEquals(500.0, t.amount, 0.01)
assertEquals(500.0, t.fulizaAmount!!, 0.01)
assertEquals(500.0, t.fulizaOutstanding!!, 0.01)
assertEquals("JOHN DOE", t.recipientName)
assertEquals(TransactionDirection.OUTFLOW, t.direction)
}
// --- Fuliza Repayment ---
@Test
fun `parse fuliza repayment`() {
val sms = "RK31B7X4ZQ Confirmed. You have paid Ksh200.00 to Fuliza M-PESA on 21/3/26 at 2:00 PM. Fuliza M-PESA outstanding amount is Ksh300.00."
val result = assertSuccess(sms)
val t = result.transaction
assertEquals(TransactionType.FULIZA_REPAYMENT, t.type)
assertEquals(200.0, t.amount, 0.01)
assertEquals(300.0, t.fulizaOutstanding!!, 0.01)
assertEquals(TransactionDirection.OUTFLOW, t.direction)
}
// --- Fuliza Reversal ---
@Test
fun `parse fuliza reversal`() {
val sms = "RK31B7X4ZQ Confirmed. Fuliza M-PESA of Ksh500.00 has been reversed on 21/3/26 at 3:00 PM. Fuliza M-PESA outstanding amount is Ksh0.00."
val result = assertSuccess(sms)
val t = result.transaction
assertEquals(TransactionType.FULIZA_REVERSAL, t.type)
assertEquals(500.0, t.amount, 0.01)
assertEquals(0.0, t.fulizaOutstanding!!, 0.01)
assertEquals(TransactionDirection.INFLOW, t.direction)
}
// --- Reversal ---
@Test
fun `parse reversal`() {
val sms = "RK31B7X4ZQ Confirmed. Transaction RJ12345678 has been reversed. Your account balance is Ksh2,000.00."
val result = assertSuccess(sms)
val t = result.transaction
assertEquals(TransactionType.REVERSAL, t.type)
assertEquals("RJ12345678", t.reversedTransactionCode)
assertEquals(2000.0, t.balance, 0.01)
assertEquals(TransactionDirection.INFLOW, t.direction)
}
// --- M-Shwari ---
@Test
fun `parse mshwari transfer to`() {
val sms = "RK31B7X4ZQ Confirmed. Ksh1,000.00 transferred to M-Shwari account on 21/3/26 at 5:00 PM. New M-PESA balance is Ksh500.00."
val result = assertSuccess(sms)
val t = result.transaction
assertEquals(TransactionType.MSHWARI, t.type)
assertEquals(1000.0, t.amount, 0.01)
assertEquals(TransactionDirection.OUTFLOW, t.direction)
}
@Test
fun `parse mshwari transfer from`() {
val sms = "RK31B7X4ZQ Confirmed. Ksh1,000.00 transferred from M-Shwari account on 21/3/26 at 5:00 PM. New M-PESA balance is Ksh1,500.00."
val result = assertSuccess(sms)
assertEquals(TransactionDirection.INFLOW, result.transaction.direction)
}
// --- KCB M-Pesa ---
@Test
fun `parse kcb mpesa`() {
val sms = "RK31B7X4ZQ Confirmed. Ksh2,000.00 transferred to KCB M-Pesa account on 21/3/26 at 6:00 PM. New M-PESA balance is Ksh3,000.00."
val result = assertSuccess(sms)
val t = result.transaction
assertEquals(TransactionType.KCB_MPESA, t.type)
assertEquals(2000.0, t.amount, 0.01)
assertEquals(TransactionDirection.OUTFLOW, t.direction)
}
// --- Edge cases ---
@Test
fun `parse failure for non-confirmed message`() {
val sms = "RK31B7X4ZQ Failed. Ksh500.00 sent to JOHN DOE."
val result = MpesaSmsParser.parse(sms)
assertTrue(result is ParseResult.Failure)
}
@Test
fun `parse failure for no transaction code`() {
val sms = "Confirmed. Ksh500.00 sent to JOHN DOE on 21/3/26 at 1:30 PM."
val result = MpesaSmsParser.parse(sms)
assertTrue(result is ParseResult.Failure)
}
@Test
fun `parse small amount`() {
val sms = "AB12CD34EF Confirmed. Ksh1.00 of airtime purchased on 21/3/26 at 12:00 PM.New M-PESA balance is Ksh0.50. Transaction cost, Ksh0.00."
val result = assertSuccess(sms)
assertEquals(1.0, result.transaction.amount, 0.01)
assertEquals(0.5, result.transaction.balance, 0.01)
}
@Test
fun `unknown type for unrecognized mpesa message`() {
val sms = "AB12CD34EF Confirmed. Some new M-PESA feature we don't know about."
val result = assertSuccess(sms)
assertEquals(TransactionType.UNKNOWN, result.transaction.type)
}
@Test
fun `transaction code extraction`() {
val sms = "RK31B7X4ZQ Confirmed. Ksh500.00 sent to JOHN DOE 0712345678 on 21/3/26 at 1:30 PM. New M-PESA balance is Ksh1,200.00. Transaction cost, Ksh0.00."
val result = assertSuccess(sms)
assertEquals("RK31B7X4ZQ", result.transaction.transactionCode)
}
}
+12
View File
@@ -0,0 +1,12 @@
plugins {
id("com.android.application") version "8.13.2" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.1.0" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.1.0" apply false
id("com.google.devtools.ksp") version "2.1.0-1.0.29" apply false
id("com.google.dagger.hilt.android") version "2.57.1" apply false
}
tasks.register("clean", Delete::class) {
delete(rootProject.layout.buildDirectory)
}
+6
View File
@@ -0,0 +1,6 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.parallel=true
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
org.gradle.configuration-cache=true
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+180
View File
@@ -0,0 +1,180 @@
#!/bin/sh
##############################################################################
# Gradle startup script for Unix
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
APP_NAME="Gradle"
APP_BASE_NAME=${0##*/}
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
ulimit -n "$MAX_FD" ||
warn "Could neot set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+67
View File
@@ -0,0 +1,67 @@
@rem Gradle startup script for Windows
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+18
View File
@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Ledga"
include(":app")