From 51264301a87a8126009e373ca85171248cf9a19d Mon Sep 17 00:00:00 2001 From: ngenohkevin Date: Sat, 16 May 2026 13:16:22 +0300 Subject: [PATCH] =?UTF-8?q?v1.3.0:=20major=20redesign=20=E2=80=94=20bento?= =?UTF-8?q?=20UI,=20Insights,=20Goals,=20multi-SIM,=20in-app=20updates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI overhaul - Modern fintech bento design system (LedgaText, Space, Radius, Motion). - Inter font via Google Fonts. - New PillTabBar + LedgaTopBar; 4-tab IA: Home / Activity / Insights / You. - Home rewritten as bento cards; new AccountChip leading slot. - Activity wraps Transactions + Trends behind a segmented control. - Trends gains a GitHub-style heatmap calendar. - You consolidates Settings into grouped bento cards. Insights (Phase C) - Rule engine + 6 rules: anomaly (z-score), recurring detection, fee tips, Fuliza outstanding, Fuliza auto-pay pairing, positive nudges. - Daily InsightWorker (3 AM); generates on screen entry for first-time users. - Natural-key dedup so dismiss/snooze state survives re-runs. - Home teaser surfaces the top active insight. Goals (Phase D) - New Goal entity + GoalContribution table. - 3 contribution rules: AllSavingsDeposits, ToRecipient, Manual. - Rule-based contributions compute live via SQL aggregation (no duplicate rows). Manual rule uses goal_contributions table. - GoalsScreen with progress rings + status badges; AddGoalSheet; GoalDetailScreen with contributing-transactions list. - "Add to goal" entry in TransactionDetailSheet for Manual goals. Multi-SIM (Phase E) - MpesaAccount entity captured per-subscription on SMS receive (SubscriptionManager.EXTRA_SUBSCRIPTION_INDEX with legacy fallback). - READ_PHONE_STATE permission (optional; falls back to "Line N" labels). - Every screen-facing query is account-scoped via TransactionRepository composing with the selected-account flow. - AccountSwitcherSheet on Home; AccountsScreen with rename / recolor / set-primary; BackfillScreen with 3-prong attribution (SMS-inbox auto, date range, per-transaction manual via TransactionDetailSheet). In-app updates (Phase F + G) - Chunked APK download with progress reporting. - UpdateRepository state machine (Idle → Downloading → Ready → Failed). - Full-screen UpdateScreen with parsed What's new / Fixes sections. - "Remind me later" persists dismissed version. - Background UpdateCheckWorker pre-fetches APK on unmetered network. - Home banner: "available" vs "ready to install" copy variants. - Proactive REQUEST_INSTALL_PACKAGES permission prompt with settings deep-link before firing the installer intent. Parser quality - Hustler Fund "sent KshX to NAME" amount-first phrasing now matches. - "Give cash to AGENT" agent-deposit format recognized as DEPOSIT. - Fuliza auto-pay recipient labels rewritten as "Fuliza overdraft (auto-cleared / auto-partial)" for clearer transaction lists. - 8 new real-world regression fixtures from sanitized export. - Audit: 0 UNKNOWN against 6,565-transaction real history (was 838). Database - v3 schema: mpesa_accounts, goals, goal_contributions, insights tables. - transactions gains accountId (FK), note. - Explicit MIGRATION_1_2 + MIGRATION_2_3. Tests - 65 unit tests across parser (33), rule engine (14), goal rules (8), update logic (8), parser audit (1), insights dry-run (1). - Two test suites (audit, dry-run) read the local export when present, skip cleanly in CI. Design spec - LEDGA_REDESIGN.md captures the full direction + tokens + per-screen wireframes + phased PR plan. versionCode 3, versionName 1.3.0. --- LEDGA_REDESIGN.md | 943 ++++++++++++++++++ PROJECT_PLAN.md | 26 +- app/build.gradle.kts | 5 +- app/src/main/AndroidManifest.xml | 3 + app/src/main/java/com/ledga/app/LedgaApp.kt | 4 + .../main/java/com/ledga/app/MainActivity.kt | 56 +- .../java/com/ledga/app/data/db/AppDatabase.kt | 18 +- .../java/com/ledga/app/data/db/Converters.kt | 14 + .../java/com/ledga/app/data/db/Migrations.kt | 128 ++- .../java/com/ledga/app/data/db/dao/GoalDao.kt | 61 ++ .../com/ledga/app/data/db/dao/InsightDao.kt | 93 ++ .../ledga/app/data/db/dao/MpesaAccountDao.kt | 40 + .../ledga/app/data/db/dao/TransactionDao.kt | 170 +++- .../java/com/ledga/app/data/db/entity/Goal.kt | 84 ++ .../com/ledga/app/data/db/entity/Insight.kt | 83 ++ .../ledga/app/data/db/entity/MpesaAccount.kt | 30 + .../app/data/db/entity/TransactionEntity.kt | 12 +- .../ledga/app/data/insights/InsightEngine.kt | 89 ++ .../ledga/app/data/insights/InsightRule.kt | 38 + .../app/data/insights/rules/AnomalyRule.kt | 111 +++ .../app/data/insights/rules/FeeTipRule.kt | 82 ++ .../data/insights/rules/FulizaAutoPayRule.kt | 105 ++ .../app/data/insights/rules/FulizaRule.kt | 55 + .../data/insights/rules/PositiveNudgeRule.kt | 105 ++ .../app/data/insights/rules/RecurringRule.kt | 93 ++ .../ledga/app/data/parser/MpesaSmsParser.kt | 46 +- .../app/data/repository/AccountsRepository.kt | 109 ++ .../app/data/repository/GoalsRepository.kt | 203 ++++ .../HistoricalBackfillRepository.kt | 159 +++ .../app/data/repository/InsightsRepository.kt | 36 + .../app/data/repository/SettingsRepository.kt | 34 + .../data/repository/TransactionRepository.kt | 58 +- .../app/data/repository/UpdateRepository.kt | 138 +++ .../java/com/ledga/app/di/DatabaseModule.kt | 14 + .../java/com/ledga/app/di/InsightsModule.kt | 30 + .../com/ledga/app/receiver/SmsReceiver.kt | 46 +- .../ledga/app/ui/accounts/AccountsScreen.kt | 264 +++++ .../app/ui/accounts/AccountsViewModel.kt | 46 + .../ledga/app/ui/accounts/BackfillScreen.kt | 324 ++++++ .../app/ui/accounts/BackfillViewModel.kt | 90 ++ .../ledga/app/ui/activity/ActivityScreen.kt | 99 ++ .../app/ui/components/BudgetProgressBar.kt | 11 +- .../app/ui/components/TransactionCard.kt | 5 +- .../ui/components/v2/AccountSwitcherSheet.kt | 197 ++++ .../ledga/app/ui/components/v2/BentoCard.kt | 135 +++ .../com/ledga/app/ui/components/v2/Chip.kt | 71 ++ .../app/ui/components/v2/HeatmapCalendar.kt | 187 ++++ .../ledga/app/ui/components/v2/PillTabBar.kt | 135 +++ .../app/ui/components/v2/ProgressRing.kt | 85 ++ .../ledga/app/ui/components/v2/SearchField.kt | 95 ++ .../ledga/app/ui/components/v2/StatCard.kt | 121 +++ .../com/ledga/app/ui/components/v2/TopBar.kt | 176 ++++ .../app/ui/components/v2/TransactionRow.kt | 86 ++ .../com/ledga/app/ui/goals/AddGoalSheet.kt | 255 +++++ .../ledga/app/ui/goals/GoalDetailScreen.kt | 195 ++++ .../ledga/app/ui/goals/GoalDetailViewModel.kt | 63 ++ .../com/ledga/app/ui/goals/GoalsScreen.kt | 212 ++++ .../com/ledga/app/ui/goals/GoalsViewModel.kt | 55 + .../java/com/ledga/app/ui/home/HomeScreen.kt | 592 ++++++----- .../com/ledga/app/ui/home/HomeViewModel.kt | 42 +- .../ledga/app/ui/insights/InsightsScreen.kt | 240 +++++ .../app/ui/insights/InsightsViewModel.kt | 43 + .../ledga/app/ui/navigation/AppNavigation.kt | 69 +- .../ledga/app/ui/navigation/BottomNavBar.kt | 71 +- .../com/ledga/app/ui/navigation/Routes.kt | 20 +- .../main/java/com/ledga/app/ui/theme/Color.kt | 85 +- .../java/com/ledga/app/ui/theme/Dimens.kt | 55 + .../main/java/com/ledga/app/ui/theme/Theme.kt | 96 +- .../main/java/com/ledga/app/ui/theme/Type.kt | 122 ++- .../ui/transactions/TransactionDetailSheet.kt | 67 +- .../app/ui/transactions/TransactionsScreen.kt | 208 ++-- .../ui/transactions/TransactionsViewModel.kt | 59 +- .../com/ledga/app/ui/trends/TrendsScreen.kt | 318 +++--- .../com/ledga/app/ui/update/UpdateScreen.kt | 333 +++++++ .../ledga/app/ui/update/UpdateViewModel.kt | 95 ++ .../java/com/ledga/app/ui/you/YouScreen.kt | 371 +++++++ .../com/ledga/app/util/InstallPermission.kt | 38 + .../com/ledga/app/worker/InsightWorker.kt | 61 ++ .../com/ledga/app/worker/UpdateCheckWorker.kt | 114 ++- app/src/main/res/values/font_certs.xml | 63 ++ .../ledga/app/goals/ContributionRuleTest.kt | 78 ++ .../ledga/app/insights/InsightRulesTest.kt | 276 +++++ .../ledga/app/insights/InsightsDryRunTest.kt | 267 +++++ .../ledga/app/parser/MpesaSmsParserTest.kt | 80 ++ .../com/ledga/app/parser/ParserAuditTest.kt | 94 ++ .../com/ledga/app/update/UpdateLogicTest.kt | 96 ++ 86 files changed, 9613 insertions(+), 668 deletions(-) create mode 100644 LEDGA_REDESIGN.md create mode 100644 app/src/main/java/com/ledga/app/data/db/dao/GoalDao.kt create mode 100644 app/src/main/java/com/ledga/app/data/db/dao/InsightDao.kt create mode 100644 app/src/main/java/com/ledga/app/data/db/dao/MpesaAccountDao.kt create mode 100644 app/src/main/java/com/ledga/app/data/db/entity/Goal.kt create mode 100644 app/src/main/java/com/ledga/app/data/db/entity/Insight.kt create mode 100644 app/src/main/java/com/ledga/app/data/db/entity/MpesaAccount.kt create mode 100644 app/src/main/java/com/ledga/app/data/insights/InsightEngine.kt create mode 100644 app/src/main/java/com/ledga/app/data/insights/InsightRule.kt create mode 100644 app/src/main/java/com/ledga/app/data/insights/rules/AnomalyRule.kt create mode 100644 app/src/main/java/com/ledga/app/data/insights/rules/FeeTipRule.kt create mode 100644 app/src/main/java/com/ledga/app/data/insights/rules/FulizaAutoPayRule.kt create mode 100644 app/src/main/java/com/ledga/app/data/insights/rules/FulizaRule.kt create mode 100644 app/src/main/java/com/ledga/app/data/insights/rules/PositiveNudgeRule.kt create mode 100644 app/src/main/java/com/ledga/app/data/insights/rules/RecurringRule.kt create mode 100644 app/src/main/java/com/ledga/app/data/repository/AccountsRepository.kt create mode 100644 app/src/main/java/com/ledga/app/data/repository/GoalsRepository.kt create mode 100644 app/src/main/java/com/ledga/app/data/repository/HistoricalBackfillRepository.kt create mode 100644 app/src/main/java/com/ledga/app/data/repository/InsightsRepository.kt create mode 100644 app/src/main/java/com/ledga/app/data/repository/UpdateRepository.kt create mode 100644 app/src/main/java/com/ledga/app/di/InsightsModule.kt create mode 100644 app/src/main/java/com/ledga/app/ui/accounts/AccountsScreen.kt create mode 100644 app/src/main/java/com/ledga/app/ui/accounts/AccountsViewModel.kt create mode 100644 app/src/main/java/com/ledga/app/ui/accounts/BackfillScreen.kt create mode 100644 app/src/main/java/com/ledga/app/ui/accounts/BackfillViewModel.kt create mode 100644 app/src/main/java/com/ledga/app/ui/activity/ActivityScreen.kt create mode 100644 app/src/main/java/com/ledga/app/ui/components/v2/AccountSwitcherSheet.kt create mode 100644 app/src/main/java/com/ledga/app/ui/components/v2/BentoCard.kt create mode 100644 app/src/main/java/com/ledga/app/ui/components/v2/Chip.kt create mode 100644 app/src/main/java/com/ledga/app/ui/components/v2/HeatmapCalendar.kt create mode 100644 app/src/main/java/com/ledga/app/ui/components/v2/PillTabBar.kt create mode 100644 app/src/main/java/com/ledga/app/ui/components/v2/ProgressRing.kt create mode 100644 app/src/main/java/com/ledga/app/ui/components/v2/SearchField.kt create mode 100644 app/src/main/java/com/ledga/app/ui/components/v2/StatCard.kt create mode 100644 app/src/main/java/com/ledga/app/ui/components/v2/TopBar.kt create mode 100644 app/src/main/java/com/ledga/app/ui/components/v2/TransactionRow.kt create mode 100644 app/src/main/java/com/ledga/app/ui/goals/AddGoalSheet.kt create mode 100644 app/src/main/java/com/ledga/app/ui/goals/GoalDetailScreen.kt create mode 100644 app/src/main/java/com/ledga/app/ui/goals/GoalDetailViewModel.kt create mode 100644 app/src/main/java/com/ledga/app/ui/goals/GoalsScreen.kt create mode 100644 app/src/main/java/com/ledga/app/ui/goals/GoalsViewModel.kt create mode 100644 app/src/main/java/com/ledga/app/ui/insights/InsightsScreen.kt create mode 100644 app/src/main/java/com/ledga/app/ui/insights/InsightsViewModel.kt create mode 100644 app/src/main/java/com/ledga/app/ui/theme/Dimens.kt create mode 100644 app/src/main/java/com/ledga/app/ui/update/UpdateScreen.kt create mode 100644 app/src/main/java/com/ledga/app/ui/update/UpdateViewModel.kt create mode 100644 app/src/main/java/com/ledga/app/ui/you/YouScreen.kt create mode 100644 app/src/main/java/com/ledga/app/util/InstallPermission.kt create mode 100644 app/src/main/java/com/ledga/app/worker/InsightWorker.kt create mode 100644 app/src/main/res/values/font_certs.xml create mode 100644 app/src/test/java/com/ledga/app/goals/ContributionRuleTest.kt create mode 100644 app/src/test/java/com/ledga/app/insights/InsightRulesTest.kt create mode 100644 app/src/test/java/com/ledga/app/insights/InsightsDryRunTest.kt create mode 100644 app/src/test/java/com/ledga/app/parser/ParserAuditTest.kt create mode 100644 app/src/test/java/com/ledga/app/update/UpdateLogicTest.kt diff --git a/LEDGA_REDESIGN.md b/LEDGA_REDESIGN.md new file mode 100644 index 0000000..5ce0ccf --- /dev/null +++ b/LEDGA_REDESIGN.md @@ -0,0 +1,943 @@ +# Ledga — Major Redesign Spec (v2) + +> Target visual direction: **Modern Fintech Bento**. Soft neutral surfaces, M-Pesa green as a single accent, big confident numbers, generous whitespace, rounded everything. Feels at home next to Revolut / Cash App / Monzo while staying friendly for a Kenyan family audience. +> +> Scope: full UI overhaul + four new feature areas: +> 1. **Insights** — smart, derived suggestions (recurring detection, fee tips, anomaly callouts) +> 2. **Goals** — savings targets with progress + ETA +> 3. **Multi-SIM** — switch between two M-Pesa lines on a dual-SIM device +> 4. **In-app update flow** — surface release notes + one-tap install when a new APK is published + +--- + +## 1. Design Tokens + +These are the canonical names. The Compose `Color.kt` / `Theme.kt` files should mirror them 1:1. (They're also already saved into the pencil document via `set_variables`, so when Pencil starts cooperating we can wire them up as variables there too.) + +### 1.1 Color — Light + +| Token | Hex | Use | +|----------------------|-----------|--------------------------------------------------------| +| `--bg` | `#FAFAF7` | Page background (warm off-white, not stark) | +| `--surface` | `#FFFFFF` | Card / sheet background | +| `--surface-2` | `#F2F2EE` | Subtle elevation, chip backgrounds | +| `--ink` | `#0E1311` | Primary text, big numbers | +| `--ink-2` | `#3A4240` | Body text | +| `--muted` | `#6B7270` | Secondary text, captions | +| `--muted-2` | `#A4A8A5` | Disabled, hints | +| `--line` | `#E8E8E2` | Dividers, hairlines | +| `--accent` | `#10A37F` | M-Pesa-ish green, but de-saturated for premium feel | +| `--accent-soft` | `#E2F3EC` | Accent tints (chip backgrounds, progress tracks) | +| `--accent-deep` | `#0E7C5F` | Accent text on light, hover states | +| `--ink-on-accent` | `#FFFFFF` | Text on accent surfaces | +| `--danger` | `#D14B4B` | Destructive, over-budget, errors | +| `--danger-soft` | `#FBE8E8` | Danger backgrounds | +| `--warning` | `#E7A82C` | "Budget at 80%", fuliza outstanding | +| `--warning-soft` | `#FBEFD2` | Warning backgrounds | +| `--inflow` | `#1F8B5A` | Received / deposit / refund amounts | +| `--outflow` | `#0E1311` | Outflow uses `--ink` (not red) — red is for alerts only| + +### 1.2 Color — Dark + +Same token names, different values. Designed for OLED-friendly true-blacks while keeping the accent vivid. + +| Token | Hex | +|----------------------|-----------| +| `--bg` | `#0A0C0B` | +| `--surface` | `#15191B` | +| `--surface-2` | `#1E2326` | +| `--ink` | `#F4F6F5` | +| `--ink-2` | `#C9CFCC` | +| `--muted` | `#8E948F` | +| `--muted-2` | `#5A615D` | +| `--line` | `#262B2D` | +| `--accent` | `#3CD9A8` | brighter on dark for pop | +| `--accent-soft` | `#0F2A23` | +| `--accent-deep` | `#85F2C8` | +| `--ink-on-accent` | `#06120E` | +| `--danger` | `#FF7A7A` | +| `--danger-soft` | `#2A1414` | +| `--warning` | `#FFC25E` | +| `--warning-soft` | `#2A2009` | +| `--inflow` | `#3CD9A8` | +| `--outflow` | `#F4F6F5` | + +### 1.3 Category palette (same on both themes — saturated enough to stay distinct) + +``` +Groceries #4CAF50 Transport #3478F6 Bills #FF9F2D +Airtime/Data #A155F0 Food #EC4072 Send Money #6E7B8B +Received #06B6D4 Withdrawal #8B5A3C Deposit #7CC242 +Shopping #FF6A3D International #4F58D9 Savings/Loans #089B91 +Other #9E9E9E +``` + +Always paired with a 12% tint as the icon-tile background, full color for the icon glyph itself. + +### 1.4 Typography + +Font: **Inter** (variable) everywhere. Numbers use `font-feature-settings: "tnum"` (tabular figures) so balances align in columns. + +| Style | Size | Weight | Line | Letter-spacing | Use | +|----------------|------|--------|------|-----------------|--------------------------------------------------| +| Display XL | 56 | 700 | 1.05 | -1.5 | Onboarding hero amounts (rare) | +| Display L | 44 | 700 | 1.05 | -1.0 | Home "Spent this month" | +| Display M | 32 | 700 | 1.1 | -0.5 | Goal current amount, trend hero | +| Title L | 24 | 700 | 1.2 | -0.2 | Screen titles (Home, Transactions, …) | +| Title M | 20 | 600 | 1.25 | -0.1 | Section headers, sheet titles | +| Title S | 17 | 600 | 1.3 | 0 | Card titles | +| Body L | 16 | 500 | 1.4 | 0 | Transaction recipient, primary body | +| Body M | 14 | 500 | 1.4 | 0 | Secondary body, descriptions | +| Caption | 12 | 500 | 1.3 | 0.1 | Day labels, meta info | +| Overline | 11 | 600 | 1.3 | 0.5 (uppercase) | Tab labels, section eyebrows | +| Mono S | 13 | 500 | 1.4 | 0 | Transaction codes (use system mono) | + +### 1.5 Spacing scale + +`4 · 8 · 12 · 16 · 20 · 24 · 32 · 48 · 64` + +- Screen horizontal padding: **20** +- Card inner padding: **20** (compact cards 16) +- Section vertical gap: **24** +- Tight group vertical gap: **12** + +### 1.6 Radius + +`xs:8 · sm:12 · md:16 · lg:24 · xl:32 · pill:999` + +- Cards: **24** (lg) +- Sheets: **32 top-only** +- Inputs / chips: **pill** +- Tab bar pill: **36** +- Icon tiles: **14** + +### 1.7 Elevation + +No drop shadows on light theme — depth comes from **1px `--line` borders + subtle surface-2 wells**. On dark theme, no shadows either — depth via surface stepping (`--surface` over `--bg`). + +The only exception: the floating tab bar uses a soft drop shadow on light (y:8, blur:24, color: `#0E1311 @ 6%`). + +### 1.8 Motion + +| Token | Duration | Easing | Use | +|------------------|----------|-----------------------------------|------------------------------------| +| `motion-fast` | 120ms | `cubic-bezier(0.2, 0, 0, 1)` | Press states, ripples | +| `motion-base` | 240ms | `cubic-bezier(0.2, 0, 0, 1)` | Sheet open, chip select | +| `motion-emph` | 360ms | `cubic-bezier(0.2, 0, 0, 1)` | Screen enter, hero number flip | +| `motion-spring` | spring(stiffness=300, damping=30) | Tab switch, FAB scale | + +Numbers (balances, totals) **animate from old → new value** when changed (counting up/down). Spec: 600ms `easeOutCubic`. Use Compose `animateFloatAsState` + `String.format`. + +--- + +## 2. Component Inventory + +### 2.1 Atoms + +- **Avatar** 40 / 32 / 24 — circular, initial inside on `--surface-2`. Used in account switcher, transaction rows for person-to-person. +- **CategoryIcon tile** 40×40 — 14-radius rounded square, category color @ 12% bg, glyph in full color. +- **Chip / Filter** — pill, 32 height, 12px horizontal padding, `--surface-2` bg, `--ink` text. Selected: `--accent-soft` bg, `--accent-deep` text, 1px `--accent` border. +- **Switch** — Material 3 with `--accent` selected track. +- **TextField** — 56 height, pill radius, `--surface-2` bg, no outline. Floating label collapses on focus. +- **Button** — Primary (filled accent, pill, 52h), Secondary (1px `--line`, `--surface`), Ghost (text only), Danger (filled `--danger`). +- **Badge** — 20h pill, 8px horizontal, caption text. Variants: neutral, success, warning, danger. +- **ProgressBar** — 8h pill, `--surface-2` track, `--accent` fill. Over-budget: `--danger` fill. +- **ProgressRing** — 56 / 80 / 120 sizes, 6 stroke, same color rules as ProgressBar. + +### 2.2 Molecules + +- **TransactionRow** + + ``` + ┌────────────────────────────────────────────────┐ + │ [🛒] Naivas Supermarket -Ksh 1,200 │ + │ Groceries · 2:15 PM Bal 3,500 │ + └────────────────────────────────────────────────┘ + ``` + Icon (40), middle stack (recipient L, meta M muted), right stack (amount L, balance Caption muted). 16px vertical padding, no borders between rows — separated only by 12px gap inside a card. + +- **StatCard** — title (Overline muted), value (Display M ink), delta chip (Caption with ▲/▼). +- **BentoTile** — generic 24-radius card with optional header row (icon + title + trailing chevron) and content slot. +- **DayHeader** — sticky inside the transactions list. "Today · Ksh 3,420" left, weekday caption right. +- **AccountChip** — used in Home header for multi-SIM. Avatar + carrier + chevron, opens AccountSwitcherSheet. +- **InsightCard** — illustrated, 24-radius, accent-soft bg, icon top-left, title + body, optional CTA. +- **GoalCard** — ProgressRing left, name + amount stack right, ETA badge bottom. + +### 2.3 Organisms + +- **TopBar** — sticky on every screen. Three slots: leading (Avatar + greeting OR back button), title (only on detail screens), trailing (icons: notifications, settings). +- **PillTabBar** — floating, bottom-anchored, 4 tabs: Home / Activity / Insights / You. + - Spec from mobile-app guideline: container padding 12 top / 21 sides+bottom, pill 62h with 36 radius, items vertical (18 icon + 10 uppercase label), active = `--accent` fill + `--ink-on-accent`. + - Slots: Home, Activity (combines Transactions + Trends in one screen with segmented top), Insights, You (profile/settings/goals). + - FAB-like + center action removed to keep the bar honest — actions belong inside screens. + +- **BottomSheet** — radius 32 top-only, 4-wide 40-long grab handle, 24px padding, max 90% screen height. + +- **EmptyState** — 240 illustrated SVG centered, Title M, Body M muted, single primary CTA. + +--- + +## 3. Information Architecture + +Old IA: Home / Transactions / Trends / Settings (4 tabs). +New IA: **Home / Activity / Insights / You** (4 tabs) — but with more inside. + +``` +┌─ Home — overview, hero spend, quick stats, recent +│ +├─ Activity — segmented [ Transactions · Trends ] +│ ├─ Transactions — day-grouped list, search, filter chips +│ └─ Trends — charts, top merchants, fees, heatmap +│ +├─ Insights — smart cards (auto-generated) +│ • recurring detection +│ • fee tips +│ • spending anomalies +│ • category trends +│ +└─ You — profile + grouped settings + goals + ├─ Accounts — multi-SIM management + ├─ Goals — list + add + ├─ Budgets + ├─ Appearance + ├─ Notifications + ├─ Data + ├─ Backup + └─ About / Updates +``` + +**Why this IA**: +- "Activity" merges Transactions + Trends because users always toggle between "what happened" and "what's the pattern" — they shouldn't be two tabs apart. +- "You" pulls in Goals because goals are personal, not screen-scoped. It also hosts Accounts (multi-SIM). +- "Insights" earns top-level because the feature only works if users see it without going looking. + +--- + +## 4. Screen Specs + +For each screen below: **status bar 62**, then **wrapper** with horizontal padding `--pad-screen` (20) and gap `--gap-7` (24) between sections, then **pill tab bar** floating at the bottom (where shown). + +Bottom padding on the scroll content: **120** (tab bar height 62 + 21 padding + 32 breathing room) so the last item is never hidden behind the bar. + +### 4.1 Onboarding (4 steps, swipeable + dotted progress) + +**Step 1 — Welcome** +``` +┌──────────────────────────────────────────────┐ +│ │ +│ │ +│ ● ○ ○ ○ │ +│ │ +│ ┌──────┐ │ +│ │ L │ (logo, 88px) │ +│ └──────┘ │ +│ │ +│ Track every M-Pesa shilling. │ +│ Automatically. │ +│ │ +│ Ledga reads your M-Pesa SMS and turns │ +│ it into a calm, private spending │ +│ picture. Nothing leaves your phone. │ +│ │ +│ │ +│ ┌────────────────────────────────────┐ │ +│ │ Get started → │ │ +│ └────────────────────────────────────┘ │ +│ │ +│ I'll set this up later │ +└──────────────────────────────────────────────┘ +``` +- Hero text: Title L +- Body: Body L muted, max-width ~320 +- Primary CTA: 52h pill filled accent +- "Set up later" = Ghost button, only proceeds with read-only mode + +**Step 2 — SMS permission (the trust moment)** +``` +┌──────────────────────────────────────────────┐ +│ ● ● ○ ○ │ +│ │ +│ ┌──────┐ │ +│ │ 📩 │ (icon tile, 56) │ +│ └──────┘ │ +│ │ +│ Let Ledga read M-Pesa SMS │ +│ │ +│ • Only messages from MPESA are read │ +│ • Nothing is sent to a server │ +│ • You can revoke this in system settings │ +│ │ +│ ┌──────────────────────────────────────┐ │ +│ │ 🔒 Your data is on this device only │ │ +│ │ and is backed up to your Google │ │ +│ │ Drive if you turn it on. │ │ +│ └──────────────────────────────────────┘ │ +│ │ +│ ┌────────────────────────────────────┐ │ +│ │ Allow SMS access → │ │ +│ └────────────────────────────────────┘ │ +│ │ +│ Not now │ +└──────────────────────────────────────────────┘ +``` +- The accent-soft callout card under the bullets is the "trust signal" — keep it. +- "Not now" routes to a minimal manual-entry mode (out of scope this release, just a stub screen). + +**Step 3 — Import history** +``` +┌──────────────────────────────────────────────┐ +│ ● ● ● ○ │ +│ │ +│ Bring in your history? │ +│ │ +│ We can scan the M-Pesa messages already │ +│ on your phone to build a full picture. │ +│ │ +│ ┌──────────────────────────────────────┐ │ +│ │ 📊 342 M-Pesa messages found │ │ +│ │ Going back to Aug 2024 │ │ +│ └──────────────────────────────────────┘ │ +│ │ +│ ┌────────────────────────────────────┐ │ +│ │ Import all 342 messages → │ │ +│ └────────────────────────────────────┘ │ +│ │ +│ Start fresh │ +│ │ +│ ───── while importing ───── │ +│ │ +│ ┌──────────────────────────────────────┐ │ +│ │ Importing… │ │ +│ │ ▓▓▓▓▓▓▓▓▓░░░░░░ 68% │ │ +│ │ 231 of 342 messages │ │ +│ └──────────────────────────────────────┘ │ +└──────────────────────────────────────────────┘ +``` +- Show found-count BEFORE asking, so the user can decide informedly. +- Progress card replaces the CTA when import is running, then auto-advances. + +**Step 4 — Multi-SIM detection (NEW, optional)** + +Only shown if two active M-Pesa lines are detected via subscription IDs in the SMS data. + +``` +┌──────────────────────────────────────────────┐ +│ ● ● ● ● │ +│ │ +│ We spotted two M-Pesa lines │ +│ │ +│ You can name them so it's clear which is │ +│ which. Both are tracked separately and you │ +│ can switch from the home screen anytime. │ +│ │ +│ ┌──────────────────────────────────────┐ │ +│ │ ⓅⓛⓁ Line 1 Safaricom · 07XX…23 │ │ +│ │ Name ┌──────────────────────────┐ │ │ +│ │ │ Personal × │ │ │ +│ │ └──────────────────────────┘ │ │ +│ └──────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────┐ │ +│ │ ⓆⓀ Line 2 Safaricom · 07XX…87 │ │ +│ │ Name ┌──────────────────────────┐ │ │ +│ │ │ Business × │ │ │ +│ │ └──────────────────────────┘ │ │ +│ └──────────────────────────────────────┘ │ +│ │ +│ ┌────────────────────────────────────┐ │ +│ │ You're set → │ │ +│ └────────────────────────────────────┘ │ +└──────────────────────────────────────────────┘ +``` + +### 4.2 Home + +``` +┌──────────────────────────────────────────────┐ +│ Status bar │ +├──────────────────────────────────────────────┤ +│ │ +│ ⒶPersonal ▾ 🔔 ⚙ │ ← AccountChip + 2 icon buttons +│ │ +│ Hi, Kevin │ Title L +│ Here's how March is going. │ Body M muted +│ │ +│ ┌──────────────────────────────────────────┐ │ ← HERO bento card +│ │ SPENT THIS MONTH › │ │ overline muted +│ │ │ │ +│ │ Ksh 45,200 │ │ Display L +│ │ ▼ 12% vs February · Ksh 320 in fees │ │ Body M w/ inline chip +│ │ │ │ +│ │ ▓▓▓▓▓▓▓▓▓▓▓▓░░░ 78% of 60,000 budget │ │ progress +│ └──────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────┐ ┌──────────────────┐ │ ← 2-up small bentos +│ │ BALANCE │ │ TOP CATEGORY │ │ +│ │ Ksh 12,500 │ │ 🛒 Groceries │ │ +│ │ from last tx │ │ Ksh 18,200 │ │ +│ └──────────────────┘ └──────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────┐ │ ← Donut bento +│ │ Where it went ›│ │ +│ │ │ │ +│ │ ○─◐─● Groceries 40%│ │ +│ │ ╱ donut ╲ Transport 18│ │ +│ │ │ 45,200 │ Bills 14│ │ +│ │ ╲ ╱ Food 11│ │ +│ │ ───── Other 17│ │ +│ └──────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────┐ │ ← Insights teaser +│ │ ✨ INSIGHTS ›│ │ +│ │ │ │ +│ │ You're spending 40% more on transport │ │ +│ │ this week. 3 Uber trips, all weekends. │ │ +│ └──────────────────────────────────────────┘ │ +│ │ +│ Recent activity See all│ ← section header +│ ┌──────────────────────────────────────────┐ │ +│ │ [🛒] Naivas −Ksh 1,200 │ │ +│ │ 2:15 PM Bal 3,500 │ │ +│ │ ───────────────────────────────────── │ │ hairline +│ │ [📱] Airtime −Ksh 100 │ │ +│ │ 12:00 PM │ │ +│ │ ───────────────────────────────────── │ │ +│ │ [💰] Jane Doe +Ksh 5,000 │ │ inflow color +│ │ 11:00 AM │ │ +│ └──────────────────────────────────────────┘ │ +│ │ +│ (UPDATE BANNER, conditional, see §4.10) │ +│ │ +│ ⌒ ⌒ ⌒ ⌒ pill tab bar floating │ +└──────────────────────────────────────────────┘ +``` + +Behaviors: +- The hero `Spent` number animates from last value to new on receipt of a new SMS. +- Tapping the hero card → Trends (Activity tab → Trends segment). +- Tapping "Balance" small card → reveals "balance history" sparkline in-place (300ms expand, doesn't navigate). +- Tapping "Top category" → filter Transactions list by that category. +- Donut card uses Vico segments; legend is right-justified. +- Insights teaser shows the highest-priority unresolved insight; rotates daily. + +### 4.3 Activity — Transactions segment + +``` +┌──────────────────────────────────────────────┐ +│ ‹ │ +│ Activity │ +│ │ +│ ┌──────────────────────────────────────────┐ │ +│ │ [ Transactions ] Trends │ │ ← segmented (pill) +│ └──────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────┐ │ +│ │ 🔍 Search merchants, codes… │ │ ← search input +│ └──────────────────────────────────────────┘ │ +│ │ +│ All · Sent · Received · Bills · Goods … ‹ ›│ ← horizontal chip row +│ │ +│ ┌──────────────────────────────────────────┐ │ +│ │ TODAY · Out 3,420 · In 5,000 │ │ ← sticky day header +│ │ │ │ +│ │ [🛒] Naivas −Ksh 1,200 │ │ +│ │ [📱] Airtime −Ksh 100 │ │ +│ │ [💰] Jane Doe +Ksh 5,000 │ │ +│ └──────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────┐ │ +│ │ YESTERDAY · Out 2,150 │ │ +│ │ [🍔] KFC Westgate −Ksh 850 │ │ +│ │ [🚗] Uber −Ksh 1,300 │ │ +│ └──────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────┐ │ +│ │ WED 18 MAR │ │ +│ │ [⚡] KPLC −Ksh 2,500 │ │ +│ │ Account 12345678 │ │ +│ └──────────────────────────────────────────┘ │ +│ │ +│ pill tab bar │ +└──────────────────────────────────────────────┘ +``` + +Interactions: +- Tap row → TransactionDetailSheet (§4.9). +- Long-press row → quick category picker (mini sheet). +- Filter chips multi-select; the row scrolls horizontally; "Customize…" at end opens the full filter sheet. +- Search ranks by transaction code prefix > merchant name fuzzy > recipient phone. + +### 4.4 Activity — Trends segment + +``` +┌──────────────────────────────────────────────┐ +│ Activity │ +│ │ +│ [ Transactions ] [ Trends ] │ +│ │ +│ ┌──────────────────────────────────────────┐ │ +│ │ 7D [ 30D ] 90D 1Y Custom │ │ ← period selector pill +│ └──────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────┐ │ ← Hero compare bento +│ │ NET CASHFLOW │ │ +│ │ │ │ +│ │ −Ksh 39,880 ▼ 8% vs prev 30D │ │ +│ │ Out 45,200 In 5,320 │ │ +│ └──────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────┐ │ ← Daily bars +│ │ Daily spending │ │ +│ │ │ │ +│ │ ▁▂█▃▅▂▁▄▆▂▁▃▅▇▂▁▃▆▄▂▃▅▇▃▁▂▄▆▂▁ │ │ +│ │ 1 5 10 15 20 25 30 │ │ +│ └──────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────┐ │ ← Heatmap calendar (NEW) +│ │ Spending heatmap › │ │ +│ │ │ │ +│ │ M T W T F S S │ │ +│ │ □ □ ▤ ▤ ▥ ▦ ■ │ │ +│ │ ▤ ■ ▦ ▤ ■ ▦ ▥ │ │ 4 intensity steps +│ │ ▦ ▤ ▥ ■ ▤ □ ▦ │ │ +│ │ ▤ ▥ ▦ ▤ ▤ ■ ▥ │ │ +│ │ ▦ ▤ □ ▥ □ ▤ ▦ │ │ +│ │ low ▢▢▢▢ high │ │ +│ └──────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────┐ │ ← Top merchants +│ │ Top merchants › │ │ +│ │ 🛒 Naivas 5 visits Ksh 8,400 │ │ +│ │ 🚗 Uber 4 trips Ksh 5,200 │ │ +│ │ 🍔 KFC 3 visits Ksh 2,550 │ │ +│ │ ⚡ KPLC 1 bill Ksh 2,500 │ │ +│ │ 📱 Airtime 9 buys Ksh 900 │ │ +│ └──────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────┐ │ ← Fees summary (NEW) +│ │ 💸 FEES PAID │ │ +│ │ Ksh 320 over 30 days │ │ +│ │ ▲ 12% vs previous 30D │ │ +│ │ Most fees came from Withdraw (Ksh 215) │ │ +│ └──────────────────────────────────────────┘ │ +└──────────────────────────────────────────────┘ +``` + +The heatmap is the high-value new visual — turns "you spent more last Friday" into something instantly readable. 4 intensity steps using `--accent` opacity 8/24/56/88%. + +### 4.5 Insights (NEW) + +Algorithmically generated. Each card has a "Got it" button (dismiss for 30 days) and optional secondary action (e.g., "See transactions"). + +``` +┌──────────────────────────────────────────────┐ +│ Insights │ +│ 4 new this week │ +│ │ +│ ┌──────────────────────────────────────────┐ │ ← Anomaly insight +│ │ 🚨 WATCH OUT │ │ +│ │ You spent 40% more on transport this │ │ +│ │ week than your typical week. │ │ +│ │ 3 Uber trips on weekends · Ksh 4,800 │ │ +│ │ [ See transactions ] [ Got it ] │ │ +│ └──────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────┐ │ ← Recurring detection +│ │ 🔁 RECURRING DETECTED │ │ +│ │ KPLC seems to be a monthly bill. │ │ +│ │ Avg Ksh 2,420 · last 3 months │ │ +│ │ [ Add to budget ] [ Got it ] │ │ +│ └──────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────┐ │ ← Fee tip +│ │ 💡 FEE TIP │ │ +│ │ You paid Ksh 215 in agent withdrawal │ │ +│ │ fees this month. Withdrawing fewer, │ │ +│ │ larger amounts could save ~Ksh 90. │ │ +│ │ [ Show me how ] [ Got it ] │ │ +│ └──────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────┐ │ ← Positive nudge +│ │ ✨ NICE │ │ +│ │ Groceries spending is down 14% this │ │ +│ │ month. Keep it up! │ │ +│ │ │ │ +│ └──────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────┐ │ ← Fuliza warning +│ │ ⚠ FULIZA │ │ +│ │ Outstanding Fuliza: Ksh 500 │ │ +│ │ Last borrowed 3 days ago. │ │ +│ │ [ How Fuliza fees work ] │ │ +│ └──────────────────────────────────────────┘ │ +│ │ +│ Want fewer of these? Settings → Insights │ +│ │ +└──────────────────────────────────────────────┘ +``` + +Card variants (background colors): +- **Watch out / anomaly**: warning-soft bg, warning icon +- **Recurring**: surface bg, accent icon +- **Fee tip**: accent-soft bg +- **Positive nudge**: accent-soft bg, sparkles icon +- **Fuliza / danger**: danger-soft bg, danger icon + +Generation cadence: re-evaluate on every new transaction (cheap rules) + weekly batch (heavier comparisons). All logic local, deterministic — no ML. + +### 4.6 Goals (NEW, lives under "You") + +``` +┌──────────────────────────────────────────────┐ +│ ‹ You │ +│ Goals + │ +│ │ +│ ┌──────────────────────────────────────────┐ │ ← Active goal card +│ │ ◖◗ School fees │ │ +│ │ 67% Term 2 │ │ +│ │ Ksh 40,000 / 60,000 │ │ +│ │ ▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░ │ │ +│ │ ETA 14 Apr · on track ✓ │ │ +│ └──────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────┐ │ +│ │ ◖◗ Pi server budget │ │ +│ │ 32% Hardware fund │ │ +│ │ Ksh 8,000 / 25,000 │ │ +│ │ ▓▓▓▓▓▓░░░░░░░░░░░░░░ │ │ +│ │ ETA 12 Jul · slightly behind ⚠ │ │ +│ └──────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────┐ │ +│ │ ◖◗ Eldoret trip │ │ +│ │ 100% Completed Mar 4 🎉 │ │ +│ │ Ksh 15,000 / 15,000 │ │ +│ └──────────────────────────────────────────┘ │ +│ │ +│ Suggested by your activity: │ +│ │ +│ ┌──────────────────────────────────────────┐ │ +│ │ 💡 You've moved Ksh 5,000 to M-Shwari │ +│ │ each month for 3 months. Turn that │ │ +│ │ into a goal? │ │ +│ │ [ Create goal ] │ │ +│ └──────────────────────────────────────────┘ │ +└──────────────────────────────────────────────┘ +``` + +**Goal model**: + +```kotlin +data class Goal( + val id: Long, + val name: String, + val targetAmount: Double, + val targetDate: LocalDate?, // nullable → no ETA, only progress + val contributionRule: ContributionRule, + val createdAt: Long, + val completedAt: Long?, +) + +sealed class ContributionRule { + // count every M-Shwari/KCB deposit as contribution + object AllSavingsDeposits : ContributionRule() + // count outflows to a specific paybill (e.g. school fees paybill) + data class ToPaybill(val paybill: String) : ContributionRule() + // user manually marks a transaction as contributing + object Manual : ContributionRule() +} +``` + +Progress is derived. ETA = `(targetAmount - currentAmount) / avgMonthlyContribution * 30 days from today`. + +**Add Goal sheet** (full screen sheet): +- Goal name +- Target amount +- Target date (optional) +- Contribution rule (radio: All savings · Specific paybill · Manual mark) +- Color picker (one of the category colors) + +### 4.7 You + +``` +┌──────────────────────────────────────────────┐ +│ You │ +│ │ +│ ┌──────────────────────────────────────────┐ │ +│ │ ⒶⒶ Kevin │ │ ← profile card +│ │ 2 M-Pesa lines · 1,432 tx tracked │ │ +│ │ [ Manage accounts ] │ │ +│ └──────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────┐ │ +│ │ 🎯 Goals 3 ›│ │ +│ │ 💰 Budgets 2 ›│ │ +│ │ 🏷 Categories ›│ │ +│ └──────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────┐ │ +│ │ 🌓 Appearance │ │ +│ │ Theme System › │ │ +│ │ Font size Medium › │ │ +│ │ Accent ●●●●● › │ │ +│ └──────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────┐ │ +│ │ 🔔 Notifications │ │ +│ │ Daily summary 8:00 PM │ │ +│ │ Weekly summary Sun, 6 PM │ │ +│ │ Budget alerts ● │ │ +│ │ Large transaction alert OFF › │ │ +│ │ Insights ● │ │ +│ └──────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────┐ │ +│ │ 🗄 Data │ │ +│ │ Import SMS history › │ │ +│ │ Re-parse 14 unknown messages › │ │ +│ │ Export (CSV / JSON) › │ │ +│ │ Clear all data › │ │ +│ └──────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────┐ │ +│ │ ☁ Backup │ │ +│ │ Google Drive ngenoh@… │ │ +│ │ Last backup Today 3 AM │ │ +│ │ Back up now › │ │ +│ │ Restore › │ │ +│ └──────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────┐ │ +│ │ ℹ About │ │ +│ │ Version 1.0.0 │ │ +│ │ [ NEW ] v1.1.0 available Update › │ │ +│ │ What's new › │ │ +│ │ Privacy › │ │ +│ └──────────────────────────────────────────┘ │ +└──────────────────────────────────────────────┘ +``` + +Settings rows are **grouped into bento cards** instead of being a flat list — same `--surface` cards, hairlines between rows inside a card, no hairline at edges. Each "group" = one bento. + +### 4.8 Account Switcher Sheet (Multi-SIM) + +Triggered from the AccountChip on Home. + +``` +┌──────────────────────────────────────────────┐ +│ │ +│ ━━━━ │ ← grab handle +│ │ +│ Accounts │ +│ │ +│ ┌──────────────────────────────────────┐ │ +│ │ ⓅⓛⓁ Personal ● selected │ │ ← accent border + check +│ │ Safaricom · 07XX…23 │ │ +│ │ Bal 12,500 · 1,200 tx │ │ +│ └──────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────┐ │ +│ │ ⓆⓀ Business │ │ +│ │ Safaricom · 07XX…87 │ │ +│ │ Bal 4,300 · 232 tx │ │ +│ └──────────────────────────────────────┘ │ +│ │ +│ View │ +│ ( ) Selected account only │ +│ (●) Combined view │ +│ │ +│ ┌──────────────────────────────────────┐ │ +│ │ + Add another line │ │ +│ └──────────────────────────────────────┘ │ +│ │ +│ Rename · Recolor accounts in You → Accounts│ +│ │ +└──────────────────────────────────────────────┘ +``` + +**Data model addition**: + +```kotlin +@Entity(tableName = "mpesa_accounts") +data class MpesaAccount( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val subscriptionId: Int, // Android SubscriptionInfo id + val phoneNumber: String?, // E.164 if available + val displayName: String, // "Personal" / "Business" + val colorHex: String, // accent for tiles + val isPrimary: Boolean, +) +``` + +`Transaction.accountId: Long?` is added. Migration: +1. ALTER TABLE add column nullable. +2. Existing rows stay null = "default" account. +3. On next SMS receive, populate the `accounts` table from SubscriptionManager and stamp incoming transactions. + +### 4.9 Transaction Detail Sheet + +``` +┌──────────────────────────────────────────────┐ +│ ━━━━ │ +│ │ +│ [🛒] Naivas Supermarket │ +│ BUY GOODS │ +│ │ +│ −Ksh 1,200.00 │ Display L outflow ink +│ cost Ksh 0.00 │ Caption muted +│ │ +│ ┌──────────────────────────────────────┐ │ +│ │ Code RK31B7X4ZQ │ │ +│ │ Date 21 Mar 2026, 2:15PM │ │ +│ │ Balance after Ksh 3,500.00 │ │ +│ │ Account Personal ⓅⓛⓁ │ │ ← only if multi-SIM +│ └──────────────────────────────────────┘ │ +│ │ +│ Category │ +│ ┌──────────────────────────────────────┐ │ +│ │ 🛒 Groceries › │ │ ← tap to change +│ └──────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────┐ │ +│ │ Mark as contribution to goal › │ │ +│ └──────────────────────────────────────┘ │ +│ │ +│ Notes │ +│ ┌──────────────────────────────────────┐ │ +│ │ Tap to add note │ │ +│ └──────────────────────────────────────┘ │ +│ │ +│ ⌄ Original SMS │ ← collapsible +│ ┌──────────────────────────────────────┐ │ +│ │ RK31B7X4ZQ Confirmed. Ksh1,200.00 │ │ mono +│ │ paid to NAIVAS SUPERMARKET. on … │ │ +│ └──────────────────────────────────────┘ │ +│ │ +│ [ Share ] [ Delete ] │ +│ │ +└──────────────────────────────────────────────┘ +``` + +Notes: +- The "Mark as contribution" row appears only if user has at least one Goal with `Manual` rule. +- Delete only removes from app (the SMS remains in Android's SMS DB), and creates an UNDO snackbar. + +### 4.10 In-app Update Flow + +**Discovered (Home banner)** — slot just above Recent Activity: +``` +┌──────────────────────────────────────────────┐ +│ ✨ Ledga v1.1.0 is ready │ +│ • Insights · Goals · Multi-SIM │ +│ [ See what's new ] [ Update ›] │ +└──────────────────────────────────────────────┘ +``` +- Background: `--accent-soft`, border: `--accent`. +- Dismissable. If dismissed it reappears in You → About. + +**Full screen** — tap "See what's new": +``` +┌──────────────────────────────────────────────┐ +│ ‹ │ +│ │ +│ Ledga v1.1.0 │ Title L +│ March 2026 · 4.8 MB │ Body M muted +│ │ +│ ┌──────────────────────────────────────────┐ │ hero illustration +│ │ [ release artwork ] │ │ +│ └──────────────────────────────────────────┘ │ +│ │ +│ What's new │ +│ │ +│ ✨ Insights tab — smart suggestions │ +│ based on your spending patterns. │ +│ │ +│ 🎯 Goals — track savings towards rent, │ +│ school fees, trips. │ +│ │ +│ Ⓢ Dual-SIM support — switch between │ +│ two M-Pesa lines from the home screen. │ +│ │ +│ 🎨 Brand new design — bento layout, big │ +│ numbers, dark theme polish. │ +│ │ +│ Fixes │ +│ • Faster startup on Android 8 devices │ +│ • Better Fuliza parsing │ +│ • 14 small fixes from your feedback │ +│ │ +│ ┌────────────────────────────────────┐ │ +│ │ Download and install → │ │ +│ └────────────────────────────────────┘ │ +│ │ +│ Remind me later │ +└──────────────────────────────────────────────┘ +``` + +**Installing** — replaces the CTA: +``` +┌────────────────────────────────────┐ +│ Downloading ▓▓▓▓▓▓▓▓░░░ 72% │ +│ 3.4 MB of 4.8 MB │ +└────────────────────────────────────┘ +``` + +Then Android's standard installer intent fires. + +--- + +## 5. Implementation Order + +The redesign maps cleanly to phased PRs. Each phase is shippable. + +### Phase A — Foundation (PR 1) +- Replace `Color.kt` with new token names + dark theme. +- Add `MpesaAccount` entity + DAO + migration. +- Add `Goal` entity + DAO + migration. +- Extend `Transaction` with `accountId: Long?`, `note: String?`. +- New `BentoCard`, `StatCard`, `Chip`, `ProgressRing` composables. +- New `TopBar` and `PillTabBar` composables. + +### Phase B — Visual Overhaul (PR 2) +- Rewrite Home, Transactions, Trends with the new components, tokens, IA. +- New segmented "Activity" parent screen. +- New You screen consolidating Settings + new entries. + +### Phase C — Insights (PR 3) +- Rule engine: anomaly (z-score against same-category rolling 4-week mean), recurring detection (3+ same merchant ≥21-day apart), fee tips, Fuliza warnings. +- `InsightsRepository` + `InsightWorker` (daily WorkManager). +- Insights screen + dismiss/snooze table. + +### Phase D — Goals (PR 4) +- Goals screen + Add sheet + detail flow. +- Contribution attribution on transaction insert. +- "Mark as contribution" entry point in TransactionDetailSheet. + +### Phase E — Multi-SIM (PR 5) +- Subscription detection on SMS receive (`SmsMessage.getSubscriptionId` / Android `SubscriptionManager`). +- Account switcher sheet. +- Filter on all queries via selected account or "combined". +- Onboarding step 4. + +### Phase F — Update flow (PR 6) +- GitHub Releases polling. +- Banner + full-screen changelog. +- DownloadManager + install intent. + +--- + +## 6. Open questions (decide before Phase A) + +1. **Tab bar count**: keep 4 (Home / Activity / Insights / You) or 5 (split Trends out from Activity)? Recommendation: **4** — keeps thumb reach generous, Insights doesn't fight Trends. +2. **Multi-SIM detection**: Android only exposes `subscriptionId` on incoming SMS — not on already-imported ones. For historical imports we'll have to leave `accountId = null` and let the user assign in bulk. OK? +3. **Insight cadence**: notify on new insight (push) or only show in-app? Recommendation: **in-app only**, with a daily-summary mention if any new insights exist. + +--- + +## 7. What's intentionally NOT in this redesign + +- Web / desktop companion — out of scope, app stays mobile-only. +- Bill-pay shortcuts — Ledga is read-only; it tracks, not transacts. +- Bank-account integration — M-Pesa SMS only. +- Cloud sync between devices — backup-only via Google Drive; no per-device sync. +- AI summaries — Insights are deterministic rule output, not LLM output. Keeps the app offline and predictable. diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index bc2c901..b17ce6c 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -682,10 +682,34 @@ First launch experience for non-technical users. Only shown once — a `hasCompl ## APK Signing & CI/CD ### Keystore +- Path: `mobile/ledga/keystore/ledga-release.jks` (gitignored) +- Key alias: `ledga` - 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) +- **CRITICAL:** Back up the keystore — if lost, existing installs can never receive updates (Android requires consistent signing). The VPS backup job does not cover this file; back it up manually to a password manager or offline storage. - Store keystore password and key alias in a secure location (not in the repo) +### Build Commands +```bash +cd ~/dev/mobile/ledga + +# Debug APK +./gradlew assembleDebug +# -> app/build/outputs/apk/debug/app-debug.apk + +# Release APK (signed, requires keystore + secrets) +./gradlew assembleRelease +# -> app/build/outputs/apk/release/app-release.apk + +# Tests +./gradlew testDebugUnitTest +``` + +### Release a Version +```bash +git tag v1.0.0 && git push origin v1.0.0 +# GitHub Action builds signed APK and creates release automatically +``` + ### GitHub Actions ```yaml # .github/workflows/release.yml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 82e8aca..aaadeff 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -27,8 +27,8 @@ android { applicationId = "com.ledga.app" minSdk = 26 targetSdk = 34 - versionCode = 1 - versionName = "1.0.0" + versionCode = 3 + versionName = "1.3.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -78,6 +78,7 @@ dependencies { implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-graphics") implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.ui:ui-text-google-fonts") implementation("androidx.compose.material3:material3") implementation("androidx.compose.material:material-icons-extended") debugImplementation("androidx.compose.ui:ui-tooling") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2abcb4a..9adb353 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,6 +7,9 @@ + + - 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 index ff40e19..9fae8e0 100644 --- a/app/src/main/java/com/ledga/app/data/db/AppDatabase.kt +++ b/app/src/main/java/com/ledga/app/data/db/AppDatabase.kt @@ -6,10 +6,17 @@ 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.GoalDao +import com.ledga.app.data.db.dao.InsightDao +import com.ledga.app.data.db.dao.MpesaAccountDao 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.Goal +import com.ledga.app.data.db.entity.GoalContribution +import com.ledga.app.data.db.entity.Insight +import com.ledga.app.data.db.entity.MpesaAccount import com.ledga.app.data.db.entity.TransactionEntity @Database( @@ -17,9 +24,13 @@ import com.ledga.app.data.db.entity.TransactionEntity TransactionEntity::class, Category::class, Budget::class, - CategoryRule::class + CategoryRule::class, + MpesaAccount::class, + Goal::class, + GoalContribution::class, + Insight::class, ], - version = 1, + version = 3, exportSchema = true ) @TypeConverters(Converters::class) @@ -28,4 +39,7 @@ abstract class AppDatabase : RoomDatabase() { abstract fun categoryDao(): CategoryDao abstract fun categoryRuleDao(): CategoryRuleDao abstract fun budgetDao(): BudgetDao + abstract fun mpesaAccountDao(): MpesaAccountDao + abstract fun goalDao(): GoalDao + abstract fun insightDao(): InsightDao } 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 index 7783aed..5f50d42 100644 --- a/app/src/main/java/com/ledga/app/data/db/Converters.kt +++ b/app/src/main/java/com/ledga/app/data/db/Converters.kt @@ -1,6 +1,8 @@ package com.ledga.app.data.db import androidx.room.TypeConverter +import com.ledga.app.data.db.entity.InsightSeverity +import com.ledga.app.data.db.entity.InsightType import com.ledga.app.data.db.entity.MatchType import com.ledga.app.data.parser.TransactionDirection import com.ledga.app.data.parser.TransactionType @@ -24,4 +26,16 @@ class Converters { @TypeConverter fun toMatchType(value: String): MatchType = MatchType.valueOf(value) + + @TypeConverter + fun fromInsightType(value: InsightType): String = value.name + + @TypeConverter + fun toInsightType(value: String): InsightType = InsightType.valueOf(value) + + @TypeConverter + fun fromInsightSeverity(value: InsightSeverity): String = value.name + + @TypeConverter + fun toInsightSeverity(value: String): InsightSeverity = InsightSeverity.valueOf(value) } 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 index a7442c7..9a12487 100644 --- a/app/src/main/java/com/ledga/app/data/db/Migrations.kt +++ b/app/src/main/java/com/ledga/app/data/db/Migrations.kt @@ -1,5 +1,8 @@ package com.ledga.app.data.db +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + /** * Room database migrations. * @@ -7,13 +10,124 @@ package com.ledga.app.data.db * Always define explicit migrations for schema changes. * * Version history: - * 1 - Initial schema (transactions, categories, budgets, category_rules) + * 1 - Initial schema (transactions, categories, budgets, category_rules) + * 2 - Add mpesa_accounts, goals, goal_contributions; transactions gains + * accountId (FK → mpesa_accounts.id) and note columns. + * 3 - Add insights table (Phase C — rule-engine derived suggestions, + * with natural-key dedup + dismiss/snooze state). */ 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 ...") - // } - // } + + val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + // ---- mpesa_accounts ---- + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `mpesa_accounts` ( + `id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + `subscriptionId` INTEGER NOT NULL, + `phoneNumber` TEXT, + `displayName` TEXT NOT NULL, + `colorHex` TEXT NOT NULL, + `isPrimary` INTEGER NOT NULL DEFAULT 0, + `createdAt` INTEGER NOT NULL + ) + """.trimIndent() + ) + db.execSQL( + "CREATE UNIQUE INDEX IF NOT EXISTS `index_mpesa_accounts_subscriptionId` " + + "ON `mpesa_accounts` (`subscriptionId`)" + ) + + // ---- goals ---- + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `goals` ( + `id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + `name` TEXT NOT NULL, + `targetAmount` REAL NOT NULL, + `targetDate` INTEGER, + `contributionRule` TEXT NOT NULL, + `colorHex` TEXT NOT NULL, + `createdAt` INTEGER NOT NULL, + `completedAt` INTEGER + ) + """.trimIndent() + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_goals_completedAt` ON `goals` (`completedAt`)" + ) + + // ---- goal_contributions ---- + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `goal_contributions` ( + `goalId` INTEGER NOT NULL, + `transactionId` INTEGER NOT NULL, + `markedAt` INTEGER NOT NULL, + PRIMARY KEY(`goalId`, `transactionId`) + ) + """.trimIndent() + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_goal_contributions_goalId` " + + "ON `goal_contributions` (`goalId`)" + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_goal_contributions_transactionId` " + + "ON `goal_contributions` (`transactionId`)" + ) + + // ---- transactions.accountId + note ---- + // SQLite ALTER TABLE … ADD COLUMN can't carry a FOREIGN KEY clause — + // Room re-asserts the FK constraint via its own validation, but the + // raw column add is enough here. The index makes account-scoped + // queries cheap (we'll add a lot of them in Phase E). + db.execSQL("ALTER TABLE `transactions` ADD COLUMN `accountId` INTEGER") + db.execSQL("ALTER TABLE `transactions` ADD COLUMN `note` TEXT") + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_transactions_accountId` " + + "ON `transactions` (`accountId`)" + ) + } + } + + val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `insights` ( + `id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + `naturalKey` TEXT NOT NULL, + `type` TEXT NOT NULL, + `severity` TEXT NOT NULL, + `typeLabel` TEXT NOT NULL, + `headline` TEXT NOT NULL, + `body` TEXT, + `ctaLabel` TEXT, + `ctaArgs` TEXT, + `generatedAt` INTEGER NOT NULL, + `snoozedUntil` INTEGER, + `dismissedAt` INTEGER + ) + """.trimIndent() + ) + db.execSQL( + "CREATE UNIQUE INDEX IF NOT EXISTS `index_insights_naturalKey` " + + "ON `insights` (`naturalKey`)" + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_insights_generatedAt` " + + "ON `insights` (`generatedAt`)" + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_insights_dismissedAt` " + + "ON `insights` (`dismissedAt`)" + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_insights_snoozedUntil` " + + "ON `insights` (`snoozedUntil`)" + ) + } + } } diff --git a/app/src/main/java/com/ledga/app/data/db/dao/GoalDao.kt b/app/src/main/java/com/ledga/app/data/db/dao/GoalDao.kt new file mode 100644 index 0000000..7e12182 --- /dev/null +++ b/app/src/main/java/com/ledga/app/data/db/dao/GoalDao.kt @@ -0,0 +1,61 @@ +package com.ledga.app.data.db.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import com.ledga.app.data.db.entity.Goal +import com.ledga.app.data.db.entity.GoalContribution +import kotlinx.coroutines.flow.Flow + +data class GoalProgress( + val goalId: Long, + val contributedAmount: Double, +) + +@Dao +interface GoalDao { + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insert(goal: Goal): Long + + @Update + suspend fun update(goal: Goal) + + @Delete + suspend fun delete(goal: Goal) + + @Query("SELECT * FROM goals ORDER BY completedAt IS NULL DESC, createdAt DESC") + fun observeAll(): Flow> + + @Query("SELECT * FROM goals WHERE id = :id LIMIT 1") + suspend fun findById(id: Long): Goal? + + @Query("UPDATE goals SET completedAt = :completedAt WHERE id = :id") + suspend fun markComplete(id: Long, completedAt: Long) + + // ---- Contributions (manual marks) ---- + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertContribution(contribution: GoalContribution) + + @Query("DELETE FROM goal_contributions WHERE goalId = :goalId AND transactionId = :transactionId") + suspend fun removeContribution(goalId: Long, transactionId: Long) + + @Query(""" + SELECT goalId, COALESCE(SUM(t.amount), 0.0) AS contributedAmount + FROM goal_contributions gc + INNER JOIN transactions t ON t.id = gc.transactionId + WHERE goalId = :goalId + GROUP BY goalId + """) + fun observeManualProgress(goalId: Long): Flow + + @Query("SELECT transactionId FROM goal_contributions WHERE goalId = :goalId") + fun observeManualContributionIds(goalId: Long): Flow> + + @Query("SELECT goalId FROM goal_contributions WHERE transactionId = :transactionId") + fun observeGoalIdsForTransaction(transactionId: Long): Flow> +} diff --git a/app/src/main/java/com/ledga/app/data/db/dao/InsightDao.kt b/app/src/main/java/com/ledga/app/data/db/dao/InsightDao.kt new file mode 100644 index 0000000..4295c36 --- /dev/null +++ b/app/src/main/java/com/ledga/app/data/db/dao/InsightDao.kt @@ -0,0 +1,93 @@ +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.Insight +import kotlinx.coroutines.flow.Flow + +@Dao +interface InsightDao { + + /** + * Upsert by natural key. If a row with the same naturalKey exists we + * keep its id + dismiss/snooze state and just refresh content + timestamp. + */ + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertIfAbsent(insight: Insight): Long + + @Query(""" + UPDATE insights + SET headline = :headline, + body = :body, + typeLabel = :typeLabel, + severity = :severity, + ctaLabel = :ctaLabel, + ctaArgs = :ctaArgs, + generatedAt = :generatedAt + WHERE naturalKey = :naturalKey + """) + suspend fun refresh( + naturalKey: String, + typeLabel: String, + severity: String, + headline: String, + body: String?, + ctaLabel: String?, + ctaArgs: String?, + generatedAt: Long, + ) + + @Query("SELECT * FROM insights WHERE naturalKey = :key LIMIT 1") + suspend fun findByKey(key: String): Insight? + + /** + * Active = not dismissed AND (not snoozed OR snooze expired). + * Ordered by severity priority desc, then by generation time desc. + */ + @Query(""" + SELECT * FROM insights + WHERE dismissedAt IS NULL + AND (snoozedUntil IS NULL OR snoozedUntil < :now) + ORDER BY + CASE severity + WHEN 'ALERT' THEN 3 + WHEN 'WARN' THEN 2 + WHEN 'INFO' THEN 1 + WHEN 'NUDGE' THEN 0 + ELSE 0 + END DESC, + generatedAt DESC + """) + fun observeActive(now: Long): Flow> + + @Query(""" + SELECT * FROM insights + WHERE dismissedAt IS NULL + AND (snoozedUntil IS NULL OR snoozedUntil < :now) + ORDER BY + CASE severity + WHEN 'ALERT' THEN 3 + WHEN 'WARN' THEN 2 + WHEN 'INFO' THEN 1 + WHEN 'NUDGE' THEN 0 + ELSE 0 + END DESC, + generatedAt DESC + LIMIT 1 + """) + fun observeTop(now: Long): Flow + + @Query("UPDATE insights SET dismissedAt = :now WHERE id = :id") + suspend fun dismiss(id: Long, now: Long) + + @Query("UPDATE insights SET snoozedUntil = :until WHERE id = :id") + suspend fun snooze(id: Long, until: Long) + + @Query("DELETE FROM insights WHERE id = :id") + suspend fun deleteById(id: Long) + + @Query("DELETE FROM insights WHERE generatedAt < :before AND dismissedAt IS NOT NULL") + suspend fun pruneOldDismissed(before: Long) +} diff --git a/app/src/main/java/com/ledga/app/data/db/dao/MpesaAccountDao.kt b/app/src/main/java/com/ledga/app/data/db/dao/MpesaAccountDao.kt new file mode 100644 index 0000000..036a233 --- /dev/null +++ b/app/src/main/java/com/ledga/app/data/db/dao/MpesaAccountDao.kt @@ -0,0 +1,40 @@ +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.Update +import com.ledga.app.data.db.entity.MpesaAccount +import kotlinx.coroutines.flow.Flow + +@Dao +interface MpesaAccountDao { + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insert(account: MpesaAccount): Long + + @Update + suspend fun update(account: MpesaAccount) + + @Query("SELECT * FROM mpesa_accounts ORDER BY isPrimary DESC, createdAt ASC") + fun observeAll(): Flow> + + @Query("SELECT * FROM mpesa_accounts ORDER BY isPrimary DESC, createdAt ASC") + suspend fun getAll(): List + + @Query("SELECT * FROM mpesa_accounts WHERE subscriptionId = :subscriptionId LIMIT 1") + suspend fun findBySubscriptionId(subscriptionId: Int): MpesaAccount? + + @Query("SELECT * FROM mpesa_accounts WHERE id = :id LIMIT 1") + suspend fun findById(id: Long): MpesaAccount? + + @Query("UPDATE mpesa_accounts SET isPrimary = (id = :id)") + suspend fun setPrimary(id: Long) + + @Query("DELETE FROM mpesa_accounts WHERE id = :id") + suspend fun deleteById(id: Long) + + @Query("SELECT COUNT(*) FROM mpesa_accounts") + fun count(): Flow +} diff --git a/app/src/main/java/com/ledga/app/data/db/dao/TransactionDao.kt b/app/src/main/java/com/ledga/app/data/db/dao/TransactionDao.kt index 9aa4387..ad5ba42 100644 --- a/app/src/main/java/com/ledga/app/data/db/dao/TransactionDao.kt +++ b/app/src/main/java/com/ledga/app/data/db/dao/TransactionDao.kt @@ -26,6 +26,11 @@ data class TopMerchant( val transactionCount: Int ) +/** + * Every account-aware query takes a nullable `accountId` — null means + * "Combined view" (all accounts). The repository wires this from the + * user's selected-account preference. + */ @Dao interface TransactionDao { @@ -35,41 +40,104 @@ interface TransactionDao { @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> + @Query("UPDATE transactions SET accountId = :accountId WHERE id = :id") + suspend fun updateAccount(id: Long, accountId: Long?) + + /** + * Bulk-attribute transactions in a timestamp range to an account. + * Used by the historical-tagging backfill (date-range fallback). + */ + @Query(""" + UPDATE transactions SET accountId = :accountId + WHERE timestamp BETWEEN :startTime AND :endTime + """) + suspend fun updateAccountForRange(accountId: Long?, startTime: Long, endTime: Long): Int + + /** + * Bulk-attribute by transaction code list — used by the SMS-DB backfill + * after we match codes to subscription ids. + */ + @Query(""" + UPDATE transactions SET accountId = :accountId + WHERE transactionCode IN (:codes) + """) + suspend fun updateAccountForCodes(accountId: Long, codes: List): Int @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 + WHERE (:accountId IS NULL OR accountId = :accountId) + ORDER BY timestamp DESC + LIMIT :limit + """) + fun getRecentTransactions(limit: Int = 10, accountId: Long? = null): Flow> - @Query("SELECT * FROM transactions ORDER BY timestamp DESC LIMIT 1") - fun getLatestTransaction(): Flow + @Transaction + @Query(""" + SELECT * FROM transactions + WHERE timestamp BETWEEN :startTime AND :endTime + AND (:accountId IS NULL OR accountId = :accountId) + ORDER BY timestamp DESC + """) + fun getTransactions(startTime: Long, endTime: Long, accountId: Long? = null): Flow> - @Query("SELECT * FROM transactions WHERE balance > 0 ORDER BY timestamp DESC LIMIT 1") - fun getLatestTransactionWithBalance(): Flow + @Query(""" + SELECT * FROM transactions + WHERE (:accountId IS NULL OR accountId = :accountId) + ORDER BY timestamp DESC LIMIT 1 + """) + fun getLatestTransaction(accountId: Long? = null): Flow + + @Query(""" + SELECT * FROM transactions + WHERE balance > 0 + AND (:accountId IS NULL OR accountId = :accountId) + ORDER BY timestamp DESC LIMIT 1 + """) + fun getLatestTransactionWithBalance(accountId: Long? = null): Flow @Query(""" SELECT categoryId, SUM(amount) as totalAmount FROM transactions - WHERE direction = 'OUTFLOW' AND timestamp BETWEEN :startTime AND :endTime + WHERE direction = 'OUTFLOW' + AND timestamp BETWEEN :startTime AND :endTime + AND (:accountId IS NULL OR accountId = :accountId) GROUP BY categoryId """) - fun getSpendingByCategory(startTime: Long, endTime: Long): Flow> + fun getSpendingByCategory(startTime: Long, endTime: Long, accountId: Long? = null): 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(amount), 0.0) FROM transactions + WHERE direction = 'OUTFLOW' + AND timestamp BETWEEN :startTime AND :endTime + AND (:accountId IS NULL OR accountId = :accountId) + """) + fun getTotalSpending(startTime: Long, endTime: Long, accountId: Long? = null): Flow - @Query("SELECT COALESCE(SUM(transactionCost), 0.0) FROM transactions WHERE timestamp BETWEEN :startTime AND :endTime") - fun getTotalFees(startTime: Long, endTime: Long): Flow + @Query(""" + SELECT COALESCE(SUM(transactionCost), 0.0) FROM transactions + WHERE timestamp BETWEEN :startTime AND :endTime + AND (:accountId IS NULL OR accountId = :accountId) + """) + fun getTotalFees(startTime: Long, endTime: Long, accountId: Long? = null): Flow @Transaction - @Query("SELECT * FROM transactions WHERE recipientName LIKE '%' || :query || '%' ORDER BY timestamp DESC") - fun searchTransactions(query: String): Flow> + @Query(""" + SELECT * FROM transactions + WHERE recipientName LIKE '%' || :query || '%' + AND (:accountId IS NULL OR accountId = :accountId) + ORDER BY timestamp DESC + """) + fun searchTransactions(query: String, accountId: Long? = null): Flow> @Transaction - @Query("SELECT * FROM transactions WHERE type IN (:types) ORDER BY timestamp DESC") - fun getTransactionsByType(types: List): Flow> + @Query(""" + SELECT * FROM transactions + WHERE type IN (:types) + AND (:accountId IS NULL OR accountId = :accountId) + ORDER BY timestamp DESC + """) + fun getTransactionsByType(types: List, accountId: Long? = null): Flow> @Query("SELECT * FROM transactions WHERE transactionCode = :code LIMIT 1") suspend fun getTransactionByCode(code: String): TransactionEntity? @@ -78,31 +146,85 @@ interface TransactionDao { @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 + WHERE direction = 'OUTFLOW' + AND timestamp BETWEEN :startTime AND :endTime + AND (:accountId IS NULL OR accountId = :accountId) GROUP BY dayTimestamp ORDER BY dayTimestamp ASC """) - fun getDailySpending(startTime: Long, endTime: Long): Flow> + fun getDailySpending(startTime: Long, endTime: Long, accountId: Long? = null): 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 + AND (:accountId IS NULL OR accountId = :accountId) GROUP BY recipientName ORDER BY totalAmount DESC LIMIT :limit """) - fun getTopMerchants(startTime: Long, endTime: Long, limit: Int = 5): Flow> + fun getTopMerchants(startTime: Long, endTime: Long, accountId: Long? = null, limit: Int = 5): Flow> @Query("SELECT COUNT(*) FROM transactions WHERE type = 'UNKNOWN'") fun getUnparsedCount(): Flow + // ---- Goal progress helpers (Phase D) ---- + // These are account-agnostic by design: a goal spans every line. + + @Query(""" + SELECT COALESCE(SUM(amount), 0.0) FROM transactions + WHERE type IN ('MSHWARI', 'KCB_MPESA') + AND direction = 'OUTFLOW' + AND timestamp >= :since + """) + fun observeSavingsContributionsSince(since: Long): Flow + + @Query(""" + SELECT COALESCE(SUM(amount), 0.0) FROM transactions + WHERE recipientName LIKE '%' || :recipientFragment || '%' COLLATE NOCASE + AND direction = 'OUTFLOW' + AND timestamp >= :since + """) + fun observeRecipientContributionsSince(recipientFragment: String, since: Long): Flow + + /** All transactions in a window — used by Goal detail to list contributors. */ + @Transaction + @Query(""" + SELECT * FROM transactions + WHERE timestamp >= :since + AND type IN ('MSHWARI', 'KCB_MPESA') + AND direction = 'OUTFLOW' + ORDER BY timestamp DESC + """) + fun observeSavingsTransactionsSince(since: Long): Flow> + + @Transaction + @Query(""" + SELECT * FROM transactions + WHERE timestamp >= :since + AND recipientName LIKE '%' || :recipientFragment || '%' COLLATE NOCASE + AND direction = 'OUTFLOW' + ORDER BY timestamp DESC + """) + fun observeRecipientTransactionsSince(recipientFragment: String, since: Long): Flow> + + /** Per-recipient outflow totals, used to suggest recipient candidates when building a goal. */ + @Query(""" + SELECT recipientName as recipientName, SUM(amount) as totalAmount, COUNT(*) as transactionCount + FROM transactions + WHERE direction = 'OUTFLOW' + AND recipientName IS NOT NULL + AND timestamp >= :since + GROUP BY recipientName + ORDER BY totalAmount DESC + LIMIT :limit + """) + fun observeRecipientsSince(since: Long, limit: Int = 20): Flow> + @Query("SELECT * FROM transactions WHERE type = 'UNKNOWN'") suspend fun getUnparsedSync(): List diff --git a/app/src/main/java/com/ledga/app/data/db/entity/Goal.kt b/app/src/main/java/com/ledga/app/data/db/entity/Goal.kt new file mode 100644 index 0000000..2646be3 --- /dev/null +++ b/app/src/main/java/com/ledga/app/data/db/entity/Goal.kt @@ -0,0 +1,84 @@ +package com.ledga.app.data.db.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * Goals — LEDGA_REDESIGN.md §4.6. + * + * ContributionRule is persisted as two flat columns so Room can index + * and query it cheaply. See [ContributionRule.encode]/[decode] in the + * repository for the mapping. + * + * Rule encodings (column = `contributionRule`): + * ALL_SAVINGS_DEPOSITS -> matches every M-Shwari / KCB deposit + * TO_RECIPIENT: -> matches outflows whose recipientName + * contains the fragment (case-insensitive) + * MANUAL -> only counts transactions the user + * explicitly marked as contributions + */ +@Entity( + tableName = "goals", + indices = [Index(value = ["completedAt"])] +) +data class Goal( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + val name: String, + val targetAmount: Double, + /** Epoch millis; null = no ETA, only progress matters. */ + val targetDate: Long?, + /** Encoded ContributionRule. See class header for grammar. */ + val contributionRule: String, + val colorHex: String, + val createdAt: Long = System.currentTimeMillis(), + val completedAt: Long? = null, +) + +/** + * Join row for transactions a user manually marked as contributing to a + * specific goal. Needed because rule-matched contributions are computed, + * but a user override has to be persisted. + */ +@Entity( + tableName = "goal_contributions", + primaryKeys = ["goalId", "transactionId"], + indices = [ + Index(value = ["goalId"]), + Index(value = ["transactionId"]), + ] +) +data class GoalContribution( + val goalId: Long, + val transactionId: Long, + val markedAt: Long = System.currentTimeMillis(), +) + +sealed class ContributionRule { + object AllSavingsDeposits : ContributionRule() + data class ToRecipient(val recipientName: String) : ContributionRule() + object Manual : ContributionRule() + + fun encode(): String = when (this) { + AllSavingsDeposits -> ENC_ALL_SAVINGS + is ToRecipient -> "$ENC_TO_RECIPIENT:$recipientName" + Manual -> ENC_MANUAL + } + + companion object { + private const val ENC_ALL_SAVINGS = "ALL_SAVINGS_DEPOSITS" + private const val ENC_TO_RECIPIENT = "TO_RECIPIENT" + private const val ENC_MANUAL = "MANUAL" + + fun decode(encoded: String): ContributionRule = when { + encoded == ENC_ALL_SAVINGS -> AllSavingsDeposits + encoded == ENC_MANUAL -> Manual + encoded.startsWith("$ENC_TO_RECIPIENT:") -> + ToRecipient(encoded.removePrefix("$ENC_TO_RECIPIENT:")) + // Unknown rule strings degrade gracefully to Manual so a stale row + // never crashes the app. + else -> Manual + } + } +} diff --git a/app/src/main/java/com/ledga/app/data/db/entity/Insight.kt b/app/src/main/java/com/ledga/app/data/db/entity/Insight.kt new file mode 100644 index 0000000..398df7f --- /dev/null +++ b/app/src/main/java/com/ledga/app/data/db/entity/Insight.kt @@ -0,0 +1,83 @@ +package com.ledga.app.data.db.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * A single derived insight surfaced to the user. See LEDGA_REDESIGN.md §4.5. + * + * **Natural-key dedup**: every rule produces a stable [naturalKey] for the + * insight it would emit (e.g. `anomaly:2:2026-W11`). Re-running the rule + * engine upserts on that key, so the same insight doesn't pile up day after + * day, and the user's dismiss/snooze state survives regeneration. + */ +@Entity( + tableName = "insights", + indices = [ + Index(value = ["naturalKey"], unique = true), + Index(value = ["generatedAt"]), + Index(value = ["dismissedAt"]), + Index(value = ["snoozedUntil"]), + ] +) +data class Insight( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + val naturalKey: String, + val type: InsightType, + val severity: InsightSeverity, + /** Short uppercase chip label, e.g. "WATCH OUT", "RECURRING DETECTED". */ + val typeLabel: String, + /** The big "the thing to know" — one sentence. */ + val headline: String, + /** Optional supporting detail line shown under the headline. */ + val body: String?, + /** Optional action button label, e.g. "See transactions". */ + val ctaLabel: String? = null, + /** Free-form args the screen uses to wire navigation, e.g. "category=2". */ + val ctaArgs: String? = null, + val generatedAt: Long, + /** Set to a future epoch ms to hide until that time. */ + val snoozedUntil: Long? = null, + /** Set when the user has dismissed the card. */ + val dismissedAt: Long? = null, +) + +/** + * Visual treatment driver — maps to background tint + icon tint on the card. + */ +enum class InsightSeverity { + /** Neutral / positive nudges. Accent-soft background. */ + NUDGE, + /** General information. Surface background. */ + INFO, + /** Watch-out / unusual activity. Warning-soft background. */ + WARN, + /** Outstanding Fuliza, mistakes worth acting on. Danger-soft background. */ + ALERT, + ; + + /** Sort priority — higher first. */ + val priority: Int + get() = when (this) { + ALERT -> 3 + WARN -> 2 + INFO -> 1 + NUDGE -> 0 + } +} + +enum class InsightType { + ANOMALY, + RECURRING, + FEE_TIP, + FULIZA, + /** + * Fires after an auto-deduction event — distinct from [FULIZA] (which + * warns about an *outstanding* balance) so the UI can treat them + * differently (icon, severity). + */ + FULIZA_AUTO_PAY, + POSITIVE_NUDGE, +} diff --git a/app/src/main/java/com/ledga/app/data/db/entity/MpesaAccount.kt b/app/src/main/java/com/ledga/app/data/db/entity/MpesaAccount.kt new file mode 100644 index 0000000..57a0f58 --- /dev/null +++ b/app/src/main/java/com/ledga/app/data/db/entity/MpesaAccount.kt @@ -0,0 +1,30 @@ +package com.ledga.app.data.db.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * A single M-Pesa line tied to a SIM subscription on this device. + * Multiple accounts let dual-SIM users keep Personal / Business separate. + * + * subscriptionId is the Android SubscriptionInfo.getSubscriptionId() value + * captured at SMS-receive time. It's stable per-SIM across reboots but + * changes if the SIM is removed and reinserted in a different slot. + */ +@Entity( + tableName = "mpesa_accounts", + indices = [ + Index(value = ["subscriptionId"], unique = true), + ] +) +data class MpesaAccount( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + val subscriptionId: Int, + val phoneNumber: String?, + val displayName: String, + val colorHex: String, + val isPrimary: Boolean = false, + val createdAt: Long = System.currentTimeMillis(), +) diff --git a/app/src/main/java/com/ledga/app/data/db/entity/TransactionEntity.kt b/app/src/main/java/com/ledga/app/data/db/entity/TransactionEntity.kt index 0b18163..9b3d8d2 100644 --- a/app/src/main/java/com/ledga/app/data/db/entity/TransactionEntity.kt +++ b/app/src/main/java/com/ledga/app/data/db/entity/TransactionEntity.kt @@ -13,6 +13,7 @@ import com.ledga.app.data.parser.TransactionType indices = [ Index(value = ["transactionCode"], unique = true), Index(value = ["categoryId"]), + Index(value = ["accountId"]), Index(value = ["timestamp"]), Index(value = ["type"]) ], @@ -22,6 +23,12 @@ import com.ledga.app.data.parser.TransactionType parentColumns = ["id"], childColumns = ["categoryId"], onDelete = ForeignKey.SET_NULL + ), + ForeignKey( + entity = MpesaAccount::class, + parentColumns = ["id"], + childColumns = ["accountId"], + onDelete = ForeignKey.SET_NULL ) ] ) @@ -44,5 +51,8 @@ data class TransactionEntity( val reversedTransactionCode: String?, val rawSms: String, val timestamp: Long, - val createdAt: Long = System.currentTimeMillis() + val createdAt: Long = System.currentTimeMillis(), + // v2 additions (DB v2) + val accountId: Long? = null, + val note: String? = null, ) diff --git a/app/src/main/java/com/ledga/app/data/insights/InsightEngine.kt b/app/src/main/java/com/ledga/app/data/insights/InsightEngine.kt new file mode 100644 index 0000000..7b53116 --- /dev/null +++ b/app/src/main/java/com/ledga/app/data/insights/InsightEngine.kt @@ -0,0 +1,89 @@ +package com.ledga.app.data.insights + +import com.ledga.app.data.db.dao.InsightDao +import com.ledga.app.data.db.entity.Insight +import com.ledga.app.data.repository.CategoryRepository +import com.ledga.app.data.repository.TransactionRepository +import kotlinx.coroutines.flow.first +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Orchestrator that runs every [InsightRule] against the same data snapshot + * and persists candidates as [Insight] rows. + * + * Idempotency is the key contract: re-running with no data changes must not + * create duplicate rows or destroy user dismiss/snooze state. We achieve that + * with [InsightCandidate.naturalKey] + an "insert-if-absent, else refresh" + * pattern. + * + * Old dismissed rows older than 30 days are pruned so the table doesn't grow + * unboundedly. + */ +@Singleton +class InsightEngine @Inject constructor( + private val rules: List<@JvmSuppressWildcards InsightRule>, + private val insightDao: InsightDao, + private val transactionRepository: TransactionRepository, + private val categoryRepository: CategoryRepository, +) { + + suspend fun run(now: Long = System.currentTimeMillis()): Int { + val ninetyDaysAgo = now - 90L * DAY_MS + val txns = transactionRepository + .getTransactions(ninetyDaysAgo, now) + .first() + .map { it.transaction } + .sortedByDescending { it.timestamp } + + val categoriesById = categoryRepository + .getAllCategories() + .first() + .associateBy { it.id } + + val ctx = RuleContext(now = now, transactions = txns, categoriesById = categoriesById) + val candidates = rules.flatMap { rule -> + runCatching { rule.evaluate(ctx) } + .onFailure { /* rules are pure — but never let one bad rule poison the run */ } + .getOrDefault(emptyList()) + } + + candidates.forEach { upsert(it, now) } + insightDao.pruneOldDismissed(before = now - 30L * DAY_MS) + return candidates.size + } + + private suspend fun upsert(c: InsightCandidate, now: Long) { + val existing = insightDao.findByKey(c.naturalKey) + if (existing == null) { + insightDao.insertIfAbsent( + Insight( + naturalKey = c.naturalKey, + type = c.type, + severity = c.severity, + typeLabel = c.typeLabel, + headline = c.headline, + body = c.body, + ctaLabel = c.ctaLabel, + ctaArgs = c.ctaArgs, + generatedAt = now, + ) + ) + } else { + insightDao.refresh( + naturalKey = c.naturalKey, + typeLabel = c.typeLabel, + severity = c.severity.name, + headline = c.headline, + body = c.body, + ctaLabel = c.ctaLabel, + ctaArgs = c.ctaArgs, + generatedAt = now, + ) + } + } + + companion object { + private const val DAY_MS = 86_400_000L + } +} diff --git a/app/src/main/java/com/ledga/app/data/insights/InsightRule.kt b/app/src/main/java/com/ledga/app/data/insights/InsightRule.kt new file mode 100644 index 0000000..913192b --- /dev/null +++ b/app/src/main/java/com/ledga/app/data/insights/InsightRule.kt @@ -0,0 +1,38 @@ +package com.ledga.app.data.insights + +import com.ledga.app.data.db.entity.Category +import com.ledga.app.data.db.entity.InsightSeverity +import com.ledga.app.data.db.entity.InsightType +import com.ledga.app.data.db.entity.TransactionEntity + +/** + * What a rule sees when it runs. + * + * Rules are pure functions over this context — no IO, no DAOs — so they're + * trivially unit-testable. The engine loads everything once per run. + */ +data class RuleContext( + val now: Long, + /** All transactions in the last 90 days, newest first. */ + val transactions: List, + val categoriesById: Map, +) + +/** + * One thing a rule would tell the user. The engine maps these to [Insight] + * rows, deduping by [naturalKey]. + */ +data class InsightCandidate( + val naturalKey: String, + val type: InsightType, + val severity: InsightSeverity, + val typeLabel: String, + val headline: String, + val body: String? = null, + val ctaLabel: String? = null, + val ctaArgs: String? = null, +) + +interface InsightRule { + fun evaluate(ctx: RuleContext): List +} diff --git a/app/src/main/java/com/ledga/app/data/insights/rules/AnomalyRule.kt b/app/src/main/java/com/ledga/app/data/insights/rules/AnomalyRule.kt new file mode 100644 index 0000000..d831a32 --- /dev/null +++ b/app/src/main/java/com/ledga/app/data/insights/rules/AnomalyRule.kt @@ -0,0 +1,111 @@ +package com.ledga.app.data.insights.rules + +import com.ledga.app.data.db.entity.InsightSeverity +import com.ledga.app.data.db.entity.InsightType +import com.ledga.app.data.insights.InsightCandidate +import com.ledga.app.data.insights.InsightRule +import com.ledga.app.data.insights.RuleContext +import com.ledga.app.data.parser.TransactionDirection +import java.util.Calendar +import javax.inject.Inject +import kotlin.math.roundToInt +import kotlin.math.sqrt + +/** + * Per-category anomaly detection. + * + * For each category, compute spending in the **current ISO-week** vs the + * mean+stddev of the previous 4 ISO-weeks. Flag when this week is more than + * `MIN_Z` standard deviations above the baseline AND the absolute amount + * crosses [MIN_THRESHOLD] (to suppress noise on tiny categories). + * + * Natural key: `anomaly:{categoryId}:{ISO-year-week}` — so the same insight + * doesn't fire twice in the same week, and the user's dismiss state survives + * regeneration. + */ +class AnomalyRule @Inject constructor() : InsightRule { + + override fun evaluate(ctx: RuleContext): List { + val outflows = ctx.transactions.filter { it.direction == TransactionDirection.OUTFLOW } + if (outflows.isEmpty()) return emptyList() + + val byCategoryByWeek: Map> = outflows + .filter { it.categoryId != null } + .groupBy { it.categoryId!! } + .mapValues { (_, txns) -> + txns.groupBy { weekKey(it.timestamp) } + .mapValues { (_, ts) -> ts.sumOf { it.amount } } + } + + val currentWeek = weekKey(ctx.now) + val candidates = mutableListOf() + + byCategoryByWeek.forEach { (categoryId, weekly) -> + val thisWeek = weekly[currentWeek] ?: return@forEach + if (thisWeek < MIN_THRESHOLD) return@forEach + + val baselineWeeks = previousWeekKeys(ctx.now, n = 4) + val baseline = baselineWeeks.map { weekly[it] ?: 0.0 } + if (baseline.all { it == 0.0 }) return@forEach // no history to compare + + val mean = baseline.average() + val variance = baseline.map { (it - mean) * (it - mean) }.average() + val stddev = sqrt(variance) + // If baseline is mostly zero with a tiny stddev, fall back to a flat + // 80% spike threshold so we still catch obvious jumps. + val triggered = if (stddev > 1.0) { + (thisWeek - mean) / stddev >= MIN_Z + } else { + mean > 0.0 && thisWeek >= mean * 1.8 + } + if (!triggered) return@forEach + + val category = ctx.categoriesById[categoryId] ?: return@forEach + val pctOver = if (mean > 0.0) { + ((thisWeek / mean) - 1.0).times(100).roundToInt() + } else 100 + + candidates += InsightCandidate( + naturalKey = "anomaly:$categoryId:$currentWeek", + type = InsightType.ANOMALY, + severity = InsightSeverity.WARN, + typeLabel = "WATCH OUT", + headline = "You're spending $pctOver% more on " + + "${category.name.lowercase()} this week than usual.", + body = "Typical week ~Ksh ${formatCompact(mean)}; this week Ksh ${formatCompact(thisWeek)}.", + ctaLabel = "See transactions", + ctaArgs = "category=$categoryId", + ) + } + return candidates + } + + private fun weekKey(timestampMs: Long): String { + val cal = Calendar.getInstance().apply { timeInMillis = timestampMs } + // ISO-style: yyyy-Www + val year = cal.weekYear + val week = cal.get(Calendar.WEEK_OF_YEAR) + return "%d-W%02d".format(year, week) + } + + private fun previousWeekKeys(nowMs: Long, n: Int): List { + val cal = Calendar.getInstance().apply { timeInMillis = nowMs } + return (1..n).map { + cal.add(Calendar.WEEK_OF_YEAR, -1) + weekKey(cal.timeInMillis) + } + } + + private fun formatCompact(amount: Double): String = when { + amount >= 1000 -> "%.1fK".format(amount / 1000) + else -> "%.0f".format(amount) + } + + companion object { + /** Minimum z-score over the rolling baseline to fire. */ + const val MIN_Z = 1.5 + + /** Minimum absolute weekly spend to consider — suppresses noise. */ + const val MIN_THRESHOLD = 1000.0 + } +} diff --git a/app/src/main/java/com/ledga/app/data/insights/rules/FeeTipRule.kt b/app/src/main/java/com/ledga/app/data/insights/rules/FeeTipRule.kt new file mode 100644 index 0000000..ffb3432 --- /dev/null +++ b/app/src/main/java/com/ledga/app/data/insights/rules/FeeTipRule.kt @@ -0,0 +1,82 @@ +package com.ledga.app.data.insights.rules + +import com.ledga.app.data.db.entity.InsightSeverity +import com.ledga.app.data.db.entity.InsightType +import com.ledga.app.data.insights.InsightCandidate +import com.ledga.app.data.insights.InsightRule +import com.ledga.app.data.insights.RuleContext +import com.ledga.app.data.parser.TransactionType +import java.util.Calendar +import javax.inject.Inject +import kotlin.math.roundToInt + +/** + * Fee-tip insight. + * + * If total transaction costs in the **current calendar month** exceed + * [MIN_FEES_KSH], surface a tip pointing out where the fees came from. + * The dominant fee source drives the copy. + * + * Natural key: `fees:{yyyy-MM}` — one per month, refreshes as the running + * total climbs through the month. + */ +class FeeTipRule @Inject constructor() : InsightRule { + + override fun evaluate(ctx: RuleContext): List { + val monthStart = startOfMonth(ctx.now) + val monthTxns = ctx.transactions.filter { it.timestamp >= monthStart } + val totalFees = monthTxns.sumOf { it.transactionCost } + if (totalFees < MIN_FEES_KSH) return emptyList() + + val byType = monthTxns + .filter { it.transactionCost > 0 } + .groupBy { it.type } + .mapValues { (_, txns) -> txns.sumOf { it.transactionCost } } + + val dominant = byType.maxByOrNull { it.value } + val dominantSource = dominant?.key?.let(::sourceLabel) ?: "transactions" + val dominantAmount = dominant?.value ?: 0.0 + + return listOf( + InsightCandidate( + naturalKey = "fees:${monthKey(ctx.now)}", + type = InsightType.FEE_TIP, + severity = InsightSeverity.INFO, + typeLabel = "FEE TIP", + headline = "You paid Ksh ${totalFees.roundToInt()} in fees this month.", + body = "Most came from $dominantSource (Ksh ${dominantAmount.roundToInt()}). " + + "Fewer, larger withdrawals could trim this.", + ctaLabel = null, + ) + ) + } + + private fun sourceLabel(type: TransactionType): String = when (type) { + TransactionType.WITHDRAW_AGENT -> "agent withdrawals" + TransactionType.WITHDRAW_ATM -> "ATM withdrawals" + TransactionType.SEND -> "person-to-person sends" + TransactionType.PAY_BILL -> "paybill payments" + TransactionType.BUY_GOODS -> "till payments" + TransactionType.MPESA_GLOBAL -> "international transfers" + else -> type.name.lowercase().replace('_', ' ') + } + + private fun startOfMonth(nowMs: Long): Long = + Calendar.getInstance().apply { + timeInMillis = nowMs + 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 monthKey(nowMs: Long): String { + val cal = Calendar.getInstance().apply { timeInMillis = nowMs } + return "%d-%02d".format(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1) + } + + companion object { + const val MIN_FEES_KSH = 200.0 + } +} diff --git a/app/src/main/java/com/ledga/app/data/insights/rules/FulizaAutoPayRule.kt b/app/src/main/java/com/ledga/app/data/insights/rules/FulizaAutoPayRule.kt new file mode 100644 index 0000000..f9a5f07 --- /dev/null +++ b/app/src/main/java/com/ledga/app/data/insights/rules/FulizaAutoPayRule.kt @@ -0,0 +1,105 @@ +package com.ledga.app.data.insights.rules + +import com.ledga.app.data.db.entity.InsightSeverity +import com.ledga.app.data.db.entity.InsightType +import com.ledga.app.data.db.entity.TransactionEntity +import com.ledga.app.data.insights.InsightCandidate +import com.ledga.app.data.insights.InsightRule +import com.ledga.app.data.insights.RuleContext +import com.ledga.app.data.parser.TransactionType +import javax.inject.Inject +import kotlin.math.roundToInt + +/** + * Tells the user that money they received was auto-deducted to repay + * Fuliza — connecting the two SMS that arrive in quick succession. + * + * Without this, users see "Ksh 1,000 received" followed seconds later + * by "Ksh 800 used to pay your Fuliza" and have to mentally connect the + * dots. The insight makes the cause-and-effect explicit. + * + * **Natural key** is tied to the specific auto-pay transaction code so + * each event surfaces exactly once and persists in history. The rule + * looks at auto-pays within the last [WINDOW_DAYS] days so a stale row + * doesn't keep generating "new" insights forever. + */ +class FulizaAutoPayRule @Inject constructor() : InsightRule { + + override fun evaluate(ctx: RuleContext): List { + val cutoff = ctx.now - WINDOW_DAYS * DAY_MS + val recent = ctx.transactions + .asSequence() + .filter { it.type == TransactionType.FULIZA_AUTO_PAY } + .filter { it.timestamp >= cutoff } + .sortedByDescending { it.timestamp } + .take(MAX_INSIGHTS) + .toList() + + return recent.map { tx -> + val isFullClear = tx.recipientName?.contains("cleared", ignoreCase = true) == true + val nearbyReceived = findRecentReceived(ctx.transactions, tx) + + val headline = if (isFullClear) { + "Ksh ${tx.amount.roundToInt()} cleared your Fuliza overdraft." + } else { + "Ksh ${tx.amount.roundToInt()} went toward your Fuliza overdraft." + } + + val sourceCopy = nearbyReceived?.let { + val from = it.recipientName?.takeIf { n -> n.isNotBlank() } + ?: it.recipientPhone + ?: "money you received" + "Triggered by Ksh ${it.amount.roundToInt()} from $from. " + } ?: "" + + val body = if (isFullClear) { + "${sourceCopy}Money landed in your wallet and the overdraft auto-cleared." + } else { + "${sourceCopy}Money landed in your wallet and was auto-deducted. " + + "Some Fuliza is still outstanding." + } + + InsightCandidate( + naturalKey = "fuliza_auto_pay:${tx.transactionCode}", + type = InsightType.FULIZA_AUTO_PAY, + // Full clear is mildly positive (debt gone), partial is informational. + severity = if (isFullClear) InsightSeverity.NUDGE else InsightSeverity.INFO, + typeLabel = if (isFullClear) "FULIZA CLEARED" else "FULIZA AUTO-DEDUCT", + headline = headline, + body = body, + ) + } + } + + /** + * The RECEIVED transaction that most plausibly triggered this auto-pay. + * Heuristic: latest RECEIVED in the [PAIR_WINDOW_MIN] minutes before the + * auto-pay. Returns null if no such transaction exists (common when the + * trigger was a deposit or a later inflow). + */ + private fun findRecentReceived( + all: List, + autoPay: TransactionEntity, + ): TransactionEntity? { + val windowStart = autoPay.timestamp - PAIR_WINDOW_MIN * 60_000L + return all.asSequence() + .filter { it.type == TransactionType.RECEIVED } + .filter { it.timestamp in windowStart..autoPay.timestamp } + .maxByOrNull { it.timestamp } + } + + companion object { + /** Don't re-surface auto-pays older than this. */ + const val WINDOW_DAYS = 7L + + /** Defense against pathological histories: never emit more than this + * many in a single rule pass. The engine upserts by natural key, so + * caps just bound work per run, not total events captured. */ + const val MAX_INSIGHTS = 5 + + /** How close in time a RECEIVED must be to count as the trigger. */ + const val PAIR_WINDOW_MIN = 15L + + private const val DAY_MS = 86_400_000L + } +} diff --git a/app/src/main/java/com/ledga/app/data/insights/rules/FulizaRule.kt b/app/src/main/java/com/ledga/app/data/insights/rules/FulizaRule.kt new file mode 100644 index 0000000..d8f0ce0 --- /dev/null +++ b/app/src/main/java/com/ledga/app/data/insights/rules/FulizaRule.kt @@ -0,0 +1,55 @@ +package com.ledga.app.data.insights.rules + +import com.ledga.app.data.db.entity.InsightSeverity +import com.ledga.app.data.db.entity.InsightType +import com.ledga.app.data.insights.InsightCandidate +import com.ledga.app.data.insights.InsightRule +import com.ledga.app.data.insights.RuleContext +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import kotlin.math.roundToInt + +/** + * Fuliza outstanding-balance alert. + * + * Inspects the latest transaction whose `fulizaOutstanding` is non-null and + * fires an ALERT if the outstanding amount is positive. Recorded with a + * fixed natural key so there's always at most one Fuliza card active. + * + * The card auto-resolves: when a later transaction shows the outstanding is + * back to zero, the rule emits no candidate, and the engine will fall back + * to the previously generated card eventually expiring via [pruneOldDismissed]. + * In the meantime we mark it dismissed implicitly by stamping `dismissedAt` + * — that happens in the repository's `generateAll` after every rule pass. + */ +class FulizaRule @Inject constructor() : InsightRule { + + override fun evaluate(ctx: RuleContext): List { + val latestWithFuliza = ctx.transactions + .firstOrNull { it.fulizaOutstanding != null } + ?: return emptyList() + val outstanding = latestWithFuliza.fulizaOutstanding ?: 0.0 + if (outstanding <= 0.0) return emptyList() + + val daysAgo = TimeUnit.MILLISECONDS.toDays(ctx.now - latestWithFuliza.timestamp) + val ago = when (daysAgo) { + 0L -> "today" + 1L -> "yesterday" + else -> "$daysAgo days ago" + } + + return listOf( + InsightCandidate( + naturalKey = "fuliza:outstanding", + type = InsightType.FULIZA, + severity = InsightSeverity.ALERT, + typeLabel = "FULIZA OVERDRAFT", + headline = "You owe Ksh ${outstanding.roundToInt()} on Fuliza.", + body = "Drawn $ago. Any money you receive on M-Pesa is " + + "auto-deducted toward this — a daily charge keeps " + + "adding until it's cleared.", + ctaLabel = "How Fuliza charges work", + ) + ) + } +} diff --git a/app/src/main/java/com/ledga/app/data/insights/rules/PositiveNudgeRule.kt b/app/src/main/java/com/ledga/app/data/insights/rules/PositiveNudgeRule.kt new file mode 100644 index 0000000..8b1a8d6 --- /dev/null +++ b/app/src/main/java/com/ledga/app/data/insights/rules/PositiveNudgeRule.kt @@ -0,0 +1,105 @@ +package com.ledga.app.data.insights.rules + +import com.ledga.app.data.db.entity.InsightSeverity +import com.ledga.app.data.db.entity.InsightType +import com.ledga.app.data.insights.InsightCandidate +import com.ledga.app.data.insights.InsightRule +import com.ledga.app.data.insights.RuleContext +import com.ledga.app.data.parser.TransactionDirection +import java.util.Calendar +import javax.inject.Inject +import kotlin.math.roundToInt + +/** + * Per-category "nice — this is down" nudge. + * + * Compares this calendar-month's outflow per category against last month's. + * Fires NUDGE severity when this month is ≤[IMPROVEMENT_RATIO] × last month + * AND last month was at least [MIN_BASELINE] (so we don't celebrate + * dropping from 50 → 30 shillings). + * + * Natural key: `nudge:{categoryId}:{yyyy-MM}` — one per category per month. + */ +class PositiveNudgeRule @Inject constructor() : InsightRule { + + override fun evaluate(ctx: RuleContext): List { + val thisMonthStart = startOfMonth(ctx.now) + val lastMonthStart = startOfMonth(thisMonthStart - 1) + + val outflows = ctx.transactions.filter { + it.direction == TransactionDirection.OUTFLOW && it.categoryId != null + } + + val thisMonth = outflows + .filter { it.timestamp >= thisMonthStart } + .groupBy { it.categoryId!! } + .mapValues { (_, txns) -> txns.sumOf { it.amount } } + + val lastMonth = outflows + .filter { it.timestamp in lastMonthStart until thisMonthStart } + .groupBy { it.categoryId!! } + .mapValues { (_, txns) -> txns.sumOf { it.amount } } + + val monthKey = monthKey(ctx.now) + val candidates = mutableListOf() + + thisMonth.forEach { (categoryId, current) -> + val previous = lastMonth[categoryId] ?: return@forEach + if (previous < MIN_BASELINE) return@forEach + if (current > previous * IMPROVEMENT_RATIO) return@forEach + + val category = ctx.categoriesById[categoryId] ?: return@forEach + if (category.name in EXCLUDED_CATEGORIES) return@forEach + + val pctDown = ((1.0 - current / previous) * 100).roundToInt() + + candidates += InsightCandidate( + naturalKey = "nudge:$categoryId:$monthKey", + type = InsightType.POSITIVE_NUDGE, + severity = InsightSeverity.NUDGE, + typeLabel = "NICE", + headline = "${category.name} is down $pctDown% this month. Keep it up!", + body = null, + ) + } + return candidates + } + + private fun startOfMonth(timestamp: Long): Long = + 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 monthKey(nowMs: Long): String { + val cal = Calendar.getInstance().apply { timeInMillis = nowMs } + return "%d-%02d".format(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1) + } + + companion object { + /** This month must be ≤ this fraction of last month to fire. */ + const val IMPROVEMENT_RATIO = 0.85 + + /** Last month must have at least this many shillings to count. */ + const val MIN_BASELINE = 2000.0 + + /** + * Categories whose "down" direction is ambiguous and shouldn't fire + * a celebratory nudge. + * - "Other" → uncategorized; movement here usually means recategorization. + * - "Savings & Loans" → groups Fuliza (less = good) with M-Shwari/KCB + * deposits (less = bad). Without splitting it, the nudge is mixed. + * - "Received" → it's inflow; the rule already filters to OUTFLOW, + * but defense-in-depth doesn't hurt. + */ + private val EXCLUDED_CATEGORIES = setOf( + "Other", + "Savings & Loans", + "Received", + ) + } +} diff --git a/app/src/main/java/com/ledga/app/data/insights/rules/RecurringRule.kt b/app/src/main/java/com/ledga/app/data/insights/rules/RecurringRule.kt new file mode 100644 index 0000000..9c998df --- /dev/null +++ b/app/src/main/java/com/ledga/app/data/insights/rules/RecurringRule.kt @@ -0,0 +1,93 @@ +package com.ledga.app.data.insights.rules + +import com.ledga.app.data.db.entity.InsightSeverity +import com.ledga.app.data.db.entity.InsightType +import com.ledga.app.data.insights.InsightCandidate +import com.ledga.app.data.insights.InsightRule +import com.ledga.app.data.insights.RuleContext +import com.ledga.app.data.parser.TransactionDirection +import com.ledga.app.data.parser.TransactionType +import javax.inject.Inject +import kotlin.math.abs +import kotlin.math.roundToInt + +/** + * Recurring-payment detection. + * + * For each outflow merchant (recipientName) with ≥3 transactions in the + * last 90 days, compute consecutive-interval days. If the median interval + * is within ±[INTERVAL_SLOP_DAYS] days of 30, flag as a recurring monthly + * payment. + * + * Copy differentiates "monthly bill" (paybill / buy-goods → businesses) + * from "recurring payment" (send-to-person → likely rent, allowances, etc). + * Both are useful but reading "X looks like a monthly bill" for a person + * name is jarring. + * + * Natural key: `recurring:{merchantHash}` — one persistent insight per + * merchant. Re-runs refresh content (e.g., updated average amount). + */ +class RecurringRule @Inject constructor() : InsightRule { + + override fun evaluate(ctx: RuleContext): List { + val byMerchant = ctx.transactions + .asSequence() + .filter { it.direction == TransactionDirection.OUTFLOW } + .filter { it.recipientName != null } + .groupBy { it.recipientName!! } + .filterValues { it.size >= MIN_OCCURRENCES } + + return byMerchant.mapNotNull { (merchant, txns) -> + val sorted = txns.sortedBy { it.timestamp } + val intervals = sorted.zipWithNext { a, b -> + (b.timestamp - a.timestamp) / DAY_MS + } + val median = intervals.sorted()[intervals.size / 2] + val isMonthly = abs(median - 30) <= INTERVAL_SLOP_DAYS + if (!isMonthly) return@mapNotNull null + + // Distinguish "monthly bill" (looks like a business) from "monthly + // payment" (looks like a person — name has multiple words). + val looksLikeBusiness = sorted.any { + it.type in BUSINESS_TYPES + } || !merchant.trim().contains(' ') + val descriptor = if (looksLikeBusiness) "a monthly bill" + else "a monthly payment" + + val avg = sorted.map { it.amount }.average() + InsightCandidate( + naturalKey = "recurring:${merchant.hashCode()}", + type = InsightType.RECURRING, + severity = InsightSeverity.INFO, + typeLabel = "RECURRING DETECTED", + headline = "${merchant.titlecase()} looks like $descriptor.", + body = "Avg Ksh ${formatCompact(avg)} · last ${sorted.size} months. " + + "Consider pinning it to your budget.", + ctaLabel = "Add to budget", + ctaArgs = "merchant=$merchant", + ) + } + } + + private fun String.titlecase(): String = + split(" ").joinToString(" ") { word -> + word.lowercase().replaceFirstChar { c -> c.uppercase() } + } + + private fun formatCompact(amount: Double): String = when { + amount >= 1000 -> "%.1fK".format(amount / 1000) + else -> amount.roundToInt().toString() + } + + companion object { + const val MIN_OCCURRENCES = 3 + const val INTERVAL_SLOP_DAYS = 7L + private const val DAY_MS = 86_400_000L + + /** Types that strongly indicate a business / bill, not a person. */ + private val BUSINESS_TYPES = setOf( + TransactionType.PAY_BILL, + TransactionType.BUY_GOODS, + ) + } +} diff --git a/app/src/main/java/com/ledga/app/data/parser/MpesaSmsParser.kt b/app/src/main/java/com/ledga/app/data/parser/MpesaSmsParser.kt index 8fe18fb..d9d9b51 100644 --- a/app/src/main/java/com/ledga/app/data/parser/MpesaSmsParser.kt +++ b/app/src/main/java/com/ledga/app/data/parser/MpesaSmsParser.kt @@ -150,7 +150,10 @@ object MpesaSmsParser { private fun isDeposit(sms: String) = sms.contains("You have deposited", ignoreCase = true) || - sms.contains("deposited", ignoreCase = true) + sms.contains("deposited", ignoreCase = true) || + // "Give KshX cash to AGENT" — agent-deposit format, user hands cash in + Regex("""Give\s+Ksh\s?[\d,]+\.\d{2}\s+cash\s+to""", RegexOption.IGNORE_CASE) + .containsMatchIn(sms) private fun isReceived(sms: String) = sms.contains("You have received", ignoreCase = true) @@ -186,24 +189,37 @@ object MpesaSmsParser { sms.contains("paid to", ignoreCase = true) && !sms.contains("Account Number", ignoreCase = true) && !sms.contains("for account", ignoreCase = true) - private fun isSendMoney(sms: String) = - sms.contains("sent to", ignoreCase = true) + private fun isSendMoney(sms: String): Boolean { + // Standard: "Ksh500.00 sent to JOHN ..." + if (sms.contains("sent to", ignoreCase = true)) return true + // Variant: "You have sent Ksh500.00 to Hustler Fund on ..." + // Amount sits between "sent" and "to", so the literal "sent to" miss. + return Regex("""\bsent\s+Ksh\s?[\d,]+\.\d{2}\s+to\b""", RegexOption.IGNORE_CASE) + .containsMatchIn(sms) + } // --- Parsers --- private fun parseSendMoney(sms: String, code: String, balance: Double, cost: Double, timestamp: Long): ParsedTransaction { val amount = extractFirstAmount(sms) ?: 0.0 - // Try standard: "sent to NAME PHONE on" + // Standard: "sent to NAME PHONE on" val sendToRegex = Regex("""sent to\s+(.+?)\s+(\d{10,})\s+on""", RegexOption.IGNORE_CASE) val match = sendToRegex.find(sms) - // Try alt: "sent to NAME on" (no phone, e.g. Hustler Fund) + // Alt 1: "sent to NAME on" (no phone) val sendToAltRegex = Regex("""sent to\s+(.+?)\s+(?:on|for)""", RegexOption.IGNORE_CASE) - val altMatch = if (match == null) sendToAltRegex.find(sms) else null + // Alt 2: "sent KshX to NAME on" — Hustler Fund and similar products + val sendAmountToRegex = Regex( + """sent\s+Ksh\s?[\d,]+\.\d{2}\s+to\s+(.+?)\s+on""", + RegexOption.IGNORE_CASE, + ) + val altMatch = match + ?: sendToAltRegex.find(sms) + ?: sendAmountToRegex.find(sms) return ParsedTransaction( transactionCode = code, type = TransactionType.SEND, amount = amount, transactionCost = cost, - recipientName = match?.groupValues?.get(1)?.trim() ?: altMatch?.groupValues?.get(1)?.trim(), + recipientName = (match ?: altMatch)?.groupValues?.get(1)?.trim(), recipientPhone = match?.groupValues?.get(2), accountNumber = null, destinationCountry = null, balance = balance, direction = TransactionDirection.OUTFLOW, @@ -291,9 +307,17 @@ object MpesaSmsParser { private fun parseDeposit(sms: String, code: String, balance: Double, timestamp: Long): ParsedTransaction { val amount = extractFirstAmount(sms) ?: 0.0 + // "Give Ksh300.00 cash to Jamag Holdings ... New M-PESA balance" — extract the agent. + val giveCashRegex = Regex( + """Give\s+Ksh\s?[\d,]+\.\d{2}\s+cash\s+to\s+(.+?)\s+New M-PESA balance""", + RegexOption.IGNORE_CASE, + ) + val giveCashMatch = giveCashRegex.find(sms) return ParsedTransaction( transactionCode = code, type = TransactionType.DEPOSIT, amount = amount, - transactionCost = 0.0, recipientName = null, recipientPhone = null, + transactionCost = 0.0, + recipientName = giveCashMatch?.groupValues?.get(1)?.trim(), + recipientPhone = null, accountNumber = null, destinationCountry = null, balance = balance, direction = TransactionDirection.INFLOW, fulizaAmount = null, fulizaOutstanding = null, reversedTransactionCode = null, timestamp = timestamp, rawSms = sms @@ -372,6 +396,8 @@ object MpesaSmsParser { } // "Ksh X from your M-PESA has been used to fully/partially pay your outstanding Fuliza" + // Fuliza is M-Pesa's overdraft loan; when fresh money lands, the wallet + // auto-deducts to repay it. private fun parseFulizaAutoPay(sms: String, code: String, balance: Double, timestamp: Long): ParsedTransaction { val amount = extractFirstAmount(sms) ?: 0.0 val fulizaLimit = FULIZA_LIMIT_REGEX.find(sms)?.groupValues?.get(1)?.let { parseAmount(it) } @@ -379,7 +405,9 @@ object MpesaSmsParser { return ParsedTransaction( transactionCode = code, type = TransactionType.FULIZA_AUTO_PAY, amount = amount, - transactionCost = 0.0, recipientName = if (isFullPay) "Fuliza Full Payment" else "Fuliza Partial Payment", + transactionCost = 0.0, + recipientName = if (isFullPay) "Fuliza overdraft (auto-cleared)" + else "Fuliza overdraft (auto-partial)", recipientPhone = null, accountNumber = null, destinationCountry = null, balance = balance, direction = TransactionDirection.OUTFLOW, fulizaAmount = null, fulizaOutstanding = fulizaLimit, diff --git a/app/src/main/java/com/ledga/app/data/repository/AccountsRepository.kt b/app/src/main/java/com/ledga/app/data/repository/AccountsRepository.kt new file mode 100644 index 0000000..6aec85c --- /dev/null +++ b/app/src/main/java/com/ledga/app/data/repository/AccountsRepository.kt @@ -0,0 +1,109 @@ +package com.ledga.app.data.repository + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.telephony.SubscriptionManager +import androidx.core.content.ContextCompat +import com.ledga.app.data.db.dao.MpesaAccountDao +import com.ledga.app.data.db.entity.MpesaAccount +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AccountsRepository @Inject constructor( + private val accountDao: MpesaAccountDao, + @ApplicationContext private val context: Context, +) { + + /** Default color rotation when we auto-create accounts. */ + private val autoColors = listOf( + "#10A37F", // accent + "#3478F6", // transport blue + "#A155F0", // airtime purple + "#EC4072", // food pink + ) + + fun observeAll(): Flow> = accountDao.observeAll() + fun observeCount(): Flow = accountDao.count() + suspend fun getAll(): List = accountDao.getAll() + suspend fun findById(id: Long): MpesaAccount? = accountDao.findById(id) + + /** + * Look up an account by Android SubscriptionInfo id, creating one on + * first sight. Carrier name / phone number is filled in from + * SubscriptionManager when READ_PHONE_STATE is granted; otherwise we + * fall back to "Line {n}". + * + * Returns null when [subscriptionId] is [SubscriptionManager.INVALID_SUBSCRIPTION_ID] + * — single-SIM devices or pre-Lollipop intents. + */ + suspend fun getOrCreateForSubscription(subscriptionId: Int): MpesaAccount? { + if (subscriptionId == SubscriptionManager.INVALID_SUBSCRIPTION_ID) return null + + accountDao.findBySubscriptionId(subscriptionId)?.let { return it } + + val (displayName, phone) = lookupSubscriptionInfo(subscriptionId) + val existingCount = accountDao.getAll().size + val nextNumber = existingCount + 1 + + val account = MpesaAccount( + subscriptionId = subscriptionId, + phoneNumber = phone, + displayName = displayName ?: "Line $nextNumber", + colorHex = autoColors[existingCount % autoColors.size], + isPrimary = existingCount == 0, + ) + val id = accountDao.insert(account) + // If insert returned -1 (race: another thread inserted) re-read. + return if (id == -1L) { + accountDao.findBySubscriptionId(subscriptionId) + } else { + account.copy(id = id) + } + } + + suspend fun rename(id: Long, name: String) { + accountDao.findById(id)?.copy(displayName = name)?.let { accountDao.update(it) } + } + + suspend fun recolor(id: Long, hex: String) { + accountDao.findById(id)?.copy(colorHex = hex)?.let { accountDao.update(it) } + } + + suspend fun setPrimary(id: Long) { + accountDao.setPrimary(id) + } + + suspend fun delete(id: Long) { + accountDao.deleteById(id) + } + + /** + * Resolve display name + phone number for a subscription ID. + * Returns nulls when READ_PHONE_STATE is denied (graceful degradation). + */ + private fun lookupSubscriptionInfo(subscriptionId: Int): Pair { + if (ContextCompat.checkSelfPermission( + context, + Manifest.permission.READ_PHONE_STATE + ) != PackageManager.PERMISSION_GRANTED + ) { + return null to null + } + return try { + val mgr = context.getSystemService(SubscriptionManager::class.java) + ?: return null to null + @Suppress("MissingPermission") + val info = mgr.getActiveSubscriptionInfo(subscriptionId) + val name = info?.displayName?.toString()?.takeIf { it.isNotBlank() } + @Suppress("MissingPermission") + val number = info?.number?.takeIf { it.isNotBlank() } + name to number + } catch (_: SecurityException) { + null to null + } + } +} diff --git a/app/src/main/java/com/ledga/app/data/repository/GoalsRepository.kt b/app/src/main/java/com/ledga/app/data/repository/GoalsRepository.kt new file mode 100644 index 0000000..a2a1129 --- /dev/null +++ b/app/src/main/java/com/ledga/app/data/repository/GoalsRepository.kt @@ -0,0 +1,203 @@ +package com.ledga.app.data.repository + +import com.ledga.app.data.db.dao.GoalDao +import com.ledga.app.data.db.dao.TransactionDao +import com.ledga.app.data.db.entity.ContributionRule +import com.ledga.app.data.db.entity.Goal +import com.ledga.app.data.db.entity.GoalContribution +import com.ledga.app.data.db.entity.TransactionWithCategory +import com.ledga.app.data.parser.TransactionType +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.math.roundToLong + +/** + * A goal plus its live progress, ETA and status. Built by [GoalsRepository] + * by combining the goal row with whichever progress flow matches its rule. + */ +data class GoalWithProgress( + val goal: Goal, + val currentAmount: Double, + val percent: Float, + val eta: Long?, + val status: GoalStatus, +) + +enum class GoalStatus { OnTrack, Behind, Ahead, Completed } + +@OptIn(ExperimentalCoroutinesApi::class) +@Singleton +class GoalsRepository @Inject constructor( + private val goalDao: GoalDao, + private val transactionDao: TransactionDao, +) { + + // ---- CRUD ---- + + suspend fun create( + name: String, + targetAmount: Double, + targetDate: Long?, + rule: ContributionRule, + colorHex: String, + ): Long = goalDao.insert( + Goal( + name = name.trim(), + targetAmount = targetAmount, + targetDate = targetDate, + contributionRule = rule.encode(), + colorHex = colorHex, + ) + ) + + suspend fun update(goal: Goal) = goalDao.update(goal) + suspend fun delete(goal: Goal) = goalDao.delete(goal) + suspend fun findById(id: Long): Goal? = goalDao.findById(id) + + suspend fun markComplete(id: Long) { + goalDao.markComplete(id, System.currentTimeMillis()) + } + + // ---- Manual contributions ---- + + suspend fun addManualContribution(goalId: Long, transactionId: Long) { + goalDao.upsertContribution(GoalContribution(goalId = goalId, transactionId = transactionId)) + } + + suspend fun removeManualContribution(goalId: Long, transactionId: Long) { + goalDao.removeContribution(goalId, transactionId) + } + + fun observeManualContributionIds(goalId: Long): Flow> = + goalDao.observeManualContributionIds(goalId) + + /** Which goals contain this transaction as a manual contribution. */ + fun observeGoalIdsForTransaction(transactionId: Long): Flow> = + goalDao.observeGoalIdsForTransaction(transactionId) + + /** All goals with the Manual rule — what the detail sheet shows. */ + fun observeManualGoals(): Flow> = + goalDao.observeAll().map { all -> + all.filter { ContributionRule.decode(it.contributionRule) is ContributionRule.Manual } + } + + // ---- Progress observation ---- + + /** + * Stream of all goals with progress computed live. Updates on any + * transaction insert that affects an active rule. + */ + fun observeGoalsWithProgress(): Flow> = + goalDao.observeAll().flatMapLatest { goals -> + if (goals.isEmpty()) return@flatMapLatest flowOf(emptyList()) + // Combine each goal with its own progress flow. + val progressFlows = goals.map { goal -> progressFlow(goal).map { goal to it } } + combine(progressFlows) { pairs -> + pairs.map { (goal, amount) -> goal.toWithProgress(amount) } + } + } + + /** Live progress for one goal — handy for the detail screen. */ + fun observeProgress(goalId: Long): Flow = + flow { emit(goalDao.findById(goalId)) } + .flatMapLatest { goal -> + if (goal == null) flowOf(null) + else progressFlow(goal).map { goal.toWithProgress(it) } + } + + /** Contributing transactions for a goal — used by the detail screen list. */ + fun observeContributingTransactions(goal: Goal): Flow> = + when (val rule = ContributionRule.decode(goal.contributionRule)) { + ContributionRule.AllSavingsDeposits -> + transactionDao.observeSavingsTransactionsSince(goal.createdAt) + is ContributionRule.ToRecipient -> + transactionDao.observeRecipientTransactionsSince(rule.recipientName, goal.createdAt) + ContributionRule.Manual -> + flowOf(emptyList()) + } + + // ---- Internal: per-rule progress flow ---- + + private fun progressFlow(goal: Goal): Flow = + when (val rule = ContributionRule.decode(goal.contributionRule)) { + ContributionRule.AllSavingsDeposits -> + transactionDao.observeSavingsContributionsSince(goal.createdAt) + is ContributionRule.ToRecipient -> + transactionDao.observeRecipientContributionsSince(rule.recipientName, goal.createdAt) + ContributionRule.Manual -> + goalDao.observeManualProgress(goal.id).map { it?.contributedAmount ?: 0.0 } + } + + /** + * The progress→status mapping. + * + * - `Completed` when target reached. + * - For dated goals: project expected progress at "now" given a linear + * pace from createdAt → targetDate. Within ±10% = OnTrack, otherwise + * Behind / Ahead. + * - For undated goals: just OnTrack until target is reached. We don't + * have signal to call it "Behind". + */ + private fun Goal.toWithProgress(currentAmount: Double): GoalWithProgress { + val pct = (currentAmount / targetAmount).coerceAtLeast(0.0) + val completed = pct >= 1.0 + val now = System.currentTimeMillis() + + val eta = if (completed || currentAmount <= 0.0) null + else { + val elapsed = (now - createdAt).coerceAtLeast(DAY_MS) + val rate = currentAmount / elapsed.toDouble() + if (rate <= 0.0) null + else { + val remaining = targetAmount - currentAmount + val msToTarget = (remaining / rate).roundToLong() + now + msToTarget + } + } + + val status = when { + completed -> GoalStatus.Completed + targetDate == null -> GoalStatus.OnTrack + else -> { + val totalSpan = (targetDate - createdAt).coerceAtLeast(DAY_MS) + val elapsed = (now - createdAt).coerceAtLeast(0L) + val expectedPct = (elapsed.toDouble() / totalSpan.toDouble()).coerceIn(0.0, 1.0) + val ratio = pct / expectedPct.coerceAtLeast(0.0001) + when { + ratio < 0.9 -> GoalStatus.Behind + ratio > 1.1 -> GoalStatus.Ahead + else -> GoalStatus.OnTrack + } + } + } + + return GoalWithProgress( + goal = this, + currentAmount = currentAmount, + percent = pct.toFloat(), + eta = eta, + status = status, + ) + } + + companion object { + const val DAY_MS = 86_400_000L + + /** Used by SmsReceiver for auto-attribution on insert. */ + fun matches(rule: ContributionRule, type: TransactionType, recipient: String?): Boolean = + when (rule) { + ContributionRule.AllSavingsDeposits -> + type == TransactionType.MSHWARI || type == TransactionType.KCB_MPESA + is ContributionRule.ToRecipient -> + recipient?.contains(rule.recipientName, ignoreCase = true) == true + ContributionRule.Manual -> false + } + } +} diff --git a/app/src/main/java/com/ledga/app/data/repository/HistoricalBackfillRepository.kt b/app/src/main/java/com/ledga/app/data/repository/HistoricalBackfillRepository.kt new file mode 100644 index 0000000..3955e73 --- /dev/null +++ b/app/src/main/java/com/ledga/app/data/repository/HistoricalBackfillRepository.kt @@ -0,0 +1,159 @@ +package com.ledga.app.data.repository + +import android.content.ContentResolver +import android.database.Cursor +import android.net.Uri +import android.telephony.SubscriptionManager +import com.ledga.app.data.db.dao.TransactionDao +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +data class BackfillResult( + /** Total MPESA SMS scanned in the system inbox. */ + val scanned: Int, + /** Transactions in Ledga's DB that we matched + tagged. */ + val tagged: Int, + /** SMS that referenced a transaction code not present in Ledga's DB. */ + val unmatched: Int, + /** Distinct accounts created during the run. */ + val accountsCreated: Int, +) + +data class DateRangeBackfillResult(val updated: Int) + +/** + * Two-prong historical backfill for multi-SIM attribution. + * + * 1. **Auto** — re-read Android's SMS inbox (which records `sub_id` per + * message), extract each transaction code from the body, then bulk-update + * the `accountId` on every matching row in Ledga's DB. This is the highest- + * fidelity backfill we can offer; it's lossless when the OEM keeps sub_id. + * + * 2. **Date range** — for stretches where Android's SMS history was wiped or + * the OEM didn't preserve sub_id, the user can bulk-attribute everything + * in a date range to a chosen account. + * + * Per-transaction overrides happen elsewhere (TransactionDetailSheet). + */ +@Singleton +class HistoricalBackfillRepository @Inject constructor( + private val contentResolver: ContentResolver, + private val transactionDao: TransactionDao, + private val accountsRepository: AccountsRepository, +) { + + /** Code regex matches the parser — 10 alphanumeric at the message start. */ + private val codeRegex = Regex("""^([A-Z0-9]{10})\s""") + + suspend fun runAutoBackfill( + onProgress: (scanned: Int, total: Int) -> Unit = { _, _ -> }, + ): BackfillResult = withContext(Dispatchers.IO) { + val rows = readMpesaInbox() + val total = rows.size + if (total == 0) return@withContext BackfillResult(0, 0, 0, 0) + + // Group transaction codes by subscription id. + val codesBySub = mutableMapOf>() + var scanned = 0 + var unmatched = 0 + + for (row in rows) { + scanned++ + onProgress(scanned, total) + val code = codeRegex.find(row.body)?.groupValues?.get(1) ?: continue + // Skip if the row's sub_id is meaningless. + val sub = row.subscriptionId + if (sub == SubscriptionManager.INVALID_SUBSCRIPTION_ID) { + unmatched++ + continue + } + codesBySub.getOrPut(sub) { mutableListOf() }.add(code) + } + + val accountsBefore = accountsRepository.getAll().size + var taggedTotal = 0 + + for ((subId, codes) in codesBySub) { + val account = accountsRepository.getOrCreateForSubscription(subId) ?: continue + // Update in chunks to avoid SQLite's IN-list limit (~1000 typically). + codes.chunked(500).forEach { chunk -> + taggedTotal += transactionDao.updateAccountForCodes(account.id, chunk) + } + } + + val accountsCreated = accountsRepository.getAll().size - accountsBefore + BackfillResult( + scanned = scanned, + tagged = taggedTotal, + unmatched = unmatched, + accountsCreated = accountsCreated, + ) + } + + suspend fun runDateRangeBackfill( + accountId: Long, + startTime: Long, + endTime: Long, + ): DateRangeBackfillResult = withContext(Dispatchers.IO) { + DateRangeBackfillResult( + updated = transactionDao.updateAccountForRange(accountId, startTime, endTime) + ) + } + + /** + * Read MPESA SMS rows + their subscription column. The column name varies: + * - "sub_id" on AOSP and most OEMs (since ~API 22) + * - Some Samsung / Xiaomi builds use "sim_id" or omit it entirely + * We probe both and fall back to a cursor without the column. + */ + private fun readMpesaInbox(): List { + val uri = Uri.parse("content://sms/inbox") + // Try sub_id first (the AOSP name). + readWithColumns(uri, arrayOf("body", "date", "sub_id"))?.let { return it } + // Fallback: sim_id (Samsung ROMs). + readWithColumns(uri, arrayOf("body", "date", "sim_id"))?.let { return it } + // No SIM column — still useful; sub_ids will all come back invalid. + return readWithColumns(uri, arrayOf("body", "date")).orEmpty() + } + + private fun readWithColumns(uri: Uri, projection: Array): List? = try { + val cursor: Cursor? = contentResolver.query( + uri, + projection, + "address = ?", + arrayOf("MPESA"), + "date DESC", + ) + cursor?.use { c -> + val bodyIdx = c.getColumnIndex("body") + val dateIdx = c.getColumnIndex("date") + val subIdx = projection.lastIndex + .takeIf { projection.size > 2 } + ?.let { c.getColumnIndex(projection[it]) } + ?: -1 + val rows = mutableListOf() + while (c.moveToNext()) { + val body = if (bodyIdx >= 0) c.getString(bodyIdx) else continue + val date = if (dateIdx >= 0) c.getLong(dateIdx) else 0L + val sub = if (subIdx >= 0) c.getInt(subIdx) + else SubscriptionManager.INVALID_SUBSCRIPTION_ID + rows += InboxRow(body = body, date = date, subscriptionId = sub) + } + rows + } + } catch (_: IllegalArgumentException) { + // Projection contained an unknown column — try the next fallback. + null + } catch (_: SecurityException) { + // READ_SMS not granted — caller should have checked, but be defensive. + null + } + + private data class InboxRow( + val body: String, + val date: Long, + val subscriptionId: Int, + ) +} diff --git a/app/src/main/java/com/ledga/app/data/repository/InsightsRepository.kt b/app/src/main/java/com/ledga/app/data/repository/InsightsRepository.kt new file mode 100644 index 0000000..958e191 --- /dev/null +++ b/app/src/main/java/com/ledga/app/data/repository/InsightsRepository.kt @@ -0,0 +1,36 @@ +package com.ledga.app.data.repository + +import com.ledga.app.data.db.dao.InsightDao +import com.ledga.app.data.db.entity.Insight +import com.ledga.app.data.insights.InsightEngine +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class InsightsRepository @Inject constructor( + private val insightDao: InsightDao, + private val engine: InsightEngine, +) { + + /** Re-run the rule engine. Idempotent — safe to call on every app open. */ + suspend fun generateAll(): Int = engine.run() + + /** Live list of currently visible insights (not dismissed, not snoozed). */ + fun observeActive(): Flow> = + insightDao.observeActive(System.currentTimeMillis()) + + /** The single highest-priority active insight — for the Home teaser. */ + fun observeTop(): Flow = + insightDao.observeTop(System.currentTimeMillis()) + + suspend fun dismiss(id: Long) { + insightDao.dismiss(id, System.currentTimeMillis()) + } + + /** Snooze by [days]; once expired the insight reappears. */ + suspend fun snooze(id: Long, days: Long = 30) { + val until = System.currentTimeMillis() + days * 86_400_000L + insightDao.snooze(id, until) + } +} 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 index b564403..dbb4eb9 100644 --- a/app/src/main/java/com/ledga/app/data/repository/SettingsRepository.kt +++ b/app/src/main/java/com/ledga/app/data/repository/SettingsRepository.kt @@ -6,6 +6,7 @@ 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.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import com.ledga.app.ui.theme.ThemeMode import kotlinx.coroutines.flow.Flow @@ -27,6 +28,12 @@ class SettingsRepository @Inject constructor( 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") + // Multi-SIM: which account the user is viewing. Special value -1 = "Combined". + private val SELECTED_ACCOUNT_ID = longPreferencesKey("selected_account_id") + private const val COMBINED_ACCOUNT = -1L + // Self-update: which version the user "Remind me later"'d. Banner stays hidden + // until a newer version is available. + private val DISMISSED_UPDATE_VERSION = stringPreferencesKey("dismissed_update_version") } fun getThemeMode(): Flow = dataStore.data.map { prefs -> @@ -84,6 +91,33 @@ class SettingsRepository @Inject constructor( suspend fun setFontScale(scale: FontScale) { dataStore.edit { it[FONT_SCALE] = scale.name } } + + // --- Selected M-Pesa account --- + + /** + * The currently selected account id. `null` means "Combined" view (all + * accounts merged). Default is null — first-time users see everything + * until they actively pick a line. + */ + fun getSelectedAccountId(): Flow = dataStore.data.map { prefs -> + prefs[SELECTED_ACCOUNT_ID]?.takeUnless { it == COMBINED_ACCOUNT } + } + + suspend fun setSelectedAccountId(id: Long?) { + dataStore.edit { prefs -> + prefs[SELECTED_ACCOUNT_ID] = id ?: COMBINED_ACCOUNT + } + } + + // --- Self-update: snoozed version --- + + fun getDismissedUpdateVersion(): Flow = dataStore.data.map { prefs -> + prefs[DISMISSED_UPDATE_VERSION] + } + + suspend fun dismissUpdateVersion(version: String) { + dataStore.edit { it[DISMISSED_UPDATE_VERSION] = version } + } } enum class FontScale(val label: String, val scale: Float) { diff --git a/app/src/main/java/com/ledga/app/data/repository/TransactionRepository.kt b/app/src/main/java/com/ledga/app/data/repository/TransactionRepository.kt index 97652fe..7fd3d1b 100644 --- a/app/src/main/java/com/ledga/app/data/repository/TransactionRepository.kt +++ b/app/src/main/java/com/ledga/app/data/repository/TransactionRepository.kt @@ -10,16 +10,26 @@ 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.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest import javax.inject.Inject import javax.inject.Singleton +@OptIn(ExperimentalCoroutinesApi::class) @Singleton class TransactionRepository @Inject constructor( private val transactionDao: TransactionDao, - private val categoryRuleDao: CategoryRuleDao + private val categoryRuleDao: CategoryRuleDao, + private val settingsRepository: SettingsRepository, ) { - suspend fun insertTransaction(parsed: ParsedTransaction): Long { + /** + * Re-emit whenever the selected account changes. Every screen-facing + * query in this repository composes with this so the UI scope updates + * automatically when the user switches lines. + */ + private val selectedAccount: Flow get() = settingsRepository.getSelectedAccountId() + suspend fun insertTransaction(parsed: ParsedTransaction, accountId: Long? = null): Long { val categoryId = autoCategorize(parsed) val entity = TransactionEntity( transactionCode = parsed.transactionCode, @@ -37,43 +47,44 @@ class TransactionRepository @Inject constructor( fulizaOutstanding = parsed.fulizaOutstanding, reversedTransactionCode = parsed.reversedTransactionCode, rawSms = parsed.rawSms, - timestamp = parsed.timestamp + timestamp = parsed.timestamp, + accountId = accountId, ) return transactionDao.insert(entity) } fun getRecentTransactions(limit: Int = 10): Flow> = - transactionDao.getRecentTransactions(limit) + selectedAccount.flatMapLatest { transactionDao.getRecentTransactions(limit, it) } fun getTransactions(startTime: Long, endTime: Long): Flow> = - transactionDao.getTransactions(startTime, endTime) + selectedAccount.flatMapLatest { transactionDao.getTransactions(startTime, endTime, it) } fun getLatestTransaction(): Flow = - transactionDao.getLatestTransaction() + selectedAccount.flatMapLatest { transactionDao.getLatestTransaction(it) } fun getLatestTransactionWithBalance(): Flow = - transactionDao.getLatestTransactionWithBalance() + selectedAccount.flatMapLatest { transactionDao.getLatestTransactionWithBalance(it) } fun getSpendingByCategory(startTime: Long, endTime: Long): Flow> = - transactionDao.getSpendingByCategory(startTime, endTime) + selectedAccount.flatMapLatest { transactionDao.getSpendingByCategory(startTime, endTime, it) } fun getTotalSpending(startTime: Long, endTime: Long): Flow = - transactionDao.getTotalSpending(startTime, endTime) + selectedAccount.flatMapLatest { transactionDao.getTotalSpending(startTime, endTime, it) } fun getTotalFees(startTime: Long, endTime: Long): Flow = - transactionDao.getTotalFees(startTime, endTime) + selectedAccount.flatMapLatest { transactionDao.getTotalFees(startTime, endTime, it) } fun searchTransactions(query: String): Flow> = - transactionDao.searchTransactions(query) + selectedAccount.flatMapLatest { transactionDao.searchTransactions(query, it) } fun getTransactionsByType(types: List): Flow> = - transactionDao.getTransactionsByType(types) + selectedAccount.flatMapLatest { transactionDao.getTransactionsByType(types, it) } fun getDailySpending(startTime: Long, endTime: Long): Flow> = - transactionDao.getDailySpending(startTime, endTime) + selectedAccount.flatMapLatest { transactionDao.getDailySpending(startTime, endTime, it) } fun getTopMerchants(startTime: Long, endTime: Long, limit: Int = 5): Flow> = - transactionDao.getTopMerchants(startTime, endTime, limit) + selectedAccount.flatMapLatest { transactionDao.getTopMerchants(startTime, endTime, it, limit) } fun getUnparsedTransactions(): Flow> = transactionDao.getUnparsedTransactions() @@ -85,6 +96,19 @@ class TransactionRepository @Inject constructor( transactionDao.updateCategory(transactionId, categoryId) } + /** Used by the per-transaction backfill UI in [TransactionDetailSheet]. */ + suspend fun updateAccount(transactionId: Long, accountId: Long?) { + transactionDao.updateAccount(transactionId, accountId) + } + + /** Bulk date-range backfill. Returns the number of rows updated. */ + suspend fun bulkAttribute(accountId: Long?, startTime: Long, endTime: Long): Int = + transactionDao.updateAccountForRange(accountId, startTime, endTime) + + /** Bulk attribute by raw transaction-code list (used by SMS-DB backfill). */ + suspend fun bulkAttributeByCodes(accountId: Long, codes: List): Int = + if (codes.isEmpty()) 0 else transactionDao.updateAccountForCodes(accountId, codes) + suspend fun reparseUnknownTransactions(): ReparseResult { val unknowns = transactionDao.getUnparsedSync() var fixed = 0 @@ -95,9 +119,11 @@ class TransactionRepository @Inject constructor( when (result) { is com.ledga.app.data.parser.ParseResult.Success -> { if (result.transaction.type != com.ledga.app.data.parser.TransactionType.UNKNOWN) { - // Delete old UNKNOWN entry, insert re-parsed one + // Delete old UNKNOWN entry, insert re-parsed one — carry the + // original accountId forward so re-parsing never loses + // hard-won multi-SIM attribution. transactionDao.deleteById(entity.id) - insertTransaction(result.transaction) + insertTransaction(result.transaction, accountId = entity.accountId) fixed++ } else { stillUnknown++ diff --git a/app/src/main/java/com/ledga/app/data/repository/UpdateRepository.kt b/app/src/main/java/com/ledga/app/data/repository/UpdateRepository.kt new file mode 100644 index 0000000..17c90a8 --- /dev/null +++ b/app/src/main/java/com/ledga/app/data/repository/UpdateRepository.kt @@ -0,0 +1,138 @@ +package com.ledga.app.data.repository + +import android.content.Context +import com.ledga.app.BuildConfig +import com.ledga.app.worker.GitHubAsset +import com.ledga.app.worker.GitHubRelease +import com.ledga.app.worker.UpdateChecker +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton + +/** + * State machine for downloading + installing a release APK. + * + * The Home banner only needs the "is there one?" Flow exposed by + * [HomeViewModel] directly via [UpdateChecker]. This repository owns the + * heavier download/install lifecycle so any screen can drive it. + */ +sealed class DownloadState { + object Idle : DownloadState() + data class Downloading(val bytesRead: Long, val totalBytes: Long) : DownloadState() + data class Ready(val file: File) : DownloadState() + data class Failed(val message: String) : DownloadState() +} + +@Singleton +class UpdateRepository @Inject constructor( + @ApplicationContext private val context: Context, +) { + + private val _downloadState = MutableStateFlow(DownloadState.Idle) + val downloadState: Flow = _downloadState.asStateFlow() + + /** + * Fresh check ignoring the once-per-day rate limit — used by the "Check + * for updates" tap in You → About. + */ + suspend fun checkNow(owner: String = OWNER, repo: String = REPO): GitHubRelease? { + val prefs = context.getSharedPreferences("update_prefs", Context.MODE_PRIVATE) + prefs.edit().putLong("last_check", 0).apply() // bust rate limit + return UpdateChecker.checkForUpdate(context, owner, repo, BuildConfig.VERSION_NAME) + } + + suspend fun download(release: GitHubRelease) { + val asset = release.apkAsset() ?: run { + _downloadState.value = DownloadState.Failed("No APK asset on this release.") + return + } + _downloadState.value = DownloadState.Downloading(0, asset.size) + val file = UpdateChecker.downloadApkWithProgress( + context = context, + downloadUrl = asset.browser_download_url, + filename = asset.name, + expectedSize = asset.size, + ) { bytes, total -> + _downloadState.value = DownloadState.Downloading(bytes, total) + } + _downloadState.value = if (file != null) DownloadState.Ready(file) + else DownloadState.Failed("Download failed. Check your connection and try again.") + } + + fun install(file: File) { + UpdateChecker.installApk(context, file) + } + + fun reset() { + _downloadState.value = DownloadState.Idle + } + + /** + * Force the download-state machine to [DownloadState.Ready] without + * re-downloading. Used when the background worker has already cached + * the APK and the screen just opened. + */ + fun markReady(file: File) { + _downloadState.value = DownloadState.Ready(file) + } + + /** + * Returns the on-disk APK for [release] if it's already been pre-fetched + * (filename + size match the GitHub asset). Null otherwise. + * + * Used by the Home banner to flip to "Ready to install" and by + * UpdateViewModel to short-circuit the download dance. + */ + fun findCachedApk(release: GitHubRelease): File? { + val asset = release.apkAsset() ?: return null + val file = File(File(context.cacheDir, "updates"), asset.name) + if (!file.exists() || !file.isFile) return null + // Size guard — if a download was interrupted, the file may be a + // partial. Treat anything that doesn't match exactly as missing. + if (asset.size > 0 && file.length() != asset.size) return null + return file + } + + /** + * Delete any stale APKs in cache so we don't accumulate megabytes per + * release. Keeps the file matching [release], if present. + */ + fun cleanupOldApks(release: GitHubRelease?) { + val dir = File(context.cacheDir, "updates") + if (!dir.isDirectory) return + val keep = release?.apkAsset()?.name + dir.listFiles()?.forEach { f -> + if (f.isFile && f.name != keep) f.delete() + } + } + + /** + * Background pre-fetch entry point used by [UpdateCheckWorker]. Idempotent: + * returns immediately if the APK is already cached for this release. + */ + suspend fun prefetchInBackground(release: GitHubRelease): Boolean { + val asset = release.apkAsset() ?: return false + findCachedApk(release)?.let { return true } + cleanupOldApks(release) // drop older partials before downloading + val file = UpdateChecker.downloadApkWithProgress( + context = context, + downloadUrl = asset.browser_download_url, + filename = asset.name, + expectedSize = asset.size, + ) { _, _ -> /* no UI to report to here */ } + return file != null + } + + /** First .apk asset on the release, by convention. */ + private fun GitHubRelease.apkAsset(): GitHubAsset? = + assets.firstOrNull { it.name.endsWith(".apk", ignoreCase = true) } + + companion object { + const val OWNER = "ngenohkevin" + const val REPO = "ledga" + } +} diff --git a/app/src/main/java/com/ledga/app/di/DatabaseModule.kt b/app/src/main/java/com/ledga/app/di/DatabaseModule.kt index d77b6ae..9713300 100644 --- a/app/src/main/java/com/ledga/app/di/DatabaseModule.kt +++ b/app/src/main/java/com/ledga/app/di/DatabaseModule.kt @@ -6,9 +6,13 @@ 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.Migrations 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.GoalDao +import com.ledga.app.data.db.dao.InsightDao +import com.ledga.app.data.db.dao.MpesaAccountDao import com.ledga.app.data.db.dao.TransactionDao import dagger.Module import dagger.Provides @@ -32,6 +36,7 @@ object DatabaseModule { AppDatabase::class.java, "ledga.db" ) + .addMigrations(Migrations.MIGRATION_1_2, Migrations.MIGRATION_2_3) .addCallback(object : RoomDatabase.Callback() { override fun onCreate(db: SupportSQLiteDatabase) { super.onCreate(db) @@ -58,4 +63,13 @@ object DatabaseModule { @Provides fun provideBudgetDao(db: AppDatabase): BudgetDao = db.budgetDao() + + @Provides + fun provideMpesaAccountDao(db: AppDatabase): MpesaAccountDao = db.mpesaAccountDao() + + @Provides + fun provideGoalDao(db: AppDatabase): GoalDao = db.goalDao() + + @Provides + fun provideInsightDao(db: AppDatabase): InsightDao = db.insightDao() } diff --git a/app/src/main/java/com/ledga/app/di/InsightsModule.kt b/app/src/main/java/com/ledga/app/di/InsightsModule.kt new file mode 100644 index 0000000..e71dc8d --- /dev/null +++ b/app/src/main/java/com/ledga/app/di/InsightsModule.kt @@ -0,0 +1,30 @@ +package com.ledga.app.di + +import com.ledga.app.data.insights.InsightRule +import com.ledga.app.data.insights.rules.AnomalyRule +import com.ledga.app.data.insights.rules.FeeTipRule +import com.ledga.app.data.insights.rules.FulizaAutoPayRule +import com.ledga.app.data.insights.rules.FulizaRule +import com.ledga.app.data.insights.rules.PositiveNudgeRule +import com.ledga.app.data.insights.rules.RecurringRule +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object InsightsModule { + + @Provides + @Singleton + fun provideInsightRules( + anomaly: AnomalyRule, + recurring: RecurringRule, + feeTip: FeeTipRule, + fuliza: FulizaRule, + fulizaAutoPay: FulizaAutoPayRule, + positive: PositiveNudgeRule, + ): List = listOf(anomaly, recurring, feeTip, fuliza, fulizaAutoPay, positive) +} diff --git a/app/src/main/java/com/ledga/app/receiver/SmsReceiver.kt b/app/src/main/java/com/ledga/app/receiver/SmsReceiver.kt index 22b12cc..a397ec1 100644 --- a/app/src/main/java/com/ledga/app/receiver/SmsReceiver.kt +++ b/app/src/main/java/com/ledga/app/receiver/SmsReceiver.kt @@ -4,8 +4,10 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.provider.Telephony +import android.telephony.SubscriptionManager import com.ledga.app.data.parser.MpesaSmsParser import com.ledga.app.data.parser.ParseResult +import com.ledga.app.data.repository.AccountsRepository import com.ledga.app.data.repository.SettingsRepository import com.ledga.app.data.repository.TransactionRepository import com.ledga.app.worker.TransactionAlerts @@ -28,6 +30,9 @@ class SmsReceiver : BroadcastReceiver() { @Inject lateinit var settingsRepository: SettingsRepository + @Inject + lateinit var accountsRepository: AccountsRepository + override fun onReceive(context: Context, intent: Intent) { if (intent.action != Telephony.Sms.Intents.SMS_RECEIVED_ACTION) return @@ -36,18 +41,25 @@ class SmsReceiver : BroadcastReceiver() { 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 } } + // Group message parts by sender (multi-part SMS). + // SubscriptionId is per-message but identical across parts of the + // same SMS — keep the first valid one we see per sender. + val partsBySender = messages.groupBy { it.displayOriginatingAddress } - for ((sender, body) in fullMessages) { + val subscriptionId = subscriptionIdFromIntent(intent) + + for ((sender, parts) in partsBySender) { if (!MpesaSmsParser.isMpesaMessage(sender ?: "")) continue + val body = parts.joinToString("") { it.displayMessageBody } + val account = accountsRepository.getOrCreateForSubscription(subscriptionId) + when (val result = MpesaSmsParser.parse(body, System.currentTimeMillis())) { is ParseResult.Success -> { - transactionRepository.insertTransaction(result.transaction) - // Check for alerts + transactionRepository.insertTransaction( + parsed = result.transaction, + accountId = account?.id, + ) val largeTxnEnabled = settingsRepository.getLargeTransactionAlertEnabled().first() val largeTxnThreshold = settingsRepository.getLargeTransactionThreshold().first() val budgetEnabled = settingsRepository.getBudgetAlertsEnabled().first() @@ -60,7 +72,7 @@ class SmsReceiver : BroadcastReceiver() { ) } is ParseResult.Failure -> { - // Store as UNKNOWN for later review + // Filtered (balance check etc) — quietly drop. } } } @@ -69,4 +81,22 @@ class SmsReceiver : BroadcastReceiver() { } } } + + /** + * Reads the subscription id off the SMS_RECEIVED intent. + * + * Android writes the subscription as an extra with key "subscription" + * (older builds) or [SubscriptionManager.EXTRA_SUBSCRIPTION_INDEX] + * (modern). We check both so we work on the broad device population + * Ledga targets (SDK 26+). + */ + private fun subscriptionIdFromIntent(intent: Intent): Int { + val invalid = SubscriptionManager.INVALID_SUBSCRIPTION_ID + val modern = intent.getIntExtra( + SubscriptionManager.EXTRA_SUBSCRIPTION_INDEX, + invalid + ) + if (modern != invalid) return modern + return intent.getIntExtra("subscription", invalid) + } } diff --git a/app/src/main/java/com/ledga/app/ui/accounts/AccountsScreen.kt b/app/src/main/java/com/ledga/app/ui/accounts/AccountsScreen.kt new file mode 100644 index 0000000..7bd60ae --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/accounts/AccountsScreen.kt @@ -0,0 +1,264 @@ +package com.ledga.app.ui.accounts + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.SimCard +import androidx.compose.material.icons.filled.Star +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.ledga.app.data.db.entity.MpesaAccount +import com.ledga.app.ui.components.parseColor +import com.ledga.app.ui.components.v2.Avatar +import com.ledga.app.ui.components.v2.BackLeading +import com.ledga.app.ui.components.v2.BentoCard +import com.ledga.app.ui.components.v2.LedgaTopBar +import com.ledga.app.ui.theme.CatAirtime +import com.ledga.app.ui.theme.CatFood +import com.ledga.app.ui.theme.CatInternational +import com.ledga.app.ui.theme.CatReceived +import com.ledga.app.ui.theme.CatSavings +import com.ledga.app.ui.theme.CatShopping +import com.ledga.app.ui.theme.CatTransport +import com.ledga.app.ui.theme.LedgaAccent +import com.ledga.app.ui.theme.LedgaAccentDeep +import com.ledga.app.ui.theme.LedgaText +import com.ledga.app.ui.theme.Space + +private val ColorChoices = listOf( + LedgaAccent, CatTransport, CatFood, CatAirtime, + CatReceived, CatInternational, CatShopping, CatSavings, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AccountsScreen( + onBack: () -> Unit, + onOpenBackfill: () -> Unit = {}, + viewModel: AccountsViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsState() + var editingAccount by remember { mutableStateOf(null) } + + if (editingAccount != null) { + EditAccountDialog( + account = editingAccount!!, + onDismiss = { editingAccount = null }, + onRename = { newName -> viewModel.rename(editingAccount!!.id, newName) }, + onRecolor = { hex -> viewModel.recolor(editingAccount!!.id, hex) }, + onSetPrimary = { viewModel.setPrimary(editingAccount!!.id) }, + ) + } + + Column(modifier = Modifier.fillMaxSize()) { + LedgaTopBar(title = "Accounts", leading = { BackLeading(onBack) }) + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = Space.Screen) + .padding(bottom = 120.dp), + verticalArrangement = Arrangement.spacedBy(Space.Section), + ) { + if (state.accounts.isEmpty()) { + BentoCard( + overline = "No accounts yet", + title = "Lines appear automatically", + icon = Icons.Filled.SimCard, + iconTint = LedgaAccentDeep, + ) { + Text( + text = "When a new M-Pesa SMS lands, Ledga registers the SIM " + + "it came from as an account. Two SIMs = two accounts.", + style = LedgaText.BodyM, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + BentoCard( + contentPadding = androidx.compose.foundation.layout.PaddingValues(0.dp), + ) { + state.accounts.forEachIndexed { index, account -> + AccountManagementRow( + account = account, + onClick = { editingAccount = account }, + ) + if (index < state.accounts.lastIndex) { + androidx.compose.material3.HorizontalDivider( + color = MaterialTheme.colorScheme.outline, + modifier = Modifier.padding(horizontal = Space.Card), + ) + } + } + } + } + + BentoCard( + overline = "Backfill", + title = "Tag historical transactions", + icon = Icons.Filled.History, + iconTint = LedgaAccentDeep, + onClick = onOpenBackfill, + ) { + Text( + text = "Imported transactions don't know which line they came " + + "from. Tag them in bulk so Combined / per-line views work " + + "on your full history.", + style = LedgaText.BodyM, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(modifier = Modifier.height(Space.s5)) + } + } +} + +@Composable +private fun AccountManagementRow(account: MpesaAccount, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() } + .padding(horizontal = Space.Card, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Space.s4), + ) { + Avatar(initials = account.displayName.initials(), color = parseColor(account.colorHex), size = 40.dp) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = account.displayName, + style = LedgaText.TitleS, + color = MaterialTheme.colorScheme.onSurface, + ) + if (account.isPrimary) { + Spacer(modifier = Modifier.size(6.dp)) + Icon( + imageVector = Icons.Filled.Star, + contentDescription = "Primary", + tint = LedgaAccentDeep, + modifier = Modifier.size(14.dp), + ) + } + } + Text( + text = account.phoneNumber ?: "Subscription ${account.subscriptionId}", + style = LedgaText.Caption, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun EditAccountDialog( + account: MpesaAccount, + onDismiss: () -> Unit, + onRename: (String) -> Unit, + onRecolor: (String) -> Unit, + onSetPrimary: () -> Unit, +) { + var name by remember { mutableStateOf(account.displayName) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(text = "Edit account", style = LedgaText.TitleM) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(Space.s5)) { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Name") }, + singleLine = true, + ) + + Text( + text = "Color".uppercase(), + style = LedgaText.Overline, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row(horizontalArrangement = Arrangement.spacedBy(Space.s3)) { + ColorChoices.forEach { color -> + ColorSwatch( + color = color, + selected = color.toHex() == account.colorHex, + onClick = { onRecolor(color.toHex()) }, + ) + } + } + + if (!account.isPrimary) { + TextButton(onClick = { onSetPrimary(); onDismiss() }) { + Text("Make primary line") + } + } + } + }, + confirmButton = { + TextButton(onClick = { onRename(name); onDismiss() }) { Text("Save") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + } + ) +} + +@Composable +private fun ColorSwatch(color: Color, selected: Boolean, onClick: () -> Unit) { + Box( + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .background(color) + .then( + if (selected) Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape) + else Modifier + ) + .clickable { onClick() } + ) +} + +private fun Color.toHex(): String { + val argb = this.value.toLong() shr 32 and 0xFFFFFFFFL + return "#%06X".format(argb.toInt() and 0xFFFFFF) +} + +private fun String.initials(): String = + split(" ", limit = 2) + .mapNotNull { it.firstOrNull()?.toString() } + .joinToString("") + .take(2) + .uppercase() diff --git a/app/src/main/java/com/ledga/app/ui/accounts/AccountsViewModel.kt b/app/src/main/java/com/ledga/app/ui/accounts/AccountsViewModel.kt new file mode 100644 index 0000000..51c901e --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/accounts/AccountsViewModel.kt @@ -0,0 +1,46 @@ +package com.ledga.app.ui.accounts + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ledga.app.data.db.entity.MpesaAccount +import com.ledga.app.data.repository.AccountsRepository +import com.ledga.app.data.repository.SettingsRepository +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 AccountsUiState( + val accounts: List = emptyList(), + val selectedAccountId: Long? = null, +) + +@HiltViewModel +class AccountsViewModel @Inject constructor( + private val accountsRepository: AccountsRepository, + private val settingsRepository: SettingsRepository, +) : ViewModel() { + + val uiState: StateFlow = combine( + accountsRepository.observeAll(), + settingsRepository.getSelectedAccountId(), + ) { accounts, selectedId -> + AccountsUiState(accounts = accounts, selectedAccountId = selectedId) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AccountsUiState()) + + fun rename(id: Long, newName: String) { + if (newName.isBlank()) return + viewModelScope.launch { accountsRepository.rename(id, newName.trim()) } + } + + fun recolor(id: Long, hex: String) { + viewModelScope.launch { accountsRepository.recolor(id, hex) } + } + + fun setPrimary(id: Long) { + viewModelScope.launch { accountsRepository.setPrimary(id) } + } +} diff --git a/app/src/main/java/com/ledga/app/ui/accounts/BackfillScreen.kt b/app/src/main/java/com/ledga/app/ui/accounts/BackfillScreen.kt new file mode 100644 index 0000000..6b292f8 --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/accounts/BackfillScreen.kt @@ -0,0 +1,324 @@ +package com.ledga.app.ui.accounts + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDatePickerState +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.ledga.app.ui.components.parseColor +import com.ledga.app.ui.components.v2.Avatar +import com.ledga.app.ui.components.v2.BackLeading +import com.ledga.app.ui.components.v2.BentoCard +import com.ledga.app.ui.components.v2.LedgaChip +import com.ledga.app.ui.components.v2.LedgaTopBar +import com.ledga.app.ui.theme.LedgaAccent +import com.ledga.app.ui.theme.LedgaAccentDeep +import com.ledga.app.ui.theme.LedgaAccentSoft +import com.ledga.app.ui.theme.LedgaText +import com.ledga.app.ui.theme.Space +import com.ledga.app.util.DateUtils +import java.text.SimpleDateFormat +import java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BackfillScreen( + onBack: () -> Unit, + viewModel: BackfillViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsState() + + var selectedAccountId by remember { mutableStateOf(null) } + var fromDate by remember { mutableStateOf(null) } + var toDate by remember { mutableStateOf(null) } + var pickingFrom by remember { mutableStateOf(false) } + var pickingTo by remember { mutableStateOf(false) } + + if (pickingFrom) { + val pickerState = rememberDatePickerState(initialSelectedDateMillis = fromDate) + DatePickerDialog( + onDismissRequest = { pickingFrom = false }, + confirmButton = { + TextButton(onClick = { + fromDate = pickerState.selectedDateMillis + pickingFrom = false + }) { Text("OK") } + }, + dismissButton = { + TextButton(onClick = { pickingFrom = false }) { Text("Cancel") } + } + ) { + DatePicker(state = pickerState) + } + } + if (pickingTo) { + val pickerState = rememberDatePickerState(initialSelectedDateMillis = toDate) + DatePickerDialog( + onDismissRequest = { pickingTo = false }, + confirmButton = { + TextButton(onClick = { + toDate = pickerState.selectedDateMillis + pickingTo = false + }) { Text("OK") } + }, + dismissButton = { + TextButton(onClick = { pickingTo = false }) { Text("Cancel") } + } + ) { + DatePicker(state = pickerState) + } + } + + Column(modifier = Modifier.fillMaxSize()) { + LedgaTopBar(title = "Backfill", leading = { BackLeading(onBack) }) + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = Space.Screen) + .padding(bottom = 120.dp), + verticalArrangement = Arrangement.spacedBy(Space.Section), + ) { + // ---- Auto scan ---- + BentoCard( + overline = "Best match", + title = "Auto-scan SMS inbox", + icon = Icons.Filled.Search, + iconTint = LedgaAccentDeep, + ) { + Text( + text = "Re-reads every MPESA message in your phone's SMS inbox, " + + "matches each transaction code to Ledga's DB, and writes " + + "the originating SIM as the account.", + style = LedgaText.BodyM, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + when { + state.isAutoRunning -> { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Space.s4), + ) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = LedgaAccent, + ) + val p = state.autoProgress + Text( + text = if (p != null) "Scanning ${p.first} / ${p.second}…" + else "Starting…", + style = LedgaText.BodyM, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + state.autoResult != null -> { + val r = state.autoResult!! + Text( + text = buildString { + append("Scanned ${r.scanned} SMS. ") + append("Tagged ${r.tagged} transactions.") + if (r.accountsCreated > 0) { + append(" Created ${r.accountsCreated} new account(s).") + } + if (r.unmatched > 0) { + append(" ${r.unmatched} SMS without a SIM column.") + } + }, + style = LedgaText.BodyM, + color = MaterialTheme.colorScheme.onSurface, + ) + Button( + onClick = { viewModel.runAuto() }, + colors = ButtonDefaults.buttonColors(containerColor = LedgaAccent), + ) { Text("Run again") } + } + else -> { + Button( + onClick = { viewModel.runAuto() }, + colors = ButtonDefaults.buttonColors(containerColor = LedgaAccent), + ) { Text("Start auto-scan") } + } + } + } + + // ---- Date range ---- + BentoCard( + overline = "Fallback", + title = "Bulk by date range", + icon = Icons.Filled.History, + iconTint = LedgaAccentDeep, + ) { + Text( + text = "Use this when an SMS was deleted from your phone's inbox " + + "but the transaction is still in Ledga.", + style = LedgaText.BodyM, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + if (state.accounts.isEmpty()) { + Text( + text = "Add at least one account before tagging by date range.", + style = LedgaText.Caption, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Text( + text = "Account".uppercase(), + style = LedgaText.Overline, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Space.s3), + ) { + state.accounts.forEach { account -> + val color = parseColor(account.colorHex) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Avatar(initials = account.displayName.first().toString(), + color = color, size = 20.dp) + LedgaChip( + label = account.displayName, + selected = selectedAccountId == account.id, + onClick = { selectedAccountId = account.id }, + ) + } + } + } + + Text( + text = "Range".uppercase(), + style = LedgaText.Overline, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Space.s4), + ) { + DateButton( + label = "From", + value = fromDate?.let { fmt(it) } ?: "Pick", + modifier = Modifier.weight(1f), + onClick = { pickingFrom = true }, + ) + DateButton( + label = "To", + value = toDate?.let { fmt(it) } ?: "Pick", + modifier = Modifier.weight(1f), + onClick = { pickingTo = true }, + ) + } + + val ready = selectedAccountId != null && fromDate != null && toDate != null + Button( + onClick = { + val s = fromDate ?: return@Button + val e = (toDate ?: return@Button) + 86_400_000L - 1 // include the day + viewModel.runDateRange(selectedAccountId!!, s, e) + }, + enabled = ready && !state.dateRangeRunning, + colors = ButtonDefaults.buttonColors(containerColor = LedgaAccent), + ) { + if (state.dateRangeRunning) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(modifier = Modifier.size(8.dp)) + Text("Applying…") + } else { + Text("Tag this range") + } + } + state.dateRangeResult?.let { updated -> + Text( + text = "Tagged $updated transactions.", + style = LedgaText.BodyM, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } + + BentoCard( + title = "Per-transaction override", + tonal = true, + tonalColor = LedgaAccentSoft, + ) { + Text( + text = "Tap any transaction to open its detail sheet — there's an " + + "Account row at the bottom that lets you reassign one row " + + "at a time. Useful for stragglers.", + style = LedgaText.BodyM, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + Spacer(modifier = Modifier.height(Space.s5)) + } + } +} + +@Composable +private fun DateButton( + label: String, + value: String, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + Box( + modifier = modifier + .fillMaxWidth() + .let { it }, + ) { + TextButton( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.Start) { + Text(text = label.uppercase(), style = LedgaText.Overline) + Text(text = value, style = LedgaText.BodyL) + } + } + } +} + +private val Fmt = SimpleDateFormat("d MMM yyyy", Locale.ENGLISH) +private fun fmt(epoch: Long): String = Fmt.format(java.util.Date(epoch)) diff --git a/app/src/main/java/com/ledga/app/ui/accounts/BackfillViewModel.kt b/app/src/main/java/com/ledga/app/ui/accounts/BackfillViewModel.kt new file mode 100644 index 0000000..a519004 --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/accounts/BackfillViewModel.kt @@ -0,0 +1,90 @@ +package com.ledga.app.ui.accounts + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ledga.app.data.db.entity.MpesaAccount +import com.ledga.app.data.repository.AccountsRepository +import com.ledga.app.data.repository.BackfillResult +import com.ledga.app.data.repository.HistoricalBackfillRepository +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.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class BackfillUiState( + val accounts: List = emptyList(), + val isAutoRunning: Boolean = false, + val autoProgress: Pair? = null, + val autoResult: BackfillResult? = null, + val dateRangeResult: Int? = null, + val dateRangeRunning: Boolean = false, +) + +@HiltViewModel +class BackfillViewModel @Inject constructor( + private val accountsRepository: AccountsRepository, + private val historicalBackfillRepository: HistoricalBackfillRepository, +) : ViewModel() { + + private val _autoRunning = MutableStateFlow(false) + private val _autoProgress = MutableStateFlow?>(null) + private val _autoResult = MutableStateFlow(null) + private val _dateRangeResult = MutableStateFlow(null) + private val _dateRangeRunning = MutableStateFlow(false) + + val uiState: StateFlow = combine( + accountsRepository.observeAll(), + _autoRunning, + _autoProgress, + _autoResult, + combine(_dateRangeRunning, _dateRangeResult) { r, res -> r to res }, + ) { accounts, autoRunning, progress, result, (dateRunning, dateResult) -> + BackfillUiState( + accounts = accounts, + isAutoRunning = autoRunning, + autoProgress = progress, + autoResult = result, + dateRangeResult = dateResult, + dateRangeRunning = dateRunning, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), BackfillUiState()) + + fun runAuto() { + if (_autoRunning.value) return + _autoRunning.value = true + _autoResult.value = null + viewModelScope.launch { + try { + val result = historicalBackfillRepository.runAutoBackfill { scanned, total -> + _autoProgress.value = scanned to total + } + _autoResult.value = result + } finally { + _autoRunning.value = false + _autoProgress.value = null + } + } + } + + fun runDateRange(accountId: Long, startTime: Long, endTime: Long) { + if (_dateRangeRunning.value) return + _dateRangeRunning.value = true + _dateRangeResult.value = null + viewModelScope.launch { + try { + val res = historicalBackfillRepository.runDateRangeBackfill( + accountId = accountId, + startTime = startTime, + endTime = endTime, + ) + _dateRangeResult.value = res.updated + } finally { + _dateRangeRunning.value = false + } + } + } +} diff --git a/app/src/main/java/com/ledga/app/ui/activity/ActivityScreen.kt b/app/src/main/java/com/ledga/app/ui/activity/ActivityScreen.kt new file mode 100644 index 0000000..9e4e679 --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/activity/ActivityScreen.kt @@ -0,0 +1,99 @@ +package com.ledga.app.ui.activity + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.clickable +import com.ledga.app.ui.components.v2.LedgaTopBar +import com.ledga.app.ui.theme.LedgaText +import com.ledga.app.ui.theme.Space +import com.ledga.app.ui.transactions.TransactionsScreen +import com.ledga.app.ui.trends.TrendsScreen + +private enum class Segment { Transactions, Trends } + +/** + * Activity tab — pairs the Transactions list and Trends analytics behind + * a single tab with a segmented switch on top. Users always toggle between + * "what happened" and "what's the pattern"; they shouldn't be two taps apart. + */ +@Composable +fun ActivityScreen() { + var segment by remember { mutableStateOf(Segment.Transactions) } + + Column(modifier = Modifier.fillMaxSize()) { + LedgaTopBar(title = "Activity") + + Segmented( + options = Segment.entries.toList(), + selected = segment, + onSelect = { segment = it }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Space.Screen, vertical = Space.s4), + ) + + when (segment) { + Segment.Transactions -> TransactionsScreen() + Segment.Trends -> TrendsScreen() + } + } +} + +@Composable +private fun Segmented( + options: List, + selected: Segment, + onSelect: (Segment) -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(999.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Row(modifier = Modifier.padding(4.dp)) { + options.forEach { opt -> + val isSel = opt == selected + val bg = if (isSel) MaterialTheme.colorScheme.surface + else androidx.compose.ui.graphics.Color.Transparent + val fg = if (isSel) MaterialTheme.colorScheme.onSurface + else MaterialTheme.colorScheme.onSurfaceVariant + Surface( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(999.dp)) + .clickable { onSelect(opt) }, + shape = RoundedCornerShape(999.dp), + color = bg, + ) { + Text( + text = opt.name, + style = LedgaText.BodyL, + color = fg, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + ) + } + } + } + } +} 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 index 7aed3e6..b789c40 100644 --- a/app/src/main/java/com/ledga/app/ui/components/BudgetProgressBar.kt +++ b/app/src/main/java/com/ledga/app/ui/components/BudgetProgressBar.kt @@ -12,8 +12,9 @@ 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.ui.theme.LedgaAccent +import com.ledga.app.ui.theme.LedgaDanger +import com.ledga.app.ui.theme.LedgaWarning import com.ledga.app.util.CurrencyFormatter @Composable @@ -26,9 +27,9 @@ fun BudgetProgressBar( 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 + percentage >= 100 -> LedgaDanger + percentage >= 80 -> LedgaWarning + else -> LedgaAccent } Column(modifier = modifier.fillMaxWidth()) { diff --git a/app/src/main/java/com/ledga/app/ui/components/TransactionCard.kt b/app/src/main/java/com/ledga/app/ui/components/TransactionCard.kt index b53124f..b7b6670 100644 --- a/app/src/main/java/com/ledga/app/ui/components/TransactionCard.kt +++ b/app/src/main/java/com/ledga/app/ui/components/TransactionCard.kt @@ -24,8 +24,7 @@ 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.ui.theme.LedgaInflow import com.ledga.app.util.CurrencyFormatter import com.ledga.app.util.DateUtils @@ -37,7 +36,7 @@ fun TransactionCard( modifier: Modifier = Modifier ) { val isInflow = transaction.direction == TransactionDirection.INFLOW - val amountColor = if (isInflow) InflowGreen else OutflowRed + val amountColor = if (isInflow) LedgaInflow else MaterialTheme.colorScheme.onSurface val displayName = transaction.recipientName ?: transaction.type.name.replace("_", " ") Card( diff --git a/app/src/main/java/com/ledga/app/ui/components/v2/AccountSwitcherSheet.kt b/app/src/main/java/com/ledga/app/ui/components/v2/AccountSwitcherSheet.kt new file mode 100644 index 0000000..e61aec6 --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/components/v2/AccountSwitcherSheet.kt @@ -0,0 +1,197 @@ +package com.ledga.app.ui.components.v2 + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.SimCard +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +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.draw.clip +import androidx.compose.ui.unit.dp +import com.ledga.app.data.db.entity.MpesaAccount +import com.ledga.app.ui.components.parseColor +import com.ledga.app.ui.theme.LedgaAccent +import com.ledga.app.ui.theme.LedgaAccentDeep +import com.ledga.app.ui.theme.LedgaText +import com.ledga.app.ui.theme.Space + +/** + * Bottom sheet that lists every linked M-Pesa account plus a "Combined" + * option. Opened from the AccountChip on Home. + * + * Selecting an entry updates the user's selected-account preference, + * which the repository observes and re-scopes every query against. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AccountSwitcherSheet( + accounts: List, + selectedAccountId: Long?, + onSelectAccount: (Long?) -> Unit, + onDismiss: () -> Unit, + onManage: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Space.Screen, vertical = Space.s4), + verticalArrangement = Arrangement.spacedBy(Space.s3), + ) { + Text( + text = "Accounts", + style = LedgaText.TitleL, + color = MaterialTheme.colorScheme.onSurface, + ) + + AccountRow( + title = "Combined", + subtitle = "Every line in one view", + color = LedgaAccent, + initials = null, + selected = selectedAccountId == null, + onClick = { + onSelectAccount(null) + onDismiss() + }, + ) + + accounts.forEach { account -> + AccountRow( + title = account.displayName, + subtitle = account.phoneNumber ?: "Subscription ${account.subscriptionId}", + color = parseColor(account.colorHex), + initials = account.displayName.initials(), + selected = selectedAccountId == account.id, + onClick = { + onSelectAccount(account.id) + onDismiss() + }, + ) + } + + ManageRow(onClick = { + onDismiss() + onManage() + }) + + Box(modifier = Modifier.padding(bottom = Space.s5)) + } + } +} + +@Composable +private fun AccountRow( + title: String, + subtitle: String, + color: androidx.compose.ui.graphics.Color, + initials: String?, + selected: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .clickable { onClick() } + .padding(vertical = 12.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Space.s4), + ) { + if (initials != null) { + Avatar(initials = initials, color = color, size = 40.dp) + } else { + Box( + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(14.dp)) + .background(color.copy(alpha = 0.14f)), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.SimCard, + contentDescription = null, + tint = color, + modifier = Modifier.size(20.dp), + ) + } + } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = title, + style = LedgaText.TitleS, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = subtitle, + style = LedgaText.Caption, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (selected) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = "Selected", + tint = LedgaAccentDeep, + modifier = Modifier.size(20.dp), + ) + } + } +} + +@Composable +private fun ManageRow(onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .clickable { onClick() } + .padding(vertical = 12.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Space.s4), + ) { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = null, + tint = LedgaAccentDeep, + modifier = Modifier.size(20.dp), + ) + Text( + text = "Manage accounts", + style = LedgaText.BodyL, + color = LedgaAccentDeep, + ) + } +} + +private fun String.initials(): String = + split(" ", limit = 2) + .mapNotNull { it.firstOrNull()?.toString() } + .joinToString("") + .take(2) + .uppercase() diff --git a/app/src/main/java/com/ledga/app/ui/components/v2/BentoCard.kt b/app/src/main/java/com/ledga/app/ui/components/v2/BentoCard.kt new file mode 100644 index 0000000..a3811b5 --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/components/v2/BentoCard.kt @@ -0,0 +1,135 @@ +package com.ledga.app.ui.components.v2 + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import com.ledga.app.ui.theme.LedgaText +import com.ledga.app.ui.theme.Radius +import com.ledga.app.ui.theme.Space + +/** + * Generic bento card — the workhorse container of the v2 UI. + * + * Variants: + * - tonal = false -> default white surface + 1px outline (light theme) + * - tonal = true -> filled with [tonalColor], no outline (e.g. accent-soft) + * + * Optional header row (icon + title + trailing chevron). Content goes in [content]. + */ +@Composable +fun BentoCard( + modifier: Modifier = Modifier, + title: String? = null, + overline: String? = null, + icon: ImageVector? = null, + iconTint: Color? = null, + trailing: (@Composable () -> Unit)? = null, + tonal: Boolean = false, + tonalColor: Color = MaterialTheme.colorScheme.primaryContainer, + contentPadding: androidx.compose.foundation.layout.PaddingValues = + androidx.compose.foundation.layout.PaddingValues(Space.Card), + onClick: (() -> Unit)? = null, + content: @Composable ColumnScope.() -> Unit, +) { + val container = if (tonal) tonalColor else MaterialTheme.colorScheme.surface + val border = if (tonal) null else BorderStroke(1.dp, MaterialTheme.colorScheme.outline) + + Surface( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(Radius.Card)) + .then(if (onClick != null) Modifier.clickable { onClick() } else Modifier), + shape = RoundedCornerShape(Radius.Card), + color = container, + border = border, + ) { + Column( + modifier = Modifier.padding(contentPadding), + verticalArrangement = Arrangement.spacedBy(Space.s4), + ) { + if (overline != null || title != null || icon != null || trailing != null) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Space.s4), + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + tint = iconTint ?: MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(20.dp), + ) + } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + if (overline != null) { + Text( + text = overline.uppercase(), + style = LedgaText.Overline, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (title != null) { + Text( + text = title, + style = LedgaText.TitleS, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + if (trailing != null) trailing() + } + } + content() + } + } +} + +/** + * Small 40×40 icon tile with rounded corners, category-color tint. + * Used in transaction rows, category chips, account avatars. + */ +@Composable +fun CategoryIconTile( + color: Color, + icon: ImageVector, + modifier: Modifier = Modifier, + size: androidx.compose.ui.unit.Dp = 40.dp, +) { + androidx.compose.foundation.layout.Box( + modifier = modifier + .size(size) + .clip(RoundedCornerShape(Radius.IconTile)) + .background(color.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = color, + modifier = Modifier.size(size * 0.5f), + ) + } +} diff --git a/app/src/main/java/com/ledga/app/ui/components/v2/Chip.kt b/app/src/main/java/com/ledga/app/ui/components/v2/Chip.kt new file mode 100644 index 0000000..d3d2c54 --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/components/v2/Chip.kt @@ -0,0 +1,71 @@ +package com.ledga.app.ui.components.v2 + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import com.ledga.app.ui.theme.LedgaAccent +import com.ledga.app.ui.theme.LedgaAccentDeep +import com.ledga.app.ui.theme.LedgaAccentSoft +import com.ledga.app.ui.theme.LedgaText + +/** + * Pill chip — filter rails, category chips, segmented controls. + * + * Selected state uses accent-soft bg + accent-deep text + accent border + * for the deliberate, glanceable "this is on" look from the spec. + */ +@Composable +fun LedgaChip( + label: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + leadingIcon: ImageVector? = null, +) { + val bg = if (selected) LedgaAccentSoft else MaterialTheme.colorScheme.surfaceVariant + val fg = if (selected) LedgaAccentDeep else MaterialTheme.colorScheme.onSurface + val border = if (selected) BorderStroke(1.dp, LedgaAccent) else null + + Surface( + modifier = modifier + .height(32.dp) + .clickable { onClick() }, + shape = RoundedCornerShape(999.dp), + color = bg, + border = border, + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + if (leadingIcon != null) { + Icon( + imageVector = leadingIcon, + contentDescription = null, + tint = fg, + modifier = Modifier.size(14.dp), + ) + } + Text( + text = label, + style = LedgaText.BodyM, + color = fg, + ) + } + } +} diff --git a/app/src/main/java/com/ledga/app/ui/components/v2/HeatmapCalendar.kt b/app/src/main/java/com/ledga/app/ui/components/v2/HeatmapCalendar.kt new file mode 100644 index 0000000..f4ae1a8 --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/components/v2/HeatmapCalendar.kt @@ -0,0 +1,187 @@ +package com.ledga.app.ui.components.v2 + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.ledga.app.ui.theme.LedgaAccent +import com.ledga.app.ui.theme.LedgaText +import java.util.Calendar + +/** + * GitHub-style heatmap calendar — one cell per day, columns are weeks. + * + * Spec from LEDGA_REDESIGN.md §4.4: "low ▢▢▢▢ high" intensity legend + * using --accent at 8/24/56/88% opacity (plus an empty step at ~6% for + * days with zero spend). + * + * [dailyAmounts] maps a day-start timestamp (epoch ms, normalized to + * 00:00:00) to a total spend amount for that day. Days that don't appear + * in the map render as empty cells. + */ +@Composable +fun HeatmapCalendar( + dailyAmounts: Map, + weeks: Int = 12, + modifier: Modifier = Modifier, +) { + val maxAmount = (dailyAmounts.values.maxOrNull() ?: 0.0).coerceAtLeast(1.0) + val today = Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + } + + // Anchor on Monday of the current week — then walk backwards `weeks` weeks. + val mondayThisWeek = Calendar.getInstance().apply { + timeInMillis = today.timeInMillis + val daysFromMonday = ((get(Calendar.DAY_OF_WEEK) + 5) % 7) + add(Calendar.DAY_OF_YEAR, -daysFromMonday) + } + val firstDay = Calendar.getInstance().apply { + timeInMillis = mondayThisWeek.timeInMillis + add(Calendar.WEEK_OF_YEAR, -(weeks - 1)) + } + + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + // Day-of-week labels row + cell grid as one block, with the day labels on the left + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.Top, + ) { + // Day labels column — only show M/W/F to avoid clutter. + Column( + modifier = Modifier.padding(end = 4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + listOf("M", "", "W", "", "F", "", "").forEach { letter -> + Box( + modifier = Modifier.size(width = 12.dp, height = 14.dp), + contentAlignment = Alignment.Center, + ) { + if (letter.isNotEmpty()) { + Text( + text = letter, + style = LedgaText.Caption, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + + // Week columns + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(4.dp, alignment = Alignment.End), + ) { + val cellsPerWeek = 7 + repeat(weeks) { weekIndex -> + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + repeat(cellsPerWeek) { dayIndex -> + val cellDay = Calendar.getInstance().apply { + timeInMillis = firstDay.timeInMillis + add(Calendar.WEEK_OF_YEAR, weekIndex) + add(Calendar.DAY_OF_YEAR, dayIndex) + } + val isFuture = cellDay.timeInMillis > today.timeInMillis + val amount = dailyAmounts[cellDay.timeInMillis] ?: 0.0 + HeatmapCell( + amount = amount, + maxAmount = maxAmount, + isFuture = isFuture, + ) + } + } + } + } + } + + // Legend row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "low", + style = LedgaText.Caption, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row( + modifier = Modifier.padding(horizontal = 6.dp), + horizontalArrangement = Arrangement.spacedBy(3.dp), + ) { + listOf(0.0, 0.2, 0.5, 0.8, 1.0).forEach { intensity -> + LegendCell(intensity) + } + } + Text( + text = "high", + style = LedgaText.Caption, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun HeatmapCell(amount: Double, maxAmount: Double, isFuture: Boolean) { + val color = when { + isFuture -> Color.Transparent + amount <= 0.0 -> LedgaAccent.copy(alpha = 0.06f) + else -> { + val intensity = (amount / maxAmount).coerceIn(0.0, 1.0) + val alpha = when { + intensity < 0.25 -> 0.18f + intensity < 0.50 -> 0.34f + intensity < 0.75 -> 0.60f + else -> 0.92f + } + LedgaAccent.copy(alpha = alpha) + } + } + Box( + modifier = Modifier + .size(14.dp) + .clip(RoundedCornerShape(3.dp)) + .background(color), + ) +} + +@Composable +private fun LegendCell(intensity: Double) { + val alpha = when { + intensity <= 0.0 -> 0.06f + intensity < 0.25 -> 0.18f + intensity < 0.50 -> 0.34f + intensity < 0.75 -> 0.60f + else -> 0.92f + } + Box( + modifier = Modifier + .size(10.dp) + .clip(RoundedCornerShape(2.dp)) + .background(LedgaAccent.copy(alpha = alpha)), + ) +} diff --git a/app/src/main/java/com/ledga/app/ui/components/v2/PillTabBar.kt b/app/src/main/java/com/ledga/app/ui/components/v2/PillTabBar.kt new file mode 100644 index 0000000..e91a21a --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/components/v2/PillTabBar.kt @@ -0,0 +1,135 @@ +package com.ledga.app.ui.components.v2 + +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +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.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import com.ledga.app.ui.theme.LedgaText +import com.ledga.app.ui.theme.Radius + +/** + * Tab definition for [PillTabBar]. Keep labels SHORT — they render uppercase + * at 10sp and need to fit comfortably in a quarter-width pill segment. + */ +data class PillTab( + val key: String, + val label: String, + val icon: ImageVector, +) + +/** + * Floating pill tab bar — spec from LEDGA_REDESIGN.md §2.3 and the Mobile + * Apps guideline. + * + * Container padding: 12 top / 21 sides+bottom (clears the home-indicator). + * Pill: 62h, 36 radius, 1px outline, 4px inner padding. + * Active item: solid accent fill + ink-on-accent label. + */ +@Composable +fun PillTabBar( + tabs: List, + selectedKey: String, + onSelect: (String) -> Unit, + modifier: Modifier = Modifier, +) { + require(tabs.size in 3..5) { + "PillTabBar supports 3–5 tabs only (got ${tabs.size})." + } + Box( + modifier = modifier + .fillMaxWidth() + .padding(PaddingValues(start = 21.dp, end = 21.dp, top = 12.dp, bottom = 21.dp)), + ) { + Surface( + modifier = Modifier + .fillMaxWidth() + .height(62.dp), + shape = RoundedCornerShape(Radius.TabBarPill), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + shadowElevation = 8.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + tabs.forEach { tab -> + PillTabItem( + tab = tab, + selected = tab.key == selectedKey, + onClick = { onSelect(tab.key) }, + modifier = Modifier.weight(1f), + ) + } + } + } + } +} + +@Composable +private fun PillTabItem( + tab: PillTab, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val bg by animateColorAsState( + targetValue = if (selected) MaterialTheme.colorScheme.primary + else androidx.compose.ui.graphics.Color.Transparent, + label = "pill-bg", + ) + val fg by animateColorAsState( + targetValue = if (selected) MaterialTheme.colorScheme.onPrimary + else MaterialTheme.colorScheme.onSurfaceVariant, + label = "pill-fg", + ) + + Column( + modifier = modifier + .fillMaxWidth() + .height(54.dp) + .clip(RoundedCornerShape(26.dp)) + .background(bg) + .clickable { onClick() } + .padding(4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = tab.icon, + contentDescription = tab.label, + tint = fg, + modifier = Modifier.size(18.dp), + ) + Box(modifier = Modifier.height(4.dp)) + Text( + text = tab.label.uppercase(), + style = LedgaText.Overline, + color = fg, + ) + } +} diff --git a/app/src/main/java/com/ledga/app/ui/components/v2/ProgressRing.kt b/app/src/main/java/com/ledga/app/ui/components/v2/ProgressRing.kt new file mode 100644 index 0000000..531be88 --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/components/v2/ProgressRing.kt @@ -0,0 +1,85 @@ +package com.ledga.app.ui.components.v2 + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +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.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.ledga.app.ui.theme.LedgaDanger +import com.ledga.app.ui.theme.LedgaText +import kotlin.math.min + +/** + * Circular progress ring used by Goal cards and Budget cards. + * + * - [progress] is clamped to 0f..1f, but values > 1 still render as an + * "over" state in [LedgaDanger] (over-budget). + * - Sizes from the spec: 56 / 80 / 120; stroke 6 fixed. + */ +@Composable +fun ProgressRing( + progress: Float, + modifier: Modifier = Modifier, + size: Dp = 80.dp, + strokeWidth: Dp = 6.dp, + trackColor: Color = MaterialTheme.colorScheme.surfaceVariant, + progressColor: Color = MaterialTheme.colorScheme.primary, + overColor: Color = LedgaDanger, + label: String? = null, +) { + val animated by animateFloatAsState( + targetValue = progress.coerceAtLeast(0f), + label = "ring-progress", + ) + val clamped = min(animated, 1f) + val isOver = animated > 1f + val fillColor = if (isOver) overColor else progressColor + + Box( + modifier = modifier.size(size), + contentAlignment = Alignment.Center, + ) { + Canvas(modifier = Modifier.size(size)) { + val sw = strokeWidth.toPx() + val arcSize = Size(this.size.width - sw, this.size.height - sw) + val topLeft = Offset(sw / 2f, sw / 2f) + drawArc( + color = trackColor, + startAngle = 0f, + sweepAngle = 360f, + useCenter = false, + topLeft = topLeft, + size = arcSize, + style = Stroke(width = sw, cap = StrokeCap.Round), + ) + drawArc( + color = fillColor, + startAngle = -90f, + sweepAngle = 360f * clamped, + useCenter = false, + topLeft = topLeft, + size = arcSize, + style = Stroke(width = sw, cap = StrokeCap.Round), + ) + } + if (label != null) { + Text( + text = label, + style = LedgaText.TitleS, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } +} diff --git a/app/src/main/java/com/ledga/app/ui/components/v2/SearchField.kt b/app/src/main/java/com/ledga/app/ui/components/v2/SearchField.kt new file mode 100644 index 0000000..3b9587b --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/components/v2/SearchField.kt @@ -0,0 +1,95 @@ +package com.ledga.app.ui.components.v2 + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +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.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import com.ledga.app.ui.theme.LedgaText + +/** + * Pill-shaped search input — matches the bento-card aesthetic by sitting + * on `surfaceVariant` with no outline (depth comes from the well, not a border). + */ +@Composable +fun LedgaSearchField( + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier + .fillMaxWidth() + .height(48.dp), + shape = RoundedCornerShape(999.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Row( + modifier = Modifier.padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + imageVector = Icons.Filled.Search, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + androidx.compose.foundation.layout.Box( + modifier = Modifier.weight(1f), + contentAlignment = Alignment.CenterStart, + ) { + if (value.isEmpty()) { + Text( + text = placeholder, + style = LedgaText.BodyL, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + BasicTextField( + value = value, + onValueChange = onValueChange, + textStyle = LedgaText.BodyL.copy(color = MaterialTheme.colorScheme.onSurface), + cursorBrush = androidx.compose.ui.graphics.SolidColor( + MaterialTheme.colorScheme.primary + ), + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + modifier = Modifier.fillMaxWidth(), + ) + } + if (value.isNotEmpty()) { + IconButton( + onClick = { onValueChange("") }, + modifier = Modifier.size(28.dp), + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "Clear", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp), + ) + } + } + } + } +} diff --git a/app/src/main/java/com/ledga/app/ui/components/v2/StatCard.kt b/app/src/main/java/com/ledga/app/ui/components/v2/StatCard.kt new file mode 100644 index 0000000..a0b98ce --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/components/v2/StatCard.kt @@ -0,0 +1,121 @@ +package com.ledga.app.ui.components.v2 + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.ledga.app.ui.theme.LedgaAccent +import com.ledga.app.ui.theme.LedgaAccentSoft +import com.ledga.app.ui.theme.LedgaDanger +import com.ledga.app.ui.theme.LedgaDangerSoft +import com.ledga.app.ui.theme.LedgaText +import com.ledga.app.ui.theme.Space + +/** + * A compact stat card — overline, big value, optional delta chip. + * + * Used everywhere a key number lives: Spent / Balance / Top category / + * Net cashflow / Fees. Keeps numbers consistent in scale and treatment. + */ +@Composable +fun StatCard( + overline: String, + value: String, + modifier: Modifier = Modifier, + delta: DeltaChip? = null, + caption: String? = null, + tonal: Boolean = false, +) { + BentoCard( + modifier = modifier, + tonal = tonal, + ) { + Text( + text = overline.uppercase(), + style = LedgaText.Overline, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = value, + style = LedgaText.DisplayM, + color = MaterialTheme.colorScheme.onSurface, + ) + if (delta != null || caption != null) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Space.s3), + ) { + if (delta != null) DeltaChipUI(delta) + if (caption != null) { + Text( + text = caption, + style = LedgaText.BodyM, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +enum class DeltaDirection { Up, Down, Flat } + +data class DeltaChip( + val direction: DeltaDirection, + val label: String, + /** Default semantics: Up = inflow (good) when [positiveIsGood] = true. */ + val positiveIsGood: Boolean = true, +) + +@Composable +private fun DeltaChipUI(delta: DeltaChip) { + val good = when (delta.direction) { + DeltaDirection.Up -> delta.positiveIsGood + DeltaDirection.Down -> !delta.positiveIsGood + DeltaDirection.Flat -> true + } + val bg = if (good) LedgaAccentSoft else LedgaDangerSoft + val fg = if (good) LedgaAccent else LedgaDanger + val icon = when (delta.direction) { + DeltaDirection.Up -> Icons.Filled.ArrowUpward + DeltaDirection.Down -> Icons.Filled.ArrowDownward + DeltaDirection.Flat -> null + } + Surface( + shape = RoundedCornerShape(999.dp), + color = bg, + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + tint = fg, + modifier = Modifier.size(12.dp), + ) + } + Text( + text = delta.label, + style = LedgaText.Caption, + color = fg, + ) + } + } +} + diff --git a/app/src/main/java/com/ledga/app/ui/components/v2/TopBar.kt b/app/src/main/java/com/ledga/app/ui/components/v2/TopBar.kt new file mode 100644 index 0000000..d054976 --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/components/v2/TopBar.kt @@ -0,0 +1,176 @@ +package com.ledga.app.ui.components.v2 + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +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.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import com.ledga.app.ui.theme.LedgaText +import com.ledga.app.ui.theme.Space + +/** + * The Ledga v2 top bar. Three slots: + * leading -> avatar + greeting, or back button + * center -> screen title (when [showTitle] = true; default for non-Home) + * trailing -> 0-2 icon actions + * + * Background is transparent (sits on top of the screen wrapper) — the + * caller is responsible for vertical padding to clear the status bar. + */ +@Composable +fun LedgaTopBar( + title: String? = null, + modifier: Modifier = Modifier, + leading: @Composable () -> Unit = {}, + trailing: @Composable () -> Unit = {}, + showTitle: Boolean = title != null, +) { + Row( + modifier = modifier + .fillMaxWidth() + .height(64.dp) + .padding(horizontal = Space.Screen), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Space.s4), + ) { + leading() + if (showTitle && title != null) { + Text( + text = title, + style = LedgaText.TitleL, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + } else { + Box(modifier = Modifier.weight(1f)) + } + trailing() + } +} + +/** + * AccountChip — header element on Home that opens the multi-SIM switcher. + * Shown only when there are 2+ accounts; for single-account users this + * collapses to a plain Avatar. + */ +@Composable +fun AccountChip( + initials: String, + label: String, + accentColor: Color, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier + .clip(RoundedCornerShape(999.dp)) + .clickable { onClick() }, + shape = RoundedCornerShape(999.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Row( + modifier = Modifier.padding(start = 4.dp, end = 10.dp, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Avatar(initials = initials, color = accentColor) + Text( + text = label, + style = LedgaText.BodyM, + color = MaterialTheme.colorScheme.onSurface, + ) + Icon( + imageVector = Icons.Filled.KeyboardArrowDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp), + ) + } + } +} + +@Composable +fun Avatar( + initials: String, + color: Color, + modifier: Modifier = Modifier, + size: androidx.compose.ui.unit.Dp = 32.dp, +) { + Box( + modifier = modifier + .size(size) + .clip(CircleShape) + .background(color.copy(alpha = 0.18f)), + contentAlignment = Alignment.Center, + ) { + Text( + text = initials.take(2).uppercase(), + style = LedgaText.Caption, + color = color, + ) + } +} + +/** Convenience leading for detail screens. */ +@Composable +fun BackLeading(onBack: () -> Unit) { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.Filled.ArrowBack, + contentDescription = "Back", + tint = MaterialTheme.colorScheme.onSurface, + ) + } +} + +/** Convenience trailing slot: a round icon button on surfaceVariant. */ +@Composable +fun TopBarIconButton( + icon: ImageVector, + contentDescription: String, + onClick: () -> Unit, +) { + Surface( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .clickable { onClick() }, + shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(20.dp), + ) + } + } +} + +// Padding helper for screens that want the standard top-bar inset (status bar 62 + 4 buffer). +val TopBarHeightWithStatus = PaddingValues(top = 62.dp + 4.dp) diff --git a/app/src/main/java/com/ledga/app/ui/components/v2/TransactionRow.kt b/app/src/main/java/com/ledga/app/ui/components/v2/TransactionRow.kt new file mode 100644 index 0000000..89381db --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/components/v2/TransactionRow.kt @@ -0,0 +1,86 @@ +package com.ledga.app.ui.components.v2 + +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +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.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.ledga.app.ui.components.categoryIcon +import com.ledga.app.ui.theme.LedgaInflow +import com.ledga.app.ui.theme.LedgaText +import com.ledga.app.ui.theme.Space +import com.ledga.app.util.CurrencyFormatter +import com.ledga.app.util.DateUtils + +/** + * Canonical transaction row used inside bento cards (recent activity, day + * groups, search results). Designed to sit edge-to-edge inside a card — + * the card supplies the rounded background and horizontal padding. + */ +@Composable +fun TransactionRowV2( + recipient: String, + amount: Double, + isInflow: Boolean, + category: String?, + color: Color, + icon: String, + balance: Double, + timestamp: Long? = null, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, +) { + Row( + modifier = modifier + .fillMaxWidth() + .then(if (onClick != null) Modifier.clickable { onClick() } else Modifier) + .padding(horizontal = Space.Card, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Space.s4), + ) { + CategoryIconTile(color = color, icon = categoryIcon(icon)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = recipient, + style = LedgaText.BodyL, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = buildString { + append(category ?: "Uncategorized") + if (timestamp != null) { + append(" · ") + append(DateUtils.formatTime(timestamp)) + } + }, + style = LedgaText.Caption, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Column(horizontalAlignment = Alignment.End) { + Text( + text = CurrencyFormatter.formatKshSigned(amount, isInflow), + style = LedgaText.TitleS, + color = if (isInflow) LedgaInflow else MaterialTheme.colorScheme.onSurface, + ) + Text( + text = "Bal ${CurrencyFormatter.formatKshCompact(balance)}", + style = LedgaText.Caption, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/app/src/main/java/com/ledga/app/ui/goals/AddGoalSheet.kt b/app/src/main/java/com/ledga/app/ui/goals/AddGoalSheet.kt new file mode 100644 index 0000000..244d96f --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/goals/AddGoalSheet.kt @@ -0,0 +1,255 @@ +package com.ledga.app.ui.goals + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.ledga.app.data.db.entity.ContributionRule +import com.ledga.app.ui.theme.CatAirtime +import com.ledga.app.ui.theme.CatFood +import com.ledga.app.ui.theme.CatReceived +import com.ledga.app.ui.theme.CatSavings +import com.ledga.app.ui.theme.CatShopping +import com.ledga.app.ui.theme.CatTransport +import com.ledga.app.ui.theme.LedgaAccent +import com.ledga.app.ui.theme.LedgaText +import com.ledga.app.ui.theme.Space +import com.ledga.app.util.DateUtils + +private enum class RuleChoice(val label: String, val help: String) { + AllSavings( + label = "All savings deposits", + help = "Every M-Shwari and KCB M-Pesa deposit counts.", + ), + ToRecipient( + label = "Specific recipient or bill", + help = "Match any outflow whose recipient name contains a fragment you give (e.g., \"School fees\").", + ), + Manual( + label = "Manual", + help = "You'll tap \"Add to goal\" inside any transaction's detail.", + ), +} + +private val ColorChoices = listOf( + LedgaAccent, CatTransport, CatFood, CatAirtime, + CatReceived, CatSavings, CatShopping, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddGoalSheet( + onDismiss: () -> Unit, + onCreate: ( + name: String, + targetAmount: Double, + targetDate: Long?, + rule: ContributionRule, + colorHex: String, + ) -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var name by remember { mutableStateOf("") } + var amountText by remember { mutableStateOf("") } + var ruleChoice by remember { mutableStateOf(RuleChoice.AllSavings) } + var recipientFragment by remember { mutableStateOf("") } + var targetDate by remember { mutableStateOf(null) } + var color by remember { mutableStateOf(LedgaAccent) } + var pickingDate by remember { mutableStateOf(false) } + + if (pickingDate) { + val ps = rememberDatePickerState(initialSelectedDateMillis = targetDate) + DatePickerDialog( + onDismissRequest = { pickingDate = false }, + confirmButton = { + TextButton(onClick = { + targetDate = ps.selectedDateMillis + pickingDate = false + }) { Text("OK") } + }, + dismissButton = { + TextButton(onClick = { pickingDate = false }) { Text("Cancel") } + } + ) { DatePicker(state = ps) } + } + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Space.Screen, vertical = Space.s4) + .padding(bottom = Space.s6), + verticalArrangement = Arrangement.spacedBy(Space.s5), + ) { + Text( + text = "New goal", + style = LedgaText.TitleL, + color = MaterialTheme.colorScheme.onSurface, + ) + + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Name (e.g. School fees)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedTextField( + value = amountText, + onValueChange = { amountText = it.filter { c -> c.isDigit() || c == '.' } }, + label = { Text("Target amount (Ksh)") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { pickingDate = true } + .border( + 1.dp, + MaterialTheme.colorScheme.outline, + RoundedCornerShape(8.dp), + ) + .padding(horizontal = 16.dp, vertical = 14.dp), + ) { + Text( + text = targetDate?.let { "Target date · ${DateUtils.formatDate(it)}" } + ?: "Target date (optional)", + style = LedgaText.BodyL, + color = if (targetDate != null) MaterialTheme.colorScheme.onSurface + else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Text( + text = "How to track".uppercase(), + style = LedgaText.Overline, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + RuleChoice.entries.forEach { choice -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { ruleChoice = choice } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.Top, + ) { + RadioButton( + selected = ruleChoice == choice, + onClick = { ruleChoice = choice }, + ) + Column( + modifier = Modifier.padding(start = 4.dp, top = 12.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = choice.label, + style = LedgaText.BodyL, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = choice.help, + style = LedgaText.Caption, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + + if (ruleChoice == RuleChoice.ToRecipient) { + OutlinedTextField( + value = recipientFragment, + onValueChange = { recipientFragment = it }, + label = { Text("Recipient name fragment") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + + Text( + text = "Color".uppercase(), + style = LedgaText.Overline, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row(horizontalArrangement = Arrangement.spacedBy(Space.s3)) { + ColorChoices.forEach { c -> + Box( + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .background(c) + .then( + if (c == color) + Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape) + else Modifier + ) + .clickable { color = c } + ) + } + } + + val target = amountText.toDoubleOrNull() ?: 0.0 + val canSubmit = name.isNotBlank() && target > 0 && + (ruleChoice != RuleChoice.ToRecipient || recipientFragment.isNotBlank()) + + Button( + enabled = canSubmit, + onClick = { + val rule = when (ruleChoice) { + RuleChoice.AllSavings -> ContributionRule.AllSavingsDeposits + RuleChoice.ToRecipient -> + ContributionRule.ToRecipient(recipientFragment.trim()) + RuleChoice.Manual -> ContributionRule.Manual + } + onCreate(name.trim(), target, targetDate, rule, color.toHex()) + }, + colors = ButtonDefaults.buttonColors(containerColor = LedgaAccent), + modifier = Modifier.fillMaxWidth(), + ) { Text("Create goal") } + } + } +} + +private fun Color.toHex(): String { + val argb = this.value.toLong() shr 32 and 0xFFFFFFFFL + return "#%06X".format(argb.toInt() and 0xFFFFFF) +} diff --git a/app/src/main/java/com/ledga/app/ui/goals/GoalDetailScreen.kt b/app/src/main/java/com/ledga/app/ui/goals/GoalDetailScreen.kt new file mode 100644 index 0000000..af0c7e7 --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/goals/GoalDetailScreen.kt @@ -0,0 +1,195 @@ +package com.ledga.app.ui.goals + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.ledga.app.data.parser.TransactionDirection +import com.ledga.app.ui.components.parseColor +import com.ledga.app.ui.components.v2.BackLeading +import com.ledga.app.ui.components.v2.BentoCard +import com.ledga.app.ui.components.v2.LedgaTopBar +import com.ledga.app.ui.components.v2.ProgressRing +import com.ledga.app.ui.components.v2.TopBarIconButton +import com.ledga.app.ui.components.v2.TransactionRowV2 +import com.ledga.app.ui.theme.LedgaText +import com.ledga.app.ui.theme.Space +import com.ledga.app.util.CurrencyFormatter +import com.ledga.app.util.DateUtils + +@Composable +fun GoalDetailScreen( + onBack: () -> Unit, + viewModel: GoalDetailViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsState() + var confirmingDelete by remember { mutableStateOf(false) } + + if (confirmingDelete) { + AlertDialog( + onDismissRequest = { confirmingDelete = false }, + title = { Text("Delete goal?") }, + text = { Text("This removes the goal but keeps all transactions intact.") }, + confirmButton = { + TextButton(onClick = { + confirmingDelete = false + viewModel.delete(onDeleted = onBack) + }) { Text("Delete") } + }, + dismissButton = { + TextButton(onClick = { confirmingDelete = false }) { Text("Cancel") } + } + ) + } + + Column(modifier = Modifier.fillMaxSize()) { + LedgaTopBar( + title = state.goal?.goal?.name ?: "Goal", + leading = { BackLeading(onBack) }, + trailing = { + TopBarIconButton( + icon = Icons.Filled.Delete, + contentDescription = "Delete goal", + onClick = { confirmingDelete = true }, + ) + }, + ) + + val gp = state.goal + if (gp == null) { + Column( + modifier = Modifier.fillMaxSize().padding(Space.Screen), + ) { + Text( + text = if (state.isLoading) "Loading…" else "Goal not found.", + style = LedgaText.BodyL, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + return@Column + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = Space.Screen) + .padding(bottom = 120.dp), + verticalArrangement = Arrangement.spacedBy(Space.Section), + ) { + // Hero + BentoCard { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Space.s7), + ) { + ProgressRing( + progress = gp.percent, + size = 120.dp, + strokeWidth = 8.dp, + progressColor = parseColor(gp.goal.colorHex), + label = "${(gp.percent * 100).toInt()}%", + ) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = CurrencyFormatter.formatKsh(gp.currentAmount), + style = LedgaText.TitleL, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = "of ${CurrencyFormatter.formatKsh(gp.goal.targetAmount)}", + style = LedgaText.BodyM, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + gp.goal.targetDate?.let { + Text( + text = "Target ${DateUtils.formatDate(it)}", + style = LedgaText.Caption, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + gp.eta?.let { + Text( + text = "Projected ${DateUtils.formatDate(it)}", + style = LedgaText.Caption, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + + // Contributions + BentoCard( + contentPadding = androidx.compose.foundation.layout.PaddingValues(0.dp), + ) { + Text( + text = "Contributions (${state.contributions.size})".uppercase(), + style = LedgaText.Overline, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = Space.Card, vertical = 14.dp), + ) + HorizontalDivider( + color = MaterialTheme.colorScheme.outline, + modifier = Modifier.padding(horizontal = Space.Card), + ) + + if (state.contributions.isEmpty()) { + Text( + text = "No contributing transactions yet. New matching " + + "transactions will appear here automatically.", + style = LedgaText.BodyM, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(Space.Card), + ) + } else { + state.contributions.forEachIndexed { index, twc -> + TransactionRowV2( + recipient = twc.transaction.recipientName + ?: twc.transaction.type.name.replace("_", " "), + amount = twc.transaction.amount, + isInflow = twc.transaction.direction == TransactionDirection.INFLOW, + category = twc.category?.name, + color = twc.category?.color?.let(::parseColor) + ?: androidx.compose.ui.graphics.Color.Gray, + icon = twc.category?.icon ?: "category", + balance = twc.transaction.balance, + timestamp = twc.transaction.timestamp, + ) + if (index < state.contributions.lastIndex) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outline, + modifier = Modifier.padding(horizontal = Space.Card), + ) + } + } + } + } + + Box(modifier = Modifier.padding(bottom = Space.s5)) + } + } +} diff --git a/app/src/main/java/com/ledga/app/ui/goals/GoalDetailViewModel.kt b/app/src/main/java/com/ledga/app/ui/goals/GoalDetailViewModel.kt new file mode 100644 index 0000000..77a2fbb --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/goals/GoalDetailViewModel.kt @@ -0,0 +1,63 @@ +package com.ledga.app.ui.goals + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ledga.app.data.db.entity.TransactionWithCategory +import com.ledga.app.data.repository.GoalWithProgress +import com.ledga.app.data.repository.GoalsRepository +import com.ledga.app.ui.navigation.GoalDetailRoute +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import androidx.navigation.toRoute +import javax.inject.Inject + +data class GoalDetailUiState( + val goal: GoalWithProgress? = null, + val contributions: List = emptyList(), + val isLoading: Boolean = true, +) + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class GoalDetailViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val goalsRepository: GoalsRepository, +) : ViewModel() { + + private val goalId: Long = savedStateHandle.toRoute().goalId + + val uiState: StateFlow = goalsRepository.observeProgress(goalId) + .flatMapLatest { withProgress -> + if (withProgress == null) flowOf(GoalDetailUiState(isLoading = false)) + else goalsRepository.observeContributingTransactions(withProgress.goal) + .map { contributions -> + GoalDetailUiState( + goal = withProgress, + contributions = contributions, + isLoading = false, + ) + } + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), GoalDetailUiState()) + + fun markComplete() { + viewModelScope.launch { goalsRepository.markComplete(goalId) } + } + + fun delete(onDeleted: () -> Unit) { + viewModelScope.launch { + val g = goalsRepository.findById(goalId) ?: return@launch + goalsRepository.delete(g) + onDeleted() + } + } +} diff --git a/app/src/main/java/com/ledga/app/ui/goals/GoalsScreen.kt b/app/src/main/java/com/ledga/app/ui/goals/GoalsScreen.kt new file mode 100644 index 0000000..630143d --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/goals/GoalsScreen.kt @@ -0,0 +1,212 @@ +package com.ledga.app.ui.goals + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Flag +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.GoalStatus +import com.ledga.app.data.repository.GoalWithProgress +import com.ledga.app.ui.components.parseColor +import com.ledga.app.ui.components.v2.BackLeading +import com.ledga.app.ui.components.v2.BentoCard +import com.ledga.app.ui.components.v2.LedgaTopBar +import com.ledga.app.ui.components.v2.ProgressRing +import com.ledga.app.ui.components.v2.TopBarIconButton +import com.ledga.app.ui.theme.LedgaAccent +import com.ledga.app.ui.theme.LedgaAccentDeep +import com.ledga.app.ui.theme.LedgaAccentSoft +import com.ledga.app.ui.theme.LedgaDanger +import com.ledga.app.ui.theme.LedgaDangerSoft +import com.ledga.app.ui.theme.LedgaText +import com.ledga.app.ui.theme.LedgaWarning +import com.ledga.app.ui.theme.LedgaWarningSoft +import com.ledga.app.ui.theme.Space +import com.ledga.app.util.CurrencyFormatter +import com.ledga.app.util.DateUtils + +@Composable +fun GoalsScreen( + onBack: () -> Unit, + viewModel: GoalsViewModel = hiltViewModel(), + onOpenGoal: (Long) -> Unit = {}, +) { + val state by viewModel.uiState.collectAsState() + var addOpen by remember { mutableStateOf(false) } + + if (addOpen) { + AddGoalSheet( + onDismiss = { addOpen = false }, + onCreate = { name, target, date, rule, color -> + viewModel.create(name, target, date, rule, color) + addOpen = false + }, + ) + } + + Column(modifier = Modifier.fillMaxSize()) { + LedgaTopBar( + title = "Goals", + leading = { BackLeading(onBack) }, + trailing = { + TopBarIconButton( + icon = Icons.Filled.Add, + contentDescription = "Add goal", + onClick = { addOpen = true }, + ) + }, + ) + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = Space.Screen) + .padding(bottom = 120.dp), + verticalArrangement = Arrangement.spacedBy(Space.Section), + ) { + when { + state.isLoading -> Unit + state.goals.isEmpty() -> EmptyState() + else -> state.goals.forEach { gp -> + GoalCard(gp = gp, onClick = { onOpenGoal(gp.goal.id) }) + } + } + + Box(modifier = Modifier.padding(bottom = Space.s5)) + } + } +} + +@Composable +private fun EmptyState() { + BentoCard( + overline = "Set a goal", + title = "Track rent, school fees, a trip", + icon = Icons.Filled.Flag, + iconTint = LedgaAccentDeep, + tonal = true, + tonalColor = LedgaAccentSoft, + ) { + Text( + text = "Pick what you're saving toward and how you'll contribute. " + + "Ledga keeps the progress moving on its own — no manual entry.", + style = LedgaText.BodyM, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} + +@Composable +private fun GoalCard(gp: GoalWithProgress, onClick: () -> Unit) { + val ringColor = parseColor(gp.goal.colorHex) + BentoCard(onClick = onClick) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Space.s5), + ) { + ProgressRing( + progress = gp.percent, + size = 72.dp, + strokeWidth = 6.dp, + progressColor = ringColor, + label = "${(gp.percent * 100).toInt()}%", + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = gp.goal.name, + style = LedgaText.TitleM, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = "${CurrencyFormatter.formatKshCompact(gp.currentAmount)} of " + + CurrencyFormatter.formatKshCompact(gp.goal.targetAmount), + style = LedgaText.BodyM, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + StatusBadge(status = gp.status, eta = gp.eta, targetDate = gp.goal.targetDate) + } + } + } +} + +@Composable +private fun StatusBadge(status: GoalStatus, eta: Long?, targetDate: Long?) { + val (bg, fg, icon, text) = when (status) { + GoalStatus.Completed -> StatusVisual( + bg = LedgaAccentSoft, fg = LedgaAccentDeep, + icon = Icons.Filled.Check, text = "Completed", + ) + GoalStatus.OnTrack -> StatusVisual( + bg = LedgaAccentSoft, fg = LedgaAccentDeep, + icon = null, text = buildEtaText("On track", eta, targetDate), + ) + GoalStatus.Ahead -> StatusVisual( + bg = LedgaAccentSoft, fg = LedgaAccentDeep, + icon = null, text = buildEtaText("Ahead", eta, targetDate), + ) + GoalStatus.Behind -> StatusVisual( + bg = LedgaWarningSoft, fg = LedgaWarning, + icon = null, text = buildEtaText("Behind", eta, targetDate), + ) + } + Surface( + shape = RoundedCornerShape(999.dp), + color = bg, + modifier = Modifier.padding(top = 4.dp), + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (icon != null) { + androidx.compose.material3.Icon( + imageVector = icon, + contentDescription = null, + tint = fg, + modifier = Modifier.size(12.dp), + ) + } + Text(text = text, style = LedgaText.Caption, color = fg) + } + } +} + +private data class StatusVisual( + val bg: androidx.compose.ui.graphics.Color, + val fg: androidx.compose.ui.graphics.Color, + val icon: androidx.compose.ui.graphics.vector.ImageVector?, + val text: String, +) + +private fun buildEtaText(prefix: String, eta: Long?, targetDate: Long?): String { + val ref = eta ?: targetDate ?: return prefix + return "$prefix · ETA ${DateUtils.formatDate(ref)}" +} diff --git a/app/src/main/java/com/ledga/app/ui/goals/GoalsViewModel.kt b/app/src/main/java/com/ledga/app/ui/goals/GoalsViewModel.kt new file mode 100644 index 0000000..2ee1d2d --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/goals/GoalsViewModel.kt @@ -0,0 +1,55 @@ +package com.ledga.app.ui.goals + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ledga.app.data.db.entity.ContributionRule +import com.ledga.app.data.db.entity.Goal +import com.ledga.app.data.repository.GoalsRepository +import com.ledga.app.data.repository.GoalWithProgress +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class GoalsUiState( + val goals: List = emptyList(), + val isLoading: Boolean = true, +) + +@HiltViewModel +class GoalsViewModel @Inject constructor( + private val goalsRepository: GoalsRepository, +) : ViewModel() { + + val uiState: StateFlow = goalsRepository + .observeGoalsWithProgress() + .let { flow -> + kotlinx.coroutines.flow.flow { + emit(GoalsUiState(isLoading = true)) + flow.collect { emit(GoalsUiState(goals = it, isLoading = false)) } + } + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), GoalsUiState()) + + fun create( + name: String, + targetAmount: Double, + targetDate: Long?, + rule: ContributionRule, + colorHex: String, + ) { + viewModelScope.launch { + goalsRepository.create(name, targetAmount, targetDate, rule, colorHex) + } + } + + fun markComplete(goalId: Long) { + viewModelScope.launch { goalsRepository.markComplete(goalId) } + } + + fun delete(goal: Goal) { + viewModelScope.launch { goalsRepository.delete(goal) } + } +} diff --git a/app/src/main/java/com/ledga/app/ui/home/HomeScreen.kt b/app/src/main/java/com/ledga/app/ui/home/HomeScreen.kt index a1bbb5d..5946a46 100644 --- a/app/src/main/java/com/ledga/app/ui/home/HomeScreen.kt +++ b/app/src/main/java/com/ledga/app/ui/home/HomeScreen.kt @@ -1,23 +1,22 @@ package com.ledga.app.ui.home import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AutoAwesome +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -25,286 +24,379 @@ import androidx.compose.material3.Text 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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import com.ledga.app.ui.components.DonutChart -import com.ledga.app.ui.components.PeriodSelector -import com.ledga.app.ui.components.TransactionCard -import com.ledga.app.ui.components.categoryIcon import com.ledga.app.ui.components.parseColor -import com.ledga.app.ui.theme.MpesaGreen -import com.ledga.app.ui.theme.MpesaGreenDark +import com.ledga.app.ui.components.v2.AccountChip +import com.ledga.app.ui.components.v2.AccountSwitcherSheet +import com.ledga.app.ui.components.v2.Avatar +import com.ledga.app.ui.components.v2.BentoCard +import com.ledga.app.ui.components.v2.LedgaTopBar +import com.ledga.app.ui.components.v2.StatCard +import com.ledga.app.ui.components.v2.TopBarIconButton +import com.ledga.app.ui.components.v2.TransactionRowV2 +import com.ledga.app.ui.theme.LedgaAccent +import com.ledga.app.ui.theme.LedgaAccentDeep +import com.ledga.app.ui.theme.LedgaAccentSoft +import com.ledga.app.ui.theme.LedgaInflow +import com.ledga.app.ui.theme.LedgaInk +import com.ledga.app.ui.theme.LedgaText +import com.ledga.app.ui.theme.Radius +import com.ledga.app.ui.theme.Space import com.ledga.app.util.CurrencyFormatter @Composable fun HomeScreen( - viewModel: HomeViewModel = hiltViewModel() + viewModel: HomeViewModel = hiltViewModel(), + onNavigateToActivity: () -> Unit = {}, + onNavigateToInsights: () -> Unit = {}, + onNavigateToYou: () -> Unit = {}, + onManageAccounts: () -> Unit = {}, + onOpenUpdate: () -> Unit = {}, ) { val state by viewModel.uiState.collectAsState() + var switcherOpen by remember { mutableStateOf(false) } - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - // Update banner - if (state.updateAvailable != null) { - item { - Spacer(modifier = Modifier.height(12.dp)) - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors( - containerColor = MpesaGreenDark + if (switcherOpen) { + AccountSwitcherSheet( + accounts = state.accounts, + selectedAccountId = state.selectedAccountId, + onSelectAccount = { viewModel.selectAccount(it) }, + onDismiss = { switcherOpen = false }, + onManage = onManageAccounts, + ) + } + + val activeAccount = state.accounts.firstOrNull { it.id == state.selectedAccountId } + val accountLabel = activeAccount?.displayName + ?: if (state.accounts.size > 1) "Combined" else state.accounts.firstOrNull()?.displayName + val accountColor = activeAccount?.colorHex?.let(::parseColor) ?: LedgaAccent + val accountInitials = accountLabel?.split(" ") + ?.mapNotNull { it.firstOrNull()?.toString() } + ?.joinToString("") + ?.take(2) + ?.uppercase() + ?: "ME" + + Column(modifier = Modifier.fillMaxSize()) { + // ---- Top bar: account chip + greeting + actions ---- + LedgaTopBar( + leading = { + if (state.accounts.size > 1) { + AccountChip( + initials = accountInitials, + label = accountLabel ?: "Combined", + accentColor = accountColor, + onClick = { switcherOpen = true }, ) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically + } else { + Avatar(initials = accountInitials, color = accountColor) + Column( + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = Modifier.padding(start = 4.dp), ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = "Update Available", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - color = androidx.compose.ui.graphics.Color.White - ) - Text( - text = "Version ${state.updateAvailable!!.tag_name}", - style = MaterialTheme.typography.bodySmall, - color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.8f) + Text( + text = state.greeting.takeIf { it.isNotBlank() } ?: "Hi", + style = LedgaText.BodyM, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = accountLabel ?: "Your tracker", + style = LedgaText.TitleS, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + }, + trailing = { + Row(horizontalArrangement = Arrangement.spacedBy(Space.s3)) { + TopBarIconButton( + icon = Icons.Filled.Notifications, + contentDescription = "Notifications", + onClick = {}, + ) + TopBarIconButton( + icon = Icons.Filled.Settings, + contentDescription = "Settings", + onClick = onNavigateToYou, + ) + } + }, + ) + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = Space.Screen) + .padding(bottom = 120.dp), + verticalArrangement = Arrangement.spacedBy(Space.Section), + ) { + // ---- Update banner (conditional) ---- + state.updateAvailable?.let { release -> + UpdateBanner( + versionLabel = release.tag_name, + prefetched = state.updatePrefetched, + onSeeWhatsNew = onOpenUpdate, + onUpdate = onOpenUpdate, + ) + } + + // ---- HERO: spent this month ---- + BentoCard(onClick = onNavigateToActivity) { + Text( + text = "SPENT THIS MONTH", + style = LedgaText.Overline, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = CurrencyFormatter.formatKsh(state.totalSpending), + style = LedgaText.DisplayL, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = state.monthLabel + + if (state.totalFees > 0) " · ${CurrencyFormatter.formatKsh(state.totalFees)} in fees" else "", + style = LedgaText.BodyM, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // ---- 2-up: Balance · Top category ---- + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Space.s4), + ) { + StatCard( + overline = "Balance", + value = state.balance?.let { CurrencyFormatter.formatKshCompact(it) } ?: "—", + caption = "from last tx", + modifier = Modifier.weight(1f), + ) + val top = state.categoryBreakdown.firstOrNull() + if (top != null) { + StatCard( + overline = "Top category", + value = CurrencyFormatter.formatKshCompact(top.amount), + caption = top.name, + modifier = Modifier.weight(1f), + ) + } else { + StatCard( + overline = "Top category", + value = "—", + caption = "no spending yet", + modifier = Modifier.weight(1f), + ) + } + } + + // ---- Donut breakdown ---- + if (state.donutSegments.isNotEmpty()) { + BentoCard(title = "Where it went", onClick = onNavigateToActivity) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Space.s5), + ) { + Box( + modifier = Modifier + .weight(1f) + .padding(vertical = Space.s3), + contentAlignment = Alignment.Center, + ) { + DonutChart( + segments = state.donutSegments, + totalAmount = state.totalSpending, + modifier = Modifier.fillMaxWidth(), ) } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(Space.s3), + ) { + state.categoryBreakdown.take(5).forEach { item -> + LegendRow(name = item.name, color = parseColor(item.color)) + } + } } } } - } - // Greeting - item { - if (state.updateAvailable == null) { - Spacer(modifier = Modifier.height(12.dp)) - } - Text( - text = state.greeting, - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold - ) - } - - // Balance card — prominent - item { - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(20.dp), - colors = CardDefaults.cardColors( - containerColor = MpesaGreen - ) - ) { - Column( - modifier = Modifier.padding(20.dp) + // ---- Insights teaser ---- + val topInsight = state.topInsight + if (topInsight != null) { + BentoCard( + overline = topInsight.typeLabel, + title = topInsight.headline, + icon = Icons.Filled.AutoAwesome, + iconTint = LedgaAccentDeep, + tonal = true, + tonalColor = LedgaAccentSoft, + onClick = onNavigateToInsights, ) { - Text( - text = "M-PESA Balance", - style = MaterialTheme.typography.bodyMedium, - color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.8f) - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = state.balance?.let { CurrencyFormatter.formatKsh(it) } ?: "—", - style = MaterialTheme.typography.headlineLarge, - fontWeight = FontWeight.Bold, - color = androidx.compose.ui.graphics.Color.White - ) - Text( - text = "from last transaction", - style = MaterialTheme.typography.bodySmall, - color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.6f) - ) - } - } - } - - // Spending + Fees row - item { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Card( - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ) - ) { - Column(modifier = Modifier.padding(16.dp)) { + if (!topInsight.body.isNullOrBlank()) { Text( - text = "Spent", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - text = CurrencyFormatter.formatKshCompact(state.totalSpending), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold - ) - Text( - text = state.monthLabel, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = topInsight.body, + style = LedgaText.BodyM, + color = LedgaInk, ) } } - Card( - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ) + } else { + BentoCard( + overline = "Insights", + title = "All quiet — nothing unusual", + icon = Icons.Filled.AutoAwesome, + iconTint = LedgaAccentDeep, + tonal = true, + tonalColor = LedgaAccentSoft, + onClick = onNavigateToInsights, ) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = "Fees Paid", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - text = CurrencyFormatter.formatKsh(state.totalFees), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold - ) - Text( - text = "this period", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + Text( + text = "Ledga watches your activity daily and surfaces anything " + + "worth attention. Tap to see how it works.", + style = LedgaText.BodyM, + color = LedgaInk, + ) } } - } - // Period selector - item { - PeriodSelector( - selected = state.selectedPeriod, - onSelect = { viewModel.selectPeriod(it) } - ) - } - - // Donut chart - if (state.donutSegments.isNotEmpty()) { - item { - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(20.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ) - ) { - Column( - modifier = Modifier.padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - DonutChart( - segments = state.donutSegments, - totalAmount = state.totalSpending, - modifier = Modifier.fillMaxWidth() - ) - } - } - } - } - - // Category breakdown — "You spent X on Y" - if (state.categoryBreakdown.isNotEmpty()) { - item { - Text( - text = "Where your money went", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold - ) - } - - items(state.categoryBreakdown) { item -> + // ---- Recent activity ---- + Column(verticalArrangement = Arrangement.spacedBy(Space.s4)) { Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, ) { - val color = parseColor(item.color) - Box( - modifier = Modifier - .size(36.dp) - .background(color.copy(alpha = 0.15f), RoundedCornerShape(10.dp)), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = categoryIcon(item.icon), - contentDescription = item.name, - modifier = Modifier.size(20.dp), - tint = color - ) - } - Spacer(modifier = Modifier.width(12.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = item.name, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium - ) - } Text( - text = CurrencyFormatter.formatKsh(item.amount), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold + text = "Recent activity", + style = LedgaText.TitleM, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + Text( + text = "See all", + style = LedgaText.BodyM, + color = LedgaAccentDeep, + modifier = Modifier.clickable { onNavigateToActivity() }, ) } - } - } - // Recent transactions - item { - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "Recent Transactions", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold - ) - } - - 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) - ) + if (state.recentTransactions.isEmpty()) { + BentoCard { + Text( + text = "No transactions yet. M-Pesa SMS will land here automatically.", + style = LedgaText.BodyM, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + BentoCard( + contentPadding = androidx.compose.foundation.layout.PaddingValues(0.dp), + ) { + state.recentTransactions.take(5).forEachIndexed { index, twc -> + TransactionRowV2( + recipient = twc.transaction.recipientName + ?: twc.transaction.type.name.replace("_", " "), + amount = twc.transaction.amount, + isInflow = twc.transaction.direction.name == "INFLOW", + category = twc.category?.name, + color = twc.category?.color?.let(::parseColor) ?: Color.Gray, + icon = twc.category?.icon ?: "category", + balance = twc.transaction.balance, + ) + if (index < state.recentTransactions.lastIndex.coerceAtMost(4)) { + androidx.compose.material3.HorizontalDivider( + color = MaterialTheme.colorScheme.outline, + modifier = Modifier.padding(horizontal = Space.Card), + ) + } + } + } + } } - } else { - items( - items = state.recentTransactions, - key = { it.transaction.id } - ) { twc -> - TransactionCard( - transaction = twc.transaction, - category = twc.category - ) - } - } - item { Spacer(modifier = Modifier.height(16.dp)) } + Box(modifier = Modifier.padding(bottom = Space.s7)) + } + } +} + +@Composable +private fun LegendRow(name: String, color: Color) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + modifier = Modifier + .size(8.dp) + .clip(RoundedCornerShape(2.dp)) + .background(color), + ) + Text( + text = name, + style = LedgaText.BodyM, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} + +@Composable +private fun UpdateBanner( + versionLabel: String, + prefetched: Boolean, + onSeeWhatsNew: () -> Unit, + onUpdate: () -> Unit, +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(Radius.Card)), + shape = RoundedCornerShape(Radius.Card), + color = LedgaAccentSoft, + border = androidx.compose.foundation.BorderStroke(1.dp, LedgaAccent), + ) { + Column( + modifier = Modifier.padding(Space.Card), + verticalArrangement = Arrangement.spacedBy(Space.s4), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Filled.AutoAwesome, + contentDescription = null, + tint = LedgaAccentDeep, + modifier = Modifier.size(20.dp), + ) + val title = if (prefetched) " Ledga $versionLabel is ready to install" + else " Ledga $versionLabel is available" + Text( + text = title, + style = LedgaText.TitleS, + color = LedgaInk, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(Space.s4)) { + Text( + text = "See what's new", + style = LedgaText.BodyM, + color = LedgaAccentDeep, + modifier = Modifier.clickable { onSeeWhatsNew() }, + ) + Text( + text = if (prefetched) "Install →" else "Update →", + style = LedgaText.BodyM, + color = LedgaAccentDeep, + modifier = Modifier.clickable { onUpdate() }, + ) + } + } } } diff --git a/app/src/main/java/com/ledga/app/ui/home/HomeViewModel.kt b/app/src/main/java/com/ledga/app/ui/home/HomeViewModel.kt index e6d1210..75b07e9 100644 --- a/app/src/main/java/com/ledga/app/ui/home/HomeViewModel.kt +++ b/app/src/main/java/com/ledga/app/ui/home/HomeViewModel.kt @@ -7,9 +7,15 @@ import androidx.lifecycle.viewModelScope 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.Insight +import com.ledga.app.data.db.entity.MpesaAccount import com.ledga.app.data.db.entity.TransactionWithCategory +import com.ledga.app.data.repository.AccountsRepository import com.ledga.app.data.repository.CategoryRepository +import com.ledga.app.data.repository.InsightsRepository +import com.ledga.app.data.repository.SettingsRepository import com.ledga.app.data.repository.TransactionRepository +import com.ledga.app.data.repository.UpdateRepository import com.ledga.app.ui.components.DonutSegment import com.ledga.app.ui.components.Period import com.ledga.app.ui.components.parseColor @@ -23,6 +29,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.stateIn @@ -46,7 +53,12 @@ data class HomeUiState( val recentTransactions: List = emptyList(), val selectedPeriod: Period = Period.THIS_MONTH, val monthLabel: String = "", - val updateAvailable: GitHubRelease? = null + val updateAvailable: GitHubRelease? = null, + /** True when the background worker has already downloaded the APK. */ + val updatePrefetched: Boolean = false, + val topInsight: Insight? = null, + val accounts: List = emptyList(), + val selectedAccountId: Long? = null, ) @OptIn(ExperimentalCoroutinesApi::class) @@ -54,10 +66,19 @@ data class HomeUiState( class HomeViewModel @Inject constructor( @ApplicationContext private val appContext: Context, private val transactionRepository: TransactionRepository, - private val categoryRepository: CategoryRepository + private val categoryRepository: CategoryRepository, + private val insightsRepository: InsightsRepository, + private val accountsRepository: AccountsRepository, + private val settingsRepository: SettingsRepository, + private val updateRepository: UpdateRepository, ) : ViewModel() { + fun selectAccount(id: Long?) { + viewModelScope.launch { settingsRepository.setSelectedAccountId(id) } + } + private val _updateAvailable = MutableStateFlow(null) + private val _updatePrefetched = MutableStateFlow(false) init { viewModelScope.launch { @@ -67,7 +88,14 @@ class HomeViewModel @Inject constructor( repo = "ledga", currentVersion = BuildConfig.VERSION_NAME ) - _updateAvailable.value = release + // Respect the user's "Remind me later" snooze: hide the banner if + // they dismissed this exact version. Newer versions still surface. + val dismissed = settingsRepository.getDismissedUpdateVersion().first() + val surfaced = release?.takeIf { it.tag_name != dismissed } + _updateAvailable.value = surfaced + // Did the background worker already pull the APK? + _updatePrefetched.value = surfaced != null && + updateRepository.findCachedApk(surfaced) != null } } @@ -130,6 +158,14 @@ class HomeViewModel @Inject constructor( ) }.combine(_updateAvailable) { state, update -> state.copy(updateAvailable = update) + }.combine(_updatePrefetched) { state, prefetched -> + state.copy(updatePrefetched = prefetched) + }.combine(insightsRepository.observeTop()) { state, insight -> + state.copy(topInsight = insight) + }.combine(accountsRepository.observeAll()) { state, accounts -> + state.copy(accounts = accounts) + }.combine(settingsRepository.getSelectedAccountId()) { state, accountId -> + state.copy(selectedAccountId = accountId) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), HomeUiState()) fun selectPeriod(period: Period) { diff --git a/app/src/main/java/com/ledga/app/ui/insights/InsightsScreen.kt b/app/src/main/java/com/ledga/app/ui/insights/InsightsScreen.kt new file mode 100644 index 0000000..4041dce --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/insights/InsightsScreen.kt @@ -0,0 +1,240 @@ +package com.ledga.app.ui.insights + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.material.icons.Icons +import androidx.compose.material.icons.filled.AutoAwesome +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Lightbulb +import androidx.compose.material.icons.filled.NotificationImportant +import androidx.compose.material.icons.filled.Replay +import androidx.compose.material.icons.filled.ReportProblem +import androidx.compose.material.icons.filled.SwapHoriz +import androidx.compose.material.icons.filled.WarningAmber +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.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.ledga.app.data.db.entity.Insight +import com.ledga.app.data.db.entity.InsightSeverity +import com.ledga.app.data.db.entity.InsightType +import com.ledga.app.ui.components.v2.BentoCard +import com.ledga.app.ui.components.v2.LedgaTopBar +import com.ledga.app.ui.theme.LedgaAccent +import com.ledga.app.ui.theme.LedgaAccentDeep +import com.ledga.app.ui.theme.LedgaAccentSoft +import com.ledga.app.ui.theme.LedgaDanger +import com.ledga.app.ui.theme.LedgaDangerSoft +import com.ledga.app.ui.theme.LedgaText +import com.ledga.app.ui.theme.LedgaWarning +import com.ledga.app.ui.theme.LedgaWarningSoft +import com.ledga.app.ui.theme.Space + +@Composable +fun InsightsScreen( + viewModel: InsightsViewModel = hiltViewModel(), + onSeeTransactionsForCategory: (Long) -> Unit = {}, +) { + val state by viewModel.uiState.collectAsState() + + Column(modifier = Modifier.fillMaxSize()) { + LedgaTopBar(title = "Insights") + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = Space.Screen), + verticalArrangement = Arrangement.spacedBy(Space.s5), + contentPadding = androidx.compose.foundation.layout.PaddingValues( + bottom = 120.dp, + ), + ) { + item { Spacer(modifier = Modifier.height(Space.s2)) } + + if (state.isLoading) { + item { LoadingPlaceholder() } + } else if (state.insights.isEmpty()) { + item { EmptyState() } + } else { + item { + Text( + text = "${state.insights.size} active ${if (state.insights.size == 1) "insight" else "insights"}", + style = LedgaText.BodyM, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + items(state.insights, key = { it.id }) { insight -> + InsightCard( + insight = insight, + onDismiss = { viewModel.dismiss(insight.id) }, + onSnooze = { viewModel.snooze(insight.id) }, + onCta = { + val args = insight.ctaArgs ?: return@InsightCard + // very simple kv parser + val kv = args.split("=").takeIf { it.size == 2 } ?: return@InsightCard + if (kv[0] == "category") { + kv[1].toLongOrNull()?.let(onSeeTransactionsForCategory) + } + }, + ) + } + } + } + } +} + +@Composable +private fun InsightCard( + insight: Insight, + onDismiss: () -> Unit, + onSnooze: () -> Unit, + onCta: () -> Unit, +) { + val tone = toneFor(insight.severity) + BentoCard( + tonal = tone.tonal, + tonalColor = tone.background, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + androidx.compose.material3.Icon( + imageVector = iconFor(insight.type), + contentDescription = null, + tint = tone.icon, + modifier = Modifier.size(20.dp), + ) + Text( + text = insight.typeLabel, + style = LedgaText.Overline, + color = tone.icon, + ) + } + Text( + text = insight.headline, + style = LedgaText.TitleS, + color = MaterialTheme.colorScheme.onSurface, + ) + if (!insight.body.isNullOrBlank()) { + Text( + text = insight.body, + style = LedgaText.BodyM, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = Space.s3), + horizontalArrangement = Arrangement.spacedBy(Space.s5), + verticalAlignment = Alignment.CenterVertically, + ) { + if (insight.ctaLabel != null) { + Text( + text = insight.ctaLabel, + style = LedgaText.BodyM, + color = tone.icon, + modifier = Modifier.clickable { onCta() }, + ) + } + Box(modifier = Modifier.weight(1f)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier.clickable { onSnooze() }, + ) { + androidx.compose.material3.Icon( + imageVector = Icons.Filled.Replay, + contentDescription = "Snooze", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(14.dp), + ) + Text( + text = "Snooze 30d", + style = LedgaText.Caption, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = "Got it", + style = LedgaText.BodyM, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.clickable { onDismiss() }, + ) + } + } +} + +@Composable +private fun EmptyState() { + BentoCard( + overline = "All clear", + title = "Nothing unusual right now", + icon = Icons.Filled.AutoAwesome, + iconTint = LedgaAccentDeep, + tonal = true, + tonalColor = LedgaAccentSoft, + ) { + Text( + text = "Ledga checks your activity every day and surfaces anything " + + "worth your attention here. Quiet means things look healthy.", + style = LedgaText.BodyM, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} + +@Composable +private fun LoadingPlaceholder() { + BentoCard { + Text( + text = "Looking through your activity…", + style = LedgaText.BodyM, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +private data class Tone(val background: Color, val icon: Color, val tonal: Boolean) + +private fun toneFor(severity: InsightSeverity): Tone = when (severity) { + InsightSeverity.ALERT -> Tone(background = LedgaDangerSoft, icon = LedgaDanger, tonal = true) + InsightSeverity.WARN -> Tone(background = LedgaWarningSoft, icon = LedgaWarning, tonal = true) + InsightSeverity.NUDGE -> Tone(background = LedgaAccentSoft, icon = LedgaAccentDeep, tonal = true) + InsightSeverity.INFO -> Tone(background = Color.Transparent, icon = LedgaAccent, tonal = false) +} + +private fun iconFor(type: InsightType): ImageVector = when (type) { + InsightType.ANOMALY -> Icons.Filled.WarningAmber + InsightType.RECURRING -> Icons.Filled.NotificationImportant + InsightType.FEE_TIP -> Icons.Filled.Lightbulb + InsightType.FULIZA -> Icons.Filled.ReportProblem + // Full clear gets the check, partial gets a swap icon. The rule sets + // severity = NUDGE for full and INFO for partial, so we drive icon + // choice off the same axis via the helper below. + InsightType.FULIZA_AUTO_PAY -> Icons.Filled.SwapHoriz + InsightType.POSITIVE_NUDGE -> Icons.Filled.AutoAwesome +} + +@Suppress("unused") // available for future per-insight icon override +private fun fulizaAutoPayIconFor(severity: com.ledga.app.data.db.entity.InsightSeverity): ImageVector = + if (severity == com.ledga.app.data.db.entity.InsightSeverity.NUDGE) Icons.Filled.CheckCircle + else Icons.Filled.SwapHoriz diff --git a/app/src/main/java/com/ledga/app/ui/insights/InsightsViewModel.kt b/app/src/main/java/com/ledga/app/ui/insights/InsightsViewModel.kt new file mode 100644 index 0000000..6429a4a --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/insights/InsightsViewModel.kt @@ -0,0 +1,43 @@ +package com.ledga.app.ui.insights + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ledga.app.data.db.entity.Insight +import com.ledga.app.data.repository.InsightsRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class InsightsUiState( + val insights: List = emptyList(), + val isLoading: Boolean = true, +) + +@HiltViewModel +class InsightsViewModel @Inject constructor( + private val insightsRepository: InsightsRepository, +) : ViewModel() { + + init { + // Generate on screen entry so first-time users see something immediately + // even before the daily WorkManager job has run. + viewModelScope.launch { insightsRepository.generateAll() } + } + + val uiState: StateFlow = insightsRepository + .observeActive() + .map { InsightsUiState(insights = it, isLoading = false) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), InsightsUiState()) + + fun dismiss(id: Long) { + viewModelScope.launch { insightsRepository.dismiss(id) } + } + + fun snooze(id: Long) { + viewModelScope.launch { insightsRepository.snooze(id, days = 30) } + } +} 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 index 58adbe3..1f5bc06 100644 --- a/app/src/main/java/com/ledga/app/ui/navigation/AppNavigation.kt +++ b/app/src/main/java/com/ledga/app/ui/navigation/AppNavigation.kt @@ -5,13 +5,18 @@ import androidx.compose.ui.Modifier import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import com.ledga.app.ui.accounts.AccountsScreen +import com.ledga.app.ui.accounts.BackfillScreen +import com.ledga.app.ui.update.UpdateScreen +import com.ledga.app.ui.activity.ActivityScreen +import com.ledga.app.ui.goals.GoalDetailScreen +import com.ledga.app.ui.goals.GoalsScreen import com.ledga.app.ui.home.HomeScreen +import com.ledga.app.ui.insights.InsightsScreen 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 +import com.ledga.app.ui.you.YouScreen @Composable fun AppNavigation( @@ -33,26 +38,60 @@ fun AppNavigation( } ) } + + // ---- Tab roots ---- composable { - HomeScreen() - } - composable { - TransactionsScreen() - } - composable { - TrendsScreen() - } - composable { - SettingsScreen( - onNavigateToUnparsed = { navController.navigate(UnparsedSmsRoute) }, - onNavigateToBudgets = { navController.navigate(BudgetRoute) } + HomeScreen( + onNavigateToActivity = { navController.navigate(ActivityRoute) }, + onNavigateToInsights = { navController.navigate(InsightsRoute) }, + onNavigateToYou = { navController.navigate(YouRoute) }, + onManageAccounts = { navController.navigate(AccountsRoute) }, + onOpenUpdate = { navController.navigate(UpdateRoute) }, ) } + composable { + ActivityScreen() + } + composable { + InsightsScreen() + } + composable { + YouScreen( + onNavigateToUnparsed = { navController.navigate(UnparsedSmsRoute) }, + onNavigateToBudgets = { navController.navigate(BudgetRoute) }, + onNavigateToGoals = { navController.navigate(GoalsRoute) }, + onNavigateToAccounts = { navController.navigate(AccountsRoute) }, + onNavigateToUpdate = { navController.navigate(UpdateRoute) }, + ) + } + + // ---- Pushed detail screens ---- composable { UnparsedSmsScreen(onBack = { navController.popBackStack() }) } composable { BudgetScreen(onBack = { navController.popBackStack() }) } + composable { + GoalsScreen( + onBack = { navController.popBackStack() }, + onOpenGoal = { id -> navController.navigate(GoalDetailRoute(id)) }, + ) + } + composable { + GoalDetailScreen(onBack = { navController.popBackStack() }) + } + composable { + AccountsScreen( + onBack = { navController.popBackStack() }, + onOpenBackfill = { navController.navigate(AccountBackfillRoute) }, + ) + } + composable { + BackfillScreen(onBack = { navController.popBackStack() }) + } + composable { + UpdateScreen(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 index 39b4f09..1b405d2 100644 --- a/app/src/main/java/com/ledga/app/ui/navigation/BottomNavBar.kt +++ b/app/src/main/java/com/ledga/app/ui/navigation/BottomNavBar.kt @@ -2,52 +2,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.material.icons.filled.Insights +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.ShowChart 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 +import com.ledga.app.ui.components.v2.PillTab +import com.ledga.app.ui.components.v2.PillTabBar -data class BottomNavItem( - val label: String, - val icon: ImageVector, - val route: T -) +private val TabHome = PillTab("home", "Home", Icons.Filled.Home) +private val TabActivity = PillTab("activity", "Activity", Icons.Filled.ShowChart) +private val TabInsights = PillTab("insights", "Insights", Icons.Filled.Insights) +private val TabYou = PillTab("you", "You", Icons.Filled.Person) + +private val Tabs = listOf(TabHome, TabActivity, TabInsights, TabYou) @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 - } - } - ) - } + val selectedKey = when { + currentDestination?.hasRoute(HomeRoute::class) == true -> TabHome.key + currentDestination?.hasRoute(ActivityRoute::class) == true -> TabActivity.key + currentDestination?.hasRoute(InsightsRoute::class) == true -> TabInsights.key + currentDestination?.hasRoute(YouRoute::class) == true -> TabYou.key + else -> TabHome.key } + + PillTabBar( + tabs = Tabs, + selectedKey = selectedKey, + onSelect = { key -> + val route: Any = when (key) { + TabHome.key -> HomeRoute + TabActivity.key -> ActivityRoute + TabInsights.key -> InsightsRoute + TabYou.key -> YouRoute + else -> return@PillTabBar + } + navController.navigate(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 index a486487..e0ec5f2 100644 --- a/app/src/main/java/com/ledga/app/ui/navigation/Routes.kt +++ b/app/src/main/java/com/ledga/app/ui/navigation/Routes.kt @@ -2,10 +2,24 @@ package com.ledga.app.ui.navigation import kotlinx.serialization.Serializable +// ---- Tab roots (4-tab IA per LEDGA_REDESIGN.md §3) ---- @Serializable object HomeRoute -@Serializable object TransactionsRoute -@Serializable object TrendsRoute -@Serializable object SettingsRoute +@Serializable object ActivityRoute +@Serializable object InsightsRoute +@Serializable object YouRoute + +// ---- Detail / pushed screens ---- @Serializable object OnboardingRoute +@Serializable object SettingsRoute // legacy entry — YouScreen replaces, kept for back-compat @Serializable object UnparsedSmsRoute @Serializable object BudgetRoute +@Serializable object GoalsRoute +@Serializable data class GoalDetailRoute(val goalId: Long) +@Serializable object AccountsRoute +@Serializable object AccountBackfillRoute +@Serializable object UpdateRoute + +// ---- Legacy aliases kept until Phase B sweep is complete ---- +// Activity bar replaces these; AppNavigation will route them inside Activity. +@Serializable object TransactionsRoute +@Serializable object TrendsRoute 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 index 3973483..e467d6a 100644 --- a/app/src/main/java/com/ledga/app/ui/theme/Color.kt +++ b/app/src/main/java/com/ledga/app/ui/theme/Color.kt @@ -2,28 +2,67 @@ 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) +// ===================================================================== +// Ledga v2 tokens — Modern Fintech Bento +// See LEDGA_REDESIGN.md §1 for canonical names + dark theme values. +// ===================================================================== -// 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) +// ---- Light surfaces ---- +val LedgaBg = Color(0xFFFAFAF7) +val LedgaSurface = Color(0xFFFFFFFF) +val LedgaSurface2 = Color(0xFFF2F2EE) +val LedgaInk = Color(0xFF0E1311) +val LedgaInk2 = Color(0xFF3A4240) +val LedgaMuted = Color(0xFF6B7270) +val LedgaMuted2 = Color(0xFFA4A8A5) +val LedgaLine = Color(0xFFE8E8E2) + +// ---- Light accents / semantic ---- +val LedgaAccent = Color(0xFF10A37F) +val LedgaAccentSoft = Color(0xFFE2F3EC) +val LedgaAccentDeep = Color(0xFF0E7C5F) +val LedgaInkOnAccent = Color(0xFFFFFFFF) +val LedgaDanger = Color(0xFFD14B4B) +val LedgaDangerSoft = Color(0xFFFBE8E8) +val LedgaWarning = Color(0xFFE7A82C) +val LedgaWarningSoft = Color(0xFFFBEFD2) +val LedgaInflow = Color(0xFF1F8B5A) +val LedgaOutflow = LedgaInk + +// ---- Dark surfaces ---- +val LedgaBgDark = Color(0xFF0A0C0B) +val LedgaSurfaceDark = Color(0xFF15191B) +val LedgaSurface2Dark = Color(0xFF1E2326) +val LedgaInkDark = Color(0xFFF4F6F5) +val LedgaInk2Dark = Color(0xFFC9CFCC) +val LedgaMutedDark = Color(0xFF8E948F) +val LedgaMuted2Dark = Color(0xFF5A615D) +val LedgaLineDark = Color(0xFF262B2D) + +// ---- Dark accents / semantic ---- +val LedgaAccentDark = Color(0xFF3CD9A8) +val LedgaAccentSoftDark = Color(0xFF0F2A23) +val LedgaAccentDeepDark = Color(0xFF85F2C8) +val LedgaInkOnAccentDark = Color(0xFF06120E) +val LedgaDangerDark = Color(0xFFFF7A7A) +val LedgaDangerSoftDark = Color(0xFF2A1414) +val LedgaWarningDark = Color(0xFFFFC25E) +val LedgaWarningSoftDark = Color(0xFF2A2009) +val LedgaInflowDark = LedgaAccentDark +val LedgaOutflowDark = LedgaInkDark + +// ---- Category palette (shared light/dark) ---- +val CatGroceries = Color(0xFF4CAF50) +val CatTransport = Color(0xFF3478F6) +val CatBills = Color(0xFFFF9F2D) +val CatAirtime = Color(0xFFA155F0) +val CatFood = Color(0xFFEC4072) +val CatSendMoney = Color(0xFF6E7B8B) +val CatReceived = Color(0xFF06B6D4) +val CatWithdrawal = Color(0xFF8B5A3C) +val CatDeposit = Color(0xFF7CC242) +val CatShopping = Color(0xFFFF6A3D) +val CatInternational = Color(0xFF4F58D9) +val CatSavings = Color(0xFF089B91) +val CatOther = 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/Dimens.kt b/app/src/main/java/com/ledga/app/ui/theme/Dimens.kt new file mode 100644 index 0000000..a4b2827 --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/theme/Dimens.kt @@ -0,0 +1,55 @@ +package com.ledga.app.ui.theme + +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.tween +import androidx.compose.ui.unit.dp + +// ===================================================================== +// Spacing scale — LEDGA_REDESIGN.md §1.5 +// ===================================================================== +object Space { + val s2 = 4.dp + val s3 = 8.dp + val s4 = 12.dp + val s5 = 16.dp + val s6 = 20.dp + val s7 = 24.dp + val s8 = 32.dp + val s9 = 48.dp + val s10 = 64.dp + + val Screen = 20.dp + val Card = 20.dp + val CardCompact = 16.dp + val Section = 24.dp + val Tight = 12.dp +} + +// ===================================================================== +// Radius — LEDGA_REDESIGN.md §1.6 +// ===================================================================== +object Radius { + val xs = 8.dp + val sm = 12.dp + val md = 16.dp + val lg = 24.dp + val xl = 32.dp + val pill = 999.dp + + val Card = lg + val SheetTop = xl + val IconTile = 14.dp + val TabBarPill = 36.dp +} + +// ===================================================================== +// Motion — LEDGA_REDESIGN.md §1.8 +// ===================================================================== +object Motion { + val EmphasizedDecelerate = CubicBezierEasing(0.2f, 0f, 0f, 1f) + + fun fast() = tween(durationMillis = 120, easing = EmphasizedDecelerate) + fun base() = tween(durationMillis = 240, easing = EmphasizedDecelerate) + fun emphasized() = tween(durationMillis = 360, easing = EmphasizedDecelerate) + fun number() = tween(durationMillis = 600, easing = EmphasizedDecelerate) +} 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 index beb771a..ea7ab21 100644 --- a/app/src/main/java/com/ledga/app/ui/theme/Theme.kt +++ b/app/src/main/java/com/ledga/app/ui/theme/Theme.kt @@ -1,35 +1,91 @@ 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 LedgaLightColorScheme = lightColorScheme( + primary = LedgaAccent, + onPrimary = LedgaInkOnAccent, + primaryContainer = LedgaAccentSoft, + onPrimaryContainer = LedgaAccentDeep, + + secondary = LedgaInk2, + onSecondary = LedgaSurface, + secondaryContainer = LedgaSurface2, + onSecondaryContainer = LedgaInk, + + tertiary = LedgaWarning, + onTertiary = LedgaInk, + tertiaryContainer = LedgaWarningSoft, + onTertiaryContainer = LedgaInk, + + error = LedgaDanger, + onError = LedgaInkOnAccent, + errorContainer = LedgaDangerSoft, + onErrorContainer = LedgaInk, + + background = LedgaBg, + onBackground = LedgaInk, + surface = LedgaSurface, + onSurface = LedgaInk, + surfaceVariant = LedgaSurface2, + onSurfaceVariant = LedgaMuted, + surfaceContainerLowest = LedgaBg, + surfaceContainerLow = LedgaSurface, + surfaceContainer = LedgaSurface, + surfaceContainerHigh = LedgaSurface2, + surfaceContainerHighest = LedgaSurface2, + + outline = LedgaLine, + outlineVariant = LedgaLine, ) -private val DarkColorScheme = darkColorScheme( - primary = MpesaGreenLight, - onPrimary = MpesaGreenDark, - primaryContainer = MpesaGreen, - onPrimaryContainer = MpesaGreenLight, +private val LedgaDarkColorScheme = darkColorScheme( + primary = LedgaAccentDark, + onPrimary = LedgaInkOnAccentDark, + primaryContainer = LedgaAccentSoftDark, + onPrimaryContainer = LedgaAccentDeepDark, + + secondary = LedgaInk2Dark, + onSecondary = LedgaSurfaceDark, + secondaryContainer = LedgaSurface2Dark, + onSecondaryContainer = LedgaInkDark, + + tertiary = LedgaWarningDark, + onTertiary = LedgaInkDark, + tertiaryContainer = LedgaWarningSoftDark, + onTertiaryContainer = LedgaInkDark, + + error = LedgaDangerDark, + onError = LedgaInkOnAccentDark, + errorContainer = LedgaDangerSoftDark, + onErrorContainer = LedgaInkDark, + + background = LedgaBgDark, + onBackground = LedgaInkDark, + surface = LedgaSurfaceDark, + onSurface = LedgaInkDark, + surfaceVariant = LedgaSurface2Dark, + onSurfaceVariant = LedgaMutedDark, + surfaceContainerLowest = LedgaBgDark, + surfaceContainerLow = LedgaSurfaceDark, + surfaceContainer = LedgaSurfaceDark, + surfaceContainerHigh = LedgaSurface2Dark, + surfaceContainerHighest = LedgaSurface2Dark, + + outline = LedgaLineDark, + outlineVariant = LedgaLineDark, ) @Composable fun LedgaTheme( themeMode: ThemeMode = ThemeMode.SYSTEM, - dynamicColor: Boolean = true, + @Suppress("UNUSED_PARAMETER") dynamicColor: Boolean = false, content: @Composable () -> Unit ) { val darkTheme = when (themeMode) { @@ -38,14 +94,8 @@ fun LedgaTheme( 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 - } + // v2: brand identity is too specific to defer to dynamic color — always use Ledga palette. + val colorScheme = if (darkTheme) LedgaDarkColorScheme else LedgaLightColorScheme MaterialTheme( colorScheme = colorScheme, 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 index 726561f..d16ad7c 100644 --- a/app/src/main/java/com/ledga/app/ui/theme/Type.kt +++ b/app/src/main/java/com/ledga/app/ui/theme/Type.kt @@ -1,5 +1,125 @@ package com.ledga.app.ui.theme import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.googlefonts.Font +import androidx.compose.ui.text.googlefonts.GoogleFont +import androidx.compose.ui.unit.sp +import com.ledga.app.R -val LedgaTypography = Typography() +// ===================================================================== +// Ledga v2 type scale — LEDGA_REDESIGN.md §1.4 +// Font: Inter (via Google Fonts). Numbers should use tnum where shown. +// ===================================================================== + +private val GoogleFontsProvider = GoogleFont.Provider( + providerAuthority = "com.google.android.gms.fonts", + providerPackage = "com.google.android.gms", + certificates = R.array.com_google_android_gms_fonts_certs, +) + +private val InterFont = GoogleFont("Inter") + +private val Inter = FontFamily( + Font(googleFont = InterFont, fontProvider = GoogleFontsProvider, weight = FontWeight.Normal), + Font(googleFont = InterFont, fontProvider = GoogleFontsProvider, weight = FontWeight.Medium), + Font(googleFont = InterFont, fontProvider = GoogleFontsProvider, weight = FontWeight.SemiBold), + Font(googleFont = InterFont, fontProvider = GoogleFontsProvider, weight = FontWeight.Bold), +) + +object LedgaText { + val DisplayXL = TextStyle( + fontFamily = Inter, + fontSize = 56.sp, + fontWeight = FontWeight.Bold, + lineHeight = 58.8.sp, + letterSpacing = (-1.5).sp, + ) + val DisplayL = TextStyle( + fontFamily = Inter, + fontSize = 44.sp, + fontWeight = FontWeight.Bold, + lineHeight = 46.2.sp, + letterSpacing = (-1).sp, + ) + val DisplayM = TextStyle( + fontFamily = Inter, + fontSize = 32.sp, + fontWeight = FontWeight.Bold, + lineHeight = 35.2.sp, + letterSpacing = (-0.5).sp, + ) + val TitleL = TextStyle( + fontFamily = Inter, + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + lineHeight = 28.8.sp, + letterSpacing = (-0.2).sp, + ) + val TitleM = TextStyle( + fontFamily = Inter, + fontSize = 20.sp, + fontWeight = FontWeight.SemiBold, + lineHeight = 25.sp, + letterSpacing = (-0.1).sp, + ) + val TitleS = TextStyle( + fontFamily = Inter, + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + lineHeight = 22.1.sp, + ) + val BodyL = TextStyle( + fontFamily = Inter, + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + lineHeight = 22.4.sp, + ) + val BodyM = TextStyle( + fontFamily = Inter, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + lineHeight = 19.6.sp, + ) + val Caption = TextStyle( + fontFamily = Inter, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + lineHeight = 15.6.sp, + letterSpacing = 0.1.sp, + ) + val Overline = TextStyle( + fontFamily = Inter, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + lineHeight = 14.3.sp, + letterSpacing = 0.5.sp, + ) + val Mono = TextStyle( + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + lineHeight = 18.2.sp, + ) +} + +// Material 3 maps onto our scale so existing M3 components inherit reasonably. +val LedgaTypography = Typography( + displayLarge = LedgaText.DisplayL, + displayMedium = LedgaText.DisplayM, + displaySmall = LedgaText.TitleL, + headlineLarge = LedgaText.TitleL, + headlineMedium = LedgaText.TitleM, + headlineSmall = LedgaText.TitleS, + titleLarge = LedgaText.TitleL, + titleMedium = LedgaText.TitleM, + titleSmall = LedgaText.TitleS, + bodyLarge = LedgaText.BodyL, + bodyMedium = LedgaText.BodyM, + bodySmall = LedgaText.Caption, + labelLarge = LedgaText.BodyM, + labelMedium = LedgaText.Caption, + labelSmall = LedgaText.Overline, +) 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 index a16921d..7bbf552 100644 --- a/app/src/main/java/com/ledga/app/ui/transactions/TransactionDetailSheet.kt +++ b/app/src/main/java/com/ledga/app/ui/transactions/TransactionDetailSheet.kt @@ -25,11 +25,13 @@ 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.Goal +import com.ledga.app.data.db.entity.MpesaAccount 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.ui.components.v2.LedgaChip +import com.ledga.app.ui.theme.LedgaInflow import com.ledga.app.util.CurrencyFormatter import com.ledga.app.util.DateUtils @@ -40,7 +42,12 @@ fun TransactionDetailSheet( category: Category?, categories: List, onDismiss: () -> Unit, - onCategoryChange: (Long) -> Unit + onCategoryChange: (Long) -> Unit, + accounts: List = emptyList(), + onAccountChange: ((Long?) -> Unit)? = null, + manualGoals: List = emptyList(), + goalIdsForTransaction: List = emptyList(), + onToggleGoal: ((goalId: Long, currentlyIn: Boolean) -> Unit)? = null, ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val isInflow = transaction.direction == TransactionDirection.INFLOW @@ -58,7 +65,7 @@ fun TransactionDetailSheet( Text( text = CurrencyFormatter.formatKshSigned(transaction.amount, isInflow), style = MaterialTheme.typography.headlineMedium, - color = if (isInflow) InflowGreen else OutflowRed, + color = if (isInflow) LedgaInflow else MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold ) @@ -140,6 +147,58 @@ fun TransactionDetailSheet( } } + // ---- Add to goal (only when user has manual-rule goals) ---- + if (manualGoals.isNotEmpty() && onToggleGoal != null) { + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "Add to goal", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(8.dp)) + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(manualGoals) { goal -> + val isIn = goalIdsForTransaction.contains(goal.id) + LedgaChip( + label = goal.name, + selected = isIn, + onClick = { onToggleGoal(goal.id, isIn) }, + ) + } + } + } + + // ---- Account selector (only when 2+ accounts exist) ---- + if (accounts.size >= 2 && onAccountChange != null) { + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "Account", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(8.dp)) + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + item { + LedgaChip( + label = "Unassigned", + selected = transaction.accountId == null, + onClick = { onAccountChange(null) }, + ) + } + items(accounts) { acc -> + LedgaChip( + label = acc.displayName, + selected = transaction.accountId == acc.id, + onClick = { onAccountChange(acc.id) }, + ) + } + } + } + Spacer(modifier = Modifier.height(24.dp)) } } 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 index ee18af3..6605ac3 100644 --- a/app/src/main/java/com/ledga/app/ui/transactions/TransactionsScreen.kt +++ b/app/src/main/java/com/ledga/app/ui/transactions/TransactionsScreen.kt @@ -1,7 +1,9 @@ package com.ledga.app.ui.transactions import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -10,20 +12,28 @@ 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.HorizontalDivider 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.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.ledga.app.ui.components.CategoryChip -import com.ledga.app.ui.components.TransactionCard +import com.ledga.app.data.db.entity.TransactionWithCategory +import com.ledga.app.data.parser.TransactionDirection +import com.ledga.app.ui.components.parseColor +import com.ledga.app.ui.components.v2.BentoCard +import com.ledga.app.ui.components.v2.LedgaChip +import com.ledga.app.ui.components.v2.LedgaSearchField +import com.ledga.app.ui.components.v2.TransactionRowV2 +import com.ledga.app.ui.theme.LedgaInflow +import com.ledga.app.ui.theme.LedgaText +import com.ledga.app.ui.theme.Space +import com.ledga.app.util.CurrencyFormatter @Composable fun TransactionsScreen( @@ -31,7 +41,6 @@ fun TransactionsScreen( ) { val state by viewModel.uiState.collectAsState() - // Detail bottom sheet if (state.selectedTransaction != null) { TransactionDetailSheet( transaction = state.selectedTransaction!!.transaction, @@ -43,83 +52,166 @@ fun TransactionsScreen( state.selectedTransaction!!.transaction.id, categoryId ) - } + }, + accounts = state.accounts, + onAccountChange = { accountId -> + viewModel.changeAccount( + state.selectedTransaction!!.transaction.id, + accountId + ) + }, + manualGoals = state.manualGoals, + goalIdsForTransaction = state.selectedTxGoalIds, + onToggleGoal = { goalId, currentlyIn -> + viewModel.toggleGoalContribution( + transactionId = state.selectedTransaction!!.transaction.id, + goalId = goalId, + currentlyIn = currentlyIn, + ) + }, ) } Column( modifier = Modifier .fillMaxSize() - .padding(horizontal = 16.dp) + .padding(horizontal = Space.Screen), ) { - Spacer(modifier = Modifier.height(16.dp)) - - // Search bar - OutlinedTextField( + LedgaSearchField( value = state.searchQuery, onValueChange = { viewModel.setSearchQuery(it) }, - modifier = Modifier.fillMaxWidth(), - placeholder = { Text("Search transactions...") }, - leadingIcon = { Icon(Icons.Default.Search, "Search") }, - singleLine = true + placeholder = "Search merchants, codes…", + modifier = Modifier.padding(top = Space.s4), ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(Space.s4)) - // Filter chips - LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + LazyRow(horizontalArrangement = Arrangement.spacedBy(Space.s3)) { items(TransactionsViewModel.FILTERS.keys.toList()) { filter -> - CategoryChip( + LedgaChip( label = filter, - isSelected = state.activeFilter == filter, - onClick = { viewModel.setFilter(filter) } + selected = state.activeFilter == filter, + onClick = { viewModel.setFilter(filter) }, ) } } - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(Space.s5)) - // Transactions grouped by day LazyColumn( modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(4.dp) + verticalArrangement = Arrangement.spacedBy(Space.s5), + contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 120.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) - ) + BentoCard { + Text( + text = if (state.searchQuery.isNotBlank()) { + "No transactions match \"${state.searchQuery}\"." + } else { + "No transactions in this filter yet." + }, + style = LedgaText.BodyM, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } else { + state.groupedTransactions.forEach { (day, transactions) -> + item(key = "day-$day") { + DayGroupCard(day = day, transactions = transactions) { twc -> + viewModel.selectTransaction(twc) + } + } } } - - item { Spacer(modifier = Modifier.height(16.dp)) } } } } + +@Composable +private fun DayGroupCard( + day: String, + transactions: List, + onSelect: (TransactionWithCategory) -> Unit, +) { + val totals = remember(transactions) { computeDayTotals(transactions) } + + BentoCard(contentPadding = androidx.compose.foundation.layout.PaddingValues(0.dp)) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Space.Card, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = day.uppercase(), + style = LedgaText.Overline, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + if (totals.outflow > 0) { + Text( + text = "Out ${CurrencyFormatter.formatKshCompact(totals.outflow)}", + style = LedgaText.Caption, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (totals.inflow > 0) { + if (totals.outflow > 0) { + Text( + text = " · ", + style = LedgaText.Caption, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = "In ${CurrencyFormatter.formatKshCompact(totals.inflow)}", + style = LedgaText.Caption, + color = LedgaInflow, + ) + } + } + HorizontalDivider( + color = MaterialTheme.colorScheme.outline, + modifier = Modifier.padding(horizontal = Space.Card), + ) + transactions.forEachIndexed { index, twc -> + TransactionRowV2( + recipient = twc.transaction.recipientName + ?: twc.transaction.type.name.replace("_", " "), + amount = twc.transaction.amount, + isInflow = twc.transaction.direction == TransactionDirection.INFLOW, + category = twc.category?.name, + color = twc.category?.color?.let(::parseColor) + ?: androidx.compose.ui.graphics.Color.Gray, + icon = twc.category?.icon ?: "category", + balance = twc.transaction.balance, + timestamp = twc.transaction.timestamp, + onClick = { onSelect(twc) }, + ) + if (index < transactions.lastIndex) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outline, + modifier = Modifier.padding(horizontal = Space.Card), + ) + } + } + } +} + +private data class DayTotals(val inflow: Double, val outflow: Double) + +private fun computeDayTotals(transactions: List): DayTotals { + var inflow = 0.0 + var outflow = 0.0 + for (twc in transactions) { + when (twc.transaction.direction) { + TransactionDirection.INFLOW -> inflow += twc.transaction.amount + TransactionDirection.OUTFLOW -> outflow += twc.transaction.amount + } + } + return DayTotals(inflow, outflow) +} + 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 index f878150..9489ca3 100644 --- a/app/src/main/java/com/ledga/app/ui/transactions/TransactionsViewModel.kt +++ b/app/src/main/java/com/ledga/app/ui/transactions/TransactionsViewModel.kt @@ -3,10 +3,16 @@ 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.Goal +import com.ledga.app.data.db.entity.MpesaAccount import com.ledga.app.data.db.entity.TransactionWithCategory import com.ledga.app.data.parser.TransactionType +import com.ledga.app.data.repository.AccountsRepository import com.ledga.app.data.repository.CategoryRepository +import com.ledga.app.data.repository.GoalsRepository import com.ledga.app.data.repository.TransactionRepository +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import com.ledga.app.util.DateUtils import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.FlowPreview @@ -25,14 +31,20 @@ data class TransactionsUiState( val searchQuery: String = "", val activeFilter: String = "All", val categories: List = emptyList(), + val accounts: List = emptyList(), + val manualGoals: List = emptyList(), + /** Goal IDs the currently-selected transaction is already manually attributed to. */ + val selectedTxGoalIds: List = emptyList(), val selectedTransaction: TransactionWithCategory? = null ) -@OptIn(FlowPreview::class) +@OptIn(FlowPreview::class, kotlinx.coroutines.ExperimentalCoroutinesApi::class) @HiltViewModel class TransactionsViewModel @Inject constructor( private val transactionRepository: TransactionRepository, - private val categoryRepository: CategoryRepository + private val categoryRepository: CategoryRepository, + private val accountsRepository: AccountsRepository, + private val goalsRepository: GoalsRepository, ) : ViewModel() { private val _searchQuery = MutableStateFlow("") @@ -65,13 +77,25 @@ class TransactionsViewModel @Inject constructor( private val _selectedTransaction = MutableStateFlow(null) + private val selectedTxGoalIds = _selectedTransaction.flatMapLatest { selected -> + if (selected == null) flowOf(emptyList()) + else goalsRepository.observeGoalIdsForTransaction(selected.transaction.id) + } + val uiState: StateFlow = combine( transactions, _searchQuery, _activeFilter, categoryRepository.getAllCategories(), - _selectedTransaction - ) { txns, query, filter, categories, selected -> + combine( + _selectedTransaction, + accountsRepository.observeAll(), + goalsRepository.observeManualGoals(), + selectedTxGoalIds, + ) { sel, accts, manualGoals, txGoals -> + GoalsBundle(selected = sel, accounts = accts, manualGoals = manualGoals, txGoals = txGoals) + }, + ) { txns, query, filter, categories, bundle -> val grouped = txns.groupBy { twc -> DateUtils.formatRelativeDate(twc.transaction.timestamp) } @@ -80,10 +104,20 @@ class TransactionsViewModel @Inject constructor( searchQuery = query, activeFilter = filter, categories = categories, - selectedTransaction = selected + accounts = bundle.accounts, + manualGoals = bundle.manualGoals, + selectedTxGoalIds = bundle.txGoals, + selectedTransaction = bundle.selected ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TransactionsUiState()) + private data class GoalsBundle( + val selected: TransactionWithCategory?, + val accounts: List, + val manualGoals: List, + val txGoals: List, + ) + fun setSearchQuery(query: String) { _searchQuery.value = query } @@ -106,4 +140,19 @@ class TransactionsViewModel @Inject constructor( _selectedTransaction.value = null } } + + fun changeAccount(transactionId: Long, accountId: Long?) { + viewModelScope.launch { + transactionRepository.updateAccount(transactionId, accountId) + _selectedTransaction.value = null + } + } + + /** Toggle membership of the selected transaction in a Manual-rule goal. */ + fun toggleGoalContribution(transactionId: Long, goalId: Long, currentlyIn: Boolean) { + viewModelScope.launch { + if (currentlyIn) goalsRepository.removeManualContribution(goalId, transactionId) + else goalsRepository.addManualContribution(goalId, transactionId) + } + } } 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 index 018184e..555ca39 100644 --- a/app/src/main/java/com/ledga/app/ui/trends/TrendsScreen.kt +++ b/app/src/main/java/com/ledga/app/ui/trends/TrendsScreen.kt @@ -1,21 +1,24 @@ package com.ledga.app.ui.trends +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer 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.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.LocalAtm +import androidx.compose.material3.HorizontalDivider 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 @@ -24,8 +27,19 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import com.ledga.app.ui.components.parseColor +import com.ledga.app.ui.components.v2.BentoCard +import com.ledga.app.ui.components.v2.HeatmapCalendar +import com.ledga.app.ui.components.v2.LedgaChip +import com.ledga.app.ui.components.v2.StatCard +import com.ledga.app.ui.theme.LedgaAccentDeep +import com.ledga.app.ui.theme.LedgaText +import com.ledga.app.ui.theme.Space +import com.ledga.app.util.CurrencyFormatter import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost import com.patrykandpatrick.vico.compose.cartesian.axis.rememberBottom import com.patrykandpatrick.vico.compose.cartesian.axis.rememberStart @@ -35,10 +49,6 @@ 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( @@ -46,169 +56,190 @@ fun TrendsScreen( ) { val state by viewModel.uiState.collectAsState() - LazyColumn( + Column( modifier = Modifier .fillMaxSize() - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + .verticalScroll(rememberScrollState()) + .padding(horizontal = Space.Screen) + .padding(top = Space.s4, bottom = 120.dp), + verticalArrangement = Arrangement.spacedBy(Space.Section), ) { - 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) + // ---- Period selector ---- + LazyRow(horizontalArrangement = Arrangement.spacedBy(Space.s3)) { + items(TrendsPeriod.entries.toList()) { period -> + LedgaChip( + label = period.label, + selected = state.selectedPeriod == period, + onClick = { viewModel.selectPeriod(period) }, ) } } - // 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)) + // ---- Total spent hero ---- + StatCard( + overline = "Total spent · ${state.selectedPeriod.label}", + value = CurrencyFormatter.formatKsh(state.totalSpending), + caption = "${state.dailySpending.size} active days", + ) - 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) + // ---- Daily bars ---- + BentoCard(title = "Daily spending") { + if (state.dailySpending.isNotEmpty()) { + DailySpendingChart( + dailySpending = state.dailySpending.map { it.totalAmount }, + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + ) + } else { + EmptyHint("No spending in this period yet.") + } + } + + // ---- Heatmap (new) ---- + BentoCard(title = "Spending heatmap") { + val heatmapData = remember(state.dailySpending) { + state.dailySpending.associate { it.dayTimestamp to it.totalAmount } + } + HeatmapCalendar( + dailyAmounts = heatmapData, + weeks = when (state.selectedPeriod) { + TrendsPeriod.WEEK -> 4 + TrendsPeriod.MONTH -> 12 + TrendsPeriod.QUARTER -> 16 + TrendsPeriod.YEAR -> 24 + }, + ) + } + + // ---- Top merchants ---- + if (state.topMerchants.isNotEmpty()) { + BentoCard( + contentPadding = androidx.compose.foundation.layout.PaddingValues(0.dp), + ) { + Text( + text = "Top merchants".uppercase(), + style = LedgaText.Overline, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = Space.Card, vertical = 14.dp), + ) + HorizontalDivider( + color = MaterialTheme.colorScheme.outline, + modifier = Modifier.padding(horizontal = Space.Card), + ) + state.topMerchants.forEachIndexed { index, merchant -> + MerchantRow( + name = merchant.recipientName, + count = merchant.transactionCount, + amount = merchant.totalAmount, + ) + if (index < state.topMerchants.lastIndex) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outline, + modifier = Modifier.padding(horizontal = Space.Card), ) } } } } - // Category breakdown + // ---- 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) - ) { + BentoCard(title = "By category") { + state.categorySpending.take(8).forEach { cs -> Row( modifier = Modifier .fillMaxWidth() - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically + .padding(vertical = 6.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 - ) - } + Box( + modifier = Modifier + .size(10.dp) + .clip(CircleShape) + .background(parseColor(cs.color)) + ) Text( - text = CurrencyFormatter.formatKsh(merchant.totalAmount), - style = MaterialTheme.typography.titleSmall + text = cs.name, + style = LedgaText.BodyL, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .weight(1f) + .padding(start = Space.s4), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = CurrencyFormatter.formatKshCompact(cs.totalAmount), + style = LedgaText.BodyL, + color = MaterialTheme.colorScheme.onSurface, ) } } } } - item { Spacer(modifier = Modifier.height(16.dp)) } + // ---- Fees summary ---- + BentoCard( + overline = "Fees paid", + title = CurrencyFormatter.formatKsh(state.totalFees), + icon = Icons.Filled.LocalAtm, + iconTint = LedgaAccentDeep, + ) { + Text( + text = if (state.totalFees == 0.0) { + "Nice — zero fees in this period." + } else { + "Withdrawals and Fuliza usage drive most of these. Insights " + + "will suggest ways to cut them down." + }, + style = LedgaText.BodyM, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } +@Composable +private fun MerchantRow(name: String, count: Int, amount: Double) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Space.Card, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = name, + style = LedgaText.BodyL, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "$count ${if (count == 1) "transaction" else "transactions"}", + style = LedgaText.Caption, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = CurrencyFormatter.formatKshCompact(amount), + style = LedgaText.TitleS, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} + +@Composable +private fun EmptyHint(text: String) { + Text( + text = text, + style = LedgaText.BodyM, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = Space.s5), + ) +} + @Composable private fun DailySpendingChart( dailySpending: List, @@ -236,3 +267,4 @@ private fun DailySpendingChart( modifier = modifier, ) } + diff --git a/app/src/main/java/com/ledga/app/ui/update/UpdateScreen.kt b/app/src/main/java/com/ledga/app/ui/update/UpdateScreen.kt new file mode 100644 index 0000000..aef1268 --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/update/UpdateScreen.kt @@ -0,0 +1,333 @@ +package com.ledga.app.ui.update + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AutoAwesome +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.NewReleases +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.ledga.app.BuildConfig +import com.ledga.app.data.repository.DownloadState +import com.ledga.app.ui.components.v2.BackLeading +import com.ledga.app.ui.components.v2.BentoCard +import com.ledga.app.ui.components.v2.LedgaTopBar +import com.ledga.app.ui.theme.LedgaAccent +import com.ledga.app.ui.theme.LedgaAccentDeep +import com.ledga.app.ui.theme.LedgaAccentSoft +import com.ledga.app.ui.theme.LedgaDanger +import com.ledga.app.ui.theme.LedgaText +import com.ledga.app.ui.theme.Space +import com.ledga.app.util.InstallPermission +import com.ledga.app.worker.GitHubRelease + +@Composable +fun UpdateScreen( + onBack: () -> Unit, + viewModel: UpdateViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsState() + val release = state.release + val context = LocalContext.current + var permissionDialogOpen by remember { mutableStateOf(false) } + + if (permissionDialogOpen) { + AlertDialog( + onDismissRequest = { permissionDialogOpen = false }, + title = { Text("Allow installs from Ledga") }, + text = { + Text( + "Android needs your permission to install apps from Ledga. " + + "Tap Open settings, flip the switch for Ledga, then come back." + ) + }, + confirmButton = { + TextButton(onClick = { + permissionDialogOpen = false + InstallPermission.openSettings(context) + }) { Text("Open settings") } + }, + dismissButton = { + TextButton(onClick = { permissionDialogOpen = false }) { Text("Cancel") } + } + ) + } + + Column(modifier = Modifier.fillMaxSize()) { + LedgaTopBar( + title = "Updates", + leading = { BackLeading(onBack) }, + ) + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = Space.Screen) + .padding(bottom = 120.dp), + verticalArrangement = Arrangement.spacedBy(Space.Section), + ) { + when { + release == null && state.isChecking -> StatusCard("Checking for updates…") + release == null -> UpToDateCard(onCheckAgain = { viewModel.checkAgain() }) + else -> ReleaseCard( + release = release, + downloadState = state.downloadState, + onDownload = { viewModel.startDownload(release) }, + onInstall = { file -> + if (InstallPermission.canInstall(context)) { + viewModel.install(file) + } else { + permissionDialogOpen = true + } + }, + onRemindLater = { + viewModel.remindLater(release.tag_name) + onBack() + }, + ) + } + + Box(modifier = Modifier.padding(bottom = Space.s5)) + } + } +} + +@Composable +private fun UpToDateCard(onCheckAgain: () -> Unit) { + BentoCard( + overline = "You're up to date", + title = "Running Ledga v${BuildConfig.VERSION_NAME}", + icon = Icons.Filled.CheckCircle, + iconTint = LedgaAccentDeep, + tonal = true, + tonalColor = LedgaAccentSoft, + ) { + Text( + text = "Nothing newer on GitHub Releases right now.", + style = LedgaText.BodyM, + color = MaterialTheme.colorScheme.onSurface, + ) + TextButton(onClick = onCheckAgain) { + Text("Check again", color = LedgaAccentDeep) + } + } +} + +@Composable +private fun StatusCard(message: String) { + BentoCard { + Text( + text = message, + style = LedgaText.BodyM, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun ReleaseCard( + release: GitHubRelease, + downloadState: DownloadState, + onDownload: () -> Unit, + onInstall: (java.io.File) -> Unit, + onRemindLater: () -> Unit, +) { + val sections = parseChangelog(release.body.orEmpty()) + val sizeMb = release.assets.firstOrNull { it.name.endsWith(".apk", ignoreCase = true) } + ?.size + ?.let { "%.1f MB".format(it / 1_000_000.0) } + ?: "—" + + BentoCard( + overline = "New version", + title = release.name ?: "Ledga ${release.tag_name}", + icon = Icons.Filled.NewReleases, + iconTint = LedgaAccentDeep, + tonal = true, + tonalColor = LedgaAccentSoft, + ) { + Text( + text = "Currently running v${BuildConfig.VERSION_NAME} · Update is $sizeMb", + style = LedgaText.BodyM, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + if (sections.whatsNew.isNotEmpty()) { + BentoCard(title = "What's new") { + sections.whatsNew.forEach { item -> + ChangelogLine(icon = Icons.Filled.AutoAwesome, tint = LedgaAccentDeep, text = item) + } + } + } + if (sections.fixes.isNotEmpty()) { + BentoCard(title = "Fixes") { + sections.fixes.forEach { item -> + ChangelogLine(icon = Icons.Filled.BugReport, tint = MaterialTheme.colorScheme.onSurfaceVariant, text = item) + } + } + } + if (sections.other.isNotEmpty()) { + BentoCard(title = "Notes") { + Text( + text = sections.other.joinToString("\n"), + style = LedgaText.BodyM, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + BentoCard { + when (val s = downloadState) { + DownloadState.Idle -> { + Button( + onClick = onDownload, + colors = ButtonDefaults.buttonColors(containerColor = LedgaAccent), + modifier = Modifier.fillMaxWidth(), + ) { + androidx.compose.material3.Icon( + imageVector = Icons.Filled.Download, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Text(text = " Download and install") + } + TextButton(onClick = onRemindLater, modifier = Modifier.fillMaxWidth()) { + Text("Remind me later", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + is DownloadState.Downloading -> { + val total = s.totalBytes.coerceAtLeast(1) + val pct = (s.bytesRead.toFloat() / total.toFloat()).coerceIn(0f, 1f) + Text( + text = "Downloading… ${(pct * 100).toInt()}%", + style = LedgaText.BodyL, + color = MaterialTheme.colorScheme.onSurface, + ) + LinearProgressIndicator( + progress = { pct }, + modifier = Modifier.fillMaxWidth(), + color = LedgaAccent, + ) + Text( + text = "${"%.1f".format(s.bytesRead / 1_000_000.0)} MB of " + + "${"%.1f".format(total / 1_000_000.0)} MB", + style = LedgaText.Caption, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + is DownloadState.Ready -> { + Button( + onClick = { onInstall(s.file) }, + colors = ButtonDefaults.buttonColors(containerColor = LedgaAccent), + modifier = Modifier.fillMaxWidth(), + ) { Text("Install now") } + Text( + text = "Android will ask you to confirm the install.", + style = LedgaText.Caption, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + is DownloadState.Failed -> { + Text( + text = s.message, + style = LedgaText.BodyM, + color = LedgaDanger, + ) + Button( + onClick = onDownload, + colors = ButtonDefaults.buttonColors(containerColor = LedgaAccent), + modifier = Modifier.fillMaxWidth(), + ) { Text("Try again") } + } + } + } +} + +@Composable +private fun ChangelogLine(icon: ImageVector, tint: androidx.compose.ui.graphics.Color, text: String) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + androidx.compose.material3.Icon( + imageVector = icon, + contentDescription = null, + tint = tint, + modifier = Modifier.padding(top = 2.dp).size(16.dp), + ) + Text( + text = text, + style = LedgaText.BodyM, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} + +/** + * Minimal markdown-ish splitter. Looks for `What's new`, `Fixes`, or + * `What is new` style headers and groups bullet lines under them. Anything + * uncategorized falls into [other] verbatim. + */ +internal data class Changelog( + val whatsNew: List, + val fixes: List, + val other: List, +) + +internal fun parseChangelog(body: String): Changelog { + if (body.isBlank()) return Changelog(emptyList(), emptyList(), emptyList()) + val whatsNew = mutableListOf() + val fixes = mutableListOf() + val other = mutableListOf() + var bucket: MutableList = other + + body.lineSequence().forEach { raw -> + val line = raw.trim() + if (line.isEmpty()) return@forEach + val lower = line.lowercase() + when { + line.startsWith("#") || lower.endsWith(":") -> { + bucket = when { + "fix" in lower || "bug" in lower -> fixes + "new" in lower || "feature" in lower || "added" in lower -> whatsNew + else -> other + } + } + line.startsWith("-") || line.startsWith("*") -> { + bucket.add(line.removePrefix("-").removePrefix("*").trim()) + } + else -> other.add(line) + } + } + return Changelog(whatsNew, fixes, other) +} diff --git a/app/src/main/java/com/ledga/app/ui/update/UpdateViewModel.kt b/app/src/main/java/com/ledga/app/ui/update/UpdateViewModel.kt new file mode 100644 index 0000000..a0a75d1 --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/update/UpdateViewModel.kt @@ -0,0 +1,95 @@ +package com.ledga.app.ui.update + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ledga.app.data.repository.DownloadState +import com.ledga.app.data.repository.SettingsRepository +import com.ledga.app.data.repository.UpdateRepository +import com.ledga.app.worker.GitHubRelease +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.stateIn +import kotlinx.coroutines.launch +import java.io.File +import javax.inject.Inject + +data class UpdateUiState( + val release: GitHubRelease? = null, + val downloadState: DownloadState = DownloadState.Idle, + val isChecking: Boolean = false, + val checkedAt: Long? = null, +) + +@HiltViewModel +class UpdateViewModel @Inject constructor( + private val updateRepository: UpdateRepository, + private val settingsRepository: SettingsRepository, +) : ViewModel() { + + private val _release = MutableStateFlow(null) + private val _checking = MutableStateFlow(false) + private val _checkedAt = MutableStateFlow(null) + + val uiState: StateFlow = combine( + _release, + updateRepository.downloadState, + _checking, + _checkedAt, + ) { release, dl, checking, at -> + UpdateUiState( + release = release, + downloadState = dl, + isChecking = checking, + checkedAt = at, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), UpdateUiState()) + + init { + // First-time entry hits the cached check so we paint immediately. + viewModelScope.launch { + val release = updateRepository.checkNow() + _release.value = release + // If the background worker already pre-fetched the APK, skip the + // download dance — the user lands on "Install now" directly. + if (release != null) { + updateRepository.findCachedApk(release)?.let { file -> + updateRepository.markReady(file) + } + } + } + } + + fun checkAgain() { + if (_checking.value) return + _checking.value = true + viewModelScope.launch { + try { + val release = updateRepository.checkNow() + _release.value = release + if (release != null) { + updateRepository.findCachedApk(release)?.let { file -> + updateRepository.markReady(file) + } + } + _checkedAt.value = System.currentTimeMillis() + } finally { + _checking.value = false + } + } + } + + fun startDownload(release: GitHubRelease) { + viewModelScope.launch { updateRepository.download(release) } + } + + fun install(file: File) { + updateRepository.install(file) + } + + fun remindLater(version: String) { + viewModelScope.launch { settingsRepository.dismissUpdateVersion(version) } + } +} diff --git a/app/src/main/java/com/ledga/app/ui/you/YouScreen.kt b/app/src/main/java/com/ledga/app/ui/you/YouScreen.kt new file mode 100644 index 0000000..cc5f9ec --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/you/YouScreen.kt @@ -0,0 +1,371 @@ +package com.ledga.app.ui.you + +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.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Backup +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.CloudUpload +import androidx.compose.material.icons.filled.Flag +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.SimCard +import androidx.compose.material.icons.filled.Storage +import androidx.compose.material.icons.filled.Wallet +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +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.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.ledga.app.BuildConfig +import com.ledga.app.data.repository.FontScale +import com.ledga.app.ui.components.v2.Avatar +import com.ledga.app.ui.components.v2.BentoCard +import com.ledga.app.ui.components.v2.LedgaTopBar +import com.ledga.app.ui.settings.SettingsViewModel +import com.ledga.app.ui.theme.LedgaAccent +import com.ledga.app.ui.theme.LedgaAccentDeep +import com.ledga.app.ui.theme.LedgaText +import com.ledga.app.ui.theme.Space +import com.ledga.app.ui.theme.ThemeMode + +/** + * You — profile + consolidated settings (LEDGA_REDESIGN.md §4.7). + * + * Reuses the existing [SettingsViewModel] (data + repo are unchanged in + * Phase B) but renders everything with v2 bento cards. + */ +@Composable +fun YouScreen( + onNavigateToUnparsed: () -> Unit, + onNavigateToBudgets: () -> Unit, + onNavigateToGoals: () -> Unit, + onNavigateToAccounts: () -> Unit, + onNavigateToUpdate: () -> 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 reparseResult by viewModel.reparseResult.collectAsState() + val exportResult by viewModel.exportResult.collectAsState() + val fileImportResult by viewModel.fileImportResult.collectAsState() + val backupStatus by viewModel.backupStatus.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()) { + LedgaTopBar(title = "You") + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = Space.Screen) + .padding(bottom = 120.dp), + verticalArrangement = Arrangement.spacedBy(Space.Section), + ) { + // ---- Profile card ---- + BentoCard { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Space.s5), + ) { + Avatar(initials = "KN", color = LedgaAccent, size = 48.dp) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = "Your tracker", + style = LedgaText.TitleM, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = "All data stays on this device", + style = LedgaText.BodyM, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + // ---- Personal tools ---- + BentoCard(contentPadding = androidx.compose.foundation.layout.PaddingValues(0.dp)) { + NavRow( + icon = Icons.Filled.Flag, + iconTint = LedgaAccentDeep, + label = "Goals", + trailing = "—", + onClick = onNavigateToGoals, + ) + CardDivider() + NavRow( + icon = Icons.Filled.Wallet, + iconTint = LedgaAccentDeep, + label = "Budgets", + onClick = onNavigateToBudgets, + ) + CardDivider() + NavRow( + icon = Icons.Filled.SimCard, + iconTint = LedgaAccentDeep, + label = "Accounts", + trailing = "1 line", + onClick = onNavigateToAccounts, + ) + } + + // ---- Appearance ---- + BentoCard(title = "Appearance", icon = Icons.Filled.Palette) { + Text( + text = "Theme".uppercase(), + style = LedgaText.Overline, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + ThemeMode.entries.forEach { mode -> + RadioRow( + label = mode.name.lowercase().replaceFirstChar { it.uppercase() }, + selected = themeMode == mode, + onSelect = { viewModel.setThemeMode(mode) }, + ) + } + Text( + text = "Font size".uppercase(), + style = LedgaText.Overline, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = Space.s3), + ) + FontScale.entries.forEach { scale -> + RadioRow( + label = scale.label, + selected = fontScale == scale, + onSelect = { viewModel.setFontScale(scale) }, + ) + } + } + + // ---- Notifications ---- + BentoCard(title = "Notifications", icon = Icons.Filled.Notifications) { + ToggleRow("Daily summary", dailySummary) { viewModel.setDailySummaryEnabled(it) } + ToggleRow("Weekly summary", weeklySummary) { viewModel.setWeeklySummaryEnabled(it) } + ToggleRow("Budget alerts", budgetAlerts) { viewModel.setBudgetAlertsEnabled(it) } + ToggleRow("Large-transaction alert", largeTxnAlert) { viewModel.setLargeTxnAlertEnabled(it) } + } + + // ---- Data ---- + BentoCard(title = "Data", icon = Icons.Filled.Storage) { + TextButtonRow("Import SMS history") { viewModel.importSmsHistory() } + importStatus?.let { + StatusLine("Imported ${it.imported} of ${it.total} messages") + } + TextButtonRow("Export (ZIP — CSV + JSON)") { + exportLauncher.launch("ledga-export.zip") + } + exportResult?.let { StatusLine("Exported ${it.count} transactions") } + TextButtonRow("Restore from ZIP") { + importLauncher.launch(arrayOf("application/zip")) + } + fileImportResult?.let { + StatusLine( + "Imported ${it.imported} of ${it.total} (${it.skipped} duplicates)" + ) + } + if (unparsedCount > 0) { + TextButtonRow("Re-parse $unparsedCount unparsed messages") { + viewModel.reparseUnknown() + } + reparseResult?.let { + StatusLine( + "Fixed ${it.fixed} of ${it.total} (${it.stillUnknown} still unknown)" + ) + } + TextButtonRow("View unparsed messages", onNavigateToUnparsed) + } + } + + // ---- Backup ---- + BentoCard(title = "Backup", icon = Icons.Filled.Backup) { + val signedIn = viewModel.isSignedIn() + val email = viewModel.getAccountEmail() + if (signedIn && email != null) { + StatusLine("Google account: $email") + } + TextButtonRow("Back up now") { viewModel.backupNow() } + TextButtonRow("Restore from backup") { viewModel.restoreBackup() } + backupStatus?.let { StatusLine(it) } + } + + // ---- About ---- + BentoCard(title = "About", icon = Icons.Filled.Info) { + Text( + text = "Ledga v${BuildConfig.VERSION_NAME}", + style = LedgaText.BodyM, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = "All data stays on your device.", + style = LedgaText.Caption, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + TextButtonRow("Check for updates · What's new", onNavigateToUpdate) + } + + Box(modifier = Modifier.padding(bottom = Space.s7)) + } + } +} + +// ---- Local helpers ---- + +@Composable +private fun NavRow( + icon: ImageVector, + iconTint: androidx.compose.ui.graphics.Color, + label: String, + trailing: String? = null, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() } + .padding(horizontal = Space.Card, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Space.s4), + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = iconTint, + modifier = Modifier.size(20.dp), + ) + Text( + text = label, + style = LedgaText.BodyL, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + if (trailing != null) { + Text( + text = trailing, + style = LedgaText.BodyM, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + imageVector = Icons.Filled.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } +} + +@Composable +private fun CardDivider() { + HorizontalDivider( + color = MaterialTheme.colorScheme.outline, + modifier = Modifier.padding(horizontal = Space.Card), + ) +} + +@Composable +private fun RadioRow(label: String, selected: Boolean, onSelect: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onSelect() } + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = selected, onClick = onSelect) + Text( + text = label, + style = LedgaText.BodyL, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(start = 4.dp), + ) + } +} + +@Composable +private fun ToggleRow(label: String, checked: Boolean, onToggle: (Boolean) -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = LedgaText.BodyL, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + Switch( + checked = checked, + onCheckedChange = onToggle, + colors = SwitchDefaults.colors( + checkedTrackColor = LedgaAccent, + ), + ) + } +} + +@Composable +private fun TextButtonRow(label: String, onClick: () -> Unit) { + Text( + text = label, + style = LedgaText.BodyL, + color = LedgaAccentDeep, + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() } + .padding(vertical = 10.dp), + ) +} + +@Composable +private fun StatusLine(text: String) { + Text( + text = text, + style = LedgaText.Caption, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 4.dp), + ) +} + diff --git a/app/src/main/java/com/ledga/app/util/InstallPermission.kt b/app/src/main/java/com/ledga/app/util/InstallPermission.kt new file mode 100644 index 0000000..c8ad358 --- /dev/null +++ b/app/src/main/java/com/ledga/app/util/InstallPermission.kt @@ -0,0 +1,38 @@ +package com.ledga.app.util + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings + +/** + * Helpers around `REQUEST_INSTALL_PACKAGES`. + * + * On Android 8+ (API 26) the user must explicitly enable "Install unknown + * apps" for any app that calls the package installer. We check the runtime + * grant before firing the install intent so the user gets a one-tap path + * to Settings instead of seeing the install fail silently. + */ +object InstallPermission { + + /** True when the app can already trigger an install without further user setup. */ + fun canInstall(context: Context): Boolean { + // API 26+ is our minSdk, but guard anyway for clarity. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return true + return context.packageManager.canRequestPackageInstalls() + } + + /** + * Open Settings → "Install unknown apps" for this app. The user flips + * the toggle for Ledga and returns to the app — at which point [canInstall] + * will return true on the next check. + */ + fun openSettings(context: Context) { + val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply { + data = Uri.parse("package:${context.packageName}") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } +} diff --git a/app/src/main/java/com/ledga/app/worker/InsightWorker.kt b/app/src/main/java/com/ledga/app/worker/InsightWorker.kt new file mode 100644 index 0000000..ac76a2c --- /dev/null +++ b/app/src/main/java/com/ledga/app/worker/InsightWorker.kt @@ -0,0 +1,61 @@ +package com.ledga.app.worker + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.ledga.app.data.repository.InsightsRepository +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import java.util.Calendar +import java.util.concurrent.TimeUnit + +@HiltWorker +class InsightWorker @AssistedInject constructor( + @Assisted context: Context, + @Assisted params: WorkerParameters, + private val insightsRepository: InsightsRepository, +) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result = runCatching { + insightsRepository.generateAll() + Result.success() + }.getOrElse { Result.retry() } +} + +object InsightScheduler { + + /** + * Schedule a daily run at ~3 AM local. Idempotent — `KEEP` keeps an + * existing schedule, so app upgrades don't reset the timer. + */ + fun schedulePeriodic(context: Context) { + val now = Calendar.getInstance() + val target = Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, 3) + 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( + UNIQUE_NAME, + ExistingPeriodicWorkPolicy.KEEP, + request, + ) + } + + fun cancel(context: Context) { + WorkManager.getInstance(context).cancelUniqueWork(UNIQUE_NAME) + } + + private const val UNIQUE_NAME = "insight_generation" +} diff --git a/app/src/main/java/com/ledga/app/worker/UpdateCheckWorker.kt b/app/src/main/java/com/ledga/app/worker/UpdateCheckWorker.kt index f8581af..4a8f1fd 100644 --- a/app/src/main/java/com/ledga/app/worker/UpdateCheckWorker.kt +++ b/app/src/main/java/com/ledga/app/worker/UpdateCheckWorker.kt @@ -2,19 +2,28 @@ 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.Constraints import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager import androidx.work.WorkerParameters +import com.ledga.app.BuildConfig +import com.ledga.app.data.repository.SettingsRepository +import com.ledga.app.data.repository.UpdateRepository import dagger.assisted.Assisted import dagger.assisted.AssistedInject import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import java.io.File import java.net.URL +import java.util.concurrent.TimeUnit @Serializable data class GitHubRelease( @@ -31,16 +40,66 @@ data class GitHubAsset( val size: Long = 0 ) +/** + * Daily background pre-fetch of the latest APK. + * + * Runs at most once per day on unmetered network with battery not low — + * so we never spend a user's mobile data without their say-so. When a + * newer release is detected and the user hasn't dismissed that version, + * the APK is downloaded to cacheDir/updates/. The Home banner picks it + * up on next launch and flips to "Ready to install". + */ @HiltWorker class UpdateCheckWorker @AssistedInject constructor( @Assisted context: Context, - @Assisted params: WorkerParameters + @Assisted params: WorkerParameters, + private val updateRepository: UpdateRepository, + private val settingsRepository: SettingsRepository, ) : CoroutineWorker(context, params) { - override suspend fun doWork(): Result { - // This worker is a placeholder — the actual check is done in UpdateChecker - return Result.success() + override suspend fun doWork(): Result = runCatching { + val release = UpdateChecker.checkForUpdate( + context = applicationContext, + owner = UpdateRepository.OWNER, + repo = UpdateRepository.REPO, + currentVersion = BuildConfig.VERSION_NAME, + ) ?: run { + updateRepository.cleanupOldApks(null) + return@runCatching Result.success() + } + + val dismissed = settingsRepository.getDismissedUpdateVersion().first() + if (release.tag_name == dismissed) return@runCatching Result.success() + + updateRepository.prefetchInBackground(release) + Result.success() + }.getOrElse { Result.retry() } +} + +object UpdateScheduler { + + fun schedulePeriodic(context: Context) { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.UNMETERED) + .setRequiresBatteryNotLow(true) + .build() + + val request = PeriodicWorkRequestBuilder(1, TimeUnit.DAYS) + .setConstraints(constraints) + .build() + + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + UNIQUE_NAME, + ExistingPeriodicWorkPolicy.KEEP, + request, + ) } + + fun cancel(context: Context) { + WorkManager.getInstance(context).cancelUniqueWork(UNIQUE_NAME) + } + + private const val UNIQUE_NAME = "update_check" } object UpdateChecker { @@ -103,6 +162,49 @@ object UpdateChecker { } } + /** + * Same as [downloadApk] but reports progress. + * + * `onProgress(bytesRead, totalBytes)` fires roughly every 64 KB. When + * [expectedSize] is unknown (0 or missing), `totalBytes` is reported as + * the asset's `size` (always present on GitHub Releases) or -1 as a + * last resort. + */ + suspend fun downloadApkWithProgress( + context: Context, + downloadUrl: String, + filename: String, + expectedSize: Long, + onProgress: (bytesRead: Long, totalBytes: Long) -> Unit, + ): File? = withContext(Dispatchers.IO) { + try { + val cacheDir = File(context.cacheDir, "updates") + cacheDir.mkdirs() + val file = File(cacheDir, filename) + + val connection = URL(downloadUrl).openConnection() + connection.connect() + val contentLength = connection.contentLengthLong.takeIf { it > 0 } ?: expectedSize + + connection.getInputStream().use { input -> + file.outputStream().use { output -> + val buffer = ByteArray(64 * 1024) + var total = 0L + while (true) { + val read = input.read(buffer) + if (read == -1) break + output.write(buffer, 0, read) + total += read + onProgress(total, contentLength) + } + } + } + file + } catch (e: Exception) { + null + } + } + fun installApk(context: Context, file: File) { val uri = FileProvider.getUriForFile( context, @@ -117,7 +219,7 @@ object UpdateChecker { context.startActivity(intent) } - private fun isNewerVersion(latest: String, current: String): Boolean { + internal 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)) { diff --git a/app/src/main/res/values/font_certs.xml b/app/src/main/res/values/font_certs.xml new file mode 100644 index 0000000..517b258 --- /dev/null +++ b/app/src/main/res/values/font_certs.xml @@ -0,0 +1,63 @@ + + + + @array/com_google_android_gms_fonts_certs_dev + @array/com_google_android_gms_fonts_certs_prod + + + + MIIEqDCCA5CgAwIBAgIJANWFuGx90071MA0GCSqGSIb3DQEBBAUAMIGUMQswCQYD + VQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4g + VmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UE + AxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTAe + Fw0wODA0MTUyMzM2NTZaFw0zNTA5MDEyMzM2NTZaMIGUMQswCQYDVQQGEwJVUzET + MBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4G + A1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9p + ZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTCCASAwDQYJKoZI + hvcNAQEBBQADggENADCCAQgCggEBANbOLggKv+IxTdGNs8/TGFy0PTP6DHThvbbR + 24kT9ixcOd9W+EaBPWW+wPPKQmsHxajtWjmQwWfna8mZuSeJS48LIgAZlKkpoyLc + fxDS9XLiFjRTdGIVhDqRwbFrW0YoQ6L0jReSCt4mzpr+vYW7ucYsP7eJgYTUE3FA + yXqXKzc8gOe+8nVABTzZJfX6cBzcPe6ALc42Qf+u/JRY2hpO4ChlKxsxFYbcdAlF + xC8t4d0BfWNGzFOZbjj/lzWxC6BlhgyKfg42UDKDPxkBgQz9zWXX2YJC6agJN0X2 + QNeKtmlFXBYYUFlymMr+iL+m8c6lkN5VkjbLGdLrIB4Ya0aFGRkCAQOjgfwwgfkw + HQYDVR0OBBYEFI0cxb6VTEM8YYY6FbBMvAPyT+CyMIHJBgNVHSMEgcEwgb6AFI0c + xb6VTEM8YYY6FbBMvAPyT+CyoYGapIGXMIGUMQswCQYDVQQGEwJVUzETMBEGA1UE + CBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMH + QW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAG + CSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbYIJANWFuGx90071MAwGA1Ud + EwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADggEBABnTDPEF+3iSP0wNfdIjIz1AlnrP + zgAIHVvXxunW7SBrDhEglQZBbKJEk5kT0mtKoOD1JMrSu1xuTKEBahWRbqHsXcle + li3HhWiISKwS6+stdrkSF24PvA5KbVqPg5kMmoVlxhRpgN3wllSmoYFRDQfWHi9d + 8aJtzia9TaorRJX/G2cQ/MfX2gpfJSQwT5xT9zE+R3/m0AhpoFcUgmenZW1lwwLs + Pf0ahbR3Bcgw7lONO6XQTNvAOlOmKNzGKxsCBZyZ52rfHCkywVNeKJ3+8RY7XV56 + EpoadF/79n01zT3uEjlR5XOREhdc/MoSjCdsM/eUkRJW+kdyFkVcXqznpwI= + + + + + MIIEQzCCAyugAwIBAgIJAMLgh0ZkSjCNMA0GCSqGSIb3DQEBBAUAMHQxCzAJBgNV + BAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBW + aWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4G + A1UEAxMHQW5kcm9pZDAeFw0wODA4MjEyMzEzMzRaFw0zNjAxMDcyMzEzMzRaMHQx + CzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3Vu + dGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9p + ZDEQMA4GA1UEAxMHQW5kcm9pZDCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgC + ggEBAKtWLgDYO6IIrgqWbxJOKdoR8qtW0I9Y4sypEwPpt1TTcvZApxsdyxMJZ2JO + RToKvLDYRoTwvVNu0CszrSPnsl0VhrxXFGOLPaWGo32EKxlSAOdcyXl3pXEORxBD + bRT5si83BlEh0ts5/qbZyrdHe9zwseTOOK1xPLthEdpL0u3jjybtSjV/JuI6cQzv + i0Mz/x9pyXVu3GtnaP5dD93iVcb3RAQs5HMrupTWMmtWGgxx0bN98LO+IIlFcRz1 + cIumXh4ZaJlpRgPnt53o/u3kt+kRGfWQ4FuBeP3UPYTKkN10vTGgOOiHcFhPNYxz + FuUkH56Q9wQwzgFAfeu/UPN+vCsCAQOjgdkwgdYwHQYDVR0OBBYEFMd9MMowi+yA + QtkA1mUu1IRZe5nzMIGmBgNVHSMEgZ4wgZuAFMd9MMowi+yAQtkA1mUu1IRZe5nz + oXikdjB0MQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UE + BxMNTW91bnRhaW4gVmlldzEUMBIGA1UEChMLR29vZ2xlIEluYy4xEDAOBgNVBAsT + B0FuZHJvaWQxEDAOBgNVBAMTB0FuZHJvaWSCCQDC4IdGZEowjTAMBgNVHRMEBTAD + AQH/MA0GCSqGSIb3DQEBBAUAA4IBAQB/1eayYvOPe/3UEepfM03Mj4t9Mw2/0wEv + xQv9I6kZc3/jXNJxsObTplA9Yp3sBYM33Z9TbWuI8ED1ATq0fqHaQ8Bw9NU+TKQq + 08wt+wbsfYUz4n3rwfsjTSf1m0Ny3oZIzU/QyldzaMtoFKEv8GfgM4yqdmf9Vyim + 8/qmsbgYj+7/2g4lA5xLI1KymU5xpQbb1IGd9xkpDQzCMyVAlj7llIQ8XMt6gK0u + cGu6OJ7DTl0NMNZdR/wRPjm5Vbj9zRDmGdr1mTpBkMS8ekOWWGdfAfawAW5jr0HF + kdW2NyjLPb46asLnNJN3jubF7XSn/RyNtmrXdJfDahmqXxOxnvAj + + + diff --git a/app/src/test/java/com/ledga/app/goals/ContributionRuleTest.kt b/app/src/test/java/com/ledga/app/goals/ContributionRuleTest.kt new file mode 100644 index 0000000..ec48ce8 --- /dev/null +++ b/app/src/test/java/com/ledga/app/goals/ContributionRuleTest.kt @@ -0,0 +1,78 @@ +package com.ledga.app.goals + +import com.ledga.app.data.db.entity.ContributionRule +import com.ledga.app.data.parser.TransactionType +import com.ledga.app.data.repository.GoalsRepository +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ContributionRuleTest { + + // ---- Encoding round-trip ---- + + @Test + fun `AllSavingsDeposits encodes and decodes`() { + val encoded = ContributionRule.AllSavingsDeposits.encode() + assertEquals(ContributionRule.AllSavingsDeposits, ContributionRule.decode(encoded)) + } + + @Test + fun `ToRecipient round-trips with fragment intact`() { + val rule = ContributionRule.ToRecipient("School fees") + val decoded = ContributionRule.decode(rule.encode()) + assertEquals(rule, decoded) + } + + @Test + fun `Manual encodes and decodes`() { + val encoded = ContributionRule.Manual.encode() + assertEquals(ContributionRule.Manual, ContributionRule.decode(encoded)) + } + + @Test + fun `decode of unknown string falls back to Manual safely`() { + val decoded = ContributionRule.decode("SOMETHING_FROM_THE_FUTURE") + assertEquals(ContributionRule.Manual, decoded) + } + + @Test + fun `ToRecipient handles fragments containing colons`() { + // Important: the encoding uses ':' as separator. Anything after the + // first colon should be treated as the recipient fragment, even if it + // contains more colons. + val tricky = ContributionRule.ToRecipient("KPLC: Prepaid") + val decoded = ContributionRule.decode(tricky.encode()) + assertEquals(tricky, decoded) + } + + // ---- Static matches() — what auto-attribution will use ---- + + @Test + fun `AllSavings matches MSHWARI and KCB_MPESA only`() { + val rule = ContributionRule.AllSavingsDeposits + assertTrue(GoalsRepository.matches(rule, TransactionType.MSHWARI, recipient = null)) + assertTrue(GoalsRepository.matches(rule, TransactionType.KCB_MPESA, recipient = "anything")) + assertFalse(GoalsRepository.matches(rule, TransactionType.SEND, recipient = "Bob")) + assertFalse(GoalsRepository.matches(rule, TransactionType.PAY_BILL, recipient = "KPLC")) + } + + @Test + fun `ToRecipient matches case-insensitively on substring`() { + val rule = ContributionRule.ToRecipient("KPLC") + assertTrue(GoalsRepository.matches(rule, TransactionType.PAY_BILL, recipient = "KPLC PREPAID")) + assertTrue(GoalsRepository.matches(rule, TransactionType.PAY_BILL, recipient = "kplc postpaid")) + assertTrue(GoalsRepository.matches(rule, TransactionType.SEND, recipient = "KPLC AGENT")) + assertFalse(GoalsRepository.matches(rule, TransactionType.SEND, recipient = "Nairobi Water")) + assertFalse(GoalsRepository.matches(rule, TransactionType.SEND, recipient = null)) + } + + @Test + fun `Manual never auto-matches`() { + val rule = ContributionRule.Manual + assertFalse(GoalsRepository.matches(rule, TransactionType.MSHWARI, recipient = null)) + assertFalse(GoalsRepository.matches(rule, TransactionType.SEND, recipient = "Anyone")) + assertFalse(GoalsRepository.matches(rule, TransactionType.PAY_BILL, recipient = "KPLC")) + } +} diff --git a/app/src/test/java/com/ledga/app/insights/InsightRulesTest.kt b/app/src/test/java/com/ledga/app/insights/InsightRulesTest.kt new file mode 100644 index 0000000..4f64e7d --- /dev/null +++ b/app/src/test/java/com/ledga/app/insights/InsightRulesTest.kt @@ -0,0 +1,276 @@ +package com.ledga.app.insights + +import com.ledga.app.data.db.entity.Category +import com.ledga.app.data.db.entity.InsightSeverity +import com.ledga.app.data.db.entity.InsightType +import com.ledga.app.data.db.entity.TransactionEntity +import com.ledga.app.data.insights.RuleContext +import com.ledga.app.data.insights.rules.AnomalyRule +import com.ledga.app.data.insights.rules.FeeTipRule +import com.ledga.app.data.insights.rules.FulizaAutoPayRule +import com.ledga.app.data.insights.rules.FulizaRule +import com.ledga.app.data.insights.rules.PositiveNudgeRule +import com.ledga.app.data.insights.rules.RecurringRule +import com.ledga.app.data.parser.TransactionDirection +import com.ledga.app.data.parser.TransactionType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Calendar + +class InsightRulesTest { + + // ---- Test fixtures -------------------------------------------------- + + private val now = Calendar.getInstance().apply { + set(2026, Calendar.APRIL, 15, 12, 0, 0) + set(Calendar.MILLISECOND, 0) + }.timeInMillis + + private val groceries = Category(id = 1, name = "Groceries", icon = "shopping_cart", color = "#4CAF50") + private val transport = Category(id = 2, name = "Transport", icon = "directions_car", color = "#2196F3") + private val bills = Category(id = 3, name = "Bills", icon = "receipt", color = "#FF9800") + + private val categories = mapOf( + groceries.id to groceries, + transport.id to transport, + bills.id to bills, + ) + + private fun txn( + amount: Double, + daysAgo: Int = 0, + categoryId: Long? = null, + direction: TransactionDirection = TransactionDirection.OUTFLOW, + type: TransactionType = TransactionType.BUY_GOODS, + recipientName: String? = null, + transactionCost: Double = 0.0, + fulizaOutstanding: Double? = null, + ): TransactionEntity = TransactionEntity( + id = 0, + transactionCode = "T${System.nanoTime()}", + type = type, + amount = amount, + transactionCost = transactionCost, + recipientName = recipientName, + recipientPhone = null, + accountNumber = null, + destinationCountry = null, + balance = 0.0, + direction = direction, + categoryId = categoryId, + fulizaAmount = null, + fulizaOutstanding = fulizaOutstanding, + reversedTransactionCode = null, + rawSms = "", + timestamp = now - daysAgo * 86_400_000L, + ) + + private fun ctx(vararg t: TransactionEntity) = + RuleContext(now = now, transactions = t.toList(), categoriesById = categories) + + // ---- AnomalyRule ---------------------------------------------------- + + @Test + fun `anomaly rule fires when this week spikes vs rolling baseline`() { + // Baseline: ~2000/week on transport for last 4 weeks; this week 6000. + val baseline = (0..3).flatMap { weekOffset -> + listOf(txn(amount = 2000.0, daysAgo = 7 + weekOffset * 7, categoryId = transport.id)) + } + val thisWeek = listOf( + txn(amount = 3000.0, daysAgo = 1, categoryId = transport.id), + txn(amount = 3000.0, daysAgo = 2, categoryId = transport.id), + ) + val rule = AnomalyRule() + val result = rule.evaluate(ctx(*(baseline + thisWeek).toTypedArray())) + + assertEquals(1, result.size) + assertEquals(InsightType.ANOMALY, result[0].type) + assertEquals(InsightSeverity.WARN, result[0].severity) + assertTrue( + "headline should mention category transport, got: ${result[0].headline}", + result[0].headline.contains("transport", ignoreCase = true) + ) + assertTrue( + "naturalKey should embed category and week", + result[0].naturalKey.startsWith("anomaly:${transport.id}:") + ) + } + + @Test + fun `anomaly rule does not fire on small categories`() { + // Below MIN_THRESHOLD even with a big delta. + val baseline = (0..3).flatMap { weekOffset -> + listOf(txn(amount = 50.0, daysAgo = 7 + weekOffset * 7, categoryId = groceries.id)) + } + val thisWeek = listOf(txn(amount = 500.0, daysAgo = 1, categoryId = groceries.id)) + val rule = AnomalyRule() + assertEquals(0, rule.evaluate(ctx(*(baseline + thisWeek).toTypedArray())).size) + } + + // ---- RecurringRule -------------------------------------------------- + + @Test + fun `recurring rule detects roughly-monthly merchant`() { + // KPLC bill 3 months ago, 2 months ago, 1 month ago — ~30 day intervals. + val txns = listOf( + txn(amount = 2400.0, daysAgo = 1, recipientName = "KPLC"), + txn(amount = 2500.0, daysAgo = 32, recipientName = "KPLC"), + txn(amount = 2400.0, daysAgo = 62, recipientName = "KPLC"), + ) + val rule = RecurringRule() + val result = rule.evaluate(ctx(*txns.toTypedArray())) + + assertEquals(1, result.size) + assertEquals(InsightType.RECURRING, result[0].type) + assertTrue(result[0].headline.contains("Kplc", ignoreCase = true)) + } + + @Test + fun `recurring rule ignores weekly merchants`() { + // Naivas weekly trips — not a monthly bill. + val txns = (1..5).map { + txn(amount = 1200.0, daysAgo = it * 7, recipientName = "NAIVAS") + } + val rule = RecurringRule() + assertEquals(0, rule.evaluate(ctx(*txns.toTypedArray())).size) + } + + // ---- FeeTipRule ----------------------------------------------------- + + @Test + fun `fee tip fires when monthly fees exceed threshold`() { + val txns = listOf( + txn(amount = 5000.0, daysAgo = 2, transactionCost = 150.0, type = TransactionType.WITHDRAW_AGENT), + txn(amount = 3000.0, daysAgo = 5, transactionCost = 100.0, type = TransactionType.WITHDRAW_AGENT), + ) + val rule = FeeTipRule() + val result = rule.evaluate(ctx(*txns.toTypedArray())) + + assertEquals(1, result.size) + assertEquals(InsightType.FEE_TIP, result[0].type) + assertTrue(result[0].headline.contains("250")) + } + + @Test + fun `fee tip stays silent below threshold`() { + val txns = listOf( + txn(amount = 500.0, daysAgo = 2, transactionCost = 30.0, type = TransactionType.WITHDRAW_AGENT), + ) + assertEquals(0, FeeTipRule().evaluate(ctx(*txns.toTypedArray())).size) + } + + // ---- FulizaRule ----------------------------------------------------- + + @Test + fun `fuliza rule fires when latest outstanding is positive`() { + val txns = listOf( + txn(amount = 500.0, daysAgo = 1, fulizaOutstanding = 500.0), + txn(amount = 200.0, daysAgo = 2), + ) + val result = FulizaRule().evaluate(ctx(*txns.toTypedArray())) + assertEquals(1, result.size) + assertEquals(InsightSeverity.ALERT, result[0].severity) + assertEquals("fuliza:outstanding", result[0].naturalKey) + } + + @Test + fun `fuliza rule silent when outstanding cleared`() { + val txns = listOf( + txn(amount = 500.0, daysAgo = 1, fulizaOutstanding = 0.0), + ) + assertEquals(0, FulizaRule().evaluate(ctx(*txns.toTypedArray())).size) + } + + // ---- FulizaAutoPayRule ---- + + @Test + fun `fuliza auto-pay fires for full clear with positive nudge`() { + val received = txn( + amount = 1000.0, + daysAgo = 0, + type = TransactionType.RECEIVED, + direction = TransactionDirection.INFLOW, + recipientName = "JANE DOE", + ) + // Auto-pay must be slightly later than the RECEIVED for the pairing logic. + val autoPay = txn( + amount = 800.0, + daysAgo = 0, + type = TransactionType.FULIZA_AUTO_PAY, + recipientName = "Fuliza overdraft (auto-cleared)", + ) + val rule = FulizaAutoPayRule() + val result = rule.evaluate(ctx(received, autoPay)) + + assertEquals(1, result.size) + assertEquals(InsightType.FULIZA_AUTO_PAY, result[0].type) + assertEquals(InsightSeverity.NUDGE, result[0].severity) + assertTrue( + "headline should mention 800, got: ${result[0].headline}", + result[0].headline.contains("800") + ) + assertTrue( + "headline should say 'cleared', got: ${result[0].headline}", + result[0].headline.contains("cleared", ignoreCase = true) + ) + } + + @Test + fun `fuliza auto-pay fires for partial as INFO`() { + val autoPay = txn( + amount = 200.0, + daysAgo = 0, + type = TransactionType.FULIZA_AUTO_PAY, + recipientName = "Fuliza overdraft (auto-partial)", + ) + val result = FulizaAutoPayRule().evaluate(ctx(autoPay)) + assertEquals(1, result.size) + assertEquals(InsightSeverity.INFO, result[0].severity) + assertTrue(result[0].headline.contains("toward your Fuliza")) + } + + @Test + fun `fuliza auto-pay ignores events older than 7 days`() { + val old = txn( + amount = 500.0, + daysAgo = 10, + type = TransactionType.FULIZA_AUTO_PAY, + recipientName = "Fuliza overdraft (auto-cleared)", + ) + assertEquals(0, FulizaAutoPayRule().evaluate(ctx(old)).size) + } + + @Test + fun `fuliza auto-pay naturalKey is per-transaction-code`() { + val a = txn(amount = 100.0, daysAgo = 0, type = TransactionType.FULIZA_AUTO_PAY, + recipientName = "Fuliza overdraft (auto-partial)") + val b = txn(amount = 100.0, daysAgo = 1, type = TransactionType.FULIZA_AUTO_PAY, + recipientName = "Fuliza overdraft (auto-partial)") + val keys = FulizaAutoPayRule().evaluate(ctx(a, b)).map { it.naturalKey }.toSet() + assertEquals(2, keys.size) // distinct keys keep both visible in history + assertTrue(keys.all { it.startsWith("fuliza_auto_pay:") }) + } + + // ---- PositiveNudgeRule ---------------------------------------------- + + @Test + fun `positive nudge fires when category drops significantly`() { + // Anchor 15 Apr — last month (Mar) had 5000 on groceries, this month (Apr 1-15) only 1000. + val lastMonth = txn(amount = 5000.0, daysAgo = 30, categoryId = groceries.id) + val thisMonth = txn(amount = 1000.0, daysAgo = 2, categoryId = groceries.id) + val rule = PositiveNudgeRule() + val result = rule.evaluate(ctx(lastMonth, thisMonth)) + + assertEquals(1, result.size) + assertEquals(InsightSeverity.NUDGE, result[0].severity) + assertTrue(result[0].headline.contains("Groceries")) + } + + @Test + fun `positive nudge stays silent on small baselines`() { + val lastMonth = txn(amount = 200.0, daysAgo = 30, categoryId = groceries.id) + val thisMonth = txn(amount = 50.0, daysAgo = 2, categoryId = groceries.id) + assertEquals(0, PositiveNudgeRule().evaluate(ctx(lastMonth, thisMonth)).size) + } +} diff --git a/app/src/test/java/com/ledga/app/insights/InsightsDryRunTest.kt b/app/src/test/java/com/ledga/app/insights/InsightsDryRunTest.kt new file mode 100644 index 0000000..46805da --- /dev/null +++ b/app/src/test/java/com/ledga/app/insights/InsightsDryRunTest.kt @@ -0,0 +1,267 @@ +package com.ledga.app.insights + +import com.ledga.app.data.db.DefaultData +import com.ledga.app.data.db.entity.InsightSeverity +import com.ledga.app.data.db.entity.InsightType +import com.ledga.app.data.db.entity.TransactionEntity +import com.ledga.app.data.insights.InsightCandidate +import com.ledga.app.data.insights.InsightRule +import com.ledga.app.data.insights.RuleContext +import com.ledga.app.data.insights.rules.AnomalyRule +import com.ledga.app.data.insights.rules.FeeTipRule +import com.ledga.app.data.insights.rules.FulizaAutoPayRule +import com.ledga.app.data.insights.rules.FulizaRule +import com.ledga.app.data.insights.rules.PositiveNudgeRule +import com.ledga.app.data.insights.rules.RecurringRule +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 kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.junit.Assume.assumeTrue +import org.junit.Test +import java.io.File +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale + +/** + * Sweeps the 5 insight rules forward through real exported history one + * week at a time, recording every distinct insight that would have fired + * (deduped by naturalKey) along with the week it first appeared. + * + * Used to sanity-check the rule thresholds before users hit them live. + * Skipped on machines without the export. Never asserts — it just prints. + */ +class InsightsDryRunTest { + + private val exportPath = File(System.getProperty("user.home"), "Documents/ledga-export/data.json") + + @Serializable + private data class ExportRoot(val transactions: List) + + @Serializable + private data class Exported( + val transactionCode: String, + val type: String, + val amount: Double, + val transactionCost: Double = 0.0, + val recipientName: String? = null, + val recipientPhone: String? = null, + val accountNumber: String? = null, + val destinationCountry: String? = null, + val balance: Double = 0.0, + val direction: String, + val categoryId: Long? = null, + val fulizaAmount: Double? = null, + val fulizaOutstanding: Double? = null, + val reversedTransactionCode: String? = null, + val rawSms: String = "", + val timestamp: Long, + ) + + private val rules: List = listOf( + AnomalyRule(), + RecurringRule(), + FeeTipRule(), + FulizaRule(), + FulizaAutoPayRule(), + PositiveNudgeRule(), + ) + + @Test + fun `insights dry-run over exported history`() { + assumeTrue("Skipping: export not present at ${exportPath.absolutePath}", exportPath.exists()) + + val json = Json { ignoreUnknownKeys = true } + val txns = json.decodeFromString(exportPath.readText()) + .transactions + .mapNotNull { it.toEntityOrNull() } + .sortedBy { it.timestamp } + + require(txns.isNotEmpty()) { "Export contained no transactions" } + + val categoriesById = DefaultData.DEFAULT_CATEGORIES.associateBy { it.id } + + // Walk forward in 7-day steps from the first transaction's week to today. + val firstTs = txns.first().timestamp + val lastTs = txns.last().timestamp + val startWeek = startOfWeek(firstTs) + val endWeek = startOfWeek(System.currentTimeMillis()) + + val firstFired = mutableMapOf() + val firesPerWeek = mutableMapOf() + var weekCount = 0 + + var weekStart = startWeek + while (weekStart <= endWeek) { + weekCount++ + val now = weekStart + WEEK_MS - 1 + val ninetyDaysAgo = now - 90L * DAY_MS + val window = txns.asSequence() + .filter { it.timestamp in ninetyDaysAgo..now } + .sortedByDescending { it.timestamp } + .toList() + + val ctx = RuleContext(now = now, transactions = window, categoriesById = categoriesById) + val firedThisWeek = mutableListOf() + rules.forEach { rule -> + runCatching { firedThisWeek.addAll(rule.evaluate(ctx)) } + } + + firedThisWeek.forEach { c -> + if (firstFired.putIfAbsent(c.naturalKey, FiredInsight(c, weekStart)) == null) { + val week = weekKey(weekStart) + firesPerWeek.merge(week, 1) { a, _ -> a + 1 } + } + } + weekStart += WEEK_MS + } + + printReport( + txnCount = txns.size, + weekCount = weekCount, + firstFired = firstFired, + firesPerWeek = firesPerWeek, + firstTs = firstTs, + lastTs = lastTs, + ) + } + + private fun printReport( + txnCount: Int, + weekCount: Int, + firstFired: Map, + firesPerWeek: Map, + firstTs: Long, + lastTs: Long, + ) { + val fmt = SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH) + println("--- Insights dry-run ---") + println("Transactions : $txnCount (${fmt.format(Date(firstTs))} → ${fmt.format(Date(lastTs))})") + println("Weeks swept : $weekCount") + println("Unique insights : ${firstFired.size}") + + val byType = firstFired.values.groupingBy { it.candidate.type }.eachCount() + println() + println("By type:") + InsightType.entries.forEach { type -> + println(" %-18s %d".format(type.name, byType[type] ?: 0)) + } + + val bySeverity = firstFired.values.groupingBy { it.candidate.severity }.eachCount() + println() + println("By severity:") + InsightSeverity.entries.forEach { sev -> + println(" %-8s %d".format(sev.name, bySeverity[sev] ?: 0)) + } + + // Noisiness signal — average fires per active week. + val activeWeeks = firesPerWeek.values.count { it > 0 } + val avg = if (activeWeeks > 0) firstFired.size.toDouble() / activeWeeks else 0.0 + println() + println("Active weeks : $activeWeeks of $weekCount") + println("Avg per active : %.2f".format(avg)) + val noisyWeeks = firesPerWeek.entries.sortedByDescending { it.value }.take(5) + println("Noisiest weeks :") + noisyWeeks.forEach { (week, count) -> + println(" $week → $count insights") + } + + // Sample headlines per type so we can sanity-check the copy. + println() + println("Sample headlines (first fire per type):") + InsightType.entries.forEach { type -> + val sample = firstFired.values + .filter { it.candidate.type == type } + .minByOrNull { it.weekStart } + if (sample != null) { + val date = fmt.format(Date(sample.weekStart)) + println(" [${type.name}] $date") + println(" ${sample.candidate.headline}") + sample.candidate.body?.let { println(" └─ $it") } + } + } + } + + // ---- Conversion ---- + + /** + * Re-parse each rawSms through the CURRENT parser before evaluating + * rules. The export was created before recent parser fixes (Fuliza + * auto-pay, withdraw alt format, etc.), so trusting the stored `type` + * would under-count rule fires. Re-parsing simulates what a user sees + * after tapping "Reparse unknown messages". + */ + private fun Exported.toEntityOrNull(): TransactionEntity? { + if (rawSms.isNotBlank()) { + val parsed = MpesaSmsParser.parse(rawSms, timestamp) + if (parsed is ParseResult.Success) { + val p = parsed.transaction + return TransactionEntity( + transactionCode = p.transactionCode, + type = p.type, + amount = p.amount, + transactionCost = p.transactionCost, + recipientName = p.recipientName, + recipientPhone = p.recipientPhone, + accountNumber = p.accountNumber, + destinationCountry = p.destinationCountry, + balance = p.balance, + direction = p.direction, + categoryId = categoryId, // category isn't re-derived; keep export's + fulizaAmount = p.fulizaAmount, + fulizaOutstanding = p.fulizaOutstanding, + reversedTransactionCode = p.reversedTransactionCode, + rawSms = p.rawSms, + timestamp = p.timestamp, + ) + } + } + // Fallback to the export's stored fields if reparsing wasn't possible. + return try { + TransactionEntity( + transactionCode = transactionCode, + type = TransactionType.valueOf(type), + amount = amount, + transactionCost = transactionCost, + recipientName = recipientName, + recipientPhone = recipientPhone, + accountNumber = accountNumber, + destinationCountry = destinationCountry, + balance = balance, + direction = TransactionDirection.valueOf(direction), + categoryId = categoryId, + fulizaAmount = fulizaAmount, + fulizaOutstanding = fulizaOutstanding, + reversedTransactionCode = reversedTransactionCode, + rawSms = rawSms, + timestamp = timestamp, + ) + } catch (_: Exception) { + null + } + } + + private data class FiredInsight(val candidate: InsightCandidate, val weekStart: Long) + + private fun startOfWeek(ts: Long): Long = Calendar.getInstance().apply { + timeInMillis = ts + val dow = ((get(Calendar.DAY_OF_WEEK) + 5) % 7) // Mon = 0 + add(Calendar.DAY_OF_YEAR, -dow) + set(Calendar.HOUR_OF_DAY, 0); set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0) + }.timeInMillis + + private fun weekKey(weekStart: Long): String { + val cal = Calendar.getInstance().apply { timeInMillis = weekStart } + return "%d-W%02d".format(cal.weekYear, cal.get(Calendar.WEEK_OF_YEAR)) + } + + companion object { + private const val DAY_MS = 86_400_000L + private const val WEEK_MS = 7L * DAY_MS + } +} diff --git a/app/src/test/java/com/ledga/app/parser/MpesaSmsParserTest.kt b/app/src/test/java/com/ledga/app/parser/MpesaSmsParserTest.kt index d4c9b16..4b09a82 100644 --- a/app/src/test/java/com/ledga/app/parser/MpesaSmsParserTest.kt +++ b/app/src/test/java/com/ledga/app/parser/MpesaSmsParserTest.kt @@ -31,6 +31,86 @@ class MpesaSmsParserTest { assertFalse(MpesaSmsParser.isMpesaMessage("SAFARICOM")) } + // --- Real-world regression fixtures --- + // Sanitized from /Documents/ledga-export — phone numbers and account + // balances replaced with placeholders to keep real-world fidelity without + // committing live data. + + @Test + fun `real Hustler Fund send — amount-first phrasing`() { + val sms = "SEK8U08F14 Confirmed. You have sent Ksh1,002.84 to Hustler Fund on 20/05/2024 at 03:23 PM. New MPESA balance is Ksh9,429.84." + val t = assertSuccess(sms).transaction + assertEquals(TransactionType.SEND, t.type) + assertEquals(1002.84, t.amount, 0.01) + assertEquals("Hustler Fund", t.recipientName) + assertEquals(TransactionDirection.OUTFLOW, t.direction) + } + + @Test + fun `real Give-cash agent deposit`() { + val sms = "TGE6WZSMPY Confirmed. On 14/7/25 at 11:50 AM Give Ksh300.00 cash to Jamag Holdings Harrysal Mali Mali Street Busia New M-PESA balance is Ksh300.00. You can now access M-PESA via *334#" + val t = assertSuccess(sms).transaction + assertEquals(TransactionType.DEPOSIT, t.type) + assertEquals(300.0, t.amount, 0.01) + assertEquals(TransactionDirection.INFLOW, t.direction) + assertTrue("recipientName should mention the agent, got: ${t.recipientName}", + t.recipientName?.contains("Jamag", ignoreCase = true) == true) + } + + @Test + fun `real Fuliza auto-pay full`() { + val sms = "UCHIE9OIM9 Confirmed. Ksh 1738.92 from your M-PESA has been used to fully pay your outstanding Fuliza M-PESA. Available Fuliza M-PESA limit is Ksh 11000.00. Your M-PESA balance is 8581.08." + val t = assertSuccess(sms).transaction + assertEquals(TransactionType.FULIZA_AUTO_PAY, t.type) + assertEquals(1738.92, t.amount, 0.01) + assertEquals(TransactionDirection.OUTFLOW, t.direction) + } + + @Test + fun `real Fuliza auto-pay partial`() { + val sms = "UC3IE8BHFR Confirmed. Ksh 200.00 from your M-PESA has been used to partially pay your outstanding Fuliza M-PESA. Your available Fuliza M-PESA limit is Ksh 8018.37. M-PESA balance is Ksh0.00." + val t = assertSuccess(sms).transaction + assertEquals(TransactionType.FULIZA_AUTO_PAY, t.type) + assertEquals(200.0, t.amount, 0.01) + } + + @Test + fun `real withdraw alt format with merchant code`() { + val sms = "UCEIE9EQ8H Confirmed.on 14/3/26 at 7:17 PMWithdraw Ksh6,500.00 from 031824 - Jamag Holdings Harrysal Mali Mali Street Busia New M-PESA balance is Ksh1,200.10. Transaction cost, Ksh87.00." + val t = assertSuccess(sms).transaction + assertEquals(TransactionType.WITHDRAW_AGENT, t.type) + assertEquals(6500.0, t.amount, 0.01) + assertEquals(87.0, t.transactionCost, 0.01) + assertEquals("031824", t.recipientPhone) + assertTrue("merchant name should contain 'Jamag', got: ${t.recipientName}", + t.recipientName?.contains("Jamag", ignoreCase = true) == true) + } + + @Test + fun `real airtime self with no-space confirmed`() { + val sms = "UC2IE88HZW confirmed.You bought Ksh200.00 of airtime on 2/3/26 at 2:52 PM.New M-PESA balance is Ksh5,240.88. Transaction cost, Ksh0.00." + val t = assertSuccess(sms).transaction + assertEquals(TransactionType.AIRTIME_SELF, t.type) + assertEquals(200.0, t.amount, 0.01) + } + + @Test + fun `real reversal alt`() { + val sms = "SH37SPJZKZ confirmed. Reversal of transaction SH38SP5YUQ has been successfully reversed on 3/8/24 at 2:30 PM and Ksh200.00 is credited to your M-PESA account. New M-PESA account balance is Ksh1,770.00." + val t = assertSuccess(sms).transaction + assertEquals(TransactionType.REVERSAL, t.type) + assertEquals("SH38SP5YUQ", t.reversedTransactionCode) + assertEquals(TransactionDirection.INFLOW, t.direction) + } + + @Test + fun `real balance inquiry is filtered, not stored as UNKNOWN`() { + val sms = "UCJQF9UGN5 Confirmed. Your account balance was: M-PESA Account : Ksh458.00 Business Account : Ksh0.00 on 19/3/26 at 1:33 PM. Transaction cost, Ksh0.00." + val result = MpesaSmsParser.parse(sms) + assertTrue("Expected balance inquiry to be Failure, got: $result", + result is ParseResult.Failure) + } + // --- Send Money --- @Test diff --git a/app/src/test/java/com/ledga/app/parser/ParserAuditTest.kt b/app/src/test/java/com/ledga/app/parser/ParserAuditTest.kt new file mode 100644 index 0000000..d88bb88 --- /dev/null +++ b/app/src/test/java/com/ledga/app/parser/ParserAuditTest.kt @@ -0,0 +1,94 @@ +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.TransactionType +import org.junit.Assume.assumeTrue +import org.junit.Test +import java.io.File + +/** + * Validates the parser against a real exported dataset. + * + * Reads from `~/Documents/ledga-export/data.json` if present; skipped in CI + * and on any machine without the export so we don't commit real data. + * + * Goal: drive the unknown rate as close to zero as possible on the captured + * 6,565-transaction history. The export was generated before parser fixes + * landed — re-parsing it tests the current code path. + */ +class ParserAuditTest { + + private val exportPath = File(System.getProperty("user.home"), "Documents/ledga-export/data.json") + + @Test + fun `current parser handles real exported SMS — unknowns under 50`() { + assumeTrue("Skipping: export not present at ${exportPath.absolutePath}", exportPath.exists()) + + val rawTexts = extractRawSms(exportPath) + require(rawTexts.isNotEmpty()) { "Export contained no rawSms strings" } + + val outcomes = rawTexts.map { sms -> + when (val result = MpesaSmsParser.parse(sms)) { + is ParseResult.Success -> Outcome.Parsed(result.transaction.type, sms) + is ParseResult.Failure -> Outcome.Filtered(result.reason, sms) + } + } + + val typeCounts = outcomes.filterIsInstance() + .groupingBy { it.type } + .eachCount() + .toList() + .sortedByDescending { it.second } + val unknowns = outcomes.count { it is Outcome.Parsed && it.type == TransactionType.UNKNOWN } + val filtered = outcomes.count { it is Outcome.Filtered } + + // Print a summary that's useful when iterating on the parser. + println("--- Parser audit ---") + println("Total rawSms inputs : ${rawTexts.size}") + println("Filtered (excluded) : $filtered") + typeCounts.forEach { (type, count) -> println(" %-20s %d".format(type.name, count)) } + println("UNKNOWN : $unknowns") + + if (unknowns > 0) { + println("\n--- Sample UNKNOWN strings (up to 10) ---") + outcomes.asSequence() + .filterIsInstance() + .filter { it.type == TransactionType.UNKNOWN } + .take(10) + .forEach { println(" ${it.sms.take(180)}") } + } + + // Be strict: the parser has explicit handling for every observed + // pattern in this dataset. >50 unknowns means a regression. + assert(unknowns < 50) { + "Expected < 50 UNKNOWN parses, got $unknowns. " + + "Investigate the printed samples above." + } + } + + /** + * The export is a JSON object: `{ "transactions": [ { "rawSms": "...", ... }, ... ] }`. + * We parse without bringing in a JSON dependency by extracting the rawSms + * field with a deliberate regex. Good enough for fixture loading; not for + * production code. + */ + private fun extractRawSms(file: File): List { + val text = file.readText() + val regex = Regex(""""rawSms"\s*:\s*"((?:\\.|[^"\\])*)"""") + return regex.findAll(text) + .map { it.groupValues[1].unescapeJson() } + .toList() + } + + private fun String.unescapeJson(): String = + replace("\\n", "\n") + .replace("\\t", "\t") + .replace("\\\"", "\"") + .replace("\\\\", "\\") + + private sealed class Outcome { + data class Parsed(val type: TransactionType, val sms: String) : Outcome() + data class Filtered(val reason: String, val sms: String) : Outcome() + } +} diff --git a/app/src/test/java/com/ledga/app/update/UpdateLogicTest.kt b/app/src/test/java/com/ledga/app/update/UpdateLogicTest.kt new file mode 100644 index 0000000..dfaf720 --- /dev/null +++ b/app/src/test/java/com/ledga/app/update/UpdateLogicTest.kt @@ -0,0 +1,96 @@ +package com.ledga.app.update + +import com.ledga.app.ui.update.parseChangelog +import com.ledga.app.worker.UpdateChecker +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class UpdateLogicTest { + + // ---- Version comparison ---- + + @Test + fun `isNewerVersion catches simple bumps`() { + assertTrue(UpdateChecker.isNewerVersion(latest = "1.1.0", current = "1.0.0")) + assertTrue(UpdateChecker.isNewerVersion(latest = "1.0.1", current = "1.0.0")) + assertTrue(UpdateChecker.isNewerVersion(latest = "2.0.0", current = "1.9.9")) + } + + @Test + fun `isNewerVersion handles double-digit segments correctly`() { + // A naive lexicographic compare would say "1.10" < "1.9". We compare numerically. + assertTrue(UpdateChecker.isNewerVersion(latest = "1.10", current = "1.9")) + assertTrue(UpdateChecker.isNewerVersion(latest = "1.10.0", current = "1.9.5")) + } + + @Test + fun `isNewerVersion treats missing segments as zero`() { + // 1.1 is the same as 1.1.0. + assertFalse(UpdateChecker.isNewerVersion(latest = "1.1", current = "1.1.0")) + assertFalse(UpdateChecker.isNewerVersion(latest = "1.1.0", current = "1.1")) + // But 1.1.1 should beat 1.1. + assertTrue(UpdateChecker.isNewerVersion(latest = "1.1.1", current = "1.1")) + } + + @Test + fun `isNewerVersion returns false on same or older`() { + assertFalse(UpdateChecker.isNewerVersion(latest = "1.0.0", current = "1.0.0")) + assertFalse(UpdateChecker.isNewerVersion(latest = "1.0.0", current = "1.1.0")) + assertFalse(UpdateChecker.isNewerVersion(latest = "0.9.9", current = "1.0.0")) + } + + // ---- Changelog parsing ---- + + @Test + fun `parseChangelog groups whats-new and fixes`() { + val body = """ + ## What's new + - Insights tab + - Goals + - Dual-SIM support + + ## Fixes + - Faster startup on Android 8 + - Better Fuliza parsing + """.trimIndent() + val parsed = parseChangelog(body) + assertEquals(3, parsed.whatsNew.size) + assertTrue(parsed.whatsNew.contains("Insights tab")) + assertEquals(2, parsed.fixes.size) + assertTrue(parsed.fixes.contains("Faster startup on Android 8")) + assertTrue(parsed.other.isEmpty()) + } + + @Test + fun `parseChangelog strips bullet markers`() { + val body = """ + ## What's new + * One + - Two + """.trimIndent() + val parsed = parseChangelog(body) + assertEquals(listOf("One", "Two"), parsed.whatsNew) + } + + @Test + fun `parseChangelog falls through to other for non-bulleted lines`() { + val body = """ + Release notes attached. + See the GitHub page for the full story. + """.trimIndent() + val parsed = parseChangelog(body) + assertTrue(parsed.whatsNew.isEmpty()) + assertTrue(parsed.fixes.isEmpty()) + assertEquals(2, parsed.other.size) + } + + @Test + fun `parseChangelog returns empty on blank input`() { + val parsed = parseChangelog("") + assertTrue(parsed.whatsNew.isEmpty()) + assertTrue(parsed.fixes.isEmpty()) + assertTrue(parsed.other.isEmpty()) + } +}