Files
Kifi/kifi-app/lib/features/budget/presentation/budget_screen.dart

425 lines
19 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons_flutter/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)),
),
],
),
),
);
}),
);
},
),
),
],
),
);
}
}