added pagination to group and batch detail screens
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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!,
|
||||
|
||||
Reference in New Issue
Block a user