added pagination to group and batch detail screens

This commit is contained in:
2026-06-24 23:19:45 +02:00
parent 263c10d2bb
commit a31e38765d
4 changed files with 1119 additions and 210 deletions

View File

@@ -24,6 +24,7 @@ class BatchScreenModel {
int currentPage = 0;
int totalPages = 0;
int totalElements = 0;
int pageSize = 10;
String searchQuery = '';
String? statusFilter;
String? currencyFilter;
@@ -34,6 +35,7 @@ class BatchScreenModel {
int batchItemsCurrentPage = 0;
int batchItemsTotalPages = 0;
int batchItemsTotalElements = 0;
int batchItemsPageSize = 10;
String batchItemsSearchQuery = '';
String? batchItemsStatusFilter;
String? batchItemsRecipientAccount;
@@ -167,7 +169,7 @@ class BatchController extends ChangeNotifier {
Future<ApiResponse<List<GroupBatch>>> listBatches(
String groupId, {
int page = 0,
int size = 20,
int size = 10,
String? search,
String? status,
String? currency,
@@ -248,6 +250,7 @@ class BatchController extends ChangeNotifier {
await listBatches(
groupId,
page: 0,
size: model.pageSize,
search: search,
status: status,
currency: currency,
@@ -261,6 +264,43 @@ class BatchController extends ChangeNotifier {
await listBatches(
groupId,
page: model.currentPage + 1,
size: model.pageSize,
search: model.searchQuery.isNotEmpty ? model.searchQuery : null,
status: model.statusFilter,
currency: model.currencyFilter,
);
}
/// Navigates to a specific page of batches (0-based index).
/// Clears the current list and loads the requested page.
Future<void> goToPage(String groupId, int page) async {
if (model.isLoadingMore) return;
if (page < 0 || page >= model.totalPages) return;
if (page == model.currentPage) return;
// Clear existing batches so listBatches replaces instead of appending
model.batches = [];
model.currentPage = 0;
if (!_disposed) notifyListeners();
await listBatches(
groupId,
page: page,
size: model.pageSize,
search: model.searchQuery.isNotEmpty ? model.searchQuery : null,
status: model.statusFilter,
currency: model.currencyFilter,
);
}
/// Changes the page size for batches and reloads from page 0.
Future<void> setPageSize(String groupId, int size) async {
if (size == model.pageSize) return;
model.pageSize = size;
if (!_disposed) notifyListeners();
// Reload from page 0 with the new size
await searchBatches(
groupId,
search: model.searchQuery.isNotEmpty ? model.searchQuery : null,
status: model.statusFilter,
currency: model.currencyFilter,
@@ -270,7 +310,7 @@ class BatchController extends ChangeNotifier {
Future<ApiResponse<List<GroupBatchItem>>> listBatchItems(
String batchId, {
int page = 0,
int size = 20,
int size = 10,
Map<String, String>? filters,
}) async {
if (page == 0) {
@@ -343,6 +383,7 @@ class BatchController extends ChangeNotifier {
await listBatchItems(
batchId,
page: 0,
size: model.batchItemsPageSize,
filters: _buildBatchItemFilters(
search: search,
status: status,
@@ -359,6 +400,7 @@ class BatchController extends ChangeNotifier {
await listBatchItems(
batchId,
page: model.batchItemsCurrentPage + 1,
size: model.batchItemsPageSize,
filters: _buildBatchItemFilters(
search: model.batchItemsSearchQuery.isNotEmpty
? model.batchItemsSearchQuery
@@ -369,6 +411,39 @@ class BatchController extends ChangeNotifier {
);
}
/// Navigates to a specific page of batch items (0-based index).
/// Clears the current list and loads the requested page.
Future<void> goToBatchItemsPage(String batchId, int page) async {
if (model.batchItemsIsLoadingMore) return;
if (page < 0 || page >= model.batchItemsTotalPages) return;
if (page == model.batchItemsCurrentPage) return;
// Clear existing items so listBatchItems replaces instead of appending
model.batchItems = [];
model.batchItemsCurrentPage = 0;
if (!_disposed) notifyListeners();
await listBatchItems(
batchId,
page: page,
size: model.batchItemsPageSize,
filters: _buildBatchItemFilters(
search: model.batchItemsSearchQuery.isNotEmpty
? model.batchItemsSearchQuery
: null,
status: model.batchItemsStatusFilter,
account: model.batchItemsRecipientAccount,
),
);
}
/// Changes the page size for batch items and reloads from page 0.
void setBatchItemsPageSize(int size) {
if (size == model.batchItemsPageSize) return;
model.batchItemsPageSize = size;
if (!_disposed) notifyListeners();
}
/// Builds the filters map for batch items queries from individual params.
Map<String, String>? _buildBatchItemFilters({
String? search,

View File

@@ -8,6 +8,7 @@ import 'package:qpay/screens/groups/models/recipient_group_model.dart';
class GroupScreenModel {
List<RecipientGroup> groups = [];
List<RecipientGroupMember> members = [];
PageableModel? membersPage;
bool isLoading = false;
bool isLoadingMore = false;
String? errorMessage;
@@ -141,6 +142,7 @@ class GroupController extends ChangeNotifier {
'/recipient-groups/members?recipientGroupId=$groupId',
);
final response = _unwrap(raw);
model.membersPage = PageableModel.fromJson(response);
final List<dynamic> content;
if (response is List) {
content = response;

View File

@@ -75,7 +75,6 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
controller.addListener(() => setState(() {}));
_searchController.addListener(() => setState(() {}));
_scrollController.addListener(_onScroll);
_init();
}
@@ -104,15 +103,6 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
}
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
if (_batch?.id != null && _batch!.id!.isNotEmpty) {
controller.loadNextBatchItemsPage(_batch!.id!);
}
}
}
@override
void dispose() {
_searchController.dispose();
@@ -1781,9 +1771,9 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
child: Column(
children: [
_buildCompactHeader(batch, theme, isDark),
const SizedBox(height: 12),
const SizedBox(height: 20),
_buildActionBar(theme, isDark),
const SizedBox(height: 12),
const SizedBox(height: 20),
_buildItemsList(batch, theme, isDark),
],
),
@@ -1808,125 +1798,244 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
final status = batch.status ?? 'UNKNOWN';
final createdAt = batch.createdAt;
final parsedDate = createdAt != null ? DateTime.tryParse(createdAt) : null;
final totalCount = batch.count ?? 0;
final successCount = batch.successfulCount ?? 0;
final failedCount = batch.failedCount ?? 0;
return Container(
width: double.infinity,
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1A1A2E) : Colors.white,
borderRadius: BorderRadius.circular(16),
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.06)
? Colors.white.withValues(alpha: 0.08)
: Colors.grey.shade200,
),
boxShadow: [
BoxShadow(
color: isDark
? Colors.black.withValues(alpha: 0.3)
: Colors.black.withValues(alpha: 0.04),
blurRadius: 16,
offset: const Offset(0, 4),
? Colors.black.withValues(alpha: 0.35)
: Colors.black.withValues(alpha: 0.05),
blurRadius: 20,
offset: const Offset(0, 6),
),
],
),
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16),
padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Row 1: Status + Amount + Refresh
// ── Section 1: Status badge + Refresh ──
Row(
children: [
StatusChip(status: status),
const SizedBox(width: 8),
if (parsedDate != null)
Expanded(
child: Text(
DateFormat.yMMMd().add_jm().format(parsedDate),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w400,
color: isDark ? Colors.white38 : Colors.grey.shade500,
),
),
),
const Spacer(),
_buildRefreshButton(theme, isDark),
],
),
const SizedBox(height: 6),
// Row 2: Date + Summary chips
const SizedBox(height: 20),
// ── Section 2: Hero Amount ⫶ the expressive centre-piece ──
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Row(
children: [
Text(
'$currency ',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: isDark ? Colors.white60 : Colors.grey.shade600,
),
),
Text(
value?.toStringAsFixed(2) ?? '',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: isDark ? Colors.white : Colors.black87,
),
),
],
),
if (batch.count != null && parsedDate != null)
const SizedBox(width: 6),
if (batch.count != null)
Text(
'',
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
currency,
style: TextStyle(
fontSize: 11,
color: isDark ? Colors.white24 : Colors.grey.shade400,
fontSize: 15,
fontWeight: FontWeight.w700,
color: theme.colorScheme.primary,
letterSpacing: 0.5,
),
),
if (batch.count != null) const SizedBox(width: 6),
if (batch.count != null)
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_miniChip(
label: 'Total',
value: batch.count!,
color: Colors.blue,
isDark: isDark,
),
const SizedBox(width: 4),
_miniChip(
label: 'OK',
value: batch.successfulCount ?? 0,
color: Colors.green,
isDark: isDark,
),
const SizedBox(width: 4),
_miniChip(
label: 'Fail',
value: batch.failedCount ?? 0,
color: Colors.red,
isDark: isDark,
),
],
),
),
),
const SizedBox(width: 12),
Text(
value?.toStringAsFixed(2) ?? '',
style: TextStyle(
fontSize: 36,
fontWeight: FontWeight.w800,
color: isDark ? Colors.white : Colors.black87,
height: 1.0,
letterSpacing: -1.0,
),
),
],
),
// Row 3: Action buttons
const SizedBox(height: 10),
const SizedBox(height: 14),
// ── Section 3: Date display ──
if (parsedDate != null)
Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: isDark
? Colors.white.withValues(alpha: 0.05)
: Colors.grey.shade100,
borderRadius: BorderRadius.circular(10),
),
child: Icon(
Icons.calendar_today_rounded,
size: 18,
color: isDark ? Colors.white54 : Colors.grey.shade600,
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
DateFormat.yMMMMd().format(parsedDate),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: isDark ? Colors.white70 : Colors.black87,
),
),
Text(
DateFormat.jm().format(parsedDate),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w400,
color: isDark ? Colors.white38 : Colors.grey.shade500,
),
),
],
),
],
),
if (parsedDate != null) const SizedBox(height: 20),
// ── Section 4: Stat cards ──
if (totalCount > 0) ...[
Row(
children: [
Expanded(
child: _statCard(
icon: Icons.people_rounded,
label: 'Total',
value: totalCount,
color: const Color(0xFF3B82F6),
isDark: isDark,
),
),
const SizedBox(width: 10),
Expanded(
child: _statCard(
icon: Icons.check_circle_rounded,
label: 'Successful',
value: successCount,
color: const Color(0xFF10B981),
isDark: isDark,
),
),
const SizedBox(width: 10),
Expanded(
child: _statCard(
icon: Icons.cancel_rounded,
label: 'Failed',
value: failedCount,
color: const Color(0xFFEF4444),
isDark: isDark,
),
),
],
),
const SizedBox(height: 20),
],
// ── Section 5: Divider ──
Container(
height: 1,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: isDark
? [
Colors.white.withValues(alpha: 0.0),
Colors.white.withValues(alpha: 0.1),
Colors.white.withValues(alpha: 0.0),
]
: [
Colors.grey.shade200,
Colors.grey.shade300,
Colors.grey.shade200,
],
),
),
),
const SizedBox(height: 16),
// ── Section 6: Action buttons ──
_buildHeaderActionButtons(batch, theme),
],
),
);
}
Widget _statCard({
required IconData icon,
required String label,
required int value,
required Color color,
required bool isDark,
}) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 10),
decoration: BoxDecoration(
color: isDark
? color.withValues(alpha: 0.08)
: color.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isDark
? color.withValues(alpha: 0.18)
: color.withValues(alpha: 0.12),
),
),
child: Column(
children: [
Icon(icon, size: 20, color: color),
const SizedBox(height: 6),
Text(
'$value',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w800,
color: isDark ? Colors.white : Colors.black87,
height: 1.0,
),
),
const SizedBox(height: 2),
Text(
label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: isDark ? Colors.white54 : Colors.grey.shade500,
),
),
],
),
);
}
Widget _miniChip({
required String label,
required int value,
@@ -2015,9 +2124,10 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
if (canConfirm) {
buttons.add(
_inlineActionButton(
icon: Icons.check_circle_outline_rounded,
label: 'Confirm',
_prominentActionButton(
icon: Icons.check_circle_rounded,
label: 'Confirm Batch',
subtitle: 'Verify items and prepare for payment',
color: theme.colorScheme.primary,
isLoading: isActing,
onPressed: _onConfirm,
@@ -2025,9 +2135,10 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
);
} else if (canRequest) {
buttons.add(
_inlinePaymentButton(
_prominentPaymentButton(
image: _batch!.paymentProcessorImage ?? '',
label: 'Push Payment',
subtitle: 'Send payments to all recipients',
color: const Color(0xFF10B981),
isLoading: isActing,
onPressed: _onRequest,
@@ -2035,9 +2146,10 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
);
} else if (canPoll) {
buttons.add(
_inlineActionButton(
_prominentActionButton(
icon: Icons.refresh_rounded,
label: 'Check Status',
subtitle: 'Poll for the latest payment status',
color: const Color(0xFFF59E0B),
isLoading: isActing,
onPressed: _onPoll,
@@ -2047,9 +2159,10 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
if (canDownloadReport) {
buttons.add(
_inlineActionButton(
_prominentActionButton(
icon: Icons.download_rounded,
label: 'Report',
label: 'Download Report',
subtitle: 'Export batch results as CSV',
color: const Color(0xFF6366F1),
isLoading: isActing,
onPressed: _onDownloadReport,
@@ -2059,19 +2172,20 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
if (buttons.isEmpty) return const SizedBox.shrink();
return Row(
children: [
...buttons.map(
(btn) =>
Padding(padding: const EdgeInsets.only(right: 8), child: btn),
),
],
return Column(
children: buttons
.map(
(btn) =>
Padding(padding: const EdgeInsets.only(bottom: 10), child: btn),
)
.toList(),
);
}
Widget _inlineActionButton({
Widget _prominentActionButton({
required IconData icon,
required String label,
required String subtitle,
required Color color,
required bool isLoading,
required VoidCallback onPressed,
@@ -2079,37 +2193,71 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(10),
borderRadius: BorderRadius.circular(14),
onTap: isLoading ? null : onPressed,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: color.withValues(alpha: 0.3)),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
color.withValues(alpha: 0.12),
color.withValues(alpha: 0.06),
],
),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: color.withValues(alpha: 0.25)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isLoading)
SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(color),
),
)
else
Icon(icon, size: 16, color: color),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: color,
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: isLoading
? Padding(
padding: const EdgeInsets.all(10),
child: CircularProgressIndicator(
strokeWidth: 2.5,
valueColor: AlwaysStoppedAnimation<Color>(color),
),
)
: Icon(icon, size: 22, color: color),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: color,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w400,
color: color.withValues(alpha: 0.7),
),
),
],
),
),
Icon(
Icons.arrow_forward_ios_rounded,
size: 16,
color: color.withValues(alpha: 0.5),
),
],
),
@@ -2118,9 +2266,10 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
);
}
Widget _inlinePaymentButton({
Widget _prominentPaymentButton({
required String image,
required String label,
required String subtitle,
required Color color,
required bool isLoading,
required VoidCallback onPressed,
@@ -2128,47 +2277,82 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(10),
borderRadius: BorderRadius.circular(14),
onTap: isLoading ? null : onPressed,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: color.withValues(alpha: 0.3)),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
color.withValues(alpha: 0.12),
color.withValues(alpha: 0.06),
],
),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: color.withValues(alpha: 0.25)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isLoading)
SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(color),
),
)
else
Image.asset(
'assets/$image',
fit: BoxFit.cover,
width: 30,
errorBuilder: (_, __, ___) => Icon(
Icons.monetization_on_outlined,
size: 12,
color: Colors.white.withValues(alpha: 0.8),
),
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: color,
padding: const EdgeInsets.all(8),
child: isLoading
? CircularProgressIndicator(
strokeWidth: 2.5,
valueColor: AlwaysStoppedAnimation<Color>(color),
)
: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.asset(
'assets/$image',
width: 28,
height: 28,
errorBuilder: (_, __, ___) => Icon(
Icons.payment_rounded,
size: 22,
color: color,
),
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: color,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w400,
color: color.withValues(alpha: 0.7),
),
),
],
),
),
Icon(
Icons.arrow_forward_ios_rounded,
size: 16,
color: color.withValues(alpha: 0.5),
),
],
),
),
@@ -2481,15 +2665,28 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
);
}
// Show item count
final totalPages = controller.model.batchItemsTotalPages;
final currentPage = controller.model.batchItemsCurrentPage;
final totalElements = controller.model.batchItemsTotalElements;
final isLoadingMore = controller.model.batchItemsIsLoadingMore;
final hasMultiplePages = totalPages > 1;
// Calculate the range of items being shown (1-based)
final pageSize = controller.model.batchItemsPageSize;
final startItem = totalElements > 0 ? (currentPage * pageSize) + 1 : 0;
final endItem = (startItem + controller.model.batchItems.length - 1).clamp(
0,
totalElements,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (controller.model.batchItemsTotalElements > 0)
if (totalElements > 0)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'${controller.model.batchItems.length} of ${controller.model.batchItemsTotalElements} items',
'Showing $startItem$endItem of $totalElements items',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
@@ -2503,15 +2700,201 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
child: _buildItemCard(item, batch, theme, isDark),
),
),
if (controller.model.batchItemsIsLoadingMore)
if (isLoadingMore)
const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
),
if (hasMultiplePages) _buildPaginationControls(batch, theme, isDark),
],
);
}
/// Builds prev/next pagination controls for batch items.
Widget _buildPaginationControls(
GroupBatch batch,
ThemeData theme,
bool isDark,
) {
final totalPages = controller.model.batchItemsTotalPages;
final currentPage = controller.model.batchItemsCurrentPage;
final isLoadingMore = controller.model.batchItemsIsLoadingMore;
final batchId = batch.id ?? '';
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: () {
if (batchId.isNotEmpty) {
controller.goToBatchItemsPage(batchId, currentPage - 1);
}
},
theme: theme,
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: () {
if (batchId.isNotEmpty) {
controller.goToBatchItemsPage(batchId, currentPage + 1);
}
},
theme: theme,
isDark: isDark,
),
],
),
_buildPageSizeSelector(batch, theme, isDark),
],
),
],
);
}
/// Builds a dropdown to select the number of items per page.
Widget _buildPageSizeSelector(
GroupBatch batch,
ThemeData theme,
bool isDark,
) {
final pageSize = controller.model.batchItemsPageSize;
final isLoadingMore = controller.model.batchItemsIsLoadingMore;
final batchId = batch.id ?? '';
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 && batchId.isNotEmpty) {
controller.setBatchItemsPageSize(value);
// Re-load from page 0 with the new page size
controller.searchBatchItems(
batchId,
search:
controller.model.batchItemsSearchQuery.isNotEmpty
? controller.model.batchItemsSearchQuery
: null,
status: controller.model.batchItemsStatusFilter,
account: controller.model.batchItemsRecipientAccount,
);
}
},
),
),
),
],
);
}
Widget _paginationButton({
required IconData icon,
required String tooltip,
required bool enabled,
required VoidCallback onTap,
required ThemeData theme,
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 _buildItemCard(
GroupBatchItem item,
GroupBatch batch,

View File

@@ -24,7 +24,6 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
late GroupController groupController;
late BatchController batchController;
final _searchController = TextEditingController();
final _scrollController = ScrollController();
final _memberSearchController = TextEditingController();
late final AnimationController _membersAnimController;
String _memberQuery = '';
@@ -56,7 +55,6 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
groupController = GroupController(context);
batchController = BatchController(context);
_loadData();
_scrollController.addListener(_onScroll);
_searchController.addListener(() => setState(() {}));
_membersAnimController = AnimationController(
vsync: this,
@@ -64,19 +62,10 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
)..forward();
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
batchController.loadNextPage(widget.group.id ?? '');
}
}
Future<void> _loadData() async {
final groupId = widget.group.id ?? '';
await Future.wait([
batchController.listBatches(groupId),
groupController.listMembers(groupId),
]);
await batchController.listBatches(groupId);
await groupController.listMembers(groupId);
}
void _onSearchChanged(String value) {
@@ -376,8 +365,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
member.account,
isDark,
),
if (recipient.phoneNumber != null &&
recipient.phoneNumber!.isNotEmpty) ...[
if (recipient.phoneNumber?.isNotEmpty == true) ...[
const SizedBox(height: 4),
_buildContactLine(
Icons.phone_outlined,
@@ -385,8 +373,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
isDark,
),
],
if (recipient.email != null &&
recipient.email!.isNotEmpty) ...[
if (recipient.email?.isNotEmpty == true) ...[
const SizedBox(height: 4),
_buildContactLine(
Icons.email_outlined,
@@ -435,9 +422,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
? memberAccount
: recipient.account;
final hasAccount = account != null && account.isNotEmpty;
final hasProvider =
recipient.latestProviderLabel != null &&
recipient.latestProviderLabel!.isNotEmpty;
final hasProvider = recipient.latestProviderLabel?.isNotEmpty == true;
if (!hasAccount && !hasProvider) {
return const SizedBox.shrink();
}
@@ -467,7 +452,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
if (hasAccount)
Flexible(
child: Text(
account!,
account,
style: TextStyle(
fontSize: 12,
color: isDark ? Colors.white54 : Colors.grey.shade600,
@@ -825,7 +810,6 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
@override
void dispose() {
_searchController.dispose();
_scrollController.dispose();
_memberSearchController.dispose();
_membersAnimController.dispose();
groupController.dispose();
@@ -835,18 +819,10 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
return Scaffold(
appBar: AppBar(
title: Text(widget.group.name),
centerTitle: true,
actions: [
IconButton(
icon: const Icon(Icons.people_outline),
tooltip: 'View members',
onPressed: _showMembersSheet,
),
],
),
body: ListenableBuilder(
listenable: batchController,
builder: (context, _) {
@@ -854,8 +830,38 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
children: [
Expanded(
child: CustomScrollView(
controller: _scrollController,
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(
@@ -871,8 +877,10 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildGroupHeaderCard(theme, isDark),
const SizedBox(height: 20),
_buildSearchRow(),
const SizedBox(height: 16),
const SizedBox(height: 20),
_buildContent(),
],
),
@@ -893,6 +901,251 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
);
}
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: [
@@ -1027,7 +1280,21 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
);
}
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(
@@ -1064,6 +1331,18 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
],
),
),
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(),
@@ -1077,15 +1356,186 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
);
},
),
if (batchController.model.isLoadingMore)
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),
@@ -1193,8 +1643,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
children: [
Row(
children: [
if (batch.description != null &&
batch.description!.isNotEmpty)
if (batch.description?.isNotEmpty == true)
Flexible(
child: Text(
batch.description!,