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 currentPage = 0;
int totalPages = 0; int totalPages = 0;
int totalElements = 0; int totalElements = 0;
int pageSize = 10;
String searchQuery = ''; String searchQuery = '';
String? statusFilter; String? statusFilter;
String? currencyFilter; String? currencyFilter;
@@ -34,6 +35,7 @@ class BatchScreenModel {
int batchItemsCurrentPage = 0; int batchItemsCurrentPage = 0;
int batchItemsTotalPages = 0; int batchItemsTotalPages = 0;
int batchItemsTotalElements = 0; int batchItemsTotalElements = 0;
int batchItemsPageSize = 10;
String batchItemsSearchQuery = ''; String batchItemsSearchQuery = '';
String? batchItemsStatusFilter; String? batchItemsStatusFilter;
String? batchItemsRecipientAccount; String? batchItemsRecipientAccount;
@@ -167,7 +169,7 @@ class BatchController extends ChangeNotifier {
Future<ApiResponse<List<GroupBatch>>> listBatches( Future<ApiResponse<List<GroupBatch>>> listBatches(
String groupId, { String groupId, {
int page = 0, int page = 0,
int size = 20, int size = 10,
String? search, String? search,
String? status, String? status,
String? currency, String? currency,
@@ -248,6 +250,7 @@ class BatchController extends ChangeNotifier {
await listBatches( await listBatches(
groupId, groupId,
page: 0, page: 0,
size: model.pageSize,
search: search, search: search,
status: status, status: status,
currency: currency, currency: currency,
@@ -261,6 +264,43 @@ class BatchController extends ChangeNotifier {
await listBatches( await listBatches(
groupId, groupId,
page: model.currentPage + 1, 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, search: model.searchQuery.isNotEmpty ? model.searchQuery : null,
status: model.statusFilter, status: model.statusFilter,
currency: model.currencyFilter, currency: model.currencyFilter,
@@ -270,7 +310,7 @@ class BatchController extends ChangeNotifier {
Future<ApiResponse<List<GroupBatchItem>>> listBatchItems( Future<ApiResponse<List<GroupBatchItem>>> listBatchItems(
String batchId, { String batchId, {
int page = 0, int page = 0,
int size = 20, int size = 10,
Map<String, String>? filters, Map<String, String>? filters,
}) async { }) async {
if (page == 0) { if (page == 0) {
@@ -343,6 +383,7 @@ class BatchController extends ChangeNotifier {
await listBatchItems( await listBatchItems(
batchId, batchId,
page: 0, page: 0,
size: model.batchItemsPageSize,
filters: _buildBatchItemFilters( filters: _buildBatchItemFilters(
search: search, search: search,
status: status, status: status,
@@ -359,6 +400,7 @@ class BatchController extends ChangeNotifier {
await listBatchItems( await listBatchItems(
batchId, batchId,
page: model.batchItemsCurrentPage + 1, page: model.batchItemsCurrentPage + 1,
size: model.batchItemsPageSize,
filters: _buildBatchItemFilters( filters: _buildBatchItemFilters(
search: model.batchItemsSearchQuery.isNotEmpty search: model.batchItemsSearchQuery.isNotEmpty
? model.batchItemsSearchQuery ? 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. /// Builds the filters map for batch items queries from individual params.
Map<String, String>? _buildBatchItemFilters({ Map<String, String>? _buildBatchItemFilters({
String? search, String? search,

View File

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

View File

@@ -75,7 +75,6 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
controller.addListener(() => setState(() {})); controller.addListener(() => setState(() {}));
_searchController.addListener(() => setState(() {})); _searchController.addListener(() => setState(() {}));
_scrollController.addListener(_onScroll);
_init(); _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 @override
void dispose() { void dispose() {
_searchController.dispose(); _searchController.dispose();
@@ -1781,9 +1771,9 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
child: Column( child: Column(
children: [ children: [
_buildCompactHeader(batch, theme, isDark), _buildCompactHeader(batch, theme, isDark),
const SizedBox(height: 12), const SizedBox(height: 20),
_buildActionBar(theme, isDark), _buildActionBar(theme, isDark),
const SizedBox(height: 12), const SizedBox(height: 20),
_buildItemsList(batch, theme, isDark), _buildItemsList(batch, theme, isDark),
], ],
), ),
@@ -1808,125 +1798,244 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
final status = batch.status ?? 'UNKNOWN'; final status = batch.status ?? 'UNKNOWN';
final createdAt = batch.createdAt; final createdAt = batch.createdAt;
final parsedDate = createdAt != null ? DateTime.tryParse(createdAt) : null; 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( return Container(
width: double.infinity, width: double.infinity,
decoration: BoxDecoration( decoration: BoxDecoration(
color: isDark ? const Color(0xFF1A1A2E) : Colors.white, gradient: LinearGradient(
borderRadius: BorderRadius.circular(16), 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( border: Border.all(
color: isDark color: isDark
? Colors.white.withValues(alpha: 0.06) ? Colors.white.withValues(alpha: 0.08)
: Colors.grey.shade200, : Colors.grey.shade200,
), ),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: isDark color: isDark
? Colors.black.withValues(alpha: 0.3) ? Colors.black.withValues(alpha: 0.35)
: Colors.black.withValues(alpha: 0.04), : Colors.black.withValues(alpha: 0.05),
blurRadius: 16, blurRadius: 20,
offset: const Offset(0, 4), offset: const Offset(0, 6),
), ),
], ],
), ),
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 20),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Row 1: Status + Amount + Refresh // ── Section 1: Status badge + Refresh ──
Row( Row(
children: [ children: [
StatusChip(status: status), StatusChip(status: status),
const SizedBox(width: 8), const Spacer(),
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,
),
),
),
_buildRefreshButton(theme, isDark), _buildRefreshButton(theme, isDark),
], ],
), ),
const SizedBox(height: 6), const SizedBox(height: 20),
// Row 2: Date + Summary chips
// ── Section 2: Hero Amount ⫶ the expressive centre-piece ──
Row( Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [ children: [
Row( Container(
children: [ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
Text( decoration: BoxDecoration(
'$currency ', color: theme.colorScheme.primary.withValues(alpha: 0.1),
style: TextStyle( borderRadius: BorderRadius.circular(8),
fontSize: 13, ),
fontWeight: FontWeight.w500, child: Text(
color: isDark ? Colors.white60 : Colors.grey.shade600, currency,
),
),
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(
'',
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 15,
color: isDark ? Colors.white24 : Colors.grey.shade400, fontWeight: FontWeight.w700,
color: theme.colorScheme.primary,
letterSpacing: 0.5,
), ),
), ),
if (batch.count != null) const SizedBox(width: 6), ),
if (batch.count != null) const SizedBox(width: 12),
Expanded( Text(
child: SingleChildScrollView( value?.toStringAsFixed(2) ?? '',
scrollDirection: Axis.horizontal, style: TextStyle(
child: Row( fontSize: 36,
children: [ fontWeight: FontWeight.w800,
_miniChip( color: isDark ? Colors.white : Colors.black87,
label: 'Total', height: 1.0,
value: batch.count!, letterSpacing: -1.0,
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,
),
],
),
),
), ),
),
], ],
), ),
// Row 3: Action buttons const SizedBox(height: 14),
const SizedBox(height: 10),
// ── 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), _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({ Widget _miniChip({
required String label, required String label,
required int value, required int value,
@@ -2015,9 +2124,10 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
if (canConfirm) { if (canConfirm) {
buttons.add( buttons.add(
_inlineActionButton( _prominentActionButton(
icon: Icons.check_circle_outline_rounded, icon: Icons.check_circle_rounded,
label: 'Confirm', label: 'Confirm Batch',
subtitle: 'Verify items and prepare for payment',
color: theme.colorScheme.primary, color: theme.colorScheme.primary,
isLoading: isActing, isLoading: isActing,
onPressed: _onConfirm, onPressed: _onConfirm,
@@ -2025,9 +2135,10 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
); );
} else if (canRequest) { } else if (canRequest) {
buttons.add( buttons.add(
_inlinePaymentButton( _prominentPaymentButton(
image: _batch!.paymentProcessorImage ?? '', image: _batch!.paymentProcessorImage ?? '',
label: 'Push Payment', label: 'Push Payment',
subtitle: 'Send payments to all recipients',
color: const Color(0xFF10B981), color: const Color(0xFF10B981),
isLoading: isActing, isLoading: isActing,
onPressed: _onRequest, onPressed: _onRequest,
@@ -2035,9 +2146,10 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
); );
} else if (canPoll) { } else if (canPoll) {
buttons.add( buttons.add(
_inlineActionButton( _prominentActionButton(
icon: Icons.refresh_rounded, icon: Icons.refresh_rounded,
label: 'Check Status', label: 'Check Status',
subtitle: 'Poll for the latest payment status',
color: const Color(0xFFF59E0B), color: const Color(0xFFF59E0B),
isLoading: isActing, isLoading: isActing,
onPressed: _onPoll, onPressed: _onPoll,
@@ -2047,9 +2159,10 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
if (canDownloadReport) { if (canDownloadReport) {
buttons.add( buttons.add(
_inlineActionButton( _prominentActionButton(
icon: Icons.download_rounded, icon: Icons.download_rounded,
label: 'Report', label: 'Download Report',
subtitle: 'Export batch results as CSV',
color: const Color(0xFF6366F1), color: const Color(0xFF6366F1),
isLoading: isActing, isLoading: isActing,
onPressed: _onDownloadReport, onPressed: _onDownloadReport,
@@ -2059,19 +2172,20 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
if (buttons.isEmpty) return const SizedBox.shrink(); if (buttons.isEmpty) return const SizedBox.shrink();
return Row( return Column(
children: [ children: buttons
...buttons.map( .map(
(btn) => (btn) =>
Padding(padding: const EdgeInsets.only(right: 8), child: btn), Padding(padding: const EdgeInsets.only(bottom: 10), child: btn),
), )
], .toList(),
); );
} }
Widget _inlineActionButton({ Widget _prominentActionButton({
required IconData icon, required IconData icon,
required String label, required String label,
required String subtitle,
required Color color, required Color color,
required bool isLoading, required bool isLoading,
required VoidCallback onPressed, required VoidCallback onPressed,
@@ -2079,37 +2193,71 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
return Material( return Material(
color: Colors.transparent, color: Colors.transparent,
child: InkWell( child: InkWell(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(14),
onTap: isLoading ? null : onPressed, onTap: isLoading ? null : onPressed,
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: color.withValues(alpha: 0.1), gradient: LinearGradient(
borderRadius: BorderRadius.circular(10), begin: Alignment.topLeft,
border: Border.all(color: color.withValues(alpha: 0.3)), 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( child: Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
if (isLoading) Container(
SizedBox( width: 44,
width: 14, height: 44,
height: 14, decoration: BoxDecoration(
child: CircularProgressIndicator( color: color.withValues(alpha: 0.15),
strokeWidth: 2, borderRadius: BorderRadius.circular(12),
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,
), ),
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 image,
required String label, required String label,
required String subtitle,
required Color color, required Color color,
required bool isLoading, required bool isLoading,
required VoidCallback onPressed, required VoidCallback onPressed,
@@ -2128,47 +2277,82 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
return Material( return Material(
color: Colors.transparent, color: Colors.transparent,
child: InkWell( child: InkWell(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(14),
onTap: isLoading ? null : onPressed, onTap: isLoading ? null : onPressed,
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: color.withValues(alpha: 0.1), gradient: LinearGradient(
borderRadius: BorderRadius.circular(10), begin: Alignment.topLeft,
border: Border.all(color: color.withValues(alpha: 0.3)), 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( child: Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
if (isLoading) Container(
SizedBox( width: 44,
width: 14, height: 44,
height: 14, decoration: BoxDecoration(
child: CircularProgressIndicator( color: Colors.white.withValues(alpha: 0.2),
strokeWidth: 2, borderRadius: BorderRadius.circular(12),
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),
),
), ),
const SizedBox(width: 6), padding: const EdgeInsets.all(8),
Text( child: isLoading
label, ? CircularProgressIndicator(
style: TextStyle( strokeWidth: 2.5,
fontSize: 12, valueColor: AlwaysStoppedAnimation<Color>(color),
fontWeight: FontWeight.w600, )
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( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (controller.model.batchItemsTotalElements > 0) if (totalElements > 0)
Padding( Padding(
padding: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.only(bottom: 8),
child: Text( child: Text(
'${controller.model.batchItems.length} of ${controller.model.batchItemsTotalElements} items', 'Showing $startItem$endItem of $totalElements items',
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@@ -2503,15 +2700,201 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
child: _buildItemCard(item, batch, theme, isDark), child: _buildItemCard(item, batch, theme, isDark),
), ),
), ),
if (controller.model.batchItemsIsLoadingMore) if (isLoadingMore)
const Padding( const Padding(
padding: EdgeInsets.symmetric(vertical: 16), padding: EdgeInsets.symmetric(vertical: 16),
child: Center(child: CircularProgressIndicator(strokeWidth: 2)), 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( Widget _buildItemCard(
GroupBatchItem item, GroupBatchItem item,
GroupBatch batch, GroupBatch batch,

View File

@@ -24,7 +24,6 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
late GroupController groupController; late GroupController groupController;
late BatchController batchController; late BatchController batchController;
final _searchController = TextEditingController(); final _searchController = TextEditingController();
final _scrollController = ScrollController();
final _memberSearchController = TextEditingController(); final _memberSearchController = TextEditingController();
late final AnimationController _membersAnimController; late final AnimationController _membersAnimController;
String _memberQuery = ''; String _memberQuery = '';
@@ -56,7 +55,6 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
groupController = GroupController(context); groupController = GroupController(context);
batchController = BatchController(context); batchController = BatchController(context);
_loadData(); _loadData();
_scrollController.addListener(_onScroll);
_searchController.addListener(() => setState(() {})); _searchController.addListener(() => setState(() {}));
_membersAnimController = AnimationController( _membersAnimController = AnimationController(
vsync: this, vsync: this,
@@ -64,19 +62,10 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
)..forward(); )..forward();
} }
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
batchController.loadNextPage(widget.group.id ?? '');
}
}
Future<void> _loadData() async { Future<void> _loadData() async {
final groupId = widget.group.id ?? ''; final groupId = widget.group.id ?? '';
await Future.wait([ await batchController.listBatches(groupId);
batchController.listBatches(groupId), await groupController.listMembers(groupId);
groupController.listMembers(groupId),
]);
} }
void _onSearchChanged(String value) { void _onSearchChanged(String value) {
@@ -376,8 +365,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
member.account, member.account,
isDark, isDark,
), ),
if (recipient.phoneNumber != null && if (recipient.phoneNumber?.isNotEmpty == true) ...[
recipient.phoneNumber!.isNotEmpty) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
_buildContactLine( _buildContactLine(
Icons.phone_outlined, Icons.phone_outlined,
@@ -385,8 +373,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
isDark, isDark,
), ),
], ],
if (recipient.email != null && if (recipient.email?.isNotEmpty == true) ...[
recipient.email!.isNotEmpty) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
_buildContactLine( _buildContactLine(
Icons.email_outlined, Icons.email_outlined,
@@ -435,9 +422,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
? memberAccount ? memberAccount
: recipient.account; : recipient.account;
final hasAccount = account != null && account.isNotEmpty; final hasAccount = account != null && account.isNotEmpty;
final hasProvider = final hasProvider = recipient.latestProviderLabel?.isNotEmpty == true;
recipient.latestProviderLabel != null &&
recipient.latestProviderLabel!.isNotEmpty;
if (!hasAccount && !hasProvider) { if (!hasAccount && !hasProvider) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
@@ -467,7 +452,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
if (hasAccount) if (hasAccount)
Flexible( Flexible(
child: Text( child: Text(
account!, account,
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: isDark ? Colors.white54 : Colors.grey.shade600, color: isDark ? Colors.white54 : Colors.grey.shade600,
@@ -825,7 +810,6 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
@override @override
void dispose() { void dispose() {
_searchController.dispose(); _searchController.dispose();
_scrollController.dispose();
_memberSearchController.dispose(); _memberSearchController.dispose();
_membersAnimController.dispose(); _membersAnimController.dispose();
groupController.dispose(); groupController.dispose();
@@ -835,18 +819,10 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
return Scaffold( 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( body: ListenableBuilder(
listenable: batchController, listenable: batchController,
builder: (context, _) { builder: (context, _) {
@@ -854,8 +830,38 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
children: [ children: [
Expanded( Expanded(
child: CustomScrollView( child: CustomScrollView(
controller: _scrollController,
slivers: [ 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( SliverToBoxAdapter(
child: Center( child: Center(
child: LayoutBuilder( child: LayoutBuilder(
@@ -871,8 +877,10 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildGroupHeaderCard(theme, isDark),
const SizedBox(height: 20),
_buildSearchRow(), _buildSearchRow(),
const SizedBox(height: 16), const SizedBox(height: 20),
_buildContent(), _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() { Widget _buildSearchRow() {
return Row( return Row(
children: [ 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( return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (batchController.model.selectMode) if (batchController.model.selectMode)
Padding( 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( ListView.separated(
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
@@ -1077,15 +1356,186 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
); );
}, },
), ),
if (batchController.model.isLoadingMore) if (isLoadingMore)
const Padding( const Padding(
padding: EdgeInsets.symmetric(vertical: 16), padding: EdgeInsets.symmetric(vertical: 16),
child: Center(child: CircularProgressIndicator(strokeWidth: 2)), 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) { Widget _activeFilterChip(String label, VoidCallback onRemove) {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
@@ -1193,8 +1643,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
children: [ children: [
Row( Row(
children: [ children: [
if (batch.description != null && if (batch.description?.isNotEmpty == true)
batch.description!.isNotEmpty)
Flexible( Flexible(
child: Text( child: Text(
batch.description!, batch.description!,