import 'package:flutter/material.dart'; import 'package:qpay/http/http.dart'; import 'package:qpay/models/api_response.dart'; import 'package:qpay/screens/groups/models/group_batch_model.dart'; import 'package:qpay/models/pageable_model.dart'; import 'package:qpay/screens/home/home_controller.dart'; import 'package:qpay/screens/pay/pay_controller.dart'; import 'package:qpay/screens/transactions/transaction_model.dart'; import '../models/batch_item_update_model.dart'; class BatchScreenModel { List batches = []; List batchItems = []; GroupBatch? currentBatch; List providers = []; List processors = []; BillProvider? selectedProvider; PaymentProcessor? selectedProcessor; bool isLoading = false; bool isLoadingMore = false; bool isActing = false; String? errorMessage; int currentPage = 0; int totalPages = 0; int totalElements = 0; int pageSize = 10; String searchQuery = ''; String? statusFilter; String? currencyFilter; bool selectMode = false; Set selectedBatchIds = {}; // Batch items pagination state int batchItemsCurrentPage = 0; int batchItemsTotalPages = 0; int batchItemsTotalElements = 0; int batchItemsPageSize = 10; String batchItemsSearchQuery = ''; String? batchItemsStatusFilter; String? batchItemsRecipientAccount; bool batchItemsIsLoadingMore = false; // Provider products cache: providerId -> list of products Map> providerProducts = {}; Set loadingProviderProducts = {}; } class BatchController extends ChangeNotifier { final BatchScreenModel model = BatchScreenModel(); final Http http = Http(); BuildContext context; bool _disposed = false; BatchController(this.context); @override void dispose() { _disposed = true; super.dispose(); } dynamic _unwrap(dynamic response) { if (response is Map && response.containsKey('body')) { return response['body']; } return response; } String buildQueryParameters(Map params) { if (params.isEmpty) { return ''; } String query = ''; params.forEach((key, value) { query += '$key=$value&'; }); return query.substring(0, query.length - 1); } Future>> loadProviders(String currency) async { try { final raw = await http.get( '/public/providers?currency=$currency&sort=priority,asc', ); final response = _unwrap(raw); final List content; if (response is Map && response.containsKey('content')) { content = response['content'] as List; } else if (response is List) { content = response; } else { content = []; } model.providers = content .map((e) => BillProvider.fromJson(e as Map)) .toList(); if (model.providers.isNotEmpty) { model.selectedProvider = model.providers.first; } if (!_disposed) notifyListeners(); return ApiResponse.success(model.providers); } catch (e) { logger.e(e); return ApiResponse.failure('Failed to load providers'); } } Future>> loadProcessors(currency) async { try { final raw = await http.get( '/public/payment-processors?currency=$currency', ); final response = _unwrap(raw); final List content = response is List ? response : (response['content'] ?? []); model.processors = content .map((e) => PaymentProcessor.fromJson(e as Map)) .toList(); if (model.processors.isNotEmpty) { model.selectedProcessor = model.processors.first; } if (!_disposed) notifyListeners(); return ApiResponse.success(model.processors); } catch (e) { logger.e(e); return ApiResponse.failure('Failed to load processors'); } } Future>> loadProviderProducts( String providerId, ) async { if (model.loadingProviderProducts.contains(providerId)) { // Already loading, return cached data if available final cached = model.providerProducts[providerId]; if (cached != null) { return ApiResponse.success(cached); } return ApiResponse.failure('Loading in progress'); } model.loadingProviderProducts.add(providerId); if (!_disposed) notifyListeners(); try { final raw = await http.get('/public/providers/$providerId/products'); final response = _unwrap(raw); final List list = response is List ? response : (response['content'] ?? []); final products = list .map((e) => BillProduct.fromJson(e as Map)) .toList(); model.providerProducts[providerId] = products; model.loadingProviderProducts.remove(providerId); if (!_disposed) notifyListeners(); return ApiResponse.success(products); } catch (e) { logger.e(e); model.loadingProviderProducts.remove(providerId); if (!_disposed) notifyListeners(); return ApiResponse.failure('Failed to load products'); } } Future>> listBatches( String groupId, { int page = 0, int size = 10, String? search, String? status, String? currency, }) async { if (page == 0) { model.isLoading = true; model.batches = []; model.currentPage = 0; } else { model.isLoadingMore = true; } if (!_disposed) notifyListeners(); try { final params = { 'recipientGroupId': groupId, 'page': page.toString(), 'size': size.toString(), 'sort': 'createdAt,desc', }; if (search != null && search.isNotEmpty) { params['description'] = search; } if (status != null && status.isNotEmpty) { params['status'] = status; } if (currency != null && currency.isNotEmpty) { params['currency'] = currency; } final raw = await http.get( '/group-batches?${buildQueryParameters(params)}', ); final response = _unwrap(raw); final pageableModel = PageableModel.fromJson( response as Map, ); final newBatches = pageableModel.content .map((e) => GroupBatch.fromJson(e as Map)) .toList(); if (page == 0) { model.batches = newBatches; } else { model.batches.addAll(newBatches); } model.currentPage = pageableModel.number; model.totalPages = pageableModel.totalPages; model.totalElements = pageableModel.totalElements; model.isLoading = false; model.isLoadingMore = false; if (!_disposed) notifyListeners(); return ApiResponse.success(model.batches); } catch (e) { logger.e(e); model.errorMessage = e.toString(); if (page == 0) { model.batches = []; } model.isLoading = false; model.isLoadingMore = false; if (!_disposed) notifyListeners(); return ApiResponse.failure('Failed to load batches'); } } Future searchBatches( String groupId, { String? search, String? status, String? currency, }) async { model.searchQuery = search ?? ''; model.statusFilter = status; model.currencyFilter = currency; await listBatches( groupId, page: 0, size: model.pageSize, search: search, status: status, currency: currency, ); } Future loadNextPage(String groupId) async { if (model.isLoadingMore || model.currentPage + 1 >= model.totalPages) { return; } await listBatches( groupId, page: model.currentPage + 1, size: model.pageSize, search: model.searchQuery.isNotEmpty ? model.searchQuery : null, status: model.statusFilter, currency: model.currencyFilter, ); } /// Navigates to a specific page of batches (0-based index). /// Clears the current list and loads the requested page. Future goToPage(String groupId, int page) async { if (model.isLoadingMore) return; if (page < 0 || page >= model.totalPages) return; if (page == model.currentPage) return; // Clear existing batches so listBatches replaces instead of appending model.batches = []; model.currentPage = 0; if (!_disposed) notifyListeners(); await listBatches( groupId, page: page, size: model.pageSize, search: model.searchQuery.isNotEmpty ? model.searchQuery : null, status: model.statusFilter, currency: model.currencyFilter, ); } /// Changes the page size for batches and reloads from page 0. Future setPageSize(String groupId, int size) async { if (size == model.pageSize) return; model.pageSize = size; if (!_disposed) notifyListeners(); // Reload from page 0 with the new size await searchBatches( groupId, search: model.searchQuery.isNotEmpty ? model.searchQuery : null, status: model.statusFilter, currency: model.currencyFilter, ); } Future>> listBatchItems( String batchId, { int page = 0, int size = 10, Map? filters, }) async { if (page == 0) { model.isLoading = true; model.batchItems = []; model.batchItemsCurrentPage = 0; } else { model.batchItemsIsLoadingMore = true; } if (!_disposed) notifyListeners(); try { final params = { 'groupBatchId': batchId, 'page': page.toString(), 'size': size.toString(), 'sort': 'createdAt,desc', }; if (filters != null) { params.addAll(filters); } final raw = await http.get( '/group-batches/items?${buildQueryParameters(params)}', ); final response = _unwrap(raw); final pageableModel = PageableModel.fromJson( response as Map, ); final newItems = pageableModel.content .map((e) => GroupBatchItem.fromJson(e as Map)) .toList(); if (page == 0) { model.batchItems = newItems; } else { model.batchItems.addAll(newItems); } model.batchItemsCurrentPage = pageableModel.number; model.batchItemsTotalPages = pageableModel.totalPages; model.batchItemsTotalElements = pageableModel.totalElements; model.isLoading = false; model.batchItemsIsLoadingMore = false; if (!_disposed) notifyListeners(); return ApiResponse.success(model.batchItems); } catch (e) { logger.e(e); model.errorMessage = e.toString(); if (page == 0) { model.batchItems = []; } model.isLoading = false; model.batchItemsIsLoadingMore = false; if (!_disposed) notifyListeners(); return ApiResponse.failure('Failed to load batch items'); } } Future searchBatchItems( String batchId, { String? search, String? status, String? account, }) async { model.batchItemsSearchQuery = search ?? ''; model.batchItemsStatusFilter = status; model.batchItemsRecipientAccount = account; await listBatchItems( batchId, page: 0, size: model.batchItemsPageSize, filters: _buildBatchItemFilters( search: search, status: status, account: account, ), ); } Future loadNextBatchItemsPage(String batchId) async { if (model.batchItemsIsLoadingMore || model.batchItemsCurrentPage + 1 >= model.batchItemsTotalPages) { return; } await listBatchItems( batchId, page: model.batchItemsCurrentPage + 1, size: model.batchItemsPageSize, filters: _buildBatchItemFilters( search: model.batchItemsSearchQuery.isNotEmpty ? model.batchItemsSearchQuery : null, status: model.batchItemsStatusFilter, account: model.batchItemsRecipientAccount, ), ); } /// Navigates to a specific page of batch items (0-based index). /// Clears the current list and loads the requested page. Future goToBatchItemsPage(String batchId, int page) async { if (model.batchItemsIsLoadingMore) return; if (page < 0 || page >= model.batchItemsTotalPages) return; if (page == model.batchItemsCurrentPage) return; // Clear existing items so listBatchItems replaces instead of appending model.batchItems = []; model.batchItemsCurrentPage = 0; if (!_disposed) notifyListeners(); await listBatchItems( batchId, page: page, size: model.batchItemsPageSize, filters: _buildBatchItemFilters( search: model.batchItemsSearchQuery.isNotEmpty ? model.batchItemsSearchQuery : null, status: model.batchItemsStatusFilter, account: model.batchItemsRecipientAccount, ), ); } /// Changes the page size for batch items and reloads from page 0. void setBatchItemsPageSize(int size) { if (size == model.batchItemsPageSize) return; model.batchItemsPageSize = size; if (!_disposed) notifyListeners(); } /// Builds the filters map for batch items queries from individual params. Map? _buildBatchItemFilters({ String? search, String? status, String? account, }) { final filters = {}; if (search != null && search.isNotEmpty) { filters['recipientName'] = search; } if (status != null && status.isNotEmpty) { filters['status'] = status; } if (account != null && account.isNotEmpty) { filters['recipientAccount'] = account; } return filters.isEmpty ? null : filters; } Future> getBatch( String batchId, String workspaceId, ) async { model.isActing = true; if (!_disposed) notifyListeners(); try { final raw = await http.get( '/group-batches/$batchId?workspaceId=$workspaceId', ); final response = _unwrap(raw); final batch = GroupBatch.fromJson(response as Map); model.currentBatch = batch; model.isActing = false; if (!_disposed) notifyListeners(); return ApiResponse.success(batch); } catch (e) { logger.e(e); model.isActing = false; if (!_disposed) notifyListeners(); return ApiResponse.failure('Failed to load batch'); } } Future> createBatch(CreateBatchRequest req) async { model.isActing = true; if (!_disposed) notifyListeners(); try { final raw = await http.post('/group-batches', req.toJson()); final response = _unwrap(raw); final batch = GroupBatch.fromJson(response as Map); model.currentBatch = batch; model.isActing = false; if (!_disposed) notifyListeners(); return ApiResponse.success(batch); } catch (e, s) { logger.e(e); logger.e(s); model.isActing = false; if (!_disposed) notifyListeners(); return ApiResponse.failure('Failed to create batch'); } } Future> updateBatch( GroupBatchUpdateRequest request, ) async { model.isActing = true; if (!_disposed) notifyListeners(); try { final raw = await http.put( '/group-batches/${request.id}', request.toJson(), ); final response = _unwrap(raw); final batch = GroupBatch.fromJson(response as Map); // Update the batch in the local list if present final index = model.batches.indexWhere((b) => b.id == batch.id); if (index != -1) { model.batches[index] = batch; } model.currentBatch = batch; model.isActing = false; if (!_disposed) notifyListeners(); return ApiResponse.success(batch); } catch (e, s) { logger.e(e); logger.e(s); model.isActing = false; if (!_disposed) notifyListeners(); return ApiResponse.failure('Failed to update batch'); } } Future>> updateBatchItems( String batchId, GroupBatchItemUpdateList updateList, ) async { model.isActing = true; if (!_disposed) notifyListeners(); try { final raw = await http.put( '/group-batches/$batchId/items', updateList.toJson(), ); final response = _unwrap(raw); final items = (response as List) .map((e) => GroupBatchItem.fromJson(e as Map)) .toList(); // Update items in the local batch items list if the current batch matches model.batchItems = items; model.isActing = false; if (!_disposed) notifyListeners(); return ApiResponse.success(items); } catch (e, s) { logger.e(e); logger.e(s); model.isActing = false; if (!_disposed) notifyListeners(); return ApiResponse.failure('Failed to update batch items'); } } Future> confirmBatch( String batchId, String workspaceId, String authType, ) async { model.isActing = true; if (!_disposed) notifyListeners(); try { final key = 'batch-$batchId-confirm-${DateTime.now().millisecondsSinceEpoch}'; final body = BatchActionRequest( workspaceId: workspaceId, authType: authType, idempotencyKey: key, ); final raw = await http.post( '/group-batches/$batchId/confirm', body.toJson(), ); final response = _unwrap(raw); final batch = GroupBatch.fromJson(response as Map); model.currentBatch = batch; model.isActing = false; if (!_disposed) notifyListeners(); return ApiResponse.success(batch); } catch (e) { logger.e(e); model.isActing = false; if (!_disposed) notifyListeners(); return ApiResponse.failure('Failed to confirm batch'); } } Future> requestBatch( String batchId, String workspaceId, ) async { model.isActing = true; if (!_disposed) notifyListeners(); try { final key = 'batch-$batchId-request-${DateTime.now().millisecondsSinceEpoch}'; final body = BatchActionRequest( workspaceId: workspaceId, idempotencyKey: key, ); final raw = await http.post( '/group-batches/$batchId/request', body.toJson(), ); final response = _unwrap(raw); final txn = TransactionModel.fromJson(response as Map); model.isActing = false; if (!_disposed) notifyListeners(); return ApiResponse.success(txn); } catch (e, s) { logger.e(e); logger.e(s); model.isActing = false; if (!_disposed) notifyListeners(); return ApiResponse.failure('Failed to request batch payment'); } } Future>> downloadBatchReport( String batchId, String workspaceId, ) async { model.isActing = true; if (!_disposed) notifyListeners(); try { final raw = await http.get( '/group-batches/$batchId/report?workspaceId=$workspaceId', ); final response = _unwrap(raw); model.isActing = false; if (!_disposed) notifyListeners(); return ApiResponse.success(response as Map); } catch (e, s) { logger.e(e); logger.e(s); model.isActing = false; if (!_disposed) notifyListeners(); return ApiResponse.failure('Failed to download batch report'); } } Future> pollBatch( String batchId, String workspaceId, ) async { model.isActing = true; if (!_disposed) notifyListeners(); try { final key = 'batch-$batchId-poll-${DateTime.now().millisecondsSinceEpoch}'; final body = BatchActionRequest( workspaceId: workspaceId, idempotencyKey: key, ); final raw = await http.post( '/group-batches/$batchId/poll', body.toJson(), ); final response = _unwrap(raw); final batchTransaction = TransactionModel.fromJson( response as Map, ); model.isActing = false; if (!_disposed) notifyListeners(); if (batchTransaction.pollingStatus == 'SUCCESS') { return ApiResponse.success(batchTransaction); } else { return ApiResponse.failure( response['errorMessage'] ?? "Transaction failed", ); } } catch (e, s) { logger.e(e); logger.e(s); model.isActing = false; if (!_disposed) notifyListeners(); return ApiResponse.failure('Failed to poll batch'); } } Future deleteBatch({ required String batchId, required String workspaceId, }) async { try { await http.delete('/group-batches/$batchId?workspaceId=$workspaceId'); model.batches.removeWhere((b) => b.id == batchId); model.selectedBatchIds.remove(batchId); model.currentBatch = null; if (!_disposed) notifyListeners(); } catch (e) { logger.e(e); model.errorMessage = e.toString(); if (!_disposed) notifyListeners(); } } Future deleteSelectedBatches({ required String groupId, required String workspaceId, }) async { final idsToDelete = List.from(model.selectedBatchIds); model.selectMode = false; model.selectedBatchIds.clear(); if (!_disposed) notifyListeners(); for (final id in idsToDelete) { await deleteBatch(batchId: id, workspaceId: workspaceId); } // Refresh after deletion await listBatches(groupId, page: 0); } void selectProvider(BillProvider provider) { model.selectedProvider = provider; if (!_disposed) notifyListeners(); } void selectProcessor(PaymentProcessor processor) { model.selectedProcessor = processor; if (!_disposed) notifyListeners(); } void toggleSelectMode() { model.selectMode = !model.selectMode; if (!model.selectMode) { model.selectedBatchIds.clear(); } if (!_disposed) notifyListeners(); } void toggleBatchSelection(String batchId) { if (model.selectedBatchIds.contains(batchId)) { model.selectedBatchIds.remove(batchId); } else { model.selectedBatchIds.add(batchId); } if (!_disposed) notifyListeners(); } }