1125 lines
60 KiB
Dart
1125 lines
60 KiB
Dart
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();
|
|
final creditLimitCtrl = TextEditingController();
|
|
final fixedAmountCtrl = TextEditingController();
|
|
final cycleDateCtrl = TextEditingController();
|
|
DateTime openingDate = DateTime.now();
|
|
String selectedNature = 'CASH';
|
|
String? selectedSubNature;
|
|
String? selectedPaymentCycle;
|
|
|
|
final natures = ['CASH', 'SAVINGS', 'INVESTMENTS', 'LOAN', 'LENDING', 'PAYABLES', 'INCOME', 'EXPENSE', 'RECEIVABLES'];
|
|
final subNatures = ['CREDIT_CARD', 'OD_LIMIT', 'LOAN_EMI', 'POLICY_PREMIUM', 'OTHER'];
|
|
final paymentCycles = ['DAILY', 'WEEKLY', 'MONTHLY', 'QUARTERLY', 'YEARLY'];
|
|
|
|
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;
|
|
if (val != 'PAYABLES') {
|
|
selectedSubNature = null;
|
|
selectedPaymentCycle = null;
|
|
creditLimitCtrl.clear();
|
|
fixedAmountCtrl.clear();
|
|
cycleDateCtrl.clear();
|
|
} else {
|
|
selectedSubNature = 'CREDIT_CARD';
|
|
selectedPaymentCycle = 'MONTHLY';
|
|
}
|
|
});
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
if (selectedNature == 'PAYABLES') ...[
|
|
DropdownButtonFormField<String>(
|
|
value: selectedSubNature,
|
|
style: const TextStyle(fontWeight: FontWeight.w500, color: Colors.black87),
|
|
decoration: InputDecoration(
|
|
labelText: 'Sub-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: subNatures.map((n) => DropdownMenuItem(value: n, child: Text(n.replaceAll('_', ' ')))).toList(),
|
|
onChanged: (val) {
|
|
if (val != null) setStateDialog(() => selectedSubNature = val);
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
if (selectedSubNature == 'CREDIT_CARD' || selectedSubNature == 'OD_LIMIT') ...[
|
|
TextField(
|
|
controller: creditLimitCtrl,
|
|
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: false),
|
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
|
decoration: InputDecoration(
|
|
hintText: 'Credit Limit (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),
|
|
],
|
|
|
|
if (selectedSubNature == 'LOAN_EMI' || selectedSubNature == 'POLICY_PREMIUM') ...[
|
|
TextField(
|
|
controller: fixedAmountCtrl,
|
|
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: false),
|
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
|
decoration: InputDecoration(
|
|
hintText: 'Fixed Amount Due (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),
|
|
],
|
|
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
flex: 2,
|
|
child: DropdownButtonFormField<String>(
|
|
value: selectedPaymentCycle,
|
|
style: const TextStyle(fontWeight: FontWeight.w500, color: Colors.black87),
|
|
decoration: InputDecoration(
|
|
labelText: 'Cycle',
|
|
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: paymentCycles.map((n) => DropdownMenuItem(value: n, child: Text(n))).toList(),
|
|
onChanged: (val) {
|
|
if (val != null) setStateDialog(() => selectedPaymentCycle = val);
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
flex: 1,
|
|
child: TextField(
|
|
controller: cycleDateCtrl,
|
|
keyboardType: TextInputType.number,
|
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
|
decoration: InputDecoration(
|
|
labelText: 'Date (1-31)',
|
|
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),
|
|
],
|
|
|
|
TextField(
|
|
controller: amtCtrl,
|
|
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true),
|
|
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 double? cl = double.tryParse(creditLimitCtrl.text);
|
|
final double? fa = double.tryParse(fixedAmountCtrl.text);
|
|
final int? cd = int.tryParse(cycleDateCtrl.text);
|
|
|
|
final newWallet = await ref.read(walletProvider.notifier).createWallet(
|
|
name: ctrl.text,
|
|
nature: selectedNature,
|
|
initialBalance: 0.0,
|
|
subNature: selectedNature == 'PAYABLES' ? selectedSubNature : null,
|
|
creditLimit: cl,
|
|
fixedAmount: fa,
|
|
paymentCycle: selectedNature == 'PAYABLES' ? selectedPaymentCycle : null,
|
|
cycleDate: cd,
|
|
);
|
|
|
|
if (amt != 0) {
|
|
final tx = Transaction(
|
|
id: 0,
|
|
type: amt > 0 ? 'INCOME' : 'EXPENSE',
|
|
amount: amt.abs(),
|
|
date: openingDate,
|
|
description: 'Opening Balance',
|
|
fromWalletId: amt < 0 ? newWallet.id : null,
|
|
toWalletId: amt > 0 ? newWallet.id : null,
|
|
);
|
|
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(
|
|
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';
|
|
String? editSubNature = w.subNature;
|
|
final editCreditLimitCtrl = TextEditingController(text: w.creditLimit?.toString() ?? '');
|
|
final editFixedAmountCtrl = TextEditingController(text: w.fixedAmount?.toString() ?? '');
|
|
final editCycleDateCtrl = TextEditingController(text: w.cycleDate?.toString() ?? '');
|
|
String? editPaymentCycle = w.paymentCycle;
|
|
|
|
final natures = ['CASH', 'SAVINGS', 'EXPENSE', 'INVESTMENTS', 'LOAN', 'LENDING', 'PAYABLES', 'RECEIVABLES', 'INCOME'];
|
|
final subNatures = ['CREDIT_CARD', 'OD_LIMIT', 'LOAN_EMI', 'POLICY_PREMIUM', 'OTHER'];
|
|
final paymentCycles = ['DAILY', 'WEEKLY', 'MONTHLY', 'QUARTERLY', 'YEARLY'];
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder: (ctx) => StatefulBuilder(
|
|
builder: (context, setStateDialog) => 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)),
|
|
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;
|
|
if (val != 'PAYABLES') {
|
|
editSubNature = null;
|
|
editPaymentCycle = null;
|
|
editCreditLimitCtrl.clear();
|
|
editFixedAmountCtrl.clear();
|
|
editCycleDateCtrl.clear();
|
|
} else if (editSubNature == null) {
|
|
editSubNature = 'CREDIT_CARD';
|
|
editPaymentCycle = 'MONTHLY';
|
|
}
|
|
});
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
if (editNature == 'PAYABLES') ...[
|
|
DropdownButtonFormField<String>(
|
|
value: editSubNature,
|
|
decoration: InputDecoration(
|
|
labelText: 'Sub-Nature',
|
|
filled: true,
|
|
fillColor: Colors.grey.shade100,
|
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
|
),
|
|
items: subNatures.map((n) => DropdownMenuItem(value: n, child: Text(n.replaceAll('_', ' ')))).toList(),
|
|
onChanged: (val) {
|
|
if (val != null) setStateDialog(() => editSubNature = val);
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
if (editSubNature == 'CREDIT_CARD' || editSubNature == 'OD_LIMIT') ...[
|
|
TextField(
|
|
controller: editCreditLimitCtrl,
|
|
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: false),
|
|
decoration: InputDecoration(
|
|
hintText: 'Credit Limit (Optional)',
|
|
prefixText: 'Rs. ',
|
|
filled: true,
|
|
fillColor: Colors.grey.shade100,
|
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
],
|
|
|
|
if (editSubNature == 'LOAN_EMI' || editSubNature == 'POLICY_PREMIUM') ...[
|
|
TextField(
|
|
controller: editFixedAmountCtrl,
|
|
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: false),
|
|
decoration: InputDecoration(
|
|
hintText: 'Fixed Amount Due (Optional)',
|
|
prefixText: 'Rs. ',
|
|
filled: true,
|
|
fillColor: Colors.grey.shade100,
|
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
],
|
|
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
flex: 2,
|
|
child: DropdownButtonFormField<String>(
|
|
value: editPaymentCycle,
|
|
decoration: InputDecoration(
|
|
labelText: 'Cycle',
|
|
filled: true,
|
|
fillColor: Colors.grey.shade100,
|
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
|
),
|
|
items: paymentCycles.map((n) => DropdownMenuItem(value: n, child: Text(n))).toList(),
|
|
onChanged: (val) {
|
|
if (val != null) setStateDialog(() => editPaymentCycle = val);
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
flex: 1,
|
|
child: TextField(
|
|
controller: editCycleDateCtrl,
|
|
keyboardType: TextInputType.number,
|
|
decoration: InputDecoration(
|
|
labelText: 'Date (1-31)',
|
|
filled: true,
|
|
fillColor: Colors.grey.shade100,
|
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
],
|
|
|
|
const SizedBox(height: 8),
|
|
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) {
|
|
final double? cl = double.tryParse(editCreditLimitCtrl.text);
|
|
final double? fa = double.tryParse(editFixedAmountCtrl.text);
|
|
final int? cd = int.tryParse(editCycleDateCtrl.text);
|
|
|
|
try {
|
|
await ref.read(walletProvider.notifier).editWallet(
|
|
w.id,
|
|
name: editCtrl.text.trim(),
|
|
nature: editNature,
|
|
subNature: editNature == 'PAYABLES' ? editSubNature : null,
|
|
creditLimit: cl,
|
|
fixedAmount: fa,
|
|
paymentCycle: editNature == 'PAYABLES' ? editPaymentCycle : null,
|
|
cycleDate: cd,
|
|
);
|
|
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,
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|