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..46c2111 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(); @@ -18,71 +19,329 @@ 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 = 'essential' - ..name = 'Essential' - ..isSynced = false - ..updatedAt = DateTime.now() - ..parentId = null - ..type = CategoryType.expense - ..expectedMonthlyBudget = 0.0 - ..behavioralModifier = BehavioralModifier.active, - CategoryModel() - ..uuid = 'lifestyle' - ..name = 'Lifestyle' - ..isSynced = false - ..updatedAt = DateTime.now() - ..parentId = null - ..type = CategoryType.expense - ..expectedMonthlyBudget = 0.0 - ..behavioralModifier = BehavioralModifier.active, - CategoryModel() - ..uuid = 'growth' - ..name = 'Financial Growth' - ..isSynced = false - ..updatedAt = DateTime.now() - ..parentId = null - ..type = CategoryType.expense - ..expectedMonthlyBudget = 0.0 - ..behavioralModifier = BehavioralModifier.active, - // Income Pillars - CategoryModel() - ..uuid = 'primary_revenue' - ..name = 'Primary Revenue' - ..isSynced = false - ..updatedAt = DateTime.now() - ..parentId = null - ..type = CategoryType.income - ..expectedMonthlyBudget = 0.0 - ..behavioralModifier = BehavioralModifier.active, - CategoryModel() - ..uuid = 'secondary_income' - ..name = 'Secondary Income' - ..isSynced = false - ..updatedAt = DateTime.now() - ..parentId = null - ..type = CategoryType.income - ..expectedMonthlyBudget = 0.0 - ..behavioralModifier = BehavioralModifier.active, - CategoryModel() - ..uuid = 'portfolio_growth' - ..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, + ); + 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 + 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/domain/entities/category.dart b/lib/features/category/domain/entities/category.dart index af6c236..5be62d5 100644 --- a/lib/features/category/domain/entities/category.dart +++ b/lib/features/category/domain/entities/category.dart @@ -61,6 +61,51 @@ 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}; + var 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}; + var 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/pages/category_manage_page.dart b/lib/features/category/presentation/pages/category_manage_page.dart index 6de382b..1ce4145 100644 --- a/lib/features/category/presentation/pages/category_manage_page.dart +++ b/lib/features/category/presentation/pages/category_manage_page.dart @@ -11,6 +11,93 @@ 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, + (sum, c) => sum + _sumBudgetUnder(c, allCategories), + ); + } + + Future _confirmDelete( + BuildContext context, + BuildContext blocContext, + Category category, { + bool popNavAfter = false, + }) async { + final categoryCubit = blocContext.read(); + 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 ?? false) { + await categoryCubit.deleteCategory( + category.uuid.getOrCrash(), + ); + if (popNavAfter) { + categoryCubit.goBack(); + } + } + } + @override Widget build(BuildContext context) { return BlocBuilder( @@ -39,6 +126,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 +168,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 +293,298 @@ 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), + ), + ), + ], + ), + ), + ); + } } 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..dd00dbc 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,23 @@ 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 + // 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 new file mode 100644 index 0000000..809718f --- /dev/null +++ b/lib/features/category/presentation/widgets/hierarchical_envelope_picker_sheet.dart @@ -0,0 +1,932 @@ +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( + 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/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, + (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..fc5f69f 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( + 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/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(), diff --git a/test/features/category/domain/entities/category_test.dart b/test/features/category/domain/entities/category_test.dart index 9ba3c03..81025f0 100644 --- a/test/features/category/domain/entities/category_test.dart +++ b/test/features/category/domain/entities/category_test.dart @@ -55,5 +55,54 @@ 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..eef6d64 --- /dev/null +++ b/test/features/category/presentation/widgets/hierarchical_envelope_picker_sheet_test.dart @@ -0,0 +1,246 @@ +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', + budget: 1500, + ); + final housingSub = _makeCategory( + uuid: sp1, + name: 'Housing', + parentId: p1, + budget: 1200, + ); + final rentEnvelope = _makeCategory( + uuid: e1, + name: 'Rent & Mortgage', + parentId: sp1, + budget: 1200, + ); + + final lifestylePillar = _makeCategory( + uuid: p2, + name: 'Lifestyle', + budget: 500, + ); + final diningSub = _makeCategory( + uuid: sp2, + name: 'Dining', + 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, + required ValueChanged onSelected, + Category? selectedCategory, + }) { + 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..b0c4b41 100644 --- a/test/features/transaction/presentation/blocs/transaction_cubit_test.dart +++ b/test/features/transaction/presentation/blocs/transaction_cubit_test.dart @@ -175,4 +175,19 @@ void main() { verifyZeroInteractions(mockCreateTransactionUseCase); }, ); + + blocTest( + 'should clear selectedCategory when updateType changes to mismatched type', + build: () => cubit, + act: (cubit) => cubit + ..selectCategory(tCategory) // expense category + ..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), + ], + ); }