From 79741192b96a8549e4aa36f93a3b99482f1d7fbe Mon Sep 17 00:00:00 2001 From: ngenohkevin Date: Sat, 21 Mar 2026 20:53:59 +0300 Subject: [PATCH] =?UTF-8?q?Initial=20release=20=E2=80=94=20Ledga=20M-Pesa?= =?UTF-8?q?=20spending=20tracker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/release.yml | 67 ++ .gitignore | 19 + PROJECT_PLAN.md | 746 ++++++++++++++++++ app/build.gradle.kts | 135 ++++ app/proguard-rules.pro | 3 + app/src/main/AndroidManifest.xml | 49 ++ app/src/main/java/com/ledga/app/LedgaApp.kt | 28 + .../main/java/com/ledga/app/MainActivity.kt | 98 +++ .../java/com/ledga/app/data/db/AppDatabase.kt | 31 + .../java/com/ledga/app/data/db/Converters.kt | 27 + .../java/com/ledga/app/data/db/DefaultData.kt | 54 ++ .../java/com/ledga/app/data/db/Migrations.kt | 19 + .../com/ledga/app/data/db/dao/BudgetDao.kt | 18 + .../com/ledga/app/data/db/dao/CategoryDao.kt | 21 + .../ledga/app/data/db/dao/CategoryRuleDao.kt | 24 + .../ledga/app/data/db/dao/TransactionDao.kt | 102 +++ .../com/ledga/app/data/db/entity/Budget.kt | 26 + .../com/ledga/app/data/db/entity/Category.kt | 14 + .../ledga/app/data/db/entity/CategoryRule.kt | 30 + .../app/data/db/entity/TransactionEntity.kt | 47 ++ .../data/db/entity/TransactionWithCategory.kt | 13 + .../ledga/app/data/parser/MpesaSmsParser.kt | 388 +++++++++ .../app/data/repository/BackupRepository.kt | 179 +++++ .../app/data/repository/CategoryRepository.kt | 21 + .../data/repository/ExportImportRepository.kt | 154 ++++ .../app/data/repository/SettingsRepository.kt | 95 +++ .../ledga/app/data/repository/SmsImporter.kt | 74 ++ .../data/repository/TransactionRepository.kt | 129 +++ .../main/java/com/ledga/app/di/AppModule.kt | 31 + .../java/com/ledga/app/di/DatabaseModule.kt | 61 ++ .../com/ledga/app/receiver/SmsReceiver.kt | 72 ++ .../app/ui/components/BudgetProgressBar.kt | 55 ++ .../ledga/app/ui/components/CategoryChip.kt | 21 + .../com/ledga/app/ui/components/DonutChart.kt | 87 ++ .../ledga/app/ui/components/PeriodSelector.kt | 34 + .../com/ledga/app/ui/components/StatCard.kt | 45 ++ .../app/ui/components/TransactionCard.kt | 98 +++ .../java/com/ledga/app/ui/home/HomeScreen.kt | 132 ++++ .../com/ledga/app/ui/home/HomeViewModel.kt | 132 ++++ .../ledga/app/ui/navigation/AppNavigation.kt | 58 ++ .../ledga/app/ui/navigation/BottomNavBar.kt | 53 ++ .../com/ledga/app/ui/navigation/Routes.kt | 11 + .../app/ui/onboarding/OnboardingScreen.kt | 266 +++++++ .../app/ui/onboarding/OnboardingViewModel.kt | 66 ++ .../com/ledga/app/ui/settings/BudgetScreen.kt | 155 ++++ .../ledga/app/ui/settings/BudgetViewModel.kt | 73 ++ .../ledga/app/ui/settings/SettingsScreen.kt | 302 +++++++ .../app/ui/settings/SettingsViewModel.kt | 144 ++++ .../app/ui/settings/UnparsedSmsScreen.kt | 88 +++ .../app/ui/settings/UnparsedSmsViewModel.kt | 21 + .../main/java/com/ledga/app/ui/theme/Color.kt | 29 + .../main/java/com/ledga/app/ui/theme/Theme.kt | 55 ++ .../main/java/com/ledga/app/ui/theme/Type.kt | 5 + .../ui/transactions/TransactionDetailSheet.kt | 166 ++++ .../app/ui/transactions/TransactionsScreen.kt | 125 +++ .../ui/transactions/TransactionsViewModel.kt | 109 +++ .../com/ledga/app/ui/trends/TrendsScreen.kt | 238 ++++++ .../ledga/app/ui/trends/TrendsViewModel.kt | 97 +++ .../com/ledga/app/util/CurrencyFormatter.kt | 35 + .../main/java/com/ledga/app/util/DateUtils.kt | 96 +++ .../java/com/ledga/app/worker/BackupWorker.kt | 26 + .../ledga/app/worker/NotificationHelper.kt | 67 ++ .../ledga/app/worker/NotificationScheduler.kt | 62 ++ .../ledga/app/worker/NotificationWorker.kt | 84 ++ .../com/ledga/app/worker/TransactionAlerts.kt | 74 ++ .../com/ledga/app/worker/UpdateCheckWorker.kt | 120 +++ .../res/drawable/ic_launcher_background.xml | 10 + .../res/drawable/ic_launcher_foreground.xml | 12 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + app/src/main/res/values/strings.xml | 4 + app/src/main/res/values/themes.xml | 13 + app/src/main/res/xml/file_paths.xml | 4 + .../ledga/app/parser/MpesaSmsParserTest.kt | 310 ++++++++ build.gradle.kts | 12 + gradle.properties | 6 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 46175 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 180 +++++ gradlew.bat | 67 ++ settings.gradle.kts | 18 + 81 files changed, 6657 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 PROJECT_PLAN.md create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/com/ledga/app/LedgaApp.kt create mode 100644 app/src/main/java/com/ledga/app/MainActivity.kt create mode 100644 app/src/main/java/com/ledga/app/data/db/AppDatabase.kt create mode 100644 app/src/main/java/com/ledga/app/data/db/Converters.kt create mode 100644 app/src/main/java/com/ledga/app/data/db/DefaultData.kt create mode 100644 app/src/main/java/com/ledga/app/data/db/Migrations.kt create mode 100644 app/src/main/java/com/ledga/app/data/db/dao/BudgetDao.kt create mode 100644 app/src/main/java/com/ledga/app/data/db/dao/CategoryDao.kt create mode 100644 app/src/main/java/com/ledga/app/data/db/dao/CategoryRuleDao.kt create mode 100644 app/src/main/java/com/ledga/app/data/db/dao/TransactionDao.kt create mode 100644 app/src/main/java/com/ledga/app/data/db/entity/Budget.kt create mode 100644 app/src/main/java/com/ledga/app/data/db/entity/Category.kt create mode 100644 app/src/main/java/com/ledga/app/data/db/entity/CategoryRule.kt create mode 100644 app/src/main/java/com/ledga/app/data/db/entity/TransactionEntity.kt create mode 100644 app/src/main/java/com/ledga/app/data/db/entity/TransactionWithCategory.kt create mode 100644 app/src/main/java/com/ledga/app/data/parser/MpesaSmsParser.kt create mode 100644 app/src/main/java/com/ledga/app/data/repository/BackupRepository.kt create mode 100644 app/src/main/java/com/ledga/app/data/repository/CategoryRepository.kt create mode 100644 app/src/main/java/com/ledga/app/data/repository/ExportImportRepository.kt create mode 100644 app/src/main/java/com/ledga/app/data/repository/SettingsRepository.kt create mode 100644 app/src/main/java/com/ledga/app/data/repository/SmsImporter.kt create mode 100644 app/src/main/java/com/ledga/app/data/repository/TransactionRepository.kt create mode 100644 app/src/main/java/com/ledga/app/di/AppModule.kt create mode 100644 app/src/main/java/com/ledga/app/di/DatabaseModule.kt create mode 100644 app/src/main/java/com/ledga/app/receiver/SmsReceiver.kt create mode 100644 app/src/main/java/com/ledga/app/ui/components/BudgetProgressBar.kt create mode 100644 app/src/main/java/com/ledga/app/ui/components/CategoryChip.kt create mode 100644 app/src/main/java/com/ledga/app/ui/components/DonutChart.kt create mode 100644 app/src/main/java/com/ledga/app/ui/components/PeriodSelector.kt create mode 100644 app/src/main/java/com/ledga/app/ui/components/StatCard.kt create mode 100644 app/src/main/java/com/ledga/app/ui/components/TransactionCard.kt create mode 100644 app/src/main/java/com/ledga/app/ui/home/HomeScreen.kt create mode 100644 app/src/main/java/com/ledga/app/ui/home/HomeViewModel.kt create mode 100644 app/src/main/java/com/ledga/app/ui/navigation/AppNavigation.kt create mode 100644 app/src/main/java/com/ledga/app/ui/navigation/BottomNavBar.kt create mode 100644 app/src/main/java/com/ledga/app/ui/navigation/Routes.kt create mode 100644 app/src/main/java/com/ledga/app/ui/onboarding/OnboardingScreen.kt create mode 100644 app/src/main/java/com/ledga/app/ui/onboarding/OnboardingViewModel.kt create mode 100644 app/src/main/java/com/ledga/app/ui/settings/BudgetScreen.kt create mode 100644 app/src/main/java/com/ledga/app/ui/settings/BudgetViewModel.kt create mode 100644 app/src/main/java/com/ledga/app/ui/settings/SettingsScreen.kt create mode 100644 app/src/main/java/com/ledga/app/ui/settings/SettingsViewModel.kt create mode 100644 app/src/main/java/com/ledga/app/ui/settings/UnparsedSmsScreen.kt create mode 100644 app/src/main/java/com/ledga/app/ui/settings/UnparsedSmsViewModel.kt create mode 100644 app/src/main/java/com/ledga/app/ui/theme/Color.kt create mode 100644 app/src/main/java/com/ledga/app/ui/theme/Theme.kt create mode 100644 app/src/main/java/com/ledga/app/ui/theme/Type.kt create mode 100644 app/src/main/java/com/ledga/app/ui/transactions/TransactionDetailSheet.kt create mode 100644 app/src/main/java/com/ledga/app/ui/transactions/TransactionsScreen.kt create mode 100644 app/src/main/java/com/ledga/app/ui/transactions/TransactionsViewModel.kt create mode 100644 app/src/main/java/com/ledga/app/ui/trends/TrendsScreen.kt create mode 100644 app/src/main/java/com/ledga/app/ui/trends/TrendsViewModel.kt create mode 100644 app/src/main/java/com/ledga/app/util/CurrencyFormatter.kt create mode 100644 app/src/main/java/com/ledga/app/util/DateUtils.kt create mode 100644 app/src/main/java/com/ledga/app/worker/BackupWorker.kt create mode 100644 app/src/main/java/com/ledga/app/worker/NotificationHelper.kt create mode 100644 app/src/main/java/com/ledga/app/worker/NotificationScheduler.kt create mode 100644 app/src/main/java/com/ledga/app/worker/NotificationWorker.kt create mode 100644 app/src/main/java/com/ledga/app/worker/TransactionAlerts.kt create mode 100644 app/src/main/java/com/ledga/app/worker/UpdateCheckWorker.kt create mode 100644 app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/file_paths.xml create mode 100644 app/src/test/java/com/ledga/app/parser/MpesaSmsParserTest.kt create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2c3e564 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f0f2dd9 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md new file mode 100644 index 0000000..bc2c901 --- /dev/null +++ b/PROJECT_PLAN.md @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..82e8aca --- /dev/null +++ b/app/build.gradle.kts @@ -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") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..e059141 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,3 @@ +# Ledga ProGuard Rules +# Keep Room entities +-keep class com.ledga.app.data.db.entity.** { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c5dc2fd --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/ledga/app/LedgaApp.kt b/app/src/main/java/com/ledga/app/LedgaApp.kt new file mode 100644 index 0000000..9d40527 --- /dev/null +++ b/app/src/main/java/com/ledga/app/LedgaApp.kt @@ -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) + } +} diff --git a/app/src/main/java/com/ledga/app/MainActivity.kt b/app/src/main/java/com/ledga/app/MainActivity.kt new file mode 100644 index 0000000..21afd2b --- /dev/null +++ b/app/src/main/java/com/ledga/app/MainActivity.kt @@ -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 = settingsRepository.getThemeMode() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ThemeMode.SYSTEM) + + val hasCompletedOnboarding: StateFlow = settingsRepository.hasCompletedOnboarding() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true) + + val fontScale: StateFlow = 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) + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/ledga/app/data/db/AppDatabase.kt b/app/src/main/java/com/ledga/app/data/db/AppDatabase.kt new file mode 100644 index 0000000..ff40e19 --- /dev/null +++ b/app/src/main/java/com/ledga/app/data/db/AppDatabase.kt @@ -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 +} diff --git a/app/src/main/java/com/ledga/app/data/db/Converters.kt b/app/src/main/java/com/ledga/app/data/db/Converters.kt new file mode 100644 index 0000000..7783aed --- /dev/null +++ b/app/src/main/java/com/ledga/app/data/db/Converters.kt @@ -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) +} diff --git a/app/src/main/java/com/ledga/app/data/db/DefaultData.kt b/app/src/main/java/com/ledga/app/data/db/DefaultData.kt new file mode 100644 index 0000000..087bc6b --- /dev/null +++ b/app/src/main/java/com/ledga/app/data/db/DefaultData.kt @@ -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"), + ) +} diff --git a/app/src/main/java/com/ledga/app/data/db/Migrations.kt b/app/src/main/java/com/ledga/app/data/db/Migrations.kt new file mode 100644 index 0000000..a7442c7 --- /dev/null +++ b/app/src/main/java/com/ledga/app/data/db/Migrations.kt @@ -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 ...") + // } + // } +} diff --git a/app/src/main/java/com/ledga/app/data/db/dao/BudgetDao.kt b/app/src/main/java/com/ledga/app/data/db/dao/BudgetDao.kt new file mode 100644 index 0000000..f9178e0 --- /dev/null +++ b/app/src/main/java/com/ledga/app/data/db/dao/BudgetDao.kt @@ -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> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(budget: Budget): Long +} diff --git a/app/src/main/java/com/ledga/app/data/db/dao/CategoryDao.kt b/app/src/main/java/com/ledga/app/data/db/dao/CategoryDao.kt new file mode 100644 index 0000000..b9b9943 --- /dev/null +++ b/app/src/main/java/com/ledga/app/data/db/dao/CategoryDao.kt @@ -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> + + @Query("SELECT * FROM categories WHERE id = :id") + suspend fun getCategoryById(id: Long): Category? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(categories: List) +} diff --git a/app/src/main/java/com/ledga/app/data/db/dao/CategoryRuleDao.kt b/app/src/main/java/com/ledga/app/data/db/dao/CategoryRuleDao.kt new file mode 100644 index 0000000..0fc895e --- /dev/null +++ b/app/src/main/java/com/ledga/app/data/db/dao/CategoryRuleDao.kt @@ -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> + + @Query("SELECT * FROM category_rules WHERE categoryId = :categoryId") + fun getRulesForCategory(categoryId: Long): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(rules: List) + + @Query("SELECT * FROM category_rules") + suspend fun getAllRulesSync(): List +} diff --git a/app/src/main/java/com/ledga/app/data/db/dao/TransactionDao.kt b/app/src/main/java/com/ledga/app/data/db/dao/TransactionDao.kt new file mode 100644 index 0000000..d268c1a --- /dev/null +++ b/app/src/main/java/com/ledga/app/data/db/dao/TransactionDao.kt @@ -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> + + @Transaction + @Query("SELECT * FROM transactions WHERE timestamp BETWEEN :startTime AND :endTime ORDER BY timestamp DESC") + fun getTransactions(startTime: Long, endTime: Long): Flow> + + @Query("SELECT * FROM transactions ORDER BY timestamp DESC LIMIT 1") + fun getLatestTransaction(): Flow + + @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> + + @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 + + @Query("SELECT COALESCE(SUM(transactionCost), 0.0) FROM transactions WHERE timestamp BETWEEN :startTime AND :endTime") + fun getTotalFees(startTime: Long, endTime: Long): Flow + + @Transaction + @Query("SELECT * FROM transactions WHERE recipientName LIKE '%' || :query || '%' ORDER BY timestamp DESC") + fun searchTransactions(query: String): Flow> + + @Transaction + @Query("SELECT * FROM transactions WHERE type IN (:types) ORDER BY timestamp DESC") + fun getTransactionsByType(types: List): Flow> + + @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> + + // 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> + + // 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> + + @Query("SELECT COUNT(*) FROM transactions WHERE type = 'UNKNOWN'") + fun getUnparsedCount(): Flow +} diff --git a/app/src/main/java/com/ledga/app/data/db/entity/Budget.kt b/app/src/main/java/com/ledga/app/data/db/entity/Budget.kt new file mode 100644 index 0000000..f84b890 --- /dev/null +++ b/app/src/main/java/com/ledga/app/data/db/entity/Budget.kt @@ -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 +) diff --git a/app/src/main/java/com/ledga/app/data/db/entity/Category.kt b/app/src/main/java/com/ledga/app/data/db/entity/Category.kt new file mode 100644 index 0000000..3203b07 --- /dev/null +++ b/app/src/main/java/com/ledga/app/data/db/entity/Category.kt @@ -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 +) diff --git a/app/src/main/java/com/ledga/app/data/db/entity/CategoryRule.kt b/app/src/main/java/com/ledga/app/data/db/entity/CategoryRule.kt new file mode 100644 index 0000000..cc63d34 --- /dev/null +++ b/app/src/main/java/com/ledga/app/data/db/entity/CategoryRule.kt @@ -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 +) diff --git a/app/src/main/java/com/ledga/app/data/db/entity/TransactionEntity.kt b/app/src/main/java/com/ledga/app/data/db/entity/TransactionEntity.kt new file mode 100644 index 0000000..9defdf9 --- /dev/null +++ b/app/src/main/java/com/ledga/app/data/db/entity/TransactionEntity.kt @@ -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() +) diff --git a/app/src/main/java/com/ledga/app/data/db/entity/TransactionWithCategory.kt b/app/src/main/java/com/ledga/app/data/db/entity/TransactionWithCategory.kt new file mode 100644 index 0000000..36e6ea4 --- /dev/null +++ b/app/src/main/java/com/ledga/app/data/db/entity/TransactionWithCategory.kt @@ -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? +) diff --git a/app/src/main/java/com/ledga/app/data/parser/MpesaSmsParser.kt b/app/src/main/java/com/ledga/app/data/parser/MpesaSmsParser.kt new file mode 100644 index 0000000..8982a88 --- /dev/null +++ b/app/src/main/java/com/ledga/app/data/parser/MpesaSmsParser.kt @@ -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 + } +} diff --git a/app/src/main/java/com/ledga/app/data/repository/BackupRepository.kt b/app/src/main/java/com/ledga/app/data/repository/BackupRepository.kt new file mode 100644 index 0000000..568b5c8 --- /dev/null +++ b/app/src/main/java/com/ledga/app/data/repository/BackupRepository.kt @@ -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 +) + +@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(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 + ) +} diff --git a/app/src/main/java/com/ledga/app/data/repository/CategoryRepository.kt b/app/src/main/java/com/ledga/app/data/repository/CategoryRepository.kt new file mode 100644 index 0000000..ca6aa0e --- /dev/null +++ b/app/src/main/java/com/ledga/app/data/repository/CategoryRepository.kt @@ -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> = categoryDao.getAllCategories() + + fun getAllRules(): Flow> = categoryRuleDao.getAllRules() + + suspend fun getCategoryById(id: Long): Category? = categoryDao.getCategoryById(id) +} diff --git a/app/src/main/java/com/ledga/app/data/repository/ExportImportRepository.kt b/app/src/main/java/com/ledga/app/data/repository/ExportImportRepository.kt new file mode 100644 index 0000000..60bbafd --- /dev/null +++ b/app/src/main/java/com/ledga/app/data/repository/ExportImportRepository.kt @@ -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 +) + +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(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 + } +} diff --git a/app/src/main/java/com/ledga/app/data/repository/SettingsRepository.kt b/app/src/main/java/com/ledga/app/data/repository/SettingsRepository.kt new file mode 100644 index 0000000..b564403 --- /dev/null +++ b/app/src/main/java/com/ledga/app/data/repository/SettingsRepository.kt @@ -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 +) { + 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 = 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 = dataStore.data.map { prefs -> + prefs[ONBOARDING_COMPLETED] ?: false + } + + suspend fun setOnboardingCompleted() { + dataStore.edit { it[ONBOARDING_COMPLETED] = true } + } + + // --- Notification settings --- + + fun getDailySummaryEnabled(): Flow = dataStore.data.map { it[DAILY_SUMMARY] ?: true } + suspend fun setDailySummaryEnabled(enabled: Boolean) { dataStore.edit { it[DAILY_SUMMARY] = enabled } } + + fun getDailySummaryHour(): Flow = 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 = dataStore.data.map { it[WEEKLY_SUMMARY] ?: true } + suspend fun setWeeklySummaryEnabled(enabled: Boolean) { dataStore.edit { it[WEEKLY_SUMMARY] = enabled } } + + fun getBudgetAlertsEnabled(): Flow = dataStore.data.map { it[BUDGET_ALERTS] ?: true } + suspend fun setBudgetAlertsEnabled(enabled: Boolean) { dataStore.edit { it[BUDGET_ALERTS] = enabled } } + + fun getLargeTransactionAlertEnabled(): Flow = dataStore.data.map { it[LARGE_TXN_ALERT] ?: false } + suspend fun setLargeTransactionAlertEnabled(enabled: Boolean) { dataStore.edit { it[LARGE_TXN_ALERT] = enabled } } + + fun getLargeTransactionThreshold(): Flow = 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 = 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) +} diff --git a/app/src/main/java/com/ledga/app/data/repository/SmsImporter.kt b/app/src/main/java/com/ledga/app/data/repository/SmsImporter.kt new file mode 100644 index 0000000..64364b3 --- /dev/null +++ b/app/src/main/java/com/ledga/app/data/repository/SmsImporter.kt @@ -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 { + val messages = mutableListOf() + 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) +} diff --git a/app/src/main/java/com/ledga/app/data/repository/TransactionRepository.kt b/app/src/main/java/com/ledga/app/data/repository/TransactionRepository.kt new file mode 100644 index 0000000..efa5fd2 --- /dev/null +++ b/app/src/main/java/com/ledga/app/data/repository/TransactionRepository.kt @@ -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> = + transactionDao.getRecentTransactions(limit) + + fun getTransactions(startTime: Long, endTime: Long): Flow> = + transactionDao.getTransactions(startTime, endTime) + + fun getLatestTransaction(): Flow = + transactionDao.getLatestTransaction() + + fun getSpendingByCategory(startTime: Long, endTime: Long): Flow> = + transactionDao.getSpendingByCategory(startTime, endTime) + + fun getTotalSpending(startTime: Long, endTime: Long): Flow = + transactionDao.getTotalSpending(startTime, endTime) + + fun getTotalFees(startTime: Long, endTime: Long): Flow = + transactionDao.getTotalFees(startTime, endTime) + + fun searchTransactions(query: String): Flow> = + transactionDao.searchTransactions(query) + + fun getTransactionsByType(types: List): Flow> = + transactionDao.getTransactionsByType(types) + + fun getDailySpending(startTime: Long, endTime: Long): Flow> = + transactionDao.getDailySpending(startTime, endTime) + + fun getTopMerchants(startTime: Long, endTime: Long, limit: Int = 5): Flow> = + transactionDao.getTopMerchants(startTime, endTime, limit) + + fun getUnparsedTransactions(): Flow> = + transactionDao.getUnparsedTransactions() + + fun getUnparsedCount(): Flow = + 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" + } +} diff --git a/app/src/main/java/com/ledga/app/di/AppModule.kt b/app/src/main/java/com/ledga/app/di/AppModule.kt new file mode 100644 index 0000000..36abe07 --- /dev/null +++ b/app/src/main/java/com/ledga/app/di/AppModule.kt @@ -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 by preferencesDataStore(name = "ledga_settings") + +@Module +@InstallIn(SingletonComponent::class) +object AppModule { + + @Provides + @Singleton + fun provideDataStore(@ApplicationContext context: Context): DataStore { + return context.dataStore + } + + @Provides + fun provideContentResolver(@ApplicationContext context: Context): ContentResolver { + return context.contentResolver + } +} diff --git a/app/src/main/java/com/ledga/app/di/DatabaseModule.kt b/app/src/main/java/com/ledga/app/di/DatabaseModule.kt new file mode 100644 index 0000000..d77b6ae --- /dev/null +++ b/app/src/main/java/com/ledga/app/di/DatabaseModule.kt @@ -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() +} diff --git a/app/src/main/java/com/ledga/app/receiver/SmsReceiver.kt b/app/src/main/java/com/ledga/app/receiver/SmsReceiver.kt new file mode 100644 index 0000000..22b12cc --- /dev/null +++ b/app/src/main/java/com/ledga/app/receiver/SmsReceiver.kt @@ -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() + } + } + } +} diff --git a/app/src/main/java/com/ledga/app/ui/components/BudgetProgressBar.kt b/app/src/main/java/com/ledga/app/ui/components/BudgetProgressBar.kt new file mode 100644 index 0000000..7aed3e6 --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/components/BudgetProgressBar.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/com/ledga/app/ui/components/CategoryChip.kt b/app/src/main/java/com/ledga/app/ui/components/CategoryChip.kt new file mode 100644 index 0000000..6b5faa9 --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/components/CategoryChip.kt @@ -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) } + ) +} diff --git a/app/src/main/java/com/ledga/app/ui/components/DonutChart.kt b/app/src/main/java/com/ledga/app/ui/components/DonutChart.kt new file mode 100644 index 0000000..18d13a8 --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/components/DonutChart.kt @@ -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, + 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 + ) + } + } +} diff --git a/app/src/main/java/com/ledga/app/ui/components/PeriodSelector.kt b/app/src/main/java/com/ledga/app/ui/components/PeriodSelector.kt new file mode 100644 index 0000000..f546bbb --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/components/PeriodSelector.kt @@ -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) } + ) + } + } +} diff --git a/app/src/main/java/com/ledga/app/ui/components/StatCard.kt b/app/src/main/java/com/ledga/app/ui/components/StatCard.kt new file mode 100644 index 0000000..ddef493 --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/components/StatCard.kt @@ -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 + ) + } + } + } +} diff --git a/app/src/main/java/com/ledga/app/ui/components/TransactionCard.kt b/app/src/main/java/com/ledga/app/ui/components/TransactionCard.kt new file mode 100644 index 0000000..cb1a471 --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/components/TransactionCard.kt @@ -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 + } +} diff --git a/app/src/main/java/com/ledga/app/ui/home/HomeScreen.kt b/app/src/main/java/com/ledga/app/ui/home/HomeScreen.kt new file mode 100644 index 0000000..eb6b971 --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/home/HomeScreen.kt @@ -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)) } + } +} diff --git a/app/src/main/java/com/ledga/app/ui/home/HomeViewModel.kt b/app/src/main/java/com/ledga/app/ui/home/HomeViewModel.kt new file mode 100644 index 0000000..7f3c7ec --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/home/HomeViewModel.kt @@ -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 = emptyList(), + val recentTransactions: List = 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(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 = 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, + categories: List + ): List { + 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 + ) + } + } +} diff --git a/app/src/main/java/com/ledga/app/ui/navigation/AppNavigation.kt b/app/src/main/java/com/ledga/app/ui/navigation/AppNavigation.kt new file mode 100644 index 0000000..58adbe3 --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/navigation/AppNavigation.kt @@ -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 { + OnboardingScreen( + onComplete = { + navController.navigate(HomeRoute) { + popUpTo(OnboardingRoute) { inclusive = true } + } + } + ) + } + composable { + HomeScreen() + } + composable { + TransactionsScreen() + } + composable { + TrendsScreen() + } + composable { + SettingsScreen( + onNavigateToUnparsed = { navController.navigate(UnparsedSmsRoute) }, + onNavigateToBudgets = { navController.navigate(BudgetRoute) } + ) + } + composable { + UnparsedSmsScreen(onBack = { navController.popBackStack() }) + } + composable { + BudgetScreen(onBack = { navController.popBackStack() }) + } + } +} diff --git a/app/src/main/java/com/ledga/app/ui/navigation/BottomNavBar.kt b/app/src/main/java/com/ledga/app/ui/navigation/BottomNavBar.kt new file mode 100644 index 0000000..39b4f09 --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/navigation/BottomNavBar.kt @@ -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( + 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 + } + } + ) + } + } +} diff --git a/app/src/main/java/com/ledga/app/ui/navigation/Routes.kt b/app/src/main/java/com/ledga/app/ui/navigation/Routes.kt new file mode 100644 index 0000000..a486487 --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/navigation/Routes.kt @@ -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 diff --git a/app/src/main/java/com/ledga/app/ui/onboarding/OnboardingScreen.kt b/app/src/main/java/com/ledga/app/ui/onboarding/OnboardingScreen.kt new file mode 100644 index 0000000..4828c5e --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/onboarding/OnboardingScreen.kt @@ -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") + } + } +} diff --git a/app/src/main/java/com/ledga/app/ui/onboarding/OnboardingViewModel.kt b/app/src/main/java/com/ledga/app/ui/onboarding/OnboardingViewModel.kt new file mode 100644 index 0000000..ce7efc9 --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/onboarding/OnboardingViewModel.kt @@ -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 = _currentStep + + private val _importProgress = MutableStateFlow(ImportProgress()) + val importProgress: StateFlow = _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() + } + } +} diff --git a/app/src/main/java/com/ledga/app/ui/settings/BudgetScreen.kt b/app/src/main/java/com/ledga/app/ui/settings/BudgetScreen.kt new file mode 100644 index 0000000..4868cd5 --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/settings/BudgetScreen.kt @@ -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)) } + } + } +} diff --git a/app/src/main/java/com/ledga/app/ui/settings/BudgetViewModel.kt b/app/src/main/java/com/ledga/app/ui/settings/BudgetViewModel.kt new file mode 100644 index 0000000..2de9fb9 --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/settings/BudgetViewModel.kt @@ -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 = 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 = 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)) + } + } +} diff --git a/app/src/main/java/com/ledga/app/ui/settings/SettingsScreen.kt b/app/src/main/java/com/ledga/app/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000..92dd4d1 --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/settings/SettingsScreen.kt @@ -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) + } +} diff --git a/app/src/main/java/com/ledga/app/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/ledga/app/ui/settings/SettingsViewModel.kt new file mode 100644 index 0000000..7dd86a2 --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/settings/SettingsViewModel.kt @@ -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 = settingsRepository.getThemeMode() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ThemeMode.SYSTEM) + + val unparsedCount: StateFlow = transactionRepository.getUnparsedCount() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0) + + val fontScale: StateFlow = settingsRepository.getFontScale() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), FontScale.SYSTEM) + + val dailySummaryEnabled: StateFlow = settingsRepository.getDailySummaryEnabled() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true) + + val weeklySummaryEnabled: StateFlow = settingsRepository.getWeeklySummaryEnabled() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true) + + val budgetAlertsEnabled: StateFlow = settingsRepository.getBudgetAlertsEnabled() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true) + + val largeTxnAlertEnabled: StateFlow = settingsRepository.getLargeTransactionAlertEnabled() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + + val largeTxnThreshold: StateFlow = settingsRepository.getLargeTransactionThreshold() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 5000.0) + + private val _importStatus = MutableStateFlow(null) + val importStatus: StateFlow = _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(null) + val exportResult: StateFlow = _exportResult + + private val _fileImportResult = MutableStateFlow(null) + val fileImportResult: StateFlow = _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(null) + val backupStatus: StateFlow = _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" + } + } +} diff --git a/app/src/main/java/com/ledga/app/ui/settings/UnparsedSmsScreen.kt b/app/src/main/java/com/ledga/app/ui/settings/UnparsedSmsScreen.kt new file mode 100644 index 0000000..934ddf6 --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/settings/UnparsedSmsScreen.kt @@ -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)) } + } + } +} diff --git a/app/src/main/java/com/ledga/app/ui/settings/UnparsedSmsViewModel.kt b/app/src/main/java/com/ledga/app/ui/settings/UnparsedSmsViewModel.kt new file mode 100644 index 0000000..19aa773 --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/settings/UnparsedSmsViewModel.kt @@ -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> = + transactionRepository.getUnparsedTransactions() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) +} diff --git a/app/src/main/java/com/ledga/app/ui/theme/Color.kt b/app/src/main/java/com/ledga/app/ui/theme/Color.kt new file mode 100644 index 0000000..3973483 --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/theme/Color.kt @@ -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) diff --git a/app/src/main/java/com/ledga/app/ui/theme/Theme.kt b/app/src/main/java/com/ledga/app/ui/theme/Theme.kt new file mode 100644 index 0000000..beb771a --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/theme/Theme.kt @@ -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 + ) +} diff --git a/app/src/main/java/com/ledga/app/ui/theme/Type.kt b/app/src/main/java/com/ledga/app/ui/theme/Type.kt new file mode 100644 index 0000000..726561f --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/theme/Type.kt @@ -0,0 +1,5 @@ +package com.ledga.app.ui.theme + +import androidx.compose.material3.Typography + +val LedgaTypography = Typography() diff --git a/app/src/main/java/com/ledga/app/ui/transactions/TransactionDetailSheet.kt b/app/src/main/java/com/ledga/app/ui/transactions/TransactionDetailSheet.kt new file mode 100644 index 0000000..a16921d --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/transactions/TransactionDetailSheet.kt @@ -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, + 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 + ) + } +} diff --git a/app/src/main/java/com/ledga/app/ui/transactions/TransactionsScreen.kt b/app/src/main/java/com/ledga/app/ui/transactions/TransactionsScreen.kt new file mode 100644 index 0000000..ee18af3 --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/transactions/TransactionsScreen.kt @@ -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)) } + } + } +} diff --git a/app/src/main/java/com/ledga/app/ui/transactions/TransactionsViewModel.kt b/app/src/main/java/com/ledga/app/ui/transactions/TransactionsViewModel.kt new file mode 100644 index 0000000..f878150 --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/transactions/TransactionsViewModel.kt @@ -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> = emptyMap(), + val searchQuery: String = "", + val activeFilter: String = "All", + val categories: List = 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(), + "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(null) + + val uiState: StateFlow = 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 + } + } +} diff --git a/app/src/main/java/com/ledga/app/ui/trends/TrendsScreen.kt b/app/src/main/java/com/ledga/app/ui/trends/TrendsScreen.kt new file mode 100644 index 0000000..018184e --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/trends/TrendsScreen.kt @@ -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, + 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, + ) +} diff --git a/app/src/main/java/com/ledga/app/ui/trends/TrendsViewModel.kt b/app/src/main/java/com/ledga/app/ui/trends/TrendsViewModel.kt new file mode 100644 index 0000000..43d7a08 --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/trends/TrendsViewModel.kt @@ -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 = emptyList(), + val totalSpending: Double = 0.0, + val totalFees: Double = 0.0, + val topMerchants: List = emptyList(), + val categorySpending: List = 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 = 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 + } +} diff --git a/app/src/main/java/com/ledga/app/util/CurrencyFormatter.kt b/app/src/main/java/com/ledga/app/util/CurrencyFormatter.kt new file mode 100644 index 0000000..2e4a449 --- /dev/null +++ b/app/src/main/java/com/ledga/app/util/CurrencyFormatter.kt @@ -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) + } + } +} diff --git a/app/src/main/java/com/ledga/app/util/DateUtils.kt b/app/src/main/java/com/ledga/app/util/DateUtils.kt new file mode 100644 index 0000000..72d4423 --- /dev/null +++ b/app/src/main/java/com/ledga/app/util/DateUtils.kt @@ -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) + } +} diff --git a/app/src/main/java/com/ledga/app/worker/BackupWorker.kt b/app/src/main/java/com/ledga/app/worker/BackupWorker.kt new file mode 100644 index 0000000..90e905d --- /dev/null +++ b/app/src/main/java/com/ledga/app/worker/BackupWorker.kt @@ -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() + } + } +} diff --git a/app/src/main/java/com/ledga/app/worker/NotificationHelper.kt b/app/src/main/java/com/ledga/app/worker/NotificationHelper.kt new file mode 100644 index 0000000..6b77d53 --- /dev/null +++ b/app/src/main/java/com/ledga/app/worker/NotificationHelper.kt @@ -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 + } + } +} diff --git a/app/src/main/java/com/ledga/app/worker/NotificationScheduler.kt b/app/src/main/java/com/ledga/app/worker/NotificationScheduler.kt new file mode 100644 index 0000000..2bb5099 --- /dev/null +++ b/app/src/main/java/com/ledga/app/worker/NotificationScheduler.kt @@ -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(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(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") + } +} diff --git a/app/src/main/java/com/ledga/app/worker/NotificationWorker.kt b/app/src/main/java/com/ledga/app/worker/NotificationWorker.kt new file mode 100644 index 0000000..0207572 --- /dev/null +++ b/app/src/main/java/com/ledga/app/worker/NotificationWorker.kt @@ -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() + } +} diff --git a/app/src/main/java/com/ledga/app/worker/TransactionAlerts.kt b/app/src/main/java/com/ledga/app/worker/TransactionAlerts.kt new file mode 100644 index 0000000..db72b86 --- /dev/null +++ b/app/src/main/java/com/ledga/app/worker/TransactionAlerts.kt @@ -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)})." + ) + } + } + } +} diff --git a/app/src/main/java/com/ledga/app/worker/UpdateCheckWorker.kt b/app/src/main/java/com/ledga/app/worker/UpdateCheckWorker.kt new file mode 100644 index 0000000..7806d36 --- /dev/null +++ b/app/src/main/java/com/ledga/app/worker/UpdateCheckWorker.kt @@ -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 = 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(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 + } +} diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..5cc0508 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..b514012 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6b78462 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..6b78462 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..656fa24 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Ledga + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..ca4e882 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..c9cc257 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/test/java/com/ledga/app/parser/MpesaSmsParserTest.kt b/app/src/test/java/com/ledga/app/parser/MpesaSmsParserTest.kt new file mode 100644 index 0000000..d4c9b16 --- /dev/null +++ b/app/src/test/java/com/ledga/app/parser/MpesaSmsParserTest.kt @@ -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) + } +} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..010e17f --- /dev/null +++ b/build.gradle.kts @@ -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) +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..6e13f34 --- /dev/null +++ b/gradle.properties @@ -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 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..61285a659d17295f1de7c53e24fdf13ad755c379 GIT binary patch literal 46175 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

pVX@HRij*jw$g4?}Pn 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 literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..37f853b --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..632453c --- /dev/null +++ b/gradlew @@ -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" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..911f07f --- /dev/null +++ b/gradlew.bat @@ -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 diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..ba76178 --- /dev/null +++ b/settings.gradle.kts @@ -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")