Skip to content

feat: three-level envelope tree, counter easter egg, and first-launch dashboard fix - #109

Open
MF-Rozi wants to merge 9 commits into
masterfrom
feat/three-level-envelope-tree
Open

feat: three-level envelope tree, counter easter egg, and first-launch dashboard fix#109
MF-Rozi wants to merge 9 commits into
masterfrom
feat/three-level-envelope-tree

Conversation

@MF-Rozi

@MF-Rozi MF-Rozi commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Description

What:

  1. Three-level envelope tree (category manager) — the manage view now renders Level-3 envelopes inline below their sub-parents instead of requiring drill-down. Gesture split on sub-parent rows: row tap opens the focused sub-envelopes page, the trailing chevron expands/collapses inline, and a pencil opens the edit form (new onChildEdit callback). Budget aggregation is unified into one depth-capped, cycle-safe helper (CategoryTreeX.sumBudgetUnder) shared by the tree, the focused page, and the transaction-entry picker sheet.
  2. Counter Easter egg — the leftover template counter becomes a hidden feature, unlocked Android-developer-options style: tap the Settings version row 7 times → ritual hint dialog → complete 3 steps (log a transaction, visit Stats, open Categories; steps count only after the hint is seen) → unlock flash + Counter tile in Settings. Hiding from the Counter screen re-locks everything (full reset, ritual redo). Progress persists via SharedPreferences as one atomic JSON snapshot, with restore-time self-heal for partial writes.
  3. First-launch dashboard fix — transaction cards resolved their category with a one-shot context.read, so on cold start (before the categories stream emitted) every card showed "Uncategorized" and never recovered. Now uses context.watch so cards rebuild when categories arrive.
  4. Build — Gradle JVM heap raised 1536M → 4G (Jetify transform was failing with Java heap space).

How:

  1. EnvelopeTreeListView gained a second collapse set (_collapsedSubs), inline level-3 rendering reusing the stagger-line visual, a trailing action cluster (chevron + pencil), and a cycle-safe budget walk; CategoryManagePage wires onChildEdit and hides the FAB on level-3 focused pages (level-4 envelopes would be invisible).
  2. New easter_egg feature (entity/storage/cubit), ritual triggers wired from TransactionEntryPage (create-only), StatsComingSoonPage, and CategoryManagePage; /counter route redirects locked users; FlashCubit promoted to @lazySingleton so app-shell flashes actually reach the UI.
  3. TransactionCard switch from context.read to context.watch, regression-tested with a whenListen-driven widget test proving fallback → resolution.

Why:

  1. Inline rendering keeps budget context visible while organizing; the depth cap matches the 3-level product model (Pillar → Sub-Parent → Envelope) and prevents stack overflow on malformed parent cycles.
  2. Preserves a piece of app history as a discoverable reward instead of deleting it; the re-lock mirrors Android's developer-options behavior.
  3. First-time users saw wrong data on the hottest screen — unacceptable for an expense tracker's landing page.

Type of Change

  • ✨ New feature (non-breaking change which adds functionality)
  • 🛠️ Bug fix (non-breaking change which fixes an issue)
  • ❌ Breaking change (fix or feature that would cause existing functionality to change)
  • 🧹 Code refactor
  • ✅ Build configuration change
  • 📝 Documentation
  • 🗑️ Chore

Code Checklist

  • 134 widget/unit tests pass (fvm flutter test), fvm flutter analyze --no-pub clean
  • Tested on device (POCO X6 Pro 5G, wireless ADB): cold-start category resolution, 3-level tree gestures, full easter-egg ritual incl. re-lock — screenshots available on request

The default 1536M heap caused Java heap space failures in the
JetifyTransform when assembling the development flavor.
EnvelopeTreeListView now renders level-3 envelopes inline below their
sub-parent instead of requiring drill-down navigation. Gesture split on
sub-parent rows: row tap opens the focused sub-envelopes page via
onChildTap, the trailing chevron expands/collapses inline, and a new
onChildEdit callback opens the form for sub-parents with children.

Budget aggregation is depth-capped at three levels so malformed data
containing a parent cycle cannot recurse unbounded.

CategoryManagePage wires onChildEdit to the existing edit form. Adds
five widget tests covering inline rendering, gesture split, and
orphaned-cycle safety.
The leftover template counter becomes a hidden Easter egg instead of
dead code. Unlock works like Android's developer options:

1. Tap the Settings version row 7 times to reveal a hint dialog.
2. Complete three steps (log a transaction, visit Stats, open
   Categories) — steps only count after the hint is seen, with live
   progress in the version subtitle.
3. Unlock flashes a celebration and adds a Counter entry to Settings.

Hiding from the Counter screen (appbar action) re-locks everything,
requiring the full ritual again. Progress persists via SharedPreferences
across restarts and app reinstalls.

Supporting changes: extracted the Stats placeholder into a page,
converted CategoryManagePage to stateful for the trigger hook, added a
/counter route, and lightly polished the counter page to match the app
while keeping its template logic (and existing tests) intact.

Adds 11 cubit tests covering the ritual, re-locking, idempotency, and
persistence.
Code-review findings applied (7-reviewer wave on
feat/three-level-envelope-tree):

- FlashCubit registered as @lazySingleton so the app-shell listener and
  EasterEggCubit share one instance; unlock/re-lock flashes were
  previously emitted on a private factory instance no UI observed (P1,
  triple-reviewer agreement).
- Self-heal on restore: a partial write of the unlocking transition
  (3/3 steps persisted, unlocked flag lost) previously bricked the
  ritual with no recovery path; restore now promotes it.
- /counter route redirects to Settings while locked, closing the
  direct-navigation hole.
- Editing an existing transaction no longer satisfies the "log a
  transaction" ritual step.
- Budget aggregation unified into one depth-capped extension used by
  both the tree and the focused page; the page's uncapped duplicate is
  gone.
- FAB hidden on level-3 focused pages so level-4 envelopes (invisible
  in the tree) can no longer be created.
- totalSteps constant replaces the magic 3; _collapsed renamed to
  _collapsedPillars; stale chevron docs corrected; CounterPage gained a
  test seam.

Tests: cubit suite moved to the conventional presentation/blocs path
and extended with restore/mid-ritual/self-heal coverage; new
SettingsPage widget tests (dialog-once, subtitle, tile gating);
CounterPage hide-action test; tree aggregation and collapse-depth
assertions. 128 tests pass.
…stence atomic

Second-wave review findings applied (delta re-review of the fix commit):

- The hierarchical picker sheet kept a third, uncapped _sumBudgetUnder
  copy that the "unified aggregation" fix missed; all surfaces now use
  the depth-capped CategoryTreeX.sumBudgetUnder.
- Ritual persistence rewritten as one atomic JSON snapshot: separate
  keys allowed torn writes that the restore self-heal could not
  disambiguate (a torn deactivate looked like a torn unlock and
  resurrected the unlocked state after a re-lock).
- FAB depth guard now reuses Category.getHierarchyChain (visited-set
  cycle protection) and treats an orphan parent as uncreatable; the
  level-3 focused page no longer promises nested envelopes it can
  never have.
- Settings subtitle uses EasterEggProgress.totalSteps instead of a
  hardcoded 3.
- Shared test helper (RecordingFlashCubit, buildEggCubit) replaces the
  triplicated private stubs; new tests pin the FlashCubit singleton DI
  identity and the /counter redirect (locked -> Settings, unlocked ->
  Counter). 131 tests pass.
…irst launch

TransactionCard resolved its category via context.read, a one-shot
lookup. On a cold start the categories Isar stream (which also waits
for the seed) emits after the cards build, so every card rendered the
"Uncategorized" fallback and never rebuilt when data arrived. Navigating
away and back rebuilt the cards against the now-loaded state, which
masked the bug.

Switch to context.watch so cards rebuild whenever categories load or
change. Regression-tested with a whenListen-driven widget test that
proves the fallback renders first and resolves when the stream emits.
CI runs `very_good test --optimization`, which merges every test file
into a single process — so GetIt.init runs once per suite on one shared
instance, and the second configure call threw "Type CounterCubit is
already registered inside GetIt" (exit 69). A GetIt.reset() before
re-init was tried first but leaves the registry empty after reset, so
every later getIt<>() call failed with "not registered".

Instead, enable allowReassignment before each init: duplicate
registrations now replace, every suite gets fresh canonical
singletons, and app_test's mock override keeps working. Verified with
the exact CI invocation (`very_good test --optimization --coverage
--min-coverage 0 --report-on lib --show-uncovered`) locally: exit 0,
133/133 tests.
CI randomizes test order (--test-randomize-ordering-seed random), so
"unlocked users reach the counter" could run before "redirects locked
users" in the merged runner; the unlocked test drives the shared
EasterEggCubit singleton to unlocked, leaving the locked test reading a
mutated global state and landing on the Counter instead of Settings.

Instead of reading global state, router() now accepts an optional
isCounterUnlocked predicate (defaulting to the easter-egg cubit) and the
tests inject it directly — deterministic under any ordering, and the
prior review's "untestable redirect closure" concern is resolved too.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant