Files
velocity-pay-flutter/lib/screens/groups/screens/group_detail_screen.dart

1796 lines
60 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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),
),
],
),
),
),
);
}
}