425 lines
19 KiB
Dart
425 lines
19 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:lucide_icons/lucide_icons.dart';
|
|
import '../../transactions/providers/providers.dart';
|
|
import '../../transactions/data/models.dart';
|
|
import '../../../core/widgets/shimmer_loading.dart';
|
|
|
|
class BudgetScreen extends ConsumerStatefulWidget {
|
|
const BudgetScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<BudgetScreen> createState() => _BudgetScreenState();
|
|
}
|
|
|
|
class _BudgetScreenState extends ConsumerState<BudgetScreen> {
|
|
String _searchQuery = '';
|
|
String? _filterNature;
|
|
bool _isSearching = false;
|
|
final TextEditingController _searchController = TextEditingController();
|
|
|
|
@override
|
|
void dispose() {
|
|
_searchController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _onRefresh() async {
|
|
ref.invalidate(budgetProvider);
|
|
ref.invalidate(walletProvider);
|
|
ref.invalidate(transactionProvider);
|
|
await Future.delayed(const Duration(milliseconds: 500));
|
|
}
|
|
|
|
void _showSetBudgetDialog(BuildContext context, Wallet wallet, Budget? existingBudget) {
|
|
final controller = TextEditingController(text: existingBudget?.monthlyLimit.toString() ?? '');
|
|
bool isShared = existingBudget?.isShared ?? false;
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder: (ctx) => StatefulBuilder(
|
|
builder: (context, setState) {
|
|
return Dialog(
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
|
|
elevation: 0,
|
|
backgroundColor: Colors.transparent,
|
|
child: GestureDetector(
|
|
onTap: () => FocusScope.of(context).unfocus(),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(24),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(28),
|
|
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 20, offset: const Offset(0, 10))],
|
|
),
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text('Set Budget for ${wallet.name}', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
|
const SizedBox(height: 24),
|
|
TextField(
|
|
controller: controller,
|
|
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: false),
|
|
textInputAction: TextInputAction.done,
|
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
|
decoration: InputDecoration(
|
|
hintText: 'Monthly Limit',
|
|
prefixText: 'Rs. ',
|
|
prefixStyle: const TextStyle(color: Colors.black87, fontWeight: FontWeight.w500, fontSize: 16),
|
|
filled: true,
|
|
fillColor: Colors.grey.shade100,
|
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
|
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
SwitchListTile(
|
|
title: const Text('Share budget with wallet members', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
|
|
value: isShared,
|
|
activeColor: const Color(0xFF6C63FF),
|
|
contentPadding: EdgeInsets.zero,
|
|
onChanged: (val) {
|
|
setState(() {
|
|
isShared = val;
|
|
});
|
|
},
|
|
),
|
|
const SizedBox(height: 24),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx),
|
|
style: TextButton.styleFrom(foregroundColor: Colors.grey.shade700, padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12))),
|
|
child: const Text('Cancel', style: TextStyle(fontWeight: FontWeight.bold)),
|
|
),
|
|
const SizedBox(width: 12),
|
|
ElevatedButton(
|
|
onPressed: () async {
|
|
final limit = double.tryParse(controller.text);
|
|
if (limit != null && limit > 0) {
|
|
final newBudget = Budget(
|
|
id: existingBudget?.id ?? 0,
|
|
walletId: wallet.id,
|
|
monthlyLimit: limit,
|
|
isShared: isShared,
|
|
);
|
|
await ref.read(budgetProvider.notifier).addOrUpdateBudget(newBudget);
|
|
if (mounted) Navigator.pop(ctx);
|
|
}
|
|
},
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: const Color(0xFF6C63FF),
|
|
foregroundColor: Colors.white,
|
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
|
elevation: 0,
|
|
),
|
|
child: const Text('Save', style: TextStyle(fontWeight: FontWeight.bold)),
|
|
)
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showFilterSheet() {
|
|
final natures = ['ALL', 'CASH', 'SAVINGS', 'INVESTMENTS', 'LOAN', 'LENDING', 'PAYABLES', 'INCOME', 'EXPENSE', 'RECEIVABLES'];
|
|
String? tempFilterNature = _filterNature;
|
|
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
|
|
builder: (ctx) => StatefulBuilder(
|
|
builder: (context, setSheetState) {
|
|
return Container(
|
|
padding: EdgeInsets.only(
|
|
bottom: MediaQuery.of(context).viewInsets.bottom,
|
|
),
|
|
child: SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text('Filter Budgets', style: Theme.of(context).textTheme.titleLarge),
|
|
IconButton(icon: const Icon(LucideIcons.x), onPressed: () => Navigator.pop(ctx)),
|
|
],
|
|
),
|
|
const SizedBox(height: 24),
|
|
|
|
Text('Account Nature', style: Theme.of(context).textTheme.titleSmall),
|
|
const SizedBox(height: 8),
|
|
DropdownButtonFormField<String>(
|
|
value: tempFilterNature ?? 'ALL',
|
|
style: const TextStyle(fontWeight: FontWeight.w500, color: Colors.black87, fontSize: 16),
|
|
decoration: InputDecoration(
|
|
filled: true,
|
|
fillColor: Colors.grey.shade100,
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
|
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
|
|
),
|
|
items: natures.map((n) => DropdownMenuItem(value: n, child: Text(n))).toList(),
|
|
onChanged: (val) {
|
|
if (val != null) {
|
|
setSheetState(() {
|
|
tempFilterNature = val == 'ALL' ? null : val;
|
|
});
|
|
}
|
|
},
|
|
),
|
|
const SizedBox(height: 32),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: OutlinedButton(
|
|
onPressed: () {
|
|
setState(() => _filterNature = null);
|
|
Navigator.pop(ctx);
|
|
},
|
|
style: OutlinedButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
|
side: BorderSide(color: Colors.grey.shade300),
|
|
),
|
|
child: Text('Reset', style: TextStyle(color: Colors.grey.shade700, fontWeight: FontWeight.bold)),
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: ElevatedButton(
|
|
onPressed: () {
|
|
setState(() => _filterNature = tempFilterNature);
|
|
Navigator.pop(ctx);
|
|
},
|
|
style: ElevatedButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
backgroundColor: const Color(0xFF6C63FF),
|
|
foregroundColor: Colors.white,
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
|
elevation: 0,
|
|
),
|
|
child: const Text('Apply Filter', style: TextStyle(fontWeight: FontWeight.bold)),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final budgetsState = ref.watch(budgetProvider);
|
|
final walletsState = ref.watch(walletProvider);
|
|
final transState = ref.watch(transactionProvider);
|
|
|
|
return Scaffold(
|
|
body: Column(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _searchController,
|
|
onChanged: (val) => setState(() => _searchQuery = val),
|
|
decoration: InputDecoration(
|
|
hintText: 'Search budgets...',
|
|
prefixIcon: const Icon(LucideIcons.search),
|
|
suffixIcon: _searchController.text.isNotEmpty ? IconButton(
|
|
icon: const Icon(LucideIcons.x),
|
|
onPressed: () {
|
|
_searchController.clear();
|
|
setState(() => _searchQuery = '');
|
|
},
|
|
) : null,
|
|
filled: true,
|
|
fillColor: Colors.grey.shade100,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(16),
|
|
borderSide: BorderSide.none,
|
|
),
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
IconButton(
|
|
icon: Icon(
|
|
LucideIcons.filter,
|
|
color: _filterNature != null ? const Color(0xFF6C63FF) : null,
|
|
),
|
|
onPressed: _showFilterSheet,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Expanded(
|
|
child: budgetsState.when(
|
|
loading: () => ListView.builder(
|
|
padding: const EdgeInsets.all(16),
|
|
itemCount: 4,
|
|
itemBuilder: (context, index) => const ShimmerCard(),
|
|
),
|
|
error: (err, stack) => Center(child: Text('Error: $err')),
|
|
data: (budgets) {
|
|
if (!walletsState.hasValue || !transState.hasValue) {
|
|
return ListView.builder(
|
|
padding: const EdgeInsets.all(16),
|
|
itemCount: 4,
|
|
itemBuilder: (context, index) => const ShimmerCard(),
|
|
);
|
|
}
|
|
|
|
final wallets = walletsState.value!.where((w) {
|
|
final nature = (w.nature ?? 'CASH').trim().toUpperCase();
|
|
final hasBudget = budgets.any((b) => b.walletId == w.id);
|
|
|
|
final matchSearch = _searchQuery.isEmpty || w.name.toLowerCase().contains(_searchQuery.toLowerCase());
|
|
final matchNature = _filterNature == null || nature == _filterNature;
|
|
|
|
final matchBaseCondition = hasBudget || nature == 'EXPENSE' || nature == 'INVESTMENTS';
|
|
|
|
return matchBaseCondition && matchSearch && matchNature;
|
|
}).toList();
|
|
|
|
final transactions = transState.value!;
|
|
final now = DateTime.now();
|
|
|
|
// Calculate spent per wallet for the current month
|
|
final currentMonthTxs = transactions.where((t) => t.date.month == now.month && t.date.year == now.year);
|
|
final Map<int, double> spentByWallet = {};
|
|
|
|
for (var t in currentMonthTxs) {
|
|
if (t.toWalletId != null) {
|
|
spentByWallet[t.toWalletId!] = (spentByWallet[t.toWalletId!] ?? 0) + t.amount;
|
|
}
|
|
}
|
|
|
|
if (wallets.isEmpty) {
|
|
return const Center(child: Padding(padding: EdgeInsets.all(16), child: Text('No budgets or eligible accounts found.')));
|
|
}
|
|
|
|
return RefreshIndicator(
|
|
onRefresh: _onRefresh,
|
|
child: ListView.builder(
|
|
physics: const AlwaysScrollableScrollPhysics(),
|
|
padding: const EdgeInsets.all(16),
|
|
itemCount: wallets.length,
|
|
itemBuilder: (context, index) {
|
|
final wallet = wallets[index];
|
|
final existingBudgetIndex = budgets.indexWhere((b) => b.walletId == wallet.id);
|
|
final budget = existingBudgetIndex >= 0 ? budgets[existingBudgetIndex] : null;
|
|
final spent = spentByWallet[wallet.id] ?? 0.0;
|
|
|
|
if (budget == null) {
|
|
return Card(
|
|
margin: const EdgeInsets.only(bottom: 12),
|
|
child: ListTile(
|
|
leading: const CircleAvatar(child: Icon(LucideIcons.wallet)),
|
|
title: Text(wallet.name),
|
|
subtitle: Text('${wallet.nature} • No budget set'),
|
|
trailing: TextButton(
|
|
onPressed: () => _showSetBudgetDialog(context, wallet, null),
|
|
child: const Text('Set Budget'),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
final limit = budget.monthlyLimit;
|
|
final double progress;
|
|
if (limit <= 0) {
|
|
progress = 1.0;
|
|
} else {
|
|
progress = (spent / limit).clamp(0.0, 1.0);
|
|
}
|
|
Color progressColor = Colors.green;
|
|
if (progress > 0.9) progressColor = Colors.red;
|
|
else if (progress > 0.7) progressColor = Colors.orange;
|
|
|
|
return Card(
|
|
margin: const EdgeInsets.only(bottom: 16),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Expanded(
|
|
child: Row(
|
|
children: [
|
|
const Icon(LucideIcons.wallet, size: 20, color: Colors.blue),
|
|
const SizedBox(width: 8),
|
|
Expanded(child: Text(wallet.name, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), overflow: TextOverflow.ellipsis)),
|
|
],
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(LucideIcons.edit3, size: 18),
|
|
onPressed: () => _showSetBudgetDialog(context, wallet, budget),
|
|
)
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
LinearProgressIndicator(
|
|
value: progress,
|
|
backgroundColor: Colors.grey.shade200,
|
|
valueColor: AlwaysStoppedAnimation<Color>(progressColor),
|
|
minHeight: 8,
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text('Spent: Rs. ${spent.toStringAsFixed(2)}', style: TextStyle(color: Colors.grey.shade700)),
|
|
Text('Limit: Rs. ${limit.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.bold)),
|
|
],
|
|
),
|
|
if (progress >= 1.0)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 8.0),
|
|
child: Text('Budget Exceeded!', style: TextStyle(color: Colors.red.shade700, fontSize: 12, fontWeight: FontWeight.bold)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|