mirror of
https://github.com/andwati/ledga.git
synced 2026-10-18 03:12:55 +00:00
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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user