mirror of
https://github.com/andwati/ledga.git
synced 2026-10-18 03:12:55 +00:00
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:
@@ -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
@@ -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
@@ -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
|
||||
@@ -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")
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
# Ledga ProGuard Rules
|
||||
# Keep Room entities
|
||||
-keep class com.ledga.app.data.db.entity.** { *; }
|
||||
@@ -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>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Ledga</string>
|
||||
</resources>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
Vendored
BIN
Binary file not shown.
+7
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user