From a31e38765ddac9b8b874ccaf55dd6443da902547 Mon Sep 17 00:00:00 2001 From: Vusa Date: Wed, 24 Jun 2026 23:19:45 +0200 Subject: [PATCH] added pagination to group and batch detail screens --- .../groups/controllers/batch_controller.dart | 79 +- .../groups/controllers/group_controller.dart | 2 + .../groups/screens/batch_detail_screen.dart | 723 ++++++++++++++---- .../groups/screens/group_detail_screen.dart | 525 ++++++++++++- 4 files changed, 1119 insertions(+), 210 deletions(-) diff --git a/lib/screens/groups/controllers/batch_controller.dart b/lib/screens/groups/controllers/batch_controller.dart index a11bb18..5c08456 100644 --- a/lib/screens/groups/controllers/batch_controller.dart +++ b/lib/screens/groups/controllers/batch_controller.dart @@ -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>> 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 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 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>> listBatchItems( String batchId, { int page = 0, - int size = 20, + int size = 10, Map? 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 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? _buildBatchItemFilters({ String? search, diff --git a/lib/screens/groups/controllers/group_controller.dart b/lib/screens/groups/controllers/group_controller.dart index 2238378..23aa102 100644 --- a/lib/screens/groups/controllers/group_controller.dart +++ b/lib/screens/groups/controllers/group_controller.dart @@ -8,6 +8,7 @@ import 'package:qpay/screens/groups/models/recipient_group_model.dart'; class GroupScreenModel { List groups = []; List 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 content; if (response is List) { content = response; diff --git a/lib/screens/groups/screens/batch_detail_screen.dart b/lib/screens/groups/screens/batch_detail_screen.dart index 9241f30..b6f3143 100644 --- a/lib/screens/groups/screens/batch_detail_screen.dart +++ b/lib/screens/groups/screens/batch_detail_screen.dart @@ -75,7 +75,6 @@ class _BatchDetailScreenState extends State controller.addListener(() => setState(() {})); _searchController.addListener(() => setState(() {})); - _scrollController.addListener(_onScroll); _init(); } @@ -104,15 +103,6 @@ class _BatchDetailScreenState extends State } } - 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 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 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 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 ); } 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 ); } 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 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 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 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), - ), - ) - 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), + ), + ) + : 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 ); } - 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 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), - ), - ) - 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), + ) + : 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 ); } - // 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 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( + 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, diff --git a/lib/screens/groups/screens/group_detail_screen.dart b/lib/screens/groups/screens/group_detail_screen.dart index 31b210e..06747bc 100644 --- a/lib/screens/groups/screens/group_detail_screen.dart +++ b/lib/screens/groups/screens/group_detail_screen.dart @@ -24,7 +24,6 @@ class _GroupDetailScreenState extends State 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 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 )..forward(); } - void _onScroll() { - if (_scrollController.position.pixels >= - _scrollController.position.maxScrollExtent - 200) { - batchController.loadNextPage(widget.group.id ?? ''); - } - } - Future _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 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 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 ? 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 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 @override void dispose() { _searchController.dispose(); - _scrollController.dispose(); _memberSearchController.dispose(); _membersAnimController.dispose(); groupController.dispose(); @@ -835,18 +819,10 @@ class _GroupDetailScreenState extends State @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 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 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 ); } + 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 ); } + 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 ], ), ), + 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 ); }, ), - 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( + 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 children: [ Row( children: [ - if (batch.description != null && - batch.description!.isNotEmpty) + if (batch.description?.isNotEmpty == true) Flexible( child: Text( batch.description!,