v1.3.0: major redesign — bento UI, Insights, Goals, multi-SIM, in-app updates

UI overhaul
- Modern fintech bento design system (LedgaText, Space, Radius, Motion).
- Inter font via Google Fonts.
- New PillTabBar + LedgaTopBar; 4-tab IA: Home / Activity / Insights / You.
- Home rewritten as bento cards; new AccountChip leading slot.
- Activity wraps Transactions + Trends behind a segmented control.
- Trends gains a GitHub-style heatmap calendar.
- You consolidates Settings into grouped bento cards.

Insights (Phase C)
- Rule engine + 6 rules: anomaly (z-score), recurring detection, fee tips,
  Fuliza outstanding, Fuliza auto-pay pairing, positive nudges.
- Daily InsightWorker (3 AM); generates on screen entry for first-time users.
- Natural-key dedup so dismiss/snooze state survives re-runs.
- Home teaser surfaces the top active insight.

Goals (Phase D)
- New Goal entity + GoalContribution table.
- 3 contribution rules: AllSavingsDeposits, ToRecipient, Manual.
- Rule-based contributions compute live via SQL aggregation (no
  duplicate rows). Manual rule uses goal_contributions table.
- GoalsScreen with progress rings + status badges; AddGoalSheet;
  GoalDetailScreen with contributing-transactions list.
- "Add to goal" entry in TransactionDetailSheet for Manual goals.

Multi-SIM (Phase E)
- MpesaAccount entity captured per-subscription on SMS receive
  (SubscriptionManager.EXTRA_SUBSCRIPTION_INDEX with legacy fallback).
- READ_PHONE_STATE permission (optional; falls back to "Line N" labels).
- Every screen-facing query is account-scoped via TransactionRepository
  composing with the selected-account flow.
- AccountSwitcherSheet on Home; AccountsScreen with rename / recolor /
  set-primary; BackfillScreen with 3-prong attribution (SMS-inbox auto,
  date range, per-transaction manual via TransactionDetailSheet).

In-app updates (Phase F + G)
- Chunked APK download with progress reporting.
- UpdateRepository state machine (Idle → Downloading → Ready → Failed).
- Full-screen UpdateScreen with parsed What's new / Fixes sections.
- "Remind me later" persists dismissed version.
- Background UpdateCheckWorker pre-fetches APK on unmetered network.
- Home banner: "available" vs "ready to install" copy variants.
- Proactive REQUEST_INSTALL_PACKAGES permission prompt with settings
  deep-link before firing the installer intent.

Parser quality
- Hustler Fund "sent KshX to NAME" amount-first phrasing now matches.
- "Give cash to AGENT" agent-deposit format recognized as DEPOSIT.
- Fuliza auto-pay recipient labels rewritten as "Fuliza overdraft
  (auto-cleared / auto-partial)" for clearer transaction lists.
- 8 new real-world regression fixtures from sanitized export.
- Audit: 0 UNKNOWN against 6,565-transaction real history (was 838).

Database
- v3 schema: mpesa_accounts, goals, goal_contributions, insights tables.
- transactions gains accountId (FK), note.
- Explicit MIGRATION_1_2 + MIGRATION_2_3.

Tests
- 65 unit tests across parser (33), rule engine (14), goal rules (8),
  update logic (8), parser audit (1), insights dry-run (1).
- Two test suites (audit, dry-run) read the local export when present,
  skip cleanly in CI.

Design spec
- LEDGA_REDESIGN.md captures the full direction + tokens + per-screen
  wireframes + phased PR plan.

versionCode 3, versionName 1.3.0.
This commit is contained in:
ngenohkevin
2026-05-16 13:17:54 +03:00
parent 00975aa7cc
commit 51264301a8
86 changed files with 9613 additions and 668 deletions
+943
View File
@@ -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.
+25 -1
View File
@@ -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
+3 -2
View File
@@ -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")
+3
View File
@@ -7,6 +7,9 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- Multi-SIM: needed to resolve subscription IDs to carrier/line names.
Gracefully optional — without it we fall back to "Line 1" / "Line 2" labels. -->
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<application
android:name=".LedgaApp"
@@ -3,8 +3,10 @@ package com.ledga.app
import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import com.ledga.app.worker.InsightScheduler
import com.ledga.app.worker.NotificationHelper
import com.ledga.app.worker.NotificationScheduler
import com.ledga.app.worker.UpdateScheduler
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
@@ -24,5 +26,7 @@ class LedgaApp : Application(), Configuration.Provider {
NotificationHelper.createChannels(this)
NotificationScheduler.scheduleDailySummary(this)
NotificationScheduler.scheduleWeeklySummary(this)
InsightScheduler.schedulePeriodic(this)
UpdateScheduler.schedulePeriodic(this)
}
}
+35 -21
View File
@@ -5,9 +5,12 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.compose.runtime.collectAsState
@@ -24,7 +27,6 @@ import com.ledga.app.ui.navigation.AppNavigation
import com.ledga.app.ui.navigation.HomeRoute
import com.ledga.app.ui.navigation.LedgaBottomNavBar
import com.ledga.app.ui.navigation.OnboardingRoute
import com.ledga.app.ui.navigation.UnparsedSmsRoute
import com.ledga.app.ui.theme.LedgaTheme
import com.ledga.app.ui.theme.ThemeMode
import dagger.hilt.android.AndroidEntryPoint
@@ -67,30 +69,42 @@ class MainActivity : ComponentActivity() {
else Density(density.density, fontScale.scale)
CompositionLocalProvider(LocalDensity provides scaledDensity) {
val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
val startDestination: Any = if (hasCompletedOnboarding) HomeRoute else OnboardingRoute
val startDestination: Any = if (hasCompletedOnboarding) HomeRoute else OnboardingRoute
val showBottomBar = currentRoute != null &&
!currentRoute.contains("Onboarding") &&
!currentRoute.contains("UnparsedSms") &&
!currentRoute.contains("Budget")
// Tabs are visible on tab roots only (Home / Activity / Insights / You).
val showBottomBar = currentRoute != null &&
(currentRoute.endsWith(".HomeRoute") ||
currentRoute.endsWith(".ActivityRoute") ||
currentRoute.endsWith(".InsightsRoute") ||
currentRoute.endsWith(".YouRoute"))
Scaffold(
bottomBar = {
// Box overlay — the PillTabBar is floating, so it sits ON TOP of
// content rather than displacing it. Screens add 120dp bottom
// padding themselves to clear the bar.
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
) {
AppNavigation(
navController = navController,
startDestination = startDestination,
modifier = Modifier.fillMaxSize(),
)
if (showBottomBar) {
LedgaBottomNavBar(navController = navController)
Box(
modifier = Modifier
.fillMaxSize(),
contentAlignment = Alignment.BottomCenter,
) {
LedgaBottomNavBar(navController = navController)
}
}
}
) { innerPadding ->
AppNavigation(
navController = navController,
startDestination = startDestination,
modifier = Modifier.padding(innerPadding)
)
}
}
}
}
@@ -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
}
@@ -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)
}
@@ -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`)"
)
}
}
}
@@ -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<List<Goal>>
@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<GoalProgress?>
@Query("SELECT transactionId FROM goal_contributions WHERE goalId = :goalId")
fun observeManualContributionIds(goalId: Long): Flow<List<Long>>
@Query("SELECT goalId FROM goal_contributions WHERE transactionId = :transactionId")
fun observeGoalIdsForTransaction(transactionId: Long): Flow<List<Long>>
}
@@ -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<List<Insight>>
@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<Insight?>
@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)
}
@@ -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<List<MpesaAccount>>
@Query("SELECT * FROM mpesa_accounts ORDER BY isPrimary DESC, createdAt ASC")
suspend fun getAll(): List<MpesaAccount>
@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<Int>
}
@@ -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<List<TransactionWithCategory>>
@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<String>): Int
@Transaction
@Query("SELECT * FROM transactions WHERE timestamp BETWEEN :startTime AND :endTime ORDER BY timestamp DESC")
fun getTransactions(startTime: Long, endTime: Long): Flow<List<TransactionWithCategory>>
@Query("""
SELECT * FROM transactions
WHERE (:accountId IS NULL OR accountId = :accountId)
ORDER BY timestamp DESC
LIMIT :limit
""")
fun getRecentTransactions(limit: Int = 10, accountId: Long? = null): Flow<List<TransactionWithCategory>>
@Query("SELECT * FROM transactions ORDER BY timestamp DESC LIMIT 1")
fun getLatestTransaction(): Flow<TransactionEntity?>
@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<List<TransactionWithCategory>>
@Query("SELECT * FROM transactions WHERE balance > 0 ORDER BY timestamp DESC LIMIT 1")
fun getLatestTransactionWithBalance(): Flow<TransactionEntity?>
@Query("""
SELECT * FROM transactions
WHERE (:accountId IS NULL OR accountId = :accountId)
ORDER BY timestamp DESC LIMIT 1
""")
fun getLatestTransaction(accountId: Long? = null): Flow<TransactionEntity?>
@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<TransactionEntity?>
@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<List<CategorySpending>>
fun getSpendingByCategory(startTime: Long, endTime: Long, accountId: Long? = null): Flow<List<CategorySpending>>
@Query("SELECT COALESCE(SUM(amount), 0.0) FROM transactions WHERE direction = 'OUTFLOW' AND timestamp BETWEEN :startTime AND :endTime")
fun getTotalSpending(startTime: Long, endTime: Long): Flow<Double>
@Query("""
SELECT COALESCE(SUM(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<Double>
@Query("SELECT COALESCE(SUM(transactionCost), 0.0) FROM transactions WHERE timestamp BETWEEN :startTime AND :endTime")
fun getTotalFees(startTime: Long, endTime: Long): Flow<Double>
@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<Double>
@Transaction
@Query("SELECT * FROM transactions WHERE recipientName LIKE '%' || :query || '%' ORDER BY timestamp DESC")
fun searchTransactions(query: String): Flow<List<TransactionWithCategory>>
@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<List<TransactionWithCategory>>
@Transaction
@Query("SELECT * FROM transactions WHERE type IN (:types) ORDER BY timestamp DESC")
fun getTransactionsByType(types: List<TransactionType>): Flow<List<TransactionWithCategory>>
@Query("""
SELECT * FROM transactions
WHERE type IN (:types)
AND (:accountId IS NULL OR accountId = :accountId)
ORDER BY timestamp DESC
""")
fun getTransactionsByType(types: List<TransactionType>, accountId: Long? = null): Flow<List<TransactionWithCategory>>
@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<List<TransactionWithCategory>>
// Trends: daily spending totals for a period
@Query("""
SELECT (timestamp / 86400000) * 86400000 as dayTimestamp, SUM(amount) as totalAmount
FROM transactions
WHERE direction = 'OUTFLOW' AND timestamp BETWEEN :startTime AND :endTime
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<List<DailySpending>>
fun getDailySpending(startTime: Long, endTime: Long, accountId: Long? = null): Flow<List<DailySpending>>
// Trends: top merchants by total spending
@Query("""
SELECT recipientName, SUM(amount) as totalAmount, COUNT(*) as transactionCount
FROM transactions
WHERE direction = 'OUTFLOW' AND recipientName IS NOT NULL
AND timestamp BETWEEN :startTime AND :endTime
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<List<TopMerchant>>
fun getTopMerchants(startTime: Long, endTime: Long, accountId: Long? = null, limit: Int = 5): Flow<List<TopMerchant>>
@Query("SELECT COUNT(*) FROM transactions WHERE type = 'UNKNOWN'")
fun getUnparsedCount(): Flow<Int>
// ---- 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<Double>
@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<Double>
/** 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<List<TransactionWithCategory>>
@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<List<TransactionWithCategory>>
/** 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<List<TopMerchant>>
@Query("SELECT * FROM transactions WHERE type = 'UNKNOWN'")
suspend fun getUnparsedSync(): List<TransactionEntity>
@@ -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:<name fragment> -> 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
}
}
}
@@ -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,
}
@@ -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(),
)
@@ -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,
)
@@ -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
}
}
@@ -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<TransactionEntity>,
val categoriesById: Map<Long, Category>,
)
/**
* 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<InsightCandidate>
}
@@ -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<InsightCandidate> {
val outflows = ctx.transactions.filter { it.direction == TransactionDirection.OUTFLOW }
if (outflows.isEmpty()) return emptyList()
val byCategoryByWeek: Map<Long, Map<String, Double>> = 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<InsightCandidate>()
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<String> {
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
}
}
@@ -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<InsightCandidate> {
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
}
}
@@ -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<InsightCandidate> {
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<TransactionEntity>,
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
}
}
@@ -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<InsightCandidate> {
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",
)
)
}
}
@@ -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<InsightCandidate> {
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<InsightCandidate>()
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",
)
}
}
@@ -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<InsightCandidate> {
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,
)
}
}
@@ -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,
@@ -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<List<MpesaAccount>> = accountDao.observeAll()
fun observeCount(): Flow<Int> = accountDao.count()
suspend fun getAll(): List<MpesaAccount> = 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<String?, String?> {
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
}
}
}
@@ -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<List<Long>> =
goalDao.observeManualContributionIds(goalId)
/** Which goals contain this transaction as a manual contribution. */
fun observeGoalIdsForTransaction(transactionId: Long): Flow<List<Long>> =
goalDao.observeGoalIdsForTransaction(transactionId)
/** All goals with the Manual rule — what the detail sheet shows. */
fun observeManualGoals(): Flow<List<Goal>> =
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<List<GoalWithProgress>> =
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<GoalWithProgress?> =
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<List<TransactionWithCategory>> =
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<Double> =
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
}
}
}
@@ -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<Int, MutableList<String>>()
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<InboxRow> {
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<String>): List<InboxRow>? = 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<InboxRow>()
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,
)
}
@@ -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<List<Insight>> =
insightDao.observeActive(System.currentTimeMillis())
/** The single highest-priority active insight — for the Home teaser. */
fun observeTop(): Flow<Insight?> =
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)
}
}
@@ -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<ThemeMode> = 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<Long?> = 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<String?> = 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) {
@@ -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<Long?> 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<List<TransactionWithCategory>> =
transactionDao.getRecentTransactions(limit)
selectedAccount.flatMapLatest { transactionDao.getRecentTransactions(limit, it) }
fun getTransactions(startTime: Long, endTime: Long): Flow<List<TransactionWithCategory>> =
transactionDao.getTransactions(startTime, endTime)
selectedAccount.flatMapLatest { transactionDao.getTransactions(startTime, endTime, it) }
fun getLatestTransaction(): Flow<TransactionEntity?> =
transactionDao.getLatestTransaction()
selectedAccount.flatMapLatest { transactionDao.getLatestTransaction(it) }
fun getLatestTransactionWithBalance(): Flow<TransactionEntity?> =
transactionDao.getLatestTransactionWithBalance()
selectedAccount.flatMapLatest { transactionDao.getLatestTransactionWithBalance(it) }
fun getSpendingByCategory(startTime: Long, endTime: Long): Flow<List<CategorySpending>> =
transactionDao.getSpendingByCategory(startTime, endTime)
selectedAccount.flatMapLatest { transactionDao.getSpendingByCategory(startTime, endTime, it) }
fun getTotalSpending(startTime: Long, endTime: Long): Flow<Double> =
transactionDao.getTotalSpending(startTime, endTime)
selectedAccount.flatMapLatest { transactionDao.getTotalSpending(startTime, endTime, it) }
fun getTotalFees(startTime: Long, endTime: Long): Flow<Double> =
transactionDao.getTotalFees(startTime, endTime)
selectedAccount.flatMapLatest { transactionDao.getTotalFees(startTime, endTime, it) }
fun searchTransactions(query: String): Flow<List<TransactionWithCategory>> =
transactionDao.searchTransactions(query)
selectedAccount.flatMapLatest { transactionDao.searchTransactions(query, it) }
fun getTransactionsByType(types: List<TransactionType>): Flow<List<TransactionWithCategory>> =
transactionDao.getTransactionsByType(types)
selectedAccount.flatMapLatest { transactionDao.getTransactionsByType(types, it) }
fun getDailySpending(startTime: Long, endTime: Long): Flow<List<DailySpending>> =
transactionDao.getDailySpending(startTime, endTime)
selectedAccount.flatMapLatest { transactionDao.getDailySpending(startTime, endTime, it) }
fun getTopMerchants(startTime: Long, endTime: Long, limit: Int = 5): Flow<List<TopMerchant>> =
transactionDao.getTopMerchants(startTime, endTime, limit)
selectedAccount.flatMapLatest { transactionDao.getTopMerchants(startTime, endTime, it, limit) }
fun getUnparsedTransactions(): Flow<List<TransactionWithCategory>> =
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<String>): 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++
@@ -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>(DownloadState.Idle)
val downloadState: Flow<DownloadState> = _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"
}
}
@@ -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()
}
@@ -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<InsightRule> = listOf(anomaly, recurring, feeTip, fuliza, fulizaAutoPay, positive)
}
@@ -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)
}
}
@@ -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<MpesaAccount?>(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()
@@ -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<MpesaAccount> = emptyList(),
val selectedAccountId: Long? = null,
)
@HiltViewModel
class AccountsViewModel @Inject constructor(
private val accountsRepository: AccountsRepository,
private val settingsRepository: SettingsRepository,
) : ViewModel() {
val uiState: StateFlow<AccountsUiState> = 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) }
}
}
@@ -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<Long?>(null) }
var fromDate by remember { mutableStateOf<Long?>(null) }
var toDate by remember { mutableStateOf<Long?>(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))
@@ -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<MpesaAccount> = emptyList(),
val isAutoRunning: Boolean = false,
val autoProgress: Pair<Int, Int>? = 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<Pair<Int, Int>?>(null)
private val _autoResult = MutableStateFlow<BackfillResult?>(null)
private val _dateRangeResult = MutableStateFlow<Int?>(null)
private val _dateRangeRunning = MutableStateFlow(false)
val uiState: StateFlow<BackfillUiState> = 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
}
}
}
}
@@ -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<Segment>,
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,
)
}
}
}
}
}
@@ -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()) {
@@ -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(
@@ -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<MpesaAccount>,
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()
@@ -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),
)
}
}
@@ -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,
)
}
}
}
@@ -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<Long, Double>,
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)),
)
}
@@ -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<PillTab>,
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,
)
}
}
@@ -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,
)
}
}
}
@@ -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),
)
}
}
}
}
}
@@ -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,
)
}
}
}
@@ -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)
@@ -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,
)
}
}
}
@@ -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<Long?>(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)
}
@@ -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))
}
}
}
@@ -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<TransactionWithCategory> = 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<GoalDetailRoute>().goalId
val uiState: StateFlow<GoalDetailUiState> = 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()
}
}
}
@@ -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)}"
}
@@ -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<GoalWithProgress> = emptyList(),
val isLoading: Boolean = true,
)
@HiltViewModel
class GoalsViewModel @Inject constructor(
private val goalsRepository: GoalsRepository,
) : ViewModel() {
val uiState: StateFlow<GoalsUiState> = 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) }
}
}
@@ -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() },
)
}
}
}
}
@@ -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<TransactionWithCategory> = 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<MpesaAccount> = 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<GitHubRelease?>(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) {
@@ -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
@@ -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<Insight> = 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<InsightsUiState> = 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) }
}
}
@@ -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<HomeRoute> {
HomeScreen()
}
composable<TransactionsRoute> {
TransactionsScreen()
}
composable<TrendsRoute> {
TrendsScreen()
}
composable<SettingsRoute> {
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<ActivityRoute> {
ActivityScreen()
}
composable<InsightsRoute> {
InsightsScreen()
}
composable<YouRoute> {
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<UnparsedSmsRoute> {
UnparsedSmsScreen(onBack = { navController.popBackStack() })
}
composable<BudgetRoute> {
BudgetScreen(onBack = { navController.popBackStack() })
}
composable<GoalsRoute> {
GoalsScreen(
onBack = { navController.popBackStack() },
onOpenGoal = { id -> navController.navigate(GoalDetailRoute(id)) },
)
}
composable<GoalDetailRoute> {
GoalDetailScreen(onBack = { navController.popBackStack() })
}
composable<AccountsRoute> {
AccountsScreen(
onBack = { navController.popBackStack() },
onOpenBackfill = { navController.navigate(AccountBackfillRoute) },
)
}
composable<AccountBackfillRoute> {
BackfillScreen(onBack = { navController.popBackStack() })
}
composable<UpdateRoute> {
UpdateScreen(onBack = { navController.popBackStack() })
}
}
}
@@ -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<T : Any>(
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
}
},
)
}
@@ -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
@@ -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)
@@ -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<Float>(durationMillis = 120, easing = EmphasizedDecelerate)
fun base() = tween<Float>(durationMillis = 240, easing = EmphasizedDecelerate)
fun emphasized() = tween<Float>(durationMillis = 360, easing = EmphasizedDecelerate)
fun number() = tween<Float>(durationMillis = 600, easing = EmphasizedDecelerate)
}
@@ -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,
@@ -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,
)
@@ -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<Category>,
onDismiss: () -> Unit,
onCategoryChange: (Long) -> Unit
onCategoryChange: (Long) -> Unit,
accounts: List<MpesaAccount> = emptyList(),
onAccountChange: ((Long?) -> Unit)? = null,
manualGoals: List<Goal> = emptyList(),
goalIdsForTransaction: List<Long> = 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))
}
}
@@ -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<TransactionWithCategory>,
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<TransactionWithCategory>): 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)
}
@@ -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<Category> = emptyList(),
val accounts: List<MpesaAccount> = emptyList(),
val manualGoals: List<Goal> = emptyList(),
/** Goal IDs the currently-selected transaction is already manually attributed to. */
val selectedTxGoalIds: List<Long> = 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<TransactionWithCategory?>(null)
private val selectedTxGoalIds = _selectedTransaction.flatMapLatest { selected ->
if (selected == null) flowOf(emptyList())
else goalsRepository.observeGoalIdsForTransaction(selected.transaction.id)
}
val uiState: StateFlow<TransactionsUiState> = 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<MpesaAccount>,
val manualGoals: List<Goal>,
val txGoals: List<Long>,
)
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)
}
}
}
@@ -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<Double>,
@@ -236,3 +267,4 @@ private fun DailySpendingChart(
modifier = modifier,
)
}
@@ -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<String>,
val fixes: List<String>,
val other: List<String>,
)
internal fun parseChangelog(body: String): Changelog {
if (body.isBlank()) return Changelog(emptyList(), emptyList(), emptyList())
val whatsNew = mutableListOf<String>()
val fixes = mutableListOf<String>()
val other = mutableListOf<String>()
var bucket: MutableList<String> = 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)
}
@@ -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<GitHubRelease?>(null)
private val _checking = MutableStateFlow(false)
private val _checkedAt = MutableStateFlow<Long?>(null)
val uiState: StateFlow<UpdateUiState> = 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) }
}
}
@@ -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),
)
}
@@ -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)
}
}
@@ -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<InsightWorker>(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"
}
@@ -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<UpdateCheckWorker>(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)) {
+63
View File
@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<array name="com_google_android_gms_fonts_certs">
<item>@array/com_google_android_gms_fonts_certs_dev</item>
<item>@array/com_google_android_gms_fonts_certs_prod</item>
</array>
<string-array name="com_google_android_gms_fonts_certs_dev">
<item>
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=
</item>
</string-array>
<string-array name="com_google_android_gms_fonts_certs_prod">
<item>
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
</item>
</string-array>
</resources>
@@ -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"))
}
}
@@ -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)
}
}
@@ -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<Exported>)
@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<InsightRule> = 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<ExportRoot>(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<String, FiredInsight>()
val firesPerWeek = mutableMapOf<String, Int>()
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<InsightCandidate>()
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<String, FiredInsight>,
firesPerWeek: Map<String, Int>,
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
}
}
@@ -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
@@ -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<Outcome.Parsed>()
.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<Outcome.Parsed>()
.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<String> {
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()
}
}
@@ -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())
}
}