diff --git a/lib/core/utils/hive_bootstrap.dart b/lib/core/utils/hive_bootstrap.dart index acfd7b5..48fdd52 100644 --- a/lib/core/utils/hive_bootstrap.dart +++ b/lib/core/utils/hive_bootstrap.dart @@ -3,6 +3,7 @@ import 'package:hive_flutter/hive_flutter.dart'; import '../../features/expense/data/datasource/account_local_datasource.dart'; import '../../features/expense/data/datasource/budget_local_datasource.dart'; import '../../features/expense/data/datasource/expense_local_datasource.dart'; +import '../../features/expense/data/datasource/month_budget_local_datasource.dart'; import '../../features/expense/data/datasource/preferences_local_datasource.dart'; import '../../features/expense/data/datasource/recurring_subscription_local_datasource.dart'; import '../../features/expense/data/models/account_model.dart'; @@ -52,5 +53,8 @@ abstract final class HiveBootstrap { RecurringSubscriptionLocalDatasource.boxName, ); } + if (!Hive.isBoxOpen(MonthBudgetLocalDatasource.boxName)) { + await Hive.openBox(MonthBudgetLocalDatasource.boxName); + } } } diff --git a/lib/features/expense/data/datasource/month_budget_local_datasource.dart b/lib/features/expense/data/datasource/month_budget_local_datasource.dart new file mode 100644 index 0000000..29d9fb6 --- /dev/null +++ b/lib/features/expense/data/datasource/month_budget_local_datasource.dart @@ -0,0 +1,79 @@ +import 'package:hive/hive.dart'; + +/// Hive-backed store for **per-month** budget data using a plain [Box]. +/// +/// Key conventions: +/// * Total monthly budget → `"__total__::YYYY-MM"` +/// * Category override → `"YYYY-MM::CategoryName"` +class MonthBudgetLocalDatasource { + static const String boxName = 'month_budgets'; + static const String _kTotalPrefix = '__total__::'; + + Box get _box => Hive.box(boxName); + + // ── total budget ────────────────────────────────────────────────────────── + + double? getMonthTotalBudget(String monthKey) => + _box.get('$_kTotalPrefix$monthKey'); + + Future saveMonthTotalBudget(String monthKey, double amount) => + _box.put('$_kTotalPrefix$monthKey', amount); + + // ── per-category overrides ───────────────────────────────────────────────── + + /// Returns category budget overrides stored for [monthKey]. + /// + /// This scans all box keys, so prefer calling [loadAll] once at startup to + /// build a complete in-memory snapshot rather than calling this repeatedly. + Map getCategoryBudgetsForMonth(String monthKey) { + final prefix = '$monthKey::'; + final result = {}; + for (final rawKey in _box.keys) { + final key = rawKey as String; + if (key.startsWith(prefix)) { + final value = _box.get(key); + if (value != null) { + result[key.substring(prefix.length)] = value; + } + } + } + return result; + } + + Future saveCategoryBudgetForMonth( + String monthKey, + String category, + double amount, + ) => + _box.put('$monthKey::$category', amount); + + // ── bulk load ────────────────────────────────────────────────────────────── + + /// Returns every stored entry as two maps so the provider layer can build + /// its in-memory state without scanning the box again later. + ({ + Map totalBudgets, + Map> categoryBudgets, + }) loadAll() { + final totalBudgets = {}; + final categoryBudgets = >{}; + + for (final rawKey in _box.keys) { + final key = rawKey as String; + final value = _box.get(key); + if (value == null) continue; + + if (key.startsWith(_kTotalPrefix)) { + totalBudgets[key.substring(_kTotalPrefix.length)] = value; + } else { + final sep = key.indexOf('::'); + if (sep == -1) continue; + final monthKey = key.substring(0, sep); + final category = key.substring(sep + 2); + (categoryBudgets[monthKey] ??= {})[category] = value; + } + } + + return (totalBudgets: totalBudgets, categoryBudgets: categoryBudgets); + } +} diff --git a/lib/features/expense/presentation/provider/budget_state.dart b/lib/features/expense/presentation/provider/budget_state.dart new file mode 100644 index 0000000..7b0d0ea --- /dev/null +++ b/lib/features/expense/presentation/provider/budget_state.dart @@ -0,0 +1,226 @@ +import 'dart:developer' as dev; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../data/datasource/month_budget_local_datasource.dart'; +import '../../data/models/expense_model.dart'; +import 'budget_providers.dart'; +import 'expense_providers.dart'; + +// ── helpers ─────────────────────────────────────────────────────────────────── + +/// Normalised "YYYY-MM" string for a given month. +String monthKeyOf(DateTime dt) => + '${dt.year}-${dt.month.toString().padLeft(2, '0')}'; + +// ── selected month ──────────────────────────────────────────────────────────── + +/// The currently-viewed month (always normalised to the 1st of that month). +final selectedMonthProvider = + NotifierProvider( + SelectedMonthNotifier.new, +); + +class SelectedMonthNotifier extends Notifier { + @override + DateTime build() { + final now = DateTime.now(); + return DateTime(now.year, now.month); + } +} + +// ── datasource provider ─────────────────────────────────────────────────────── + +final monthBudgetDatasourceProvider = + Provider((ref) { + return MonthBudgetLocalDatasource(); +}); + +// ── state model ─────────────────────────────────────────────────────────────── + +class MonthBudgetState { + const MonthBudgetState({ + this.totalBudgets = const {}, + this.categoryBudgets = const {}, + }); + + /// monthKey → total spending budget for that month. + final Map totalBudgets; + + /// monthKey → { categoryName → monthly limit }. + final Map> categoryBudgets; + + double? totalForMonth(String monthKey) => totalBudgets[monthKey]; + + Map categoriesForMonth(String monthKey) => + categoryBudgets[monthKey] ?? const {}; + + MonthBudgetState copyWith({ + Map? totalBudgets, + Map>? categoryBudgets, + }) => + MonthBudgetState( + totalBudgets: totalBudgets ?? this.totalBudgets, + categoryBudgets: categoryBudgets ?? this.categoryBudgets, + ); +} + +// ── notifier ────────────────────────────────────────────────────────────────── + +class MonthBudgetNotifier extends AsyncNotifier { + MonthBudgetLocalDatasource get _ds => + ref.read(monthBudgetDatasourceProvider); + + @override + Future build() async { + try { + final raw = _ds.loadAll(); + return MonthBudgetState( + totalBudgets: Map.from(raw.totalBudgets), + categoryBudgets: { + for (final entry in raw.categoryBudgets.entries) + entry.key: Map.from(entry.value), + }, + ); + } catch (e, st) { + if (kDebugMode) { + dev.log('MonthBudgetNotifier.build failed', + error: e, stackTrace: st, name: 'MonthBudgetNotifier'); + } + return const MonthBudgetState(); + } + } + + Future saveMonthTotal(DateTime month, double amount) async { + try { + final key = monthKeyOf(month); + await _ds.saveMonthTotalBudget(key, amount); + final current = state.value ?? const MonthBudgetState(); + final updatedTotals = Map.from(current.totalBudgets) + ..[key] = amount; + state = AsyncData(current.copyWith(totalBudgets: updatedTotals)); + } catch (e, st) { + if (kDebugMode) { + dev.log('saveMonthTotal failed', + error: e, stackTrace: st, name: 'MonthBudgetNotifier'); + } + } + } + + Future copyFromPreviousMonth(DateTime targetMonth) async { + try { + final prevMonth = + DateTime(targetMonth.year, targetMonth.month - 1); + final prevKey = monthKeyOf(prevMonth); + final targetKey = monthKeyOf(targetMonth); + + final current = state.value ?? const MonthBudgetState(); + + // Copy total budget + final prevTotal = current.totalForMonth(prevKey); + + // Copy category budgets: month-specific overrides take precedence over + // global defaults; if no previous month-specific budgets exist, fall + // back to the global budgets. + final globalBudgets = + ref.read(budgetTargetsProvider).value ?? defaultBudgetTargets; + final prevCategoryOverrides = current.categoriesForMonth(prevKey); + final sourceBudgets = { + ...globalBudgets, + ...prevCategoryOverrides, + }; + + // Persist to Hive + if (prevTotal != null) { + await _ds.saveMonthTotalBudget(targetKey, prevTotal); + } + for (final entry in sourceBudgets.entries) { + await _ds.saveCategoryBudgetForMonth( + targetKey, entry.key, entry.value); + } + + // Update in-memory state + final updatedTotals = Map.from(current.totalBudgets); + if (prevTotal != null) updatedTotals[targetKey] = prevTotal; + + final updatedCategories = { + for (final e in current.categoryBudgets.entries) + e.key: Map.from(e.value), + }; + updatedCategories[targetKey] = Map.from(sourceBudgets); + + state = AsyncData(current.copyWith( + totalBudgets: updatedTotals, + categoryBudgets: updatedCategories, + )); + } catch (e, st) { + if (kDebugMode) { + dev.log('copyFromPreviousMonth failed', + error: e, stackTrace: st, name: 'MonthBudgetNotifier'); + } + } + } +} + +final monthBudgetProvider = + AsyncNotifierProvider( + MonthBudgetNotifier.new, +); + +// ── derived providers ───────────────────────────────────────────────────────── + +/// Expense + income stats scoped to the selected month. +final monthlyStatsForMonthProvider = Provider((ref) { + final expenses = + ref.watch(expenseListProvider).value ?? const []; + final month = ref.watch(selectedMonthProvider); + return ExpenseStats.fromExpenses(expenses, forMonth: month); +}); + +/// Total spending budget set for the selected month (may be null if not set). +final monthTotalBudgetProvider = Provider((ref) { + final month = ref.watch(selectedMonthProvider); + final budgetState = ref.watch(monthBudgetProvider).value; + return budgetState?.totalForMonth(monthKeyOf(month)); +}); + +/// How much remains in the selected month's total budget. +/// Returns null when no total budget has been set. +final monthRemainingBudgetProvider = Provider((ref) { + final total = ref.watch(monthTotalBudgetProvider); + if (total == null) return null; + final stats = ref.watch(monthlyStatsForMonthProvider); + return total - stats.monthTotal; +}); + +/// Effective category budgets for the selected month. +/// Month-specific overrides shadow the global per-category limits. +final effectiveMonthBudgetsProvider = Provider>((ref) { + final globalBudgets = + ref.watch(budgetTargetsProvider).value ?? defaultBudgetTargets; + final month = ref.watch(selectedMonthProvider); + final budgetState = ref.watch(monthBudgetProvider).value; + final monthOverrides = + budgetState?.categoriesForMonth(monthKeyOf(month)) ?? const {}; + return {...globalBudgets, ...monthOverrides}; +}); + +// ── controller ──────────────────────────────────────────────────────────────── + +class MonthBudgetController { + MonthBudgetController(this._ref); + + final Ref _ref; + + Future saveMonthTotal(DateTime month, double amount) => + _ref.read(monthBudgetProvider.notifier).saveMonthTotal(month, amount); + + Future copyFromPreviousMonth(DateTime targetMonth) => _ref + .read(monthBudgetProvider.notifier) + .copyFromPreviousMonth(targetMonth); +} + +final monthBudgetControllerProvider = Provider((ref) { + return MonthBudgetController(ref); +}); diff --git a/lib/features/expense/presentation/provider/expense_providers.dart b/lib/features/expense/presentation/provider/expense_providers.dart index 917cebc..76fd2af 100644 --- a/lib/features/expense/presentation/provider/expense_providers.dart +++ b/lib/features/expense/presentation/provider/expense_providers.dart @@ -297,10 +297,16 @@ class ExpenseStats { required this.incomeCategoryTotals, }); - factory ExpenseStats.fromExpenses(List expenses) { + factory ExpenseStats.fromExpenses( + List expenses, { + DateTime? forMonth, + }) { final now = DateTime.now(); - final int currentYear = now.year; - final int currentMonth = now.month; + final reference = forMonth ?? now; + final int currentYear = reference.year; + final int currentMonth = reference.month; + final bool isCurrentMonth = + reference.year == now.year && reference.month == now.month; final int currentDay = now.day; int transactionCount = 0; @@ -333,7 +339,8 @@ class ExpenseStats { ifAbsent: () => amount, ); - final bool isToday = localDate.day == currentDay; + final bool isToday = + isCurrentMonth && localDate.day == currentDay; if (isIncome) { monthIncomeTotal += amount; diff --git a/lib/features/expense/presentation/screens/add_expense_screen.dart b/lib/features/expense/presentation/screens/add_expense_screen.dart index acd5031..685ff8b 100644 --- a/lib/features/expense/presentation/screens/add_expense_screen.dart +++ b/lib/features/expense/presentation/screens/add_expense_screen.dart @@ -37,16 +37,19 @@ class AddExpenseScreen extends ConsumerStatefulWidget { final String? initialToAccountId; final TransactionType initialType; - /// When non-null the screen starts in "Pay Directly" mode. + /// When non-null the screen starts in "Pay via UPI" mode. /// - /// The save button is replaced with a **Pay** button that launches the UPI - /// deep-link. After the user returns from the UPI app the button - /// automatically reverts to the normal save/check button. + /// The save button is replaced with an **Open UPI App** button that hands + /// the user off to their preferred UPI app with only the payee VPA + /// (no pre-filled amount), avoiding the fraud-score spike that + /// externally-injected payment requests trigger in GPay / PhonePe / Paytm. + /// After the user returns from the UPI app, a "Did the payment go through?" + /// dialog is shown before the transaction is saved. final String? payUpiUri; bool get isEditing => expenseId != null; - /// Whether the screen was opened for the "Pay Directly" flow. + /// Whether the screen was opened for the "Pay via UPI" flow. bool get isPayMode => payUpiUri != null; @override @@ -67,12 +70,16 @@ class _AddExpenseScreenState extends ConsumerState late bool _hasExplicitAccountChoice; bool _isSaving = false; - /// True once the user has returned from the UPI app (pay-mode only). + /// True once the user has returned from the UPI app AND confirmed the payment. bool _paymentDone = false; /// True while the UPI launch is in progress. bool _isLaunching = false; + /// Set when the app resumes after a UPI-app handoff so the post-frame + /// callback can show the "Did the payment go through?" dialog. + bool _pendingPaymentConfirm = false; + @override void initState() { super.initState(); @@ -127,16 +134,52 @@ class _AddExpenseScreenState extends ConsumerState @override void didChangeAppLifecycleState(AppLifecycleState state) { - // When the user returns from the UPI app, switch the button to Save. + // When the user returns from the UPI app, schedule a confirmation dialog. if (widget.isPayMode && !_paymentDone && _isLaunching && state == AppLifecycleState.resumed) { setState(() { - _paymentDone = true; _isLaunching = false; + _pendingPaymentConfirm = true; }); + WidgetsBinding.instance + .addPostFrameCallback((_) => _askPaymentConfirmation()); + } + } + + Future _askPaymentConfirmation() async { + if (!mounted || !_pendingPaymentConfirm) return; + setState(() => _pendingPaymentConfirm = false); + + final confirmed = await showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => AlertDialog( + title: const Text('Payment Complete?'), + content: const Text( + 'Did the payment go through in your UPI app?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('No, Retry'), + ), + FilledButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Yes, Save'), + ), + ], + ), + ); + + if (!mounted) return; + if (confirmed == true) { + setState(() => _paymentDone = true); + _saveExpense(); } + // If "No, Retry", _paymentDone stays false and the "Pay via UPI" button + // is shown again. } @override @@ -507,8 +550,8 @@ class _AddExpenseScreenState extends ConsumerState foregroundColor: const Color(0xFFC23358), child: const Icon(Icons.backspace_outlined), ), - // Pay Directly mode: show "Pay" before payment, - // then revert to the normal save/check button. + // Pay via UPI mode: show "Open UPI App" button before + // the payment, then revert to the normal save button. if (widget.isPayMode && !_paymentDone) AddExpenseKeypadButton( onTap: canSubmit ? _launchUpiPayment : null, @@ -1088,23 +1131,78 @@ class _AddExpenseScreenState extends ConsumerState Future _launchUpiPayment() async { if (_isLaunching || widget.payUpiUri == null) return; - // Build a fresh URI with the current amount so the UPI app pre-fills it. - final amountState = evaluateAmountExpression(_amountExpression); final baseUri = Uri.parse(widget.payUpiUri!); - final updatedParams = Map.from(baseUri.queryParameters); - if (amountState.previewAmount > 0) { - updatedParams['am'] = amountState.previewAmount.toStringAsFixed(2); - } - if (_noteController.text.trim().isNotEmpty) { - updatedParams['tn'] = _noteController.text.trim(); + final params = Map.from(baseUri.queryParameters); + + // Validate that payee VPA (pa) is present — mandatory for any UPI app. + final paRaw = params['pa']; + final pa = paRaw?.trim() ?? ''; + if (pa.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Invalid QR: payee UPI ID is missing.'), + ), + ); + return; } + + // Build a "safe" launch URI with ONLY pa + pn. + // Omitting am/tn/cu avoids the fraud-score spike that most UPI apps + // apply to externally-prefilled payment requests. The user enters the + // amount themselves inside their trusted payment app. + final safeParams = {'pa': pa}; + final pn = params['pn']?.trim(); + if (pn != null && pn.isNotEmpty) safeParams['pn'] = pn; + final launchUri = Uri( scheme: baseUri.scheme, host: baseUri.host, path: baseUri.path, - queryParameters: updatedParams, + queryParameters: safeParams, ); + final payeeName = pn ?? pa; + final amountState = evaluateAmountExpression(_amountExpression); + // Build an optional hint shown in the dialog body. Leading space is + // intentional — it is appended directly to the "pay $payeeName." sentence. + final amountHint = amountState.previewAmount > 0 + ? ' Enter ₹${amountState.previewAmount.toStringAsFixed(2)} when prompted.' + : ''; + + if (!mounted) return; + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Open UPI App'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Your UPI app will open to pay $payeeName.$amountHint', + ), + const SizedBox(height: 8), + Text( + 'UPI ID: $pa', + style: const TextStyle(fontSize: 13, color: Colors.grey), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Open'), + ), + ], + ), + ); + + if (confirmed != true || !mounted) return; + setState(() => _isLaunching = true); final launched = await launchUrl( @@ -1123,8 +1221,8 @@ class _AddExpenseScreenState extends ConsumerState ), ); } - // If launched successfully, _isLaunching stays true until - // didChangeAppLifecycleState(resumed) fires when the user returns. + // If launched, _isLaunching stays true until didChangeAppLifecycleState + // fires on resume, which schedules the "Payment Complete?" dialog. } Future _saveExpense() async { diff --git a/lib/features/expense/presentation/screens/categories/budget_popup.dart b/lib/features/expense/presentation/screens/categories/budget_popup.dart new file mode 100644 index 0000000..0c7f5bd --- /dev/null +++ b/lib/features/expense/presentation/screens/categories/budget_popup.dart @@ -0,0 +1,348 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../../../../../core/theme/app_colors.dart'; +import '../../../../../core/theme/app_tokens.dart'; +import '../../../../../core/utils/context_extensions.dart'; + +/// Shows the "Set Monthly Budget" bottom sheet. +/// +/// The sheet offers two actions: +/// 1. **Enter Manually** – reveals an amount input and a Save button. +/// 2. **Copy Previous Month** – immediately copies the previous month's +/// total budget and category limits into [selectedMonth]. +Future showSetBudgetPopup( + BuildContext context, { + required DateTime selectedMonth, + required String currencySymbol, + required double? currentTotal, + required Future Function(double amount) onManual, + required Future Function() onCopyPrevious, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => _SetBudgetPopup( + selectedMonth: selectedMonth, + currencySymbol: currencySymbol, + currentTotal: currentTotal, + onManual: onManual, + onCopyPrevious: onCopyPrevious, + ), + ); +} + +// ── private widget ──────────────────────────────────────────────────────────── + +class _SetBudgetPopup extends StatefulWidget { + const _SetBudgetPopup({ + required this.selectedMonth, + required this.currencySymbol, + required this.currentTotal, + required this.onManual, + required this.onCopyPrevious, + }); + + final DateTime selectedMonth; + final String currencySymbol; + final double? currentTotal; + final Future Function(double) onManual; + final Future Function() onCopyPrevious; + + @override + State<_SetBudgetPopup> createState() => _SetBudgetPopupState(); +} + +class _SetBudgetPopupState extends State<_SetBudgetPopup> { + bool _showManualEntry = false; + bool _saving = false; + late final TextEditingController _amountController; + + @override + void initState() { + super.initState(); + _amountController = TextEditingController( + text: widget.currentTotal != null + ? widget.currentTotal!.toStringAsFixed(0) + : '', + ); + } + + @override + void dispose() { + _amountController.dispose(); + super.dispose(); + } + + String get _monthLabel => + DateFormat('MMMM yyyy').format(widget.selectedMonth); + + String get _previousMonthLabel { + final prev = DateTime( + widget.selectedMonth.year, widget.selectedMonth.month - 1); + return DateFormat('MMMM yyyy').format(prev); + } + + Future _handleManualSave() async { + final raw = _amountController.text.trim(); + final amount = double.tryParse(raw); + if (amount == null || amount < 0) { + context.showSnackBar('Enter a valid budget amount.', + type: AppFeedbackType.error); + return; + } + setState(() => _saving = true); + try { + await widget.onManual(amount); + if (!mounted) return; + Navigator.of(context).pop(); + context.showSnackBar('Budget saved for $_monthLabel.', + type: AppFeedbackType.success); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future _handleCopyPrevious() async { + setState(() => _saving = true); + try { + await widget.onCopyPrevious(); + if (!mounted) return; + Navigator.of(context).pop(); + context.showSnackBar( + 'Copied budget from $_previousMonthLabel to $_monthLabel.', + type: AppFeedbackType.success, + ); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + final viewInsets = MediaQuery.of(context).viewInsets; + + return Padding( + padding: EdgeInsets.only(bottom: viewInsets.bottom), + child: Container( + padding: const EdgeInsets.fromLTRB( + AppSpacing.xl, AppSpacing.md, AppSpacing.xl, AppSpacing.xxl), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical( + top: Radius.circular(AppRadii.sheet)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Drag handle + Center( + child: Container( + width: 46, + height: 4, + decoration: BoxDecoration( + color: AppColors.backgroundLight, + borderRadius: BorderRadius.circular(AppRadii.pill), + ), + ), + ), + const SizedBox(height: AppSpacing.lg), + + // Title + const Text( + 'Set Monthly Budget', + style: TextStyle( + color: AppColors.textDark, + fontSize: 22, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 4), + Text( + _monthLabel, + style: const TextStyle( + color: AppColors.primaryBlue, + fontSize: 14, + fontWeight: FontWeight.w800, + letterSpacing: 0.4, + ), + ), + const SizedBox(height: AppSpacing.xl), + + if (!_showManualEntry) ...[ + // Option 1: Enter Manually + _OptionTile( + icon: Icons.edit_rounded, + label: 'Enter Manually', + subtitle: 'Type a custom total budget for this month.', + onTap: _saving + ? null + : () => setState(() => _showManualEntry = true), + ), + const SizedBox(height: AppSpacing.sm), + + // Option 2: Copy Previous Month + _OptionTile( + icon: Icons.content_copy_rounded, + label: 'Copy Previous Month', + subtitle: + 'Import budget & category limits from $_previousMonthLabel.', + onTap: _saving ? null : _handleCopyPrevious, + loading: _saving, + ), + ] else ...[ + // Manual amount entry + TextField( + controller: _amountController, + autofocus: true, + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + decoration: InputDecoration( + labelText: 'Monthly budget', + prefixText: '${widget.currencySymbol} ', + filled: true, + fillColor: AppColors.surfaceLight, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadii.md), + borderSide: BorderSide.none, + ), + ), + ), + const SizedBox(height: AppSpacing.md), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: + _saving ? null : () => setState(() => _showManualEntry = false), + style: OutlinedButton.styleFrom( + padding: + const EdgeInsets.symmetric(vertical: AppSpacing.sm), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadii.md), + ), + ), + child: const Text('Back'), + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + flex: 2, + child: FilledButton( + onPressed: _saving ? null : _handleManualSave, + style: FilledButton.styleFrom( + backgroundColor: AppColors.primaryBlue, + padding: + const EdgeInsets.symmetric(vertical: AppSpacing.sm), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadii.md), + ), + ), + child: _saving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white), + ) + : const Text( + 'Save Budget', + style: + TextStyle(fontWeight: FontWeight.w800), + ), + ), + ), + ], + ), + ], + ], + ), + ), + ); + } +} + +// ── option tile ─────────────────────────────────────────────────────────────── + +class _OptionTile extends StatelessWidget { + const _OptionTile({ + required this.icon, + required this.label, + required this.subtitle, + this.onTap, + this.loading = false, + }); + + final IconData icon; + final String label; + final String subtitle; + final VoidCallback? onTap; + final bool loading; + + @override + Widget build(BuildContext context) { + final radius = BorderRadius.circular(AppRadii.lg); + return Material( + color: AppColors.surfaceLight, + borderRadius: radius, + child: InkWell( + onTap: onTap, + borderRadius: radius, + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: AppColors.lightBlueBg, + borderRadius: BorderRadius.circular(14), + ), + child: Icon(icon, color: AppColors.primaryBlue, size: 22), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + color: AppColors.textDark, + fontSize: 15, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 3), + Text( + subtitle, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12, + fontWeight: FontWeight.w600, + height: 1.3, + ), + ), + ], + ), + ), + const SizedBox(width: AppSpacing.xs), + if (loading) + const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else + const Icon(Icons.chevron_right_rounded, + color: AppColors.textMuted, size: 20), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/expense/presentation/screens/categories/categories_widgets.dart b/lib/features/expense/presentation/screens/categories/categories_widgets.dart index c4895d9..ca186c2 100644 --- a/lib/features/expense/presentation/screens/categories/categories_widgets.dart +++ b/lib/features/expense/presentation/screens/categories/categories_widgets.dart @@ -188,6 +188,218 @@ class AddCategoryCard extends StatelessWidget { } } +class CategoryListCard extends StatelessWidget { + const CategoryListCard({ + super.key, + required this.title, + required this.icon, + required this.tone, + required this.amount, + required this.progress, + required this.isEnabled, + required this.onToggle, + this.onTap, + this.progressLabel, + this.amountColor, + }); + + final String title; + final IconData icon; + final Color tone; + final String amount; + final String? progressLabel; + final double progress; + final bool isEnabled; + final VoidCallback? onTap; + final ValueChanged onToggle; + final Color? amountColor; + + @override + Widget build(BuildContext context) { + final resolvedAmountColor = amountColor ?? AppColors.textDark; + final resolvedProgressColor = isEnabled ? tone : AppColors.disabledContent; + final radius = BorderRadius.circular(AppRadii.lg); + + return AnimatedOpacity( + duration: const Duration(milliseconds: 160), + opacity: isEnabled ? 1 : 0.6, + child: Material( + color: Colors.white, + borderRadius: radius, + shadowColor: AppColors.cardShadow, + elevation: 2, + child: InkWell( + onTap: onTap, + borderRadius: radius, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.sm, + ), + child: Row( + children: [ + _IconBadge(icon: icon, tone: tone, enabled: isEnabled), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textDark, + fontSize: 14, + fontWeight: FontWeight.w800, + height: 1.2, + ), + ), + if (progressLabel != null) ...[ + const SizedBox(height: 3), + Text( + progressLabel!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 11, + fontWeight: FontWeight.w700, + height: 1.2, + ), + ), + ], + const SizedBox(height: AppSpacing.xs), + _ProgressBar( + value: progress, color: resolvedProgressColor), + ], + ), + ), + const SizedBox(width: AppSpacing.sm), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + amount, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: resolvedAmountColor, + fontSize: 16, + fontWeight: FontWeight.w900, + height: 1, + fontFeatures: const [ + FontFeature.tabularFigures(), + ], + ), + ), + const SizedBox(height: AppSpacing.xs), + AppToggleSwitch( + value: isEnabled, + activeColor: AppColors.primaryBlue, + onChanged: onToggle, + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} + +class AddCategoryListCard extends StatelessWidget { + const AddCategoryListCard({ + super.key, + required this.onTap, + required this.title, + required this.detail, + }); + + final VoidCallback onTap; + final String title; + final String detail; + + @override + Widget build(BuildContext context) { + final radius = BorderRadius.circular(AppRadii.lg); + return Material( + color: Colors.white, + borderRadius: radius, + shadowColor: AppColors.cardShadow, + elevation: 2, + child: InkWell( + onTap: onTap, + borderRadius: radius, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.sm, + ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: AppColors.lightBlueBg, + borderRadius: BorderRadius.circular(14), + ), + child: const Icon( + Icons.add_rounded, + color: AppColors.primaryBlue, + size: 22, + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textDark, + fontSize: 15, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 3), + Text( + detail, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 11, + fontWeight: FontWeight.w700, + height: 1.3, + ), + ), + ], + ), + ), + const SizedBox(width: AppSpacing.xs), + const Icon( + Icons.chevron_right_rounded, + color: AppColors.textMuted, + size: 20, + ), + ], + ), + ), + ), + ); + } +} + class CategoryGridData { const CategoryGridData({ required this.title, diff --git a/lib/features/expense/presentation/screens/categories_screen.dart b/lib/features/expense/presentation/screens/categories_screen.dart index e10fbf2..30b9930 100644 --- a/lib/features/expense/presentation/screens/categories_screen.dart +++ b/lib/features/expense/presentation/screens/categories_screen.dart @@ -3,13 +3,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; import '../../../../core/theme/app_colors.dart'; -import '../../../../shared/widgets/app_page_header.dart'; +import '../../../../core/theme/app_tokens.dart'; import '../../../../shared/widgets/app_tab_switcher.dart'; import '../../data/models/account_model.dart'; import '../../data/models/custom_category_model.dart'; import '../../data/models/expense_model.dart'; import '../provider/account_providers.dart'; import '../provider/budget_providers.dart'; +import '../provider/budget_state.dart'; import '../provider/expense_providers.dart'; import '../provider/preferences_providers.dart'; import '../widgets/account_editor_sheet.dart'; @@ -17,6 +18,7 @@ import '../widgets/account_icons.dart'; import '../widgets/amount_visibility.dart'; import '../widgets/category_editor_sheet.dart'; import '../widgets/expense_category.dart'; +import 'categories/budget_popup.dart'; import 'categories/categories_widgets.dart'; enum _BoardMode { expenses, income, accounts } @@ -31,7 +33,9 @@ class CategoriesScreen extends ConsumerStatefulWidget { class _CategoriesScreenState extends ConsumerState with SingleTickerProviderStateMixin { late final TabController _tabController; + late final TextEditingController _searchController; _BoardMode _mode = _BoardMode.expenses; + String _searchQuery = ''; static const List _categoryTabs = [ AppTabItem(label: 'Expense', icon: Icons.arrow_outward_rounded), @@ -43,11 +47,22 @@ class _CategoriesScreenState extends ConsumerState void initState() { super.initState(); _tabController = TabController(length: _categoryTabs.length, vsync: this); + _searchController = TextEditingController(); + _searchController.addListener(() { + final q = _searchController.text; + if (q != _searchQuery) setState(() => _searchQuery = q); + }); _tabController.addListener(() { final newIndex = _tabController.index; final newMode = _BoardMode.values[newIndex]; if (_mode != newMode) { - setState(() => _mode = newMode); + // Update state (including _searchQuery) first so the searchController + // listener finds the query already empty and skips its own setState. + setState(() { + _mode = newMode; + _searchQuery = ''; + }); + _searchController.clear(); } }); } @@ -55,15 +70,22 @@ class _CategoriesScreenState extends ConsumerState @override void dispose() { _tabController.dispose(); + _searchController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { - final stats = ref.watch(statsProvider); + // Month-aware providers + final selectedMonth = ref.watch(selectedMonthProvider); + final monthStats = ref.watch(monthlyStatsForMonthProvider); + final effectiveBudgets = ref.watch(effectiveMonthBudgetsProvider); + final totalBudget = ref.watch(monthTotalBudgetProvider); + final remainingBudget = ref.watch(monthRemainingBudgetProvider); + + // Infrastructure providers final budgetState = ref.watch(budgetTargetsProvider); final accountState = ref.watch(accountListProvider); - final accountSummary = ref.watch(accountSummaryProvider); final privacyModeEnabled = ref.watch(privacyModeEnabledProvider); final currency = ref.watch(currencyFormatProvider); final disabledExpenseCategories = @@ -76,35 +98,23 @@ class _CategoriesScreenState extends ConsumerState final customExpenseCategories = ref.watch(customExpenseCategoryListProvider); final customIncomeCategories = ref.watch(customIncomeCategoryListProvider); - final budgets = budgetState.value ?? defaultBudgetTargets; final accounts = accountState.value ?? const []; - final summaryAmount = _summaryAmount( - mode: _mode, - currency: currency, - stats: stats, - accountSummary: accountSummary, - masked: privacyModeEnabled, - ); - final summaryLabel = _summaryLabel(_mode); + final now = DateTime.now(); + final canSetBudget = selectedMonth.year > now.year || + (selectedMonth.year == now.year && + selectedMonth.month >= now.month); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - AppPageHeader( - eyebrow: 'Categories', - title: summaryAmount, - subtitle: summaryLabel, - bottom: AppTabSwitcher( - tabs: _categoryTabs, - selected: _mode.index, - onChanged: (index) { - setState(() { - _mode = _BoardMode.values[index]; - }); - _tabController.animateTo(index); - }, - ), + _buildHeader( + context: context, + selectedMonth: selectedMonth, + totalBudget: totalBudget, + remainingBudget: remainingBudget, + currency: currency, + masked: privacyModeEnabled, ), if ((_mode == _BoardMode.expenses && budgetState.isLoading) || (_mode == _BoardMode.accounts && accountState.isLoading)) @@ -113,10 +123,10 @@ class _CategoriesScreenState extends ConsumerState child: TabBarView( controller: _tabController, children: _BoardMode.values.map((mode) { - final modeCards = _buildCards( + final allCards = _buildCards( mode: mode, - stats: stats, - budgets: budgets, + stats: monthStats, + budgets: effectiveBudgets, accounts: accounts, disabledExpenseCategories: disabledExpenseCategories, disabledIncomeCategories: disabledIncomeCategories, @@ -126,11 +136,19 @@ class _CategoriesScreenState extends ConsumerState customExpenseCategories: customExpenseCategories, customIncomeCategories: customIncomeCategories, ); + final filteredCards = _searchQuery.isEmpty + ? allCards + : allCards + .where((c) => c.title + .toLowerCase() + .contains(_searchQuery.toLowerCase())) + .toList(growable: false); return _buildModeScrollPane( mode: mode, - cards: modeCards, + cards: filteredCards, currency: currency, privacyModeEnabled: privacyModeEnabled, + canSetBudget: canSetBudget, ); }).toList(growable: false), ), @@ -139,66 +157,253 @@ class _CategoriesScreenState extends ConsumerState ); } + // ── header ───────────────────────────────────────────────────────────────── + + Widget _buildHeader({ + required BuildContext context, + required DateTime selectedMonth, + required double? totalBudget, + required double? remainingBudget, + required NumberFormat currency, + required bool masked, + }) { + final monthLabel = DateFormat('MMMM yyyy').format(selectedMonth); + final topPadding = MediaQuery.of(context).padding.top; + + return Container( + width: double.infinity, + decoration: const BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: AppColors.cardShadow, + blurRadius: 10, + offset: Offset(0, 3), + ), + ], + ), + child: Padding( + padding: EdgeInsets.fromLTRB( + AppSpacing.lg, topPadding + AppSpacing.md, AppSpacing.lg, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Row 1: month navigation + budget summary ────────────────── + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _MonthNavButton( + icon: Icons.chevron_left_rounded, + onTap: _goToPreviousMonth, + ), + const SizedBox(width: AppSpacing.xs), + Text( + monthLabel, + style: const TextStyle( + color: AppColors.textDark, + fontSize: 17, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(width: AppSpacing.xs), + _MonthNavButton( + icon: Icons.chevron_right_rounded, + onTap: _goToNextMonth, + ), + const Spacer(), + if (totalBudget != null) + Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Budget: ${maskAmount(currency.format(totalBudget), masked: masked)}', + style: const TextStyle( + color: AppColors.textDark, + fontSize: 13, + fontWeight: FontWeight.w800, + ), + ), + if (remainingBudget != null) ...[ + const SizedBox(height: 2), + Text( + remainingBudget >= 0 + ? 'Remaining: ${maskAmount(currency.format(remainingBudget), masked: masked)}' + : 'Over by: ${maskAmount(currency.format(remainingBudget.abs()), masked: masked)}', + style: TextStyle( + color: remainingBudget >= 0 + ? AppColors.success + : AppColors.danger, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ], + ], + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + + // ── Search bar ──────────────────────────────────────────────── + TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Search ${_modeName(_mode)}...', + hintStyle: const TextStyle( + color: AppColors.textMuted, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + prefixIcon: const Icon(Icons.search_rounded, + color: AppColors.textMuted, size: 20), + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + icon: const Icon(Icons.close_rounded, + size: 18, color: AppColors.textMuted), + onPressed: () { + _searchController.clear(); + setState(() => _searchQuery = ''); + }, + ) + : null, + filled: true, + fillColor: AppColors.surfaceLight, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadii.md), + borderSide: BorderSide.none, + ), + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 0), + isDense: true, + ), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textDark, + ), + ), + const SizedBox(height: AppSpacing.sm), + + // ── Tab switcher ────────────────────────────────────────────── + AppTabSwitcher( + tabs: _categoryTabs, + selected: _mode.index, + onChanged: (index) { + // Update mode + query in one setState, then clear the + // controller so its listener finds the query already empty. + setState(() { + _mode = _BoardMode.values[index]; + _searchQuery = ''; + }); + _searchController.clear(); + _tabController.animateTo(index); + }, + ), + const SizedBox(height: AppSpacing.xs), + ], + ), + ), + ); + } + + // ── scroll pane ──────────────────────────────────────────────────────────── + Widget _buildModeScrollPane({ required _BoardMode mode, required List cards, required NumberFormat currency, required bool privacyModeEnabled, + required bool canSetBudget, }) { - return SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 124), - child: LayoutBuilder( - builder: (context, constraints) { - final width = constraints.maxWidth; - final crossAxisCount = width >= 900 - ? 4 - : width >= 640 - ? 3 - : 2; - final ratio = width >= 900 - ? 1.35 - : width >= 640 - ? 1.28 - : 1.22; - - return GridView.builder( - itemCount: cards.length + 1, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: crossAxisCount, - mainAxisSpacing: 12, - crossAxisSpacing: 12, - childAspectRatio: ratio, + // index 0 = action bar, 1..N = category cards + return ListView.separated( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 130), + itemCount: cards.length + 1, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (context, index) { + if (index == 0) { + return _buildActionBar(mode, canSetBudget); + } + final entry = cards[index - 1]; + return CategoryListCard( + title: entry.title, + icon: entry.icon, + tone: entry.tone, + amount: _displayAmount( + entry.amount, entry.amountColor, currency, + masked: privacyModeEnabled), + progressLabel: entry.progressLabel, + progress: entry.progress, + isEnabled: entry.isEnabled, + onTap: entry.onTap, + onToggle: entry.onToggle, + amountColor: entry.amountColor, + ); + }, + ); + } + + // ── action bar ───────────────────────────────────────────────────────────── + + Widget _buildActionBar(_BoardMode mode, bool canSetBudget) { + return Row( + children: [ + Expanded( + child: _ActionButton( + icon: Icons.add_rounded, + label: _actionTitle(mode), + onTap: () => _handlePrimaryActionTapFor(mode), + ), + ), + if (canSetBudget) ...[ + const SizedBox(width: 10), + Expanded( + child: _ActionButton( + icon: Icons.savings_outlined, + label: 'Set Budget', + accentColor: AppColors.success, + onTap: _openSetBudgetPopup, ), - itemBuilder: (context, index) { - if (index == cards.length) { - return AddCategoryCard( - onTap: () => _handlePrimaryActionTapFor(mode), - title: _actionTitle(mode), - detail: _actionDetail(mode), - ); - } - - final entry = cards[index]; - return CategoryGridCard( - title: entry.title, - icon: entry.icon, - tone: entry.tone, - amount: _displayAmount( - entry.amount, entry.amountColor, currency, - masked: privacyModeEnabled), - progressLabel: entry.progressLabel, - progress: entry.progress, - isEnabled: entry.isEnabled, - onTap: entry.onTap, - onToggle: entry.onToggle, - amountColor: entry.amountColor, - ); - }, - ); - }, - ), + ), + ], + ], + ); + } + + // ── month navigation ─────────────────────────────────────────────────────── + + void _goToPreviousMonth() { + final current = ref.read(selectedMonthProvider); + ref.read(selectedMonthProvider.notifier).state = + DateTime(current.year, current.month - 1); + } + + void _goToNextMonth() { + final current = ref.read(selectedMonthProvider); + ref.read(selectedMonthProvider.notifier).state = + DateTime(current.year, current.month + 1); + } + + // ── set budget popup ─────────────────────────────────────────────────────── + + Future _openSetBudgetPopup() async { + final selectedMonth = ref.read(selectedMonthProvider); + final currentTotal = ref.read(monthTotalBudgetProvider); + await showSetBudgetPopup( + context, + selectedMonth: selectedMonth, + currencySymbol: ref.read(currencySymbolProvider), + currentTotal: currentTotal, + onManual: (amount) => + ref.read(monthBudgetControllerProvider).saveMonthTotal( + selectedMonth, + amount, + ), + onCopyPrevious: () => + ref.read(monthBudgetControllerProvider).copyFromPreviousMonth( + selectedMonth, + ), ); } @@ -361,36 +566,14 @@ class _CategoriesScreenState extends ConsumerState return maskAmount(currency.format(amount), masked: masked); } - String _summaryAmount({ - required _BoardMode mode, - required NumberFormat currency, - required ExpenseStats stats, - required AccountSummary accountSummary, - required bool masked, - }) { + String _modeName(_BoardMode mode) { switch (mode) { case _BoardMode.expenses: - return maskAmount(currency.format(stats.monthTotal), masked: masked); + return 'expense categories'; case _BoardMode.income: - return maskAmount(currency.format(stats.monthIncomeTotal), - masked: masked); + return 'income categories'; case _BoardMode.accounts: - final balance = accountSummary.totalBalance; - if (balance < 0) { - return '-${maskAmount(currency.format(balance.abs()), masked: masked)}'; - } - return maskAmount(currency.format(balance), masked: masked); - } - } - - String _summaryLabel(_BoardMode mode) { - switch (mode) { - case _BoardMode.expenses: - return 'Expenses this month'; - case _BoardMode.income: - return 'Income this month'; - case _BoardMode.accounts: - return 'Tracked account balance'; + return 'accounts'; } } @@ -405,17 +588,6 @@ class _CategoriesScreenState extends ConsumerState } } - String _actionDetail(_BoardMode mode) { - switch (mode) { - case _BoardMode.expenses: - return 'Create your own expense category.'; - case _BoardMode.income: - return 'Create your own income category.'; - case _BoardMode.accounts: - return 'Create a new account entry.'; - } - } - void _handlePrimaryActionTapFor(_BoardMode mode) { switch (mode) { case _BoardMode.expenses: @@ -695,3 +867,78 @@ class _CategoriesScreenState extends ConsumerState .showSnackBar(SnackBar(content: Text(message))); } } + +// ── private widgets ─────────────────────────────────────────────────────────── + +class _MonthNavButton extends StatelessWidget { + const _MonthNavButton({required this.icon, required this.onTap}); + + final IconData icon; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: AppColors.lightBlueBg, + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(6), + child: Icon(icon, color: AppColors.primaryBlue, size: 20), + ), + ), + ); + } +} + +class _ActionButton extends StatelessWidget { + const _ActionButton({ + required this.icon, + required this.label, + required this.onTap, + this.accentColor = AppColors.primaryBlue, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + final Color accentColor; + + @override + Widget build(BuildContext context) { + final radius = BorderRadius.circular(AppRadii.md); + return Material( + color: accentColor.withValues(alpha: 0.1), + borderRadius: radius, + child: InkWell( + onTap: onTap, + borderRadius: radius, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, vertical: AppSpacing.xs), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 18, color: accentColor), + const SizedBox(width: 6), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: accentColor, + fontSize: 13, + fontWeight: FontWeight.w800, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/expense/presentation/screens/upi_scanner_screen.dart b/lib/features/expense/presentation/screens/upi_scanner_screen.dart index b32d7ee..b6200b2 100644 --- a/lib/features/expense/presentation/screens/upi_scanner_screen.dart +++ b/lib/features/expense/presentation/screens/upi_scanner_screen.dart @@ -5,11 +5,12 @@ import 'package:mobile_scanner/mobile_scanner.dart'; import '../../../../core/theme/app_colors.dart'; import '../../../../routes/app_routes.dart'; -/// A QR scanner specialised for the "Pay Directly" flow. +/// A QR scanner specialised for the "Pay via UPI" flow. /// -/// Detects a UPI payment QR, extracts the full URI, and opens -/// [AddExpenseScreen] in pay-mode so the user can fill in the amount, -/// launch their UPI app, and then save the transaction. +/// Detects a UPI payment QR, extracts the payee VPA and merchant name, and +/// opens [AddExpenseScreen] in pay-mode. The amount the user enters in +/// XPensa is shown as a hint; the user manually enters it in their UPI app +/// to avoid the fraud flags that externally pre-filled payment intents trigger. class UpiScannerScreen extends StatefulWidget { const UpiScannerScreen({super.key}); @@ -47,9 +48,28 @@ class _UpiScannerScreenState extends State { return; } + final Uri uri; + try { + uri = Uri.parse(rawValue); + } on FormatException { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not read QR data. Try again.')), + ); + return; + } + + final pa = uri.queryParameters['pa']; + if (pa == null || pa.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Invalid QR: payee UPI ID is missing. Try again.'), + ), + ); + return; + } + setState(() => _isProcessed = true); - final uri = Uri.parse(rawValue); final am = uri.queryParameters['am']; final pn = uri.queryParameters['pn']; final tn = uri.queryParameters['tn']; @@ -112,7 +132,7 @@ class _UpiScannerScreenState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text('Scan UPI QR'), + title: const Text('Pay via UPI'), backgroundColor: AppColors.primaryBlue, foregroundColor: Colors.white, ), @@ -150,7 +170,7 @@ class _UpiScannerScreenState extends State { borderRadius: BorderRadius.circular(20), ), child: const Text( - 'Point camera at a UPI payment QR', + 'Scan merchant UPI QR to pay', style: TextStyle( color: Colors.white, fontWeight: FontWeight.bold, diff --git a/lib/features/expense/presentation/widgets/power_pill_menu.dart b/lib/features/expense/presentation/widgets/power_pill_menu.dart index b6cc8fd..6db2936 100644 --- a/lib/features/expense/presentation/widgets/power_pill_menu.dart +++ b/lib/features/expense/presentation/widgets/power_pill_menu.dart @@ -10,12 +10,8 @@ import '../../../../shared/widgets/app_toggle_switch.dart'; /// An expandable power FAB. /// /// Shows a circular `+` button. When tapped the button rotates 135° (making it -/// look like ×) and five action pills animate up above it: -/// Quick Add · Pay Directly · Scanner · Voice · SMS -/// -/// The SMS pill has a split interaction: -/// - Tapping the label / icon area opens the SMS settings sheet. -/// - The inline toggle switch toggles SMS parsing on/off directly. +/// look like ×) and action pills animate up above it: +/// Quick Add · Voice /// /// Use [PowerFabState] via a [GlobalKey] to imperatively [close] the menu /// (e.g. from a barrier tap in the parent). @@ -117,43 +113,13 @@ class PowerFabState extends State if (_open) ...[ _AnimatedPill( animation: _ctrl, - staggerStart: 0.4, - icon: Icons.sms_outlined, - label: 'SMS', - infoText: 'Reads transaction SMS and logs expenses automatically', - onTap: () => _closeAndRun(widget.onSms), - trailingToggleValue: widget.smsParsingEnabled, - onTrailingToggle: widget.onSmsToggle, - ), - const SizedBox(height: 8), - _AnimatedPill( - animation: _ctrl, - staggerStart: 0.3, + staggerStart: 0.1, icon: Icons.mic_none_rounded, label: 'Voice', infoText: 'Speak an expense aloud — parsed and saved for you', onTap: () => _closeAndRun(widget.onVoice), ), const SizedBox(height: 8), - _AnimatedPill( - animation: _ctrl, - staggerStart: 0.2, - icon: Icons.document_scanner_outlined, - label: 'Scan & Log', - infoText: - 'Scan a bill barcode/QR or photograph a product — XPens fills in the details for you', - onTap: () => _closeAndRun(widget.onScanner), - ), - const SizedBox(height: 8), - _AnimatedPill( - animation: _ctrl, - staggerStart: 0.1, - icon: Icons.currency_rupee_rounded, - label: 'Pay Directly', - infoText: 'Pay via UPI QR code and log the transaction instantly', - onTap: () => _closeAndRun(widget.onPayDirectly), - ), - const SizedBox(height: 8), _AnimatedPill( animation: _ctrl, staggerStart: 0.0, @@ -291,7 +257,7 @@ class _AnimatedPillState extends State<_AnimatedPill> { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ - // ── Pill row ──────────────────────────────────────────────────── + // ── Pill row ────────────────────────────────────────────────── Material( color: Colors.transparent, child: InkWell( @@ -303,7 +269,7 @@ class _AnimatedPillState extends State<_AnimatedPill> { : null, borderRadius: BorderRadius.circular(28), child: Container( - padding: EdgeInsets.only( + padding: const EdgeInsets.only( left: 20, right: 12, top: 14, @@ -344,7 +310,8 @@ class _AnimatedPillState extends State<_AnimatedPill> { color: Colors.white.withValues(alpha: 0.25), ), const SizedBox(width: 8), - // GestureDetector absorbs taps so they don't bubble to InkWell + // GestureDetector absorbs taps so they don't bubble + // to InkWell GestureDetector( behavior: HitTestBehavior.opaque, onTap: () { @@ -359,7 +326,7 @@ class _AnimatedPillState extends State<_AnimatedPill> { ), ), ], - // ── Info icon ────────────────────────────────────────── + // ── Info icon ──────────────────────────────────────── const SizedBox(width: 10), Container( width: 1, @@ -392,7 +359,7 @@ class _AnimatedPillState extends State<_AnimatedPill> { ), ), - // ── Info bar ──────────────────────────────────────────────────── + // ── Info bar ──────────────────────────────────────────────── AnimatedSwitcher( duration: const Duration(milliseconds: 180), transitionBuilder: (child, anim) => FadeTransition( @@ -404,7 +371,10 @@ class _AnimatedPillState extends State<_AnimatedPill> { ), ), child: _showInfo - ? _InfoBar(key: const ValueKey('info'), text: widget.infoText) + ? _InfoBar( + key: const ValueKey('info'), + text: widget.infoText, + ) : const SizedBox.shrink(key: ValueKey('empty')), ), ], diff --git a/lib/features/sms_parser/data/sms_transaction.dart b/lib/features/sms_parser/data/sms_transaction.dart index 05f5b1e..750e6b0 100644 --- a/lib/features/sms_parser/data/sms_transaction.dart +++ b/lib/features/sms_parser/data/sms_transaction.dart @@ -11,6 +11,7 @@ class SmsTransaction { required this.rawMessage, required this.senderAddress, required this.confidence, + this.suggestedCategory, }); /// Stable identifier (typically a hash of sender + body + timestamp). @@ -37,4 +38,8 @@ class SmsTransaction { /// Parser confidence score in [0, 1]. Values below [SmsParserEngine.kMinConfidence] /// are considered low-confidence and require manual edit. final double confidence; + + /// Expense category inferred from message content (e.g. "Food & Dining"). + /// Null if no category could be reliably inferred. + final String? suggestedCategory; } diff --git a/lib/features/sms_parser/domain/sms_parser_engine.dart b/lib/features/sms_parser/domain/sms_parser_engine.dart index b936f80..dd0dd8f 100644 --- a/lib/features/sms_parser/domain/sms_parser_engine.dart +++ b/lib/features/sms_parser/domain/sms_parser_engine.dart @@ -12,7 +12,8 @@ import '../data/sms_transaction.dart'; /// 2. Detect transaction direction (credit/income vs debit/expense). /// 3. Extract date/time. /// 4. Build a notes string from ref IDs, UPI IDs, merchant names, etc. -/// 5. Compute a confidence score. +/// 5. Infer an expense category from merchant/sender keywords. +/// 6. Compute a confidence score. /// /// A result with [confidence] < [kMinConfidence] should be treated as /// low-confidence and shown to the user for manual review rather than being @@ -33,7 +34,9 @@ abstract final class SmsParserEngine { upper.contains('BANK') || upper.contains('FINANCE') || upper.contains('PAY') || - upper.contains('UPI')) { return true; } + upper.contains('UPI')) { + return true; + } return false; } @@ -66,7 +69,10 @@ abstract final class SmsParserEngine { // ── 4. Notes ────────────────────────────────────────────────────────── final notes = _buildNotes(body, senderAddress); - // ── 5. Confidence ───────────────────────────────────────────────────── + // ── 5. Category inference ───────────────────────────────────────────── + final suggestedCategory = inferCategory(body, senderAddress); + + // ── 6. Confidence ───────────────────────────────────────────────────── final confidence = _computeConfidence( body: lower, amount: amount, @@ -75,7 +81,7 @@ abstract final class SmsParserEngine { receivedAt: receivedAt, ); - // ── 6. Stable ID ────────────────────────────────────────────────────── + // ── 7. Stable ID ────────────────────────────────────────────────────── final id = _stableId(senderAddress, body, receivedAt); return SmsTransaction( @@ -87,6 +93,7 @@ abstract final class SmsParserEngine { rawMessage: body, senderAddress: senderAddress, confidence: confidence, + suggestedCategory: suggestedCategory, ); } @@ -100,6 +107,9 @@ abstract final class SmsParserEngine { 'deposited', 'refund', 'cashback', + 'reversed', + 'transfer received', + 'money received', ]; static const List _kDebitKeywords = [ @@ -113,6 +123,15 @@ abstract final class SmsParserEngine { 'purchase', 'txn', 'transaction', + 'transferred', + 'transfer', + 'charged', + 'neft', + 'imps', + 'rtgs', + 'emi', + 'mandate', + 'bill payment', ]; // Matches: ₹1,234.56 | Rs.1234 | Rs 1234 | INR 1234 | 1234 INR @@ -199,6 +218,12 @@ abstract final class SmsParserEngine { caseSensitive: false, ); + // UTR is always a 12-digit numeric reference used by UPI/NEFT/IMPS + static final RegExp _kUtrPattern = RegExp( + r'(?:UTR|UPI\s*Ref(?:\s*No)?|Transaction\s*ID|Txn\s*ID)\s*:?\s*([A-Z0-9]{10,22})', + caseSensitive: false, + ); + static final RegExp _kUpiPattern = RegExp( r'(?:UPI(?:\s*ID)?:?\s*)([a-zA-Z0-9._\-]+@[a-zA-Z0-9._\-]+)', caseSensitive: false, @@ -210,15 +235,21 @@ abstract final class SmsParserEngine { ); static final RegExp _kMerchantPattern = RegExp( - r'(?:to|at|from|merchant|shop|store|vendor|using)\s+([A-Z][A-Za-z0-9 &\.\-]{2,30})', + r'(?:to|at|from|merchant|shop|store|vendor|using|towards|for)\s+([A-Z][A-Za-z0-9 &\.\-]{2,30})', caseSensitive: false, ); static String _buildNotes(String body, String sender) { final parts = []; - final refMatch = _kRefPattern.firstMatch(body); - if (refMatch != null) parts.add('Ref: ${refMatch.group(1)}'); + // Prefer UTR over generic ref — UTR is the canonical UPI transaction ID + final utrMatch = _kUtrPattern.firstMatch(body); + if (utrMatch != null) { + parts.add('UTR: ${utrMatch.group(1)}'); + } else { + final refMatch = _kRefPattern.firstMatch(body); + if (refMatch != null) parts.add('Ref: ${refMatch.group(1)}'); + } final upiMatch = _kUpiPattern.firstMatch(body) ?? _kVpaPattern.firstMatch(body); @@ -241,6 +272,66 @@ abstract final class SmsParserEngine { return parts.join(' · '); } + // ── Category inference ──────────────────────────────────────────────────── + + /// Infer a likely expense category from message body and sender ID. + /// + /// Returns a category name string (matching the built-in expense category + /// names) or `null` if no strong match is found. + static String? inferCategory(String body, String sender) { + final lower = body.toLowerCase(); + final senderLower = sender.toLowerCase(); + + if (_containsAny(lower, const [ + 'swiggy', 'zomato', 'dunzo', 'blinkit', 'restaurant', 'cafe', + 'food', 'dine', 'hotel', 'domino', 'pizza', 'biryani', + ])) return 'Food & Dining'; + + if (_containsAny(lower, const [ + 'ola', 'uber', 'rapido', 'redbus', 'irctc', 'petrol', 'fuel', + 'diesel', 'metro', 'auto', 'cab', 'bus ticket', 'train', + ])) return 'Transport'; + + if (_containsAny(lower, const [ + 'amazon', 'flipkart', 'myntra', 'ajio', 'meesho', 'nykaa', + 'shopping', 'mall', 'retail', + ])) return 'Shopping'; + + // Combine message body + sender once to check both with a single pass. + final combined = lower + senderLower; + if (_containsAny(combined, const [ + 'electricity', 'water bill', 'gas bill', 'broadband', 'wifi', + 'jio', 'airtel', 'vodafone', + 'vi ', // trailing space avoids false-positive on words containing 'vi' + 'bsnl', 'recharge', 'dth', + 'tata sky', 'dish tv', 'bill pay', 'bill payment', + ])) return 'Bills & Utilities'; + + if (_containsAny(lower, const [ + 'pharmacy', 'medical', 'hospital', 'clinic', 'doctor', 'apollo', + 'medplus', 'netmeds', 'pharmeasy', 'health', 'medicine', + ])) return 'Healthcare'; + + if (_containsAny(lower, const [ + 'netflix', 'hotstar', 'prime video', 'amazon prime', 'spotify', + 'youtube', 'game', 'movie', 'theatre', 'multiplex', 'pvr', 'inox', + ])) return 'Entertainment'; + + if (_containsAny(lower, const [ + 'school', 'college', 'university', 'tuition', 'coaching', + 'education', 'course', 'udemy', 'coursera', + ])) return 'Education'; + + if (_containsAny(lower, const [ + 'salary', 'payroll', 'stipend', 'wages', + ])) return 'Salary'; + + return null; + } + + static bool _containsAny(String text, List keywords) => + keywords.any(text.contains); + // ── Confidence scoring ──────────────────────────────────────────────────── static double _computeConfidence({ @@ -264,8 +355,10 @@ abstract final class SmsParserEngine { if (_kDatePattern.hasMatch(body)) score += 0.15; if (_kTimePattern.hasMatch(body)) score += 0.05; - // Ref ID or UPI found - if (_kRefPattern.hasMatch(body) || _kUpiPattern.hasMatch(body)) { + // UTR, Ref ID, or UPI ID found — strong signal of a real bank transaction + if (_kUtrPattern.hasMatch(body) || + _kRefPattern.hasMatch(body) || + _kUpiPattern.hasMatch(body)) { score += 0.1; } diff --git a/lib/features/sms_parser/presentation/provider/sms_providers.dart b/lib/features/sms_parser/presentation/provider/sms_providers.dart index 64628f2..9881ed0 100644 --- a/lib/features/sms_parser/presentation/provider/sms_providers.dart +++ b/lib/features/sms_parser/presentation/provider/sms_providers.dart @@ -128,7 +128,10 @@ class SmsQueueController { final defaultAccountId = prefs?.smsDefaultAccountId ?? ''; final defaultCategory = prefs?.smsDefaultCategory ?? ''; - final category = defaultCategory.isNotEmpty ? defaultCategory : 'Other'; + // Prefer user-configured default → parser-inferred category → fallback + final category = defaultCategory.isNotEmpty + ? defaultCategory + : (tx.suggestedCategory ?? 'Other'); final accountId = defaultAccountId.isNotEmpty ? defaultAccountId : null; await _ref.read(expenseControllerProvider).addExpense( @@ -179,9 +182,13 @@ class SmsQueueController { final prefs = _ref.read(appPreferencesProvider).value; final defaultAccountId = prefs?.smsDefaultAccountId ?? ''; final defaultCategory = prefs?.smsDefaultCategory ?? ''; + // Prefer user-configured default → parser-inferred category → fallback + final category = defaultCategory.isNotEmpty + ? defaultCategory + : (tx.suggestedCategory ?? 'Other'); return ( amount: tx.amount, - category: defaultCategory.isNotEmpty ? defaultCategory : 'Other', + category: category, date: tx.timestamp, note: tx.notes, accountId: defaultAccountId.isNotEmpty ? defaultAccountId : null, diff --git a/lib/routes/app_routes.dart b/lib/routes/app_routes.dart index 06e0874..fa80a86 100644 --- a/lib/routes/app_routes.dart +++ b/lib/routes/app_routes.dart @@ -112,9 +112,9 @@ abstract final class AppRoutes { ); } - // ── UPI Scanner (Pay Directly) ───────────────────────────────────────────── + // ── UPI Scanner (Pay via UPI) ────────────────────────────────────────────── - /// Push the UPI-payment QR scanner for the "Pay Directly" flow. + /// Push the UPI QR scanner for the "Pay via UPI" flow. static Future pushUpiScanner(BuildContext context) { return Navigator.of(context).push( MaterialPageRoute(builder: (_) => const UpiScannerScreen()), @@ -123,8 +123,10 @@ abstract final class AppRoutes { /// Replace the current route with [AddExpenseScreen] in pay-mode. /// - /// Used by [UpiScannerScreen] after a successful scan so that the user returns - /// directly to the expense form (pre-filled, pay mode) rather than the scanner. + /// Used by [UpiScannerScreen] after a successful scan. The screen shows an + /// "Open UPI App" button that hands the user off to their preferred payment + /// app with only the payee VPA — no pre-filled amount that could trigger + /// fraud detection in GPay / PhonePe / Paytm. static void replaceWithPayExpense( BuildContext context, { required String payUpiUri,