From 4280bba7c5c3917f8089bf02434975da705398c0 Mon Sep 17 00:00:00 2001 From: Khesir Date: Sat, 30 May 2026 21:04:04 +0800 Subject: [PATCH 1/2] finished all tasks, added ai-scan reader --- .../local/month_plan_datasource_local.dart | 13 + .../datasources/month_plan_datasource.dart | 3 + .../budget/data/models/month_plan_model.dart | 25 +- .../month_plan_repository_impl.dart | 12 + .../budget/domain/entities/month_plan.dart | 23 +- .../repositories/month_plan_repository.dart | 3 + .../screens/budget_month_screen.dart | 129 ++- .../screens/budget_simple_sections.dart | 85 +- .../screens/budget_simple_view.dart | 222 ++++- .../sections/budget_screen_body.dart | 157 +++- .../sections/budget_summary_bar.dart | 96 +- .../presentation/sections/debt_section.dart | 30 +- .../presentation/sections/goal_section.dart | 35 +- .../sections/subscription_section.dart | 45 +- .../sheets/budget_settings_sheet.dart | 16 + .../sheets/start_planning_sheet.dart | 159 +++- .../presentation/widgets/all_summary_tab.dart | 623 ++++++++----- .../widgets/all_transaction_tab.dart | 194 +++- .../presentation/widgets/category_row.dart | 9 +- .../budget/presentation/widgets/debt_row.dart | 6 +- .../widgets/side_summary_panel.dart | 83 +- .../datasources/receipt_parser_service.dart | 79 ++ .../tabs/budget/budget_tab_screen.dart | 99 ++- .../tabs/dashboard/dashboard_insights.dart | 17 +- .../screens/tabs/dashboard/dashboard_tab.dart | 169 +++- .../create_transaction_sheet.dart | 67 +- .../transactions/scan_expenses_sheet.dart | 835 ++++++++++++++++++ .../state/month_plan_controller.dart | 10 + .../finance_module_screen.dart | 39 +- 29 files changed, 2689 insertions(+), 594 deletions(-) create mode 100644 lib/features/finance/modules/transaction/data/datasources/receipt_parser_service.dart create mode 100644 lib/features/finance/presentation/screens/transactions/scan_expenses_sheet.dart diff --git a/lib/features/finance/modules/budget/data/datasources/local/month_plan_datasource_local.dart b/lib/features/finance/modules/budget/data/datasources/local/month_plan_datasource_local.dart index 1cf83cf..7bd0e61 100644 --- a/lib/features/finance/modules/budget/data/datasources/local/month_plan_datasource_local.dart +++ b/lib/features/finance/modules/budget/data/datasources/local/month_plan_datasource_local.dart @@ -146,4 +146,17 @@ class MonthPlanDataSourceLocal implements MonthPlanDataSource { return plan; } } + + @override + Future closeMonthPlan(String id) async { + final data = await _cache.get(_box, id); + if (data == null) throw StateError('MonthPlan $id not found'); + final plan = MonthPlanModel.fromJson(data); + final closed = MonthPlanModel.fromJson({ + ...plan.toJson(), + 'status': 'closed', + }); + await _cache.put(_box, id, closed.toJson()); + return closed; + } } diff --git a/lib/features/finance/modules/budget/data/datasources/month_plan_datasource.dart b/lib/features/finance/modules/budget/data/datasources/month_plan_datasource.dart index 538b249..725a74d 100644 --- a/lib/features/finance/modules/budget/data/datasources/month_plan_datasource.dart +++ b/lib/features/finance/modules/budget/data/datasources/month_plan_datasource.dart @@ -20,4 +20,7 @@ abstract class MonthPlanDataSource { /// Get or create the plan for a custom budget profile. Future getOrCreatePlanForProfile(String profileId); + + /// Set the plan's status to closed. + Future closeMonthPlan(String id); } diff --git a/lib/features/finance/modules/budget/data/models/month_plan_model.dart b/lib/features/finance/modules/budget/data/models/month_plan_model.dart index 9e06374..b3f864a 100644 --- a/lib/features/finance/modules/budget/data/models/month_plan_model.dart +++ b/lib/features/finance/modules/budget/data/models/month_plan_model.dart @@ -1,7 +1,7 @@ import '../../domain/entities/month_plan.dart'; import 'budget_model.dart'; -/// MonthPlan model - DTO for Supabase +/// MonthPlan model - DTO for local cache class MonthPlanModel extends MonthPlan { MonthPlanModel({ super.id, @@ -9,13 +9,13 @@ class MonthPlanModel extends MonthPlan { super.budgetProfileId, super.userId, super.notes, + super.status = MonthPlanStatus.active, super.budgets = const [], super.budgetIds = const [], super.createdAt, super.updatedAt, }); - /// Create model from entity factory MonthPlanModel.fromEntity(MonthPlan plan) { return MonthPlanModel( id: plan.id, @@ -23,25 +23,31 @@ class MonthPlanModel extends MonthPlan { budgetProfileId: plan.budgetProfileId, userId: plan.userId, notes: plan.notes, + status: plan.status, budgets: plan.budgets, createdAt: plan.createdAt, updatedAt: plan.updatedAt, ); } - /// Create model from JSON (NestJS camelCase response, budgets loaded separately) factory MonthPlanModel.fromJson(Map json) { final rawIds = json['budgetIds']; final parsedIds = rawIds is List ? rawIds.map((e) => e.toString()).toList() : []; + final rawStatus = json['status'] as String?; + final status = rawStatus == 'closed' + ? MonthPlanStatus.closed + : MonthPlanStatus.active; + return MonthPlanModel( id: json['id'] as String?, month: json['month'] as String?, budgetProfileId: json['budgetProfileId'] as String?, userId: json['userId'] as String?, notes: json['notes'] as String?, + status: status, budgets: const [], budgetIds: parsedIds, createdAt: json['createdAt'] != null @@ -53,16 +59,6 @@ class MonthPlanModel extends MonthPlan { ); } - /// NestJS API request body - Map toApiJson() { - return { - if (month != null) 'month': month, - if (budgetProfileId != null) 'budgetProfileId': budgetProfileId, - if (notes != null) 'notes': notes, - }; - } - - /// Cache serialisation (local storage) Map toJson() { return { if (id != null) 'id': id, @@ -70,19 +66,20 @@ class MonthPlanModel extends MonthPlan { if (budgetProfileId != null) 'budgetProfileId': budgetProfileId, if (userId != null) 'userId': userId, if (notes != null) 'notes': notes, + 'status': status.name, 'budgetIds': budgetIds, if (createdAt != null) 'createdAt': createdAt!.toIso8601String(), if (updatedAt != null) 'updatedAt': updatedAt!.toIso8601String(), }; } - /// Return a new model with hydrated budgets attached MonthPlanModel withBudgets(List newBudgets) { return MonthPlanModel( id: id, month: month, userId: userId, notes: notes, + status: status, budgets: newBudgets, createdAt: createdAt, updatedAt: updatedAt, diff --git a/lib/features/finance/modules/budget/data/repositories/month_plan_repository_impl.dart b/lib/features/finance/modules/budget/data/repositories/month_plan_repository_impl.dart index 5374fec..5d305d4 100644 --- a/lib/features/finance/modules/budget/data/repositories/month_plan_repository_impl.dart +++ b/lib/features/finance/modules/budget/data/repositories/month_plan_repository_impl.dart @@ -137,4 +137,16 @@ class MonthPlanRepositoryImpl implements MonthPlanRepository { ); } } + + @override + Future> closeMonthPlan(String id) async { + try { + final plan = await dataSource.closeMonthPlan(id); + return Result.success(plan); + } catch (e) { + return Result.error( + UnknownFailure(message: 'Failed to close month plan: $e'), + ); + } + } } diff --git a/lib/features/finance/modules/budget/domain/entities/month_plan.dart b/lib/features/finance/modules/budget/domain/entities/month_plan.dart index b0400c0..c413379 100644 --- a/lib/features/finance/modules/budget/domain/entities/month_plan.dart +++ b/lib/features/finance/modules/budget/domain/entities/month_plan.dart @@ -1,20 +1,28 @@ import 'budget.dart'; +enum MonthPlanStatus { + active, + closed; + + String get displayName => switch (this) { + MonthPlanStatus.active => 'Active', + MonthPlanStatus.closed => 'Closed', + }; +} + /// MonthPlan entity - Parent container for all budget groups in a given month. -/// -/// A MonthPlan groups all Budget objects (income and expense) for a single -/// YYYY-MM month, enabling month-level planning and copying. class MonthPlan { final String? id; final String? month; // YYYY-MM for monthly plans; null for profile plans final String? budgetProfileId; // null for monthly plans; set for profile plans final String? userId; final String? notes; - final List budgets; // All budgets for this month + final MonthPlanStatus status; + final List budgets; final DateTime? createdAt; final DateTime? updatedAt; - final List budgetIds; // Raw IDs from month_plan.budgetIds + final List budgetIds; const MonthPlan({ this.id, @@ -22,12 +30,15 @@ class MonthPlan { this.budgetProfileId, this.userId, this.notes, + this.status = MonthPlanStatus.active, this.budgets = const [], this.budgetIds = const [], this.createdAt, this.updatedAt, }); + bool get isClosed => status == MonthPlanStatus.closed; + /// Total planned income across all income budgets double get totalPlannedIncome => budgets.fold(0.0, (sum, b) => sum + b.totalBudgetedIncome); @@ -62,6 +73,7 @@ class MonthPlan { String? budgetProfileId, String? userId, String? notes, + MonthPlanStatus? status, List? budgets, List? budgetIds, DateTime? createdAt, @@ -73,6 +85,7 @@ class MonthPlan { budgetProfileId: budgetProfileId ?? this.budgetProfileId, userId: userId ?? this.userId, notes: notes ?? this.notes, + status: status ?? this.status, budgets: budgets ?? this.budgets, budgetIds: budgetIds ?? this.budgetIds, createdAt: createdAt ?? this.createdAt, diff --git a/lib/features/finance/modules/budget/domain/repositories/month_plan_repository.dart b/lib/features/finance/modules/budget/domain/repositories/month_plan_repository.dart index f7d55a3..23315c7 100644 --- a/lib/features/finance/modules/budget/domain/repositories/month_plan_repository.dart +++ b/lib/features/finance/modules/budget/domain/repositories/month_plan_repository.dart @@ -39,4 +39,7 @@ abstract class MonthPlanRepository { /// Get or create the plan for a custom budget profile. Future> getOrCreatePlanForProfile(String profileId); + + /// Set the plan's status to closed. + Future> closeMonthPlan(String id); } diff --git a/lib/features/finance/modules/budget/presentation/screens/budget_month_screen.dart b/lib/features/finance/modules/budget/presentation/screens/budget_month_screen.dart index 28da925..a4ff598 100644 --- a/lib/features/finance/modules/budget/presentation/screens/budget_month_screen.dart +++ b/lib/features/finance/modules/budget/presentation/screens/budget_month_screen.dart @@ -32,8 +32,10 @@ import '../sheets/add_category_sheet.dart'; import '../sheets/add_debts_sheet.dart'; import '../sheets/add_subscription_sheet.dart'; import 'package:keep_track/features/finance/presentation/screens/configuration/goals/widgets/goals_management_dialog.dart'; +import 'package:keep_track/features/auth/presentation/state/auth_controller.dart'; +import 'package:keep_track/features/finance/modules/finance_category/domain/entities/finance_category_enums.dart'; import 'package:keep_track/features/finance/modules/budget/presentation/screens/budget_simple_sheets.dart' - show GoalDetailSheet; + show GoalDetailSheet, DebtDetailSheet, SubDetailSheet; import '../sheets/category_detail_sheet.dart'; import '../sheets/commitment_sheet.dart'; import '../sheets/create_group_sheet.dart'; @@ -46,9 +48,18 @@ import '../widgets/debt_detail_content.dart'; class BudgetMonthScreen extends ScopedScreen { final VoidCallback? onToggleView; final VoidCallback? onOpenSettings; + final VoidCallback? onBack; final String? budgetProfileId; + final bool profileIsMonthly; - const BudgetMonthScreen({super.key, this.onToggleView, this.onOpenSettings, this.budgetProfileId}); + const BudgetMonthScreen({ + super.key, + this.onToggleView, + this.onOpenSettings, + this.onBack, + this.budgetProfileId, + this.profileIsMonthly = false, + }); @override State createState() => _BudgetMonthScreenState(); @@ -198,7 +209,10 @@ class _BudgetMonthScreenState extends ScopedScreenState { onCreateGroup: _showCreateGroupSheet, onToggleView: widget.onToggleView, onOverrideSettings: widget.onOpenSettings, + onBack: widget.onBack, + onBackToCurrentMonth: _backToCurrentMonth, budgetProfileId: widget.budgetProfileId, + profileIsMonthly: widget.profileIsMonthly, onStartPlanning: _showStartPlanningSheet, onDeletePlan: _confirmDeletePlan, onShowCommitments: _showCommitmentsSheet, @@ -207,6 +221,7 @@ class _BudgetMonthScreenState extends ScopedScreenState { onAddDebt: (isReceivable) => _showAddDebtSheet(isReceivable: isReceivable), onAddSubscription: _showAddSubscriptionSheet, onPaySubscription: _paySubscription, + onSubscriptionTap: _showSubDetail, onAddGoal: _showAddGoalSheet, onGoalTap: _showGoalDetailSheet, selectedTab: _selectedTab, @@ -225,10 +240,11 @@ class _BudgetMonthScreenState extends ScopedScreenState { loadingBuilder: (_) => const Center(child: CircularProgressIndicator()), errorBuilder: (_, msg) => Center(child: Text('Error: $msg')), ), - floatingActionButton: _buildFab(), ); } + // keep for potential standalone use + // ignore: unused_element Widget _buildFab() { return FloatingActionButton.extended( onPressed: () => CreateTransactionSheet.show( @@ -283,6 +299,18 @@ extension BudgetMonthHelpers on _BudgetMonthScreenState { _loadMonthTransactions(); } + void _backToCurrentMonth() { + final now = DateTime.now(); + setState(() { + _currentMonth = now; + _selectedGroup = null; + _selectedCategory = null; + _selectedCategoryGroup = null; + _selectedDebt = null; + }); + _loadMonthTransactions(); + } + void _loadMonthTransactions() { final start = DateTime(_currentMonth.year, _currentMonth.month, 1); final end = DateTime( @@ -447,14 +475,15 @@ extension BudgetMonthDialogSheets on _BudgetMonthScreenState { /// Shown when no budget exists for the month — creates the MonthPlan first, /// then offers "Copy from previous month" or "Start fresh". void _showStartPlanningSheet(List allBudgets) { - final prevBudgets = allBudgets - .where( - (b) => - b.month == _prevMonthKey && - b.periodType == BudgetPeriodType.monthly && - b.status == BudgetStatus.active, - ) - .toList(); + final prevBudgets = widget.profileIsMonthly && widget.budgetProfileId != null + ? allBudgets.where((b) => + b.budgetProfileId == widget.budgetProfileId && + b.month == _prevMonthKey && + b.status == BudgetStatus.active).toList() + : allBudgets.where((b) => + b.month == _prevMonthKey && + b.periodType == BudgetPeriodType.monthly && + b.status == BudgetStatus.active).toList(); showModalBottomSheet( context: context, @@ -465,6 +494,7 @@ extension BudgetMonthDialogSheets on _BudgetMonthScreenState { prevMonthKey: _prevMonthKey, prevMonthLabel: _prevMonthLabel, hasPrevBudgets: prevBudgets.isNotEmpty, + budgetProfileId: widget.budgetProfileId, monthPlanController: _monthPlanController, budgetController: _budgetController, ), @@ -658,26 +688,71 @@ extension BudgetMonthDialogSheets on _BudgetMonthScreenState { } void _showDebtDetailSheet(Debt debt, List allTransactions) { - final debtTxns = allTransactions.where((t) => t.debtId == debt.id).toList() - ..sort((a, b) => b.date.compareTo(a.date)); + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => DebtDetailSheet( + debt: debt, + debtController: _debtController, + onPay: (amount, fee) async { + final isReceivable = debt.type == DebtType.lending; + final userId = locator.get().currentUser?.id ?? ''; + final categoryId = await _categoryController.findOrCreate( + name: isReceivable ? 'Receivables' : 'Debt Payment', + type: isReceivable ? CategoryType.income : CategoryType.expense, + userId: userId, + ); + if (categoryId == null) return; + await _transactionController.createTransaction(Transaction( + amount: amount, + fee: fee ?? 0.0, + type: isReceivable ? TransactionType.income : TransactionType.expense, + date: DateTime.now(), + debtId: debt.id, + financeCategoryId: categoryId, + description: isReceivable + ? 'Collection from ${debt.personName}' + : 'Payment to ${debt.personName}', + budgetProfileId: widget.budgetProfileId, + )); + await _debtController.payDebt(debt.id!, amount: amount, fee: fee); + _budgetController.refreshBudgetsWithSpentAmounts(); + }, + onUpdate: (updated) => _debtController.updateDebt(updated), + ), + ); + } + void _showSubDetail(Subscription sub) { showModalBottomSheet( context: context, isScrollControlled: true, - builder: (_) => DraggableScrollableSheet( - initialChildSize: 0.6, - minChildSize: 0.4, - maxChildSize: 0.9, - expand: false, - builder: (_, controller) => DebtDetailContent( - debt: debt, - transactions: debtTxns, - scrollController: controller, - onPay: () { - Navigator.pop(context); - _showDebtPaymentDialog(debt); - }, - ), + backgroundColor: Colors.transparent, + builder: (_) => SubDetailSheet( + sub: sub, + subController: _subscriptionController, + month: _currentMonth, + onPay: () async { + final userId = locator.get().currentUser?.id ?? ''; + final categoryId = await _categoryController.findOrCreate( + name: 'Subscriptions', + type: CategoryType.expense, + userId: userId, + ); + if (categoryId == null) return; + await _transactionController.createTransaction(Transaction( + amount: sub.amount, + type: TransactionType.expense, + date: DateTime.now(), + subscriptionId: sub.id, + financeCategoryId: categoryId, + description: sub.name, + budgetProfileId: widget.budgetProfileId, + )); + await _subscriptionController.pay(sub.id!); + }, + onUpdate: (updated) => _subscriptionController.updateSubscription(updated), ), ); } diff --git a/lib/features/finance/modules/budget/presentation/screens/budget_simple_sections.dart b/lib/features/finance/modules/budget/presentation/screens/budget_simple_sections.dart index 10e20e5..1e310bb 100644 --- a/lib/features/finance/modules/budget/presentation/screens/budget_simple_sections.dart +++ b/lib/features/finance/modules/budget/presentation/screens/budget_simple_sections.dart @@ -19,13 +19,26 @@ import 'package:keep_track/features/finance/modules/transaction/domain/entities/ class SimpleMonthNav extends StatelessWidget { final DateTime month; final bool isDark; + final bool isClosed; + final bool isCurrentMonth; final VoidCallback onPrev; final VoidCallback? onNext; + final VoidCallback? onBackToCurrentMonth; final VoidCallback? onSettings; - final VoidCallback? onToggleView; - const SimpleMonthNav({super.key, required this.month, required this.isDark, required this.onPrev, this.onNext, this.onSettings, this.onToggleView}); + const SimpleMonthNav({ + super.key, + required this.month, + required this.isDark, + this.isClosed = false, + this.isCurrentMonth = false, + required this.onPrev, + this.onNext, + this.onBackToCurrentMonth, + this.onSettings, + this.onToggleView, + }); @override Widget build(BuildContext context) { @@ -35,7 +48,26 @@ class SimpleMonthNav extends StatelessWidget { child: Row( children: [ IconButton(icon: Icon(Icons.chevron_left_rounded, color: AppColors.textSecondary), onPressed: onPrev, padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 36, minHeight: 36)), - Expanded(child: Text(DateFormat('MMMM yyyy').format(month), style: GoogleFonts.dmSans(fontSize: 15, fontWeight: FontWeight.w600, color: textPrimary), textAlign: TextAlign.center)), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(DateFormat('MMMM yyyy').format(month), style: GoogleFonts.dmSans(fontSize: 15, fontWeight: FontWeight.w600, color: textPrimary), textAlign: TextAlign.center), + if (isClosed) + Container( + margin: const EdgeInsets.only(top: 2), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration(color: AppColors.textTertiary.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(10)), + child: Text('Closed', style: GoogleFonts.dmSans(fontSize: 10, fontWeight: FontWeight.w600, color: AppColors.textSecondary)), + ) + else if (!isCurrentMonth && onBackToCurrentMonth != null) + GestureDetector( + onTap: onBackToCurrentMonth, + child: Text('Back to current', style: GoogleFonts.dmSans(fontSize: 10, color: AppColors.accent)), + ), + ], + ), + ), IconButton(icon: Icon(Icons.chevron_right_rounded, color: onNext != null ? AppColors.textSecondary : AppColors.textTertiary), onPressed: onNext, padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 36, minHeight: 36)), if (onToggleView != null) IconButton(icon: Icon(Icons.table_rows_outlined, color: AppColors.textSecondary), onPressed: onToggleView, padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 36, minHeight: 36), tooltip: 'Switch to Sheets'), @@ -637,8 +669,9 @@ class SimpleSubscriptionsSection extends StatelessWidget { final VoidCallback onAdd; final void Function(Subscription) onRowTap; final void Function(Subscription)? onSkip; + final bool isLocked; - const SimpleSubscriptionsSection({super.key, required this.isDark, required this.subs, required this.month, required this.onAdd, required this.onRowTap, this.onSkip}); + const SimpleSubscriptionsSection({super.key, required this.isDark, required this.subs, required this.month, required this.onAdd, required this.onRowTap, this.onSkip, this.isLocked = false}); bool _paidThisMonth(Subscription s) { final d = s.lastBilledDate; @@ -668,6 +701,7 @@ class SimpleSubscriptionsSection extends StatelessWidget { trailing: entries.isEmpty ? null : '$paidCount/${entries.length} paid', trailingColor: paidCount == entries.length && entries.isNotEmpty ? AppColors.success : null, onAdd: onAdd, + isLocked: isLocked, child: entries.isEmpty ? _EmptyRow(isDark: isDark, text: 'No subscriptions yet') : Column( @@ -711,7 +745,7 @@ class SimpleSubscriptionsSection extends StatelessWidget { child: Transform.translate(offset: Offset(0, (1 - v) * 10), child: child), ), child: InkWell( - onTap: () => onRowTap(s), + onTap: isLocked ? null : () => onRowTap(s), child: Padding( padding: const EdgeInsets.fromLTRB(16, 11, 12, 11), child: Row(children: [ @@ -779,8 +813,9 @@ class SimpleDebtsSection extends StatelessWidget { final Map paidThisMonth; final VoidCallback onAddDebt, onAddReceivable; final void Function(Debt) onRowTap; + final bool isLocked; - const SimpleDebtsSection({super.key, required this.isDark, required this.debts, required this.receivables, required this.onAddDebt, required this.onAddReceivable, required this.onRowTap, this.paidThisMonth = const {}}); + const SimpleDebtsSection({super.key, required this.isDark, required this.debts, required this.receivables, required this.onAddDebt, required this.onAddReceivable, required this.onRowTap, this.paidThisMonth = const {}, this.isLocked = false}); @override Widget build(BuildContext context) { @@ -797,6 +832,7 @@ class SimpleDebtsSection extends StatelessWidget { trailing: totalCount == 0 ? null : '$totalCount active', onAdd: debts.isNotEmpty || receivables.isEmpty ? onAddDebt : onAddReceivable, addLabel: debts.isNotEmpty || receivables.isEmpty ? 'Add Debt' : 'Add Receivable', + isLocked: isLocked, child: (debts.isEmpty && receivables.isEmpty) ? _EmptyRow(isDark: isDark, text: emptyLabel) : Column(children: [ @@ -812,7 +848,7 @@ class SimpleDebtsSection extends StatelessWidget { curve: Curves.easeOut, ), builder: (_, v, child) => Opacity(opacity: v, child: Transform.translate(offset: Offset(0, (1 - v) * 10), child: child)), - child: _DebtRow(isDark: isDark, debt: e.value, textPrimary: textPrimary, paidThisMonth: paidThisMonth[e.value.id] ?? 0, onTap: () => onRowTap(e.value)), + child: _DebtRow(isDark: isDark, debt: e.value, textPrimary: textPrimary, paidThisMonth: paidThisMonth[e.value.id] ?? 0, onTap: isLocked ? null : () => onRowTap(e.value)), ), ]), ...receivables.asMap().entries.expand((e) { @@ -829,7 +865,7 @@ class SimpleDebtsSection extends StatelessWidget { curve: Curves.easeOut, ), builder: (_, v, child) => Opacity(opacity: v, child: Transform.translate(offset: Offset(0, (1 - v) * 10), child: child)), - child: _DebtRow(isDark: isDark, debt: e.value, textPrimary: textPrimary, paidThisMonth: paidThisMonth[e.value.id] ?? 0, onTap: () => onRowTap(e.value)), + child: _DebtRow(isDark: isDark, debt: e.value, textPrimary: textPrimary, paidThisMonth: paidThisMonth[e.value.id] ?? 0, onTap: isLocked ? null : () => onRowTap(e.value)), ), ]; }), @@ -843,7 +879,7 @@ class _DebtRow extends StatelessWidget { final Debt debt; final Color textPrimary; final double paidThisMonth; - final VoidCallback onTap; + final VoidCallback? onTap; const _DebtRow({required this.isDark, required this.debt, required this.textPrimary, required this.onTap, this.paidThisMonth = 0}); @@ -911,8 +947,9 @@ class _SimpleSection extends StatelessWidget { final VoidCallback onAdd; final Widget? extraAction; final Widget child; + final bool isLocked; - const _SimpleSection({required this.isDark, required this.label, this.trailing, this.trailingColor, required this.onAdd, this.addLabel, this.extraAction, required this.child}); + const _SimpleSection({required this.isDark, required this.label, this.trailing, this.trailingColor, required this.onAdd, this.addLabel, this.extraAction, required this.child, this.isLocked = false}); @override Widget build(BuildContext context) { @@ -933,16 +970,18 @@ class _SimpleSection extends StatelessWidget { child: Text(trailing!, style: GoogleFonts.dmSans(fontSize: 10, color: trailingColor ?? AppColors.textSecondary, fontWeight: FontWeight.w600))), ], const Spacer(), - if (extraAction != null) ...[extraAction!, const SizedBox(width: 6)], - GestureDetector( - onTap: onAdd, - child: Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration(color: AppColors.accent.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(20)), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.add, size: 11, color: AppColors.accent), - const SizedBox(width: 3), - Text(addLabel ?? 'Add', style: GoogleFonts.dmSans(fontSize: 10, fontWeight: FontWeight.w600, color: AppColors.accent)), - ])), - ), + if (!isLocked) ...[ + if (extraAction != null) ...[extraAction!, const SizedBox(width: 6)], + GestureDetector( + onTap: onAdd, + child: Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration(color: AppColors.accent.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(20)), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.add, size: 11, color: AppColors.accent), + const SizedBox(width: 3), + Text(addLabel ?? 'Add', style: GoogleFonts.dmSans(fontSize: 10, fontWeight: FontWeight.w600, color: AppColors.accent)), + ])), + ), + ], ]), ), Container( @@ -1039,8 +1078,9 @@ class SimpleGoalsSection extends StatelessWidget { final Map contributedThisMonth; final VoidCallback onAdd; final ValueChanged? onRowTap; + final bool isLocked; - const SimpleGoalsSection({super.key, required this.isDark, required this.goals, required this.onAdd, this.onRowTap, this.contributedThisMonth = const {}}); + const SimpleGoalsSection({super.key, required this.isDark, required this.goals, required this.onAdd, this.onRowTap, this.contributedThisMonth = const {}, this.isLocked = false}); @override Widget build(BuildContext context) { @@ -1053,6 +1093,7 @@ class SimpleGoalsSection extends StatelessWidget { trailing: goals.isEmpty ? null : '${goals.where((g) => g.status == GoalStatus.active).length} active', onAdd: onAdd, addLabel: 'Add Goal', + isLocked: isLocked, child: goals.isEmpty ? _EmptyRow(isDark: isDark, text: 'No goals yet — tap Add Goal to get started') : Column( @@ -1084,7 +1125,7 @@ class SimpleGoalsSection extends StatelessWidget { ), builder: (_, v, child) => Opacity(opacity: v, child: Transform.translate(offset: Offset(0, (1 - v) * 10), child: child)), child: InkWell( - onTap: onRowTap != null ? () => onRowTap!(g) : null, + onTap: !isLocked && onRowTap != null ? () => onRowTap!(g) : null, child: Padding( padding: const EdgeInsets.fromLTRB(16, 13, 16, 13), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/features/finance/modules/budget/presentation/screens/budget_simple_view.dart b/lib/features/finance/modules/budget/presentation/screens/budget_simple_view.dart index 1a5db7c..7e291d2 100644 --- a/lib/features/finance/modules/budget/presentation/screens/budget_simple_view.dart +++ b/lib/features/finance/modules/budget/presentation/screens/budget_simple_view.dart @@ -49,7 +49,7 @@ class BudgetSimpleView extends StatefulWidget { final DateTime? profileEndDate; final void Function(bool isIncome)? onAddProfileGroup; final VoidCallback? onToggleView; - final VoidCallback? onOpenSettings; + final void Function(List monthBudgets)? onOpenSettings; final bool profileIsMonthly; const BudgetSimpleView({ @@ -117,8 +117,20 @@ class _BudgetSimpleViewState extends State { } } - void _prevMonth() => setState(() { _month = DateTime(_month.year, _month.month - 1); _loadTx(); }); - void _nextMonth() => setState(() { _month = DateTime(_month.year, _month.month + 1); _loadTx(); }); + void _prevMonth() { + setState(() => _month = DateTime(_month.year, _month.month - 1)); + _loadTx(); + } + + void _nextMonth() { + setState(() => _month = DateTime(_month.year, _month.month + 1)); + _loadTx(); + } + + void _backToCurrentMonth() { + setState(() => _month = DateTime.now()); + _loadTx(); + } String get _monthKey => '${_month.year}-${_month.month.toString().padLeft(2, '0')}'; String get _monthLabel => DateFormat('MMMM yyyy').format(_month); @@ -157,14 +169,81 @@ class _BudgetSimpleViewState extends State { ); } - void _showSettings(List monthBudgets) { + void _showSettings(List monthBudgets, MonthPlan? monthPlan) { + final allClosed = monthBudgets.isNotEmpty && + monthBudgets.every((b) => b.status == BudgetStatus.closed); BudgetSettingsSheet.show( context, monthLabel: _monthLabel, + onCloseBudget: monthPlan != null && !monthPlan.isClosed + ? () => _confirmClosePlan(monthPlan) + : widget._isProfileMode && widget.profileIsMonthly && monthBudgets.isNotEmpty && !allClosed + ? () => _confirmCloseMonthBudgets(monthBudgets) + : null, onDeleteBudget: () => _confirmDeleteBudget(monthBudgets), ); } + Future _confirmClosePlan(MonthPlan plan) async { + final confirmed = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: Text('Close budget for $_monthLabel?'), + content: const Text('This month plan will be marked as closed.'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')), + FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Close Budget')), + ], + ), + ); + if (confirmed != true || !mounted || plan.id == null) return; + await _monthPlanController.closeMonthPlan(plan.id!); + } + + Future _confirmCloseMonthBudgets(List monthBudgets) async { + final confirmed = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: Text('Close $_monthLabel budget?'), + content: const Text('All active budget groups for this month will be marked as closed.'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')), + FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Close Budget')), + ], + ), + ); + if (confirmed != true || !mounted) return; + for (final b in monthBudgets) { + if (b.id != null && b.status == BudgetStatus.active) { + await _budgetController.closeBudget(b.id!); + } + } + } + + void _showStartPlanningForMonthlyProfile(List allBudgets) { + final prevMonth = DateTime(_month.year, _month.month - 1); + final prevKey = '${prevMonth.year}-${prevMonth.month.toString().padLeft(2, '0')}'; + final prevLabel = DateFormat('MMMM yyyy').format(prevMonth); + final hasPrev = allBudgets.any((b) => + b.budgetProfileId == widget.budgetProfileId && + b.month == prevKey && + b.status == BudgetStatus.active); + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => StartPlanningSheet( + monthKey: _monthKey, + monthLabel: _monthLabel, + prevMonthKey: prevKey, + prevMonthLabel: prevLabel, + hasPrevBudgets: hasPrev, + budgetProfileId: widget.budgetProfileId, + monthPlanController: _monthPlanController, + budgetController: _budgetController, + ), + ); + } + Future _confirmDeleteBudget(List monthBudgets) async { if (monthBudgets.isEmpty) return; final confirmed = await showDialog( @@ -546,6 +625,16 @@ class _BudgetSimpleViewState extends State { // ── Plan gate ───────────────────────────────────────────────────────────── final plans = _monthPlanController.data ?? []; + + // Only show "Back to current" when the current real month has a plan + final realNow = DateTime.now(); + final realMonthKey = '${realNow.year}-${realNow.month.toString().padLeft(2, '0')}'; + final currentMonthHasPlan = widget._isProfileMode && widget.profileIsMonthly + ? budgets.any((b) => + b.budgetProfileId == widget.budgetProfileId && + b.month == realMonthKey && + b.status == BudgetStatus.active) + : plans.any((p) => p.month == realMonthKey); final MonthPlan? monthPlan = widget._isProfileMode ? null : plans.cast().firstWhere( @@ -555,9 +644,17 @@ class _BudgetSimpleViewState extends State { (p) => p?.budgetProfileId == widget.budgetProfileId, orElse: () => null) : null; final bool hasMonthPlan = widget._isProfileMode - ? (profilePlan != null || monthBudgets.isNotEmpty) + ? widget.profileIsMonthly + ? monthBudgets.isNotEmpty + : (profilePlan != null || monthBudgets.isNotEmpty) : monthPlan != null; + final bool isMonthClosed = widget._isProfileMode + ? widget.profileIsMonthly + ? monthBudgets.isNotEmpty && monthBudgets.every((b) => b.status == BudgetStatus.closed) + : false + : monthPlan?.isClosed ?? false; + // ── Shared header slivers ───────────────────────────────────────────────── final headerSlivers = [ if (!widget._isProfileMode && widget.onBack != null) @@ -581,7 +678,8 @@ class _BudgetSimpleViewState extends State { color: widget.profileAccentColor ?? AppColors.accent, onBack: widget.onBack, onToggleView: widget.onToggleView, - onSettings: hasMonthPlan && widget.onOpenSettings != null ? () => widget.onOpenSettings!() : null, + // Always show settings for profiles (user can still edit/delete even without a plan) + onSettings: widget.onOpenSettings != null ? () => widget.onOpenSettings!(monthBudgets) : null, )) else if (widget._isProfileMode && widget.profileIsMonthly) SliverToBoxAdapter(child: _MonthlyProfileHeader( @@ -589,46 +687,46 @@ class _BudgetSimpleViewState extends State { name: widget.profileName ?? '', color: widget.profileAccentColor ?? AppColors.accent, month: _month, + isClosed: isMonthClosed, + isCurrentMonth: _isCurrentMonth, onBack: widget.onBack, onPrev: _prevMonth, - onNext: _isCurrentMonth ? null : _nextMonth, + onNext: _nextMonth, + onBackToCurrentMonth: !_isCurrentMonth && currentMonthHasPlan ? _backToCurrentMonth : null, onToggleView: widget.onToggleView, - onSettings: hasMonthPlan && widget.onOpenSettings != null ? () => widget.onOpenSettings!() : null, + // Always show settings for profiles + onSettings: widget.onOpenSettings != null ? () => widget.onOpenSettings!(monthBudgets) : null, )) else SliverToBoxAdapter(child: SimpleMonthNav( - month: _month, isDark: isDark, onPrev: _prevMonth, onNext: _isCurrentMonth ? null : _nextMonth, + month: _month, isDark: isDark, + isClosed: isMonthClosed, + isCurrentMonth: _isCurrentMonth, + onPrev: _prevMonth, onNext: _nextMonth, + onBackToCurrentMonth: !_isCurrentMonth && currentMonthHasPlan ? _backToCurrentMonth : null, onToggleView: widget.onToggleView, - onSettings: hasMonthPlan ? () => (widget.onOpenSettings ?? () => _showSettings(monthBudgets))() : null, + onSettings: hasMonthPlan ? () => _showSettings(monthBudgets, monthPlan) : null, )), ]; - // ── No plan: locked gate ────────────────────────────────────────────────── - if (!hasMonthPlan) { - return CustomScrollView(slivers: [ - ...headerSlivers, - SliverFillRemaining( - hasScrollBody: false, - child: _NoPlanGate( - isDark: isDark, - label: widget._isProfileMode ? (widget.profileName ?? 'Custom Budget') : _monthLabel, - isProfile: widget._isProfileMode, - onStartPlanning: widget._isProfileMode - ? _startPlanningProfile - : _startPlanningMonthly, - ), - ), - ]); - } + final Widget noPlanGate = _NoPlanGate( + isDark: isDark, + label: widget._isProfileMode ? (widget.profileName ?? 'Custom Budget') : _monthLabel, + isProfile: widget._isProfileMode, + onStartPlanning: widget._isProfileMode + ? widget.profileIsMonthly + ? () => _showStartPlanningForMonthlyProfile(budgets) + : _startPlanningProfile + : () => _showStartPlanning(budgets), + ); - // ── Normal content ──────────────────────────────────────────────────────── + // ── Content (tab bar always visible; gate only on budget/summary tabs) ──── return CustomScrollView(slivers: [ ...headerSlivers, SliverToBoxAdapter(child: BudgetSimpleTabBar( isDark: isDark, selected: tab, onSelect: widget.onTabChange ?? (_) {}, - // Tab 0 = Summary (no count), 1 = Budget, 2 = Subs, 3 = Debts, 4 = Receivables, 5 = Goals counts: [ null, null, @@ -638,24 +736,11 @@ class _BudgetSimpleViewState extends State { sortedGoals.where((g) => g.status == GoalStatus.active).length, ], )), - if (tab != 0) - SliverToBoxAdapter( - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 280), - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeInCubic, - transitionBuilder: (child, animation) => FadeTransition( - opacity: animation, - child: SlideTransition( - position: Tween(begin: const Offset(0, 0.06), end: Offset.zero).animate(animation), - child: child, - ), - ), - child: KeyedSubtree(key: ValueKey(tab), child: summaryCard), - ), - ), + // Tabs 0 & 1 require a plan — show the gate if none exists + if (!hasMonthPlan && (tab == 0 || tab == 1)) + SliverFillRemaining(hasScrollBody: false, child: noPlanGate), // Tab 0: Overall summary - if (tab == 0) ...[ + if (hasMonthPlan && tab == 0) ...[ SliverToBoxAdapter( child: BudgetOverallSummary( monthBudgets: monthBudgets, @@ -684,8 +769,25 @@ class _BudgetSimpleViewState extends State { ), ), ], - // Tab 1: Budget groups - if (tab == 1) ...[ + // Summary card: shown first for tabs 1-5 (tab 1 requires a plan) + if (tab != 0 && (tab != 1 || hasMonthPlan)) + SliverToBoxAdapter( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 280), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: SlideTransition( + position: Tween(begin: const Offset(0, 0.06), end: Offset.zero).animate(animation), + child: child, + ), + ), + child: KeyedSubtree(key: ValueKey(tab), child: summaryCard), + ), + ), + // Tab 1: Budget groups (below summary card) + if (hasMonthPlan && tab == 1) ...[ SliverToBoxAdapter(child: SimpleBudgetSection( isDark: isDark, label: 'INCOME', groups: incomeGroups, spentByCategory: spentByCategory, isIncome: true, onAddGroup: () => _showCreateGroup(true), onAddCategory: _showAddCategory, @@ -700,6 +802,7 @@ class _BudgetSimpleViewState extends State { if (tab == 2) SliverToBoxAdapter(child: SimpleSubscriptionsSection( isDark: isDark, subs: activeSubs, month: _month, + isLocked: !hasMonthPlan, onAdd: _showAddSubscription, onRowTap: _showSubDetail, onSkip: _skipSubscription, )), @@ -707,6 +810,7 @@ class _BudgetSimpleViewState extends State { SliverToBoxAdapter(child: SimpleDebtsSection( isDark: isDark, debts: sortedDebts, receivables: const [], paidThisMonth: paidThisMonthByDebt, + isLocked: !hasMonthPlan, onAddDebt: () => _showAddDebt(isReceivable: false), onAddReceivable: () => _showAddDebt(isReceivable: false), onRowTap: _showDebtDetail, @@ -715,6 +819,7 @@ class _BudgetSimpleViewState extends State { SliverToBoxAdapter(child: SimpleDebtsSection( isDark: isDark, debts: const [], receivables: sortedReceivables, paidThisMonth: paidThisMonthByDebt, + isLocked: !hasMonthPlan, onAddDebt: () => _showAddDebt(isReceivable: true), onAddReceivable: () => _showAddDebt(isReceivable: true), onRowTap: _showDebtDetail, @@ -723,6 +828,7 @@ class _BudgetSimpleViewState extends State { SliverToBoxAdapter(child: SimpleGoalsSection( isDark: isDark, goals: sortedGoals, onAdd: _showAddGoal, onRowTap: _showGoalDetail, contributedThisMonth: contributedThisMonthByGoal, + isLocked: !hasMonthPlan, )), const SliverToBoxAdapter(child: SizedBox(height: 100)), ]); @@ -824,15 +930,19 @@ class _MonthlyProfileHeader extends StatelessWidget { final String name; final Color color; final DateTime month; + final bool isClosed; + final bool isCurrentMonth; final VoidCallback? onBack; final VoidCallback? onPrev; final VoidCallback? onNext; + final VoidCallback? onBackToCurrentMonth; final VoidCallback? onToggleView; final VoidCallback? onSettings; const _MonthlyProfileHeader({ required this.isDark, required this.name, required this.color, - required this.month, this.onBack, this.onPrev, this.onNext, + required this.month, this.isClosed = false, this.isCurrentMonth = false, + this.onBack, this.onPrev, this.onNext, this.onBackToCurrentMonth, this.onToggleView, this.onSettings, }); @@ -867,6 +977,20 @@ class _MonthlyProfileHeader extends StatelessWidget { onTap: onNext, child: Icon(Icons.chevron_right_rounded, size: 14, color: AppColors.textSecondary), ), + if (isClosed) ...[ + const SizedBox(width: 4), + Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + decoration: BoxDecoration(color: AppColors.textTertiary.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(6)), + child: Text('Closed', style: GoogleFonts.dmSans(fontSize: 9, fontWeight: FontWeight.w600, color: AppColors.textSecondary)), + ), + ] else if (!isCurrentMonth && onBackToCurrentMonth != null) ...[ + const SizedBox(width: 4), + GestureDetector( + onTap: onBackToCurrentMonth, + child: Text('Today', style: GoogleFonts.dmSans(fontSize: 9, color: AppColors.accent, fontWeight: FontWeight.w600)), + ), + ], ]), ]), ), diff --git a/lib/features/finance/modules/budget/presentation/sections/budget_screen_body.dart b/lib/features/finance/modules/budget/presentation/sections/budget_screen_body.dart index 53dbf43..f1acb52 100644 --- a/lib/features/finance/modules/budget/presentation/sections/budget_screen_body.dart +++ b/lib/features/finance/modules/budget/presentation/sections/budget_screen_body.dart @@ -66,6 +66,7 @@ class BudgetScreenBody extends StatelessWidget { final Future Function(Debt debt, double amount) onUpdateDebtPayment; final VoidCallback onAddSubscription; final Future Function(Subscription) onPaySubscription; + final void Function(Subscription)? onSubscriptionTap; final VoidCallback onAddGoal; final void Function(Goal) onGoalTap; final int selectedTab; @@ -74,7 +75,10 @@ class BudgetScreenBody extends StatelessWidget { final void Function(int oldIndex, int newIndex) onItemReorder; final VoidCallback? onToggleView; final VoidCallback? onOverrideSettings; + final VoidCallback? onBack; + final VoidCallback? onBackToCurrentMonth; final String? budgetProfileId; + final bool profileIsMonthly; final void Function(Debt debt, List transactions) onDebtDetailTap; final void Function( @@ -116,6 +120,7 @@ class BudgetScreenBody extends StatelessWidget { required this.onUpdateDebtPayment, required this.onAddSubscription, required this.onPaySubscription, + this.onSubscriptionTap, required this.onAddGoal, required this.onGoalTap, required this.selectedTab, @@ -124,7 +129,10 @@ class BudgetScreenBody extends StatelessWidget { required this.onItemReorder, this.onToggleView, this.onOverrideSettings, + this.onBack, + this.onBackToCurrentMonth, this.budgetProfileId, + this.profileIsMonthly = false, required this.onDebtDetailTap, required this.onCategoryDetailTap, }); @@ -169,29 +177,40 @@ class BudgetScreenBody extends StatelessWidget { : null; final monthBudgets = budgetProfileId != null - ? data.budgets.where((b) => b.budgetProfileId == budgetProfileId && b.status == BudgetStatus.active).toList() + ? profileIsMonthly + ? data.budgets.where((b) => + b.budgetProfileId == budgetProfileId && + b.month == monthKey && + b.status == BudgetStatus.active).toList() + : data.budgets.where((b) => + b.budgetProfileId == budgetProfileId && + b.status == BudgetStatus.active).toList() : BudgetMonthFilter.filterBudgets(data.budgets, monthPlan); - // Profile mode: plan exists OR budget groups already exist (started without creating plan first) - // Monthly mode: a month-keyed plan must exist final hasMonthPlan = budgetProfileId != null - ? (profilePlan != null || monthBudgets.isNotEmpty) + ? profileIsMonthly + ? monthBudgets.isNotEmpty + : (profilePlan != null || monthBudgets.isNotEmpty) : monthPlan != null; + // Profile scope filter — mirrors the simple view's matchesProfile logic + bool matchesProfile(String? itemProfileId) => budgetProfileId != null + ? itemProfileId == budgetProfileId + : itemProfileId == null; + + // Month-filtered: used for Summary tab (financial overview for this month) final debts = data.debts - .where( - (d) => - d.type == DebtType.borrowing && - BudgetMonthFilter.debtVisibleInMonth(d, monthStart, monthEnd), - ) + .where((d) => + d.type == DebtType.borrowing && + matchesProfile(d.budgetProfileId) && + BudgetMonthFilter.debtVisibleInMonth(d, monthStart, monthEnd)) .toList(); final receivables = data.debts - .where( - (d) => - d.type == DebtType.lending && - BudgetMonthFilter.debtVisibleInMonth(d, monthStart, monthEnd), - ) + .where((d) => + d.type == DebtType.lending && + matchesProfile(d.budgetProfileId) && + BudgetMonthFilter.debtVisibleInMonth(d, monthStart, monthEnd)) .toList(); final activePayments = data.payments @@ -201,12 +220,33 @@ class BudgetScreenBody extends StatelessWidget { final monthSubscriptions = data.subscriptions .where((s) => s.status == SubscriptionStatus.active && + matchesProfile(s.budgetProfileId) && (s.nextBillingDate.year == currentMonth.year && s.nextBillingDate.month == currentMonth.month || s.nextBillingDate.isBefore(monthStart))) .toList() ..sort((a, b) => a.nextBillingDate.compareTo(b.nextBillingDate)); + // All-active (not month-filtered): used for tabs 2-5 and pill counts — + // debts, subs, receivables, and goals are not month-specific. + int debtOrder(DebtStatus s) => s == DebtStatus.active ? 0 : 1; + final allDebts = (data.debts + .where((d) => d.type == DebtType.borrowing && matchesProfile(d.budgetProfileId)) + .toList() + ..sort((a, b) => debtOrder(a.status).compareTo(debtOrder(b.status)))); + final allReceivables = (data.debts + .where((d) => d.type == DebtType.lending && matchesProfile(d.budgetProfileId)) + .toList() + ..sort((a, b) => debtOrder(a.status).compareTo(debtOrder(b.status)))); + final allSubs = data.subscriptions + .where((s) => s.status != SubscriptionStatus.cancelled && matchesProfile(s.budgetProfileId)) + .toList() + ..sort((a, b) => a.nextBillingDate.compareTo(b.nextBillingDate)); + + final filteredGoals = data.goals + .where((g) => matchesProfile(g.budgetProfileId)) + .toList(); + // ── sync selection ──────────────────────────────────────────────────────── final syncedSelected = selectedGroup == null ? null @@ -293,22 +333,30 @@ class BudgetScreenBody extends StatelessWidget { List contentSlivers; if (selectedTab == 0) { - // Summary tab: full financial overview + // Summary tab: gate if no plan, otherwise full overview contentSlivers = [ const SliverToBoxAdapter(child: SizedBox(height: 24)), - SliverToBoxAdapter( - child: BudgetOverallSummary( - monthBudgets: monthBudgets, - spentByCategory: spentByCategory, - subscriptions: monthSubscriptions, - debts: debts, - receivables: receivables, - goals: data.goals, - transactions: monthTransactions, - currentMonth: currentMonth, - isDark: isDark, + if (!hasMonthPlan) + SliverToBoxAdapter( + child: EmptyBudgetState( + monthLabel: monthLabel, + onStart: () => onStartPlanning(data.budgets), + ), + ), + if (hasMonthPlan) + SliverToBoxAdapter( + child: BudgetOverallSummary( + monthBudgets: monthBudgets, + spentByCategory: spentByCategory, + subscriptions: monthSubscriptions, + debts: debts, + receivables: receivables, + goals: filteredGoals, + transactions: monthTransactions, + currentMonth: currentMonth, + isDark: isDark, + ), ), - ), ]; } else if (selectedTab == 1) { // Budget tab: reorderable budget groups only @@ -340,6 +388,7 @@ class BudgetScreenBody extends StatelessWidget { debts: debts, receivables: receivables, monthSubscriptions: monthSubscriptions, + goals: filteredGoals, monthBudgets: monthBudgets, monthLabel: monthLabel, spentByCategory: spentByCategory, @@ -354,12 +403,13 @@ class BudgetScreenBody extends StatelessWidget { ); }, ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 0), - child: GhostAddRow(label: 'Add Budget Group', onTap: onCreateGroup), + if (hasMonthPlan) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 0), + child: GhostAddRow(label: 'Add Budget Group', onTap: onCreateGroup), + ), ), - ), ]; } else { // Single section tab (tabs 2-5 after adding summary at 0) @@ -378,9 +428,10 @@ class BudgetScreenBody extends StatelessWidget { context, key: sectionKey, dragIndex: sectionDragIndex >= 0 ? sectionDragIndex : 0, - debts: debts, - receivables: receivables, - monthSubscriptions: monthSubscriptions, + debts: allDebts, + receivables: allReceivables, + monthSubscriptions: allSubs, + goals: filteredGoals, monthBudgets: monthBudgets, monthLabel: monthLabel, spentByCategory: spentByCategory, @@ -391,6 +442,7 @@ class BudgetScreenBody extends StatelessWidget { isWide: isWide, monthTransactions: monthTransactions, divColor: divColor, + isLocked: !hasMonthPlan, ), ), ]; @@ -404,12 +456,30 @@ class BudgetScreenBody extends StatelessWidget { ); // ── Shared summary bar widget ───────────────────────────────────────── + final now = DateTime.now(); + final isCurrentMonth = currentMonth.year == now.year && currentMonth.month == now.month; + final realMonthKey = '${now.year}-${now.month.toString().padLeft(2, '0')}'; + final currentMonthHasPlan = budgetProfileId != null && profileIsMonthly + ? data.budgets.any((b) => + b.budgetProfileId == budgetProfileId && + b.month == realMonthKey && + b.status == BudgetStatus.active) + : data.monthPlans.any((p) => p.month == realMonthKey); + + final isMonthClosed = budgetProfileId != null && profileIsMonthly + ? monthBudgets.isNotEmpty && monthBudgets.every((b) => b.status == BudgetStatus.closed) + : monthPlan?.isClosed ?? false; + Widget summaryBar({bool narrowActions = false}) => Container( color: panelBg, child: BudgetSummaryBar( monthLabel: monthLabel, onPrev: onPrevMonth, onNext: onNextMonth, + isClosed: isMonthClosed, + isCurrentMonth: isCurrentMonth, + onBack: onBack, + onBackToCurrentMonth: !isCurrentMonth && currentMonthHasPlan ? onBackToCurrentMonth : null, // On narrow, show actions in the bar; on wide, they're in the side panel onToggleView: narrowActions ? onToggleView : null, onSummaryTap: narrowActions ? () => _showSummarySheet(context, buildSummaryPanel()) : null, @@ -423,10 +493,10 @@ class BudgetScreenBody extends StatelessWidget { selectedTab: selectedTab, onTabSelect: onTabSelect, budgetGroupCount: monthBudgets.length, - subsCount: monthSubscriptions.length, - debtsCount: debts.length, - receivablesCount: receivables.length, - goalsCount: data.goals.length, + subsCount: allSubs.length, + debtsCount: allDebts.where((d) => d.status == DebtStatus.active).length, + receivablesCount: allReceivables.where((d) => d.status == DebtStatus.active).length, + goalsCount: filteredGoals.length, ), ); @@ -477,6 +547,7 @@ class BudgetScreenBody extends StatelessWidget { required List debts, required List receivables, required List monthSubscriptions, + required List goals, required List monthBudgets, required String monthLabel, required Map spentByCategory, @@ -487,6 +558,7 @@ class BudgetScreenBody extends StatelessWidget { required bool isWide, required List monthTransactions, required Color divColor, + bool isLocked = false, }) { // Budget group item if (!kSectionKeys.contains(key)) { @@ -520,6 +592,7 @@ class BudgetScreenBody extends StatelessWidget { selectedDebt: selectedDebt, paidThisMonth: paidThisMonthByDebt, dragIndex: dragIndex, + isLocked: isLocked, onAdd: () => onAddDebt(false), onPay: onDebtPay, onEdit: onEditDebt, @@ -536,6 +609,7 @@ class BudgetScreenBody extends StatelessWidget { selectedDebt: selectedDebt, paidThisMonth: paidThisMonthByDebt, dragIndex: dragIndex, + isLocked: isLocked, onAdd: () => onAddDebt(true), onPay: onDebtPay, onEdit: onEditDebt, @@ -548,13 +622,16 @@ class BudgetScreenBody extends StatelessWidget { 'subscriptions' => SubscriptionSection( subscriptions: monthSubscriptions, dragIndex: dragIndex, + isLocked: isLocked, onAdd: onAddSubscription, onPay: onPaySubscription, + onRowTap: onSubscriptionTap, ), 'goals' => GoalSection( - goals: data.goals, + goals: goals, contributedThisMonth: contributedThisMonthByGoal, dragIndex: dragIndex, + isLocked: isLocked, onAdd: onAddGoal, onGoalTap: onGoalTap, ), diff --git a/lib/features/finance/modules/budget/presentation/sections/budget_summary_bar.dart b/lib/features/finance/modules/budget/presentation/sections/budget_summary_bar.dart index 8d252ab..a22ac8a 100644 --- a/lib/features/finance/modules/budget/presentation/sections/budget_summary_bar.dart +++ b/lib/features/finance/modules/budget/presentation/sections/budget_summary_bar.dart @@ -32,6 +32,12 @@ class BudgetSummaryBar extends StatelessWidget { final int selectedTab; final void Function(int) onTabSelect; + // Month state + final bool isClosed; + final bool isCurrentMonth; + final VoidCallback? onBack; + final VoidCallback? onBackToCurrentMonth; + // Narrow-only actions (hidden on wide where they live in the side panel) final VoidCallback? onToggleView; final VoidCallback? onSettings; @@ -55,6 +61,10 @@ class BudgetSummaryBar extends StatelessWidget { required this.debtsCount, required this.receivablesCount, required this.goalsCount, + this.isClosed = false, + this.isCurrentMonth = false, + this.onBack, + this.onBackToCurrentMonth, this.onToggleView, this.onSettings, this.onSummaryTap, @@ -117,38 +127,84 @@ class BudgetSummaryBar extends StatelessWidget { children: [ // ── Main header row ─────────────────────────────────────────────── Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 12, 12), + padding: const EdgeInsets.fromLTRB(8, 14, 12, 12), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ + if (onBack != null) + IconButton( + icon: Icon(Icons.arrow_back_rounded, size: 20, color: textPrimary), + onPressed: onBack, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 36, minHeight: 36), + ), // Title + subtitle — updates based on selected tab Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text( - _tabTitle(monthLabel), - style: GoogleFonts.dmSans( - fontSize: 20, - fontWeight: FontWeight.w700, - color: textPrimary, - letterSpacing: -0.4, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Flexible( + child: Text( + _tabTitle(monthLabel), + style: GoogleFonts.dmSans( + fontSize: 20, + fontWeight: FontWeight.w700, + color: textPrimary, + letterSpacing: -0.4, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (isClosed) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), + decoration: BoxDecoration( + color: AppColors.textTertiary.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + 'Closed', + style: GoogleFonts.dmSans( + fontSize: 10, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + ), + ], + ], ), const SizedBox(height: 2), - Text( - _tabSubtitle(leftLabel), - style: GoogleFonts.dmSans( - fontSize: 13, - fontWeight: FontWeight.w500, - color: _tabSubtitleColor(leftColor), + if (!isCurrentMonth && onBackToCurrentMonth != null) + GestureDetector( + onTap: onBackToCurrentMonth, + child: Text( + 'Back to current month', + style: GoogleFonts.dmSans( + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppColors.accent, + ), + ), + ) + else + Text( + _tabSubtitle(leftLabel), + style: GoogleFonts.dmSans( + fontSize: 13, + fontWeight: FontWeight.w500, + color: _tabSubtitleColor(leftColor), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), ], ), ), diff --git a/lib/features/finance/modules/budget/presentation/sections/debt_section.dart b/lib/features/finance/modules/budget/presentation/sections/debt_section.dart index e946903..8f30006 100644 --- a/lib/features/finance/modules/budget/presentation/sections/debt_section.dart +++ b/lib/features/finance/modules/budget/presentation/sections/debt_section.dart @@ -16,6 +16,7 @@ class DebtSection extends StatelessWidget { final void Function(Debt) onSelect; final Future Function(Debt, double) onUpdateMonthlyPayment; final int? dragIndex; + final bool isLocked; const DebtSection({ super.key, @@ -30,6 +31,7 @@ class DebtSection extends StatelessWidget { this.selectedDebt, this.paidThisMonth = const {}, this.dragIndex, + this.isLocked = false, }); @override @@ -128,6 +130,7 @@ class DebtSection extends StatelessWidget { isReceivable: isReceivable, isSelected: selectedDebt?.id == d.id, paidThisMonth: paidThisMonth[d.id] ?? 0, + isLocked: isLocked, onSelect: () => onSelect(d), onPay: () => onPay(d), onEdit: () => onEdit(d), @@ -137,20 +140,23 @@ class DebtSection extends StatelessWidget { ]), // ── Add link ─────────────────────────────────────────────────── - GestureDetector( - onTap: onAdd, - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 14), - child: Text( - addLabel, - style: GoogleFonts.dmSans( - fontSize: 13, - fontWeight: FontWeight.w600, - color: accentColor, + if (!isLocked) + GestureDetector( + onTap: onAdd, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 14), + child: Text( + addLabel, + style: GoogleFonts.dmSans( + fontSize: 13, + fontWeight: FontWeight.w600, + color: accentColor, + ), ), ), - ), - ), + ) + else + const SizedBox(height: 14), ], ), ); diff --git a/lib/features/finance/modules/budget/presentation/sections/goal_section.dart b/lib/features/finance/modules/budget/presentation/sections/goal_section.dart index 0857aba..09bb6e2 100644 --- a/lib/features/finance/modules/budget/presentation/sections/goal_section.dart +++ b/lib/features/finance/modules/budget/presentation/sections/goal_section.dart @@ -11,6 +11,7 @@ class GoalSection extends StatelessWidget { final VoidCallback onAdd; final void Function(Goal) onGoalTap; final int? dragIndex; + final bool isLocked; const GoalSection({ super.key, @@ -19,6 +20,7 @@ class GoalSection extends StatelessWidget { required this.onGoalTap, this.contributedThisMonth = const {}, this.dragIndex, + this.isLocked = false, }); @override @@ -119,26 +121,29 @@ class GoalSection extends StatelessWidget { goal: g, isDark: isDark, contributedThisMonth: contributedThisMonth[g.id] ?? 0, - onTap: () => onGoalTap(g), + onTap: isLocked ? null : () => onGoalTap(g), ), Divider(height: 1, color: borderColor), ]), - // ── Add link ─────────────────────────────────────────────────── - GestureDetector( - onTap: onAdd, - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 14), - child: Text( - 'Add Goal', - style: GoogleFonts.dmSans( - fontSize: 13, - fontWeight: FontWeight.w600, - color: AppColors.accent, + // ── Add link ────────────────────────────────��────────────────── + if (!isLocked) + GestureDetector( + onTap: onAdd, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 14), + child: Text( + 'Add Goal', + style: GoogleFonts.dmSans( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.accent, + ), ), ), - ), - ), + ) + else + const SizedBox(height: 14), ], ), ); @@ -149,7 +154,7 @@ class _GoalRow extends StatelessWidget { final Goal goal; final bool isDark; final double contributedThisMonth; - final VoidCallback onTap; + final VoidCallback? onTap; const _GoalRow({ required this.goal, diff --git a/lib/features/finance/modules/budget/presentation/sections/subscription_section.dart b/lib/features/finance/modules/budget/presentation/sections/subscription_section.dart index 4f92944..149143b 100644 --- a/lib/features/finance/modules/budget/presentation/sections/subscription_section.dart +++ b/lib/features/finance/modules/budget/presentation/sections/subscription_section.dart @@ -10,6 +10,8 @@ class SubscriptionSection extends StatelessWidget { final VoidCallback onAdd; final Future Function(Subscription) onPay; final int? dragIndex; + final bool isLocked; + final void Function(Subscription)? onRowTap; const SubscriptionSection({ super.key, @@ -17,6 +19,8 @@ class SubscriptionSection extends StatelessWidget { required this.onAdd, required this.onPay, this.dragIndex, + this.isLocked = false, + this.onRowTap, }); @override @@ -107,25 +111,28 @@ class SubscriptionSection extends StatelessWidget { ) else ...subscriptions.expand((s) => [ - _SubscriptionRow(subscription: s, onPay: () => onPay(s), isDark: isDark, borderColor: borderColor), + _SubscriptionRow(subscription: s, onPay: () => onPay(s), onRowTap: onRowTap != null ? () => onRowTap!(s) : null, isDark: isDark, borderColor: borderColor, isLocked: isLocked), Divider(height: 1, color: borderColor), ]), // ── Add link ─────────────────────────────────────────────────── - GestureDetector( - onTap: onAdd, - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 14), - child: Text( - 'Add Subscription', - style: GoogleFonts.dmSans( - fontSize: 13, - fontWeight: FontWeight.w600, - color: AppColors.warning, + if (!isLocked) + GestureDetector( + onTap: onAdd, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 14), + child: Text( + 'Add Subscription', + style: GoogleFonts.dmSans( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.warning, + ), ), ), - ), - ), + ) + else + const SizedBox(height: 14), ], ), ); @@ -135,14 +142,18 @@ class SubscriptionSection extends StatelessWidget { class _SubscriptionRow extends StatelessWidget { final Subscription subscription; final VoidCallback onPay; + final VoidCallback? onRowTap; final bool isDark; final Color borderColor; + final bool isLocked; const _SubscriptionRow({ required this.subscription, required this.onPay, required this.isDark, required this.borderColor, + this.onRowTap, + this.isLocked = false, }); @override @@ -157,7 +168,9 @@ class _SubscriptionRow extends StatelessWidget { ? AppColors.warning : AppColors.textSecondary; - return Padding( + return InkWell( + onTap: onRowTap, + child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 11), child: Row( children: [ @@ -218,7 +231,7 @@ class _SubscriptionRow extends StatelessWidget { ), SizedBox( width: 56, - child: s.status == SubscriptionStatus.active + child: s.status == SubscriptionStatus.active && !isLocked ? Center( child: GestureDetector( onTap: onPay, @@ -240,6 +253,6 @@ class _SubscriptionRow extends StatelessWidget { ), ], ), - ); + )); } } diff --git a/lib/features/finance/modules/budget/presentation/sheets/budget_settings_sheet.dart b/lib/features/finance/modules/budget/presentation/sheets/budget_settings_sheet.dart index bc46126..385ae78 100644 --- a/lib/features/finance/modules/budget/presentation/sheets/budget_settings_sheet.dart +++ b/lib/features/finance/modules/budget/presentation/sheets/budget_settings_sheet.dart @@ -5,12 +5,14 @@ import 'package:keep_track/core/theme/app_theme.dart'; class BudgetSettingsSheet extends StatelessWidget { final String monthLabel; final VoidCallback? onEditBudget; + final VoidCallback? onCloseBudget; final VoidCallback onDeleteBudget; const BudgetSettingsSheet({ super.key, required this.monthLabel, this.onEditBudget, + this.onCloseBudget, required this.onDeleteBudget, }); @@ -18,6 +20,7 @@ class BudgetSettingsSheet extends StatelessWidget { BuildContext context, { required String monthLabel, VoidCallback? onEditBudget, + VoidCallback? onCloseBudget, required VoidCallback onDeleteBudget, }) { return showModalBottomSheet( @@ -27,6 +30,7 @@ class BudgetSettingsSheet extends StatelessWidget { builder: (_) => BudgetSettingsSheet( monthLabel: monthLabel, onEditBudget: onEditBudget, + onCloseBudget: onCloseBudget, onDeleteBudget: onDeleteBudget, ), ); @@ -75,6 +79,18 @@ class BudgetSettingsSheet extends StatelessWidget { }, ), ], + if (onCloseBudget != null) ...[ + Divider(height: 1, color: divColor), + _SettingsRow( + icon: Icons.check_circle_outline_rounded, + label: 'Close Budget', + color: isDark ? AppColors.primaryForeground : AppColors.textPrimary, + onTap: () { + Navigator.pop(context); + onCloseBudget!(); + }, + ), + ], Divider(height: 1, color: divColor), _SettingsRow( icon: Icons.delete_outline_rounded, diff --git a/lib/features/finance/modules/budget/presentation/sheets/start_planning_sheet.dart b/lib/features/finance/modules/budget/presentation/sheets/start_planning_sheet.dart index f6f76d0..9c2fb35 100644 --- a/lib/features/finance/modules/budget/presentation/sheets/start_planning_sheet.dart +++ b/lib/features/finance/modules/budget/presentation/sheets/start_planning_sheet.dart @@ -1,5 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:keep_track/core/di/service_locator.dart'; +import 'package:keep_track/core/state/stream_state.dart'; import 'package:keep_track/core/theme/app_theme.dart'; +import 'package:keep_track/features/auth/presentation/state/auth_controller.dart'; +import 'package:keep_track/features/finance/modules/budget/domain/entities/budget.dart'; +import 'package:keep_track/features/finance/modules/budget/domain/entities/budget_category.dart'; +import 'package:keep_track/features/finance/modules/budget/presentation/helpers/currency_formatter.dart'; +import 'package:keep_track/features/finance/modules/finance_category/domain/entities/finance_category_enums.dart'; +import 'package:keep_track/features/finance/presentation/state/finance_category_controller.dart'; import '../controllers/budget_controller.dart'; import '../../../../presentation/state/month_plan_controller.dart'; @@ -10,6 +18,7 @@ class StartPlanningSheet extends StatefulWidget { final String prevMonthKey; final String prevMonthLabel; final bool hasPrevBudgets; + final String? budgetProfileId; final MonthPlanController monthPlanController; final BudgetController budgetController; @@ -20,10 +29,13 @@ class StartPlanningSheet extends StatefulWidget { required this.prevMonthKey, required this.prevMonthLabel, required this.hasPrevBudgets, + this.budgetProfileId, required this.monthPlanController, required this.budgetController, }); + bool get _isProfileMode => budgetProfileId != null; + @override State createState() => _StartPlanningSheetState(); } @@ -31,21 +43,136 @@ class StartPlanningSheet extends StatefulWidget { class _StartPlanningSheetState extends State { bool _loading = false; - Future _copyFromPrev() async { - setState(() => _loading = true); - try { - await widget.monthPlanController.copyMonthPlan( - widget.prevMonthKey, + double _calculateCarryOver() { + final prevBudgets = (widget.budgetController.data ?? []) + .where((b) => + b.month == widget.prevMonthKey && + (widget.budgetProfileId == null || + b.budgetProfileId == widget.budgetProfileId)) + .toList(); + final income = prevBudgets + .where((b) => b.budgetType == BudgetType.income) + .fold(0.0, (s, b) => s + b.budgetTarget); + final expenses = prevBudgets + .where((b) => b.budgetType == BudgetType.expense) + .fold(0.0, (s, b) => s + b.budgetTarget); + return income - expenses; + } + + Future _applyCarryOver(double amount) async { + final userId = locator.get().currentUser?.id ?? ''; + final catCtrl = locator.get(); + + final categoryId = await catCtrl.findOrCreate( + name: 'Carry Over', + type: CategoryType.income, + userId: userId, + ); + if (categoryId == null) return; + + final created = await widget.budgetController.createBudget(Budget( + month: widget.monthKey, + title: 'Carry Over', + budgetType: BudgetType.income, + periodType: BudgetPeriodType.monthly, + status: BudgetStatus.active, + budgetProfileId: widget.budgetProfileId, + )); + + await widget.budgetController.addCategory( + created.id!, + BudgetCategory( + budgetId: created.id!, + financeCategoryId: categoryId, + targetAmount: amount, + ), + ); + + // Only link to month plan for non-profile mode + if (!widget._isProfileMode) { + await widget.monthPlanController.addBudgetToMonthPlan( widget.monthKey, + created.id!, ); - // Refresh budget list so the new budgets appear immediately + } + } + + Future _promptCarryOver() async { + final carryOver = _calculateCarryOver(); + if (carryOver <= 0 || !mounted) return; + + final confirmed = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Carry Over'), + content: Text( + 'Your previous month had a remaining balance of ${formatCurrency(carryOver)}. ' + 'Add it as carry-over income for ${widget.monthLabel}?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Skip'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Add Carry Over'), + ), + ], + ), + ); + + if (confirmed == true && mounted) { + await _applyCarryOver(carryOver); await widget.budgetController.refreshBudgetsWithSpentAmounts(); + } + } + + Future _copyFromPrev() async { + setState(() => _loading = true); + try { + if (widget._isProfileMode) { + // Copy profile-scoped budgets from previous month + final prevBudgets = (widget.budgetController.data ?? []) + .where((b) => + b.budgetProfileId == widget.budgetProfileId && + b.month == widget.prevMonthKey && + b.status == BudgetStatus.active) + .toList(); + for (final src in prevBudgets) { + final created = await widget.budgetController.createBudget(Budget( + month: widget.monthKey, + title: src.title, + budgetType: src.budgetType, + periodType: src.periodType, + status: BudgetStatus.active, + budgetProfileId: widget.budgetProfileId, + )); + for (final cat in src.categories) { + await widget.budgetController.addCategory( + created.id!, + BudgetCategory( + budgetId: created.id!, + financeCategoryId: cat.financeCategoryId, + targetAmount: cat.targetAmount, + ), + ); + } + } + await widget.budgetController.refreshBudgetsWithSpentAmounts(); + } else { + await widget.monthPlanController.copyMonthPlan( + widget.prevMonthKey, + widget.monthKey, + ); + await widget.budgetController.refreshBudgetsWithSpentAmounts(); + } + await _promptCarryOver(); if (mounted) Navigator.pop(context); } catch (e) { if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Failed to copy: $e'))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('Failed to copy: $e'))); setState(() => _loading = false); } } @@ -54,8 +181,11 @@ class _StartPlanningSheetState extends State { Future _startFresh() async { setState(() => _loading = true); try { - await widget.monthPlanController.getOrCreateMonthPlan(widget.monthKey); - await widget.budgetController.refreshBudgetsWithSpentAmounts(); + if (!widget._isProfileMode) { + await widget.monthPlanController.getOrCreateMonthPlan(widget.monthKey); + await widget.budgetController.refreshBudgetsWithSpentAmounts(); + } + await _promptCarryOver(); if (mounted) Navigator.pop(context); } catch (_) { if (mounted) setState(() => _loading = false); @@ -74,7 +204,6 @@ class _StartPlanningSheetState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Handle Center( child: Container( width: 36, @@ -97,7 +226,6 @@ class _StartPlanningSheetState extends State { const SizedBox(height: 24), if (widget.hasPrevBudgets) ...[ - // Copy option SizedBox( width: double.infinity, child: FilledButton.icon( @@ -113,16 +241,13 @@ class _StartPlanningSheetState extends State { ) : const Icon(Icons.copy_outlined, size: 18), label: Text( - _loading - ? 'Copying…' - : 'Copy from ${widget.prevMonthLabel}', + _loading ? 'Copying…' : 'Copy from ${widget.prevMonthLabel}', ), ), ), const SizedBox(height: 12), ], - // Start fresh option SizedBox( width: double.infinity, child: OutlinedButton.icon( diff --git a/lib/features/finance/modules/budget/presentation/widgets/all_summary_tab.dart b/lib/features/finance/modules/budget/presentation/widgets/all_summary_tab.dart index 7daaabb..2f1b6d7 100644 --- a/lib/features/finance/modules/budget/presentation/widgets/all_summary_tab.dart +++ b/lib/features/finance/modules/budget/presentation/widgets/all_summary_tab.dart @@ -1,15 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; import 'package:keep_track/core/theme/app_theme.dart'; import '../../../transaction/domain/entities/transaction.dart'; import '../../domain/entities/budget.dart'; import '../helpers/currency_formatter.dart'; - -class _ChartItem { - final String name; - final double value; - const _ChartItem(this.name, this.value); -} +import '../screens/budget_simple_sections.dart' show SimpleDonutInsightCard; class AllSummaryTab extends StatelessWidget { final List budgets; @@ -17,45 +13,24 @@ class AllSummaryTab extends StatelessWidget { const AllSummaryTab({required this.budgets, required this.transactions}); - // Income Pallet Possible to be moved? - // TODO: Investigate central coloring setting - static const _incomePalette = [ - Color(0xFF12B886), - Color(0xFF2F9E44), - Color(0xFF087F5B), - Color(0xFF40C057), - Color(0xFF63E6BE), - Color(0xFF20C997), - ]; - - static const _expensePalette = [ - Color(0xFF4C6EF5), - Color(0xFFF76707), - Color(0xFFE64980), - Color(0xFF7950F2), - Color(0xFF1C7ED6), - Color(0xFFE67700), - Color(0xFFAE3EC9), - Color(0xFF0CA678), - Color(0xFFD6336C), - Color(0xFF3BC9DB), - ]; - @override Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final textPrimary = isDark ? AppColors.primaryForeground : AppColors.textPrimary; + final cardBg = isDark ? const Color(0xFF242422) : AppColors.background; + final divColor = AppColors.border.withValues(alpha: isDark ? 0.12 : 0.3); + if (budgets.isEmpty) { return Center( child: Text( 'No budget groups yet.', - style: AppTextStyles.bodySmall.copyWith( - color: AppColors.textTertiary, - ), + style: GoogleFonts.dmSans(fontSize: 13, color: AppColors.textTertiary), ), ); } - // Build spent-per-financeCategoryId map from actual transactions - final Map spentByCategory = {}; + // Build spent map from transactions + final spentByCategory = {}; for (final t in transactions) { if (t.financeCategoryId != null) { spentByCategory[t.financeCategoryId!] = @@ -63,179 +38,437 @@ class AllSummaryTab extends StatelessWidget { } } - // Flatten categories per type - final incomeItems = budgets - .where((b) => b.budgetType == BudgetType.income) - .expand((b) => b.categories) - .map( - (c) => _ChartItem( - c.financeCategory?.name ?? '—', - spentByCategory[c.financeCategoryId] ?? 0.0, - ), - ) - .where((i) => i.value > 0) - .toList(); + double groupActual(Budget b) => + b.categories.fold(0.0, (s, c) => s + (spentByCategory[c.financeCategoryId] ?? 0.0)); - final expenseItems = budgets - .where((b) => b.budgetType == BudgetType.expense) - .expand((b) => b.categories) - .map( - (c) => _ChartItem( - c.financeCategory?.name ?? '—', - spentByCategory[c.financeCategoryId] ?? 0.0, - ), - ) - .where((i) => i.value > 0) - .toList(); + final incomeBudgets = budgets.where((b) => b.budgetType == BudgetType.income).toList(); + final expenseBudgets = budgets.where((b) => b.budgetType == BudgetType.expense).toList(); + + final plannedIncome = incomeBudgets.fold(0.0, (s, b) => s + b.budgetTarget); + final actualIncome = incomeBudgets.fold(0.0, (s, b) => s + groupActual(b)); + final plannedExpenses = expenseBudgets.fold(0.0, (s, b) => s + b.budgetTarget); + final actualExpenses = expenseBudgets.fold(0.0, (s, b) => s + groupActual(b)); + final netActual = actualIncome - actualExpenses; + final netPlanned = plannedIncome - plannedExpenses; + + final incomeProgress = plannedIncome > 0 ? (actualIncome / plannedIncome).clamp(0.0, 1.0) : 0.0; + final expenseProgress = plannedExpenses > 0 ? (actualExpenses / plannedExpenses).clamp(0.0, 1.0) : 0.0; + final netColor = netActual >= 0 ? AppColors.success : AppColors.error; return SingleChildScrollView( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (incomeItems.isNotEmpty) ...[ - _DonutSection( - label: 'INCOME', - labelColor: AppColors.success, - items: incomeItems, - palette: _incomePalette, - centerLabel: 'received', + // ── Net Balance card ─────────────────────────────────────────── + Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(14, 12, 14, 14), + decoration: BoxDecoration( + color: netColor.withValues(alpha: isDark ? 0.12 : 0.07), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: netColor.withValues(alpha: 0.2), width: 0.5), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'NET BALANCE', + style: GoogleFonts.dmSans( + fontSize: 9, fontWeight: FontWeight.w700, + color: netColor, letterSpacing: 0.8, + ), + ), + const SizedBox(height: 4), + Text( + formatCurrency(netActual), + style: GoogleFonts.dmMono( + fontSize: 22, fontWeight: FontWeight.w700, + color: netColor, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + const SizedBox(height: 2), + Text( + 'Planned: ${formatCurrency(netPlanned)}', + style: GoogleFonts.dmSans(fontSize: 11, color: netColor.withValues(alpha: 0.7)), + ), + ], + ), + ), + + const SizedBox(height: 12), + + // ── Income & Expense rows ────────────────────────────────────── + Container( + decoration: BoxDecoration( + color: cardBg, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: divColor, width: 0.5), + ), + child: Column( + children: [ + _ProgressRow( + isDark: isDark, + label: 'INCOME', + actual: actualIncome, + planned: plannedIncome, + progress: incomeProgress, + color: AppColors.success, + isOver: actualIncome > plannedIncome, + ), + Divider(height: 1, color: divColor), + _ProgressRow( + isDark: isDark, + label: 'EXPENSES', + actual: actualExpenses, + planned: plannedExpenses, + progress: expenseProgress, + color: AppColors.error, + isOver: actualExpenses > plannedExpenses, + ), + ], + ), + ), + + const SizedBox(height: 12), + + // ── Budget groups breakdown ──────────────────────────────────── + if (budgets.isNotEmpty) ...[ + Text( + 'BUDGET GROUPS', + style: GoogleFonts.dmSans( + fontSize: 9, fontWeight: FontWeight.w700, + color: AppColors.textTertiary, letterSpacing: 0.8, + ), + ), + const SizedBox(height: 6), + Container( + decoration: BoxDecoration( + color: cardBg, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: divColor, width: 0.5), + ), + child: Column( + children: [ + for (int i = 0; i < budgets.length; i++) ...[ + if (i > 0) Divider(height: 1, color: divColor), + _GroupRow( + isDark: isDark, + group: budgets[i], + actual: groupActual(budgets[i]), + ), + ], + ], + ), ), - const SizedBox(height: 24), ], - if (expenseItems.isNotEmpty) - _DonutSection( - label: 'EXPENSES', - labelColor: AppColors.error, - items: expenseItems, - palette: _expensePalette, - centerLabel: 'spent', + + const SizedBox(height: 12), + + // ── Category breakdown (compact stacked bar) ────────────────── + _CategoryBreakdown( + isDark: isDark, + label: 'INCOME BREAKDOWN', + labelColor: AppColors.success, + budgets: incomeBudgets, + spentByCategory: spentByCategory, + palette: _incomePalette, + ), + if (incomeBudgets.isNotEmpty) const SizedBox(height: 10), + _CategoryBreakdown( + isDark: isDark, + label: 'EXPENSE BREAKDOWN', + labelColor: AppColors.error, + budgets: expenseBudgets, + spentByCategory: spentByCategory, + palette: _expensePalette, + ), + + const SizedBox(height: 20), + + // ── Insights ring cards (from simple view) ───────────────────── + SimpleDonutInsightCard( + isDark: isDark, + budgets: budgets, + spentByCategory: spentByCategory, + ), + + const SizedBox(height: 8), + ], + ), + ); + } + + static const _incomePalette = [ + Color(0xFF12B886), Color(0xFF2F9E44), Color(0xFF087F5B), + Color(0xFF40C057), Color(0xFF63E6BE), Color(0xFF20C997), + ]; + static const _expensePalette = [ + Color(0xFF4C6EF5), Color(0xFFF76707), Color(0xFFE64980), + Color(0xFF7950F2), Color(0xFF1C7ED6), Color(0xFFE67700), + Color(0xFFAE3EC9), Color(0xFF0CA678), Color(0xFFD6336C), + ]; +} + +// ── Progress row (income / expenses) ───────────────────────────────────────── + +class _ProgressRow extends StatelessWidget { + final bool isDark; + final String label; + final double actual, planned, progress; + final Color color; + final bool isOver; + + const _ProgressRow({ + required this.isDark, required this.label, required this.actual, + required this.planned, required this.progress, required this.color, + required this.isOver, + }); + + @override + Widget build(BuildContext context) { + final textPrimary = isDark ? AppColors.primaryForeground : AppColors.textPrimary; + final barColor = isOver ? (label == 'INCOME' ? AppColors.success : AppColors.error) : color; + + return Padding( + padding: const EdgeInsets.fromLTRB(14, 11, 14, 11), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + label, + style: GoogleFonts.dmSans( + fontSize: 9, fontWeight: FontWeight.w700, + color: color, letterSpacing: 0.8, + ), + ), + const Spacer(), + Text( + formatCurrency(actual), + style: GoogleFonts.dmMono( + fontSize: 12, fontWeight: FontWeight.w700, color: textPrimary, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + Text( + ' / ${formatCurrency(planned)}', + style: GoogleFonts.dmMono( + fontSize: 11, color: AppColors.textTertiary, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ), + const SizedBox(height: 6), + TweenAnimationBuilder( + tween: Tween(begin: 0, end: progress), + duration: const Duration(milliseconds: 700), + curve: Curves.easeOutCubic, + builder: (_, v, __) => ClipRRect( + borderRadius: BorderRadius.circular(3), + child: LinearProgressIndicator( + value: v, + minHeight: 5, + backgroundColor: AppColors.textTertiary.withValues(alpha: 0.12), + valueColor: AlwaysStoppedAnimation(barColor), + ), ), - if (incomeItems.isEmpty && expenseItems.isEmpty) - Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Text( - 'No transactions yet this month.', - style: AppTextStyles.bodySmall.copyWith( - color: AppColors.textTertiary, + ), + const SizedBox(height: 3), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + planned > 0 ? '${(progress * 100).toStringAsFixed(0)}%' : '—', + style: GoogleFonts.dmSans(fontSize: 10, color: AppColors.textTertiary), + ), + if (isOver) + Text( + label == 'INCOME' ? '▲ over target' : '▲ over budget', + style: GoogleFonts.dmSans( + fontSize: 10, fontWeight: FontWeight.w600, + color: label == 'INCOME' ? AppColors.success : AppColors.error, ), - textAlign: TextAlign.center, + ), + ], + ), + ], + ), + ); + } +} + +// ── Individual budget group row ─────────────────────────────────────────────── + +class _GroupRow extends StatelessWidget { + final bool isDark; + final Budget group; + final double actual; + + const _GroupRow({required this.isDark, required this.group, required this.actual}); + + @override + Widget build(BuildContext context) { + final textPrimary = isDark ? AppColors.primaryForeground : AppColors.textPrimary; + final isIncome = group.budgetType == BudgetType.income; + final planned = group.budgetTarget; + final progress = planned > 0 ? (actual / planned).clamp(0.0, 1.0) : 0.0; + final isOver = actual > planned; + final color = isOver + ? (isIncome ? AppColors.success : AppColors.error) + : (isIncome ? AppColors.success : AppColors.accent); + + return Padding( + padding: const EdgeInsets.fromLTRB(14, 9, 14, 9), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 6, height: 6, + margin: const EdgeInsets.only(right: 6), + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + Expanded( + child: Text( + group.title ?? (isIncome ? 'Income' : 'Expenses'), + style: GoogleFonts.dmSans(fontSize: 12, fontWeight: FontWeight.w500, color: textPrimary), + overflow: TextOverflow.ellipsis, ), ), + Text( + formatCurrency(actual), + style: GoogleFonts.dmMono( + fontSize: 11, fontWeight: FontWeight.w600, color: color, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + Text( + ' / ${formatCurrency(planned)}', + style: GoogleFonts.dmMono( + fontSize: 10, color: AppColors.textTertiary, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ), + const SizedBox(height: 5), + TweenAnimationBuilder( + tween: Tween(begin: 0, end: progress), + duration: const Duration(milliseconds: 600), + curve: Curves.easeOutCubic, + builder: (_, v, __) => ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator( + value: v, + minHeight: 3, + backgroundColor: AppColors.textTertiary.withValues(alpha: 0.1), + valueColor: AlwaysStoppedAnimation(color), + ), ), + ), ], ), ); } } -class _DonutSection extends StatelessWidget { +// ── Category breakdown (compact legend, no donut) ──────────────────────────── + +class _CategoryBreakdown extends StatelessWidget { + final bool isDark; final String label; final Color labelColor; - final List<_ChartItem> items; + final List budgets; + final Map spentByCategory; final List palette; - final String centerLabel; - - const _DonutSection({ - required this.label, - required this.labelColor, - required this.items, - required this.palette, - required this.centerLabel, + + const _CategoryBreakdown({ + required this.isDark, required this.label, required this.labelColor, + required this.budgets, required this.spentByCategory, required this.palette, }); @override Widget build(BuildContext context) { - final total = items.fold(0, (s, i) => s + i.value); + final textPrimary = isDark ? AppColors.primaryForeground : AppColors.textPrimary; + final items = budgets + .expand((b) => b.categories) + .map((c) => ( + name: c.financeCategory?.name ?? '—', + value: spentByCategory[c.financeCategoryId] ?? 0.0, + )) + .where((i) => i.value > 0) + .toList(); + + if (items.isEmpty) return const SizedBox.shrink(); + + final total = items.fold(0.0, (s, i) => s + i.value); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( label, - style: AppTextStyles.caption.copyWith( - color: labelColor, - fontWeight: FontWeight.w700, - letterSpacing: 0.8, + style: GoogleFonts.dmSans( + fontSize: 9, fontWeight: FontWeight.w700, + color: labelColor, letterSpacing: 0.8, ), ), - const SizedBox(height: 10), - TweenAnimationBuilder( - tween: Tween(begin: 0.0, end: 1.0), - duration: const Duration(milliseconds: 800), - curve: Curves.easeOutCubic, - builder: (_, v, __) => SizedBox( - height: 160, - child: CustomPaint( - painter: _DonutChartPainter( - items: items, - palette: palette, - totalValue: total, - animProgress: v, - ), - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - formatCurrency(total * v), - style: AppTextStyles.bodyMedium.copyWith( - fontWeight: FontWeight.w700, - ), - ), - Text( - centerLabel, - style: AppTextStyles.caption.copyWith( - color: AppColors.textTertiary, - ), - ), - ], - ), - ), + const SizedBox(height: 6), + // Stacked bar + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: SizedBox( + height: 8, + child: Row( + children: [ + for (int i = 0; i < items.length; i++) + Flexible( + flex: (items[i].value / total * 1000).round(), + child: Container(color: palette[i % palette.length]), + ), + ], ), ), ), - const SizedBox(height: 12), + const SizedBox(height: 8), + // Legend ...items.asMap().entries.map((e) { - final i = e.key; - final color = palette[i % palette.length]; - final pct = total > 0 - ? (e.value.value / total * 100).toStringAsFixed(1) - : '0.0'; - return TweenAnimationBuilder( - tween: Tween(begin: 0.0, end: 1.0), - duration: const Duration(milliseconds: 500), - curve: Interval( - (i * 0.12).clamp(0.0, 0.7), - (i * 0.12 + 0.5).clamp(0.0, 1.0), - curve: Curves.easeOut, - ), - builder: (_, v, child) => Opacity( - opacity: v, - child: Transform.translate(offset: Offset((1 - v) * 8, 0), child: child), - ), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - children: [ - Container( - width: 10, - height: 10, - decoration: BoxDecoration(color: color, shape: BoxShape.circle), - ), - const SizedBox(width: 8), - Expanded( - child: Text(e.value.name, style: AppTextStyles.bodySmall, overflow: TextOverflow.ellipsis), + final color = palette[e.key % palette.length]; + final pct = total > 0 ? (e.value.value / total * 100).toStringAsFixed(0) : '0'; + return Padding( + padding: const EdgeInsets.only(bottom: 3), + child: Row( + children: [ + Container( + width: 8, height: 8, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + e.value.name, + style: GoogleFonts.dmSans(fontSize: 11, color: textPrimary), + overflow: TextOverflow.ellipsis, ), - Text('$pct%', style: AppTextStyles.caption.copyWith(color: AppColors.textSecondary)), - const SizedBox(width: 8), - Text( - formatCurrency(e.value.value), - style: AppTextStyles.bodySmall.copyWith(fontWeight: FontWeight.w600), + ), + Text( + '$pct%', + style: GoogleFonts.dmSans(fontSize: 10, color: AppColors.textTertiary), + ), + const SizedBox(width: 8), + Text( + formatCurrency(e.value.value), + style: GoogleFonts.dmMono( + fontSize: 11, fontWeight: FontWeight.w600, color: textPrimary, + fontFeatures: const [FontFeature.tabularFigures()], ), - ], - ), + ), + ], ), ); }), @@ -243,57 +476,3 @@ class _DonutSection extends StatelessWidget { ); } } - -class _DonutChartPainter extends CustomPainter { - final List<_ChartItem> items; - final List palette; - final double totalValue; - final double animProgress; - - _DonutChartPainter({ - required this.items, - required this.palette, - required this.totalValue, - this.animProgress = 1.0, - }); - - @override - void paint(Canvas canvas, Size size) { - final cx = size.width / 2; - final cy = size.height / 2; - final radius = (size.height / 2) - 8; - final strokeWidth = radius * 0.38; - final rect = Rect.fromCircle( - center: Offset(cx, cy), - radius: radius - strokeWidth / 2, - ); - - final paint = Paint() - ..style = PaintingStyle.stroke - ..strokeWidth = strokeWidth - ..strokeCap = StrokeCap.butt; - - if (totalValue <= 0) { - paint.color = AppColors.textTertiary.withValues(alpha: 0.15); - canvas.drawCircle(Offset(cx, cy), radius - strokeWidth / 2, paint); - return; - } - - double startAngle = -1.5708; // -π/2 (top) - for (var i = 0; i < items.length; i++) { - final val = items[i].value; - if (val <= 0) continue; - final fullSweep = (val / totalValue) * 6.2832; - final sweep = fullSweep * animProgress; - paint.color = palette[i % palette.length]; - if (sweep > 0.03) canvas.drawArc(rect, startAngle, sweep - 0.03, false, paint); - startAngle += fullSweep * animProgress; - } - } - - @override - bool shouldRepaint(_DonutChartPainter old) => - old.totalValue != totalValue || - old.items != items || - old.animProgress != animProgress; -} diff --git a/lib/features/finance/modules/budget/presentation/widgets/all_transaction_tab.dart b/lib/features/finance/modules/budget/presentation/widgets/all_transaction_tab.dart index c0ba75e..c56c674 100644 --- a/lib/features/finance/modules/budget/presentation/widgets/all_transaction_tab.dart +++ b/lib/features/finance/modules/budget/presentation/widgets/all_transaction_tab.dart @@ -1,47 +1,177 @@ import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:intl/intl.dart'; +import 'package:keep_track/core/settings/utils/currency_formatter.dart'; import 'package:keep_track/core/theme/app_theme.dart'; import '../../../transaction/domain/entities/transaction.dart'; -import 'transaction_mini_row.dart'; -class AllTransactionsTab extends StatelessWidget { +class AllTransactionsTab extends StatefulWidget { final List transactions; const AllTransactionsTab({required this.transactions}); + @override + State createState() => _AllTransactionsTabState(); +} + +class _AllTransactionsTabState extends State { + static const _pageSize = 10; + bool _expanded = false; + @override Widget build(BuildContext context) { - final sorted = [...transactions]..sort((a, b) => b.date.compareTo(a.date)); - - if (sorted.isEmpty) { - return Center( - child: Text( - 'No transactions this month.', - style: AppTextStyles.bodySmall.copyWith( - color: AppColors.textTertiary, + final isDark = Theme.of(context).brightness == Brightness.dark; + final cardBg = isDark ? const Color(0xFF2C2C2A) : Colors.white; + final border = isDark ? AppColors.border.withValues(alpha: 0.2) : AppColors.border.withValues(alpha: 0.5); + final divColor = isDark ? AppColors.border.withValues(alpha: 0.15) : AppColors.border.withValues(alpha: 0.4); + final textPrimary = isDark ? AppColors.primaryForeground : AppColors.textPrimary; + + final sorted = [...widget.transactions]..sort((a, b) => b.date.compareTo(a.date)); + final hasMore = sorted.length > _pageSize; + final visible = _expanded ? sorted : sorted.take(_pageSize).toList(); + + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Text( + 'TRANSACTIONS', + style: GoogleFonts.dmSans( + fontSize: 9, fontWeight: FontWeight.w700, + color: AppColors.textSecondary, letterSpacing: 1.2, + ), + ), + if (widget.transactions.isNotEmpty) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: AppColors.textSecondary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + '${widget.transactions.length}', + style: GoogleFonts.dmSans(fontSize: 9, color: AppColors.textSecondary, fontWeight: FontWeight.w600), + ), + ), + ], + ]), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: cardBg, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: border, width: 0.5), + ), + child: sorted.isEmpty + ? Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Center( + child: Text( + 'No transactions this period', + style: GoogleFonts.dmSans(fontSize: 12, color: AppColors.textTertiary), + ), + ), + ) + : Column( + children: [ + ...visible.asMap().entries.map((e) { + final i = e.key; + final t = e.value; + final isIncome = t.type == TransactionType.income; + final color = isIncome ? AppColors.success : AppColors.error; + final sign = isIncome ? '+' : '-'; + + return TweenAnimationBuilder( + key: ValueKey(t.id ?? i), + tween: Tween(begin: 0.0, end: 1.0), + duration: const Duration(milliseconds: 400), + curve: Interval( + (i * 0.06).clamp(0.0, 0.5), + ((i * 0.06) + 0.4).clamp(0.0, 1.0), + curve: Curves.easeOut, + ), + builder: (_, v, child) => Opacity( + opacity: v, + child: Transform.translate(offset: Offset(0, (1 - v) * 8), child: child), + ), + child: Column(children: [ + if (i > 0) Divider(height: 1, thickness: 0.5, color: divColor, indent: 12, endIndent: 12), + Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 10), + child: Row(children: [ + Container( + width: 28, height: 28, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(7), + ), + child: Icon( + isIncome ? Icons.arrow_downward_rounded : Icons.arrow_upward_rounded, + size: 13, color: color, + ), + ), + const SizedBox(width: 9), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text( + t.description ?? '—', + style: GoogleFonts.dmSans(fontSize: 12, fontWeight: FontWeight.w500, color: textPrimary), + maxLines: 1, overflow: TextOverflow.ellipsis, + ), + Text( + DateFormat('MMM d, yyyy').format(t.date), + style: GoogleFonts.dmSans(fontSize: 10, color: AppColors.textSecondary), + ), + ]), + ), + Column(crossAxisAlignment: CrossAxisAlignment.end, children: [ + Text( + '$sign${currencyFormatter.format(t.amount, decimalDigits: 2)}', + style: GoogleFonts.dmMono( + fontSize: 12, fontWeight: FontWeight.w600, color: color, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + if (t.hasFee) + Text( + '+${currencyFormatter.format(t.fee, decimalDigits: 2)} fee', + style: GoogleFonts.dmSans(fontSize: 9, color: AppColors.textTertiary), + ), + ]), + ]), + ), + ]), + ); + }), + if (hasMore) ...[ + Divider(height: 1, thickness: 0.5, color: divColor), + GestureDetector( + onTap: () => setState(() => _expanded = !_expanded), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 11), + child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + Text( + _expanded ? 'Show less' : 'View ${sorted.length - _pageSize} more', + style: GoogleFonts.dmSans(fontSize: 11, fontWeight: FontWeight.w600, color: AppColors.accent), + ), + const SizedBox(width: 4), + AnimatedRotation( + turns: _expanded ? 0.5 : 0, + duration: const Duration(milliseconds: 150), + child: Icon(Icons.keyboard_arrow_down_rounded, size: 15, color: AppColors.accent), + ), + ]), + ), + ), + ], + ], + ), ), - ), - ); - } - - return ListView.separated( - padding: const EdgeInsets.symmetric(vertical: 4), - itemCount: sorted.length, - separatorBuilder: (_, __) => const Divider(height: 1, indent: 16), - itemBuilder: (_, i) => TweenAnimationBuilder( - key: ValueKey(sorted[i].id), - tween: Tween(begin: 0.0, end: 1.0), - duration: const Duration(milliseconds: 400), - curve: Interval( - (i * 0.08).clamp(0.0, 0.6), - (i * 0.08 + 0.4).clamp(0.0, 1.0), - curve: Curves.easeOut, - ), - builder: (_, v, child) => Opacity( - opacity: v, - child: Transform.translate(offset: Offset(0, (1 - v) * 8), child: child), - ), - child: TransactionMiniRow(transaction: sorted[i]), + ], ), ); } diff --git a/lib/features/finance/modules/budget/presentation/widgets/category_row.dart b/lib/features/finance/modules/budget/presentation/widgets/category_row.dart index 1f35f1f..e413cf8 100644 --- a/lib/features/finance/modules/budget/presentation/widgets/category_row.dart +++ b/lib/features/finance/modules/budget/presentation/widgets/category_row.dart @@ -138,12 +138,13 @@ class CategoryRowState extends State { @override Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final textPrimary = isDark ? AppColors.primaryForeground : AppColors.textPrimary; final planned = widget.category.targetAmount; final actual = widget.spentAmount; final progress = planned > 0 ? (actual / planned).clamp(0.0, 1.0) : 0.0; final isOver = actual > planned; final isIncome = widget.isIncomeGroup; - // Income: over = good (green). Expense: over = bad (red). final overColor = isIncome ? AppColors.success : AppColors.error; final progressColor = isOver ? overColor @@ -165,9 +166,7 @@ class CategoryRowState extends State { Expanded( child: Text( widget.category.financeCategory?.name ?? '—', - style: AppTextStyles.bodySmall.copyWith( - color: AppColors.textPrimary, - ), + style: AppTextStyles.bodySmall.copyWith(color: textPrimary), overflow: TextOverflow.ellipsis, ), ), @@ -255,7 +254,7 @@ class CategoryRowState extends State { color: isOver ? overColor : actual > 0 - ? (Theme.of(context).brightness == Brightness.dark ? AppColors.primaryForeground : AppColors.textPrimary) + ? textPrimary : AppColors.textTertiary, fontFeatures: const [FontFeature.tabularFigures()], ), diff --git a/lib/features/finance/modules/budget/presentation/widgets/debt_row.dart b/lib/features/finance/modules/budget/presentation/widgets/debt_row.dart index ec3b95b..cd5188b 100644 --- a/lib/features/finance/modules/budget/presentation/widgets/debt_row.dart +++ b/lib/features/finance/modules/budget/presentation/widgets/debt_row.dart @@ -14,6 +14,7 @@ class DebtRow extends StatefulWidget { final VoidCallback onPay; final VoidCallback onEdit; final Future Function(double) onUpdateMonthlyPayment; + final bool isLocked; const DebtRow({ super.key, @@ -21,6 +22,7 @@ class DebtRow extends StatefulWidget { required this.isReceivable, required this.isSelected, this.paidThisMonth = 0, + this.isLocked = false, required this.onSelect, required this.onPay, required this.onEdit, @@ -160,7 +162,7 @@ class _DebtRowState extends State { const SizedBox(width: 6), // Planned — tap to edit (absorb tap so row select doesn't fire) GestureDetector( - onTap: _editing ? null : _startEdit, + onTap: widget.isLocked || _editing ? null : _startEdit, behavior: HitTestBehavior.opaque, child: SizedBox( width: 70, @@ -242,7 +244,7 @@ class _DebtRowState extends State { const SizedBox(width: 4), // Pay / Collect button - if (d.status == DebtStatus.active) + if (d.status == DebtStatus.active && !widget.isLocked) GestureDetector( onTap: widget.onPay, child: Container( diff --git a/lib/features/finance/modules/budget/presentation/widgets/side_summary_panel.dart b/lib/features/finance/modules/budget/presentation/widgets/side_summary_panel.dart index bdca70c..5d9e8a8 100644 --- a/lib/features/finance/modules/budget/presentation/widgets/side_summary_panel.dart +++ b/lib/features/finance/modules/budget/presentation/widgets/side_summary_panel.dart @@ -160,35 +160,38 @@ class _SideSummaryPanelState extends ScopedScreenState Widget _buildTopBar(bool isDark, Color headerBorder) { final hasActions = widget.onToggleView != null || widget.onSettings != null; if (!hasActions) return const SizedBox.shrink(); + final textPrimary = isDark ? AppColors.primaryForeground : AppColors.textPrimary; return Container( height: 44, decoration: BoxDecoration( - color: isDark ? const Color(0xFF2C2C2A) : Colors.white, + color: isDark ? const Color(0xFF242422) : Colors.white, border: Border(bottom: BorderSide(color: headerBorder, width: 0.5)), ), - padding: const EdgeInsets.symmetric(horizontal: 12), + padding: const EdgeInsets.symmetric(horizontal: 14), child: Row( children: [ + Icon(Icons.analytics_outlined, size: 14, color: AppColors.accent), + const SizedBox(width: 6), Text( - 'Panel', - style: GoogleFonts.dmSans(fontSize: 12, fontWeight: FontWeight.w600, color: AppColors.textSecondary), + 'Overview', + style: GoogleFonts.dmSans(fontSize: 13, fontWeight: FontWeight.w700, color: textPrimary), ), const Spacer(), if (widget.onToggleView != null) IconButton( - icon: Icon(Icons.view_list_outlined, size: 18, color: AppColors.textSecondary), + icon: Icon(Icons.view_list_outlined, size: 17, color: AppColors.textSecondary), onPressed: widget.onToggleView, - tooltip: 'Switch to Simple', + tooltip: 'Switch to Simple view', padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + constraints: const BoxConstraints(minWidth: 30, minHeight: 30), ), if (widget.onSettings != null) IconButton( - icon: Icon(Icons.more_vert_rounded, size: 18, color: AppColors.textSecondary), + icon: Icon(Icons.more_vert_rounded, size: 17, color: AppColors.textSecondary), onPressed: widget.onSettings, tooltip: 'Budget settings', padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + constraints: const BoxConstraints(minWidth: 30, minHeight: 30), ), ], ), @@ -207,6 +210,9 @@ class _SideSummaryPanelState extends ScopedScreenState Widget _buildDebtMode(Debt debt, Color headerBg, Color headerBorder) { final isReceivable = debt.type == DebtType.lending; + final isDark = headerBg.computeLuminance() < 0.1; + final textPrimary = isDark ? AppColors.primaryForeground : AppColors.textPrimary; + final debtColor = isReceivable ? AppColors.success : AppColors.error; final debtTxns = widget.allTransactions.where((t) => t.debtId == debt.id).toList() ..sort((a, b) => b.date.compareTo(a.date)); @@ -218,29 +224,28 @@ class _SideSummaryPanelState extends ScopedScreenState color: headerBg, border: Border(top: BorderSide(color: headerBorder, width: 0.5)), ), - padding: const EdgeInsets.fromLTRB(8, 4, 8, 4), + padding: const EdgeInsets.fromLTRB(12, 6, 8, 6), child: Row( children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( - color: (isReceivable ? AppColors.success : AppColors.error).withValues(alpha: 0.12), + color: debtColor.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(4), ), child: Text( isReceivable ? 'RECEIVABLE' : 'DEBT', - style: AppTextStyles.caption.copyWith( - color: isReceivable ? AppColors.success : AppColors.error, - fontWeight: FontWeight.w700, - fontSize: 9, + style: GoogleFonts.dmSans( + fontSize: 9, fontWeight: FontWeight.w700, + color: debtColor, letterSpacing: 0.5, ), ), ), - const SizedBox(width: 6), + const SizedBox(width: 8), Expanded( child: Text( debt.personName, - style: AppTextStyles.bodySmall.copyWith(fontWeight: FontWeight.w600, color: AppColors.accent), + style: GoogleFonts.dmSans(fontSize: 13, fontWeight: FontWeight.w700, color: textPrimary), overflow: TextOverflow.ellipsis, ), ), @@ -279,6 +284,8 @@ class _SideSummaryPanelState extends ScopedScreenState } Widget _buildCategoryMode(BudgetCategory cat, Color headerBg, Color headerBorder) { + final isDark = headerBg.computeLuminance() < 0.1; + final textPrimary = isDark ? AppColors.primaryForeground : AppColors.textPrimary; final catTxns = widget.allTransactions .where((t) => t.financeCategoryId == cat.financeCategoryId) .toList() @@ -292,22 +299,20 @@ class _SideSummaryPanelState extends ScopedScreenState color: headerBg, border: Border(top: BorderSide(color: headerBorder, width: 0.5)), ), - padding: const EdgeInsets.fromLTRB(8, 4, 8, 4), + padding: const EdgeInsets.fromLTRB(4, 4, 8, 4), child: Row( children: [ IconButton( - icon: const Icon(Icons.arrow_back_ios_rounded, size: 14), + icon: Icon(Icons.arrow_back_ios_rounded, size: 14, color: AppColors.textSecondary), onPressed: widget.onCategoryPanelClose, - color: AppColors.textSecondary, visualDensity: VisualDensity.compact, padding: const EdgeInsets.all(6), constraints: const BoxConstraints(), ), - const SizedBox(width: 4), Expanded( child: Text( cat.financeCategory?.name ?? 'Category', - style: AppTextStyles.bodySmall.copyWith(fontWeight: FontWeight.w600, color: AppColors.accent), + style: GoogleFonts.dmSans(fontSize: 13, fontWeight: FontWeight.w700, color: textPrimary), overflow: TextOverflow.ellipsis, ), ), @@ -345,6 +350,11 @@ class _SideSummaryPanelState extends ScopedScreenState Widget _buildGroupMode(Color headerBg, Color headerBorder) { final group = widget.selectedGroup; + final isDark = headerBg == const Color(0xFF1E1E1C) || headerBg.computeLuminance() < 0.1; + final textPrimary = isDark ? AppColors.primaryForeground : AppColors.textPrimary; + + final tabLabelStyle = GoogleFonts.dmSans(fontSize: 12, fontWeight: FontWeight.w600); + final tabUnselectedStyle = GoogleFonts.dmSans(fontSize: 12, fontWeight: FontWeight.w400); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -357,9 +367,10 @@ class _SideSummaryPanelState extends ScopedScreenState child: group == null ? TabBar( controller: _tabController, - labelStyle: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600), - unselectedLabelStyle: const TextStyle(fontSize: 12), + labelStyle: tabLabelStyle, + unselectedLabelStyle: tabUnselectedStyle, indicatorColor: AppColors.accent, + indicatorWeight: 2, labelColor: AppColors.accent, unselectedLabelColor: AppColors.textSecondary, tabs: const [Tab(text: 'Summary'), Tab(text: 'Transactions')], @@ -368,22 +379,29 @@ class _SideSummaryPanelState extends ScopedScreenState crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 8, 0), + padding: const EdgeInsets.fromLTRB(14, 8, 8, 0), child: Row( children: [ - Icon(Icons.folder_outlined, size: 13, color: AppColors.accent), - const SizedBox(width: 6), + Container( + width: 6, height: 6, + margin: const EdgeInsets.only(right: 7), + decoration: BoxDecoration( + color: group.budgetType == BudgetType.income ? AppColors.success : AppColors.accent, + shape: BoxShape.circle, + ), + ), Expanded( child: Text( group.title ?? '', - style: AppTextStyles.bodySmall.copyWith(color: AppColors.accent, fontWeight: FontWeight.w600), + style: GoogleFonts.dmSans( + fontSize: 13, fontWeight: FontWeight.w700, color: textPrimary, + ), overflow: TextOverflow.ellipsis, ), ), IconButton( - icon: const Icon(Icons.close, size: 15), + icon: Icon(Icons.close_rounded, size: 14, color: AppColors.textTertiary), onPressed: widget.onClose, - color: AppColors.textTertiary, visualDensity: VisualDensity.compact, padding: const EdgeInsets.all(8), constraints: const BoxConstraints(), @@ -393,9 +411,10 @@ class _SideSummaryPanelState extends ScopedScreenState ), TabBar( controller: _tabController, - labelStyle: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600), - unselectedLabelStyle: const TextStyle(fontSize: 12), + labelStyle: tabLabelStyle, + unselectedLabelStyle: tabUnselectedStyle, indicatorColor: AppColors.accent, + indicatorWeight: 2, labelColor: AppColors.accent, unselectedLabelColor: AppColors.textSecondary, tabs: const [Tab(text: 'Transactions')], diff --git a/lib/features/finance/modules/transaction/data/datasources/receipt_parser_service.dart b/lib/features/finance/modules/transaction/data/datasources/receipt_parser_service.dart new file mode 100644 index 0000000..66ab407 --- /dev/null +++ b/lib/features/finance/modules/transaction/data/datasources/receipt_parser_service.dart @@ -0,0 +1,79 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:dio/dio.dart'; +import 'package:keep_track/core/network/api_client.dart'; + +class ParsedTransactionItem { + final double amount; + final String type; + final String description; + final DateTime date; + final String categoryName; + + const ParsedTransactionItem({ + required this.amount, + required this.type, + required this.description, + required this.date, + required this.categoryName, + }); + + factory ParsedTransactionItem.fromJson(Map json) => + ParsedTransactionItem( + amount: (json['amount'] as num).toDouble(), + type: json['type'] as String, + description: json['description'] as String, + date: DateTime.tryParse(json['date'] as String? ?? '') ?? DateTime.now(), + categoryName: json['categoryName'] as String, + ); + + ParsedTransactionItem copyWith({ + double? amount, + String? type, + String? description, + DateTime? date, + String? categoryName, + }) => + ParsedTransactionItem( + amount: amount ?? this.amount, + type: type ?? this.type, + description: description ?? this.description, + date: date ?? this.date, + categoryName: categoryName ?? this.categoryName, + ); +} + +class ReceiptParserService { + final Dio _dio = ApiClient.instance; + + Future> parseReceiptImage(File imageFile) async { + final fileSize = await imageFile.length(); + if (fileSize > 5 * 1024 * 1024) { + throw Exception('Image is too large. Please choose a smaller photo (max 5 MB).'); + } + + final bytes = await imageFile.readAsBytes(); + final base64Image = base64Encode(bytes); + final mimeType = _mimeType(imageFile.path); + + final response = await _dio.post>( + '/transactions/parse-receipt', + data: {'imageBase64': base64Image, 'mimeType': mimeType}, + options: Options(receiveTimeout: const Duration(seconds: 60)), + ); + + return (response.data as List) + .map((e) => ParsedTransactionItem.fromJson(e as Map)) + .toList(); + } + + String _mimeType(String path) { + final ext = path.split('.').last.toLowerCase(); + return switch (ext) { + 'png' => 'image/png', + 'webp' => 'image/webp', + 'gif' => 'image/gif', + _ => 'image/jpeg', + }; + } +} diff --git a/lib/features/finance/presentation/screens/tabs/budget/budget_tab_screen.dart b/lib/features/finance/presentation/screens/tabs/budget/budget_tab_screen.dart index 1b4a16f..b382222 100644 --- a/lib/features/finance/presentation/screens/tabs/budget/budget_tab_screen.dart +++ b/lib/features/finance/presentation/screens/tabs/budget/budget_tab_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:keep_track/core/di/service_locator.dart'; import 'package:keep_track/core/state/stream_state.dart'; import 'package:keep_track/core/theme/app_theme.dart'; +import 'package:keep_track/features/finance/modules/budget/domain/entities/budget.dart'; import 'package:keep_track/features/finance/modules/budget/presentation/controllers/budget_controller.dart'; import 'package:keep_track/features/finance/modules/budget/presentation/screens/budget_month_screen.dart'; import 'package:keep_track/features/finance/modules/budget/presentation/screens/budget_simple_view.dart'; @@ -40,23 +41,95 @@ class _BudgetTabScreenState extends State { bool get _inBudget => _activeProfile != null; - void _back() => setState(() { - _activeProfile = null; - _sheetMode = false; - }); + void _back() { + _profileController.selectedProfileId = null; + setState(() { + _activeProfile = null; + _sheetMode = false; + }); + } void _toggleView() => setState(() => _sheetMode = !_sheetMode); // ── Settings ────────────────────────────────────────────────────────────── - void _openProfileSettings() { + void _openSettings(List monthBudgets) { final profile = _activeProfile!; - BudgetSettingsSheet.show( - context, - monthLabel: profile.name, - onEditBudget: _editProfile, - onDeleteBudget: () => _confirmDeleteProfile(profile), + if (profile.isMonthly) { + // Monthly profile: close this month's budgets, not the whole profile + final allClosed = monthBudgets.isNotEmpty && + monthBudgets.every((b) => b.status == BudgetStatus.closed); + BudgetSettingsSheet.show( + context, + monthLabel: profile.name, + onEditBudget: _editProfile, + onCloseBudget: monthBudgets.isNotEmpty && !allClosed + ? () => _confirmCloseMonthBudgets(profile, monthBudgets) + : null, + onDeleteBudget: () => _confirmDeleteProfile(profile), + ); + } else { + // Custom profile: close the whole profile + BudgetSettingsSheet.show( + context, + monthLabel: profile.name, + onEditBudget: _editProfile, + onCloseBudget: profile.status == BudgetProfileStatus.active + ? () => _confirmCloseProfile(profile) + : null, + onDeleteBudget: () => _confirmDeleteProfile(profile), + ); + } + } + + Future _confirmCloseMonthBudgets(BudgetProfile profile, List monthBudgets) async { + final confirmed = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Close this month\'s budget?'), + content: const Text('All active budget groups for this month will be marked as closed.'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')), + FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Close Budget')), + ], + ), + ); + if (confirmed != true || !mounted) return; + for (final b in monthBudgets) { + if (b.id != null && b.status == BudgetStatus.active) { + await _budgetController.closeBudget(b.id!); + } + } + } + + Future _confirmCloseProfile(BudgetProfile profile) async { + final confirmed = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: Text('Close "${profile.name}"?'), + content: const Text( + 'This profile will be marked as completed. You can still view it.', + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')), + FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Close Budget')), + ], + ), ); + if (confirmed != true || !mounted) return; + + await _profileController.updateProfile( + profile.copyWith(status: BudgetProfileStatus.completed), + ); + + final s = _profileController.state; + if (s is AsyncData> && mounted) { + final updated = s.data.firstWhere( + (p) => p.id == profile.id, + orElse: () => profile.copyWith(status: BudgetProfileStatus.completed), + ); + setState(() => _activeProfile = updated); + } } Future _editProfile() async { @@ -158,8 +231,10 @@ class _BudgetTabScreenState extends State { if (_sheetMode) { return BudgetMonthScreen( onToggleView: _toggleView, - onOpenSettings: _openProfileSettings, + onOpenSettings: () => _openSettings([]), + onBack: _back, budgetProfileId: _activeProfile!.id, + profileIsMonthly: _activeProfile!.isMonthly, ); } @@ -178,7 +253,7 @@ class _BudgetTabScreenState extends State { profileIsMonthly: _activeProfile!.isMonthly, onAddProfileGroup: _showAddProfileGroup, onToggleView: _toggleView, - onOpenSettings: _openProfileSettings, + onOpenSettings: _openSettings, ), ); } diff --git a/lib/features/finance/presentation/screens/tabs/dashboard/dashboard_insights.dart b/lib/features/finance/presentation/screens/tabs/dashboard/dashboard_insights.dart index d7401fb..5ce6946 100644 --- a/lib/features/finance/presentation/screens/tabs/dashboard/dashboard_insights.dart +++ b/lib/features/finance/presentation/screens/tabs/dashboard/dashboard_insights.dart @@ -167,25 +167,10 @@ class _DashboardInsightsState extends State with TickerProvid final hasExpenseBreakdown = expenseBudgets.isNotEmpty; return Padding( - padding: const EdgeInsets.fromLTRB(16, 20, 16, 0), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // ── Section header ────────────────────────────────────────────────── - Text( - 'Monthly Insights', - style: GoogleFonts.dmSans( - fontSize: 15, - fontWeight: FontWeight.w700, - color: textPrimary, - ), - ), - const SizedBox(height: 3), - Text( - 'Visualize spending patterns and budget performance over time.', - style: GoogleFonts.dmSans(fontSize: 12, color: AppColors.textSecondary), - ), - const SizedBox(height: 14), _buildRangeChips(), // ── Transactions Involved + Expense Breakdown ──────────────────── diff --git a/lib/features/finance/presentation/screens/tabs/dashboard/dashboard_tab.dart b/lib/features/finance/presentation/screens/tabs/dashboard/dashboard_tab.dart index 8b8db70..0298433 100644 --- a/lib/features/finance/presentation/screens/tabs/dashboard/dashboard_tab.dart +++ b/lib/features/finance/presentation/screens/tabs/dashboard/dashboard_tab.dart @@ -44,6 +44,7 @@ class _DashboardTabState extends State { late final TransactionPlanController _txPlanController; String? _selectedProfileId; + bool _showingInsights = false; @override void initState() { @@ -83,6 +84,13 @@ class _DashboardTabState extends State { (profiles.isEmpty ? null : (profiles.where((p) => p.isMain).firstOrNull ?? profiles.first).id); + // Keep BudgetProfileController in sync so the global FAB knows the current profile + if (effectiveProfileId != null && + _budgetProfileController.selectedProfileId != effectiveProfileId) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _budgetProfileController.selectedProfileId = effectiveProfileId; + }); + } return AsyncStreamBuilder>( state: _savingsController, builder: (_, buckets) => AsyncStreamBuilder>( @@ -154,13 +162,41 @@ class _DashboardTabState extends State { .toList() ..sort((a, b) => a.plannedDate.compareTo(b.plannedDate)); + final profileBudgets = budgets.where((b) => b.budgetProfileId == selectedProfileId).toList(); + + // ── Insights view ────────────────────────────────────────────────────────── + if (_showingInsights) { + return CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: _InsightsHeader( + isDark: isDark, + onBack: () => setState(() => _showingInsights = false), + ), + ), + SliverToBoxAdapter( + child: DashboardInsights( + isDark: isDark, + transactions: profileTxs, + budgets: profileBudgets, + ), + ), + const SliverToBoxAdapter(child: SizedBox(height: 40)), + ], + ); + } + + // ── Normal dashboard ─────────────────────────────────────────────────────── return CustomScrollView( slivers: [ if (profiles.isNotEmpty) SliverToBoxAdapter(child: _ProfilePills( profiles: profiles, selectedId: selectedProfileId, - onSelect: (id) => setState(() => _selectedProfileId = id), + onSelect: (id) { + setState(() => _selectedProfileId = id); + _budgetProfileController.selectedProfileId = id; + }, isDark: isDark, )), SliverToBoxAdapter(child: _MonthOverviewCard( @@ -202,11 +238,15 @@ class _DashboardTabState extends State { ), ), ), - SliverToBoxAdapter(child: DashboardInsights( - isDark: isDark, - transactions: profileTxs, - budgets: budgets.where((b) => b.budgetProfileId == selectedProfileId).toList(), - )), + SliverToBoxAdapter( + child: _InsightsEntryCard( + isDark: isDark, + hasBudgets: profileBudgets.isNotEmpty, + transactionCount: profileTxs.length, + monthLabel: DateFormat('MMMM yyyy').format(DateTime.now()), + onTap: () => setState(() => _showingInsights = true), + ), + ), const SliverToBoxAdapter(child: SizedBox(height: 32)), ], ); @@ -1279,6 +1319,123 @@ class _FadeSlideIn extends StatelessWidget { } } +// ─── Insights Entry Card ────────────────────────────────────────────────────── + +class _InsightsEntryCard extends StatelessWidget { + final bool isDark; + final bool hasBudgets; + final int transactionCount; + final String monthLabel; + final VoidCallback onTap; + + const _InsightsEntryCard({ + required this.isDark, + required this.hasBudgets, + required this.transactionCount, + required this.monthLabel, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final cardBg = isDark ? const Color(0xFF2C2C2A) : Colors.white; + final borderColor = isDark ? AppColors.border.withValues(alpha: 0.2) : AppColors.border.withValues(alpha: 0.5); + final textPrimary = isDark ? AppColors.primaryForeground : AppColors.textPrimary; + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: cardBg, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: borderColor, width: 0.5), + ), + child: Row( + children: [ + Container( + width: 44, height: 44, + decoration: BoxDecoration( + color: AppColors.accent.withValues(alpha: isDark ? 0.15 : 0.08), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(Icons.insights_rounded, size: 22, color: AppColors.accent), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Monthly Insights', + style: GoogleFonts.dmSans(fontSize: 14, fontWeight: FontWeight.w700, color: textPrimary), + ), + const SizedBox(height: 3), + Text( + hasBudgets + ? 'Review your budget performance & spending patterns for $monthLabel' + : 'Explore your $transactionCount transaction${transactionCount == 1 ? '' : 's'} for $monthLabel', + style: GoogleFonts.dmSans(fontSize: 12, color: AppColors.textSecondary, height: 1.4), + maxLines: 2, + ), + ], + ), + ), + const SizedBox(width: 10), + Icon(Icons.chevron_right_rounded, size: 20, color: AppColors.textTertiary), + ], + ), + ), + ), + ); + } +} + +// ─── Insights View Header ───────────────────────────────────────────────────── + +class _InsightsHeader extends StatelessWidget { + final bool isDark; + final VoidCallback onBack; + + const _InsightsHeader({required this.isDark, required this.onBack}); + + @override + Widget build(BuildContext context) { + final textPrimary = isDark ? AppColors.primaryForeground : AppColors.textPrimary; + return Padding( + padding: const EdgeInsets.fromLTRB(8, 12, 16, 4), + child: Row( + children: [ + IconButton( + icon: Icon(Icons.arrow_back_rounded, size: 20, color: textPrimary), + onPressed: onBack, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 36, minHeight: 36), + ), + const SizedBox(width: 4), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Monthly Insights', + style: GoogleFonts.dmSans(fontSize: 17, fontWeight: FontWeight.w700, color: textPrimary), + ), + Text( + 'Spending patterns & budget performance over time', + style: GoogleFonts.dmSans(fontSize: 12, color: AppColors.textSecondary), + ), + ], + ), + ), + ], + ), + ); + } +} + // ─── X-axis labels ──────────────────────────────────────────────────────────── class _XAxisLabels extends StatelessWidget { diff --git a/lib/features/finance/presentation/screens/transactions/create_transaction_sheet.dart b/lib/features/finance/presentation/screens/transactions/create_transaction_sheet.dart index cdafe76..76d2973 100644 --- a/lib/features/finance/presentation/screens/transactions/create_transaction_sheet.dart +++ b/lib/features/finance/presentation/screens/transactions/create_transaction_sheet.dart @@ -18,19 +18,28 @@ import 'package:keep_track/features/finance/presentation/state/budget_profile_co import 'package:keep_track/features/finance/presentation/state/finance_category_controller.dart'; import 'package:keep_track/features/finance/presentation/state/transaction_controller.dart'; import 'package:keep_track/features/finance/presentation/state/transaction_plan_controller.dart'; +import 'scan_expenses_sheet.dart'; class CreateTransactionSheet extends StatefulWidget { final VoidCallback? onCreated; + final String? initialProfileId; - const CreateTransactionSheet({super.key, this.onCreated}); + const CreateTransactionSheet({super.key, this.onCreated, this.initialProfileId}); - static Future show(BuildContext context, {VoidCallback? onCreated}) { + static Future show( + BuildContext context, { + VoidCallback? onCreated, + String? initialProfileId, + }) { return showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, useSafeArea: true, - builder: (_) => CreateTransactionSheet(onCreated: onCreated), + builder: (_) => CreateTransactionSheet( + onCreated: onCreated, + initialProfileId: initialProfileId, + ), ); } @@ -69,6 +78,19 @@ class _CreateTransactionSheetState extends State { _profileController = locator.get(); _authController = locator.get(); _catController.loadCategories(); + + // Pre-select profile if provided + if (widget.initialProfileId != null) { + final profiles = _profileController.data ?? []; + final match = profiles.cast().firstWhere( + (p) => p?.id == widget.initialProfileId, + orElse: () => null, + ); + if (match != null) { + _selectedProfileId = match.id; + _selectedProfileName = match.name; + } + } } @override @@ -175,10 +197,12 @@ class _CreateTransactionSheetState extends State { if (_selectedProfileId == null) return null; final s = _budgetController.state; if (s is! AsyncData>) return null; - return s.data + final ids = s.data .where((b) => b.budgetProfileId == _selectedProfileId) .expand((b) => b.categories.map((c) => c.financeCategoryId)) .toSet(); + // If profile has no budget categories yet, fall back to showing all categories + return ids.isEmpty ? null : ids; } void _pickBudgetProfile() { @@ -226,7 +250,7 @@ class _CreateTransactionSheetState extends State { context: context, isScrollControlled: true, backgroundColor: Colors.transparent, - builder: (_) => _CategoryPicker( + builder: (_) => TransactionCategoryPicker( type: _type, selectedId: _category?.id, controller: _catController, @@ -367,6 +391,35 @@ class _CreateTransactionSheetState extends State { ), ), ), + // Scan from image banner + GestureDetector( + onTap: () { + Navigator.pop(context); + WidgetsBinding.instance.addPostFrameCallback((_) { + ScanExpensesSheet.show(context, onConfirmed: widget.onCreated); + }); + }, + child: Container( + margin: const EdgeInsets.fromLTRB(16, 12, 16, 4), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11), + decoration: BoxDecoration( + color: AppColors.accent.withValues(alpha: isDark ? 0.12 : 0.07), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.accent.withValues(alpha: 0.25), width: 0.5), + ), + child: Row(children: [ + Icon(Icons.document_scanner_outlined, size: 18, color: AppColors.accent), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Scan expenses from image', + style: GoogleFonts.dmSans(fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.accent), + ), + ), + Icon(Icons.chevron_right_rounded, size: 16, color: AppColors.accent.withValues(alpha: 0.6)), + ]), + ), + ), Divider(height: 20, color: borderColor), // Numpad Padding(padding: const EdgeInsets.symmetric(horizontal: 16), child: _Numpad(onTap: _numTap)), @@ -598,14 +651,14 @@ class _DatePickerSheetState extends State<_DatePickerSheet> { // ─── Category Picker ────────────────────────────────────────────────────────── -class _CategoryPicker extends StatelessWidget { +class TransactionCategoryPicker extends StatelessWidget { final TransactionType type; final String? selectedId; final FinanceCategoryController controller; final Set? allowedIds; final bool isDark; final void Function(FinanceCategory) onSelect; - const _CategoryPicker({required this.type, required this.selectedId, required this.controller, this.allowedIds, required this.isDark, required this.onSelect}); + const TransactionCategoryPicker({super.key, required this.type, required this.selectedId, required this.controller, this.allowedIds, required this.isDark, required this.onSelect}); @override Widget build(BuildContext context) { diff --git a/lib/features/finance/presentation/screens/transactions/scan_expenses_sheet.dart b/lib/features/finance/presentation/screens/transactions/scan_expenses_sheet.dart new file mode 100644 index 0000000..0e0ef86 --- /dev/null +++ b/lib/features/finance/presentation/screens/transactions/scan_expenses_sheet.dart @@ -0,0 +1,835 @@ +import 'dart:io'; +import 'package:flutter/foundation.dart' show defaultTargetPlatform, TargetPlatform; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:intl/intl.dart'; +import 'package:keep_track/core/di/service_locator.dart'; +import 'package:keep_track/core/theme/app_theme.dart'; +import 'package:keep_track/features/auth/presentation/state/auth_controller.dart'; +import 'package:keep_track/features/finance/modules/budget/domain/entities/budget.dart'; +import 'package:keep_track/features/finance/modules/budget/presentation/controllers/budget_controller.dart'; +import 'package:keep_track/features/finance/modules/budget_profile/domain/entities/budget_profile.dart'; +import 'package:keep_track/features/finance/modules/finance_category/domain/entities/finance_category.dart'; +import 'package:keep_track/features/finance/modules/finance_category/domain/entities/finance_category_enums.dart'; +import 'package:keep_track/features/finance/modules/transaction/data/datasources/receipt_parser_service.dart'; +import 'package:keep_track/features/finance/modules/transaction/domain/entities/transaction.dart'; +import 'package:keep_track/features/finance/presentation/state/budget_profile_controller.dart'; +import 'package:keep_track/features/finance/presentation/state/finance_category_controller.dart'; +import 'package:keep_track/features/finance/presentation/state/transaction_controller.dart'; +import 'package:keep_track/core/state/stream_state.dart'; +import 'package:uuid/uuid.dart'; +import 'create_transaction_sheet.dart' show TransactionCategoryPicker; + +enum _ScanStep { pick, loading, review, saving } + +class ScanExpensesSheet extends StatefulWidget { + final VoidCallback? onConfirmed; + + const ScanExpensesSheet({super.key, this.onConfirmed}); + + static Future show(BuildContext context, {VoidCallback? onConfirmed}) => + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + useSafeArea: true, + builder: (_) => ScanExpensesSheet(onConfirmed: onConfirmed), + ); + + @override + State createState() => _ScanExpensesSheetState(); +} + +class _ScanExpensesSheetState extends State { + late final TransactionController _txController; + late final FinanceCategoryController _catController; + late final BudgetProfileController _profileController; + late final BudgetController _budgetController; + late final AuthController _authController; + final ReceiptParserService _parserService = ReceiptParserService(); + final _picker = ImagePicker(); + final _uuid = const Uuid(); + + _ScanStep _step = _ScanStep.pick; + File? _pickedFile; + List<_EditableItem> _items = []; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _txController = locator.get(); + _catController = locator.get(); + _profileController = locator.get(); + _budgetController = locator.get(); + _authController = locator.get(); + } + + FinanceCategory? _matchCategory(String name, TransactionType type) { + final cats = _catController.data ?? []; + final targetType = type == TransactionType.income ? CategoryType.income : CategoryType.expense; + try { + return cats.firstWhere( + (c) => c.type == targetType && !c.isArchive && + c.name.toLowerCase() == name.toLowerCase(), + ); + } catch (_) { + return null; + } + } + + Set? _allowedCategoryIds(String? profileId) { + if (profileId == null) return null; + final s = _budgetController.state; + if (s is! AsyncData>) return null; + final ids = s.data + .where((b) => b.budgetProfileId == profileId) + .expand((b) => b.categories.map((c) => c.financeCategoryId)) + .toSet(); + return ids.isEmpty ? null : ids; + } + + void _showCategoryPicker(_EditableItem item) { + final isDark = Theme.of(context).brightness == Brightness.dark; + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => TransactionCategoryPicker( + type: item.type, + selectedId: item.category?.id, + controller: _catController, + allowedIds: _allowedCategoryIds(item.profileId), + isDark: isDark, + onSelect: (cat) { + setState(() { + item.category = cat; + item.categoryName = cat.name; + }); + Navigator.pop(context); + }, + ), + ); + } + + List get _profiles { + final s = _profileController.state; + return s is AsyncData> ? s.data : []; + } + + bool get _isDesktop => + defaultTargetPlatform == TargetPlatform.windows || + defaultTargetPlatform == TargetPlatform.macOS || + defaultTargetPlatform == TargetPlatform.linux; + + String? get _defaultProfileId => _profileController.activeProfileId; + String? _profileNameFor(String? id) { + if (id == null) return null; + try { return _profiles.firstWhere((p) => p.id == id).name; } + catch (_) { return null; } + } + + Future _pickImage(ImageSource source) async { + final xfile = await _picker.pickImage(source: source, imageQuality: 85); + if (xfile == null) return; + _pickedFile = File(xfile.path); + await _parseImage(); + } + + Future _parseImage() async { + setState(() { _step = _ScanStep.loading; _errorMessage = null; }); + try { + final parsed = await _parserService.parseReceiptImage(_pickedFile!); + if (!mounted) return; + final pid = _defaultProfileId; + final pname = _profileNameFor(pid); + final today = DateTime.now(); + setState(() { + _items = parsed.map((p) { + final txType = p.type == 'income' ? TransactionType.income : TransactionType.expense; + final matched = _matchCategory(p.categoryName, txType); + return _EditableItem( + id: _uuid.v4(), + amount: p.amount, + type: txType, + description: p.description, + date: today, + categoryName: p.categoryName, + category: matched, + profileId: pid, + profileName: pname, + ); + }).toList(); + _step = _ScanStep.review; + }); + } catch (e) { + if (!mounted) return; + setState(() { _items = []; _errorMessage = _friendlyError(e); _step = _ScanStep.review; }); + } + } + + Future _confirmAll() async { + final toSave = _items.where((i) => i.included).toList(); + if (toSave.isEmpty) return; + setState(() => _step = _ScanStep.saving); + final userId = _authController.currentUser?.id ?? ''; + for (final item in toSave) { + String? categoryId = item.category?.id; + if (categoryId == null) { + final catType = item.type == TransactionType.income ? CategoryType.income : CategoryType.expense; + categoryId = await _catController.findOrCreate( + name: item.categoryName.isNotEmpty ? item.categoryName : 'Other', + type: catType, + userId: userId, + ); + } + await _txController.createTransaction(Transaction( + amount: item.amount, + type: item.type, + financeCategoryId: categoryId, + date: item.date, + description: item.description.isNotEmpty ? item.description : item.categoryName, + budgetProfileId: item.profileId, + )); + } + widget.onConfirmed?.call(); + if (mounted) Navigator.pop(context); + } + + String _friendlyError(Object e) { + final msg = e.toString(); + if (msg.contains('too large')) return msg; + if (msg.contains('400') || msg.contains('BadRequest')) return 'The image could not be read. Try a clearer photo.'; + if (msg.contains('401')) return 'Session expired. Please sign in again.'; + if (msg.contains('429')) return 'Too many requests. Please wait a moment.'; + if (msg.contains('timeout') || msg.contains('SocketException')) return 'Connection timed out. Check your internet.'; + return 'Something went wrong. Please try again.'; + } + + void _showProfilePicker(_EditableItem item) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final bg = isDark ? const Color(0xFF2C2C2A) : Colors.white; + final fg = isDark ? AppColors.primaryForeground : AppColors.textPrimary; + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (_) => Container( + decoration: BoxDecoration(color: bg, borderRadius: const BorderRadius.vertical(top: Radius.circular(20))), + child: SafeArea(top: false, child: Column(mainAxisSize: MainAxisSize.min, children: [ + const SizedBox(height: 12), + Container(width: 36, height: 4, + decoration: BoxDecoration(color: AppColors.textTertiary.withValues(alpha: 0.35), borderRadius: BorderRadius.circular(2))), + Padding(padding: const EdgeInsets.fromLTRB(20, 14, 20, 8), + child: Text('Select Budget', style: GoogleFonts.dmSans(fontSize: 15, fontWeight: FontWeight.w700, color: fg))), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 280), + child: ListView.builder( + shrinkWrap: true, + itemCount: _profiles.length, + itemBuilder: (_, i) { + final p = _profiles[i]; + final sel = item.profileId == p.id; + return ListTile( + title: Text(p.name, style: GoogleFonts.dmSans(fontSize: 13, + fontWeight: sel ? FontWeight.w600 : FontWeight.w400, color: fg)), + trailing: sel ? const Icon(Icons.check_rounded, size: 16, color: AppColors.accent) : null, + onTap: () { + setState(() { item.profileId = p.id; item.profileName = p.name; }); + Navigator.pop(context); + }, + ); + }, + ), + ), + const SizedBox(height: 8), + ])), + ), + ); + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return switch (_step) { + _ScanStep.pick => _buildPickStep(isDark), + _ScanStep.loading => _buildLoadingStep(isDark), + _ScanStep.review => _buildReviewStep(isDark), + _ScanStep.saving => _buildSavingStep(isDark), + }; + } + + // ── Pick ───────────────────────────────────────────────────────────────── + + Widget _buildPickStep(bool isDark) { + final bg = isDark ? const Color(0xFF1E1E1C) : Colors.white; + final textPrimary = isDark ? AppColors.primaryForeground : AppColors.textPrimary; + final bottomPad = MediaQuery.of(context).viewInsets.bottom + 24; + + return Container( + decoration: BoxDecoration(color: bg, borderRadius: const BorderRadius.vertical(top: Radius.circular(20))), + child: SafeArea( + top: false, + child: Padding( + padding: EdgeInsets.only(bottom: bottomPad), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + _Handle(), + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Row(children: [ + Container( + width: 44, height: 44, + decoration: BoxDecoration( + color: AppColors.accent.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(Icons.document_scanner_outlined, size: 22, color: AppColors.accent), + ), + const SizedBox(width: 12), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('Scan Expenses', style: GoogleFonts.dmSans(fontSize: 16, fontWeight: FontWeight.w700, color: textPrimary)), + Text('AI reads your handwritten notes', style: GoogleFonts.dmSans(fontSize: 12, color: AppColors.textSecondary)), + ])), + ]), + ), + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column(children: [ + if (!_isDesktop) ...[ + _SourceCard(isDark: isDark, icon: Icons.camera_alt_outlined, + label: 'Camera', sublabel: 'Take a photo now', + onTap: () => _pickImage(ImageSource.camera)), + const SizedBox(height: 10), + ], + _SourceCard(isDark: isDark, icon: Icons.photo_library_outlined, + label: _isDesktop ? 'Choose from Files' : 'Gallery', + sublabel: _isDesktop ? 'JPG, PNG or WebP' : 'Pick from files', + onTap: () => _pickImage(ImageSource.gallery)), + ]), + ), + ]), + ), + ), + ); + } + + // ── Loading ─────────────────────────────────────────────────────────────── + + Widget _buildLoadingStep(bool isDark) { + final bg = isDark ? const Color(0xFF1E1E1C) : Colors.white; + final textPrimary = isDark ? AppColors.primaryForeground : AppColors.textPrimary; + return Container( + height: 240, + decoration: BoxDecoration(color: bg, borderRadius: const BorderRadius.vertical(top: Radius.circular(20))), + child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + Container( + width: 52, height: 52, + decoration: BoxDecoration(color: AppColors.accent.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(14)), + child: Padding(padding: const EdgeInsets.all(14), + child: CircularProgressIndicator(color: AppColors.accent, strokeWidth: 2.5)), + ), + const SizedBox(height: 16), + Text('Scanning expenses…', style: GoogleFonts.dmSans(fontSize: 15, fontWeight: FontWeight.w600, color: textPrimary)), + const SizedBox(height: 4), + Text('AI is reading your handwritten notes', style: GoogleFonts.dmSans(fontSize: 12, color: AppColors.textSecondary)), + ]), + ); + } + + // ── Review ──────────────────────────────────────────────────────────────── + + Widget _buildReviewStep(bool isDark) { + final bg = isDark ? const Color(0xFF1E1E1C) : AppColors.background; + final cardBg = isDark ? const Color(0xFF2C2C2A) : Colors.white; + final textPrimary = isDark ? AppColors.primaryForeground : AppColors.textPrimary; + final includedCount = _items.where((i) => i.included).length; + + return DraggableScrollableSheet( + initialChildSize: _errorMessage != null || _items.isEmpty ? 0.45 : 0.88, + minChildSize: 0.35, + maxChildSize: 0.95, + expand: false, + builder: (_, controller) => Container( + decoration: BoxDecoration(color: bg, borderRadius: const BorderRadius.vertical(top: Radius.circular(20))), + child: Column(children: [ + // Header + Container( + decoration: BoxDecoration( + color: cardBg, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + border: Border(bottom: BorderSide(color: AppColors.border.withValues(alpha: isDark ? 0.15 : 0.3))), + ), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + _Handle(), + Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 12, 14), + child: Row(children: [ + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text( + _errorMessage != null ? 'Could not read image' + : _items.isEmpty ? 'Nothing found' + : '${_items.length} transaction${_items.length == 1 ? '' : 's'} found', + style: GoogleFonts.dmSans(fontSize: 16, fontWeight: FontWeight.w700, color: textPrimary), + ), + Text( + _items.isEmpty ? 'Try a clearer photo' : 'Review and edit before saving', + style: GoogleFonts.dmSans(fontSize: 12, color: AppColors.textSecondary), + ), + ])), + TextButton.icon( + onPressed: () => setState(() { _step = _ScanStep.pick; _items = []; _errorMessage = null; }), + icon: const Icon(Icons.camera_alt_outlined, size: 14), + label: const Text('Re-scan'), + style: TextButton.styleFrom( + foregroundColor: AppColors.accent, + textStyle: GoogleFonts.dmSans(fontSize: 13, fontWeight: FontWeight.w600), + ), + ), + ]), + ), + ]), + ), + + // Error banner + if (_errorMessage != null) + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.error.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColors.error.withValues(alpha: 0.2), width: 0.5), + ), + child: Row(children: [ + Icon(Icons.error_outline_rounded, size: 16, color: AppColors.error), + const SizedBox(width: 8), + Expanded(child: Text(_errorMessage!, style: GoogleFonts.dmSans(fontSize: 12, color: AppColors.error))), + ]), + ), + ), + + // Empty state + if (_items.isEmpty && _errorMessage == null) + Expanded(child: Center(child: Padding( + padding: const EdgeInsets.all(32), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.image_search_rounded, size: 48, color: AppColors.textTertiary), + const SizedBox(height: 12), + Text('No transactions detected', style: GoogleFonts.dmSans(fontSize: 15, fontWeight: FontWeight.w600, color: textPrimary)), + const SizedBox(height: 6), + Text('The image may be blurry or not contain expense data.', + style: GoogleFonts.dmSans(fontSize: 13, color: AppColors.textSecondary), textAlign: TextAlign.center), + ]), + ))), + + // Item list + if (_items.isNotEmpty) + Expanded( + child: ListView.separated( + controller: controller, + padding: const EdgeInsets.fromLTRB(16, 12, 16, 100), + itemCount: _items.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (_, i) => _ReviewItemTile( + key: ValueKey(_items[i].id), + item: _items[i], + isDark: isDark, + onChanged: () => setState(() {}), + onPickProfile: () => _showProfilePicker(_items[i]), + onPickCategory: () => _showCategoryPicker(_items[i]), + ), + ), + ), + + // Confirm bar + if (_items.isNotEmpty) _ConfirmBar(isDark: isDark, count: includedCount, onConfirm: includedCount > 0 ? _confirmAll : null), + ]), + ), + ); + } + + // ── Saving ──────────────────────────────────────────────────────────────── + + Widget _buildSavingStep(bool isDark) { + final bg = isDark ? const Color(0xFF1E1E1C) : Colors.white; + final textPrimary = isDark ? AppColors.primaryForeground : AppColors.textPrimary; + return Container( + height: 200, + decoration: BoxDecoration(color: bg, borderRadius: const BorderRadius.vertical(top: Radius.circular(20))), + child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + CircularProgressIndicator(color: AppColors.accent, strokeWidth: 2.5), + const SizedBox(height: 16), + Text('Saving transactions…', style: GoogleFonts.dmSans(fontSize: 15, fontWeight: FontWeight.w600, color: textPrimary)), + const SizedBox(height: 4), + Text('Please wait', style: GoogleFonts.dmSans(fontSize: 12, color: AppColors.textSecondary)), + ]), + ); + } +} + +// ── Editable item ───────────────────────────────────────────────────────────── + +class _EditableItem { + final String id; + double amount; + TransactionType type; + String description; + DateTime date; + String categoryName; + FinanceCategory? category; + String? profileId; + String? profileName; + bool included; + + _EditableItem({ + required this.id, + required this.amount, + required this.type, + required this.description, + required this.date, + required this.categoryName, + this.category, + this.profileId, + this.profileName, + this.included = true, + }); +} + +// ── Review tile ─────────────────────────────────────────────────────────────── + +class _ReviewItemTile extends StatefulWidget { + final _EditableItem item; + final bool isDark; + final VoidCallback onChanged; + final VoidCallback onPickProfile; + final VoidCallback onPickCategory; + + const _ReviewItemTile({ + super.key, + required this.item, + required this.isDark, + required this.onChanged, + required this.onPickProfile, + required this.onPickCategory, + }); + + @override + State<_ReviewItemTile> createState() => _ReviewItemTileState(); +} + +class _ReviewItemTileState extends State<_ReviewItemTile> { + late final TextEditingController _descCtrl; + late final TextEditingController _amountCtrl; + + @override + void initState() { + super.initState(); + _descCtrl = TextEditingController(text: widget.item.description); + _amountCtrl = TextEditingController(text: widget.item.amount.toStringAsFixed(2)); + } + + @override + void dispose() { + _descCtrl.dispose(); + _amountCtrl.dispose(); + super.dispose(); + } + + Future _pickDate() async { + final picked = await showDatePicker( + context: context, + initialDate: widget.item.date, + firstDate: DateTime(2020), + lastDate: DateTime.now().add(const Duration(days: 365)), + ); + if (picked != null) { + setState(() => widget.item.date = picked); + widget.onChanged(); + } + } + + + @override + Widget build(BuildContext context) { + final isDark = widget.isDark; + final item = widget.item; + final cardBg = isDark ? const Color(0xFF2C2C2A) : Colors.white; + final borderColor = item.included + ? AppColors.accent.withValues(alpha: 0.35) + : AppColors.border.withValues(alpha: isDark ? 0.15 : 0.4); + final textPrimary = isDark ? AppColors.primaryForeground : AppColors.textPrimary; + final divColor = AppColors.border.withValues(alpha: isDark ? 0.12 : 0.3); + final typeColor = item.type == TransactionType.income ? AppColors.success : AppColors.error; + + return AnimatedOpacity( + opacity: item.included ? 1.0 : 0.4, + duration: const Duration(milliseconds: 160), + child: Container( + decoration: BoxDecoration( + color: cardBg, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: borderColor, width: 0.5), + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + + // ── Row 1: checkbox + description + type ──────────────────────── + Padding( + padding: const EdgeInsets.fromLTRB(8, 10, 12, 4), + child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ + SizedBox( + width: 28, + child: Checkbox( + value: item.included, + onChanged: (v) { setState(() => item.included = v ?? true); widget.onChanged(); }, + activeColor: AppColors.accent, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + ), + Expanded( + child: TextField( + controller: _descCtrl, + enabled: item.included, + style: GoogleFonts.dmSans(fontSize: 14, fontWeight: FontWeight.w600, color: textPrimary), + decoration: InputDecoration( + isDense: true, contentPadding: EdgeInsets.zero, border: InputBorder.none, + hintText: 'Description', + hintStyle: GoogleFonts.dmSans(fontSize: 14, color: AppColors.textTertiary), + ), + onChanged: (v) => item.description = v, + ), + ), + const SizedBox(width: 8), + // Type toggle + GestureDetector( + onTap: item.included ? () { setState(() { item.type = item.type == TransactionType.income ? TransactionType.expense : TransactionType.income; }); widget.onChanged(); } : null, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: typeColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: typeColor.withValues(alpha: 0.3), width: 0.5), + ), + child: Text( + item.type == TransactionType.income ? 'Income' : 'Expense', + style: GoogleFonts.dmSans(fontSize: 11, fontWeight: FontWeight.w700, color: typeColor), + ), + ), + ), + ]), + ), + + // ── Row 2: Amount (prominent) ──────────────────────────────────── + Padding( + padding: const EdgeInsets.fromLTRB(36, 2, 12, 10), + child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: [ + Text('₱', style: GoogleFonts.dmMono(fontSize: 16, fontWeight: FontWeight.w500, color: AppColors.textSecondary)), + const SizedBox(width: 2), + SizedBox( + width: 130, + child: TextField( + controller: _amountCtrl, + enabled: item.included, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + style: GoogleFonts.dmMono( + fontSize: 28, fontWeight: FontWeight.w700, color: typeColor, + fontFeatures: const [FontFeature.tabularFigures()], + ), + decoration: const InputDecoration(isDense: true, contentPadding: EdgeInsets.zero, border: InputBorder.none), + onChanged: (v) => item.amount = double.tryParse(v) ?? item.amount, + ), + ), + ]), + ), + + Divider(height: 1, color: divColor), + + // ── Row 3: chips ───────────────────────────────────────────────── + SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.fromLTRB(12, 9, 12, 10), + child: Row(children: [ + // Budget profile + _ChipButton( + icon: Icons.account_balance_wallet_outlined, + label: item.profileName ?? 'Select Budget', + color: item.profileName != null ? AppColors.accent : AppColors.error, + isDark: isDark, + onTap: item.included ? widget.onPickProfile : null, + ), + const SizedBox(width: 6), + // Date + _ChipButton( + icon: Icons.calendar_today_outlined, + label: DateFormat('MMM d, yyyy').format(item.date), + color: AppColors.textSecondary, + isDark: isDark, + onTap: item.included ? _pickDate : null, + ), + const SizedBox(width: 6), + // Category + _ChipButton( + icon: Icons.category_outlined, + label: item.category?.name ?? 'Select Category', + color: item.category != null ? AppColors.textSecondary : AppColors.warning, + isDark: isDark, + onTap: item.included ? widget.onPickCategory : null, + ), + ]), + ), + ]), + ), + ); + } +} + +// ── Chip button ─────────────────────────────────────────────────────────────── + +class _ChipButton extends StatelessWidget { + final IconData icon; + final String label; + final Color color; + final bool isDark; + final VoidCallback? onTap; + final IconData? trailingIcon; + + const _ChipButton({ + required this.icon, + required this.label, + required this.color, + required this.isDark, + this.onTap, + this.trailingIcon, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: color.withValues(alpha: isDark ? 0.12 : 0.07), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withValues(alpha: 0.25), width: 0.5), + ), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(icon, size: 12, color: color), + const SizedBox(width: 5), + Text(label, style: GoogleFonts.dmSans(fontSize: 12, fontWeight: FontWeight.w600, color: color)), + if (trailingIcon != null) ...[ + const SizedBox(width: 4), + Icon(trailingIcon, size: 10, color: color.withValues(alpha: 0.7)), + ], + ]), + ), + ); + } +} + +// ── Confirm bar ─────────────────────────────────────────────────────────────── + +class _ConfirmBar extends StatelessWidget { + final bool isDark; + final int count; + final VoidCallback? onConfirm; + + const _ConfirmBar({required this.isDark, required this.count, required this.onConfirm}); + + @override + Widget build(BuildContext context) { + final bg = isDark ? const Color(0xFF2C2C2A) : Colors.white; + return Container( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 16), + decoration: BoxDecoration( + color: bg, + border: Border(top: BorderSide(color: AppColors.border.withValues(alpha: isDark ? 0.15 : 0.3))), + ), + child: SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: onConfirm, + style: FilledButton.styleFrom( + backgroundColor: onConfirm != null ? AppColors.accent : AppColors.textTertiary, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: Text( + onConfirm != null ? 'Save $count transaction${count == 1 ? '' : 's'}' : 'Select transactions to save', + style: GoogleFonts.dmSans(fontSize: 14, fontWeight: FontWeight.w700, color: Colors.white), + ), + ), + ), + ); + } +} + +// ── Source card ─────────────────────────────────────────────────────────────── + +class _SourceCard extends StatelessWidget { + final bool isDark; + final IconData icon; + final String label; + final String sublabel; + final VoidCallback onTap; + + const _SourceCard({ + required this.isDark, required this.icon, + required this.label, required this.sublabel, required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final bg = isDark ? const Color(0xFF2C2C2A) : AppColors.background; + final border = isDark ? AppColors.border.withValues(alpha: 0.2) : AppColors.border.withValues(alpha: 0.5); + final textPrimary = isDark ? AppColors.primaryForeground : AppColors.textPrimary; + return GestureDetector( + onTap: onTap, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 16), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: border, width: 0.5), + ), + child: Row(children: [ + Container( + width: 44, height: 44, + decoration: BoxDecoration( + color: AppColors.accent.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(11), + ), + child: Icon(icon, size: 22, color: AppColors.accent), + ), + const SizedBox(width: 14), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(label, style: GoogleFonts.dmSans(fontSize: 14, fontWeight: FontWeight.w700, color: textPrimary)), + const SizedBox(height: 2), + Text(sublabel, style: GoogleFonts.dmSans(fontSize: 12, color: AppColors.textTertiary)), + ])), + Icon(Icons.chevron_right_rounded, size: 18, color: AppColors.textTertiary), + ]), + ), + ); + } +} + +// ── Drag handle ─────────────────────────────────────────────────────────────── + +class _Handle extends StatelessWidget { + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(top: 12), + child: Center(child: Container( + width: 36, height: 4, + decoration: BoxDecoration( + color: AppColors.textTertiary.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + )), + ); +} diff --git a/lib/features/finance/presentation/state/month_plan_controller.dart b/lib/features/finance/presentation/state/month_plan_controller.dart index 541ab0d..3f3831f 100644 --- a/lib/features/finance/presentation/state/month_plan_controller.dart +++ b/lib/features/finance/presentation/state/month_plan_controller.dart @@ -117,6 +117,16 @@ class MonthPlanController extends StreamState>> { return plan; } + /// Set the plan's status to closed. + Future closeMonthPlan(String id) async { + await executeSilent(() async { + final result = await _repository.closeMonthPlan(id); + final closed = result.unwrap(); + final current = data ?? []; + return current.map((p) => p.id == id ? closed : p).toList(); + }); + } + /// Clear all month plan state (called on sign-out to prevent data leaking to next user) void clear() { emit(const AsyncData([])); diff --git a/lib/features/module_selection/finance_module_screen.dart b/lib/features/module_selection/finance_module_screen.dart index c61cbf8..928d5a4 100644 --- a/lib/features/module_selection/finance_module_screen.dart +++ b/lib/features/module_selection/finance_module_screen.dart @@ -1,6 +1,5 @@ import 'dart:ui'; import 'package:flutter/material.dart'; -import 'package:flutter_svg/flutter_svg.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:keep_track/core/di/service_locator.dart'; import 'package:keep_track/core/logging/log_viewer_screen.dart'; @@ -17,6 +16,7 @@ import 'package:keep_track/features/finance/presentation/screens/tabs/dashboard/ import 'package:keep_track/features/finance/presentation/screens/tabs/savings/savings_tab.dart'; import 'package:keep_track/features/finance/presentation/screens/transaction_planner_screen.dart'; import 'package:keep_track/features/finance/presentation/screens/transactions/create_transaction_sheet.dart'; +import 'package:keep_track/features/finance/presentation/state/budget_profile_controller.dart'; import 'package:keep_track/features/finance/presentation/state/transaction_controller.dart'; import '../auth/presentation/screens/auth_settings_screen.dart'; import '../settings/setting_page.dart'; @@ -92,6 +92,17 @@ class _FinanceModuleScreenState extends State { }); } + void _refreshTransactions() { + final now = DateTime.now(); + locator.get().loadTransactionsByDateRange( + DateTime(now.year, now.month, 1), + DateTime(now.year, now.month + 1, 1), + ); + } + + String? get _activeProfileId => + locator.get().activeProfileId; + // Savings tab (index 2) has no FAB @override Widget build(BuildContext context) { @@ -111,16 +122,7 @@ class _FinanceModuleScreenState extends State { Widget _buildDesktop() { return Scaffold( floatingActionButton: FloatingActionButton( - onPressed: () => CreateTransactionSheet.show( - context, - onCreated: () { - final now = DateTime.now(); - locator.get().loadTransactionsByDateRange( - DateTime(now.year, now.month, 1), - DateTime(now.year, now.month + 1, 1), - ); - }, - ), + onPressed: () => CreateTransactionSheet.show(context, onCreated: _refreshTransactions, initialProfileId: _activeProfileId), backgroundColor: AppColors.accent, foregroundColor: Colors.white, child: const Icon(Icons.add), @@ -173,7 +175,7 @@ class _FinanceModuleScreenState extends State { title: Row( mainAxisSize: MainAxisSize.min, children: [ - SvgPicture.asset('assets/app-icon.svg', width: 26, height: 26), + Image.asset('assets/icon/app_icon.png', width: 26, height: 26), const SizedBox(width: 8), Text( 'Keep Track', @@ -291,16 +293,7 @@ class _FinanceModuleScreenState extends State { ), const SizedBox(width: 10), GestureDetector( - onTap: () => CreateTransactionSheet.show( - context, - onCreated: () { - final now = DateTime.now(); - locator.get().loadTransactionsByDateRange( - DateTime(now.year, now.month, 1), - DateTime(now.year, now.month + 1, 1), - ); - }, - ), + onTap: () => CreateTransactionSheet.show(context, onCreated: _refreshTransactions, initialProfileId: _activeProfileId), child: Container( width: 62, height: 62, @@ -406,7 +399,7 @@ class _SidebarHeader extends StatelessWidget { padding: const EdgeInsets.fromLTRB(16, 20, 16, 14), child: Row( children: [ - SvgPicture.asset('assets/app-icon.svg', width: 36, height: 36), + Image.asset('assets/icon/app_icon.png', width: 36, height: 36), const SizedBox(width: 10), Column( crossAxisAlignment: CrossAxisAlignment.start, From bac92568a9b16f18c1b1f3affc730a2dc15370c4 Mon Sep 17 00:00:00 2001 From: Khesir Date: Sun, 31 May 2026 20:22:44 +0800 Subject: [PATCH 2/2] improved and ready to test budget month module --- .../local/month_plan_datasource_local.dart | 82 +- .../datasources/month_plan_datasource.dart | 6 + .../month_plan_repository_impl.dart | 24 +- .../repositories/month_plan_repository.dart | 6 + .../dialogs/budget_settings_dialog.dart | 245 ++++++ .../screens/budget_month_screen.dart | 2 +- .../screens/budget_simple_sections.dart | 111 +-- .../screens/budget_simple_sheets.dart | 4 +- .../screens/budget_simple_view.dart | 150 ++-- .../sections/budget_screen_body.dart | 41 +- .../sections/budget_summary_bar.dart | 9 +- .../sheets/add_category_sheet.dart | 256 +++--- .../sheets/budget_settings_sheet.dart | 44 +- .../sheets/edit_category_sheet.dart | 261 +++--- .../sheets/start_planning_sheet.dart | 309 +++---- .../sheets/transaction_detail_sheet.dart | 833 ++++++++++++++++++ .../presentation/widgets/all_summary_tab.dart | 457 +--------- .../widgets/all_transaction_tab.dart | 10 +- .../widgets/group_transaction_tab.dart | 6 +- .../widgets/side_summary_panel.dart | 22 +- .../widgets/transaction_mini_row.dart | 13 +- .../datasources/receipt_parser_service.dart | 21 +- .../tabs/budget/budget_tab_screen.dart | 62 +- .../budget/profile_create_group_sheet.dart | 15 +- .../screens/tabs/dashboard/dashboard_tab.dart | 76 +- .../screens/transaction_planner_screen.dart | 12 +- .../create_transaction_sheet.dart | 86 +- .../transactions/scan_expenses_sheet.dart | 651 ++++++++++++-- .../presentation/state/goal_controller.dart | 16 +- .../state/month_plan_controller.dart | 33 +- .../state/subscription_controller.dart | 9 +- .../state/transaction_controller.dart | 10 +- .../finance_module_screen.dart | 9 +- 33 files changed, 2644 insertions(+), 1247 deletions(-) create mode 100644 lib/features/finance/modules/budget/presentation/dialogs/budget_settings_dialog.dart create mode 100644 lib/features/finance/modules/budget/presentation/sheets/transaction_detail_sheet.dart diff --git a/lib/features/finance/modules/budget/data/datasources/local/month_plan_datasource_local.dart b/lib/features/finance/modules/budget/data/datasources/local/month_plan_datasource_local.dart index 7bd0e61..a7a5905 100644 --- a/lib/features/finance/modules/budget/data/datasources/local/month_plan_datasource_local.dart +++ b/lib/features/finance/modules/budget/data/datasources/local/month_plan_datasource_local.dart @@ -1,5 +1,4 @@ import 'package:keep_track/core/cache/local_cache.dart'; -import 'package:keep_track/features/finance/modules/budget/data/models/budget_model.dart'; import 'package:keep_track/features/finance/modules/budget/data/models/month_plan_model.dart'; import 'package:uuid/uuid.dart'; import '../month_plan_datasource.dart'; @@ -10,6 +9,7 @@ class MonthPlanDataSourceLocal implements MonthPlanDataSource { static const _box = 'month_plans'; static const _budgetsBox = 'budgets'; + static const _categoriesBox = 'budget_categories'; MonthPlanDataSourceLocal(this._cache); @@ -61,22 +61,37 @@ class MonthPlanDataSourceLocal implements MonthPlanDataSource { orElse: () => throw StateError('Source month plan $sourceMonth not found'), ); - final allBudgetEntries = await _cache.getAll(_budgetsBox); - final sourceBudgets = allBudgetEntries - .map((e) => BudgetModel.fromJson(e)) - .where((b) => source.budgetIds.contains(b.id)) - .toList(); - + final allCategoryEntries = await _cache.getAll(_categoriesBox); final newBudgetIds = []; - for (final budget in sourceBudgets) { + + for (final sourceBudgetId in source.budgetIds) { + final budgetData = await _cache.get(_budgetsBox, sourceBudgetId); + if (budgetData == null) continue; + final newBudgetId = _uuid.v4(); - final newBudget = BudgetModel.fromJson({ - ...budget.toJson(), + final newBudgetData = { + ...budgetData, 'id': newBudgetId, 'month': targetMonth, - }); - await _cache.put(_budgetsBox, newBudgetId, newBudget.toJson()); + 'status': 'active', + 'closedAt': null, + }..remove('closedAt'); + await _cache.put(_budgetsBox, newBudgetId, newBudgetData); newBudgetIds.add(newBudgetId); + + // Duplicate categories for the new budget (structure only, no spent data) + final srcCats = allCategoryEntries + .where((e) => e['budgetId'] == sourceBudgetId) + .toList(); + for (final cat in srcCats) { + final newCatId = _uuid.v4(); + final newCat = { + ...cat, + 'id': newCatId, + 'budgetId': newBudgetId, + }; + await _cache.put(_categoriesBox, newCatId, newCat); + } } final existing = allPlans.firstWhere( @@ -111,7 +126,17 @@ class MonthPlanDataSourceLocal implements MonthPlanDataSource { final data = await _cache.get(_box, id); if (data != null) { final plan = MonthPlanModel.fromJson(data); + final allCats = await _cache.getAll(_categoriesBox); for (final budgetId in plan.budgetIds) { + // Delete categories belonging to this budget + final catIds = allCats + .where((c) => c['budgetId'] == budgetId) + .map((c) => c['id'] as String?) + .whereType() + .toList(); + for (final catId in catIds) { + await _cache.delete(_categoriesBox, catId); + } await _cache.delete(_budgetsBox, budgetId); } } @@ -147,16 +172,41 @@ class MonthPlanDataSourceLocal implements MonthPlanDataSource { } } + @override + Future getOrCreateMonthPlanForMonthlyProfile(String month, String profileId) async { + final all = await _getAll(); + try { + return all.firstWhere((p) => p.month == month && p.budgetProfileId == profileId); + } catch (_) { + final id = _uuid.v4(); + final plan = MonthPlanModel.fromJson({ + 'id': id, + 'month': month, + 'budgetProfileId': profileId, + 'budgetIds': [], + }); + await _cache.put(_box, id, plan.toJson()); + return plan; + } + } + @override Future closeMonthPlan(String id) async { final data = await _cache.get(_box, id); if (data == null) throw StateError('MonthPlan $id not found'); final plan = MonthPlanModel.fromJson(data); - final closed = MonthPlanModel.fromJson({ - ...plan.toJson(), - 'status': 'closed', - }); + final closed = MonthPlanModel.fromJson({...plan.toJson(), 'status': 'closed'}); await _cache.put(_box, id, closed.toJson()); return closed; } + + @override + Future reopenMonthPlan(String id) async { + final data = await _cache.get(_box, id); + if (data == null) throw StateError('MonthPlan $id not found'); + final plan = MonthPlanModel.fromJson(data); + final reopened = MonthPlanModel.fromJson({...plan.toJson(), 'status': 'active'}); + await _cache.put(_box, id, reopened.toJson()); + return reopened; + } } diff --git a/lib/features/finance/modules/budget/data/datasources/month_plan_datasource.dart b/lib/features/finance/modules/budget/data/datasources/month_plan_datasource.dart index 725a74d..78a7881 100644 --- a/lib/features/finance/modules/budget/data/datasources/month_plan_datasource.dart +++ b/lib/features/finance/modules/budget/data/datasources/month_plan_datasource.dart @@ -21,6 +21,12 @@ abstract class MonthPlanDataSource { /// Get or create the plan for a custom budget profile. Future getOrCreatePlanForProfile(String profileId); + /// Get or create a plan for a monthly profile scoped to a specific month. + Future getOrCreateMonthPlanForMonthlyProfile(String month, String profileId); + /// Set the plan's status to closed. Future closeMonthPlan(String id); + + /// Set the plan's status back to active. + Future reopenMonthPlan(String id); } diff --git a/lib/features/finance/modules/budget/data/repositories/month_plan_repository_impl.dart b/lib/features/finance/modules/budget/data/repositories/month_plan_repository_impl.dart index 5d305d4..0ca808a 100644 --- a/lib/features/finance/modules/budget/data/repositories/month_plan_repository_impl.dart +++ b/lib/features/finance/modules/budget/data/repositories/month_plan_repository_impl.dart @@ -138,15 +138,33 @@ class MonthPlanRepositoryImpl implements MonthPlanRepository { } } + @override + Future> getOrCreateMonthPlanForMonthlyProfile(String month, String profileId) async { + try { + final plan = await dataSource.getOrCreateMonthPlanForMonthlyProfile(month, profileId); + return Result.success(plan); + } catch (e) { + return Result.error(UnknownFailure(message: 'Failed to get or create monthly profile plan: $e')); + } + } + @override Future> closeMonthPlan(String id) async { try { final plan = await dataSource.closeMonthPlan(id); return Result.success(plan); } catch (e) { - return Result.error( - UnknownFailure(message: 'Failed to close month plan: $e'), - ); + return Result.error(UnknownFailure(message: 'Failed to close month plan: $e')); + } + } + + @override + Future> reopenMonthPlan(String id) async { + try { + final plan = await dataSource.reopenMonthPlan(id); + return Result.success(plan); + } catch (e) { + return Result.error(UnknownFailure(message: 'Failed to reopen month plan: $e')); } } } diff --git a/lib/features/finance/modules/budget/domain/repositories/month_plan_repository.dart b/lib/features/finance/modules/budget/domain/repositories/month_plan_repository.dart index 23315c7..8e9c317 100644 --- a/lib/features/finance/modules/budget/domain/repositories/month_plan_repository.dart +++ b/lib/features/finance/modules/budget/domain/repositories/month_plan_repository.dart @@ -40,6 +40,12 @@ abstract class MonthPlanRepository { /// Get or create the plan for a custom budget profile. Future> getOrCreatePlanForProfile(String profileId); + /// Get or create a plan for a monthly profile scoped to a specific month. + Future> getOrCreateMonthPlanForMonthlyProfile(String month, String profileId); + /// Set the plan's status to closed. Future> closeMonthPlan(String id); + + /// Set the plan's status back to active. + Future> reopenMonthPlan(String id); } diff --git a/lib/features/finance/modules/budget/presentation/dialogs/budget_settings_dialog.dart b/lib/features/finance/modules/budget/presentation/dialogs/budget_settings_dialog.dart new file mode 100644 index 0000000..b5501ed --- /dev/null +++ b/lib/features/finance/modules/budget/presentation/dialogs/budget_settings_dialog.dart @@ -0,0 +1,245 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:keep_track/core/theme/app_theme.dart'; +import 'package:keep_track/features/finance/modules/budget/domain/entities/month_plan.dart'; + +class BudgetSettingsDialog extends StatefulWidget { + final String monthLabel; + final String? profileName; + final Color? profileColor; + final MonthPlan? monthPlan; + final bool hasMonthPlan; + // Month plan callbacks + final Future Function()? onStartPlanning; + final Future Function()? onCloseMonthPlan; + final Future Function()? onReopenMonthPlan; + final VoidCallback? onDeleteMonth; + // Profile callbacks + final VoidCallback? onEditProfile; + final VoidCallback? onDeleteProfile; + + const BudgetSettingsDialog({ + super.key, + required this.monthLabel, + this.profileName, + this.profileColor, + this.monthPlan, + this.hasMonthPlan = false, + this.onStartPlanning, + this.onCloseMonthPlan, + this.onReopenMonthPlan, + this.onDeleteMonth, + this.onEditProfile, + this.onDeleteProfile, + }); + + static Future show( + BuildContext context, { + required String monthLabel, + String? profileName, + Color? profileColor, + MonthPlan? monthPlan, + bool hasMonthPlan = false, + Future Function()? onStartPlanning, + Future Function()? onCloseMonthPlan, + Future Function()? onReopenMonthPlan, + VoidCallback? onDeleteMonth, + VoidCallback? onEditProfile, + VoidCallback? onDeleteProfile, + }) { + return showDialog( + context: context, + barrierDismissible: true, + builder: (_) => BudgetSettingsDialog( + monthLabel: monthLabel, + profileName: profileName, + profileColor: profileColor, + monthPlan: monthPlan, + hasMonthPlan: hasMonthPlan, + onStartPlanning: onStartPlanning, + onCloseMonthPlan: onCloseMonthPlan, + onReopenMonthPlan: onReopenMonthPlan, + onDeleteMonth: onDeleteMonth, + onEditProfile: onEditProfile, + onDeleteProfile: onDeleteProfile, + ), + ); + } + + @override + State createState() => _BudgetSettingsDialogState(); +} + +class _BudgetSettingsDialogState extends State { + bool _loading = false; + + Future _run(Future Function() action) async { + setState(() => _loading = true); + try { + await action(); + if (mounted) Navigator.pop(context); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final bg = isDark ? const Color(0xFF2C2C2A) : Colors.white; + final textPrimary = isDark ? AppColors.primaryForeground : AppColors.textPrimary; + final divColor = AppColors.border.withValues(alpha: isDark ? 0.15 : 0.3); + final accent = widget.profileColor ?? AppColors.accent; + final plan = widget.monthPlan; + + return Dialog( + backgroundColor: bg, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: _buildMain(bg, textPrimary, divColor, accent, plan, isDark), + ); + } + + Widget _buildMain(Color bg, Color textPrimary, Color divColor, Color accent, MonthPlan? plan, bool isDark) { + final isClosed = plan?.isClosed ?? false; + final showMonthPlanSection = widget.onStartPlanning != null || widget.hasMonthPlan; + final showProfileSection = widget.onEditProfile != null || widget.onDeleteProfile != null; + + return Padding( + padding: const EdgeInsets.fromLTRB(0, 20, 0, 8), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 16), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (widget.profileName != null) + Row(children: [ + Container(width: 8, height: 8, decoration: BoxDecoration(color: accent, shape: BoxShape.circle)), + const SizedBox(width: 6), + Text(widget.profileName!, style: GoogleFonts.dmSans(fontSize: 12, color: AppColors.textSecondary)), + ]), + const SizedBox(height: 4), + Text('Budget Settings', style: GoogleFonts.dmSans(fontSize: 16, fontWeight: FontWeight.w700, color: textPrimary)), + Text(widget.monthLabel, style: GoogleFonts.dmSans(fontSize: 12, color: AppColors.textSecondary)), + ]), + ), + + // Month Plan section + if (showMonthPlanSection) ...[ + Divider(height: 1, color: divColor), + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 4), + child: Text('MONTH PLAN', style: GoogleFonts.dmSans(fontSize: 10, fontWeight: FontWeight.w700, color: AppColors.textTertiary, letterSpacing: 0.8)), + ), + if (!widget.hasMonthPlan && widget.onStartPlanning != null) + _loading + ? const Padding(padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator())) + : _DialogRow( + icon: Icons.play_circle_outline_rounded, + label: 'Start Planning', + color: accent, + onTap: () => _run(widget.onStartPlanning!), + ), + if (widget.hasMonthPlan) ...[ + if (_loading) + const Padding(padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator())) + else if (!isClosed && widget.onCloseMonthPlan != null) + _DialogRow( + icon: Icons.check_circle_outline_rounded, + label: 'Close Budget', + onTap: () => _run(widget.onCloseMonthPlan!), + color: textPrimary, + ) + else if (isClosed && widget.onReopenMonthPlan != null) + _DialogRow( + icon: Icons.lock_open_rounded, + label: 'Re-open Budget', + onTap: () => _run(widget.onReopenMonthPlan!), + color: textPrimary, + ), + if (widget.onDeleteMonth != null) + _DialogRow( + icon: Icons.delete_outline_rounded, + label: 'Delete Month', + color: AppColors.error, + onTap: _loading ? null : () { + Navigator.pop(context); + widget.onDeleteMonth!(); + }, + ), + ], + if (!widget.hasMonthPlan && widget.onDeleteMonth == null) + const SizedBox(height: 4), + ], + + // Profile section + if (showProfileSection) ...[ + Divider(height: 1, color: divColor), + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 4), + child: Text('PROFILE', style: GoogleFonts.dmSans(fontSize: 10, fontWeight: FontWeight.w700, color: AppColors.textTertiary, letterSpacing: 0.8)), + ), + if (widget.onEditProfile != null) + _DialogRow( + icon: Icons.edit_outlined, + label: 'Edit Profile', + color: textPrimary, + onTap: _loading ? null : () { + Navigator.pop(context); + widget.onEditProfile!(); + }, + ), + if (widget.onDeleteProfile != null) + _DialogRow( + icon: Icons.delete_forever_rounded, + label: 'Delete Profile', + color: AppColors.error, + onTap: _loading ? null : () { + Navigator.pop(context); + widget.onDeleteProfile!(); + }, + ), + ], + + // Cancel + Divider(height: 1, color: divColor), + TextButton( + onPressed: () => Navigator.pop(context), + style: TextButton.styleFrom(minimumSize: const Size(double.infinity, 48)), + child: Text('Cancel', style: GoogleFonts.dmSans(fontSize: 14, color: AppColors.textSecondary)), + ), + ], + ), + ); + } + +} + +class _DialogRow extends StatelessWidget { + final IconData icon; + final String label; + final Color color; + final VoidCallback? onTap; + + const _DialogRow({required this.icon, required this.label, required this.color, this.onTap}); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 13), + child: Row(children: [ + Icon(icon, size: 18, color: onTap != null ? color : AppColors.textTertiary), + const SizedBox(width: 12), + Text(label, style: GoogleFonts.dmSans( + fontSize: 14, fontWeight: FontWeight.w500, + color: onTap != null ? color : AppColors.textTertiary, + )), + ]), + ), + ); + } +} diff --git a/lib/features/finance/modules/budget/presentation/screens/budget_month_screen.dart b/lib/features/finance/modules/budget/presentation/screens/budget_month_screen.dart index a4ff598..b4401b1 100644 --- a/lib/features/finance/modules/budget/presentation/screens/budget_month_screen.dart +++ b/lib/features/finance/modules/budget/presentation/screens/budget_month_screen.dart @@ -78,7 +78,7 @@ class _BudgetMonthScreenState extends ScopedScreenState { DateTime _currentMonth = DateTime.now(); List _itemOrder = []; - int _selectedTab = 0; + int _selectedTab = 1; Budget? _selectedGroup; BudgetCategory? _selectedCategory; Budget? _selectedCategoryGroup; diff --git a/lib/features/finance/modules/budget/presentation/screens/budget_simple_sections.dart b/lib/features/finance/modules/budget/presentation/screens/budget_simple_sections.dart index 1e310bb..5b8a271 100644 --- a/lib/features/finance/modules/budget/presentation/screens/budget_simple_sections.dart +++ b/lib/features/finance/modules/budget/presentation/screens/budget_simple_sections.dart @@ -13,6 +13,7 @@ import 'package:keep_track/features/finance/modules/goal/domain/entities/goal.da import 'package:keep_track/features/finance/modules/savings/domain/entities/savings_bucket.dart'; import 'package:keep_track/features/finance/modules/subscriptions/domain/entities/subscription.dart'; import 'package:keep_track/features/finance/modules/transaction/domain/entities/transaction.dart'; +import '../sheets/transaction_detail_sheet.dart'; // ─── Month Nav ──────────────────────────────────────────────────────────────── @@ -1352,7 +1353,7 @@ class _RingRow { double get remaining => planned - spent; } -class _CompactRingCard extends StatefulWidget { +class _CompactRingCard extends StatelessWidget { final bool isDark; final String label; final double actual, planned; @@ -1366,34 +1367,26 @@ class _CompactRingCard extends StatefulWidget { required this.rows, }); - @override - State<_CompactRingCard> createState() => _CompactRingCardState(); -} - -class _CompactRingCardState extends State<_CompactRingCard> { - bool _showActual = true; - void _openDetail(BuildContext context) { showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, builder: (_) => _RingDetailSheet( - isDark: widget.isDark, - label: widget.label, - actual: widget.actual, - planned: widget.planned, - rows: widget.rows, + isDark: isDark, + label: label, + actual: actual, + planned: planned, + rows: rows, ), ); } @override Widget build(BuildContext context) { - final total = _showActual ? widget.actual : widget.planned; - final textPrimary = widget.isDark ? AppColors.primaryForeground : AppColors.textPrimary; - final cardBg = widget.isDark ? const Color(0xFF2C2C2A) : Colors.white; - final border = widget.isDark ? AppColors.border.withValues(alpha: 0.2) : AppColors.border.withValues(alpha: 0.5); + final textPrimary = isDark ? AppColors.primaryForeground : AppColors.textPrimary; + final cardBg = isDark ? const Color(0xFF2C2C2A) : Colors.white; + final border = isDark ? AppColors.border.withValues(alpha: 0.2) : AppColors.border.withValues(alpha: 0.5); return TweenAnimationBuilder( tween: Tween(begin: 0.0, end: 1.0), @@ -1413,8 +1406,6 @@ class _CompactRingCardState extends State<_CompactRingCard> { border: Border.all(color: border, width: 0.5), ), child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ - _TogglePills(showActual: _showActual, onToggle: (v) => setState(() => _showActual = v)), - const SizedBox(height: 12), TweenAnimationBuilder( tween: Tween(begin: 0.0, end: 1.0), duration: const Duration(milliseconds: 800), @@ -1422,14 +1413,14 @@ class _CompactRingCardState extends State<_CompactRingCard> { builder: (_, ringV, __) => SizedBox( width: 110, height: 110, child: CustomPaint( - painter: _RingPainter(rows: widget.rows, showActual: _showActual, isDark: widget.isDark, animProgress: ringV), + painter: _RingPainter(rows: rows, showActual: true, isDark: isDark, animProgress: ringV), child: Center( child: Column(mainAxisSize: MainAxisSize.min, children: [ - Text(widget.label, + Text(label, style: GoogleFonts.dmSans(fontSize: 9, fontWeight: FontWeight.w600, color: AppColors.textSecondary, letterSpacing: 0.5)), const SizedBox(height: 2), Text( - currencyFormatter.format(total * ringV, decimalDigits: 0), + currencyFormatter.format(actual * ringV, decimalDigits: 0), style: GoogleFonts.dmMono(fontSize: 12, fontWeight: FontWeight.w700, color: textPrimary, fontFeatures: const [FontFeature.tabularFigures()]), ), ]), @@ -1450,7 +1441,7 @@ class _CompactRingCardState extends State<_CompactRingCard> { } } -class _RingDetailSheet extends StatefulWidget { +class _RingDetailSheet extends StatelessWidget { final bool isDark; final String label; final double actual, planned; @@ -1464,19 +1455,11 @@ class _RingDetailSheet extends StatefulWidget { required this.rows, }); - @override - State<_RingDetailSheet> createState() => _RingDetailSheetState(); -} - -class _RingDetailSheetState extends State<_RingDetailSheet> { - bool _showActual = true; - @override Widget build(BuildContext context) { - final total = _showActual ? widget.actual : widget.planned; - final sheetBg = widget.isDark ? const Color(0xFF1E1E1C) : Colors.white; - final textPrimary = widget.isDark ? AppColors.primaryForeground : AppColors.textPrimary; - final divColor = widget.isDark ? AppColors.border.withValues(alpha: 0.15) : AppColors.border.withValues(alpha: 0.4); + final sheetBg = isDark ? const Color(0xFF1E1E1C) : Colors.white; + final textPrimary = isDark ? AppColors.primaryForeground : AppColors.textPrimary; + final divColor = isDark ? AppColors.border.withValues(alpha: 0.15) : AppColors.border.withValues(alpha: 0.4); return DraggableScrollableSheet( initialChildSize: 0.55, @@ -1496,11 +1479,7 @@ class _RingDetailSheetState extends State<_RingDetailSheet> { ), Padding( padding: const EdgeInsets.fromLTRB(20, 4, 20, 14), - child: Row(children: [ - Text(widget.label, style: GoogleFonts.dmSans(fontSize: 15, fontWeight: FontWeight.w700, color: textPrimary)), - const Spacer(), - _TogglePills(showActual: _showActual, onToggle: (v) => setState(() => _showActual = v)), - ]), + child: Text(label, style: GoogleFonts.dmSans(fontSize: 15, fontWeight: FontWeight.w700, color: textPrimary)), ), Divider(height: 1, thickness: 0.5, color: divColor), Expanded( @@ -1516,17 +1495,17 @@ class _RingDetailSheetState extends State<_RingDetailSheet> { child: SizedBox( width: 150, height: 150, child: CustomPaint( - painter: _RingPainter(rows: widget.rows, showActual: _showActual, isDark: widget.isDark, animProgress: ringV), + painter: _RingPainter(rows: rows, showActual: true, isDark: isDark, animProgress: ringV), child: Center( child: Column(mainAxisSize: MainAxisSize.min, children: [ - Text(widget.label, style: GoogleFonts.dmSans(fontSize: 10, fontWeight: FontWeight.w600, color: AppColors.textSecondary, letterSpacing: 0.5)), + Text(label, style: GoogleFonts.dmSans(fontSize: 10, fontWeight: FontWeight.w600, color: AppColors.textSecondary, letterSpacing: 0.5)), const SizedBox(height: 3), Text( - currencyFormatter.format(total * ringV, decimalDigits: 0), + currencyFormatter.format(actual * ringV, decimalDigits: 0), style: GoogleFonts.dmMono(fontSize: 17, fontWeight: FontWeight.w700, color: textPrimary, fontFeatures: const [FontFeature.tabularFigures()]), ), Text( - _showActual ? 'actual' : 'planned', + 'actual', style: GoogleFonts.dmSans(fontSize: 9, color: AppColors.textTertiary), ), ]), @@ -1546,7 +1525,7 @@ class _RingDetailSheetState extends State<_RingDetailSheet> { ]), const SizedBox(height: 8), Divider(height: 1, thickness: 0.5, color: divColor), - ...widget.rows.asMap().entries.map((e) { + ...rows.asMap().entries.map((e) { final i = e.key; final r = e.value; return TweenAnimationBuilder( @@ -1589,42 +1568,6 @@ class _RingDetailSheetState extends State<_RingDetailSheet> { } } -class _TogglePills extends StatelessWidget { - final bool showActual; - final void Function(bool) onToggle; - const _TogglePills({required this.showActual, required this.onToggle}); - - @override - Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final bg = isDark ? Colors.white.withValues(alpha: 0.06) : AppColors.background; - - Widget pill(String label, bool active, VoidCallback onTap) { - return GestureDetector( - onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: active ? AppColors.accent : Colors.transparent, - borderRadius: BorderRadius.circular(20), - ), - child: Text(label, style: GoogleFonts.dmSans(fontSize: 10, fontWeight: FontWeight.w600, color: active ? Colors.white : AppColors.textSecondary)), - ), - ); - } - - return Container( - padding: const EdgeInsets.all(3), - decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(20)), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - pill('Actual', showActual, () => onToggle(true)), - pill('Planned', !showActual, () => onToggle(false)), - ]), - ); - } -} - class _RingColHead extends StatelessWidget { final String text; const _RingColHead(this.text); @@ -1783,7 +1726,9 @@ class _SimpleTransactionsSectionState extends State { ), child: Column(children: [ if (i > 0) Divider(height: 1, thickness: 0.5, color: divColor, indent: 16, endIndent: 16), - Padding( + InkWell( + onTap: () => TransactionDetailSheet.show(context, transaction: t), + child: Padding( padding: const EdgeInsets.fromLTRB(16, 11, 16, 11), child: Row(children: [ Container( @@ -1807,8 +1752,10 @@ class _SimpleTransactionsSectionState extends State { if (t.hasFee) Text('+${currencyFormatter.format(t.fee, decimalDigits: 2)} fee', style: GoogleFonts.dmSans(fontSize: 10, color: AppColors.textTertiary)), ]), + const SizedBox(width: 4), + Icon(Icons.chevron_right_rounded, size: 14, color: AppColors.textTertiary), ]), - ), + )), ]), ); }), diff --git a/lib/features/finance/modules/budget/presentation/screens/budget_simple_sheets.dart b/lib/features/finance/modules/budget/presentation/screens/budget_simple_sheets.dart index 5aeb905..2aedfaa 100644 --- a/lib/features/finance/modules/budget/presentation/screens/budget_simple_sheets.dart +++ b/lib/features/finance/modules/budget/presentation/screens/budget_simple_sheets.dart @@ -366,9 +366,9 @@ class CategoryDetailSheet extends StatelessWidget { final Budget group; final BudgetCategory cat; final double spent; - final VoidCallback onEdit; + final VoidCallback? onEdit; - const CategoryDetailSheet({super.key, required this.group, required this.cat, required this.spent, required this.onEdit}); + const CategoryDetailSheet({super.key, required this.group, required this.cat, required this.spent, this.onEdit}); @override Widget build(BuildContext context) { diff --git a/lib/features/finance/modules/budget/presentation/screens/budget_simple_view.dart b/lib/features/finance/modules/budget/presentation/screens/budget_simple_view.dart index 7e291d2..d206008 100644 --- a/lib/features/finance/modules/budget/presentation/screens/budget_simple_view.dart +++ b/lib/features/finance/modules/budget/presentation/screens/budget_simple_view.dart @@ -34,7 +34,7 @@ import 'package:keep_track/features/finance/presentation/state/budget_profile_co import 'budget_simple_sections.dart'; import 'budget_simple_sheets.dart'; import '../sections/budget_overall_summary.dart'; -import '../sheets/budget_settings_sheet.dart'; +import '../dialogs/budget_settings_dialog.dart'; import '../sheets/profile_start_planning_sheet.dart'; import '../sheets/start_planning_sheet.dart'; @@ -47,9 +47,11 @@ class BudgetSimpleView extends StatefulWidget { final Color? profileAccentColor; final DateTime? profileStartDate; final DateTime? profileEndDate; - final void Function(bool isIncome)? onAddProfileGroup; + final void Function(bool isIncome, String monthKey)? onAddProfileGroup; final VoidCallback? onToggleView; final void Function(List monthBudgets)? onOpenSettings; + final VoidCallback? onEditProfile; + final VoidCallback? onDeleteProfile; final bool profileIsMonthly; const BudgetSimpleView({ @@ -66,6 +68,8 @@ class BudgetSimpleView extends StatefulWidget { this.onAddProfileGroup, this.onToggleView, this.onOpenSettings, + this.onEditProfile, + this.onDeleteProfile, }); bool get _isProfileMode => budgetProfileId != null; @@ -110,10 +114,7 @@ class _BudgetSimpleViewState extends State { final end = widget.profileEndDate ?? DateTime.now().add(const Duration(days: 365)); _txController.loadTransactionsByDateRange(start, end); } else { - _txController.loadTransactionsByDateRange( - DateTime(_month.year, _month.month, 1), - DateTime(_month.year, _month.month + 1, 1), - ); + _txController.loadAllTransactions(); } } @@ -170,17 +171,32 @@ class _BudgetSimpleViewState extends State { } void _showSettings(List monthBudgets, MonthPlan? monthPlan) { - final allClosed = monthBudgets.isNotEmpty && - monthBudgets.every((b) => b.status == BudgetStatus.closed); - BudgetSettingsSheet.show( + BudgetSettingsDialog.show( context, monthLabel: _monthLabel, - onCloseBudget: monthPlan != null && !monthPlan.isClosed - ? () => _confirmClosePlan(monthPlan) - : widget._isProfileMode && widget.profileIsMonthly && monthBudgets.isNotEmpty && !allClosed - ? () => _confirmCloseMonthBudgets(monthBudgets) - : null, - onDeleteBudget: () => _confirmDeleteBudget(monthBudgets), + profileName: widget.profileName, + profileColor: widget.profileAccentColor, + monthPlan: monthPlan, + hasMonthPlan: monthPlan != null, + onStartPlanning: monthPlan == null + ? () async { + if (widget._isProfileMode && widget.budgetProfileId != null) { + await _monthPlanController.getOrCreateMonthPlanForMonthlyProfile(_monthKey, widget.budgetProfileId!); + } else { + await _monthPlanController.getOrCreateMonthPlan(_monthKey); + } + await _budgetController.refreshBudgetsWithSpentAmounts(); + } + : null, + onCloseMonthPlan: monthPlan != null && !monthPlan.isClosed + ? () => _monthPlanController.closeMonthPlan(monthPlan.id!) + : null, + onReopenMonthPlan: monthPlan != null && monthPlan.isClosed + ? () => _monthPlanController.reopenMonthPlan(monthPlan.id!) + : null, + onDeleteMonth: () => _confirmDeleteBudget(monthBudgets, monthPlan), + onEditProfile: widget.onEditProfile, + onDeleteProfile: widget.onDeleteProfile, ); } @@ -244,15 +260,14 @@ class _BudgetSimpleViewState extends State { ); } - Future _confirmDeleteBudget(List monthBudgets) async { - if (monthBudgets.isEmpty) return; + Future _confirmDeleteBudget(List monthBudgets, MonthPlan? plan) async { final confirmed = await showDialog( context: context, builder: (_) => AlertDialog( title: Text('Delete budget for $_monthLabel?'), - content: Text( - '${monthBudgets.length} group${monthBudgets.length == 1 ? '' : 's'} and all their categories will be permanently removed.', - ), + content: Text(monthBudgets.isEmpty + ? 'The month plan will be removed.' + : '${monthBudgets.length} group${monthBudgets.length == 1 ? '' : 's'} and their categories will be permanently removed.'), actions: [ TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')), TextButton( @@ -267,6 +282,9 @@ class _BudgetSimpleViewState extends State { for (final group in monthBudgets) { if (group.id != null) await _budgetController.deleteBudget(group.id!); } + if (plan?.id != null) { + await _monthPlanController.deleteMonthPlan(plan!.id!); + } } void _showStartPlanning(List allBudgets) { @@ -288,7 +306,7 @@ class _BudgetSimpleViewState extends State { void _showCreateGroup(bool isIncome) { if (widget._isProfileMode && widget.onAddProfileGroup != null) { - widget.onAddProfileGroup!(isIncome); + widget.onAddProfileGroup!(isIncome, _monthKey); return; } showModalBottomSheet( @@ -324,12 +342,12 @@ class _BudgetSimpleViewState extends State { ); } - void _showCategoryDetail(Budget group, BudgetCategory cat, double spent) { + void _showCategoryDetail(Budget group, BudgetCategory cat, double spent, {bool canEdit = true}) { showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, builder: (_) => CategoryDetailSheet( group: group, cat: cat, spent: spent, - onEdit: () { Navigator.pop(context); _showEditCategory(group, cat); }, + onEdit: canEdit ? () { Navigator.pop(context); _showEditCategory(group, cat); } : null, ), ); } @@ -515,37 +533,47 @@ class _BudgetSimpleViewState extends State { @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; - return AsyncStreamBuilder>( - state: _budgetController, - builder: (_, budgets) => AsyncStreamBuilder>( - state: _txController, - builder: (_, txs) => AsyncStreamBuilder>( - state: _debtController, - builder: (_, debts) => AsyncStreamBuilder>( - state: _subController, - builder: (_, subs) => AsyncStreamBuilder>( - state: _goalController, - builder: (_, goals) => _build(context, isDark, budgets, txs, debts, subs, goals), - loadingBuilder: (_) => _build(context, isDark, budgets, txs, debts, subs, []), - errorBuilder: (_, __) => _build(context, isDark, budgets, txs, debts, subs, []), + return AsyncStreamBuilder>( + state: _monthPlanController, + builder: (_, __) => AsyncStreamBuilder>( + state: _budgetController, + builder: (_, budgets) => AsyncStreamBuilder>( + state: _txController, + builder: (_, txs) => AsyncStreamBuilder>( + state: _debtController, + builder: (_, debts) => AsyncStreamBuilder>( + state: _subController, + builder: (_, subs) => AsyncStreamBuilder>( + state: _goalController, + builder: (_, goals) => _build(context, isDark, budgets, txs, debts, subs, goals), + loadingBuilder: (_) => _build(context, isDark, budgets, txs, debts, subs, []), + errorBuilder: (_, __) => _build(context, isDark, budgets, txs, debts, subs, []), + ), + loadingBuilder: (_) => _build(context, isDark, budgets, txs, debts, [], []), + errorBuilder: (_, __) => _build(context, isDark, budgets, txs, debts, [], []), ), - loadingBuilder: (_) => _build(context, isDark, budgets, txs, debts, [], []), - errorBuilder: (_, __) => _build(context, isDark, budgets, txs, debts, [], []), + loadingBuilder: (_) => _build(context, isDark, budgets, txs, [], [], []), + errorBuilder: (_, __) => _build(context, isDark, budgets, txs, [], [], []), ), - loadingBuilder: (_) => _build(context, isDark, budgets, txs, [], [], []), - errorBuilder: (_, __) => _build(context, isDark, budgets, txs, [], [], []), + loadingBuilder: (_) => _build(context, isDark, budgets, [], [], [], []), + errorBuilder: (_, __) => _build(context, isDark, budgets, [], [], [], []), ), - loadingBuilder: (_) => _build(context, isDark, budgets, [], [], [], []), - errorBuilder: (_, __) => _build(context, isDark, budgets, [], [], [], []), + loadingBuilder: (_) => const Center(child: CircularProgressIndicator()), + errorBuilder: (_, msg) => Center(child: Text(msg)), ), loadingBuilder: (_) => const Center(child: CircularProgressIndicator()), - errorBuilder: (_, msg) => Center(child: Text(msg)), + errorBuilder: (_, __) => const Center(child: CircularProgressIndicator()), ); } Widget _build(BuildContext ctx, bool isDark, List budgets, List txs, List debts, List subs, List goals) { - final spentByCategory = BudgetMonthFilter.buildSpentByCategory(txs); + // Scope all per-month aggregations to the currently viewed month, filtered by profile when in profile mode + final allMonthTxs = BudgetMonthFilter.filterTransactions(txs, _month); + final monthTxs = widget._isProfileMode && widget.budgetProfileId != null + ? allMonthTxs.where((t) => t.budgetProfileId == widget.budgetProfileId).toList() + : allMonthTxs; + final spentByCategory = BudgetMonthFilter.buildSpentByCategory(monthTxs); final categoryNames = { for (final c in (_categoryController.data ?? [])) if (c.id != null) c.id!: c.name, @@ -553,7 +581,7 @@ class _BudgetSimpleViewState extends State { final paidThisMonthByDebt = {}; final contributedThisMonthByGoal = {}; - for (final t in txs) { + for (final t in monthTxs) { if (t.debtId != null) { paidThisMonthByDebt[t.debtId!] = (paidThisMonthByDebt[t.debtId!] ?? 0) + t.amount; } @@ -639,19 +667,24 @@ class _BudgetSimpleViewState extends State { ? null : plans.cast().firstWhere( (p) => p?.month == _monthKey, orElse: () => null); - final MonthPlan? profilePlan = widget._isProfileMode + final MonthPlan? profilePlan = widget._isProfileMode && !widget.profileIsMonthly ? plans.cast().firstWhere( (p) => p?.budgetProfileId == widget.budgetProfileId, orElse: () => null) : null; + final MonthPlan? monthlyProfilePlan = widget._isProfileMode && widget.profileIsMonthly + ? plans.cast().firstWhere( + (p) => p?.month == _monthKey && p?.budgetProfileId == widget.budgetProfileId, + orElse: () => null) + : null; final bool hasMonthPlan = widget._isProfileMode ? widget.profileIsMonthly - ? monthBudgets.isNotEmpty + ? monthlyProfilePlan != null || monthBudgets.isNotEmpty : (profilePlan != null || monthBudgets.isNotEmpty) : monthPlan != null; final bool isMonthClosed = widget._isProfileMode ? widget.profileIsMonthly - ? monthBudgets.isNotEmpty && monthBudgets.every((b) => b.status == BudgetStatus.closed) + ? monthlyProfilePlan?.isClosed ?? false : false : monthPlan?.isClosed ?? false; @@ -694,8 +727,7 @@ class _BudgetSimpleViewState extends State { onNext: _nextMonth, onBackToCurrentMonth: !_isCurrentMonth && currentMonthHasPlan ? _backToCurrentMonth : null, onToggleView: widget.onToggleView, - // Always show settings for profiles - onSettings: widget.onOpenSettings != null ? () => widget.onOpenSettings!(monthBudgets) : null, + onSettings: () => _showSettings(monthBudgets, monthlyProfilePlan), )) else SliverToBoxAdapter(child: SimpleMonthNav( @@ -705,7 +737,7 @@ class _BudgetSimpleViewState extends State { onPrev: _prevMonth, onNext: _nextMonth, onBackToCurrentMonth: !_isCurrentMonth && currentMonthHasPlan ? _backToCurrentMonth : null, onToggleView: widget.onToggleView, - onSettings: hasMonthPlan ? () => _showSettings(monthBudgets, monthPlan) : null, + onSettings: () => _showSettings(monthBudgets, monthPlan), )), ]; @@ -749,7 +781,7 @@ class _BudgetSimpleViewState extends State { debts: sortedDebts, receivables: sortedReceivables, goals: sortedGoals, - transactions: txs, + transactions: monthTxs, currentMonth: _month, isDark: isDark, ), @@ -764,7 +796,7 @@ class _BudgetSimpleViewState extends State { SliverToBoxAdapter( child: SimpleTransactionsSection( isDark: isDark, - transactions: txs, + transactions: monthTxs, categoryNames: categoryNames, ), ), @@ -790,13 +822,17 @@ class _BudgetSimpleViewState extends State { if (hasMonthPlan && tab == 1) ...[ SliverToBoxAdapter(child: SimpleBudgetSection( isDark: isDark, label: 'INCOME', groups: incomeGroups, spentByCategory: spentByCategory, isIncome: true, - onAddGroup: () => _showCreateGroup(true), onAddCategory: _showAddCategory, - onEditGroup: _showEditGroup, onCategoryTap: _showCategoryDetail, + onAddGroup: isMonthClosed ? null : () => _showCreateGroup(true), + onAddCategory: isMonthClosed ? null : _showAddCategory, + onEditGroup: isMonthClosed ? null : _showEditGroup, + onCategoryTap: (g, c, s) => _showCategoryDetail(g, c, s, canEdit: !isMonthClosed), )), SliverToBoxAdapter(child: SimpleBudgetSection( isDark: isDark, label: 'EXPENSES', groups: expenseGroups, spentByCategory: spentByCategory, isIncome: false, - onAddGroup: () => _showCreateGroup(false), onAddCategory: _showAddCategory, - onEditGroup: _showEditGroup, onCategoryTap: _showCategoryDetail, + onAddGroup: isMonthClosed ? null : () => _showCreateGroup(false), + onAddCategory: isMonthClosed ? null : _showAddCategory, + onEditGroup: isMonthClosed ? null : _showEditGroup, + onCategoryTap: (g, c, s) => _showCategoryDetail(g, c, s, canEdit: !isMonthClosed), )), ], if (tab == 2) diff --git a/lib/features/finance/modules/budget/presentation/sections/budget_screen_body.dart b/lib/features/finance/modules/budget/presentation/sections/budget_screen_body.dart index f1acb52..9eb4c38 100644 --- a/lib/features/finance/modules/budget/presentation/sections/budget_screen_body.dart +++ b/lib/features/finance/modules/budget/presentation/sections/budget_screen_body.dart @@ -12,7 +12,6 @@ import 'package:keep_track/features/finance/modules/transaction/domain/entities/ import '../helpers/budget_month_filter.dart'; import '../sheets/budget_settings_sheet.dart'; -import '../sections/budget_overall_summary.dart'; import '../sections/budget_summary_bar.dart'; import '../sections/debt_section.dart'; import '../sections/goal_section.dart'; @@ -143,10 +142,13 @@ class BudgetScreenBody extends StatelessWidget { final monthStart = DateTime(currentMonth.year, currentMonth.month, 1); final monthEnd = DateTime(currentMonth.year, currentMonth.month + 1, 1); - final monthTransactions = BudgetMonthFilter.filterTransactions( + final allMonthTxs = BudgetMonthFilter.filterTransactions( data.transactions, currentMonth, ); + final monthTransactions = budgetProfileId != null + ? allMonthTxs.where((t) => t.budgetProfileId == budgetProfileId).toList() + : allMonthTxs; final spentByCategory = BudgetMonthFilter.buildSpentByCategory( monthTransactions, ); @@ -295,6 +297,11 @@ class BudgetScreenBody extends StatelessWidget { selectedDebt: selectedDebt, allBudgets: monthBudgets, allTransactions: monthTransactions, + subscriptions: monthSubscriptions, + debts: debts, + receivables: receivables, + goals: filteredGoals, + currentMonth: currentMonth, onClose: onClearGroup, onCategoryPanelClose: onClearCategory, onDebtClose: () => onDebtSelect(null), @@ -332,34 +339,8 @@ class BudgetScreenBody extends StatelessWidget { // ── scrollable main content ─────────────────────────────────────────── List contentSlivers; - if (selectedTab == 0) { - // Summary tab: gate if no plan, otherwise full overview - contentSlivers = [ - const SliverToBoxAdapter(child: SizedBox(height: 24)), - if (!hasMonthPlan) - SliverToBoxAdapter( - child: EmptyBudgetState( - monthLabel: monthLabel, - onStart: () => onStartPlanning(data.budgets), - ), - ), - if (hasMonthPlan) - SliverToBoxAdapter( - child: BudgetOverallSummary( - monthBudgets: monthBudgets, - spentByCategory: spentByCategory, - subscriptions: monthSubscriptions, - debts: debts, - receivables: receivables, - goals: filteredGoals, - transactions: monthTransactions, - currentMonth: currentMonth, - isDark: isDark, - ), - ), - ]; - } else if (selectedTab == 1) { - // Budget tab: reorderable budget groups only + if (selectedTab <= 1) { + // Budget tab (default) contentSlivers = [ const SliverToBoxAdapter(child: SizedBox(height: 16)), if (!hasMonthPlan) diff --git a/lib/features/finance/modules/budget/presentation/sections/budget_summary_bar.dart b/lib/features/finance/modules/budget/presentation/sections/budget_summary_bar.dart index a22ac8a..3b84ba0 100644 --- a/lib/features/finance/modules/budget/presentation/sections/budget_summary_bar.dart +++ b/lib/features/finance/modules/budget/presentation/sections/budget_summary_bar.dart @@ -71,7 +71,6 @@ class BudgetSummaryBar extends StatelessWidget { }); String _tabTitle(String monthLabel) => switch (selectedTab) { - 0 => 'Summary', 1 => monthLabel, 2 => 'Subscriptions', 3 => 'Debts', @@ -81,7 +80,6 @@ class BudgetSummaryBar extends StatelessWidget { }; String _tabSubtitle(String leftLabel) => switch (selectedTab) { - 0 => 'Financial overview', 1 => leftLabel, 2 => '$subsCount active', 3 => '$debtsCount active', @@ -259,7 +257,6 @@ class BudgetSummaryBar extends StatelessWidget { LayoutBuilder( builder: (context, constraints) { final pills = [ - _TabPill(label: 'Summary', count: 0, color: AppColors.info, selected: selectedTab == 0, onTap: () => onTabSelect(0)), _TabPill(label: 'Budget', count: budgetGroupCount, color: AppColors.accent, selected: selectedTab == 1, onTap: () => onTabSelect(1)), _TabPill(label: 'Subs', count: subsCount, color: AppColors.warning, selected: selectedTab == 2, onTap: () => onTabSelect(2)), _TabPill(label: 'Debts', count: debtsCount, color: AppColors.error, selected: selectedTab == 3, onTap: () => onTabSelect(3)), @@ -333,9 +330,7 @@ class _TabPill extends StatelessWidget { : AppColors.border.withValues(alpha: isDark ? 0.2 : 1.0); final textColor = selected ? Colors.white - : (count > 0 - ? (isDark ? AppColors.primaryForeground : AppColors.textPrimary) - : AppColors.textTertiary); + : (isDark ? AppColors.primaryForeground : AppColors.textPrimary); return GestureDetector( onTap: onTap, @@ -358,7 +353,7 @@ class _TabPill extends StatelessWidget { width: 6, height: 6, margin: const EdgeInsets.only(right: 6), decoration: BoxDecoration( - color: count > 0 ? color : AppColors.textTertiary, + color: color, shape: BoxShape.circle, ), ), diff --git a/lib/features/finance/modules/budget/presentation/sheets/add_category_sheet.dart b/lib/features/finance/modules/budget/presentation/sheets/add_category_sheet.dart index 65b263a..b2d7f11 100644 --- a/lib/features/finance/modules/budget/presentation/sheets/add_category_sheet.dart +++ b/lib/features/finance/modules/budget/presentation/sheets/add_category_sheet.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:keep_track/core/settings/utils/currency_formatter.dart'; import 'package:keep_track/core/theme/app_theme.dart'; import 'package:keep_track/features/finance/modules/budget/domain/entities/budget_category.dart'; import 'package:keep_track/features/finance/modules/finance_category/domain/entities/finance_category.dart' @@ -7,6 +9,7 @@ import '../../../../../../core/state/stream_state.dart'; import '../../../../presentation/state/finance_category_controller.dart'; import '../../../finance_category/domain/entities/finance_category_enums.dart'; import '../../domain/entities/budget.dart'; +import 'sheet_helpers.dart'; class AddCategorySheet extends StatefulWidget { final Budget group; @@ -25,173 +28,192 @@ class AddCategorySheet extends StatefulWidget { } class _AddCategorySheetState extends State { - final _nameController = TextEditingController(); - final _amountController = TextEditingController(); + final _nameCtrl = TextEditingController(); + final _amountCtrl = TextEditingController(); bool _saving = false; @override void dispose() { - _nameController.dispose(); - _amountController.dispose(); + _nameCtrl.dispose(); + _amountCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final bg = isDark ? const Color(0xFF2C2C2A) : Colors.white; + final borderColor = isDark ? AppColors.border.withValues(alpha: 0.2) : AppColors.border.withValues(alpha: 0.5); + final textPrimary = isDark ? AppColors.primaryForeground : AppColors.textPrimary; + final isIncome = widget.group.budgetType == BudgetType.income; + final accentColor = isIncome ? AppColors.success : AppColors.accent; + return Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - ), + padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), child: Container( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Handle - Center( - child: Container( - width: 36, - height: 4, - decoration: BoxDecoration( - color: AppColors.textTertiary.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(2), + decoration: BoxDecoration( + color: bg, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 10), + Center( + child: Container( + width: 36, height: 4, + decoration: BoxDecoration( + color: AppColors.textTertiary.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), ), ), - ), - const SizedBox(height: 16), - Text('Add Category', style: AppTextStyles.h4), - const SizedBox(height: 4), - Text( - widget.group.title ?? '', - style: AppTextStyles.caption.copyWith( - color: AppColors.textSecondary, - ), - ), - const SizedBox(height: 20), - TextField( - controller: _nameController, - autofocus: true, - textCapitalization: TextCapitalization.words, - decoration: const InputDecoration( - labelText: 'Category Name', - hintText: 'e.g., Groceries, Netflix, Salary', - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 16), - TextField( - controller: _amountController, - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - ), - decoration: const InputDecoration( - labelText: 'Planned Amount', - border: OutlineInputBorder(), - prefixText: '₱ ', + Padding( + padding: const EdgeInsets.fromLTRB(20, 14, 16, 0), + child: Row(children: [ + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text( + 'Add Category', + style: GoogleFonts.dmSans(fontSize: 16, fontWeight: FontWeight.w700, color: textPrimary), + ), + if ((widget.group.title ?? '').isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + widget.group.title!, + style: GoogleFonts.dmSans(fontSize: 12, color: AppColors.textSecondary), + ), + ], + ]), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: accentColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + isIncome ? 'Income' : 'Expense', + style: GoogleFonts.dmSans(fontSize: 11, fontWeight: FontWeight.w600, color: accentColor), + ), + ), + GestureDetector( + onTap: () => Navigator.pop(context), + child: Padding( + padding: const EdgeInsets.all(6), + child: Icon(Icons.close_rounded, size: 18, color: AppColors.textSecondary), + ), + ), + ]), ), - onSubmitted: (_) => _save(), - ), - const SizedBox(height: 24), - SizedBox( - width: double.infinity, - child: FilledButton( - onPressed: _saving ? null : _save, - child: _saving - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, + Divider(height: 24, color: borderColor), + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SheetLabel('CATEGORY NAME'), + SheetField( + ctrl: _nameCtrl, + hint: 'e.g. Groceries, Netflix, Salary', + isDark: isDark, + autofocus: true, + capitalize: true, + ), + const SizedBox(height: 16), + SheetLabel('PLANNED AMOUNT'), + SheetField( + ctrl: _amountCtrl, + hint: '0.00', + prefix: '${currencyFormatter.currencySymbol} ', + isDark: isDark, + numeric: true, + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + height: 46, + child: ElevatedButton.icon( + onPressed: _saving ? null : _save, + icon: _saving + ? const SizedBox(width: 15, height: 15, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : const Icon(Icons.add_rounded, size: 15), + label: Text(_saving ? 'Adding…' : 'Add Category'), + style: ElevatedButton.styleFrom( + backgroundColor: accentColor, + foregroundColor: Colors.white, + elevation: 0, + textStyle: GoogleFonts.dmSans(fontSize: 13, fontWeight: FontWeight.w600), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), ), - ) - : const Text('Add'), + ), + ), + ], + ), ), - ), - ], + ], + ), ), ), ); } Future _save() async { - final name = _nameController.text.trim(); + final name = _nameCtrl.text.trim(); if (name.isEmpty) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Enter a category name'))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Enter a category name', style: GoogleFonts.dmSans()), backgroundColor: AppColors.error), + ); return; } - final amount = double.tryParse(_amountController.text); + final amount = double.tryParse(_amountCtrl.text); if (amount == null || amount < 0) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Enter a valid amount'))); - return; - } - final budgetId = widget.group.id; - if (budgetId == null) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Budget group has no ID — please try again.'), - ), + SnackBar(content: Text('Enter a valid amount', style: GoogleFonts.dmSans()), backgroundColor: AppColors.error), ); return; } + final budgetId = widget.group.id; + if (budgetId == null) return; + setState(() => _saving = true); try { - // Derive CategoryType from the group's BudgetType - final catType = widget.group.budgetType == BudgetType.income - ? CategoryType.income - : CategoryType.expense; + final catType = widget.group.budgetType == BudgetType.income ? CategoryType.income : CategoryType.expense; - // Check if a matching FinanceCategory already exists — reuse it, don't duplicate final currentState = widget.categoryController.state; - final currentCats = currentState is AsyncData> - ? currentState.data - : []; + final currentCats = currentState is AsyncData> ? currentState.data : []; final existing = currentCats - .where( - (c) => - c.name.toLowerCase() == name.toLowerCase() && c.type == catType, - ) + .where((c) => c.name.toLowerCase() == name.toLowerCase() && c.type == catType) .firstOrNull; FinanceCategory created; if (existing != null) { created = existing; } else { - // Create a new FinanceCategory in the DB - await widget.categoryController.createCategory( - FinanceCategory(name: name, type: catType), - ); - // Find the freshly created category in the updated cache + await widget.categoryController.createCategory(FinanceCategory(name: name, type: catType)); final updatedState = widget.categoryController.state; - final updatedCats = updatedState is AsyncData> - ? updatedState.data - : []; + final updatedCats = updatedState is AsyncData> ? updatedState.data : []; created = updatedCats.firstWhere( - (c) => - c.name.toLowerCase() == name.toLowerCase() && c.type == catType, + (c) => c.name.toLowerCase() == name.toLowerCase() && c.type == catType, orElse: () => FinanceCategory(name: name, type: catType), ); } - await widget.onSave( - BudgetCategory( - budgetId: budgetId, - financeCategoryId: created.id ?? '', - targetAmount: amount, - financeCategory: created, - ), - ); + await widget.onSave(BudgetCategory( + budgetId: budgetId, + financeCategoryId: created.id ?? '', + targetAmount: amount, + financeCategory: created, + )); if (mounted) Navigator.pop(context); } catch (e) { if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Error: $e'))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e', style: GoogleFonts.dmSans()), backgroundColor: AppColors.error), + ); setState(() => _saving = false); } } diff --git a/lib/features/finance/modules/budget/presentation/sheets/budget_settings_sheet.dart b/lib/features/finance/modules/budget/presentation/sheets/budget_settings_sheet.dart index 385ae78..aa8cb26 100644 --- a/lib/features/finance/modules/budget/presentation/sheets/budget_settings_sheet.dart +++ b/lib/features/finance/modules/budget/presentation/sheets/budget_settings_sheet.dart @@ -6,14 +6,16 @@ class BudgetSettingsSheet extends StatelessWidget { final String monthLabel; final VoidCallback? onEditBudget; final VoidCallback? onCloseBudget; - final VoidCallback onDeleteBudget; + final VoidCallback? onDeleteBudget; + final VoidCallback? onDeleteProfile; const BudgetSettingsSheet({ super.key, required this.monthLabel, this.onEditBudget, this.onCloseBudget, - required this.onDeleteBudget, + this.onDeleteBudget, + this.onDeleteProfile, }); static Future show( @@ -21,7 +23,8 @@ class BudgetSettingsSheet extends StatelessWidget { required String monthLabel, VoidCallback? onEditBudget, VoidCallback? onCloseBudget, - required VoidCallback onDeleteBudget, + VoidCallback? onDeleteBudget, + VoidCallback? onDeleteProfile, }) { return showModalBottomSheet( context: context, @@ -32,6 +35,7 @@ class BudgetSettingsSheet extends StatelessWidget { onEditBudget: onEditBudget, onCloseBudget: onCloseBudget, onDeleteBudget: onDeleteBudget, + onDeleteProfile: onDeleteProfile, ), ); } @@ -91,16 +95,30 @@ class BudgetSettingsSheet extends StatelessWidget { }, ), ], - Divider(height: 1, color: divColor), - _SettingsRow( - icon: Icons.delete_outline_rounded, - label: 'Delete Budget', - color: AppColors.error, - onTap: () { - Navigator.pop(context); - onDeleteBudget(); - }, - ), + if (onDeleteBudget != null) ...[ + Divider(height: 1, color: divColor), + _SettingsRow( + icon: Icons.delete_outline_rounded, + label: 'Delete Month', + color: AppColors.error, + onTap: () { + Navigator.pop(context); + onDeleteBudget!(); + }, + ), + ], + if (onDeleteProfile != null) ...[ + Divider(height: 1, color: divColor), + _SettingsRow( + icon: Icons.delete_forever_rounded, + label: 'Delete Profile', + color: AppColors.error, + onTap: () { + Navigator.pop(context); + onDeleteProfile!(); + }, + ), + ], Divider(height: 1, color: divColor), const SizedBox(height: 8), Padding( diff --git a/lib/features/finance/modules/budget/presentation/sheets/edit_category_sheet.dart b/lib/features/finance/modules/budget/presentation/sheets/edit_category_sheet.dart index 337d5b6..ec8ec7f 100644 --- a/lib/features/finance/modules/budget/presentation/sheets/edit_category_sheet.dart +++ b/lib/features/finance/modules/budget/presentation/sheets/edit_category_sheet.dart @@ -1,10 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:keep_track/core/settings/utils/currency_formatter.dart'; import 'package:keep_track/core/theme/app_theme.dart'; import 'package:keep_track/features/finance/presentation/state/finance_category_controller.dart'; import '../controllers/budget_controller.dart'; import '../../domain/entities/budget.dart'; import '../../domain/entities/budget_category.dart'; +import 'sheet_helpers.dart'; class EditCategorySheet extends StatefulWidget { final Budget group; @@ -34,12 +37,8 @@ class _EditCategorySheetState extends State { @override void initState() { super.initState(); - _nameCtrl = TextEditingController( - text: widget.category.financeCategory?.name ?? '', - ); - _amountCtrl = TextEditingController( - text: widget.category.targetAmount.toStringAsFixed(2), - ); + _nameCtrl = TextEditingController(text: widget.category.financeCategory?.name ?? ''); + _amountCtrl = TextEditingController(text: widget.category.targetAmount.toStringAsFixed(2)); } @override @@ -49,29 +48,167 @@ class _EditCategorySheetState extends State { super.dispose(); } + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final bg = isDark ? const Color(0xFF2C2C2A) : Colors.white; + final borderColor = isDark ? AppColors.border.withValues(alpha: 0.2) : AppColors.border.withValues(alpha: 0.5); + final textPrimary = isDark ? AppColors.primaryForeground : AppColors.textPrimary; + final isIncome = widget.group.budgetType == BudgetType.income; + final accentColor = isIncome ? AppColors.success : AppColors.accent; + + return Padding( + padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + child: Container( + decoration: BoxDecoration( + color: bg, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 10), + Center( + child: Container( + width: 36, height: 4, + decoration: BoxDecoration( + color: AppColors.textTertiary.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 14, 16, 0), + child: Row(children: [ + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text( + 'Edit Category', + style: GoogleFonts.dmSans(fontSize: 16, fontWeight: FontWeight.w700, color: textPrimary), + ), + if ((widget.group.title ?? '').isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + widget.group.title!, + style: GoogleFonts.dmSans(fontSize: 12, color: AppColors.textSecondary), + ), + ], + ]), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: accentColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + isIncome ? 'Income' : 'Expense', + style: GoogleFonts.dmSans(fontSize: 11, fontWeight: FontWeight.w600, color: accentColor), + ), + ), + GestureDetector( + onTap: () => Navigator.pop(context), + child: Padding( + padding: const EdgeInsets.all(6), + child: Icon(Icons.close_rounded, size: 18, color: AppColors.textSecondary), + ), + ), + ]), + ), + Divider(height: 24, color: borderColor), + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SheetLabel('CATEGORY NAME'), + SheetField( + ctrl: _nameCtrl, + hint: 'Category name', + isDark: isDark, + autofocus: true, + capitalize: true, + ), + const SizedBox(height: 16), + SheetLabel('PLANNED AMOUNT'), + SheetField( + ctrl: _amountCtrl, + hint: '0.00', + prefix: '${currencyFormatter.currencySymbol} ', + isDark: isDark, + numeric: true, + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + height: 46, + child: ElevatedButton.icon( + onPressed: _saving ? null : _save, + icon: _saving + ? const SizedBox(width: 15, height: 15, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : const Icon(Icons.check_rounded, size: 15), + label: Text(_saving ? 'Saving…' : 'Save Changes'), + style: ElevatedButton.styleFrom( + backgroundColor: accentColor, + foregroundColor: Colors.white, + elevation: 0, + textStyle: GoogleFonts.dmSans(fontSize: 13, fontWeight: FontWeight.w600), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ), + ), + if (widget.onDelete != null) ...[ + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + height: 46, + child: OutlinedButton.icon( + onPressed: _saving ? null : widget.onDelete, + icon: const Icon(Icons.delete_outline_rounded, size: 15), + label: const Text('Delete Category'), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.error, + side: BorderSide(color: AppColors.error.withValues(alpha: 0.4)), + textStyle: GoogleFonts.dmSans(fontSize: 13, fontWeight: FontWeight.w500), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ), + ), + ], + ], + ), + ), + ], + ), + ), + ), + ); + } + Future _save() async { final name = _nameCtrl.text.trim(); final amount = double.tryParse(_amountCtrl.text); if (name.isEmpty) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Enter a category name'))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Enter a category name', style: GoogleFonts.dmSans()), backgroundColor: AppColors.error), + ); return; } if (amount == null || amount < 0) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Enter a valid amount'))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Enter a valid amount', style: GoogleFonts.dmSans()), backgroundColor: AppColors.error), + ); return; } setState(() => _saving = true); try { - // Update FinanceCategory name if changed final fc = widget.category.financeCategory; if (fc != null && fc.name != name) { await widget.categoryController.updateCategory(fc.copyWith(name: name)); } - // Update planned amount if changed if (amount != widget.category.targetAmount) { await widget.budgetController.updateCategory( widget.group.id!, @@ -81,101 +218,11 @@ class _EditCategorySheetState extends State { if (mounted) Navigator.pop(context); } catch (e) { if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Error: $e'))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e', style: GoogleFonts.dmSans()), backgroundColor: AppColors.error), + ); setState(() => _saving = false); } } } - - @override - Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - ), - child: Container( - padding: const EdgeInsets.fromLTRB(24, 20, 24, 24), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: Container( - width: 36, - height: 4, - decoration: BoxDecoration( - color: AppColors.textTertiary.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(2), - ), - ), - ), - const SizedBox(height: 16), - Text('Edit Category', style: AppTextStyles.h4), - const SizedBox(height: 4), - Text( - widget.group.title ?? '', - style: AppTextStyles.caption.copyWith( - color: AppColors.textSecondary, - ), - ), - const SizedBox(height: 20), - TextField( - controller: _nameCtrl, - autofocus: true, - textCapitalization: TextCapitalization.words, - decoration: const InputDecoration( - labelText: 'Category Name', - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 16), - TextField( - controller: _amountCtrl, - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - ), - decoration: const InputDecoration( - labelText: 'Planned Amount', - border: OutlineInputBorder(), - prefixText: '₱ ', - ), - onSubmitted: (_) => _save(), - ), - const SizedBox(height: 24), - SizedBox( - width: double.infinity, - child: FilledButton( - onPressed: _saving ? null : _save, - child: _saving - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : const Text('Save'), - ), - ), - if (widget.onDelete != null) ...[ - const SizedBox(height: 8), - SizedBox( - width: double.infinity, - child: TextButton( - onPressed: _saving ? null : widget.onDelete, - child: const Text( - 'Delete Category', - style: TextStyle(color: AppColors.error), - ), - ), - ), - ], - ], - ), - ), - ); - } } diff --git a/lib/features/finance/modules/budget/presentation/sheets/start_planning_sheet.dart b/lib/features/finance/modules/budget/presentation/sheets/start_planning_sheet.dart index 9c2fb35..3e0db62 100644 --- a/lib/features/finance/modules/budget/presentation/sheets/start_planning_sheet.dart +++ b/lib/features/finance/modules/budget/presentation/sheets/start_planning_sheet.dart @@ -1,13 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:keep_track/core/di/service_locator.dart'; +import 'package:google_fonts/google_fonts.dart'; import 'package:keep_track/core/state/stream_state.dart'; import 'package:keep_track/core/theme/app_theme.dart'; -import 'package:keep_track/features/auth/presentation/state/auth_controller.dart'; import 'package:keep_track/features/finance/modules/budget/domain/entities/budget.dart'; import 'package:keep_track/features/finance/modules/budget/domain/entities/budget_category.dart'; -import 'package:keep_track/features/finance/modules/budget/presentation/helpers/currency_formatter.dart'; -import 'package:keep_track/features/finance/modules/finance_category/domain/entities/finance_category_enums.dart'; -import 'package:keep_track/features/finance/presentation/state/finance_category_controller.dart'; import '../controllers/budget_controller.dart'; import '../../../../presentation/state/month_plan_controller.dart'; @@ -43,96 +39,14 @@ class StartPlanningSheet extends StatefulWidget { class _StartPlanningSheetState extends State { bool _loading = false; - double _calculateCarryOver() { - final prevBudgets = (widget.budgetController.data ?? []) - .where((b) => - b.month == widget.prevMonthKey && - (widget.budgetProfileId == null || - b.budgetProfileId == widget.budgetProfileId)) - .toList(); - final income = prevBudgets - .where((b) => b.budgetType == BudgetType.income) - .fold(0.0, (s, b) => s + b.budgetTarget); - final expenses = prevBudgets - .where((b) => b.budgetType == BudgetType.expense) - .fold(0.0, (s, b) => s + b.budgetTarget); - return income - expenses; - } - - Future _applyCarryOver(double amount) async { - final userId = locator.get().currentUser?.id ?? ''; - final catCtrl = locator.get(); - - final categoryId = await catCtrl.findOrCreate( - name: 'Carry Over', - type: CategoryType.income, - userId: userId, - ); - if (categoryId == null) return; - - final created = await widget.budgetController.createBudget(Budget( - month: widget.monthKey, - title: 'Carry Over', - budgetType: BudgetType.income, - periodType: BudgetPeriodType.monthly, - status: BudgetStatus.active, - budgetProfileId: widget.budgetProfileId, - )); - - await widget.budgetController.addCategory( - created.id!, - BudgetCategory( - budgetId: created.id!, - financeCategoryId: categoryId, - targetAmount: amount, - ), - ); - - // Only link to month plan for non-profile mode - if (!widget._isProfileMode) { - await widget.monthPlanController.addBudgetToMonthPlan( - widget.monthKey, - created.id!, - ); - } - } - - Future _promptCarryOver() async { - final carryOver = _calculateCarryOver(); - if (carryOver <= 0 || !mounted) return; - - final confirmed = await showDialog( - context: context, - builder: (_) => AlertDialog( - title: const Text('Carry Over'), - content: Text( - 'Your previous month had a remaining balance of ${formatCurrency(carryOver)}. ' - 'Add it as carry-over income for ${widget.monthLabel}?', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text('Skip'), - ), - FilledButton( - onPressed: () => Navigator.pop(context, true), - child: const Text('Add Carry Over'), - ), - ], - ), - ); - - if (confirmed == true && mounted) { - await _applyCarryOver(carryOver); - await widget.budgetController.refreshBudgetsWithSpentAmounts(); - } - } - Future _copyFromPrev() async { setState(() => _loading = true); try { if (widget._isProfileMode) { - // Copy profile-scoped budgets from previous month + final plan = widget.budgetProfileId != null + ? await widget.monthPlanController.getOrCreateMonthPlanForMonthlyProfile( + widget.monthKey, widget.budgetProfileId!) + : null; final prevBudgets = (widget.budgetController.data ?? []) .where((b) => b.budgetProfileId == widget.budgetProfileId && @@ -158,16 +72,15 @@ class _StartPlanningSheetState extends State { ), ); } + if (plan?.id != null && created.id != null) { + await widget.monthPlanController.addBudgetToPlanById(plan!.id!, created.id!); + } } await widget.budgetController.refreshBudgetsWithSpentAmounts(); } else { - await widget.monthPlanController.copyMonthPlan( - widget.prevMonthKey, - widget.monthKey, - ); + await widget.monthPlanController.copyMonthPlan(widget.prevMonthKey, widget.monthKey); await widget.budgetController.refreshBudgetsWithSpentAmounts(); } - await _promptCarryOver(); if (mounted) Navigator.pop(context); } catch (e) { if (mounted) { @@ -181,11 +94,13 @@ class _StartPlanningSheetState extends State { Future _startFresh() async { setState(() => _loading = true); try { - if (!widget._isProfileMode) { + if (widget.budgetProfileId != null) { + await widget.monthPlanController.getOrCreateMonthPlanForMonthlyProfile( + widget.monthKey, widget.budgetProfileId!); + } else { await widget.monthPlanController.getOrCreateMonthPlan(widget.monthKey); - await widget.budgetController.refreshBudgetsWithSpentAmounts(); } - await _promptCarryOver(); + await widget.budgetController.refreshBudgetsWithSpentAmounts(); if (mounted) Navigator.pop(context); } catch (_) { if (mounted) setState(() => _loading = false); @@ -194,69 +109,159 @@ class _StartPlanningSheetState extends State { @override Widget build(BuildContext context) { - return Padding( + final isDark = Theme.of(context).brightness == Brightness.dark; + final bg = isDark ? const Color(0xFF1E1E1C) : Colors.white; + final textPrimary = isDark ? AppColors.primaryForeground : AppColors.textPrimary; + + return Container( + decoration: BoxDecoration( + color: bg, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + ), padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, + bottom: MediaQuery.of(context).viewInsets.bottom + 28, ), - child: Container( - padding: const EdgeInsets.fromLTRB(24, 20, 24, 32), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: Container( - width: 36, - height: 4, - decoration: BoxDecoration( - color: AppColors.textTertiary.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(2), - ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Handle + const SizedBox(height: 12), + Center( + child: Container( + width: 36, height: 4, + decoration: BoxDecoration( + color: AppColors.textTertiary.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), ), ), - const SizedBox(height: 16), - Text('Start Planning', style: AppTextStyles.h4), - const SizedBox(height: 4), - Text( - widget.monthLabel, - style: AppTextStyles.caption.copyWith( - color: AppColors.textSecondary, - ), - ), - const SizedBox(height: 24), + ), + const SizedBox(height: 24), - if (widget.hasPrevBudgets) ...[ - SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: _loading ? null : _copyFromPrev, - icon: _loading - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : const Icon(Icons.copy_outlined, size: 18), - label: Text( - _loading ? 'Copying…' : 'Copy from ${widget.prevMonthLabel}', - ), + // Header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Row(children: [ + Container( + width: 44, height: 44, + decoration: BoxDecoration( + color: AppColors.accent.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), ), + child: const Icon(Icons.calendar_month_rounded, size: 22, color: AppColors.accent), ), - const SizedBox(height: 12), - ], + const SizedBox(width: 14), + Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('Start Planning', + style: GoogleFonts.dmSans(fontSize: 16, fontWeight: FontWeight.w700, color: textPrimary)), + Text(widget.monthLabel, + style: GoogleFonts.dmSans(fontSize: 12, color: AppColors.textSecondary)), + ]), + ]), + ), - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: _loading ? null : _startFresh, - icon: const Icon(Icons.add, size: 18), - label: const Text('Start Fresh'), + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column(children: [ + if (widget.hasPrevBudgets) ...[ + _OptionCard( + isDark: isDark, + icon: Icons.copy_all_rounded, + label: 'Copy from ${widget.prevMonthLabel}', + sublabel: 'Duplicate all budget groups and categories', + isPrimary: true, + loading: _loading, + onTap: _loading ? null : _copyFromPrev, + ), + const SizedBox(height: 10), + ], + _OptionCard( + isDark: isDark, + icon: Icons.add_circle_outline_rounded, + label: 'Start Fresh', + sublabel: 'Create a new empty budget for this month', + isPrimary: !widget.hasPrevBudgets, + loading: false, + onTap: _loading ? null : _startFresh, ), + ]), + ), + ], + ), + ); + } +} + +class _OptionCard extends StatelessWidget { + final bool isDark; + final IconData icon; + final String label; + final String sublabel; + final bool isPrimary; + final bool loading; + final VoidCallback? onTap; + + const _OptionCard({ + required this.isDark, + required this.icon, + required this.label, + required this.sublabel, + required this.isPrimary, + required this.loading, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final bg = isPrimary + ? AppColors.accent + : (isDark ? const Color(0xFF2C2C2A) : AppColors.background); + final border = isDark + ? AppColors.border.withValues(alpha: 0.2) + : AppColors.border.withValues(alpha: 0.5); + final labelColor = isPrimary ? Colors.white : (isDark ? AppColors.primaryForeground : AppColors.textPrimary); + final subColor = isPrimary ? Colors.white.withValues(alpha: 0.75) : AppColors.textSecondary; + final iconColor = isPrimary ? Colors.white : AppColors.accent; + final iconBg = isPrimary + ? Colors.white.withValues(alpha: 0.15) + : AppColors.accent.withValues(alpha: 0.1); + + return GestureDetector( + onTap: onTap, + child: AnimatedOpacity( + opacity: onTap == null ? 0.5 : 1.0, + duration: const Duration(milliseconds: 150), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(14), + border: isPrimary ? null : Border.all(color: border, width: 0.5), + ), + child: Row(children: [ + Container( + width: 40, height: 40, + decoration: BoxDecoration(color: iconBg, borderRadius: BorderRadius.circular(10)), + child: loading + ? Padding( + padding: const EdgeInsets.all(10), + child: CircularProgressIndicator( + strokeWidth: 2, + color: iconColor, + ), + ) + : Icon(icon, size: 20, color: iconColor), ), - ], + const SizedBox(width: 14), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(label, style: GoogleFonts.dmSans(fontSize: 14, fontWeight: FontWeight.w600, color: labelColor)), + const SizedBox(height: 2), + Text(sublabel, style: GoogleFonts.dmSans(fontSize: 12, color: subColor)), + ])), + Icon(Icons.chevron_right_rounded, size: 18, + color: isPrimary ? Colors.white.withValues(alpha: 0.7) : AppColors.textTertiary), + ]), ), ), ); diff --git a/lib/features/finance/modules/budget/presentation/sheets/transaction_detail_sheet.dart b/lib/features/finance/modules/budget/presentation/sheets/transaction_detail_sheet.dart new file mode 100644 index 0000000..9fd5705 --- /dev/null +++ b/lib/features/finance/modules/budget/presentation/sheets/transaction_detail_sheet.dart @@ -0,0 +1,833 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:intl/intl.dart'; +import 'package:keep_track/core/di/service_locator.dart'; +import 'package:keep_track/core/settings/utils/currency_formatter.dart'; +import 'package:keep_track/core/state/stream_state.dart'; +import 'package:keep_track/core/theme/app_theme.dart'; +import 'package:keep_track/features/finance/modules/debt/domain/entities/debt.dart'; +import 'package:keep_track/features/finance/modules/goal/domain/entities/goal.dart'; +import 'package:keep_track/features/finance/modules/finance_category/domain/entities/finance_category.dart'; +import 'package:keep_track/features/finance/modules/subscriptions/domain/entities/subscription.dart'; +import 'package:keep_track/features/finance/modules/transaction/domain/entities/transaction.dart'; +import 'package:keep_track/features/finance/presentation/state/budget_profile_controller.dart'; +import 'package:keep_track/features/finance/presentation/state/debt_controller.dart'; +import 'package:keep_track/features/finance/presentation/state/finance_category_controller.dart'; +import 'package:keep_track/features/finance/presentation/state/goal_controller.dart'; +import 'package:keep_track/features/finance/presentation/state/savings_controller.dart'; +import 'package:keep_track/features/finance/presentation/state/subscription_controller.dart'; +import 'package:keep_track/features/finance/presentation/state/transaction_controller.dart'; +import 'package:keep_track/features/finance/presentation/screens/transactions/create_transaction_sheet.dart' + show TransactionCategoryPicker; + +class TransactionDetailSheet extends StatefulWidget { + final Transaction transaction; + + const TransactionDetailSheet({super.key, required this.transaction}); + + static Future show(BuildContext context, {required Transaction transaction}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => TransactionDetailSheet(transaction: transaction), + ); + } + + @override + State createState() => _TransactionDetailSheetState(); +} + +class _TransactionDetailSheetState extends State { + bool _editMode = false; + bool _loading = false; + late final TextEditingController _descCtrl; + late DateTime _editDate; + + // Edit-mode category & profile state + FinanceCategory? _editCategory; + String? _editProfileId; + String? _editProfileName; + String? _editProfileBaseName; + bool _editProfileIsMonthly = false; + + // Edit-mode entity link state + String? _editEntityType; // "subscription" | "debt_payment" | "lending" | "goal" + String? _editSubscriptionId; + String? _editDebtId; + String? _editGoalId; + String? _editEntityLabel; + + @override + void initState() { + super.initState(); + _descCtrl = TextEditingController(text: widget.transaction.description ?? ''); + _editDate = widget.transaction.date; + + // Pre-populate category from current controller state + final cats = locator.get().data ?? []; + _editCategory = cats.where((c) => c.id == widget.transaction.financeCategoryId).firstOrNull; + + // Pre-populate profile + final profiles = locator.get().data ?? []; + final profile = profiles.where((p) => p.id == widget.transaction.budgetProfileId).firstOrNull; + if (profile != null) { + _editProfileId = profile.id; + _editProfileBaseName = profile.name; + _editProfileIsMonthly = profile.isMonthly; + _editProfileName = profile.isMonthly + ? '${profile.name} · ${DateFormat('MMM yyyy').format(_editDate)}' + : profile.name; + } + + // Pre-populate entity link + final t = widget.transaction; + if (t.subscriptionId != null) { + _editEntityType = 'subscription'; + _editSubscriptionId = t.subscriptionId; + final subs = locator.get().data ?? []; + _editEntityLabel = subs.where((s) => s.id == t.subscriptionId).firstOrNull?.name; + } else if (t.debtId != null) { + _editDebtId = t.debtId; + final debts = locator.get().data ?? []; + final debt = debts.where((d) => d.id == t.debtId).firstOrNull; + if (debt != null) { + _editEntityType = debt.type == DebtType.lending ? 'lending' : 'debt_payment'; + _editEntityLabel = debt.personName; + } + } else if (t.goalId != null) { + _editEntityType = 'goal'; + _editGoalId = t.goalId; + final goals = locator.get().data ?? []; + _editEntityLabel = goals.where((g) => g.id == t.goalId).firstOrNull?.name; + } + } + + @override + void dispose() { + _descCtrl.dispose(); + super.dispose(); + } + + String? _linkedLabel() { + final t = widget.transaction; + if (t.goalId != null) { + final goals = locator.get().data ?? []; + final goal = goals.where((g) => g.id == t.goalId).firstOrNull; + return 'Goal: ${goal?.name ?? '—'}'; + } + if (t.debtId != null) { + final debts = locator.get().data ?? []; + final debt = debts.where((d) => d.id == t.debtId).firstOrNull; + if (debt != null) { + return '${debt.type == DebtType.lending ? 'Receivable' : 'Debt'}: ${debt.personName}'; + } + return 'Debt'; + } + if (t.subscriptionId != null) { + final subs = locator.get().data ?? []; + final sub = subs.where((s) => s.id == t.subscriptionId).firstOrNull; + return 'Subscription: ${sub?.name ?? '—'}'; + } + if (t.savingsId != null) { + final buckets = locator.get().data ?? []; + final bucket = buckets.where((b) => b.id == t.savingsId).firstOrNull; + return 'Savings: ${bucket?.name ?? '—'}'; + } + return null; + } + + void _showCategoryPicker(bool isDark) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => TransactionCategoryPicker( + type: widget.transaction.type, + selectedId: _editCategory?.id, + controller: locator.get(), + isDark: isDark, + onSelect: (cat) { + setState(() => _editCategory = cat); + Navigator.pop(context); + }, + ), + ); + } + + void _showProfilePicker(bool isDark) { + final profiles = locator.get().data ?? []; + final bg = isDark ? const Color(0xFF2C2C2A) : Colors.white; + final textPrimary = isDark ? AppColors.primaryForeground : AppColors.textPrimary; + final divColor = isDark ? AppColors.border.withValues(alpha: 0.2) : AppColors.border.withValues(alpha: 0.5); + + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (_) => Container( + decoration: BoxDecoration(color: bg, borderRadius: const BorderRadius.vertical(top: Radius.circular(20))), + child: SafeArea(top: false, child: Column(mainAxisSize: MainAxisSize.min, children: [ + const SizedBox(height: 10), + Container(width: 36, height: 4, + decoration: BoxDecoration(color: AppColors.textTertiary.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2))), + Padding( + padding: const EdgeInsets.fromLTRB(20, 14, 16, 10), + child: Row(children: [ + Expanded(child: Text('Select Budget', style: GoogleFonts.dmSans(fontSize: 16, fontWeight: FontWeight.w700, color: textPrimary))), + GestureDetector( + onTap: () => Navigator.pop(context), + child: Padding(padding: const EdgeInsets.all(6), child: Icon(Icons.close_rounded, size: 18, color: AppColors.textSecondary)), + ), + ]), + ), + Divider(height: 1, color: divColor), + // None option + ListTile( + title: Text('None', style: GoogleFonts.dmSans(fontSize: 13, color: AppColors.textSecondary)), + trailing: _editProfileId == null ? const Icon(Icons.check_rounded, size: 16, color: AppColors.accent) : null, + onTap: () { + setState(() { _editProfileId = null; _editProfileName = null; _editProfileBaseName = null; _editProfileIsMonthly = false; }); + Navigator.pop(context); + }, + ), + Divider(height: 1, color: divColor), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 280), + child: ListView.builder( + shrinkWrap: true, + itemCount: profiles.length, + itemBuilder: (_, i) { + final p = profiles[i]; + final sel = _editProfileId == p.id; + final displayName = p.isMonthly + ? '${p.name} · ${DateFormat('MMM yyyy').format(_editDate)}' + : p.name; + return ListTile( + title: Text(displayName, style: GoogleFonts.dmSans( + fontSize: 13, fontWeight: sel ? FontWeight.w600 : FontWeight.w400, color: textPrimary)), + subtitle: p.isMonthly + ? Text('Monthly', style: GoogleFonts.dmSans(fontSize: 11, color: AppColors.textSecondary)) + : null, + trailing: sel ? const Icon(Icons.check_rounded, size: 16, color: AppColors.accent) : null, + onTap: () { + setState(() { + _editProfileId = p.id; + _editProfileBaseName = p.name; + _editProfileIsMonthly = p.isMonthly; + _editProfileName = displayName; + }); + Navigator.pop(context); + }, + ); + }, + ), + ), + const SizedBox(height: 8), + ])), + ), + ); + } + + Future _applyEntitySideEffects({ + required String? newSubscriptionId, + required String? newDebtId, + required String? newGoalId, + required String? entityType, + required TransactionType txType, + required double amount, + }) async { + final origSub = widget.transaction.subscriptionId; + final origDebt = widget.transaction.debtId; + final origGoal = widget.transaction.goalId; + + // Subscription: only if newly linked (not already linked to the same one) + if (newSubscriptionId != null && newSubscriptionId != origSub) { + await locator.get().pay(newSubscriptionId); + } + + // Debt / receivable: only if newly linked + if (newDebtId != null && newDebtId != origDebt) { + final isPayment = entityType == 'debt_payment' || + (entityType == 'lending' && txType == TransactionType.income); + if (isPayment) { + final debtCtrl = locator.get(); + final cached = (debtCtrl.data ?? []).where((d) => d.id == newDebtId).firstOrNull; + if (cached == null) await debtCtrl.loadDebts(); + final debt = (debtCtrl.data ?? []).where((d) => d.id == newDebtId).firstOrNull; + if (debt != null) { + final newRemaining = (debt.remainingAmount - amount).clamp(0.0, double.infinity); + if (newRemaining <= 0) { + await debtCtrl.updateDebt(debt.copyWith(remainingAmount: 0, status: DebtStatus.settled)); + } else { + await debtCtrl.updateDebtPayment(newDebtId, newRemaining); + } + } + } + } + + // Goal: only if newly linked + if (newGoalId != null && newGoalId != origGoal) { + final goalCtrl = locator.get(); + await goalCtrl.contributeToGoal(newGoalId, amount); + final savingsCtrl = locator.get(); + if (savingsCtrl.data == null) await savingsCtrl.loadSavings(); + final goal = (goalCtrl.data ?? []).where((g) => g.id == newGoalId).firstOrNull; + if (goal?.savingsBucketId != null) { + final bucket = (savingsCtrl.data ?? []).where((b) => b.id == goal!.savingsBucketId).firstOrNull; + if (bucket != null) { + await savingsCtrl.updateSavingsBucket(bucket.copyWith(balance: bucket.balance + amount)); + } + } + } + } + + Future _save() async { + setState(() => _loading = true); + try { + final t = widget.transaction; + await locator.get().updateTransaction( + Transaction( + id: t.id, + financeCategoryId: _editCategory?.id ?? t.financeCategoryId, + amount: t.amount, + type: t.type, + description: _descCtrl.text.trim().isEmpty ? null : _descCtrl.text.trim(), + date: _editDate, + notes: t.notes, + fee: t.fee, + feeDescription: t.feeDescription, + budgetId: t.budgetId, + budgetProfileId: _editProfileId, + subscriptionId: _editSubscriptionId, + debtId: _editDebtId, + goalId: _editGoalId, + plannedPaymentId: t.plannedPaymentId, + savingsId: t.savingsId, + userId: t.userId, + createdAt: t.createdAt, + ), + ); + // Apply entity side-effects for newly linked entities + await _applyEntitySideEffects( + newSubscriptionId: _editSubscriptionId, + newDebtId: _editDebtId, + newGoalId: _editGoalId, + entityType: _editEntityType, + txType: widget.transaction.type, + amount: widget.transaction.amount, + ); + + if (mounted) setState(() => _editMode = false); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(e.toString().replaceFirst('Exception: ', '')), + backgroundColor: AppColors.error, + )); + } + } finally { + if (mounted) setState(() => _loading = false); + } + } + + Future _delete() async { + final confirmed = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Delete transaction?'), + content: const Text('Linked effects (goal progress, debt balance, subscription status, savings) will be reversed.'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')), + TextButton( + onPressed: () => Navigator.pop(context, true), + style: TextButton.styleFrom(foregroundColor: AppColors.error), + child: const Text('Delete'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + + final nav = Navigator.of(context); + final messenger = ScaffoldMessenger.of(context); + setState(() => _loading = true); + try { + final t = widget.transaction; + + if (t.goalId != null) { + await locator.get().withdrawFromGoal(t.goalId!, t.amount); + } + if (t.debtId != null) { + final debtCtrl = locator.get(); + final debt = (debtCtrl.data ?? []).where((d) => d.id == t.debtId).firstOrNull; + if (debt != null) { + final newRemaining = debt.remainingAmount + t.amount; + await debtCtrl.updateDebt(debt.copyWith( + remainingAmount: newRemaining, + status: newRemaining > 0 ? DebtStatus.active : debt.status, + )); + } + } + if (t.subscriptionId != null) { + final subCtrl = locator.get(); + final sub = (subCtrl.data ?? []).where((s) => s.id == t.subscriptionId).firstOrNull; + if (sub != null) { + await subCtrl.updateSubscription(Subscription( + id: sub.id, userId: sub.userId, name: sub.name, provider: sub.provider, + amount: sub.amount, billingCycle: sub.billingCycle, status: sub.status, + nextBillingDate: sub.nextBillingDate, lastBilledDate: null, + budgetCategoryId: sub.budgetCategoryId, notes: sub.notes, + colorHex: sub.colorHex, iconCodePoint: sub.iconCodePoint, + budgetProfileId: sub.budgetProfileId, + )); + } + } + if (t.savingsId != null) { + final savingsCtrl = locator.get(); + final bucket = (savingsCtrl.data ?? []).where((b) => b.id == t.savingsId).firstOrNull; + if (bucket != null) { + await savingsCtrl.updateSavingsBucket( + bucket.copyWith(balance: (bucket.balance - t.amount).clamp(0.0, double.infinity)), + ); + } + } + + await locator.get().deleteTransaction(t.id!); + nav.pop(); + } catch (e) { + if (mounted) { + messenger.showSnackBar(SnackBar( + content: Text(e.toString().replaceFirst('Exception: ', '')), + backgroundColor: AppColors.error, + )); + setState(() => _loading = false); + } + } + } + + void _showEntityTypePicker(bool isDark) { + final bg = isDark ? const Color(0xFF2C2C2A) : Colors.white; + final textPrimary = isDark ? AppColors.primaryForeground : AppColors.textPrimary; + final divColor = isDark ? AppColors.border.withValues(alpha: 0.2) : AppColors.border.withValues(alpha: 0.4); + + final types = [ + (icon: Icons.autorenew_rounded, label: 'Subscription', sub: 'Recurring service payment', type: 'subscription', color: AppColors.warning), + (icon: Icons.arrow_upward_rounded, label: 'Debt', sub: 'Money you owe', type: 'debt_payment', color: AppColors.error), + (icon: Icons.arrow_downward_rounded, label: 'Receivable', sub: 'Money owed to you', type: 'lending', color: AppColors.success), + (icon: Icons.flag_outlined, label: 'Goal', sub: 'Savings contribution', type: 'goal', color: AppColors.accent), + ]; + + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (_) => Container( + decoration: BoxDecoration(color: bg, borderRadius: const BorderRadius.vertical(top: Radius.circular(20))), + child: SafeArea(top: false, child: Column(mainAxisSize: MainAxisSize.min, children: [ + const SizedBox(height: 10), + Container(width: 36, height: 4, decoration: BoxDecoration(color: AppColors.textTertiary.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2))), + Padding( + padding: const EdgeInsets.fromLTRB(20, 14, 16, 10), + child: Row(children: [ + Expanded(child: Text('Link to…', style: GoogleFonts.dmSans(fontSize: 16, fontWeight: FontWeight.w700, color: textPrimary))), + GestureDetector(onTap: () => Navigator.pop(context), child: Padding(padding: const EdgeInsets.all(6), child: Icon(Icons.close_rounded, size: 18, color: AppColors.textSecondary))), + ]), + ), + Divider(height: 1, color: divColor), + if (_editEntityLabel != null) ...[ + ListTile( + leading: Icon(Icons.link_off_rounded, size: 18, color: AppColors.error), + title: Text('Remove link', style: GoogleFonts.dmSans(fontSize: 13, fontWeight: FontWeight.w500, color: AppColors.error)), + onTap: () { + setState(() { _editEntityType = null; _editSubscriptionId = null; _editDebtId = null; _editGoalId = null; _editEntityLabel = null; }); + Navigator.pop(context); + }, + ), + Divider(height: 1, color: divColor), + ], + ...types.map((t) => ListTile( + leading: Container(width: 36, height: 36, decoration: BoxDecoration(color: t.color.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(9)), child: Icon(t.icon, size: 17, color: t.color)), + title: Text(t.label, style: GoogleFonts.dmSans(fontSize: 13, fontWeight: FontWeight.w600, color: textPrimary)), + subtitle: Text(t.sub, style: GoogleFonts.dmSans(fontSize: 11, color: AppColors.textSecondary)), + onTap: () { Navigator.pop(context); _showEntityPicker(t.type, isDark); }, + )), + const SizedBox(height: 8), + ])), + ), + ); + } + + void _showEntityPicker(String entityType, bool isDark) { + final bg = isDark ? const Color(0xFF2C2C2A) : Colors.white; + final textPrimary = isDark ? AppColors.primaryForeground : AppColors.textPrimary; + final divColor = isDark ? AppColors.border.withValues(alpha: 0.2) : AppColors.border.withValues(alpha: 0.4); + + String title; + Widget listWidget; + + Widget buildList(List items, String Function(T) label, String? Function(T) id, Color dotColor, void Function(T) onSelect) { + if (items.isEmpty) { + return Center(child: Padding(padding: const EdgeInsets.all(24), + child: Text('No items found', style: GoogleFonts.dmSans(fontSize: 13, color: AppColors.textSecondary)))); + } + return Column(mainAxisSize: MainAxisSize.min, children: items.map((e) { + final sel = id(e) == (_editSubscriptionId ?? _editDebtId ?? _editGoalId); + return ListTile( + leading: Container(width: 8, height: 8, decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle)), + title: Text(label(e), style: GoogleFonts.dmSans(fontSize: 13, fontWeight: sel ? FontWeight.w600 : FontWeight.w400, color: textPrimary)), + trailing: sel ? const Icon(Icons.check_rounded, size: 16, color: AppColors.accent) : null, + onTap: () { onSelect(e); Navigator.pop(context); }, + ); + }).toList()); + } + + switch (entityType) { + case 'subscription': + title = 'Select Subscription'; + final subs = (locator.get().data ?? []).where((s) => s.status == SubscriptionStatus.active).toList()..sort((a, b) => a.name.compareTo(b.name)); + listWidget = buildList(subs, (s) => s.name, (s) => s.id, AppColors.warning, + (s) => setState(() { _editEntityType = 'subscription'; _editSubscriptionId = s.id; _editDebtId = null; _editGoalId = null; _editEntityLabel = s.name; })); + case 'debt_payment': + title = 'Select Debt'; + final debts = (locator.get().data ?? []).where((d) => d.type == DebtType.borrowing && d.status == DebtStatus.active).toList()..sort((a, b) => a.personName.compareTo(b.personName)); + listWidget = buildList(debts, (d) => d.personName, (d) => d.id, AppColors.error, + (d) => setState(() { _editEntityType = 'debt_payment'; _editDebtId = d.id; _editSubscriptionId = null; _editGoalId = null; _editEntityLabel = d.personName; })); + case 'lending': + title = 'Select Receivable'; + final recv = (locator.get().data ?? []).where((d) => d.type == DebtType.lending && d.status == DebtStatus.active).toList()..sort((a, b) => a.personName.compareTo(b.personName)); + listWidget = buildList(recv, (d) => d.personName, (d) => d.id, AppColors.success, + (d) => setState(() { _editEntityType = 'lending'; _editDebtId = d.id; _editSubscriptionId = null; _editGoalId = null; _editEntityLabel = d.personName; })); + case 'goal': + title = 'Select Goal'; + final goals = (locator.get().data ?? []).where((g) => g.status == GoalStatus.active).toList()..sort((a, b) => a.name.compareTo(b.name)); + listWidget = buildList(goals, (g) => g.name, (g) => g.id, AppColors.accent, + (g) => setState(() { _editEntityType = 'goal'; _editGoalId = g.id; _editSubscriptionId = null; _editDebtId = null; _editEntityLabel = g.name; })); + default: + return; + } + + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (_) => Container( + decoration: BoxDecoration(color: bg, borderRadius: const BorderRadius.vertical(top: Radius.circular(20))), + child: SafeArea(top: false, child: Column(mainAxisSize: MainAxisSize.min, children: [ + const SizedBox(height: 10), + Container(width: 36, height: 4, decoration: BoxDecoration(color: AppColors.textTertiary.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2))), + Padding( + padding: const EdgeInsets.fromLTRB(20, 14, 16, 10), + child: Row(children: [ + Expanded(child: Text(title, style: GoogleFonts.dmSans(fontSize: 16, fontWeight: FontWeight.w700, color: textPrimary))), + GestureDetector(onTap: () => Navigator.pop(context), child: Padding(padding: const EdgeInsets.all(6), child: Icon(Icons.close_rounded, size: 18, color: AppColors.textSecondary))), + ]), + ), + Divider(height: 1, color: divColor), + ConstrainedBox(constraints: const BoxConstraints(maxHeight: 320), child: SingleChildScrollView(child: listWidget)), + const SizedBox(height: 8), + ])), + ), + ); + } + + Future _pickDate(bool isDark) async { + final picked = await showDatePicker( + context: context, + initialDate: _editDate, + firstDate: DateTime(2000), + lastDate: DateTime.now().add(const Duration(days: 365)), + ); + if (picked != null && mounted) { + setState(() { + _editDate = picked; + // Update monthly profile label to reflect new month + if (_editProfileIsMonthly && _editProfileBaseName != null) { + _editProfileName = '$_editProfileBaseName · ${DateFormat('MMM yyyy').format(picked)}'; + } + }); + } + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final t = widget.transaction; + final isIncome = t.type == TransactionType.income; + final color = isIncome ? AppColors.success : AppColors.error; + final bg = isDark ? const Color(0xFF2C2C2A) : Colors.white; + final borderColor = isDark ? AppColors.border.withValues(alpha: 0.2) : AppColors.border.withValues(alpha: 0.5); + final textPrimary = isDark ? AppColors.primaryForeground : AppColors.textPrimary; + final keyboardInset = MediaQuery.of(context).viewInsets.bottom; + final linked = _linkedLabel(); + + return Padding( + padding: EdgeInsets.only(bottom: keyboardInset), + child: Container( + decoration: BoxDecoration(color: bg, borderRadius: const BorderRadius.vertical(top: Radius.circular(20))), + child: SafeArea(top: false, child: Column(mainAxisSize: MainAxisSize.min, children: [ + const SizedBox(height: 10), + Container(width: 36, height: 4, decoration: BoxDecoration(color: AppColors.textTertiary.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2))), + Padding( + padding: const EdgeInsets.fromLTRB(20, 14, 16, 0), + child: Row(children: [ + if (_editMode) ...[ + GestureDetector( + onTap: () => setState(() => _editMode = false), + child: Padding(padding: const EdgeInsets.only(right: 8), child: Icon(Icons.arrow_back_ios_rounded, size: 16, color: AppColors.textSecondary)), + ), + ], + Expanded( + child: Text( + _editMode ? 'Edit Transaction' : (t.description ?? '—'), + style: GoogleFonts.dmSans(fontSize: 16, fontWeight: FontWeight.w700, color: textPrimary), + maxLines: 1, overflow: TextOverflow.ellipsis, + ), + ), + if (!_editMode) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration(color: color.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(20)), + child: Text(isIncome ? 'Income' : 'Expense', + style: GoogleFonts.dmSans(fontSize: 11, fontWeight: FontWeight.w600, color: color)), + ), + ], + GestureDetector( + onTap: () => Navigator.pop(context), + child: Padding(padding: const EdgeInsets.all(6), child: Icon(Icons.close_rounded, size: 18, color: AppColors.textSecondary)), + ), + ]), + ), + Divider(height: 20, color: borderColor), + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), + child: _editMode + ? _buildEditBody(isDark, borderColor, textPrimary) + : _buildViewBody(isDark, color, textPrimary, linked), + ), + ])), + ), + ); + } + + Widget _buildViewBody(bool isDark, Color color, Color textPrimary, String? linked) { + final t = widget.transaction; + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: isDark ? Colors.white.withValues(alpha: 0.04) : AppColors.background, + borderRadius: BorderRadius.circular(12), + ), + child: Row(children: [ + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('Amount', style: GoogleFonts.dmSans(fontSize: 11, color: AppColors.textSecondary)), + Text( + '${isIncome(t) ? '+' : '-'}${currencyFormatter.format(t.amount, decimalDigits: 2)}', + style: GoogleFonts.dmMono(fontSize: 22, fontWeight: FontWeight.w700, color: color, fontFeatures: const [FontFeature.tabularFigures()]), + ), + ])), + if (t.hasFee) + Column(crossAxisAlignment: CrossAxisAlignment.end, children: [ + Text('Fee', style: GoogleFonts.dmSans(fontSize: 11, color: AppColors.textSecondary)), + Text('+${currencyFormatter.format(t.fee, decimalDigits: 2)}', + style: GoogleFonts.dmMono(fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.textSecondary, fontFeatures: const [FontFeature.tabularFigures()])), + ]), + ]), + ), + const SizedBox(height: 14), + _InfoRow(isDark: isDark, label: 'Date', value: DateFormat('MMMM d, yyyy').format(t.date), textPrimary: textPrimary), + if (linked != null) _InfoRow(isDark: isDark, label: 'Linked to', value: linked, textPrimary: AppColors.accent), + if (t.notes != null && t.notes!.isNotEmpty) _InfoRow(isDark: isDark, label: 'Notes', value: t.notes!, textPrimary: textPrimary), + const SizedBox(height: 20), + _Button(label: 'Edit Transaction', icon: Icons.edit_outlined, + color: isDark ? AppColors.primaryForeground : AppColors.textPrimary, + outlined: true, isDark: isDark, onTap: () => setState(() => _editMode = true)), + const SizedBox(height: 8), + _Button(label: 'Delete Transaction', icon: Icons.delete_outline_rounded, + color: AppColors.error, loading: _loading, onTap: t.id != null ? _delete : null), + ]); + } + + bool isIncome(Transaction t) => t.type == TransactionType.income; + + Widget _buildEditBody(bool isDark, Color borderColor, Color textPrimary) { + final chipBg = isDark ? Colors.white.withValues(alpha: 0.06) : AppColors.background; + final chipBorder = isDark ? AppColors.border.withValues(alpha: 0.25) : AppColors.border.withValues(alpha: 0.6); + + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Description + TextField( + controller: _descCtrl, + decoration: const InputDecoration(labelText: 'Description', border: OutlineInputBorder()), + ), + const SizedBox(height: 12), + + // Date + GestureDetector( + onTap: () => _pickDate(isDark), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 15), + decoration: BoxDecoration(border: Border.all(color: borderColor), borderRadius: BorderRadius.circular(4)), + child: Row(children: [ + Expanded(child: Text(DateFormat('MMMM d, yyyy').format(_editDate), + style: GoogleFonts.dmSans(fontSize: 14, color: textPrimary))), + Icon(Icons.calendar_today_outlined, size: 16, color: AppColors.textSecondary), + ]), + ), + ), + const SizedBox(height: 12), + + // Category picker + GestureDetector( + onTap: () => _showCategoryPicker(isDark), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + decoration: BoxDecoration( + color: chipBg, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: _editCategory != null ? AppColors.accent.withValues(alpha: 0.4) : chipBorder, width: 0.5), + ), + child: Row(children: [ + Icon(Icons.category_outlined, size: 16, + color: _editCategory != null ? AppColors.accent : AppColors.textSecondary), + const SizedBox(width: 10), + Expanded(child: Text( + _editCategory?.name ?? 'Select Category', + style: GoogleFonts.dmSans(fontSize: 13, fontWeight: FontWeight.w500, + color: _editCategory != null ? textPrimary : AppColors.textSecondary), + )), + Icon(Icons.chevron_right_rounded, size: 16, color: AppColors.textTertiary), + ]), + ), + ), + const SizedBox(height: 8), + + // Budget profile picker + GestureDetector( + onTap: () => _showProfilePicker(isDark), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + decoration: BoxDecoration( + color: chipBg, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: _editProfileId != null ? AppColors.accent.withValues(alpha: 0.4) : chipBorder, width: 0.5), + ), + child: Row(children: [ + Icon(Icons.account_balance_wallet_outlined, size: 16, + color: _editProfileId != null ? AppColors.accent : AppColors.textSecondary), + const SizedBox(width: 10), + Expanded(child: Text( + _editProfileName ?? 'No Budget Profile', + style: GoogleFonts.dmSans(fontSize: 13, fontWeight: FontWeight.w500, + color: _editProfileId != null ? textPrimary : AppColors.textSecondary), + )), + Icon(Icons.chevron_right_rounded, size: 16, color: AppColors.textTertiary), + ]), + ), + ), + const SizedBox(height: 8), + + // Entity link picker + GestureDetector( + onTap: () => _showEntityTypePicker(isDark), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + decoration: BoxDecoration( + color: chipBg, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: _editEntityLabel != null ? AppColors.success.withValues(alpha: 0.4) : chipBorder, width: 0.5), + ), + child: Row(children: [ + Icon(_editEntityType != null ? _entityIcon(_editEntityType!) : Icons.link_rounded, + size: 16, color: _editEntityLabel != null ? AppColors.success : AppColors.textSecondary), + const SizedBox(width: 10), + Expanded(child: Text( + _editEntityLabel ?? 'Link Entity (optional)', + style: GoogleFonts.dmSans(fontSize: 13, fontWeight: FontWeight.w500, + color: _editEntityLabel != null ? textPrimary : AppColors.textSecondary), + )), + Icon(Icons.chevron_right_rounded, size: 16, color: AppColors.textTertiary), + ]), + ), + ), + const SizedBox(height: 20), + + _Button(label: 'Save Changes', icon: Icons.check_rounded, color: AppColors.accent, loading: _loading, onTap: _save), + const SizedBox(height: 8), + _Button(label: 'Cancel', icon: Icons.close_rounded, color: AppColors.textSecondary, + outlined: true, isDark: isDark, onTap: () => setState(() => _editMode = false)), + ]); + } + + IconData _entityIcon(String type) => switch (type) { + 'subscription' => Icons.autorenew_rounded, + 'debt_payment' => Icons.arrow_upward_rounded, + 'lending' => Icons.arrow_downward_rounded, + 'goal' => Icons.flag_outlined, + _ => Icons.link_rounded, + }; +} + +class _InfoRow extends StatelessWidget { + final bool isDark; + final String label, value; + final Color? textPrimary; + const _InfoRow({required this.isDark, required this.label, required this.value, this.textPrimary}); + + @override + Widget build(BuildContext context) { + final def = isDark ? AppColors.primaryForeground : AppColors.textPrimary; + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + SizedBox(width: 90, child: Text(label, style: GoogleFonts.dmSans(fontSize: 12, color: AppColors.textSecondary))), + Expanded(child: Text(value, style: GoogleFonts.dmSans(fontSize: 13, fontWeight: FontWeight.w500, color: textPrimary ?? def))), + ]), + ); + } +} + +class _Button extends StatelessWidget { + final String label; + final IconData icon; + final Color color; + final bool loading, outlined; + final bool? isDark; + final VoidCallback? onTap; + const _Button({required this.label, required this.icon, required this.color, this.loading = false, this.outlined = false, this.isDark, this.onTap}); + + @override + Widget build(BuildContext context) { + if (outlined) { + return SizedBox(width: double.infinity, height: 46, + child: OutlinedButton.icon( + onPressed: onTap, icon: Icon(icon, size: 15), label: Text(label), + style: OutlinedButton.styleFrom( + foregroundColor: color, side: BorderSide(color: color.withValues(alpha: 0.4)), + textStyle: GoogleFonts.dmSans(fontSize: 13, fontWeight: FontWeight.w500), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ), + ); + } + return SizedBox(width: double.infinity, height: 46, + child: ElevatedButton.icon( + onPressed: loading ? null : onTap, + icon: loading + ? const SizedBox(width: 15, height: 15, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : Icon(icon, size: 15), + label: Text(label), + style: ElevatedButton.styleFrom( + backgroundColor: onTap == null ? AppColors.textTertiary : color, + foregroundColor: Colors.white, elevation: 0, + textStyle: GoogleFonts.dmSans(fontSize: 13, fontWeight: FontWeight.w600), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ), + ); + } +} diff --git a/lib/features/finance/modules/budget/presentation/widgets/all_summary_tab.dart b/lib/features/finance/modules/budget/presentation/widgets/all_summary_tab.dart index 2f1b6d7..adad329 100644 --- a/lib/features/finance/modules/budget/presentation/widgets/all_summary_tab.dart +++ b/lib/features/finance/modules/budget/presentation/widgets/all_summary_tab.dart @@ -2,23 +2,37 @@ import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:keep_track/core/theme/app_theme.dart'; +import '../../../debt/domain/entities/debt.dart'; +import '../../../goal/domain/entities/goal.dart'; +import '../../../subscriptions/domain/entities/subscription.dart'; import '../../../transaction/domain/entities/transaction.dart'; import '../../domain/entities/budget.dart'; -import '../helpers/currency_formatter.dart'; import '../screens/budget_simple_sections.dart' show SimpleDonutInsightCard; +import '../sections/budget_overall_summary.dart'; class AllSummaryTab extends StatelessWidget { final List budgets; final List transactions; - - const AllSummaryTab({required this.budgets, required this.transactions}); + final List subscriptions; + final List debts; + final List receivables; + final List goals; + final DateTime currentMonth; + + const AllSummaryTab({ + super.key, + required this.budgets, + required this.transactions, + required this.subscriptions, + required this.debts, + required this.receivables, + required this.goals, + required this.currentMonth, + }); @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; - final textPrimary = isDark ? AppColors.primaryForeground : AppColors.textPrimary; - final cardBg = isDark ? const Color(0xFF242422) : AppColors.background; - final divColor = AppColors.border.withValues(alpha: isDark ? 0.12 : 0.3); if (budgets.isEmpty) { return Center( @@ -29,7 +43,6 @@ class AllSummaryTab extends StatelessWidget { ); } - // Build spent map from transactions final spentByCategory = {}; for (final t in transactions) { if (t.financeCategoryId != null) { @@ -38,441 +51,29 @@ class AllSummaryTab extends StatelessWidget { } } - double groupActual(Budget b) => - b.categories.fold(0.0, (s, c) => s + (spentByCategory[c.financeCategoryId] ?? 0.0)); - - final incomeBudgets = budgets.where((b) => b.budgetType == BudgetType.income).toList(); - final expenseBudgets = budgets.where((b) => b.budgetType == BudgetType.expense).toList(); - - final plannedIncome = incomeBudgets.fold(0.0, (s, b) => s + b.budgetTarget); - final actualIncome = incomeBudgets.fold(0.0, (s, b) => s + groupActual(b)); - final plannedExpenses = expenseBudgets.fold(0.0, (s, b) => s + b.budgetTarget); - final actualExpenses = expenseBudgets.fold(0.0, (s, b) => s + groupActual(b)); - final netActual = actualIncome - actualExpenses; - final netPlanned = plannedIncome - plannedExpenses; - - final incomeProgress = plannedIncome > 0 ? (actualIncome / plannedIncome).clamp(0.0, 1.0) : 0.0; - final expenseProgress = plannedExpenses > 0 ? (actualExpenses / plannedExpenses).clamp(0.0, 1.0) : 0.0; - final netColor = netActual >= 0 ? AppColors.success : AppColors.error; - return SingleChildScrollView( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.only(top: 12, bottom: 20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // ── Net Balance card ─────────────────────────────────────────── - Container( - width: double.infinity, - padding: const EdgeInsets.fromLTRB(14, 12, 14, 14), - decoration: BoxDecoration( - color: netColor.withValues(alpha: isDark ? 0.12 : 0.07), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: netColor.withValues(alpha: 0.2), width: 0.5), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'NET BALANCE', - style: GoogleFonts.dmSans( - fontSize: 9, fontWeight: FontWeight.w700, - color: netColor, letterSpacing: 0.8, - ), - ), - const SizedBox(height: 4), - Text( - formatCurrency(netActual), - style: GoogleFonts.dmMono( - fontSize: 22, fontWeight: FontWeight.w700, - color: netColor, - fontFeatures: const [FontFeature.tabularFigures()], - ), - ), - const SizedBox(height: 2), - Text( - 'Planned: ${formatCurrency(netPlanned)}', - style: GoogleFonts.dmSans(fontSize: 11, color: netColor.withValues(alpha: 0.7)), - ), - ], - ), - ), - - const SizedBox(height: 12), - - // ── Income & Expense rows ────────────────────────────────────── - Container( - decoration: BoxDecoration( - color: cardBg, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: divColor, width: 0.5), - ), - child: Column( - children: [ - _ProgressRow( - isDark: isDark, - label: 'INCOME', - actual: actualIncome, - planned: plannedIncome, - progress: incomeProgress, - color: AppColors.success, - isOver: actualIncome > plannedIncome, - ), - Divider(height: 1, color: divColor), - _ProgressRow( - isDark: isDark, - label: 'EXPENSES', - actual: actualExpenses, - planned: plannedExpenses, - progress: expenseProgress, - color: AppColors.error, - isOver: actualExpenses > plannedExpenses, - ), - ], - ), - ), - - const SizedBox(height: 12), - - // ── Budget groups breakdown ──────────────────────────────────── - if (budgets.isNotEmpty) ...[ - Text( - 'BUDGET GROUPS', - style: GoogleFonts.dmSans( - fontSize: 9, fontWeight: FontWeight.w700, - color: AppColors.textTertiary, letterSpacing: 0.8, - ), - ), - const SizedBox(height: 6), - Container( - decoration: BoxDecoration( - color: cardBg, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: divColor, width: 0.5), - ), - child: Column( - children: [ - for (int i = 0; i < budgets.length; i++) ...[ - if (i > 0) Divider(height: 1, color: divColor), - _GroupRow( - isDark: isDark, - group: budgets[i], - actual: groupActual(budgets[i]), - ), - ], - ], - ), - ), - ], - - const SizedBox(height: 12), - - // ── Category breakdown (compact stacked bar) ────────────────── - _CategoryBreakdown( - isDark: isDark, - label: 'INCOME BREAKDOWN', - labelColor: AppColors.success, - budgets: incomeBudgets, + BudgetOverallSummary( + monthBudgets: budgets, spentByCategory: spentByCategory, - palette: _incomePalette, - ), - if (incomeBudgets.isNotEmpty) const SizedBox(height: 10), - _CategoryBreakdown( + subscriptions: subscriptions, + debts: debts, + receivables: receivables, + goals: goals, + transactions: transactions, + currentMonth: currentMonth, isDark: isDark, - label: 'EXPENSE BREAKDOWN', - labelColor: AppColors.error, - budgets: expenseBudgets, - spentByCategory: spentByCategory, - palette: _expensePalette, ), - - const SizedBox(height: 20), - - // ── Insights ring cards (from simple view) ───────────────────── SimpleDonutInsightCard( isDark: isDark, budgets: budgets, spentByCategory: spentByCategory, ), - - const SizedBox(height: 8), ], ), ); } - - static const _incomePalette = [ - Color(0xFF12B886), Color(0xFF2F9E44), Color(0xFF087F5B), - Color(0xFF40C057), Color(0xFF63E6BE), Color(0xFF20C997), - ]; - static const _expensePalette = [ - Color(0xFF4C6EF5), Color(0xFFF76707), Color(0xFFE64980), - Color(0xFF7950F2), Color(0xFF1C7ED6), Color(0xFFE67700), - Color(0xFFAE3EC9), Color(0xFF0CA678), Color(0xFFD6336C), - ]; -} - -// ── Progress row (income / expenses) ───────────────────────────────────────── - -class _ProgressRow extends StatelessWidget { - final bool isDark; - final String label; - final double actual, planned, progress; - final Color color; - final bool isOver; - - const _ProgressRow({ - required this.isDark, required this.label, required this.actual, - required this.planned, required this.progress, required this.color, - required this.isOver, - }); - - @override - Widget build(BuildContext context) { - final textPrimary = isDark ? AppColors.primaryForeground : AppColors.textPrimary; - final barColor = isOver ? (label == 'INCOME' ? AppColors.success : AppColors.error) : color; - - return Padding( - padding: const EdgeInsets.fromLTRB(14, 11, 14, 11), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - label, - style: GoogleFonts.dmSans( - fontSize: 9, fontWeight: FontWeight.w700, - color: color, letterSpacing: 0.8, - ), - ), - const Spacer(), - Text( - formatCurrency(actual), - style: GoogleFonts.dmMono( - fontSize: 12, fontWeight: FontWeight.w700, color: textPrimary, - fontFeatures: const [FontFeature.tabularFigures()], - ), - ), - Text( - ' / ${formatCurrency(planned)}', - style: GoogleFonts.dmMono( - fontSize: 11, color: AppColors.textTertiary, - fontFeatures: const [FontFeature.tabularFigures()], - ), - ), - ], - ), - const SizedBox(height: 6), - TweenAnimationBuilder( - tween: Tween(begin: 0, end: progress), - duration: const Duration(milliseconds: 700), - curve: Curves.easeOutCubic, - builder: (_, v, __) => ClipRRect( - borderRadius: BorderRadius.circular(3), - child: LinearProgressIndicator( - value: v, - minHeight: 5, - backgroundColor: AppColors.textTertiary.withValues(alpha: 0.12), - valueColor: AlwaysStoppedAnimation(barColor), - ), - ), - ), - const SizedBox(height: 3), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - planned > 0 ? '${(progress * 100).toStringAsFixed(0)}%' : '—', - style: GoogleFonts.dmSans(fontSize: 10, color: AppColors.textTertiary), - ), - if (isOver) - Text( - label == 'INCOME' ? '▲ over target' : '▲ over budget', - style: GoogleFonts.dmSans( - fontSize: 10, fontWeight: FontWeight.w600, - color: label == 'INCOME' ? AppColors.success : AppColors.error, - ), - ), - ], - ), - ], - ), - ); - } -} - -// ── Individual budget group row ─────────────────────────────────────────────── - -class _GroupRow extends StatelessWidget { - final bool isDark; - final Budget group; - final double actual; - - const _GroupRow({required this.isDark, required this.group, required this.actual}); - - @override - Widget build(BuildContext context) { - final textPrimary = isDark ? AppColors.primaryForeground : AppColors.textPrimary; - final isIncome = group.budgetType == BudgetType.income; - final planned = group.budgetTarget; - final progress = planned > 0 ? (actual / planned).clamp(0.0, 1.0) : 0.0; - final isOver = actual > planned; - final color = isOver - ? (isIncome ? AppColors.success : AppColors.error) - : (isIncome ? AppColors.success : AppColors.accent); - - return Padding( - padding: const EdgeInsets.fromLTRB(14, 9, 14, 9), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - width: 6, height: 6, - margin: const EdgeInsets.only(right: 6), - decoration: BoxDecoration(color: color, shape: BoxShape.circle), - ), - Expanded( - child: Text( - group.title ?? (isIncome ? 'Income' : 'Expenses'), - style: GoogleFonts.dmSans(fontSize: 12, fontWeight: FontWeight.w500, color: textPrimary), - overflow: TextOverflow.ellipsis, - ), - ), - Text( - formatCurrency(actual), - style: GoogleFonts.dmMono( - fontSize: 11, fontWeight: FontWeight.w600, color: color, - fontFeatures: const [FontFeature.tabularFigures()], - ), - ), - Text( - ' / ${formatCurrency(planned)}', - style: GoogleFonts.dmMono( - fontSize: 10, color: AppColors.textTertiary, - fontFeatures: const [FontFeature.tabularFigures()], - ), - ), - ], - ), - const SizedBox(height: 5), - TweenAnimationBuilder( - tween: Tween(begin: 0, end: progress), - duration: const Duration(milliseconds: 600), - curve: Curves.easeOutCubic, - builder: (_, v, __) => ClipRRect( - borderRadius: BorderRadius.circular(2), - child: LinearProgressIndicator( - value: v, - minHeight: 3, - backgroundColor: AppColors.textTertiary.withValues(alpha: 0.1), - valueColor: AlwaysStoppedAnimation(color), - ), - ), - ), - ], - ), - ); - } -} - -// ── Category breakdown (compact legend, no donut) ──────────────────────────── - -class _CategoryBreakdown extends StatelessWidget { - final bool isDark; - final String label; - final Color labelColor; - final List budgets; - final Map spentByCategory; - final List palette; - - const _CategoryBreakdown({ - required this.isDark, required this.label, required this.labelColor, - required this.budgets, required this.spentByCategory, required this.palette, - }); - - @override - Widget build(BuildContext context) { - final textPrimary = isDark ? AppColors.primaryForeground : AppColors.textPrimary; - final items = budgets - .expand((b) => b.categories) - .map((c) => ( - name: c.financeCategory?.name ?? '—', - value: spentByCategory[c.financeCategoryId] ?? 0.0, - )) - .where((i) => i.value > 0) - .toList(); - - if (items.isEmpty) return const SizedBox.shrink(); - - final total = items.fold(0.0, (s, i) => s + i.value); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: GoogleFonts.dmSans( - fontSize: 9, fontWeight: FontWeight.w700, - color: labelColor, letterSpacing: 0.8, - ), - ), - const SizedBox(height: 6), - // Stacked bar - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: SizedBox( - height: 8, - child: Row( - children: [ - for (int i = 0; i < items.length; i++) - Flexible( - flex: (items[i].value / total * 1000).round(), - child: Container(color: palette[i % palette.length]), - ), - ], - ), - ), - ), - const SizedBox(height: 8), - // Legend - ...items.asMap().entries.map((e) { - final color = palette[e.key % palette.length]; - final pct = total > 0 ? (e.value.value / total * 100).toStringAsFixed(0) : '0'; - return Padding( - padding: const EdgeInsets.only(bottom: 3), - child: Row( - children: [ - Container( - width: 8, height: 8, - decoration: BoxDecoration(color: color, shape: BoxShape.circle), - ), - const SizedBox(width: 6), - Expanded( - child: Text( - e.value.name, - style: GoogleFonts.dmSans(fontSize: 11, color: textPrimary), - overflow: TextOverflow.ellipsis, - ), - ), - Text( - '$pct%', - style: GoogleFonts.dmSans(fontSize: 10, color: AppColors.textTertiary), - ), - const SizedBox(width: 8), - Text( - formatCurrency(e.value.value), - style: GoogleFonts.dmMono( - fontSize: 11, fontWeight: FontWeight.w600, color: textPrimary, - fontFeatures: const [FontFeature.tabularFigures()], - ), - ), - ], - ), - ); - }), - ], - ); - } } diff --git a/lib/features/finance/modules/budget/presentation/widgets/all_transaction_tab.dart b/lib/features/finance/modules/budget/presentation/widgets/all_transaction_tab.dart index c56c674..9e85bb4 100644 --- a/lib/features/finance/modules/budget/presentation/widgets/all_transaction_tab.dart +++ b/lib/features/finance/modules/budget/presentation/widgets/all_transaction_tab.dart @@ -5,6 +5,7 @@ import 'package:keep_track/core/settings/utils/currency_formatter.dart'; import 'package:keep_track/core/theme/app_theme.dart'; import '../../../transaction/domain/entities/transaction.dart'; +import '../sheets/transaction_detail_sheet.dart'; class AllTransactionsTab extends StatefulWidget { final List transactions; @@ -100,7 +101,10 @@ class _AllTransactionsTabState extends State { ), child: Column(children: [ if (i > 0) Divider(height: 1, thickness: 0.5, color: divColor, indent: 12, endIndent: 12), - Padding( + InkWell( + onTap: () => TransactionDetailSheet.show(context, transaction: t), + borderRadius: i == 0 ? const BorderRadius.vertical(top: Radius.circular(12)) : BorderRadius.zero, + child: Padding( padding: const EdgeInsets.fromLTRB(12, 10, 12, 10), child: Row(children: [ Container( @@ -142,8 +146,10 @@ class _AllTransactionsTabState extends State { style: GoogleFonts.dmSans(fontSize: 9, color: AppColors.textTertiary), ), ]), + const SizedBox(width: 4), + Icon(Icons.chevron_right_rounded, size: 14, color: AppColors.textTertiary), ]), - ), + )), ]), ); }), diff --git a/lib/features/finance/modules/budget/presentation/widgets/group_transaction_tab.dart b/lib/features/finance/modules/budget/presentation/widgets/group_transaction_tab.dart index 86322a2..432d168 100644 --- a/lib/features/finance/modules/budget/presentation/widgets/group_transaction_tab.dart +++ b/lib/features/finance/modules/budget/presentation/widgets/group_transaction_tab.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../../../../../../core/theme/app_theme.dart'; import '../../../transaction/domain/entities/transaction.dart'; +import '../sheets/transaction_detail_sheet.dart'; import 'transaction_mini_row.dart'; class GroupTransactionsTab extends StatelessWidget { @@ -39,7 +40,10 @@ class GroupTransactionsTab extends StatelessWidget { opacity: v, child: Transform.translate(offset: Offset(0, (1 - v) * 8), child: child), ), - child: TransactionMiniRow(transaction: transactions[i]), + child: TransactionMiniRow( + transaction: transactions[i], + onTap: () => TransactionDetailSheet.show(context, transaction: transactions[i]), + ), ), ); } diff --git a/lib/features/finance/modules/budget/presentation/widgets/side_summary_panel.dart b/lib/features/finance/modules/budget/presentation/widgets/side_summary_panel.dart index 5d9e8a8..fe82bac 100644 --- a/lib/features/finance/modules/budget/presentation/widgets/side_summary_panel.dart +++ b/lib/features/finance/modules/budget/presentation/widgets/side_summary_panel.dart @@ -4,6 +4,8 @@ import 'package:keep_track/core/theme/app_theme.dart'; import 'package:keep_track/core/ui/scoped_screen.dart'; import '../../../debt/domain/entities/debt.dart'; +import '../../../goal/domain/entities/goal.dart'; +import '../../../subscriptions/domain/entities/subscription.dart'; import '../../../transaction/domain/entities/transaction.dart'; import '../../domain/entities/budget.dart'; import '../../domain/entities/budget_category.dart'; @@ -20,6 +22,11 @@ class SideSummaryPanel extends ScopedScreen { final Debt? selectedDebt; final List allBudgets; final List allTransactions; + final List subscriptions; + final List debts; + final List receivables; + final List goals; + final DateTime currentMonth; final VoidCallback onClose; final VoidCallback onCategoryPanelClose; final VoidCallback onDebtClose; @@ -43,6 +50,11 @@ class SideSummaryPanel extends ScopedScreen { required this.selectedDebt, required this.allBudgets, required this.allTransactions, + required this.subscriptions, + required this.debts, + required this.receivables, + required this.goals, + required this.currentMonth, required this.onClose, required this.onCategoryPanelClose, required this.onDebtClose, @@ -427,7 +439,15 @@ class _SideSummaryPanelState extends ScopedScreenState controller: _tabController, children: group == null ? [ - AllSummaryTab(budgets: widget.allBudgets, transactions: widget.allTransactions), + AllSummaryTab( + budgets: widget.allBudgets, + transactions: widget.allTransactions, + subscriptions: widget.subscriptions, + debts: widget.debts, + receivables: widget.receivables, + goals: widget.goals, + currentMonth: widget.currentMonth, + ), AllTransactionsTab(transactions: widget.allTransactions), ] : [ diff --git a/lib/features/finance/modules/budget/presentation/widgets/transaction_mini_row.dart b/lib/features/finance/modules/budget/presentation/widgets/transaction_mini_row.dart index 6a4864f..0c89b13 100644 --- a/lib/features/finance/modules/budget/presentation/widgets/transaction_mini_row.dart +++ b/lib/features/finance/modules/budget/presentation/widgets/transaction_mini_row.dart @@ -7,8 +7,9 @@ import '../../../transaction/domain/entities/transaction.dart'; class TransactionMiniRow extends StatelessWidget { final Transaction transaction; + final VoidCallback? onTap; - const TransactionMiniRow({required this.transaction}); + const TransactionMiniRow({required this.transaction, this.onTap}); @override Widget build(BuildContext context) { @@ -16,7 +17,9 @@ class TransactionMiniRow extends StatelessWidget { final amtColor = isIncome ? AppColors.success : AppColors.error; final sign = isIncome ? '+' : '-'; - return Padding( + return InkWell( + onTap: onTap, + child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), child: Row( children: [ @@ -43,8 +46,12 @@ class TransactionMiniRow extends StatelessWidget { fontWeight: FontWeight.w600, ), ), + if (onTap != null) ...[ + const SizedBox(width: 4), + Icon(Icons.chevron_right_rounded, size: 14, color: AppColors.textTertiary), + ], ], ), - ); + )); } } diff --git a/lib/features/finance/modules/transaction/data/datasources/receipt_parser_service.dart b/lib/features/finance/modules/transaction/data/datasources/receipt_parser_service.dart index 66ab407..93b7f67 100644 --- a/lib/features/finance/modules/transaction/data/datasources/receipt_parser_service.dart +++ b/lib/features/finance/modules/transaction/data/datasources/receipt_parser_service.dart @@ -9,6 +9,8 @@ class ParsedTransactionItem { final String description; final DateTime date; final String categoryName; + final String? entityType; // "subscription" | "debt_payment" | "lending" | "goal" | null + final String? entityHint; // name hint to pre-filter entity picker const ParsedTransactionItem({ required this.amount, @@ -16,6 +18,8 @@ class ParsedTransactionItem { required this.description, required this.date, required this.categoryName, + this.entityType, + this.entityHint, }); factory ParsedTransactionItem.fromJson(Map json) => @@ -25,21 +29,8 @@ class ParsedTransactionItem { description: json['description'] as String, date: DateTime.tryParse(json['date'] as String? ?? '') ?? DateTime.now(), categoryName: json['categoryName'] as String, - ); - - ParsedTransactionItem copyWith({ - double? amount, - String? type, - String? description, - DateTime? date, - String? categoryName, - }) => - ParsedTransactionItem( - amount: amount ?? this.amount, - type: type ?? this.type, - description: description ?? this.description, - date: date ?? this.date, - categoryName: categoryName ?? this.categoryName, + entityType: json['entityType'] as String?, + entityHint: json['entityHint'] as String?, ); } diff --git a/lib/features/finance/presentation/screens/tabs/budget/budget_tab_screen.dart b/lib/features/finance/presentation/screens/tabs/budget/budget_tab_screen.dart index b382222..a294a5d 100644 --- a/lib/features/finance/presentation/screens/tabs/budget/budget_tab_screen.dart +++ b/lib/features/finance/presentation/screens/tabs/budget/budget_tab_screen.dart @@ -6,7 +6,7 @@ import 'package:keep_track/features/finance/modules/budget/domain/entities/budge import 'package:keep_track/features/finance/modules/budget/presentation/controllers/budget_controller.dart'; import 'package:keep_track/features/finance/modules/budget/presentation/screens/budget_month_screen.dart'; import 'package:keep_track/features/finance/modules/budget/presentation/screens/budget_simple_view.dart'; -import 'package:keep_track/features/finance/modules/budget/presentation/sheets/budget_settings_sheet.dart'; +import 'package:keep_track/features/finance/modules/budget/presentation/dialogs/budget_settings_dialog.dart'; import 'package:keep_track/features/finance/modules/budget_profile/domain/entities/budget_profile.dart'; import 'package:keep_track/features/finance/presentation/state/budget_profile_controller.dart'; import 'package:keep_track/features/finance/presentation/state/month_plan_controller.dart'; @@ -55,53 +55,20 @@ class _BudgetTabScreenState extends State { void _openSettings(List monthBudgets) { final profile = _activeProfile!; - if (profile.isMonthly) { - // Monthly profile: close this month's budgets, not the whole profile - final allClosed = monthBudgets.isNotEmpty && - monthBudgets.every((b) => b.status == BudgetStatus.closed); - BudgetSettingsSheet.show( - context, - monthLabel: profile.name, - onEditBudget: _editProfile, - onCloseBudget: monthBudgets.isNotEmpty && !allClosed - ? () => _confirmCloseMonthBudgets(profile, monthBudgets) - : null, - onDeleteBudget: () => _confirmDeleteProfile(profile), - ); - } else { - // Custom profile: close the whole profile - BudgetSettingsSheet.show( - context, - monthLabel: profile.name, - onEditBudget: _editProfile, - onCloseBudget: profile.status == BudgetProfileStatus.active - ? () => _confirmCloseProfile(profile) - : null, - onDeleteBudget: () => _confirmDeleteProfile(profile), - ); - } - } - - Future _confirmCloseMonthBudgets(BudgetProfile profile, List monthBudgets) async { - final confirmed = await showDialog( - context: context, - builder: (_) => AlertDialog( - title: const Text('Close this month\'s budget?'), - content: const Text('All active budget groups for this month will be marked as closed.'), - actions: [ - TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')), - FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Close Budget')), - ], - ), + final profileColor = profile.colorHex != null + ? Color(int.parse(profile.colorHex!.replaceFirst('#', '0xFF'))) + : AppColors.accent; + // Custom profile — no month plan concept; profile-level settings only + BudgetSettingsDialog.show( + context, + monthLabel: profile.name, + profileColor: profileColor, + onEditProfile: _editProfile, + onDeleteProfile: () => _confirmDeleteProfile(profile), ); - if (confirmed != true || !mounted) return; - for (final b in monthBudgets) { - if (b.id != null && b.status == BudgetStatus.active) { - await _budgetController.closeBudget(b.id!); - } - } } + Future _confirmCloseProfile(BudgetProfile profile) async { final confirmed = await showDialog( context: context, @@ -182,7 +149,7 @@ class _BudgetTabScreenState extends State { if (mounted) _back(); } - void _showAddProfileGroup(bool isIncome) { + void _showAddProfileGroup(bool isIncome, String monthKey) { final profile = _activeProfile!; showModalBottomSheet( context: context, @@ -191,6 +158,7 @@ class _BudgetTabScreenState extends State { builder: (_) => ProfileCreateGroupSheet( profile: profile, isIncome: isIncome, + monthKey: monthKey, budgetController: _budgetController, monthPlanController: _monthPlanController, onCreated: () => _budgetController.loadBudgets(), @@ -254,6 +222,8 @@ class _BudgetTabScreenState extends State { onAddProfileGroup: _showAddProfileGroup, onToggleView: _toggleView, onOpenSettings: _openSettings, + onEditProfile: _editProfile, + onDeleteProfile: () => _confirmDeleteProfile(_activeProfile!), ), ); } diff --git a/lib/features/finance/presentation/screens/tabs/budget/profile_create_group_sheet.dart b/lib/features/finance/presentation/screens/tabs/budget/profile_create_group_sheet.dart index a421797..fca8275 100644 --- a/lib/features/finance/presentation/screens/tabs/budget/profile_create_group_sheet.dart +++ b/lib/features/finance/presentation/screens/tabs/budget/profile_create_group_sheet.dart @@ -11,6 +11,7 @@ import 'package:keep_track/features/finance/presentation/state/month_plan_contro class ProfileCreateGroupSheet extends StatefulWidget { final BudgetProfile profile; final bool isIncome; + final String monthKey; final BudgetController budgetController; final MonthPlanController monthPlanController; final VoidCallback onCreated; @@ -19,6 +20,7 @@ class ProfileCreateGroupSheet extends StatefulWidget { super.key, required this.profile, required this.isIncome, + required this.monthKey, required this.budgetController, required this.monthPlanController, required this.onCreated, @@ -51,11 +53,9 @@ class _ProfileCreateGroupSheetState extends State { if (title.isEmpty) return; setState(() => _saving = true); try { - final now = DateTime.now(); - final month = '${now.year}-${now.month.toString().padLeft(2, '0')}'; final created = await widget.budgetController.createBudget( Budget( - month: month, + month: widget.monthKey, title: title, budgetType: _type, periodType: widget.profile.isMonthly ? BudgetPeriodType.monthly : BudgetPeriodType.oneTime, @@ -63,11 +63,16 @@ class _ProfileCreateGroupSheetState extends State { budgetProfileId: widget.profile.id, ), ); - // Register this budget in the profile's MonthPlan + // Register this budget in the correct month plan if (created.id != null) { final plans = widget.monthPlanController.data ?? []; final plan = plans.cast().firstWhere( - (p) => p?.budgetProfileId == widget.profile.id, orElse: () => null); + (p) => p?.budgetProfileId == widget.profile.id && p?.month == widget.monthKey, + orElse: () => plans.cast().firstWhere( + (p) => p?.budgetProfileId == widget.profile.id && p?.month == null, + orElse: () => null, + ), + ); if (plan?.id != null) { await widget.monthPlanController.addBudgetToPlanById(plan!.id!, created.id!); } diff --git a/lib/features/finance/presentation/screens/tabs/dashboard/dashboard_tab.dart b/lib/features/finance/presentation/screens/tabs/dashboard/dashboard_tab.dart index 0298433..7436779 100644 --- a/lib/features/finance/presentation/screens/tabs/dashboard/dashboard_tab.dart +++ b/lib/features/finance/presentation/screens/tabs/dashboard/dashboard_tab.dart @@ -25,6 +25,7 @@ import 'package:keep_track/features/finance/presentation/screens/tabs/dashboard/ import 'package:keep_track/features/finance/modules/transaction_plan/domain/entities/transaction_plan.dart'; import 'package:keep_track/features/finance/presentation/state/transaction_controller.dart'; import 'package:keep_track/features/finance/presentation/state/transaction_plan_controller.dart'; +import 'package:keep_track/features/finance/modules/budget/presentation/sheets/transaction_detail_sheet.dart'; class DashboardTab extends StatefulWidget { const DashboardTab({super.key}); @@ -244,7 +245,10 @@ class _DashboardTabState extends State { hasBudgets: profileBudgets.isNotEmpty, transactionCount: profileTxs.length, monthLabel: DateFormat('MMMM yyyy').format(DateTime.now()), - onTap: () => setState(() => _showingInsights = true), + onTap: () { + _txController.loadAllTransactions(); + setState(() => _showingInsights = true); + }, ), ), const SliverToBoxAdapter(child: SizedBox(height: 32)), @@ -327,68 +331,6 @@ class _MonthOverviewCard extends StatelessWidget { ), ], ), - const SizedBox(height: 16), - Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Total Balance', - style: GoogleFonts.dmSans( - fontSize: 11, - color: AppColors.textSecondary, - ), - ), - const SizedBox(height: 2), - TweenAnimationBuilder( - tween: Tween(begin: 0, end: totalSavings), - duration: const Duration(milliseconds: 900), - curve: Curves.easeOutCubic, - builder: (_, value, __) => Text( - currencyFormatter.format(value, decimalDigits: 0), - style: GoogleFonts.dmMono( - fontSize: 22, - fontWeight: FontWeight.w700, - color: textPrimary, - fontFeatures: const [FontFeature.tabularFigures()], - ), - ), - ), - ], - ), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - netDebt >= 0 ? 'Receivables' : 'You Owe', - style: GoogleFonts.dmSans( - fontSize: 11, - color: AppColors.textSecondary, - ), - ), - const SizedBox(height: 2), - TweenAnimationBuilder( - tween: Tween(begin: 0, end: netDebt.abs()), - duration: const Duration(milliseconds: 900), - curve: Curves.easeOutCubic, - builder: (_, value, __) => Text( - currencyFormatter.format(value, decimalDigits: 0), - style: GoogleFonts.dmMono( - fontSize: 14, - fontWeight: FontWeight.w600, - color: netDebt >= 0 ? AppColors.info : AppColors.warning, - fontFeatures: const [FontFeature.tabularFigures()], - ), - ), - ), - ], - ), - ], - ), if (hasBudgets) ...[ const SizedBox(height: 18), _ProgressRow( @@ -889,7 +831,9 @@ class _TxRow extends StatelessWidget { final dateStr = isToday ? 'Today' : isYesterday ? 'Yesterday' : DateFormat('MMM d').format(t.date); final textPrimary = isDark ? AppColors.primaryForeground : AppColors.textPrimary; - return Padding( + return InkWell( + onTap: () => TransactionDetailSheet.show(context, transaction: t), + child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 11), child: Row(children: [ Container( @@ -920,8 +864,10 @@ class _TxRow extends StatelessWidget { ])), Text('$sign${currencyFormatter.format(t.totalCost, decimalDigits: 2)}', style: GoogleFonts.dmMono(fontSize: 13, fontWeight: FontWeight.w600, color: color, fontFeatures: const [FontFeature.tabularFigures()])), + const SizedBox(width: 4), + Icon(Icons.chevron_right_rounded, size: 14, color: AppColors.textTertiary), ]), - ); + )); } } diff --git a/lib/features/finance/presentation/screens/transaction_planner_screen.dart b/lib/features/finance/presentation/screens/transaction_planner_screen.dart index 6f4c57f..19563ed 100644 --- a/lib/features/finance/presentation/screens/transaction_planner_screen.dart +++ b/lib/features/finance/presentation/screens/transaction_planner_screen.dart @@ -20,6 +20,7 @@ import 'package:keep_track/features/finance/presentation/state/finance_category_ import 'package:keep_track/features/finance/presentation/state/transaction_controller.dart'; import 'package:keep_track/features/finance/presentation/state/transaction_plan_controller.dart'; import 'package:keep_track/features/finance/modules/budget/presentation/controllers/budget_controller.dart'; +import 'package:keep_track/features/finance/modules/budget/presentation/sheets/transaction_detail_sheet.dart'; class TransactionPlannerScreen extends ScopedScreen { const TransactionPlannerScreen({super.key}); @@ -211,7 +212,7 @@ class _Feed extends StatelessWidget { const SizedBox(height: 8), Text('No upcoming plans', style: GoogleFonts.dmSans(fontSize: 13, color: AppColors.textSecondary)), const SizedBox(height: 4), - Text('Tap New Plan to schedule a future transaction', + Text('Tap + to schedule a future transaction', style: GoogleFonts.dmSans(fontSize: 11, color: AppColors.textTertiary)), ]), ), @@ -566,7 +567,10 @@ class _TxRow extends StatelessWidget { ]; final profileLabel = profileName; - return Padding( + return InkWell( + onTap: () => TransactionDetailSheet.show(context, transaction: transaction), + borderRadius: BorderRadius.circular(8), + child: Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: Row(children: [ Container( @@ -618,8 +622,10 @@ class _TxRow extends StatelessWidget { Text('fee ${currencyFormatter.format(transaction.fee, decimalDigits: 2)}', style: GoogleFonts.dmSans(fontSize: 10, color: AppColors.textTertiary)), ]), + const SizedBox(width: 4), + Icon(Icons.chevron_right_rounded, size: 14, color: AppColors.textTertiary), ]), - ); + )); } Color _catColor(CategoryType t) => switch (t) { diff --git a/lib/features/finance/presentation/screens/transactions/create_transaction_sheet.dart b/lib/features/finance/presentation/screens/transactions/create_transaction_sheet.dart index 76d2973..bd358aa 100644 --- a/lib/features/finance/presentation/screens/transactions/create_transaction_sheet.dart +++ b/lib/features/finance/presentation/screens/transactions/create_transaction_sheet.dart @@ -16,6 +16,7 @@ import 'package:keep_track/features/finance/modules/transaction/domain/entities/ import 'package:keep_track/features/finance/modules/transaction_plan/domain/entities/transaction_plan.dart'; import 'package:keep_track/features/finance/presentation/state/budget_profile_controller.dart'; import 'package:keep_track/features/finance/presentation/state/finance_category_controller.dart'; +import 'package:keep_track/features/finance/presentation/state/month_plan_controller.dart'; import 'package:keep_track/features/finance/presentation/state/transaction_controller.dart'; import 'package:keep_track/features/finance/presentation/state/transaction_plan_controller.dart'; import 'scan_expenses_sheet.dart'; @@ -23,13 +24,15 @@ import 'scan_expenses_sheet.dart'; class CreateTransactionSheet extends StatefulWidget { final VoidCallback? onCreated; final String? initialProfileId; + final String? initialMonthKey; - const CreateTransactionSheet({super.key, this.onCreated, this.initialProfileId}); + const CreateTransactionSheet({super.key, this.onCreated, this.initialProfileId, this.initialMonthKey}); static Future show( BuildContext context, { VoidCallback? onCreated, String? initialProfileId, + String? initialMonthKey, }) { return showModalBottomSheet( context: context, @@ -39,6 +42,7 @@ class CreateTransactionSheet extends StatefulWidget { builder: (_) => CreateTransactionSheet( onCreated: onCreated, initialProfileId: initialProfileId, + initialMonthKey: initialMonthKey, ), ); } @@ -53,6 +57,7 @@ class _CreateTransactionSheetState extends State { late final FinanceCategoryController _catController; late final BudgetController _budgetController; late final BudgetProfileController _profileController; + late final MonthPlanController _monthPlanController; late final AuthController _authController; TransactionType _type = TransactionType.expense; @@ -67,6 +72,7 @@ class _CreateTransactionSheetState extends State { bool _profileError = false; String? _selectedProfileId; String? _selectedProfileName; + String? _selectedPlanMonth; @override void initState() { @@ -76,6 +82,7 @@ class _CreateTransactionSheetState extends State { _catController = locator.get(); _budgetController = locator.get(); _profileController = locator.get(); + _monthPlanController = locator.get(); _authController = locator.get(); _catController.loadCategories(); @@ -88,7 +95,25 @@ class _CreateTransactionSheetState extends State { ); if (match != null) { _selectedProfileId = match.id; - _selectedProfileName = match.name; + if (match.isMonthly && widget.initialMonthKey != null) { + final plans = _monthPlanController.data ?? []; + final plan = plans.cast().firstWhere( + (p) => p.budgetProfileId == match.id && p.month == widget.initialMonthKey, + orElse: () => null, + ); + if (plan != null) { + _selectedPlanMonth = plan.month as String; + final monthDt = DateTime.tryParse('${plan.month}-01'); + final label = monthDt != null ? DateFormat('MMM yyyy').format(monthDt) : plan.month as String; + _selectedProfileName = '${match.name} – $label'; + } else { + _selectedProfileName = match.name; + _selectedPlanMonth = null; + } + } else { + _selectedProfileName = match.name; + _selectedPlanMonth = null; + } } } } @@ -197,21 +222,44 @@ class _CreateTransactionSheetState extends State { if (_selectedProfileId == null) return null; final s = _budgetController.state; if (s is! AsyncData>) return null; - final ids = s.data - .where((b) => b.budgetProfileId == _selectedProfileId) - .expand((b) => b.categories.map((c) => c.financeCategoryId)) - .toSet(); - // If profile has no budget categories yet, fall back to showing all categories + final ids = s.data.where((b) { + if (b.budgetProfileId != _selectedProfileId) return false; + if (_selectedPlanMonth != null) return b.month == _selectedPlanMonth && b.status == BudgetStatus.active; + return b.status == BudgetStatus.active; + }).expand((b) => b.categories.map((c) => c.financeCategoryId)).toSet(); return ids.isEmpty ? null : ids; } void _pickBudgetProfile() { final isDark = Theme.of(context).brightness == Brightness.dark; - final s = _profileController.state; - final profiles = s is AsyncData> ? s.data : []; + final profiles = _profileController.data ?? []; + final plans = _monthPlanController.data ?? []; final bg = isDark ? const Color(0xFF2C2C2A) : Colors.white; final fg = isDark ? AppColors.primaryForeground : AppColors.textPrimary; + // Build picker items: monthly profiles → one entry per open plan; custom → one entry per profile + final items = <({String label, String profileId, String? planMonth})>[]; + for (final p in profiles) { + if (!p.isActive) continue; + if (p.isMonthly) { + final profilePlans = plans + .where((pl) => pl.budgetProfileId == p.id && !pl.isClosed && pl.month != null) + .toList() + ..sort((a, b) => (b.month ?? '').compareTo(a.month ?? '')); + if (profilePlans.isEmpty) { + items.add((label: p.name, profileId: p.id!, planMonth: null)); + } else { + for (final pl in profilePlans) { + final monthDt = DateTime.tryParse('${pl.month!}-01'); + final monthLabel = monthDt != null ? DateFormat('MMM yyyy').format(monthDt) : pl.month!; + items.add((label: '${p.name} – $monthLabel', profileId: p.id!, planMonth: pl.month)); + } + } + } else { + items.add((label: p.name, profileId: p.id!, planMonth: null)); + } + } + showModalBottomSheet( context: context, backgroundColor: Colors.transparent, @@ -223,17 +271,25 @@ class _CreateTransactionSheetState extends State { Padding(padding: const EdgeInsets.fromLTRB(20, 14, 20, 8), child: Text('Select Budget', style: GoogleFonts.dmSans(fontSize: 15, fontWeight: FontWeight.w700, color: fg))), ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 280), + constraints: const BoxConstraints(maxHeight: 320), child: ListView.builder( shrinkWrap: true, - itemCount: profiles.length, + itemCount: items.length, itemBuilder: (_, i) { - final p = profiles[i]; - final sel = _selectedProfileId == p.id; + final item = items[i]; + final sel = _selectedProfileId == item.profileId && _selectedPlanMonth == item.planMonth; return ListTile( - title: Text(p.name, style: GoogleFonts.dmSans(fontSize: 13, fontWeight: sel ? FontWeight.w600 : FontWeight.w400, color: fg)), + title: Text(item.label, style: GoogleFonts.dmSans(fontSize: 13, fontWeight: sel ? FontWeight.w600 : FontWeight.w400, color: fg)), trailing: sel ? const Icon(Icons.check_rounded, size: 16, color: AppColors.accent) : null, - onTap: () { setState(() { _selectedProfileId = p.id; _selectedProfileName = p.name; _category = null; }); Navigator.pop(context); }, + onTap: () { + setState(() { + _selectedProfileId = item.profileId; + _selectedProfileName = item.label; + _selectedPlanMonth = item.planMonth; + _category = null; + }); + Navigator.pop(context); + }, ); }, ), diff --git a/lib/features/finance/presentation/screens/transactions/scan_expenses_sheet.dart b/lib/features/finance/presentation/screens/transactions/scan_expenses_sheet.dart index 0e0ef86..c30ac92 100644 --- a/lib/features/finance/presentation/screens/transactions/scan_expenses_sheet.dart +++ b/lib/features/finance/presentation/screens/transactions/scan_expenses_sheet.dart @@ -5,6 +5,8 @@ import 'package:google_fonts/google_fonts.dart'; import 'package:image_picker/image_picker.dart'; import 'package:intl/intl.dart'; import 'package:keep_track/core/di/service_locator.dart'; +import 'package:keep_track/core/settings/presentation/settings_controller.dart'; +import 'package:keep_track/core/settings/utils/currency_formatter.dart'; import 'package:keep_track/core/theme/app_theme.dart'; import 'package:keep_track/features/auth/presentation/state/auth_controller.dart'; import 'package:keep_track/features/finance/modules/budget/domain/entities/budget.dart'; @@ -16,6 +18,13 @@ import 'package:keep_track/features/finance/modules/transaction/data/datasources import 'package:keep_track/features/finance/modules/transaction/domain/entities/transaction.dart'; import 'package:keep_track/features/finance/presentation/state/budget_profile_controller.dart'; import 'package:keep_track/features/finance/presentation/state/finance_category_controller.dart'; +import 'package:keep_track/features/finance/modules/debt/domain/entities/debt.dart'; +import 'package:keep_track/features/finance/modules/goal/domain/entities/goal.dart'; +import 'package:keep_track/features/finance/modules/subscriptions/domain/entities/subscription.dart'; +import 'package:keep_track/features/finance/presentation/state/debt_controller.dart'; +import 'package:keep_track/features/finance/presentation/state/goal_controller.dart'; +import 'package:keep_track/features/finance/presentation/state/savings_controller.dart'; +import 'package:keep_track/features/finance/presentation/state/subscription_controller.dart'; import 'package:keep_track/features/finance/presentation/state/transaction_controller.dart'; import 'package:keep_track/core/state/stream_state.dart'; import 'package:uuid/uuid.dart'; @@ -47,6 +56,11 @@ class _ScanExpensesSheetState extends State { late final BudgetProfileController _profileController; late final BudgetController _budgetController; late final AuthController _authController; + late final SettingsController _settingsController; + late final SubscriptionController _subController; + late final DebtController _debtController; + late final GoalController _goalController; + late final SavingsController _savingsController; final ReceiptParserService _parserService = ReceiptParserService(); final _picker = ImagePicker(); final _uuid = const Uuid(); @@ -64,6 +78,11 @@ class _ScanExpensesSheetState extends State { _profileController = locator.get(); _budgetController = locator.get(); _authController = locator.get(); + _settingsController = locator.get(); + _subController = locator.get(); + _debtController = locator.get(); + _goalController = locator.get(); + _savingsController = locator.get(); } FinanceCategory? _matchCategory(String name, TransactionType type) { @@ -124,11 +143,6 @@ class _ScanExpensesSheetState extends State { defaultTargetPlatform == TargetPlatform.linux; String? get _defaultProfileId => _profileController.activeProfileId; - String? _profileNameFor(String? id) { - if (id == null) return null; - try { return _profiles.firstWhere((p) => p.id == id).name; } - catch (_) { return null; } - } Future _pickImage(ImageSource source) async { final xfile = await _picker.pickImage(source: source, imageQuality: 85); @@ -143,13 +157,22 @@ class _ScanExpensesSheetState extends State { final parsed = await _parserService.parseReceiptImage(_pickedFile!); if (!mounted) return; final pid = _defaultProfileId; - final pname = _profileNameFor(pid); final today = DateTime.now(); + final defaultProfile = pid != null + ? _profiles.cast().firstWhere((p) => p?.id == pid, orElse: () => null) + : null; + final defaultIsMonthly = defaultProfile?.isMonthly ?? false; + final defaultBaseName = defaultProfile?.name; + final defaultDisplayName = defaultProfile == null + ? null + : defaultIsMonthly + ? '${defaultProfile.name} · ${DateFormat('MMM yyyy').format(today)}' + : defaultProfile.name; setState(() { _items = parsed.map((p) { final txType = p.type == 'income' ? TransactionType.income : TransactionType.expense; final matched = _matchCategory(p.categoryName, txType); - return _EditableItem( + final item = _EditableItem( id: _uuid.v4(), amount: p.amount, type: txType, @@ -158,8 +181,15 @@ class _ScanExpensesSheetState extends State { categoryName: p.categoryName, category: matched, profileId: pid, - profileName: pname, + profileName: defaultDisplayName, + profileBaseName: defaultBaseName, + isMonthlyProfile: defaultIsMonthly, + entityType: p.entityType, + entityHint: p.entityHint, ); + // Auto-match entity from AI hint so chip starts green without manual linking + _autoLinkEntity(item, p.entityType, p.entityHint); + return item; }).toList(); _step = _ScanStep.review; }); @@ -177,9 +207,27 @@ class _ScanExpensesSheetState extends State { for (final item in toSave) { String? categoryId = item.category?.id; if (categoryId == null) { - final catType = item.type == TransactionType.income ? CategoryType.income : CategoryType.expense; + // Use proper category names for entity-linked transactions + final String categoryName; + final CategoryType catType; + if (item.subscriptionId != null) { + categoryName = 'Subscriptions'; + catType = CategoryType.expense; + } else if (item.debtId != null && item.entityType == 'debt_payment') { + categoryName = 'Debt Payment'; + catType = CategoryType.expense; + } else if (item.debtId != null && item.entityType == 'lending') { + categoryName = item.type == TransactionType.income ? 'Receivables' : 'Debt Payment'; + catType = item.type == TransactionType.income ? CategoryType.income : CategoryType.expense; + } else if (item.goalId != null) { + categoryName = 'Savings'; + catType = CategoryType.savings; + } else { + categoryName = item.categoryName.isNotEmpty ? item.categoryName : 'Other'; + catType = item.type == TransactionType.income ? CategoryType.income : CategoryType.expense; + } categoryId = await _catController.findOrCreate( - name: item.categoryName.isNotEmpty ? item.categoryName : 'Other', + name: categoryName, type: catType, userId: userId, ); @@ -191,20 +239,32 @@ class _ScanExpensesSheetState extends State { date: item.date, description: item.description.isNotEmpty ? item.description : item.categoryName, budgetProfileId: item.profileId, + subscriptionId: item.subscriptionId, + debtId: item.debtId, + goalId: item.goalId, )); + + if (item.subscriptionId != null || item.debtId != null || item.goalId != null) { + try { + await _applyEntitySideEffects(item); + } catch (_) {} + } } widget.onConfirmed?.call(); if (mounted) Navigator.pop(context); } String _friendlyError(Object e) { - final msg = e.toString(); - if (msg.contains('too large')) return msg; - if (msg.contains('400') || msg.contains('BadRequest')) return 'The image could not be read. Try a clearer photo.'; - if (msg.contains('401')) return 'Session expired. Please sign in again.'; - if (msg.contains('429')) return 'Too many requests. Please wait a moment.'; - if (msg.contains('timeout') || msg.contains('SocketException')) return 'Connection timed out. Check your internet.'; - return 'Something went wrong. Please try again.'; + final msg = e.toString().toLowerCase(); + if (msg.contains('too large')) { return 'Image is too large. Try a smaller or lower-quality photo.'; } + if (msg.contains('unsupported') || msg.contains('format') || msg.contains('invalid image')) { return 'This file type couldn\'t be read. Use a JPG or PNG image.'; } + if (msg.contains('no transaction') || msg.contains('no item') || msg.contains('nothing found')) { return 'No transactions were detected. Make sure the document contains clear expense or income entries.'; } + if (msg.contains('400') || msg.contains('badrequest') || msg.contains('unreadable')) { return 'The image couldn\'t be processed. Make sure the text is sharp and fully visible.'; } + if (msg.contains('401') || msg.contains('unauthorized')) { return 'Session expired. Please sign in again.'; } + if (msg.contains('429') || msg.contains('rate limit')) { return 'Too many requests. Wait a moment then try again.'; } + if (msg.contains('timeout') || msg.contains('socketexception') || msg.contains('network')) { return 'Connection timed out. Check your internet and try again.'; } + if (msg.contains('parse') || msg.contains('decode') || msg.contains('json')) { return 'The scan result couldn\'t be understood. Try again with a different image.'; } + return 'We couldn\'t read this document. Try a clearer photo with better lighting and make sure all text is fully visible.'; } void _showProfilePicker(_EditableItem item) { @@ -230,12 +290,22 @@ class _ScanExpensesSheetState extends State { itemBuilder: (_, i) { final p = _profiles[i]; final sel = item.profileId == p.id; + final displayMonth = DateFormat('MMM yyyy').format(item.date); + final displayName = p.isMonthly ? '${p.name} · $displayMonth' : p.name; return ListTile( - title: Text(p.name, style: GoogleFonts.dmSans(fontSize: 13, + title: Text(displayName, style: GoogleFonts.dmSans(fontSize: 13, fontWeight: sel ? FontWeight.w600 : FontWeight.w400, color: fg)), + subtitle: p.isMonthly + ? Text('Monthly', style: GoogleFonts.dmSans(fontSize: 11, color: AppColors.textSecondary)) + : null, trailing: sel ? const Icon(Icons.check_rounded, size: 16, color: AppColors.accent) : null, onTap: () { - setState(() { item.profileId = p.id; item.profileName = p.name; }); + setState(() { + item.profileId = p.id; + item.profileBaseName = p.name; + item.isMonthlyProfile = p.isMonthly; + item.profileName = displayName; + }); Navigator.pop(context); }, ); @@ -248,6 +318,324 @@ class _ScanExpensesSheetState extends State { ); } + // Try to auto-link the entity from the AI hint so the chip starts green. + void _autoLinkEntity(_EditableItem item, String? entityType, String? hint) { + if (entityType == null) return; + final h = hint?.toLowerCase() ?? ''; + + switch (entityType) { + case 'subscription': + final subs = _subController.data ?? []; + final match = subs.where((s) => + s.status == SubscriptionStatus.active && + (h.isEmpty || s.name.toLowerCase().contains(h) || (s.provider?.toLowerCase().contains(h) ?? false)) + ).firstOrNull; + if (match != null) { + item.subscriptionId = match.id; + item.entityLabel = match.name; + } + case 'debt_payment': + final debts = _debtController.data ?? []; + final match = debts.where((d) => + d.type == DebtType.borrowing && + d.status == DebtStatus.active && + (h.isEmpty || d.personName.toLowerCase().contains(h)) + ).firstOrNull; + if (match != null) { + item.debtId = match.id; + item.entityLabel = match.personName; + } + case 'lending': + final recv = _debtController.data ?? []; + final match = recv.where((d) => + d.type == DebtType.lending && + d.status == DebtStatus.active && + (h.isEmpty || d.personName.toLowerCase().contains(h)) + ).firstOrNull; + if (match != null) { + item.debtId = match.id; + item.entityLabel = match.personName; + } + case 'goal': + final goals = _goalController.data ?? []; + final match = goals.where((g) => + g.status == GoalStatus.active && + (h.isEmpty || g.name.toLowerCase().contains(h)) + ).firstOrNull; + if (match != null) { + item.goalId = match.id; + item.entityLabel = match.name; + } + } + } + + Future _applyEntitySideEffects(_EditableItem item) async { + // ── Subscription: uses Hive pay() which sets lastBilledDate + advances nextBillingDate ─ + if (item.subscriptionId != null) { + await _subController.pay(item.subscriptionId!); + } + + // ── Debt / receivable: updateDebtPayment / updateDebt reload from Hive ──── + // (payDebt only does an optimistic in-memory update which misses filtered caches) + if (item.debtId != null) { + final isPayment = item.entityType == 'debt_payment' || + (item.entityType == 'lending' && item.type == TransactionType.income); + if (isPayment) { + // Reload if null OR if the specific debt isn't in the current (possibly filtered) cache + final cached = (_debtController.data ?? []).where((d) => d.id == item.debtId).firstOrNull; + if (cached == null) await _debtController.loadDebts(); + final debt = (_debtController.data ?? []) + .where((d) => d.id == item.debtId) + .firstOrNull; + if (debt != null) { + final newRemaining = (debt.remainingAmount - item.amount).clamp(0.0, double.infinity); + if (newRemaining <= 0) { + await _debtController.updateDebt(debt.copyWith( + remainingAmount: 0, + status: DebtStatus.settled, + )); + } else { + await _debtController.updateDebtPayment(item.debtId!, newRemaining); + } + } + } + } + + // ── Goal: contributeToGoal writes to Hive + reloads; savings sync ───────── + if (item.goalId != null) { + await _goalController.contributeToGoal(item.goalId!, item.amount); + if (_savingsController.data == null) await _savingsController.loadSavings(); + final goal = (_goalController.data ?? []).where((g) => g.id == item.goalId).firstOrNull; + if (goal?.savingsBucketId != null) { + final bucket = (_savingsController.data ?? []) + .where((b) => b.id == goal!.savingsBucketId) + .firstOrNull; + if (bucket != null) { + await _savingsController.updateSavingsBucket( + bucket.copyWith(balance: bucket.balance + item.amount), + ); + } + } + } + } + + void _showEntityTypePicker(_EditableItem item) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final bg = isDark ? const Color(0xFF2C2C2A) : Colors.white; + final textPrimary = isDark ? AppColors.primaryForeground : AppColors.textPrimary; + final divColor = isDark ? AppColors.border.withValues(alpha: 0.2) : AppColors.border.withValues(alpha: 0.4); + + final types = [ + (icon: Icons.autorenew_rounded, label: 'Subscription', sub: 'Recurring service payment', type: 'subscription', color: AppColors.warning), + (icon: Icons.arrow_upward_rounded, label: 'Debt', sub: 'Money you owe to someone', type: 'debt_payment', color: AppColors.error), + (icon: Icons.arrow_downward_rounded, label: 'Receivable', sub: 'Money owed to you', type: 'lending', color: AppColors.success), + (icon: Icons.flag_outlined, label: 'Goal', sub: 'Savings contribution', type: 'goal', color: AppColors.accent), + ]; + + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (_) => Container( + decoration: BoxDecoration(color: bg, borderRadius: const BorderRadius.vertical(top: Radius.circular(20))), + child: SafeArea(top: false, child: Column(mainAxisSize: MainAxisSize.min, children: [ + const SizedBox(height: 10), + Container(width: 36, height: 4, + decoration: BoxDecoration(color: AppColors.textTertiary.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2))), + Padding( + padding: const EdgeInsets.fromLTRB(20, 14, 16, 10), + child: Text('Link to…', style: GoogleFonts.dmSans(fontSize: 16, fontWeight: FontWeight.w700, color: textPrimary)), + ), + Divider(height: 1, color: divColor), + ...types.map((t) => ListTile( + leading: Container( + width: 36, height: 36, + decoration: BoxDecoration(color: t.color.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(9)), + child: Icon(t.icon, size: 17, color: t.color), + ), + title: Text(t.label, style: GoogleFonts.dmSans(fontSize: 13, fontWeight: FontWeight.w600, color: textPrimary)), + subtitle: Text(t.sub, style: GoogleFonts.dmSans(fontSize: 11, color: AppColors.textSecondary)), + onTap: () { + Navigator.pop(context); + setState(() { item.entityType = t.type; item.entityHint = null; }); + _showEntityPicker(item); + }, + )), + const SizedBox(height: 8), + ])), + ), + ); + } + + void _showEntityPicker(_EditableItem item) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final bg = isDark ? const Color(0xFF2C2C2A) : Colors.white; + final textPrimary = isDark ? AppColors.primaryForeground : AppColors.textPrimary; + final divColor = isDark ? AppColors.border.withValues(alpha: 0.2) : AppColors.border.withValues(alpha: 0.4); + final hint = item.entityHint?.toLowerCase() ?? ''; + + Widget buildList( + List items, + String Function(T) label, + String Function(T) id, + String? Function(T) subtitle, + Color Function(T) color, + void Function(T) onSelect, + ) { + final filtered = hint.isEmpty + ? items + : items.where((e) => label(e).toLowerCase().contains(hint)).toList(); + final display = filtered.isEmpty ? items : filtered; + if (display.isEmpty) { + return Center(child: Padding( + padding: const EdgeInsets.all(24), + child: Text('No items found', style: GoogleFonts.dmSans(fontSize: 13, color: AppColors.textSecondary)), + )); + } + return ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: display.length, + separatorBuilder: (_, __) => Divider(height: 1, color: divColor), + itemBuilder: (_, i) { + final e = display[i]; + final sel = id(e) == (item.subscriptionId ?? item.debtId ?? item.goalId); + final sub = subtitle(e); + return ListTile( + leading: Container( + width: 8, height: 8, + decoration: BoxDecoration(color: color(e), shape: BoxShape.circle), + ), + title: Text(label(e), style: GoogleFonts.dmSans(fontSize: 13, + fontWeight: sel ? FontWeight.w600 : FontWeight.w400, color: textPrimary)), + subtitle: sub != null ? Text(sub, style: GoogleFonts.dmSans(fontSize: 11, color: AppColors.textSecondary)) : null, + trailing: sel ? const Icon(Icons.check_rounded, size: 16, color: AppColors.accent) : null, + onTap: () { onSelect(e); Navigator.pop(context); }, + ); + }, + ); + } + + String title; + Widget listContent; + + switch (item.entityType) { + case 'subscription': + title = 'Link Subscription'; + final subs = (_subController.data ?? []) + .where((s) => s.status == SubscriptionStatus.active) + .toList() + ..sort((a, b) => a.name.compareTo(b.name)); + listContent = buildList( + subs, + (s) => s.name, + (s) => s.id ?? '', + (s) => s.provider ?? s.billingCycle.displayName, + (_) => AppColors.warning, + (s) => setState(() { item.subscriptionId = s.id; item.entityLabel = s.name; }), + ); + case 'debt_payment': + title = 'Link Debt'; + final debts = (_debtController.data ?? []) + .where((d) => d.type == DebtType.borrowing && d.status == DebtStatus.active) + .toList() + ..sort((a, b) => a.personName.compareTo(b.personName)); + listContent = buildList( + debts, + (d) => d.personName, + (d) => d.id ?? '', + (d) => '${currencyFormatter.format(d.remainingAmount, decimalDigits: 0)} remaining', + (_) => AppColors.error, + (d) => setState(() { item.debtId = d.id; item.entityLabel = d.personName; }), + ); + case 'lending': + title = 'Link Receivable'; + final receivables = (_debtController.data ?? []) + .where((d) => d.type == DebtType.lending && d.status == DebtStatus.active) + .toList() + ..sort((a, b) => a.personName.compareTo(b.personName)); + listContent = buildList( + receivables, + (d) => d.personName, + (d) => d.id ?? '', + (d) => '${currencyFormatter.format(d.remainingAmount, decimalDigits: 0)} remaining', + (_) => AppColors.success, + (d) => setState(() { item.debtId = d.id; item.entityLabel = d.personName; }), + ); + case 'goal': + title = 'Link Goal'; + final goals = (_goalController.data ?? []) + .where((g) => g.status == GoalStatus.active) + .toList() + ..sort((a, b) => a.name.compareTo(b.name)); + listContent = buildList( + goals, + (g) => g.name, + (g) => g.id ?? '', + (g) => '${currencyFormatter.format(g.currentAmount, decimalDigits: 0)} / ${currencyFormatter.format(g.targetAmount, decimalDigits: 0)}', + (g) => g.colorHex != null + ? Color(int.parse(g.colorHex!.replaceFirst('#', '0xff'))) + : AppColors.accent, + (g) => setState(() { item.goalId = g.id; item.entityLabel = g.name; }), + ); + default: + return; + } + + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (_) => Container( + decoration: BoxDecoration(color: bg, borderRadius: const BorderRadius.vertical(top: Radius.circular(20))), + child: SafeArea(top: false, child: Column(mainAxisSize: MainAxisSize.min, children: [ + const SizedBox(height: 10), + Container(width: 36, height: 4, + decoration: BoxDecoration(color: AppColors.textTertiary.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2))), + Padding( + padding: const EdgeInsets.fromLTRB(20, 14, 16, 10), + child: Row(children: [ + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(title, style: GoogleFonts.dmSans(fontSize: 16, fontWeight: FontWeight.w700, color: textPrimary)), + if (item.entityHint != null) + Text('Suggested: ${item.entityHint}', style: GoogleFonts.dmSans(fontSize: 11, color: AppColors.textSecondary)), + ])), + GestureDetector( + onTap: () => Navigator.pop(context), + child: Padding(padding: const EdgeInsets.all(6), child: Icon(Icons.close_rounded, size: 18, color: AppColors.textSecondary)), + ), + ]), + ), + Divider(height: 1, color: divColor), + // Remove link option + if (item.entityLabel != null || item.subscriptionId != null || item.debtId != null || item.goalId != null) + Column(mainAxisSize: MainAxisSize.min, children: [ + ListTile( + leading: Icon(Icons.link_off_rounded, size: 18, color: AppColors.error), + title: Text('Remove link', style: GoogleFonts.dmSans(fontSize: 13, fontWeight: FontWeight.w500, color: AppColors.error)), + onTap: () { + setState(() { + item.subscriptionId = null; + item.debtId = null; + item.goalId = null; + item.entityLabel = null; + item.entityType = null; + item.entityHint = null; + }); + Navigator.pop(context); + }, + ), + Divider(height: 1, color: divColor), + ]), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 320), + child: SingleChildScrollView(child: listContent), + ), + const SizedBox(height: 8), + ])), + ), + ); + } + @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; @@ -368,13 +756,15 @@ class _ScanExpensesSheetState extends State { child: Row(children: [ Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - _errorMessage != null ? 'Could not read image' - : _items.isEmpty ? 'Nothing found' + _errorMessage != null ? 'Couldn\'t read document' + : _items.isEmpty ? 'Nothing detected' : '${_items.length} transaction${_items.length == 1 ? '' : 's'} found', style: GoogleFonts.dmSans(fontSize: 16, fontWeight: FontWeight.w700, color: textPrimary), ), Text( - _items.isEmpty ? 'Try a clearer photo' : 'Review and edit before saving', + _errorMessage != null ? 'See tips below and try again' + : _items.isEmpty ? 'Try a clearer photo of your document' + : 'Review and edit before saving', style: GoogleFonts.dmSans(fontSize: 12, color: AppColors.textSecondary), ), ])), @@ -392,39 +782,100 @@ class _ScanExpensesSheetState extends State { ]), ), - // Error banner + // Error state if (_errorMessage != null) - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppColors.error.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: AppColors.error.withValues(alpha: 0.2), width: 0.5), + Expanded(child: SingleChildScrollView(child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppColors.error.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.error.withValues(alpha: 0.2), width: 0.5), + ), + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding( + padding: const EdgeInsets.only(top: 1), + child: Icon(Icons.error_outline_rounded, size: 17, color: AppColors.error), + ), + const SizedBox(width: 10), + Expanded(child: Text(_errorMessage!, style: GoogleFonts.dmSans(fontSize: 13, color: AppColors.error, height: 1.45))), + ]), ), - child: Row(children: [ - Icon(Icons.error_outline_rounded, size: 16, color: AppColors.error), - const SizedBox(width: 8), - Expanded(child: Text(_errorMessage!, style: GoogleFonts.dmSans(fontSize: 12, color: AppColors.error))), - ]), - ), - ), + const SizedBox(height: 20), + Text('Tips for a better scan', style: GoogleFonts.dmSans(fontSize: 12, fontWeight: FontWeight.w700, color: textPrimary, letterSpacing: 0.3)), + const SizedBox(height: 10), + ...[ + (Icons.light_mode_outlined, 'Use good lighting — avoid shadows and glare'), + (Icons.crop_free_rounded, 'Fit the full document in the frame'), + (Icons.blur_off_rounded, 'Hold your camera steady for a sharp photo'), + (Icons.text_fields_rounded, 'Make sure all text is clearly readable'), + (Icons.rotate_right_rounded, 'Try a different angle if text is skewed'), + ].map((tip) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Container( + margin: const EdgeInsets.only(top: 3), + width: 6, height: 6, + decoration: BoxDecoration(color: AppColors.textSecondary, shape: BoxShape.circle), + ), + const SizedBox(width: 10), + Expanded(child: Text(tip.$2, style: GoogleFonts.dmSans(fontSize: 13, color: AppColors.textSecondary, height: 1.4))), + ]), + )), + ]), + ))), // Empty state if (_items.isEmpty && _errorMessage == null) Expanded(child: Center(child: Padding( padding: const EdgeInsets.all(32), child: Column(mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.image_search_rounded, size: 48, color: AppColors.textTertiary), - const SizedBox(height: 12), - Text('No transactions detected', style: GoogleFonts.dmSans(fontSize: 15, fontWeight: FontWeight.w600, color: textPrimary)), + Container( + width: 56, height: 56, + decoration: BoxDecoration(color: AppColors.textTertiary.withValues(alpha: 0.1), shape: BoxShape.circle), + child: Icon(Icons.image_search_rounded, size: 28, color: AppColors.textTertiary), + ), + const SizedBox(height: 14), + Text('No transactions found', style: GoogleFonts.dmSans(fontSize: 15, fontWeight: FontWeight.w700, color: textPrimary)), const SizedBox(height: 6), - Text('The image may be blurry or not contain expense data.', - style: GoogleFonts.dmSans(fontSize: 13, color: AppColors.textSecondary), textAlign: TextAlign.center), + Text('The document may not contain recognisable expense data, or the image quality is too low. Try a clearer photo.', + style: GoogleFonts.dmSans(fontSize: 13, color: AppColors.textSecondary, height: 1.5), textAlign: TextAlign.center), ]), ))), + // Entity link legend — shown when any item has an unlinked entity chip + if (_items.isNotEmpty && _items.any((i) => i.entityType != null)) + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: isDark ? Colors.white.withValues(alpha: 0.04) : AppColors.background, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: isDark ? AppColors.border.withValues(alpha: 0.2) : AppColors.border.withValues(alpha: 0.4), width: 0.5), + ), + child: Row(children: [ + Icon(Icons.info_outline_rounded, size: 13, color: AppColors.textSecondary), + const SizedBox(width: 8), + Expanded(child: RichText(text: TextSpan(children: [ + TextSpan(text: 'Entity chip: ', style: GoogleFonts.dmSans(fontSize: 11, fontWeight: FontWeight.w600, color: AppColors.textSecondary)), + WidgetSpan(alignment: PlaceholderAlignment.middle, child: Container( + margin: const EdgeInsets.symmetric(horizontal: 3), + width: 8, height: 8, decoration: const BoxDecoration(color: AppColors.error, shape: BoxShape.circle), + )), + TextSpan(text: 'red = tap to link ', style: GoogleFonts.dmSans(fontSize: 11, color: AppColors.textSecondary)), + WidgetSpan(alignment: PlaceholderAlignment.middle, child: Container( + margin: const EdgeInsets.symmetric(horizontal: 3), + width: 8, height: 8, decoration: const BoxDecoration(color: AppColors.success, shape: BoxShape.circle), + )), + TextSpan(text: 'green = linked', style: GoogleFonts.dmSans(fontSize: 11, color: AppColors.textSecondary)), + ]))), + ]), + ), + ), + // Item list if (_items.isNotEmpty) Expanded( @@ -437,9 +888,13 @@ class _ScanExpensesSheetState extends State { key: ValueKey(_items[i].id), item: _items[i], isDark: isDark, + currencySymbol: _settingsController.data?.currency.symbol ?? '₱', onChanged: () => setState(() {}), onPickProfile: () => _showProfilePicker(_items[i]), onPickCategory: () => _showCategoryPicker(_items[i]), + onPickEntity: () => _items[i].entityType != null + ? _showEntityPicker(_items[i]) + : _showEntityTypePicker(_items[i]), ), ), ), @@ -482,8 +937,18 @@ class _EditableItem { FinanceCategory? category; String? profileId; String? profileName; + String? profileBaseName; + bool isMonthlyProfile; bool included; + // Entity linking + String? entityType; // "subscription" | "debt_payment" | "lending" | "goal" | null + String? entityHint; // AI-suggested name to pre-filter picker + String? subscriptionId; + String? debtId; + String? goalId; + String? entityLabel; // display name of the linked entity + _EditableItem({ required this.id, required this.amount, @@ -494,7 +959,11 @@ class _EditableItem { this.category, this.profileId, this.profileName, + this.profileBaseName, + this.isMonthlyProfile = false, this.included = true, + this.entityType, + this.entityHint, }); } @@ -503,17 +972,21 @@ class _EditableItem { class _ReviewItemTile extends StatefulWidget { final _EditableItem item; final bool isDark; + final String currencySymbol; final VoidCallback onChanged; final VoidCallback onPickProfile; final VoidCallback onPickCategory; + final VoidCallback? onPickEntity; const _ReviewItemTile({ super.key, required this.item, required this.isDark, + required this.currencySymbol, required this.onChanged, required this.onPickProfile, required this.onPickCategory, + this.onPickEntity, }); @override @@ -524,6 +997,25 @@ class _ReviewItemTileState extends State<_ReviewItemTile> { late final TextEditingController _descCtrl; late final TextEditingController _amountCtrl; + IconData _entityIcon(String type) => switch (type) { + 'subscription' => Icons.autorenew_rounded, + 'debt_payment' => Icons.arrow_upward_rounded, + 'lending' => Icons.arrow_downward_rounded, + 'goal' => Icons.flag_outlined, + _ => Icons.link_rounded, + }; + + String _entityPlaceholder(String type, String? hint) { + final suffix = hint != null ? ' ($hint)' : ''; + return switch (type) { + 'subscription' => 'Link Subscription$suffix', + 'debt_payment' => 'Link Debt$suffix', + 'lending' => 'Link Receivable$suffix', + 'goal' => 'Link Goal$suffix', + _ => 'Link Entity', + }; + } + @override void initState() { super.initState(); @@ -546,7 +1038,12 @@ class _ReviewItemTileState extends State<_ReviewItemTile> { lastDate: DateTime.now().add(const Duration(days: 365)), ); if (picked != null) { - setState(() => widget.item.date = picked); + setState(() { + widget.item.date = picked; + if (widget.item.isMonthlyProfile && widget.item.profileBaseName != null) { + widget.item.profileName = '${widget.item.profileBaseName!} · ${DateFormat('MMM yyyy').format(picked)}'; + } + }); widget.onChanged(); } } @@ -557,6 +1054,7 @@ class _ReviewItemTileState extends State<_ReviewItemTile> { final isDark = widget.isDark; final item = widget.item; final cardBg = isDark ? const Color(0xFF2C2C2A) : Colors.white; + final inputBg = isDark ? const Color(0xFF1E1E1C) : AppColors.background; final borderColor = item.included ? AppColors.accent.withValues(alpha: 0.35) : AppColors.border.withValues(alpha: isDark ? 0.15 : 0.4); @@ -577,7 +1075,7 @@ class _ReviewItemTileState extends State<_ReviewItemTile> { // ── Row 1: checkbox + description + type ──────────────────────── Padding( - padding: const EdgeInsets.fromLTRB(8, 10, 12, 4), + padding: const EdgeInsets.fromLTRB(8, 10, 12, 8), child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ SizedBox( width: 28, @@ -622,27 +1120,37 @@ class _ReviewItemTileState extends State<_ReviewItemTile> { ]), ), - // ── Row 2: Amount (prominent) ──────────────────────────────────── + // ── Row 2: Amount input ────────────────────────────────────────── Padding( - padding: const EdgeInsets.fromLTRB(36, 2, 12, 10), - child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: [ - Text('₱', style: GoogleFonts.dmMono(fontSize: 16, fontWeight: FontWeight.w500, color: AppColors.textSecondary)), - const SizedBox(width: 2), - SizedBox( - width: 130, - child: TextField( - controller: _amountCtrl, - enabled: item.included, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - style: GoogleFonts.dmMono( - fontSize: 28, fontWeight: FontWeight.w700, color: typeColor, - fontFeatures: const [FontFeature.tabularFigures()], + padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: inputBg, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: typeColor.withValues(alpha: 0.25), width: 0.5), + ), + child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ + Text( + widget.currencySymbol, + style: GoogleFonts.dmMono(fontSize: 16, fontWeight: FontWeight.w500, color: AppColors.textSecondary), + ), + const SizedBox(width: 6), + Expanded( + child: TextField( + controller: _amountCtrl, + enabled: item.included, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + style: GoogleFonts.dmMono( + fontSize: 22, fontWeight: FontWeight.w700, color: typeColor, + fontFeatures: const [FontFeature.tabularFigures()], + ), + decoration: const InputDecoration(isDense: true, contentPadding: EdgeInsets.zero, border: InputBorder.none), + onChanged: (v) => item.amount = double.tryParse(v) ?? item.amount, ), - decoration: const InputDecoration(isDense: true, contentPadding: EdgeInsets.zero, border: InputBorder.none), - onChanged: (v) => item.amount = double.tryParse(v) ?? item.amount, ), - ), - ]), + ]), + ), ), Divider(height: 1, color: divColor), @@ -678,6 +1186,23 @@ class _ReviewItemTileState extends State<_ReviewItemTile> { isDark: isDark, onTap: item.included ? widget.onPickCategory : null, ), + // Entity link chip — always shown + const SizedBox(width: 6), + _ChipButton( + icon: item.entityType != null ? _entityIcon(item.entityType!) : Icons.link_rounded, + label: item.entityLabel != null + ? item.entityLabel! + : item.entityType != null + ? _entityPlaceholder(item.entityType!, item.entityHint) + : 'Link Entity', + color: item.entityLabel != null + ? AppColors.success + : item.entityType != null + ? AppColors.error + : AppColors.textSecondary, + isDark: isDark, + onTap: item.included && widget.onPickEntity != null ? widget.onPickEntity : null, + ), ]), ), ]), diff --git a/lib/features/finance/presentation/state/goal_controller.dart b/lib/features/finance/presentation/state/goal_controller.dart index bef5ce3..a61b3e9 100644 --- a/lib/features/finance/presentation/state/goal_controller.dart +++ b/lib/features/finance/presentation/state/goal_controller.dart @@ -66,9 +66,19 @@ class GoalController extends StreamState>> { /// The caller is responsible for creating the transaction record. Future contributeToGoal(String goalId, double amount) async { final previous = data; - final goal = previous?.where((g) => g.id == goalId).firstOrNull; - if (goal == null) return; - final newAmount = goal.currentAmount + amount; + var goal = previous?.where((g) => g.id == goalId).firstOrNull; + + // If goal isn't in the current cache (e.g., filtered by profile), fetch directly. + if (goal == null) { + final result = await _repository.getGoals(); + result.fold( + onSuccess: (goals) { goal = goals.where((g) => g.id == goalId).firstOrNull; }, + onError: (_) {}, + ); + if (goal == null) return; + } + + final newAmount = goal!.currentAmount + amount; if (previous != null) { emit(AsyncData(previous .map((g) => g.id == goalId diff --git a/lib/features/finance/presentation/state/month_plan_controller.dart b/lib/features/finance/presentation/state/month_plan_controller.dart index 3f3831f..f280666 100644 --- a/lib/features/finance/presentation/state/month_plan_controller.dart +++ b/lib/features/finance/presentation/state/month_plan_controller.dart @@ -117,7 +117,29 @@ class MonthPlanController extends StreamState>> { return plan; } - /// Set the plan's status to closed. + Future updateMonthPlan(MonthPlan plan) async { + await executeSilent(() async { + final result = await _repository.updateMonthPlan(plan); + final updated = result.unwrap(); + final current = data ?? []; + return current.map((p) => p.id == plan.id ? updated : p).toList(); + }); + } + + /// Get or create a plan for a monthly profile scoped to a specific month. + Future getOrCreateMonthPlanForMonthlyProfile(String month, String profileId) async { + MonthPlan? plan; + await executeSilent(() async { + final result = await _repository.getOrCreateMonthPlanForMonthlyProfile(month, profileId); + plan = result.unwrap(); + final current = data ?? []; + final exists = current.any((p) => p.month == month && p.budgetProfileId == profileId); + if (!exists) return [...current, plan!]; + return current.map((p) => p.month == month && p.budgetProfileId == profileId ? plan! : p).toList(); + }); + return plan!; + } + Future closeMonthPlan(String id) async { await executeSilent(() async { final result = await _repository.closeMonthPlan(id); @@ -127,6 +149,15 @@ class MonthPlanController extends StreamState>> { }); } + Future reopenMonthPlan(String id) async { + await executeSilent(() async { + final result = await _repository.reopenMonthPlan(id); + final reopened = result.unwrap(); + final current = data ?? []; + return current.map((p) => p.id == id ? reopened : p).toList(); + }); + } + /// Clear all month plan state (called on sign-out to prevent data leaking to next user) void clear() { emit(const AsyncData([])); diff --git a/lib/features/finance/presentation/state/subscription_controller.dart b/lib/features/finance/presentation/state/subscription_controller.dart index 21390ef..18b30ae 100644 --- a/lib/features/finance/presentation/state/subscription_controller.dart +++ b/lib/features/finance/presentation/state/subscription_controller.dart @@ -33,7 +33,9 @@ class SubscriptionController extends StreamState>> Future updateSubscription(Subscription subscription) async { await _repository.updateSubscription(subscription).then((r) => r.unwrap()); - await loadSubscriptions(); + await executeSilent(() async { + return await _repository.getSubscriptions().then((r) => r.unwrap()); + }); } Future deleteSubscription(String id) async { @@ -43,7 +45,10 @@ class SubscriptionController extends StreamState>> Future pay(String id, {String? budgetId}) async { final updated = await _repository.pay(id, budgetId: budgetId).then((r) => r.unwrap()); - await loadSubscriptions(); + // Use executeSilent so we don't flash AsyncLoading — the list stays visible during refresh + await executeSilent(() async { + return await _repository.getSubscriptions().then((r) => r.unwrap()); + }); return updated; } } diff --git a/lib/features/finance/presentation/state/transaction_controller.dart b/lib/features/finance/presentation/state/transaction_controller.dart index b487145..dda7d1f 100644 --- a/lib/features/finance/presentation/state/transaction_controller.dart +++ b/lib/features/finance/presentation/state/transaction_controller.dart @@ -77,9 +77,9 @@ class TransactionController extends StreamState>> { /// Update an existing transaction Future updateTransaction(Transaction transaction) async { await executeSilent(() async { - await _repository.updateTransaction(transaction).then((r) => r.unwrap()); - await loadRecentTransactions(); - return data ?? []; + final updated = await _repository.updateTransaction(transaction).then((r) => r.unwrap()); + final current = data ?? []; + return current.map((t) => t.id == transaction.id ? updated : t).toList(); }); _cache.invalidateAll(); onMutated?.call(); @@ -89,8 +89,8 @@ class TransactionController extends StreamState>> { Future deleteTransaction(String id) async { await executeSilent(() async { await _repository.deleteTransaction(id).then((r) => r.unwrap()); - await loadRecentTransactions(); - return data ?? []; + final current = data ?? []; + return current.where((t) => t.id != id).toList(); }); _cache.invalidateAll(); onMutated?.call(); diff --git a/lib/features/module_selection/finance_module_screen.dart b/lib/features/module_selection/finance_module_screen.dart index 928d5a4..24a88dc 100644 --- a/lib/features/module_selection/finance_module_screen.dart +++ b/lib/features/module_selection/finance_module_screen.dart @@ -103,6 +103,11 @@ class _FinanceModuleScreenState extends State { String? get _activeProfileId => locator.get().activeProfileId; + String get _currentMonthKey { + final now = DateTime.now(); + return '${now.year}-${now.month.toString().padLeft(2, '0')}'; + } + // Savings tab (index 2) has no FAB @override Widget build(BuildContext context) { @@ -122,7 +127,7 @@ class _FinanceModuleScreenState extends State { Widget _buildDesktop() { return Scaffold( floatingActionButton: FloatingActionButton( - onPressed: () => CreateTransactionSheet.show(context, onCreated: _refreshTransactions, initialProfileId: _activeProfileId), + onPressed: () => CreateTransactionSheet.show(context, onCreated: _refreshTransactions, initialProfileId: _activeProfileId, initialMonthKey: _currentMonthKey), backgroundColor: AppColors.accent, foregroundColor: Colors.white, child: const Icon(Icons.add), @@ -293,7 +298,7 @@ class _FinanceModuleScreenState extends State { ), const SizedBox(width: 10), GestureDetector( - onTap: () => CreateTransactionSheet.show(context, onCreated: _refreshTransactions, initialProfileId: _activeProfileId), + onTap: () => CreateTransactionSheet.show(context, onCreated: _refreshTransactions, initialProfileId: _activeProfileId, initialMonthKey: _currentMonthKey), child: Container( width: 62, height: 62,