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())
+ }
+}