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