From 272506084585b5d61392ff986003fc6a2d60beac Mon Sep 17 00:00:00 2001 From: MF-Rozi Date: Mon, 7 Sep 2026 08:32:34 +0700 Subject: [PATCH 1/9] build(android): raise Gradle JVM heap to 4G The default 1536M heap caused Java heap space failures in the JetifyTransform when assembling the development flavor. --- android/gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/gradle.properties b/android/gradle.properties index eac5ff9..f2122b2 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,4 +1,4 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4G android.useAndroidX=true android.enableJetifier=true # This builtInKotlin flag was added automatically by Flutter migrator From f58bce7fc98e48585ed82bfa19665ca7b510459b Mon Sep 17 00:00:00 2001 From: MF-Rozi Date: Mon, 7 Sep 2026 08:32:49 +0700 Subject: [PATCH 2/9] feat(category): render three-level envelope tree inline in manage view 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. --- .../pages/category_manage_page.dart | 15 ++ .../widgets/envelope_tree_list_view.dart | 211 +++++++++++++++--- .../widgets/category_widgets_test.dart | 182 +++++++++++++++ 3 files changed, 381 insertions(+), 27 deletions(-) diff --git a/lib/features/category/presentation/pages/category_manage_page.dart b/lib/features/category/presentation/pages/category_manage_page.dart index 1ce4145..766b576 100644 --- a/lib/features/category/presentation/pages/category_manage_page.dart +++ b/lib/features/category/presentation/pages/category_manage_page.dart @@ -200,6 +200,21 @@ class CategoryManagePage extends StatelessWidget { .selectParent(child.uuid.getOrCrash()); } }, + onChildEdit: (child) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + BlocProvider.value( + value: blocContext.read(), + child: CategoryFormPage( + categoryToEdit: child, + activeParentUuid: + child.parentId?.getOrCrash(), + ), + ), + ), + ); + }, onAddChild: (pillar) { Navigator.of(context).push( MaterialPageRoute( diff --git a/lib/features/category/presentation/widgets/envelope_tree_list_view.dart b/lib/features/category/presentation/widgets/envelope_tree_list_view.dart index dd00dbc..68b493f 100644 --- a/lib/features/category/presentation/widgets/envelope_tree_list_view.dart +++ b/lib/features/category/presentation/widgets/envelope_tree_list_view.dart @@ -4,19 +4,21 @@ import 'package:google_fonts/google_fonts.dart'; /// Expandable tree list view for the Category Architecture screen. /// -/// Renders Level 1 pillars as section headers and their Level 2 sub-parents -/// (or Level 3 envelopes) as indented child rows with the `stagger-line` / -/// `stagger-line-item` visual decoration from the HTML mockup -/// (category-new-design.html, lines 874–892, 961–1120). +/// Renders Level 1 pillars as section headers, Level 2 sub-parents as +/// expandable rows, and Level 3 envelopes inline below their sub-parent — +/// all with the `stagger-line` / `stagger-line-item` visual decoration from +/// the HTML mockup (category-new-design.html, lines 874–892, 961–1120). /// -/// Only two levels of nesting are rendered here (Pillar → Sub-Parent / -/// direct Envelopes). A third level requires the user to tap a sub-parent -/// row and navigate deeper — this widget delegates that via [onChildTap]. +/// The hierarchy is capped at three levels (Pillar → Sub-Parent → Envelope). +/// Tapping a sub-parent row with children opens its focused page via +/// [onChildTap]; the trailing chevron expands or collapses it inline instead. +/// Sub-parents with children expose editing through [onChildEdit]. class EnvelopeTreeListView extends StatefulWidget { const EnvelopeTreeListView({ required this.allCategories, this.onPillarTap, this.onChildTap, + this.onChildEdit, this.onAddChild, super.key, }); @@ -28,8 +30,13 @@ class EnvelopeTreeListView extends StatefulWidget { final void Function(Category pillar)? onPillarTap; /// Called when the user taps a child row (sub-parent or leaf envelope). + /// Sub-parents with children expand or collapse via the trailing chevron + /// instead of calling this. final void Function(Category child)? onChildTap; + /// Called when the user taps the edit button on a sub-parent row. + final void Function(Category child)? onChildEdit; + /// Called when the user taps the add button on a pillar section. final void Function(Category pillar)? onAddChild; @@ -41,6 +48,9 @@ class _EnvelopeTreeListViewState extends State { /// Tracks which pillar UUIDs are collapsed. Pillars start expanded. final Set _collapsed = {}; + /// Tracks which sub-parent UUIDs are collapsed. Sub-parents start expanded. + final Set _collapsedSubs = {}; + List get _pillars => widget.allCategories.where((c) => c.isRoot).toList(); @@ -51,12 +61,12 @@ class _EnvelopeTreeListViewState extends State { .toList(); } - void _togglePillar(String uuid) { + void _toggleCollapsed(Set collapsed, String uuid) { setState(() { - if (_collapsed.contains(uuid)) { - _collapsed.remove(uuid); + if (collapsed.contains(uuid)) { + collapsed.remove(uuid); } else { - _collapsed.add(uuid); + collapsed.add(uuid); } }); } @@ -111,7 +121,7 @@ class _EnvelopeTreeListViewState extends State { return InkWell( onTap: () { - _togglePillar(pillarId); + _toggleCollapsed(_collapsed, pillarId); widget.onPillarTap?.call(pillar); }, borderRadius: BorderRadius.circular(12), @@ -264,32 +274,173 @@ class _EnvelopeTreeListViewState extends State { Widget _buildChildRow(Category child) { final name = child.name.getOrCrash(); final budget = _sumBudgetUnder(child); + final childId = child.uuid.getOrCrash(); + final grandchildren = _childrenOf(child); + final hasChildren = grandchildren.isNotEmpty; + final isCollapsed = _collapsedSubs.contains(childId); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + InkWell( + onTap: () => widget.onChildTap?.call(child), + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFFFFFFF), // surface-container-lowest + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: const Color(0xFFF3F4F5), // surface-container-low + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + _iconForChild(name), + size: 16, + color: const Color(0xFF757682), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: GoogleFonts.manrope( + fontSize: 13, + fontWeight: FontWeight.w600, + color: const Color(0xFF191C1D), + ), + ), + Text( + child.behavioralModifier.name.toUpperCase(), + style: GoogleFonts.inter( + fontSize: 9, + fontWeight: FontWeight.w500, + letterSpacing: 1.5, + color: const Color(0xFF757682), + ), + ), + ], + ), + ), + Text( + '\$${budget.toStringAsFixed(0)}', + style: GoogleFonts.manrope( + fontSize: 13, + fontWeight: FontWeight.w700, + color: const Color(0xFF00113A), + ), + ), + if (hasChildren) ...[ + const SizedBox(width: 4), + if (widget.onChildEdit != null) + InkResponse( + onTap: () => widget.onChildEdit?.call(child), + radius: 16, + child: const SizedBox( + width: 32, + height: 32, + child: Icon( + Icons.edit_outlined, + size: 18, + color: Color(0xFF757682), + ), + ), + ), + InkResponse( + onTap: () => _toggleCollapsed(_collapsedSubs, childId), + radius: 16, + child: SizedBox( + width: 32, + height: 32, + child: AnimatedRotation( + turns: isCollapsed ? 0 : 0.5, + duration: const Duration(milliseconds: 250), + child: const Icon( + Icons.keyboard_arrow_down, + size: 18, + color: Color(0xFF757682), + ), + ), + ), + ), + ], + ], + ), + ), + ), + if (hasChildren && !isCollapsed) ...[ + const SizedBox(height: 8), + _buildGrandchildrenContainer(grandchildren), + ], + ], + ); + } + + /// Renders Level 3 envelopes inline below their sub-parent with the same + /// stagger-line visual, one indent deeper. + Widget _buildGrandchildrenContainer(List grandchildren) { + return Padding( + padding: const EdgeInsets.only(left: 16), + child: CustomPaint( + painter: _StaggerLinePainter(), + child: Padding( + padding: const EdgeInsets.only(left: 16, top: 8), + child: Column( + children: [ + for (int i = 0; i < grandchildren.length; i++) ...[ + if (i > 0) const SizedBox(height: 8), + _StaggerLineItemWrapper( + child: _buildLeafRow(grandchildren[i]), + ), + ], + ], + ), + ), + ), + ); + } + + /// A Level 3 envelope row. Shows its own budget (not an aggregate) and + /// delegates taps to [EnvelopeTreeListView.onChildTap]. + Widget _buildLeafRow(Category leaf) { + final name = leaf.name.getOrCrash(); + final budget = leaf.expectedMonthlyBudget; return InkWell( - onTap: () => widget.onChildTap?.call(child), + onTap: () => widget.onChildTap?.call(leaf), borderRadius: BorderRadius.circular(12), child: Container( - padding: const EdgeInsets.all(14), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11), decoration: BoxDecoration( - color: const Color(0xFFFFFFFF), // surface-container-lowest + color: const Color(0xFFF8F9FA), // surface (tinted vs parent card) borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE1E3E4)), ), child: Row( children: [ Container( - width: 32, - height: 32, + width: 28, + height: 28, decoration: BoxDecoration( color: const Color(0xFFF3F4F5), // surface-container-low borderRadius: BorderRadius.circular(8), ), child: Icon( _iconForChild(name), - size: 16, + size: 14, color: const Color(0xFF757682), ), ), - const SizedBox(width: 12), + const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -297,15 +448,15 @@ class _EnvelopeTreeListViewState extends State { Text( name, style: GoogleFonts.manrope( - fontSize: 13, + fontSize: 12.5, fontWeight: FontWeight.w600, color: const Color(0xFF191C1D), ), ), Text( - child.behavioralModifier.name.toUpperCase(), + leaf.behavioralModifier.name.toUpperCase(), style: GoogleFonts.inter( - fontSize: 9, + fontSize: 8.5, fontWeight: FontWeight.w500, letterSpacing: 1.5, color: const Color(0xFF757682), @@ -317,7 +468,7 @@ class _EnvelopeTreeListViewState extends State { Text( '\$${budget.toStringAsFixed(0)}', style: GoogleFonts.manrope( - fontSize: 13, + fontSize: 12, fontWeight: FontWeight.w700, color: const Color(0xFF00113A), ), @@ -330,14 +481,20 @@ class _EnvelopeTreeListViewState extends State { // ─────────────────────────────── Helpers ────────────────────────────────── - /// Recursively sums expected monthly budgets for [category] and all its - /// descendants (so Pillars/Sub-Parents show aggregated totals). - double _sumBudgetUnder(Category category) { + /// Sums expected monthly budgets across [category]'s subtree so Pillars + /// and Sub-Parents show aggregated totals. The walk is capped at + /// [maxDepth] levels (Pillar → Sub-Parent → Envelope), which also guards + /// against infinite recursion if malformed data ever contains a cycle. + double _sumBudgetUnder(Category category, {int maxDepth = 3}) { + if (maxDepth <= 0) return 0; final directChildren = _childrenOf(category); if (directChildren.isEmpty) { return category.expectedMonthlyBudget; } - return directChildren.fold(0, (sum, c) => sum + _sumBudgetUnder(c)); + return directChildren.fold( + 0, + (sum, c) => sum + _sumBudgetUnder(c, maxDepth: maxDepth - 1), + ); } IconData _iconForPillar(String name) { diff --git a/test/features/category/presentation/widgets/category_widgets_test.dart b/test/features/category/presentation/widgets/category_widgets_test.dart index 285673d..73d1b66 100644 --- a/test/features/category/presentation/widgets/category_widgets_test.dart +++ b/test/features/category/presentation/widgets/category_widgets_test.dart @@ -33,6 +33,9 @@ const _p2Id = '550e8400-e29b-41d4-a716-446655440002'; const _p3Id = '550e8400-e29b-41d4-a716-446655440003'; const _c1Id = '550e8400-e29b-41d4-a716-446655440004'; const _c2Id = '550e8400-e29b-41d4-a716-446655440005'; +const _subId = '550e8400-e29b-41d4-a716-446655440006'; +const _e1Id = '550e8400-e29b-41d4-a716-446655440007'; +const _e2Id = '550e8400-e29b-41d4-a716-446655440008'; final _essential = _makeCategory(uuid: _p1Id, name: 'Essential'); final _lifestyle = _makeCategory(uuid: _p2Id, name: 'Lifestyle'); @@ -49,6 +52,19 @@ final _dining = _makeCategory( parentId: _p2Id, budget: 300, ); +final _food = _makeCategory(uuid: _subId, name: 'Food', parentId: _p1Id); +final _coffee = _makeCategory( + uuid: _e1Id, + name: 'Coffee', + parentId: _subId, + budget: 120, +); +final _groceries = _makeCategory( + uuid: _e2Id, + name: 'Groceries', + parentId: _subId, + budget: 180, +); // ───────────────────────────────────────────────────────────────────────────── @@ -337,5 +353,171 @@ void main() { expect(find.textContaining('No envelopes yet'), findsOneWidget); }); + + group('three-level hierarchy', () { + final deepCategories = [ + ...allCategories, + _food, + _coffee, + _groceries, + ]; + + testWidgets('renders level-3 envelopes inline under sub-parents', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: EnvelopeTreeListView(allCategories: deepCategories), + ), + ), + ), + ); + + expect(find.text('Food'), findsOneWidget); + expect(find.text('Coffee'), findsOneWidget); + expect(find.text('Groceries'), findsOneWidget); + // $300 appears as: Dining leaf budget, Lifestyle pillar aggregate, + // and Food sub-parent aggregate (120 + 180). + expect(find.text(r'$300'), findsNWidgets(3)); + }); + + testWidgets('chevron tap toggles level-3 children without onChildTap', + (tester) async { + var childTaps = 0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: EnvelopeTreeListView( + allCategories: deepCategories, + onChildTap: (_) => childTaps++, + ), + ), + ), + ), + ); + + final foodRowChevron = find.descendant( + of: find.ancestor( + of: find.text('Food'), + matching: find.byType(InkWell), + ), + matching: find.byIcon(Icons.keyboard_arrow_down), + ); + + await tester.tap(foodRowChevron); + await tester.pumpAndSettle(); + expect(find.text('Coffee'), findsNothing); + expect(find.text('Groceries'), findsNothing); + expect(childTaps, 0); + + await tester.tap(foodRowChevron); + await tester.pumpAndSettle(); + expect(find.text('Coffee'), findsOneWidget); + expect(find.text('Groceries'), findsOneWidget); + expect(childTaps, 0); + }); + + testWidgets('sub-parent row tap fires onChildTap for focused page', + (tester) async { + Category? tapped; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: EnvelopeTreeListView( + allCategories: deepCategories, + onChildTap: (c) => tapped = c, + ), + ), + ), + ), + ); + + await tester.tap(find.text('Food')); + await tester.pumpAndSettle(); + expect(tapped?.uuid.getOrCrash(), _subId); + }); + + testWidgets('onChildTap fires for leaf envelope rows', (tester) async { + Category? tapped; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: EnvelopeTreeListView( + allCategories: deepCategories, + onChildTap: (c) => tapped = c, + ), + ), + ), + ), + ); + + await tester.tap(find.text('Coffee')); + await tester.pumpAndSettle(); + expect(tapped?.uuid.getOrCrash(), _e1Id); + }); + + testWidgets('onChildEdit fires from sub-parent edit button', + (tester) async { + Category? edited; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: EnvelopeTreeListView( + allCategories: deepCategories, + onChildEdit: (c) => edited = c, + ), + ), + ), + ), + ); + + await tester.tap(find.byIcon(Icons.edit_outlined)); + await tester.pumpAndSettle(); + expect(edited?.uuid.getOrCrash(), _subId); + }); + + testWidgets('orphaned cycle data does not crash rendering', + (tester) async { + // In a single-parent model a parent cycle can only exist as an + // orphan island (unreachable from any root). Rendering must still + // not crash, and budget aggregation's depth cap guards against + // unbounded recursion if such data is ever summed directly. + final cyclicFood = _makeCategory( + uuid: _subId, + name: 'Food', + parentId: _subId, // cycle: Food is its own ancestor + ); + final cyclicCoffee = _makeCategory( + uuid: _e1Id, + name: 'Coffee', + parentId: _subId, + budget: 120, + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: EnvelopeTreeListView( + allCategories: [ + _essential, + cyclicFood, + cyclicCoffee, + ], + ), + ), + ), + ), + ); + + expect(find.text('Essential'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + }); }); } From ae099dbd2867e2782aff26cb353e728af900feed Mon Sep 17 00:00:00 2001 From: MF-Rozi Date: Tue, 8 Sep 2026 00:37:17 +0700 Subject: [PATCH 3/9] feat(easter-egg): gate the counter behind a version-tap ritual MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lib/app/router/app_router.dart | 14 +- .../pages/category_manage_page.dart | 15 +- .../presentation/pages/counter_page.dart | 119 +++++++++--- .../pages/stats_coming_soon_page.dart | 27 +++ .../data/datasources/easter_egg_storage.dart | 46 +++++ .../domain/entities/easter_egg_progress.dart | 59 ++++++ .../presentation/blocs/easter_egg_cubit.dart | 99 ++++++++++ .../presentation/blocs/easter_egg_state.dart | 17 ++ .../presentation/pages/settings_page.dart | 94 ++++++++-- .../pages/transaction_entry_page.dart | 2 + .../easter_egg/easter_egg_cubit_test.dart | 174 ++++++++++++++++++ 11 files changed, 621 insertions(+), 45 deletions(-) create mode 100644 lib/features/dashboard/presentation/pages/stats_coming_soon_page.dart create mode 100644 lib/features/easter_egg/data/datasources/easter_egg_storage.dart create mode 100644 lib/features/easter_egg/domain/entities/easter_egg_progress.dart create mode 100644 lib/features/easter_egg/presentation/blocs/easter_egg_cubit.dart create mode 100644 lib/features/easter_egg/presentation/blocs/easter_egg_state.dart create mode 100644 test/features/easter_egg/easter_egg_cubit_test.dart diff --git a/lib/app/router/app_router.dart b/lib/app/router/app_router.dart index 3b066f9..e42e5e7 100644 --- a/lib/app/router/app_router.dart +++ b/lib/app/router/app_router.dart @@ -1,8 +1,10 @@ import 'package:equatable/equatable.dart'; import 'package:expense_tracker/app/view/main_layout.dart'; import 'package:expense_tracker/features/category/presentation/pages/category_manage_page.dart'; +import 'package:expense_tracker/features/counter/presentation/pages/counter_page.dart'; import 'package:expense_tracker/features/dashboard/presentation/blocs/dashboard_cubit.dart'; import 'package:expense_tracker/features/dashboard/presentation/pages/home_page.dart'; +import 'package:expense_tracker/features/dashboard/presentation/pages/stats_coming_soon_page.dart'; import 'package:expense_tracker/features/settings/presentation/pages/settings_page.dart'; import 'package:expense_tracker/features/transaction/domain/entities/transaction.dart'; import 'package:expense_tracker/features/transaction/presentation/blocs/transaction_cubit.dart'; @@ -10,7 +12,6 @@ import 'package:expense_tracker/features/transaction/presentation/pages/transact import 'package:expense_tracker/features/transaction/presentation/pages/transaction_history_page.dart'; import 'package:expense_tracker/injector.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; @@ -44,11 +45,7 @@ GoRouter router([String? initialLocation]) => GoRouter( GoRoute( path: '/stats', name: 'stats', - builder: (context, state) => const Scaffold( - body: Center( - child: Text('Coming Soon'), - ), - ), + builder: (context, state) => const StatsComingSoonPage(), ), GoRoute( path: '/settings', @@ -73,5 +70,10 @@ GoRouter router([String? initialLocation]) => GoRouter( name: 'categories', builder: (context, state) => const CategoryManagePage(), ), + GoRoute( + path: '/counter', + name: 'counter', + builder: (context, state) => const CounterPage(), + ), ], ); diff --git a/lib/features/category/presentation/pages/category_manage_page.dart b/lib/features/category/presentation/pages/category_manage_page.dart index 766b576..43f76bd 100644 --- a/lib/features/category/presentation/pages/category_manage_page.dart +++ b/lib/features/category/presentation/pages/category_manage_page.dart @@ -4,13 +4,26 @@ import 'package:expense_tracker/features/category/presentation/blocs/category_st import 'package:expense_tracker/features/category/presentation/pages/category_form_page.dart'; import 'package:expense_tracker/features/category/presentation/widgets/envelope_tree_list_view.dart'; import 'package:expense_tracker/features/category/presentation/widgets/portfolio_distribution_card.dart'; +import 'package:expense_tracker/features/easter_egg/presentation/blocs/easter_egg_cubit.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 CategoryManagePage extends StatelessWidget { +class CategoryManagePage extends StatefulWidget { const CategoryManagePage({super.key}); + @override + State createState() => _CategoryManagePageState(); +} + +class _CategoryManagePageState extends State { + @override + void initState() { + super.initState(); + getIt().onCategoriesOpened(); + } + IconData _iconForChild(String name) { final lower = name.toLowerCase(); if (lower.contains('mortgage') || diff --git a/lib/features/counter/presentation/pages/counter_page.dart b/lib/features/counter/presentation/pages/counter_page.dart index d5c86ef..8660ca0 100644 --- a/lib/features/counter/presentation/pages/counter_page.dart +++ b/lib/features/counter/presentation/pages/counter_page.dart @@ -1,18 +1,14 @@ -// Copyright (c) 2022, Adryan Eka Vandra -// https://github.com/adryanev/flutter-template-architecture-template -// -// Use of this source code is governed by an MIT-style -// license that can be found in the LICENSE file or at -// https://opensource.org/licenses/MIT. - -import 'package:expense_tracker/core/extensions/context_extensions.dart'; -import 'package:expense_tracker/core/presentation/mixins/failure_message_handler.dart'; -import 'package:expense_tracker/features/counter/counter.dart'; -import 'package:expense_tracker/l10n/l10n.dart'; +import 'package:expense_tracker/features/counter/presentation/blocs/counter_cubit.dart'; +import 'package:expense_tracker/features/easter_egg/presentation/blocs/easter_egg_cubit.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 CounterPage extends StatelessWidget with FailureMessageHandler { +/// The hidden Counter Easter egg — unlocked through the Settings ritual. +/// Deliberately keeps the template's bare-bones logic; the joke is that +/// this page survived the cleanup that deleted everything else. +class CounterPage extends StatelessWidget { const CounterPage({super.key}); @override @@ -29,25 +25,60 @@ class CounterView extends StatelessWidget { @override Widget build(BuildContext context) { - final l10n = context.l10n; return Scaffold( - appBar: AppBar(title: Text(l10n.counterAppBarTitle)), - body: const Center(child: CounterText()), - floatingActionButton: Column( - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - FloatingActionButton( - onPressed: () => context.read().increment(), - child: const Icon(Icons.add), + appBar: AppBar( + title: Text( + 'Counter', + style: GoogleFonts.manrope( + fontWeight: FontWeight.w800, + letterSpacing: -0.5, ), - const SizedBox(height: 8), - FloatingActionButton( - onPressed: () => context.read().decrement(), - child: const Icon(Icons.remove), + ), + backgroundColor: const Color(0xFF00113A), + foregroundColor: Colors.white, + actions: [ + IconButton( + tooltip: 'Hide the secret room', + icon: const Icon(Icons.visibility_off_outlined), + onPressed: () { + getIt().deactivate(); + Navigator.of(context).pop(); + }, ), ], ), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'You found the secret room.', + style: GoogleFonts.inter( + fontSize: 13, + fontStyle: FontStyle.italic, + color: const Color(0xFF757682), + ), + ), + const SizedBox(height: 8), + const CounterText(), + const SizedBox(height: 32), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + _EggButton( + icon: Icons.remove, + onTap: () => context.read().decrement(), + ), + const SizedBox(width: 16), + _EggButton( + icon: Icons.add, + onTap: () => context.read().increment(), + ), + ], + ), + ], + ), + ), ); } } @@ -58,6 +89,40 @@ class CounterText extends StatelessWidget { @override Widget build(BuildContext context) { final count = context.select((CounterCubit cubit) => cubit.state); - return Text('$count', style: context.theme.textTheme.displayLarge); + return Text( + '$count', + style: GoogleFonts.manrope( + fontSize: 72, + fontWeight: FontWeight.w800, + color: const Color(0xFF00113A), + ), + ); + } +} + +class _EggButton extends StatelessWidget { + const _EggButton({required this.icon, required this.onTap}); + + final IconData icon; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: const Color(0xFFF3F4F5), + borderRadius: BorderRadius.circular(16), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFC5C6D2)), + ), + child: Icon(icon, size: 28, color: const Color(0xFF00113A)), + ), + ), + ); } } diff --git a/lib/features/dashboard/presentation/pages/stats_coming_soon_page.dart b/lib/features/dashboard/presentation/pages/stats_coming_soon_page.dart new file mode 100644 index 0000000..758a34d --- /dev/null +++ b/lib/features/dashboard/presentation/pages/stats_coming_soon_page.dart @@ -0,0 +1,27 @@ +import 'package:expense_tracker/features/easter_egg/presentation/blocs/easter_egg_cubit.dart'; +import 'package:expense_tracker/injector.dart'; +import 'package:flutter/material.dart'; + +class StatsComingSoonPage extends StatefulWidget { + const StatsComingSoonPage({super.key}); + + @override + State createState() => _StatsComingSoonPageState(); +} + +class _StatsComingSoonPageState extends State { + @override + void initState() { + super.initState(); + getIt().onStatsVisited(); + } + + @override + Widget build(BuildContext context) { + return const Scaffold( + body: Center( + child: Text('Coming Soon'), + ), + ); + } +} diff --git a/lib/features/easter_egg/data/datasources/easter_egg_storage.dart b/lib/features/easter_egg/data/datasources/easter_egg_storage.dart new file mode 100644 index 0000000..0cc9a2b --- /dev/null +++ b/lib/features/easter_egg/data/datasources/easter_egg_storage.dart @@ -0,0 +1,46 @@ +import 'package:expense_tracker/features/easter_egg/domain/entities/easter_egg_progress.dart'; +import 'package:injectable/injectable.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +abstract class EasterEggStorage { + EasterEggProgress read(); + Future write(EasterEggProgress progress); +} + +@LazySingleton(as: EasterEggStorage) +class EasterEggStorageImpl implements EasterEggStorage { + const EasterEggStorageImpl(this._preferences); + + final SharedPreferences _preferences; + + static const _tapsKey = 'easterEgg.versionTaps'; + static const _hintKey = 'easterEgg.hintSeen'; + static const _transactionKey = 'easterEgg.loggedTransaction'; + static const _statsKey = 'easterEgg.visitedStats'; + static const _categoriesKey = 'easterEgg.openedCategories'; + static const _unlockedKey = 'easterEgg.unlocked'; + + @override + EasterEggProgress read() { + return EasterEggProgress( + versionTaps: _preferences.getInt(_tapsKey) ?? 0, + hintSeen: _preferences.getBool(_hintKey) ?? false, + loggedTransaction: _preferences.getBool(_transactionKey) ?? false, + visitedStats: _preferences.getBool(_statsKey) ?? false, + openedCategories: _preferences.getBool(_categoriesKey) ?? false, + unlocked: _preferences.getBool(_unlockedKey) ?? false, + ); + } + + @override + Future write(EasterEggProgress progress) async { + await Future.wait([ + _preferences.setInt(_tapsKey, progress.versionTaps), + _preferences.setBool(_hintKey, progress.hintSeen), + _preferences.setBool(_transactionKey, progress.loggedTransaction), + _preferences.setBool(_statsKey, progress.visitedStats), + _preferences.setBool(_categoriesKey, progress.openedCategories), + _preferences.setBool(_unlockedKey, progress.unlocked), + ]); + } +} diff --git a/lib/features/easter_egg/domain/entities/easter_egg_progress.dart b/lib/features/easter_egg/domain/entities/easter_egg_progress.dart new file mode 100644 index 0000000..7c48f5a --- /dev/null +++ b/lib/features/easter_egg/domain/entities/easter_egg_progress.dart @@ -0,0 +1,59 @@ +import 'package:equatable/equatable.dart'; + +/// Progress snapshot of the hidden Counter Easter egg. +/// +/// The ritual: tap the version row in Settings [versionTapsRequired] +/// times to reveal the hint, then complete the three listed steps. +class EasterEggProgress extends Equatable { + const EasterEggProgress({ + this.versionTaps = 0, + this.hintSeen = false, + this.loggedTransaction = false, + this.visitedStats = false, + this.openedCategories = false, + this.unlocked = false, + }); + + static const int versionTapsRequired = 7; + + final int versionTaps; + final bool hintSeen; + final bool loggedTransaction; + final bool visitedStats; + final bool openedCategories; + final bool unlocked; + + int get completedSteps => [ + loggedTransaction, + visitedStats, + openedCategories, + ].where((step) => step).length; + + EasterEggProgress copyWith({ + int? versionTaps, + bool? hintSeen, + bool? loggedTransaction, + bool? visitedStats, + bool? openedCategories, + bool? unlocked, + }) { + return EasterEggProgress( + versionTaps: versionTaps ?? this.versionTaps, + hintSeen: hintSeen ?? this.hintSeen, + loggedTransaction: loggedTransaction ?? this.loggedTransaction, + visitedStats: visitedStats ?? this.visitedStats, + openedCategories: openedCategories ?? this.openedCategories, + unlocked: unlocked ?? this.unlocked, + ); + } + + @override + List get props => [ + versionTaps, + hintSeen, + loggedTransaction, + visitedStats, + openedCategories, + unlocked, + ]; +} diff --git a/lib/features/easter_egg/presentation/blocs/easter_egg_cubit.dart b/lib/features/easter_egg/presentation/blocs/easter_egg_cubit.dart new file mode 100644 index 0000000..43ec7ee --- /dev/null +++ b/lib/features/easter_egg/presentation/blocs/easter_egg_cubit.dart @@ -0,0 +1,99 @@ +import 'dart:async'; + +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:expense_tracker/features/easter_egg/data/datasources/easter_egg_storage.dart'; +import 'package:expense_tracker/features/easter_egg/domain/entities/easter_egg_progress.dart'; +import 'package:expense_tracker/shared/flash/presentation/blocs/cubit/flash_cubit.dart'; +import 'package:injectable/injectable.dart'; + +part 'easter_egg_state.dart'; + +/// Drives the hidden Counter Easter egg: +/// 1. Tap the Settings version row [EasterEggProgress.versionTapsRequired] +/// times to reveal the ritual hint. +/// 2. Complete the three steps (log a transaction, visit Stats, open +/// Categories) — steps only count once the hint has been seen. +/// 3. Unlock flashes a celebration and adds a Counter entry in Settings. +/// 4. Hiding from the Counter screen re-locks everything ([deactivate]), +/// requiring the ritual to be completed again. +@lazySingleton +class EasterEggCubit extends Cubit { + EasterEggCubit(this._storage, this._flashCubit) + : super(const EasterEggState()) { + _restore(); + } + + final EasterEggStorage _storage; + final FlashCubit _flashCubit; + + void _restore() { + emit(EasterEggState(progress: _storage.read())); + } + + void onVersionTapped() { + final current = state.progress; + if (current.unlocked) return; + + var progress = current.copyWith(versionTaps: current.versionTaps + 1); + var justRevealedHint = false; + if (!progress.hintSeen && + progress.versionTaps >= + EasterEggProgress.versionTapsRequired) { + progress = progress.copyWith(hintSeen: true); + justRevealedHint = true; + } + + unawaited(_storage.write(progress)); + emit( + EasterEggState( + progress: progress, + justRevealedHint: justRevealedHint, + ), + ); + } + + void onTransactionLogged() => + _completeStep((p) => p.copyWith(loggedTransaction: true)); + + /// Hides the Counter again — like Android's developer options, closing + /// it resets the entire ritual so it must be unlocked from scratch. + void deactivate() { + if (!state.progress.unlocked) return; + + const reset = EasterEggProgress(); + unawaited(_storage.write(reset)); + emit(const EasterEggState()); + unawaited( + _flashCubit.displayFlash( + 'Counter locked again — repeat the ritual to reopen it.', + ), + ); + } + + void onStatsVisited() => _completeStep((p) => p.copyWith(visitedStats: true)); + + void onCategoriesOpened() => + _completeStep((p) => p.copyWith(openedCategories: true)); + + void _completeStep(EasterEggProgress Function(EasterEggProgress) update) { + final current = state.progress; + if (!current.hintSeen || current.unlocked) return; + + final stepped = update(current); + if (stepped == current) return; + + var progress = stepped; + if (progress.completedSteps == 3) { + progress = progress.copyWith(unlocked: true); + unawaited( + _flashCubit.displayFlash( + 'Secret unlocked! Counter is now available in Settings.', + ), + ); + } + + unawaited(_storage.write(progress)); + emit(EasterEggState(progress: progress)); + } +} diff --git a/lib/features/easter_egg/presentation/blocs/easter_egg_state.dart b/lib/features/easter_egg/presentation/blocs/easter_egg_state.dart new file mode 100644 index 0000000..bb4f744 --- /dev/null +++ b/lib/features/easter_egg/presentation/blocs/easter_egg_state.dart @@ -0,0 +1,17 @@ +part of 'easter_egg_cubit.dart'; + +class EasterEggState extends Equatable { + const EasterEggState({ + this.progress = const EasterEggProgress(), + this.justRevealedHint = false, + }); + + final EasterEggProgress progress; + + /// True only on the frame where the hint is first revealed, so the + /// Settings page can show the ritual dialog exactly once. + final bool justRevealedHint; + + @override + List get props => [progress, justRevealedHint]; +} diff --git a/lib/features/settings/presentation/pages/settings_page.dart b/lib/features/settings/presentation/pages/settings_page.dart index 9d8769b..9cba8c1 100644 --- a/lib/features/settings/presentation/pages/settings_page.dart +++ b/lib/features/settings/presentation/pages/settings_page.dart @@ -1,22 +1,94 @@ +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/injector.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:google_fonts/google_fonts.dart'; class SettingsPage extends StatelessWidget { - const SettingsPage({super.key}); + const SettingsPage({super.key, this.easterEggCubit}); + + /// Overridable for tests; defaults to the app-wide singleton. + final EasterEggCubit? easterEggCubit; @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Settings'), + return BlocProvider.value( + value: easterEggCubit ?? getIt(), + child: Scaffold( + appBar: AppBar( + title: const Text('Settings'), + ), + body: BlocConsumer( + listenWhen: (previous, current) => + current.justRevealedHint && !previous.justRevealedHint, + listener: (context, state) => _showRitualDialog(context), + builder: (context, state) { + final progress = state.progress; + return ListView( + children: [ + ListTile( + leading: const Icon(Icons.category), + title: const Text('Manage Categories'), + subtitle: + const Text('Add or edit income and expense categories'), + onTap: () => context.push('/categories'), + ), + ListTile( + leading: const Icon(Icons.info_outline), + title: const Text('Version'), + subtitle: Text(_versionSubtitle(progress)), + onTap: () => + context.read().onVersionTapped(), + ), + 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'), + ), + ], + ); + }, + ), ), - body: ListView( - children: [ - ListTile( - leading: const Icon(Icons.category), - title: const Text('Manage Categories'), - subtitle: const Text('Add or edit income and expense categories'), - onTap: () => context.push('/categories'), + ); + } + + String _versionSubtitle(EasterEggProgress progress) { + if (progress.unlocked) return 'Secret unlocked — Counter is below'; + if (!progress.hintSeen) return '1.0.0'; + return 'Something is stirring... ' + '(${progress.completedSteps}/3 steps)'; + } + + void _showRitualDialog(BuildContext context) { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: Text( + 'You found something...', + style: GoogleFonts.manrope(fontWeight: FontWeight.bold), + ), + content: Text( + 'A long-forgotten page lies sealed inside this app. ' + 'To open it, complete these steps:\n\n' + '1. Log a transaction\n' + '2. Visit the Stats tab\n' + '3. Open your Categories\n\n' + 'Then come back here.', + style: GoogleFonts.inter(fontSize: 14, height: 1.5), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text( + 'Got it', + style: GoogleFonts.inter(fontWeight: FontWeight.bold), + ), ), ], ), diff --git a/lib/features/transaction/presentation/pages/transaction_entry_page.dart b/lib/features/transaction/presentation/pages/transaction_entry_page.dart index fc5f69f..79c45f8 100644 --- a/lib/features/transaction/presentation/pages/transaction_entry_page.dart +++ b/lib/features/transaction/presentation/pages/transaction_entry_page.dart @@ -2,6 +2,7 @@ import 'package:expense_tracker/features/category/domain/entities/category.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/category/presentation/widgets/hierarchical_envelope_picker_sheet.dart'; +import 'package:expense_tracker/features/easter_egg/presentation/blocs/easter_egg_cubit.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/presentation/blocs/transaction_cubit.dart'; @@ -150,6 +151,7 @@ class _TransactionEntryPageState extends State { body: BlocConsumer( listener: (context, state) { if (state.status == TransactionFormStatus.success) { + getIt().onTransactionLogged(); Navigator.of(context).pop(); } else if (state.status == TransactionFormStatus.failure) { ScaffoldMessenger.of(context).showSnackBar( diff --git a/test/features/easter_egg/easter_egg_cubit_test.dart b/test/features/easter_egg/easter_egg_cubit_test.dart new file mode 100644 index 0000000..a2b1c67 --- /dev/null +++ b/test/features/easter_egg/easter_egg_cubit_test.dart @@ -0,0 +1,174 @@ +import 'package:expense_tracker/features/easter_egg/data/datasources/easter_egg_storage.dart'; +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/shared/flash/presentation/blocs/cubit/flash_cubit.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _RecordingFlashCubit extends FlashCubit { + final List messages = []; + + @override + Future displayFlash(String message) async { + messages.add(message); + } +} + +void main() { + late SharedPreferences preferences; + late _RecordingFlashCubit flashCubit; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + preferences = await SharedPreferences.getInstance(); + flashCubit = _RecordingFlashCubit(); + }); + + EasterEggCubit buildCubit() => + EasterEggCubit(EasterEggStorageImpl(preferences), flashCubit); + + void revealHint(EasterEggCubit cubit) { + for (var i = 0; i < EasterEggProgress.versionTapsRequired; i++) { + cubit.onVersionTapped(); + } + } + + group('EasterEggCubit', () { + test('starts locked with zero progress', () { + final cubit = buildCubit(); + expect(cubit.state.progress.unlocked, isFalse); + expect(cubit.state.progress.versionTaps, 0); + expect(cubit.state.progress.hintSeen, isFalse); + }); + + test('reveals hint exactly on the 7th version tap', () { + final cubit = buildCubit() + ..onVersionTapped() + ..onVersionTapped(); + expect(cubit.state.progress.hintSeen, isFalse); + + for (var i = 2; i < EasterEggProgress.versionTapsRequired - 1; i++) { + cubit.onVersionTapped(); + } + expect(cubit.state.progress.hintSeen, isFalse); + + cubit.onVersionTapped(); + expect(cubit.state.progress.hintSeen, isTrue); + expect(cubit.state.justRevealedHint, isTrue); + expect(cubit.state.progress.unlocked, isFalse); + }); + + test('does not count steps before the hint is seen', () { + final cubit = buildCubit() + ..onTransactionLogged() + ..onStatsVisited() + ..onCategoriesOpened(); + + expect(cubit.state.progress.completedSteps, 0); + expect(cubit.state.progress.unlocked, isFalse); + }); + + test('unlocks after all three steps once the hint is seen', () { + final cubit = buildCubit(); + revealHint(cubit); + + cubit.onTransactionLogged(); + expect(cubit.state.progress.completedSteps, 1); + expect(cubit.state.progress.unlocked, isFalse); + + cubit + ..onStatsVisited() + ..onCategoriesOpened(); + + expect(cubit.state.progress.unlocked, isTrue); + expect(flashCubit.messages, hasLength(1)); + expect(flashCubit.messages.first, contains('Counter')); + }); + + test('steps are idempotent', () { + final cubit = buildCubit(); + revealHint(cubit); + + cubit + ..onTransactionLogged() + ..onTransactionLogged() + ..onTransactionLogged(); + + expect(cubit.state.progress.completedSteps, 1); + expect(cubit.state.progress.unlocked, isFalse); + }); + + test('progress persists across instances', () { + final cubit = buildCubit(); + revealHint(cubit); + cubit + ..onTransactionLogged() + ..onStatsVisited() + ..onCategoriesOpened(); + expect(cubit.state.progress.unlocked, isTrue); + + final restored = buildCubit(); + expect(restored.state.progress.unlocked, isTrue); + expect(restored.state.progress.completedSteps, 3); + }); + + test('version taps stop counting once unlocked', () { + final cubit = buildCubit(); + revealHint(cubit); + cubit + ..onTransactionLogged() + ..onStatsVisited() + ..onCategoriesOpened(); + expect(cubit.state.progress.unlocked, isTrue); + + cubit.onVersionTapped(); + expect( + cubit.state.progress.versionTaps, + EasterEggProgress.versionTapsRequired, + ); + }); + + test('deactivate resets the whole ritual', () { + final cubit = buildCubit(); + revealHint(cubit); + cubit + ..onTransactionLogged() + ..onStatsVisited() + ..onCategoriesOpened(); + expect(cubit.state.progress.unlocked, isTrue); + + cubit.deactivate(); + expect(cubit.state.progress, const EasterEggProgress()); + expect(flashCubit.messages.last, contains('locked again')); + + // Steps no longer count and the hint must be revealed again with + // seven fresh taps — like re-enabling Android's developer options. + cubit.onTransactionLogged(); + expect(cubit.state.progress.completedSteps, 0); + revealHint(cubit); + cubit.onTransactionLogged(); + expect(cubit.state.progress.completedSteps, 1); + }); + + test('deactivate does nothing before unlocking', () { + final cubit = buildCubit()..deactivate(); + + expect(cubit.state.progress.versionTaps, 0); + expect(flashCubit.messages, isEmpty); + }); + + test('deactivated state persists across instances', () { + final cubit = buildCubit(); + revealHint(cubit); + cubit + ..onTransactionLogged() + ..onStatsVisited() + ..onCategoriesOpened() + ..deactivate(); + + final restored = buildCubit(); + expect(restored.state.progress.unlocked, isFalse); + expect(restored.state.progress.versionTaps, 0); + }); + }); +} From 00dbbab20af9c2e26adcfeeb775d7dce58f66bd5 Mon Sep 17 00:00:00 2001 From: MF-Rozi Date: Tue, 8 Sep 2026 01:18:28 +0700 Subject: [PATCH 4/9] fix(review): harden easter egg lifecycle and unify budget aggregation 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. --- lib/app/router/app_router.dart | 8 ++ .../domain/extensions/category_tree.dart | 29 ++++++ .../pages/category_manage_page.dart | 85 +++++++++-------- .../widgets/envelope_tree_list_view.dart | 38 +++----- .../presentation/pages/counter_page.dart | 16 +++- .../domain/entities/easter_egg_progress.dart | 1 + .../presentation/blocs/easter_egg_cubit.dart | 18 +++- .../presentation/pages/settings_page.dart | 3 +- .../pages/transaction_entry_page.dart | 6 +- .../presentation/blocs/cubit/flash_cubit.dart | 5 +- pubspec.lock | 22 ++--- .../widgets/category_widgets_test.dart | 43 ++++++++- .../presentation/pages/counter_page_test.dart | 56 +++++++++++- .../blocs}/easter_egg_cubit_test.dart | 44 ++++++++- .../pages/settings_page_test.dart | 91 +++++++++++++++++++ 15 files changed, 374 insertions(+), 91 deletions(-) create mode 100644 lib/features/category/domain/extensions/category_tree.dart rename test/features/easter_egg/{ => presentation/blocs}/easter_egg_cubit_test.dart (77%) create mode 100644 test/features/settings/presentation/pages/settings_page_test.dart diff --git a/lib/app/router/app_router.dart b/lib/app/router/app_router.dart index e42e5e7..d7c4368 100644 --- a/lib/app/router/app_router.dart +++ b/lib/app/router/app_router.dart @@ -5,6 +5,7 @@ import 'package:expense_tracker/features/counter/presentation/pages/counter_page import 'package:expense_tracker/features/dashboard/presentation/blocs/dashboard_cubit.dart'; import 'package:expense_tracker/features/dashboard/presentation/pages/home_page.dart'; import 'package:expense_tracker/features/dashboard/presentation/pages/stats_coming_soon_page.dart'; +import 'package:expense_tracker/features/easter_egg/presentation/blocs/easter_egg_cubit.dart'; import 'package:expense_tracker/features/settings/presentation/pages/settings_page.dart'; import 'package:expense_tracker/features/transaction/domain/entities/transaction.dart'; import 'package:expense_tracker/features/transaction/presentation/blocs/transaction_cubit.dart'; @@ -73,6 +74,13 @@ GoRouter router([String? initialLocation]) => GoRouter( GoRoute( path: '/counter', name: 'counter', + // Locked users never reach the secret room — even via direct + // navigation or a future deep link. + redirect: (context, state) { + final unlocked = + getIt().state.progress.unlocked; + return unlocked ? null : '/settings'; + }, builder: (context, state) => const CounterPage(), ), ], diff --git a/lib/features/category/domain/extensions/category_tree.dart b/lib/features/category/domain/extensions/category_tree.dart new file mode 100644 index 0000000..d0ab172 --- /dev/null +++ b/lib/features/category/domain/extensions/category_tree.dart @@ -0,0 +1,29 @@ +import 'package:expense_tracker/features/category/domain/entities/category.dart'; + +extension CategoryTreeX on Category { + /// Sums expected monthly budgets across this category's subtree so + /// Pillars and Sub-Parents show aggregated totals. + /// + /// A category's own budget counts only when it has no children — the + /// budget model expects allocations on leaves. The walk is capped at + /// [maxDepth] levels (Pillar → Sub-Parent → Envelope), which keeps + /// every surface in agreement and guards against infinite recursion + /// on malformed parent cycles. + double sumBudgetUnder( + List allCategories, { + int maxDepth = 3, + }) { + if (maxDepth <= 0) return 0; + final parentId = uuid.getOrCrash(); + final directChildren = allCategories + .where((c) => c.parentId?.getOrCrash() == parentId) + .toList(); + if (directChildren.isEmpty) { + return expectedMonthlyBudget; + } + return directChildren.fold( + 0, + (sum, c) => sum + c.sumBudgetUnder(allCategories, maxDepth: maxDepth - 1), + ); + } +} diff --git a/lib/features/category/presentation/pages/category_manage_page.dart b/lib/features/category/presentation/pages/category_manage_page.dart index 43f76bd..fc96902 100644 --- a/lib/features/category/presentation/pages/category_manage_page.dart +++ b/lib/features/category/presentation/pages/category_manage_page.dart @@ -1,4 +1,5 @@ import 'package:expense_tracker/features/category/domain/entities/category.dart'; +import 'package:expense_tracker/features/category/domain/extensions/category_tree.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/category/presentation/pages/category_form_page.dart'; @@ -24,6 +25,28 @@ class _CategoryManagePageState extends State { getIt().onCategoriesOpened(); } + /// The tree renders and aggregates three levels, so creation is capped + /// there too: the FAB is hidden on focused pages that are already at + /// level 3 (a level-4 envelope would be invisible in the tree). + bool _canCreateChild(Category? activeCategory, List all) { + if (activeCategory == null) return true; + return _depthOf(activeCategory, all) < 3; + } + + int _depthOf(Category category, List all) { + var depth = 1; + Category? current = category; + while (current?.parentId != null && depth < 10) { + final parentId = current!.parentId!.getOrCrash(); + current = all.cast().firstWhere( + (c) => c?.uuid.getOrCrash() == parentId, + orElse: () => null, + ); + depth++; + } + return depth; + } + IconData _iconForChild(String name) { final lower = name.toLowerCase(); if (lower.contains('mortgage') || @@ -46,19 +69,6 @@ class _CategoryManagePageState extends State { return Icons.category_outlined; } - double _sumBudgetUnder(Category category, List allCategories) { - final directChildren = allCategories - .where((c) => c.parentId?.getOrCrash() == category.uuid.getOrCrash()) - .toList(); - if (directChildren.isEmpty) { - return category.expectedMonthlyBudget; - } - return directChildren.fold( - 0, - (sum, c) => sum + _sumBudgetUnder(c, allCategories), - ); - } - Future _confirmDelete( BuildContext context, BuildContext blocContext, @@ -295,27 +305,30 @@ class _CategoryManagePageState extends State { ), ], ), - floatingActionButton: FloatingActionButton.extended( - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => BlocProvider.value( - value: blocContext.read(), - child: CategoryFormPage( - activeParentUuid: state.activeParentUuid, - ), - ), - ), - ); - }, - backgroundColor: const Color(0xFF00113A), - foregroundColor: Colors.white, - label: Text( - 'Add Envelope', - style: GoogleFonts.inter(fontWeight: FontWeight.bold), - ), - icon: const Icon(Icons.add), - ), + floatingActionButton: + _canCreateChild(activeCategory, state.allCategories) + ? FloatingActionButton.extended( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => BlocProvider.value( + value: blocContext.read(), + child: CategoryFormPage( + activeParentUuid: state.activeParentUuid, + ), + ), + ), + ); + }, + backgroundColor: const Color(0xFF00113A), + foregroundColor: Colors.white, + label: Text( + 'Add Envelope', + style: GoogleFonts.inter(fontWeight: FontWeight.bold), + ), + icon: const Icon(Icons.add), + ) + : null, ), ); }, @@ -335,7 +348,7 @@ class _CategoryManagePageState extends State { ) : null; - final totalBudget = _sumBudgetUnder(activeCategory, state.allCategories); + final totalBudget = activeCategory.sumBudgetUnder(state.allCategories); return Container( padding: const EdgeInsets.all(20), @@ -487,7 +500,7 @@ class _CategoryManagePageState extends State { CategoryState state, ) { final name = child.name.getOrCrash(); - final budget = _sumBudgetUnder(child, state.allCategories); + final budget = child.sumBudgetUnder(state.allCategories); return Container( padding: const EdgeInsets.all(14), diff --git a/lib/features/category/presentation/widgets/envelope_tree_list_view.dart b/lib/features/category/presentation/widgets/envelope_tree_list_view.dart index 68b493f..d956363 100644 --- a/lib/features/category/presentation/widgets/envelope_tree_list_view.dart +++ b/lib/features/category/presentation/widgets/envelope_tree_list_view.dart @@ -1,4 +1,5 @@ import 'package:expense_tracker/features/category/domain/entities/category.dart'; +import 'package:expense_tracker/features/category/domain/extensions/category_tree.dart'; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -10,9 +11,10 @@ import 'package:google_fonts/google_fonts.dart'; /// the HTML mockup (category-new-design.html, lines 874–892, 961–1120). /// /// The hierarchy is capped at three levels (Pillar → Sub-Parent → Envelope). -/// Tapping a sub-parent row with children opens its focused page via -/// [onChildTap]; the trailing chevron expands or collapses it inline instead. -/// Sub-parents with children expose editing through [onChildEdit]. +/// Tapping any child row opens its focused page via [onChildTap]; a +/// sub-parent row additionally shows a trailing chevron that expands or +/// collapses its level-3 children inline, plus an edit button routed to +/// [onChildEdit]. class EnvelopeTreeListView extends StatefulWidget { const EnvelopeTreeListView({ required this.allCategories, @@ -30,8 +32,8 @@ class EnvelopeTreeListView extends StatefulWidget { final void Function(Category pillar)? onPillarTap; /// Called when the user taps a child row (sub-parent or leaf envelope). - /// Sub-parents with children expand or collapse via the trailing chevron - /// instead of calling this. + /// The trailing chevron on sub-parent rows toggles their inline + /// expansion instead of calling this. final void Function(Category child)? onChildTap; /// Called when the user taps the edit button on a sub-parent row. @@ -46,7 +48,7 @@ class EnvelopeTreeListView extends StatefulWidget { class _EnvelopeTreeListViewState extends State { /// Tracks which pillar UUIDs are collapsed. Pillars start expanded. - final Set _collapsed = {}; + final Set _collapsedPillars = {}; /// Tracks which sub-parent UUIDs are collapsed. Sub-parents start expanded. final Set _collapsedSubs = {}; @@ -89,13 +91,13 @@ class _EnvelopeTreeListViewState extends State { Widget _buildPillarSection(Category pillar) { final pillarId = pillar.uuid.getOrCrash(); - final isCollapsed = _collapsed.contains(pillarId); + final isCollapsed = _collapsedPillars.contains(pillarId); final children = _childrenOf(pillar); // Compute aggregated budget for display double totalBudget = 0; for (final child in children) { - totalBudget += _sumBudgetUnder(child); + totalBudget += child.sumBudgetUnder(widget.allCategories); } return Column( @@ -121,7 +123,7 @@ class _EnvelopeTreeListViewState extends State { return InkWell( onTap: () { - _toggleCollapsed(_collapsed, pillarId); + _toggleCollapsed(_collapsedPillars, pillarId); widget.onPillarTap?.call(pillar); }, borderRadius: BorderRadius.circular(12), @@ -273,7 +275,7 @@ class _EnvelopeTreeListViewState extends State { Widget _buildChildRow(Category child) { final name = child.name.getOrCrash(); - final budget = _sumBudgetUnder(child); + final budget = child.sumBudgetUnder(widget.allCategories); final childId = child.uuid.getOrCrash(); final grandchildren = _childrenOf(child); final hasChildren = grandchildren.isNotEmpty; @@ -481,22 +483,6 @@ class _EnvelopeTreeListViewState extends State { // ─────────────────────────────── Helpers ────────────────────────────────── - /// Sums expected monthly budgets across [category]'s subtree so Pillars - /// and Sub-Parents show aggregated totals. The walk is capped at - /// [maxDepth] levels (Pillar → Sub-Parent → Envelope), which also guards - /// against infinite recursion if malformed data ever contains a cycle. - double _sumBudgetUnder(Category category, {int maxDepth = 3}) { - if (maxDepth <= 0) return 0; - final directChildren = _childrenOf(category); - if (directChildren.isEmpty) { - return category.expectedMonthlyBudget; - } - return directChildren.fold( - 0, - (sum, c) => sum + _sumBudgetUnder(c, maxDepth: maxDepth - 1), - ); - } - IconData _iconForPillar(String name) { final lower = name.toLowerCase(); if (lower.contains('essential') || lower.contains('need')) { diff --git a/lib/features/counter/presentation/pages/counter_page.dart b/lib/features/counter/presentation/pages/counter_page.dart index 8660ca0..84218d0 100644 --- a/lib/features/counter/presentation/pages/counter_page.dart +++ b/lib/features/counter/presentation/pages/counter_page.dart @@ -9,13 +9,19 @@ import 'package:google_fonts/google_fonts.dart'; /// Deliberately keeps the template's bare-bones logic; the joke is that /// this page survived the cleanup that deleted everything else. class CounterPage extends StatelessWidget { - const CounterPage({super.key}); + const CounterPage({super.key, this.easterEggCubit}); + + /// Overridable for tests; defaults to the app-wide singleton. + final EasterEggCubit? easterEggCubit; @override Widget build(BuildContext context) { - return BlocProvider( - create: (_) => CounterCubit(), - child: const CounterView(), + return BlocProvider.value( + value: easterEggCubit ?? getIt(), + child: BlocProvider( + create: (_) => CounterCubit(), + child: const CounterView(), + ), ); } } @@ -41,7 +47,7 @@ class CounterView extends StatelessWidget { tooltip: 'Hide the secret room', icon: const Icon(Icons.visibility_off_outlined), onPressed: () { - getIt().deactivate(); + context.read().deactivate(); Navigator.of(context).pop(); }, ), diff --git a/lib/features/easter_egg/domain/entities/easter_egg_progress.dart b/lib/features/easter_egg/domain/entities/easter_egg_progress.dart index 7c48f5a..96c87e6 100644 --- a/lib/features/easter_egg/domain/entities/easter_egg_progress.dart +++ b/lib/features/easter_egg/domain/entities/easter_egg_progress.dart @@ -15,6 +15,7 @@ class EasterEggProgress extends Equatable { }); static const int versionTapsRequired = 7; + static const int totalSteps = 3; final int versionTaps; final bool hintSeen; diff --git a/lib/features/easter_egg/presentation/blocs/easter_egg_cubit.dart b/lib/features/easter_egg/presentation/blocs/easter_egg_cubit.dart index 43ec7ee..569c4c7 100644 --- a/lib/features/easter_egg/presentation/blocs/easter_egg_cubit.dart +++ b/lib/features/easter_egg/presentation/blocs/easter_egg_cubit.dart @@ -28,7 +28,18 @@ class EasterEggCubit extends Cubit { final FlashCubit _flashCubit; void _restore() { - emit(EasterEggState(progress: _storage.read())); + var progress = _storage.read(); + // Self-heal a partial write from the unlocking transition: if the + // process died between persisting the third step and the unlocked + // flag, the ritual is complete by definition — promote it instead of + // stranding the user in an unrecoverable 3/3 state. + if (!progress.unlocked && + progress.hintSeen && + progress.completedSteps == EasterEggProgress.totalSteps) { + progress = progress.copyWith(unlocked: true); + unawaited(_storage.write(progress)); + } + emit(EasterEggState(progress: progress)); } void onVersionTapped() { @@ -38,8 +49,7 @@ class EasterEggCubit extends Cubit { var progress = current.copyWith(versionTaps: current.versionTaps + 1); var justRevealedHint = false; if (!progress.hintSeen && - progress.versionTaps >= - EasterEggProgress.versionTapsRequired) { + progress.versionTaps >= EasterEggProgress.versionTapsRequired) { progress = progress.copyWith(hintSeen: true); justRevealedHint = true; } @@ -84,7 +94,7 @@ class EasterEggCubit extends Cubit { if (stepped == current) return; var progress = stepped; - if (progress.completedSteps == 3) { + if (progress.completedSteps == EasterEggProgress.totalSteps) { progress = progress.copyWith(unlocked: true); unawaited( _flashCubit.displayFlash( diff --git a/lib/features/settings/presentation/pages/settings_page.dart b/lib/features/settings/presentation/pages/settings_page.dart index 9cba8c1..5467cd5 100644 --- a/lib/features/settings/presentation/pages/settings_page.dart +++ b/lib/features/settings/presentation/pages/settings_page.dart @@ -39,8 +39,7 @@ class SettingsPage extends StatelessWidget { leading: const Icon(Icons.info_outline), title: const Text('Version'), subtitle: Text(_versionSubtitle(progress)), - onTap: () => - context.read().onVersionTapped(), + onTap: () => context.read().onVersionTapped(), ), if (progress.unlocked) ListTile( diff --git a/lib/features/transaction/presentation/pages/transaction_entry_page.dart b/lib/features/transaction/presentation/pages/transaction_entry_page.dart index 79c45f8..f6c2828 100644 --- a/lib/features/transaction/presentation/pages/transaction_entry_page.dart +++ b/lib/features/transaction/presentation/pages/transaction_entry_page.dart @@ -151,7 +151,11 @@ class _TransactionEntryPageState extends State { body: BlocConsumer( listener: (context, state) { if (state.status == TransactionFormStatus.success) { - getIt().onTransactionLogged(); + // The ritual step is "log a transaction" — editing an + // existing one does not count. + if (widget.existingTransaction == null) { + getIt().onTransactionLogged(); + } Navigator.of(context).pop(); } else if (state.status == TransactionFormStatus.failure) { ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/shared/flash/presentation/blocs/cubit/flash_cubit.dart b/lib/shared/flash/presentation/blocs/cubit/flash_cubit.dart index 5de1199..0e2604e 100644 --- a/lib/shared/flash/presentation/blocs/cubit/flash_cubit.dart +++ b/lib/shared/flash/presentation/blocs/cubit/flash_cubit.dart @@ -3,7 +3,10 @@ import 'package:injectable/injectable.dart'; part 'flash_state.dart'; -@injectable +/// App-wide one-shot message bus. Must be a singleton: the app shell +/// listens to the instance provided at the root, so feature cubits that +/// display flashes (e.g. the easter egg) must resolve the same instance. +@lazySingleton class FlashCubit extends Cubit { FlashCubit() : super(const FlashState.disappeared()); diff --git a/pubspec.lock b/pubspec.lock index 4caecff..758fd38 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: @@ -1131,5 +1131,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.11.0 <4.0.0" + dart: ">=3.12.0 <4.0.0" flutter: ">=3.38.0" diff --git a/test/features/category/presentation/widgets/category_widgets_test.dart b/test/features/category/presentation/widgets/category_widgets_test.dart index 73d1b66..5951af2 100644 --- a/test/features/category/presentation/widgets/category_widgets_test.dart +++ b/test/features/category/presentation/widgets/category_widgets_test.dart @@ -52,7 +52,12 @@ final _dining = _makeCategory( parentId: _p2Id, budget: 300, ); -final _food = _makeCategory(uuid: _subId, name: 'Food', parentId: _p1Id); +final _food = _makeCategory( + uuid: _subId, + name: 'Food', + parentId: _p1Id, + budget: 50, +); final _coffee = _makeCategory( uuid: _e1Id, name: 'Coffee', @@ -377,9 +382,39 @@ void main() { expect(find.text('Food'), findsOneWidget); expect(find.text('Coffee'), findsOneWidget); expect(find.text('Groceries'), findsOneWidget); - // $300 appears as: Dining leaf budget, Lifestyle pillar aggregate, - // and Food sub-parent aggregate (120 + 180). + // Sub-parent aggregates children only (120 + 180); its own 50 + // budget is ignored because it has children. expect(find.text(r'$300'), findsNWidgets(3)); + // Level-3 leaves show their own budget, not an aggregate. + expect(find.text(r'$120'), findsOneWidget); + expect(find.text(r'$180'), findsOneWidget); + // Pillar header aggregates across the full subtree. + expect(find.text(r'$1500'), findsOneWidget); + }); + + testWidgets('pillar collapse hides subs and level-3 envelopes', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: EnvelopeTreeListView(allCategories: deepCategories), + ), + ), + ), + ); + + await tester.tap(find.text('Essential')); + await tester.pumpAndSettle(); + expect(find.text('Food'), findsNothing); + expect(find.text('Coffee'), findsNothing); + expect(find.text('Groceries'), findsNothing); + // Other pillars keep their own collapse state. + expect(find.text('Dining'), findsOneWidget); + + await tester.tap(find.text('Essential')); + await tester.pumpAndSettle(); + expect(find.text('Coffee'), findsOneWidget); }); testWidgets('chevron tap toggles level-3 children without onChildTap', @@ -411,6 +446,8 @@ void main() { expect(find.text('Coffee'), findsNothing); expect(find.text('Groceries'), findsNothing); expect(childTaps, 0); + // Without onChildEdit the pencil is not rendered at all. + expect(find.byIcon(Icons.edit_outlined), findsNothing); await tester.tap(foodRowChevron); await tester.pumpAndSettle(); diff --git a/test/features/counter/presentation/pages/counter_page_test.dart b/test/features/counter/presentation/pages/counter_page_test.dart index 1f2e568..0ade4fd 100644 --- a/test/features/counter/presentation/pages/counter_page_test.dart +++ b/test/features/counter/presentation/pages/counter_page_test.dart @@ -7,19 +7,42 @@ import 'package:bloc_test/bloc_test.dart'; import 'package:expense_tracker/features/counter/counter.dart'; +import 'package:expense_tracker/features/easter_egg/data/datasources/easter_egg_storage.dart'; +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/shared/flash/presentation/blocs/cubit/flash_cubit.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../../../../helpers/helpers.dart'; class MockCounterCubit extends MockCubit implements CounterCubit {} +class _RecordingFlashCubit extends FlashCubit { + final List messages = []; + + @override + Future displayFlash(String message) async { + messages.add(message); + } +} + +Future buildEggCubit() async { + SharedPreferences.setMockInitialValues({}); + final preferences = await SharedPreferences.getInstance(); + return EasterEggCubit( + EasterEggStorageImpl(preferences), + _RecordingFlashCubit(), + ); +} + void main() { group('CounterPage', () { testWidgets('renders CounterView', (tester) async { - await tester.pumpApp(const CounterPage()); + await tester.pumpApp(CounterPage(easterEggCubit: await buildEggCubit())); expect(find.byType(CounterView), findsOneWidget); }); }); @@ -71,4 +94,35 @@ void main() { verify(() => counterCubit.decrement()).called(1); }); }); + + group('CounterPage hide action', () { + testWidgets('re-locks the easter egg when the eye-off icon is tapped', + (tester) async { + SharedPreferences.setMockInitialValues({}); + final preferences = await SharedPreferences.getInstance(); + await EasterEggStorageImpl(preferences).write( + const EasterEggProgress( + hintSeen: true, + loggedTransaction: true, + visitedStats: true, + openedCategories: true, + unlocked: true, + ), + ); + final eggCubit = EasterEggCubit( + EasterEggStorageImpl(preferences), + _RecordingFlashCubit(), + ); + expect(eggCubit.state.progress.unlocked, isTrue); + + await tester.pumpApp(CounterPage(easterEggCubit: eggCubit)); + expect(find.byIcon(Icons.visibility_off_outlined), findsOneWidget); + + await tester.tap(find.byIcon(Icons.visibility_off_outlined)); + await tester.pumpAndSettle(); + + expect(eggCubit.state.progress.unlocked, isFalse); + expect(eggCubit.state.progress.versionTaps, 0); + }); + }); } diff --git a/test/features/easter_egg/easter_egg_cubit_test.dart b/test/features/easter_egg/presentation/blocs/easter_egg_cubit_test.dart similarity index 77% rename from test/features/easter_egg/easter_egg_cubit_test.dart rename to test/features/easter_egg/presentation/blocs/easter_egg_cubit_test.dart index a2b1c67..5583a4e 100644 --- a/test/features/easter_egg/easter_egg_cubit_test.dart +++ b/test/features/easter_egg/presentation/blocs/easter_egg_cubit_test.dart @@ -26,7 +26,6 @@ void main() { EasterEggCubit buildCubit() => EasterEggCubit(EasterEggStorageImpl(preferences), flashCubit); - void revealHint(EasterEggCubit cubit) { for (var i = 0; i < EasterEggProgress.versionTapsRequired; i++) { cubit.onVersionTapped(); @@ -56,6 +55,11 @@ void main() { expect(cubit.state.progress.hintSeen, isTrue); expect(cubit.state.justRevealedHint, isTrue); expect(cubit.state.progress.unlocked, isFalse); + + // The edge-triggered flag clears on the next state change so the + // ritual dialog neither stacks nor re-shows. + cubit.onVersionTapped(); + expect(cubit.state.justRevealedHint, isFalse); }); test('does not count steps before the hint is seen', () { @@ -107,6 +111,43 @@ void main() { ..onCategoriesOpened(); expect(cubit.state.progress.unlocked, isTrue); + final restored = buildCubit(); + expect(restored.state.progress.unlocked, isTrue); + expect(restored.state.progress.completedSteps, 3); + // The dialog must not re-show after a restart. + expect(restored.state.justRevealedHint, isFalse); + }); + + test('mid-ritual state restores and completes', () { + final first = buildCubit(); + revealHint(first); + first + ..onTransactionLogged() + ..onStatsVisited(); + expect(first.state.progress.unlocked, isFalse); + + final restored = buildCubit(); + expect(restored.state.justRevealedHint, isFalse); + expect(restored.state.progress.hintSeen, isTrue); + expect(restored.state.progress.completedSteps, 2); + expect(restored.state.progress.unlocked, isFalse); + + restored.onCategoriesOpened(); + expect(restored.state.progress.unlocked, isTrue); + expect(flashCubit.messages, hasLength(1)); + }); + + test('self-heals a partial unlock write on restore', () async { + // Simulates the app dying mid-write of the unlocking transition: + // all three steps persisted, but the unlocked flag was lost. + const stuck = EasterEggProgress( + hintSeen: true, + loggedTransaction: true, + visitedStats: true, + openedCategories: true, + ); + await EasterEggStorageImpl(preferences).write(stuck); + final restored = buildCubit(); expect(restored.state.progress.unlocked, isTrue); expect(restored.state.progress.completedSteps, 3); @@ -169,6 +210,7 @@ void main() { final restored = buildCubit(); expect(restored.state.progress.unlocked, isFalse); expect(restored.state.progress.versionTaps, 0); + expect(restored.state.justRevealedHint, isFalse); }); }); } diff --git a/test/features/settings/presentation/pages/settings_page_test.dart b/test/features/settings/presentation/pages/settings_page_test.dart new file mode 100644 index 0000000..794e558 --- /dev/null +++ b/test/features/settings/presentation/pages/settings_page_test.dart @@ -0,0 +1,91 @@ +import 'package:expense_tracker/features/easter_egg/data/datasources/easter_egg_storage.dart'; +import 'package:expense_tracker/features/easter_egg/presentation/blocs/easter_egg_cubit.dart'; +import 'package:expense_tracker/features/settings/presentation/pages/settings_page.dart'; +import 'package:expense_tracker/shared/flash/presentation/blocs/cubit/flash_cubit.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../../../helpers/helpers.dart'; + +class _RecordingFlashCubit extends FlashCubit { + final List messages = []; + + @override + Future displayFlash(String message) async { + messages.add(message); + } +} + +void main() { + late SharedPreferences preferences; + late _RecordingFlashCubit flashCubit; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + preferences = await SharedPreferences.getInstance(); + flashCubit = _RecordingFlashCubit(); + }); + + Future buildCubit() async => + EasterEggCubit(EasterEggStorageImpl(preferences), flashCubit); + + testWidgets('shows the ritual dialog exactly once on the 7th version tap', + (tester) async { + final cubit = await buildCubit(); + await tester.pumpApp(SettingsPage(easterEggCubit: cubit)); + + for (var i = 0; i < 6; i++) { + await tester.tap(find.text('Version')); + await tester.pump(); + } + expect(find.text('You found something...'), findsNothing); + + await tester.tap(find.text('Version')); + await tester.pump(); + expect(find.text('You found something...'), findsOneWidget); + + await tester.tap(find.text('Got it')); + await tester.pumpAndSettle(); + + // Further taps never re-show the dialog. + await tester.tap(find.text('Version')); + await tester.pump(); + expect(find.text('You found something...'), findsNothing); + }); + + testWidgets('version subtitle tracks ritual progress', (tester) async { + final cubit = await buildCubit(); + await tester.pumpApp(SettingsPage(easterEggCubit: cubit)); + expect(find.text('1.0.0'), findsOneWidget); + + 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(); + expect(find.textContaining('(0/3 steps)'), findsOneWidget); + }); + + testWidgets('counter tile appears only when unlocked', (tester) async { + final cubit = await buildCubit(); + await tester.pumpApp(SettingsPage(easterEggCubit: cubit)); + expect(find.text('Counter'), 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('Counter'), findsOneWidget); + expect(find.text('Secret unlocked — Counter is below'), findsOneWidget); + }); +} From 5c238ecfc2000af608fe6bc36b58d928a483b433 Mon Sep 17 00:00:00 2001 From: MF-Rozi Date: Tue, 8 Sep 2026 09:26:35 +0700 Subject: [PATCH 5/9] fix(review): finish aggregation unification and make easter-egg persistence 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. --- lib/app/router/app_router.dart | 3 +- .../pages/category_manage_page.dart | 46 +++++++++------- .../hierarchical_envelope_picker_sheet.dart | 15 +----- .../data/datasources/easter_egg_storage.dart | 48 +++++++++-------- .../presentation/pages/settings_page.dart | 2 +- test/app/router/app_router_test.dart | 53 +++++++++++++++++++ .../presentation/pages/counter_page_test.dart | 25 +-------- .../blocs/easter_egg_cubit_test.dart | 17 ++---- .../pages/settings_page_test.dart | 17 ++---- test/helpers/easter_egg_test_helpers.dart | 35 ++++++++++++ test/helpers/helpers.dart | 1 + test/shared/flash/flash_cubit_test.dart | 18 +++++++ 12 files changed, 173 insertions(+), 107 deletions(-) create mode 100644 test/app/router/app_router_test.dart create mode 100644 test/helpers/easter_egg_test_helpers.dart create mode 100644 test/shared/flash/flash_cubit_test.dart diff --git a/lib/app/router/app_router.dart b/lib/app/router/app_router.dart index d7c4368..93bf1a1 100644 --- a/lib/app/router/app_router.dart +++ b/lib/app/router/app_router.dart @@ -77,8 +77,7 @@ GoRouter router([String? initialLocation]) => GoRouter( // Locked users never reach the secret room — even via direct // navigation or a future deep link. redirect: (context, state) { - final unlocked = - getIt().state.progress.unlocked; + final unlocked = getIt().state.progress.unlocked; return unlocked ? null : '/settings'; }, builder: (context, state) => const CounterPage(), diff --git a/lib/features/category/presentation/pages/category_manage_page.dart b/lib/features/category/presentation/pages/category_manage_page.dart index fc96902..de0a261 100644 --- a/lib/features/category/presentation/pages/category_manage_page.dart +++ b/lib/features/category/presentation/pages/category_manage_page.dart @@ -27,24 +27,19 @@ class _CategoryManagePageState extends State { /// The tree renders and aggregates three levels, so creation is capped /// there too: the FAB is hidden on focused pages that are already at - /// level 3 (a level-4 envelope would be invisible in the tree). + /// level 3 (a level-4 envelope would be invisible in the tree). An + /// orphan parent (uuid missing from the list) also hides the FAB — + /// children under it would be unreachable in every surface. bool _canCreateChild(Category? activeCategory, List all) { if (activeCategory == null) return true; - return _depthOf(activeCategory, all) < 3; - } - - int _depthOf(Category category, List all) { - var depth = 1; - Category? current = category; - while (current?.parentId != null && depth < 10) { - final parentId = current!.parentId!.getOrCrash(); - current = all.cast().firstWhere( - (c) => c?.uuid.getOrCrash() == parentId, - orElse: () => null, - ); - depth++; + if (activeCategory.parentId != null) { + final parentId = activeCategory.parentId!.getOrCrash(); + final parentExists = all.any( + (c) => c.uuid.getOrCrash() == parentId, + ); + if (!parentExists) return false; } - return depth; + return activeCategory.getHierarchyChain(all).length < 3; } IconData _iconForChild(String name) { @@ -285,7 +280,10 @@ class _CategoryManagePageState extends State { ), const SizedBox(height: 16), if (state.currentViewCategories.isEmpty) - _buildEmptySubEnvelopesState(activeCategory) + _buildEmptySubEnvelopesState( + activeCategory, + state.allCategories, + ) else ...state.currentViewCategories.map( (child) => Padding( @@ -593,7 +591,11 @@ class _CategoryManagePageState extends State { ); } - Widget _buildEmptySubEnvelopesState(Category activeCategory) { + Widget _buildEmptySubEnvelopesState( + Category activeCategory, + List all, + ) { + final isMaxDepth = activeCategory.getHierarchyChain(all).length >= 3; return Center( child: Padding( padding: const EdgeInsets.symmetric(vertical: 40), @@ -606,7 +608,7 @@ class _CategoryManagePageState extends State { ), const SizedBox(height: 12), Text( - 'No sub-envelopes yet', + isMaxDepth ? 'Leaf envelope' : 'No sub-envelopes yet', style: GoogleFonts.manrope( fontSize: 15, fontWeight: FontWeight.w600, @@ -615,8 +617,12 @@ class _CategoryManagePageState extends State { ), const SizedBox(height: 4), Text( - 'Nested envelopes inside "${activeCategory.name.getOrCrash()}" ' - 'will appear here.', + isMaxDepth + ? '"${activeCategory.name.getOrCrash()}" is at the ' + 'deepest level and cannot contain nested envelopes.' + : 'Nested envelopes inside ' + '"${activeCategory.name.getOrCrash()}" ' + 'will appear here.', textAlign: TextAlign.center, style: GoogleFonts.inter( fontSize: 13, diff --git a/lib/features/category/presentation/widgets/hierarchical_envelope_picker_sheet.dart b/lib/features/category/presentation/widgets/hierarchical_envelope_picker_sheet.dart index 809718f..0f50b6a 100644 --- a/lib/features/category/presentation/widgets/hierarchical_envelope_picker_sheet.dart +++ b/lib/features/category/presentation/widgets/hierarchical_envelope_picker_sheet.dart @@ -1,4 +1,5 @@ import 'package:expense_tracker/features/category/domain/entities/category.dart'; +import 'package:expense_tracker/features/category/domain/extensions/category_tree.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/category/presentation/pages/category_form_page.dart'; @@ -328,7 +329,7 @@ class _HierarchicalEnvelopePickerSheetState ) { final name = pillar.name.getOrCrash(); final isSelected = widget.selectedCategory?.uuid == pillar.uuid; - final totalBudget = _sumBudgetUnder(pillar, allCategories); + final totalBudget = pillar.sumBudgetUnder(allCategories); return InkWell( onTap: () { @@ -828,18 +829,6 @@ class _HierarchicalEnvelopePickerSheetState // ────────────────────────────── Helpers ─────────────────────────────────── - double _sumBudgetUnder(Category category, List allCategories) { - final directChildren = - allCategories.where((c) => c.parentId == category.uuid).toList(); - if (directChildren.isEmpty) { - return category.expectedMonthlyBudget; - } - return directChildren.fold( - 0, - (sum, c) => sum + _sumBudgetUnder(c, allCategories), - ); - } - Widget _buildPillarChip(Category pillar) { final name = pillar.name.getOrCrash().toLowerCase(); Color chipBg; diff --git a/lib/features/easter_egg/data/datasources/easter_egg_storage.dart b/lib/features/easter_egg/data/datasources/easter_egg_storage.dart index 0cc9a2b..d26c164 100644 --- a/lib/features/easter_egg/data/datasources/easter_egg_storage.dart +++ b/lib/features/easter_egg/data/datasources/easter_egg_storage.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:expense_tracker/features/easter_egg/domain/entities/easter_egg_progress.dart'; import 'package:injectable/injectable.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -7,40 +9,44 @@ abstract class EasterEggStorage { Future write(EasterEggProgress progress); } +/// Persists the ritual as one atomic JSON snapshot. Separate keys would +/// allow torn writes (e.g. steps persisted without the unlocked flag), +/// which no in-process code can distinguish from an intentional state. @LazySingleton(as: EasterEggStorage) class EasterEggStorageImpl implements EasterEggStorage { const EasterEggStorageImpl(this._preferences); final SharedPreferences _preferences; - static const _tapsKey = 'easterEgg.versionTaps'; - static const _hintKey = 'easterEgg.hintSeen'; - static const _transactionKey = 'easterEgg.loggedTransaction'; - static const _statsKey = 'easterEgg.visitedStats'; - static const _categoriesKey = 'easterEgg.openedCategories'; - static const _unlockedKey = 'easterEgg.unlocked'; + static const _progressKey = 'easterEgg.progress'; @override EasterEggProgress read() { + final raw = _preferences.getString(_progressKey); + if (raw == null) return const EasterEggProgress(); + final map = jsonDecode(raw) as Map; return EasterEggProgress( - versionTaps: _preferences.getInt(_tapsKey) ?? 0, - hintSeen: _preferences.getBool(_hintKey) ?? false, - loggedTransaction: _preferences.getBool(_transactionKey) ?? false, - visitedStats: _preferences.getBool(_statsKey) ?? false, - openedCategories: _preferences.getBool(_categoriesKey) ?? false, - unlocked: _preferences.getBool(_unlockedKey) ?? false, + versionTaps: map['versionTaps'] as int? ?? 0, + hintSeen: map['hintSeen'] as bool? ?? false, + loggedTransaction: map['loggedTransaction'] as bool? ?? false, + visitedStats: map['visitedStats'] as bool? ?? false, + openedCategories: map['openedCategories'] as bool? ?? false, + unlocked: map['unlocked'] as bool? ?? false, ); } @override - Future write(EasterEggProgress progress) async { - await Future.wait([ - _preferences.setInt(_tapsKey, progress.versionTaps), - _preferences.setBool(_hintKey, progress.hintSeen), - _preferences.setBool(_transactionKey, progress.loggedTransaction), - _preferences.setBool(_statsKey, progress.visitedStats), - _preferences.setBool(_categoriesKey, progress.openedCategories), - _preferences.setBool(_unlockedKey, progress.unlocked), - ]); + Future write(EasterEggProgress progress) { + return _preferences.setString( + _progressKey, + jsonEncode({ + 'versionTaps': progress.versionTaps, + 'hintSeen': progress.hintSeen, + 'loggedTransaction': progress.loggedTransaction, + 'visitedStats': progress.visitedStats, + 'openedCategories': progress.openedCategories, + 'unlocked': progress.unlocked, + }), + ); } } diff --git a/lib/features/settings/presentation/pages/settings_page.dart b/lib/features/settings/presentation/pages/settings_page.dart index 5467cd5..283f1b1 100644 --- a/lib/features/settings/presentation/pages/settings_page.dart +++ b/lib/features/settings/presentation/pages/settings_page.dart @@ -60,7 +60,7 @@ class SettingsPage extends StatelessWidget { if (progress.unlocked) return 'Secret unlocked — Counter is below'; if (!progress.hintSeen) return '1.0.0'; return 'Something is stirring... ' - '(${progress.completedSteps}/3 steps)'; + '(${progress.completedSteps}/${EasterEggProgress.totalSteps} steps)'; } void _showRitualDialog(BuildContext context) { diff --git a/test/app/router/app_router_test.dart b/test/app/router/app_router_test.dart new file mode 100644 index 0000000..efb3dad --- /dev/null +++ b/test/app/router/app_router_test.dart @@ -0,0 +1,53 @@ +import 'package:expense_tracker/app/router/app_router.dart'; +import 'package:expense_tracker/features/easter_egg/presentation/blocs/easter_egg_cubit.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:shared_preferences/shared_preferences.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + setUpAll(() async { + SharedPreferences.setMockInitialValues({}); + await configureInjector(); + }); + + setUp(() => GoogleFonts.config.allowRuntimeFetching = false); + + Future pumpRouter(WidgetTester tester, String location) { + return tester.pumpWidget( + MaterialApp.router(routerConfig: router(location)), + ); + } + + testWidgets('redirects locked users from /counter to settings', + (tester) async { + await pumpRouter(tester, '/counter'); + await tester.pumpAndSettle(); + + // App bar title + bottom-nav label both render "Settings"; the page + // itself is confirmed by its unique list tile. + expect(find.text('Manage Categories'), findsOneWidget); + expect(find.byIcon(Icons.visibility_off_outlined), findsNothing); + }); + + testWidgets('unlocked users reach the counter', (tester) async { + final egg = getIt(); + for (var i = 0; i < 7; i++) { + egg.onVersionTapped(); + } + egg + ..onTransactionLogged() + ..onStatsVisited() + ..onCategoriesOpened(); + expect(egg.state.progress.unlocked, isTrue); + + await pumpRouter(tester, '/counter'); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.visibility_off_outlined), findsOneWidget); + expect(find.text('Counter'), findsOneWidget); + }); +} diff --git a/test/features/counter/presentation/pages/counter_page_test.dart b/test/features/counter/presentation/pages/counter_page_test.dart index 0ade4fd..acc413b 100644 --- a/test/features/counter/presentation/pages/counter_page_test.dart +++ b/test/features/counter/presentation/pages/counter_page_test.dart @@ -10,35 +10,15 @@ import 'package:expense_tracker/features/counter/counter.dart'; import 'package:expense_tracker/features/easter_egg/data/datasources/easter_egg_storage.dart'; 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/shared/flash/presentation/blocs/cubit/flash_cubit.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import '../../../../helpers/helpers.dart'; class MockCounterCubit extends MockCubit implements CounterCubit {} -class _RecordingFlashCubit extends FlashCubit { - final List messages = []; - - @override - Future displayFlash(String message) async { - messages.add(message); - } -} - -Future buildEggCubit() async { - SharedPreferences.setMockInitialValues({}); - final preferences = await SharedPreferences.getInstance(); - return EasterEggCubit( - EasterEggStorageImpl(preferences), - _RecordingFlashCubit(), - ); -} - void main() { group('CounterPage', () { testWidgets('renders CounterView', (tester) async { @@ -98,8 +78,7 @@ void main() { group('CounterPage hide action', () { testWidgets('re-locks the easter egg when the eye-off icon is tapped', (tester) async { - SharedPreferences.setMockInitialValues({}); - final preferences = await SharedPreferences.getInstance(); + final preferences = await mockPreferences(); await EasterEggStorageImpl(preferences).write( const EasterEggProgress( hintSeen: true, @@ -111,7 +90,7 @@ void main() { ); final eggCubit = EasterEggCubit( EasterEggStorageImpl(preferences), - _RecordingFlashCubit(), + RecordingFlashCubit(), ); expect(eggCubit.state.progress.unlocked, isTrue); diff --git a/test/features/easter_egg/presentation/blocs/easter_egg_cubit_test.dart b/test/features/easter_egg/presentation/blocs/easter_egg_cubit_test.dart index 5583a4e..46892b2 100644 --- a/test/features/easter_egg/presentation/blocs/easter_egg_cubit_test.dart +++ b/test/features/easter_egg/presentation/blocs/easter_egg_cubit_test.dart @@ -1,27 +1,18 @@ import 'package:expense_tracker/features/easter_egg/data/datasources/easter_egg_storage.dart'; 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/shared/flash/presentation/blocs/cubit/flash_cubit.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; -class _RecordingFlashCubit extends FlashCubit { - final List messages = []; - - @override - Future displayFlash(String message) async { - messages.add(message); - } -} +import '../../../../helpers/helpers.dart'; void main() { late SharedPreferences preferences; - late _RecordingFlashCubit flashCubit; + late RecordingFlashCubit flashCubit; setUp(() async { - SharedPreferences.setMockInitialValues({}); - preferences = await SharedPreferences.getInstance(); - flashCubit = _RecordingFlashCubit(); + preferences = await mockPreferences(); + flashCubit = RecordingFlashCubit(); }); EasterEggCubit buildCubit() => diff --git a/test/features/settings/presentation/pages/settings_page_test.dart b/test/features/settings/presentation/pages/settings_page_test.dart index 794e558..cc7c6ae 100644 --- a/test/features/settings/presentation/pages/settings_page_test.dart +++ b/test/features/settings/presentation/pages/settings_page_test.dart @@ -1,29 +1,18 @@ import 'package:expense_tracker/features/easter_egg/data/datasources/easter_egg_storage.dart'; import 'package:expense_tracker/features/easter_egg/presentation/blocs/easter_egg_cubit.dart'; import 'package:expense_tracker/features/settings/presentation/pages/settings_page.dart'; -import 'package:expense_tracker/shared/flash/presentation/blocs/cubit/flash_cubit.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../../../../helpers/helpers.dart'; -class _RecordingFlashCubit extends FlashCubit { - final List messages = []; - - @override - Future displayFlash(String message) async { - messages.add(message); - } -} - void main() { late SharedPreferences preferences; - late _RecordingFlashCubit flashCubit; + late RecordingFlashCubit flashCubit; setUp(() async { - SharedPreferences.setMockInitialValues({}); - preferences = await SharedPreferences.getInstance(); - flashCubit = _RecordingFlashCubit(); + preferences = await mockPreferences(); + flashCubit = RecordingFlashCubit(); }); Future buildCubit() async => diff --git a/test/helpers/easter_egg_test_helpers.dart b/test/helpers/easter_egg_test_helpers.dart new file mode 100644 index 0000000..2202822 --- /dev/null +++ b/test/helpers/easter_egg_test_helpers.dart @@ -0,0 +1,35 @@ +import 'package:expense_tracker/features/easter_egg/data/datasources/easter_egg_storage.dart'; +import 'package:expense_tracker/features/easter_egg/presentation/blocs/easter_egg_cubit.dart'; +import 'package:expense_tracker/shared/flash/presentation/blocs/cubit/flash_cubit.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Flash bus fake that records displayed messages instead of emitting +/// state — lets suites assert which flashes fired without UI wiring. +class RecordingFlashCubit extends FlashCubit { + final List messages = []; + + @override + Future displayFlash(String message) async { + messages.add(message); + } +} + +/// Builds a real [EasterEggCubit] backed by mock SharedPreferences. +/// +/// With no [preferences] given, fresh mock storage is created; pass one +/// explicitly to share state across cubit instances (restore tests). +Future buildEggCubit({ + SharedPreferences? preferences, + RecordingFlashCubit? flashCubit, +}) async { + final effectivePreferences = preferences ?? await mockPreferences(); + return EasterEggCubit( + EasterEggStorageImpl(effectivePreferences), + flashCubit ?? RecordingFlashCubit(), + ); +} + +Future mockPreferences() async { + SharedPreferences.setMockInitialValues({}); + return SharedPreferences.getInstance(); +} diff --git a/test/helpers/helpers.dart b/test/helpers/helpers.dart index 6a66757..e60a872 100644 --- a/test/helpers/helpers.dart +++ b/test/helpers/helpers.dart @@ -6,4 +6,5 @@ // https://opensource.org/licenses/MIT. export 'configure_injector.dart'; +export 'easter_egg_test_helpers.dart'; export 'pump_app.dart'; diff --git a/test/shared/flash/flash_cubit_test.dart b/test/shared/flash/flash_cubit_test.dart new file mode 100644 index 0000000..e6adbb9 --- /dev/null +++ b/test/shared/flash/flash_cubit_test.dart @@ -0,0 +1,18 @@ +import 'package:expense_tracker/injector.dart'; +import 'package:expense_tracker/shared/flash/presentation/blocs/cubit/flash_cubit.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + test('FlashCubit is registered as a singleton across resolutions', () async { + // Guards the wiring regression where a factory registration gave the + // app-shell listener and feature cubits (e.g. the easter egg) separate + // instances, silently dropping every displayed flash. + SharedPreferences.setMockInitialValues({}); + await configureInjector(); + + expect(getIt(), same(getIt())); + }); +} From 7da8d563a4f1cb2f8f30951232d29b5640a771fb Mon Sep 17 00:00:00 2001 From: MF-Rozi Date: Tue, 8 Sep 2026 16:01:58 +0700 Subject: [PATCH 6/9] fix(dashboard): resolve transaction cards stuck on Uncategorized at first 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. --- .../widgets/transaction_card.dart | 5 +- .../widgets/transaction_card_test.dart | 92 +++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 test/features/transaction/presentation/widgets/transaction_card_test.dart diff --git a/lib/features/transaction/presentation/widgets/transaction_card.dart b/lib/features/transaction/presentation/widgets/transaction_card.dart index 43eb3ff..7a5e14b 100644 --- a/lib/features/transaction/presentation/widgets/transaction_card.dart +++ b/lib/features/transaction/presentation/widgets/transaction_card.dart @@ -17,7 +17,10 @@ class TransactionCard extends StatelessWidget { @override Widget build(BuildContext context) { - final categories = context.read().state.allCategories; + // watch (not read): on first launch the categories stream has not + // emitted yet — the card must rebuild and resolve names once it does, + // instead of being stuck on the "Uncategorized" fallback. + final categories = context.watch().state.allCategories; final category = categories.firstWhere( (c) => c.uuid.getOrCrash() == transaction.categoryUuid.getOrCrash(), orElse: () => Category( diff --git a/test/features/transaction/presentation/widgets/transaction_card_test.dart b/test/features/transaction/presentation/widgets/transaction_card_test.dart new file mode 100644 index 0000000..cf2cc58 --- /dev/null +++ b/test/features/transaction/presentation/widgets/transaction_card_test.dart @@ -0,0 +1,92 @@ +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:expense_tracker/features/category/domain/entities/category.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/transaction/domain/entities/transaction.dart'; +import 'package:expense_tracker/features/transaction/domain/entities/transaction_type.dart'; +import 'package:expense_tracker/features/transaction/presentation/widgets/transaction_card.dart'; +import 'package:expense_tracker/shared/domain/entities/value_objects.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../../../helpers/helpers.dart'; + +class MockCategoryCubit extends MockCubit + implements CategoryCubit {} + +void main() { + late MockCategoryCubit categoryCubit; + late StreamController categoriesController; + + final uncategorized = Transaction( + uuid: UniqueId.generate(), + amount: Amount(99000), + description: StringSingleLine('Snack'), + date: DateTime(2026), + categoryUuid: UniqueId('550e8400-e29b-41d4-a716-446655440001'), + type: TransactionType.expense, + ); + + final groceries = Category( + uuid: UniqueId('550e8400-e29b-41d4-a716-446655440001'), + name: StringSingleLine('Groceries & Household'), + isSynced: false, + updatedAt: DateTime(2026), + type: CategoryType.expense, + expectedMonthlyBudget: 0, + behavioralModifier: BehavioralModifier.active, + ); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + categoryCubit = MockCategoryCubit(); + categoriesController = StreamController(); + whenListen( + categoryCubit, + categoriesController.stream, + initialState: CategoryState.initial(), + ); + }); + + tearDown(() async { + await categoriesController.close(); + }); + + testWidgets('falls back to Uncategorized before categories load', + (tester) async { + await tester.pumpApp( + BlocProvider.value( + value: categoryCubit, + child: TransactionCard(transaction: uncategorized), + ), + ); + + expect(find.textContaining('Uncategorized'), findsOneWidget); + expect(find.textContaining('Groceries'), findsNothing); + }); + + testWidgets('resolves the category once the stream emits it', + (tester) async { + await tester.pumpApp( + BlocProvider.value( + value: categoryCubit, + child: TransactionCard(transaction: uncategorized), + ), + ); + expect(find.textContaining('Uncategorized'), findsOneWidget); + + // First-launch race: the categories stream emits after the card has + // already rendered with the empty fallback. + categoriesController.add( + CategoryState.initial().copyWith(allCategories: [groceries]), + ); + await tester.pump(); + await tester.pump(); + + expect(find.textContaining('Groceries & Household'), findsOneWidget); + expect(find.textContaining('Uncategorized'), findsNothing); + }); +} From aac4dc4f045ceec4ef556e0c55d55997acc7ad13 Mon Sep 17 00:00:00 2001 From: MF-Rozi Date: Wed, 9 Sep 2026 21:15:52 +0700 Subject: [PATCH 7/9] style: format transaction card test (dart format) --- .../presentation/widgets/transaction_card_test.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/features/transaction/presentation/widgets/transaction_card_test.dart b/test/features/transaction/presentation/widgets/transaction_card_test.dart index cf2cc58..494c670 100644 --- a/test/features/transaction/presentation/widgets/transaction_card_test.dart +++ b/test/features/transaction/presentation/widgets/transaction_card_test.dart @@ -68,8 +68,7 @@ void main() { expect(find.textContaining('Groceries'), findsNothing); }); - testWidgets('resolves the category once the stream emits it', - (tester) async { + testWidgets('resolves the category once the stream emits it', (tester) async { await tester.pumpApp( BlocProvider.value( value: categoryCubit, From 13582a08192f3249470900634411f31fc9d41eb8 Mon Sep 17 00:00:00 2001 From: MF-Rozi Date: Thu, 10 Sep 2026 10:43:59 +0700 Subject: [PATCH 8/9] fix(test): make DI setup idempotent for very_good's merged test runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- test/helpers/configure_injector.dart | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/helpers/configure_injector.dart b/test/helpers/configure_injector.dart index aac2f85..6b7e748 100644 --- a/test/helpers/configure_injector.dart +++ b/test/helpers/configure_injector.dart @@ -1,6 +1,14 @@ import 'package:expense_tracker/core/utils/constants.dart'; import 'package:expense_tracker/injector.dart'; +/// Configures the app-wide GetIt instance for tests. +/// +/// `very_good test --optimization` (used by CI) merges every test file +/// into a single process, where `GetIt.init` therefore runs once per +/// suite on the shared instance. Enable reassignment so the duplicate +/// registrations replace instead of throwing, and each suite starts +/// from the canonical generated registrations. Future configureInjector() async { + getIt.allowReassignment = true; await configureDependencies(environment: Environment.test); } From b5ccc6578400a62b4c91a3580f70da47f088129b Mon Sep 17 00:00:00 2001 From: MF-Rozi Date: Thu, 10 Sep 2026 11:34:07 +0700 Subject: [PATCH 9/9] fix(test): make /counter redirect tests order-independent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lib/app/router/app_router.dart | 13 ++++++++++--- test/app/router/app_router_test.dart | 26 ++++++++++---------------- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/lib/app/router/app_router.dart b/lib/app/router/app_router.dart index 93bf1a1..d49a7ea 100644 --- a/lib/app/router/app_router.dart +++ b/lib/app/router/app_router.dart @@ -23,7 +23,11 @@ class AppRouter extends Equatable { List get props => [home]; } -GoRouter router([String? initialLocation]) => GoRouter( +GoRouter router([ + String? initialLocation, + bool Function()? isCounterUnlocked, +]) => + GoRouter( debugLogDiagnostics: kDebugMode || kProfileMode, initialLocation: initialLocation ?? '/', routes: [ @@ -77,8 +81,11 @@ GoRouter router([String? initialLocation]) => GoRouter( // Locked users never reach the secret room — even via direct // navigation or a future deep link. redirect: (context, state) { - final unlocked = getIt().state.progress.unlocked; - return unlocked ? null : '/settings'; + final isUnlocked = (isCounterUnlocked ?? + () => getIt().state.progress.unlocked)() + ? null + : '/settings'; + return isUnlocked; }, builder: (context, state) => const CounterPage(), ), diff --git a/test/app/router/app_router_test.dart b/test/app/router/app_router_test.dart index efb3dad..85b1812 100644 --- a/test/app/router/app_router_test.dart +++ b/test/app/router/app_router_test.dart @@ -1,6 +1,4 @@ import 'package:expense_tracker/app/router/app_router.dart'; -import 'package:expense_tracker/features/easter_egg/presentation/blocs/easter_egg_cubit.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'; @@ -16,15 +14,21 @@ void main() { setUp(() => GoogleFonts.config.allowRuntimeFetching = false); - Future pumpRouter(WidgetTester tester, String location) { + Future pumpRouter( + WidgetTester tester, + String location, { + required bool isCounterUnlocked, + }) { return tester.pumpWidget( - MaterialApp.router(routerConfig: router(location)), + MaterialApp.router( + routerConfig: router('/counter', () => isCounterUnlocked), + ), ); } testWidgets('redirects locked users from /counter to settings', (tester) async { - await pumpRouter(tester, '/counter'); + await pumpRouter(tester, '/counter', isCounterUnlocked: false); await tester.pumpAndSettle(); // App bar title + bottom-nav label both render "Settings"; the page @@ -34,17 +38,7 @@ void main() { }); testWidgets('unlocked users reach the counter', (tester) async { - final egg = getIt(); - for (var i = 0; i < 7; i++) { - egg.onVersionTapped(); - } - egg - ..onTransactionLogged() - ..onStatsVisited() - ..onCategoriesOpened(); - expect(egg.state.progress.unlocked, isTrue); - - await pumpRouter(tester, '/counter'); + await pumpRouter(tester, '/counter', isCounterUnlocked: true); await tester.pumpAndSettle(); expect(find.byIcon(Icons.visibility_off_outlined), findsOneWidget);