1796 lines
60 KiB
Dart
1796 lines
60 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:go_router/go_router.dart';
|
||
import 'package:qpay/screens/groups/models/group_batch_model.dart';
|
||
import 'package:intl/intl.dart';
|
||
import 'package:qpay/screens/groups/models/recipient_group_model.dart';
|
||
import 'package:qpay/models/responsive_policy.dart';
|
||
import 'package:qpay/screens/groups/controllers/batch_controller.dart';
|
||
import 'package:qpay/screens/groups/controllers/group_controller.dart';
|
||
import 'package:qpay/screens/recipient/recipients_controller.dart';
|
||
import 'package:qpay/widgets/status_chip_widget.dart';
|
||
import 'package:skeletonizer/skeletonizer.dart';
|
||
|
||
class GroupDetailScreen extends StatefulWidget {
|
||
final RecipientGroup group;
|
||
|
||
const GroupDetailScreen({super.key, required this.group});
|
||
|
||
@override
|
||
State<GroupDetailScreen> createState() => _GroupDetailScreenState();
|
||
}
|
||
|
||
class _GroupDetailScreenState extends State<GroupDetailScreen>
|
||
with TickerProviderStateMixin {
|
||
late GroupController groupController;
|
||
late BatchController batchController;
|
||
final _searchController = TextEditingController();
|
||
final _memberSearchController = TextEditingController();
|
||
late final AnimationController _membersAnimController;
|
||
String _memberQuery = '';
|
||
String? _statusFilter;
|
||
String? _currencyFilter;
|
||
|
||
static const _statusFilterItems = <DropdownMenuItem<String?>>[
|
||
DropdownMenuItem(value: null, child: Text('All')),
|
||
DropdownMenuItem(value: 'CREATED', child: Text('Created')),
|
||
DropdownMenuItem(value: 'CONFIRMING', child: Text('Confirming')),
|
||
DropdownMenuItem(value: 'CONFIRMED_ALL', child: Text('Confirmed All')),
|
||
DropdownMenuItem(value: 'REQUESTED', child: Text('Requested')),
|
||
DropdownMenuItem(value: 'COMPLETED', child: Text('Completed')),
|
||
DropdownMenuItem(value: 'POLL_FAILED', child: Text('Poll Failed')),
|
||
DropdownMenuItem(value: 'CONFIRM_FAILED', child: Text('Confirm Failed')),
|
||
DropdownMenuItem(value: 'FAILED', child: Text('Failed')),
|
||
];
|
||
|
||
static const _currencyFilterItems = <DropdownMenuItem<String?>>[
|
||
DropdownMenuItem(value: null, child: Text('All')),
|
||
DropdownMenuItem(value: 'USD', child: Text('USD')),
|
||
DropdownMenuItem(value: 'ZWL', child: Text('ZWL')),
|
||
DropdownMenuItem(value: 'ZAR', child: Text('ZAR')),
|
||
];
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
groupController = GroupController(context);
|
||
batchController = BatchController(context);
|
||
_loadData();
|
||
_searchController.addListener(() => setState(() {}));
|
||
_membersAnimController = AnimationController(
|
||
vsync: this,
|
||
duration: const Duration(milliseconds: 600),
|
||
)..forward();
|
||
}
|
||
|
||
Future<void> _loadData() async {
|
||
final groupId = widget.group.id ?? '';
|
||
await batchController.listBatches(groupId);
|
||
await groupController.listMembers(groupId);
|
||
}
|
||
|
||
void _onSearchChanged(String value) {
|
||
batchController.searchBatches(
|
||
widget.group.id ?? '',
|
||
search: value.isNotEmpty ? value : null,
|
||
status: _statusFilter,
|
||
currency: _currencyFilter,
|
||
);
|
||
}
|
||
|
||
void _showMembersSheet() {
|
||
_memberSearchController.clear();
|
||
_memberQuery = '';
|
||
showModalBottomSheet(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||
shape: const RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||
),
|
||
builder: (ctx) {
|
||
return DraggableScrollableSheet(
|
||
initialChildSize: 0.7,
|
||
minChildSize: 0.4,
|
||
maxChildSize: 0.92,
|
||
expand: false,
|
||
builder: (context, scrollController) {
|
||
return StatefulBuilder(
|
||
builder: (context, setSheetState) {
|
||
return Column(
|
||
children: [
|
||
_buildMembersSheetHandle(),
|
||
_buildMembersSheetHeader(setSheetState, scrollController),
|
||
Expanded(
|
||
child: ListenableBuilder(
|
||
listenable: groupController,
|
||
builder: (context, _) {
|
||
final allMembers = groupController.model.members;
|
||
final filtered = _filteredMembers(allMembers);
|
||
if (groupController.model.isLoading) {
|
||
return _buildMembersSkeletonList(scrollController);
|
||
}
|
||
if (allMembers.isEmpty) {
|
||
return _buildMembersEmptyState();
|
||
}
|
||
if (filtered.isEmpty) {
|
||
return _buildNoSearchResultsState();
|
||
}
|
||
return ListView.separated(
|
||
controller: scrollController,
|
||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||
itemCount: filtered.length,
|
||
separatorBuilder: (_, __) =>
|
||
const SizedBox(height: 10),
|
||
itemBuilder: (context, index) {
|
||
return _buildAnimatedMemberCard(
|
||
index,
|
||
filtered[index],
|
||
);
|
||
},
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
},
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
Widget _buildMembersSheetHandle() {
|
||
return Padding(
|
||
padding: const EdgeInsets.only(top: 10, bottom: 6),
|
||
child: Container(
|
||
width: 44,
|
||
height: 5,
|
||
decoration: BoxDecoration(
|
||
color: Colors.grey.shade300,
|
||
borderRadius: BorderRadius.circular(10),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildMembersSheetHeader(
|
||
StateSetter setSheetState,
|
||
ScrollController scrollController,
|
||
) {
|
||
final total = groupController.model.members.length;
|
||
return Padding(
|
||
padding: const EdgeInsets.fromLTRB(20, 4, 12, 8),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Container(
|
||
width: 40,
|
||
height: 40,
|
||
decoration: BoxDecoration(
|
||
color: Theme.of(
|
||
context,
|
||
).colorScheme.primary.withValues(alpha: 0.1),
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: Icon(
|
||
Icons.people_alt_outlined,
|
||
color: Theme.of(context).colorScheme.primary,
|
||
size: 22,
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
const Text(
|
||
'Members',
|
||
style: TextStyle(
|
||
fontSize: 20,
|
||
fontWeight: FontWeight.w700,
|
||
color: Colors.black87,
|
||
),
|
||
),
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
'$total ${total == 1 ? "member" : "members"} in this group',
|
||
style: TextStyle(
|
||
fontSize: 13,
|
||
color: Colors.grey.shade600,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
IconButton(
|
||
icon: Icon(Icons.close, color: Colors.grey.shade700),
|
||
tooltip: 'Close',
|
||
onPressed: () => Navigator.of(context).pop(),
|
||
),
|
||
],
|
||
),
|
||
if (total > 0) ...[
|
||
const SizedBox(height: 14),
|
||
TextFormField(
|
||
controller: _memberSearchController,
|
||
onChanged: (v) {
|
||
setSheetState(() => _memberQuery = v);
|
||
},
|
||
decoration: InputDecoration(
|
||
hintText: 'Search members...',
|
||
hintStyle: TextStyle(color: Colors.grey.shade500, fontSize: 14),
|
||
prefixIcon: Icon(
|
||
Icons.search,
|
||
color: Colors.grey.shade600,
|
||
size: 20,
|
||
),
|
||
suffixIcon: _memberQuery.isNotEmpty
|
||
? IconButton(
|
||
icon: Icon(
|
||
Icons.clear,
|
||
color: Colors.grey.shade600,
|
||
size: 18,
|
||
),
|
||
onPressed: () {
|
||
_memberSearchController.clear();
|
||
setSheetState(() => _memberQuery = '');
|
||
},
|
||
)
|
||
: null,
|
||
isDense: true,
|
||
contentPadding: const EdgeInsets.symmetric(
|
||
horizontal: 12,
|
||
vertical: 12,
|
||
),
|
||
filled: true,
|
||
fillColor: Colors.grey.shade100,
|
||
border: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(12),
|
||
borderSide: BorderSide.none,
|
||
),
|
||
focusedBorder: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(12),
|
||
borderSide: BorderSide(
|
||
color: Theme.of(context).colorScheme.primary,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
List<RecipientGroupMember> _filteredMembers(
|
||
List<RecipientGroupMember> members,
|
||
) {
|
||
if (_memberQuery.trim().isEmpty) return members;
|
||
final q = _memberQuery.toLowerCase().trim();
|
||
return members.where((m) {
|
||
final r = m.recipient;
|
||
return (r.name?.toLowerCase().contains(q) ?? false) ||
|
||
(r.account?.toLowerCase().contains(q) ?? false) ||
|
||
(r.email?.toLowerCase().contains(q) ?? false) ||
|
||
(r.phoneNumber?.toLowerCase().contains(q) ?? false) ||
|
||
(r.latestProviderLabel?.toLowerCase().contains(q) ?? false);
|
||
}).toList();
|
||
}
|
||
|
||
Widget _buildAnimatedMemberCard(int index, RecipientGroupMember member) {
|
||
final clampedIndex = index.clamp(0, 5);
|
||
final animation =
|
||
Tween<Offset>(begin: const Offset(0, 0.06), end: Offset.zero).animate(
|
||
CurvedAnimation(
|
||
parent: _membersAnimController,
|
||
curve: Interval(
|
||
0.05 * clampedIndex,
|
||
1.0,
|
||
curve: Curves.easeOutCubic,
|
||
),
|
||
),
|
||
);
|
||
return FadeTransition(
|
||
opacity: _membersAnimController,
|
||
child: SlideTransition(
|
||
position: animation,
|
||
child: _buildMemberCard(member),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildMemberCard(RecipientGroupMember member) {
|
||
final theme = Theme.of(context);
|
||
final isDark = theme.brightness == Brightness.dark;
|
||
final recipient = member.recipient;
|
||
final name = recipient.name?.isNotEmpty == true
|
||
? recipient.name!
|
||
: 'Unnamed recipient';
|
||
final initials = _initialsFor(name);
|
||
return Material(
|
||
color: Colors.transparent,
|
||
child: InkWell(
|
||
borderRadius: BorderRadius.circular(16),
|
||
onTap: () {
|
||
Navigator.of(context).pop();
|
||
},
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
color: isDark ? const Color(0xFF1A1A2E) : Colors.white,
|
||
borderRadius: BorderRadius.circular(16),
|
||
border: Border.all(
|
||
color: isDark
|
||
? Colors.white.withValues(alpha: 0.06)
|
||
: Colors.grey.shade200,
|
||
width: 1,
|
||
),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: isDark
|
||
? Colors.black.withValues(alpha: 0.2)
|
||
: Colors.black.withValues(alpha: 0.03),
|
||
blurRadius: 8,
|
||
offset: const Offset(0, 2),
|
||
),
|
||
],
|
||
),
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 14),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
_buildMemberAvatar(initials),
|
||
const SizedBox(width: 14),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
name,
|
||
style: TextStyle(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w600,
|
||
color: isDark ? Colors.white : Colors.black87,
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
const SizedBox(height: 4),
|
||
_buildMemberSubtitleRow(
|
||
recipient,
|
||
member.account,
|
||
isDark,
|
||
),
|
||
if (recipient.phoneNumber?.isNotEmpty == true) ...[
|
||
const SizedBox(height: 4),
|
||
_buildContactLine(
|
||
Icons.phone_outlined,
|
||
recipient.phoneNumber!,
|
||
isDark,
|
||
),
|
||
],
|
||
if (recipient.email?.isNotEmpty == true) ...[
|
||
const SizedBox(height: 4),
|
||
_buildContactLine(
|
||
Icons.email_outlined,
|
||
recipient.email!,
|
||
isDark,
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
_buildMemberPopupMenu(member),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildMemberAvatar(String initials) {
|
||
return Container(
|
||
width: 44,
|
||
height: 44,
|
||
decoration: BoxDecoration(
|
||
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1),
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
alignment: Alignment.center,
|
||
child: Text(
|
||
initials,
|
||
style: TextStyle(
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w600,
|
||
color: Theme.of(context).colorScheme.primary,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildMemberSubtitleRow(
|
||
Recipient recipient,
|
||
String? memberAccount,
|
||
bool isDark,
|
||
) {
|
||
final account = memberAccount?.isNotEmpty == true
|
||
? memberAccount
|
||
: recipient.account;
|
||
final hasAccount = account != null && account.isNotEmpty;
|
||
final hasProvider = recipient.latestProviderLabel?.isNotEmpty == true;
|
||
if (!hasAccount && !hasProvider) {
|
||
return const SizedBox.shrink();
|
||
}
|
||
return Wrap(
|
||
crossAxisAlignment: WrapCrossAlignment.center,
|
||
spacing: 6,
|
||
runSpacing: 4,
|
||
children: [
|
||
if (hasProvider)
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||
decoration: BoxDecoration(
|
||
color: Theme.of(
|
||
context,
|
||
).colorScheme.primary.withValues(alpha: 0.08),
|
||
borderRadius: BorderRadius.circular(6),
|
||
),
|
||
child: Text(
|
||
recipient.latestProviderLabel!,
|
||
style: TextStyle(
|
||
fontSize: 11,
|
||
fontWeight: FontWeight.w600,
|
||
color: Theme.of(context).colorScheme.primary,
|
||
),
|
||
),
|
||
),
|
||
if (hasAccount)
|
||
Flexible(
|
||
child: Text(
|
||
account,
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
color: isDark ? Colors.white54 : Colors.grey.shade600,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildContactLine(IconData icon, String text, bool isDark) {
|
||
return Row(
|
||
children: [
|
||
Icon(
|
||
icon,
|
||
size: 13,
|
||
color: isDark ? Colors.white38 : Colors.grey.shade500,
|
||
),
|
||
const SizedBox(width: 4),
|
||
Flexible(
|
||
child: Text(
|
||
text,
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
color: isDark ? Colors.white54 : Colors.grey.shade600,
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildMemberPopupMenu(RecipientGroupMember member) {
|
||
return PopupMenuButton<String>(
|
||
icon: Icon(Icons.more_vert, color: Colors.grey.shade500, size: 20),
|
||
padding: EdgeInsets.zero,
|
||
splashRadius: 20,
|
||
tooltip: 'More options',
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||
onSelected: (value) async {
|
||
if (value == 'remove') {
|
||
await _confirmRemoveMember(member);
|
||
}
|
||
},
|
||
itemBuilder: (context) => [
|
||
const PopupMenuItem(
|
||
value: 'remove',
|
||
child: Row(
|
||
children: [
|
||
Icon(Icons.person_remove_outlined, size: 18, color: Colors.red),
|
||
SizedBox(width: 10),
|
||
Text(
|
||
'Remove from group',
|
||
style: TextStyle(
|
||
color: Colors.red,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Future<void> _confirmRemoveMember(RecipientGroupMember member) async {
|
||
final name = member.recipient.name ?? 'this member';
|
||
final confirmed = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||
title: const Text('Remove member'),
|
||
content: Text('Are you sure you want to remove $name from this group?'),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(ctx).pop(false),
|
||
child: const Text('Cancel'),
|
||
),
|
||
TextButton(
|
||
onPressed: () => Navigator.of(ctx).pop(true),
|
||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||
child: const Text('Remove'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
if (confirmed == true && mounted) {
|
||
await groupController.removeMember(
|
||
groupId: widget.group.id ?? '',
|
||
recipientId: member.id ?? '',
|
||
workspaceId: widget.group.workspaceId ?? '',
|
||
);
|
||
}
|
||
}
|
||
|
||
Widget _buildMembersSkeletonList(ScrollController scrollController) {
|
||
return ListView.separated(
|
||
controller: scrollController,
|
||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||
itemCount: 6,
|
||
separatorBuilder: (_, __) => const SizedBox(height: 10),
|
||
itemBuilder: (context, index) {
|
||
return Skeletonizer(
|
||
enabled: true,
|
||
child: _buildMemberCard(
|
||
RecipientGroupMember(
|
||
recipientGroupId: '',
|
||
recipient: Recipient(
|
||
name: 'Loading Name Here',
|
||
account: '0000000000',
|
||
phoneNumber: '+000 000 0000',
|
||
email: 'loading@example.com',
|
||
latestProviderLabel: 'PROVIDER',
|
||
),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
Widget _buildMembersEmptyState() {
|
||
return Center(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(32),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Container(
|
||
width: 88,
|
||
height: 88,
|
||
decoration: BoxDecoration(
|
||
color: Theme.of(
|
||
context,
|
||
).colorScheme.primary.withValues(alpha: 0.08),
|
||
shape: BoxShape.circle,
|
||
),
|
||
child: Icon(
|
||
Icons.people_alt_outlined,
|
||
size: 44,
|
||
color: Theme.of(context).colorScheme.primary,
|
||
),
|
||
),
|
||
const SizedBox(height: 20),
|
||
const Text(
|
||
'No members yet',
|
||
style: TextStyle(
|
||
fontSize: 18,
|
||
fontWeight: FontWeight.w700,
|
||
color: Colors.black87,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'Members added to this group will appear\nhere once they are available.',
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(
|
||
fontSize: 14,
|
||
color: Colors.grey.shade600,
|
||
height: 1.4,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildNoSearchResultsState() {
|
||
return Center(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(32),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(
|
||
Icons.search_off_outlined,
|
||
size: 56,
|
||
color: Colors.grey.shade400,
|
||
),
|
||
const SizedBox(height: 16),
|
||
const Text(
|
||
'No matching members',
|
||
style: TextStyle(
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w600,
|
||
color: Colors.black87,
|
||
),
|
||
),
|
||
const SizedBox(height: 6),
|
||
Text(
|
||
'No results for "$_memberQuery"',
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
String _initialsFor(String name) {
|
||
final cleaned = name.trim();
|
||
if (cleaned.isEmpty) return '?';
|
||
final parts = cleaned.split(RegExp(r'\s+'));
|
||
if (parts.length == 1) {
|
||
return parts.first.characters.first.toUpperCase();
|
||
}
|
||
return (parts.first.characters.first + parts.last.characters.first)
|
||
.toUpperCase();
|
||
}
|
||
|
||
void _onDeleteSelected() async {
|
||
final workspaceId = widget.group.workspaceId ?? '';
|
||
final count = batchController.model.selectedBatchIds.length;
|
||
|
||
final confirmed = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
title: const Text('Delete batches'),
|
||
content: Text('Are you sure you want to delete $count batch(es)?'),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(ctx).pop(false),
|
||
child: const Text('Cancel'),
|
||
),
|
||
TextButton(
|
||
onPressed: () => Navigator.of(ctx).pop(true),
|
||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||
child: const Text('Delete'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
|
||
if (confirmed == true) {
|
||
await batchController.deleteSelectedBatches(
|
||
groupId: widget.group.id ?? '',
|
||
workspaceId: workspaceId,
|
||
);
|
||
}
|
||
}
|
||
|
||
void _showFilterSheet() {
|
||
showModalBottomSheet(
|
||
context: context,
|
||
shape: const RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||
),
|
||
builder: (ctx) {
|
||
return StatefulBuilder(
|
||
builder: (context, setSheetState) {
|
||
return Padding(
|
||
padding: const EdgeInsets.all(20),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
const Text(
|
||
'Filter Batches',
|
||
style: TextStyle(
|
||
fontSize: 18,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
const Spacer(),
|
||
TextButton(
|
||
onPressed: () {
|
||
setSheetState(() {
|
||
_statusFilter = null;
|
||
_currencyFilter = null;
|
||
});
|
||
},
|
||
child: const Text('Clear All'),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 16),
|
||
DropdownButtonFormField<String?>(
|
||
initialValue: _statusFilter,
|
||
decoration: InputDecoration(
|
||
labelText: 'Status',
|
||
filled: true,
|
||
fillColor: Colors.grey.shade100,
|
||
border: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(12),
|
||
borderSide: BorderSide.none,
|
||
),
|
||
contentPadding: const EdgeInsets.symmetric(
|
||
horizontal: 16,
|
||
vertical: 14,
|
||
),
|
||
),
|
||
items: _statusFilterItems,
|
||
onChanged: (v) {
|
||
setSheetState(() => _statusFilter = v);
|
||
},
|
||
),
|
||
const SizedBox(height: 16),
|
||
DropdownButtonFormField<String?>(
|
||
initialValue: _currencyFilter,
|
||
decoration: InputDecoration(
|
||
labelText: 'Currency',
|
||
filled: true,
|
||
fillColor: Colors.grey.shade100,
|
||
border: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(12),
|
||
borderSide: BorderSide.none,
|
||
),
|
||
contentPadding: const EdgeInsets.symmetric(
|
||
horizontal: 16,
|
||
vertical: 14,
|
||
),
|
||
),
|
||
items: _currencyFilterItems,
|
||
onChanged: (v) {
|
||
setSheetState(() => _currencyFilter = v);
|
||
},
|
||
),
|
||
const SizedBox(height: 24),
|
||
SizedBox(
|
||
width: double.infinity,
|
||
child: ElevatedButton(
|
||
onPressed: () => _applyFilters(ctx),
|
||
child: const Text('Apply Filters'),
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
void _applyFilters(BuildContext ctx) {
|
||
Navigator.of(ctx).pop();
|
||
batchController.searchBatches(
|
||
widget.group.id ?? '',
|
||
search: _searchController.text.isNotEmpty ? _searchController.text : null,
|
||
status: _statusFilter,
|
||
currency: _currencyFilter,
|
||
);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_searchController.dispose();
|
||
_memberSearchController.dispose();
|
||
_membersAnimController.dispose();
|
||
groupController.dispose();
|
||
batchController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final isDark = theme.brightness == Brightness.dark;
|
||
|
||
return Scaffold(
|
||
body: ListenableBuilder(
|
||
listenable: batchController,
|
||
builder: (context, _) {
|
||
return Column(
|
||
children: [
|
||
Expanded(
|
||
child: CustomScrollView(
|
||
slivers: [
|
||
SliverAppBar(
|
||
pinned: false,
|
||
floating: true,
|
||
stretch: true,
|
||
stretchTriggerOffset: 80.0,
|
||
expandedHeight: 60.0,
|
||
collapsedHeight: 60.0,
|
||
surfaceTintColor: Colors.transparent,
|
||
backgroundColor: isDark
|
||
? const Color(0xFF0D0D0D)
|
||
: const Color(0xFFF8F9FA),
|
||
title: Text(
|
||
widget.group.name,
|
||
style: TextStyle(
|
||
fontWeight: FontWeight.w700,
|
||
fontSize: 20,
|
||
color: isDark ? Colors.white : Colors.black87,
|
||
),
|
||
),
|
||
centerTitle: true,
|
||
actions: [
|
||
IconButton(
|
||
icon: Icon(
|
||
Icons.people_outline,
|
||
color: theme.colorScheme.primary,
|
||
),
|
||
tooltip: 'View members',
|
||
onPressed: _showMembersSheet,
|
||
),
|
||
],
|
||
),
|
||
SliverToBoxAdapter(
|
||
child: Center(
|
||
child: LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
final width =
|
||
constraints.maxWidth > ResponsivePolicy.md
|
||
? ResponsivePolicy.md.toDouble()
|
||
: constraints.maxWidth;
|
||
return SizedBox(
|
||
width: width,
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
_buildGroupHeaderCard(theme, isDark),
|
||
const SizedBox(height: 20),
|
||
_buildSearchRow(),
|
||
const SizedBox(height: 20),
|
||
_buildContent(),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
if (batchController.model.selectMode) _buildSelectionBottomBar(),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildGroupHeaderCard(ThemeData theme, bool isDark) {
|
||
return ListenableBuilder(
|
||
listenable: groupController,
|
||
builder: (context, value) {
|
||
final memberCount =
|
||
groupController.model.membersPage?.totalElements ?? 0;
|
||
final batchCount = batchController.model.batches.length;
|
||
final description = widget.group.description;
|
||
final hasDescription = description != null && description.isNotEmpty;
|
||
|
||
return Container(
|
||
width: double.infinity,
|
||
decoration: BoxDecoration(
|
||
gradient: LinearGradient(
|
||
begin: Alignment.topLeft,
|
||
end: Alignment.bottomRight,
|
||
colors: isDark
|
||
? [const Color(0xFF1E1E3A), const Color(0xFF1A1A2E)]
|
||
: [Colors.white, const Color(0xFFF8F9FC)],
|
||
),
|
||
borderRadius: BorderRadius.circular(20),
|
||
border: Border.all(
|
||
color: isDark
|
||
? Colors.white.withValues(alpha: 0.08)
|
||
: Colors.grey.shade200,
|
||
),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: isDark
|
||
? Colors.black.withValues(alpha: 0.35)
|
||
: Colors.black.withValues(alpha: 0.05),
|
||
blurRadius: 20,
|
||
offset: const Offset(0, 6),
|
||
),
|
||
],
|
||
),
|
||
padding: const EdgeInsets.all(20),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// Section 1: Group icon + name + description
|
||
Row(
|
||
children: [
|
||
Container(
|
||
width: 48,
|
||
height: 48,
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.primary.withValues(alpha: 0.1),
|
||
borderRadius: BorderRadius.circular(14),
|
||
),
|
||
child: Icon(
|
||
Icons.group_rounded,
|
||
size: 26,
|
||
color: theme.colorScheme.primary,
|
||
),
|
||
),
|
||
const SizedBox(width: 14),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'Group',
|
||
style: TextStyle(
|
||
fontSize: 11,
|
||
fontWeight: FontWeight.w600,
|
||
color: theme.colorScheme.primary,
|
||
letterSpacing: 0.8,
|
||
),
|
||
),
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
widget.group.name,
|
||
style: TextStyle(
|
||
fontSize: 18,
|
||
fontWeight: FontWeight.w700,
|
||
color: isDark ? Colors.white : Colors.black87,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
_buildMembersCountBadge(theme, memberCount),
|
||
],
|
||
),
|
||
if (hasDescription) ...[
|
||
const SizedBox(height: 14),
|
||
Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.all(12),
|
||
decoration: BoxDecoration(
|
||
color: isDark
|
||
? Colors.white.withValues(alpha: 0.03)
|
||
: Colors.grey.shade50,
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: Text(
|
||
description,
|
||
style: TextStyle(
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w400,
|
||
color: isDark ? Colors.white60 : Colors.grey.shade700,
|
||
height: 1.4,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
if (memberCount > 0 || batchCount > 0) ...[
|
||
const SizedBox(height: 18),
|
||
Container(
|
||
height: 1,
|
||
decoration: BoxDecoration(
|
||
gradient: LinearGradient(
|
||
colors: isDark
|
||
? [
|
||
Colors.white.withValues(alpha: 0.0),
|
||
Colors.white.withValues(alpha: 0.08),
|
||
Colors.white.withValues(alpha: 0.0),
|
||
]
|
||
: [
|
||
Colors.grey.shade200,
|
||
Colors.grey.shade300,
|
||
Colors.grey.shade200,
|
||
],
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: _statItem(
|
||
icon: Icons.receipt_long_rounded,
|
||
label: 'Batches',
|
||
value: batchCount,
|
||
color: const Color(0xFF3B82F6),
|
||
isDark: isDark,
|
||
),
|
||
),
|
||
Container(
|
||
width: 1,
|
||
height: 32,
|
||
color: isDark
|
||
? Colors.white.withValues(alpha: 0.08)
|
||
: Colors.grey.shade200,
|
||
),
|
||
Expanded(
|
||
child: _statItem(
|
||
icon: Icons.people_rounded,
|
||
label: 'Members',
|
||
value: memberCount,
|
||
color: const Color(0xFF10B981),
|
||
isDark: isDark,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
Widget _buildMembersCountBadge(ThemeData theme, int count) {
|
||
return GestureDetector(
|
||
onTap: _showMembersSheet,
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFF10B981).withValues(alpha: 0.1),
|
||
borderRadius: BorderRadius.circular(20),
|
||
border: Border.all(
|
||
color: const Color(0xFF10B981).withValues(alpha: 0.25),
|
||
),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const Icon(
|
||
Icons.people_rounded,
|
||
size: 14,
|
||
color: Color(0xFF10B981),
|
||
),
|
||
const SizedBox(width: 4),
|
||
Text(
|
||
'$count',
|
||
style: const TextStyle(
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w700,
|
||
color: Color(0xFF10B981),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _statItem({
|
||
required IconData icon,
|
||
required String label,
|
||
required int value,
|
||
required Color color,
|
||
required bool isDark,
|
||
}) {
|
||
return Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Container(
|
||
width: 36,
|
||
height: 36,
|
||
decoration: BoxDecoration(
|
||
color: color.withValues(alpha: 0.1),
|
||
borderRadius: BorderRadius.circular(10),
|
||
),
|
||
child: Icon(icon, size: 18, color: color),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'$value',
|
||
style: TextStyle(
|
||
fontSize: 18,
|
||
fontWeight: FontWeight.w700,
|
||
color: isDark ? Colors.white : Colors.black87,
|
||
),
|
||
),
|
||
Text(
|
||
label,
|
||
style: TextStyle(
|
||
fontSize: 11,
|
||
fontWeight: FontWeight.w500,
|
||
color: isDark ? Colors.white54 : Colors.grey.shade500,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildSearchRow() {
|
||
return Row(
|
||
children: [
|
||
Expanded(
|
||
child: TextFormField(
|
||
controller: _searchController,
|
||
decoration: InputDecoration(
|
||
hintText: 'Search batches by description...',
|
||
prefixIcon: const Icon(Icons.search),
|
||
suffixIcon: _searchController.text.isNotEmpty
|
||
? IconButton(
|
||
icon: const Icon(Icons.clear),
|
||
onPressed: () {
|
||
_searchController.clear();
|
||
_onSearchChanged('');
|
||
},
|
||
)
|
||
: null,
|
||
filled: true,
|
||
fillColor: Colors.grey.shade100,
|
||
border: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(12),
|
||
borderSide: BorderSide.none,
|
||
),
|
||
focusedBorder: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(12),
|
||
borderSide: BorderSide(
|
||
color: Theme.of(context).colorScheme.primary,
|
||
),
|
||
),
|
||
),
|
||
onChanged: _onSearchChanged,
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
_buildIconButton(
|
||
icon: Icons.filter_list,
|
||
tooltip: 'Filter batches',
|
||
isActive: _statusFilter != null || _currencyFilter != null,
|
||
onPressed: _showFilterSheet,
|
||
),
|
||
const SizedBox(width: 8),
|
||
_buildIconButton(
|
||
icon: batchController.model.selectMode
|
||
? Icons.checklist
|
||
: Icons.checklist_outlined,
|
||
tooltip: 'Select batches',
|
||
isActive: batchController.model.selectMode,
|
||
onPressed: () => batchController.toggleSelectMode(),
|
||
),
|
||
const SizedBox(width: 8),
|
||
_buildIconButton(
|
||
icon: Icons.refresh,
|
||
tooltip: 'Refresh batches',
|
||
isActive: false,
|
||
onPressed: _loadData,
|
||
),
|
||
const SizedBox(width: 8),
|
||
_buildIconButton(
|
||
icon: Icons.add,
|
||
tooltip: 'Create batch',
|
||
isActive: false,
|
||
onPressed: () async {
|
||
final result = await context.push(
|
||
'/groups/${widget.group.id}/batches/create',
|
||
extra: widget.group,
|
||
);
|
||
if (result == true) {
|
||
_loadData();
|
||
}
|
||
},
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildIconButton({
|
||
required IconData icon,
|
||
required String tooltip,
|
||
required bool isActive,
|
||
required VoidCallback onPressed,
|
||
}) {
|
||
return Container(
|
||
decoration: BoxDecoration(
|
||
color: isActive
|
||
? Theme.of(context).colorScheme.primary
|
||
: Colors.grey.shade100,
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: IconButton(
|
||
icon: Icon(icon, color: isActive ? Colors.white : Colors.grey.shade700),
|
||
tooltip: tooltip,
|
||
onPressed: onPressed,
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildContent() {
|
||
if (batchController.model.isLoading) {
|
||
return const Center(
|
||
child: Padding(
|
||
padding: EdgeInsets.symmetric(vertical: 60),
|
||
child: CircularProgressIndicator(),
|
||
),
|
||
);
|
||
}
|
||
|
||
if (batchController.model.batches.isEmpty) {
|
||
return Center(
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 40),
|
||
child: Column(
|
||
children: [
|
||
Icon(
|
||
Icons.receipt_long_outlined,
|
||
size: 64,
|
||
color: Colors.grey.shade400,
|
||
),
|
||
const SizedBox(height: 16),
|
||
const Text(
|
||
'No batches yet.',
|
||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'Tap + to create a new batch payment.',
|
||
style: TextStyle(fontSize: 14, color: Colors.grey.shade600),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
final totalPages = batchController.model.totalPages;
|
||
final currentPage = batchController.model.currentPage;
|
||
final totalElements = batchController.model.totalElements;
|
||
final pageSize = batchController.model.pageSize;
|
||
final isLoadingMore = batchController.model.isLoadingMore;
|
||
final hasMultiplePages = totalPages > 1;
|
||
final groupId = widget.group.id ?? '';
|
||
|
||
// Calculate the range of items being shown (1-based)
|
||
final startItem = totalElements > 0 ? (currentPage * pageSize) + 1 : 0;
|
||
final endItem = (startItem + batchController.model.batches.length - 1)
|
||
.clamp(0, totalElements);
|
||
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
if (batchController.model.selectMode)
|
||
Padding(
|
||
padding: const EdgeInsets.only(bottom: 8),
|
||
child: Row(
|
||
children: [
|
||
Text(
|
||
'${batchController.model.selectedBatchIds.length} selected',
|
||
style: TextStyle(
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w500,
|
||
color: Colors.grey.shade600,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
if (_statusFilter != null || _currencyFilter != null)
|
||
Padding(
|
||
padding: const EdgeInsets.only(bottom: 8),
|
||
child: Row(
|
||
children: [
|
||
if (_statusFilter != null)
|
||
_activeFilterChip('Status: $_statusFilter', () {
|
||
setState(() => _statusFilter = null);
|
||
_onSearchChanged(_searchController.text);
|
||
}),
|
||
if (_currencyFilter != null) const SizedBox(width: 6),
|
||
if (_currencyFilter != null)
|
||
_activeFilterChip('Currency: $_currencyFilter', () {
|
||
setState(() => _currencyFilter = null);
|
||
_onSearchChanged(_searchController.text);
|
||
}),
|
||
],
|
||
),
|
||
),
|
||
if (totalElements > 0)
|
||
Padding(
|
||
padding: const EdgeInsets.only(bottom: 8),
|
||
child: Text(
|
||
'Showing $startItem–$endItem of $totalElements batches',
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w500,
|
||
color: Colors.grey.shade500,
|
||
),
|
||
),
|
||
),
|
||
ListView.separated(
|
||
shrinkWrap: true,
|
||
physics: const NeverScrollableScrollPhysics(),
|
||
itemCount: batchController.model.batches.length,
|
||
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||
itemBuilder: (context, index) {
|
||
return _buildBatchCard(
|
||
context,
|
||
batchController.model.batches[index],
|
||
widget.group,
|
||
);
|
||
},
|
||
),
|
||
if (isLoadingMore)
|
||
const Padding(
|
||
padding: EdgeInsets.symmetric(vertical: 16),
|
||
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||
),
|
||
if (hasMultiplePages)
|
||
_buildPaginationControls(
|
||
groupId: groupId,
|
||
theme: Theme.of(context),
|
||
isDark: Theme.of(context).brightness == Brightness.dark,
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
/// Builds prev/next pagination controls for batches.
|
||
Widget _buildPaginationControls({
|
||
required String groupId,
|
||
required ThemeData theme,
|
||
required bool isDark,
|
||
}) {
|
||
final totalPages = batchController.model.totalPages;
|
||
final currentPage = batchController.model.currentPage;
|
||
final isLoadingMore = batchController.model.isLoadingMore;
|
||
|
||
return Column(
|
||
children: [
|
||
const SizedBox(height: 4),
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
_paginationButton(
|
||
icon: Icons.chevron_left_rounded,
|
||
tooltip: 'Previous page',
|
||
enabled: currentPage > 0 && !isLoadingMore,
|
||
onTap: () {
|
||
batchController.goToPage(groupId, currentPage - 1);
|
||
},
|
||
isDark: isDark,
|
||
),
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||
child: Text(
|
||
'Page ${currentPage + 1} of $totalPages',
|
||
style: TextStyle(
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w600,
|
||
color: isDark ? Colors.white70 : Colors.grey.shade700,
|
||
),
|
||
),
|
||
),
|
||
_paginationButton(
|
||
icon: Icons.chevron_right_rounded,
|
||
tooltip: 'Next page',
|
||
enabled: currentPage < totalPages - 1 && !isLoadingMore,
|
||
onTap: () {
|
||
batchController.goToPage(groupId, currentPage + 1);
|
||
},
|
||
isDark: isDark,
|
||
),
|
||
],
|
||
),
|
||
_buildPageSizeSelector(groupId: groupId, isDark: isDark),
|
||
],
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
/// Builds a dropdown to select the number of batches per page.
|
||
Widget _buildPageSizeSelector({
|
||
required String groupId,
|
||
required bool isDark,
|
||
}) {
|
||
final pageSize = batchController.model.pageSize;
|
||
final isLoadingMore = batchController.model.isLoadingMore;
|
||
|
||
return Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Text(
|
||
'Items per page: ',
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w500,
|
||
color: isDark ? Colors.white54 : Colors.grey.shade500,
|
||
),
|
||
),
|
||
Container(
|
||
height: 32,
|
||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||
decoration: BoxDecoration(
|
||
color: isDark
|
||
? Colors.white.withValues(alpha: 0.06)
|
||
: Colors.grey.shade50,
|
||
borderRadius: BorderRadius.circular(8),
|
||
border: Border.all(
|
||
color: isDark
|
||
? Colors.white.withValues(alpha: 0.10)
|
||
: Colors.grey.shade300,
|
||
),
|
||
),
|
||
child: DropdownButtonHideUnderline(
|
||
child: DropdownButton<int>(
|
||
value: pageSize,
|
||
isDense: true,
|
||
style: TextStyle(
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w600,
|
||
color: isDark ? Colors.white70 : Colors.grey.shade700,
|
||
),
|
||
dropdownColor: isDark ? const Color(0xFF1A1A2E) : Colors.white,
|
||
items: const [
|
||
DropdownMenuItem(value: 10, child: Text('10')),
|
||
DropdownMenuItem(value: 20, child: Text('20')),
|
||
DropdownMenuItem(value: 50, child: Text('50')),
|
||
],
|
||
onChanged: isLoadingMore
|
||
? null
|
||
: (value) {
|
||
if (value != null && groupId.isNotEmpty) {
|
||
batchController.setPageSize(groupId, value);
|
||
}
|
||
},
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _paginationButton({
|
||
required IconData icon,
|
||
required String tooltip,
|
||
required bool enabled,
|
||
required VoidCallback onTap,
|
||
required bool isDark,
|
||
}) {
|
||
return Material(
|
||
color: Colors.transparent,
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||
child: InkWell(
|
||
borderRadius: BorderRadius.circular(10),
|
||
onTap: enabled ? onTap : null,
|
||
child: Container(
|
||
width: 40,
|
||
height: 40,
|
||
decoration: BoxDecoration(
|
||
color: enabled
|
||
? (isDark
|
||
? Colors.white.withValues(alpha: 0.08)
|
||
: Colors.grey.shade100)
|
||
: (isDark
|
||
? Colors.white.withValues(alpha: 0.03)
|
||
: Colors.grey.shade50),
|
||
borderRadius: BorderRadius.circular(10),
|
||
border: Border.all(
|
||
color: enabled
|
||
? (isDark
|
||
? Colors.white.withValues(alpha: 0.12)
|
||
: Colors.grey.shade300)
|
||
: (isDark
|
||
? Colors.white.withValues(alpha: 0.04)
|
||
: Colors.grey.shade200),
|
||
),
|
||
),
|
||
child: Icon(
|
||
icon,
|
||
size: 20,
|
||
color: enabled
|
||
? (isDark ? Colors.white70 : Colors.grey.shade700)
|
||
: (isDark ? Colors.white24 : Colors.grey.shade300),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _activeFilterChip(String label, VoidCallback onRemove) {
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||
decoration: BoxDecoration(
|
||
color: Theme.of(context).colorScheme.primary.withAlpha(20),
|
||
borderRadius: BorderRadius.circular(20),
|
||
border: Border.all(
|
||
color: Theme.of(context).colorScheme.primary.withAlpha(60),
|
||
),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
label,
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
color: Theme.of(context).colorScheme.primary,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
const SizedBox(width: 4),
|
||
InkWell(
|
||
onTap: onRemove,
|
||
borderRadius: BorderRadius.circular(10),
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(2),
|
||
child: Icon(
|
||
Icons.close,
|
||
size: 14,
|
||
color: Theme.of(context).colorScheme.primary,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildBatchCard(
|
||
BuildContext context,
|
||
GroupBatch batch,
|
||
RecipientGroup group,
|
||
) {
|
||
final isSelected = batchController.model.selectedBatchIds.contains(
|
||
batch.id,
|
||
);
|
||
final inSelectMode = batchController.model.selectMode;
|
||
|
||
final status = batch.status ?? 'UNKNOWN';
|
||
final createdAt = batch.createdAt;
|
||
final parsedDate = createdAt != null ? DateTime.tryParse(createdAt) : null;
|
||
final theme = Theme.of(context);
|
||
final isDark = theme.brightness == Brightness.dark;
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 8),
|
||
child: Material(
|
||
color: Colors.transparent,
|
||
child: InkWell(
|
||
borderRadius: BorderRadius.circular(16),
|
||
onTap: inSelectMode
|
||
? () => batchController.toggleBatchSelection(batch.id!)
|
||
: () => context.push('/groups/batches/${batch.id}'),
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 14),
|
||
decoration: BoxDecoration(
|
||
color: isDark ? const Color(0xFF1A1A2E) : Colors.white,
|
||
borderRadius: BorderRadius.circular(16),
|
||
border: Border.all(
|
||
color: inSelectMode && isSelected
|
||
? theme.colorScheme.primary
|
||
: isDark
|
||
? Colors.white.withValues(alpha: 0.06)
|
||
: Colors.grey.shade200,
|
||
width: inSelectMode && isSelected ? 2 : 1,
|
||
),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: isDark
|
||
? Colors.black.withValues(alpha: 0.2)
|
||
: Colors.black.withValues(alpha: 0.03),
|
||
blurRadius: 8,
|
||
offset: const Offset(0, 2),
|
||
),
|
||
],
|
||
),
|
||
child: Row(
|
||
children: [
|
||
if (inSelectMode)
|
||
Padding(
|
||
padding: const EdgeInsets.only(right: 12),
|
||
child: Icon(
|
||
isSelected
|
||
? Icons.check_box
|
||
: Icons.check_box_outline_blank,
|
||
size: 20,
|
||
color: isSelected
|
||
? theme.colorScheme.primary
|
||
: Colors.grey.shade400,
|
||
),
|
||
),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
if (batch.description?.isNotEmpty == true)
|
||
Flexible(
|
||
child: Text(
|
||
batch.description!,
|
||
style: TextStyle(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w600,
|
||
color: isDark
|
||
? Colors.white
|
||
: const Color.fromARGB(221, 58, 58, 58),
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
StatusChip(status: status),
|
||
],
|
||
),
|
||
const SizedBox(height: 4),
|
||
Row(
|
||
children: [
|
||
Text(
|
||
'${batch.currency ?? 'USD'} ${batch.value?.toStringAsFixed(2) ?? '—'}',
|
||
style: TextStyle(
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w700,
|
||
color: isDark ? Colors.white : Colors.black87,
|
||
),
|
||
),
|
||
if (parsedDate != null) ...[
|
||
Text(
|
||
' • ',
|
||
style: TextStyle(
|
||
fontSize: 11,
|
||
color: isDark
|
||
? Colors.white24
|
||
: Colors.grey.shade400,
|
||
),
|
||
),
|
||
Text(
|
||
DateFormat.yMMMd().add_jm().format(parsedDate),
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w400,
|
||
color: isDark
|
||
? Colors.white54
|
||
: Colors.grey.shade500,
|
||
),
|
||
),
|
||
],
|
||
const Spacer(),
|
||
if (batch.count != null)
|
||
Row(
|
||
children: [
|
||
_statIcon(
|
||
Icons.people_outline,
|
||
'${batch.count}',
|
||
isDark,
|
||
),
|
||
const SizedBox(width: 6),
|
||
_statIcon(
|
||
Icons.check_circle_outline,
|
||
'${batch.successfulCount ?? 0}',
|
||
isDark,
|
||
),
|
||
const SizedBox(width: 6),
|
||
_statIcon(
|
||
Icons.cancel_outlined,
|
||
'${batch.failedCount ?? 0}',
|
||
isDark,
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
if (!inSelectMode)
|
||
Icon(Icons.chevron_right, color: Colors.grey.shade400),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _statIcon(IconData icon, String label, bool isDark) {
|
||
return Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(
|
||
icon,
|
||
size: 12,
|
||
color: isDark ? Colors.white38 : Colors.grey.shade500,
|
||
),
|
||
const SizedBox(width: 3),
|
||
Text(
|
||
label,
|
||
style: TextStyle(
|
||
fontSize: 11,
|
||
color: isDark ? Colors.white54 : Colors.grey.shade600,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildSelectionBottomBar() {
|
||
final selectedCount = batchController.model.selectedBatchIds.length;
|
||
return Container(
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.black.withValues(alpha: 0.08),
|
||
blurRadius: 8,
|
||
offset: const Offset(0, -2),
|
||
),
|
||
],
|
||
),
|
||
child: SafeArea(
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||
child: Row(
|
||
children: [
|
||
Text(
|
||
'$selectedCount selected',
|
||
style: const TextStyle(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
const Spacer(),
|
||
TextButton.icon(
|
||
onPressed: selectedCount > 0 ? _onDeleteSelected : null,
|
||
icon: const Icon(Icons.delete_outline, size: 20),
|
||
label: const Text('Delete'),
|
||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|