V1 - Personal Account and Budgeting Done

Personal Account and Budgeting
This commit is contained in:
2026-08-16 20:13:21 +05:30
commit 17c0526ed6
247 changed files with 19002 additions and 0 deletions

View File

@@ -0,0 +1,881 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:intl/intl.dart';
import '../../transactions/providers/providers.dart';
import '../../transactions/data/models.dart';
import '../../transactions/data/repository.dart';
import 'wallet_ledger_screen.dart';
class AccountsScreen extends ConsumerStatefulWidget {
final String? initialFilterNature;
const AccountsScreen({super.key, this.initialFilterNature});
@override
ConsumerState<AccountsScreen> createState() => _AccountsScreenState();
}
class _AccountsScreenState extends ConsumerState<AccountsScreen> {
String _searchQuery = '';
String? _filterNature;
bool _isSearching = false;
final TextEditingController _searchController = TextEditingController();
@override
void initState() {
super.initState();
_filterNature = widget.initialFilterNature;
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _onRefresh() async {
ref.invalidate(walletProvider);
ref.invalidate(invitationProvider);
await Future.delayed(const Duration(milliseconds: 500));
}
void _showCreateWalletDialog() {
final ctrl = TextEditingController();
final amtCtrl = TextEditingController();
DateTime openingDate = DateTime.now();
String selectedNature = 'CASH';
final natures = ['CASH', 'SAVINGS', 'INVESTMENTS', 'LOAN', 'LENDING', 'PAYABLES', 'INCOME', 'EXPENSE', 'RECEIVABLES'];
showDialog(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (context, setStateDialog) {
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: [
Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: const Color(0xFF6C63FF).withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: const Icon(LucideIcons.wallet, color: Color(0xFF6C63FF), size: 24),
),
const SizedBox(width: 16),
const Text(
'New Account',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 24),
TextField(
controller: ctrl,
style: const TextStyle(fontWeight: FontWeight.w500),
decoration: InputDecoration(
hintText: 'Account Name (e.g. Household)',
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),
DropdownButtonFormField<String>(
value: selectedNature,
style: const TextStyle(fontWeight: FontWeight.w500, color: Colors.black87),
decoration: InputDecoration(
labelText: 'Account Nature',
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)),
),
items: natures.map((n) => DropdownMenuItem(value: n, child: Text(n))).toList(),
onChanged: (val) {
if (val != null) setStateDialog(() => selectedNature = val);
},
),
const SizedBox(height: 16),
TextField(
controller: amtCtrl,
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: false),
textInputAction: TextInputAction.done,
style: const TextStyle(fontWeight: FontWeight.w500),
decoration: InputDecoration(
hintText: 'Opening Balance (Optional)',
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),
InkWell(
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: openingDate,
firstDate: DateTime.now().subtract(const Duration(days: 365 * 10)),
lastDate: DateTime.now(),
);
if (picked != null) {
setStateDialog(() => openingDate = picked);
}
},
borderRadius: BorderRadius.circular(16),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
const Icon(LucideIcons.calendar, color: Colors.grey),
const SizedBox(width: 12),
Text(
'Date: ${DateFormat('MMM dd, yyyy').format(openingDate)}',
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500),
),
],
),
),
),
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 {
if (ctrl.text.isNotEmpty) {
final double amt = double.tryParse(amtCtrl.text) ?? 0.0;
final newWallet = await ref.read(walletProvider.notifier).createWallet(
name: ctrl.text,
nature: selectedNature,
initialBalance: 0.0,
);
if (amt > 0) {
final tx = Transaction(
id: 0,
type: 'INCOME',
amount: amt,
date: openingDate,
description: 'Opening Balance',
toWalletId: newWallet.id,
);
await ref.read(transactionProvider.notifier).addTransaction(tx);
}
if (context.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('Create', 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 Accounts', 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 walletsState = ref.watch(walletProvider);
return Scaffold(
backgroundColor: Colors.transparent,
body: SafeArea(
bottom: false,
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
child: Row(
children: [
if (Navigator.of(context).canPop())
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: IconButton(
icon: const Icon(LucideIcons.arrowLeft),
onPressed: () => Navigator.pop(context),
),
),
Expanded(
child: TextField(
controller: _searchController,
onChanged: (val) => setState(() => _searchQuery = val),
decoration: InputDecoration(
hintText: 'Search accounts...',
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,
),
IconButton(
icon: const Icon(LucideIcons.plusCircle),
onPressed: _showCreateWalletDialog,
),
],
),
),
Expanded(
child: walletsState.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, st) => Center(child: Text('Error: $e')),
data: (allWallets) {
final wallets = allWallets.where((w) {
final matchSearch = _searchQuery.isEmpty ||
w.name.toLowerCase().contains(_searchQuery.toLowerCase());
final matchNature = _filterNature == null || w.nature == _filterNature;
return matchSearch && matchNature;
}).toList();
if (wallets.isEmpty) {
return const Center(child: Text('No accounts found.'));
}
return RefreshIndicator(
onRefresh: _onRefresh,
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
itemCount: wallets.length,
itemBuilder: (context, index) {
final w = wallets[index];
return Card(
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16), side: BorderSide(color: Colors.grey.shade200)),
elevation: 0,
child: Column(
children: [
InkWell(
onTap: () {
Navigator.push(context, MaterialPageRoute(builder: (_) => WalletLedgerScreen(wallet: w)));
},
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
CircleAvatar(
backgroundColor: Colors.blue.withValues(alpha: 0.1),
child: const Icon(LucideIcons.wallet, color: Colors.blue),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(w.name, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
const SizedBox(height: 4),
Text('${w.nature ?? "CASH"} • Balance: Rs. ${w.balance}', style: TextStyle(color: Colors.grey.shade600, fontSize: 13)),
],
),
),
],
),
),
),
Divider(height: 1, color: Colors.grey.shade200),
FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
TextButton.icon(
icon: const Icon(LucideIcons.userPlus, size: 14),
label: const Text('Invite', style: TextStyle(fontSize: 12)),
style: TextButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 8)),
onPressed: () {
showDialog(
context: context,
builder: (ctx) => InviteDialogWidget(wallet: w),
);
},
),
TextButton.icon(
icon: const Icon(LucideIcons.edit2, size: 14),
label: const Text('Edit', style: TextStyle(fontSize: 12)),
style: TextButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 8)),
onPressed: () {
final editCtrl = TextEditingController(text: w.name);
String editNature = w.nature ?? 'CASH';
final natures = ['CASH', 'SAVINGS', 'EXPENSE', 'INVESTMENTS', 'LOAN', 'LENDING', 'PAYABLES', 'RECEIVABLES', 'INCOME'];
showDialog(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (context, setStateDialog) => Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
elevation: 0,
backgroundColor: Colors.transparent,
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(28)),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Edit Account', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 24),
TextField(
controller: editCtrl,
decoration: InputDecoration(
hintText: 'Account Name',
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
),
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: editNature,
decoration: InputDecoration(
labelText: 'Account Nature',
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
),
items: natures.map((n) => DropdownMenuItem(value: n, child: Text(n))).toList(),
onChanged: (val) {
if (val != null) setStateDialog(() => editNature = val);
},
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel', style: TextStyle(fontWeight: FontWeight.bold)),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: () async {
if (editCtrl.text.isNotEmpty) {
try {
await ref.read(walletProvider.notifier).editWallet(
w.id,
name: editCtrl.text.trim(),
nature: editNature,
);
if (context.mounted) {
Navigator.pop(ctx);
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Wallet updated')));
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to edit: $e')));
}
}
}
},
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF6C63FF), foregroundColor: Colors.white),
child: const Text('Save', style: TextStyle(fontWeight: FontWeight.bold)),
)
],
),
],
),
),
),
),
),
);
},
),
TextButton.icon(
icon: const Icon(LucideIcons.trash2, size: 14, color: Colors.red),
label: const Text('Delete', style: TextStyle(color: Colors.red, fontSize: 12)),
style: TextButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 8)),
onPressed: () async {
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
elevation: 0,
backgroundColor: Colors.transparent,
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(28)),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Delete Account', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.red)),
const SizedBox(height: 16),
Text('Are you sure you want to delete ${w.name}? This action cannot be undone.', style: const TextStyle(fontSize: 16)),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.grey)),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
style: ElevatedButton.styleFrom(backgroundColor: Colors.red, foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
child: const Text('Delete', style: TextStyle(fontWeight: FontWeight.bold)),
)
],
),
],
),
),
),
);
if (confirm == true) {
try {
await ref.read(walletProvider.notifier).deleteWallet(w.id);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Wallet deleted')));
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))));
}
}
}
},
),
TextButton.icon(
icon: const Icon(LucideIcons.list, size: 14),
label: const Text('Ledger', style: TextStyle(fontSize: 12)),
style: TextButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 8)),
onPressed: () {
Navigator.push(context, MaterialPageRoute(builder: (_) => WalletLedgerScreen(wallet: w)));
},
),
],
),
), // Close FittedBox
],
),
);
},
),
);
},
),
),
],
),
),
);
}
}
class InviteDialogWidget extends ConsumerStatefulWidget {
final Wallet wallet;
const InviteDialogWidget({super.key, required this.wallet});
@override
ConsumerState<InviteDialogWidget> createState() => _InviteDialogWidgetState();
}
class _InviteDialogWidgetState extends ConsumerState<InviteDialogWidget> {
TextEditingController _autoCompleteCtrl = TextEditingController();
bool _isInviting = false;
bool _isLoadingMembers = true;
List<WalletMember> _members = [];
List<String> _knownContacts = [];
@override
void initState() {
super.initState();
_fetchMembers();
}
@override
void dispose() {
_autoCompleteCtrl.dispose();
super.dispose();
}
Future<void> _fetchMembers() async {
try {
final members = await ApiRepository().getWalletMembers(widget.wallet.id);
List<String> contacts = [];
try {
contacts = await ApiRepository().getKnownContacts();
} catch (e) {
debugPrint("Error fetching contacts: $e");
}
if (mounted) {
setState(() {
_members = members;
_knownContacts = contacts.where((c) => !members.any((m) => m.email == c)).toList();
_isLoadingMembers = false;
});
}
} catch (e) {
if (mounted) {
setState(() => _isLoadingMembers = false);
}
}
}
Future<void> _removeMember(WalletMember member) async {
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Remove Member'),
content: Text('Are you sure you want to remove ${member.email} from this wallet?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
style: ElevatedButton.styleFrom(backgroundColor: Colors.red, foregroundColor: Colors.white),
child: const Text('Remove'),
),
],
),
);
if (confirm == true) {
try {
await ApiRepository().removeWalletMember(widget.wallet.id, member.userId);
await _fetchMembers();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Member removed.')));
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))));
}
}
}
}
@override
Widget build(BuildContext context) {
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('Invite to ${widget.wallet.name}', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 24),
TextField(
controller: _autoCompleteCtrl,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.done,
style: const TextStyle(fontWeight: FontWeight.w500),
onChanged: (val) {
setState(() {});
},
decoration: InputDecoration(
hintText: 'Enter Email Address to invite',
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)),
),
),
if (_autoCompleteCtrl?.text.trim().isNotEmpty == true && (_autoCompleteCtrl?.text.length ?? 0) >= 2)
Builder(
builder: (context) {
final query = _autoCompleteCtrl.text.trim().toLowerCase();
final matches = _knownContacts.where((c) => c.toLowerCase().contains(query)).toList();
if (matches.isEmpty || (matches.length == 1 && matches.first.toLowerCase() == query)) return const SizedBox.shrink();
return Container(
margin: const EdgeInsets.only(top: 8),
constraints: const BoxConstraints(maxHeight: 160),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 10, offset: const Offset(0, 4))],
),
child: ListView.separated(
shrinkWrap: true,
padding: EdgeInsets.zero,
itemCount: matches.length,
separatorBuilder: (_, __) => const Divider(height: 1, indent: 16, endIndent: 16),
itemBuilder: (ctx, index) {
final email = matches[index];
return ListTile(
leading: const CircleAvatar(radius: 14, backgroundColor: Color(0xFF6C63FF), child: Icon(LucideIcons.user, size: 14, color: Colors.white)),
title: Text(email, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
onTap: () {
setState(() {
_autoCompleteCtrl.text = email;
_autoCompleteCtrl.selection = TextSelection.fromPosition(TextPosition(offset: email.length));
});
FocusScope.of(context).unfocus();
},
);
},
),
);
},
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: _isInviting ? null : () => Navigator.pop(context),
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: _isInviting ? null : () async {
final email = _autoCompleteCtrl.text.trim();
if (email.isNotEmpty && email.contains('@')) {
setState(() => _isInviting = true);
try {
await ref.read(walletProvider.notifier).inviteUser(widget.wallet.id, email);
if (context.mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('User invited!')));
}
} catch (e) {
if (context.mounted) {
setState(() => _isInviting = false);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))));
}
}
}
},
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: _isInviting
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: const Text('Invite', style: TextStyle(fontWeight: FontWeight.bold)),
)
],
),
const SizedBox(height: 24),
const Text('Existing Members', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
_isLoadingMembers
? const Center(child: CircularProgressIndicator())
: _members.isEmpty
? const Text('No members found.', style: TextStyle(color: Colors.grey))
: ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _members.length,
itemBuilder: (context, index) {
final member = _members[index];
final isOwner = member.role == 'OWNER';
return ListTile(
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(backgroundColor: Colors.grey.shade200, child: const Icon(LucideIcons.user, color: Colors.grey)),
title: Text(member.email, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
subtitle: Text(member.role, style: TextStyle(color: isOwner ? Colors.blue : Colors.grey, fontSize: 12)),
trailing: !isOwner
? IconButton(
icon: const Icon(LucideIcons.userMinus, color: Colors.red, size: 20),
onPressed: () => _removeMember(member),
)
: null,
);
},
),
],
),
),
),
),
);
}
}