Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion android/gradle.properties
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
org.gradle.jvmargs=-Xmx1536M
org.gradle.jvmargs=-Xmx4G
android.useAndroidX=true
android.enableJetifier=true
# This builtInKotlin flag was added automatically by Flutter migrator
Expand Down
30 changes: 23 additions & 7 deletions lib/app/router/app_router.dart
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import 'package:equatable/equatable.dart';
import 'package:expense_tracker/app/view/main_layout.dart';
import 'package:expense_tracker/features/category/presentation/pages/category_manage_page.dart';
import 'package:expense_tracker/features/counter/presentation/pages/counter_page.dart';
import 'package:expense_tracker/features/dashboard/presentation/blocs/dashboard_cubit.dart';
import 'package:expense_tracker/features/dashboard/presentation/pages/home_page.dart';
import 'package:expense_tracker/features/dashboard/presentation/pages/stats_coming_soon_page.dart';
import 'package:expense_tracker/features/easter_egg/presentation/blocs/easter_egg_cubit.dart';
import 'package:expense_tracker/features/settings/presentation/pages/settings_page.dart';
import 'package:expense_tracker/features/transaction/domain/entities/transaction.dart';
import 'package:expense_tracker/features/transaction/presentation/blocs/transaction_cubit.dart';
import 'package:expense_tracker/features/transaction/presentation/pages/transaction_entry_page.dart';
import 'package:expense_tracker/features/transaction/presentation/pages/transaction_history_page.dart';
import 'package:expense_tracker/injector.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';

Expand All @@ -21,7 +23,11 @@ class AppRouter extends Equatable {
List<Object?> get props => [home];
}

GoRouter router([String? initialLocation]) => GoRouter(
GoRouter router([
String? initialLocation,
bool Function()? isCounterUnlocked,
]) =>
GoRouter(
debugLogDiagnostics: kDebugMode || kProfileMode,
initialLocation: initialLocation ?? '/',
routes: [
Expand All @@ -44,11 +50,7 @@ GoRouter router([String? initialLocation]) => GoRouter(
GoRoute(
path: '/stats',
name: 'stats',
builder: (context, state) => const Scaffold(
body: Center(
child: Text('Coming Soon'),
),
),
builder: (context, state) => const StatsComingSoonPage(),
),
GoRoute(
path: '/settings',
Expand All @@ -73,5 +75,19 @@ GoRouter router([String? initialLocation]) => GoRouter(
name: 'categories',
builder: (context, state) => const CategoryManagePage(),
),
GoRoute(
path: '/counter',
name: 'counter',
// Locked users never reach the secret room — even via direct
// navigation or a future deep link.
redirect: (context, state) {
final isUnlocked = (isCounterUnlocked ??
() => getIt<EasterEggCubit>().state.progress.unlocked)()
? null
: '/settings';
return isUnlocked;
},
builder: (context, state) => const CounterPage(),
),
],
);
29 changes: 29 additions & 0 deletions lib/features/category/domain/extensions/category_tree.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import 'package:expense_tracker/features/category/domain/entities/category.dart';

extension CategoryTreeX on Category {
/// Sums expected monthly budgets across this category's subtree so
/// Pillars and Sub-Parents show aggregated totals.
///
/// A category's own budget counts only when it has no children — the
/// budget model expects allocations on leaves. The walk is capped at
/// [maxDepth] levels (Pillar → Sub-Parent → Envelope), which keeps
/// every surface in agreement and guards against infinite recursion
/// on malformed parent cycles.
double sumBudgetUnder(
List<Category> allCategories, {
int maxDepth = 3,
}) {
if (maxDepth <= 0) return 0;
final parentId = uuid.getOrCrash();
final directChildren = allCategories
.where((c) => c.parentId?.getOrCrash() == parentId)
.toList();
if (directChildren.isEmpty) {
return expectedMonthlyBudget;
}
return directChildren.fold(
0,
(sum, c) => sum + c.sumBudgetUnder(allCategories, maxDepth: maxDepth - 1),
);
}
}
131 changes: 89 additions & 42 deletions lib/features/category/presentation/pages/category_manage_page.dart
Original file line number Diff line number Diff line change
@@ -1,16 +1,47 @@
import 'package:expense_tracker/features/category/domain/entities/category.dart';
import 'package:expense_tracker/features/category/domain/extensions/category_tree.dart';
import 'package:expense_tracker/features/category/presentation/blocs/category_cubit.dart';
import 'package:expense_tracker/features/category/presentation/blocs/category_state.dart';
import 'package:expense_tracker/features/category/presentation/pages/category_form_page.dart';
import 'package:expense_tracker/features/category/presentation/widgets/envelope_tree_list_view.dart';
import 'package:expense_tracker/features/category/presentation/widgets/portfolio_distribution_card.dart';
import 'package:expense_tracker/features/easter_egg/presentation/blocs/easter_egg_cubit.dart';
import 'package:expense_tracker/injector.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:google_fonts/google_fonts.dart';

class CategoryManagePage extends StatelessWidget {
class CategoryManagePage extends StatefulWidget {
const CategoryManagePage({super.key});

@override
State<CategoryManagePage> createState() => _CategoryManagePageState();
}

class _CategoryManagePageState extends State<CategoryManagePage> {
@override
void initState() {
super.initState();
getIt<EasterEggCubit>().onCategoriesOpened();
}

/// The tree renders and aggregates three levels, so creation is capped
/// there too: the FAB is hidden on focused pages that are already at
/// level 3 (a level-4 envelope would be invisible in the tree). An
/// orphan parent (uuid missing from the list) also hides the FAB —
/// children under it would be unreachable in every surface.
bool _canCreateChild(Category? activeCategory, List<Category> all) {
if (activeCategory == null) return true;
if (activeCategory.parentId != null) {
final parentId = activeCategory.parentId!.getOrCrash();
final parentExists = all.any(
(c) => c.uuid.getOrCrash() == parentId,
);
if (!parentExists) return false;
}
return activeCategory.getHierarchyChain(all).length < 3;
}

IconData _iconForChild(String name) {
final lower = name.toLowerCase();
if (lower.contains('mortgage') ||
Expand All @@ -33,19 +64,6 @@ class CategoryManagePage extends StatelessWidget {
return Icons.category_outlined;
}

double _sumBudgetUnder(Category category, List<Category> allCategories) {
final directChildren = allCategories
.where((c) => c.parentId?.getOrCrash() == category.uuid.getOrCrash())
.toList();
if (directChildren.isEmpty) {
return category.expectedMonthlyBudget;
}
return directChildren.fold(
0,
(sum, c) => sum + _sumBudgetUnder(c, allCategories),
);
}

Future<void> _confirmDelete(
BuildContext context,
BuildContext blocContext,
Expand Down Expand Up @@ -200,6 +218,21 @@ class CategoryManagePage extends StatelessWidget {
.selectParent(child.uuid.getOrCrash());
}
},
onChildEdit: (child) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) =>
BlocProvider<CategoryCubit>.value(
value: blocContext.read<CategoryCubit>(),
child: CategoryFormPage(
categoryToEdit: child,
activeParentUuid:
child.parentId?.getOrCrash(),
),
),
),
);
},
onAddChild: (pillar) {
Navigator.of(context).push(
MaterialPageRoute<void>(
Expand Down Expand Up @@ -247,7 +280,10 @@ class CategoryManagePage extends StatelessWidget {
),
const SizedBox(height: 16),
if (state.currentViewCategories.isEmpty)
_buildEmptySubEnvelopesState(activeCategory)
_buildEmptySubEnvelopesState(
activeCategory,
state.allCategories,
)
else
...state.currentViewCategories.map(
(child) => Padding(
Expand All @@ -267,27 +303,30 @@ class CategoryManagePage extends StatelessWidget {
),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => BlocProvider<CategoryCubit>.value(
value: blocContext.read<CategoryCubit>(),
child: CategoryFormPage(
activeParentUuid: state.activeParentUuid,
),
),
),
);
},
backgroundColor: const Color(0xFF00113A),
foregroundColor: Colors.white,
label: Text(
'Add Envelope',
style: GoogleFonts.inter(fontWeight: FontWeight.bold),
),
icon: const Icon(Icons.add),
),
floatingActionButton:
_canCreateChild(activeCategory, state.allCategories)
? FloatingActionButton.extended(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => BlocProvider<CategoryCubit>.value(
value: blocContext.read<CategoryCubit>(),
child: CategoryFormPage(
activeParentUuid: state.activeParentUuid,
),
),
),
);
},
backgroundColor: const Color(0xFF00113A),
foregroundColor: Colors.white,
label: Text(
'Add Envelope',
style: GoogleFonts.inter(fontWeight: FontWeight.bold),
),
icon: const Icon(Icons.add),
)
: null,
),
);
},
Expand All @@ -307,7 +346,7 @@ class CategoryManagePage extends StatelessWidget {
)
: null;

final totalBudget = _sumBudgetUnder(activeCategory, state.allCategories);
final totalBudget = activeCategory.sumBudgetUnder(state.allCategories);

return Container(
padding: const EdgeInsets.all(20),
Expand Down Expand Up @@ -459,7 +498,7 @@ class CategoryManagePage extends StatelessWidget {
CategoryState state,
) {
final name = child.name.getOrCrash();
final budget = _sumBudgetUnder(child, state.allCategories);
final budget = child.sumBudgetUnder(state.allCategories);

return Container(
padding: const EdgeInsets.all(14),
Expand Down Expand Up @@ -552,7 +591,11 @@ class CategoryManagePage extends StatelessWidget {
);
}

Widget _buildEmptySubEnvelopesState(Category activeCategory) {
Widget _buildEmptySubEnvelopesState(
Category activeCategory,
List<Category> all,
) {
final isMaxDepth = activeCategory.getHierarchyChain(all).length >= 3;
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 40),
Expand All @@ -565,7 +608,7 @@ class CategoryManagePage extends StatelessWidget {
),
const SizedBox(height: 12),
Text(
'No sub-envelopes yet',
isMaxDepth ? 'Leaf envelope' : 'No sub-envelopes yet',
style: GoogleFonts.manrope(
fontSize: 15,
fontWeight: FontWeight.w600,
Expand All @@ -574,8 +617,12 @@ class CategoryManagePage extends StatelessWidget {
),
const SizedBox(height: 4),
Text(
'Nested envelopes inside "${activeCategory.name.getOrCrash()}" '
'will appear here.',
isMaxDepth
? '"${activeCategory.name.getOrCrash()}" is at the '
'deepest level and cannot contain nested envelopes.'
: 'Nested envelopes inside '
'"${activeCategory.name.getOrCrash()}" '
'will appear here.',
textAlign: TextAlign.center,
style: GoogleFonts.inter(
fontSize: 13,
Expand Down
Loading