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 diff --git a/lib/app/router/app_router.dart b/lib/app/router/app_router.dart index 3b066f9..d49a7ea 100644 --- a/lib/app/router/app_router.dart +++ b/lib/app/router/app_router.dart @@ -1,8 +1,11 @@ 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/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'; @@ -10,7 +13,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'; @@ -21,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: [ @@ -44,11 +50,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 +75,19 @@ GoRouter router([String? initialLocation]) => GoRouter( name: 'categories', builder: (context, state) => const CategoryManagePage(), ), + 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 isUnlocked = (isCounterUnlocked ?? + () => getIt().state.progress.unlocked)() + ? null + : '/settings'; + return isUnlocked; + }, + 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 1ce4145..de0a261 100644 --- a/lib/features/category/presentation/pages/category_manage_page.dart +++ b/lib/features/category/presentation/pages/category_manage_page.dart @@ -1,16 +1,47 @@ 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'; 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(); + } + + /// 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). 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; + if (activeCategory.parentId != null) { + final parentId = activeCategory.parentId!.getOrCrash(); + final parentExists = all.any( + (c) => c.uuid.getOrCrash() == parentId, + ); + if (!parentExists) return false; + } + return activeCategory.getHierarchyChain(all).length < 3; + } + IconData _iconForChild(String name) { final lower = name.toLowerCase(); if (lower.contains('mortgage') || @@ -33,19 +64,6 @@ class CategoryManagePage extends StatelessWidget { 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, @@ -200,6 +218,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( @@ -247,7 +280,10 @@ class CategoryManagePage extends StatelessWidget { ), const SizedBox(height: 16), if (state.currentViewCategories.isEmpty) - _buildEmptySubEnvelopesState(activeCategory) + _buildEmptySubEnvelopesState( + activeCategory, + state.allCategories, + ) else ...state.currentViewCategories.map( (child) => Padding( @@ -267,27 +303,30 @@ class CategoryManagePage extends StatelessWidget { ), ], ), - 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, ), ); }, @@ -307,7 +346,7 @@ class CategoryManagePage extends StatelessWidget { ) : null; - final totalBudget = _sumBudgetUnder(activeCategory, state.allCategories); + final totalBudget = activeCategory.sumBudgetUnder(state.allCategories); return Container( padding: const EdgeInsets.all(20), @@ -459,7 +498,7 @@ class CategoryManagePage extends StatelessWidget { 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), @@ -552,7 +591,11 @@ class CategoryManagePage extends StatelessWidget { ); } - 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), @@ -565,7 +608,7 @@ class CategoryManagePage extends StatelessWidget { ), const SizedBox(height: 12), Text( - 'No sub-envelopes yet', + isMaxDepth ? 'Leaf envelope' : 'No sub-envelopes yet', style: GoogleFonts.manrope( fontSize: 15, fontWeight: FontWeight.w600, @@ -574,8 +617,12 @@ class CategoryManagePage extends StatelessWidget { ), 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/envelope_tree_list_view.dart b/lib/features/category/presentation/widgets/envelope_tree_list_view.dart index dd00dbc..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,22 +1,26 @@ 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'; /// 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 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, this.onPillarTap, this.onChildTap, + this.onChildEdit, this.onAddChild, super.key, }); @@ -28,8 +32,13 @@ class EnvelopeTreeListView extends StatefulWidget { final void Function(Category pillar)? onPillarTap; /// Called when the user taps a child row (sub-parent or leaf envelope). + /// 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. + final void Function(Category child)? onChildEdit; + /// Called when the user taps the add button on a pillar section. final void Function(Category pillar)? onAddChild; @@ -39,7 +48,10 @@ 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 = {}; List get _pillars => widget.allCategories.where((c) => c.isRoot).toList(); @@ -51,12 +63,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); } }); } @@ -79,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( @@ -111,7 +123,7 @@ class _EnvelopeTreeListViewState extends State { return InkWell( onTap: () { - _togglePillar(pillarId); + _toggleCollapsed(_collapsedPillars, pillarId); widget.onPillarTap?.call(pillar); }, borderRadius: BorderRadius.circular(12), @@ -263,33 +275,174 @@ 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; + 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 +450,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 +470,7 @@ class _EnvelopeTreeListViewState extends State { Text( '\$${budget.toStringAsFixed(0)}', style: GoogleFonts.manrope( - fontSize: 13, + fontSize: 12, fontWeight: FontWeight.w700, color: const Color(0xFF00113A), ), @@ -330,16 +483,6 @@ 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) { - final directChildren = _childrenOf(category); - if (directChildren.isEmpty) { - return category.expectedMonthlyBudget; - } - return directChildren.fold(0, (sum, c) => sum + _sumBudgetUnder(c)); - } - IconData _iconForPillar(String name) { final lower = name.toLowerCase(); if (lower.contains('essential') || lower.contains('need')) { 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/counter/presentation/pages/counter_page.dart b/lib/features/counter/presentation/pages/counter_page.dart index d5c86ef..84218d0 100644 --- a/lib/features/counter/presentation/pages/counter_page.dart +++ b/lib/features/counter/presentation/pages/counter_page.dart @@ -1,25 +1,27 @@ -// 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 { - const CounterPage({super.key}); +/// 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, 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(), + ), ); } } @@ -29,25 +31,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: () { + context.read().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 +95,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..d26c164 --- /dev/null +++ b/lib/features/easter_egg/data/datasources/easter_egg_storage.dart @@ -0,0 +1,52 @@ +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'; + +abstract class EasterEggStorage { + EasterEggProgress read(); + 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 _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: 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) { + 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/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..96c87e6 --- /dev/null +++ b/lib/features/easter_egg/domain/entities/easter_egg_progress.dart @@ -0,0 +1,60 @@ +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; + static const int totalSteps = 3; + + 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..569c4c7 --- /dev/null +++ b/lib/features/easter_egg/presentation/blocs/easter_egg_cubit.dart @@ -0,0 +1,109 @@ +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() { + 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() { + 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 == EasterEggProgress.totalSteps) { + 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..283f1b1 100644 --- a/lib/features/settings/presentation/pages/settings_page.dart +++ b/lib/features/settings/presentation/pages/settings_page.dart @@ -1,22 +1,93 @@ +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}/${EasterEggProgress.totalSteps} 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..f6c2828 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,11 @@ class _TransactionEntryPageState extends State { body: BlocConsumer( listener: (context, state) { if (state.status == TransactionFormStatus.success) { + // 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/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/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/app/router/app_router_test.dart b/test/app/router/app_router_test.dart new file mode 100644 index 0000000..85b1812 --- /dev/null +++ b/test/app/router/app_router_test.dart @@ -0,0 +1,47 @@ +import 'package:expense_tracker/app/router/app_router.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, { + required bool isCounterUnlocked, + }) { + return tester.pumpWidget( + MaterialApp.router( + routerConfig: router('/counter', () => isCounterUnlocked), + ), + ); + } + + testWidgets('redirects locked users from /counter to settings', + (tester) async { + await pumpRouter(tester, '/counter', isCounterUnlocked: false); + 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 { + await pumpRouter(tester, '/counter', isCounterUnlocked: true); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.visibility_off_outlined), findsOneWidget); + expect(find.text('Counter'), findsOneWidget); + }); +} diff --git a/test/features/category/presentation/widgets/category_widgets_test.dart b/test/features/category/presentation/widgets/category_widgets_test.dart index 285673d..5951af2 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,24 @@ final _dining = _makeCategory( parentId: _p2Id, budget: 300, ); +final _food = _makeCategory( + uuid: _subId, + name: 'Food', + parentId: _p1Id, + budget: 50, +); +final _coffee = _makeCategory( + uuid: _e1Id, + name: 'Coffee', + parentId: _subId, + budget: 120, +); +final _groceries = _makeCategory( + uuid: _e2Id, + name: 'Groceries', + parentId: _subId, + budget: 180, +); // ───────────────────────────────────────────────────────────────────────────── @@ -337,5 +358,203 @@ 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); + // 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', + (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); + // Without onChildEdit the pencil is not rendered at all. + expect(find.byIcon(Icons.edit_outlined), findsNothing); + + 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); + }); + }); }); } diff --git a/test/features/counter/presentation/pages/counter_page_test.dart b/test/features/counter/presentation/pages/counter_page_test.dart index 1f2e568..acc413b 100644 --- a/test/features/counter/presentation/pages/counter_page_test.dart +++ b/test/features/counter/presentation/pages/counter_page_test.dart @@ -7,6 +7,9 @@ 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:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -19,7 +22,7 @@ class MockCounterCubit extends MockCubit implements CounterCubit {} 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 +74,34 @@ 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 { + final preferences = await mockPreferences(); + 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/presentation/blocs/easter_egg_cubit_test.dart b/test/features/easter_egg/presentation/blocs/easter_egg_cubit_test.dart new file mode 100644 index 0000000..46892b2 --- /dev/null +++ b/test/features/easter_egg/presentation/blocs/easter_egg_cubit_test.dart @@ -0,0 +1,207 @@ +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:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../../../helpers/helpers.dart'; + +void main() { + late SharedPreferences preferences; + late RecordingFlashCubit flashCubit; + + setUp(() async { + preferences = await mockPreferences(); + 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); + + // 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', () { + 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); + // 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); + }); + + 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); + 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..cc7c6ae --- /dev/null +++ b/test/features/settings/presentation/pages/settings_page_test.dart @@ -0,0 +1,80 @@ +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:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../../../helpers/helpers.dart'; + +void main() { + late SharedPreferences preferences; + late RecordingFlashCubit flashCubit; + + setUp(() async { + preferences = await mockPreferences(); + 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); + }); +} 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..494c670 --- /dev/null +++ b/test/features/transaction/presentation/widgets/transaction_card_test.dart @@ -0,0 +1,91 @@ +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); + }); +} 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); } 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())); + }); +}