From e70b933c2a2cb8c7a45025bdd2c10b963f3b2499 Mon Sep 17 00:00:00 2001 From: MF-Rozi Date: Sat, 22 Aug 2026 14:16:10 +0700 Subject: [PATCH 1/7] feat: replace legacy hardcoded category UUIDs with dynamically generated IDs and implement migration logic --- .tool-versions | 1 + .../category_local_data_source.dart | 49 ++++++++++++++++--- .../widgets/envelope_creation_form.dart | 43 ++++++++-------- .../widgets/envelope_tree_list_view.dart | 15 ++++-- .../widgets/transaction_card.dart | 2 +- 5 files changed, 80 insertions(+), 30 deletions(-) create mode 100644 .tool-versions diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..456c488 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +java temurin-21.0.11+10.0.LTS diff --git a/lib/features/category/data/datasources/category_local_data_source.dart b/lib/features/category/data/datasources/category_local_data_source.dart index 13851be..bb004b5 100644 --- a/lib/features/category/data/datasources/category_local_data_source.dart +++ b/lib/features/category/data/datasources/category_local_data_source.dart @@ -2,6 +2,7 @@ import 'package:expense_tracker/features/category/data/models/category_model.dar import 'package:expense_tracker/features/category/domain/entities/category.dart'; import 'package:injectable/injectable.dart'; import 'package:isar_community/isar.dart'; +import 'package:uuid/uuid.dart'; abstract class CategoryLocalDataSource { Stream> watchCategories(); @@ -26,7 +27,7 @@ class IsarCategoryLocalDataSource implements CategoryLocalDataSource { final defaultPillars = [ // Expense Pillars CategoryModel() - ..uuid = 'essential' + ..uuid = const Uuid().v4() ..name = 'Essential' ..isSynced = false ..updatedAt = DateTime.now() @@ -35,7 +36,7 @@ class IsarCategoryLocalDataSource implements CategoryLocalDataSource { ..expectedMonthlyBudget = 0.0 ..behavioralModifier = BehavioralModifier.active, CategoryModel() - ..uuid = 'lifestyle' + ..uuid = const Uuid().v4() ..name = 'Lifestyle' ..isSynced = false ..updatedAt = DateTime.now() @@ -44,7 +45,7 @@ class IsarCategoryLocalDataSource implements CategoryLocalDataSource { ..expectedMonthlyBudget = 0.0 ..behavioralModifier = BehavioralModifier.active, CategoryModel() - ..uuid = 'growth' + ..uuid = const Uuid().v4() ..name = 'Financial Growth' ..isSynced = false ..updatedAt = DateTime.now() @@ -54,7 +55,7 @@ class IsarCategoryLocalDataSource implements CategoryLocalDataSource { ..behavioralModifier = BehavioralModifier.active, // Income Pillars CategoryModel() - ..uuid = 'primary_revenue' + ..uuid = const Uuid().v4() ..name = 'Primary Revenue' ..isSynced = false ..updatedAt = DateTime.now() @@ -63,7 +64,7 @@ class IsarCategoryLocalDataSource implements CategoryLocalDataSource { ..expectedMonthlyBudget = 0.0 ..behavioralModifier = BehavioralModifier.active, CategoryModel() - ..uuid = 'secondary_income' + ..uuid = const Uuid().v4() ..name = 'Secondary Income' ..isSynced = false ..updatedAt = DateTime.now() @@ -72,7 +73,7 @@ class IsarCategoryLocalDataSource implements CategoryLocalDataSource { ..expectedMonthlyBudget = 0.0 ..behavioralModifier = BehavioralModifier.active, CategoryModel() - ..uuid = 'portfolio_growth' + ..uuid = const Uuid().v4() ..name = 'Portfolio Growth' ..isSynced = false ..updatedAt = DateTime.now() @@ -83,6 +84,42 @@ class IsarCategoryLocalDataSource implements CategoryLocalDataSource { ]; await _isar.categoryModels.putAll(defaultPillars); }); + } else { + // Migration check for legacy non-UUID seeded pillars + final legacyPillars = await _isar.categoryModels + .filter() + .uuidEqualTo('essential') + .or() + .uuidEqualTo('lifestyle') + .or() + .uuidEqualTo('growth') + .or() + .uuidEqualTo('primary_revenue') + .or() + .uuidEqualTo('secondary_income') + .or() + .uuidEqualTo('portfolio_growth') + .findAll(); + + if (legacyPillars.isNotEmpty) { + await _isar.writeTxn(() async { + for (final cat in legacyPillars) { + final oldUuid = cat.uuid; + final newUuid = const Uuid().v4(); + cat.uuid = newUuid; + await _isar.categoryModels.put(cat); + + final children = await _isar.categoryModels + .filter() + .parentIdEqualTo(oldUuid) + .findAll(); + for (final child in children) { + child.parentId = newUuid; + await _isar.categoryModels.put(child); + } + } + }); + } } } catch (_) { // Ignore in tests or if Isar collection is not available diff --git a/lib/features/category/presentation/widgets/envelope_creation_form.dart b/lib/features/category/presentation/widgets/envelope_creation_form.dart index b85c6ef..10c795f 100644 --- a/lib/features/category/presentation/widgets/envelope_creation_form.dart +++ b/lib/features/category/presentation/widgets/envelope_creation_form.dart @@ -477,29 +477,32 @@ class _PillarCard extends StatelessWidget { : null, ), child: Stack( + alignment: Alignment.center, children: [ - Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - _iconForPillar(name), - size: 22, - color: isSelected ? Colors.white : _onSurfaceVariant, - ), - const SizedBox(height: 6), - Text( - name.toUpperCase(), - textAlign: TextAlign.center, - style: GoogleFonts.inter( - fontSize: 9, - fontWeight: FontWeight.w700, - letterSpacing: 0.8, + Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + _iconForPillar(name), + size: 22, color: isSelected ? Colors.white : _onSurfaceVariant, ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ], + const SizedBox(height: 6), + Text( + name.toUpperCase(), + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 9, + fontWeight: FontWeight.w700, + letterSpacing: 0.8, + color: isSelected ? Colors.white : _onSurfaceVariant, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), ), if (isSelected) Positioned( 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 2a3a60d..e2a9fd8 100644 --- a/lib/features/category/presentation/widgets/envelope_tree_list_view.dart +++ b/lib/features/category/presentation/widgets/envelope_tree_list_view.dart @@ -197,13 +197,22 @@ class _EnvelopeTreeListViewState extends State { Color chipBg; Color chipText; if (name.contains('essential') || name.contains('need')) { - chipBg = const Color(0xFFA0F399); // secondary-container + chipBg = const Color(0xFFA0F399); // secondary-container (green) chipText = const Color(0xFF217128); // on-secondary-container } else if (name.contains('lifestyle') || name.contains('life')) { - chipBg = const Color(0xFF002366); // primary-container + chipBg = const Color(0xFF002366); // primary-container (navy blue) chipText = const Color(0xFF758DD5); // on-primary-container + } else if (name.contains('revenue') || name.contains('primary')) { + chipBg = const Color(0xFFD6E3FF); // light blue + chipText = const Color(0xFF003884); // dark blue + } else if (name.contains('secondary') || name.contains('side')) { + chipBg = const Color(0xFFCCE8E0); // soft teal + chipText = const Color(0xFF004F44); // dark teal + } else if (name.contains('portfolio')) { + chipBg = const Color(0xFFE8DEF8); // soft purple + chipText = const Color(0xFF4A4458); // dark purple } else { - chipBg = const Color(0xFF5A0006); // tertiary-container + chipBg = const Color(0xFF5A0006); // tertiary-container (dark red for Financial Growth) chipText = const Color(0xFFFF524C); // on-tertiary-container } diff --git a/lib/features/transaction/presentation/widgets/transaction_card.dart b/lib/features/transaction/presentation/widgets/transaction_card.dart index 88fef8f..43eb3ff 100644 --- a/lib/features/transaction/presentation/widgets/transaction_card.dart +++ b/lib/features/transaction/presentation/widgets/transaction_card.dart @@ -21,7 +21,7 @@ class TransactionCard extends StatelessWidget { final category = categories.firstWhere( (c) => c.uuid.getOrCrash() == transaction.categoryUuid.getOrCrash(), orElse: () => Category( - uuid: UniqueId(''), + uuid: UniqueId.generate(), name: StringSingleLine('Uncategorized'), isSynced: false, updatedAt: DateTime.now(), From 9304da050fb42b20de7aa25fdf1de2cf3dd2521a Mon Sep 17 00:00:00 2001 From: MF-Rozi Date: Sun, 23 Aug 2026 16:12:33 +0700 Subject: [PATCH 2/7] feat: add category drill-down navigation, sub-envelope display, and deletion confirmation to CategoryManagePage --- .../pages/category_manage_page.dart | 518 ++++++++++++++++-- 1 file changed, 476 insertions(+), 42 deletions(-) diff --git a/lib/features/category/presentation/pages/category_manage_page.dart b/lib/features/category/presentation/pages/category_manage_page.dart index 6de382b..151a485 100644 --- a/lib/features/category/presentation/pages/category_manage_page.dart +++ b/lib/features/category/presentation/pages/category_manage_page.dart @@ -11,6 +11,91 @@ import 'package:google_fonts/google_fonts.dart'; class CategoryManagePage extends StatelessWidget { const CategoryManagePage({super.key}); + IconData _iconForChild(String name) { + final lower = name.toLowerCase(); + if (lower.contains('mortgage') || + lower.contains('rent') || + lower.contains('home')) { + return Icons.home_outlined; + } else if (lower.contains('util') || lower.contains('electric')) { + return Icons.bolt_outlined; + } else if (lower.contains('din') || + lower.contains('food') || + lower.contains('restaurant')) { + return Icons.restaurant_outlined; + } else if (lower.contains('travel') || lower.contains('flight')) { + return Icons.flight_outlined; + } else if (lower.contains('hobbi') || lower.contains('game')) { + return Icons.sports_esports_outlined; + } else if (lower.contains('market') || lower.contains('invest')) { + return Icons.bar_chart_outlined; + } + 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.0, + (sum, c) => sum + _sumBudgetUnder(c, allCategories), + ); + } + + Future _confirmDelete( + BuildContext context, + BuildContext blocContext, + Category category, { + bool popNavAfter = false, + }) async { + final confirmed = await showDialog( + context: context, + builder: (dialogCtx) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: Text( + 'Delete Envelope', + style: GoogleFonts.manrope(fontWeight: FontWeight.bold), + ), + content: Text( + 'Are you sure you want to delete "${category.name.getOrCrash()}" and all its nested sub-envelopes?', + style: GoogleFonts.inter(fontSize: 14), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogCtx).pop(false), + child: Text( + 'Cancel', + style: GoogleFonts.inter(color: const Color(0xFF757682)), + ), + ), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFFBA1A1A), + ), + onPressed: () => Navigator.of(dialogCtx).pop(true), + child: Text( + 'Delete', + style: GoogleFonts.inter(fontWeight: FontWeight.bold), + ), + ), + ], + ), + ); + + if (confirmed == true) { + blocContext.read().deleteCategory( + category.uuid.getOrCrash(), + ); + if (popNavAfter) { + blocContext.read().goBack(); + } + } + } + @override Widget build(BuildContext context) { return BlocBuilder( @@ -39,6 +124,17 @@ class CategoryManagePage extends StatelessWidget { backgroundColor: const Color(0xFFF8FAFC).withValues(alpha: 0.8), elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + color: const Color(0xFF00113A), + onPressed: () { + if (state.navigationStack.isNotEmpty) { + blocContext.read().goBack(); + } else { + Navigator.of(context).pop(); + } + }, + ), flexibleSpace: FlexibleSpaceBar( titlePadding: const EdgeInsets.symmetric( horizontal: 24, @@ -70,54 +166,99 @@ class CategoryManagePage extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 24), sliver: SliverList( delegate: SliverChildListDelegate([ - const SizedBox(height: 8), - Text( - 'Architecture'.toUpperCase(), - style: GoogleFonts.inter( - fontSize: 12, - fontWeight: FontWeight.w600, - letterSpacing: 2, - color: const Color(0xFF757682), // outline + if (activeCategory == null) ...[ + const SizedBox(height: 8), + Text( + 'Architecture'.toUpperCase(), + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w600, + letterSpacing: 2, + color: const Color(0xFF757682), // outline + ), ), - ), - const SizedBox(height: 24), + const SizedBox(height: 24), - // Portfolio Distribution Card Header - PortfolioDistributionCard( - pillars: state.activePillars, - pillarBudgets: state.pillarBudgets, - totalBudget: state.totalBudget, - ), + // Portfolio Distribution Card Header + PortfolioDistributionCard( + pillars: state.activePillars, + pillarBudgets: state.pillarBudgets, + totalBudget: state.totalBudget, + ), + + const SizedBox(height: 32), - const SizedBox(height: 32), - - // Envelope Tree List View - EnvelopeTreeListView( - allCategories: state.allCategories, - onPillarTap: (pillar) { - // Standard expand/collapse handled inside EnvelopeTreeListView, - // or selectParent if navigating deeper - }, - onChildTap: (child) { - if (!child.isRoot) { - context - .read() - .selectParent(child.uuid.getOrCrash()); - } - }, - onAddChild: (pillar) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => BlocProvider.value( - value: blocContext.read(), - child: CategoryFormPage( - activeParentUuid: pillar.uuid.getOrCrash(), + // Envelope Tree List View + EnvelopeTreeListView( + allCategories: state.allCategories, + onChildTap: (child) { + if (!child.isRoot) { + blocContext + .read() + .selectParent(child.uuid.getOrCrash()); + } + }, + onAddChild: (pillar) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + BlocProvider.value( + value: blocContext.read(), + child: CategoryFormPage( + activeParentUuid: pillar.uuid.getOrCrash(), + ), ), ), + ); + }, + ), + ] else ...[ + const SizedBox(height: 8), + _buildActiveParentCard( + context, + blocContext, + activeCategory, + state, + ), + const SizedBox(height: 32), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'SUB-ENVELOPES', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w600, + letterSpacing: 2, + color: const Color(0xFF757682), + ), ), - ); - }, - ), + Text( + '${state.currentViewCategories.length} items', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w500, + color: const Color(0xFF757682), + ), + ), + ], + ), + const SizedBox(height: 16), + if (state.currentViewCategories.isEmpty) + _buildEmptySubEnvelopesState(activeCategory) + else + ...state.currentViewCategories.map( + (child) => Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _buildSubEnvelopeItem( + context, + blocContext, + child, + state, + ), + ), + ), + ], const SizedBox(height: 100), // Space for FAB ]), ), @@ -150,4 +291,297 @@ class CategoryManagePage extends StatelessWidget { }, ); } + + Widget _buildActiveParentCard( + BuildContext context, + BuildContext blocContext, + Category activeCategory, + CategoryState state, + ) { + final parentPillar = activeCategory.parentId != null + ? state.allCategories.cast().firstWhere( + (c) => c?.uuid == activeCategory.parentId, + orElse: () => null, + ) + : null; + + final totalBudget = _sumBudgetUnder(activeCategory, state.allCategories); + + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: const Color(0xFF00113A).withValues(alpha: 0.06), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: const Color(0xFF00113A).withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + _iconForChild(activeCategory.name.getOrCrash()), + size: 22, + color: const Color(0xFF00113A), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (parentPillar != null) + Text( + parentPillar.name.getOrCrash().toUpperCase(), + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + color: const Color(0xFF757682), + ), + ), + Text( + activeCategory.name.getOrCrash(), + style: GoogleFonts.manrope( + fontSize: 18, + fontWeight: FontWeight.w800, + color: const Color(0xFF191C1D), + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.edit_outlined, size: 20), + color: const Color(0xFF757682), + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => BlocProvider.value( + value: blocContext.read(), + child: CategoryFormPage( + categoryToEdit: activeCategory, + activeParentUuid: + activeCategory.parentId?.getOrCrash(), + ), + ), + ), + ); + }, + ), + IconButton( + icon: const Icon(Icons.delete_outline, size: 20), + color: const Color(0xFFBA1A1A), + onPressed: () { + _confirmDelete( + context, + blocContext, + activeCategory, + popNavAfter: true, + ); + }, + ), + ], + ), + const SizedBox(height: 16), + const Divider(height: 1, color: Color(0xFFEDEEEF)), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: const Color(0xFFF3F4F5), + borderRadius: BorderRadius.circular(99), + ), + child: Text( + activeCategory.behavioralModifier.name.toUpperCase(), + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w600, + letterSpacing: 1, + color: const Color(0xFF444650), + ), + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '\$${totalBudget.toStringAsFixed(0)}', + style: GoogleFonts.manrope( + fontSize: 16, + fontWeight: FontWeight.w700, + color: const Color(0xFF00113A), + ), + ), + Text( + 'TOTAL ALLOCATED', + style: GoogleFonts.inter( + fontSize: 9, + fontWeight: FontWeight.w500, + letterSpacing: 1.5, + color: const Color(0xFF757682), + ), + ), + ], + ), + ], + ), + ], + ), + ); + } + + Widget _buildSubEnvelopeItem( + BuildContext context, + BuildContext blocContext, + Category child, + CategoryState state, + ) { + final name = child.name.getOrCrash(); + final budget = _sumBudgetUnder(child, state.allCategories); + + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: const Color(0xFF00113A).withValues(alpha: 0.03), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: const Color(0xFFF3F4F5), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + _iconForChild(name), + size: 18, + color: const Color(0xFF757682), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: GoogleFonts.manrope( + fontSize: 14, + 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: 14, + fontWeight: FontWeight.w700, + color: const Color(0xFF00113A), + ), + ), + const SizedBox(width: 4), + IconButton( + icon: const Icon(Icons.edit_outlined, size: 18), + color: const Color(0xFF757682), + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => BlocProvider.value( + value: blocContext.read(), + child: CategoryFormPage( + categoryToEdit: child, + activeParentUuid: child.parentId?.getOrCrash(), + ), + ), + ), + ); + }, + ), + IconButton( + icon: const Icon(Icons.delete_outline, size: 18), + color: const Color(0xFFBA1A1A), + onPressed: () { + _confirmDelete(context, blocContext, child); + }, + ), + ], + ), + ); + } + + Widget _buildEmptySubEnvelopesState(Category activeCategory) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 40), + child: Column( + children: [ + const Icon( + Icons.folder_open_outlined, + size: 48, + color: Color(0xFF757682), + ), + const SizedBox(height: 12), + Text( + 'No sub-envelopes yet', + style: GoogleFonts.manrope( + fontSize: 15, + fontWeight: FontWeight.w600, + color: const Color(0xFF191C1D), + ), + ), + const SizedBox(height: 4), + Text( + 'Nested envelopes inside "${activeCategory.name.getOrCrash()}" will appear here.', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 13, + color: const Color(0xFF757682), + ), + ), + ], + ), + ), + ); + } } From 16e39f9aace330709da59e4d2958a149fe283340 Mon Sep 17 00:00:00 2001 From: MF-Rozi Date: Mon, 24 Aug 2026 00:01:00 +0700 Subject: [PATCH 3/7] feat: expand default category seeding to include multi-level hierarchical sub-categories --- .../category_local_data_source.dart | 277 ++++++++++++++---- 1 file changed, 216 insertions(+), 61 deletions(-) diff --git a/lib/features/category/data/datasources/category_local_data_source.dart b/lib/features/category/data/datasources/category_local_data_source.dart index bb004b5..8bf7505 100644 --- a/lib/features/category/data/datasources/category_local_data_source.dart +++ b/lib/features/category/data/datasources/category_local_data_source.dart @@ -19,70 +19,225 @@ class IsarCategoryLocalDataSource implements CategoryLocalDataSource { final Isar _isar; + CategoryModel _createCategory({ + required String name, + required CategoryType type, + String? parentId, + BehavioralModifier modifier = BehavioralModifier.active, + double budget = 0.0, + }) { + return CategoryModel() + ..uuid = const Uuid().v4() + ..name = name + ..isSynced = false + ..updatedAt = DateTime.now() + ..parentId = parentId + ..type = type + ..expectedMonthlyBudget = budget + ..behavioralModifier = modifier; + } + Future _seedPillarsIfNeeded() async { try { - final count = await _isar.categoryModels.count(); - if (count == 0) { + final totalCount = await _isar.categoryModels.count(); + final nonRootCount = + await _isar.categoryModels.filter().parentIdIsNotNull().count(); + + // Definition of the default hierarchy + final defaultHierarchy = <({ + String pillarName, + CategoryType type, + Map> subParents, + })>[ + // ─── EXPENSE PILLARS ─── + ( + pillarName: 'Essential', + type: CategoryType.expense, + subParents: { + 'Groceries & Household': [ + (name: 'Groceries', modifier: BehavioralModifier.active), + (name: 'Household Supplies', modifier: BehavioralModifier.active), + (name: 'Electricity & Water', modifier: BehavioralModifier.recurring), + (name: 'Internet & Mobile', modifier: BehavioralModifier.recurring), + ], + 'Transportation': [ + (name: 'Fuel & Gas', modifier: BehavioralModifier.active), + (name: 'Public Transit & Tolls', modifier: BehavioralModifier.active), + (name: 'Vehicle Maintenance', modifier: BehavioralModifier.active), + ], + 'Healthcare': [ + (name: 'Medical & Pharmacy', modifier: BehavioralModifier.active), + (name: 'Health Insurance', modifier: BehavioralModifier.recurring), + ], + 'Education': [ + (name: 'Tuition & School Fees', modifier: BehavioralModifier.recurring), + (name: 'Books & Courses', modifier: BehavioralModifier.active), + ], + }, + ), + ( + pillarName: 'Lifestyle', + type: CategoryType.expense, + subParents: { + 'Shopping': [ + (name: 'Clothing & Apparel', modifier: BehavioralModifier.active), + (name: 'Online Shopping', modifier: BehavioralModifier.active), + (name: 'Gadgets & Electronics', modifier: BehavioralModifier.active), + ], + 'Dining & Leisure': [ + (name: 'Restaurants & Dining Out', modifier: BehavioralModifier.active), + (name: 'Coffee & Snacks', modifier: BehavioralModifier.active), + ], + 'Entertainment': [ + (name: 'Streaming & Subscriptions', modifier: BehavioralModifier.recurring), + (name: 'Hobbies & Gaming', modifier: BehavioralModifier.active), + (name: 'Travel & Vacations', modifier: BehavioralModifier.active), + ], + 'Donations & Charity': [ + (name: 'Charity & Tithe', modifier: BehavioralModifier.recurring), + (name: 'Donations & Contributions', modifier: BehavioralModifier.active), + ], + 'Work & Career': [ + (name: 'Work & Office Supplies', modifier: BehavioralModifier.active), + ], + }, + ), + ( + pillarName: 'Financial Growth', + type: CategoryType.expense, + subParents: { + 'Investments': [ + (name: 'Stocks', modifier: BehavioralModifier.active), + (name: 'Mutual Funds', modifier: BehavioralModifier.active), + (name: 'Gold & Precious Metals', modifier: BehavioralModifier.active), + (name: 'Crypto & Other Assets', modifier: BehavioralModifier.active), + ], + 'Savings': [ + (name: 'Emergency Fund', modifier: BehavioralModifier.passive), + (name: 'High-Yield Savings', modifier: BehavioralModifier.passive), + ], + }, + ), + // ─── INCOME PILLARS ─── + ( + pillarName: 'Primary Revenue', + type: CategoryType.income, + subParents: { + 'Salary': [ + (name: 'Base Salary', modifier: BehavioralModifier.recurring), + (name: 'Allowances & Benefits', modifier: BehavioralModifier.recurring), + ], + 'Business': [ + (name: 'Business Revenue & Sales', modifier: BehavioralModifier.active), + ], + }, + ), + ( + pillarName: 'Secondary Income', + type: CategoryType.income, + subParents: { + 'Bonus & Incentives': [ + (name: 'Performance Bonus', modifier: BehavioralModifier.passive), + (name: 'Holiday Bonus', modifier: BehavioralModifier.passive), + ], + 'Side Hustle & Sales': [ + (name: 'Freelance & Consulting', modifier: BehavioralModifier.active), + (name: 'Item Resale', modifier: BehavioralModifier.active), + ], + 'Gifts & Grants': [ + (name: 'Gifts & Financial Support', modifier: BehavioralModifier.passive), + ], + }, + ), + ( + pillarName: 'Portfolio Growth', + type: CategoryType.income, + subParents: { + 'Investment Returns': [ + (name: 'Stock Dividends & Gains', modifier: BehavioralModifier.passive), + (name: 'Deposit Interest & Yields', modifier: BehavioralModifier.passive), + (name: 'Rental & Property Income', modifier: BehavioralModifier.passive), + ], + }, + ), + ]; + + if (totalCount == 0) { await _isar.writeTxn(() async { - final defaultPillars = [ - // Expense Pillars - CategoryModel() - ..uuid = const Uuid().v4() - ..name = 'Essential' - ..isSynced = false - ..updatedAt = DateTime.now() - ..parentId = null - ..type = CategoryType.expense - ..expectedMonthlyBudget = 0.0 - ..behavioralModifier = BehavioralModifier.active, - CategoryModel() - ..uuid = const Uuid().v4() - ..name = 'Lifestyle' - ..isSynced = false - ..updatedAt = DateTime.now() - ..parentId = null - ..type = CategoryType.expense - ..expectedMonthlyBudget = 0.0 - ..behavioralModifier = BehavioralModifier.active, - CategoryModel() - ..uuid = const Uuid().v4() - ..name = 'Financial Growth' - ..isSynced = false - ..updatedAt = DateTime.now() - ..parentId = null - ..type = CategoryType.expense - ..expectedMonthlyBudget = 0.0 - ..behavioralModifier = BehavioralModifier.active, - // Income Pillars - CategoryModel() - ..uuid = const Uuid().v4() - ..name = 'Primary Revenue' - ..isSynced = false - ..updatedAt = DateTime.now() - ..parentId = null - ..type = CategoryType.income - ..expectedMonthlyBudget = 0.0 - ..behavioralModifier = BehavioralModifier.active, - CategoryModel() - ..uuid = const Uuid().v4() - ..name = 'Secondary Income' - ..isSynced = false - ..updatedAt = DateTime.now() - ..parentId = null - ..type = CategoryType.income - ..expectedMonthlyBudget = 0.0 - ..behavioralModifier = BehavioralModifier.active, - CategoryModel() - ..uuid = const Uuid().v4() - ..name = 'Portfolio Growth' - ..isSynced = false - ..updatedAt = DateTime.now() - ..parentId = null - ..type = CategoryType.income - ..expectedMonthlyBudget = 0.0 - ..behavioralModifier = BehavioralModifier.active, - ]; - await _isar.categoryModels.putAll(defaultPillars); + final allModels = []; + + for (final group in defaultHierarchy) { + final pillar = _createCategory( + name: group.pillarName, + type: group.type, + parentId: null, + ); + allModels.add(pillar); + + for (final entry in group.subParents.entries) { + final subParent = _createCategory( + name: entry.key, + type: group.type, + parentId: pillar.uuid, + ); + allModels.add(subParent); + + for (final child in entry.value) { + final childModel = _createCategory( + name: child.name, + type: group.type, + parentId: subParent.uuid, + modifier: child.modifier, + ); + allModels.add(childModel); + } + } + } + + await _isar.categoryModels.putAll(allModels); + }); + } else if (nonRootCount == 0) { + // If only pillars were seeded previously, seed default sub-categories under existing pillars + final existingPillars = + await _isar.categoryModels.filter().parentIdIsNull().findAll(); + + await _isar.writeTxn(() async { + final newModels = []; + + for (final group in defaultHierarchy) { + final pillar = existingPillars.firstWhere( + (p) => p.name.toLowerCase() == group.pillarName.toLowerCase(), + orElse: () => _createCategory( + name: group.pillarName, + type: group.type, + ), + ); + + if (pillar.id == Isar.autoIncrement) { + newModels.add(pillar); + } + + for (final entry in group.subParents.entries) { + final subParent = _createCategory( + name: entry.key, + type: group.type, + parentId: pillar.uuid, + ); + newModels.add(subParent); + + for (final child in entry.value) { + final childModel = _createCategory( + name: child.name, + type: group.type, + parentId: subParent.uuid, + modifier: child.modifier, + ); + newModels.add(childModel); + } + } + } + + await _isar.categoryModels.putAll(newModels); }); } else { // Migration check for legacy non-UUID seeded pillars From 515367f1965557c30c4a81944755ef7ae53ecbc8 Mon Sep 17 00:00:00 2001 From: MF-Rozi Date: Tue, 25 Aug 2026 15:08:10 +0700 Subject: [PATCH 4/7] feat: replace category picker with HierarchicalEnvelopePickerSheet and auto-clear category on type mismatch --- .../category/domain/entities/category.dart | 43 + .../hierarchical_envelope_picker_sheet.dart | 933 ++++++++++++++++++ .../presentation/blocs/transaction_cubit.dart | 14 +- .../presentation/blocs/transaction_state.dart | 5 +- .../pages/transaction_entry_page.dart | 312 +++--- .../domain/entities/category_test.dart | 48 + ...erarchical_envelope_picker_sheet_test.dart | 251 +++++ .../blocs/transaction_cubit_test.dart | 16 + 8 files changed, 1496 insertions(+), 126 deletions(-) create mode 100644 lib/features/category/presentation/widgets/hierarchical_envelope_picker_sheet.dart create mode 100644 test/features/category/presentation/widgets/hierarchical_envelope_picker_sheet_test.dart diff --git a/lib/features/category/domain/entities/category.dart b/lib/features/category/domain/entities/category.dart index af6c236..f975138 100644 --- a/lib/features/category/domain/entities/category.dart +++ b/lib/features/category/domain/entities/category.dart @@ -61,6 +61,49 @@ class Category extends Equatable { return false; // Level 4 or deeper is invalid } + /// Returns the root ancestor (Pillar) of this category, or itself if it is a root. + Category getRootPillar(List allCategories) { + if (parentId == null) return this; + final visited = {uuid}; + Category current = this; + while (current.parentId != null) { + if (visited.contains(current.parentId)) break; + visited.add(current.parentId!); + final parent = allCategories.cast().firstWhere( + (c) => c?.uuid == current.parentId, + orElse: () => null, + ); + if (parent == null) break; + current = parent; + } + return current; + } + + /// Returns the ordered list of categories from root Pillar down to this category. + List getHierarchyChain(List allCategories) { + final chain = [this]; + final visited = {uuid}; + Category current = this; + while (current.parentId != null) { + if (visited.contains(current.parentId)) break; + visited.add(current.parentId!); + final parent = allCategories.cast().firstWhere( + (c) => c?.uuid == current.parentId, + orElse: () => null, + ); + if (parent == null) break; + chain.insert(0, parent); + current = parent; + } + return chain; + } + + /// Returns a formatted breadcrumb string (e.g. "Essential › Housing › Rent"). + String getBreadcrumbPath(List allCategories) { + final chain = getHierarchyChain(allCategories); + return chain.map((c) => c.name.getOrCrash()).join(' › '); + } + @override List get props => [ uuid, diff --git a/lib/features/category/presentation/widgets/hierarchical_envelope_picker_sheet.dart b/lib/features/category/presentation/widgets/hierarchical_envelope_picker_sheet.dart new file mode 100644 index 0000000..ddb2764 --- /dev/null +++ b/lib/features/category/presentation/widgets/hierarchical_envelope_picker_sheet.dart @@ -0,0 +1,933 @@ +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/pages/category_form_page.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:google_fonts/google_fonts.dart'; + +/// A modern, hierarchical envelope picker bottom sheet for transaction entry. +/// +/// Features: +/// - Filters categories by [targetType] (Expense vs. Income) +/// - Interactive expandable/collapsible tree by Pillar > Sub-Parent > Envelope +/// - Instant search across envelopes, sub-parents, and pillars with breadcrumb results +/// - Highlights the [selectedCategory] with a clear visual active state +/// - Quick "+ Add Envelope" action to create envelopes directly from the modal +class HierarchicalEnvelopePickerSheet extends StatefulWidget { + const HierarchicalEnvelopePickerSheet({ + required this.targetType, + required this.onCategorySelected, + this.selectedCategory, + super.key, + }); + + final CategoryType targetType; + final Category? selectedCategory; + final ValueChanged onCategorySelected; + + /// Helper to show this picker in a modal bottom sheet. + static Future show({ + required BuildContext context, + required CategoryType targetType, + required CategoryCubit categoryCubit, + Category? selectedCategory, + }) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (sheetContext) => BlocProvider.value( + value: categoryCubit, + child: HierarchicalEnvelopePickerSheet( + targetType: targetType, + selectedCategory: selectedCategory, + onCategorySelected: (category) { + Navigator.of(sheetContext).pop(category); + }, + ), + ), + ); + } + + @override + State createState() => + _HierarchicalEnvelopePickerSheetState(); +} + +class _HierarchicalEnvelopePickerSheetState + extends State { + final TextEditingController _searchController = TextEditingController(); + final Set _collapsedPillars = {}; + String _searchQuery = ''; + + @override + void initState() { + super.initState(); + _searchController.addListener(() { + setState(() { + _searchQuery = _searchController.text.trim().toLowerCase(); + }); + }); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + void _togglePillar(String uuid) { + setState(() { + if (_collapsedPillars.contains(uuid)) { + _collapsedPillars.remove(uuid); + } else { + _collapsedPillars.add(uuid); + } + }); + } + + @override + Widget build(BuildContext context) { + final bottomInset = MediaQuery.of(context).viewInsets.bottom; + final isExpense = widget.targetType == CategoryType.expense; + + return Container( + height: MediaQuery.of(context).size.height * 0.82, + padding: EdgeInsets.only(bottom: bottomInset), + decoration: const BoxDecoration( + color: Color(0xFFF8F9FA), + borderRadius: BorderRadius.vertical(top: Radius.circular(32)), + ), + child: Column( + children: [ + const SizedBox(height: 12), + // Drag handle + Container( + width: 44, + height: 4, + decoration: BoxDecoration( + color: const Color(0xFF00113A).withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 16), + + // Header Row + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Select Envelope', + style: GoogleFonts.manrope( + fontSize: 22, + fontWeight: FontWeight.w800, + color: const Color(0xFF00113A), + letterSpacing: -0.5, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: isExpense + ? const Color(0xFFFFEAEA) + : const Color(0xFFE6F8EB), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + isExpense + ? 'EXPENSE ENVELOPES' + : 'INCOME ENVELOPES', + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 0.6, + color: isExpense + ? const Color(0xFFBA1A1A) + : const Color(0xFF1B6B2B), + ), + ), + ), + ], + ), + ], + ), + ), + TextButton.icon( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => BlocProvider.value( + value: context.read(), + child: const CategoryFormPage(), + ), + ), + ); + }, + icon: const Icon( + Icons.add_circle_outline, + size: 18, + color: Color(0xFF00113A), + ), + label: Text( + 'New Envelope', + style: GoogleFonts.manrope( + fontSize: 13, + fontWeight: FontWeight.w700, + color: const Color(0xFF00113A), + ), + ), + style: TextButton.styleFrom( + backgroundColor: + const Color(0xFF00113A).withValues(alpha: 0.06), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + // Search Field + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Container( + decoration: BoxDecoration( + color: const Color(0xFFEEF0F2), + borderRadius: BorderRadius.circular(14), + ), + child: TextField( + controller: _searchController, + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w500, + color: const Color(0xFF191C1D), + ), + decoration: InputDecoration( + hintText: 'Search envelopes, sub-categories, pillars...', + hintStyle: GoogleFonts.inter( + fontSize: 14, + color: const Color(0xFF757682), + ), + prefixIcon: const Icon( + Icons.search, + color: Color(0xFF757682), + size: 20, + ), + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + icon: const Icon( + Icons.close, + size: 18, + color: Color(0xFF757682), + ), + onPressed: () { + _searchController.clear(); + }, + ) + : null, + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + ), + const SizedBox(height: 16), + + // Content List + Expanded( + child: BlocBuilder( + builder: (context, state) { + if (state.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + // Filter categories for the active target type (Expense or Income) + final typedCategories = state.allCategories + .where((c) => c.type == widget.targetType) + .toList(); + + if (typedCategories.isEmpty) { + return _buildEmptyState(context, isExpense); + } + + if (_searchQuery.isNotEmpty) { + return _buildSearchResults(typedCategories, state.allCategories); + } + + return _buildPillarsTree(typedCategories, state.allCategories); + }, + ), + ), + ], + ), + ); + } + + // ──────────────────────────── Tree View ─────────────────────────────────── + + Widget _buildPillarsTree( + List typedCategories, + List allCategories, + ) { + final pillars = typedCategories.where((c) => c.isRoot).toList(); + + if (pillars.isEmpty) { + return _buildFlatCategoryList(typedCategories, allCategories); + } + + return ListView.separated( + padding: const EdgeInsets.fromLTRB(24, 8, 24, 40), + itemCount: pillars.length, + separatorBuilder: (_, __) => const SizedBox(height: 20), + itemBuilder: (context, index) { + final pillar = pillars[index]; + final isCollapsed = _collapsedPillars.contains(pillar.uuid.getOrCrash()); + final children = typedCategories + .where((c) => c.parentId == pillar.uuid) + .toList(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildPillarCard(pillar, children, isCollapsed, allCategories), + if (!isCollapsed && children.isNotEmpty) ...[ + const SizedBox(height: 8), + _buildPillarChildren(children, typedCategories, allCategories), + ], + ], + ); + }, + ); + } + + Widget _buildPillarCard( + Category pillar, + List children, + bool isCollapsed, + List allCategories, + ) { + final name = pillar.name.getOrCrash(); + final isSelected = widget.selectedCategory?.uuid == pillar.uuid; + final totalBudget = _sumBudgetUnder(pillar, allCategories); + + return InkWell( + onTap: () { + if (children.isEmpty) { + widget.onCategorySelected(pillar); + } else { + _togglePillar(pillar.uuid.getOrCrash()); + } + }, + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: isSelected + ? const Color(0xFF00113A).withValues(alpha: 0.08) + : Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isSelected + ? const Color(0xFF00113A) + : const Color(0xFFE1E3E4), + width: isSelected ? 2 : 1, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.03), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: const Color(0xFF00113A).withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + _iconForPillar(name), + size: 20, + color: const Color(0xFF00113A), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: GoogleFonts.manrope( + fontSize: 15, + fontWeight: FontWeight.w800, + color: const Color(0xFF191C1D), + ), + ), + const SizedBox(height: 2), + Row( + children: [ + _buildPillarChip(pillar), + if (children.isNotEmpty) ...[ + const SizedBox(width: 8), + Text( + '${children.length} sub-envelopes', + style: GoogleFonts.inter( + fontSize: 11, + color: const Color(0xFF757682), + ), + ), + ], + ], + ), + ], + ), + ), + if (totalBudget > 0) ...[ + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '\$${totalBudget.toStringAsFixed(0)}', + style: GoogleFonts.manrope( + fontSize: 13, + fontWeight: FontWeight.w700, + color: const Color(0xFF00113A), + ), + ), + Text( + '/mo', + style: GoogleFonts.inter( + fontSize: 10, + color: const Color(0xFF757682), + ), + ), + ], + ), + const SizedBox(width: 8), + ], + if (children.isNotEmpty) + AnimatedRotation( + turns: isCollapsed ? 0 : 0.5, + duration: const Duration(milliseconds: 200), + child: const Icon( + Icons.keyboard_arrow_down, + size: 22, + color: Color(0xFF757682), + ), + ) + else if (isSelected) + const Icon( + Icons.check_circle, + size: 22, + color: Color(0xFF00113A), + ), + ], + ), + ), + ); + } + + Widget _buildPillarChildren( + List children, + List typedCategories, + List allCategories, + ) { + return Padding( + padding: const EdgeInsets.only(left: 16), + child: Column( + children: children.map((child) { + final subChildren = typedCategories + .where((c) => c.parentId == child.uuid) + .toList(); + + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildEnvelopeRow(child, allCategories), + if (subChildren.isNotEmpty) + Padding( + padding: const EdgeInsets.only(left: 20, top: 6), + child: Column( + children: subChildren.map((subChild) { + return Padding( + padding: const EdgeInsets.only(top: 6), + child: _buildEnvelopeRow(subChild, allCategories), + ); + }).toList(), + ), + ), + ], + ), + ); + }).toList(), + ), + ); + } + + Widget _buildEnvelopeRow(Category category, List allCategories) { + final name = category.name.getOrCrash(); + final isSelected = widget.selectedCategory?.uuid == category.uuid; + final budget = category.expectedMonthlyBudget; + + return InkWell( + onTap: () => widget.onCategorySelected(category), + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11), + decoration: BoxDecoration( + color: isSelected + ? const Color(0xFF00113A).withValues(alpha: 0.08) + : Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected + ? const Color(0xFF00113A) + : const Color(0xFFE1E3E4), + width: isSelected ? 2 : 1, + ), + ), + child: Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: isSelected + ? const Color(0xFF00113A).withValues(alpha: 0.12) + : const Color(0xFFF3F4F5), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + _iconForChild(name), + size: 16, + color: isSelected + ? const Color(0xFF00113A) + : const Color(0xFF5A5C66), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: GoogleFonts.manrope( + fontSize: 13, + fontWeight: isSelected ? FontWeight.w800 : FontWeight.w600, + color: const Color(0xFF191C1D), + ), + ), + const SizedBox(height: 2), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 1, + ), + decoration: BoxDecoration( + color: const Color(0xFFEEF0F2), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + category.behavioralModifier.name.toUpperCase(), + style: GoogleFonts.inter( + fontSize: 8.5, + fontWeight: FontWeight.w600, + letterSpacing: 0.8, + color: const Color(0xFF757682), + ), + ), + ), + ], + ), + ], + ), + ), + if (budget > 0) ...[ + Text( + '\$${budget.toStringAsFixed(0)}', + style: GoogleFonts.manrope( + fontSize: 12, + fontWeight: FontWeight.w700, + color: const Color(0xFF00113A), + ), + ), + const SizedBox(width: 8), + ], + if (isSelected) + const Icon( + Icons.check_circle, + size: 18, + color: Color(0xFF00113A), + ), + ], + ), + ), + ); + } + + // ────────────────────────── Search Results ──────────────────────────────── + + Widget _buildSearchResults( + List typedCategories, + List allCategories, + ) { + final matches = typedCategories.where((c) { + final name = c.name.getOrCrash().toLowerCase(); + final breadcrumb = c.getBreadcrumbPath(allCategories).toLowerCase(); + return name.contains(_searchQuery) || breadcrumb.contains(_searchQuery); + }).toList(); + + if (matches.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 40), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.search_off_outlined, + size: 48, + color: Color(0xFF757682), + ), + const SizedBox(height: 12), + Text( + 'No envelopes match "$_searchQuery"', + style: GoogleFonts.manrope( + fontSize: 15, + fontWeight: FontWeight.w600, + color: const Color(0xFF191C1D), + ), + ), + const SizedBox(height: 4), + Text( + 'Try searching for another keyword or pillar name', + style: GoogleFonts.inter( + fontSize: 12, + color: const Color(0xFF757682), + ), + ), + ], + ), + ), + ); + } + + return _buildFlatCategoryList(matches, allCategories); + } + + Widget _buildFlatCategoryList( + List categories, + List allCategories, + ) { + return ListView.separated( + padding: const EdgeInsets.fromLTRB(24, 8, 24, 40), + itemCount: categories.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final category = categories[index]; + final isSelected = widget.selectedCategory?.uuid == category.uuid; + final breadcrumb = category.getBreadcrumbPath(allCategories); + final budget = category.expectedMonthlyBudget; + + return InkWell( + onTap: () => widget.onCategorySelected(category), + borderRadius: BorderRadius.circular(14), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: isSelected + ? const Color(0xFF00113A).withValues(alpha: 0.08) + : Colors.white, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: isSelected + ? const Color(0xFF00113A) + : const Color(0xFFE1E3E4), + width: isSelected ? 2 : 1, + ), + ), + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: isSelected + ? const Color(0xFF00113A).withValues(alpha: 0.12) + : const Color(0xFFF3F4F5), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + _iconForChild(category.name.getOrCrash()), + size: 18, + color: isSelected + ? const Color(0xFF00113A) + : const Color(0xFF5A5C66), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (breadcrumb.contains('›')) ...[ + Text( + breadcrumb, + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w500, + color: const Color(0xFF757682), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + ], + Text( + category.name.getOrCrash(), + style: GoogleFonts.manrope( + fontSize: 14, + fontWeight: + isSelected ? FontWeight.w800 : FontWeight.w700, + color: const Color(0xFF191C1D), + ), + ), + ], + ), + ), + if (budget > 0) ...[ + Text( + '\$${budget.toStringAsFixed(0)}', + style: GoogleFonts.manrope( + fontSize: 13, + fontWeight: FontWeight.w700, + color: const Color(0xFF00113A), + ), + ), + const SizedBox(width: 8), + ], + if (isSelected) + const Icon( + Icons.check_circle, + size: 20, + color: Color(0xFF00113A), + ), + ], + ), + ), + ); + }, + ); + } + + // ──────────────────────────── Empty State ───────────────────────────────── + + Widget _buildEmptyState(BuildContext context, bool isExpense) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 40), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: const Color(0xFF00113A).withValues(alpha: 0.06), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.folder_open_outlined, + size: 32, + color: Color(0xFF00113A), + ), + ), + const SizedBox(height: 16), + Text( + 'No ${isExpense ? "Expense" : "Income"} Envelopes', + style: GoogleFonts.manrope( + fontSize: 18, + fontWeight: FontWeight.w800, + color: const Color(0xFF00113A), + ), + ), + const SizedBox(height: 8), + Text( + 'Create envelopes to organize your ${isExpense ? "expenses" : "income"} into hierarchical pillars.', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 13, + color: const Color(0xFF757682), + ), + ), + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => BlocProvider.value( + value: context.read(), + child: const CategoryFormPage(), + ), + ), + ); + }, + icon: const Icon(Icons.add, color: Colors.white, size: 18), + label: Text( + 'Create First Envelope', + style: GoogleFonts.manrope( + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF00113A), + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ], + ), + ), + ); + } + + // ────────────────────────────── 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.0, + (sum, c) => sum + _sumBudgetUnder(c, allCategories), + ); + } + + Widget _buildPillarChip(Category pillar) { + final name = pillar.name.getOrCrash().toLowerCase(); + Color chipBg; + Color chipText; + if (name.contains('essential') || name.contains('need')) { + chipBg = const Color(0xFFA0F399); + chipText = const Color(0xFF217128); + } else if (name.contains('lifestyle') || name.contains('life')) { + chipBg = const Color(0xFF002366); + chipText = const Color(0xFF758DD5); + } else if (name.contains('revenue') || name.contains('primary')) { + chipBg = const Color(0xFFD6E3FF); + chipText = const Color(0xFF003884); + } else if (name.contains('secondary') || name.contains('side')) { + chipBg = const Color(0xFFCCE8E0); + chipText = const Color(0xFF004F44); + } else if (name.contains('portfolio')) { + chipBg = const Color(0xFFE8DEF8); + chipText = const Color(0xFF4A4458); + } else { + chipBg = const Color(0xFF5A0006); + chipText = const Color(0xFFFF524C); + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: chipBg, + borderRadius: BorderRadius.circular(99), + ), + child: Text( + pillar.name.getOrCrash().toUpperCase(), + style: GoogleFonts.inter( + fontSize: 8.5, + fontWeight: FontWeight.w700, + letterSpacing: 0.6, + color: chipText, + ), + ), + ); + } + + IconData _iconForPillar(String name) { + final lower = name.toLowerCase(); + if (lower.contains('essential') || lower.contains('need')) { + return Icons.account_balance_outlined; + } else if (lower.contains('lifestyle') || lower.contains('life')) { + return Icons.beach_access_outlined; + } else if (lower.contains('growth') || lower.contains('invest')) { + return Icons.trending_up; + } else if (lower.contains('revenue')) { + return Icons.payments_outlined; + } else if (lower.contains('secondary') || lower.contains('income')) { + return Icons.show_chart; + } else if (lower.contains('portfolio')) { + return Icons.bar_chart_outlined; + } + return Icons.folder_outlined; + } + + IconData _iconForChild(String name) { + final lower = name.toLowerCase(); + if (lower.contains('mortgage') || + lower.contains('rent') || + lower.contains('home') || + lower.contains('house')) { + return Icons.home_outlined; + } else if (lower.contains('util') || lower.contains('electric')) { + return Icons.bolt_outlined; + } else if (lower.contains('din') || + lower.contains('food') || + lower.contains('restaurant') || + lower.contains('coffee')) { + return Icons.restaurant_outlined; + } else if (lower.contains('travel') || lower.contains('flight')) { + return Icons.flight_outlined; + } else if (lower.contains('hobbi') || lower.contains('game')) { + return Icons.sports_esports_outlined; + } else if (lower.contains('market') || + lower.contains('invest') || + lower.contains('crypto')) { + return Icons.bar_chart_outlined; + } else if (lower.contains('salary') || lower.contains('paycheck')) { + return Icons.account_balance_wallet_outlined; + } else if (lower.contains('freelance') || lower.contains('client')) { + return Icons.work_outline; + } + return Icons.category_outlined; + } +} diff --git a/lib/features/transaction/presentation/blocs/transaction_cubit.dart b/lib/features/transaction/presentation/blocs/transaction_cubit.dart index ec8cacd..e91c1a4 100644 --- a/lib/features/transaction/presentation/blocs/transaction_cubit.dart +++ b/lib/features/transaction/presentation/blocs/transaction_cubit.dart @@ -81,7 +81,19 @@ class TransactionCubit extends Cubit { } void updateType(TransactionType type) { - emit(state.copyWith(type: type)); + final currentCat = state.selectedCategory; + final isMismatch = currentCat != null && + ((type == TransactionType.expense && + currentCat.type != CategoryType.expense) || + (type == TransactionType.income && + currentCat.type != CategoryType.income)); + + emit( + state.copyWith( + type: type, + clearSelectedCategory: isMismatch, + ), + ); } void updateDate(DateTime date) { diff --git a/lib/features/transaction/presentation/blocs/transaction_state.dart b/lib/features/transaction/presentation/blocs/transaction_state.dart index bb2881d..2b0525f 100644 --- a/lib/features/transaction/presentation/blocs/transaction_state.dart +++ b/lib/features/transaction/presentation/blocs/transaction_state.dart @@ -34,6 +34,7 @@ class TransactionState extends Equatable { String? rawExpression, double? parsedAmount, Category? selectedCategory, + bool clearSelectedCategory = false, TransactionType? type, String? description, DateTime? date, @@ -45,7 +46,9 @@ class TransactionState extends Equatable { return TransactionState( rawExpression: rawExpression ?? this.rawExpression, parsedAmount: parsedAmount ?? this.parsedAmount, - selectedCategory: selectedCategory ?? this.selectedCategory, + selectedCategory: clearSelectedCategory + ? null + : (selectedCategory ?? this.selectedCategory), type: type ?? this.type, description: description ?? this.description, date: date ?? this.date, diff --git a/lib/features/transaction/presentation/pages/transaction_entry_page.dart b/lib/features/transaction/presentation/pages/transaction_entry_page.dart index eaddcfb..7574035 100644 --- a/lib/features/transaction/presentation/pages/transaction_entry_page.dart +++ b/lib/features/transaction/presentation/pages/transaction_entry_page.dart @@ -1,7 +1,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/category_list_item.dart'; +import 'package:expense_tracker/features/category/presentation/widgets/hierarchical_envelope_picker_sheet.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'; @@ -77,106 +77,52 @@ class _TransactionEntryPageState extends State { super.dispose(); } - void _showCategoryPicker(BuildContext context) { + void _showCategoryPicker(BuildContext context, TransactionState state) { final transactionCubit = context.read(); - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (context) => BlocProvider.value( - value: getIt(), - child: Container( - height: MediaQuery.of(context).size.height * 0.75, - decoration: const BoxDecoration( - color: Color(0xFFF8F9FA), - borderRadius: BorderRadius.vertical(top: Radius.circular(32)), - ), - child: Column( - children: [ - const SizedBox(height: 12), - Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: const Color(0xFF00113A).withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(2), - ), - ), - const SizedBox(height: 32), - Text( - 'Select Ledger Category', - style: GoogleFonts.manrope( - fontSize: 24, - fontWeight: FontWeight.w800, - color: const Color(0xFF00113A), - letterSpacing: -0.5, - ), - ), - const SizedBox(height: 8), - Text( - 'Assign this transaction to an envelope.', - style: GoogleFonts.inter( - fontSize: 14, - color: const Color(0xFF444650), - ), - ), - const SizedBox(height: 32), - Expanded( - child: BlocBuilder( - builder: (context, state) { - if (state.isLoading) { - return const Center(child: CircularProgressIndicator()); - } - final categories = state.allCategories; - if (categories.isEmpty) { - return Center( - child: Text( - 'No categories found.', - style: GoogleFonts.inter( - color: const Color(0xFF444650), - ), - ), - ); - } + final targetType = state.type == TransactionType.income + ? CategoryType.income + : CategoryType.expense; - return ListView.separated( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 40), - itemCount: categories.length, - separatorBuilder: (context, index) => - const SizedBox(height: 16), - itemBuilder: (context, index) { - final category = categories[index]; - final String subtitle; - if (category.parentId != null) { - final parent = categories.firstWhere( - (c) => - c.uuid.getOrCrash() == - category.parentId!.getOrCrash(), - orElse: () => category, - ); - subtitle = parent.name.getOrCrash(); - } else { - subtitle = 'Root Category'; - } + HierarchicalEnvelopePickerSheet.show( + context: context, + targetType: targetType, + categoryCubit: getIt(), + selectedCategory: state.selectedCategory, + ).then((selected) { + if (selected != null) { + transactionCubit.selectCategory(selected); + } + }); + } - return CategoryListItem( - category: category, - subtitle: subtitle, - onTap: () { - transactionCubit.selectCategory(category); - Navigator.of(context).pop(); - }, - ); - }, - ); - }, - ), - ), - ], - ), - ), - ), - ); + IconData _iconForCategory(String name) { + final lower = name.toLowerCase(); + if (lower.contains('mortgage') || + lower.contains('rent') || + lower.contains('home') || + lower.contains('house')) { + return Icons.home_outlined; + } else if (lower.contains('util') || lower.contains('electric')) { + return Icons.bolt_outlined; + } else if (lower.contains('din') || + lower.contains('food') || + lower.contains('restaurant') || + lower.contains('coffee')) { + return Icons.restaurant_outlined; + } else if (lower.contains('travel') || lower.contains('flight')) { + return Icons.flight_outlined; + } else if (lower.contains('hobbi') || lower.contains('game')) { + return Icons.sports_esports_outlined; + } else if (lower.contains('market') || + lower.contains('invest') || + lower.contains('crypto')) { + return Icons.bar_chart_outlined; + } else if (lower.contains('salary') || lower.contains('paycheck')) { + return Icons.account_balance_wallet_outlined; + } else if (lower.contains('freelance') || lower.contains('client')) { + return Icons.work_outline; + } + return Icons.category_outlined; } @override @@ -337,35 +283,153 @@ class _TransactionEntryPageState extends State { ), const SizedBox(height: 16), - // Category + // Category / Envelope _BentoInputCell( - label: 'Category', - child: InkWell( - onTap: () { - setState(() => _isCalculatorVisible = false); - _showCategoryPicker(context); - }, - child: Row( - children: [ - Expanded( - child: Text( - state.selectedCategory?.name.getOrCrash() ?? - 'Select Category', - style: GoogleFonts.inter( - fontSize: 16, - fontWeight: FontWeight.w500, - color: state.selectedCategory != null - ? const Color(0xFF191C1D) - : const Color(0xFF444650), - ), + label: 'Envelope', + child: BlocBuilder( + bloc: getIt(), + builder: (context, catState) { + final selectedCat = state.selectedCategory; + if (selectedCat == null) { + return InkWell( + onTap: () { + setState(() => _isCalculatorVisible = false); + _showCategoryPicker(context, state); + }, + child: Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: const Color(0xFF00113A) + .withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.folder_open_outlined, + color: Color(0xFF757682), + size: 16, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Select Ledger Envelope', + style: GoogleFonts.inter( + fontSize: 15, + fontWeight: FontWeight.w500, + color: const Color(0xFF757682), + ), + ), + ), + const Icon( + Icons.chevron_right, + color: Color(0xFF757682), + ), + ], ), + ); + } + + final breadcrumb = selectedCat + .getBreadcrumbPath(catState.allCategories); + final hasBreadcrumb = breadcrumb.contains('›'); + + return InkWell( + onTap: () { + setState(() => _isCalculatorVisible = false); + _showCategoryPicker(context, state); + }, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: const Color(0xFF00113A) + .withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + _iconForCategory( + selectedCat.name.getOrCrash(), + ), + color: const Color(0xFF00113A), + size: 18, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + if (hasBreadcrumb) ...[ + Text( + breadcrumb, + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w500, + color: const Color(0xFF757682), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + ], + Row( + children: [ + Flexible( + child: Text( + selectedCat.name.getOrCrash(), + style: GoogleFonts.manrope( + fontSize: 15, + fontWeight: FontWeight.w800, + color: const Color(0xFF191C1D), + ), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + Container( + padding: + const EdgeInsets.symmetric( + horizontal: 6, + vertical: 1.5, + ), + decoration: BoxDecoration( + color: const Color(0xFFEEF0F2), + borderRadius: + BorderRadius.circular(4), + ), + child: Text( + selectedCat + .behavioralModifier.name + .toUpperCase(), + style: GoogleFonts.inter( + fontSize: 8.5, + fontWeight: FontWeight.w700, + letterSpacing: 0.6, + color: + const Color(0xFF757682), + ), + ), + ), + ], + ), + ], + ), + ), + const Icon( + Icons.chevron_right, + color: Color(0xFF444650), + ), + ], ), - const Icon( - Icons.chevron_right, - color: Color(0xFF444650), - ), - ], - ), + ); + }, ), ), const SizedBox(height: 16), diff --git a/test/features/category/domain/entities/category_test.dart b/test/features/category/domain/entities/category_test.dart index 9ba3c03..1530a49 100644 --- a/test/features/category/domain/entities/category_test.dart +++ b/test/features/category/domain/entities/category_test.dart @@ -55,5 +55,53 @@ void main() { BehavioralModifier.active, ]); }); + + test('getRootPillar, getHierarchyChain, and getBreadcrumbPath work correctly', + () { + final p1Id = UniqueId.generate(); + final sp1Id = UniqueId.generate(); + final l1Id = UniqueId.generate(); + + final pillar = Category( + uuid: p1Id, + name: StringSingleLine('Lifestyle'), + isSynced: false, + updatedAt: DateTime.utc(2026), + type: CategoryType.expense, + expectedMonthlyBudget: 1000, + behavioralModifier: BehavioralModifier.active, + ); + final subParent = Category( + uuid: sp1Id, + name: StringSingleLine('Dining Out'), + parentId: p1Id, + isSynced: false, + updatedAt: DateTime.utc(2026), + type: CategoryType.expense, + expectedMonthlyBudget: 500, + behavioralModifier: BehavioralModifier.active, + ); + final leaf = Category( + uuid: l1Id, + name: StringSingleLine('Artisanal Coffee'), + parentId: sp1Id, + isSynced: false, + updatedAt: DateTime.utc(2026), + type: CategoryType.expense, + expectedMonthlyBudget: 100, + behavioralModifier: BehavioralModifier.active, + ); + final all = [pillar, subParent, leaf]; + + expect(pillar.getRootPillar(all), pillar); + expect(subParent.getRootPillar(all), pillar); + expect(leaf.getRootPillar(all), pillar); + + expect(leaf.getHierarchyChain(all), [pillar, subParent, leaf]); + expect( + leaf.getBreadcrumbPath(all), + 'Lifestyle › Dining Out › Artisanal Coffee', + ); + }); }); } diff --git a/test/features/category/presentation/widgets/hierarchical_envelope_picker_sheet_test.dart b/test/features/category/presentation/widgets/hierarchical_envelope_picker_sheet_test.dart new file mode 100644 index 0000000..b038a18 --- /dev/null +++ b/test/features/category/presentation/widgets/hierarchical_envelope_picker_sheet_test.dart @@ -0,0 +1,251 @@ +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/category/presentation/widgets/hierarchical_envelope_picker_sheet.dart'; +import 'package:expense_tracker/shared/domain/entities/value_objects.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'; + +class MockCategoryCubit extends MockCubit + implements CategoryCubit {} + +Category _makeCategory({ + required String uuid, + required String name, + CategoryType type = CategoryType.expense, + String? parentId, + double budget = 0, + BehavioralModifier modifier = BehavioralModifier.active, +}) { + return Category( + uuid: UniqueId(uuid), + name: StringSingleLine(name), + isSynced: false, + updatedAt: DateTime(2026), + type: type, + expectedMonthlyBudget: budget, + behavioralModifier: modifier, + parentId: parentId != null ? UniqueId(parentId) : null, + ); +} + +void main() { + late MockCategoryCubit mockCategoryCubit; + + const p1 = '550e8400-e29b-41d4-a716-446655440001'; + const sp1 = '550e8400-e29b-41d4-a716-446655440002'; + const e1 = '550e8400-e29b-41d4-a716-446655440003'; + const p2 = '550e8400-e29b-41d4-a716-446655440004'; + const sp2 = '550e8400-e29b-41d4-a716-446655440005'; + const inc1 = '550e8400-e29b-41d4-a716-446655440006'; + const incSub1 = '550e8400-e29b-41d4-a716-446655440007'; + + final essentialPillar = _makeCategory( + uuid: p1, + name: 'Essential', + type: CategoryType.expense, + budget: 1500, + ); + final housingSub = _makeCategory( + uuid: sp1, + name: 'Housing', + type: CategoryType.expense, + parentId: p1, + budget: 1200, + ); + final rentEnvelope = _makeCategory( + uuid: e1, + name: 'Rent & Mortgage', + type: CategoryType.expense, + parentId: sp1, + budget: 1200, + ); + + final lifestylePillar = _makeCategory( + uuid: p2, + name: 'Lifestyle', + type: CategoryType.expense, + budget: 500, + ); + final diningSub = _makeCategory( + uuid: sp2, + name: 'Dining', + type: CategoryType.expense, + parentId: p2, + budget: 300, + ); + + final salaryPillar = _makeCategory( + uuid: inc1, + name: 'Salary & Revenue', + type: CategoryType.income, + budget: 5000, + ); + final techJobSub = _makeCategory( + uuid: incSub1, + name: 'Tech Job', + type: CategoryType.income, + parentId: inc1, + budget: 5000, + ); + + final allCategories = [ + essentialPillar, + housingSub, + rentEnvelope, + lifestylePillar, + diningSub, + salaryPillar, + techJobSub, + ]; + + setUp(() { + mockCategoryCubit = MockCategoryCubit(); + when(() => mockCategoryCubit.state).thenReturn( + CategoryState( + allCategories: allCategories, + navigationStack: const [], + isLoading: false, + ), + ); + }); + + Widget buildTestWidget({ + required CategoryType targetType, + Category? selectedCategory, + required ValueChanged onSelected, + }) { + return MaterialApp( + home: Scaffold( + body: BlocProvider.value( + value: mockCategoryCubit, + child: HierarchicalEnvelopePickerSheet( + targetType: targetType, + selectedCategory: selectedCategory, + onCategorySelected: onSelected, + ), + ), + ), + ); + } + + testWidgets('renders expense pillars and hides income categories', + (tester) async { + await tester.pumpWidget( + buildTestWidget( + targetType: CategoryType.expense, + onSelected: (_) {}, + ), + ); + + expect(find.text('Select Envelope'), findsOneWidget); + expect(find.text('EXPENSE ENVELOPES'), findsOneWidget); + expect(find.text('Essential'), findsOneWidget); + expect(find.text('Lifestyle'), findsOneWidget); + expect(find.text('Housing'), findsOneWidget); + expect(find.text('Dining'), findsOneWidget); + + // Income categories should NOT appear + expect(find.text('Salary & Revenue'), findsNothing); + expect(find.text('Tech Job'), findsNothing); + }); + + testWidgets('renders income pillars when targetType is income', + (tester) async { + await tester.pumpWidget( + buildTestWidget( + targetType: CategoryType.income, + onSelected: (_) {}, + ), + ); + + expect(find.text('INCOME ENVELOPES'), findsOneWidget); + expect(find.text('Salary & Revenue'), findsOneWidget); + expect(find.text('Tech Job'), findsOneWidget); + + // Expense categories should NOT appear + expect(find.text('Essential'), findsNothing); + expect(find.text('Lifestyle'), findsNothing); + }); + + testWidgets('tapping an envelope triggers onCategorySelected callback', + (tester) async { + Category? selected; + await tester.pumpWidget( + buildTestWidget( + targetType: CategoryType.expense, + onSelected: (cat) => selected = cat, + ), + ); + + // Tap on Housing + await tester.tap(find.text('Housing')); + await tester.pumpAndSettle(); + + expect(selected, isNotNull); + expect(selected!.name.getOrCrash(), 'Housing'); + }); + + testWidgets('collapsing pillar hides its child envelopes', (tester) async { + await tester.pumpWidget( + buildTestWidget( + targetType: CategoryType.expense, + onSelected: (_) {}, + ), + ); + + expect(find.text('Housing'), findsOneWidget); + + // Tap Essential pillar header to collapse + await tester.tap(find.text('Essential')); + await tester.pumpAndSettle(); + + expect(find.text('Housing'), findsNothing); + + // Tap Essential pillar header again to expand + await tester.tap(find.text('Essential')); + await tester.pumpAndSettle(); + + expect(find.text('Housing'), findsOneWidget); + }); + + testWidgets('searching filters envelopes and displays breadcrumb path', + (tester) async { + await tester.pumpWidget( + buildTestWidget( + targetType: CategoryType.expense, + onSelected: (_) {}, + ), + ); + + // Enter search query + await tester.enterText(find.byType(TextField), 'Rent'); + await tester.pumpAndSettle(); + + expect(find.text('Rent & Mortgage'), findsOneWidget); + expect(find.text('Essential › Housing › Rent & Mortgage'), findsOneWidget); + + // Dining should be filtered out + expect(find.text('Dining'), findsNothing); + }); + + testWidgets('shows empty state when no categories match target type', + (tester) async { + when(() => mockCategoryCubit.state).thenReturn( + CategoryState.initial(), + ); + + await tester.pumpWidget( + buildTestWidget( + targetType: CategoryType.expense, + onSelected: (_) {}, + ), + ); + + expect(find.text('No Expense Envelopes'), findsOneWidget); + expect(find.text('Create First Envelope'), findsOneWidget); + }); +} diff --git a/test/features/transaction/presentation/blocs/transaction_cubit_test.dart b/test/features/transaction/presentation/blocs/transaction_cubit_test.dart index bf9b099..4dffc3d 100644 --- a/test/features/transaction/presentation/blocs/transaction_cubit_test.dart +++ b/test/features/transaction/presentation/blocs/transaction_cubit_test.dart @@ -175,4 +175,20 @@ void main() { verifyZeroInteractions(mockCreateTransactionUseCase); }, ); + + blocTest( + 'should clear selectedCategory when updateType changes to mismatched type', + build: () => cubit, + act: (cubit) { + cubit.selectCategory(tCategory); // expense category + cubit.updateType(TransactionType.income); + }, + expect: () => [ + isA() + .having((s) => s.selectedCategory, 'category', tCategory), + isA() + .having((s) => s.type, 'type', TransactionType.income) + .having((s) => s.selectedCategory, 'category', isNull), + ], + ); } From a2a957c8ae7a362d48c9c96ca2f8bae8e5be6e8d Mon Sep 17 00:00:00 2001 From: MF-Rozi Date: Wed, 26 Aug 2026 15:42:40 +0700 Subject: [PATCH 5/7] chore: improve code formatting and readability across multiple files --- .../category_local_data_source.dart | 117 ++++++++++++++---- .../category/domain/entities/category.dart | 10 +- .../pages/category_manage_page.dart | 19 +-- .../widgets/envelope_tree_list_view.dart | 3 +- .../hierarchical_envelope_picker_sheet.dart | 26 ++-- .../pages/transaction_entry_page.dart | 4 +- .../domain/entities/category_test.dart | 5 +- ...erarchical_envelope_picker_sheet_test.dart | 7 +- .../blocs/transaction_cubit_test.dart | 7 +- 9 files changed, 135 insertions(+), 63 deletions(-) diff --git a/lib/features/category/data/datasources/category_local_data_source.dart b/lib/features/category/data/datasources/category_local_data_source.dart index 8bf7505..9140eb6 100644 --- a/lib/features/category/data/datasources/category_local_data_source.dart +++ b/lib/features/category/data/datasources/category_local_data_source.dart @@ -47,7 +47,8 @@ class IsarCategoryLocalDataSource implements CategoryLocalDataSource { final defaultHierarchy = <({ String pillarName, CategoryType type, - Map> subParents, + Map> + subParents, })>[ // ─── EXPENSE PILLARS ─── ( @@ -57,20 +58,38 @@ class IsarCategoryLocalDataSource implements CategoryLocalDataSource { 'Groceries & Household': [ (name: 'Groceries', modifier: BehavioralModifier.active), (name: 'Household Supplies', modifier: BehavioralModifier.active), - (name: 'Electricity & Water', modifier: BehavioralModifier.recurring), - (name: 'Internet & Mobile', modifier: BehavioralModifier.recurring), + ( + name: 'Electricity & Water', + modifier: BehavioralModifier.recurring, + ), + ( + name: 'Internet & Mobile', + modifier: BehavioralModifier.recurring, + ), ], 'Transportation': [ (name: 'Fuel & Gas', modifier: BehavioralModifier.active), - (name: 'Public Transit & Tolls', modifier: BehavioralModifier.active), - (name: 'Vehicle Maintenance', modifier: BehavioralModifier.active), + ( + name: 'Public Transit & Tolls', + modifier: BehavioralModifier.active, + ), + ( + name: 'Vehicle Maintenance', + modifier: BehavioralModifier.active, + ), ], 'Healthcare': [ (name: 'Medical & Pharmacy', modifier: BehavioralModifier.active), - (name: 'Health Insurance', modifier: BehavioralModifier.recurring), + ( + name: 'Health Insurance', + modifier: BehavioralModifier.recurring, + ), ], 'Education': [ - (name: 'Tuition & School Fees', modifier: BehavioralModifier.recurring), + ( + name: 'Tuition & School Fees', + modifier: BehavioralModifier.recurring, + ), (name: 'Books & Courses', modifier: BehavioralModifier.active), ], }, @@ -82,23 +101,41 @@ class IsarCategoryLocalDataSource implements CategoryLocalDataSource { 'Shopping': [ (name: 'Clothing & Apparel', modifier: BehavioralModifier.active), (name: 'Online Shopping', modifier: BehavioralModifier.active), - (name: 'Gadgets & Electronics', modifier: BehavioralModifier.active), + ( + name: 'Gadgets & Electronics', + modifier: BehavioralModifier.active, + ), ], 'Dining & Leisure': [ - (name: 'Restaurants & Dining Out', modifier: BehavioralModifier.active), + ( + name: 'Restaurants & Dining Out', + modifier: BehavioralModifier.active, + ), (name: 'Coffee & Snacks', modifier: BehavioralModifier.active), ], 'Entertainment': [ - (name: 'Streaming & Subscriptions', modifier: BehavioralModifier.recurring), + ( + name: 'Streaming & Subscriptions', + modifier: BehavioralModifier.recurring, + ), (name: 'Hobbies & Gaming', modifier: BehavioralModifier.active), (name: 'Travel & Vacations', modifier: BehavioralModifier.active), ], 'Donations & Charity': [ - (name: 'Charity & Tithe', modifier: BehavioralModifier.recurring), - (name: 'Donations & Contributions', modifier: BehavioralModifier.active), + ( + name: 'Charity & Tithe', + modifier: BehavioralModifier.recurring, + ), + ( + name: 'Donations & Contributions', + modifier: BehavioralModifier.active, + ), ], 'Work & Career': [ - (name: 'Work & Office Supplies', modifier: BehavioralModifier.active), + ( + name: 'Work & Office Supplies', + modifier: BehavioralModifier.active, + ), ], }, ), @@ -109,12 +146,21 @@ class IsarCategoryLocalDataSource implements CategoryLocalDataSource { 'Investments': [ (name: 'Stocks', modifier: BehavioralModifier.active), (name: 'Mutual Funds', modifier: BehavioralModifier.active), - (name: 'Gold & Precious Metals', modifier: BehavioralModifier.active), - (name: 'Crypto & Other Assets', modifier: BehavioralModifier.active), + ( + name: 'Gold & Precious Metals', + modifier: BehavioralModifier.active, + ), + ( + name: 'Crypto & Other Assets', + modifier: BehavioralModifier.active, + ), ], 'Savings': [ (name: 'Emergency Fund', modifier: BehavioralModifier.passive), - (name: 'High-Yield Savings', modifier: BehavioralModifier.passive), + ( + name: 'High-Yield Savings', + modifier: BehavioralModifier.passive, + ), ], }, ), @@ -125,10 +171,16 @@ class IsarCategoryLocalDataSource implements CategoryLocalDataSource { subParents: { 'Salary': [ (name: 'Base Salary', modifier: BehavioralModifier.recurring), - (name: 'Allowances & Benefits', modifier: BehavioralModifier.recurring), + ( + name: 'Allowances & Benefits', + modifier: BehavioralModifier.recurring, + ), ], 'Business': [ - (name: 'Business Revenue & Sales', modifier: BehavioralModifier.active), + ( + name: 'Business Revenue & Sales', + modifier: BehavioralModifier.active, + ), ], }, ), @@ -141,11 +193,17 @@ class IsarCategoryLocalDataSource implements CategoryLocalDataSource { (name: 'Holiday Bonus', modifier: BehavioralModifier.passive), ], 'Side Hustle & Sales': [ - (name: 'Freelance & Consulting', modifier: BehavioralModifier.active), + ( + name: 'Freelance & Consulting', + modifier: BehavioralModifier.active, + ), (name: 'Item Resale', modifier: BehavioralModifier.active), ], 'Gifts & Grants': [ - (name: 'Gifts & Financial Support', modifier: BehavioralModifier.passive), + ( + name: 'Gifts & Financial Support', + modifier: BehavioralModifier.passive, + ), ], }, ), @@ -154,9 +212,18 @@ class IsarCategoryLocalDataSource implements CategoryLocalDataSource { type: CategoryType.income, subParents: { 'Investment Returns': [ - (name: 'Stock Dividends & Gains', modifier: BehavioralModifier.passive), - (name: 'Deposit Interest & Yields', modifier: BehavioralModifier.passive), - (name: 'Rental & Property Income', modifier: BehavioralModifier.passive), + ( + name: 'Stock Dividends & Gains', + modifier: BehavioralModifier.passive, + ), + ( + name: 'Deposit Interest & Yields', + modifier: BehavioralModifier.passive, + ), + ( + name: 'Rental & Property Income', + modifier: BehavioralModifier.passive, + ), ], }, ), @@ -170,7 +237,6 @@ class IsarCategoryLocalDataSource implements CategoryLocalDataSource { final pillar = _createCategory( name: group.pillarName, type: group.type, - parentId: null, ); allModels.add(pillar); @@ -197,7 +263,8 @@ class IsarCategoryLocalDataSource implements CategoryLocalDataSource { await _isar.categoryModels.putAll(allModels); }); } else if (nonRootCount == 0) { - // If only pillars were seeded previously, seed default sub-categories under existing pillars + // If only pillars were seeded previously, seed default sub-categories + // under existing pillars final existingPillars = await _isar.categoryModels.filter().parentIdIsNull().findAll(); diff --git a/lib/features/category/domain/entities/category.dart b/lib/features/category/domain/entities/category.dart index f975138..5be62d5 100644 --- a/lib/features/category/domain/entities/category.dart +++ b/lib/features/category/domain/entities/category.dart @@ -61,11 +61,12 @@ class Category extends Equatable { return false; // Level 4 or deeper is invalid } - /// Returns the root ancestor (Pillar) of this category, or itself if it is a root. + /// Returns the root ancestor (Pillar) of this category, + /// or itself if it is a root. Category getRootPillar(List allCategories) { if (parentId == null) return this; final visited = {uuid}; - Category current = this; + var current = this; while (current.parentId != null) { if (visited.contains(current.parentId)) break; visited.add(current.parentId!); @@ -79,11 +80,12 @@ class Category extends Equatable { return current; } - /// Returns the ordered list of categories from root Pillar down to this category. + /// Returns the ordered list of categories from root Pillar down + /// to this category. List getHierarchyChain(List allCategories) { final chain = [this]; final visited = {uuid}; - Category current = this; + var current = this; while (current.parentId != null) { if (visited.contains(current.parentId)) break; visited.add(current.parentId!); diff --git a/lib/features/category/presentation/pages/category_manage_page.dart b/lib/features/category/presentation/pages/category_manage_page.dart index 151a485..1ce4145 100644 --- a/lib/features/category/presentation/pages/category_manage_page.dart +++ b/lib/features/category/presentation/pages/category_manage_page.dart @@ -41,7 +41,7 @@ class CategoryManagePage extends StatelessWidget { return category.expectedMonthlyBudget; } return directChildren.fold( - 0.0, + 0, (sum, c) => sum + _sumBudgetUnder(c, allCategories), ); } @@ -52,6 +52,7 @@ class CategoryManagePage extends StatelessWidget { Category category, { bool popNavAfter = false, }) async { + final categoryCubit = blocContext.read(); final confirmed = await showDialog( context: context, builder: (dialogCtx) => AlertDialog( @@ -61,7 +62,8 @@ class CategoryManagePage extends StatelessWidget { style: GoogleFonts.manrope(fontWeight: FontWeight.bold), ), content: Text( - 'Are you sure you want to delete "${category.name.getOrCrash()}" and all its nested sub-envelopes?', + 'Are you sure you want to delete ' + '"${category.name.getOrCrash()}" and all its nested sub-envelopes?', style: GoogleFonts.inter(fontSize: 14), ), actions: [ @@ -86,12 +88,12 @@ class CategoryManagePage extends StatelessWidget { ), ); - if (confirmed == true) { - blocContext.read().deleteCategory( - category.uuid.getOrCrash(), - ); + if (confirmed ?? false) { + await categoryCubit.deleteCategory( + category.uuid.getOrCrash(), + ); if (popNavAfter) { - blocContext.read().goBack(); + categoryCubit.goBack(); } } } @@ -572,7 +574,8 @@ class CategoryManagePage extends StatelessWidget { ), const SizedBox(height: 4), Text( - 'Nested envelopes inside "${activeCategory.name.getOrCrash()}" will appear here.', + '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 e2a9fd8..dd00dbc 100644 --- a/lib/features/category/presentation/widgets/envelope_tree_list_view.dart +++ b/lib/features/category/presentation/widgets/envelope_tree_list_view.dart @@ -212,7 +212,8 @@ class _EnvelopeTreeListViewState extends State { chipBg = const Color(0xFFE8DEF8); // soft purple chipText = const Color(0xFF4A4458); // dark purple } else { - chipBg = const Color(0xFF5A0006); // tertiary-container (dark red for Financial Growth) + // tertiary-container (dark red for Financial Growth) + chipBg = const Color(0xFF5A0006); chipText = const Color(0xFFFF524C); // on-tertiary-container } 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 ddb2764..d3d1ee8 100644 --- a/lib/features/category/presentation/widgets/hierarchical_envelope_picker_sheet.dart +++ b/lib/features/category/presentation/widgets/hierarchical_envelope_picker_sheet.dart @@ -11,7 +11,8 @@ import 'package:google_fonts/google_fonts.dart'; /// Features: /// - Filters categories by [targetType] (Expense vs. Income) /// - Interactive expandable/collapsible tree by Pillar > Sub-Parent > Envelope -/// - Instant search across envelopes, sub-parents, and pillars with breadcrumb results +/// - Instant search across envelopes, sub-parents, and pillars with +/// breadcrumb results /// - Highlights the [selectedCategory] with a clear visual active state /// - Quick "+ Add Envelope" action to create envelopes directly from the modal class HierarchicalEnvelopePickerSheet extends StatefulWidget { @@ -117,7 +118,6 @@ class _HierarchicalEnvelopePickerSheetState Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: Row( - crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: Column( @@ -239,9 +239,7 @@ class _HierarchicalEnvelopePickerSheetState size: 18, color: Color(0xFF757682), ), - onPressed: () { - _searchController.clear(); - }, + onPressed: _searchController.clear, ) : null, border: InputBorder.none, @@ -260,7 +258,7 @@ class _HierarchicalEnvelopePickerSheetState return const Center(child: CircularProgressIndicator()); } - // Filter categories for the active target type (Expense or Income) + // Filter categories for the active target type (Expense/Income) final typedCategories = state.allCategories .where((c) => c.type == widget.targetType) .toList(); @@ -270,7 +268,10 @@ class _HierarchicalEnvelopePickerSheetState } if (_searchQuery.isNotEmpty) { - return _buildSearchResults(typedCategories, state.allCategories); + return _buildSearchResults( + typedCategories, + state.allCategories, + ); } return _buildPillarsTree(typedCategories, state.allCategories); @@ -300,7 +301,8 @@ class _HierarchicalEnvelopePickerSheetState separatorBuilder: (_, __) => const SizedBox(height: 20), itemBuilder: (context, index) { final pillar = pillars[index]; - final isCollapsed = _collapsedPillars.contains(pillar.uuid.getOrCrash()); + final isCollapsed = + _collapsedPillars.contains(pillar.uuid.getOrCrash()); final children = typedCategories .where((c) => c.parentId == pillar.uuid) .toList(); @@ -540,7 +542,8 @@ class _HierarchicalEnvelopePickerSheetState name, style: GoogleFonts.manrope( fontSize: 13, - fontWeight: isSelected ? FontWeight.w800 : FontWeight.w600, + fontWeight: + isSelected ? FontWeight.w800 : FontWeight.w600, color: const Color(0xFF191C1D), ), ), @@ -782,7 +785,8 @@ class _HierarchicalEnvelopePickerSheetState ), const SizedBox(height: 8), Text( - 'Create envelopes to organize your ${isExpense ? "expenses" : "income"} into hierarchical pillars.', + 'Create envelopes to organize your ' + '${isExpense ? "expenses" : "income"} into hierarchical pillars.', textAlign: TextAlign.center, style: GoogleFonts.inter( fontSize: 13, @@ -836,7 +840,7 @@ class _HierarchicalEnvelopePickerSheetState return category.expectedMonthlyBudget; } return directChildren.fold( - 0.0, + 0, (sum, c) => sum + _sumBudgetUnder(c, allCategories), ); } diff --git a/lib/features/transaction/presentation/pages/transaction_entry_page.dart b/lib/features/transaction/presentation/pages/transaction_entry_page.dart index 7574035..fc5f69f 100644 --- a/lib/features/transaction/presentation/pages/transaction_entry_page.dart +++ b/lib/features/transaction/presentation/pages/transaction_entry_page.dart @@ -342,7 +342,6 @@ class _TransactionEntryPageState extends State { _showCategoryPicker(context, state); }, child: Row( - crossAxisAlignment: CrossAxisAlignment.center, children: [ Container( width: 36, @@ -387,7 +386,8 @@ class _TransactionEntryPageState extends State { style: GoogleFonts.manrope( fontSize: 15, fontWeight: FontWeight.w800, - color: const Color(0xFF191C1D), + color: + const Color(0xFF191C1D), ), overflow: TextOverflow.ellipsis, ), diff --git a/test/features/category/domain/entities/category_test.dart b/test/features/category/domain/entities/category_test.dart index 1530a49..00ee0c0 100644 --- a/test/features/category/domain/entities/category_test.dart +++ b/test/features/category/domain/entities/category_test.dart @@ -56,8 +56,9 @@ void main() { ]); }); - test('getRootPillar, getHierarchyChain, and getBreadcrumbPath work correctly', - () { + test( + 'getRootPillar, getHierarchyChain, and getBreadcrumbPath work correctly', + () { final p1Id = UniqueId.generate(); final sp1Id = UniqueId.generate(); final l1Id = UniqueId.generate(); diff --git a/test/features/category/presentation/widgets/hierarchical_envelope_picker_sheet_test.dart b/test/features/category/presentation/widgets/hierarchical_envelope_picker_sheet_test.dart index b038a18..eef6d64 100644 --- a/test/features/category/presentation/widgets/hierarchical_envelope_picker_sheet_test.dart +++ b/test/features/category/presentation/widgets/hierarchical_envelope_picker_sheet_test.dart @@ -46,20 +46,17 @@ void main() { final essentialPillar = _makeCategory( uuid: p1, name: 'Essential', - type: CategoryType.expense, budget: 1500, ); final housingSub = _makeCategory( uuid: sp1, name: 'Housing', - type: CategoryType.expense, parentId: p1, budget: 1200, ); final rentEnvelope = _makeCategory( uuid: e1, name: 'Rent & Mortgage', - type: CategoryType.expense, parentId: sp1, budget: 1200, ); @@ -67,13 +64,11 @@ void main() { final lifestylePillar = _makeCategory( uuid: p2, name: 'Lifestyle', - type: CategoryType.expense, budget: 500, ); final diningSub = _makeCategory( uuid: sp2, name: 'Dining', - type: CategoryType.expense, parentId: p2, budget: 300, ); @@ -115,8 +110,8 @@ void main() { Widget buildTestWidget({ required CategoryType targetType, - Category? selectedCategory, required ValueChanged onSelected, + Category? selectedCategory, }) { return MaterialApp( home: Scaffold( diff --git a/test/features/transaction/presentation/blocs/transaction_cubit_test.dart b/test/features/transaction/presentation/blocs/transaction_cubit_test.dart index 4dffc3d..b0c4b41 100644 --- a/test/features/transaction/presentation/blocs/transaction_cubit_test.dart +++ b/test/features/transaction/presentation/blocs/transaction_cubit_test.dart @@ -179,10 +179,9 @@ void main() { blocTest( 'should clear selectedCategory when updateType changes to mismatched type', build: () => cubit, - act: (cubit) { - cubit.selectCategory(tCategory); // expense category - cubit.updateType(TransactionType.income); - }, + act: (cubit) => cubit + ..selectCategory(tCategory) // expense category + ..updateType(TransactionType.income), expect: () => [ isA() .having((s) => s.selectedCategory, 'category', tCategory), From 60c44fb3bff4b2a1c7181363a787143ed6b7aaea Mon Sep 17 00:00:00 2001 From: MF-Rozi Date: Sat, 29 Aug 2026 19:28:28 +0700 Subject: [PATCH 6/7] style: apply consistent line formatting and indentation across category domain and UI components --- .../category_local_data_source.dart | 4 +-- .../hierarchical_envelope_picker_sheet.dart | 25 ++++++++----------- .../domain/entities/category_test.dart | 4 +-- 3 files changed, 14 insertions(+), 19 deletions(-) diff --git a/lib/features/category/data/datasources/category_local_data_source.dart b/lib/features/category/data/datasources/category_local_data_source.dart index 9140eb6..46c2111 100644 --- a/lib/features/category/data/datasources/category_local_data_source.dart +++ b/lib/features/category/data/datasources/category_local_data_source.dart @@ -47,8 +47,8 @@ class IsarCategoryLocalDataSource implements CategoryLocalDataSource { final defaultHierarchy = <({ String pillarName, CategoryType type, - Map> - subParents, + Map> subParents, })>[ // ─── EXPENSE PILLARS ─── ( 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 d3d1ee8..809718f 100644 --- a/lib/features/category/presentation/widgets/hierarchical_envelope_picker_sheet.dart +++ b/lib/features/category/presentation/widgets/hierarchical_envelope_picker_sheet.dart @@ -303,9 +303,8 @@ class _HierarchicalEnvelopePickerSheetState final pillar = pillars[index]; final isCollapsed = _collapsedPillars.contains(pillar.uuid.getOrCrash()); - final children = typedCategories - .where((c) => c.parentId == pillar.uuid) - .toList(); + final children = + typedCategories.where((c) => c.parentId == pillar.uuid).toList(); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -348,9 +347,8 @@ class _HierarchicalEnvelopePickerSheetState : Colors.white, borderRadius: BorderRadius.circular(16), border: Border.all( - color: isSelected - ? const Color(0xFF00113A) - : const Color(0xFFE1E3E4), + color: + isSelected ? const Color(0xFF00113A) : const Color(0xFFE1E3E4), width: isSelected ? 2 : 1, ), boxShadow: [ @@ -462,9 +460,8 @@ class _HierarchicalEnvelopePickerSheetState padding: const EdgeInsets.only(left: 16), child: Column( children: children.map((child) { - final subChildren = typedCategories - .where((c) => c.parentId == child.uuid) - .toList(); + final subChildren = + typedCategories.where((c) => c.parentId == child.uuid).toList(); return Padding( padding: const EdgeInsets.only(top: 8), @@ -508,9 +505,8 @@ class _HierarchicalEnvelopePickerSheetState : Colors.white, borderRadius: BorderRadius.circular(12), border: Border.all( - color: isSelected - ? const Color(0xFF00113A) - : const Color(0xFFE1E3E4), + color: + isSelected ? const Color(0xFF00113A) : const Color(0xFFE1E3E4), width: isSelected ? 2 : 1, ), ), @@ -833,9 +829,8 @@ class _HierarchicalEnvelopePickerSheetState // ────────────────────────────── Helpers ─────────────────────────────────── double _sumBudgetUnder(Category category, List allCategories) { - final directChildren = allCategories - .where((c) => c.parentId == category.uuid) - .toList(); + final directChildren = + allCategories.where((c) => c.parentId == category.uuid).toList(); if (directChildren.isEmpty) { return category.expectedMonthlyBudget; } diff --git a/test/features/category/domain/entities/category_test.dart b/test/features/category/domain/entities/category_test.dart index 00ee0c0..12d35d6 100644 --- a/test/features/category/domain/entities/category_test.dart +++ b/test/features/category/domain/entities/category_test.dart @@ -57,8 +57,8 @@ void main() { }); test( - 'getRootPillar, getHierarchyChain, and getBreadcrumbPath work correctly', - () { + 'getRootPillar, getHierarchyChain, and getBreadcrumbPath work correctly', + () { final p1Id = UniqueId.generate(); final sp1Id = UniqueId.generate(); final l1Id = UniqueId.generate(); From cb90effa6e74d28a74801bd084553148e4590034 Mon Sep 17 00:00:00 2001 From: MF-Rozi Date: Mon, 31 Aug 2026 22:59:10 +0700 Subject: [PATCH 7/7] style: fix line length in category entity test --- test/features/category/domain/entities/category_test.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/features/category/domain/entities/category_test.dart b/test/features/category/domain/entities/category_test.dart index 12d35d6..81025f0 100644 --- a/test/features/category/domain/entities/category_test.dart +++ b/test/features/category/domain/entities/category_test.dart @@ -57,8 +57,8 @@ void main() { }); test( - 'getRootPillar, getHierarchyChain, and getBreadcrumbPath work correctly', - () { + 'getRootPillar, getHierarchyChain, and getBreadcrumbPath ' + 'work correctly', () { final p1Id = UniqueId.generate(); final sp1Id = UniqueId.generate(); final l1Id = UniqueId.generate();