diff --git a/docs/brainstorms/2026-09-25-streak-system-requirements.md b/docs/brainstorms/2026-09-25-streak-system-requirements.md new file mode 100644 index 0000000..3ea2812 --- /dev/null +++ b/docs/brainstorms/2026-09-25-streak-system-requirements.md @@ -0,0 +1,163 @@ +--- +date: 2026-09-25 +topic: streak-system +--- + +# Streak System — Requirements + +## Summary + +A multi-type streak system that rewards consistency with the ledger, delivered in phases: tracking streak first, then no-spend and app-open consistency streaks, then an under-budget streak. All types share one qualifying-run engine and surface as a home flame card plus a streaks page matching the Financial Atelier design. + +## Problem Frame + +Expense logging is bursty by nature: people record transactions in clusters, and a day with no spending is a good day that leaves nothing to log. A strict "log every day" streak therefore punishes exactly the behavior a personal finance app should reward, and trains users to log fake entries to keep a flame alive. + +Streaks in the design are presented as achievements ("14 Day Streak — Mastering Financial Discipline"). To be honest achievements rather than engagement pressure, the mechanic must forgive short lapses, only celebrate a real habit, and never reward data pollution. + +## Key Decisions + +- **Forgiving grace window over strict daily logging.** A run breaks only after a gap longer than the window (default 3 days). This keeps no-spend days and weekend catch-ups from destroying progress. +- **Minimum display threshold.** A streak celebrates only from day 3 (configurable); below that the UI frames it as warming up. The window makes streaks forgiving, the minimum keeps them meaningful. +- **Calendar-days counting.** The streak number counts calendar days covered by the run (logs on Mon and Wed with a 3-day window read as a 3-day streak), matching the "14 Day Streak" hero in the design. +- **One shared hidden config.** A single long-press on any streak element opens one config sheet with a section per streak type, opened at that element's section. The existing easter-egg ritual stays separate as app identity. +- **Hybrid under-budget scoring.** Completed months qualify on total spend vs total expected budgets; per-category overruns are flagged on the page without resetting the streak. The in-progress month shows an on/off-track preview and never changes the count. +- **Derived-only streak counts.** Streaks always recompute from the underlying records; nothing about a streak is stored. Backdated entries can extend or heal runs retroactively. The future rewards layer will introduce its own persisted unlock state (e.g., best-ever streak) — this principle applies to counts, not rewards. +- **Phased delivery.** Phase 1: tracking streak, config, home card. Phase 2: no-spend and app-open streaks. Phase 3: under-budget streak. Rewards later still. + +## Requirements + +**Streak engine** + +- R1. A streak is a run of qualifying days; its length counts calendar days covered by the run, from first to last qualifying day. +- R2. A gap between consecutive qualifying days up to the type's window (default 3) does not break the run. +- R3. Window and run math use local calendar dates, never raw timestamps. +- R4. Each run carries a status: `warming up` below the minimum (default 3), `active` at or above it, and `at risk` when the run would end unless the user qualifies before the window closes. +- R5. A streak is celebrated only at or above the minimum; below it the UI frames it as warming up. +- R6. Every streak type is defined solely by a qualifying rule over records; one engine computes all types identically from that rule. +- R7. Streak values are always recomputed from the underlying records. Backdated records can extend or heal the current run retroactively. + +```mermaid +stateDiagram-v2 + [*] --> WarmingUp: first qualifying day + WarmingUp --> WarmingUp: qualifying day within window + WarmingUp --> Active: length reaches minimum + Active --> Active: qualifying day within window + WarmingUp --> Broken: gap exceeds window + Active --> Broken: gap exceeds window + Broken --> WarmingUp: next qualifying day + note right of Active + At risk is a view of Active: + the current day is unqualified + and the window is nearly closed + end note +``` + +**Configuration** + +- R8. All streak settings live behind one hidden surface: long-pressing any streak element opens the single config sheet at that type's section. +- R9. Settings include the tracking window and minimum and each type's cadence where applicable; defaults: window 3, minimum 3, app-open cadence 7. +- R10. The app-open cadence accepts presets 1, 2, 3 plus a custom value; cadence 1 means qualifying every day. + +**Streak types** + +- R11. Tracking streak (phase 1): a day qualifies when at least one transaction is recorded against it. +- R12. No-spend streak (phase 2): a day qualifies when no expenses are recorded against it. +- R13. App-open consistency streak (phase 2): qualifying is defined by opening the app; consecutive qualifying opens spaced within the cadence N keep the run. +- R14. Under-budget streak (phase 3): monthly cadence; a completed month qualifies when total recorded expenses are within the total expected category budgets; the in-progress month shows an on/off-track preview and never changes the count. + +**Presentation** + +- R15. Home shows a compact streak card: flame, "N Day Streak", a subtitle carrying at-risk or next-milestone context, and progress toward the next milestone. +- R16. A streaks page shows per type: hero counter, a month calendar of qualifying days, and a consistency rate for the month. +- R17. Months that qualified while individual categories exceeded their own budgets are flagged on the page without affecting the streak. +- R18. Streaks update reactively as records change, including backdated changes. +- R19. Milestones display upcoming thresholds of the current streak; they do not persist unlock state. + +## Key Flows + +- F1. Log and extend + - **Trigger:** The user records a transaction. + - **Steps:** Record is saved; the engine recomputes qualifying days; the home card updates. + - **Outcome:** Streak length and status reflect the new data immediately. + - **Covers:** R7, R18 +- F2. Tune streak config + - **Trigger:** Long-press on a streak element. + - **Steps:** Hidden config sheet opens at that type's section; the user adjusts values; the sheet closes. + - **Outcome:** Streaks recompute under the new settings. + - **Covers:** R8, R9, R10 +- F3. Review consistency + - **Trigger:** The user opens the streaks page from the home card. + - **Steps:** The page renders hero counter, calendar, consistency rate, and milestones per type. + - **Outcome:** The user sees a per-type picture of their discipline. + - **Covers:** R16, R17, R19 + +## Acceptance Examples + +- AE1. Window keeps a run alive + - **Given:** Window 3, logs on Monday and Wednesday. + - **When:** Thursday arrives with no log. + - **Then:** The run continues and shows at risk. + - **Covers:** R2, R4 +- AE2. Window breaks the run + - **Given:** Window 3, last qualifying day Saturday. + - **When:** Four calendar days pass without a qualifying day. + - **Then:** The run ends; the next qualifying day starts a new run. + - **Covers:** R2 +- AE3. Minimum gates celebration + - **Given:** Minimum 3, two consecutive qualifying days. + - **When:** The home card renders. + - **Then:** It shows warming up rather than a celebrated streak. + - **Covers:** R5 +- AE4. Backdating heals + - **Given:** A run with a 2-day gap inside the window. + - **When:** The user logs a transaction dated within that gap. + - **Then:** The streak recomputes and the run is whole again. + - **Covers:** R7 +- AE5. Cadence collapse + - **Given:** App-open cadence 1. + - **When:** The user opens the app on consecutive days. + - **Then:** Each day qualifies; a skipped day consumes window capacity. + - **Covers:** R10, R13 +- AE6. Cadence spacing + - **Given:** App-open cadence 7, opens on the 1st and the 7th. + - **When:** The engine derives the run. + - **Then:** The run continues; an open on the 9th breaks it. + - **Covers:** R13 +- AE7. Current month preview only + - **Given:** The under-budget streak, mid-month spend above total budgets. + - **When:** The page renders. + - **Then:** The streak count is unchanged and the current month shows off track. + - **Covers:** R14 +- AE8. Hybrid flagging + - **Given:** A completed month within total budgets where one category exceeded its own budget. + - **When:** The page renders. + - **Then:** The month qualifies for the streak and the category is flagged. + - **Covers:** R17 + +## Scope Boundaries + +- Rewards and unlocks from the design are deferred. When built, they introduce persisted unlock state (e.g., best-ever streak) as their own phase; streak counts remain derived. +- Streak-risk notifications and reminders are out of v1; risk surfaces in-app only. + +## Dependencies / Assumptions + +- Transaction records carry local dates and are date-indexed (existing). +- Categories carry expected monthly budgets (existing). +- App-open tracking needs a new lightweight record of open events — the first streak source not derived from transactions. +- Streak settings persist in the app's existing key-value storage. +- Visual direction comes from `documents/stitch-export.html` (streaks page, home card, consistency rate, milestones, rewards). + +## Outstanding Questions + +**Deferred to Planning** + +- Milestone threshold set and progression, and whether milestones are per type or shared. +- Exact at-risk semantics per type (when a day is still qualify-able vs when the window is closed). +- Consistency rate definition per type. + +## Sources / Research + +- `documents/stitch-export.html` — Financial Atelier design export: streaks page (hero, calendar, consistency %, milestones, rewards), home streak card. +- Moni app (user-cited) — precedent for app-open consistency tracking with customizable cadence. +- Existing transaction domain/data layers and dashboard state the streak system hangs off. diff --git a/docs/plans/2026-09-26-001-feat-streak-system-plan.md b/docs/plans/2026-09-26-001-feat-streak-system-plan.md new file mode 100644 index 0000000..9b1803e --- /dev/null +++ b/docs/plans/2026-09-26-001-feat-streak-system-plan.md @@ -0,0 +1,306 @@ +--- +title: "feat: Streak System" +type: feat +status: active +date: 2026-09-26 +origin: docs/brainstorms/2026-09-25-streak-system-requirements.md +--- + +# feat: Streak System + +## Summary + +Build a multi-type streak system on one generic qualifying-run engine: tracking streak (phase 1) with hidden config and home flame card, no-spend and app-open consistency streaks (phase 2), and an under-budget monthly streak (phase 3). All streak values derive from records at read time — nothing about a streak is persisted. + +## Problem Frame + +The origin document frames the problem: expense logging is bursty, so a strict daily streak punishes good behavior (no-spend days) and trains fake logging. The design (`documents/stitch-export.html`) presents streaks as achievements — a home flame card and a full streaks page (hero counter, month calendar, consistency rate, milestones). To be honest achievements, the mechanic must forgive short lapses (window), only celebrate real habits (minimum), and never store a streak state that can diverge from the ledger. (see origin: docs/brainstorms/2026-09-25-streak-system-requirements.md) + +## Requirements + +Carried from the origin document — IDs match 1:1 (see origin for full prose and acceptance examples). + +**Streak engine** + +- R1. Streak length counts calendar days covered by the run, first to last qualifying day inclusive. +- R2. Gaps between consecutive qualifying days up to the type's window (default 3) do not break the run. +- R3. All run math uses local calendar dates, never raw timestamps. +- R4. Each run carries a status: warming up below minimum, active at or above it, at risk when the window is about to close. +- R5. Celebration only at or above the minimum (default 3); below it the UI frames warming up. +- R6. Every streak type is defined solely by a qualifying rule over records; one engine computes all types identically. +- R7. Streak values always recompute from records; backdated records can extend or heal runs retroactively. + +**Configuration** + +- R8. One hidden config surface: long-press any streak element opens a single sheet at that type's section. +- R9. Settings cover tracking window and minimum and per-type cadence; defaults 3 / 3 / 7. +- R10. App-open cadence accepts presets 1, 2, 3 plus custom; cadence 1 means qualifying every day. + +**Streak types** + +- R11. Tracking streak (phase 1): a day qualifies when at least one transaction is recorded against it. +- R12. No-spend streak (phase 2): a day qualifies when no expenses are recorded against it. +- R13. App-open consistency streak (phase 2): qualifying opens spaced within cadence N keep the run. +- R14. Under-budget streak (phase 3): monthly; a completed month qualifies when total expenses are within total expected budgets; the in-progress month shows an on/off-track preview and never changes the count. + +**Presentation** + +- R15. Home shows a compact streak card: flame, "N Day Streak", at-risk / next-milestone subtitle, progress toward next milestone. +- R16. Streaks page shows per type: hero counter, month calendar of qualifying days, consistency rate for the month. +- R17. Months qualifying on totals while individual categories exceeded their budgets are flagged without affecting the streak. +- R18. Streaks update reactively as records change, including backdated changes. +- R19. Milestones display upcoming thresholds; they do not persist unlock state. + +## Key Technical Decisions + +- **Derived-only computation.** No streak table, no stored counters. `StreakRepository` recomputes on demand from records; dashboard reactivity rides the existing `watchTransactions` subscription (`lib/features/dashboard/presentation/blocs/dashboard_cubit.dart` `_init`). Rationale: honest recompute per origin R7, zero reconciliation logic. +- **One pure engine, period-abstract from day one.** A single function over an ordered sequence of period indices (day or month), a window, and a minimum produces length, status, days-until-break, next milestone. Monthly support is built in up front because phase 3 needs it; retrofitting a days-only engine would be a rewrite. Engine lives in `lib/features/streak/domain/` as a pure class — no I/O, trivially unit-testable. +- **Types are predicates, not subclasses of logic.** Each `StreakType` maps to a predicate that turns records into a set of qualifying period indices; the engine is identical for all types. App-open cadence reuses the window mechanism — cadence N *is* the window for that type (cadence 1 collapses to a daily streak, origin AE5). +- **App-open events get their own Isar collection.** First record class not derived from transactions. `AppOpenModel` with a date index, written once at app bootstrap. Isar over shared_preferences because the streaks page needs the full open history for calendar and consistency rendering. +- **Config persists via existing `LocalStorage`** (`lib/core/storages/local_storages.dart`): new keys for window, minimum, and cadence with defaults 3 / 3 / 7. No migration needed — unset keys fall back to defaults. +- **Pinned defaults from the deferred origin questions:** shared milestone ladder 7 / 14 / 21 / 30 / 60 / 90 / 180 / 365 days; at-risk = today unqualified and today is the last day the window still covers (window closes tomorrow); consistency rate = qualifying periods ÷ elapsed periods in the current calendar month (per period type). All three are constants in the domain layer, tunable later without rework. +- **Budget aggregation reuses existing category helpers.** `CategoryState.pillarBudgets` / `totalBudget` (`lib/features/category/presentation/blocs/category_state.dart`) show the summing pattern for leaf-level `expectedMonthlyBudget`; the under-budget qualifier sums leaf budgets across expense categories and compares against the month's expense total. + +## High-Level Technical Design + +Data flow — record sources feed predicates, one engine, two presentation surfaces: + +```mermaid +flowchart TB + subgraph sources[Record sources] + TX[TransactionRepository] + AO[AppOpenRepository phase 2] + CAT[Category budgets phase 3] + end + subgraph streak[Streak feature] + PRED[Type predicates records to qualifying days] + ENG[Pure engine window minimum length status milestone] + REPO[StreakRepository] + UC[GetStreaksUseCase] + end + subgraph ui[Presentation] + DASH[DashboardCubit summary streak field] + CARD[Home streak card long-press config] + PAGE[Streaks page hero calendar consistency] + end + TX --> PRED + AO --> PRED + CAT --> PRED + PRED --> ENG --> REPO --> UC + CFG[Streak config storage] --> REPO + UC --> DASH --> CARD --> PAGE + UC --> PAGE +``` + +Engine decision flow per run computation (status semantics pinned in KTDs; state machine in origin): + +```mermaid +flowchart TB + A[Qualifying period indices from predicate] --> B{Any records?} + B -->|no| Z[Empty streak] + B -->|yes| C[Walk runs backward from today or last qualifying period] + C --> D{Gap to next qualifying period exceeds window?} + D -->|yes| E[Run ends before the gap] + D -->|no| F[Run continues gap absorbed] + F --> C + E --> G[Length = inclusive span of run] + G --> H{Length reaches minimum?} + H -->|no| I[warming up] + H -->|yes| J{Today unqualified and window closes today?} + J -->|yes| K[active + at risk] + J -->|no| L[active] +``` + +## Implementation Units + +Phases mirror the origin document. Units are dependency-ordered; each is one landable commit. + +### U1. Streak domain core — entities and pure engine + +- **Goal:** The complete streak math, testable in isolation. +- **Requirements:** R1, R2, R3, R4, R5, R6 (engine portion) +- **Dependencies:** none +- **Files:** `lib/features/streak/domain/entities/streak.dart` (entity: type, length, status, daysUntilBreak, nextMilestone, consistencyRate), `lib/features/streak/domain/entities/streak_status.dart`, `lib/features/streak/domain/entities/streak_type.dart`, `lib/features/streak/domain/entities/streak_config.dart`, `lib/features/streak/domain/entities/streak_period.dart` (day/month abstraction), `lib/features/streak/domain/services/streak_engine.dart`, tests in `test/features/streak/domain/services/streak_engine_test.dart` and `test/features/streak/domain/entities/` +- **Approach:** Engine takes an ordered set/list of qualifying period indices (as `DateTime` dates for days, year-month pairs for months), a `StreakConfig` (window, minimum), and "today" as explicit input for testability. Returns the current run plus the best historical run (cheap byproduct, useful later). Milestone ladder and status rules live as domain constants per KTD. Follow the pure-helper precedent of `lib/features/transaction/presentation/utils/expression_evaluator.dart`; entity style follows existing `Equatable` entities. +- **Test scenarios:** + - Happy path: consecutive daily logs produce length equal to day span; minimum met → active. + - Covers AE1. Window absorbs a 2-day gap at window 3; status at risk when today unqualified on the window's last covered day. + - Covers AE2. Gap exceeding window ends the run; next qualifying day starts a fresh run; best-run retains the old run. + - Covers AE3. Length below minimum reports warming up, never active. + - Empty record set → empty streak, no crash. + - Today qualified → never at risk regardless of older gaps. + - Month-period run: consecutive qualifying months, gap absorption across year boundary (Dec → Jan). + - DST / timezone safety: dates passed through local-date stripping; no UTC arithmetic shifts the day bucket. +- **Verification:** `fvm flutter test test/features/streak` green; engine has zero Flutter imports. + +### U2. Streak config storage + +- **Goal:** Window, minimum, and cadence persist with defaults. +- **Requirements:** R9, R10 (persistence portion) +- **Dependencies:** none +- **Files:** `lib/core/storages/local_storages.dart` (extend abstract + impl), test in `test/core/storages/local_storages_test.dart` +- **Approach:** New typed accessors on the existing `LocalStorage` abstraction (pattern-matching `getApiKey`/`setApiKey`); keys `streakWindowDays`, `streakMinimumDays`, `appOpenCadenceDays`. Getters return defaults 3 / 3 / 7 when unset. Values clamped to sane bounds at read time (window ≥ 1, minimum ≥ 1). +- **Test scenarios:** + - Unset keys return defaults 3 / 3 / 7. + - Round-trip set/get for each key. + - Out-of-range stored value (0 or negative) clamps on read. +- **Verification:** storage tests green; no Isar codegen involved. + +### U3. Streak repository and use case (tracking predicate) + +- **Goal:** Tracking streak derived from transactions, exposed through the standard use-case seam. +- **Requirements:** R6, R7, R9, R11 +- **Dependencies:** U1, U2 +- **Files:** `lib/features/streak/domain/repositories/streak_repository.dart`, `lib/features/streak/data/repositories/streak_repository_impl.dart`, `lib/features/streak/domain/usecases/get_streaks_use_case.dart`, tests in `test/features/streak/data/repositories/streak_repository_impl_test.dart` +- **Approach:** Impl injects `TransactionRepository` and `LocalStorage`. Fetches transactions via `getTransactions` (date-indexed; ranged query from run horizon — bounded lookback by longest plausible window, e.g. 2 years) and applies the tracking predicate: any transaction date → qualifying day. Config comes from U2 storage. Returns `Either` per `UseCase` conventions (`lib/core/domain/usecases/use_case.dart`). +- **Test scenarios:** + - Transactions on scattered days within window → single run, calendar-span length. + - Two clusters separated by more than window → current run is the recent one; best run is the longer cluster. + - Config override: window 1 behaves as strict daily streak. + - Repository failure from `TransactionRepository` propagates as `Left`. + - No transactions at all → empty streak entity. +- **Verification:** repository tests green with mocktail fakes; injectable registration compiles. + +### U4. Dashboard integration + +- **Goal:** Streak flows into dashboard state reactively. +- **Requirements:** R15 (state portion), R18 +- **Dependencies:** U3 +- **Files:** `lib/features/dashboard/domain/entities/dashboard_summary.dart` (add streak field), `lib/features/dashboard/domain/usecases/get_dashboard_summary_usecase.dart`, `lib/features/dashboard/presentation/blocs/dashboard_state.dart`, `lib/features/dashboard/presentation/blocs/dashboard_cubit.dart` (`loadDashboardData` success fold must pass the new streak field through `state.copyWith`), existing tests extended in `test/features/dashboard/presentation/blocs/dashboard_cubit_test.dart` and `test/features/dashboard/domain/usecases/get_dashboard_summary_usecase_test.dart` (its direct construction gains the mocked streak dependency) +- **Approach:** `GetDashboardSummaryUseCase` additionally resolves the streak and attaches it to `DashboardSummary`. `DashboardState` gains the field; existing `_init` watch subscription already re-triggers `loadDashboardData` on every transaction change, which covers reactivity including backdated edits (R18) with no new wiring. +- **Test scenarios:** + - Summary contains the streak computed from the fake transaction source. + - New transaction emission triggers reload and updated streak value. + - Streak failure degrades gracefully: dashboard still renders, streak field empty (failureOption path unchanged). +- **Verification:** dashboard cubit tests green; no behavior change to existing fields. + +### U5. Home streak card + +- **Goal:** The design's compact flame card on the home page. +- **Requirements:** R15 +- **Dependencies:** U4 +- **Files:** `lib/features/streak/presentation/widgets/streak_card.dart`, `lib/features/dashboard/presentation/pages/home_page.dart` (insert between `SummaryCard` and `WealthTrajectoryChart`), `lib/app/router/app_router.dart` (add `/streaks` route), widget tests in `test/features/streak/presentation/widgets/streak_card_test.dart` +- **Approach:** Card per design: orange flame container, Manrope bold "N Day Streak", subtitle carrying at-risk or "X to next milestone", thin progress bar toward milestone. Colors and capsule geometry from `docs/solutions/tooling-decisions/ui-design-system.md` (primary `#00113A`, on-surface-variant `#444650`, stadium radii). Tap → `context.push('/streaks')`; long-press → opens config sheet (U6). Warming-up state renders the minimum-gated framing instead of celebration. +- **Test scenarios:** + - Active streak renders "N Day Streak" with correct milestone subtitle and progress fraction. + - At-risk state renders the risk subtitle. + - Warming-up state renders warming framing (Covers AE3 at widget level). + - Tap navigates to `/streaks`; long-press opens the config sheet. + - Empty streak hides the card entirely. +- **Verification:** widget tests green; card visually matches the design's home section. + +### U6. Hidden config sheet + +- **Goal:** The single hidden tuning surface. +- **Requirements:** R8, R9, R10 (sheet portion) +- **Dependencies:** U2, U5 +- **Files:** `lib/features/streak/presentation/blocs/streak_config_cubit.dart`, `lib/features/streak/presentation/widgets/streak_config_sheet.dart`, tests in `test/features/streak/presentation/blocs/streak_config_cubit_test.dart` and `test/features/streak/presentation/widgets/streak_config_sheet_test.dart` +- **Approach:** One sheet, section per type; phase 1 ships the tracking section (window and minimum steppers) with reserved slots for app-open cadence (presets 1 / 2 / 3 + custom field) and under-budget (arrives with later phases). Sheet opens at the requested section via constructor param — contextual deep-linking per origin R8. Saves through `LocalStorage`; cubit reloads config on open so changed values recompute on next read (no cache invalidation needed since nothing caches). +- **Test scenarios:** + - Sheet opens at tracking section by default and at the requested section when deep-linked. + - Changing window / minimum persists through the storage layer and emits updated state. + - Out-of-range input is clamped before save. + - Later-phase sections render as reserved/disabled placeholders in phase 1. +- **Verification:** cubit and widget tests green. + +### U7. Streaks page (tracking type) + +- **Goal:** The design's streaks screen for the tracking streak. +- **Requirements:** R16, R19 +- **Dependencies:** U3, U5 (route exists) +- **Files:** `lib/features/streak/presentation/pages/streaks_page.dart`, `lib/features/streak/presentation/blocs/streak_cubit.dart`, `lib/features/streak/presentation/widgets/streak_hero.dart`, `lib/features/streak/presentation/widgets/streak_calendar.dart`, `lib/features/streak/presentation/widgets/milestone_progress.dart`, tests in `test/features/streak/presentation/pages/streaks_page_test.dart` +- **Approach:** Hero (flame, "N Day Streak", "Mastering Financial Discipline" eyebrow per design), month calendar marking qualifying days (design's green `secondary-container` days, today ring), consistency % tile, milestone cards with progress bars. Cubit holds selected month, fetches qualifying days for the month plus current streak; month navigation via chevrons. Capsule geometry and gradient tokens from the design system doc. +- **Test scenarios:** + - Calendar marks exactly the days with transactions for the displayed month. + - Month navigation switches data without stale marks. + - Hero shows current length and next milestone from the engine output. + - Consistency rate matches qualifying ÷ elapsed days for a partial month. + - Milestone progress bar fraction matches length vs next threshold. +- **Verification:** widget tests green; page matches the design's streaks screen minus rewards. + +### U8. App-open event log (phase 2) + +- **Goal:** A persisted record of app opens to derive the consistency streak from. +- **Requirements:** R13 (data source portion) +- **Dependencies:** U1 +- **Files:** `lib/features/streak/data/models/app_open_model.dart` (+ generated `.g.dart`), `lib/features/streak/data/datasources/app_open_local_data_source.dart`, `lib/features/streak/domain/repositories/app_open_repository.dart` + impl, registration at app bootstrap in `lib/app/view/app.dart` (or `main.dart` — wherever `Isar` opens schemas), tests in `test/features/streak/data/` +- **Approach:** `AppOpenModel` with indexed `date` and unique `uuid`; schema registered alongside existing collections; one write per app start (dedupe by local date+hour not needed — cadence logic tolerates multiple opens per day). Repository exposes `recordOpen()` and `getOpenDates()` mirroring transaction datasource conventions. +- **Test scenarios:** + - App start records one open event. + - Multiple opens same day persist as separate records; predicate later dedupes by day. + - `getOpenDates` returns indexed query results in date order. +- **Verification:** Isar codegen runs clean; datasource tests green. + +### U9. No-spend and app-open predicates (phase 2) + +- **Goal:** Types 2 and 3 live through the same engine. +- **Requirements:** R6, R12, R13 +- **Dependencies:** U3, U8 +- **Files:** `lib/features/streak/data/repositories/streak_repository_impl.dart` (extend derivation), `lib/features/streak/domain/entities/streak_type.dart` (add types), `lib/features/streak/presentation/widgets/streak_config_sheet.dart` (activate cadence section), tests extended in `test/features/streak/data/repositories/streak_repository_impl_test.dart` +- **Approach:** No-spend predicate: days with zero expense-typed transactions qualify (income-only days qualify). App-open predicate: open dates qualify, window = configured cadence N (presets 1 / 2 / 3 + custom wired to U2 storage). Both feed the identical engine — no engine changes expected; if any surface, treat as engine bug against U1 tests. +- **Test scenarios:** + - Covers AE5. Cadence 1: consecutive-day opens sustain the run; one skipped day consumes window capacity → at risk then broken. + - Covers AE6. Cadence 7: opens on the 1st and 7th continue; the 9th breaks. + - No-spend: expense-free days build a run; any expense that day disqualifies only that day. + - No-spend: income-only day still qualifies. + - Custom cadence value (e.g. 5) round-trips through config and drives the window. +- **Verification:** repository tests green; both types render via existing page with type switching deferred to U10. + +### U10. Streaks page multi-type UI (phase 2) + +- **Goal:** All live types selectable on the streaks page and home card. +- **Requirements:** R16 (per type), R8 (deep-link per type) +- **Dependencies:** U9 +- **Files:** `lib/features/streak/presentation/pages/streaks_page.dart` (type tabs/selector), `lib/features/streak/presentation/widgets/streak_card.dart` (shows configured type set), tests in `test/features/streak/presentation/pages/streaks_page_test.dart` +- **Approach:** Type selector across active types; each type renders its own hero / calendar / consistency with its own config section reachable via long-press deep-link. Home card defaults to tracking streak. +- **Test scenarios:** + - Switching type switches hero, calendar marks, and consistency source. + - Long-press on an app-open element deep-links to its config section. + - Types without records yet render empty states, not zeros. +- **Verification:** widget tests green. + +### U11. Under-budget streak (phase 3) + +- **Goal:** Monthly budget-discipline streak with hybrid scoring and preview. +- **Requirements:** R14, R17 +- **Dependencies:** U1 (month period), U10 +- **Files:** `lib/features/streak/data/repositories/streak_repository_impl.dart` (monthly predicate), `lib/features/streak/presentation/widgets/streak_calendar.dart` or sibling (month-level marks and category flags), tests in `test/features/streak/data/repositories/streak_repository_impl_test.dart` +- **Approach:** Predicate over completed months: sum expense transactions in the month ≤ sum of leaf `expectedMonthlyBudget` across expense categories (aggregation mirrors `CategoryState.totalBudget` logic, moved into a shared domain helper so presentation keeps its own copy working). In-progress month renders on/off-track preview only, never touches the run. Per-category overrun flags computed for the page: months qualify on totals; categories exceeding their own budgets are listed as flags (R17). +- **Test scenarios:** + - Covers AE7. Mid-month overspend leaves the count unchanged; preview shows off track. + - Covers AE8. Completed month within total budgets with one category over its budget → qualifies and flags the category. + - Completed month over total budgets → run breaks at that month. + - Month with zero budgets configured (all zeros) → not qualifying (avoids a free streak). + - Year-boundary consecutive months (Dec, Jan) compute correctly on the month engine. +- **Verification:** repository tests green; page renders preview and flags. + +## Scope Boundaries + +- **Deferred to Follow-Up Work:** + - Rewards / unlock system with persisted unlock state (e.g., best-ever streak) — own phase, own persistence. + - Streak-risk notifications and local reminders — risk stays in-app. +- **Non-goals for this plan:** + - Storing streak counts or frozen streak state anywhere — derivation only (see origin Key Decisions). + - Per-category strict mode for under-budget (total-spend qualification is fixed; category data is flags only). + +## Risks & Dependencies + +- **Local-date correctness** — the whole feature hinges on date-stripping in local time. Mitigation: single shared date utility, engine tests include DST-boundary cases, no UTC arithmetic anywhere in the engine. +- **Isar codegen step** — U8 adds a collection; `build_runner` must run through FVM (`fvm flutter pub run build_runner build --delete-conflicting-outputs` per repo tooling learnings in `docs/solutions/tooling-decisions/fvm-flutter-validation.md`). Forgetting it fails compilation loudly, not silently. +- **Backdated recompute performance** — full recompute per change is O(records in lookback); bounded by the ranged query (2-year horizon) and existing watch debounce pattern. If slow in practice, add memoization at the repository seam — engine stays pure. +- **injectable registration** — new `@injectable` / `@LazySingleton` symbols require a codegen pass after U3 and U8; same build_runner invocation. +- **Validation baseline** — `fvm flutter analyze --no-pub lib test` then `fvm flutter test --no-pub --coverage --test-randomize-ordering-seed random` (repo learnings). + +## Open Questions + +None blocking. Deferred to implementation: exact engine API naming (kept directional here), app-open capture point placement (`main` vs bootstrap widget), calendar widget construction (custom grid vs `table_calendar` — prefer custom given the design's capsule aesthetic and zero current dependency on such a package). + +## Sources / Research + +- `docs/brainstorms/2026-09-25-streak-system-requirements.md` — origin document; all R/AE/F references resolve there. +- `documents/stitch-export.html` — streaks page (hero, calendar, consistency, milestones), home streak card, design tokens. +- `docs/solutions/tooling-decisions/ui-design-system.md` — Financial Atelier color / typography / capsule geometry tokens. +- `docs/solutions/tooling-decisions/fvm-flutter-validation.md` — FVM validation commands. +- `lib/features/dashboard/presentation/blocs/dashboard_cubit.dart` — reactive reload pattern the streak rides on. +- `lib/features/category/presentation/blocs/category_state.dart` — budget aggregation precedent for U11. +- `lib/core/storages/local_storages.dart` — settings persistence seam for U2/U6. diff --git a/lib/app/router/app_router.dart b/lib/app/router/app_router.dart index 29cbf89..4fa4db3 100644 --- a/lib/app/router/app_router.dart +++ b/lib/app/router/app_router.dart @@ -8,6 +8,8 @@ import 'package:expense_tracker/features/easter_egg/presentation/blocs/easter_eg import 'package:expense_tracker/features/insights/presentation/blocs/insights_cubit.dart'; import 'package:expense_tracker/features/insights/presentation/pages/insights_page.dart'; import 'package:expense_tracker/features/settings/presentation/pages/settings_page.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_type.dart'; +import 'package:expense_tracker/features/streak/presentation/pages/streaks_page.dart'; import 'package:expense_tracker/features/transaction/domain/entities/transaction.dart'; import 'package:expense_tracker/features/transaction/presentation/blocs/transaction_cubit.dart'; import 'package:expense_tracker/features/transaction/presentation/pages/transaction_entry_page.dart'; @@ -19,9 +21,10 @@ import 'package:go_router/go_router.dart'; class AppRouter extends Equatable { static const home = 'home'; + static const streaks = 'streaks'; @override - List get props => [home]; + List get props => [home, streaks]; } GoRouter router([ @@ -79,6 +82,20 @@ GoRouter router([ name: 'categories', builder: (context, state) => const CategoryManagePage(), ), + GoRoute( + path: '/streaks', + name: AppRouter.streaks, + builder: (context, state) { + final typeParam = state.uri.queryParameters['type']; + final initialType = typeParam != null + ? StreakType.values.firstWhere( + (t) => t.name == typeParam, + orElse: () => StreakType.tracking, + ) + : null; + return StreaksPage(initialType: initialType); + }, + ), GoRoute( path: '/counter', name: 'counter', diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index 6d8b78b..5e74e73 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -5,10 +5,13 @@ // license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +import 'dart:async'; + import 'package:expense_tracker/app/router/app_router.dart'; import 'package:expense_tracker/core/extensions/context_extensions.dart'; import 'package:expense_tracker/core/utils/constants.dart'; import 'package:expense_tracker/features/category/presentation/blocs/category_cubit.dart'; +import 'package:expense_tracker/features/streak/domain/repositories/app_open_repository.dart'; import 'package:expense_tracker/injector.dart'; import 'package:expense_tracker/l10n/l10n.dart'; import 'package:expense_tracker/shared/flash/presentation/blocs/cubit/flash_cubit.dart'; @@ -18,9 +21,22 @@ import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:google_fonts/google_fonts.dart'; -class App extends StatelessWidget { +class App extends StatefulWidget { const App({super.key}); + @override + State createState() => _AppState(); +} + +class _AppState extends State { + @override + void initState() { + super.initState(); + if (getIt.isRegistered()) { + unawaited(getIt().recordOpen()); + } + } + @override Widget build(BuildContext context) { return MultiBlocProvider( diff --git a/lib/core/di/isar_module.dart b/lib/core/di/isar_module.dart index 8a572ff..b16047b 100644 --- a/lib/core/di/isar_module.dart +++ b/lib/core/di/isar_module.dart @@ -1,5 +1,6 @@ import 'package:expense_tracker/core/utils/constants.dart' as c; import 'package:expense_tracker/features/category/data/models/category_model.dart'; +import 'package:expense_tracker/features/streak/data/models/app_open_model.dart'; import 'package:expense_tracker/features/transaction/data/models/transaction_model.dart'; import 'package:injectable/injectable.dart'; import 'package:isar_community/isar.dart'; @@ -15,7 +16,7 @@ abstract class IsarModule { Future get isar async { final dir = await getApplicationDocumentsDirectory(); return Isar.open( - [CategoryModelSchema, TransactionModelSchema], + [CategoryModelSchema, TransactionModelSchema, AppOpenModelSchema], directory: dir.path, ); } diff --git a/lib/core/storages/local_storages.dart b/lib/core/storages/local_storages.dart index 9ad7f59..c30d75e 100644 --- a/lib/core/storages/local_storages.dart +++ b/lib/core/storages/local_storages.dart @@ -4,25 +4,84 @@ import 'package:shared_preferences/shared_preferences.dart'; abstract class LocalStorage { Future getApiKey(); Future setApiKey(String apiKey); + + Future getStreakWindowDays(); + Future setStreakWindowDays(int days); + + Future getStreakMinimumDays(); + Future setStreakMinimumDays(int days); + + Future getAppOpenCadenceDays(); + Future setAppOpenCadenceDays(int days); } @LazySingleton(as: LocalStorage) class LocalStorageImpl implements LocalStorage { const LocalStorageImpl(this._storage); + final SharedPreferences _storage; + static const _apiKeyKey = 'apiKey'; + static const _streakWindowDaysKey = 'streakWindowDays'; + static const _streakMinimumDaysKey = 'streakMinimumDays'; + static const _appOpenCadenceDaysKey = 'appOpenCadenceDays'; + + static const defaultStreakWindowDays = 3; + static const defaultStreakMinimumDays = 3; + static const defaultAppOpenCadenceDays = 7; @override Future getApiKey() { - return Future.value( - _storage.getString(_apiKeyKey), - ); + return Future.value(_storage.getString(_apiKeyKey)); } @override Future setApiKey(String apiKey) async { - await Future.value( - _storage.setString(_apiKeyKey, apiKey), - ); + await _storage.setString(_apiKeyKey, apiKey); + } + + @override + Future getStreakWindowDays() { + final value = _storage.getInt(_streakWindowDaysKey); + if (value == null) { + return Future.value(defaultStreakWindowDays); + } + return Future.value(value < 1 ? 1 : value); + } + + @override + Future setStreakWindowDays(int days) async { + final clamped = days < 1 ? 1 : days; + await _storage.setInt(_streakWindowDaysKey, clamped); + } + + @override + Future getStreakMinimumDays() { + final value = _storage.getInt(_streakMinimumDaysKey); + if (value == null) { + return Future.value(defaultStreakMinimumDays); + } + return Future.value(value < 1 ? 1 : value); + } + + @override + Future setStreakMinimumDays(int days) async { + final clamped = days < 1 ? 1 : days; + await _storage.setInt(_streakMinimumDaysKey, clamped); + } + + @override + Future getAppOpenCadenceDays() { + final value = _storage.getInt(_appOpenCadenceDaysKey); + if (value == null) { + return Future.value(defaultAppOpenCadenceDays); + } + return Future.value(value < 1 ? 1 : value); + } + + @override + Future setAppOpenCadenceDays(int days) async { + final clamped = days < 1 ? 1 : days; + await _storage.setInt(_appOpenCadenceDaysKey, clamped); } } diff --git a/lib/features/category/domain/utils/category_budget_calculator.dart b/lib/features/category/domain/utils/category_budget_calculator.dart new file mode 100644 index 0000000..6d30c76 --- /dev/null +++ b/lib/features/category/domain/utils/category_budget_calculator.dart @@ -0,0 +1,91 @@ +import 'package:expense_tracker/features/category/domain/entities/category.dart'; +import 'package:expense_tracker/features/streak/domain/entities/category_overrun_flag.dart'; +import 'package:expense_tracker/features/transaction/domain/entities/transaction.dart'; +import 'package:expense_tracker/features/transaction/domain/entities/transaction_type.dart'; +import 'package:expense_tracker/shared/domain/entities/value_objects.dart'; + +/// Shared domain helper for category budget aggregation and overrun analysis +/// (R14, R17). +class CategoryBudgetCalculator { + const CategoryBudgetCalculator._(); + + /// Calculates total expected monthly budget across leaf categories of [type]. + /// Matches CategoryState.totalBudget aggregation logic. + static double calculateTotalBudget( + List allCategories, { + CategoryType type = CategoryType.expense, + }) { + return allCategories + .where((c) => c.type == type && c.parentId != null) + .where((c) { + final parent = allCategories.cast().firstWhere( + (parent) => parent?.uuid == c.parentId, + orElse: () => null, + ); + return parent != null && parent.parentId != null; + }) + .map((c) => c.expectedMonthlyBudget) + .fold(0, (sum, val) => sum + val); + } + + /// Identifies categories exceeding their individual budgets for a given set + /// of transactions (R17). + static List calculateOverrunFlags({ + required List allCategories, + required List transactions, + }) { + final expenseTxs = + transactions.where((tx) => tx.type == TransactionType.expense); + + final spendByCategory = {}; + for (final tx in expenseTxs) { + final id = tx.categoryUuid.getOrCrash(); + spendByCategory[id] = + (spendByCategory[id] ?? 0) + tx.amount.getOrCrash(); + } + + final flags = []; + + for (final category in allCategories) { + if (category.type != CategoryType.expense) continue; + if (category.expectedMonthlyBudget <= 0) continue; + + final catId = category.uuid.getOrCrash(); + final descendantUuids = + _findDescendantUuids(category.uuid, allCategories); + + var totalSpent = spendByCategory[catId] ?? 0; + for (final descId in descendantUuids) { + totalSpent += spendByCategory[descId] ?? 0; + } + + if (totalSpent > category.expectedMonthlyBudget) { + flags.add( + CategoryOverrunFlag( + categoryUuid: catId, + categoryName: category.name.getOrCrash(), + budget: category.expectedMonthlyBudget, + spent: totalSpent, + ), + ); + } + } + + return flags; + } + + static Set _findDescendantUuids( + UniqueId parentId, + List allCategories, + ) { + final descendants = {}; + for (final category in allCategories) { + if (category.parentId == parentId) { + descendants + ..add(category.uuid.getOrCrash()) + ..addAll(_findDescendantUuids(category.uuid, allCategories)); + } + } + return descendants; + } +} diff --git a/lib/features/category/presentation/blocs/category_state.dart b/lib/features/category/presentation/blocs/category_state.dart index 457b6d8..42fdf43 100644 --- a/lib/features/category/presentation/blocs/category_state.dart +++ b/lib/features/category/presentation/blocs/category_state.dart @@ -1,5 +1,6 @@ import 'package:equatable/equatable.dart'; import 'package:expense_tracker/features/category/domain/entities/category.dart'; +import 'package:expense_tracker/features/category/domain/utils/category_budget_calculator.dart'; class CategoryState extends Equatable { const CategoryState({ @@ -64,19 +65,10 @@ class CategoryState extends Equatable { return budgets; } - double get totalBudget { - return allCategories - .where((c) => c.type == selectedType && c.parentId != null) - .where((c) { - final parent = allCategories.firstWhere( - (parent) => parent.uuid == c.parentId, - orElse: () => c, - ); - return parent != c && parent.parentId != null; - }) - .map((c) => c.expectedMonthlyBudget) - .fold(0, (sum, val) => sum + val); - } + double get totalBudget => CategoryBudgetCalculator.calculateTotalBudget( + allCategories, + type: selectedType, + ); CategoryState copyWith({ List? allCategories, diff --git a/lib/features/dashboard/domain/entities/dashboard_summary.dart b/lib/features/dashboard/domain/entities/dashboard_summary.dart index bd6ea8f..a8da0ab 100644 --- a/lib/features/dashboard/domain/entities/dashboard_summary.dart +++ b/lib/features/dashboard/domain/entities/dashboard_summary.dart @@ -1,5 +1,6 @@ import 'package:equatable/equatable.dart'; import 'package:expense_tracker/features/dashboard/domain/entities/wealth_trajectory.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak.dart'; import 'package:expense_tracker/features/transaction/domain/entities/transaction.dart'; /// Aggregated summary model for the home dashboard screen. @@ -10,6 +11,7 @@ class DashboardSummary extends Equatable { required this.totalExpense, required this.recentTransactions, required this.wealthTrajectory, + this.streak = const Streak.empty(), }); /// Net balance for the current calendar month (totalIncome - totalExpense). @@ -27,6 +29,9 @@ class DashboardSummary extends Equatable { /// 5-month wealth trajectory timeline and growth metrics. final WealthTrajectory wealthTrajectory; + /// Current tracking streak details. + final Streak streak; + @override List get props => [ totalBalance, @@ -34,5 +39,6 @@ class DashboardSummary extends Equatable { totalExpense, recentTransactions, wealthTrajectory, + streak, ]; } diff --git a/lib/features/dashboard/domain/usecases/get_dashboard_summary_usecase.dart b/lib/features/dashboard/domain/usecases/get_dashboard_summary_usecase.dart index a80de1c..0080300 100644 --- a/lib/features/dashboard/domain/usecases/get_dashboard_summary_usecase.dart +++ b/lib/features/dashboard/domain/usecases/get_dashboard_summary_usecase.dart @@ -3,6 +3,8 @@ import 'package:expense_tracker/core/domain/failures/failure.dart'; import 'package:expense_tracker/core/domain/usecases/use_case.dart'; import 'package:expense_tracker/features/dashboard/domain/entities/dashboard_summary.dart'; import 'package:expense_tracker/features/dashboard/domain/entities/wealth_trajectory.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak.dart'; +import 'package:expense_tracker/features/streak/domain/repositories/streak_repository.dart'; import 'package:expense_tracker/features/transaction/domain/entities/transaction.dart'; import 'package:expense_tracker/features/transaction/domain/entities/transaction_type.dart'; import 'package:expense_tracker/features/transaction/domain/repositories/transaction_repository.dart'; @@ -13,11 +15,16 @@ import 'package:injectable/injectable.dart'; /// - Top 5 most recent transactions /// - Rolling 5-month [WealthTrajectory] (months M-4 through M) with /// month-over-month net worth growth rate and contextual copy. +/// - Active tracking [Streak] derived from the streak system. @lazySingleton class GetDashboardSummaryUseCase extends UseCase { - GetDashboardSummaryUseCase(this._repository); + GetDashboardSummaryUseCase( + this._repository, + this._streakRepository, + ); final TransactionRepository _repository; + final StreakRepository _streakRepository; @override Future> call(NoParams params) async { @@ -29,8 +36,19 @@ class GetDashboardSummaryUseCase extends UseCase { Future> executeWithDate( DateTime now, ) async { - // Fetch all transactions - final result = await _repository.getTransactions(); + final txFuture = _repository.getTransactions(); + final streakFuture = _streakRepository.getStreak(referenceDate: now); + + final result = await txFuture; + if (result.isLeft()) { + return result.fold(Left.new, (_) => throw StateError('Unreachable')); + } + + final streakResult = await streakFuture; + final streak = streakResult.fold( + (_) => const Streak.empty(), + (s) => s, + ); return result.map((allTransactions) { // Sort transactions descending by date (most recent first) @@ -78,6 +96,7 @@ class GetDashboardSummaryUseCase extends UseCase { totalExpense: currentMonthExpense, recentTransactions: recentTransactions, wealthTrajectory: trajectory, + streak: streak, ); }); } diff --git a/lib/features/dashboard/presentation/blocs/dashboard_cubit.dart b/lib/features/dashboard/presentation/blocs/dashboard_cubit.dart index da70121..7613309 100644 --- a/lib/features/dashboard/presentation/blocs/dashboard_cubit.dart +++ b/lib/features/dashboard/presentation/blocs/dashboard_cubit.dart @@ -74,6 +74,7 @@ class DashboardCubit extends Cubit { totalExpense: summary.totalExpense, recentTransactions: summary.recentTransactions, wealthTrajectory: summary.wealthTrajectory, + streak: summary.streak, failureOption: none(), ), ); diff --git a/lib/features/dashboard/presentation/blocs/dashboard_state.dart b/lib/features/dashboard/presentation/blocs/dashboard_state.dart index 2391520..ef9d5b0 100644 --- a/lib/features/dashboard/presentation/blocs/dashboard_state.dart +++ b/lib/features/dashboard/presentation/blocs/dashboard_state.dart @@ -2,6 +2,7 @@ import 'package:dartz/dartz.dart'; import 'package:equatable/equatable.dart'; import 'package:expense_tracker/core/domain/failures/failure.dart'; import 'package:expense_tracker/features/dashboard/domain/entities/wealth_trajectory.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak.dart'; import 'package:expense_tracker/features/transaction/domain/entities/transaction.dart'; class DashboardState extends Equatable { @@ -12,6 +13,7 @@ class DashboardState extends Equatable { this.totalExpense = 0.0, this.recentTransactions = const [], this.wealthTrajectory, + this.streak = const Streak.empty(), this.failureOption = const None(), }); @@ -21,6 +23,7 @@ class DashboardState extends Equatable { final double totalExpense; final List recentTransactions; final WealthTrajectory? wealthTrajectory; + final Streak streak; final Option failureOption; DashboardState copyWith({ @@ -30,6 +33,7 @@ class DashboardState extends Equatable { double? totalExpense, List? recentTransactions, WealthTrajectory? wealthTrajectory, + Streak? streak, Option? failureOption, }) { return DashboardState( @@ -39,6 +43,7 @@ class DashboardState extends Equatable { totalExpense: totalExpense ?? this.totalExpense, recentTransactions: recentTransactions ?? this.recentTransactions, wealthTrajectory: wealthTrajectory ?? this.wealthTrajectory, + streak: streak ?? this.streak, failureOption: failureOption ?? this.failureOption, ); } @@ -51,6 +56,7 @@ class DashboardState extends Equatable { totalExpense, recentTransactions, wealthTrajectory, + streak, failureOption, ]; } diff --git a/lib/features/dashboard/presentation/pages/home_page.dart b/lib/features/dashboard/presentation/pages/home_page.dart index b203607..fa00845 100644 --- a/lib/features/dashboard/presentation/pages/home_page.dart +++ b/lib/features/dashboard/presentation/pages/home_page.dart @@ -3,6 +3,8 @@ import 'package:expense_tracker/features/dashboard/presentation/blocs/dashboard_ import 'package:expense_tracker/features/dashboard/presentation/widgets/record_entry_card.dart'; import 'package:expense_tracker/features/dashboard/presentation/widgets/summary_card.dart'; import 'package:expense_tracker/features/dashboard/presentation/widgets/wealth_trajectory_chart.dart'; +import 'package:expense_tracker/features/streak/presentation/widgets/streak_badge.dart'; +import 'package:expense_tracker/features/streak/presentation/widgets/streak_card.dart'; import 'package:expense_tracker/features/transaction/presentation/widgets/transaction_card.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -69,6 +71,8 @@ class _HomePageState extends State { ), ), const Spacer(), + StreakBadge(streak: state.streak), + const SizedBox(width: 8), IconButton( icon: const Icon( Icons.notifications_none, @@ -113,6 +117,10 @@ class _HomePageState extends State { totalIncome: state.totalIncome, totalExpense: state.totalExpense, ), + if (!state.streak.isEmpty) ...[ + const SizedBox(height: 24), + StreakCard(streak: state.streak), + ], const SizedBox(height: 24), WealthTrajectoryChart( trajectory: state.wealthTrajectory, diff --git a/lib/features/settings/presentation/pages/settings_page.dart b/lib/features/settings/presentation/pages/settings_page.dart index 283f1b1..5a0627c 100644 --- a/lib/features/settings/presentation/pages/settings_page.dart +++ b/lib/features/settings/presentation/pages/settings_page.dart @@ -1,5 +1,6 @@ import 'package:expense_tracker/features/easter_egg/domain/entities/easter_egg_progress.dart'; import 'package:expense_tracker/features/easter_egg/presentation/blocs/easter_egg_cubit.dart'; +import 'package:expense_tracker/features/streak/presentation/widgets/streak_config_sheet.dart'; import 'package:expense_tracker/injector.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -41,13 +42,23 @@ class SettingsPage extends StatelessWidget { subtitle: Text(_versionSubtitle(progress)), onTap: () => context.read().onVersionTapped(), ), - if (progress.unlocked) + if (progress.unlocked) ...[ ListTile( leading: const Icon(Icons.calculate_outlined), title: const Text('Counter'), subtitle: const Text('A long-forgotten classic'), onTap: () => context.push('/counter'), ), + ListTile( + key: const Key('streak_tuning_tile'), + leading: const Icon(Icons.local_fire_department_outlined), + title: const Text('Streak Tuning'), + subtitle: const Text( + 'Configure cadence, windows, and thresholds', + ), + onTap: () => StreakConfigSheet.show(context), + ), + ], ], ); }, diff --git a/lib/features/streak/data/datasources/app_open_local_data_source.dart b/lib/features/streak/data/datasources/app_open_local_data_source.dart new file mode 100644 index 0000000..5b1c29e --- /dev/null +++ b/lib/features/streak/data/datasources/app_open_local_data_source.dart @@ -0,0 +1,70 @@ +import 'package:expense_tracker/features/streak/data/models/app_open_model.dart'; +import 'package:injectable/injectable.dart'; +import 'package:isar_community/isar.dart'; +import 'package:uuid/uuid.dart'; + +abstract class AppOpenLocalDataSource { + Future recordOpen({DateTime? date, String? uuid}); + Future> getOpenDates({ + DateTime? startDate, + DateTime? endDate, + }); + Future> getAppOpens({ + DateTime? startDate, + DateTime? endDate, + }); +} + +@LazySingleton(as: AppOpenLocalDataSource) +class IsarAppOpenLocalDataSource implements AppOpenLocalDataSource { + IsarAppOpenLocalDataSource(this._isar); + + final Isar _isar; + + @override + Future recordOpen({DateTime? date, String? uuid}) async { + final model = AppOpenModel.create( + uuid: uuid ?? const Uuid().v4(), + date: date ?? DateTime.now(), + ); + await _isar.writeTxn(() async { + await _isar.appOpenModels.put(model); + }); + } + + @override + Future> getOpenDates({ + DateTime? startDate, + DateTime? endDate, + }) async { + final opens = await getAppOpens( + startDate: startDate, + endDate: endDate, + ); + return opens.map((e) => e.date).toList(); + } + + @override + Future> getAppOpens({ + DateTime? startDate, + DateTime? endDate, + }) async { + final query = _isar.appOpenModels + .filter() + .optional( + startDate != null && endDate != null, + (q) => q.dateBetween(startDate!, endDate!), + ) + .optional( + startDate != null && endDate == null, + (q) => q.dateGreaterThan(startDate!, include: true), + ) + .optional( + startDate == null && endDate != null, + (q) => q.dateLessThan(endDate!, include: true), + ) + .sortByDate(); + + return query.findAll(); + } +} diff --git a/lib/features/streak/data/models/app_open_model.dart b/lib/features/streak/data/models/app_open_model.dart new file mode 100644 index 0000000..993a1a6 --- /dev/null +++ b/lib/features/streak/data/models/app_open_model.dart @@ -0,0 +1,25 @@ +import 'package:isar_community/isar.dart'; + +part 'app_open_model.g.dart'; + +@collection +class AppOpenModel { + AppOpenModel(); + + factory AppOpenModel.create({ + required String uuid, + required DateTime date, + }) { + return AppOpenModel() + ..uuid = uuid + ..date = date; + } + + Id id = Isar.autoIncrement; + + @Index(unique: true, replace: true) + late String uuid; + + @Index() + late DateTime date; +} diff --git a/lib/features/streak/data/repositories/app_open_repository_impl.dart b/lib/features/streak/data/repositories/app_open_repository_impl.dart new file mode 100644 index 0000000..6b2f9de --- /dev/null +++ b/lib/features/streak/data/repositories/app_open_repository_impl.dart @@ -0,0 +1,61 @@ +import 'package:dartz/dartz.dart'; +import 'package:expense_tracker/core/domain/failures/failure.dart'; +import 'package:expense_tracker/features/streak/data/datasources/app_open_local_data_source.dart'; +import 'package:expense_tracker/features/streak/domain/entities/app_open.dart'; +import 'package:expense_tracker/features/streak/domain/repositories/app_open_repository.dart'; +import 'package:injectable/injectable.dart'; + +@LazySingleton(as: AppOpenRepository) +class AppOpenRepositoryImpl implements AppOpenRepository { + AppOpenRepositoryImpl(this._localDataSource); + + final AppOpenLocalDataSource _localDataSource; + + @override + Future> recordOpen({ + DateTime? date, + String? uuid, + }) async { + try { + await _localDataSource.recordOpen(date: date, uuid: uuid); + return const Right(unit); + } catch (e) { + return Left(Failure.localFailure(message: e.toString())); + } + } + + @override + Future>> getOpenDates({ + DateTime? startDate, + DateTime? endDate, + }) async { + try { + final dates = await _localDataSource.getOpenDates( + startDate: startDate, + endDate: endDate, + ); + return Right(dates); + } catch (e) { + return Left(Failure.localFailure(message: e.toString())); + } + } + + @override + Future>> getAppOpens({ + DateTime? startDate, + DateTime? endDate, + }) async { + try { + final models = await _localDataSource.getAppOpens( + startDate: startDate, + endDate: endDate, + ); + final entities = models + .map((m) => AppOpen(uuid: m.uuid, date: m.date)) + .toList(); + return Right(entities); + } catch (e) { + return Left(Failure.localFailure(message: e.toString())); + } + } +} diff --git a/lib/features/streak/data/repositories/streak_repository_impl.dart b/lib/features/streak/data/repositories/streak_repository_impl.dart new file mode 100644 index 0000000..9f36728 --- /dev/null +++ b/lib/features/streak/data/repositories/streak_repository_impl.dart @@ -0,0 +1,596 @@ +import 'package:dartz/dartz.dart'; +import 'package:expense_tracker/core/domain/failures/failure.dart'; +import 'package:expense_tracker/core/storages/local_storages.dart'; +import 'package:expense_tracker/features/category/domain/entities/category.dart'; +import 'package:expense_tracker/features/category/domain/repositories/category_repository.dart'; +import 'package:expense_tracker/features/category/domain/utils/category_budget_calculator.dart'; +import 'package:expense_tracker/features/streak/domain/entities/category_overrun_flag.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_config.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_period.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_status.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_type.dart'; +import 'package:expense_tracker/features/streak/domain/entities/under_budget_month_status.dart'; +import 'package:expense_tracker/features/streak/domain/repositories/app_open_repository.dart'; +import 'package:expense_tracker/features/streak/domain/repositories/streak_repository.dart'; +import 'package:expense_tracker/features/streak/domain/services/streak_engine.dart'; +import 'package:expense_tracker/features/transaction/domain/entities/transaction.dart'; +import 'package:expense_tracker/features/transaction/domain/entities/transaction_type.dart'; +import 'package:expense_tracker/features/transaction/domain/repositories/transaction_repository.dart'; +import 'package:injectable/injectable.dart'; + +@LazySingleton(as: StreakRepository) +class StreakRepositoryImpl implements StreakRepository { + StreakRepositoryImpl( + this._transactionRepository, + this._localStorage, + this._appOpenRepository, + this._categoryRepository, { + StreakEngine engine = const StreakEngine(), + }) : _engine = engine; + + final TransactionRepository _transactionRepository; + final LocalStorage _localStorage; + final AppOpenRepository _appOpenRepository; + final CategoryRepository _categoryRepository; + final StreakEngine _engine; + + static const defaultLookbackYears = 2; + + @override + Future> getStreak({ + StreakType type = StreakType.tracking, + DateTime? referenceDate, + }) async { + try { + final now = referenceDate ?? DateTime.now(); + final today = StreakDay.fromDateTime(now); + final startDate = DateTime( + now.year - defaultLookbackYears, + now.month, + now.day, + ); + final endDate = DateTime( + now.year, + now.month, + now.day, + 23, + 59, + 59, + 999, + ); + + final window = await _localStorage.getStreakWindowDays(); + final minimum = await _localStorage.getStreakMinimumDays(); + final cadence = await _localStorage.getAppOpenCadenceDays(); + + switch (type) { + case StreakType.tracking: + final config = StreakConfig( + window: window, + minimum: minimum, + cadence: cadence, + ); + final txResult = await _transactionRepository.getTransactions( + startDate: startDate, + endDate: endDate, + ); + + return txResult.fold( + Left.new, + (transactions) { + final qualifyingPeriods = transactions + .map((tx) => StreakDay.fromDateTime(tx.date)) + .toSet(); + + final result = _engine.calculate( + qualifyingPeriods: qualifyingPeriods, + today: today, + config: config, + type: type, + ); + + return Right(result.current); + }, + ); + + case StreakType.noSpend: + final config = StreakConfig( + window: window, + minimum: minimum, + cadence: cadence, + ); + final txResult = await _transactionRepository.getTransactions( + startDate: startDate, + endDate: endDate, + ); + + return txResult.fold( + Left.new, + (transactions) { + if (transactions.isEmpty) { + final result = _engine.calculate( + qualifyingPeriods: const {}, + today: today, + config: config, + type: type, + ); + return Right(result.current); + } + + final earliestTx = transactions + .map((tx) => tx.date) + .reduce((a, b) => a.isBefore(b) ? a : b); + final startDay = StreakDay.fromDateTime(earliestTx); + + final expenseDays = transactions + .where((tx) => tx.type == TransactionType.expense) + .map((tx) => StreakDay.fromDateTime(tx.date)) + .toSet(); + + final qualifyingPeriods = {}; + var current = + DateTime(startDay.year, startDay.month, startDay.day); + final target = DateTime(today.year, today.month, today.day); + + while (!current.isAfter(target)) { + final day = StreakDay.fromDateTime(current); + if (!expenseDays.contains(day)) { + qualifyingPeriods.add(day); + } + current = + DateTime(current.year, current.month, current.day + 1); + } + + final result = _engine.calculate( + qualifyingPeriods: qualifyingPeriods, + today: today, + config: config, + type: type, + ); + + return Right(result.current); + }, + ); + + case StreakType.appOpen: + // Cadence N drives the window for app-open consistency. + final config = StreakConfig( + window: cadence, + minimum: minimum, + cadence: cadence, + ); + final opensResult = await _appOpenRepository.getOpenDates( + startDate: startDate, + endDate: endDate, + ); + + return opensResult.fold( + Left.new, + (openDates) { + final qualifyingPeriods = + openDates.map(StreakDay.fromDateTime).toSet(); + + final result = _engine.calculate( + qualifyingPeriods: qualifyingPeriods, + today: today, + config: config, + type: type, + ); + + return Right(result.current); + }, + ); + + case StreakType.underBudget: + final categoriesEither = + await _categoryRepository.watchCategories().first; + final txResult = await _transactionRepository.getTransactions( + startDate: startDate, + endDate: endDate, + ); + + final failure = categoriesEither.fold((f) => f, (_) => null) ?? + txResult.fold((f) => f, (_) => null); + if (failure != null) { + return Left(failure); + } + + final allCategories = + categoriesEither.getOrElse(() => const []); + final transactions = + txResult.getOrElse(() => const []); + + final totalBudget = CategoryBudgetCalculator.calculateTotalBudget( + allCategories, + ); + + final nowMonth = StreakMonth(now.year, now.month); + final lastCompletedMonth = nowMonth.previous(); + + final expenseByMonth = {}; + for (final tx in transactions) { + if (tx.type == TransactionType.expense) { + final m = StreakMonth(tx.date.year, tx.date.month); + expenseByMonth[m] = + (expenseByMonth[m] ?? 0) + tx.amount.getOrCrash(); + } + } + + // In-progress month status (on-track preview, R14) + final currentMonthExpense = expenseByMonth[nowMonth] ?? 0; + final isCurrentMonthOnTrack = + totalBudget > 0 && currentMonthExpense <= totalBudget; + + // Zero budgets configured -> not qualifying (avoids free streak) + final underBudgetConfig = StreakConfig( + window: 1, + minimum: minimum, + ); + + if (totalBudget <= 0 || transactions.isEmpty) { + final result = _engine.calculate( + qualifyingPeriods: const {}, + today: lastCompletedMonth, + config: underBudgetConfig, + type: type, + ); + return Right( + result.current.copyWith( + isCurrentMonthOnTrack: false, + ), + ); + } + + final earliestTx = transactions + .map((tx) => tx.date) + .reduce((a, b) => a.isBefore(b) ? a : b); + final earliestMonth = + StreakMonth(earliestTx.year, earliestTx.month); + + final qualifyingMonths = {}; + if (earliestMonth.compareTo(lastCompletedMonth) <= 0) { + var m = earliestMonth; + while (m.compareTo(lastCompletedMonth) <= 0) { + final spent = expenseByMonth[m] ?? 0; + if (spent <= totalBudget) { + qualifyingMonths.add(m); + } + m = m.next(); + } + } + + if (!qualifyingMonths.contains(lastCompletedMonth)) { + final result = _engine.calculate( + qualifyingPeriods: qualifyingMonths, + today: lastCompletedMonth, + config: underBudgetConfig, + type: type, + ); + return Right( + Streak( + type: type, + length: 0, + status: qualifyingMonths.isEmpty + ? StreakStatus.none + : StreakStatus.broken, + daysUntilBreak: 0, + nextMilestone: StreakEngine.resolveNextMilestone(0), + consistencyRate: result.current.consistencyRate, + bestLength: result.best.length, + isCurrentMonthOnTrack: isCurrentMonthOnTrack, + ), + ); + } + + final result = _engine.calculate( + qualifyingPeriods: qualifyingMonths, + today: lastCompletedMonth, + config: underBudgetConfig, + type: type, + ); + + return Right( + result.current.copyWith( + isCurrentMonthOnTrack: isCurrentMonthOnTrack, + ), + ); + } + } catch (e) { + return Left(Failure.localFailure(message: e.toString())); + } + } + + @override + Future>> getQualifyingPeriods({ + StreakType type = StreakType.tracking, + DateTime? startDate, + DateTime? endDate, + }) async { + try { + switch (type) { + case StreakType.tracking: + final txResult = await _transactionRepository.getTransactions( + startDate: startDate, + endDate: endDate, + ); + + return txResult.fold( + Left.new, + (transactions) { + final periods = transactions + .map((tx) => StreakDay.fromDateTime(tx.date)) + .toSet() + .toList() + ..sort(); + return Right(periods); + }, + ); + + case StreakType.noSpend: + final txResult = await _transactionRepository.getTransactions( + startDate: startDate, + endDate: endDate, + ); + + return txResult.fold( + Left.new, + (transactions) { + if (transactions.isEmpty && startDate == null) { + return const Right([]); + } + + final start = startDate ?? + transactions + .map((tx) => tx.date) + .reduce((a, b) => a.isBefore(b) ? a : b); + final end = endDate ?? DateTime.now(); + + final startDay = StreakDay.fromDateTime(start); + final endDay = StreakDay.fromDateTime(end); + + final expenseDays = transactions + .where((tx) => tx.type == TransactionType.expense) + .map((tx) => StreakDay.fromDateTime(tx.date)) + .toSet(); + + final periods = []; + var current = + DateTime(startDay.year, startDay.month, startDay.day); + final target = DateTime(endDay.year, endDay.month, endDay.day); + + while (!current.isAfter(target)) { + final day = StreakDay.fromDateTime(current); + if (!expenseDays.contains(day)) { + periods.add(day); + } + current = + DateTime(current.year, current.month, current.day + 1); + } + + periods.sort(); + return Right(periods); + }, + ); + + case StreakType.appOpen: + final opensResult = await _appOpenRepository.getOpenDates( + startDate: startDate, + endDate: endDate, + ); + + return opensResult.fold( + Left.new, + (openDates) { + final periods = openDates + .map(StreakDay.fromDateTime) + .toSet() + .toList() + ..sort(); + return Right(periods); + }, + ); + + case StreakType.underBudget: + final categoriesEither = + await _categoryRepository.watchCategories().first; + final txResult = await _transactionRepository.getTransactions( + startDate: startDate, + endDate: endDate, + ); + + final failure = categoriesEither.fold((f) => f, (_) => null) ?? + txResult.fold((f) => f, (_) => null); + if (failure != null) { + return Left(failure); + } + + final allCategories = + categoriesEither.getOrElse(() => const []); + final transactions = + txResult.getOrElse(() => const []); + + final totalBudget = CategoryBudgetCalculator.calculateTotalBudget( + allCategories, + ); + + if (totalBudget <= 0) { + return const Right([]); + } + + final now = DateTime.now(); + final start = startDate ?? + (transactions.isNotEmpty + ? transactions + .map((tx) => tx.date) + .reduce((a, b) => a.isBefore(b) ? a : b) + : now); + final end = endDate ?? now; + + final startMonth = StreakMonth(start.year, start.month); + final endMonth = StreakMonth(end.year, end.month); + + final expenseByMonth = {}; + for (final tx in transactions) { + if (tx.type == TransactionType.expense) { + final m = StreakMonth(tx.date.year, tx.date.month); + expenseByMonth[m] = + (expenseByMonth[m] ?? 0) + tx.amount.getOrCrash(); + } + } + + final qualifyingMonths = []; + var m = startMonth; + while (m.compareTo(endMonth) <= 0) { + final spent = expenseByMonth[m] ?? 0; + if (spent <= totalBudget) { + qualifyingMonths.add(m); + } + m = m.next(); + } + + qualifyingMonths.sort(); + return Right(qualifyingMonths); + } + } catch (e) { + return Left(Failure.localFailure(message: e.toString())); + } + } + + @override + Future>> getCategoryOverrunFlags({ + DateTime? month, + }) async { + try { + final targetMonth = month ?? DateTime.now(); + final categoriesEither = + await _categoryRepository.watchCategories().first; + final startOfMonth = DateTime(targetMonth.year, targetMonth.month); + final endOfMonth = + DateTime(targetMonth.year, targetMonth.month + 1, 0, 23, 59, 59); + + final txResult = await _transactionRepository.getTransactions( + startDate: startOfMonth, + endDate: endOfMonth, + ); + + final failure = categoriesEither.fold((f) => f, (_) => null) ?? + txResult.fold((f) => f, (_) => null); + if (failure != null) { + return Left(failure); + } + + final allCategories = + categoriesEither.getOrElse(() => const []); + final transactions = + txResult.getOrElse(() => const []); + + final flags = CategoryBudgetCalculator.calculateOverrunFlags( + allCategories: allCategories, + transactions: transactions, + ); + + return Right(flags); + } catch (e) { + return Left(Failure.localFailure(message: e.toString())); + } + } + + @override + Future> isCurrentMonthOnTrack({ + DateTime? referenceDate, + }) async { + try { + final now = referenceDate ?? DateTime.now(); + final categoriesEither = + await _categoryRepository.watchCategories().first; + final startOfMonth = DateTime(now.year, now.month); + final endOfMonth = DateTime(now.year, now.month + 1, 0, 23, 59, 59); + + final txResult = await _transactionRepository.getTransactions( + startDate: startOfMonth, + endDate: endOfMonth, + ); + + final failure = categoriesEither.fold((f) => f, (_) => null) ?? + txResult.fold((f) => f, (_) => null); + if (failure != null) { + return Left(failure); + } + + final allCategories = + categoriesEither.getOrElse(() => const []); + final transactions = + txResult.getOrElse(() => const []); + + final totalBudget = CategoryBudgetCalculator.calculateTotalBudget( + allCategories, + ); + + if (totalBudget <= 0) return const Right(false); + + final currentSpent = transactions + .where((tx) => tx.type == TransactionType.expense) + .fold(0, (acc, tx) => acc + tx.amount.getOrCrash()); + + return Right(currentSpent <= totalBudget); + } catch (e) { + return Left(Failure.localFailure(message: e.toString())); + } + } + + @override + Future> getUnderBudgetStatus({ + DateTime? month, + }) async { + try { + final targetMonth = month ?? DateTime.now(); + final categoriesEither = + await _categoryRepository.watchCategories().first; + final startOfMonth = DateTime(targetMonth.year, targetMonth.month); + final endOfMonth = + DateTime(targetMonth.year, targetMonth.month + 1, 0, 23, 59, 59); + + final txResult = await _transactionRepository.getTransactions( + startDate: startOfMonth, + endDate: endOfMonth, + ); + + final failure = categoriesEither.fold((f) => f, (_) => null) ?? + txResult.fold((f) => f, (_) => null); + if (failure != null) { + return Left(failure); + } + + final allCategories = + categoriesEither.getOrElse(() => const []); + final transactions = + txResult.getOrElse(() => const []); + + final totalBudget = CategoryBudgetCalculator.calculateTotalBudget( + allCategories, + ); + + final currentSpent = transactions + .where((tx) => tx.type == TransactionType.expense) + .fold(0, (acc, tx) => acc + tx.amount.getOrCrash()); + + final flags = CategoryBudgetCalculator.calculateOverrunFlags( + allCategories: allCategories, + transactions: transactions, + ); + + return Right( + UnderBudgetMonthStatus( + month: targetMonth, + totalBudget: totalBudget, + totalSpent: currentSpent, + isOnTrack: totalBudget > 0 && currentSpent <= totalBudget, + overrunFlags: flags, + ), + ); + } catch (e) { + return Left(Failure.localFailure(message: e.toString())); + } + } +} diff --git a/lib/features/streak/domain/entities/app_open.dart b/lib/features/streak/domain/entities/app_open.dart new file mode 100644 index 0000000..0d104ac --- /dev/null +++ b/lib/features/streak/domain/entities/app_open.dart @@ -0,0 +1,14 @@ +import 'package:equatable/equatable.dart'; + +class AppOpen extends Equatable { + const AppOpen({ + required this.uuid, + required this.date, + }); + + final String uuid; + final DateTime date; + + @override + List get props => [uuid, date]; +} diff --git a/lib/features/streak/domain/entities/category_overrun_flag.dart b/lib/features/streak/domain/entities/category_overrun_flag.dart new file mode 100644 index 0000000..1ac96a0 --- /dev/null +++ b/lib/features/streak/domain/entities/category_overrun_flag.dart @@ -0,0 +1,23 @@ +import 'package:equatable/equatable.dart'; + +/// Represents a category that exceeded its expected monthly budget in a given +/// month (R17). +class CategoryOverrunFlag extends Equatable { + const CategoryOverrunFlag({ + required this.categoryUuid, + required this.categoryName, + required this.budget, + required this.spent, + }); + + final String categoryUuid; + final String categoryName; + final double budget; + final double spent; + + /// Amount spent over the allocated budget. + double get overrunAmount => (spent - budget).clamp(0, double.infinity); + + @override + List get props => [categoryUuid, categoryName, budget, spent]; +} diff --git a/lib/features/streak/domain/entities/streak.dart b/lib/features/streak/domain/entities/streak.dart new file mode 100644 index 0000000..b6f59f2 --- /dev/null +++ b/lib/features/streak/domain/entities/streak.dart @@ -0,0 +1,91 @@ +import 'package:equatable/equatable.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_status.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_type.dart'; + +/// Represents a derived streak state for a given [StreakType]. +class Streak extends Equatable { + const Streak({ + required this.type, + required this.length, + required this.status, + required this.daysUntilBreak, + required this.nextMilestone, + required this.consistencyRate, + this.bestLength = 0, + this.isCurrentMonthOnTrack, + }); + + /// Factory constructor for an empty streak when no records exist. + const Streak.empty({ + this.type = StreakType.tracking, + }) : length = 0, + status = StreakStatus.none, + daysUntilBreak = 0, + nextMilestone = 7, + consistencyRate = 0.0, + bestLength = 0, + isCurrentMonthOnTrack = null; + + /// The streak category. + final StreakType type; + + /// Length of the current run counting inclusive calendar periods. + final int length; + + /// Current status: none, warmingUp, active, atRisk, or broken. + final StreakStatus status; + + /// Number of days remaining before the current run breaks unless extended. + final int daysUntilBreak; + + /// The upcoming milestone threshold (e.g. 7, 14, 21, 30, ...). + final int nextMilestone; + + /// Consistency rate for the current period container (0.0 to 1.0). + final double consistencyRate; + + /// The longest historical streak length for this type. + final int bestLength; + + /// Preview indicator whether the in-progress month is currently within budget + /// (R14). + final bool? isCurrentMonthOnTrack; + + /// Whether this streak has no activity. + bool get isEmpty => length == 0 && status == StreakStatus.none; + + Streak copyWith({ + StreakType? type, + int? length, + StreakStatus? status, + int? daysUntilBreak, + int? nextMilestone, + double? consistencyRate, + int? bestLength, + bool? isCurrentMonthOnTrack, + }) { + return Streak( + type: type ?? this.type, + length: length ?? this.length, + status: status ?? this.status, + daysUntilBreak: daysUntilBreak ?? this.daysUntilBreak, + nextMilestone: nextMilestone ?? this.nextMilestone, + consistencyRate: consistencyRate ?? this.consistencyRate, + bestLength: bestLength ?? this.bestLength, + isCurrentMonthOnTrack: + isCurrentMonthOnTrack ?? this.isCurrentMonthOnTrack, + ); + } + + @override + List get props => [ + type, + length, + status, + daysUntilBreak, + nextMilestone, + consistencyRate, + bestLength, + isCurrentMonthOnTrack, + ]; +} diff --git a/lib/features/streak/domain/entities/streak_config.dart b/lib/features/streak/domain/entities/streak_config.dart new file mode 100644 index 0000000..d915632 --- /dev/null +++ b/lib/features/streak/domain/entities/streak_config.dart @@ -0,0 +1,46 @@ +import 'package:equatable/equatable.dart'; + +/// Configuration parameters for streak evaluation. +class StreakConfig extends Equatable { + const StreakConfig({ + this.window = defaultWindow, + this.minimum = defaultMinimum, + this.cadence = defaultCadence, + }) : assert(window >= 1, 'Window must be at least 1'), + assert(minimum >= 1, 'Minimum must be at least 1'), + assert(cadence >= 1, 'Cadence must be at least 1'); + + /// Default window in days (3 days). + static const int defaultWindow = 3; + + /// Default minimum days to celebrate an active streak (3 days). + static const int defaultMinimum = 3; + + /// Default app-open cadence in days (7 days). + static const int defaultCadence = 7; + + /// The maximum gap allowed between consecutive qualifying periods + /// without breaking the run. + final int window; + + /// The minimum span of periods required to celebrate the streak as active. + final int minimum; + + /// The cadence used for app-open consistency streaks. + final int cadence; + + StreakConfig copyWith({ + int? window, + int? minimum, + int? cadence, + }) { + return StreakConfig( + window: window ?? this.window, + minimum: minimum ?? this.minimum, + cadence: cadence ?? this.cadence, + ); + } + + @override + List get props => [window, minimum, cadence]; +} diff --git a/lib/features/streak/domain/entities/streak_period.dart b/lib/features/streak/domain/entities/streak_period.dart new file mode 100644 index 0000000..7995a24 --- /dev/null +++ b/lib/features/streak/domain/entities/streak_period.dart @@ -0,0 +1,167 @@ +import 'package:equatable/equatable.dart'; + +/// Abstraction for a discrete time period in streak computations. +/// +/// Can represent daily periods ([StreakDay]) or monthly periods +/// ([StreakMonth]). +sealed class StreakPeriod extends Equatable + implements Comparable { + const StreakPeriod(); + + factory StreakPeriod.day(DateTime date) => StreakDay.fromDateTime(date); + factory StreakPeriod.month(int year, int month) => StreakMonth(year, month); + + /// Number of periods from [other] to this period (`this - other`). + /// + /// Positive if this period is after [other], zero if identical, + /// negative if before. + int differenceInPeriods(StreakPeriod other); + + /// The subsequent period. + StreakPeriod next(); + + /// The previous period. + StreakPeriod previous(); + + /// Number of elapsed periods within the enclosing container + /// (e.g. Day of the month for [StreakDay], month of the year + /// for [StreakMonth]). + int get elapsedInContainer; + + /// Whether [other] belongs to the same enclosing container + /// (e.g. same year and month for [StreakDay], same year for [StreakMonth]). + bool isInSameContainer(StreakPeriod other); +} + +/// A calendar day period, stripped of time and timezone offsets. +class StreakDay extends StreakPeriod { + StreakDay(DateTime date) + : year = date.year, + month = date.month, + day = date.day; + + StreakDay.fromDateTime(DateTime date) + : year = date.year, + month = date.month, + day = date.day; + + const StreakDay.fromYmd(this.year, this.month, this.day); + + final int year; + final int month; + final int day; + + @override + int differenceInPeriods(StreakPeriod other) { + if (other is! StreakDay) { + throw ArgumentError( + 'Cannot compare StreakDay with ${other.runtimeType}', + ); + } + // Using UTC dates ensures exact 24-hour difference per calendar day, + // completely immune to local DST transitions. + final d1 = DateTime.utc(year, month, day); + final d2 = DateTime.utc(other.year, other.month, other.day); + return d1.difference(d2).inDays; + } + + @override + StreakDay next() { + final nextDate = DateTime.utc(year, month, day + 1); + return StreakDay.fromYmd(nextDate.year, nextDate.month, nextDate.day); + } + + @override + StreakDay previous() { + final prevDate = DateTime.utc(year, month, day - 1); + return StreakDay.fromYmd(prevDate.year, prevDate.month, prevDate.day); + } + + @override + int get elapsedInContainer => day; + + @override + bool isInSameContainer(StreakPeriod other) { + if (other is! StreakDay) return false; + return other.year == year && other.month == month; + } + + @override + int compareTo(StreakPeriod other) { + if (other is! StreakDay) { + throw ArgumentError( + 'Cannot compare StreakDay with ${other.runtimeType}', + ); + } + if (year != other.year) return year.compareTo(other.year); + if (month != other.month) return month.compareTo(other.month); + return day.compareTo(other.day); + } + + DateTime toDateTime() => DateTime(year, month, day); + + @override + List get props => [year, month, day]; +} + +/// A calendar month period, identified by year and month. +class StreakMonth extends StreakPeriod { + const StreakMonth(this.year, this.month) + : assert(month >= 1 && month <= 12, 'Month must be between 1 and 12'); + + StreakMonth.fromDateTime(DateTime date) + : year = date.year, + month = date.month; + + final int year; + final int month; + + @override + int differenceInPeriods(StreakPeriod other) { + if (other is! StreakMonth) { + throw ArgumentError( + 'Cannot compare StreakMonth with ${other.runtimeType}', + ); + } + return (year * 12 + month) - (other.year * 12 + other.month); + } + + @override + StreakMonth next() { + if (month == 12) { + return StreakMonth(year + 1, 1); + } + return StreakMonth(year, month + 1); + } + + @override + StreakMonth previous() { + if (month == 1) { + return StreakMonth(year - 1, 12); + } + return StreakMonth(year, month - 1); + } + + @override + int get elapsedInContainer => month; + + @override + bool isInSameContainer(StreakPeriod other) { + if (other is! StreakMonth) return false; + return other.year == year; + } + + @override + int compareTo(StreakPeriod other) { + if (other is! StreakMonth) { + throw ArgumentError( + 'Cannot compare StreakMonth with ${other.runtimeType}', + ); + } + if (year != other.year) return year.compareTo(other.year); + return month.compareTo(other.month); + } + + @override + List get props => [year, month]; +} diff --git a/lib/features/streak/domain/entities/streak_status.dart b/lib/features/streak/domain/entities/streak_status.dart new file mode 100644 index 0000000..0d311c3 --- /dev/null +++ b/lib/features/streak/domain/entities/streak_status.dart @@ -0,0 +1,33 @@ +/// The lifecycle status of a streak run. +enum StreakStatus { + /// No records exist or no run has started yet. + none, + + /// Run length is below the configured minimum threshold (default 3). + /// + /// Per R5, celebration is withheld and UI frames this as warming up. + warmingUp, + + /// Run length is at or above the minimum and the run is healthy. + active, + + /// Run length is at or above the minimum, today is unqualified, + /// and today is the last day covered by the window (window closes tomorrow). + atRisk, + + /// The gap between the last qualifying period and the evaluation date + /// exceeded the window, terminating the run. + broken; + + bool get isNone => this == StreakStatus.none; + bool get isWarmingUp => this == StreakStatus.warmingUp; + bool get isActive => this == StreakStatus.active; + bool get isAtRisk => this == StreakStatus.atRisk; + bool get isBroken => this == StreakStatus.broken; + + /// Whether the streak is in a celebratory state (active or at risk). + /// + /// Below the minimum threshold, the UI frames as warming up (R5). + bool get isCelebratory => + this == StreakStatus.active || this == StreakStatus.atRisk; +} diff --git a/lib/features/streak/domain/entities/streak_type.dart b/lib/features/streak/domain/entities/streak_type.dart new file mode 100644 index 0000000..c6d266e --- /dev/null +++ b/lib/features/streak/domain/entities/streak_type.dart @@ -0,0 +1,51 @@ +/// Defines the streak types supported across all streak phases. +enum StreakType { + /// Tracking streak: qualifies when at least one transaction is recorded. + tracking, + + /// No-spend streak: qualifies when no expenses are recorded on that day. + noSpend, + + /// App-open consistency streak: qualifies when the user opens the app + /// within cadence. + appOpen, + + /// Under-budget streak: monthly discipline streak when expenses are within + /// budget. + underBudget; + + /// Active streak types available across all phases (Phase 1-3). + static const activeTypes = [ + StreakType.tracking, + StreakType.noSpend, + StreakType.appOpen, + StreakType.underBudget, + ]; + + /// Short display label for chips and tabs. + String get shortLabel { + switch (this) { + case StreakType.tracking: + return 'Tracking'; + case StreakType.noSpend: + return 'No-Spend'; + case StreakType.appOpen: + return 'App Open'; + case StreakType.underBudget: + return 'Budget'; + } + } + + String get displayName { + switch (this) { + case StreakType.tracking: + return 'Tracking Streak'; + case StreakType.noSpend: + return 'No-Spend Streak'; + case StreakType.appOpen: + return 'App-Open Streak'; + case StreakType.underBudget: + return 'Under-Budget Streak'; + } + } +} diff --git a/lib/features/streak/domain/entities/under_budget_month_status.dart b/lib/features/streak/domain/entities/under_budget_month_status.dart new file mode 100644 index 0000000..01d4a85 --- /dev/null +++ b/lib/features/streak/domain/entities/under_budget_month_status.dart @@ -0,0 +1,35 @@ +import 'package:equatable/equatable.dart'; +import 'package:expense_tracker/features/streak/domain/entities/category_overrun_flag.dart'; + +/// Encapsulates monthly budget performance for under-budget streak calculations +/// (R14, R17). +class UnderBudgetMonthStatus extends Equatable { + const UnderBudgetMonthStatus({ + required this.month, + required this.totalBudget, + required this.totalSpent, + required this.isOnTrack, + this.overrunFlags = const [], + }); + + final DateTime month; + final double totalBudget; + final double totalSpent; + final bool isOnTrack; + final List overrunFlags; + + bool get hasBudget => totalBudget > 0; + double get remainingBudget => + (totalBudget - totalSpent).clamp(0, double.infinity); + double get overrunBudget => + (totalSpent - totalBudget).clamp(0, double.infinity); + + @override + List get props => [ + month, + totalBudget, + totalSpent, + isOnTrack, + overrunFlags, + ]; +} diff --git a/lib/features/streak/domain/repositories/app_open_repository.dart b/lib/features/streak/domain/repositories/app_open_repository.dart new file mode 100644 index 0000000..8208508 --- /dev/null +++ b/lib/features/streak/domain/repositories/app_open_repository.dart @@ -0,0 +1,20 @@ +import 'package:dartz/dartz.dart'; +import 'package:expense_tracker/core/domain/failures/failure.dart'; +import 'package:expense_tracker/features/streak/domain/entities/app_open.dart'; + +abstract class AppOpenRepository { + Future> recordOpen({ + DateTime? date, + String? uuid, + }); + + Future>> getOpenDates({ + DateTime? startDate, + DateTime? endDate, + }); + + Future>> getAppOpens({ + DateTime? startDate, + DateTime? endDate, + }); +} diff --git a/lib/features/streak/domain/repositories/streak_repository.dart b/lib/features/streak/domain/repositories/streak_repository.dart new file mode 100644 index 0000000..edd02f1 --- /dev/null +++ b/lib/features/streak/domain/repositories/streak_repository.dart @@ -0,0 +1,43 @@ +import 'package:dartz/dartz.dart'; +import 'package:expense_tracker/core/domain/failures/failure.dart'; +import 'package:expense_tracker/features/streak/domain/entities/category_overrun_flag.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_period.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_type.dart'; +import 'package:expense_tracker/features/streak/domain/entities/under_budget_month_status.dart'; + +/// Contract for calculating and querying streak metrics and qualifying periods. +abstract class StreakRepository { + /// Calculates the current and best streak for [type] as of [referenceDate]. + /// + /// If [referenceDate] is omitted, evaluation defaults to current date/time. + Future> getStreak({ + StreakType type = StreakType.tracking, + DateTime? referenceDate, + }); + + /// Retrieves all discrete qualifying periods for [type] within the optional + /// range [startDate]..[endDate]. + Future>> getQualifyingPeriods({ + StreakType type = StreakType.tracking, + DateTime? startDate, + DateTime? endDate, + }); + + /// Retrieves category overrun flags for [month] (defaulting to current + /// month). + Future>> getCategoryOverrunFlags({ + DateTime? month, + }); + + /// Evaluates whether the current in-progress month is on track (spent <= + /// budget). + Future> isCurrentMonthOnTrack({ + DateTime? referenceDate, + }); + + /// Retrieves comprehensive monthly budget status and flags for [month]. + Future> getUnderBudgetStatus({ + DateTime? month, + }); +} diff --git a/lib/features/streak/domain/services/streak_engine.dart b/lib/features/streak/domain/services/streak_engine.dart new file mode 100644 index 0000000..626ab35 --- /dev/null +++ b/lib/features/streak/domain/services/streak_engine.dart @@ -0,0 +1,184 @@ +import 'package:equatable/equatable.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_config.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_period.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_status.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_type.dart'; +import 'package:injectable/injectable.dart'; + +/// Calculation result containing both the current streak run and the best +/// historical run. +class StreakEngineResult extends Equatable { + const StreakEngineResult({ + required this.current, + required this.best, + }); + + final Streak current; + final Streak best; + + @override + List get props => [current, best]; +} + +/// Pure domain service calculating streak metrics over an ordered sequence of +/// [StreakPeriod]s. +/// +/// Period-abstract from day one: works identically for daily and monthly +/// periods. +/// Completely free of Flutter and I/O dependencies. +@lazySingleton +class StreakEngine { + const StreakEngine(); + + /// Shared milestone progression ladder per Key Technical Decisions. + static const List milestoneLadder = [7, 14, 21, 30, 60, 90, 180, 365]; + + /// Resolves the next milestone threshold strictly greater than [length]. + static int resolveNextMilestone(int length) { + for (final milestone in milestoneLadder) { + if (milestone > length) { + return milestone; + } + } + // For values >= 365, return the next full-year threshold. + final years = (length ~/ 365) + 1; + return years * 365; + } + + /// Calculates the current and best streak runs. + /// + /// - [qualifyingPeriods]: Periods that qualify for this streak. + /// - [today]: The reference evaluation period (e.g. today's date). + /// - [config]: Tuning parameters (window, minimum). + /// - [type]: The streak category being computed. + StreakEngineResult calculate({ + required Iterable qualifyingPeriods, + required StreakPeriod today, + required StreakConfig config, + StreakType type = StreakType.tracking, + }) { + // 1. Filter out future dates, deduplicate, and sort ascending. + final validPeriods = qualifyingPeriods + .where((p) => p.compareTo(today) <= 0) + .toSet() + .toList() + ..sort(); + + // 2. Consistency rate calculation: qualifying in container / elapsed. + final qualifyingInContainer = validPeriods + .where((p) => today.isInSameContainer(p) && p.compareTo(today) <= 0) + .length; + final elapsed = today.elapsedInContainer; + final consistencyRate = + elapsed > 0 ? (qualifyingInContainer / elapsed) : 0.0; + + // 3. Handle empty records. + if (validPeriods.isEmpty) { + final emptyStreak = Streak.empty(type: type).copyWith( + consistencyRate: consistencyRate, + ); + return StreakEngineResult(current: emptyStreak, best: emptyStreak); + } + + // 4. Partition periods into contiguous runs separated by gaps <= window. + final runs = >[]; + var currentRun = [validPeriods.first]; + + for (var i = 1; i < validPeriods.length; i++) { + final prev = validPeriods[i - 1]; + final curr = validPeriods[i]; + final diff = curr.differenceInPeriods(prev); + + if (diff <= config.window) { + currentRun.add(curr); + } else { + runs.add(currentRun); + currentRun = [curr]; + } + } + runs.add(currentRun); + + // 5. Determine the best historical run. + var maxRunLength = 0; + var bestRun = runs.first; + + for (final run in runs) { + final runLength = run.last.differenceInPeriods(run.first) + 1; + if (runLength >= maxRunLength) { + maxRunLength = runLength; + bestRun = run; + } + } + + // 6. Evaluate the current run (the last run in the sequence). + final lastRun = runs.last; + final pLast = lastRun.last; + final pFirst = lastRun.first; + final delta = today.differenceInPeriods(pLast); + + final Streak currentStreak; + + if (delta > config.window) { + // The gap between the latest qualifying period and today exceeds window. + // The previous run has ended; there is no active run today. + currentStreak = Streak( + type: type, + length: 0, + status: StreakStatus.broken, + daysUntilBreak: 0, + nextMilestone: resolveNextMilestone(0), + consistencyRate: consistencyRate, + bestLength: maxRunLength, + ); + } else { + // The run is still active or alive within the grace window. + final currentLength = pLast.differenceInPeriods(pFirst) + 1; + final isTodayQualified = delta == 0; + + final int daysUntilBreak; + if (isTodayQualified) { + daysUntilBreak = config.window; + } else { + daysUntilBreak = config.window - delta + 1; + } + + final StreakStatus status; + if (currentLength < config.minimum) { + status = StreakStatus.warmingUp; + } else { + if (!isTodayQualified && delta == config.window) { + status = StreakStatus.atRisk; + } else { + status = StreakStatus.active; + } + } + + currentStreak = Streak( + type: type, + length: currentLength, + status: status, + daysUntilBreak: daysUntilBreak, + nextMilestone: resolveNextMilestone(currentLength), + consistencyRate: consistencyRate, + bestLength: + maxRunLength > currentLength ? maxRunLength : currentLength, + ); + } + + final bestRunLength = bestRun.last.differenceInPeriods(bestRun.first) + 1; + final bestStreak = Streak( + type: type, + length: bestRunLength, + status: bestRunLength >= config.minimum + ? StreakStatus.active + : (bestRunLength > 0 ? StreakStatus.warmingUp : StreakStatus.none), + daysUntilBreak: 0, + nextMilestone: resolveNextMilestone(bestRunLength), + consistencyRate: consistencyRate, + bestLength: bestRunLength, + ); + + return StreakEngineResult(current: currentStreak, best: bestStreak); + } +} diff --git a/lib/features/streak/domain/usecases/get_qualifying_periods_use_case.dart b/lib/features/streak/domain/usecases/get_qualifying_periods_use_case.dart new file mode 100644 index 0000000..c78f99a --- /dev/null +++ b/lib/features/streak/domain/usecases/get_qualifying_periods_use_case.dart @@ -0,0 +1,42 @@ +import 'package:dartz/dartz.dart'; +import 'package:equatable/equatable.dart'; +import 'package:expense_tracker/core/domain/failures/failure.dart'; +import 'package:expense_tracker/core/domain/usecases/use_case.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_period.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_type.dart'; +import 'package:expense_tracker/features/streak/domain/repositories/streak_repository.dart'; +import 'package:injectable/injectable.dart'; + +@lazySingleton +class GetQualifyingPeriodsUseCase + extends UseCase, GetQualifyingPeriodsParams> { + const GetQualifyingPeriodsUseCase(this._repository); + + final StreakRepository _repository; + + @override + Future>> call( + GetQualifyingPeriodsParams params, + ) { + return _repository.getQualifyingPeriods( + type: params.type, + startDate: params.startDate, + endDate: params.endDate, + ); + } +} + +class GetQualifyingPeriodsParams extends Equatable { + const GetQualifyingPeriodsParams({ + this.type = StreakType.tracking, + this.startDate, + this.endDate, + }); + + final StreakType type; + final DateTime? startDate; + final DateTime? endDate; + + @override + List get props => [type, startDate, endDate]; +} diff --git a/lib/features/streak/domain/usecases/get_streaks_use_case.dart b/lib/features/streak/domain/usecases/get_streaks_use_case.dart new file mode 100644 index 0000000..72ce6bb --- /dev/null +++ b/lib/features/streak/domain/usecases/get_streaks_use_case.dart @@ -0,0 +1,36 @@ +import 'package:dartz/dartz.dart'; +import 'package:equatable/equatable.dart'; +import 'package:expense_tracker/core/domain/failures/failure.dart'; +import 'package:expense_tracker/core/domain/usecases/use_case.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_type.dart'; +import 'package:expense_tracker/features/streak/domain/repositories/streak_repository.dart'; +import 'package:injectable/injectable.dart'; + +@lazySingleton +class GetStreaksUseCase extends UseCase { + const GetStreaksUseCase(this._repository); + + final StreakRepository _repository; + + @override + Future> call(GetStreaksParams params) { + return _repository.getStreak( + type: params.type, + referenceDate: params.referenceDate, + ); + } +} + +class GetStreaksParams extends Equatable { + const GetStreaksParams({ + this.type = StreakType.tracking, + this.referenceDate, + }); + + final StreakType type; + final DateTime? referenceDate; + + @override + List get props => [type, referenceDate]; +} diff --git a/lib/features/streak/domain/usecases/get_under_budget_status_use_case.dart b/lib/features/streak/domain/usecases/get_under_budget_status_use_case.dart new file mode 100644 index 0000000..a7f98e2 --- /dev/null +++ b/lib/features/streak/domain/usecases/get_under_budget_status_use_case.dart @@ -0,0 +1,31 @@ +import 'package:dartz/dartz.dart'; +import 'package:equatable/equatable.dart'; +import 'package:expense_tracker/core/domain/failures/failure.dart'; +import 'package:expense_tracker/core/domain/usecases/use_case.dart'; +import 'package:expense_tracker/features/streak/domain/entities/under_budget_month_status.dart'; +import 'package:expense_tracker/features/streak/domain/repositories/streak_repository.dart'; +import 'package:injectable/injectable.dart'; + +class GetUnderBudgetStatusParams extends Equatable { + const GetUnderBudgetStatusParams({this.month}); + + final DateTime? month; + + @override + List get props => [month]; +} + +@lazySingleton +class GetUnderBudgetStatusUseCase + extends UseCase { + const GetUnderBudgetStatusUseCase(this._streakRepository); + + final StreakRepository _streakRepository; + + @override + Future> call( + GetUnderBudgetStatusParams params, + ) { + return _streakRepository.getUnderBudgetStatus(month: params.month); + } +} diff --git a/lib/features/streak/presentation/blocs/streak_config_cubit.dart b/lib/features/streak/presentation/blocs/streak_config_cubit.dart new file mode 100644 index 0000000..546000f --- /dev/null +++ b/lib/features/streak/presentation/blocs/streak_config_cubit.dart @@ -0,0 +1,81 @@ +import 'package:expense_tracker/core/storages/local_storages.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_config.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_type.dart'; +import 'package:expense_tracker/features/streak/presentation/blocs/streak_config_state.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:injectable/injectable.dart'; + +/// Cubit managing user-customizable streak parameters. +/// +/// Persists all values to [LocalStorage]. Clamps input values to >= 1. +@injectable +class StreakConfigCubit extends Cubit { + StreakConfigCubit(this._localStorage) : super(const StreakConfigState()); + + final LocalStorage _localStorage; + + /// Loads stored configuration and selects the [initialType] section. + Future load({StreakType initialType = StreakType.tracking}) async { + emit(state.copyWith(isLoading: true, selectedType: initialType)); + + final window = await _localStorage.getStreakWindowDays(); + final minimum = await _localStorage.getStreakMinimumDays(); + final cadence = await _localStorage.getAppOpenCadenceDays(); + + emit( + state.copyWith( + isLoading: false, + selectedType: initialType, + config: StreakConfig( + window: window, + minimum: minimum, + cadence: cadence, + ), + ), + ); + } + + /// Switches the active section tab in the sheet. + void selectType(StreakType type) { + emit(state.copyWith(selectedType: type)); + } + + /// Sets the tracking window in days (clamped >= 1) and persists. + Future updateWindow(int days) async { + final clamped = days < 1 ? 1 : days; + await _localStorage.setStreakWindowDays(clamped); + emit(state.copyWith(config: state.config.copyWith(window: clamped))); + } + + /// Increments the tracking window by 1 day. + Future incrementWindow() => updateWindow(state.config.window + 1); + + /// Decrements the tracking window by 1 day (minimum 1). + Future decrementWindow() => updateWindow(state.config.window - 1); + + /// Sets the minimum activation threshold in days (clamped >= 1) and persists. + Future updateMinimum(int days) async { + final clamped = days < 1 ? 1 : days; + await _localStorage.setStreakMinimumDays(clamped); + emit(state.copyWith(config: state.config.copyWith(minimum: clamped))); + } + + /// Increments the minimum activation threshold by 1 day. + Future incrementMinimum() => updateMinimum(state.config.minimum + 1); + + /// Decrements the minimum activation threshold by 1 day (minimum 1). + Future decrementMinimum() => updateMinimum(state.config.minimum - 1); + + /// Sets the app-open cadence in days (clamped >= 1) and persists. + Future updateCadence(int days) async { + final clamped = days < 1 ? 1 : days; + await _localStorage.setAppOpenCadenceDays(clamped); + emit(state.copyWith(config: state.config.copyWith(cadence: clamped))); + } + + /// Increments the app-open cadence by 1 day. + Future incrementCadence() => updateCadence(state.config.cadence + 1); + + /// Decrements the app-open cadence by 1 day (minimum 1). + Future decrementCadence() => updateCadence(state.config.cadence - 1); +} diff --git a/lib/features/streak/presentation/blocs/streak_config_state.dart b/lib/features/streak/presentation/blocs/streak_config_state.dart new file mode 100644 index 0000000..4ae50f3 --- /dev/null +++ b/lib/features/streak/presentation/blocs/streak_config_state.dart @@ -0,0 +1,36 @@ +import 'package:equatable/equatable.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_config.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_type.dart'; + +/// State of the streak configuration tuning surface. +class StreakConfigState extends Equatable { + const StreakConfigState({ + this.isLoading = true, + this.config = const StreakConfig(), + this.selectedType = StreakType.tracking, + }); + + /// Whether config values are being read from storage. + final bool isLoading; + + /// Current tuning parameters. + final StreakConfig config; + + /// Currently focused streak section tab. + final StreakType selectedType; + + StreakConfigState copyWith({ + bool? isLoading, + StreakConfig? config, + StreakType? selectedType, + }) { + return StreakConfigState( + isLoading: isLoading ?? this.isLoading, + config: config ?? this.config, + selectedType: selectedType ?? this.selectedType, + ); + } + + @override + List get props => [isLoading, config, selectedType]; +} diff --git a/lib/features/streak/presentation/blocs/streak_cubit.dart b/lib/features/streak/presentation/blocs/streak_cubit.dart new file mode 100644 index 0000000..5ab11b6 --- /dev/null +++ b/lib/features/streak/presentation/blocs/streak_cubit.dart @@ -0,0 +1,251 @@ +import 'dart:async'; + +import 'package:dartz/dartz.dart'; +import 'package:expense_tracker/core/domain/failures/failure.dart'; +import 'package:expense_tracker/core/domain/usecases/use_case.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_period.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_type.dart'; +import 'package:expense_tracker/features/streak/domain/entities/under_budget_month_status.dart'; +import 'package:expense_tracker/features/streak/domain/usecases/get_qualifying_periods_use_case.dart'; +import 'package:expense_tracker/features/streak/domain/usecases/get_streaks_use_case.dart'; +import 'package:expense_tracker/features/streak/domain/usecases/get_under_budget_status_use_case.dart'; +import 'package:expense_tracker/features/streak/presentation/blocs/streak_state.dart'; +import 'package:expense_tracker/features/transaction/domain/usecases/watch_transactions_use_case.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:injectable/injectable.dart'; + +/// Cubit managing state for the Streaks page. +@injectable +class StreakCubit extends Cubit { + StreakCubit( + this._getStreaksUseCase, + this._getQualifyingPeriodsUseCase, + this._watchTransactionsUseCase, { + GetUnderBudgetStatusUseCase? getUnderBudgetStatusUseCase, + }) : _getUnderBudgetStatusUseCase = getUnderBudgetStatusUseCase, + _clock = DateTime.now, + super(StreakState.initial()) { + _init(); + } + + /// Factory constructor for deterministic testing. + StreakCubit.test({ + required GetStreaksUseCase getStreaksUseCase, + required GetQualifyingPeriodsUseCase getQualifyingPeriodsUseCase, + WatchTransactionsUseCase? watchTransactionsUseCase, + GetUnderBudgetStatusUseCase? getUnderBudgetStatusUseCase, + DateTime Function()? clock, + StreakType initialType = StreakType.tracking, + }) : _getStreaksUseCase = getStreaksUseCase, + _getQualifyingPeriodsUseCase = getQualifyingPeriodsUseCase, + _watchTransactionsUseCase = watchTransactionsUseCase, + _getUnderBudgetStatusUseCase = getUnderBudgetStatusUseCase, + _clock = clock ?? DateTime.now, + super(StreakState.initial(clock?.call(), initialType)) { + _init(); + } + + final GetStreaksUseCase _getStreaksUseCase; + final GetQualifyingPeriodsUseCase _getQualifyingPeriodsUseCase; + final WatchTransactionsUseCase? _watchTransactionsUseCase; + final GetUnderBudgetStatusUseCase? _getUnderBudgetStatusUseCase; + final DateTime Function() _clock; + + StreamSubscription? _txSubscription; + + void _init() { + _txSubscription = _watchTransactionsUseCase?.call(NoParams()).listen( + (result) { + result.fold( + (_) {}, + (_) { + load(showLoading: false); + }, + ); + }, + ); + } + + @override + Future close() async { + await _txSubscription?.cancel(); + return super.close(); + } + + /// Switches the active streak type and reloads data. + Future selectType(StreakType type) async { + if (state.type == type && !state.isLoading) return; + await load(type: type); + } + + /// Loads streak data and qualifying days for [month] (defaulting to current). + Future load({ + DateTime? month, + bool showLoading = true, + StreakType? type, + }) async { + final now = _clock(); + final targetMonth = month ?? state.selectedMonth ?? now; + final normalizedMonth = DateTime(targetMonth.year, targetMonth.month); + final activeType = type ?? state.type; + + if (showLoading) { + emit( + state.copyWith( + isLoading: true, + selectedMonth: normalizedMonth, + type: activeType, + ), + ); + } + + final startDate = DateTime(normalizedMonth.year, normalizedMonth.month); + final endDate = DateTime( + normalizedMonth.year, + normalizedMonth.month + 1, + 0, + ); + + final underBudgetFuture = (activeType == StreakType.underBudget && + _getUnderBudgetStatusUseCase != null) + ? _getUnderBudgetStatusUseCase( + GetUnderBudgetStatusParams(month: normalizedMonth), + ) + : Future?>.value(); + + final results = await Future.wait([ + _getStreaksUseCase( + GetStreaksParams( + type: activeType, + referenceDate: now, + ), + ), + _getQualifyingPeriodsUseCase( + GetQualifyingPeriodsParams( + type: activeType, + startDate: startDate, + endDate: endDate, + ), + ), + underBudgetFuture, + ]); + + final streakResult = results[0]! as Either; + final periodsResult = results[1]! as Either>; + final underBudgetResult = + results[2] as Either?; + + streakResult.fold( + (failure) { + emit( + state.copyWith( + isLoading: false, + type: activeType, + failureOption: some(failure), + ), + ); + }, + (streak) { + periodsResult.fold( + (failure) { + emit( + state.copyWith( + isLoading: false, + type: activeType, + streak: streak, + failureOption: some(failure), + ), + ); + }, + (periods) { + final qualifyingDays = periods + .whereType() + .map((p) => DateTime(p.year, p.month, p.day)) + .toSet(); + + final consistencyRate = activeType == StreakType.underBudget + ? streak.consistencyRate + : _calculateMonthlyConsistency( + targetMonth: normalizedMonth, + qualifyingDays: qualifyingDays, + now: now, + ); + + final underBudgetStatus = + underBudgetResult?.fold((_) => null, (status) => status); + + emit( + state.copyWith( + isLoading: false, + type: activeType, + streak: streak, + selectedMonth: normalizedMonth, + qualifyingDays: qualifyingDays, + monthlyConsistencyRate: consistencyRate, + underBudgetStatus: underBudgetStatus, + failureOption: none(), + ), + ); + }, + ); + }, + ); + } + + /// Moves the calendar to the previous month. + Future previousMonth() async { + final current = state.displayMonth; + final prev = DateTime(current.year, current.month - 1); + await load(month: prev); + } + + /// Moves the calendar to the next month. + Future nextMonth() async { + final current = state.displayMonth; + final next = DateTime(current.year, current.month + 1); + await load(month: next); + } + + double _calculateMonthlyConsistency({ + required DateTime targetMonth, + required Set qualifyingDays, + required DateTime now, + }) { + final isCurrentMonth = + targetMonth.year == now.year && targetMonth.month == now.month; + + if (isCurrentMonth) { + final elapsedDays = now.day; + if (elapsedDays <= 0) return 0; + final count = qualifyingDays + .where( + (d) => + d.year == targetMonth.year && + d.month == targetMonth.month && + d.day <= now.day, + ) + .length; + return (count / elapsedDays).clamp(0, 1); + } + + final targetMonthStart = DateTime(targetMonth.year, targetMonth.month); + final currentMonthStart = DateTime(now.year, now.month); + + if (targetMonthStart.isBefore(currentMonthStart)) { + final totalDays = + DateTime(targetMonth.year, targetMonth.month + 1, 0).day; + if (totalDays <= 0) return 0; + final count = qualifyingDays + .where( + (d) => + d.year == targetMonth.year && + d.month == targetMonth.month, + ) + .length; + return (count / totalDays).clamp(0, 1); + } + + return 0; + } +} diff --git a/lib/features/streak/presentation/blocs/streak_state.dart b/lib/features/streak/presentation/blocs/streak_state.dart new file mode 100644 index 0000000..9535440 --- /dev/null +++ b/lib/features/streak/presentation/blocs/streak_state.dart @@ -0,0 +1,97 @@ +import 'package:dartz/dartz.dart'; +import 'package:equatable/equatable.dart'; +import 'package:expense_tracker/core/domain/failures/failure.dart'; +import 'package:expense_tracker/features/streak/domain/entities/category_overrun_flag.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_type.dart'; +import 'package:expense_tracker/features/streak/domain/entities/under_budget_month_status.dart'; + +/// State representation for the Streaks page. +class StreakState extends Equatable { + const StreakState({ + this.isLoading = true, + this.streak = const Streak.empty(), + this.selectedMonth, + this.qualifyingDays = const {}, + this.monthlyConsistencyRate = 0.0, + this.failureOption = const None(), + this.type = StreakType.tracking, + this.underBudgetStatus, + }); + + /// Factory creating initial state for a given [referenceDate] and [type]. + factory StreakState.initial([ + DateTime? referenceDate, + StreakType type = StreakType.tracking, + ]) { + final now = referenceDate ?? DateTime.now(); + return StreakState( + selectedMonth: DateTime(now.year, now.month), + type: type, + ); + } + + final bool isLoading; + final Streak streak; + final DateTime? selectedMonth; + final Set qualifyingDays; + final double monthlyConsistencyRate; + final Option failureOption; + final StreakType type; + final UnderBudgetMonthStatus? underBudgetStatus; + + /// Returns the current active month or today's month if null. + DateTime get displayMonth { + final now = DateTime.now(); + return selectedMonth ?? DateTime(now.year, now.month); + } + + /// Whether current in-progress month is within budget. + bool get isOnTrack => + underBudgetStatus?.isOnTrack ?? (streak.isCurrentMonthOnTrack ?? true); + + /// Overrun flags for the selected month (R17). + List get categoryOverrunFlags => + underBudgetStatus?.overrunFlags ?? const []; + + /// Total budget for selected month. + double get currentMonthBudget => underBudgetStatus?.totalBudget ?? 0.0; + + /// Total spent in selected month. + double get currentMonthExpense => underBudgetStatus?.totalSpent ?? 0.0; + + StreakState copyWith({ + bool? isLoading, + Streak? streak, + DateTime? selectedMonth, + Set? qualifyingDays, + double? monthlyConsistencyRate, + Option? failureOption, + StreakType? type, + UnderBudgetMonthStatus? underBudgetStatus, + }) { + return StreakState( + isLoading: isLoading ?? this.isLoading, + streak: streak ?? this.streak, + selectedMonth: selectedMonth ?? this.selectedMonth, + qualifyingDays: qualifyingDays ?? this.qualifyingDays, + monthlyConsistencyRate: + monthlyConsistencyRate ?? this.monthlyConsistencyRate, + failureOption: failureOption ?? this.failureOption, + type: type ?? this.type, + underBudgetStatus: underBudgetStatus ?? this.underBudgetStatus, + ); + } + + @override + List get props => [ + isLoading, + streak, + selectedMonth, + qualifyingDays, + monthlyConsistencyRate, + failureOption, + type, + underBudgetStatus, + ]; +} diff --git a/lib/features/streak/presentation/pages/streaks_page.dart b/lib/features/streak/presentation/pages/streaks_page.dart new file mode 100644 index 0000000..a0698aa --- /dev/null +++ b/lib/features/streak/presentation/pages/streaks_page.dart @@ -0,0 +1,251 @@ +import 'package:expense_tracker/features/streak/domain/entities/streak_type.dart'; +import 'package:expense_tracker/features/streak/presentation/blocs/streak_cubit.dart'; +import 'package:expense_tracker/features/streak/presentation/blocs/streak_state.dart'; +import 'package:expense_tracker/features/streak/presentation/widgets/milestone_progress.dart'; +import 'package:expense_tracker/features/streak/presentation/widgets/streak_calendar.dart'; +import 'package:expense_tracker/features/streak/presentation/widgets/streak_hero.dart'; +import 'package:expense_tracker/features/streak/presentation/widgets/under_budget_month_card.dart'; +import 'package:expense_tracker/injector.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:google_fonts/google_fonts.dart'; + +class StreaksPage extends StatelessWidget { + const StreaksPage({ + super.key, + this.cubit, + this.initialType, + this.referenceDate, + }); + + final StreakCubit? cubit; + final StreakType? initialType; + final DateTime? referenceDate; + + StreakCubit _resolveCubit() { + if (cubit != null) return cubit!; + try { + if (getIt.isRegistered()) { + return getIt(); + } + } catch (_) {} + throw StateError('StreakCubit is not registered in dependency injection.'); + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) { + final c = _resolveCubit(); + if (initialType != null && initialType != StreakType.tracking) { + c.selectType(initialType!); + } else { + c.load(); + } + return c; + }, + child: BlocBuilder( + builder: (context, state) { + if (state.isLoading) { + return Scaffold( + backgroundColor: const Color(0xFFF9FAFB), + appBar: AppBar( + backgroundColor: const Color(0xFFF9FAFB), + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new_rounded), + color: const Color(0xFF00113A), + onPressed: () => Navigator.of(context).maybePop(), + ), + title: Text( + 'Streaks', + style: GoogleFonts.manrope( + fontWeight: FontWeight.bold, + color: const Color(0xFF00113A), + ), + ), + centerTitle: true, + ), + body: const Center( + child: CircularProgressIndicator( + color: Color(0xFF00113A), + ), + ), + ); + } + + if (state.failureOption.isSome()) { + return Scaffold( + backgroundColor: const Color(0xFFF9FAFB), + appBar: AppBar( + backgroundColor: const Color(0xFFF9FAFB), + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new_rounded), + color: const Color(0xFF00113A), + onPressed: () => Navigator.of(context).maybePop(), + ), + title: Text( + 'Streaks', + style: GoogleFonts.manrope( + fontWeight: FontWeight.bold, + color: const Color(0xFF00113A), + ), + ), + centerTitle: true, + ), + body: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.error_outline_rounded, + color: Color(0xFFBA1A1A), + size: 48, + ), + const SizedBox(height: 12), + Text( + 'Unable to load streak data', + style: GoogleFonts.manrope( + fontSize: 16, + fontWeight: FontWeight.bold, + color: const Color(0xFF00113A), + ), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () => context.read().load(), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF00113A), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text('Try Again'), + ), + ], + ), + ), + ), + ); + } + + final cubit = context.read(); + final hasData = state.type == StreakType.underBudget + ? (state.currentMonthBudget > 0 || !state.streak.isEmpty) + : (!state.streak.isEmpty || state.qualifyingDays.isNotEmpty); + + return Scaffold( + backgroundColor: const Color(0xFFF9FAFB), + appBar: AppBar( + backgroundColor: const Color(0xFFF9FAFB), + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new_rounded), + color: const Color(0xFF00113A), + onPressed: () => Navigator.of(context).maybePop(), + ), + title: Text( + 'Streaks', + style: GoogleFonts.manrope( + fontWeight: FontWeight.bold, + color: const Color(0xFF00113A), + ), + ), + centerTitle: true, + ), + body: RefreshIndicator( + color: const Color(0xFF00113A), + onRefresh: cubit.load, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 16, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildTypeSelector(context, state.type), + const SizedBox(height: 20), + StreakHero( + streak: state.streak, + ), + const SizedBox(height: 20), + if (state.type == StreakType.underBudget) + UnderBudgetMonthCard( + selectedMonth: state.displayMonth, + status: state.underBudgetStatus, + isOnTrack: state.isOnTrack, + overrunFlags: state.categoryOverrunFlags, + referenceDate: referenceDate, + onPreviousMonth: cubit.previousMonth, + onNextMonth: cubit.nextMonth, + ) + else + StreakCalendar( + selectedMonth: state.displayMonth, + qualifyingDays: state.qualifyingDays, + onPreviousMonth: cubit.previousMonth, + onNextMonth: cubit.nextMonth, + ), + const SizedBox(height: 16), + StreakConsistencyCard( + consistencyRate: state.monthlyConsistencyRate, + hasData: hasData, + ), + const SizedBox(height: 24), + MilestoneProgress(streak: state.streak), + const SizedBox(height: 32), + ], + ), + ), + ), + ); + }, + ), + ); + } + + Widget _buildTypeSelector(BuildContext context, StreakType selectedType) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: StreakType.activeTypes.map((type) { + final isSelected = type == selectedType; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: ChoiceChip( + key: Key('streak_type_chip_${type.name}'), + label: Text(type.shortLabel), + selected: isSelected, + onSelected: (selected) { + if (selected) { + context.read().selectType(type); + } + }, + selectedColor: const Color(0xFF00113A), + backgroundColor: Colors.white, + labelStyle: GoogleFonts.inter( + color: isSelected ? Colors.white : const Color(0xFF757682), + fontWeight: isSelected ? FontWeight.bold : FontWeight.w500, + fontSize: 13, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + side: BorderSide( + color: isSelected + ? const Color(0xFF00113A) + : const Color(0xFFE5E7EB), + ), + ), + ), + ); + }).toList(), + ), + ); + } +} diff --git a/lib/features/streak/presentation/widgets/milestone_progress.dart b/lib/features/streak/presentation/widgets/milestone_progress.dart new file mode 100644 index 0000000..36b5212 --- /dev/null +++ b/lib/features/streak/presentation/widgets/milestone_progress.dart @@ -0,0 +1,213 @@ +import 'package:expense_tracker/features/streak/domain/entities/streak.dart'; +import 'package:expense_tracker/features/streak/domain/services/streak_engine.dart'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +/// Section rendering upcoming milestone cards and progress bars. +class MilestoneProgress extends StatelessWidget { + const MilestoneProgress({ + required this.streak, + super.key, + }); + + final Streak streak; + + static const _ladder = StreakEngine.milestoneLadder; + + List get _upcomingMilestones { + final remaining = _ladder.where((m) => m > streak.length).toList(); + if (remaining.isEmpty) { + final nextYear = ((streak.length ~/ 365) + 1) * 365; + return [nextYear]; + } + return remaining.take(2).toList(); + } + + String _titleForMilestone(int milestone) { + switch (milestone) { + case 7: + return '7 Day Habit'; + case 14: + return '14 Day Discipline'; + case 21: + return '21 Day Routine'; + case 30: + return 'Monthly Master'; + case 60: + return '60 Day Dedication'; + case 90: + return 'Quarterly Excellence'; + case 180: + return 'Half-Year Legend'; + case 365: + return 'Annual Champion'; + default: + return '$milestone Day Milestone'; + } + } + + IconData _iconForMilestone(int milestone) { + switch (milestone) { + case 7: + return Icons.local_fire_department_rounded; + case 14: + return Icons.trending_up_rounded; + case 21: + return Icons.star_rounded; + case 30: + return Icons.savings_rounded; + case 60: + return Icons.workspace_premium_rounded; + case 90: + return Icons.military_tech_rounded; + case 180: + return Icons.emoji_events_rounded; + case 365: + return Icons.diamond_rounded; + default: + return Icons.star_rounded; + } + } + + @override + Widget build(BuildContext context) { + final milestones = _upcomingMilestones; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Text( + 'Upcoming Milestones', + style: GoogleFonts.manrope( + fontSize: 18, + fontWeight: FontWeight.bold, + color: const Color(0xFF00113A), + ), + ), + ), + const SizedBox(height: 12), + ...milestones.map((milestone) { + final daysLeft = milestone - streak.length; + final previousMilestone = _previousMilestone(milestone); + final span = milestone - previousMilestone; + final progress = span > 0 + ? ((streak.length - previousMilestone) / span).clamp(0.0, 1.0) + : (streak.length / milestone).clamp(0.0, 1.0); + + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _MilestoneCard( + title: _titleForMilestone(milestone), + daysLeft: daysLeft, + icon: _iconForMilestone(milestone), + progress: progress, + ), + ); + }), + ], + ); + } + + int _previousMilestone(int milestone) { + final index = _ladder.indexOf(milestone); + if (index > 0) { + return _ladder[index - 1]; + } + return 0; + } +} + +class _MilestoneCard extends StatelessWidget { + const _MilestoneCard({ + required this.title, + required this.daysLeft, + required this.icon, + required this.progress, + }); + + final String title; + final int daysLeft; + final IconData icon; + final double progress; + + @override + Widget build(BuildContext context) { + final daysLeftLabel = daysLeft == 1 ? '1 day left' : '$daysLeft days left'; + + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(24), + border: Border.all(color: const Color(0xFFE5E7EB)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.02), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: const BoxDecoration( + color: Color(0xFFF1F3F9), + shape: BoxShape.circle, + ), + child: Icon( + icon, + color: const Color(0xFF00113A), + size: 24, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + title, + style: GoogleFonts.manrope( + fontSize: 14, + fontWeight: FontWeight.bold, + color: const Color(0xFF00113A), + ), + ), + Text( + daysLeftLabel, + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.bold, + color: const Color(0xFF757682), + ), + ), + ], + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular(100), + child: LinearProgressIndicator( + value: progress, + minHeight: 6, + backgroundColor: const Color(0xFFE5E7EB), + valueColor: const AlwaysStoppedAnimation( + Color(0xFF00113A), + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/streak/presentation/widgets/streak_badge.dart b/lib/features/streak/presentation/widgets/streak_badge.dart new file mode 100644 index 0000000..f59ba4f --- /dev/null +++ b/lib/features/streak/presentation/widgets/streak_badge.dart @@ -0,0 +1,109 @@ +import 'package:expense_tracker/features/streak/domain/entities/streak.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_status.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:google_fonts/google_fonts.dart'; + +/// Compact flame badge displayed in headers and app bars. +/// +/// Tapping navigates to `/streaks?type=${streak.type.name}`. +class StreakBadge extends StatelessWidget { + const StreakBadge({ + required this.streak, + this.onTap, + super.key, + }); + + final Streak streak; + final VoidCallback? onTap; + + Color get _backgroundColor { + if (streak.isEmpty || streak.status == StreakStatus.none) { + return const Color(0xFFF3F4F6); + } + if (streak.status == StreakStatus.atRisk) { + return const Color(0xFFFEF3C7); + } + if (streak.status == StreakStatus.warmingUp) { + return const Color(0xFFFFFBEB); + } + return const Color(0xFFFFEDD5); + } + + Color get _borderColor { + if (streak.isEmpty || streak.status == StreakStatus.none) { + return const Color(0xFFE5E7EB); + } + if (streak.status == StreakStatus.atRisk) { + return const Color(0xFFF59E0B).withValues(alpha: 0.4); + } + return const Color(0xFFF97316).withValues(alpha: 0.3); + } + + Color get _flameColor { + if (streak.isEmpty || streak.status == StreakStatus.none) { + return const Color(0xFF9CA3AF); + } + if (streak.status == StreakStatus.atRisk) { + return const Color(0xFFD97706); + } + return const Color(0xFFEA580C); + } + + Color get _textColor { + if (streak.isEmpty || streak.status == StreakStatus.none) { + return const Color(0xFF6B7280); + } + if (streak.status == StreakStatus.atRisk) { + return const Color(0xFF92400E); + } + return const Color(0xFF9A3412); + } + + @override + Widget build(BuildContext context) { + final count = streak.length; + + return Semantics( + button: true, + label: '$count day streak', + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: onTap ?? + () { + context.push('/streaks?type=${streak.type.name}'); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: _backgroundColor, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: _borderColor), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.local_fire_department_rounded, + color: _flameColor, + size: 18, + ), + const SizedBox(width: 4), + Text( + '$count', + style: GoogleFonts.manrope( + fontSize: 13, + fontWeight: FontWeight.bold, + color: _textColor, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/streak/presentation/widgets/streak_calendar.dart b/lib/features/streak/presentation/widgets/streak_calendar.dart new file mode 100644 index 0000000..3ba3a73 --- /dev/null +++ b/lib/features/streak/presentation/widgets/streak_calendar.dart @@ -0,0 +1,317 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:intl/intl.dart'; + +/// Bento-style month calendar marking qualifying days and today's status. +class StreakCalendar extends StatelessWidget { + const StreakCalendar({ + required this.selectedMonth, + required this.qualifyingDays, + required this.onPreviousMonth, + required this.onNextMonth, + super.key, + this.referenceDate, + }); + + final DateTime selectedMonth; + final Set qualifyingDays; + final VoidCallback onPreviousMonth; + final VoidCallback onNextMonth; + final DateTime? referenceDate; + + static const _weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + + @override + Widget build(BuildContext context) { + final now = referenceDate ?? DateTime.now(); + final firstDayOfMonth = + DateTime(selectedMonth.year, selectedMonth.month); + final daysInMonth = + DateTime(selectedMonth.year, selectedMonth.month + 1, 0).day; + + // Monday is 1, Sunday is 7 in Dart DateTime. + final leadingEmptyCells = (firstDayOfMonth.weekday - 1) % 7; + final totalCells = leadingEmptyCells + daysInMonth; + final rowCount = (totalCells / 7).ceil(); + + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(28), + border: Border.all(color: const Color(0xFFE5E7EB)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.03), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + DateFormat('MMMM yyyy').format(selectedMonth), + style: GoogleFonts.manrope( + fontSize: 18, + fontWeight: FontWeight.bold, + color: const Color(0xFF00113A), + ), + ), + Row( + children: [ + IconButton( + icon: const Icon(Icons.chevron_left_rounded), + color: const Color(0xFF757682), + tooltip: 'Previous month', + onPressed: onPreviousMonth, + ), + IconButton( + icon: const Icon(Icons.chevron_right_rounded), + color: const Color(0xFF757682), + tooltip: 'Next month', + onPressed: onNextMonth, + ), + ], + ), + ], + ), + const SizedBox(height: 12), + Row( + children: _weekdays.map((day) { + return Expanded( + child: Center( + child: Text( + day, + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.bold, + color: const Color(0xFF757682), + letterSpacing: 0.5, + ), + ), + ), + ); + }).toList(), + ), + const SizedBox(height: 12), + Column( + children: List.generate(rowCount, (rowIndex) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + children: List.generate(7, (colIndex) { + final cellIndex = rowIndex * 7 + colIndex; + final dayNumber = cellIndex - leadingEmptyCells + 1; + + if (dayNumber < 1 || dayNumber > daysInMonth) { + return const Expanded(child: SizedBox()); + } + + final cellDate = DateTime( + selectedMonth.year, + selectedMonth.month, + dayNumber, + ); + final isQualifying = qualifyingDays.contains(cellDate); + final isToday = cellDate.year == now.year && + cellDate.month == now.month && + cellDate.day == now.day; + final isFuture = cellDate.isAfter(now); + + return Expanded( + child: Center( + child: _DayCell( + dayNumber: dayNumber, + isQualifying: isQualifying, + isToday: isToday, + isFuture: isFuture, + ), + ), + ); + }), + ), + ); + }), + ), + ], + ), + ); + } +} + +class _DayCell extends StatelessWidget { + const _DayCell({ + required this.dayNumber, + required this.isQualifying, + required this.isToday, + required this.isFuture, + }); + + final int dayNumber; + final bool isQualifying; + final bool isToday; + final bool isFuture; + + static const _secondaryContainer = Color(0xFFA0F399); + static const _onSecondaryContainer = Color(0xFF217128); + static const _primary = Color(0xFF00113A); + + @override + Widget build(BuildContext context) { + if (isToday && isQualifying) { + return Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: _primary, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: _primary.withValues(alpha: 0.3), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + alignment: Alignment.center, + child: Text( + '$dayNumber', + style: GoogleFonts.manrope( + fontSize: 13, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ); + } + + if (isQualifying) { + return Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: _secondaryContainer, + shape: BoxShape.circle, + border: Border.all( + color: _onSecondaryContainer.withValues(alpha: 0.25), + width: 1.5, + ), + ), + alignment: Alignment.center, + child: Text( + '$dayNumber', + style: GoogleFonts.manrope( + fontSize: 13, + fontWeight: FontWeight.bold, + color: _onSecondaryContainer, + ), + ), + ); + } + + if (isToday) { + return Container( + width: 34, + height: 34, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: _primary, + width: 2, + ), + ), + alignment: Alignment.center, + child: Text( + '$dayNumber', + style: GoogleFonts.manrope( + fontSize: 13, + fontWeight: FontWeight.bold, + color: _primary, + ), + ), + ); + } + + return SizedBox( + width: 34, + height: 34, + child: Center( + child: Text( + '$dayNumber', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w500, + color: isFuture + ? const Color(0xFFB0B2B8) + : const Color(0xFF444650), + ), + ), + ), + ); + } +} + +/// Bento-style card displaying the monthly consistency completion rate. +class StreakConsistencyCard extends StatelessWidget { + const StreakConsistencyCard({ + required this.consistencyRate, + this.hasData = true, + super.key, + }); + + final double consistencyRate; + final bool hasData; + + @override + Widget build(BuildContext context) { + final percentage = (consistencyRate * 100).round(); + + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: const Color(0xFFF3F4F6), + borderRadius: BorderRadius.circular(24), + border: Border.all(color: const Color(0xFFE5E7EB)), + ), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'CONSISTENCY', + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.bold, + color: const Color(0xFF00113A), + letterSpacing: 1.5, + ), + ), + const SizedBox(height: 6), + Text( + hasData ? '$percentage%' : '—', + style: GoogleFonts.manrope( + fontSize: 40, + fontWeight: FontWeight.w900, + color: const Color(0xFF00113A), + letterSpacing: -1, + ), + ), + const SizedBox(height: 4), + Text( + hasData ? 'Completion rate this month' : 'No activity recorded yet', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w500, + color: const Color(0xFF757682), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/streak/presentation/widgets/streak_card.dart b/lib/features/streak/presentation/widgets/streak_card.dart new file mode 100644 index 0000000..556502f --- /dev/null +++ b/lib/features/streak/presentation/widgets/streak_card.dart @@ -0,0 +1,202 @@ +import 'package:expense_tracker/features/streak/domain/entities/streak.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_status.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_type.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:google_fonts/google_fonts.dart'; + +/// Compact flame card displayed on the home dashboard. +/// +/// Tap navigates to `/streaks`. +/// When the streak is empty, this widget renders [SizedBox.shrink]. +class StreakCard extends StatelessWidget { + const StreakCard({ + required this.streak, + super.key, + this.onTap, + this.onLongPress, + }); + + final Streak streak; + final VoidCallback? onTap; + final VoidCallback? onLongPress; + + String get _title { + final prefix = streak.type == StreakType.tracking + ? '' + : '${streak.type.shortLabel} · '; + if (streak.status == StreakStatus.warmingUp) { + return '${prefix}Warming Up'; + } + final unit = streak.type == StreakType.underBudget ? 'Month' : 'Day'; + return '$prefix${streak.length} $unit Streak'; + } + + String get _subtitle { + if (streak.status == StreakStatus.atRisk) { + switch (streak.type) { + case StreakType.noSpend: + return 'At risk! Avoid expenses today to keep your streak'; + case StreakType.appOpen: + return 'At risk! Open app today to keep your streak'; + case StreakType.underBudget: + return 'At risk! Stay under budget this month to keep your streak'; + case StreakType.tracking: + return 'At risk! Log today to keep your streak'; + } + } + if (streak.status == StreakStatus.warmingUp) { + return streak.type == StreakType.underBudget + ? 'Warming up · Stay under budget to activate your streak' + : 'Warming up · Keep logging to activate your streak'; + } + final remaining = streak.nextMilestone - streak.length; + final unitWord = streak.type == StreakType.underBudget + ? (remaining == 1 ? 'month' : 'months') + : (remaining == 1 ? 'day' : 'days'); + return '$remaining $unitWord to next milestone'; + } + + Color get _accentColor { + if (streak.status == StreakStatus.atRisk) { + return const Color(0xFFBA1A1A); + } + if (streak.status == StreakStatus.warmingUp) { + return const Color(0xFFF59E0B); + } + return const Color(0xFFF97316); + } + + Color get _flameContainerColor { + if (streak.status == StreakStatus.atRisk) { + return const Color(0xFFFFDAD6); + } + if (streak.status == StreakStatus.warmingUp) { + return const Color(0xFFFFF3E0); + } + return const Color(0xFFFFEDD5); + } + + double get _progressFraction { + if (streak.nextMilestone <= 0) return 0; + return (streak.length / streak.nextMilestone).clamp(0.0, 1.0); + } + + @override + Widget build(BuildContext context) { + if (streak.isEmpty || streak.status == StreakStatus.none) { + return const SizedBox.shrink(); + } + + final isAtRisk = streak.status == StreakStatus.atRisk; + + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isAtRisk + ? const Color(0xFFFFB4AB).withValues(alpha: 0.8) + : const Color(0xFFE5E7EB), + width: isAtRisk ? 1.5 : 1.0, + ), + boxShadow: [ + BoxShadow( + color: isAtRisk + ? const Color(0xFFBA1A1A).withValues(alpha: 0.08) + : Colors.black.withValues(alpha: 0.04), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: onTap ?? + () { + context.push('/streaks?type=${streak.type.name}'); + }, + onLongPress: onLongPress, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + // Flame icon container + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: _flameContainerColor, + shape: BoxShape.circle, + ), + child: Center( + child: Icon( + Icons.local_fire_department_rounded, + color: _accentColor, + size: 26, + ), + ), + ), + const SizedBox(width: 14), + + // Streak text & milestone progress bar + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _title, + style: GoogleFonts.manrope( + fontSize: 16, + fontWeight: FontWeight.bold, + color: const Color(0xFF00113A), + ), + ), + const SizedBox(height: 2), + Text( + _subtitle, + style: GoogleFonts.inter( + fontSize: 12, + color: isAtRisk + ? const Color(0xFFBA1A1A) + : const Color(0xFF757682), + fontWeight: isAtRisk + ? FontWeight.w600 + : FontWeight.normal, + ), + ), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: _progressFraction, + backgroundColor: const Color(0xFFF3F4F6), + color: _accentColor, + minHeight: 5, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + + // Trailing chevron + Icon( + Icons.chevron_right_rounded, + color: isAtRisk + ? const Color(0xFFBA1A1A) + : const Color(0xFF9CA3AF), + size: 22, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/streak/presentation/widgets/streak_config_sheet.dart b/lib/features/streak/presentation/widgets/streak_config_sheet.dart new file mode 100644 index 0000000..d91c1ac --- /dev/null +++ b/lib/features/streak/presentation/widgets/streak_config_sheet.dart @@ -0,0 +1,578 @@ +import 'package:expense_tracker/core/storages/local_storages.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_type.dart'; +import 'package:expense_tracker/features/streak/presentation/blocs/streak_config_cubit.dart'; +import 'package:expense_tracker/features/streak/presentation/blocs/streak_config_state.dart'; +import 'package:expense_tracker/injector.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:google_fonts/google_fonts.dart'; + +/// Hidden configuration tuning surface for streak parameters. +/// +/// Long-pressing any streak element opens this sheet at the requested section. +/// Changes persist to [LocalStorage] and take effect immediately on next read. +class StreakConfigSheet extends StatefulWidget { + const StreakConfigSheet({ + super.key, + this.initialType = StreakType.tracking, + this.cubit, + }); + + /// Deep-linked section to focus when opened. + final StreakType initialType; + + /// Optional injected cubit (used in tests or manual overrides). + final StreakConfigCubit? cubit; + + /// Convenience helper to display the sheet in a modal bottom sheet. + static Future show( + BuildContext context, { + StreakType initialType = StreakType.tracking, + StreakConfigCubit? cubit, + }) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => StreakConfigSheet( + initialType: initialType, + cubit: cubit, + ), + ); + } + + @override + State createState() => _StreakConfigSheetState(); +} + +class _StreakConfigSheetState extends State { + late final StreakConfigCubit _cubit; + bool _ownsCubit = false; + + @override + void initState() { + super.initState(); + if (widget.cubit != null) { + _cubit = widget.cubit!; + } else { + try { + _cubit = context.read(); + } catch (_) { + if (getIt.isRegistered()) { + _cubit = getIt() + ..load(initialType: widget.initialType); + } else if (getIt.isRegistered()) { + _cubit = StreakConfigCubit(getIt()) + ..load(initialType: widget.initialType); + } else { + _cubit = StreakConfigCubit(const _FallbackLocalStorage()) + ..load(initialType: widget.initialType); + } + _ownsCubit = true; + } + } + } + + @override + void dispose() { + if (_ownsCubit) { + _cubit.close(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: _cubit, + child: BlocBuilder( + builder: (context, state) { + return Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + padding: const EdgeInsets.fromLTRB(24, 16, 24, 32), + child: SafeArea( + top: false, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 36, + height: 4, + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + color: const Color(0xFFE5E7EB), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Streak Settings', + style: GoogleFonts.manrope( + fontSize: 20, + fontWeight: FontWeight.bold, + color: const Color(0xFF00113A), + letterSpacing: -0.5, + ), + ), + const SizedBox(height: 4), + Text( + 'Tune gap allowances and thresholds', + style: GoogleFonts.inter( + fontSize: 13, + color: const Color(0xFF444650), + ), + ), + ], + ), + IconButton( + icon: const Icon( + Icons.close_rounded, + color: Color(0xFF444650), + ), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + const SizedBox(height: 20), + _buildTypeSelector(context, state.selectedType), + const SizedBox(height: 24), + if (state.isLoading) + const Center( + child: Padding( + padding: EdgeInsets.all(32), + child: CircularProgressIndicator( + color: Color(0xFF00113A), + ), + ), + ) + else + _buildSectionContent(context, state), + ], + ), + ), + ), + ); + }, + ), + ); + } + + Widget _buildTypeSelector( + BuildContext context, + StreakType selectedType, + ) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: StreakType.values.map((type) { + final isSelected = type == selectedType; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: ChoiceChip( + key: Key('streak_config_type_${type.name}'), + label: Text( + _typeShortLabel(type), + style: GoogleFonts.manrope( + fontSize: 13, + fontWeight: + isSelected ? FontWeight.bold : FontWeight.w600, + color: isSelected + ? Colors.white + : const Color(0xFF444650), + ), + ), + selected: isSelected, + onSelected: (_) { + context.read().selectType(type); + }, + selectedColor: const Color(0xFF00113A), + backgroundColor: const Color(0xFFF1F2F6), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(100), + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + ); + }).toList(), + ), + ); + } + + Widget _buildSectionContent( + BuildContext context, + StreakConfigState state, + ) { + switch (state.selectedType) { + case StreakType.tracking: + return _buildTrackingSection(context, state); + case StreakType.appOpen: + return _buildAppOpenSection(context, state); + case StreakType.noSpend: + return _buildReservedPlaceholder( + title: 'No-Spend Streak', + badge: 'Phase 2', + description: + 'No-spend streaks qualify days when no expenses are recorded. ' + 'Tuning controls will unlock when Phase 2 ships.', + icon: Icons.savings_outlined, + ); + case StreakType.underBudget: + return _buildReservedPlaceholder( + title: 'Under-Budget Streak', + badge: 'Phase 3', + description: + 'Under-budget streaks evaluate monthly envelope discipline. ' + 'Tuning controls will unlock when Phase 3 ships.', + icon: Icons.account_balance_wallet_outlined, + ); + } + } + + Widget _buildTrackingSection( + BuildContext context, + StreakConfigState state, + ) { + final cubit = context.read(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildStepperCard( + title: 'Window Gap Allowance', + subtitle: + 'Maximum consecutive days allowed between transactions ' + 'without breaking the run.', + value: state.config.window, + decrementKey: const Key('window_decrement'), + incrementKey: const Key('window_increment'), + valueKey: const Key('window_value'), + onDecrement: cubit.decrementWindow, + onIncrement: cubit.incrementWindow, + ), + const SizedBox(height: 16), + _buildStepperCard( + title: 'Activation Threshold', + subtitle: + 'Days required to celebrate your streak as active. Below this, ' + 'it is framed as warming up.', + value: state.config.minimum, + decrementKey: const Key('minimum_decrement'), + incrementKey: const Key('minimum_increment'), + valueKey: const Key('minimum_value'), + onDecrement: cubit.decrementMinimum, + onIncrement: cubit.incrementMinimum, + ), + ], + ); + } + + Widget _buildAppOpenSection( + BuildContext context, + StreakConfigState state, + ) { + final cubit = context.read(); + const presets = [1, 2, 3, 7]; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'App-Open Cadence', + style: GoogleFonts.manrope( + fontSize: 16, + fontWeight: FontWeight.bold, + color: const Color(0xFF00113A), + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFFEFF6FF), + borderRadius: BorderRadius.circular(100), + ), + child: Text( + 'Phase 2 Preview', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w600, + color: const Color(0xFF1D4ED8), + ), + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Target interval in days for app visits. ' + 'Cadence 1 requires daily opens.', + style: GoogleFonts.inter( + fontSize: 13, + color: const Color(0xFF444650), + ), + ), + const SizedBox(height: 16), + Text( + 'Quick Presets', + style: GoogleFonts.manrope( + fontSize: 13, + fontWeight: FontWeight.w600, + color: const Color(0xFF00113A), + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: presets.map((p) { + final isSelected = state.config.cadence == p; + final label = p == 1 + ? 'Daily (1d)' + : p == 7 + ? 'Weekly (7d)' + : '$p days'; + return ActionChip( + key: Key('cadence_preset_$p'), + label: Text( + label, + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: + isSelected ? FontWeight.bold : FontWeight.normal, + color: isSelected + ? Colors.white + : const Color(0xFF00113A), + ), + ), + backgroundColor: isSelected + ? const Color(0xFF00113A) + : const Color(0xFFF1F2F6), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(100), + ), + onPressed: () => cubit.updateCadence(p), + ); + }).toList(), + ), + const SizedBox(height: 16), + _buildStepperCard( + title: 'Custom Cadence', + subtitle: 'Adjust cadence step-by-step.', + value: state.config.cadence, + decrementKey: const Key('cadence_decrement'), + incrementKey: const Key('cadence_increment'), + valueKey: const Key('cadence_value'), + onDecrement: cubit.decrementCadence, + onIncrement: cubit.incrementCadence, + ), + ], + ); + } + + Widget _buildReservedPlaceholder({ + required String title, + required String badge, + required String description, + required IconData icon, + }) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: const Color(0xFFF8F9FA), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: const Color(0xFFE5E7EB), + ), + ), + child: Column( + children: [ + CircleAvatar( + radius: 28, + backgroundColor: const Color(0xFFE5E7EB), + child: Icon( + icon, + size: 28, + color: const Color(0xFF757682), + ), + ), + const SizedBox(height: 16), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: GoogleFonts.manrope( + fontSize: 16, + fontWeight: FontWeight.bold, + color: const Color(0xFF00113A), + ), + ), + const SizedBox(width: 8), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFFF1F2F6), + borderRadius: BorderRadius.circular(100), + ), + child: Text( + badge, + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w600, + color: const Color(0xFF757682), + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + description, + style: GoogleFonts.inter( + fontSize: 13, + color: const Color(0xFF444650), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + Text( + 'Reserved Placeholder', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w600, + color: const Color(0xFF757682), + ), + ), + ], + ), + ); + } + + Widget _buildStepperCard({ + required String title, + required String subtitle, + required int value, + required Key decrementKey, + required Key incrementKey, + required Key valueKey, + required VoidCallback onDecrement, + required VoidCallback onIncrement, + }) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFF8F9FA), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFE5E7EB)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: GoogleFonts.manrope( + fontSize: 15, + fontWeight: FontWeight.bold, + color: const Color(0xFF00113A), + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: GoogleFonts.inter( + fontSize: 12, + color: const Color(0xFF444650), + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '$value ${value == 1 ? 'day' : 'days'}', + key: valueKey, + style: GoogleFonts.manrope( + fontSize: 18, + fontWeight: FontWeight.bold, + color: const Color(0xFF00113A), + ), + ), + Row( + children: [ + IconButton( + key: decrementKey, + onPressed: value > 1 ? onDecrement : null, + icon: const Icon(Icons.remove_circle_outline), + color: const Color(0xFF00113A), + disabledColor: const Color(0xFFBDBDBD), + ), + IconButton( + key: incrementKey, + onPressed: onIncrement, + icon: const Icon(Icons.add_circle_outline), + color: const Color(0xFF00113A), + ), + ], + ), + ], + ), + ], + ), + ); + } + + String _typeShortLabel(StreakType type) { + switch (type) { + case StreakType.tracking: + return 'Tracking'; + case StreakType.noSpend: + return 'No-Spend'; + case StreakType.appOpen: + return 'App-Open'; + case StreakType.underBudget: + return 'Under-Budget'; + } + } +} + +class _FallbackLocalStorage implements LocalStorage { + const _FallbackLocalStorage(); + + @override + Future getApiKey() => Future.value(); + + @override + Future setApiKey(String apiKey) => Future.value(); + + @override + Future getStreakWindowDays() => Future.value(3); + + @override + Future setStreakWindowDays(int days) => Future.value(); + + @override + Future getStreakMinimumDays() => Future.value(3); + + @override + Future setStreakMinimumDays(int days) => Future.value(); + + @override + Future getAppOpenCadenceDays() => Future.value(7); + + @override + Future setAppOpenCadenceDays(int days) => Future.value(); +} diff --git a/lib/features/streak/presentation/widgets/streak_hero.dart b/lib/features/streak/presentation/widgets/streak_hero.dart new file mode 100644 index 0000000..95275bb --- /dev/null +++ b/lib/features/streak/presentation/widgets/streak_hero.dart @@ -0,0 +1,158 @@ +import 'package:expense_tracker/features/streak/domain/entities/streak.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_status.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_type.dart'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +/// Hero streak counter section showing the flame, run length, and habit status. +class StreakHero extends StatelessWidget { + const StreakHero({ + required this.streak, + super.key, + this.onLongPress, + }); + + final Streak streak; + final VoidCallback? onLongPress; + + static const _brandGradient = LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF00113A), Color(0xFF002366)], + ); + + static const _flameColor = Color(0xFFA0F399); + static const _subtitleColor = Color(0xFF758DD5); + + String get _headline { + if (streak.length == 0) { + if (streak.status == StreakStatus.warmingUp) { + return 'Warming Up'; + } + return 'No Active Streak'; + } + final unit = streak.type == StreakType.underBudget ? 'Month' : 'Day'; + if (streak.length == 1) { + return '1 $unit Streak'; + } + return '${streak.length} $unit Streak'; + } + + String get _subtitle { + switch (streak.status) { + case StreakStatus.atRisk: + if (streak.type == StreakType.noSpend) { + return 'AT RISK • AVOID EXPENSES TODAY'; + } + if (streak.type == StreakType.appOpen) { + return 'AT RISK • OPEN APP TODAY'; + } + if (streak.type == StreakType.underBudget) { + return 'AT RISK • STAY UNDER BUDGET THIS MONTH'; + } + return 'AT RISK • LOG AN EXPENSE TODAY'; + case StreakStatus.warmingUp: + return 'WARMING UP • HABIT IN FORMATION'; + case StreakStatus.broken: + if (streak.type == StreakType.noSpend) { + return 'STREAK PAUSED • NO-SPEND DAY TO RESUME'; + } + if (streak.type == StreakType.appOpen) { + return 'STREAK PAUSED • OPEN DAILY TO RESUME'; + } + if (streak.type == StreakType.underBudget) { + return 'STREAK PAUSED • STAY UNDER BUDGET TO RESUME'; + } + return 'STREAK PAUSED • LOG TODAY TO RESUME'; + case StreakStatus.none: + if (streak.type == StreakType.underBudget) { + return 'STAY UNDER BUDGET THIS MONTH TO START'; + } + return 'START YOUR RUN TODAY'; + case StreakStatus.active: + return 'MASTERING FINANCIAL DISCIPLINE'; + } + } + + int get _filledDots { + if (streak.nextMilestone <= 0 || streak.length <= 0) return 0; + final progress = (streak.length / streak.nextMilestone).clamp(0.0, 1.0); + return (progress * 5).round().clamp(0, 5); + } + + @override + Widget build(BuildContext context) { + return Semantics( + label: '$_headline, $_subtitle', + child: GestureDetector( + onLongPress: onLongPress, + behavior: HitTestBehavior.opaque, + child: Container( + width: double.infinity, + decoration: BoxDecoration( + gradient: _brandGradient, + borderRadius: BorderRadius.circular(36), + boxShadow: [ + BoxShadow( + color: const Color(0xFF00113A).withValues(alpha: 0.25), + blurRadius: 20, + offset: const Offset(0, 8), + ), + ], + ), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.local_fire_department_rounded, + color: _flameColor, + size: 52, + ), + const SizedBox(height: 12), + Text( + _headline, + style: GoogleFonts.manrope( + fontSize: 32, + fontWeight: FontWeight.w800, + color: Colors.white, + letterSpacing: -0.5, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 6), + Text( + _subtitle, + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + color: _subtitleColor, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(5, (index) { + final isFilled = index < _filledDots; + return Container( + margin: const EdgeInsets.symmetric(horizontal: 4), + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isFilled + ? _flameColor + : Colors.white.withValues(alpha: 0.2), + ), + ); + }), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/streak/presentation/widgets/under_budget_month_card.dart b/lib/features/streak/presentation/widgets/under_budget_month_card.dart new file mode 100644 index 0000000..35775d3 --- /dev/null +++ b/lib/features/streak/presentation/widgets/under_budget_month_card.dart @@ -0,0 +1,380 @@ +import 'package:expense_tracker/features/streak/domain/entities/category_overrun_flag.dart'; +import 'package:expense_tracker/features/streak/domain/entities/under_budget_month_status.dart'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:intl/intl.dart'; + +/// Month-level card presenting under-budget status, in-progress preview, +/// and per-category overrun flags (R14, R17). +class UnderBudgetMonthCard extends StatelessWidget { + const UnderBudgetMonthCard({ + required this.selectedMonth, + required this.onPreviousMonth, + required this.onNextMonth, + super.key, + this.status, + this.isOnTrack, + this.overrunFlags = const [], + this.referenceDate, + }); + + final DateTime selectedMonth; + final VoidCallback onPreviousMonth; + final VoidCallback onNextMonth; + final UnderBudgetMonthStatus? status; + final bool? isOnTrack; + final List overrunFlags; + final DateTime? referenceDate; + + static const _primary = Color(0xFF00113A); + static const _secondary = Color(0xFF757682); + static const _successBg = Color(0xFFA0F399); + static const _successText = Color(0xFF217128); + static const _errorBg = Color(0xFFFEE2E2); + static const _errorText = Color(0xFF991B1B); + static const _warningText = Color(0xFFB45309); + static const _warningBg = Color(0xFFFEF3C7); + + String _buildExplanatoryNote({ + required bool hasBudget, + required bool isCurrentMonth, + required bool isPastMonth, + required bool onTrack, + }) { + if (!hasBudget) { + return 'Configure monthly envelope budgets to track your under-budget ' + 'streak.'; + } + if (isCurrentMonth) { + if (onTrack) { + return 'Mid-month spend is within your total monthly budget. ' + 'Stay under until month-end to extend your streak!'; + } + return 'Mid-month spend exceeds total monthly budget. ' + 'Streak count is preserved until month-end.'; + } + if (isPastMonth) { + if (onTrack) { + return 'Completed month met total budget goals and qualified for ' + 'your streak.'; + } + return 'Completed month exceeded total budget goals.'; + } + return 'Future month.'; + } + + @override + Widget build(BuildContext context) { + final now = referenceDate ?? DateTime.now(); + final isCurrentMonth = + selectedMonth.year == now.year && selectedMonth.month == now.month; + final isPastMonth = + DateTime(selectedMonth.year, selectedMonth.month).isBefore( + DateTime(now.year, now.month), + ); + + final totalBudget = status?.totalBudget ?? 0.0; + final totalSpent = status?.totalSpent ?? 0.0; + final flags = overrunFlags.isNotEmpty + ? overrunFlags + : (status?.overrunFlags ?? const []); + final onTrack = isOnTrack ?? + status?.isOnTrack ?? + (totalBudget > 0 && totalSpent <= totalBudget); + final hasBudget = totalBudget > 0; + + final currencyFormatter = NumberFormat.simpleCurrency(decimalDigits: 0); + final remainingFormatted = currencyFormatter.format( + (totalBudget - totalSpent).clamp(0, double.infinity), + ); + final exceededFormatted = currencyFormatter.format( + (totalSpent - totalBudget).clamp(0, double.infinity), + ); + + return Container( + key: const Key('under_budget_month_card'), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(28), + border: Border.all(color: const Color(0xFFE5E7EB)), + boxShadow: const [ + BoxShadow( + color: Color(0x08000000), + blurRadius: 16, + offset: Offset(0, 4), + ), + ], + ), + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Month navigation header + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + key: const Key('streak_calendar_prev_month'), + icon: const Icon(Icons.chevron_left_rounded), + onPressed: onPreviousMonth, + color: _primary, + ), + Text( + DateFormat('MMMM yyyy').format(selectedMonth), + style: GoogleFonts.manrope( + fontSize: 16, + fontWeight: FontWeight.bold, + color: _primary, + ), + ), + IconButton( + key: const Key('streak_calendar_next_month'), + icon: const Icon(Icons.chevron_right_rounded), + onPressed: onNextMonth, + color: _primary, + ), + ], + ), + const SizedBox(height: 12), + + // Status Badge Row + Row( + children: [ + Container( + key: const Key('under_budget_status_badge'), + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: !hasBudget + ? const Color(0xFFF3F4F6) + : onTrack + ? _successBg.withValues(alpha: 0.4) + : _errorBg, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + !hasBudget + ? Icons.info_outline_rounded + : onTrack + ? Icons.check_circle_outline_rounded + : Icons.warning_amber_rounded, + size: 16, + color: !hasBudget + ? _secondary + : onTrack + ? _successText + : _errorText, + ), + const SizedBox(width: 6), + Text( + !hasBudget + ? 'NO BUDGET SET' + : isCurrentMonth + ? (onTrack + ? 'ON TRACK (PREVIEW)' + : 'OFF TRACK (PREVIEW)') + : (onTrack ? 'QUALIFIED' : 'OVER BUDGET'), + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.bold, + color: !hasBudget + ? _secondary + : onTrack + ? _successText + : _errorText, + letterSpacing: 0.5, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + + // Explanatory note + Text( + _buildExplanatoryNote( + hasBudget: hasBudget, + isCurrentMonth: isCurrentMonth, + isPastMonth: isPastMonth, + onTrack: onTrack, + ), + style: GoogleFonts.inter( + fontSize: 13, + color: _secondary, + height: 1.4, + ), + ), + const SizedBox(height: 16), + + // Budget Numbers Overview + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'TOTAL SPENT', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w600, + color: _secondary, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 2), + Text( + currencyFormatter.format(totalSpent), + style: GoogleFonts.manrope( + fontSize: 20, + fontWeight: FontWeight.bold, + color: onTrack ? _primary : _errorText, + ), + ), + ], + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + 'BUDGET TARGET', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w600, + color: _secondary, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 2), + Text( + currencyFormatter.format(totalBudget), + style: GoogleFonts.manrope( + fontSize: 20, + fontWeight: FontWeight.bold, + color: _primary, + ), + ), + ], + ), + ], + ), + const SizedBox(height: 14), + + // Progress Bar + if (hasBudget) ...[ + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: LinearProgressIndicator( + key: const Key('under_budget_progress_bar'), + value: totalBudget > 0 + ? (totalSpent / totalBudget).clamp(0.0, 1.0) + : 0.0, + backgroundColor: const Color(0xFFF3F4F6), + color: onTrack ? _successText : _errorText, + minHeight: 10, + ), + ), + const SizedBox(height: 6), + + // Remaining / Overrun caption + Text( + onTrack + ? '$remainingFormatted remaining' + : 'Exceeded by $exceededFormatted', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w600, + color: onTrack ? _successText : _errorText, + ), + ), + ], + + // Category Overruns Section (R17) + if (flags.isNotEmpty) ...[ + const SizedBox(height: 20), + const Divider(color: Color(0xFFF3F4F6)), + const SizedBox(height: 10), + Row( + children: [ + const Icon( + Icons.flag_outlined, + size: 16, + color: _warningText, + ), + const SizedBox(width: 6), + Text( + 'Category Budget Overruns (${flags.length})', + style: GoogleFonts.manrope( + fontSize: 13, + fontWeight: FontWeight.bold, + color: _warningText, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Individual categories exceeded their budgets, ' + 'but total month qualifies.', + style: GoogleFonts.inter( + fontSize: 12, + color: _secondary, + ), + ), + const SizedBox(height: 8), + ...flags.map((flag) { + final spentFmt = currencyFormatter.format(flag.spent); + final budgetFmt = currencyFormatter.format(flag.budget); + final overFmt = currencyFormatter.format(flag.overrunAmount); + + return Container( + key: Key('category_overrun_flag_${flag.categoryUuid}'), + margin: const EdgeInsets.only(top: 6), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: _warningBg.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: _warningBg), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + flag.categoryName, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w600, + color: const Color(0xFF1E293B), + ), + overflow: TextOverflow.ellipsis, + ), + ), + Text( + '$spentFmt / $budgetFmt (+$overFmt)', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.bold, + color: _warningText, + ), + ), + ], + ), + ); + }), + ], + ], + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 8bd3cad..17fa98b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -593,10 +593,10 @@ packages: dependency: transitive description: name: matcher - sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.20" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -966,26 +966,26 @@ packages: dependency: transitive description: name: test - sha256: ca578dc12bb8b2f40b67b7d3bd2fac4f31c01a6ff7130a14e2597b919934507f + sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" url: "https://pub.dev" source: hosted - version: "1.31.1" + version: "1.31.0" test_api: dependency: transitive description: name: test_api - sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.12" + version: "0.7.11" test_core: dependency: transitive description: name: test_core - sha256: d2e98ec12998368dc59ddd47ab709f2cd55acd6b66dc7db764455a44082f4bc5 + sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" url: "https://pub.dev" source: hosted - version: "0.6.18" + version: "0.6.17" time: dependency: transitive description: @@ -1038,10 +1038,10 @@ packages: dependency: transitive description: name: vector_math - sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.2.0" very_good_analysis: dependency: "direct dev" description: diff --git a/test/app/router/app_router_test.dart b/test/app/router/app_router_test.dart index 85b1812..4187536 100644 --- a/test/app/router/app_router_test.dart +++ b/test/app/router/app_router_test.dart @@ -1,15 +1,49 @@ import 'package:expense_tracker/app/router/app_router.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_status.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_type.dart'; +import 'package:expense_tracker/features/streak/presentation/blocs/streak_cubit.dart'; +import 'package:expense_tracker/features/streak/presentation/blocs/streak_state.dart'; +import 'package:expense_tracker/features/streak/presentation/pages/streaks_page.dart'; +import 'package:expense_tracker/injector.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:google_fonts/google_fonts.dart'; +import 'package:mocktail/mocktail.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../../helpers/helpers.dart'; +class MockStreakCubit extends Mock implements StreakCubit {} + void main() { setUpAll(() async { SharedPreferences.setMockInitialValues({}); await configureInjector(); + + final mockStreakCubit = MockStreakCubit(); + when(() => mockStreakCubit.state).thenReturn( + StreakState( + isLoading: false, + streak: const Streak( + type: StreakType.tracking, + length: 5, + status: StreakStatus.active, + daysUntilBreak: 3, + nextMilestone: 7, + consistencyRate: 0.8, + ), + selectedMonth: DateTime(2026, 4), + monthlyConsistencyRate: 0.8, + ), + ); + when(() => mockStreakCubit.stream).thenAnswer((_) => const Stream.empty()); + when(mockStreakCubit.load).thenAnswer((_) async {}); + when(mockStreakCubit.close).thenAnswer((_) async {}); + + getIt + ..allowReassignment = true + ..registerFactory(() => mockStreakCubit); }); setUp(() => GoogleFonts.config.allowRuntimeFetching = false); @@ -21,7 +55,7 @@ void main() { }) { return tester.pumpWidget( MaterialApp.router( - routerConfig: router('/counter', () => isCounterUnlocked), + routerConfig: router(location, () => isCounterUnlocked), ), ); } @@ -44,4 +78,15 @@ void main() { expect(find.byIcon(Icons.visibility_off_outlined), findsOneWidget); expect(find.text('Counter'), findsOneWidget); }); + + testWidgets('routes to streaks page at /streaks', (tester) async { + await tester.pumpWidget( + MaterialApp.router( + routerConfig: router('/streaks'), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(StreaksPage), findsOneWidget); + }); } diff --git a/test/app/view/app_test.dart b/test/app/view/app_test.dart index 077f672..571a313 100644 --- a/test/app/view/app_test.dart +++ b/test/app/view/app_test.dart @@ -5,10 +5,13 @@ // license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +import 'package:dartz/dartz.dart'; +import 'package:expense_tracker/app/app.dart'; import 'package:expense_tracker/features/category/presentation/blocs/category_cubit.dart'; import 'package:expense_tracker/features/category/presentation/blocs/category_state.dart'; import 'package:expense_tracker/features/dashboard/presentation/blocs/dashboard_cubit.dart'; import 'package:expense_tracker/features/dashboard/presentation/blocs/dashboard_state.dart'; +import 'package:expense_tracker/features/streak/domain/repositories/app_open_repository.dart'; import 'package:expense_tracker/injector.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -23,9 +26,12 @@ class MockCategoryCubit extends Mock implements CategoryCubit {} class MockDashboardCubit extends Mock implements DashboardCubit {} +class MockAppOpenRepository extends Mock implements AppOpenRepository {} + void main() { late MockCategoryCubit mockCategoryCubit; late MockDashboardCubit mockDashboardCubit; + late MockAppOpenRepository mockAppOpenRepo; setUpAll(() async { SharedPreferences.setMockInitialValues({}); @@ -47,10 +53,14 @@ void main() { ).thenAnswer((_) async {}); when(() => mockDashboardCubit.close()).thenAnswer((_) async {}); + mockAppOpenRepo = MockAppOpenRepository(); + when(mockAppOpenRepo.recordOpen).thenAnswer((_) async => const Right(unit)); + getIt ..allowReassignment = true ..registerSingleton(mockCategoryCubit) - ..registerFactory(() => mockDashboardCubit); + ..registerFactory(() => mockDashboardCubit) + ..registerSingleton(mockAppOpenRepo); }); setUp(() => GoogleFonts.config.allowRuntimeFetching = false); @@ -66,5 +76,15 @@ void main() { ); expect(find.byType(MaterialApp, skipOffstage: false), findsOneWidget); }); + + testWidgets('records app open event once on startup', (tester) async { + when(mockAppOpenRepo.recordOpen) + .thenAnswer((_) async => const Right(unit)); + + await tester.pumpWidget(const App()); + await tester.pump(); + + verify(mockAppOpenRepo.recordOpen).called(1); + }); }); } diff --git a/test/core/storages/local_storages_test.dart b/test/core/storages/local_storages_test.dart new file mode 100644 index 0000000..518ce33 --- /dev/null +++ b/test/core/storages/local_storages_test.dart @@ -0,0 +1,86 @@ +import 'package:expense_tracker/core/storages/local_storages.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('LocalStorageImpl', () { + test('unset keys return default values (3, 3, 7) and null for apiKey', + () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final storage = LocalStorageImpl(prefs); + + expect(await storage.getApiKey(), isNull); + expect(await storage.getStreakWindowDays(), 3); + expect(await storage.getStreakMinimumDays(), 3); + expect(await storage.getAppOpenCadenceDays(), 7); + }); + + test('round-trip set and get for apiKey', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final storage = LocalStorageImpl(prefs); + + await storage.setApiKey('test-api-key-123'); + expect(await storage.getApiKey(), 'test-api-key-123'); + }); + + test('round-trip set and get for streakWindowDays', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final storage = LocalStorageImpl(prefs); + + await storage.setStreakWindowDays(5); + expect(await storage.getStreakWindowDays(), 5); + }); + + test('round-trip set and get for streakMinimumDays', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final storage = LocalStorageImpl(prefs); + + await storage.setStreakMinimumDays(4); + expect(await storage.getStreakMinimumDays(), 4); + }); + + test('round-trip set and get for appOpenCadenceDays', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final storage = LocalStorageImpl(prefs); + + await storage.setAppOpenCadenceDays(14); + expect(await storage.getAppOpenCadenceDays(), 14); + }); + + test('clamps out-of-range stored value (0 or negative) on read', () async { + SharedPreferences.setMockInitialValues({ + 'streakWindowDays': 0, + 'streakMinimumDays': -5, + 'appOpenCadenceDays': -1, + }); + final prefs = await SharedPreferences.getInstance(); + final storage = LocalStorageImpl(prefs); + + expect(await storage.getStreakWindowDays(), 1); + expect(await storage.getStreakMinimumDays(), 1); + expect(await storage.getAppOpenCadenceDays(), 1); + }); + + test('clamps out-of-range value (0 or negative) on set', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final storage = LocalStorageImpl(prefs); + + await storage.setStreakWindowDays(0); + expect(await storage.getStreakWindowDays(), 1); + + await storage.setStreakMinimumDays(-2); + expect(await storage.getStreakMinimumDays(), 1); + + await storage.setAppOpenCadenceDays(-10); + expect(await storage.getAppOpenCadenceDays(), 1); + }); + }); +} diff --git a/test/features/category/domain/utils/category_budget_calculator_test.dart b/test/features/category/domain/utils/category_budget_calculator_test.dart new file mode 100644 index 0000000..0521294 --- /dev/null +++ b/test/features/category/domain/utils/category_budget_calculator_test.dart @@ -0,0 +1,202 @@ +import 'package:expense_tracker/features/category/domain/entities/category.dart'; +import 'package:expense_tracker/features/category/domain/utils/category_budget_calculator.dart'; +import 'package:expense_tracker/features/transaction/domain/entities/transaction.dart'; +import 'package:expense_tracker/features/transaction/domain/entities/transaction_type.dart'; +import 'package:expense_tracker/shared/domain/entities/value_objects.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Category createCategory({ + required UniqueId uuid, + required String name, + UniqueId? parentId, + double budget = 0, + CategoryType type = CategoryType.expense, + }) { + return Category( + uuid: uuid, + name: StringSingleLine(name), + parentId: parentId, + isSynced: false, + updatedAt: DateTime.now(), + type: type, + expectedMonthlyBudget: budget, + behavioralModifier: BehavioralModifier.active, + ); + } + + Transaction createTx({ + required UniqueId categoryUuid, + required double amount, + TransactionType type = TransactionType.expense, + }) { + return Transaction( + uuid: UniqueId.generate(), + amount: Amount(amount), + description: StringSingleLine('Test Tx'), + date: DateTime(2026, 4, 15), + categoryUuid: categoryUuid, + type: type, + ); + } + + group('CategoryBudgetCalculator.calculateTotalBudget', () { + test('Calculates sum of leaf categories (Level 3) only', () { + final pillarId = UniqueId.generate(); + final subParentId = UniqueId.generate(); + final leaf1Id = UniqueId.generate(); + final leaf2Id = UniqueId.generate(); + + final categories = [ + createCategory( + uuid: pillarId, + name: 'Needs', + budget: 1000, + ), + createCategory( + uuid: subParentId, + name: 'Food', + parentId: pillarId, + budget: 600, + ), + createCategory( + uuid: leaf1Id, + name: 'Groceries', + parentId: subParentId, + budget: 400, + ), + createCategory( + uuid: leaf2Id, + name: 'Dining', + parentId: subParentId, + budget: 200, + ), + ]; + + final total = CategoryBudgetCalculator.calculateTotalBudget(categories); + expect(total, 600.0); + }); + + test('Ignores income categories when type is expense', () { + final pillarId = UniqueId.generate(); + final subParentId = UniqueId.generate(); + final leafExpense = UniqueId.generate(); + final leafIncome = UniqueId.generate(); + + final categories = [ + createCategory(uuid: pillarId, name: 'Pillar'), + createCategory(uuid: subParentId, name: 'Sub', parentId: pillarId), + createCategory( + uuid: leafExpense, + name: 'Expense Leaf', + parentId: subParentId, + budget: 500, + ), + createCategory( + uuid: leafIncome, + name: 'Income Leaf', + parentId: subParentId, + budget: 2000, + type: CategoryType.income, + ), + ]; + + final total = CategoryBudgetCalculator.calculateTotalBudget(categories); + expect(total, 500.0); + }); + }); + + group('CategoryBudgetCalculator.calculateOverrunFlags (R17)', () { + test('Returns empty when all spending is within category budget', () { + final pillarId = UniqueId.generate(); + final subParentId = UniqueId.generate(); + final leafId = UniqueId.generate(); + + final categories = [ + createCategory(uuid: pillarId, name: 'Pillar'), + createCategory(uuid: subParentId, name: 'Sub', parentId: pillarId), + createCategory( + uuid: leafId, + name: 'Groceries', + parentId: subParentId, + budget: 500, + ), + ]; + + final transactions = [ + createTx(categoryUuid: leafId, amount: 450), + ]; + + final flags = CategoryBudgetCalculator.calculateOverrunFlags( + allCategories: categories, + transactions: transactions, + ); + + expect(flags, isEmpty); + }); + + test('Identifies overruns when spending exceeds budget (AE8)', () { + final pillarId = UniqueId.generate(); + final subParentId = UniqueId.generate(); + final groceriesId = UniqueId.generate(); + final diningId = UniqueId.generate(); + + final categories = [ + createCategory(uuid: pillarId, name: 'Pillar'), + createCategory(uuid: subParentId, name: 'Sub', parentId: pillarId), + createCategory( + uuid: groceriesId, + name: 'Groceries', + parentId: subParentId, + budget: 300, + ), + createCategory( + uuid: diningId, + name: 'Dining', + parentId: subParentId, + budget: 200, + ), + ]; + + final transactions = [ + createTx(categoryUuid: groceriesId, amount: 350), // Over by 50 + createTx(categoryUuid: diningId, amount: 150), // Within budget + ]; + + final flags = CategoryBudgetCalculator.calculateOverrunFlags( + allCategories: categories, + transactions: transactions, + ); + + expect(flags.length, 1); + expect(flags.first.categoryName, 'Groceries'); + expect(flags.first.budget, 300.0); + expect(flags.first.spent, 350.0); + expect(flags.first.overrunAmount, 50.0); + }); + + test('Aggregates child spending into parent category with budget', () { + final parentId = UniqueId.generate(); + final childId = UniqueId.generate(); + + final categories = [ + createCategory(uuid: parentId, name: 'Transport', budget: 100), + createCategory(uuid: childId, name: 'Fuel', parentId: parentId), + ]; + + final transactions = [ + createTx(categoryUuid: childId, amount: 120), + ]; + + final flags = CategoryBudgetCalculator.calculateOverrunFlags( + allCategories: categories, + transactions: transactions, + ); + + expect(flags.length, 1); + expect(flags.first.categoryName, 'Transport'); + expect(flags.first.spent, 120.0); + expect(flags.first.overrunAmount, 20.0); + }); + }); +} diff --git a/test/features/dashboard/domain/usecases/get_dashboard_summary_usecase_test.dart b/test/features/dashboard/domain/usecases/get_dashboard_summary_usecase_test.dart index 6b4cdff..943b1eb 100644 --- a/test/features/dashboard/domain/usecases/get_dashboard_summary_usecase_test.dart +++ b/test/features/dashboard/domain/usecases/get_dashboard_summary_usecase_test.dart @@ -2,6 +2,10 @@ import 'package:dartz/dartz.dart'; import 'package:expense_tracker/core/domain/failures/failure.dart'; import 'package:expense_tracker/features/dashboard/domain/entities/dashboard_summary.dart'; import 'package:expense_tracker/features/dashboard/domain/usecases/get_dashboard_summary_usecase.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_status.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_type.dart'; +import 'package:expense_tracker/features/streak/domain/repositories/streak_repository.dart'; import 'package:expense_tracker/features/transaction/domain/entities/transaction.dart'; import 'package:expense_tracker/features/transaction/domain/entities/transaction_type.dart'; import 'package:expense_tracker/features/transaction/domain/repositories/transaction_repository.dart'; @@ -11,17 +15,41 @@ import 'package:mocktail/mocktail.dart'; class MockTransactionRepository extends Mock implements TransactionRepository {} +class MockStreakRepository extends Mock implements StreakRepository {} + void main() { late MockTransactionRepository mockRepository; + late MockStreakRepository mockStreakRepository; late GetDashboardSummaryUseCase useCase; // Fixed deterministic anchor: Sept 21, 2026 final fixedNow = DateTime(2026, 9, 21, 15, 30); final tCategoryUuid = UniqueId.generate(); + const tStreak = Streak( + type: StreakType.tracking, + length: 5, + status: StreakStatus.active, + daysUntilBreak: 2, + nextMilestone: 7, + consistencyRate: 0.8, + bestLength: 10, + ); + + setUpAll(() { + registerFallbackValue(StreakType.tracking); + }); + setUp(() { mockRepository = MockTransactionRepository(); - useCase = GetDashboardSummaryUseCase(mockRepository); + mockStreakRepository = MockStreakRepository(); + when( + () => mockStreakRepository.getStreak( + type: any(named: 'type'), + referenceDate: any(named: 'referenceDate'), + ), + ).thenAnswer((_) async => const Right(tStreak)); + useCase = GetDashboardSummaryUseCase(mockRepository, mockStreakRepository); }); Transaction makeTx({ @@ -234,6 +262,49 @@ void main() { ); }); + test('attaches resolved streak to dashboard summary', () async { + when(() => mockRepository.getTransactions()) + .thenAnswer((_) async => const Right([])); + + final result = await useCase.executeWithDate(fixedNow); + + expect(result.isRight(), isTrue); + result.fold( + (_) => fail('Expected Right'), + (summary) { + expect(summary.streak, tStreak); + }, + ); + }); + + test( + 'degrades gracefully when streak repository fails: returns Right with ' + 'empty streak', + () async { + when(() => mockRepository.getTransactions()) + .thenAnswer((_) async => const Right([])); + when( + () => mockStreakRepository.getStreak( + type: any(named: 'type'), + referenceDate: any(named: 'referenceDate'), + ), + ).thenAnswer( + (_) async => const Left(Failure.localFailure(message: 'Streak error')), + ); + + final result = await useCase.executeWithDate(fixedNow); + + expect(result.isRight(), isTrue); + result.fold( + (_) => fail('Expected Right'), + (summary) { + expect(summary.streak, const Streak.empty()); + expect(summary.streak.isEmpty, isTrue); + }, + ); + }, + ); + test('propagates repository failure as Left(Failure)', () async { const failure = Failure.localFailure(message: 'Database unavailable'); when(() => mockRepository.getTransactions()).thenAnswer( diff --git a/test/features/dashboard/presentation/blocs/dashboard_cubit_test.dart b/test/features/dashboard/presentation/blocs/dashboard_cubit_test.dart index 2b3ff5c..3d7d75c 100644 --- a/test/features/dashboard/presentation/blocs/dashboard_cubit_test.dart +++ b/test/features/dashboard/presentation/blocs/dashboard_cubit_test.dart @@ -9,6 +9,9 @@ import 'package:expense_tracker/features/dashboard/domain/entities/wealth_trajec import 'package:expense_tracker/features/dashboard/domain/usecases/get_dashboard_summary_usecase.dart'; import 'package:expense_tracker/features/dashboard/presentation/blocs/dashboard_cubit.dart'; import 'package:expense_tracker/features/dashboard/presentation/blocs/dashboard_state.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_status.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_type.dart'; import 'package:expense_tracker/features/transaction/domain/entities/transaction.dart'; import 'package:expense_tracker/features/transaction/domain/entities/transaction_type.dart'; import 'package:expense_tracker/features/transaction/domain/usecases/watch_transactions_use_case.dart'; @@ -91,6 +94,16 @@ void main() { headlineDescription: 'Your net worth increased by 25.0% this month.', ); + const tStreak = Streak( + type: StreakType.tracking, + length: 5, + status: StreakStatus.active, + daysUntilBreak: 2, + nextMilestone: 7, + consistencyRate: 0.8, + bestLength: 10, + ); + final tSummary = DashboardSummary( totalBalance: 180, totalIncome: 350, @@ -100,6 +113,7 @@ void main() { createTx(amount: 50, type: TransactionType.expense, desc: 'Expense 1'), ], wealthTrajectory: tTrajectory, + streak: tStreak, ); group('DashboardCubit', () { @@ -110,6 +124,7 @@ void main() { expect(cubit.state.totalExpense, 0); expect(cubit.state.recentTransactions, isEmpty); expect(cubit.state.wealthTrajectory, isNull); + expect(cubit.state.streak, const Streak.empty()); expect(cubit.state.failureOption, const None()); }); @@ -144,6 +159,7 @@ void main() { 'wealthTrajectory', tTrajectory, ) + .having((s) => s.streak, 'streak', tStreak) .having( (s) => s.failureOption, 'failureOption', @@ -202,6 +218,83 @@ void main() { (s) => s.wealthTrajectory, 'wealthTrajectory', tTrajectory, + ) + .having((s) => s.streak, 'streak', tStreak), + ], + ); + + blocTest( + 'should update streak when watchTransactions emits an update', + build: () { + final streamController = + StreamController>>(); + when(() => mockWatchUseCase(any())) + .thenAnswer((_) => streamController.stream); + + const updatedStreak = Streak( + type: StreakType.tracking, + length: 6, + status: StreakStatus.active, + daysUntilBreak: 3, + nextMilestone: 7, + consistencyRate: 0.85, + bestLength: 10, + ); + + final updatedSummary = DashboardSummary( + totalBalance: 200, + totalIncome: 400, + totalExpense: 200, + recentTransactions: const [], + wealthTrajectory: tTrajectory, + streak: updatedStreak, + ); + + when(() => mockUseCase(any())) + .thenAnswer((_) async => Right(updatedSummary)); + + final c = DashboardCubit(mockUseCase, mockWatchUseCase); + streamController.add(const Right([])); + return c; + }, + expect: () => [ + isA() + .having((s) => s.isLoading, 'isLoading', isFalse) + .having((s) => s.totalBalance, 'totalBalance', 200) + .having( + (s) => s.streak, + 'streak', + isA().having((st) => st.length, 'length', 6), + ), + ], + ); + + blocTest( + 'should emit empty streak without failure when summary streak is empty', + build: () { + final summaryWithEmptyStreak = DashboardSummary( + totalBalance: 180, + totalIncome: 350, + totalExpense: 170, + recentTransactions: const [], + wealthTrajectory: tTrajectory, + ); + when(() => mockUseCase(any())) + .thenAnswer((_) async => Right(summaryWithEmptyStreak)); + return cubit; + }, + act: (cubit) => cubit.loadDashboardData(), + expect: () => [ + isA() + .having((s) => s.isLoading, 'isLoading', isTrue), + isA() + .having((s) => s.isLoading, 'isLoading', isFalse) + .having((s) => s.streak, 'streak', const Streak.empty()) + .having((s) => s.streak.isEmpty, 'streak.isEmpty', isTrue) + .having( + (s) => s.failureOption, + 'failureOption', + const None(), ), ], ); diff --git a/test/features/dashboard/presentation/pages/home_page_test.dart b/test/features/dashboard/presentation/pages/home_page_test.dart new file mode 100644 index 0000000..04f0ad0 --- /dev/null +++ b/test/features/dashboard/presentation/pages/home_page_test.dart @@ -0,0 +1,116 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:expense_tracker/features/dashboard/presentation/blocs/dashboard_cubit.dart'; +import 'package:expense_tracker/features/dashboard/presentation/blocs/dashboard_state.dart'; +import 'package:expense_tracker/features/dashboard/presentation/pages/home_page.dart'; +import 'package:expense_tracker/features/dashboard/presentation/widgets/summary_card.dart'; +import 'package:expense_tracker/features/dashboard/presentation/widgets/wealth_trajectory_chart.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_status.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_type.dart'; +import 'package:expense_tracker/features/streak/presentation/widgets/streak_badge.dart'; +import 'package:expense_tracker/features/streak/presentation/widgets/streak_card.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockDashboardCubit extends MockCubit + implements DashboardCubit {} + +void main() { + late MockDashboardCubit mockCubit; + + setUp(() { + GoogleFonts.config.allowRuntimeFetching = false; + mockCubit = MockDashboardCubit(); + when(() => mockCubit.loadDashboardData()) + .thenAnswer((_) async {}); + }); + + Widget buildHomePage() { + return MaterialApp( + home: BlocProvider.value( + value: mockCubit, + child: const HomePage(), + ), + ); + } + + testWidgets( + 'renders StreakBadge in header', + (tester) async { + const streak = Streak( + type: StreakType.tracking, + length: 5, + status: StreakStatus.active, + daysUntilBreak: 2, + nextMilestone: 7, + consistencyRate: 0.8, + ); + + when(() => mockCubit.state).thenReturn( + const DashboardState( + isLoading: false, + streak: streak, + ), + ); + + await tester.pumpWidget(buildHomePage()); + + expect(find.byType(StreakBadge), findsOneWidget); + expect(find.text('5'), findsOneWidget); + }, + ); + + testWidgets( + 'renders StreakCard between SummaryCard and WealthTrajectoryChart ' + 'when streak is not empty', + (tester) async { + const streak = Streak( + type: StreakType.tracking, + length: 5, + status: StreakStatus.active, + daysUntilBreak: 2, + nextMilestone: 7, + consistencyRate: 0.8, + ); + + when(() => mockCubit.state).thenReturn( + const DashboardState( + isLoading: false, + streak: streak, + ), + ); + + await tester.pumpWidget(buildHomePage()); + + expect(find.byType(SummaryCard), findsOneWidget); + expect(find.byType(StreakCard), findsOneWidget); + expect(find.byType(WealthTrajectoryChart), findsOneWidget); + + final summaryTop = tester.getTopLeft(find.byType(SummaryCard)).dy; + final streakTop = tester.getTopLeft(find.byType(StreakCard)).dy; + final wealthTop = + tester.getTopLeft(find.byType(WealthTrajectoryChart)).dy; + + expect(summaryTop < streakTop, isTrue); + expect(streakTop < wealthTop, isTrue); + }, + ); + + testWidgets( + 'omits StreakCard when streak is empty', + (tester) async { + when(() => mockCubit.state).thenReturn( + const DashboardState(isLoading: false), + ); + + await tester.pumpWidget(buildHomePage()); + + expect(find.byType(SummaryCard), findsOneWidget); + expect(find.byType(StreakCard), findsNothing); + expect(find.byType(WealthTrajectoryChart), findsOneWidget); + }, + ); +} diff --git a/test/features/settings/presentation/pages/settings_page_test.dart b/test/features/settings/presentation/pages/settings_page_test.dart index cc7c6ae..74bca32 100644 --- a/test/features/settings/presentation/pages/settings_page_test.dart +++ b/test/features/settings/presentation/pages/settings_page_test.dart @@ -77,4 +77,25 @@ void main() { expect(find.text('Counter'), findsOneWidget); expect(find.text('Secret unlocked — Counter is below'), findsOneWidget); }); + + testWidgets('streak tuning tile appears only when unlocked', (tester) async { + final cubit = await buildCubit(); + await tester.pumpApp(SettingsPage(easterEggCubit: cubit)); + expect(find.text('Streak Tuning'), findsNothing); + + for (var i = 0; i < 7; i++) { + await tester.tap(find.text('Version')); + await tester.pump(); + } + await tester.tap(find.text('Got it')); + await tester.pumpAndSettle(); + + cubit + ..onTransactionLogged() + ..onStatsVisited() + ..onCategoriesOpened(); + await tester.pumpAndSettle(); + + expect(find.text('Streak Tuning'), findsOneWidget); + }); } diff --git a/test/features/streak/data/models/app_open_model_test.dart b/test/features/streak/data/models/app_open_model_test.dart new file mode 100644 index 0000000..35617f9 --- /dev/null +++ b/test/features/streak/data/models/app_open_model_test.dart @@ -0,0 +1,34 @@ +import 'package:expense_tracker/features/streak/data/models/app_open_model.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('AppOpenModel', () { + test('create factory instantiates model with uuid and date', () { + final now = DateTime(2026, 9, 26, 10, 30); + final model = AppOpenModel.create( + uuid: 'open-123', + date: now, + ); + + expect(model.uuid, equals('open-123')); + expect(model.date, equals(now)); + }); + + test( + 'multiple opens on same day persist as separate records with ' + 'unique uuids', () { + final date = DateTime(2026, 9, 26); + final morningOpen = AppOpenModel.create( + uuid: 'open-morning', + date: DateTime(date.year, date.month, date.day, 8), + ); + final eveningOpen = AppOpenModel.create( + uuid: 'open-evening', + date: DateTime(date.year, date.month, date.day, 20), + ); + + expect(morningOpen.uuid, isNot(equals(eveningOpen.uuid))); + expect(morningOpen.date.day, equals(eveningOpen.date.day)); + }); + }); +} diff --git a/test/features/streak/data/repositories/app_open_repository_impl_test.dart b/test/features/streak/data/repositories/app_open_repository_impl_test.dart new file mode 100644 index 0000000..8869724 --- /dev/null +++ b/test/features/streak/data/repositories/app_open_repository_impl_test.dart @@ -0,0 +1,189 @@ +import 'package:dartz/dartz.dart'; +import 'package:expense_tracker/core/domain/failures/failure.dart'; +import 'package:expense_tracker/features/streak/data/datasources/app_open_local_data_source.dart'; +import 'package:expense_tracker/features/streak/data/models/app_open_model.dart'; +import 'package:expense_tracker/features/streak/data/repositories/app_open_repository_impl.dart'; +import 'package:expense_tracker/features/streak/domain/entities/app_open.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockAppOpenLocalDataSource extends Mock + implements AppOpenLocalDataSource {} + +void main() { + late MockAppOpenLocalDataSource mockDataSource; + late AppOpenRepositoryImpl repository; + + setUp(() { + mockDataSource = MockAppOpenLocalDataSource(); + repository = AppOpenRepositoryImpl(mockDataSource); + }); + + group('AppOpenRepositoryImpl.recordOpen', () { + test('delegates to local data source and returns Right(unit)', () async { + final openDate = DateTime(2026, 9, 26, 8, 30); + when( + () => mockDataSource.recordOpen( + date: any(named: 'date'), + uuid: any(named: 'uuid'), + ), + ).thenAnswer((_) async {}); + + final result = await repository.recordOpen( + date: openDate, + uuid: 'test-uuid', + ); + + expect(result, equals(const Right(unit))); + verify( + () => mockDataSource.recordOpen( + date: openDate, + uuid: 'test-uuid', + ), + ).called(1); + }); + + test( + 'multiple opens on same day persist as separate calls to datasource', + () async { + when( + () => mockDataSource.recordOpen( + date: any(named: 'date'), + uuid: any(named: 'uuid'), + ), + ).thenAnswer((_) async {}); + + final morning = DateTime(2026, 9, 26, 9); + final evening = DateTime(2026, 9, 26, 21); + + await repository.recordOpen(date: morning, uuid: 'uuid-1'); + await repository.recordOpen(date: evening, uuid: 'uuid-2'); + + verify( + () => mockDataSource.recordOpen( + date: morning, + uuid: 'uuid-1', + ), + ).called(1); + verify( + () => mockDataSource.recordOpen( + date: evening, + uuid: 'uuid-2', + ), + ).called(1); + }); + + test('returns Left(Failure.localFailure) when datasource throws', () async { + when( + () => mockDataSource.recordOpen( + date: any(named: 'date'), + uuid: any(named: 'uuid'), + ), + ).thenThrow(Exception('database error')); + + final result = await repository.recordOpen(); + + expect(result.isLeft(), isTrue); + result.fold( + (failure) => expect(failure, isA()), + (_) => fail('expected Left'), + ); + }); + }); + + group('AppOpenRepositoryImpl.getOpenDates', () { + test('returns sorted date list from local data source in date order', + () async { + final sortedDates = [ + DateTime(2026, 9, 20), + DateTime(2026, 9, 22), + DateTime(2026, 9, 25), + ]; + final start = DateTime(2026, 9); + final end = DateTime(2026, 9, 30); + + when( + () => mockDataSource.getOpenDates( + startDate: any(named: 'startDate'), + endDate: any(named: 'endDate'), + ), + ).thenAnswer((_) async => sortedDates); + + final result = await repository.getOpenDates( + startDate: start, + endDate: end, + ); + + expect(result.isRight(), isTrue); + expect( + result.getOrElse(() => fail('expected Right')), + equals(sortedDates), + ); + verify( + () => mockDataSource.getOpenDates( + startDate: start, + endDate: end, + ), + ).called(1); + }); + + test('returns Left(Failure.localFailure) when datasource throws', () async { + when( + () => mockDataSource.getOpenDates( + startDate: any(named: 'startDate'), + endDate: any(named: 'endDate'), + ), + ).thenThrow(Exception('read failure')); + + final result = await repository.getOpenDates(); + + expect(result.isLeft(), isTrue); + }); + }); + + group('AppOpenRepositoryImpl.getAppOpens', () { + test('maps models to domain AppOpen entities', () async { + final models = [ + AppOpenModel.create( + uuid: 'id-1', + date: DateTime(2026, 9, 20), + ), + AppOpenModel.create( + uuid: 'id-2', + date: DateTime(2026, 9, 21), + ), + ]; + + when( + () => mockDataSource.getAppOpens( + startDate: any(named: 'startDate'), + endDate: any(named: 'endDate'), + ), + ).thenAnswer((_) async => models); + + final result = await repository.getAppOpens(); + + expect(result.isRight(), isTrue); + expect( + result.getOrElse(() => fail('expected Right')), + equals([ + AppOpen(uuid: 'id-1', date: DateTime(2026, 9, 20)), + AppOpen(uuid: 'id-2', date: DateTime(2026, 9, 21)), + ]), + ); + }); + + test('returns Left(Failure.localFailure) when datasource throws', () async { + when( + () => mockDataSource.getAppOpens( + startDate: any(named: 'startDate'), + endDate: any(named: 'endDate'), + ), + ).thenThrow(Exception('fetch error')); + + final result = await repository.getAppOpens(); + + expect(result.isLeft(), isTrue); + }); + }); +} diff --git a/test/features/streak/data/repositories/streak_repository_impl_test.dart b/test/features/streak/data/repositories/streak_repository_impl_test.dart new file mode 100644 index 0000000..9c178c4 --- /dev/null +++ b/test/features/streak/data/repositories/streak_repository_impl_test.dart @@ -0,0 +1,932 @@ +import 'package:dartz/dartz.dart'; +import 'package:expense_tracker/core/domain/failures/failure.dart'; +import 'package:expense_tracker/core/storages/local_storages.dart'; +import 'package:expense_tracker/features/category/domain/entities/category.dart'; +import 'package:expense_tracker/features/category/domain/repositories/category_repository.dart'; +import 'package:expense_tracker/features/streak/data/repositories/streak_repository_impl.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_period.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_status.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_type.dart'; +import 'package:expense_tracker/features/streak/domain/repositories/app_open_repository.dart'; +import 'package:expense_tracker/features/transaction/domain/entities/transaction.dart'; +import 'package:expense_tracker/features/transaction/domain/entities/transaction_type.dart'; +import 'package:expense_tracker/features/transaction/domain/repositories/transaction_repository.dart'; +import 'package:expense_tracker/shared/domain/entities/value_objects.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockTransactionRepository extends Mock + implements TransactionRepository {} + +class MockLocalStorage extends Mock implements LocalStorage {} + +class MockAppOpenRepository extends Mock implements AppOpenRepository {} + +class MockCategoryRepository extends Mock implements CategoryRepository {} + +void main() { + late MockTransactionRepository mockTxRepo; + late MockLocalStorage mockStorage; + late MockAppOpenRepository mockAppOpenRepo; + late MockCategoryRepository mockCategoryRepo; + late StreakRepositoryImpl repository; + + setUp(() { + mockTxRepo = MockTransactionRepository(); + mockStorage = MockLocalStorage(); + mockAppOpenRepo = MockAppOpenRepository(); + mockCategoryRepo = MockCategoryRepository(); + repository = StreakRepositoryImpl( + mockTxRepo, + mockStorage, + mockAppOpenRepo, + mockCategoryRepo, + ); + + // Default storage mocks + when(() => mockStorage.getStreakWindowDays()).thenAnswer((_) async => 3); + when(() => mockStorage.getStreakMinimumDays()).thenAnswer((_) async => 3); + when(() => mockStorage.getAppOpenCadenceDays()).thenAnswer((_) async => 7); + when(() => mockCategoryRepo.watchCategories()) + .thenAnswer((_) => Stream.value(const Right([]))); + }); + + Transaction createTx( + DateTime date, { + TransactionType type = TransactionType.expense, + double amount = 100, + UniqueId? categoryUuid, + }) { + return Transaction( + uuid: UniqueId.generate(), + amount: Amount(amount), + description: StringSingleLine('Test transaction'), + date: date, + categoryUuid: categoryUuid ?? UniqueId.generate(), + type: type, + ); + } + + Category createCategory({ + required UniqueId uuid, + required String name, + UniqueId? parentId, + double budget = 0, + CategoryType type = CategoryType.expense, + }) { + return Category( + uuid: uuid, + name: StringSingleLine(name), + parentId: parentId, + isSynced: false, + updatedAt: DateTime.now(), + type: type, + expectedMonthlyBudget: budget, + behavioralModifier: BehavioralModifier.active, + ); + } + + List createStandardTestCategories({ + required Map leafBudgets, + }) { + final pillarId = UniqueId.generate(); + final subParentId = UniqueId.generate(); + final categories = [ + createCategory(uuid: pillarId, name: 'Essential'), + createCategory(uuid: subParentId, name: 'Living', parentId: pillarId), + ]; + + leafBudgets.forEach((leafId, budget) { + final idStr = leafId.getOrCrash(); + final suffix = idStr.length >= 4 ? idStr.substring(0, 4) : idStr; + categories.add( + createCategory( + uuid: leafId, + name: 'Envelope $suffix', + parentId: subParentId, + budget: budget, + ), + ); + }); + + return categories; + } + + group('StreakRepositoryImpl.getStreak - Tracking', () { + test( + 'Records discrete transaction dates and produces active streak ' + 'when meeting minimum count', () async { + final refDate = DateTime(2026, 9, 25, 14); + final transactions = [ + createTx(DateTime(2026, 9, 21, 10)), + createTx(DateTime(2026, 9, 22, 11)), + createTx(DateTime(2026, 9, 23, 9)), + createTx(DateTime(2026, 9, 24, 15)), + createTx(DateTime(2026, 9, 25, 12)), + ]; + + when( + () => mockTxRepo.getTransactions( + startDate: any(named: 'startDate'), + endDate: any(named: 'endDate'), + ), + ).thenAnswer((_) async => Right(transactions)); + + final result = await repository.getStreak(referenceDate: refDate); + + expect(result.isRight(), isTrue); + result.fold( + (_) => fail('Expected Right'), + (streak) { + expect(streak.length, 5); + expect(streak.status, StreakStatus.active); + expect(streak.bestLength, 5); + expect(streak.daysUntilBreak, 3); + }, + ); + }); + + test('Warming up when count is below minimum', () async { + final refDate = DateTime(2026, 9, 25, 12); + final transactions = [ + createTx(DateTime(2026, 9, 24, 10)), + createTx(DateTime(2026, 9, 25, 12)), + ]; + + when( + () => mockTxRepo.getTransactions( + startDate: any(named: 'startDate'), + endDate: any(named: 'endDate'), + ), + ).thenAnswer((_) async => Right(transactions)); + + final result = await repository.getStreak(referenceDate: refDate); + + expect(result.isRight(), isTrue); + result.fold( + (_) => fail('Expected Right'), + (streak) { + expect(streak.length, 2); + expect(streak.status, StreakStatus.warmingUp); + }, + ); + }); + + test('At risk when last recorded transaction was at the edge of window', + () async { + final refDate = DateTime(2026, 9, 25, 12); + final transactions = [ + createTx(DateTime(2026, 9, 20, 10)), + createTx(DateTime(2026, 9, 21, 10)), + createTx(DateTime(2026, 9, 22, 10)), + ]; + + when( + () => mockTxRepo.getTransactions( + startDate: any(named: 'startDate'), + endDate: any(named: 'endDate'), + ), + ).thenAnswer((_) async => Right(transactions)); + + final result = await repository.getStreak(referenceDate: refDate); + + expect(result.isRight(), isTrue); + result.fold( + (_) => fail('Expected Right'), + (streak) { + expect(streak.length, 3); + expect(streak.status, StreakStatus.atRisk); + expect(streak.daysUntilBreak, 1); + }, + ); + }); + + test('Broken when gap exceeds window', () async { + final refDate = DateTime(2026, 9, 25, 12); + final transactions = [ + createTx(DateTime(2026, 9, 18, 10)), + createTx(DateTime(2026, 9, 19, 10)), + createTx(DateTime(2026, 9, 20, 10)), + createTx(DateTime(2026, 9, 21, 10)), + ]; + + when( + () => mockTxRepo.getTransactions( + startDate: any(named: 'startDate'), + endDate: any(named: 'endDate'), + ), + ).thenAnswer((_) async => Right(transactions)); + + final result = await repository.getStreak(referenceDate: refDate); + + expect(result.isRight(), isTrue); + result.fold( + (_) => fail('Expected Right'), + (streak) { + expect(streak.length, 0); + expect(streak.status, StreakStatus.broken); + expect(streak.bestLength, 4); + }, + ); + }); + + test('Empty transactions returns empty streak', () async { + when( + () => mockTxRepo.getTransactions( + startDate: any(named: 'startDate'), + endDate: any(named: 'endDate'), + ), + ).thenAnswer((_) async => const Right([])); + + final result = await repository.getStreak(); + + expect(result.isRight(), isTrue); + result.fold( + (_) => fail('Expected Right'), + (streak) { + expect(streak.isEmpty, isTrue); + expect(streak.length, 0); + expect(streak.status, StreakStatus.none); + }, + ); + }); + + test('Propagates TransactionRepository failure as Left', () async { + when( + () => mockTxRepo.getTransactions( + startDate: any(named: 'startDate'), + endDate: any(named: 'endDate'), + ), + ).thenAnswer( + (_) async => const Left( + Failure.localFailure(message: 'Database query failed'), + ), + ); + + final result = await repository.getStreak(); + + expect(result.isLeft(), isTrue); + }); + }); + + group('StreakRepositoryImpl.getStreak - No-Spend', () { + test( + 'Expense-free days build a run; any expense that day ' + 'disqualifies only that day', () async { + final refDate = DateTime(2026, 9, 25, 12); + final transactions = [ + createTx(DateTime(2026, 9, 20, 10), type: TransactionType.income), + createTx(DateTime(2026, 9, 21, 11), type: TransactionType.income), + createTx(DateTime(2026, 9, 22, 12)), + createTx(DateTime(2026, 9, 23, 14), type: TransactionType.income), + createTx(DateTime(2026, 9, 24, 15), type: TransactionType.income), + createTx(DateTime(2026, 9, 25, 9), type: TransactionType.income), + ]; + + when( + () => mockTxRepo.getTransactions( + startDate: any(named: 'startDate'), + endDate: any(named: 'endDate'), + ), + ).thenAnswer((_) async => Right(transactions)); + + final result = await repository.getStreak( + type: StreakType.noSpend, + referenceDate: refDate, + ); + + expect(result.isRight(), isTrue); + result.fold( + (_) => fail('Expected Right'), + (streak) { + expect(streak.length, 6); + expect(streak.status, StreakStatus.active); + expect(streak.daysUntilBreak, 3); + }, + ); + }); + + test( + 'Expense on last day of window coverage -> ' + 'at risk with 1 day until break', () async { + final refDate = DateTime(2026, 9, 25, 12); + final transactions = [ + createTx(DateTime(2026, 9, 20, 10), type: TransactionType.income), + createTx(DateTime(2026, 9, 21, 10), type: TransactionType.income), + createTx(DateTime(2026, 9, 22, 10), type: TransactionType.income), + createTx(DateTime(2026, 9, 23, 10)), + createTx(DateTime(2026, 9, 24, 10)), + createTx(DateTime(2026, 9, 25, 10)), + ]; + + when( + () => mockTxRepo.getTransactions( + startDate: any(named: 'startDate'), + endDate: any(named: 'endDate'), + ), + ).thenAnswer((_) async => Right(transactions)); + + final result = await repository.getStreak( + type: StreakType.noSpend, + referenceDate: refDate, + ); + + expect(result.isRight(), isTrue); + result.fold( + (_) => fail('Expected Right'), + (streak) { + expect(streak.length, 3); + expect(streak.status, StreakStatus.atRisk); + expect(streak.daysUntilBreak, 1); + }, + ); + }); + + test('Empty transactions -> empty noSpend streak', () async { + when( + () => mockTxRepo.getTransactions( + startDate: any(named: 'startDate'), + endDate: any(named: 'endDate'), + ), + ).thenAnswer((_) async => const Right([])); + + final result = await repository.getStreak(type: StreakType.noSpend); + + expect(result.isRight(), isTrue); + result.fold( + (_) => fail('Expected Right'), + (streak) { + expect(streak.isEmpty, isTrue); + expect(streak.length, 0); + }, + ); + }); + }); + + group('StreakRepositoryImpl.getStreak - App-Open Consistency', () { + test( + 'Covers AE5: Cadence 1 consecutive-day opens sustain run; ' + 'skipped day consumes capacity -> at risk then broken', () async { + when(() => mockStorage.getAppOpenCadenceDays()) + .thenAnswer((_) async => 1); + final openDates = [ + DateTime(2026, 9, 20, 9), + DateTime(2026, 9, 21, 10), + DateTime(2026, 9, 22, 8), + ]; + + when( + () => mockAppOpenRepo.getOpenDates( + startDate: any(named: 'startDate'), + endDate: any(named: 'endDate'), + ), + ).thenAnswer((_) async => Right(openDates)); + + final resultDay22 = await repository.getStreak( + type: StreakType.appOpen, + referenceDate: DateTime(2026, 9, 22, 12), + ); + resultDay22.fold( + (_) => fail('Expected Right'), + (streak) { + expect(streak.length, 3); + expect(streak.status, StreakStatus.active); + expect(streak.daysUntilBreak, 1); + }, + ); + + final resultDay23 = await repository.getStreak( + type: StreakType.appOpen, + referenceDate: DateTime(2026, 9, 23, 12), + ); + resultDay23.fold( + (_) => fail('Expected Right'), + (streak) { + expect(streak.length, 3); + expect(streak.status, StreakStatus.atRisk); + expect(streak.daysUntilBreak, 1); + }, + ); + + final resultDay24 = await repository.getStreak( + type: StreakType.appOpen, + referenceDate: DateTime(2026, 9, 24, 12), + ); + resultDay24.fold( + (_) => fail('Expected Right'), + (streak) { + expect(streak.length, 0); + expect(streak.status, StreakStatus.broken); + expect(streak.bestLength, 3); + }, + ); + }); + + test( + 'Covers AE6: Cadence 7 opens on 1st and 7th continue; ' + '9th breaks', () async { + when(() => mockStorage.getAppOpenCadenceDays()) + .thenAnswer((_) async => 7); + when(() => mockStorage.getStreakMinimumDays()) + .thenAnswer((_) async => 1); + + final opensSustained = [ + DateTime(2026, 9, 1, 9), + DateTime(2026, 9, 7, 10), + ]; + + when( + () => mockAppOpenRepo.getOpenDates( + startDate: any(named: 'startDate'), + endDate: any(named: 'endDate'), + ), + ).thenAnswer((_) async => Right(opensSustained)); + + final resultSept7 = await repository.getStreak( + type: StreakType.appOpen, + referenceDate: DateTime(2026, 9, 7, 12), + ); + resultSept7.fold( + (_) => fail('Expected Right'), + (streak) { + expect(streak.length, 7); + expect(streak.status, StreakStatus.active); + }, + ); + + final opensBroken = [ + DateTime(2026, 9, 1, 9), + DateTime(2026, 9, 9, 10), + ]; + + when( + () => mockAppOpenRepo.getOpenDates( + startDate: any(named: 'startDate'), + endDate: any(named: 'endDate'), + ), + ).thenAnswer((_) async => Right(opensBroken)); + + final resultSept9 = await repository.getStreak( + type: StreakType.appOpen, + referenceDate: DateTime(2026, 9, 9, 12), + ); + resultSept9.fold( + (_) => fail('Expected Right'), + (streak) { + expect(streak.length, 1); + expect(streak.bestLength, 1); + }, + ); + }); + }); + + group('StreakRepositoryImpl.getStreak - Under-Budget (Phase 3)', () { + test( + 'Covers AE7: Mid-month overspend leaves the count unchanged; ' + 'preview shows off track', () async { + when(() => mockStorage.getStreakMinimumDays()).thenAnswer((_) async => 1); + + final leafId = UniqueId.generate(); + final categories = createStandardTestCategories( + leafBudgets: {leafId: 1000}, + ); + when(() => mockCategoryRepo.watchCategories()) + .thenAnswer((_) => Stream.value(Right(categories))); + + final transactions = [ + createTx(DateTime(2026, 3, 10), amount: 600, categoryUuid: leafId), + createTx(DateTime(2026, 4, 12), amount: 1500, categoryUuid: leafId), + ]; + + when( + () => mockTxRepo.getTransactions( + startDate: any(named: 'startDate'), + endDate: any(named: 'endDate'), + ), + ).thenAnswer((_) async => Right(transactions)); + + final result = await repository.getStreak( + type: StreakType.underBudget, + referenceDate: DateTime(2026, 4, 15), + ); + + expect(result.isRight(), isTrue); + result.fold( + (_) => fail('Expected Right'), + (streak) { + expect(streak.length, 1); + expect(streak.status, StreakStatus.active); + expect(streak.isCurrentMonthOnTrack, isFalse); + }, + ); + }); + + test( + 'Covers AE8: Completed month within total budgets with one category ' + 'over its budget -> qualifies for streak', () async { + when(() => mockStorage.getStreakMinimumDays()).thenAnswer((_) async => 1); + + final groceriesId = UniqueId.generate(); + final diningId = UniqueId.generate(); + final categories = createStandardTestCategories( + leafBudgets: { + groceriesId: 300, + diningId: 700, + }, + ); + when(() => mockCategoryRepo.watchCategories()) + .thenAnswer((_) => Stream.value(Right(categories))); + + final transactions = [ + createTx( + DateTime(2026, 3, 10), + amount: 400, + categoryUuid: groceriesId, + ), + createTx( + DateTime(2026, 3, 15), + amount: 400, + categoryUuid: diningId, + ), + ]; + + when( + () => mockTxRepo.getTransactions( + startDate: any(named: 'startDate'), + endDate: any(named: 'endDate'), + ), + ).thenAnswer((_) async => Right(transactions)); + + final result = await repository.getStreak( + type: StreakType.underBudget, + referenceDate: DateTime(2026, 4, 10), + ); + + expect(result.isRight(), isTrue); + result.fold( + (_) => fail('Expected Right'), + (streak) { + expect(streak.length, 1); + expect(streak.status, StreakStatus.active); + }, + ); + + final flagsResult = await repository.getCategoryOverrunFlags( + month: DateTime(2026, 3), + ); + expect(flagsResult.isRight(), isTrue); + flagsResult.fold( + (_) => fail('Expected Right'), + (flags) { + expect(flags.length, 1); + expect(flags.first.categoryUuid, groceriesId.getOrCrash()); + expect(flags.first.budget, 300.0); + expect(flags.first.spent, 400.0); + expect(flags.first.overrunAmount, 100.0); + }, + ); + }); + + test('Completed month over total budgets -> run breaks at that month', + () async { + when(() => mockStorage.getStreakMinimumDays()).thenAnswer((_) async => 1); + + final leafId = UniqueId.generate(); + final categories = createStandardTestCategories( + leafBudgets: {leafId: 1000}, + ); + when(() => mockCategoryRepo.watchCategories()) + .thenAnswer((_) => Stream.value(Right(categories))); + + final transactions = [ + createTx(DateTime(2026, 1, 10), amount: 800, categoryUuid: leafId), + createTx(DateTime(2026, 2, 10), amount: 1400, categoryUuid: leafId), + ]; + + when( + () => mockTxRepo.getTransactions( + startDate: any(named: 'startDate'), + endDate: any(named: 'endDate'), + ), + ).thenAnswer((_) async => Right(transactions)); + + final result = await repository.getStreak( + type: StreakType.underBudget, + referenceDate: DateTime(2026, 3, 15), + ); + + expect(result.isRight(), isTrue); + result.fold( + (_) => fail('Expected Right'), + (streak) { + expect(streak.length, 0); + expect(streak.status, StreakStatus.broken); + expect(streak.bestLength, 1); + }, + ); + }); + + test('Month with zero budgets configured (all zeros) -> not qualifying', + () async { + when(() => mockStorage.getStreakMinimumDays()).thenAnswer((_) async => 1); + + final leafId = UniqueId.generate(); + final categories = createStandardTestCategories( + leafBudgets: {leafId: 0}, + ); + when(() => mockCategoryRepo.watchCategories()) + .thenAnswer((_) => Stream.value(Right(categories))); + + final transactions = [ + createTx(DateTime(2026, 3, 10), amount: 0, categoryUuid: leafId), + ]; + + when( + () => mockTxRepo.getTransactions( + startDate: any(named: 'startDate'), + endDate: any(named: 'endDate'), + ), + ).thenAnswer((_) async => Right(transactions)); + + final result = await repository.getStreak( + type: StreakType.underBudget, + referenceDate: DateTime(2026, 4, 15), + ); + + expect(result.isRight(), isTrue); + result.fold( + (_) => fail('Expected Right'), + (streak) { + expect(streak.length, 0); + expect(streak.isCurrentMonthOnTrack, isFalse); + }, + ); + }); + + test( + 'Year-boundary consecutive months (Dec, Jan) compute correctly on the ' + 'month engine', + () async { + when(() => mockStorage.getStreakMinimumDays()) + .thenAnswer((_) async => 2); + + final leafId = UniqueId.generate(); + final categories = createStandardTestCategories( + leafBudgets: {leafId: 1000}, + ); + when(() => mockCategoryRepo.watchCategories()) + .thenAnswer((_) => Stream.value(Right(categories))); + + final transactions = [ + createTx(DateTime(2025, 12, 15), amount: 700, categoryUuid: leafId), + createTx(DateTime(2026, 1, 15), amount: 800, categoryUuid: leafId), + ]; + + when( + () => mockTxRepo.getTransactions( + startDate: any(named: 'startDate'), + endDate: any(named: 'endDate'), + ), + ).thenAnswer((_) async => Right(transactions)); + + final result = await repository.getStreak( + type: StreakType.underBudget, + referenceDate: DateTime(2026, 2, 15), + ); + + expect(result.isRight(), isTrue); + result.fold( + (_) => fail('Expected Right'), + (streak) { + expect(streak.length, 2); + expect(streak.status, StreakStatus.active); + }, + ); + }, + ); + + test('isCurrentMonthOnTrack returns boolean based on total budget', + () async { + final leafId = UniqueId.generate(); + final categories = createStandardTestCategories( + leafBudgets: {leafId: 1000}, + ); + when(() => mockCategoryRepo.watchCategories()) + .thenAnswer((_) => Stream.value(Right(categories))); + + when( + () => mockTxRepo.getTransactions( + startDate: any(named: 'startDate'), + endDate: any(named: 'endDate'), + ), + ).thenAnswer( + (_) async => Right([ + createTx(DateTime(2026, 4, 5), amount: 400, categoryUuid: leafId), + ]), + ); + + final onTrackResult = await repository.isCurrentMonthOnTrack( + referenceDate: DateTime(2026, 4, 15), + ); + expect(onTrackResult, const Right(true)); + }); + + test('getUnderBudgetStatus aggregates budget, spent, and flags', () async { + final leafId = UniqueId.generate(); + final categories = createStandardTestCategories( + leafBudgets: {leafId: 1000}, + ); + when(() => mockCategoryRepo.watchCategories()) + .thenAnswer((_) => Stream.value(Right(categories))); + + when( + () => mockTxRepo.getTransactions( + startDate: any(named: 'startDate'), + endDate: any(named: 'endDate'), + ), + ).thenAnswer( + (_) async => Right([ + createTx(DateTime(2026, 4, 5), amount: 650, categoryUuid: leafId), + ]), + ); + + final statusResult = await repository.getUnderBudgetStatus( + month: DateTime(2026, 4), + ); + + expect(statusResult.isRight(), isTrue); + statusResult.fold( + (_) => fail('Expected Right'), + (status) { + expect(status.totalBudget, 1000.0); + expect(status.totalSpent, 650.0); + expect(status.isOnTrack, isTrue); + expect(status.remainingBudget, 350.0); + }, + ); + }); + }); + + group('StreakRepositoryImpl.getQualifyingPeriods', () { + test('extracts, deduplicates, and sorts qualifying dates for tracking', + () async { + final startDate = DateTime(2026, 9); + final endDate = DateTime(2026, 9, 30); + final transactions = [ + createTx(DateTime(2026, 9, 5, 10)), + createTx(DateTime(2026, 9, 2, 8)), + createTx(DateTime(2026, 9, 5, 16)), + createTx(DateTime(2026, 9, 10, 12)), + ]; + + when( + () => mockTxRepo.getTransactions( + startDate: startDate, + endDate: endDate, + ), + ).thenAnswer((_) async => Right(transactions)); + + final result = await repository.getQualifyingPeriods( + startDate: startDate, + endDate: endDate, + ); + + expect(result.isRight(), isTrue); + result.fold( + (_) => fail('Expected Right'), + (periods) { + expect(periods, [ + const StreakDay.fromYmd(2026, 9, 2), + const StreakDay.fromYmd(2026, 9, 5), + const StreakDay.fromYmd(2026, 9, 10), + ]); + }, + ); + }); + + test('extracts qualifying dates for noSpend (expense-free days)', () async { + final startDate = DateTime(2026, 9); + final endDate = DateTime(2026, 9, 5); + final transactions = [ + createTx(DateTime(2026, 9, 2, 10)), + createTx(DateTime(2026, 9, 4, 14)), + ]; + + when( + () => mockTxRepo.getTransactions( + startDate: startDate, + endDate: endDate, + ), + ).thenAnswer((_) async => Right(transactions)); + + final result = await repository.getQualifyingPeriods( + type: StreakType.noSpend, + startDate: startDate, + endDate: endDate, + ); + + expect(result.isRight(), isTrue); + result.fold( + (_) => fail('Expected Right'), + (periods) { + expect(periods, [ + const StreakDay.fromYmd(2026, 9, 1), + const StreakDay.fromYmd(2026, 9, 3), + const StreakDay.fromYmd(2026, 9, 5), + ]); + }, + ); + }); + + test('extracts, deduplicates, and sorts open dates for appOpen', () async { + final startDate = DateTime(2026, 9); + final endDate = DateTime(2026, 9, 30); + final openDates = [ + DateTime(2026, 9, 5, 10), + DateTime(2026, 9, 2, 8), + DateTime(2026, 9, 5, 16), + DateTime(2026, 9, 10, 12), + ]; + + when( + () => mockAppOpenRepo.getOpenDates( + startDate: startDate, + endDate: endDate, + ), + ).thenAnswer((_) async => Right(openDates)); + + final result = await repository.getQualifyingPeriods( + type: StreakType.appOpen, + startDate: startDate, + endDate: endDate, + ); + + expect(result.isRight(), isTrue); + result.fold( + (_) => fail('Expected Right'), + (periods) { + expect(periods, [ + const StreakDay.fromYmd(2026, 9, 2), + const StreakDay.fromYmd(2026, 9, 5), + const StreakDay.fromYmd(2026, 9, 10), + ]); + }, + ); + }); + + test('extracts qualifying StreakMonths for underBudget', () async { + final leafId = UniqueId.generate(); + final categories = createStandardTestCategories( + leafBudgets: {leafId: 1000}, + ); + when(() => mockCategoryRepo.watchCategories()) + .thenAnswer((_) => Stream.value(Right(categories))); + + final transactions = [ + createTx(DateTime(2025, 11, 10), amount: 800, categoryUuid: leafId), + createTx(DateTime(2025, 12, 10), amount: 900, categoryUuid: leafId), + ]; + + when( + () => mockTxRepo.getTransactions( + startDate: any(named: 'startDate'), + endDate: any(named: 'endDate'), + ), + ).thenAnswer((_) async => Right(transactions)); + + final result = await repository.getQualifyingPeriods( + type: StreakType.underBudget, + startDate: DateTime(2025, 11), + endDate: DateTime(2025, 12, 31), + ); + + expect(result.isRight(), isTrue); + result.fold( + (_) => fail('Expected Right'), + (periods) { + expect(periods, [ + const StreakMonth(2025, 11), + const StreakMonth(2025, 12), + ]); + }, + ); + }); + + test('propagates repository failure as Left', () async { + when( + () => mockTxRepo.getTransactions( + startDate: any(named: 'startDate'), + endDate: any(named: 'endDate'), + ), + ).thenAnswer( + (_) async => const Left( + Failure.localFailure(message: 'Failed to read records'), + ), + ); + + final result = await repository.getQualifyingPeriods(); + + expect(result.isLeft(), isTrue); + }); + }); +} diff --git a/test/features/streak/domain/entities/streak_period_test.dart b/test/features/streak/domain/entities/streak_period_test.dart new file mode 100644 index 0000000..5fbb19c --- /dev/null +++ b/test/features/streak/domain/entities/streak_period_test.dart @@ -0,0 +1,151 @@ +import 'package:expense_tracker/features/streak/domain/entities/streak_period.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('StreakDay', () { + test('creates from DateTime and strips time components', () { + final dt = DateTime(2026, 9, 28, 14, 30, 45, 123); + final period = StreakDay.fromDateTime(dt); + + expect(period.year, 2026); + expect(period.month, 9); + expect(period.day, 28); + expect(period.elapsedInContainer, 28); + expect(period.toDateTime(), DateTime(2026, 9, 28)); + }); + + test('calculates differenceInPeriods accurately between days', () { + const day1 = StreakDay.fromYmd(2026, 9, 1); + const day3 = StreakDay.fromYmd(2026, 9, 3); + + expect(day3.differenceInPeriods(day1), 2); + expect(day1.differenceInPeriods(day3), -2); + expect(day1.differenceInPeriods(day1), 0); + }); + + test('next and previous step one calendar day', () { + const day = StreakDay.fromYmd(2026, 2, 28); + final nextDay = day.next(); + expect(nextDay, const StreakDay.fromYmd(2026, 3, 1)); + expect(nextDay.previous(), day); + }); + + test('isInSameContainer checks same year and month', () { + const day1 = StreakDay.fromYmd(2026, 9, 5); + const day2 = StreakDay.fromYmd(2026, 9, 28); + const nextMonth = StreakDay.fromYmd(2026, 10, 1); + const nextYear = StreakDay.fromYmd(2027, 9, 5); + + expect(day1.isInSameContainer(day2), isTrue); + expect(day1.isInSameContainer(nextMonth), isFalse); + expect(day1.isInSameContainer(nextYear), isFalse); + }); + + test('compareTo orders dates chronologically', () { + const day1 = StreakDay.fromYmd(2026, 8, 31); + const day2 = StreakDay.fromYmd(2026, 9, 1); + const day3 = StreakDay.fromYmd(2026, 9, 2); + + expect(day1.compareTo(day2), isNegative); + expect(day2.compareTo(day1), isPositive); + expect(day2.compareTo(day2), isZero); + + final list = [day3, day1, day2]..sort(); + expect(list, [day1, day2, day3]); + }); + + test('throws ArgumentError when comparing with different period types', () { + const day = StreakDay.fromYmd(2026, 9, 1); + const month = StreakMonth(2026, 9); + + expect( + () => day.differenceInPeriods(month), + throwsA(isA()), + ); + expect( + () => day.compareTo(month), + throwsA(isA()), + ); + }); + + test( + 'DST and timezone safety: difference is exact across 23/25 hour days', + () { + // March DST transition in Europe/US (23 hours) + const march28 = StreakDay.fromYmd(2026, 3, 28); + const march29 = StreakDay.fromYmd(2026, 3, 29); + const march30 = StreakDay.fromYmd(2026, 3, 30); + + expect(march29.differenceInPeriods(march28), 1); + expect(march30.differenceInPeriods(march28), 2); + + // October DST fall back transition (25 hours) + const oct24 = StreakDay.fromYmd(2026, 10, 24); + const oct25 = StreakDay.fromYmd(2026, 10, 25); + const oct26 = StreakDay.fromYmd(2026, 10, 26); + + expect(oct25.differenceInPeriods(oct24), 1); + expect(oct26.differenceInPeriods(oct24), 2); + }); + }); + + group('StreakMonth', () { + test('creates from DateTime and asserts valid month', () { + final dt = DateTime(2026, 9, 15); + final month = StreakMonth.fromDateTime(dt); + + expect(month.year, 2026); + expect(month.month, 9); + expect(month.elapsedInContainer, 9); + + expect( + () => StreakMonth(2026, 0), + throwsA(isA()), + ); + expect( + () => StreakMonth(2026, 13), + throwsA(isA()), + ); + }); + + test( + 'calculates differenceInPeriods across months and year boundaries', + () { + const dec2025 = StreakMonth(2025, 12); + const jan2026 = StreakMonth(2026, 1); + const mar2026 = StreakMonth(2026, 3); + + expect(jan2026.differenceInPeriods(dec2025), 1); + expect(mar2026.differenceInPeriods(dec2025), 3); + expect(dec2025.differenceInPeriods(jan2026), -1); + }); + + test('next and previous step across year boundary', () { + const dec = StreakMonth(2025, 12); + final jan = dec.next(); + expect(jan, const StreakMonth(2026, 1)); + expect(jan.previous(), dec); + }); + + test('isInSameContainer checks same year', () { + const jan2026 = StreakMonth(2026, 1); + const dec2026 = StreakMonth(2026, 12); + const jan2027 = StreakMonth(2027, 1); + + expect(jan2026.isInSameContainer(dec2026), isTrue); + expect(jan2026.isInSameContainer(jan2027), isFalse); + }); + + test('compareTo orders months chronologically', () { + const m1 = StreakMonth(2025, 12); + const m2 = StreakMonth(2026, 1); + const m3 = StreakMonth(2026, 2); + + expect(m1.compareTo(m2), isNegative); + expect(m2.compareTo(m1), isPositive); + + final list = [m3, m1, m2]..sort(); + expect(list, [m1, m2, m3]); + }); + }); +} diff --git a/test/features/streak/domain/entities/streak_test.dart b/test/features/streak/domain/entities/streak_test.dart new file mode 100644 index 0000000..53ad4e6 --- /dev/null +++ b/test/features/streak/domain/entities/streak_test.dart @@ -0,0 +1,100 @@ +import 'package:expense_tracker/features/streak/domain/entities/streak.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_config.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_status.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_type.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('StreakConfig', () { + test('default values match requirements (3, 3, 7)', () { + const config = StreakConfig(); + expect(config.window, 3); + expect(config.minimum, 3); + expect(config.cadence, 7); + }); + + test('asserts values must be at least 1', () { + expect( + () => StreakConfig(window: 0), + throwsA(isA()), + ); + expect( + () => StreakConfig(minimum: 0), + throwsA(isA()), + ); + expect( + () => StreakConfig(cadence: 0), + throwsA(isA()), + ); + }); + + test('copyWith updates fields correctly', () { + const config = StreakConfig(); + final updated = config.copyWith(window: 5, minimum: 4, cadence: 14); + + expect(updated.window, 5); + expect(updated.minimum, 4); + expect(updated.cadence, 14); + }); + }); + + group('StreakStatus', () { + test('isCelebratory is true only for active and atRisk', () { + expect(StreakStatus.none.isCelebratory, isFalse); + expect(StreakStatus.warmingUp.isCelebratory, isFalse); + expect(StreakStatus.broken.isCelebratory, isFalse); + expect(StreakStatus.active.isCelebratory, isTrue); + expect(StreakStatus.atRisk.isCelebratory, isTrue); + }); + + test('getters correctly reflect enum state', () { + expect(StreakStatus.none.isNone, isTrue); + expect(StreakStatus.warmingUp.isWarmingUp, isTrue); + expect(StreakStatus.active.isActive, isTrue); + expect(StreakStatus.atRisk.isAtRisk, isTrue); + expect(StreakStatus.broken.isBroken, isTrue); + }); + }); + + group('StreakType', () { + test('displayName returns user-friendly label', () { + expect(StreakType.tracking.displayName, 'Tracking Streak'); + expect(StreakType.noSpend.displayName, 'No-Spend Streak'); + expect(StreakType.appOpen.displayName, 'App-Open Streak'); + expect(StreakType.underBudget.displayName, 'Under-Budget Streak'); + }); + }); + + group('Streak', () { + test('empty factory creates default empty streak', () { + const empty = Streak.empty(); + expect(empty.length, 0); + expect(empty.status, StreakStatus.none); + expect(empty.daysUntilBreak, 0); + expect(empty.nextMilestone, 7); + expect(empty.consistencyRate, 0.0); + expect(empty.bestLength, 0); + expect(empty.isEmpty, isTrue); + }); + + test('copyWith creates updated instance', () { + const initial = Streak.empty(); + final updated = initial.copyWith( + length: 5, + status: StreakStatus.active, + daysUntilBreak: 2, + nextMilestone: 14, + consistencyRate: 0.8, + bestLength: 10, + ); + + expect(updated.length, 5); + expect(updated.status, StreakStatus.active); + expect(updated.daysUntilBreak, 2); + expect(updated.nextMilestone, 14); + expect(updated.consistencyRate, 0.8); + expect(updated.bestLength, 10); + expect(updated.isEmpty, isFalse); + }); + }); +} diff --git a/test/features/streak/domain/services/streak_engine_test.dart b/test/features/streak/domain/services/streak_engine_test.dart new file mode 100644 index 0000000..f98217d --- /dev/null +++ b/test/features/streak/domain/services/streak_engine_test.dart @@ -0,0 +1,413 @@ +import 'package:expense_tracker/features/streak/domain/entities/streak_config.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_period.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_status.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_type.dart'; +import 'package:expense_tracker/features/streak/domain/services/streak_engine.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late StreakEngine engine; + const defaultConfig = StreakConfig(); + + setUp(() { + engine = const StreakEngine(); + }); + + group('StreakEngine', () { + test('Empty record set -> empty streak, no crash', () { + const today = StreakDay.fromYmd(2026, 9, 28); + final result = engine.calculate( + qualifyingPeriods: [], + today: today, + config: defaultConfig, + ); + + expect(result.current.isEmpty, isTrue); + expect(result.current.length, 0); + expect(result.current.status, StreakStatus.none); + expect(result.current.daysUntilBreak, 0); + expect(result.current.nextMilestone, 7); + expect(result.current.consistencyRate, 0.0); + expect(result.best.isEmpty, isTrue); + }); + + test( + 'Happy path: consecutive daily logs produce length equal to day span; ' + 'minimum met -> active', () { + // 5 consecutive days: Sept 20 to Sept 24, today is Sept 24 (qualified) + final periods = [ + const StreakDay.fromYmd(2026, 9, 20), + const StreakDay.fromYmd(2026, 9, 21), + const StreakDay.fromYmd(2026, 9, 22), + const StreakDay.fromYmd(2026, 9, 23), + const StreakDay.fromYmd(2026, 9, 24), + ]; + const today = StreakDay.fromYmd(2026, 9, 24); + + final result = engine.calculate( + qualifyingPeriods: periods, + today: today, + config: defaultConfig, + ); + + expect(result.current.length, 5); + expect(result.current.status, StreakStatus.active); + expect(result.current.daysUntilBreak, 3); + expect(result.current.nextMilestone, 7); + expect(result.best.length, 5); + }); + + test( + 'Covers AE1: Window absorbs a 2-day gap at window 3; ' + 'status at risk when today unqualified on last covered day', () { + // Log on Sept 20, 21, 22 (length 3, minimum met). + // Gap on Sept 23 (gap day 1) and Sept 24 (gap day 2) is absorbed. + // Sept 25 is today: unqualified, delta = 25 - 22 = 3 == window 3. + // Last covered day: window closes tomorrow (Sept 26 is delta 4 > 3). + final periods = [ + const StreakDay.fromYmd(2026, 9, 20), + const StreakDay.fromYmd(2026, 9, 21), + const StreakDay.fromYmd(2026, 9, 22), + ]; + const today = StreakDay.fromYmd(2026, 9, 25); + + final result = engine.calculate( + qualifyingPeriods: periods, + today: today, + config: defaultConfig, + ); + + expect(result.current.length, 3); + expect(result.current.status, StreakStatus.atRisk); + expect(result.current.daysUntilBreak, 1); + expect(result.best.length, 3); + }); + + test( + 'Covers AE2: Gap exceeding window ends the run; ' + 'next qualifying day starts fresh run; best-run retains old run', () { + // Saturday: Sept 20. Window: 3. + // Sunday (Sept 21): 1 day passed. + // Monday (Sept 22): 2 days passed. + // Tuesday (Sept 23): 3 days passed. + // Wednesday (Sept 24): 4 calendar days pass without log -> run ends! + final saturdayLog = [ + const StreakDay.fromYmd(2026, 9, 18), + const StreakDay.fromYmd(2026, 9, 19), + const StreakDay.fromYmd(2026, 9, 20), + ]; + const wednesday = StreakDay.fromYmd(2026, 9, 24); + + final resultWed = engine.calculate( + qualifyingPeriods: saturdayLog, + today: wednesday, + config: defaultConfig, + ); + + expect(resultWed.current.length, 0); + expect(resultWed.current.status, StreakStatus.broken); + expect(resultWed.current.daysUntilBreak, 0); + expect(resultWed.best.length, 3); + + // Thursday arrives and user logs: starts a fresh run! + final withThursdayLog = [ + ...saturdayLog, + const StreakDay.fromYmd(2026, 9, 25), + ]; + const thursday = StreakDay.fromYmd(2026, 9, 25); + + final resultThu = engine.calculate( + qualifyingPeriods: withThursdayLog, + today: thursday, + config: defaultConfig, + ); + + expect(resultThu.current.length, 1); + expect(resultThu.current.status, StreakStatus.warmingUp); + expect(resultThu.current.daysUntilBreak, 3); + // Best run retains the older 3-day run! + expect(resultThu.best.length, 3); + expect(resultThu.current.bestLength, 3); + }); + + test( + 'Covers AE3: Length below minimum reports warming up, never active', + () { + // 2 consecutive days with minimum 3 + final periods = [ + const StreakDay.fromYmd(2026, 9, 20), + const StreakDay.fromYmd(2026, 9, 21), + ]; + const today = StreakDay.fromYmd(2026, 9, 21); + + final result = engine.calculate( + qualifyingPeriods: periods, + today: today, + config: defaultConfig, + ); + + expect(result.current.length, 2); + expect(result.current.status, StreakStatus.warmingUp); + expect(result.current.status.isCelebratory, isFalse); + }); + + test( + 'Covers AE4: Backdating heals a run with a 2-day gap inside window', + () { + // User logs on Monday (Sept 21) and Friday (Sept 25). + // Gap is 4 days > window 3, so they are separate runs. + final disconnected = [ + const StreakDay.fromYmd(2026, 9, 21), + const StreakDay.fromYmd(2026, 9, 25), + ]; + const friday = StreakDay.fromYmd(2026, 9, 25); + + final resultBefore = engine.calculate( + qualifyingPeriods: disconnected, + today: friday, + config: defaultConfig, + ); + // Friday starts a fresh run of length 1 + expect(resultBefore.current.length, 1); + + // User backdates a transaction on Wednesday (Sept 23): + // Sept 21 -> Sept 23 (gap 2 <= 3), Sept 23 -> Sept 25 (gap 2 <= 3) + // Healed into single run spanning Sept 21 to Sept 25 inclusive = 5 days! + final healed = [ + const StreakDay.fromYmd(2026, 9, 21), + const StreakDay.fromYmd(2026, 9, 23), + const StreakDay.fromYmd(2026, 9, 25), + ]; + + final resultAfter = engine.calculate( + qualifyingPeriods: healed, + today: friday, + config: defaultConfig, + ); + + expect(resultAfter.current.length, 5); + expect(resultAfter.current.status, StreakStatus.active); + }); + + test('Today qualified -> never at risk regardless of older gaps', () { + // Log on Sept 20, gap Sept 21 & Sept 22, log on Sept 23 (today) + final periods = [ + const StreakDay.fromYmd(2026, 9, 18), + const StreakDay.fromYmd(2026, 9, 19), + const StreakDay.fromYmd(2026, 9, 20), + const StreakDay.fromYmd(2026, 9, 23), + ]; + const today = StreakDay.fromYmd(2026, 9, 23); + + final result = engine.calculate( + qualifyingPeriods: periods, + today: today, + config: defaultConfig, + ); + + expect(result.current.status, StreakStatus.active); + expect(result.current.status.isAtRisk, isFalse); + expect(result.current.daysUntilBreak, 3); + }); + + test( + 'Covers AE5: Cadence 1 collapse (strict daily streak): ' + 'skipped day consumes window capacity', () { + // Cadence 1 (window 1, minimum 3) + const cadence1Config = StreakConfig(window: 1, cadence: 1); + + final logs = [ + const StreakDay.fromYmd(2026, 9, 20), + const StreakDay.fromYmd(2026, 9, 21), + const StreakDay.fromYmd(2026, 9, 22), + ]; + + // On Sept 22 (today, qualified): active + final resultDay22 = engine.calculate( + qualifyingPeriods: logs, + today: const StreakDay.fromYmd(2026, 9, 22), + config: cadence1Config, + ); + expect(resultDay22.current.length, 3); + expect(resultDay22.current.status, StreakStatus.active); + + // On Sept 23 (unqualified): delta = 1 == window 1 -> at risk! + final resultDay23 = engine.calculate( + qualifyingPeriods: logs, + today: const StreakDay.fromYmd(2026, 9, 23), + config: cadence1Config, + ); + expect(resultDay23.current.length, 3); + expect(resultDay23.current.status, StreakStatus.atRisk); + expect(resultDay23.current.daysUntilBreak, 1); + + // On Sept 24 (still unqualified): delta = 2 > window 1 -> broken! + final resultDay24 = engine.calculate( + qualifyingPeriods: logs, + today: const StreakDay.fromYmd(2026, 9, 24), + config: cadence1Config, + ); + expect(resultDay24.current.length, 0); + expect(resultDay24.current.status, StreakStatus.broken); + expect(resultDay24.best.length, 3); + }); + + test( + 'Covers AE6: Cadence 7 spacing ' + '(opens on 1st and 7th continue, open on 9th breaks)', () { + // Window 7 (app-open cadence 7) + const cadence7Config = StreakConfig(window: 7, minimum: 1); + + // Opens on Sept 1 and Sept 7: difference is 6 <= 7 -> continuous run + final opens = [ + const StreakDay.fromYmd(2026, 9, 1), + const StreakDay.fromYmd(2026, 9, 7), + ]; + + final resultSept7 = engine.calculate( + qualifyingPeriods: opens, + today: const StreakDay.fromYmd(2026, 9, 7), + config: cadence7Config, + ); + // Span Sept 1 to Sept 7 inclusive = 7 days + expect(resultSept7.current.length, 7); + expect(resultSept7.current.status, StreakStatus.active); + + // What if open on Sept 1, then no open until Sept 9? + // Difference is 9 - 1 = 8 > 7 -> breaks it! + final brokenOpens = [ + const StreakDay.fromYmd(2026, 9, 1), + const StreakDay.fromYmd(2026, 9, 9), + ]; + final resultSept9 = engine.calculate( + qualifyingPeriods: brokenOpens, + today: const StreakDay.fromYmd(2026, 9, 9), + config: cadence7Config, + ); + // Sept 9 starts a fresh run of length 1; old run on Sept 1 had length 1 + expect(resultSept9.current.length, 1); + expect(resultSept9.best.length, 1); + }); + + test( + 'Month-period run: consecutive qualifying months, ' + 'gap absorption across year boundary (Dec -> Jan)', () { + const monthConfig = StreakConfig(window: 2, minimum: 2); + final months = [ + const StreakMonth(2025, 11), + const StreakMonth(2025, 12), + const StreakMonth(2026, 1), + ]; + const today = StreakMonth(2026, 1); + + final result = engine.calculate( + qualifyingPeriods: months, + today: today, + config: monthConfig, + type: StreakType.underBudget, + ); + + // 3 consecutive months: Nov 2025, Dec 2025, Jan 2026 + expect(result.current.length, 3); + expect(result.current.status, StreakStatus.active); + + // Gap across year boundary absorbed: Nov 2025 to Jan 2026 with window 2 + final gapMonths = [ + const StreakMonth(2025, 11), + const StreakMonth(2026, 1), + ]; + final resultGap = engine.calculate( + qualifyingPeriods: gapMonths, + today: today, + config: monthConfig, + type: StreakType.underBudget, + ); + // Difference = 2 <= 2, span is (2026-01 - 2025-11) + 1 = 3 months + expect(resultGap.current.length, 3); + expect(resultGap.current.status, StreakStatus.active); + }); + + test( + 'DST / timezone safety: dates passed through local-date stripping; ' + 'no UTC arithmetic shifts the day bucket', () { + // Days at the European daylight savings boundary (March 29, 2026) + final periods = [ + StreakDay.fromDateTime(DateTime(2026, 3, 28, 23, 59)), + StreakDay.fromDateTime(DateTime(2026, 3, 29, 0, 1)), + StreakDay.fromDateTime(DateTime(2026, 3, 30, 12)), + ]; + final today = StreakDay.fromDateTime(DateTime(2026, 3, 30, 18)); + + final result = engine.calculate( + qualifyingPeriods: periods, + today: today, + config: defaultConfig, + ); + + expect(result.current.length, 3); + expect(result.current.status, StreakStatus.active); + }); + + test('Consistency rate: qualifying days in month divided by elapsed days', + () { + // Today is Sept 10 (10 days elapsed in container). + // 5 qualifying days in Sept: 1, 2, 3, 4, 5 + final periods = [ + const StreakDay.fromYmd(2026, 9, 1), + const StreakDay.fromYmd(2026, 9, 2), + const StreakDay.fromYmd(2026, 9, 3), + const StreakDay.fromYmd(2026, 9, 4), + const StreakDay.fromYmd(2026, 9, 5), + // One from previous month (August) should not count toward September + const StreakDay.fromYmd(2026, 8, 31), + ]; + const today = StreakDay.fromYmd(2026, 9, 10); + + final result = engine.calculate( + qualifyingPeriods: periods, + today: today, + config: defaultConfig, + ); + + // 5 qualifying in Sept / 10 elapsed = 0.50 + expect(result.current.consistencyRate, 0.5); + }); + + test('resolveNextMilestone returns correct threshold across ladder', () { + expect(StreakEngine.resolveNextMilestone(0), 7); + expect(StreakEngine.resolveNextMilestone(6), 7); + expect(StreakEngine.resolveNextMilestone(7), 14); + expect(StreakEngine.resolveNextMilestone(10), 14); + expect(StreakEngine.resolveNextMilestone(14), 21); + expect(StreakEngine.resolveNextMilestone(21), 30); + expect(StreakEngine.resolveNextMilestone(30), 60); + expect(StreakEngine.resolveNextMilestone(60), 90); + expect(StreakEngine.resolveNextMilestone(90), 180); + expect(StreakEngine.resolveNextMilestone(180), 365); + expect(StreakEngine.resolveNextMilestone(365), 730); + expect(StreakEngine.resolveNextMilestone(400), 730); + }); + + test( + 'Filters out future periods after today and handles ' + 'unsorted duplicates', () { + const today = StreakDay.fromYmd(2026, 9, 20); + final unorderedWithFuture = [ + const StreakDay.fromYmd(2026, 9, 20), + const StreakDay.fromYmd(2026, 9, 18), + const StreakDay.fromYmd(2026, 9, 19), + const StreakDay.fromYmd(2026, 9, 19), // duplicate + const StreakDay.fromYmd(2026, 9, 25), // future + ]; + + final result = engine.calculate( + qualifyingPeriods: unorderedWithFuture, + today: today, + config: defaultConfig, + ); + + expect(result.current.length, 3); + expect(result.current.status, StreakStatus.active); + }); + }); +} diff --git a/test/features/streak/domain/usecases/get_qualifying_periods_use_case_test.dart b/test/features/streak/domain/usecases/get_qualifying_periods_use_case_test.dart new file mode 100644 index 0000000..64461d1 --- /dev/null +++ b/test/features/streak/domain/usecases/get_qualifying_periods_use_case_test.dart @@ -0,0 +1,86 @@ +import 'package:dartz/dartz.dart'; +import 'package:expense_tracker/core/domain/failures/failure.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_period.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_type.dart'; +import 'package:expense_tracker/features/streak/domain/repositories/streak_repository.dart'; +import 'package:expense_tracker/features/streak/domain/usecases/get_qualifying_periods_use_case.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockStreakRepository extends Mock implements StreakRepository {} + +void main() { + setUpAll(() { + registerFallbackValue(StreakType.tracking); + }); + + late MockStreakRepository mockRepository; + late GetQualifyingPeriodsUseCase useCase; + + setUp(() { + mockRepository = MockStreakRepository(); + useCase = GetQualifyingPeriodsUseCase(mockRepository); + }); + + final tPeriods = [ + StreakDay.fromDateTime(DateTime(2026, 4)), + StreakDay.fromDateTime(DateTime(2026, 4, 2)), + StreakDay.fromDateTime(DateTime(2026, 4, 3)), + ]; + + group('GetQualifyingPeriodsUseCase', () { + test('delegates parameters to repository and returns list of periods', + () async { + final start = DateTime(2026, 4); + final end = DateTime(2026, 4, 30); + final params = GetQualifyingPeriodsParams( + startDate: start, + endDate: end, + ); + + when( + () => mockRepository.getQualifyingPeriods( + type: any(named: 'type'), + startDate: any(named: 'startDate'), + endDate: any(named: 'endDate'), + ), + ).thenAnswer((_) async => Right(tPeriods)); + + final result = await useCase(params); + + expect(result, Right>(tPeriods)); + verify( + () => mockRepository.getQualifyingPeriods( + startDate: start, + endDate: end, + ), + ).called(1); + verifyNoMoreInteractions(mockRepository); + }); + + test('propagates Failure from repository', () async { + const failure = Failure.localFailure(message: 'Error fetching periods'); + when( + () => mockRepository.getQualifyingPeriods( + type: any(named: 'type'), + startDate: any(named: 'startDate'), + endDate: any(named: 'endDate'), + ), + ).thenAnswer((_) async => const Left(failure)); + + final result = await useCase(const GetQualifyingPeriodsParams()); + + expect(result, const Left>(failure)); + }); + + test('GetQualifyingPeriodsParams supports equality comparison', () { + final start = DateTime(2026, 4); + final p1 = GetQualifyingPeriodsParams(startDate: start); + final p2 = GetQualifyingPeriodsParams(startDate: start); + const p3 = GetQualifyingPeriodsParams(type: StreakType.appOpen); + + expect(p1, equals(p2)); + expect(p1, isNot(equals(p3))); + }); + }); +} diff --git a/test/features/streak/domain/usecases/get_streaks_use_case_test.dart b/test/features/streak/domain/usecases/get_streaks_use_case_test.dart new file mode 100644 index 0000000..6b5444e --- /dev/null +++ b/test/features/streak/domain/usecases/get_streaks_use_case_test.dart @@ -0,0 +1,87 @@ +import 'package:dartz/dartz.dart'; +import 'package:expense_tracker/core/domain/failures/failure.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_status.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_type.dart'; +import 'package:expense_tracker/features/streak/domain/repositories/streak_repository.dart'; +import 'package:expense_tracker/features/streak/domain/usecases/get_streaks_use_case.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockStreakRepository extends Mock implements StreakRepository {} + +void main() { + setUpAll(() { + registerFallbackValue(StreakType.tracking); + }); + + late MockStreakRepository mockRepository; + late GetStreaksUseCase useCase; + + setUp(() { + mockRepository = MockStreakRepository(); + useCase = GetStreaksUseCase(mockRepository); + }); + + const tStreak = Streak( + type: StreakType.tracking, + length: 5, + status: StreakStatus.active, + daysUntilBreak: 3, + nextMilestone: 7, + consistencyRate: 0.8, + bestLength: 5, + ); + + group('GetStreaksUseCase', () { + test( + 'should delegate parameter passing to repository and return the streak', + () async { + final refDate = DateTime(2026, 9, 28); + final params = GetStreaksParams( + referenceDate: refDate, + ); + + when( + () => mockRepository.getStreak( + type: any(named: 'type'), + referenceDate: any(named: 'referenceDate'), + ), + ).thenAnswer((_) async => const Right(tStreak)); + + final result = await useCase(params); + + expect(result, const Right(tStreak)); + verify( + () => mockRepository.getStreak( + referenceDate: refDate, + ), + ).called(1); + verifyNoMoreInteractions(mockRepository); + }); + + test('should propagate Failure from repository', () async { + const failure = Failure.localFailure(message: 'Error fetching streak'); + when( + () => mockRepository.getStreak( + type: any(named: 'type'), + referenceDate: any(named: 'referenceDate'), + ), + ).thenAnswer((_) async => const Left(failure)); + + final result = await useCase(const GetStreaksParams()); + + expect(result, const Left(failure)); + }); + + test('GetStreaksParams supports equality comparison', () { + final date = DateTime(2026, 9, 28); + final p1 = GetStreaksParams(referenceDate: date); + final p2 = GetStreaksParams(referenceDate: date); + const p3 = GetStreaksParams(type: StreakType.appOpen); + + expect(p1, equals(p2)); + expect(p1, isNot(equals(p3))); + }); + }); +} diff --git a/test/features/streak/presentation/blocs/streak_config_cubit_test.dart b/test/features/streak/presentation/blocs/streak_config_cubit_test.dart new file mode 100644 index 0000000..1633334 --- /dev/null +++ b/test/features/streak/presentation/blocs/streak_config_cubit_test.dart @@ -0,0 +1,274 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:expense_tracker/core/storages/local_storages.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_config.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_type.dart'; +import 'package:expense_tracker/features/streak/presentation/blocs/streak_config_cubit.dart'; +import 'package:expense_tracker/features/streak/presentation/blocs/streak_config_state.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockLocalStorage extends Mock implements LocalStorage {} + +void main() { + late MockLocalStorage mockStorage; + late StreakConfigCubit cubit; + + setUp(() { + mockStorage = MockLocalStorage(); + when(() => mockStorage.getStreakWindowDays()) + .thenAnswer((_) async => 3); + when(() => mockStorage.getStreakMinimumDays()) + .thenAnswer((_) async => 3); + when(() => mockStorage.getAppOpenCadenceDays()) + .thenAnswer((_) async => 7); + when(() => mockStorage.setStreakWindowDays(any())) + .thenAnswer((_) async {}); + when(() => mockStorage.setStreakMinimumDays(any())) + .thenAnswer((_) async {}); + when(() => mockStorage.setAppOpenCadenceDays(any())) + .thenAnswer((_) async {}); + + cubit = StreakConfigCubit(mockStorage); + }); + + tearDown(() { + cubit.close(); + }); + + group('StreakConfigCubit', () { + test('initial state has default config and loading true', () { + expect(cubit.state, const StreakConfigState()); + expect(cubit.state.isLoading, isTrue); + expect(cubit.state.selectedType, StreakType.tracking); + }); + + blocTest( + 'load reads from storage and selects initialType', + build: () => cubit, + act: (cubit) => cubit.load(initialType: StreakType.appOpen), + expect: () => [ + const StreakConfigState(selectedType: StreakType.appOpen), + const StreakConfigState( + isLoading: false, + selectedType: StreakType.appOpen, + ), + ], + verify: (_) { + verify(() => mockStorage.getStreakWindowDays()).called(1); + verify(() => mockStorage.getStreakMinimumDays()).called(1); + verify(() => mockStorage.getAppOpenCadenceDays()).called(1); + }, + ); + + blocTest( + 'selectType changes active type without reloading storage', + build: () => cubit, + seed: () => const StreakConfigState(isLoading: false), + act: (cubit) => cubit.selectType(StreakType.underBudget), + expect: () => [ + const StreakConfigState( + isLoading: false, + selectedType: StreakType.underBudget, + ), + ], + ); + + group('window tuning', () { + blocTest( + 'updateWindow persists and emits updated state', + build: () => cubit, + seed: () => const StreakConfigState(isLoading: false), + act: (cubit) => cubit.updateWindow(5), + expect: () => [ + const StreakConfigState( + isLoading: false, + config: StreakConfig(window: 5), + ), + ], + verify: (_) { + verify(() => mockStorage.setStreakWindowDays(5)).called(1); + }, + ); + + blocTest( + 'updateWindow clamps value < 1 to 1', + build: () => cubit, + seed: () => const StreakConfigState(isLoading: false), + act: (cubit) => cubit.updateWindow(0), + expect: () => [ + const StreakConfigState( + isLoading: false, + config: StreakConfig(window: 1), + ), + ], + verify: (_) { + verify(() => mockStorage.setStreakWindowDays(1)).called(1); + }, + ); + + blocTest( + 'incrementWindow increases window by 1', + build: () => cubit, + seed: () => const StreakConfigState(isLoading: false), + act: (cubit) => cubit.incrementWindow(), + expect: () => [ + const StreakConfigState( + isLoading: false, + config: StreakConfig(window: 4), + ), + ], + verify: (_) { + verify(() => mockStorage.setStreakWindowDays(4)).called(1); + }, + ); + + blocTest( + 'decrementWindow decreases window by 1', + build: () => cubit, + seed: () => const StreakConfigState(isLoading: false), + act: (cubit) => cubit.decrementWindow(), + expect: () => [ + const StreakConfigState( + isLoading: false, + config: StreakConfig(window: 2), + ), + ], + verify: (_) { + verify(() => mockStorage.setStreakWindowDays(2)).called(1); + }, + ); + }); + + group('minimum tuning', () { + blocTest( + 'updateMinimum persists and emits updated state', + build: () => cubit, + seed: () => const StreakConfigState(isLoading: false), + act: (cubit) => cubit.updateMinimum(6), + expect: () => [ + const StreakConfigState( + isLoading: false, + config: StreakConfig(minimum: 6), + ), + ], + verify: (_) { + verify(() => mockStorage.setStreakMinimumDays(6)).called(1); + }, + ); + + blocTest( + 'updateMinimum clamps value < 1 to 1', + build: () => cubit, + seed: () => const StreakConfigState(isLoading: false), + act: (cubit) => cubit.updateMinimum(-5), + expect: () => [ + const StreakConfigState( + isLoading: false, + config: StreakConfig(minimum: 1), + ), + ], + verify: (_) { + verify(() => mockStorage.setStreakMinimumDays(1)).called(1); + }, + ); + + blocTest( + 'incrementMinimum increases minimum by 1', + build: () => cubit, + seed: () => const StreakConfigState(isLoading: false), + act: (cubit) => cubit.incrementMinimum(), + expect: () => [ + const StreakConfigState( + isLoading: false, + config: StreakConfig(minimum: 4), + ), + ], + verify: (_) { + verify(() => mockStorage.setStreakMinimumDays(4)).called(1); + }, + ); + + blocTest( + 'decrementMinimum decreases minimum by 1', + build: () => cubit, + seed: () => const StreakConfigState(isLoading: false), + act: (cubit) => cubit.decrementMinimum(), + expect: () => [ + const StreakConfigState( + isLoading: false, + config: StreakConfig(minimum: 2), + ), + ], + verify: (_) { + verify(() => mockStorage.setStreakMinimumDays(2)).called(1); + }, + ); + }); + + group('cadence tuning', () { + blocTest( + 'updateCadence persists and emits updated state', + build: () => cubit, + seed: () => const StreakConfigState(isLoading: false), + act: (cubit) => cubit.updateCadence(2), + expect: () => [ + const StreakConfigState( + isLoading: false, + config: StreakConfig(cadence: 2), + ), + ], + verify: (_) { + verify(() => mockStorage.setAppOpenCadenceDays(2)).called(1); + }, + ); + + blocTest( + 'updateCadence clamps value < 1 to 1', + build: () => cubit, + seed: () => const StreakConfigState(isLoading: false), + act: (cubit) => cubit.updateCadence(0), + expect: () => [ + const StreakConfigState( + isLoading: false, + config: StreakConfig(cadence: 1), + ), + ], + verify: (_) { + verify(() => mockStorage.setAppOpenCadenceDays(1)).called(1); + }, + ); + + blocTest( + 'incrementCadence increases cadence by 1', + build: () => cubit, + seed: () => const StreakConfigState(isLoading: false), + act: (cubit) => cubit.incrementCadence(), + expect: () => [ + const StreakConfigState( + isLoading: false, + config: StreakConfig(cadence: 8), + ), + ], + verify: (_) { + verify(() => mockStorage.setAppOpenCadenceDays(8)).called(1); + }, + ); + + blocTest( + 'decrementCadence decreases cadence by 1', + build: () => cubit, + seed: () => const StreakConfigState(isLoading: false), + act: (cubit) => cubit.decrementCadence(), + expect: () => [ + const StreakConfigState( + isLoading: false, + config: StreakConfig(cadence: 6), + ), + ], + verify: (_) { + verify(() => mockStorage.setAppOpenCadenceDays(6)).called(1); + }, + ); + }); + }); +} diff --git a/test/features/streak/presentation/blocs/streak_cubit_test.dart b/test/features/streak/presentation/blocs/streak_cubit_test.dart new file mode 100644 index 0000000..2e05dcf --- /dev/null +++ b/test/features/streak/presentation/blocs/streak_cubit_test.dart @@ -0,0 +1,282 @@ +import 'dart:async'; + +import 'package:dartz/dartz.dart'; +import 'package:expense_tracker/core/domain/failures/failure.dart'; +import 'package:expense_tracker/core/domain/usecases/use_case.dart'; +import 'package:expense_tracker/features/streak/domain/entities/category_overrun_flag.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_period.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_status.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_type.dart'; +import 'package:expense_tracker/features/streak/domain/entities/under_budget_month_status.dart'; +import 'package:expense_tracker/features/streak/domain/usecases/get_qualifying_periods_use_case.dart'; +import 'package:expense_tracker/features/streak/domain/usecases/get_streaks_use_case.dart'; +import 'package:expense_tracker/features/streak/domain/usecases/get_under_budget_status_use_case.dart'; +import 'package:expense_tracker/features/streak/presentation/blocs/streak_cubit.dart'; +import 'package:expense_tracker/features/transaction/domain/entities/transaction.dart'; +import 'package:expense_tracker/features/transaction/domain/usecases/watch_transactions_use_case.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockGetStreaksUseCase extends Mock implements GetStreaksUseCase {} + +class MockGetQualifyingPeriodsUseCase extends Mock + implements GetQualifyingPeriodsUseCase {} + +class MockWatchTransactionsUseCase extends Mock + implements WatchTransactionsUseCase {} + +class MockGetUnderBudgetStatusUseCase extends Mock + implements GetUnderBudgetStatusUseCase {} + +void main() { + setUpAll(() { + registerFallbackValue(const GetStreaksParams()); + registerFallbackValue(const GetQualifyingPeriodsParams()); + registerFallbackValue(const GetUnderBudgetStatusParams()); + registerFallbackValue(NoParams()); + }); + + late MockGetStreaksUseCase mockGetStreaksUseCase; + late MockGetQualifyingPeriodsUseCase mockGetQualifyingPeriodsUseCase; + late MockWatchTransactionsUseCase mockWatchTransactionsUseCase; + late MockGetUnderBudgetStatusUseCase mockGetUnderBudgetStatusUseCase; + late StreamController>> + transactionsController; + + final fixedDate = DateTime(2026, 4, 15); + final fixedMonth = DateTime(2026, 4); + + const tStreak = Streak( + type: StreakType.tracking, + length: 5, + status: StreakStatus.active, + daysUntilBreak: 3, + nextMilestone: 7, + consistencyRate: 0.5, + ); + + final tUnderBudgetStatus = UnderBudgetMonthStatus( + month: fixedMonth, + totalBudget: 1000, + totalSpent: 600, + isOnTrack: true, + overrunFlags: const [ + CategoryOverrunFlag( + categoryUuid: 'dining-123', + categoryName: 'Dining Out', + budget: 200, + spent: 250, + ), + ], + ); + + setUp(() { + mockGetStreaksUseCase = MockGetStreaksUseCase(); + mockGetQualifyingPeriodsUseCase = MockGetQualifyingPeriodsUseCase(); + mockWatchTransactionsUseCase = MockWatchTransactionsUseCase(); + mockGetUnderBudgetStatusUseCase = MockGetUnderBudgetStatusUseCase(); + transactionsController = StreamController.broadcast(); + + when(() => mockWatchTransactionsUseCase(any())) + .thenAnswer((_) => transactionsController.stream); + + when(() => mockGetStreaksUseCase(any())) + .thenAnswer((_) async => const Right(tStreak)); + + when(() => mockGetUnderBudgetStatusUseCase(any())) + .thenAnswer((_) async => Right(tUnderBudgetStatus)); + + when(() => mockGetQualifyingPeriodsUseCase(any())).thenAnswer((invocation) { + final params = + invocation.positionalArguments.first as GetQualifyingPeriodsParams; + final m = params.startDate?.month ?? 4; + return Future.value( + Right([ + StreakDay(DateTime(2026, m, 10)), + StreakDay(DateTime(2026, m, 11)), + StreakDay(DateTime(2026, m, 12)), + ]), + ); + }); + }); + + tearDown(() { + transactionsController.close(); + }); + + StreakCubit buildCubit({ + DateTime Function()? clock, + StreakType initialType = StreakType.tracking, + }) { + return StreakCubit.test( + getStreaksUseCase: mockGetStreaksUseCase, + getQualifyingPeriodsUseCase: mockGetQualifyingPeriodsUseCase, + watchTransactionsUseCase: mockWatchTransactionsUseCase, + getUnderBudgetStatusUseCase: mockGetUnderBudgetStatusUseCase, + clock: clock ?? () => fixedDate, + initialType: initialType, + ); + } + + group('StreakCubit', () { + test('initial state has loading true and empty streak', () { + final cubit = buildCubit(); + + expect(cubit.state.isLoading, isTrue); + expect(cubit.state.streak, equals(const Streak.empty())); + expect(cubit.state.selectedMonth, equals(fixedMonth)); + expect(cubit.state.qualifyingDays, isEmpty); + expect(cubit.state.type, equals(StreakType.tracking)); + }); + + test('load populates streak, qualifying days, and consistency rate', + () async { + final cubit = buildCubit(); + + await cubit.load(); + + expect(cubit.state.isLoading, isFalse); + expect(cubit.state.streak, equals(tStreak)); + expect(cubit.state.qualifyingDays.length, equals(3)); + // 3 qualifying days out of 15 elapsed days in April = 0.20 + expect(cubit.state.monthlyConsistencyRate, closeTo(0.2, 0.001)); + expect(cubit.state.failureOption.isNone(), isTrue); + }); + + test('selectType updates type and reloads data for the selected type', + () async { + final cubit = buildCubit(); + await cubit.load(); + + clearInteractions(mockGetStreaksUseCase); + clearInteractions(mockGetQualifyingPeriodsUseCase); + + const appOpenStreak = Streak( + type: StreakType.appOpen, + length: 7, + status: StreakStatus.active, + daysUntilBreak: 2, + nextMilestone: 14, + consistencyRate: 0.7, + ); + + when(() => mockGetStreaksUseCase(any())) + .thenAnswer((_) async => const Right(appOpenStreak)); + + await cubit.selectType(StreakType.appOpen); + + expect(cubit.state.type, equals(StreakType.appOpen)); + expect(cubit.state.streak, equals(appOpenStreak)); + + verify( + () => mockGetStreaksUseCase( + any( + that: isA().having( + (p) => p.type, + 'type', + StreakType.appOpen, + ), + ), + ), + ).called(1); + }); + + test('selectType with underBudget loads budget status and overrun flags', + () async { + final cubit = buildCubit(); + await cubit.load(); + + const underBudgetStreak = Streak( + type: StreakType.underBudget, + length: 2, + status: StreakStatus.active, + daysUntilBreak: 1, + nextMilestone: 3, + consistencyRate: 1, + isCurrentMonthOnTrack: true, + ); + + when(() => mockGetStreaksUseCase(any())) + .thenAnswer((_) async => const Right(underBudgetStreak)); + + await cubit.selectType(StreakType.underBudget); + + expect(cubit.state.type, equals(StreakType.underBudget)); + expect(cubit.state.streak, equals(underBudgetStreak)); + expect(cubit.state.underBudgetStatus, equals(tUnderBudgetStatus)); + expect(cubit.state.isOnTrack, isTrue); + expect(cubit.state.currentMonthBudget, 1000); + expect(cubit.state.currentMonthExpense, 600); + expect(cubit.state.categoryOverrunFlags.length, 1); + expect(cubit.state.categoryOverrunFlags.first.categoryName, 'Dining Out'); + }); + + test('selectType does nothing if target type is already active', () async { + final cubit = buildCubit(); + await cubit.load(); + + clearInteractions(mockGetStreaksUseCase); + await cubit.selectType(StreakType.tracking); + + verifyNever(() => mockGetStreaksUseCase(any())); + }); + + test('previousMonth navigates to prior month and calculates full rate', + () async { + final cubit = buildCubit(); + await cubit.previousMonth(); + + expect(cubit.state.selectedMonth, equals(DateTime(2026, 3))); + // March 2026 has 31 days; 3 qualifying days -> 3 / 31 = 0.0967... + expect(cubit.state.monthlyConsistencyRate, closeTo(3 / 31, 0.001)); + }); + + test('nextMonth navigates forward and returns 0 for future month', + () async { + final cubit = buildCubit(); + await cubit.nextMonth(); + + expect(cubit.state.selectedMonth, equals(DateTime(2026, 5))); + expect(cubit.state.monthlyConsistencyRate, equals(0)); + }); + + test('emits failure when streak use case fails', () async { + const failure = Failure.localFailure(message: 'Database error'); + when(() => mockGetStreaksUseCase(any())) + .thenAnswer((_) async => const Left(failure)); + + final cubit = buildCubit(); + await cubit.load(); + + expect(cubit.state.isLoading, isFalse); + expect(cubit.state.failureOption, equals(const Some(failure))); + }); + + test('emits failure when qualifying periods use case fails', () async { + const failure = Failure.localFailure(message: 'Query error'); + when(() => mockGetQualifyingPeriodsUseCase(any())) + .thenAnswer((_) async => const Left(failure)); + + final cubit = buildCubit(); + await cubit.load(); + + expect(cubit.state.isLoading, isFalse); + expect(cubit.state.failureOption, equals(const Some(failure))); + }); + + test('reloads reactively when transaction stream emits', () async { + final cubit = buildCubit(); + await cubit.load(); + + clearInteractions(mockGetStreaksUseCase); + clearInteractions(mockGetQualifyingPeriodsUseCase); + + transactionsController.add(const Right([])); + await pumpEventQueue(); + + verify(() => mockGetStreaksUseCase(any())).called(1); + verify(() => mockGetQualifyingPeriodsUseCase(any())).called(1); + }); + }); +} diff --git a/test/features/streak/presentation/pages/streaks_page_test.dart b/test/features/streak/presentation/pages/streaks_page_test.dart new file mode 100644 index 0000000..5941b09 --- /dev/null +++ b/test/features/streak/presentation/pages/streaks_page_test.dart @@ -0,0 +1,399 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:expense_tracker/features/streak/domain/entities/category_overrun_flag.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_status.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_type.dart'; +import 'package:expense_tracker/features/streak/domain/entities/under_budget_month_status.dart'; +import 'package:expense_tracker/features/streak/presentation/blocs/streak_cubit.dart'; +import 'package:expense_tracker/features/streak/presentation/blocs/streak_state.dart'; +import 'package:expense_tracker/features/streak/presentation/pages/streaks_page.dart'; +import 'package:expense_tracker/features/streak/presentation/widgets/milestone_progress.dart'; +import 'package:expense_tracker/features/streak/presentation/widgets/streak_calendar.dart'; +import 'package:expense_tracker/features/streak/presentation/widgets/streak_config_sheet.dart'; +import 'package:expense_tracker/features/streak/presentation/widgets/streak_hero.dart'; +import 'package:expense_tracker/features/streak/presentation/widgets/under_budget_month_card.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockStreakCubit extends MockCubit implements StreakCubit {} + +void main() { + setUpAll(() { + registerFallbackValue(StreakType.tracking); + }); + + late MockStreakCubit mockCubit; + + final fixedMonth = DateTime(2026, 4); + + const tStreak = Streak( + type: StreakType.tracking, + length: 14, + status: StreakStatus.active, + daysUntilBreak: 3, + nextMilestone: 21, + consistencyRate: 0.8, + ); + + final tQualifyingDays = { + DateTime(2026, 4), + DateTime(2026, 4, 2), + DateTime(2026, 4, 3), + }; + + setUp(() { + GoogleFonts.config.allowRuntimeFetching = false; + mockCubit = MockStreakCubit(); + + when( + () => mockCubit.load( + month: any(named: 'month'), + showLoading: any(named: 'showLoading'), + type: any(named: 'type'), + ), + ).thenAnswer((_) async {}); + when(() => mockCubit.selectType(any())).thenAnswer((_) async {}); + when(mockCubit.previousMonth).thenAnswer((_) async {}); + when(mockCubit.nextMonth).thenAnswer((_) async {}); + }); + + Widget buildStreaksPage({ + StreakCubit? cubit, + StreakType? initialType, + DateTime? referenceDate, + }) { + return MaterialApp( + home: StreaksPage( + cubit: cubit ?? mockCubit, + initialType: initialType, + referenceDate: referenceDate, + ), + ); + } + + group('StreaksPage', () { + testWidgets('renders loading spinner when initial state is loading', + (tester) async { + when(() => mockCubit.state).thenReturn( + StreakState( + selectedMonth: fixedMonth, + ), + ); + + await tester.pumpWidget(buildStreaksPage()); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('renders hero, calendar, consistency card, and milestones', + (tester) async { + when(() => mockCubit.state).thenReturn( + StreakState( + isLoading: false, + streak: tStreak, + selectedMonth: fixedMonth, + qualifyingDays: tQualifyingDays, + monthlyConsistencyRate: 0.2, + ), + ); + + await tester.pumpWidget(buildStreaksPage()); + + expect(find.byType(StreakHero), findsOneWidget); + expect(find.byType(StreakCalendar), findsOneWidget); + expect(find.byType(StreakConsistencyCard), findsOneWidget); + expect(find.byType(MilestoneProgress), findsOneWidget); + + expect(find.text('14 Day Streak'), findsOneWidget); + expect(find.text('April 2026'), findsOneWidget); + expect(find.text('20%'), findsOneWidget); + expect(find.text('Upcoming Milestones'), findsOneWidget); + }); + + testWidgets('renders type chips for all active streak types', + (tester) async { + when(() => mockCubit.state).thenReturn( + StreakState( + isLoading: false, + streak: tStreak, + selectedMonth: fixedMonth, + qualifyingDays: tQualifyingDays, + monthlyConsistencyRate: 0.2, + ), + ); + + await tester.pumpWidget(buildStreaksPage()); + + expect( + find.byKey(const Key('streak_type_chip_tracking')), + findsOneWidget, + ); + expect( + find.byKey(const Key('streak_type_chip_noSpend')), + findsOneWidget, + ); + expect( + find.byKey(const Key('streak_type_chip_appOpen')), + findsOneWidget, + ); + expect( + find.byKey(const Key('streak_type_chip_underBudget')), + findsOneWidget, + ); + }); + + testWidgets('tapping a type chip calls selectType on cubit', + (tester) async { + when(() => mockCubit.state).thenReturn( + StreakState( + isLoading: false, + streak: tStreak, + selectedMonth: fixedMonth, + qualifyingDays: tQualifyingDays, + monthlyConsistencyRate: 0.2, + ), + ); + + await tester.pumpWidget(buildStreaksPage()); + + await tester.tap(find.byKey(const Key('streak_type_chip_noSpend'))); + verify(() => mockCubit.selectType(StreakType.noSpend)).called(1); + + await tester.tap(find.byKey(const Key('streak_type_chip_appOpen'))); + verify(() => mockCubit.selectType(StreakType.appOpen)).called(1); + + await tester.tap(find.byKey(const Key('streak_type_chip_underBudget'))); + verify(() => mockCubit.selectType(StreakType.underBudget)).called(1); + }); + + testWidgets( + 'switching type switches hero, calendar marks, and consistency source', + (tester) async { + const appOpenStreak = Streak( + type: StreakType.appOpen, + length: 7, + status: StreakStatus.active, + daysUntilBreak: 2, + nextMilestone: 14, + consistencyRate: 0.7, + ); + + final appOpenDays = { + DateTime(2026, 4, 5), + DateTime(2026, 4, 12), + }; + + when(() => mockCubit.state).thenReturn( + StreakState( + isLoading: false, + type: StreakType.appOpen, + streak: appOpenStreak, + selectedMonth: fixedMonth, + qualifyingDays: appOpenDays, + monthlyConsistencyRate: 0.7, + ), + ); + + await tester.pumpWidget(buildStreaksPage()); + + expect(find.text('7 Day Streak'), findsOneWidget); + expect(find.text('70%'), findsOneWidget); + + final appOpenChip = tester.widget( + find.byKey(const Key('streak_type_chip_appOpen')), + ); + expect(appOpenChip.selected, isTrue); + }); + + testWidgets( + 'long-press on a type chip does not open StreakConfigSheet', + (tester) async { + when(() => mockCubit.state).thenReturn( + StreakState( + isLoading: false, + type: StreakType.appOpen, + streak: const Streak( + type: StreakType.appOpen, + length: 3, + status: StreakStatus.active, + daysUntilBreak: 3, + nextMilestone: 7, + consistencyRate: 0.5, + ), + selectedMonth: fixedMonth, + ), + ); + + await tester.pumpWidget(buildStreaksPage()); + + // Long press the app-open type chip + await tester.longPress(find.byKey(const Key('streak_type_chip_appOpen'))); + await tester.pumpAndSettle(); + + expect(find.byType(StreakConfigSheet), findsNothing); + }); + + testWidgets('types without records yet render empty states, not zeros', + (tester) async { + when(() => mockCubit.state).thenReturn( + StreakState( + isLoading: false, + type: StreakType.noSpend, + selectedMonth: fixedMonth, + ), + ); + + await tester.pumpWidget(buildStreaksPage()); + + // Hero should show empty headline, not "0 Day Streak" + expect(find.text('No Active Streak'), findsOneWidget); + expect(find.text('0 Day Streak'), findsNothing); + + // Consistency should show "—" and not "0%" + expect(find.text('—'), findsOneWidget); + expect(find.text('0%'), findsNothing); + expect(find.text('No activity recorded yet'), findsOneWidget); + }); + + testWidgets('initialType parameter selects requested type on creation', + (tester) async { + when(() => mockCubit.state).thenReturn( + StreakState( + isLoading: false, + selectedMonth: fixedMonth, + ), + ); + + await tester.pumpWidget( + buildStreaksPage(initialType: StreakType.appOpen), + ); + + verify(() => mockCubit.selectType(StreakType.appOpen)).called(1); + }); + + testWidgets('calendar chevrons call previousMonth and nextMonth', + (tester) async { + when(() => mockCubit.state).thenReturn( + StreakState( + isLoading: false, + streak: tStreak, + selectedMonth: fixedMonth, + qualifyingDays: tQualifyingDays, + monthlyConsistencyRate: 0.2, + ), + ); + + await tester.pumpWidget(buildStreaksPage()); + + await tester.tap(find.byTooltip('Previous month')); + verify(mockCubit.previousMonth).called(1); + + await tester.tap(find.byTooltip('Next month')); + verify(mockCubit.nextMonth).called(1); + }); + + testWidgets('configure action is not shown in AppBar', (tester) async { + when(() => mockCubit.state).thenReturn( + StreakState( + isLoading: false, + streak: tStreak, + selectedMonth: fixedMonth, + qualifyingDays: tQualifyingDays, + monthlyConsistencyRate: 0.2, + ), + ); + + await tester.pumpWidget(buildStreaksPage()); + + expect(find.byTooltip('Configure Streak'), findsNothing); + expect(find.byIcon(Icons.tune_rounded), findsNothing); + }); + + testWidgets('long pressing hero does not open StreakConfigSheet', + (tester) async { + when(() => mockCubit.state).thenReturn( + StreakState( + isLoading: false, + streak: tStreak, + selectedMonth: fixedMonth, + qualifyingDays: tQualifyingDays, + monthlyConsistencyRate: 0.2, + ), + ); + + await tester.pumpWidget(buildStreaksPage()); + + await tester.longPress(find.byType(StreakHero)); + await tester.pumpAndSettle(); + + expect(find.byType(StreakConfigSheet), findsNothing); + }); + + testWidgets( + 'renders UnderBudgetMonthCard with flags and preview when type is ' + 'underBudget (AE7, AE8)', + (tester) async { + const underBudgetStreak = Streak( + type: StreakType.underBudget, + length: 2, + status: StreakStatus.active, + daysUntilBreak: 1, + nextMilestone: 3, + consistencyRate: 1, + isCurrentMonthOnTrack: false, + ); + + final status = UnderBudgetMonthStatus( + month: fixedMonth, + totalBudget: 1500, + totalSpent: 1600, + isOnTrack: false, + overrunFlags: const [ + CategoryOverrunFlag( + categoryUuid: 'dining-1', + categoryName: 'Dining Out', + budget: 400, + spent: 550, + ), + ], + ); + + when(() => mockCubit.state).thenReturn( + StreakState( + isLoading: false, + type: StreakType.underBudget, + streak: underBudgetStreak, + selectedMonth: fixedMonth, + underBudgetStatus: status, + monthlyConsistencyRate: 1, + ), + ); + + await tester.pumpWidget( + buildStreaksPage(referenceDate: fixedMonth), + ); + + // UnderBudgetMonthCard replaces StreakCalendar + expect(find.byType(UnderBudgetMonthCard), findsOneWidget); + expect(find.byType(StreakCalendar), findsNothing); + + // Hero shows Month unit + expect(find.text('2 Month Streak'), findsOneWidget); + + // Shows off-track preview + expect(find.text('OFF TRACK (PREVIEW)'), findsOneWidget); + + // Shows budget vs spent values + expect(find.textContaining('1,600'), findsWidgets); + expect(find.textContaining('1,500'), findsWidgets); + + // Shows category overrun flag (R17, AE8) + expect(find.textContaining('Category Budget Overruns'), findsOneWidget); + expect(find.text('Dining Out'), findsOneWidget); + expect(find.textContaining('150'), findsOneWidget); + }, + ); + }); +} diff --git a/test/features/streak/presentation/widgets/milestone_progress_test.dart b/test/features/streak/presentation/widgets/milestone_progress_test.dart new file mode 100644 index 0000000..f907c61 --- /dev/null +++ b/test/features/streak/presentation/widgets/milestone_progress_test.dart @@ -0,0 +1,81 @@ +import 'package:expense_tracker/features/streak/domain/entities/streak.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_status.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_type.dart'; +import 'package:expense_tracker/features/streak/presentation/widgets/milestone_progress.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_fonts/google_fonts.dart'; + +void main() { + setUp(() { + GoogleFonts.config.allowRuntimeFetching = false; + }); + + Widget buildMilestones(Streak streak) { + return MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: MilestoneProgress(streak: streak), + ), + ), + ); + } + + group('MilestoneProgress', () { + testWidgets('renders next upcoming milestones from ladder with days left', + (tester) async { + const streak = Streak( + type: StreakType.tracking, + length: 14, + status: StreakStatus.active, + daysUntilBreak: 3, + nextMilestone: 21, + consistencyRate: 0.8, + ); + + await tester.pumpWidget(buildMilestones(streak)); + + expect(find.text('Upcoming Milestones'), findsOneWidget); + expect(find.text('21 Day Routine'), findsOneWidget); + expect(find.text('7 days left'), findsOneWidget); + + expect(find.text('Monthly Master'), findsOneWidget); + expect(find.text('16 days left'), findsOneWidget); + expect(find.byType(LinearProgressIndicator), findsNWidgets(2)); + }); + + testWidgets('renders singular "1 day left" when 1 day remains', + (tester) async { + const streak = Streak( + type: StreakType.tracking, + length: 6, + status: StreakStatus.active, + daysUntilBreak: 3, + nextMilestone: 7, + consistencyRate: 0.8, + ); + + await tester.pumpWidget(buildMilestones(streak)); + + expect(find.text('7 Day Habit'), findsOneWidget); + expect(find.text('1 day left'), findsOneWidget); + }); + + testWidgets('advances to next year milestone when length reaches 365', + (tester) async { + const streak = Streak( + type: StreakType.tracking, + length: 365, + status: StreakStatus.active, + daysUntilBreak: 3, + nextMilestone: 730, + consistencyRate: 0.95, + ); + + await tester.pumpWidget(buildMilestones(streak)); + + expect(find.text('730 Day Milestone'), findsOneWidget); + expect(find.text('365 days left'), findsOneWidget); + }); + }); +} diff --git a/test/features/streak/presentation/widgets/streak_badge_test.dart b/test/features/streak/presentation/widgets/streak_badge_test.dart new file mode 100644 index 0000000..1fafb18 --- /dev/null +++ b/test/features/streak/presentation/widgets/streak_badge_test.dart @@ -0,0 +1,99 @@ +import 'package:expense_tracker/features/streak/domain/entities/streak.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_status.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_type.dart'; +import 'package:expense_tracker/features/streak/presentation/widgets/streak_badge.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Widget buildSubject({ + required Streak streak, + VoidCallback? onTap, + }) { + return MaterialApp( + home: Scaffold( + body: StreakBadge( + streak: streak, + onTap: onTap, + ), + ), + ); + } + + testWidgets( + 'renders flame icon and streak count when active', + (tester) async { + const streak = Streak( + type: StreakType.tracking, + length: 5, + status: StreakStatus.active, + daysUntilBreak: 2, + nextMilestone: 7, + consistencyRate: 0.8, + ); + + await tester.pumpWidget(buildSubject(streak: streak)); + + expect(find.byIcon(Icons.local_fire_department_rounded), findsOneWidget); + expect(find.text('5'), findsOneWidget); + }, + ); + + testWidgets( + 'renders 0 with muted style when streak is empty', + (tester) async { + const streak = Streak( + type: StreakType.tracking, + length: 0, + status: StreakStatus.none, + daysUntilBreak: 0, + nextMilestone: 3, + consistencyRate: 0, + ); + + await tester.pumpWidget(buildSubject(streak: streak)); + + expect(find.byIcon(Icons.local_fire_department_rounded), findsOneWidget); + expect(find.text('0'), findsOneWidget); + }, + ); + + testWidgets('renders at-risk styling when streak is at risk', (tester) async { + const streak = Streak( + type: StreakType.tracking, + length: 8, + status: StreakStatus.atRisk, + daysUntilBreak: 0, + nextMilestone: 14, + consistencyRate: 0.9, + ); + + await tester.pumpWidget(buildSubject(streak: streak)); + + expect(find.text('8'), findsOneWidget); + }); + + testWidgets('triggers onTap callback when pressed', (tester) async { + var tapped = false; + const streak = Streak( + type: StreakType.tracking, + length: 3, + status: StreakStatus.active, + daysUntilBreak: 1, + nextMilestone: 7, + consistencyRate: 0.7, + ); + + await tester.pumpWidget( + buildSubject( + streak: streak, + onTap: () => tapped = true, + ), + ); + + await tester.tap(find.byType(StreakBadge)); + await tester.pump(); + + expect(tapped, isTrue); + }); +} diff --git a/test/features/streak/presentation/widgets/streak_calendar_test.dart b/test/features/streak/presentation/widgets/streak_calendar_test.dart new file mode 100644 index 0000000..aae9a22 --- /dev/null +++ b/test/features/streak/presentation/widgets/streak_calendar_test.dart @@ -0,0 +1,118 @@ +import 'package:expense_tracker/features/streak/presentation/widgets/streak_calendar.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_fonts/google_fonts.dart'; + +void main() { + setUp(() { + GoogleFonts.config.allowRuntimeFetching = false; + }); + + final fixedMonth = DateTime(2026, 4); + final fixedToday = DateTime(2026, 4, 15); + + Widget buildCalendar({ + required Set qualifyingDays, + VoidCallback? onPreviousMonth, + VoidCallback? onNextMonth, + DateTime? referenceDate, + }) { + return MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: Column( + children: [ + StreakCalendar( + selectedMonth: fixedMonth, + qualifyingDays: qualifyingDays, + onPreviousMonth: onPreviousMonth ?? () {}, + onNextMonth: onNextMonth ?? () {}, + referenceDate: referenceDate ?? fixedToday, + ), + const StreakConsistencyCard(consistencyRate: 0.85), + ], + ), + ), + ), + ); + } + + group('StreakCalendar & StreakConsistencyCard', () { + testWidgets('renders month header, weekdays, and consistency card', + (tester) async { + await tester.pumpWidget( + buildCalendar( + qualifyingDays: { + DateTime(2026, 4), + DateTime(2026, 4, 2), + }, + ), + ); + + expect(find.text('April 2026'), findsOneWidget); + expect(find.text('Mon'), findsOneWidget); + expect(find.text('Sun'), findsOneWidget); + expect(find.text('1'), findsOneWidget); + expect(find.text('30'), findsOneWidget); + + expect(find.text('CONSISTENCY'), findsOneWidget); + expect(find.text('85%'), findsOneWidget); + expect(find.text('Completion rate this month'), findsOneWidget); + }); + + testWidgets('calls onPreviousMonth and onNextMonth on button taps', + (tester) async { + var prevCalled = false; + var nextCalled = false; + + await tester.pumpWidget( + buildCalendar( + qualifyingDays: {}, + onPreviousMonth: () => prevCalled = true, + onNextMonth: () => nextCalled = true, + ), + ); + + await tester.tap(find.byTooltip('Previous month')); + expect(prevCalled, isTrue); + + await tester.tap(find.byTooltip('Next month')); + expect(nextCalled, isTrue); + }); + + testWidgets('highlights today and qualifying days with appropriate styling', + (tester) async { + await tester.pumpWidget( + buildCalendar( + qualifyingDays: { + DateTime(2026, 4, 15), // Today is qualified + DateTime(2026, 4, 10), // Another day qualified + }, + ), + ); + + // Day 15 should have white text (on primary) because today is qualified + final day15Text = tester.widget(find.text('15')); + expect(day15Text.style?.color, equals(Colors.white)); + + // Day 10 should have dark green text (on secondary container) + final day10Text = tester.widget(find.text('10')); + expect(day10Text.style?.color, equals(const Color(0xFF217128))); + }); + + testWidgets('highlights today with primary border when not qualifying', + (tester) async { + await tester.pumpWidget( + buildCalendar( + qualifyingDays: { + DateTime(2026, 4, 10), // Only day 10 qualified + }, + ), + ); + + // Day 15 is today and not qualified -> primary text color + final day15Text = tester.widget(find.text('15')); + expect(day15Text.style?.color, equals(const Color(0xFF00113A))); + }); + }); +} diff --git a/test/features/streak/presentation/widgets/streak_card_test.dart b/test/features/streak/presentation/widgets/streak_card_test.dart new file mode 100644 index 0000000..78ee1d9 --- /dev/null +++ b/test/features/streak/presentation/widgets/streak_card_test.dart @@ -0,0 +1,313 @@ +import 'package:expense_tracker/features/streak/domain/entities/streak.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_status.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_type.dart'; +import 'package:expense_tracker/features/streak/presentation/widgets/streak_card.dart'; +import 'package:expense_tracker/features/streak/presentation/widgets/streak_config_sheet.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:google_fonts/google_fonts.dart'; + +void main() { + setUp(() { + GoogleFonts.config.allowRuntimeFetching = false; + }); + + Widget buildCard({ + required Streak streak, + VoidCallback? onTap, + VoidCallback? onLongPress, + }) { + return MaterialApp( + home: Scaffold( + body: StreakCard( + streak: streak, + onTap: onTap, + onLongPress: onLongPress, + ), + ), + ); + } + + testWidgets( + 'active streak renders "N Day Streak" with correct milestone subtitle ' + 'and progress fraction', + (tester) async { + const streak = Streak( + type: StreakType.tracking, + length: 5, + status: StreakStatus.active, + daysUntilBreak: 2, + nextMilestone: 7, + consistencyRate: 0.8, + ); + + await tester.pumpWidget(buildCard(streak: streak)); + + expect(find.text('5 Day Streak'), findsOneWidget); + expect(find.text('2 days to next milestone'), findsOneWidget); + + final progressIndicator = tester.widget( + find.byType(LinearProgressIndicator), + ); + expect(progressIndicator.value, closeTo(5 / 7, 0.001)); + }, + ); + + testWidgets( + 'active streak with 1 day remaining uses singular "day"', + (tester) async { + const streak = Streak( + type: StreakType.tracking, + length: 6, + status: StreakStatus.active, + daysUntilBreak: 2, + nextMilestone: 7, + consistencyRate: 0.9, + ); + + await tester.pumpWidget(buildCard(streak: streak)); + + expect(find.text('6 Day Streak'), findsOneWidget); + expect(find.text('1 day to next milestone'), findsOneWidget); + + final progressIndicator = tester.widget( + find.byType(LinearProgressIndicator), + ); + expect(progressIndicator.value, closeTo(6 / 7, 0.001)); + }, + ); + + testWidgets( + 'at-risk state renders the risk subtitle', + (tester) async { + const streak = Streak( + type: StreakType.tracking, + length: 5, + status: StreakStatus.atRisk, + daysUntilBreak: 1, + nextMilestone: 7, + consistencyRate: 0.7, + ); + + await tester.pumpWidget(buildCard(streak: streak)); + + expect(find.text('5 Day Streak'), findsOneWidget); + expect( + find.text('At risk! Log today to keep your streak'), + findsOneWidget, + ); + + final progressIndicator = tester.widget( + find.byType(LinearProgressIndicator), + ); + expect(progressIndicator.value, closeTo(5 / 7, 0.001)); + }, + ); + + testWidgets( + 'warming-up state renders warming framing (Covers AE3 at widget level)', + (tester) async { + const streak = Streak( + type: StreakType.tracking, + length: 2, + status: StreakStatus.warmingUp, + daysUntilBreak: 2, + nextMilestone: 7, + consistencyRate: 0.5, + ); + + await tester.pumpWidget(buildCard(streak: streak)); + + // Renders warming framing instead of celebrated streak + expect(find.text('Warming Up'), findsOneWidget); + expect(find.text('2 Day Streak'), findsNothing); + expect( + find.text('Warming up · Keep logging to activate your streak'), + findsOneWidget, + ); + + final progressIndicator = tester.widget( + find.byType(LinearProgressIndicator), + ); + expect(progressIndicator.value, closeTo(2 / 7, 0.001)); + }, + ); + + testWidgets( + 'empty streak hides the card entirely', + (tester) async { + await tester.pumpWidget(buildCard(streak: const Streak.empty())); + + expect(find.byType(StreakCard), findsOneWidget); + expect(find.textContaining('Streak'), findsNothing); + expect(find.textContaining('Warming'), findsNothing); + expect(find.byType(LinearProgressIndicator), findsNothing); + }, + ); + + testWidgets( + 'tap calls onTap callback when provided', + (tester) async { + var tapped = false; + const streak = Streak( + type: StreakType.tracking, + length: 4, + status: StreakStatus.active, + daysUntilBreak: 2, + nextMilestone: 7, + consistencyRate: 0.8, + ); + + await tester.pumpWidget( + buildCard( + streak: streak, + onTap: () => tapped = true, + ), + ); + + await tester.tap(find.byType(StreakCard)); + await tester.pump(); + + expect(tapped, isTrue); + }, + ); + + testWidgets( + 'long-press calls onLongPress callback when provided', + (tester) async { + var longPressed = false; + const streak = Streak( + type: StreakType.tracking, + length: 4, + status: StreakStatus.active, + daysUntilBreak: 2, + nextMilestone: 7, + consistencyRate: 0.8, + ); + + await tester.pumpWidget( + buildCard( + streak: streak, + onLongPress: () => longPressed = true, + ), + ); + + await tester.longPress(find.byType(StreakCard)); + await tester.pump(); + + expect(longPressed, isTrue); + }, + ); + + testWidgets( + 'tap navigates to /streaks by default', + (tester) async { + const streak = Streak( + type: StreakType.tracking, + length: 4, + status: StreakStatus.active, + daysUntilBreak: 2, + nextMilestone: 7, + consistencyRate: 0.8, + ); + + final router = GoRouter( + initialLocation: '/', + routes: [ + GoRoute( + path: '/', + builder: (context, state) => const Scaffold( + body: StreakCard(streak: streak), + ), + ), + GoRoute( + path: '/streaks', + builder: (context, state) => const Scaffold( + body: Text('Target Streaks Page'), + ), + ), + ], + ); + + await tester.pumpWidget( + MaterialApp.router( + routerConfig: router, + ), + ); + + await tester.tap(find.byType(StreakCard)); + await tester.pumpAndSettle(); + + expect(find.text('Target Streaks Page'), findsOneWidget); + }, + ); + + testWidgets( + 'long-press does not open StreakConfigSheet by default', + (tester) async { + const streak = Streak( + type: StreakType.tracking, + length: 4, + status: StreakStatus.active, + daysUntilBreak: 2, + nextMilestone: 7, + consistencyRate: 0.8, + ); + + await tester.pumpWidget( + buildCard( + streak: streak, + onTap: () {}, + ), + ); + + await tester.longPress(find.byType(StreakCard)); + await tester.pumpAndSettle(); + + expect(find.byType(StreakConfigSheet), findsNothing); + }, + ); + + testWidgets( + 'underBudget active streak renders "N Month Streak" and ' + '"months to next milestone"', + (tester) async { + const streak = Streak( + type: StreakType.underBudget, + length: 2, + status: StreakStatus.active, + daysUntilBreak: 1, + nextMilestone: 3, + consistencyRate: 1, + ); + + await tester.pumpWidget(buildCard(streak: streak)); + + expect(find.text('Budget · 2 Month Streak'), findsOneWidget); + expect(find.text('1 month to next milestone'), findsOneWidget); + }, + ); + + testWidgets( + 'underBudget at-risk streak renders under-budget risk subtitle', + (tester) async { + const streak = Streak( + type: StreakType.underBudget, + length: 2, + status: StreakStatus.atRisk, + daysUntilBreak: 1, + nextMilestone: 3, + consistencyRate: 1, + ); + + await tester.pumpWidget(buildCard(streak: streak)); + + expect(find.text('Budget · 2 Month Streak'), findsOneWidget); + expect( + find.text('At risk! Stay under budget this month to keep your streak'), + findsOneWidget, + ); + }, + ); +} diff --git a/test/features/streak/presentation/widgets/streak_config_sheet_test.dart b/test/features/streak/presentation/widgets/streak_config_sheet_test.dart new file mode 100644 index 0000000..a736d9d --- /dev/null +++ b/test/features/streak/presentation/widgets/streak_config_sheet_test.dart @@ -0,0 +1,274 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_config.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_type.dart'; +import 'package:expense_tracker/features/streak/presentation/blocs/streak_config_cubit.dart'; +import 'package:expense_tracker/features/streak/presentation/blocs/streak_config_state.dart'; +import 'package:expense_tracker/features/streak/presentation/widgets/streak_config_sheet.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockStreakConfigCubit extends MockCubit + implements StreakConfigCubit {} + +void main() { + late MockStreakConfigCubit mockCubit; + + setUp(() { + GoogleFonts.config.allowRuntimeFetching = false; + mockCubit = MockStreakConfigCubit(); + when(() => mockCubit.close()).thenAnswer((_) async {}); + }); + + Widget buildSheet({ + StreakType initialType = StreakType.tracking, + }) { + return MaterialApp( + home: Scaffold( + body: StreakConfigSheet( + initialType: initialType, + cubit: mockCubit, + ), + ), + ); + } + + group('StreakConfigSheet', () { + testWidgets('opens at tracking section by default', (tester) async { + when(() => mockCubit.state).thenReturn( + const StreakConfigState(isLoading: false), + ); + + await tester.pumpWidget(buildSheet()); + + expect(find.text('Streak Settings'), findsOneWidget); + expect(find.text('Window Gap Allowance'), findsOneWidget); + expect(find.text('Activation Threshold'), findsOneWidget); + expect(find.byKey(const Key('window_value')), findsOneWidget); + expect(find.text('3 days'), findsNWidgets(2)); // window & minimum + }); + + testWidgets( + 'opens at requested section when deep-linked to app-open', + (tester) async { + when(() => mockCubit.state).thenReturn( + const StreakConfigState( + isLoading: false, + selectedType: StreakType.appOpen, + ), + ); + + await tester.pumpWidget( + buildSheet(initialType: StreakType.appOpen), + ); + + expect(find.text('Streak Settings'), findsOneWidget); + expect(find.text('App-Open Cadence'), findsOneWidget); + expect(find.text('Phase 2 Preview'), findsOneWidget); + expect(find.byKey(const Key('cadence_preset_1')), findsOneWidget); + expect(find.byKey(const Key('cadence_preset_7')), findsOneWidget); + expect(find.text('Custom Cadence'), findsOneWidget); + }, + ); + + testWidgets( + 'opens at requested section when deep-linked to no-spend', + (tester) async { + when(() => mockCubit.state).thenReturn( + const StreakConfigState( + isLoading: false, + selectedType: StreakType.noSpend, + ), + ); + + await tester.pumpWidget( + buildSheet(initialType: StreakType.noSpend), + ); + + expect(find.text('No-Spend Streak'), findsOneWidget); + expect(find.text('Phase 2'), findsOneWidget); + expect(find.text('Reserved Placeholder'), findsOneWidget); + }, + ); + + testWidgets( + 'opens at requested section when deep-linked to under-budget', + (tester) async { + when(() => mockCubit.state).thenReturn( + const StreakConfigState( + isLoading: false, + selectedType: StreakType.underBudget, + ), + ); + + await tester.pumpWidget( + buildSheet(initialType: StreakType.underBudget), + ); + + expect(find.text('Under-Budget Streak'), findsOneWidget); + expect(find.text('Phase 3'), findsOneWidget); + expect(find.text('Reserved Placeholder'), findsOneWidget); + }, + ); + + testWidgets( + 'tapping type chips invokes selectType on cubit', + (tester) async { + when(() => mockCubit.state).thenReturn( + const StreakConfigState(isLoading: false), + ); + + await tester.pumpWidget(buildSheet()); + + await tester.tap( + find.byKey(const Key('streak_config_type_appOpen')), + ); + verify(() => mockCubit.selectType(StreakType.appOpen)).called(1); + + await tester.tap( + find.byKey(const Key('streak_config_type_noSpend')), + ); + verify(() => mockCubit.selectType(StreakType.noSpend)).called(1); + + await tester.tap( + find.byKey(const Key('streak_config_type_underBudget')), + ); + verify(() => mockCubit.selectType(StreakType.underBudget)).called(1); + }, + ); + + testWidgets( + 'tracking steppers increment and decrement window and minimum', + (tester) async { + when(() => mockCubit.incrementWindow()).thenAnswer((_) async {}); + when(() => mockCubit.decrementWindow()).thenAnswer((_) async {}); + when(() => mockCubit.incrementMinimum()).thenAnswer((_) async {}); + when(() => mockCubit.decrementMinimum()).thenAnswer((_) async {}); + + when(() => mockCubit.state).thenReturn( + const StreakConfigState(isLoading: false), + ); + + await tester.pumpWidget(buildSheet()); + + await tester.tap(find.byKey(const Key('window_increment'))); + verify(() => mockCubit.incrementWindow()).called(1); + + await tester.tap(find.byKey(const Key('window_decrement'))); + verify(() => mockCubit.decrementWindow()).called(1); + + await tester.tap(find.byKey(const Key('minimum_increment'))); + verify(() => mockCubit.incrementMinimum()).called(1); + + await tester.tap(find.byKey(const Key('minimum_decrement'))); + verify(() => mockCubit.decrementMinimum()).called(1); + }, + ); + + testWidgets( + 'stepper disables decrement when value is 1 (clamping gate)', + (tester) async { + when(() => mockCubit.state).thenReturn( + const StreakConfigState( + isLoading: false, + config: StreakConfig(window: 1, minimum: 1), + ), + ); + + await tester.pumpWidget(buildSheet()); + + final windowDecr = tester.widget( + find.byKey(const Key('window_decrement')), + ); + expect(windowDecr.onPressed, isNull); + + final minDecr = tester.widget( + find.byKey(const Key('minimum_decrement')), + ); + expect(minDecr.onPressed, isNull); + }, + ); + + testWidgets( + 'app-open preset chips and custom stepper invoke updateCadence on cubit', + (tester) async { + when(() => mockCubit.updateCadence(any())).thenAnswer((_) async {}); + when(() => mockCubit.incrementCadence()).thenAnswer((_) async {}); + when(() => mockCubit.decrementCadence()).thenAnswer((_) async {}); + + when(() => mockCubit.state).thenReturn( + const StreakConfigState( + isLoading: false, + selectedType: StreakType.appOpen, + ), + ); + + await tester.pumpWidget(buildSheet(initialType: StreakType.appOpen)); + + await tester.tap(find.byKey(const Key('cadence_preset_1'))); + verify(() => mockCubit.updateCadence(1)).called(1); + + await tester.tap(find.byKey(const Key('cadence_preset_3'))); + verify(() => mockCubit.updateCadence(3)).called(1); + + await tester.tap(find.byKey(const Key('cadence_increment'))); + verify(() => mockCubit.incrementCadence()).called(1); + + await tester.tap(find.byKey(const Key('cadence_decrement'))); + verify(() => mockCubit.decrementCadence()).called(1); + }, + ); + + testWidgets( + 'shows loading indicator when isLoading is true', + (tester) async { + when(() => mockCubit.state).thenReturn( + const StreakConfigState(), + ); + + await tester.pumpWidget(buildSheet()); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }, + ); + + testWidgets('close button pops the sheet', (tester) async { + when(() => mockCubit.state).thenReturn( + const StreakConfigState(isLoading: false), + ); + + var didPop = false; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + return ElevatedButton( + onPressed: () { + StreakConfigSheet.show( + context, + cubit: mockCubit, + ).then((_) { + didPop = true; + }); + }, + child: const Text('Open Sheet'), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Open Sheet')); + await tester.pumpAndSettle(); + + expect(find.byType(StreakConfigSheet), findsOneWidget); + + await tester.tap(find.byIcon(Icons.close_rounded)); + await tester.pumpAndSettle(); + + expect(find.byType(StreakConfigSheet), findsNothing); + expect(didPop, isTrue); + }); + }); +} diff --git a/test/features/streak/presentation/widgets/streak_hero_test.dart b/test/features/streak/presentation/widgets/streak_hero_test.dart new file mode 100644 index 0000000..4bc7b5b --- /dev/null +++ b/test/features/streak/presentation/widgets/streak_hero_test.dart @@ -0,0 +1,128 @@ +import 'package:expense_tracker/features/streak/domain/entities/streak.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_status.dart'; +import 'package:expense_tracker/features/streak/domain/entities/streak_type.dart'; +import 'package:expense_tracker/features/streak/presentation/widgets/streak_hero.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_fonts/google_fonts.dart'; + +void main() { + setUp(() { + GoogleFonts.config.allowRuntimeFetching = false; + }); + + Widget buildHero(Streak streak, {VoidCallback? onLongPress}) { + return MaterialApp( + home: Scaffold( + body: StreakHero( + streak: streak, + onLongPress: onLongPress, + ), + ), + ); + } + + group('StreakHero', () { + testWidgets('renders active status with plural headline', (tester) async { + const streak = Streak( + type: StreakType.tracking, + length: 14, + status: StreakStatus.active, + daysUntilBreak: 3, + nextMilestone: 21, + consistencyRate: 0.8, + ); + + await tester.pumpWidget(buildHero(streak)); + + expect(find.text('14 Day Streak'), findsOneWidget); + expect(find.text('MASTERING FINANCIAL DISCIPLINE'), findsOneWidget); + expect(find.byIcon(Icons.local_fire_department_rounded), findsOneWidget); + }); + + testWidgets('renders singular headline for 1 day streak', (tester) async { + const streak = Streak( + type: StreakType.tracking, + length: 1, + status: StreakStatus.warmingUp, + daysUntilBreak: 2, + nextMilestone: 7, + consistencyRate: 0.5, + ); + + await tester.pumpWidget(buildHero(streak)); + + expect(find.text('1 Day Streak'), findsOneWidget); + expect(find.text('WARMING UP • HABIT IN FORMATION'), findsOneWidget); + }); + + testWidgets('renders at risk subtitle when status is atRisk', + (tester) async { + const streak = Streak( + type: StreakType.tracking, + length: 5, + status: StreakStatus.atRisk, + daysUntilBreak: 1, + nextMilestone: 7, + consistencyRate: 0.5, + ); + + await tester.pumpWidget(buildHero(streak)); + + expect(find.text('AT RISK • LOG AN EXPENSE TODAY'), findsOneWidget); + }); + + testWidgets('renders broken subtitle when status is broken', + (tester) async { + const streak = Streak( + type: StreakType.tracking, + length: 0, + status: StreakStatus.broken, + daysUntilBreak: 0, + nextMilestone: 7, + consistencyRate: 0, + ); + + await tester.pumpWidget(buildHero(streak)); + + expect(find.text('No Active Streak'), findsOneWidget); + expect(find.text('STREAK PAUSED • LOG TODAY TO RESUME'), findsOneWidget); + }); + + testWidgets('renders warming up when length is 0 and status warmingUp', + (tester) async { + const streak = Streak( + type: StreakType.tracking, + length: 0, + status: StreakStatus.warmingUp, + daysUntilBreak: 0, + nextMilestone: 7, + consistencyRate: 0, + ); + + await tester.pumpWidget(buildHero(streak)); + + expect(find.text('Warming Up'), findsOneWidget); + expect(find.text('WARMING UP • HABIT IN FORMATION'), findsOneWidget); + }); + + testWidgets('triggers onLongPress callback', (tester) async { + var pressed = false; + const streak = Streak( + type: StreakType.tracking, + length: 5, + status: StreakStatus.active, + daysUntilBreak: 3, + nextMilestone: 7, + consistencyRate: 0.7, + ); + + await tester.pumpWidget( + buildHero(streak, onLongPress: () => pressed = true), + ); + + await tester.longPress(find.byType(StreakHero)); + expect(pressed, isTrue); + }); + }); +}