diff --git a/app/lib/database/database_helper.dart b/app/lib/database/database_helper.dart index df82b1c..33d9171 100644 --- a/app/lib/database/database_helper.dart +++ b/app/lib/database/database_helper.dart @@ -149,6 +149,24 @@ class DatabaseHelper { ) '''); + // Budgets table + await db.execute(''' + CREATE TABLE budgets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + type TEXT NOT NULL, + amount REAL NOT NULL, + categoryId INTEGER, + startDate TEXT NOT NULL, + endDate TEXT, + rollover INTEGER NOT NULL DEFAULT 0, + alertThreshold REAL NOT NULL DEFAULT 80.0, + isActive INTEGER NOT NULL DEFAULT 1, + createdAt TEXT NOT NULL, + updatedAt TEXT + ) + '''); + // Receiver category mappings table await db.execute(''' CREATE TABLE receiver_category_mappings ( @@ -189,8 +207,14 @@ class DatabaseHelper { await db.execute('CREATE INDEX idx_accounts_bank ON accounts(bank)'); await db.execute( 'CREATE INDEX idx_accounts_accountNumber ON accounts(accountNumber)'); - await db.execute( - 'CREATE INDEX idx_accounts_profileId ON accounts(profileId)'); + await db.execute('CREATE INDEX idx_budgets_type ON budgets(type)'); + await db + .execute('CREATE INDEX idx_budgets_categoryId ON budgets(categoryId)'); + await db.execute('CREATE INDEX idx_budgets_isActive ON budgets(isActive)'); + await db + .execute('CREATE INDEX idx_budgets_startDate ON budgets(startDate)'); + await db + .execute('CREATE INDEX idx_accounts_profileId ON accounts(profileId)'); await db.execute( 'CREATE INDEX idx_transactions_profileId ON transactions(profileId)'); @@ -443,6 +467,37 @@ class DatabaseHelper { } if (oldVersion < 14) { + // Add budgets table for version 14 + try { + await db.execute(''' + CREATE TABLE IF NOT EXISTS budgets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + type TEXT NOT NULL, + amount REAL NOT NULL, + categoryId INTEGER, + startDate TEXT NOT NULL, + endDate TEXT, + rollover INTEGER NOT NULL DEFAULT 0, + alertThreshold REAL NOT NULL DEFAULT 80.0, + isActive INTEGER NOT NULL DEFAULT 1, + createdAt TEXT NOT NULL, + updatedAt TEXT + ) + '''); + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_budgets_type ON budgets(type)'); + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_budgets_categoryId ON budgets(categoryId)'); + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_budgets_isActive ON budgets(isActive)'); + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_budgets_startDate ON budgets(startDate)'); + print("debug: Added budgets table"); + } catch (e) { + print("debug: Error adding budgets table (might already exist): $e"); + } + // Add receiver category mappings table for version 14 try { await db.execute(''' @@ -463,7 +518,8 @@ class DatabaseHelper { ); print("debug: Added receiver_category_mappings table"); } catch (e) { - print("debug: Error adding receiver_category_mappings table (might already exist): $e"); + print( + "debug: Error adding receiver_category_mappings table (might already exist): $e"); } } @@ -473,11 +529,12 @@ class DatabaseHelper { // Add profileId to accounts table await db.execute('ALTER TABLE accounts ADD COLUMN profileId INTEGER'); print("debug: Added profileId column to accounts table"); - + // Add profileId to transactions table - await db.execute('ALTER TABLE transactions ADD COLUMN profileId INTEGER'); + await db + .execute('ALTER TABLE transactions ADD COLUMN profileId INTEGER'); print("debug: Added profileId column to transactions table"); - + // Create indexes for better query performance await db.execute( "CREATE INDEX IF NOT EXISTS idx_accounts_profileId ON accounts(profileId)", @@ -486,13 +543,13 @@ class DatabaseHelper { "CREATE INDEX IF NOT EXISTS idx_transactions_profileId ON transactions(profileId)", ); print("debug: Created indexes for profileId columns"); - + // Migrate existing data: assign all existing accounts/transactions to active profile // Get active profile ID (or first profile if none active) // Access SharedPreferences directly to avoid circular dependency during migration final prefs = await SharedPreferences.getInstance(); int? activeProfileId = prefs.getInt('active_profile_id'); - + if (activeProfileId == null) { // Get first profile from database final profileResult = await db.query( @@ -500,7 +557,7 @@ class DatabaseHelper { orderBy: 'createdAt ASC', limit: 1, ); - + if (profileResult.isNotEmpty) { activeProfileId = profileResult.first['id'] as int?; if (activeProfileId != null) { @@ -519,7 +576,7 @@ class DatabaseHelper { await prefs.setInt('active_profile_id', activeProfileId); } } - + // Update all existing accounts to use active profile await db.update( 'accounts', @@ -527,16 +584,18 @@ class DatabaseHelper { where: 'profileId IS NULL', ); print("debug: Migrated existing accounts to profile $activeProfileId"); - + // Update all existing transactions to use active profile await db.update( 'transactions', {'profileId': activeProfileId}, where: 'profileId IS NULL', ); - print("debug: Migrated existing transactions to profile $activeProfileId"); + print( + "debug: Migrated existing transactions to profile $activeProfileId"); } catch (e) { - print("debug: Error adding profileId columns (might already exist): $e"); + print( + "debug: Error adding profileId columns (might already exist): $e"); } } } diff --git a/app/lib/main.dart b/app/lib/main.dart index cf399cd..5eaae2c 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import 'package:totals/providers/insights_provider.dart'; import 'package:totals/providers/theme_provider.dart'; import 'package:totals/providers/transaction_provider.dart'; +import 'package:totals/providers/budget_provider.dart'; import 'package:totals/screens/home_page.dart'; import 'package:totals/database/migration_helper.dart'; import 'package:totals/services/account_sync_status_service.dart'; @@ -50,6 +51,7 @@ class MyApp extends StatelessWidget { providers: [ ChangeNotifierProvider(create: (_) => ThemeProvider()), ChangeNotifierProvider(create: (_) => TransactionProvider()), + ChangeNotifierProvider(create: (_) => BudgetProvider()), // we need insights provider to use the existing transacton provider instead of using // a new transaction provider instance. diff --git a/app/lib/models/budget.dart b/app/lib/models/budget.dart new file mode 100644 index 0000000..a7a2566 --- /dev/null +++ b/app/lib/models/budget.dart @@ -0,0 +1,144 @@ +class Budget { + final int? id; + final String name; + final String type; // 'daily', 'monthly', 'yearly', 'category' + final double amount; + final int? categoryId; + final DateTime startDate; + final DateTime? endDate; + final bool rollover; + final double alertThreshold; // 0-100 percentage + final bool isActive; + final DateTime createdAt; + final DateTime? updatedAt; + + Budget({ + this.id, + required this.name, + required this.type, + required this.amount, + this.categoryId, + required this.startDate, + this.endDate, + this.rollover = false, + this.alertThreshold = 80.0, + this.isActive = true, + required this.createdAt, + this.updatedAt, + }); + + factory Budget.fromDb(Map row) { + return Budget( + id: row['id'] as int?, + name: (row['name'] as String?) ?? '', + type: (row['type'] as String?) ?? 'monthly', + amount: (row['amount'] as num?)?.toDouble() ?? 0.0, + categoryId: row['categoryId'] as int?, + startDate: row['startDate'] != null + ? DateTime.parse(row['startDate'] as String) + : DateTime.now(), + endDate: row['endDate'] != null + ? DateTime.parse(row['endDate'] as String) + : null, + rollover: (row['rollover'] as int? ?? 0) == 1, + alertThreshold: (row['alertThreshold'] as num?)?.toDouble() ?? 80.0, + isActive: (row['isActive'] as int? ?? 1) == 1, + createdAt: row['createdAt'] != null + ? DateTime.parse(row['createdAt'] as String) + : DateTime.now(), + updatedAt: row['updatedAt'] != null + ? DateTime.parse(row['updatedAt'] as String) + : null, + ); + } + + Map toDb() { + return { + 'id': id, + 'name': name, + 'type': type, + 'amount': amount, + 'categoryId': categoryId, + 'startDate': startDate.toIso8601String(), + 'endDate': endDate?.toIso8601String(), + 'rollover': rollover ? 1 : 0, + 'alertThreshold': alertThreshold, + 'isActive': isActive ? 1 : 0, + 'createdAt': createdAt.toIso8601String(), + 'updatedAt': updatedAt?.toIso8601String(), + }; + } + + Budget copyWith({ + int? id, + String? name, + String? type, + double? amount, + int? categoryId, + DateTime? startDate, + DateTime? endDate, + bool? rollover, + double? alertThreshold, + bool? isActive, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return Budget( + id: id ?? this.id, + name: name ?? this.name, + type: type ?? this.type, + amount: amount ?? this.amount, + categoryId: categoryId ?? this.categoryId, + startDate: startDate ?? this.startDate, + endDate: endDate ?? this.endDate, + rollover: rollover ?? this.rollover, + alertThreshold: alertThreshold ?? this.alertThreshold, + isActive: isActive ?? this.isActive, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + // Helper methods for period calculations + DateTime getCurrentPeriodStart() { + final now = DateTime.now(); + switch (type) { + case 'daily': + return DateTime(now.year, now.month, now.day); + case 'monthly': + return DateTime(now.year, now.month, 1); + case 'yearly': + return DateTime(now.year, 1, 1); + case 'category': + // For category budgets, use monthly by default + return DateTime(now.year, now.month, 1); + default: + return DateTime(now.year, now.month, 1); + } + } + + DateTime getCurrentPeriodEnd() { + final start = getCurrentPeriodStart(); + switch (type) { + case 'daily': + return DateTime(start.year, start.month, start.day, 23, 59, 59); + case 'monthly': + final nextMonth = DateTime(start.year, start.month + 1, 1); + return nextMonth.subtract(const Duration(seconds: 1)); + case 'yearly': + return DateTime(start.year, 12, 31, 23, 59, 59); + case 'category': + final nextMonth = DateTime(start.year, start.month + 1, 1); + return nextMonth.subtract(const Duration(seconds: 1)); + default: + final nextMonth = DateTime(start.year, start.month + 1, 1); + return nextMonth.subtract(const Duration(seconds: 1)); + } + } + + bool isDateInCurrentPeriod(DateTime date) { + final start = getCurrentPeriodStart(); + final end = getCurrentPeriodEnd(); + return !date.isBefore(start) && !date.isAfter(end); + } +} diff --git a/app/lib/providers/budget_provider.dart b/app/lib/providers/budget_provider.dart new file mode 100644 index 0000000..d096202 --- /dev/null +++ b/app/lib/providers/budget_provider.dart @@ -0,0 +1,156 @@ +import 'package:flutter/foundation.dart'; +import 'package:totals/models/budget.dart'; +import 'package:totals/repositories/budget_repository.dart'; +import 'package:totals/services/budget_service.dart'; +import 'package:totals/services/budget_alert_service.dart'; +import 'package:totals/providers/transaction_provider.dart'; + +export 'package:totals/services/budget_service.dart' show BudgetStatus; + +class BudgetProvider with ChangeNotifier { + final BudgetRepository _budgetRepository = BudgetRepository(); + final BudgetService _budgetService = BudgetService(); + final BudgetAlertService _budgetAlertService = BudgetAlertService(); + TransactionProvider? _transactionProvider; + + List _budgets = []; + List _budgetStatuses = []; + bool _isLoading = false; + + // Getters + List get budgets => _budgets; + List get budgetStatuses => _budgetStatuses; + bool get isLoading => _isLoading; + + // Set transaction provider for integration + void setTransactionProvider(TransactionProvider provider) { + _transactionProvider = provider; + } + + Future loadBudgets() async { + _isLoading = true; + notifyListeners(); + + try { + _budgets = await _budgetRepository.getActiveBudgets(); + await _refreshBudgetStatuses(); + } catch (e) { + print("debug: Error loading budgets: $e"); + } finally { + _isLoading = false; + notifyListeners(); + } + } + + Future _refreshBudgetStatuses() async { + _budgetStatuses = await _budgetService.getAllBudgetStatuses(); + } + + Future createBudget(Budget budget) async { + try { + final id = await _budgetRepository.insertBudget(budget); + // Get the created budget with its ID + final createdBudget = budget.copyWith(id: id); + await loadBudgets(); + notifyListeners(); + // Check and send notifications for the specific budget that was created + try { + await _budgetAlertService.checkAndNotifyBudgetAlert(createdBudget); + } catch (e) { + print("debug: Error checking budget alerts after creating budget: $e"); + } + return; + } catch (e) { + print("debug: Error creating budget: $e"); + rethrow; + } + } + + Future updateBudget(Budget budget) async { + try { + await _budgetRepository.updateBudget(budget); + await loadBudgets(); + notifyListeners(); + // Check and send notifications for the specific budget that was updated + try { + await _budgetAlertService.checkAndNotifyBudgetAlert(budget); + } catch (e) { + print("debug: Error checking budget alerts after updating budget: $e"); + } + } catch (e) { + print("debug: Error updating budget: $e"); + rethrow; + } + } + + Future deleteBudget(int id) async { + try { + await _budgetRepository.deleteBudget(id); + await loadBudgets(); + notifyListeners(); + } catch (e) { + print("debug: Error deleting budget: $e"); + rethrow; + } + } + + Future deactivateBudget(int id) async { + try { + await _budgetRepository.deactivateBudget(id); + await loadBudgets(); + notifyListeners(); + } catch (e) { + print("debug: Error deactivating budget: $e"); + rethrow; + } + } + + Future activateBudget(int id) async { + try { + await _budgetRepository.activateBudget(id); + await loadBudgets(); + notifyListeners(); + } catch (e) { + print("debug: Error activating budget: $e"); + rethrow; + } + } + + Future> getBudgetsByType(String type) async { + return await _budgetService.getBudgetStatusesByType(type); + } + + Future> getCategoryBudgets() async { + return await _budgetService.getCategoryBudgetStatuses(); + } + + Future getBudgetStatus(int budgetId) async { + final budget = await _budgetRepository.getBudgetById(budgetId); + if (budget == null) return null; + return await _budgetService.getBudgetStatus(budget); + } + + Future refreshBudgetStatuses() async { + await _refreshBudgetStatuses(); + notifyListeners(); + } + + // Check for budget alerts + Future> getBudgetsNeedingAlert() async { + await _refreshBudgetStatuses(); + return _budgetStatuses + .where((status) => status.isApproachingLimit || status.isExceeded) + .toList(); + } + + // Get overall budget status for a type + Future getOverallBudgetStatus(String type) async { + final budgets = await _budgetRepository.getBudgetsByType(type); + if (budgets.isEmpty) return null; + + // For overall budgets, we might have only one active budget per type + // If multiple exist, use the most recent one + final budget = budgets.first; + return await _budgetService.getBudgetStatus(budget); + } +} diff --git a/app/lib/providers/transaction_provider.dart b/app/lib/providers/transaction_provider.dart index 878bc53..783aeea 100644 --- a/app/lib/providers/transaction_provider.dart +++ b/app/lib/providers/transaction_provider.dart @@ -7,6 +7,7 @@ import 'package:totals/repositories/account_repository.dart'; import 'package:totals/repositories/category_repository.dart'; import 'package:totals/repositories/transaction_repository.dart'; import 'package:totals/services/bank_config_service.dart'; +import 'package:totals/services/budget_alert_service.dart'; import 'package:totals/services/receiver_category_service.dart'; import 'package:totals/services/notification_settings_service.dart'; @@ -15,6 +16,7 @@ class TransactionProvider with ChangeNotifier { final AccountRepository _accountRepo = AccountRepository(); final CategoryRepository _categoryRepo = CategoryRepository(); final BankConfigService _bankConfigService = BankConfigService(); + final BudgetAlertService _budgetAlertService = BudgetAlertService(); List _transactions = []; List _accounts = []; @@ -315,6 +317,14 @@ class TransactionProvider with ChangeNotifier { // This logic was in onBackgroundMessage, we should probably centralize it here or in a Service // For now, simpler to just reload everything await loadData(); + // Check budget alerts after adding transaction (only for DEBIT transactions) + if (t.type == 'DEBIT') { + try { + await _budgetAlertService.checkAndNotifyBudgetAlerts(); + } catch (e) { + print("debug: Error checking budget alerts after transaction: $e"); + } + } } Future setCategoryForTransaction( @@ -349,6 +359,16 @@ class TransactionProvider with ChangeNotifier { } await loadData(); + // Check budget alerts after categorizing transaction (only for DEBIT transactions) + // Only check budgets for the specific category that was selected + if (transaction.type == 'DEBIT' && category.id != null) { + try { + await _budgetAlertService + .checkAndNotifyBudgetAlertsForCategory(category.id!); + } catch (e) { + print("debug: Error checking budget alerts after categorizing: $e"); + } + } } Future clearCategoryForTransaction(Transaction transaction) async { @@ -416,8 +436,8 @@ class TransactionProvider with ChangeNotifier { final batch = []; for (final transaction in uncategorizedTransactions) { - final categoryId = await ReceiverCategoryService.instance - .getCategoryForTransaction( + final categoryId = + await ReceiverCategoryService.instance.getCategoryForTransaction( receiver: transaction.receiver, creditor: transaction.creditor, ); diff --git a/app/lib/repositories/budget_repository.dart b/app/lib/repositories/budget_repository.dart new file mode 100644 index 0000000..e4f83d3 --- /dev/null +++ b/app/lib/repositories/budget_repository.dart @@ -0,0 +1,174 @@ +import 'package:sqflite/sqflite.dart'; +import 'package:totals/database/database_helper.dart'; +import 'package:totals/models/budget.dart'; + +class BudgetRepository { + Future> getAllBudgets() async { + final db = await DatabaseHelper.instance.database; + final List> maps = await db.query( + 'budgets', + orderBy: 'createdAt DESC', + ); + + return maps.map((map) => Budget.fromDb(map)).toList(); + } + + Future> getActiveBudgets() async { + final db = await DatabaseHelper.instance.database; + final List> maps = await db.query( + 'budgets', + where: 'isActive = ?', + whereArgs: [1], + orderBy: 'createdAt DESC', + ); + + return maps.map((map) => Budget.fromDb(map)).toList(); + } + + Future> getBudgetsByType(String type) async { + final db = await DatabaseHelper.instance.database; + final List> maps = await db.query( + 'budgets', + where: 'type = ? AND isActive = ?', + whereArgs: [type, 1], + orderBy: 'createdAt DESC', + ); + + return maps.map((map) => Budget.fromDb(map)).toList(); + } + + Future> getCategoryBudgets() async { + final db = await DatabaseHelper.instance.database; + final List> maps = await db.query( + 'budgets', + where: 'type = ? AND isActive = ?', + whereArgs: ['category', 1], + orderBy: 'createdAt DESC', + ); + + return maps.map((map) => Budget.fromDb(map)).toList(); + } + + Future> getBudgetsByCategory(int categoryId) async { + final db = await DatabaseHelper.instance.database; + final List> maps = await db.query( + 'budgets', + where: 'categoryId = ? AND isActive = ?', + whereArgs: [categoryId, 1], + orderBy: 'createdAt DESC', + ); + + return maps.map((map) => Budget.fromDb(map)).toList(); + } + + Future getBudgetById(int id) async { + final db = await DatabaseHelper.instance.database; + final List> maps = await db.query( + 'budgets', + where: 'id = ?', + whereArgs: [id], + limit: 1, + ); + + if (maps.isEmpty) return null; + return Budget.fromDb(maps.first); + } + + Future insertBudget(Budget budget) async { + final db = await DatabaseHelper.instance.database; + final data = budget.toDb(); + data.remove('id'); // Remove id for insert + data['updatedAt'] = DateTime.now().toIso8601String(); + return await db.insert('budgets', data); + } + + Future updateBudget(Budget budget) async { + final db = await DatabaseHelper.instance.database; + final data = budget.toDb(); + data['updatedAt'] = DateTime.now().toIso8601String(); + return await db.update( + 'budgets', + data, + where: 'id = ?', + whereArgs: [budget.id], + ); + } + + Future deleteBudget(int id) async { + final db = await DatabaseHelper.instance.database; + return await db.delete( + 'budgets', + where: 'id = ?', + whereArgs: [id], + ); + } + + Future deactivateBudget(int id) async { + final db = await DatabaseHelper.instance.database; + await db.update( + 'budgets', + { + 'isActive': 0, + 'updatedAt': DateTime.now().toIso8601String(), + }, + where: 'id = ?', + whereArgs: [id], + ); + } + + Future activateBudget(int id) async { + final db = await DatabaseHelper.instance.database; + await db.update( + 'budgets', + { + 'isActive': 1, + 'updatedAt': DateTime.now().toIso8601String(), + }, + where: 'id = ?', + whereArgs: [id], + ); + } + + // Get active budgets for current period + Future> getActiveBudgetsForCurrentPeriod(String type) async { + final now = DateTime.now(); + final db = await DatabaseHelper.instance.database; + + DateTime periodStart; + DateTime periodEnd; + + switch (type) { + case 'daily': + periodStart = DateTime(now.year, now.month, now.day); + periodEnd = DateTime(now.year, now.month, now.day, 23, 59, 59); + break; + case 'monthly': + periodStart = DateTime(now.year, now.month, 1); + final nextMonth = DateTime(now.year, now.month + 1, 1); + periodEnd = nextMonth.subtract(const Duration(seconds: 1)); + break; + case 'yearly': + periodStart = DateTime(now.year, 1, 1); + periodEnd = DateTime(now.year, 12, 31, 23, 59, 59); + break; + default: + periodStart = DateTime(now.year, now.month, 1); + final nextMonth = DateTime(now.year, now.month + 1, 1); + periodEnd = nextMonth.subtract(const Duration(seconds: 1)); + } + + final List> maps = await db.query( + 'budgets', + where: 'type = ? AND isActive = ? AND startDate <= ? AND (endDate IS NULL OR endDate >= ?)', + whereArgs: [ + type, + 1, + periodEnd.toIso8601String(), + periodStart.toIso8601String(), + ], + orderBy: 'createdAt DESC', + ); + + return maps.map((map) => Budget.fromDb(map)).toList(); + } +} diff --git a/app/lib/screens/budget_page.dart b/app/lib/screens/budget_page.dart new file mode 100644 index 0000000..273b7f8 --- /dev/null +++ b/app/lib/screens/budget_page.dart @@ -0,0 +1,289 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:totals/providers/budget_provider.dart'; +import 'package:totals/providers/transaction_provider.dart'; +import 'package:totals/widgets/budget/budget_card.dart'; +import 'package:totals/widgets/budget/budget_alert_banner.dart'; +import 'package:totals/widgets/budget/budget_period_selector.dart'; +import 'package:totals/widgets/budget/category_budget_list.dart'; +import 'package:totals/widgets/budget/budget_form_sheet.dart'; +import 'package:totals/widgets/budget/category_budget_form_sheet.dart'; +import 'package:totals/services/budget_service.dart'; +import 'package:totals/models/budget.dart'; + +class BudgetPage extends StatefulWidget { + const BudgetPage({super.key}); + + @override + State createState() => _BudgetPageState(); +} + +class _BudgetPageState extends State { + String _selectedPeriod = 'monthly'; + String _selectedView = 'overview'; // 'overview' or 'categories' + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + final budgetProvider = Provider.of(context, listen: false); + final transactionProvider = Provider.of(context, listen: false); + budgetProvider.setTransactionProvider(transactionProvider); + budgetProvider.loadBudgets(); + }); + } + + void _showBudgetForm({String? type, int? categoryId, Budget? budget}) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => BudgetFormSheet( + budget: budget, + initialType: type, + initialCategoryId: categoryId, + ), + ).then((_) { + final provider = Provider.of(context, listen: false); + provider.loadBudgets(); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + body: SafeArea( + bottom: false, + child: Column( + children: [ + // App Bar + Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'Budget', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ), + // View Selector + Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.5), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Expanded( + child: _buildViewButton('overview', 'Overview'), + ), + Expanded( + child: _buildViewButton('categories', 'Categories'), + ), + ], + ), + ), + // Content + Expanded( + child: _selectedView == 'overview' + ? _buildOverviewView() + : _buildCategoriesView(), + ), + ], + ), + ), + ); + } + + Widget _buildViewButton(String view, String label) { + final isSelected = _selectedView == view; + return GestureDetector( + onTap: () { + setState(() { + _selectedView = view; + }); + }, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: isSelected + ? Theme.of(context).colorScheme.primary.withOpacity(0.2) + : Colors.transparent, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + label, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + color: isSelected + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ); + } + + Widget _buildOverviewView() { + return Consumer( + builder: (context, budgetProvider, child) { + if (budgetProvider.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + BudgetPeriodSelector( + selectedPeriod: _selectedPeriod, + onPeriodChanged: (period) { + setState(() { + _selectedPeriod = period; + }); + }, + ), + const SizedBox(height: 16), + // Overall Budget Status + FutureBuilder>( + future: budgetProvider.getBudgetsByType(_selectedPeriod), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const Center(child: CircularProgressIndicator()); + } + + final budgets = snapshot.data!; + + if (budgets.isEmpty) { + return _buildEmptyState( + 'No $_selectedPeriod budgets', + 'Create a budget to track your spending', + () => _showBudgetForm(type: _selectedPeriod), + ); + } + + return Column( + children: [ + // Alert banners + ...budgets + .where((status) => + status.isExceeded || status.isApproachingLimit) + .map((status) => BudgetAlertBanner(status: status)), + // Budget cards + ...budgets.map((status) => BudgetCard( + status: status, + onTap: () { + _showBudgetForm(budget: status.budget); + }, + )), + ], + ); + }, + ), + ], + ), + ); + }, + ); + } + + Widget _buildCategoriesView() { + return Consumer( + builder: (context, budgetProvider, child) { + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Category Budgets', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + TextButton.icon( + onPressed: () => _showCategoryBudgetForm(), + icon: const Icon(Icons.add), + label: const Text('Add Category Budget'), + ), + ], + ), + ), + CategoryBudgetList( + onBudgetTap: (budget) => _showCategoryBudgetForm(budget: budget), + ), + ], + ), + ); + }, + ); + } + + void _showCategoryBudgetForm({Budget? budget}) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => CategoryBudgetFormSheet( + budget: budget, + ), + ).then((_) { + final provider = Provider.of(context, listen: false); + provider.loadBudgets(); + }); + } + + Widget _buildEmptyState(String title, String subtitle, VoidCallback onAdd) { + return Padding( + padding: const EdgeInsets.all(32), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.account_balance_wallet_outlined, + size: 64, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + title, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(height: 8), + Text( + subtitle, + style: TextStyle( + fontSize: 14, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: onAdd, + icon: const Icon(Icons.add), + label: const Text('Create Budget'), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/screens/home_page.dart b/app/lib/screens/home_page.dart index e75ab5e..ea39d70 100644 --- a/app/lib/screens/home_page.dart +++ b/app/lib/screens/home_page.dart @@ -17,6 +17,7 @@ import 'package:totals/widgets/custom_bottom_nav.dart'; import 'package:totals/widgets/detected_banks_widget.dart'; import 'package:totals/screens/failed_parses_page.dart'; import 'package:totals/screens/analytics_page.dart'; +import 'package:totals/screens/budget_page.dart'; import 'package:totals/screens/web_page.dart'; import 'package:totals/screens/settings_page.dart'; import 'package:totals/services/notification_service.dart'; @@ -41,7 +42,7 @@ class _HomePageState extends State with WidgetsBindingObserver { final LocalAuthentication _auth = LocalAuthentication(); final SmsService _smsService = SmsService(); final PageController _pageController = PageController(); - final PageController _mainPageController = PageController(); + final PageController _mainPageController = PageController(initialPage: 2); // Start on Home (index 2) bool _isAuthenticated = false; bool _isAuthenticating = false; @@ -55,7 +56,7 @@ class _HomePageState extends State with WidgetsBindingObserver { bool showTotalBalance = false; List visibleTotalBalancesForSubCards = []; int activeTab = 0; - int _bottomNavIndex = 0; + int _bottomNavIndex = 2; // Home is now at index 2 (center) StreamSubscription? _notificationIntentSub; String? _pendingNotificationReference; String? _highlightedReference; @@ -191,11 +192,11 @@ class _HomePageState extends State with WidgetsBindingObserver { }) async { if (!mounted) return; - if (_bottomNavIndex != 0) { + if (_bottomNavIndex != 2) { // Home is now at index 2 setState(() { - _bottomNavIndex = 0; + _bottomNavIndex = 2; // Home is now at index 2 }); - _mainPageController.jumpToPage(0); + _mainPageController.jumpToPage(2); } changeTab(HomeTabs.recentTabId); @@ -1101,6 +1102,8 @@ class _HomePageState extends State with WidgetsBindingObserver { controller: _mainPageController, physics: const NeverScrollableScrollPhysics(), children: [ + const AnalyticsPage(), // index 0 + const BudgetPage(), // index 1 Consumer( builder: (context, provider, child) { return Scaffold( @@ -1109,10 +1112,9 @@ class _HomePageState extends State with WidgetsBindingObserver { body: _buildHomeContent(provider), ); }, - ), - const AnalyticsPage(), - const WebPage(), - const SettingsPage(), + ), // index 2 - Home + const WebPage(), // index 3 + const SettingsPage(), // index 4 ], ); } diff --git a/app/lib/screens/notification_settings_page.dart b/app/lib/screens/notification_settings_page.dart index 16dade1..4505b41 100644 --- a/app/lib/screens/notification_settings_page.dart +++ b/app/lib/screens/notification_settings_page.dart @@ -17,6 +17,7 @@ class NotificationSettingsPage extends StatefulWidget { class _NotificationSettingsPageState extends State { bool _loading = true; bool _transactionEnabled = true; + bool _budgetEnabled = true; bool _dailyEnabled = true; TimeOfDay _dailyTime = const TimeOfDay(hour: 20, minute: 0); DateTime? _lastDailySummarySentAt; @@ -30,12 +31,14 @@ class _NotificationSettingsPageState extends State { Future _load() async { final settings = NotificationSettingsService.instance; final tx = await settings.isTransactionNotificationsEnabled(); + final budget = await settings.isBudgetAlertsEnabled(); final daily = await settings.isDailySummaryEnabled(); final time = await settings.getDailySummaryTime(); final lastSent = await settings.getDailySummaryLastSentAt(); if (!mounted) return; setState(() { _transactionEnabled = tx; + _budgetEnabled = budget; _dailyEnabled = daily; _dailyTime = time; _lastDailySummarySentAt = lastSent; @@ -49,6 +52,11 @@ class _NotificationSettingsPageState extends State { .setTransactionNotificationsEnabled(value); } + Future _setBudgetEnabled(bool value) async { + setState(() => _budgetEnabled = value); + await NotificationSettingsService.instance.setBudgetAlertsEnabled(value); + } + Future _setDailyEnabled(bool value) async { setState(() => _dailyEnabled = value); await NotificationSettingsService.instance.setDailySummaryEnabled(value); @@ -213,6 +221,16 @@ class _NotificationSettingsPageState extends State { ), ), const SizedBox(height: 8), + Card( + child: SwitchListTile( + value: _budgetEnabled, + onChanged: _setBudgetEnabled, + title: const Text('Budget alerts'), + subtitle: + const Text('Notify when budget limits are reached'), + ), + ), + const SizedBox(height: 8), Card( child: SwitchListTile( value: _dailyEnabled, diff --git a/app/lib/services/budget_alert_service.dart b/app/lib/services/budget_alert_service.dart new file mode 100644 index 0000000..5d7b766 --- /dev/null +++ b/app/lib/services/budget_alert_service.dart @@ -0,0 +1,168 @@ +import 'package:totals/models/budget.dart'; +import 'package:totals/services/budget_service.dart'; +import 'package:totals/services/notification_service.dart'; +import 'package:totals/services/notification_settings_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class BudgetAlertService { + final BudgetService _budgetService = BudgetService(); + + static const int _budgetNotificationIdBase = 10000; + static const String _alertSentPrefix = 'budget_alert_sent'; + + // Check budgets against current spending and generate alerts + Future> checkBudgetAlerts() async { + final statuses = await _budgetService.getAllBudgetStatuses(); + final alerts = []; + + for (final status in statuses) { + if (status.isExceeded) { + alerts.add(BudgetAlert( + budget: status.budget, + status: status, + alertType: BudgetAlertType.exceeded, + message: _getExceededMessage(status), + )); + } else if (status.isApproachingLimit) { + alerts.add(BudgetAlert( + budget: status.budget, + status: status, + alertType: BudgetAlertType.approaching, + message: _getApproachingMessage(status), + )); + } + } + + return alerts; + } + + String _getExceededMessage(BudgetStatus status) { + final overAmount = status.spent - status.budget.amount; + return '${status.budget.name} budget exceeded by ${_formatCurrency(overAmount)}'; + } + + String _getApproachingMessage(BudgetStatus status) { + final percentage = status.percentageUsed.toStringAsFixed(1); + return '${status.budget.name} budget is ${percentage}% used'; + } + + String _formatCurrency(double amount) { + return 'ETB ${amount.toStringAsFixed(2)}'; + } + + // Send notification for budget alerts + Future sendBudgetAlertNotification(BudgetAlert alert) async { + final enabled = + await NotificationSettingsService.instance.isBudgetAlertsEnabled(); + if (!enabled) return; + + final alreadySent = await _hasSentAlert(alert); + if (alreadySent) return; + + final title = alert.alertType == BudgetAlertType.exceeded + ? 'Budget Exceeded' + : 'Budget Warning'; + + final id = _budgetNotificationIdBase + (alert.budget.id ?? 0); + + await NotificationService.instance.showBudgetAlertNotification( + id: id, + title: title, + body: alert.message, + ); + + await _markAlertSent(alert); + } + + Future _hasSentAlert(BudgetAlert alert) async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_alertKey(alert)) ?? false; + } + + Future _markAlertSent(BudgetAlert alert) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_alertKey(alert), true); + } + + String _alertKey(BudgetAlert alert) { + final budgetId = alert.budget.id ?? 0; + final periodStart = alert.status.periodStart.millisecondsSinceEpoch; + final type = _alertTypeKey(alert.alertType); + return '$_alertSentPrefix:$budgetId:$type:$periodStart'; + } + + String _alertTypeKey(BudgetAlertType type) { + switch (type) { + case BudgetAlertType.approaching: + return 'approaching'; + case BudgetAlertType.exceeded: + return 'exceeded'; + } + } + + // Check and send notifications for all budget alerts + Future checkAndNotifyBudgetAlerts() async { + final alerts = await checkBudgetAlerts(); + for (final alert in alerts) { + await sendBudgetAlertNotification(alert); + } + } + + // Check and send notification for a specific budget + Future checkAndNotifyBudgetAlert(Budget budget) async { + try { + final status = await _budgetService.getBudgetStatus(budget); + + if (status.isExceeded) { + final alert = BudgetAlert( + budget: budget, + status: status, + alertType: BudgetAlertType.exceeded, + message: _getExceededMessage(status), + ); + await sendBudgetAlertNotification(alert); + } else if (status.isApproachingLimit) { + final alert = BudgetAlert( + budget: budget, + status: status, + alertType: BudgetAlertType.approaching, + message: _getApproachingMessage(status), + ); + await sendBudgetAlertNotification(alert); + } + } catch (e) { + print('debug: Failed to check budget alert for budget ${budget.id}: $e'); + } + } + + // Check and send notifications for budgets of a specific category + Future checkAndNotifyBudgetAlertsForCategory(int categoryId) async { + try { + final budgets = await _budgetService.getBudgetsByCategory(categoryId); + for (final budget in budgets) { + await checkAndNotifyBudgetAlert(budget); + } + } catch (e) { + print('debug: Failed to check budget alerts for category $categoryId: $e'); + } + } +} + +class BudgetAlert { + final Budget budget; + final BudgetStatus status; + final BudgetAlertType alertType; + final String message; + + BudgetAlert({ + required this.budget, + required this.status, + required this.alertType, + required this.message, + }); +} + +enum BudgetAlertType { + approaching, + exceeded, +} diff --git a/app/lib/services/budget_service.dart b/app/lib/services/budget_service.dart new file mode 100644 index 0000000..c3e2eda --- /dev/null +++ b/app/lib/services/budget_service.dart @@ -0,0 +1,163 @@ +import 'package:totals/models/budget.dart'; +import 'package:totals/models/transaction.dart'; +import 'package:totals/repositories/budget_repository.dart'; +import 'package:totals/repositories/transaction_repository.dart'; + +class BudgetService { + final BudgetRepository _budgetRepository = BudgetRepository(); + final TransactionRepository _transactionRepository = TransactionRepository(); + + // Calculate spending for a given period and category + Future calculateSpending({ + required DateTime startDate, + required DateTime endDate, + int? categoryId, + }) async { + final transactions = await _transactionRepository.getTransactionsByDateRange( + startDate, + endDate, + type: 'DEBIT', // Only count expenses + ); + + if (categoryId != null) { + final filtered = transactions + .where((t) => t.categoryId == categoryId) + .toList(); + return filtered.fold(0.0, (sum, t) => sum + t.amount.abs()); + } + + return transactions.fold(0.0, (sum, t) => sum + t.amount.abs()); + } + + // Calculate budget usage/spent amounts for a budget + Future getBudgetStatus(Budget budget) async { + final periodStart = budget.getCurrentPeriodStart(); + final periodEnd = budget.getCurrentPeriodEnd(); + + final spent = await calculateSpending( + startDate: periodStart, + endDate: periodEnd, + categoryId: budget.categoryId, + ); + + final remaining = budget.amount - spent; + final percentageUsed = budget.amount > 0 ? (spent / budget.amount) * 100 : 0.0; + final isExceeded = spent > budget.amount; + final isApproachingLimit = percentageUsed >= budget.alertThreshold; + + return BudgetStatus( + budget: budget, + spent: spent, + remaining: remaining, + percentageUsed: percentageUsed, + isExceeded: isExceeded, + isApproachingLimit: isApproachingLimit, + periodStart: periodStart, + periodEnd: periodEnd, + ); + } + + // Get all active budgets with their status + Future> getAllBudgetStatuses() async { + final budgets = await _budgetRepository.getActiveBudgets(); + final statuses = []; + + for (final budget in budgets) { + final status = await getBudgetStatus(budget); + statuses.add(status); + } + + return statuses; + } + + // Get budgets by type with status + Future> getBudgetStatusesByType(String type) async { + final budgets = await _budgetRepository.getBudgetsByType(type); + final statuses = []; + + for (final budget in budgets) { + final status = await getBudgetStatus(budget); + statuses.add(status); + } + + return statuses; + } + + // Get category budgets with status + Future> getCategoryBudgetStatuses() async { + final budgets = await _budgetRepository.getCategoryBudgets(); + final statuses = []; + + for (final budget in budgets) { + final status = await getBudgetStatus(budget); + statuses.add(status); + } + + return statuses; + } + + // Get budgets by category ID + Future> getBudgetsByCategory(int categoryId) async { + return await _budgetRepository.getBudgetsByCategory(categoryId); + } + + // Check if budget is exceeded or approaching limit + Future isBudgetExceeded(Budget budget) async { + final status = await getBudgetStatus(budget); + return status.isExceeded; + } + + Future isBudgetApproachingLimit(Budget budget) async { + final status = await getBudgetStatus(budget); + return status.isApproachingLimit; + } + + // Handle budget rollover logic + Future handleBudgetRollover(Budget budget) async { + if (!budget.rollover) return; + + final now = DateTime.now(); + final periodEnd = budget.getCurrentPeriodEnd(); + + // If current period has ended, check for rollover + if (now.isAfter(periodEnd)) { + final status = await getBudgetStatus(budget); + final remaining = status.remaining; + + if (remaining > 0) { + // Create a new budget entry with rolled over amount + final newStartDate = budget.getCurrentPeriodStart(); + final rolledOverBudget = budget.copyWith( + id: null, + amount: budget.amount + remaining, + startDate: newStartDate, + createdAt: DateTime.now(), + ); + + await _budgetRepository.insertBudget(rolledOverBudget); + } + } + } +} + +class BudgetStatus { + final Budget budget; + final double spent; + final double remaining; + final double percentageUsed; + final bool isExceeded; + final bool isApproachingLimit; + final DateTime periodStart; + final DateTime periodEnd; + + BudgetStatus({ + required this.budget, + required this.spent, + required this.remaining, + required this.percentageUsed, + required this.isExceeded, + required this.isApproachingLimit, + required this.periodStart, + required this.periodEnd, + }); +} diff --git a/app/lib/services/notification_service.dart b/app/lib/services/notification_service.dart index ff86f91..e4d4056 100644 --- a/app/lib/services/notification_service.dart +++ b/app/lib/services/notification_service.dart @@ -15,6 +15,7 @@ class NotificationService { static const String _transactionChannelId = 'transactions'; static const String _dailySpendingChannelId = 'daily_spending'; static const String _accountSyncChannelId = 'account_sync'; + static const String _budgetChannelId = 'budgets'; static const int dailySpendingNotificationId = 9001; static const int dailySpendingTestNotificationId = 9002; @@ -63,6 +64,14 @@ class NotificationService { importance: Importance.low, ), ); + await androidPlugin?.createNotificationChannel( + const AndroidNotificationChannel( + _budgetChannelId, + 'Budget Alerts', + description: 'Notifications for budget warnings and alerts', + importance: Importance.defaultImportance, + ), + ); _initialized = true; } @@ -333,6 +342,36 @@ class NotificationService { } } + Future showBudgetAlertNotification({ + required int id, + required String title, + required String body, + }) async { + try { + await ensureInitialized(); + + await _plugin.show( + id, + title, + body, + const NotificationDetails( + android: AndroidNotificationDetails( + _budgetChannelId, + 'Budget Alerts', + channelDescription: 'Notifications for budget warnings and alerts', + importance: Importance.defaultImportance, + priority: Priority.defaultPriority, + ), + iOS: DarwinNotificationDetails(), + ), + ); + } catch (e) { + if (kDebugMode) { + print('debug: Failed to show budget alert notification: $e'); + } + } + } + static Bank? _findBank(int? bankId) { if (bankId == null) return null; for (final bank in AppConstants.banks) { diff --git a/app/lib/services/notification_settings_service.dart b/app/lib/services/notification_settings_service.dart index f0beebb..4567568 100644 --- a/app/lib/services/notification_settings_service.dart +++ b/app/lib/services/notification_settings_service.dart @@ -8,6 +8,7 @@ class NotificationSettingsService { NotificationSettingsService._(); static const _kTransactionEnabled = 'notifications_transaction_enabled'; + static const _kBudgetEnabled = 'notifications_budget_enabled'; static const _kDailyEnabled = 'notifications_daily_enabled'; static const _kDailyHour = 'notifications_daily_hour'; static const _kDailyMinute = 'notifications_daily_minute'; @@ -24,6 +25,16 @@ class NotificationSettingsService { await prefs.setBool(_kTransactionEnabled, enabled); } + Future isBudgetAlertsEnabled() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_kBudgetEnabled) ?? true; + } + + Future setBudgetAlertsEnabled(bool enabled) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_kBudgetEnabled, enabled); + } + Future isDailySummaryEnabled() async { final prefs = await SharedPreferences.getInstance(); return prefs.getBool(_kDailyEnabled) ?? true; diff --git a/app/lib/services/sms_service.dart b/app/lib/services/sms_service.dart index b52e598..63fda65 100644 --- a/app/lib/services/sms_service.dart +++ b/app/lib/services/sms_service.dart @@ -13,6 +13,7 @@ import 'package:totals/models/failed_parse.dart'; import 'package:totals/repositories/failed_parse_repository.dart'; import 'package:flutter/widgets.dart'; import 'package:totals/services/notification_service.dart'; +import 'package:totals/services/budget_alert_service.dart'; enum ParseStatus { success, @@ -455,6 +456,14 @@ class SmsService { ); } + if (newTx.type == 'DEBIT') { + try { + await BudgetAlertService().checkAndNotifyBudgetAlerts(); + } catch (e) { + print("debug: Error checking budget alerts after SMS transaction: $e"); + } + } + return ParseResult( status: ParseStatus.success, transaction: newTx, diff --git a/app/lib/widgets/budget/budget_alert_banner.dart b/app/lib/widgets/budget/budget_alert_banner.dart new file mode 100644 index 0000000..e6a8316 --- /dev/null +++ b/app/lib/widgets/budget/budget_alert_banner.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:totals/services/budget_service.dart'; + +class BudgetAlertBanner extends StatelessWidget { + final BudgetStatus status; + + const BudgetAlertBanner({ + super.key, + required this.status, + }); + + @override + Widget build(BuildContext context) { + if (!status.isExceeded && !status.isApproachingLimit) { + return const SizedBox.shrink(); + } + + final isExceeded = status.isExceeded; + final color = isExceeded ? Colors.red : Colors.orange; + final icon = isExceeded ? Icons.warning : Icons.info_outline; + final message = isExceeded + ? '${status.budget.name} budget exceeded by ${(status.spent - status.budget.amount).toStringAsFixed(2)}' + : '${status.budget.name} budget is ${status.percentageUsed.toStringAsFixed(1)}% used'; + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: color.withOpacity(0.3), + width: 1, + ), + ), + child: Row( + children: [ + Icon( + icon, + color: color, + size: 20, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + message, + style: TextStyle( + color: color, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/widgets/budget/budget_card.dart b/app/lib/widgets/budget/budget_card.dart new file mode 100644 index 0000000..6008f1f --- /dev/null +++ b/app/lib/widgets/budget/budget_card.dart @@ -0,0 +1,211 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; +import 'package:totals/services/budget_service.dart'; +import 'package:totals/providers/transaction_provider.dart'; +import 'package:totals/utils/category_icons.dart'; +import 'package:totals/utils/category_style.dart'; +import 'package:totals/widgets/budget/budget_progress_bar.dart'; + +class BudgetCard extends StatelessWidget { + final BudgetStatus status; + final VoidCallback? onTap; + + const BudgetCard({ + super.key, + required this.status, + this.onTap, + }); + + String _formatCurrency(double amount) { + final formatter = NumberFormat.currency(symbol: 'ETB ', decimalDigits: 2); + return formatter.format(amount); + } + + Color _getStatusColor() { + if (status.isExceeded) { + return Colors.red; + } else if (status.isApproachingLimit) { + return Colors.orange; + } else if (status.percentageUsed < 70) { + return Colors.green; + } else { + return Colors.yellow; + } + } + + String _getStatusText() { + if (status.isExceeded) { + return 'Exceeded'; + } else if (status.isApproachingLimit) { + return 'Warning'; + } else { + return 'On Track'; + } + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final statusColor = _getStatusColor(); + final isCategoryBudget = status.budget.type == 'category' && status.budget.categoryId != null; + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Row( + children: [ + if (isCategoryBudget) + Consumer( + builder: (context, transactionProvider, _) { + try { + final category = transactionProvider.categories.firstWhere( + (c) => c.id == status.budget.categoryId, + ); + final categoryColor = categoryTypeColor(category, context); + return Container( + margin: const EdgeInsets.only(right: 12), + width: 40, + height: 40, + decoration: BoxDecoration( + color: categoryColor.withOpacity(0.12), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + iconForCategoryKey(category.iconKey), + color: categoryColor, + size: 20, + ), + ); + } catch (e) { + return const SizedBox.shrink(); + } + }, + ), + Expanded( + child: Text( + status.budget.name, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: statusColor.withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _getStatusText(), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: statusColor, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Spent', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + Text( + _formatCurrency(status.spent), + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ], + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + 'Budget', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + Text( + _formatCurrency(status.budget.amount), + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ], + ), + ], + ), + const SizedBox(height: 12), + BudgetProgressBar(status: status), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${status.percentageUsed.toStringAsFixed(1)}% used', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + Text( + status.remaining >= 0 + ? '${_formatCurrency(status.remaining)} remaining' + : '${_formatCurrency(status.remaining.abs())} over', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: status.remaining >= 0 + ? Colors.green + : Colors.red, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/app/lib/widgets/budget/budget_form_sheet.dart b/app/lib/widgets/budget/budget_form_sheet.dart new file mode 100644 index 0000000..7d25a5e --- /dev/null +++ b/app/lib/widgets/budget/budget_form_sheet.dart @@ -0,0 +1,377 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:totals/models/budget.dart'; +import 'package:totals/providers/budget_provider.dart'; +import 'package:totals/providers/transaction_provider.dart'; + +class BudgetFormSheet extends StatefulWidget { + final Budget? budget; + final String? initialType; + final int? initialCategoryId; + + const BudgetFormSheet({ + super.key, + this.budget, + this.initialType, + this.initialCategoryId, + }); + + @override + State createState() => _BudgetFormSheetState(); +} + +class _BudgetFormSheetState extends State { + final _formKey = GlobalKey(); + final _nameController = TextEditingController(); + final _amountController = TextEditingController(); + final _alertThresholdController = TextEditingController(); + + String _selectedType = 'monthly'; + int? _selectedCategoryId; + bool _rollover = false; + bool _isLoading = false; + + @override + void initState() { + super.initState(); + if (widget.budget != null) { + _nameController.text = widget.budget!.name; + _amountController.text = widget.budget!.amount.toStringAsFixed(2); + _alertThresholdController.text = widget.budget!.alertThreshold.toStringAsFixed(1); + _selectedType = widget.budget!.type; + _selectedCategoryId = widget.budget!.categoryId; + _rollover = widget.budget!.rollover; + } else { + if (widget.initialType != null) { + _selectedType = widget.initialType!; + } + if (widget.initialCategoryId != null) { + _selectedCategoryId = widget.initialCategoryId; + _selectedType = 'category'; + } + } + } + + @override + void dispose() { + _nameController.dispose(); + _amountController.dispose(); + _alertThresholdController.dispose(); + super.dispose(); + } + + DateTime _getPeriodStart() { + final now = DateTime.now(); + switch (_selectedType) { + case 'daily': + return DateTime(now.year, now.month, now.day); + case 'monthly': + return DateTime(now.year, now.month, 1); + case 'yearly': + return DateTime(now.year, 1, 1); + case 'category': + return DateTime(now.year, now.month, 1); + default: + return DateTime(now.year, now.month, 1); + } + } + + Future _saveBudget() async { + if (!_formKey.currentState!.validate()) return; + + setState(() { + _isLoading = true; + }); + + try { + final amount = double.parse(_amountController.text); + final alertThreshold = double.parse(_alertThresholdController.text); + + final budget = Budget( + id: widget.budget?.id, + name: _nameController.text.trim(), + type: _selectedType, + amount: amount, + categoryId: _selectedCategoryId, + startDate: widget.budget?.startDate ?? _getPeriodStart(), + rollover: _rollover, + alertThreshold: alertThreshold, + isActive: widget.budget?.isActive ?? true, + createdAt: widget.budget?.createdAt ?? DateTime.now(), + ); + + final provider = Provider.of(context, listen: false); + if (widget.budget == null) { + await provider.createBudget(budget); + } else { + await provider.updateBudget(budget); + } + + if (mounted) { + Navigator.of(context).pop(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error saving budget: $e')), + ); + } + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + Future _deleteBudget() async { + if (widget.budget?.id == null) return; + + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete Budget'), + content: Text('Are you sure you want to delete "${widget.budget!.name}"? This action cannot be undone.'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + style: TextButton.styleFrom( + foregroundColor: Colors.red, + ), + child: const Text('Delete'), + ), + ], + ), + ); + + if (confirmed != true) return; + + setState(() { + _isLoading = true; + }); + + try { + final provider = Provider.of(context, listen: false); + await provider.deleteBudget(widget.budget!.id!); + + if (mounted) { + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Budget deleted successfully')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error deleting budget: $e')), + ); + } + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final categories = Provider.of(context, listen: false) + .categories + .where((c) => c.flow == 'expense') + .toList(); + + return Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Theme.of(context).scaffoldBackgroundColor, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + ), + child: Form( + key: _formKey, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + widget.budget == null ? 'Create Budget' : 'Edit Budget', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(height: 24), + TextFormField( + controller: _nameController, + decoration: const InputDecoration( + labelText: 'Budget Name', + hintText: 'e.g., Monthly Groceries', + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Please enter a budget name'; + } + return null; + }, + ), + const SizedBox(height: 16), + DropdownButtonFormField( + value: _selectedType, + decoration: const InputDecoration( + labelText: 'Budget Type', + ), + items: const [ + DropdownMenuItem(value: 'daily', child: Text('Daily')), + DropdownMenuItem(value: 'monthly', child: Text('Monthly')), + DropdownMenuItem(value: 'yearly', child: Text('Yearly')), + DropdownMenuItem(value: 'category', child: Text('Category')), + ], + onChanged: (value) { + if (value != null) { + setState(() { + _selectedType = value; + if (value != 'category') { + _selectedCategoryId = null; + } + }); + } + }, + ), + if (_selectedType == 'category') ...[ + const SizedBox(height: 16), + DropdownButtonFormField( + value: _selectedCategoryId, + decoration: const InputDecoration( + labelText: 'Category', + ), + items: [ + const DropdownMenuItem( + value: null, + child: Text('Select a category'), + ), + ...categories.map((category) { + return DropdownMenuItem( + value: category.id, + child: Text(category.name), + ); + }), + ], + onChanged: (value) { + setState(() { + _selectedCategoryId = value; + }); + }, + validator: (value) { + if (_selectedType == 'category' && value == null) { + return 'Please select a category'; + } + return null; + }, + ), + ], + const SizedBox(height: 16), + TextFormField( + controller: _amountController, + decoration: const InputDecoration( + labelText: 'Budget Amount', + hintText: '0.00', + prefixText: 'ETB ', + ), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Please enter an amount'; + } + final amount = double.tryParse(value); + if (amount == null || amount <= 0) { + return 'Please enter a valid amount'; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _alertThresholdController, + decoration: const InputDecoration( + labelText: 'Alert Threshold (%)', + hintText: '80', + helperText: 'Get notified when budget reaches this percentage', + ), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Please enter an alert threshold'; + } + final threshold = double.tryParse(value); + if (threshold == null || threshold < 0 || threshold > 100) { + return 'Please enter a value between 0 and 100'; + } + return null; + }, + ), + const SizedBox(height: 16), + SwitchListTile( + title: const Text('Enable Rollover'), + subtitle: const Text('Unused budget carries over to next period'), + value: _rollover, + onChanged: (value) { + setState(() { + _rollover = value; + }); + }, + ), + const SizedBox(height: 24), + if (widget.budget != null) ...[ + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _isLoading ? null : _deleteBudget, + icon: const Icon(Icons.delete_outline), + label: const Text('Delete Budget'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red, + side: BorderSide(color: Colors.red.withOpacity(0.5)), + ), + ), + ), + const SizedBox(height: 16), + ], + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: _isLoading ? null : () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ), + const SizedBox(width: 16), + Expanded( + flex: 2, + child: ElevatedButton( + onPressed: _isLoading ? null : _saveBudget, + child: _isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(widget.budget == null ? 'Create' : 'Update'), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/app/lib/widgets/budget/budget_period_selector.dart b/app/lib/widgets/budget/budget_period_selector.dart new file mode 100644 index 0000000..6be098c --- /dev/null +++ b/app/lib/widgets/budget/budget_period_selector.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; + +class BudgetPeriodSelector extends StatelessWidget { + final String selectedPeriod; + final ValueChanged onPeriodChanged; + + const BudgetPeriodSelector({ + super.key, + required this.selectedPeriod, + required this.onPeriodChanged, + }); + + @override + Widget build(BuildContext context) { + final periods = ['daily', 'monthly', 'yearly']; + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.5), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: periods.map((period) { + final isSelected = selectedPeriod == period; + return Expanded( + child: GestureDetector( + onTap: () => onPeriodChanged(period), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: isSelected + ? Theme.of(context).colorScheme.primary.withOpacity(0.2) + : Colors.transparent, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + period[0].toUpperCase() + period.substring(1), + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + color: isSelected + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ); + }).toList(), + ), + ); + } +} diff --git a/app/lib/widgets/budget/budget_progress_bar.dart b/app/lib/widgets/budget/budget_progress_bar.dart new file mode 100644 index 0000000..44dc85b --- /dev/null +++ b/app/lib/widgets/budget/budget_progress_bar.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:totals/services/budget_service.dart'; + +class BudgetProgressBar extends StatelessWidget { + final BudgetStatus status; + final double height; + + const BudgetProgressBar({ + super.key, + required this.status, + this.height = 8.0, + }); + + Color _getProgressColor() { + if (status.isExceeded) { + return Colors.red; + } else if (status.isApproachingLimit) { + return Colors.orange; + } else if (status.percentageUsed < 70) { + return Colors.green; + } else { + return Colors.yellow; + } + } + + @override + Widget build(BuildContext context) { + final percentage = status.percentageUsed.clamp(0.0, 100.0); + final color = _getProgressColor(); + + return ClipRRect( + borderRadius: BorderRadius.circular(height / 2), + child: LinearProgressIndicator( + value: percentage / 100, + minHeight: height, + backgroundColor: Theme.of(context).colorScheme.surfaceVariant, + valueColor: AlwaysStoppedAnimation(color), + ), + ); + } +} diff --git a/app/lib/widgets/budget/category_budget_form_sheet.dart b/app/lib/widgets/budget/category_budget_form_sheet.dart new file mode 100644 index 0000000..d835fd8 --- /dev/null +++ b/app/lib/widgets/budget/category_budget_form_sheet.dart @@ -0,0 +1,311 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:totals/models/budget.dart'; +import 'package:totals/providers/budget_provider.dart'; +import 'package:totals/providers/transaction_provider.dart'; + +class CategoryBudgetFormSheet extends StatefulWidget { + final Budget? budget; + + const CategoryBudgetFormSheet({ + super.key, + this.budget, + }); + + @override + State createState() => _CategoryBudgetFormSheetState(); +} + +class _CategoryBudgetFormSheetState extends State { + final _formKey = GlobalKey(); + final _amountController = TextEditingController(); + final _alertThresholdController = TextEditingController(); + + int? _selectedCategoryId; + bool _isLoading = false; + + @override + void initState() { + super.initState(); + if (widget.budget != null) { + _amountController.text = widget.budget!.amount.toStringAsFixed(2); + _alertThresholdController.text = widget.budget!.alertThreshold.toStringAsFixed(1); + _selectedCategoryId = widget.budget!.categoryId; + } + } + + @override + void dispose() { + _amountController.dispose(); + _alertThresholdController.dispose(); + super.dispose(); + } + + DateTime _getPeriodStart() { + final now = DateTime.now(); + return DateTime(now.year, now.month, 1); + } + + Future _saveBudget() async { + if (!_formKey.currentState!.validate()) return; + + setState(() { + _isLoading = true; + }); + + try { + final amount = double.parse(_amountController.text); + final alertThreshold = double.parse(_alertThresholdController.text); + + // Get category name for budget name + final transactionProvider = Provider.of(context, listen: false); + final category = transactionProvider.categories.firstWhere( + (c) => c.id == _selectedCategoryId, + orElse: () => throw Exception('Category not found'), + ); + + final budget = Budget( + id: widget.budget?.id, + name: '${category.name} Budget', // Auto-generate name from category + type: 'category', // Always 'category' type + amount: amount, + categoryId: _selectedCategoryId, + startDate: widget.budget?.startDate ?? _getPeriodStart(), + rollover: false, // Default to false for category budgets + alertThreshold: alertThreshold, + isActive: widget.budget?.isActive ?? true, + createdAt: widget.budget?.createdAt ?? DateTime.now(), + ); + + final provider = Provider.of(context, listen: false); + if (widget.budget == null) { + await provider.createBudget(budget); + } else { + await provider.updateBudget(budget); + } + + if (mounted) { + Navigator.of(context).pop(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error saving budget: $e')), + ); + } + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + Future _deleteBudget() async { + if (widget.budget?.id == null) return; + + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete Budget'), + content: Text('Are you sure you want to delete this category budget? This action cannot be undone.'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + style: TextButton.styleFrom( + foregroundColor: Colors.red, + ), + child: const Text('Delete'), + ), + ], + ), + ); + + if (confirmed != true) return; + + setState(() { + _isLoading = true; + }); + + try { + final provider = Provider.of(context, listen: false); + await provider.deleteBudget(widget.budget!.id!); + + if (mounted) { + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Budget deleted successfully')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error deleting budget: $e')), + ); + } + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final categories = Provider.of(context, listen: false) + .categories + .where((c) => c.flow == 'expense') + .toList(); + + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Theme.of(context).scaffoldBackgroundColor, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + ), + child: Form( + key: _formKey, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + widget.budget == null ? 'Create Category Budget' : 'Edit Category Budget', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(height: 24), + DropdownButtonFormField( + value: _selectedCategoryId, + decoration: const InputDecoration( + labelText: 'Category', + ), + items: [ + const DropdownMenuItem( + value: null, + child: Text('Select a category'), + ), + ...categories.map((category) { + return DropdownMenuItem( + value: category.id, + child: Text(category.name), + ); + }), + ], + onChanged: widget.budget == null + ? (value) { + setState(() { + _selectedCategoryId = value; + }); + } + : null, // Disable category selection when editing + validator: (value) { + if (value == null) { + return 'Please select a category'; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _amountController, + decoration: const InputDecoration( + labelText: 'Budget Amount', + hintText: '0.00', + prefixText: 'ETB ', + ), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Please enter an amount'; + } + final amount = double.tryParse(value); + if (amount == null || amount <= 0) { + return 'Please enter a valid amount'; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _alertThresholdController, + decoration: const InputDecoration( + labelText: 'Alert Threshold (%)', + hintText: '80', + helperText: 'Get notified when budget reaches this percentage', + ), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Please enter an alert threshold'; + } + final threshold = double.tryParse(value); + if (threshold == null || threshold < 0 || threshold > 100) { + return 'Please enter a value between 0 and 100'; + } + return null; + }, + ), + const SizedBox(height: 24), + if (widget.budget != null) ...[ + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _isLoading ? null : _deleteBudget, + icon: const Icon(Icons.delete_outline), + label: const Text('Delete Budget'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red, + side: BorderSide(color: Colors.red.withOpacity(0.5)), + ), + ), + ), + const SizedBox(height: 16), + ], + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: _isLoading ? null : () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ), + const SizedBox(width: 16), + Expanded( + flex: 2, + child: ElevatedButton( + onPressed: _isLoading ? null : _saveBudget, + child: _isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(widget.budget == null ? 'Create' : 'Update'), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/app/lib/widgets/budget/category_budget_list.dart b/app/lib/widgets/budget/category_budget_list.dart new file mode 100644 index 0000000..c766871 --- /dev/null +++ b/app/lib/widgets/budget/category_budget_list.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:totals/providers/budget_provider.dart'; +import 'package:totals/providers/transaction_provider.dart'; +import 'package:totals/widgets/budget/budget_card.dart'; +import 'package:totals/models/budget.dart'; + +class CategoryBudgetList extends StatelessWidget { + final Function(Budget)? onBudgetTap; + + const CategoryBudgetList({super.key, this.onBudgetTap}); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, budgetProvider, child) { + if (budgetProvider.isLoading) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + return FutureBuilder( + future: budgetProvider.getCategoryBudgets(), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + final categoryBudgets = snapshot.data!; + + if (categoryBudgets.isEmpty) { + return Padding( + padding: const EdgeInsets.all(32), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.account_balance_wallet_outlined, + size: 64, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + 'No category budgets yet', + style: TextStyle( + fontSize: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Text( + 'Create a budget for a specific category', + style: TextStyle( + fontSize: 14, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } + + return ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: categoryBudgets.length, + itemBuilder: (context, index) { + final status = categoryBudgets[index]; + return BudgetCard( + status: status, + onTap: () { + if (onBudgetTap != null) { + onBudgetTap!(status.budget); + } + }, + ); + }, + ); + }, + ); + }, + ); + } +} diff --git a/app/lib/widgets/custom_bottom_nav.dart b/app/lib/widgets/custom_bottom_nav.dart index 56c31ba..9f83d5b 100644 --- a/app/lib/widgets/custom_bottom_nav.dart +++ b/app/lib/widgets/custom_bottom_nav.dart @@ -14,8 +14,9 @@ class CustomBottomNavModern extends StatelessWidget { @override Widget build(BuildContext context) { final tabs = [ - {'icon': Icons.home_outlined, 'filledIcon': Icons.home, 'label': 'Home'}, {'icon': Icons.analytics_outlined, 'filledIcon': Icons.analytics, 'label': 'Analytics'}, + {'icon': Icons.account_balance_wallet_outlined, 'filledIcon': Icons.account_balance_wallet, 'label': 'Budget'}, + {'icon': Icons.home_outlined, 'filledIcon': Icons.home, 'label': 'Home'}, {'icon': Icons.web_outlined, 'filledIcon': Icons.web, 'label': 'Web'}, {'icon': Icons.settings_outlined, 'filledIcon': Icons.settings, 'label': 'Settings'}, ]; @@ -23,6 +24,7 @@ class CustomBottomNavModern extends StatelessWidget { final isDark = Theme.of(context).brightness == Brightness.dark; final primaryColor = Theme.of(context).colorScheme.primary; final iconColor = isDark ? Colors.white70 : Colors.black54; + const homeIndex = 2; // Home is in the center // --- Floating Pill Glassmorphism Container --- return Padding( @@ -41,7 +43,7 @@ class CustomBottomNavModern extends StatelessWidget { ), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.2), // Increased opacity for shadow prominence + color: Colors.black.withOpacity(0.2), blurRadius: 25, offset: const Offset(0, 5), spreadRadius: 3, @@ -52,29 +54,94 @@ class CustomBottomNavModern extends StatelessWidget { bottom: false, child: Container( height: 64, - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), // Reduced internal padding + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: List.generate(tabs.length, (index) { - final isActive = currentIndex == index; - final tab = tabs[index]; - - return Flexible( - flex: isActive ? 2 : 1, - child: GestureDetector( - onTap: () => onTap(index), - behavior: HitTestBehavior.opaque, - child: _BottomNavItem( - isActive: isActive, - primaryColor: primaryColor, - iconColor: iconColor, - icon: tab['icon'] as IconData, - filledIcon: tab['filledIcon'] as IconData, - label: tab['label'] as String, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + // Left side: Analytics and Budget + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + for (int index = 0; index < homeIndex; index++) + Flexible( + flex: currentIndex == index ? 2 : 1, + child: GestureDetector( + onTap: () => onTap(index), + behavior: HitTestBehavior.opaque, + child: _BottomNavItem( + isActive: currentIndex == index, + primaryColor: primaryColor, + iconColor: iconColor, + icon: tabs[index]['icon'] as IconData, + filledIcon: tabs[index]['filledIcon'] as IconData, + label: tabs[index]['label'] as String, + ), + ), + ), + ], + ), + ), + // Center: Fixed Home button + GestureDetector( + onTap: () => onTap(homeIndex), + behavior: HitTestBehavior.opaque, + child: Container( + width: 64, + height: 64, + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: currentIndex == homeIndex + ? primaryColor + : Theme.of(context).colorScheme.surfaceVariant, + boxShadow: [ + BoxShadow( + color: currentIndex == homeIndex + ? primaryColor.withOpacity(0.4) + : Colors.black.withOpacity(0.2), + blurRadius: 15, + offset: const Offset(0, 4), + spreadRadius: 2, + ), + ], + ), + child: Icon( + currentIndex == homeIndex + ? tabs[homeIndex]['filledIcon'] as IconData + : tabs[homeIndex]['icon'] as IconData, + size: 28, + color: currentIndex == homeIndex + ? Colors.white + : iconColor, ), ), - ); - }), + ), + // Right side: Web and Settings + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + for (int index = homeIndex + 1; index < tabs.length; index++) + Flexible( + flex: currentIndex == index ? 2 : 1, + child: GestureDetector( + onTap: () => onTap(index), + behavior: HitTestBehavior.opaque, + child: _BottomNavItem( + isActive: currentIndex == index, + primaryColor: primaryColor, + iconColor: iconColor, + icon: tabs[index]['icon'] as IconData, + filledIcon: tabs[index]['filledIcon'] as IconData, + label: tabs[index]['label'] as String, + ), + ), + ), + ], + ), + ), + ], ), ), ), @@ -106,7 +173,7 @@ class _BottomNavItem extends StatelessWidget { Widget build(BuildContext context) { const double iconSize = 24.0; const Duration duration = Duration(milliseconds: 300); - const double textSpacing = 4.0; // Reduced spacing + const double textSpacing = 2.0; // Reduced spacing between icon and text return AnimatedContainer( duration: duration, @@ -149,7 +216,7 @@ class _BottomNavItem extends StatelessWidget { child: Text( label, style: TextStyle( - fontSize: 13.0, + fontSize: 9.5, fontWeight: FontWeight.w600, color: primaryColor, ),