diff --git a/docs/plans/2026-08-05-011-feat-category-overhaul-plan.md b/docs/plans/2026-08-05-011-feat-category-overhaul-plan.md new file mode 100644 index 0000000..581c9df --- /dev/null +++ b/docs/plans/2026-08-05-011-feat-category-overhaul-plan.md @@ -0,0 +1,203 @@ +--- +title: "feat: Financial Architecture (Category Overhaul)" +type: feat +status: active +date: 2026-08-05 +--- + +# feat: Financial Architecture (Category Overhaul) + +## Summary +Outline the technical changes, testing scenarios, and migration pathways needed to transition the existing Category system to a hierarchical Envelope & Pillar system. This plan details domain modifications, state management additions, standalone widget implementation, and page-level integration. + +--- + +## Problem Frame +The current ledger system manages flat, unstructured categories. To enable sophisticated cashflow forecasting and behavioral modeling, the system must support a strict three-level hierarchy (Primary Pillar > Sub-Parent > Child Envelope), baseline budget inputs, and behavioral tags (Active, Passive, Recurring). We are renaming the user-facing nomenclature from "Categories" to "Envelopes" and "Category Groupings" to "Pillars". + +--- + +## Requirements + +### Domain & Terminology Shift +- R1. Terminology Update: Map user-visible strings to refer to Categories as "Envelopes" and Groupings as "Pillars". +- R2. Hierarchical Validation: Enforce a strict three-level taxonomy: Primary Pillar (Level 1, e.g. Lifestyle) > Sub-Parent (Level 2, e.g. Dining) > Child Envelope (Level 3, e.g. Artisanal Coffee). +- R3. Data Additions: Envelopes must support an Expected Monthly Budget/Amount (numeric baseline) and a Behavioral Modifier tag (Active, Passive, Recurring). + +### UI Component & Form Page +- R4. Form Segmented Control: Provide a segmented toggle for choosing "Expense" vs. "Income" nature. +- R5. Visual Breadcrumbs: Show a breadcrumb path tracking the active nesting level (`Primary Pillar > Sub-Parent > Child Envelope`). +- R6. Envelope Type Selector: Show large interactive cards for selecting Level 1 Pillars (Essential, Lifestyle, Growth for Expense; Primary Revenue, Secondary Income, Portfolio Growth for Income). +- R7. Budget Input: Implement a centered numerical input field with large typography to log expected baseline budget. +- R8. Modifier Toggles: Implement three horizontal pill-shaped toggle buttons for selecting Active, Passive, or Recurring modifiers. +- R9. Hierarchy Insight Card: Render a bottom summary card displaying how the Envelope will be nested and its relative budget impact. + +### Dashboard & Nested List View +- R10. Portfolio Distribution Card: Build a premium dark-blue header card showing the total allocated budget across envelopes, with a multi-color progress bar showing percentage breakdown between Essential, Lifestyle, and Growth. +- R11. Collapsible Envelope Tree: Display Envelopes in an expandable/collapsible tree structure with trailing budget allocations. + +--- + +## Key Technical Decisions + +- KTD1. Single-Table Database Hierarchy: Represent the three-level hierarchy within a single Isar `CategoryModel` using a nullable `parentId` field (renamed/replaced from `parentUuid`). Root Pillars have `parentId == null`. Sub-parents have `parentId` pointing to a Pillar. Child Envelopes have `parentId` pointing to a Sub-parent. This keeps the schema unified and leverages existing recursive delete logic. +- KTD2. Explicit Nature Type: Add a `CategoryType` enum (`expense`, `income`) to both `Category` and `CategoryModel` to support queries and filters on the dashboard and form pages without traversing root parents. +- KTD3. Dynamic Budget Aggregation: Total allocated budgets for Pillars and Sub-parents are calculated dynamically at the Presentation/Cubit layer by aggregating child envelope budget amounts. This ensures a single source of truth for budget amounts and prevents sync errors in the database. +- KTD4. Seed default Pillars: Automate seeding of the 6 fixed Level 1 Pillars during repository initialization if the database is empty. + - Expense Pillars: Essential, Lifestyle, Growth + - Income Pillars: Primary Revenue, Secondary Income, Portfolio Growth +- KTD5. Enforce Hierarchy in Domain: Add validation inside `SaveCategoryUseCase` or the `Category` entity constructor to prevent invalid nesting structures (e.g. child envelopes pointing directly to root pillars or nesting beyond 3 levels). + +--- + +## High-Level Technical Design + +### Hierarchy Structure +```mermaid +graph TD + subgraph EXPENSE ["Expense Architecture"] + EP1["Essential (Pillar / Level 1)"] + EP2["Lifestyle (Pillar / Level 1)"] + EP3["Growth (Pillar / Level 1)"] + + SUB_E1["Mortgage & Rent (Sub-Parent / Level 2)"] + SUB_E2["Dining (Sub-Parent / Level 2)"] + + CHILD_E1["Artisanal Coffee (Child Envelope / Level 3)"] + + EP1 --> SUB_E1 + EP2 --> SUB_E2 + SUB_E2 --> CHILD_E1 + end + + subgraph INCOME ["Income Architecture"] + IP1["Primary Revenue (Pillar / Level 1)"] + IP2["Secondary Income (Pillar / Level 1)"] + IP3["Portfolio Growth (Pillar / Level 1)"] + + SUB_I1["Consulting Fees (Sub-Parent / Level 2)"] + + IP2 --> SUB_I1 + end +``` + +### Budget Aggregation Sequence +```mermaid +sequenceDiagram + participant UI as Presentation Page + participant Cubit as CategoryCubit + participant Entity as Category Entity + + UI->>Cubit: Request Dashboard State + Cubit->>Cubit: Fetch all Categories from Stream + Cubit->>Cubit: Filter Level 3 Child Envelopes + Cubit->>Cubit: Sum Child budgets under each Level 2 Sub-Parent + Cubit->>Cubit: Sum Sub-Parent budgets under each Level 1 Pillar + Cubit->>UI: Emit state with pre-aggregated budget totals +``` + +--- + +## Implementation Units + +### U1. Update Domain Layer (Enums & Entity) +- **Goal:** Update the core Domain models and validations to support the new terminology, fields, and hierarchy rules. +- **Files:** + - `lib/features/category/domain/entities/category.dart` +- **Approach:** + - Define `BehavioralModifier` enum with values `active`, `passive`, `recurring`. + - Define `CategoryType` enum with values `expense`, `income`. + - Replace `parentUuid` with `parentId` (type `UniqueId?`). + - Add fields `expectedMonthlyBudget` (type `double`) and `behavioralModifier` (type `BehavioralModifier`). + - Add `CategoryType type` field. + - Implement validation methods on `Category` entity: `bool isValidHierarchy(List allCategories)` which verifies that Level 3 has a Level 2 parent, and Level 2 has a Level 1 parent. +- **Test Scenarios:** + - `test/features/category/domain/entities/category_test.dart`: + - Verify entity instantiation with new fields. + - Test validation logic for invalid hierarchy depth (e.g. nesting a child directly under a Pillar, or adding a Level 4 category). + +### U2. Update Data Layer (Isar Schema & Mapping) +- **Goal:** Migrate `CategoryModel` database schema and repositories to handle new fields and Isar indexing. +- **Files:** + - `lib/features/category/data/models/category_model.dart` + - `lib/features/category/data/repositories/category_repository_impl.dart` + - `lib/features/category/data/datasources/category_local_data_source.dart` +- **Approach:** + - Update `CategoryModel` class: + - Replace `parentUuid` with `parentId` index. + - Add `@enumerated` `CategoryType type`. + - Add `late double expectedMonthlyBudget`. + - Add `@enumerated` `BehavioralModifier behavioralModifier`. + - Update `fromEntity` and `toEntity` mapper methods. + - Update `CategoryLocalDataSource` search queries to filter by `parentId` instead of `parentUuid`. + - Regenerate Isar adapter via `flutter pub run build_runner build --delete-conflicting-outputs`. +- **Test Scenarios:** + - `test/features/category/data/models/category_model_test.dart`: + - Test mapping to and from the updated domain entity. + - `test/features/category/data/repositories/category_repository_impl_test.dart`: + - Validate hierarchy checks in repository implementation during save operation. + +### U3. Pillars Auto-Seeding +- **Goal:** Seed the default Level 1 Pillars during data source init so users always have root category pillars. +- **Files:** + - `lib/features/category/data/datasources/category_local_data_source.dart` +- **Approach:** + - In `IsarCategoryLocalDataSource` constructor or open stream trigger, check if `categoryModels` table is empty. + - If empty, write default root pillar categories (Essential, Lifestyle, Growth as `CategoryType.expense`, and Primary Revenue, Secondary Income, Portfolio Growth as `CategoryType.income`) with `parentId = null`. +- **Test Scenarios:** + - Add integration-level test to confirm database auto-seeding when no records are present in local storage. + +### U4. State Management (CategoryCubit & State) +- **Goal:** Update the Cubit state and business logic to support segmented filters, navigation stack, and budget sums. +- **Files:** + - `lib/features/category/presentation/blocs/category_cubit.dart` + - `lib/features/category/presentation/blocs/category_state.dart` +- **Approach:** + - In `CategoryState`, add `selectedType` field (defaulting to `CategoryType.expense`). + - Add getters to `CategoryState`: + - `List get activePillars` -> Returns root categories matching the selected type. + - `Map get pillarBudgets` -> Maps parent category UUIDs to the aggregated sum of child expected monthly budgets. + - `double get totalBudget` -> Sum of all envelope budgets of the active type. + - Update `CategoryCubit` to support: + - `void setCategoryType(CategoryType type)` to switch active nature. + - `void selectParent(String? parentId)` to drill down navigation hierarchy. +- **Test Scenarios:** + - Create `test/features/category/presentation/blocs/category_cubit_test.dart`: + - Test toggle type updates state. + - Test calculations for dynamic budget aggregation under parent pillars. + +### U5. Reusable Presentation Widgets +- **Goal:** Implement the new user interface widgets according to the design HTML mockup styling. +- **Files:** + - `lib/features/category/presentation/widgets/portfolio_distribution_card.dart` + - `lib/features/category/presentation/widgets/hierarchy_insight_card.dart` + - `lib/features/category/presentation/widgets/envelope_creation_form.dart` + - `lib/features/category/presentation/widgets/envelope_tree_list_view.dart` +- **Approach:** + - `PortfolioDistributionCard`: Dark blue card container matching the gradient, display total allocated budget, progress bar showing proportional width segments for Essential, Lifestyle, and Growth. + - `HierarchyInsightCard`: Inline bottom card showing where the new envelope resides in the hierarchy (Pillar > Sub-Parent) and its percentage of the total budget. + - `EnvelopeCreationForm`: Input components for name, budget input (large centered text), modifier toggles (horizontal pills: Active/Passive/Recurring), and pillar picker. + - `EnvelopeTreeListView`: Expandable parent list view using indentation guidelines (`stagger-line` classes from HTML mockup). + +### U6. Page-Level Integration +- **Goal:** Embed the presentation components into the page templates and connect state actions to Cubit functions. +- **Files:** + - `lib/features/category/presentation/pages/category_form_page.dart` + - `lib/features/category/presentation/pages/category_manage_page.dart` +- **Approach:** + - Replace form layout in `category_form_page.dart` with `EnvelopeCreationForm` and wire to `CategoryCubit.saveCategory`. + - Replace scroll view contents in `category_manage_page.dart` to show the updated `PortfolioDistributionCard` and the `EnvelopeTreeListView`. Connect list expand toggles to Cubit events. + +--- + +## Verification & Test Scenarios + +### Dynamic UI Interaction +- Confirm segmented toggle on Form switches Level 1 Pillar options instantly. +- Verify Hierarchy Insight card dynamically re-renders when changing parent categories or expected amounts. +- Verify progress bar segments on Portfolio Distribution Card adjust proportionally based on Category amounts. + +### Hierarchy Constraints +- Verify saving an envelope without a parent category throws a validation error (unless it is a Pillar). +- Verify database cascade deletion correctly removes child envelopes when their sub-parent is deleted. +- Verify duplicate name prevention under the same parent node in the hierarchy. 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 5f5f196..13851be 100644 --- a/lib/features/category/data/datasources/category_local_data_source.dart +++ b/lib/features/category/data/datasources/category_local_data_source.dart @@ -1,4 +1,5 @@ import 'package:expense_tracker/features/category/data/models/category_model.dart'; +import 'package:expense_tracker/features/category/domain/entities/category.dart'; import 'package:injectable/injectable.dart'; import 'package:isar_community/isar.dart'; @@ -11,10 +12,83 @@ abstract class CategoryLocalDataSource { @LazySingleton(as: CategoryLocalDataSource) class IsarCategoryLocalDataSource implements CategoryLocalDataSource { - IsarCategoryLocalDataSource(this._isar); + IsarCategoryLocalDataSource(this._isar) { + _seedPillarsIfNeeded(); + } final Isar _isar; + Future _seedPillarsIfNeeded() async { + try { + final count = await _isar.categoryModels.count(); + if (count == 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); + }); + } + } catch (_) { + // Ignore in tests or if Isar collection is not available + } + } + @override Stream> watchCategories() { return _isar.categoryModels.where().watch(fireImmediately: true); @@ -35,11 +109,8 @@ class IsarCategoryLocalDataSource implements CategoryLocalDataSource { await _isar.categoryModels.filter().uuidEqualTo(uuid).findFirst(); if (category == null) return; - // Find all descendants recursively (or just by parentUuid since it's + // Find all descendants recursively (or just by parentId since it's // hierarchical) - // Note: If we have multiple levels, we need a recursive delete or a loop. - // For now, let's assume we might have multiple levels. - final uuidsToDelete = {uuid}; final toProcess = [uuid]; @@ -47,7 +118,7 @@ class IsarCategoryLocalDataSource implements CategoryLocalDataSource { final currentUuid = toProcess.removeAt(0); final children = await _isar.categoryModels .filter() - .parentUuidEqualTo(currentUuid) + .parentIdEqualTo(currentUuid) .findAll(); for (final child in children) { if (!uuidsToDelete.contains(child.uuid)) { diff --git a/lib/features/category/data/models/category_model.dart b/lib/features/category/data/models/category_model.dart index 398758e..cd671f3 100644 --- a/lib/features/category/data/models/category_model.dart +++ b/lib/features/category/data/models/category_model.dart @@ -14,7 +14,10 @@ class CategoryModel { ..name = entity.name.getOrCrash() ..isSynced = entity.isSynced ..updatedAt = entity.updatedAt - ..parentUuid = entity.parentUuid?.getOrCrash(); + ..parentId = entity.parentId?.getOrCrash() + ..type = entity.type + ..expectedMonthlyBudget = entity.expectedMonthlyBudget + ..behavioralModifier = entity.behavioralModifier; } Id id = Isar.autoIncrement; @@ -23,7 +26,7 @@ class CategoryModel { late String uuid; @Index() - String? parentUuid; + String? parentId; late String name; @@ -31,13 +34,24 @@ class CategoryModel { late DateTime updatedAt; + @enumerated + late CategoryType type; + + late double expectedMonthlyBudget; + + @enumerated + late BehavioralModifier behavioralModifier; + Category toEntity() { return Category( uuid: UniqueId(uuid), name: StringSingleLine(name), isSynced: isSynced, updatedAt: updatedAt, - parentUuid: parentUuid != null ? UniqueId(parentUuid!) : null, + parentId: parentId != null ? UniqueId(parentId!) : null, + type: type, + expectedMonthlyBudget: expectedMonthlyBudget, + behavioralModifier: behavioralModifier, ); } } diff --git a/lib/features/category/data/repositories/category_repository_impl.dart b/lib/features/category/data/repositories/category_repository_impl.dart index 559002e..ca35c21 100644 --- a/lib/features/category/data/repositories/category_repository_impl.dart +++ b/lib/features/category/data/repositories/category_repository_impl.dart @@ -25,9 +25,9 @@ class CategoryRepositoryImpl implements CategoryRepository { @override Future> saveCategory(Category category) async { try { - if (category.parentUuid != null) { + if (category.parentId != null) { final parent = await _localDataSource.getCategoryByUuid( - category.parentUuid!.getOrCrash(), + category.parentId!.getOrCrash(), ); if (parent == null) { return const Left( diff --git a/lib/features/category/domain/entities/category.dart b/lib/features/category/domain/entities/category.dart index d57e9d4..af6c236 100644 --- a/lib/features/category/domain/entities/category.dart +++ b/lib/features/category/domain/entities/category.dart @@ -1,29 +1,75 @@ import 'package:equatable/equatable.dart'; import 'package:expense_tracker/shared/domain/entities/value_objects.dart'; +enum BehavioralModifier { active, passive, recurring } + +enum CategoryType { expense, income } + class Category extends Equatable { const Category({ required this.uuid, required this.name, required this.isSynced, required this.updatedAt, - this.parentUuid, + required this.type, + required this.expectedMonthlyBudget, + required this.behavioralModifier, + this.parentId, }); final UniqueId uuid; final StringSingleLine name; - final UniqueId? parentUuid; + final UniqueId? parentId; final bool isSynced; final DateTime updatedAt; + final CategoryType type; + final double expectedMonthlyBudget; + final BehavioralModifier behavioralModifier; + + bool get isRoot => parentId == null; + + bool isValidHierarchy(List allCategories) { + if (parentId == null) { + return true; // Root is always valid + } + + Category? findById(UniqueId id) { + for (final c in allCategories) { + if (c.uuid == id) return c; + } + return null; + } + + final parent = findById(parentId!); + if (parent == null) return false; + + // Check if parent's parent is null (parent is Level 1) + if (parent.parentId == null) { + return true; // Level 2 is valid + } + + // Check grandparent + final grandParent = findById(parent.parentId!); + if (grandParent == null) return false; + + // Grandparent must be a root (Level 1) for this to be a + // valid Level 3 category + if (grandParent.parentId == null) { + return true; // Level 3 is valid + } - bool get isRoot => parentUuid == null; + return false; // Level 4 or deeper is invalid + } @override List get props => [ uuid, name, - parentUuid, + parentId, isSynced, updatedAt, + type, + expectedMonthlyBudget, + behavioralModifier, ]; } diff --git a/lib/features/category/presentation/blocs/category_cubit.dart b/lib/features/category/presentation/blocs/category_cubit.dart index 17c8613..3b6eba5 100644 --- a/lib/features/category/presentation/blocs/category_cubit.dart +++ b/lib/features/category/presentation/blocs/category_cubit.dart @@ -51,11 +51,25 @@ class CategoryCubit extends Cubit { }); } - void selectCategory(String uuid) { - final newStack = List.from(state.navigationStack)..add(uuid); - emit(state.copyWith(navigationStack: newStack)); + void setCategoryType(CategoryType type) { + emit(state.copyWith(selectedType: type)); } + void selectParent(String? parentId) { + if (parentId == null) { + emit(state.copyWith(navigationStack: [])); + } else { + final newStack = List.from(state.navigationStack)..add(parentId); + emit(state.copyWith(navigationStack: newStack)); + } + } + + // Refactored/commented out old selectCategory method since we use selectParent now. + // void selectCategory(String uuid) { + // final newStack = List.from(state.navigationStack)..add(uuid); + // emit(state.copyWith(navigationStack: newStack)); + // } + void goBack() { if (state.navigationStack.isNotEmpty) { final newStack = List.from(state.navigationStack)..removeLast(); diff --git a/lib/features/category/presentation/blocs/category_state.dart b/lib/features/category/presentation/blocs/category_state.dart index d6bdfd5..457b6d8 100644 --- a/lib/features/category/presentation/blocs/category_state.dart +++ b/lib/features/category/presentation/blocs/category_state.dart @@ -6,6 +6,7 @@ class CategoryState extends Equatable { required this.allCategories, required this.navigationStack, required this.isLoading, + this.selectedType = CategoryType.expense, this.error, }); @@ -20,26 +21,75 @@ class CategoryState extends Equatable { final List allCategories; final List navigationStack; final bool isLoading; + final CategoryType selectedType; final String? error; String? get activeParentUuid => navigationStack.isEmpty ? null : navigationStack.last; List get currentViewCategories => allCategories.where((c) { - final parentUuidStr = c.parentUuid?.getOrCrash(); - return parentUuidStr == activeParentUuid; + final parentIdStr = c.parentId?.getOrCrash(); + return parentIdStr == activeParentUuid; }).toList(); + List get activePillars { + return allCategories + .where((c) => c.isRoot && c.type == selectedType) + .toList(); + } + + Map get pillarBudgets { + final budgets = {}; + final pillars = activePillars; + + for (final pillar in pillars) { + final pillarIdStr = pillar.uuid.getOrCrash(); + final subParents = allCategories.where( + (c) => c.parentId?.getOrCrash() == pillarIdStr, + ); + + var sum = 0.0; + for (final subParent in subParents) { + final subParentIdStr = subParent.uuid.getOrCrash(); + final children = allCategories.where( + (c) => c.parentId?.getOrCrash() == subParentIdStr, + ); + for (final child in children) { + sum += child.expectedMonthlyBudget; + } + } + budgets[pillarIdStr] = sum; + } + + return budgets; + } + + double get totalBudget { + return allCategories + .where((c) => c.type == selectedType && c.parentId != null) + .where((c) { + final parent = allCategories.firstWhere( + (parent) => parent.uuid == c.parentId, + orElse: () => c, + ); + return parent != c && parent.parentId != null; + }) + .map((c) => c.expectedMonthlyBudget) + .fold(0, (sum, val) => sum + val); + } + CategoryState copyWith({ List? allCategories, List? navigationStack, bool? isLoading, + CategoryType? selectedType, Object? error = const Object(), }) { return CategoryState( allCategories: allCategories ?? this.allCategories, navigationStack: navigationStack ?? this.navigationStack, isLoading: isLoading ?? this.isLoading, + selectedType: selectedType ?? this.selectedType, error: error == const Object() ? this.error : (error as String?), ); } @@ -49,6 +99,7 @@ class CategoryState extends Equatable { allCategories, navigationStack, isLoading, + selectedType, error, ]; } diff --git a/lib/features/category/presentation/pages/category_form_page.dart b/lib/features/category/presentation/pages/category_form_page.dart index 692dcc1..3d99719 100644 --- a/lib/features/category/presentation/pages/category_form_page.dart +++ b/lib/features/category/presentation/pages/category_form_page.dart @@ -1,6 +1,8 @@ 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/envelope_creation_form.dart'; +import 'package:expense_tracker/features/category/presentation/widgets/hierarchy_insight_card.dart'; import 'package:expense_tracker/shared/domain/entities/value_objects.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -21,8 +23,10 @@ class CategoryFormPage extends StatefulWidget { } class _CategoryFormPageState extends State { - final _formKey = GlobalKey(); late TextEditingController _nameController; + late TextEditingController _budgetController; + Category? _selectedPillar; + BehavioralModifier _selectedModifier = BehavioralModifier.active; bool _isSaving = false; @override @@ -31,31 +35,109 @@ class _CategoryFormPageState extends State { _nameController = TextEditingController( text: widget.categoryToEdit?.name.getOrCrash() ?? '', ); + _budgetController = TextEditingController( + text: widget.categoryToEdit?.expectedMonthlyBudget != null && + (widget.categoryToEdit?.expectedMonthlyBudget ?? 0) > 0 + ? widget.categoryToEdit!.expectedMonthlyBudget.toStringAsFixed(2) + : '', + ); + if (widget.categoryToEdit != null) { + _selectedModifier = widget.categoryToEdit!.behavioralModifier; + } + + _nameController.addListener(_onFormInputChanged); + _budgetController.addListener(_onFormInputChanged); + } + + void _onFormInputChanged() { + setState(() {}); } @override void dispose() { + _nameController.removeListener(_onFormInputChanged); + _budgetController.removeListener(_onFormInputChanged); _nameController.dispose(); + _budgetController.dispose(); super.dispose(); } - void _onSave() { - if (_formKey.currentState?.validate() ?? false) { - final category = Category( - uuid: widget.categoryToEdit?.uuid ?? UniqueId.generate(), - name: StringSingleLine(_nameController.text), - isSynced: false, - updatedAt: DateTime.now(), - parentUuid: widget.activeParentUuid != null - ? UniqueId(widget.activeParentUuid!) - : widget.categoryToEdit?.parentUuid, - ); + Category? _resolveParentCategory(CategoryState state) { + if (widget.activeParentUuid != null) { + return state.allCategories.cast().firstWhere( + (c) => c?.uuid.getOrCrash() == widget.activeParentUuid, + orElse: () => null, + ); + } + return null; + } + + Category? _resolvePillarCategory( + CategoryState state, + Category? activeParent, + ) { + if (_selectedPillar != null) { + return _selectedPillar; + } + + if (activeParent != null) { + if (activeParent.isRoot) { + return activeParent; + } else if (activeParent.parentId != null) { + return state.allCategories.cast().firstWhere( + (c) => c?.uuid == activeParent.parentId, + orElse: () => null, + ); + } + } + + return state.activePillars.isNotEmpty ? state.activePillars.first : null; + } + + Category? _resolveSubParentCategory(Category? activeParent) { + if (activeParent != null && !activeParent.isRoot) { + return activeParent; + } + return null; + } - setState(() { - _isSaving = true; - }); - context.read().saveCategory(category); + void _onSave(CategoryState state) { + final nameText = _nameController.text.trim(); + if (nameText.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please enter an envelope name')), + ); + return; } + + final activeParent = _resolveParentCategory(state); + final effectivePillar = _resolvePillarCategory(state, activeParent); + final effectiveSubParent = _resolveSubParentCategory(activeParent); + + // Parent ID resolution + final parentId = effectiveSubParent?.uuid ?? + effectivePillar?.uuid ?? + (widget.activeParentUuid != null + ? UniqueId(widget.activeParentUuid!) + : null); + + final parsedBudget = double.tryParse(_budgetController.text) ?? 0.0; + + final category = Category( + uuid: widget.categoryToEdit?.uuid ?? UniqueId.generate(), + name: StringSingleLine(nameText), + type: state.selectedType, + parentId: parentId, + expectedMonthlyBudget: parsedBudget, + behavioralModifier: _selectedModifier, + isSynced: false, + updatedAt: DateTime.now(), + ); + + setState(() { + _isSaving = true; + }); + context.read().saveCategory(category); } @override @@ -70,7 +152,7 @@ class _CategoryFormPageState extends State { onPressed: () => Navigator.of(context).pop(), ), title: Text( - widget.categoryToEdit == null ? 'New Category' : 'Edit Category', + widget.categoryToEdit == null ? 'New Envelope' : 'Edit Envelope', style: GoogleFonts.manrope( fontWeight: FontWeight.bold, color: const Color(0xFF00113A), @@ -86,160 +168,137 @@ class _CategoryFormPageState extends State { }); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Failed to save category: ${state.error}'), + content: Text('Failed to save envelope: ${state.error}'), ), ); } else if (!state.isLoading && state.error == null) { setState(() { _isSaving = false; }); - // Success! Pop ONLY after async operation completes successfully Navigator.of(context).pop(); } }, child: BlocBuilder( builder: (context, state) { - final parentCategory = widget.activeParentUuid != null - ? state.allCategories.firstWhere( - (c) => c.uuid.getOrCrash() == widget.activeParentUuid, - ) - : null; + final activeParent = _resolveParentCategory(state); + final pillar = _resolvePillarCategory(state, activeParent); + final subParent = _resolveSubParentCategory(activeParent); + final parsedBudget = double.tryParse(_budgetController.text) ?? 0.0; return SingleChildScrollView( padding: const EdgeInsets.all(24), - child: Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header - Text( - 'Record Entry'.toUpperCase(), - style: GoogleFonts.manrope( - fontSize: 32, - fontWeight: FontWeight.w800, - color: const Color(0xFF00113A), - letterSpacing: -1, - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Text( + 'Record Entry'.toUpperCase(), + style: GoogleFonts.manrope( + fontSize: 32, + fontWeight: FontWeight.w800, + color: const Color(0xFF00113A), + letterSpacing: -1, ), - const SizedBox(height: 8), - Text( - 'Configure your sovereign ledger architecture.', - style: GoogleFonts.inter( - fontSize: 14, - color: const Color(0xFF444650), // on-surface-variant - ), + ), + const SizedBox(height: 8), + Text( + 'Configure your sovereign ledger architecture.', + style: GoogleFonts.inter( + fontSize: 14, + color: const Color(0xFF444650), ), - const SizedBox(height: 48), + ), + const SizedBox(height: 32), - // Category Name Input - _BentoInputCell( - label: 'Category Name', - child: TextFormField( - controller: _nameController, - style: GoogleFonts.inter( - fontSize: 16, - fontWeight: FontWeight.w500, - color: const Color(0xFF191C1D), // on-surface - ), - decoration: const InputDecoration( - hintText: 'e.g., Artisan Coffee', - border: InputBorder.none, - isDense: true, - contentPadding: EdgeInsets.zero, - ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter a name'; - } - if (value.contains('\n')) { - return 'Name must be a single line'; - } - return null; - }, - ), - ), + // Main Form Component + EnvelopeCreationForm( + selectedType: state.selectedType, + availablePillars: state.activePillars, + selectedPillar: pillar, + selectedModifier: _selectedModifier, + nameController: _nameController, + budgetController: _budgetController, + onTypeChanged: (type) { + context.read().setCategoryType(type); + setState(() { + _selectedPillar = null; + }); + }, + onPillarChanged: (selectedPillar) { + setState(() { + _selectedPillar = selectedPillar; + }); + }, + onModifierChanged: (modifier) { + setState(() { + _selectedModifier = modifier; + }); + }, + ), - const SizedBox(height: 24), + const SizedBox(height: 32), - // Parent Category (Read-only if pre-selected) - _BentoInputCell( - label: 'Parent Architecture', - isReadOnly: widget.activeParentUuid != null, - child: Row( - children: [ - Expanded( - child: Text( - parentCategory?.name.getOrCrash() ?? 'Root Level', - style: GoogleFonts.inter( - fontSize: 16, - fontWeight: FontWeight.w500, - color: widget.activeParentUuid != null - ? const Color(0xFF757682) - : const Color(0xFF191C1D), - ), - ), - ), - if (widget.activeParentUuid == null) - const Icon( - Icons.expand_more, - color: Color(0xFF757682), - ), - ], - ), - ), + // Dynamic Hierarchy Insight Card + HierarchyInsightCard( + envelopeName: _nameController.text.trim(), + pillar: pillar, + subParent: subParent, + totalBudget: state.totalBudget, + envelopeBudget: parsedBudget, + ), - const SizedBox(height: 48), + const SizedBox(height: 48), - // Action Buttons - ElevatedButton( - onPressed: state.isLoading || _isSaving ? null : _onSave, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF00113A), - foregroundColor: Colors.white, - minimumSize: const Size(double.infinity, 64), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(100), - ), - elevation: 8, - shadowColor: - const Color(0xFF00113A).withValues(alpha: 0.4), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.check_circle_outline), - const SizedBox(width: 8), - Text( - 'Save Configuration', - style: GoogleFonts.manrope( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - ], + // Action Buttons + ElevatedButton( + onPressed: state.isLoading || _isSaving + ? null + : () => _onSave(state), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF00113A), + foregroundColor: Colors.white, + minimumSize: const Size(double.infinity, 64), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(100), ), + elevation: 8, + shadowColor: + const Color(0xFF00113A).withValues(alpha: 0.4), ), - const SizedBox(height: 16), - TextButton( - onPressed: () => Navigator.of(context).pop(), - style: TextButton.styleFrom( - minimumSize: const Size(double.infinity, 56), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(100), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.check_circle_outline), + const SizedBox(width: 8), + Text( + 'Save Configuration', + style: GoogleFonts.manrope( + fontSize: 18, + fontWeight: FontWeight.bold, + ), ), + ], + ), + ), + const SizedBox(height: 16), + TextButton( + onPressed: () => Navigator.of(context).pop(), + style: TextButton.styleFrom( + minimumSize: const Size(double.infinity, 56), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(100), ), - child: Text( - 'Discard Changes', - style: GoogleFonts.manrope( - fontSize: 14, - fontWeight: FontWeight.w600, - color: const Color(0xFF444650), - ), + ), + child: Text( + 'Discard Changes', + style: GoogleFonts.manrope( + fontSize: 14, + fontWeight: FontWeight.w600, + color: const Color(0xFF444650), ), ), - ], - ), + ), + ], ), ); }, @@ -248,62 +307,3 @@ class _CategoryFormPageState extends State { ); } } - -class _BentoInputCell extends StatelessWidget { - const _BentoInputCell({ - required this.label, - required this.child, - this.isReadOnly = false, - }); - - final String label; - final Widget child; - final bool isReadOnly; - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(left: 4, bottom: 8), - child: Text( - label.toUpperCase(), - style: GoogleFonts.inter( - fontSize: 10, - fontWeight: FontWeight.bold, - letterSpacing: 1.5, - color: const Color(0xFF00113A), - ), - ), - ), - Focus( - child: Builder( - builder: (context) { - final isFocused = Focus.of(context).hasFocus; - return Container( - width: double.infinity, - padding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 20), - decoration: BoxDecoration( - color: isReadOnly - ? const Color(0xFFEDEEEF) // surface-container - : const Color(0xFFF3F4F5), // surface-container-low - border: Border( - bottom: BorderSide( - color: isFocused - ? const Color(0xFF00113A) - : Colors.transparent, - width: 2, - ), - ), - ), - child: child, - ); - }, - ), - ), - ], - ); - } -} diff --git a/lib/features/category/presentation/pages/category_manage_page.dart b/lib/features/category/presentation/pages/category_manage_page.dart index fa6cab6..6de382b 100644 --- a/lib/features/category/presentation/pages/category_manage_page.dart +++ b/lib/features/category/presentation/pages/category_manage_page.dart @@ -1,7 +1,9 @@ +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:expense_tracker/features/category/presentation/widgets/category_list.dart'; +import 'package:expense_tracker/features/category/presentation/widgets/envelope_tree_list_view.dart'; +import 'package:expense_tracker/features/category/presentation/widgets/portfolio_distribution_card.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -14,9 +16,10 @@ class CategoryManagePage extends StatelessWidget { return BlocBuilder( builder: (blocContext, state) { final activeCategory = state.activeParentUuid != null - ? state.allCategories.firstWhere( - (c) => c.uuid.getOrCrash() == state.activeParentUuid, - ) + ? state.allCategories.cast().firstWhere( + (c) => c?.uuid.getOrCrash() == state.activeParentUuid, + orElse: () => null, + ) : null; return PopScope( @@ -42,7 +45,7 @@ class CategoryManagePage extends StatelessWidget { vertical: 16, ), title: Text( - activeCategory?.name.getOrCrash() ?? 'Root Categories', + activeCategory?.name.getOrCrash() ?? 'Envelopes', style: GoogleFonts.manrope( fontWeight: FontWeight.w800, fontSize: 24, @@ -79,48 +82,43 @@ class CategoryManagePage extends StatelessWidget { ), const SizedBox(height: 24), - // Stitch Premium Layout Card Banner - _PortfolioDistributionCard( - activeEnvelopesCount: state.allCategories.length, + // Portfolio Distribution Card Header + PortfolioDistributionCard( + pillars: state.activePillars, + pillarBudgets: state.pillarBudgets, + totalBudget: state.totalBudget, ), - const SizedBox(height: 24), + const SizedBox(height: 32), - // Category List Grid - CategoryList( - categories: state.currentViewCategories, - onCategoryTap: (category) { - context - .read() - .selectCategory(category.uuid.getOrCrash()); + // Envelope Tree List View + EnvelopeTreeListView( + allCategories: state.allCategories, + onPillarTap: (pillar) { + // Standard expand/collapse handled inside EnvelopeTreeListView, + // or selectParent if navigating deeper }, - onAddTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => BlocProvider.value( - value: blocContext.read(), - child: CategoryFormPage( - activeParentUuid: state.activeParentUuid, - ), - ), - ), - ); + onChildTap: (child) { + if (!child.isRoot) { + context + .read() + .selectParent(child.uuid.getOrCrash()); + } }, - onEditTap: (category) { + onAddChild: (pillar) { Navigator.of(context).push( MaterialPageRoute( builder: (_) => BlocProvider.value( value: blocContext.read(), child: CategoryFormPage( - activeParentUuid: state.activeParentUuid, - categoryToEdit: category, + activeParentUuid: pillar.uuid.getOrCrash(), ), ), ), ); }, ), - const SizedBox(height: 100), // Space for bottom nav/FAB + const SizedBox(height: 100), // Space for FAB ]), ), ), @@ -142,7 +140,7 @@ class CategoryManagePage extends StatelessWidget { backgroundColor: const Color(0xFF00113A), foregroundColor: Colors.white, label: Text( - 'Add Category', + 'Add Envelope', style: GoogleFonts.inter(fontWeight: FontWeight.bold), ), icon: const Icon(Icons.add), @@ -153,107 +151,3 @@ class CategoryManagePage extends StatelessWidget { ); } } - -class _PortfolioDistributionCard extends StatelessWidget { - const _PortfolioDistributionCard({required this.activeEnvelopesCount}); - - final int activeEnvelopesCount; - - @override - Widget build(BuildContext context) { - return Container( - width: double.infinity, - padding: const EdgeInsets.all(32), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(40), - gradient: const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFF00113A), Color(0xFF002366)], - ), - boxShadow: [ - BoxShadow( - color: const Color(0xFF00113A).withValues(alpha: 0.1), - blurRadius: 48, - offset: const Offset(0, 32), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Portfolio Distribution'.toUpperCase(), - style: GoogleFonts.inter( - fontSize: 12, - fontWeight: FontWeight.w500, - letterSpacing: 2, - color: Colors.white.withValues(alpha: 0.6), - ), - ), - const SizedBox(height: 16), - Row( - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, - children: [ - Text( - '$activeEnvelopesCount', - style: GoogleFonts.manrope( - fontSize: 48, - fontWeight: FontWeight.w800, - color: Colors.white, - ), - ), - const SizedBox(width: 8), - Text( - 'Active Envelopes', - style: GoogleFonts.manrope( - fontSize: 20, - fontWeight: FontWeight.bold, - color: Colors.white.withValues(alpha: 0.8), - ), - ), - ], - ), - const SizedBox(height: 24), - // Nested Color Distribution Bar - ClipRRect( - borderRadius: BorderRadius.circular(100), - child: Container( - height: 8, - width: double.infinity, - color: Colors.white.withValues(alpha: 0.1), - child: Row( - children: [ - Expanded( - flex: 40, - child: Container( - color: const Color(0xFFA0F399), - ), // secondary-container - ), - Expanded( - flex: 25, - child: Container( - color: const Color(0xFFFFB3AC), - ), // tertiary-fixed-dim - ), - Expanded( - flex: 20, - child: Container( - color: const Color(0xFFB3C5FF), - ), // inverse-primary - ), - Expanded( - flex: 15, - child: - Container(color: Colors.white.withValues(alpha: 0.3)), - ), - ], - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/features/category/presentation/widgets/category_list.dart b/lib/features/category/presentation/widgets/category_list.dart index 0d9d34f..4583da4 100644 --- a/lib/features/category/presentation/widgets/category_list.dart +++ b/lib/features/category/presentation/widgets/category_list.dart @@ -48,8 +48,7 @@ class CategoryList extends StatelessWidget { final childCount = allCategories .where( (c) => - c.parentUuid?.getOrCrash() == - category.uuid.getOrCrash(), + c.parentId?.getOrCrash() == category.uuid.getOrCrash(), ) .length; return CategoryListItem( diff --git a/lib/features/category/presentation/widgets/envelope_creation_form.dart b/lib/features/category/presentation/widgets/envelope_creation_form.dart new file mode 100644 index 0000000..b85c6ef --- /dev/null +++ b/lib/features/category/presentation/widgets/envelope_creation_form.dart @@ -0,0 +1,519 @@ +import 'package:expense_tracker/features/category/domain/entities/category.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:google_fonts/google_fonts.dart'; + +/// The complete creation form for a new envelope. +/// +/// Contains: +/// - Envelope nature segmented toggle (Expense / Income) +/// - Pillar picker (Level 1 cards: Essential / Lifestyle / Growth or income +/// equivalents) +/// - Name text field +/// - Large centered budget input +/// - Behavioral modifier pills (Active / Passive / Recurring) +/// +/// This is a *display-only* widget; all state is managed by the caller via +/// callbacks, so it remains independently testable. +/// +/// Mirrors the form layout in the HTML mockup (category-new-design.html, +/// lines 148–266 & 532–650). +class EnvelopeCreationForm extends StatelessWidget { + const EnvelopeCreationForm({ + required this.selectedType, + required this.availablePillars, + required this.selectedPillar, + required this.selectedModifier, + required this.nameController, + required this.budgetController, + required this.onTypeChanged, + required this.onPillarChanged, + required this.onModifierChanged, + super.key, + }); + + /// Expense or Income toggle. + final CategoryType selectedType; + + /// The available Level 1 pillar categories for the selected type. + final List availablePillars; + + /// The currently selected pillar. + final Category? selectedPillar; + + /// The currently selected behavioral modifier. + final BehavioralModifier selectedModifier; + + /// Controller for the envelope name field. + final TextEditingController nameController; + + /// Controller for the budget field. + final TextEditingController budgetController; + + final ValueChanged onTypeChanged; + final ValueChanged onPillarChanged; + final ValueChanged onModifierChanged; + + // Design tokens + static const _primary = Color(0xFF00113A); + static const _primaryContainer = Color(0xFF002366); + static const _onPrimaryContainer = Color(0xFF758DD5); + static const _surfaceContainerLow = Color(0xFFF3F4F5); + static const _surfaceContainerLowest = Color(0xFFFFFFFF); + static const _onSurface = Color(0xFF191C1D); + static const _onSurfaceVariant = Color(0xFF444650); + static const _outline = Color(0xFF757682); + static const _outlineVariant = Color(0xFFC5C6D2); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildNatureToggle(), + const SizedBox(height: 32), + _buildPillarPicker(), + const SizedBox(height: 32), + _buildNameField(), + const SizedBox(height: 32), + _buildBudgetInput(), + const SizedBox(height: 24), + _buildModifierToggles(), + ], + ); + } + + // ─────────────────────────────── Nature Toggle ──────────────────────────── + + Widget _buildNatureToggle() { + return Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: _surfaceContainerLow, + borderRadius: BorderRadius.circular(99), + ), + child: Row( + children: [ + _buildToggleButton( + label: 'Expense', + isActive: selectedType == CategoryType.expense, + onTap: () => onTypeChanged(CategoryType.expense), + ), + const SizedBox(width: 4), + _buildToggleButton( + label: 'Income', + isActive: selectedType == CategoryType.income, + onTap: () => onTypeChanged(CategoryType.income), + ), + ], + ), + ); + } + + Widget _buildToggleButton({ + required String label, + required bool isActive, + required VoidCallback onTap, + }) { + return Expanded( + child: GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + gradient: isActive + ? const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [_primary, _primaryContainer], + ) + : null, + borderRadius: BorderRadius.circular(99), + ), + child: Center( + child: Text( + label, + style: GoogleFonts.manrope( + fontSize: 14, + fontWeight: FontWeight.w700, + color: isActive ? Colors.white : _onSurfaceVariant, + ), + ), + ), + ), + ), + ); + } + + // ──────────────────────────────── Pillar Picker ─────────────────────────── + + Widget _buildPillarPicker() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionLabel('Envelope Type'), + const SizedBox(height: 8), + if (availablePillars.isEmpty) + _buildEmptyPillarsPlaceholder() + else + _buildPillarGrid(), + ], + ); + } + + Widget _buildEmptyPillarsPlaceholder() { + return Container( + padding: const EdgeInsets.symmetric(vertical: 20), + decoration: BoxDecoration( + color: _surfaceContainerLow, + borderRadius: BorderRadius.circular(12), + ), + child: Center( + child: Text( + 'No pillars available', + style: GoogleFonts.inter( + fontSize: 13, + color: _outline, + ), + ), + ), + ); + } + + Widget _buildPillarGrid() { + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + childAspectRatio: 0.9, + ), + itemCount: availablePillars.length, + itemBuilder: (context, index) { + final pillar = availablePillars[index]; + final isSelected = selectedPillar?.uuid == pillar.uuid; + return _PillarCard( + pillar: pillar, + isSelected: isSelected, + onTap: () => onPillarChanged(isSelected ? null : pillar), + ); + }, + ); + } + + // ───────────────────────────────── Name Field ───────────────────────────── + + Widget _buildNameField() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionLabel('Category Name'), + const SizedBox(height: 16), + TextField( + controller: nameController, + style: GoogleFonts.manrope( + fontSize: 22, + fontWeight: FontWeight.w700, + color: _onSurface, + ), + decoration: InputDecoration( + hintText: 'e.g. Artisanal Coffee', + hintStyle: GoogleFonts.manrope( + fontSize: 22, + fontWeight: FontWeight.w700, + color: _outlineVariant, + ), + enabledBorder: const UnderlineInputBorder( + borderSide: BorderSide(color: _outlineVariant, width: 2), + ), + focusedBorder: const UnderlineInputBorder( + borderSide: BorderSide(color: _primary, width: 2), + ), + filled: false, + contentPadding: const EdgeInsets.symmetric(vertical: 16), + ), + textCapitalization: TextCapitalization.words, + ), + ], + ); + } + + // ──────────────────────────────── Budget Input ──────────────────────────── + + Widget _buildBudgetInput() { + return Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: _surfaceContainerLowest, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionLabel('Expected Monthly Budget'), + const SizedBox(height: 16), + Row( + children: [ + Text( + r'$', + style: GoogleFonts.manrope( + fontSize: 28, + fontWeight: FontWeight.w700, + color: _outlineVariant, + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: budgetController, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + inputFormatters: [ + FilteringTextInputFormatter.allow( + RegExp(r'^\d*\.?\d{0,2}'), + ), + ], + style: GoogleFonts.manrope( + fontSize: 44, + fontWeight: FontWeight.w800, + color: _primary, + ), + decoration: InputDecoration( + hintText: '0.00', + hintStyle: GoogleFonts.manrope( + fontSize: 44, + fontWeight: FontWeight.w800, + color: const Color(0xFFE7E8E9), + ), + border: InputBorder.none, + contentPadding: EdgeInsets.zero, + isDense: true, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + const Divider(color: Color(0xFFEDEEEF), thickness: 1), + const SizedBox(height: 12), + Text( + 'This figure serves as your baseline for liquidity projections.', + style: GoogleFonts.inter( + fontSize: 12, + color: _outline, + fontStyle: FontStyle.italic, + ), + ), + ], + ), + ); + } + + // ──────────────────────────── Modifier Toggles ──────────────────────────── + + Widget _buildModifierToggles() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionLabel('Behavioral Configuration'), + const SizedBox(height: 12), + Row( + children: [ + _buildModifierPill( + label: 'Active', + icon: Icons.bolt_outlined, + modifier: BehavioralModifier.active, + ), + const SizedBox(width: 12), + _buildModifierPill( + label: 'Passive', + icon: Icons.waves_outlined, + modifier: BehavioralModifier.passive, + ), + const SizedBox(width: 12), + _buildModifierPill( + label: 'Recurring', + icon: Icons.autorenew_outlined, + modifier: BehavioralModifier.recurring, + ), + ], + ), + ], + ); + } + + Widget _buildModifierPill({ + required String label, + required IconData icon, + required BehavioralModifier modifier, + }) { + final isSelected = selectedModifier == modifier; + return Expanded( + child: GestureDetector( + onTap: () => onModifierChanged(modifier), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(vertical: 16), + decoration: BoxDecoration( + color: isSelected + ? _onPrimaryContainer.withValues(alpha: 0.15) + : _surfaceContainerLow, + borderRadius: BorderRadius.circular(12), + border: isSelected + ? Border.all( + color: _primary.withValues(alpha: 0.15), + width: 2, + ) + : null, + ), + child: Column( + children: [ + Icon( + icon, + size: 22, + color: isSelected ? _primary : _onSurfaceVariant, + ), + const SizedBox(height: 6), + Text( + label.toUpperCase(), + style: GoogleFonts.inter( + fontSize: 9, + fontWeight: FontWeight.w700, + letterSpacing: 1, + color: isSelected ? _primary : _onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ); + } + + // ─────────────────────────────── Helper ─────────────────────────────────── + + Widget _buildSectionLabel(String text) { + return Text( + text.toUpperCase(), + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w600, + letterSpacing: 2, + color: _outline, + ), + ); + } +} + +// ──────────────────────────────── Pillar Card ───────────────────────────── + +class _PillarCard extends StatelessWidget { + const _PillarCard({ + required this.pillar, + required this.isSelected, + required this.onTap, + }); + + final Category pillar; + final bool isSelected; + final VoidCallback onTap; + + static const _primary = Color(0xFF00113A); + static const _surfaceContainerLow = Color(0xFFF3F4F5); + static const _onSurfaceVariant = Color(0xFF444650); + + IconData _iconForPillar(String name) { + final lower = name.toLowerCase(); + if (lower.contains('essential') || lower.contains('need')) { + return Icons.shield_outlined; + } else if (lower.contains('lifestyle') || lower.contains('life')) { + return Icons.auto_awesome_outlined; + } else if (lower.contains('growth') || lower.contains('invest')) { + return Icons.trending_up; + } else if (lower.contains('revenue') || lower.contains('primary')) { + return Icons.account_balance_outlined; + } else if (lower.contains('secondary') || lower.contains('income')) { + return Icons.show_chart; + } else if (lower.contains('portfolio')) { + return Icons.trending_up; + } + return Icons.folder_outlined; + } + + @override + Widget build(BuildContext context) { + final name = pillar.name.getOrCrash(); + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + gradient: isSelected + ? const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF00113A), Color(0xFF002366)], + ) + : null, + color: isSelected ? null : _surfaceContainerLow, + borderRadius: BorderRadius.circular(12), + border: isSelected + ? Border.all(color: _primary.withValues(alpha: 0.1), width: 2) + : null, + boxShadow: isSelected + ? [ + BoxShadow( + color: _primary.withValues(alpha: 0.2), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ] + : null, + ), + child: Stack( + 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, + color: isSelected ? Colors.white : _onSurfaceVariant, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + if (isSelected) + Positioned( + top: 0, + right: 0, + child: Icon( + Icons.check_circle, + size: 14, + color: Colors.white.withValues(alpha: 0.9), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/category/presentation/widgets/envelope_tree_list_view.dart b/lib/features/category/presentation/widgets/envelope_tree_list_view.dart new file mode 100644 index 0000000..2a3a60d --- /dev/null +++ b/lib/features/category/presentation/widgets/envelope_tree_list_view.dart @@ -0,0 +1,446 @@ +import 'package:expense_tracker/features/category/domain/entities/category.dart'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +/// Expandable tree list view for the Category Architecture screen. +/// +/// Renders Level 1 pillars as section headers and their Level 2 sub-parents +/// (or Level 3 envelopes) as indented child rows with the `stagger-line` / +/// `stagger-line-item` visual decoration from the HTML mockup +/// (category-new-design.html, lines 874–892, 961–1120). +/// +/// Only two levels of nesting are rendered here (Pillar → Sub-Parent / +/// direct Envelopes). A third level requires the user to tap a sub-parent +/// row and navigate deeper — this widget delegates that via [onChildTap]. +class EnvelopeTreeListView extends StatefulWidget { + const EnvelopeTreeListView({ + required this.allCategories, + this.onPillarTap, + this.onChildTap, + this.onAddChild, + super.key, + }); + + /// The complete flat list of all categories (all levels). + final List allCategories; + + /// Called when the user taps a pillar header row. + final void Function(Category pillar)? onPillarTap; + + /// Called when the user taps a child row (sub-parent or leaf envelope). + final void Function(Category child)? onChildTap; + + /// Called when the user taps the add button on a pillar section. + final void Function(Category pillar)? onAddChild; + + @override + State createState() => _EnvelopeTreeListViewState(); +} + +class _EnvelopeTreeListViewState extends State { + /// Tracks which pillar UUIDs are collapsed. Pillars start expanded. + final Set _collapsed = {}; + + List get _pillars => + widget.allCategories.where((c) => c.isRoot).toList(); + + List _childrenOf(Category parent) { + final parentId = parent.uuid.getOrCrash(); + return widget.allCategories + .where((c) => c.parentId?.getOrCrash() == parentId) + .toList(); + } + + void _togglePillar(String uuid) { + setState(() { + if (_collapsed.contains(uuid)) { + _collapsed.remove(uuid); + } else { + _collapsed.add(uuid); + } + }); + } + + @override + Widget build(BuildContext context) { + if (_pillars.isEmpty) { + return _buildEmptyState(); + } + return ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: _pillars.length, + separatorBuilder: (_, __) => const SizedBox(height: 32), + itemBuilder: (context, index) => _buildPillarSection(_pillars[index]), + ); + } + + // ──────────────────────────── Pillar Section ────────────────────────────── + + Widget _buildPillarSection(Category pillar) { + final pillarId = pillar.uuid.getOrCrash(); + final isCollapsed = _collapsed.contains(pillarId); + final children = _childrenOf(pillar); + + // Compute aggregated budget for display + double totalBudget = 0; + for (final child in children) { + totalBudget += _sumBudgetUnder(child); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildPillarHeader(pillar, totalBudget, isCollapsed, children.isEmpty), + if (!isCollapsed && children.isNotEmpty) ...[ + const SizedBox(height: 8), + _buildChildrenContainer(children), + ], + ], + ); + } + + Widget _buildPillarHeader( + Category pillar, + double totalBudget, + bool isCollapsed, + bool hasNoChildren, + ) { + final name = pillar.name.getOrCrash(); + final pillarId = pillar.uuid.getOrCrash(); + + return InkWell( + onTap: () { + _togglePillar(pillarId); + widget.onPillarTap?.call(pillar); + }, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + // Pillar icon container + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: const Color(0xFFE1E3E4), // surface-container-highest + 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: 18, + fontWeight: FontWeight.w800, + color: const Color(0xFF191C1D), + ), + ), + _buildPillarChip(pillar), + ], + ), + ), + // Budget + collapse indicator + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '\$${totalBudget.toStringAsFixed(0)}', + style: GoogleFonts.manrope( + fontSize: 15, + fontWeight: FontWeight.w700, + color: const Color(0xFF191C1D), + ), + ), + Text( + 'ALLOCATED', + style: GoogleFonts.inter( + fontSize: 9, + fontWeight: FontWeight.w500, + letterSpacing: 1.5, + color: const Color(0xFF757682), + ), + ), + ], + ), + if (!hasNoChildren) ...[ + const SizedBox(width: 8), + AnimatedRotation( + turns: isCollapsed ? 0 : 0.5, + duration: const Duration(milliseconds: 250), + child: const Icon( + Icons.keyboard_arrow_down, + size: 20, + color: Color(0xFF757682), + ), + ), + ], + ], + ), + ), + ); + } + + Widget _buildPillarChip(Category pillar) { + // Reuse existing pillar names to drive chip color + final name = pillar.name.getOrCrash().toLowerCase(); + Color chipBg; + Color chipText; + if (name.contains('essential') || name.contains('need')) { + chipBg = const Color(0xFFA0F399); // secondary-container + chipText = const Color(0xFF217128); // on-secondary-container + } else if (name.contains('lifestyle') || name.contains('life')) { + chipBg = const Color(0xFF002366); // primary-container + chipText = const Color(0xFF758DD5); // on-primary-container + } else { + chipBg = const Color(0xFF5A0006); // tertiary-container + chipText = const Color(0xFFFF524C); // on-tertiary-container + } + + return Container( + margin: const EdgeInsets.only(top: 3), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: chipBg, + borderRadius: BorderRadius.circular(99), + ), + child: Text( + pillar.name.getOrCrash().toUpperCase(), + style: GoogleFonts.inter( + fontSize: 9, + fontWeight: FontWeight.w700, + letterSpacing: 0.8, + color: chipText, + ), + ), + ); + } + + // ─────────────────────────── Children Container ─────────────────────────── + + /// The indented list with the `stagger-line` + `stagger-line-item` visual. + Widget _buildChildrenContainer(List children) { + return Padding( + padding: const EdgeInsets.only(left: 20), + child: CustomPaint( + painter: _StaggerLinePainter(), + child: Padding( + padding: const EdgeInsets.only(left: 20, top: 8), + child: Column( + children: [ + for (int i = 0; i < children.length; i++) ...[ + if (i > 0) const SizedBox(height: 12), + _StaggerLineItemWrapper( + child: _buildChildRow(children[i]), + ), + ], + ], + ), + ), + ), + ); + } + + Widget _buildChildRow(Category child) { + final name = child.name.getOrCrash(); + final budget = _sumBudgetUnder(child); + + return InkWell( + onTap: () => widget.onChildTap?.call(child), + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFFFFFFF), // surface-container-lowest + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: const Color(0xFFF3F4F5), // surface-container-low + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + _iconForChild(name), + size: 16, + color: const Color(0xFF757682), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: GoogleFonts.manrope( + fontSize: 13, + fontWeight: FontWeight.w600, + color: const Color(0xFF191C1D), + ), + ), + Text( + child.behavioralModifier.name.toUpperCase(), + style: GoogleFonts.inter( + fontSize: 9, + fontWeight: FontWeight.w500, + letterSpacing: 1.5, + color: const Color(0xFF757682), + ), + ), + ], + ), + ), + Text( + '\$${budget.toStringAsFixed(0)}', + style: GoogleFonts.manrope( + fontSize: 13, + fontWeight: FontWeight.w700, + color: const Color(0xFF00113A), + ), + ), + ], + ), + ), + ); + } + + // ─────────────────────────────── Helpers ────────────────────────────────── + + /// Recursively sums expected monthly budgets for [category] and all its + /// descendants (so Pillars/Sub-Parents show aggregated totals). + double _sumBudgetUnder(Category category) { + final directChildren = _childrenOf(category); + if (directChildren.isEmpty) { + return category.expectedMonthlyBudget; + } + return directChildren.fold(0, (sum, c) => sum + _sumBudgetUnder(c)); + } + + IconData _iconForPillar(String name) { + final lower = name.toLowerCase(); + if (lower.contains('essential') || lower.contains('need')) { + 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.account_balance_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')) { + 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; + } + + Widget _buildEmptyState() { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 48), + child: Text( + 'No envelopes yet', + style: GoogleFonts.inter( + fontSize: 14, + color: const Color(0xFF757682), + ), + ), + ), + ); + } +} + +// ─────────────────── Stagger Line Painters ──────────────────────────────── + +/// Draws the vertical connector line matching `.stagger-line::before` in CSS. +class _StaggerLinePainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = const Color(0xFFC5C6D2).withValues(alpha: 0.4) + ..strokeWidth = 1.5 + ..style = PaintingStyle.stroke; + + // Vertical line from top to near bottom (leave ~24px gap at bottom) + canvas.drawLine( + Offset.zero, + Offset(0, size.height - 24), + paint, + ); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +/// Wraps each child row and draws the horizontal connector matching +/// `.stagger-line-item::after` in CSS. +class _StaggerLineItemWrapper extends StatelessWidget { + const _StaggerLineItemWrapper({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _HorizontalConnectorPainter(), + child: child, + ); + } +} + +class _HorizontalConnectorPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = const Color(0xFFC5C6D2).withValues(alpha: 0.4) + ..strokeWidth = 1.5 + ..style = PaintingStyle.stroke; + + // Horizontal connector from left edge to the child card + canvas.drawLine( + const Offset(-20, 16), + const Offset(0, 16), + paint, + ); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} diff --git a/lib/features/category/presentation/widgets/hierarchy_insight_card.dart b/lib/features/category/presentation/widgets/hierarchy_insight_card.dart new file mode 100644 index 0000000..8aa35c2 --- /dev/null +++ b/lib/features/category/presentation/widgets/hierarchy_insight_card.dart @@ -0,0 +1,138 @@ +import 'package:expense_tracker/features/category/domain/entities/category.dart'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +/// Inline summary card displayed at the bottom of the creation form. +/// +/// Shows the user where the new envelope will sit in the hierarchy +/// (Pillar > Sub-Parent) and its relative percentage of total budget. +/// +/// Mirrors the "Hierarchy Insight" card in the HTML mockup +/// (category-new-design.html, lines 684–699). +class HierarchyInsightCard extends StatelessWidget { + const HierarchyInsightCard({ + required this.envelopeName, + required this.pillar, + required this.subParent, + required this.totalBudget, + required this.envelopeBudget, + super.key, + }); + + /// The name being entered for the new envelope. + final String envelopeName; + + /// The selected Level 1 pillar category. + final Category? pillar; + + /// The selected Level 2 sub-parent category. + final Category? subParent; + + /// The total budget across all envelopes (for percentage calculation). + final double totalBudget; + + /// The budget value entered for the new envelope. + final double envelopeBudget; + + // Design tokens from HTML mockup + static const _primaryColor = Color(0xFF00113A); + static const _outlineVariant = Color(0xFFC5C6D2); + + String get _hierarchyPath { + final pillarName = pillar?.name.getOrCrash() ?? '—'; + final subParentName = subParent?.name.getOrCrash() ?? '—'; + return '$pillarName > $subParentName'; + } + + String get _percentageText { + if (totalBudget <= 0 || envelopeBudget <= 0) return ''; + final pct = ((envelopeBudget / totalBudget) * 100).toStringAsFixed(0); + return ' It represents $pct% of your planned spending.'; + } + + @override + Widget build(BuildContext context) { + final name = + envelopeName.isNotEmpty ? "'$envelopeName'" : 'The new envelope'; + + return Container( + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: _outlineVariant.withValues(alpha: 0.4)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 20, + offset: const Offset(0, 4), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildIcon(), + const SizedBox(width: 16), + Expanded(child: _buildTextContent(name)), + ], + ), + ), + ); + } + + Widget _buildIcon() { + return Container( + width: 40, + height: 40, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF00113A), Color(0xFF002366)], + ), + shape: BoxShape.circle, + ), + child: const Icon(Icons.hub_outlined, color: Colors.white, size: 18), + ); + } + + Widget _buildTextContent(String name) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hierarchy Insight', + style: GoogleFonts.manrope( + fontSize: 13, + fontWeight: FontWeight.w700, + color: const Color(0xFF00113A), + ), + ), + const SizedBox(height: 4), + RichText( + text: TextSpan( + style: GoogleFonts.inter( + fontSize: 12, + color: const Color(0xFF444650), // on-surface-variant + height: 1.5, + ), + children: [ + TextSpan(text: '$name will be nested under '), + TextSpan( + text: _hierarchyPath, + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: _primaryColor, + ), + ), + TextSpan(text: '.$_percentageText'), + ], + ), + ), + ], + ); + } +} diff --git a/lib/features/category/presentation/widgets/portfolio_distribution_card.dart b/lib/features/category/presentation/widgets/portfolio_distribution_card.dart new file mode 100644 index 0000000..080b25e --- /dev/null +++ b/lib/features/category/presentation/widgets/portfolio_distribution_card.dart @@ -0,0 +1,246 @@ +import 'package:expense_tracker/features/category/domain/entities/category.dart'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +/// Dark-blue gradient card showing the total allocated budget and a +/// three-segment distribution bar for Essential, Lifestyle, and Growth. +/// +/// Mirrors the `glass-gradient` + progress bar pattern from the HTML mockup +/// (category-new-design.html, lines 921–956). +class PortfolioDistributionCard extends StatelessWidget { + const PortfolioDistributionCard({ + required this.pillars, + required this.pillarBudgets, + required this.totalBudget, + super.key, + }); + + /// The root-level pillars (Level 1 categories, typically Essential, + /// Lifestyle, and Growth for expense type). + final List pillars; + + /// Maps each pillar's UUID string to its aggregated budget. + final Map pillarBudgets; + + /// Total allocated budget across all envelopes. + final double totalBudget; + + // Design token colours from the HTML mockup. + static const _cardGradientStart = Color(0xFF00113A); // primary + static const _cardGradientEnd = Color(0xFF002366); // primary-container + + // Segment colours matching the HTML mockup distribution bar: + // Essential → secondary-fixed (#a3f69c) + // Lifestyle → primary-fixed-dim (#b3c5ff) + // Growth → on-tertiary-container (#ff524c) + static const _essentialColor = Color(0xFF88D982); // secondary-fixed-dim + static const _lifestyleColor = Color(0xFFB3C5FF); // primary-fixed-dim + static const _growthColor = Color(0xFFFF524C); // on-tertiary-container + + Color _segmentColor(int index) { + switch (index % 3) { + case 0: + return _essentialColor; + case 1: + return _lifestyleColor; + case 2: + default: + return _growthColor; + } + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [_cardGradientStart, _cardGradientEnd], + ), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: _cardGradientStart.withValues(alpha: 0.3), + blurRadius: 24, + offset: const Offset(0, 8), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Stack( + children: [ + // Shimmer sheen overlay (static; animation omitted for widget + // isolation — the page can wrap with AnimatedContainer if desired). + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Colors.transparent, + Colors.white.withValues(alpha: 0.05), + Colors.transparent, + ], + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(), + const SizedBox(height: 24), + _buildTotalBudgetRow(), + const SizedBox(height: 20), + _buildDistributionBar(), + const SizedBox(height: 16), + _buildLegend(), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildHeader() { + return Text( + 'Portfolio Distribution', + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w600, + letterSpacing: 2, + color: Colors.white.withValues(alpha: 0.7), + ), + ); + } + + Widget _buildTotalBudgetRow() { + return Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Text( + '\$${totalBudget.toStringAsFixed(2)}', + style: GoogleFonts.manrope( + fontSize: 28, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ), + // Decorative bar chart icon (mirrors HTML bars: w-1.5 h-6/10/4) + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + _buildMiniBar(height: 16, opacity: 0.2), + const SizedBox(width: 3), + _buildMiniBar(height: 28, opacity: 1), + const SizedBox(width: 3), + _buildMiniBar(height: 10, opacity: 0.4), + ], + ), + ], + ); + } + + Widget _buildMiniBar({required double height, required double opacity}) { + return Container( + width: 4, + height: height, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: opacity), + borderRadius: BorderRadius.circular(99), + ), + ); + } + + Widget _buildDistributionBar() { + if (totalBudget == 0 || pillars.isEmpty) { + // Empty state: single grey bar + return Container( + height: 8, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(99), + ), + ); + } + + return ClipRRect( + borderRadius: BorderRadius.circular(99), + child: SizedBox( + height: 8, + child: Row( + children: [ + for (int i = 0; i < pillars.length; i++) ...[ + Builder( + builder: (context) { + final pillarId = pillars[i].uuid.getOrCrash(); + final budget = pillarBudgets[pillarId] ?? 0.0; + final fraction = + totalBudget > 0 ? (budget / totalBudget) : 0.0; + return Flexible( + flex: (fraction * 1000).round().clamp(1, 1000), + child: Container(color: _segmentColor(i)), + ); + }, + ), + ], + ], + ), + ), + ); + } + + Widget _buildLegend() { + if (pillars.isEmpty) return const SizedBox.shrink(); + + return Row( + children: [ + for (int i = 0; i < pillars.length; i++) ...[ + if (i > 0) const SizedBox(width: 8), + Expanded(child: _buildLegendItem(pillars[i], i)), + ], + ], + ); + } + + Widget _buildLegendItem(Category pillar, int index) { + final pillarId = pillar.uuid.getOrCrash(); + final budget = pillarBudgets[pillarId] ?? 0.0; + final pct = totalBudget > 0 ? ((budget / totalBudget) * 100).round() : 0; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + pillar.name.getOrCrash().toUpperCase(), + style: GoogleFonts.inter( + fontSize: 9, + fontWeight: FontWeight.w500, + letterSpacing: 1.5, + color: Colors.white.withValues(alpha: 0.6), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + '$pct%', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ], + ); + } +} diff --git a/lib/features/transaction/presentation/pages/transaction_entry_page.dart b/lib/features/transaction/presentation/pages/transaction_entry_page.dart index 520d76e..eaddcfb 100644 --- a/lib/features/transaction/presentation/pages/transaction_entry_page.dart +++ b/lib/features/transaction/presentation/pages/transaction_entry_page.dart @@ -147,11 +147,11 @@ class _TransactionEntryPageState extends State { itemBuilder: (context, index) { final category = categories[index]; final String subtitle; - if (category.parentUuid != null) { + if (category.parentId != null) { final parent = categories.firstWhere( (c) => c.uuid.getOrCrash() == - category.parentUuid!.getOrCrash(), + category.parentId!.getOrCrash(), orElse: () => category, ); subtitle = parent.name.getOrCrash(); diff --git a/lib/features/transaction/presentation/widgets/transaction_card.dart b/lib/features/transaction/presentation/widgets/transaction_card.dart index e24ecf2..88fef8f 100644 --- a/lib/features/transaction/presentation/widgets/transaction_card.dart +++ b/lib/features/transaction/presentation/widgets/transaction_card.dart @@ -25,6 +25,9 @@ class TransactionCard extends StatelessWidget { name: StringSingleLine('Uncategorized'), isSynced: false, updatedAt: DateTime.now(), + type: CategoryType.expense, + expectedMonthlyBudget: 0, + behavioralModifier: BehavioralModifier.active, ), ); final categoryName = category.name.getOrCrash(); diff --git a/pubspec.lock b/pubspec.lock index 35b643a..67efac8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1132,4 +1132,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.11.0 <4.0.0" - flutter: ">=3.35.0" + flutter: ">=3.38.0" diff --git a/test/features/category/data/models/category_model_test.dart b/test/features/category/data/models/category_model_test.dart index 128b65c..d48750d 100644 --- a/test/features/category/data/models/category_model_test.dart +++ b/test/features/category/data/models/category_model_test.dart @@ -6,7 +6,7 @@ import 'package:flutter_test/flutter_test.dart'; void main() { group('CategoryModel', () { final tUuid = UniqueId('550e8400-e29b-41d4-a716-446655440000'); - final tParentUuid = UniqueId('550e8400-e29b-41d4-a716-446655440001'); + final tParentId = UniqueId('550e8400-e29b-41d4-a716-446655440001'); final tDate = DateTime(2026, 6, 3); final tCategory = Category( @@ -14,7 +14,10 @@ void main() { name: StringSingleLine('Food'), isSynced: false, updatedAt: tDate, - parentUuid: tParentUuid, + parentId: tParentId, + type: CategoryType.expense, + expectedMonthlyBudget: 1500, + behavioralModifier: BehavioralModifier.active, ); test('should map from entity correctly', () { @@ -24,7 +27,10 @@ void main() { expect(model.name, 'Food'); expect(model.isSynced, false); expect(model.updatedAt, tDate); - expect(model.parentUuid, tParentUuid.getOrCrash()); + expect(model.parentId, tParentId.getOrCrash()); + expect(model.type, CategoryType.expense); + expect(model.expectedMonthlyBudget, 1500); + expect(model.behavioralModifier, BehavioralModifier.active); }); test('should map to entity correctly', () { @@ -33,26 +39,36 @@ void main() { ..name = 'Food' ..isSynced = false ..updatedAt = tDate - ..parentUuid = tParentUuid.getOrCrash(); + ..parentId = tParentId.getOrCrash() + ..type = CategoryType.expense + ..expectedMonthlyBudget = 1500 + ..behavioralModifier = BehavioralModifier.active; final entity = model.toEntity(); expect(entity, tCategory); }); - test('should handle null parentUuid correctly', () { + test('should handle null parentId correctly', () { final rootCategory = Category( uuid: tUuid, name: StringSingleLine('Root'), isSynced: true, updatedAt: tDate, + type: CategoryType.income, + expectedMonthlyBudget: 500, + behavioralModifier: BehavioralModifier.recurring, ); final model = CategoryModel.fromEntity(rootCategory); - expect(model.parentUuid, isNull); + expect(model.parentId, isNull); + expect(model.type, CategoryType.income); + expect(model.expectedMonthlyBudget, 500); + expect(model.behavioralModifier, BehavioralModifier.recurring); final entity = model.toEntity(); - expect(entity.parentUuid, isNull); + expect(entity.parentId, isNull); + expect(entity, rootCategory); }); }); } diff --git a/test/features/category/data/repositories/category_repository_impl_test.dart b/test/features/category/data/repositories/category_repository_impl_test.dart index 7cc10c5..2e33e2f 100644 --- a/test/features/category/data/repositories/category_repository_impl_test.dart +++ b/test/features/category/data/repositories/category_repository_impl_test.dart @@ -26,7 +26,7 @@ void main() { group('saveCategory', () { final tUuid = UniqueId('550e8400-e29b-41d4-a716-446655440000'); - final tParentUuid = UniqueId('550e8400-e29b-41d4-a716-446655440001'); + final tParentId = UniqueId('550e8400-e29b-41d4-a716-446655440001'); final tDate = DateTime(2026, 6, 3); final tCategory = Category( @@ -34,7 +34,10 @@ void main() { name: StringSingleLine('Food'), isSynced: false, updatedAt: tDate, - parentUuid: tParentUuid, + parentId: tParentId, + type: CategoryType.expense, + expectedMonthlyBudget: 1000, + behavioralModifier: BehavioralModifier.active, ); test('should return Right(unit) when saving a root category', () async { @@ -43,6 +46,9 @@ void main() { name: StringSingleLine('Root'), isSynced: false, updatedAt: tDate, + type: CategoryType.expense, + expectedMonthlyBudget: 0, + behavioralModifier: BehavioralModifier.active, ); when(() => mockDataSource.saveCategory(any())) @@ -65,7 +71,7 @@ void main() { final result = await repository.saveCategory(tCategory); expect(result, const Right(unit)); - verify(() => mockDataSource.getCategoryByUuid(tParentUuid.getOrCrash())) + verify(() => mockDataSource.getCategoryByUuid(tParentId.getOrCrash())) .called(1); verify(() => mockDataSource.saveCategory(any())).called(1); }); @@ -84,7 +90,7 @@ void main() { Failure.localFailure(message: 'Parent category not found'), ), ); - verify(() => mockDataSource.getCategoryByUuid(tParentUuid.getOrCrash())) + verify(() => mockDataSource.getCategoryByUuid(tParentId.getOrCrash())) .called(1); verifyNever(() => mockDataSource.saveCategory(any())); }); @@ -113,7 +119,10 @@ void main() { ..uuid = '550e8400-e29b-41d4-a716-446655440002' ..name = 'C1' ..isSynced = false - ..updatedAt = DateTime.now(), + ..updatedAt = DateTime.now() + ..type = CategoryType.expense + ..expectedMonthlyBudget = 0 + ..behavioralModifier = BehavioralModifier.active, ]; when(() => mockDataSource.watchCategories()) .thenAnswer((_) => Stream.value(models)); diff --git a/test/features/category/domain/entities/category_test.dart b/test/features/category/domain/entities/category_test.dart index 3fe96fb..9ba3c03 100644 --- a/test/features/category/domain/entities/category_test.dart +++ b/test/features/category/domain/entities/category_test.dart @@ -10,37 +10,49 @@ void main() { name: StringSingleLine('Food'), isSynced: false, updatedAt: DateTime.utc(2026, 6, 3), + type: CategoryType.expense, + expectedMonthlyBudget: 500, + behavioralModifier: BehavioralModifier.active, ); expect(category.isRoot, isTrue); - expect(category.parentUuid, isNull); + expect(category.parentId, isNull); expect(category.props, [ category.uuid, category.name, null, false, DateTime.utc(2026, 6, 3), + CategoryType.expense, + 500.0, + BehavioralModifier.active, ]); }); test('represents a child category', () { - final parentUuid = UniqueId('a7d7b2f7-4d18-43f0-bf8f-4f3f6d9f4b3b'); + final parentId = UniqueId('a7d7b2f7-4d18-43f0-bf8f-4f3f6d9f4b3b'); final category = Category( uuid: UniqueId('b3f76e40-4f1f-4c40-9d16-1c3a4f8a0f11'), name: StringSingleLine('Groceries'), - parentUuid: parentUuid, + parentId: parentId, isSynced: true, updatedAt: DateTime.utc(2026, 6, 3, 12), + type: CategoryType.expense, + expectedMonthlyBudget: 200, + behavioralModifier: BehavioralModifier.active, ); expect(category.isRoot, isFalse); - expect(category.parentUuid, parentUuid); + expect(category.parentId, parentId); expect(category.props, [ category.uuid, category.name, - parentUuid, + parentId, true, DateTime.utc(2026, 6, 3, 12), + CategoryType.expense, + 200.0, + BehavioralModifier.active, ]); }); }); diff --git a/test/features/category/domain/usecases/save_category_usecase_test.dart b/test/features/category/domain/usecases/save_category_usecase_test.dart index 2cf5e0e..bf73d66 100644 --- a/test/features/category/domain/usecases/save_category_usecase_test.dart +++ b/test/features/category/domain/usecases/save_category_usecase_test.dart @@ -25,6 +25,9 @@ void main() { name: StringSingleLine('Food'), isSynced: false, updatedAt: DateTime.utc(2026, 6, 3), + type: CategoryType.expense, + expectedMonthlyBudget: 0, + behavioralModifier: BehavioralModifier.active, ); when(() => repository.saveCategory(category)).thenAnswer( (_) async => right(unit), diff --git a/test/features/category/domain/usecases/watch_categories_usecase_test.dart b/test/features/category/domain/usecases/watch_categories_usecase_test.dart index 54e7052..fdc32e1 100644 --- a/test/features/category/domain/usecases/watch_categories_usecase_test.dart +++ b/test/features/category/domain/usecases/watch_categories_usecase_test.dart @@ -37,6 +37,9 @@ void main() { name: StringSingleLine('Food'), isSynced: true, updatedAt: DateTime.utc(2026, 6, 3), + type: CategoryType.expense, + expectedMonthlyBudget: 0, + behavioralModifier: BehavioralModifier.active, ); final stream = Stream>>.value( right>([category]), diff --git a/test/features/category/presentation/widgets/category_widgets_test.dart b/test/features/category/presentation/widgets/category_widgets_test.dart new file mode 100644 index 0000000..285673d --- /dev/null +++ b/test/features/category/presentation/widgets/category_widgets_test.dart @@ -0,0 +1,341 @@ +import 'package:expense_tracker/features/category/domain/entities/category.dart'; +import 'package:expense_tracker/features/category/presentation/widgets/envelope_creation_form.dart'; +import 'package:expense_tracker/features/category/presentation/widgets/envelope_tree_list_view.dart'; +import 'package:expense_tracker/features/category/presentation/widgets/hierarchy_insight_card.dart'; +import 'package:expense_tracker/features/category/presentation/widgets/portfolio_distribution_card.dart'; +import 'package:expense_tracker/shared/domain/entities/value_objects.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +// Helpers ─────────────────────────────────────────────────────────────────── + +Category _makeCategory({ + required String uuid, + required String name, + String? parentId, + double budget = 0, + BehavioralModifier modifier = BehavioralModifier.active, +}) { + return Category( + uuid: UniqueId(uuid), + name: StringSingleLine(name), + isSynced: false, + updatedAt: DateTime(2026), + type: CategoryType.expense, + expectedMonthlyBudget: budget, + behavioralModifier: modifier, + parentId: parentId != null ? UniqueId(parentId) : null, + ); +} + +const _p1Id = '550e8400-e29b-41d4-a716-446655440001'; +const _p2Id = '550e8400-e29b-41d4-a716-446655440002'; +const _p3Id = '550e8400-e29b-41d4-a716-446655440003'; +const _c1Id = '550e8400-e29b-41d4-a716-446655440004'; +const _c2Id = '550e8400-e29b-41d4-a716-446655440005'; + +final _essential = _makeCategory(uuid: _p1Id, name: 'Essential'); +final _lifestyle = _makeCategory(uuid: _p2Id, name: 'Lifestyle'); +final _growth = _makeCategory(uuid: _p3Id, name: 'Growth'); +final _mortgage = _makeCategory( + uuid: _c1Id, + name: 'Mortgage & Rent', + parentId: _p1Id, + budget: 1200, +); +final _dining = _makeCategory( + uuid: _c2Id, + name: 'Dining', + parentId: _p2Id, + budget: 300, +); + +// ───────────────────────────────────────────────────────────────────────────── + +void main() { + // ── PortfolioDistributionCard ───────────────────────────────────────────── + group('PortfolioDistributionCard', () { + testWidgets('renders total budget amount', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: PortfolioDistributionCard( + pillars: [_essential, _lifestyle, _growth], + pillarBudgets: const {_p1Id: 1200, _p2Id: 300, _p3Id: 0}, + totalBudget: 1500, + ), + ), + ), + ); + + expect(find.text(r'$1500.00'), findsOneWidget); + }); + + testWidgets('shows "Portfolio Distribution" label', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: PortfolioDistributionCard( + pillars: [_essential], + pillarBudgets: const {_p1Id: 500}, + totalBudget: 500, + ), + ), + ), + ); + + expect(find.textContaining('Portfolio Distribution'), findsOneWidget); + }); + + testWidgets('renders without pillars (empty state)', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: PortfolioDistributionCard( + pillars: [], + pillarBudgets: {}, + totalBudget: 0, + ), + ), + ), + ); + + // No crash; label is still present + expect(find.textContaining('Portfolio Distribution'), findsOneWidget); + }); + }); + + // ── HierarchyInsightCard ───────────────────────────────────────────────── + group('HierarchyInsightCard', () { + testWidgets('shows hierarchy path in bold', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: HierarchyInsightCard( + envelopeName: 'Artisanal Coffee', + pillar: _lifestyle, + subParent: _dining, + totalBudget: 1500, + envelopeBudget: 120, + ), + ), + ), + ); + + expect(find.text('Hierarchy Insight'), findsOneWidget); + expect( + find.textContaining('Artisanal Coffee', findRichText: true), + findsOneWidget, + ); + expect( + find.textContaining('Lifestyle > Dining', findRichText: true), + findsOneWidget, + ); + }); + + testWidgets('shows percentage when budget is set', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: HierarchyInsightCard( + envelopeName: 'Coffee', + pillar: _lifestyle, + subParent: _dining, + totalBudget: 1000, + envelopeBudget: 100, // 10% + ), + ), + ), + ); + + expect(find.textContaining('10%', findRichText: true), findsOneWidget); + }); + + testWidgets('handles null pillar/subParent gracefully', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: HierarchyInsightCard( + envelopeName: '', + pillar: null, + subParent: null, + totalBudget: 0, + envelopeBudget: 0, + ), + ), + ), + ); + + // Uses em-dashes for missing pillars + expect(find.textContaining('—', findRichText: true), findsWidgets); + }); + }); + + // ── EnvelopeCreationForm ───────────────────────────────────────────────── + group('EnvelopeCreationForm', () { + late TextEditingController nameCtrl; + late TextEditingController budgetCtrl; + + setUp(() { + nameCtrl = TextEditingController(); + budgetCtrl = TextEditingController(); + }); + + tearDown(() { + nameCtrl.dispose(); + budgetCtrl.dispose(); + }); + + testWidgets('renders Expense / Income toggles', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: EnvelopeCreationForm( + selectedType: CategoryType.expense, + availablePillars: [_essential, _lifestyle, _growth], + selectedPillar: null, + selectedModifier: BehavioralModifier.active, + nameController: nameCtrl, + budgetController: budgetCtrl, + onTypeChanged: (_) {}, + onPillarChanged: (_) {}, + onModifierChanged: (_) {}, + ), + ), + ), + ), + ); + + expect(find.text('Expense'), findsOneWidget); + expect(find.text('Income'), findsOneWidget); + }); + + testWidgets('fires onTypeChanged when Income tapped', (tester) async { + CategoryType? received; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: EnvelopeCreationForm( + selectedType: CategoryType.expense, + availablePillars: [_essential, _lifestyle, _growth], + selectedPillar: null, + selectedModifier: BehavioralModifier.active, + nameController: nameCtrl, + budgetController: budgetCtrl, + onTypeChanged: (t) => received = t, + onPillarChanged: (_) {}, + onModifierChanged: (_) {}, + ), + ), + ), + ), + ); + + await tester.tap(find.text('Income')); + expect(received, CategoryType.income); + }); + + testWidgets('renders modifier pills', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: EnvelopeCreationForm( + selectedType: CategoryType.expense, + availablePillars: const [], + selectedPillar: null, + selectedModifier: BehavioralModifier.passive, + nameController: nameCtrl, + budgetController: budgetCtrl, + onTypeChanged: (_) {}, + onPillarChanged: (_) {}, + onModifierChanged: (_) {}, + ), + ), + ), + ), + ); + + expect(find.text('ACTIVE'), findsOneWidget); + expect(find.text('PASSIVE'), findsOneWidget); + expect(find.text('RECURRING'), findsOneWidget); + }); + }); + + // ── EnvelopeTreeListView ───────────────────────────────────────────────── + group('EnvelopeTreeListView', () { + final allCategories = [ + _essential, + _lifestyle, + _growth, + _mortgage, + _dining, + ]; + + testWidgets('renders pillar names', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: EnvelopeTreeListView(allCategories: allCategories), + ), + ), + ), + ); + + expect(find.text('Essential'), findsOneWidget); + expect(find.text('Lifestyle'), findsOneWidget); + expect(find.text('Growth'), findsOneWidget); + }); + + testWidgets('renders child categories under pillars', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: EnvelopeTreeListView(allCategories: allCategories), + ), + ), + ), + ); + + expect(find.text('Mortgage & Rent'), findsOneWidget); + expect(find.text('Dining'), findsOneWidget); + }); + + testWidgets('collapses pillar section on tap', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: EnvelopeTreeListView(allCategories: allCategories), + ), + ), + ), + ); + + // Mortgage is visible before collapse + expect(find.text('Mortgage & Rent'), findsOneWidget); + + // Tap Essential pillar header to collapse + await tester.tap(find.text('Essential')); + await tester.pumpAndSettle(); + + expect(find.text('Mortgage & Rent'), findsNothing); + }); + + testWidgets('shows empty state when no categories', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: EnvelopeTreeListView(allCategories: []), + ), + ), + ); + + expect(find.textContaining('No envelopes yet'), findsOneWidget); + }); + }); +} diff --git a/test/features/transaction/presentation/blocs/transaction_cubit_test.dart b/test/features/transaction/presentation/blocs/transaction_cubit_test.dart index e092b9e..bf9b099 100644 --- a/test/features/transaction/presentation/blocs/transaction_cubit_test.dart +++ b/test/features/transaction/presentation/blocs/transaction_cubit_test.dart @@ -97,6 +97,9 @@ void main() { name: StringSingleLine('Food'), isSynced: false, updatedAt: DateTime.now(), + type: CategoryType.expense, + expectedMonthlyBudget: 0, + behavioralModifier: BehavioralModifier.active, ); blocTest(