diff --git a/lib/http/http.dart b/lib/http/http.dart index e467ca0..053b940 100644 --- a/lib/http/http.dart +++ b/lib/http/http.dart @@ -58,6 +58,19 @@ class Http { return response.data; } + Future patch(String url, dynamic data) async { + Map headers = await getHeaders(); + + Response response; + response = await dio.patch( + baseUrl + url, + data: data, + options: Options(headers: headers), + ); + logger.i(response.data.toString()); + return response.data; + } + Future put(String url, dynamic data) async { Map headers = await getHeaders(); diff --git a/lib/screens/accounts/account_provider.dart b/lib/screens/accounts/account_provider.dart index a3bc483..d75578b 100644 --- a/lib/screens/accounts/account_provider.dart +++ b/lib/screens/accounts/account_provider.dart @@ -23,6 +23,20 @@ class AccountProvider extends ChangeNotifier { bool get isLoadingStatement => _isLoadingStatement; String? get accountError => _accountError; String? get statementError => _statementError; + bool _isDisposed = false; + + @override + void notifyListeners() { + if (!_isDisposed) { + super.notifyListeners(); + } + } + + @override + void dispose() { + _isDisposed = true; + super.dispose(); + } Future> fetchAccount(String phoneNumber) async { _isLoadingAccount = true; diff --git a/lib/screens/groups/controllers/batch_controller.dart b/lib/screens/groups/controllers/batch_controller.dart index b604649..07e69b5 100644 --- a/lib/screens/groups/controllers/batch_controller.dart +++ b/lib/screens/groups/controllers/batch_controller.dart @@ -7,6 +7,8 @@ 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 = []; @@ -27,6 +29,18 @@ class BatchScreenModel { String? currencyFilter; bool selectMode = false; Set selectedBatchIds = {}; + + // Batch items pagination state + int batchItemsCurrentPage = 0; + int batchItemsTotalPages = 0; + int batchItemsTotalElements = 0; + String batchItemsSearchQuery = ''; + String? batchItemsStatusFilter; + bool batchItemsIsLoadingMore = false; + + // Provider products cache: providerId -> list of products + Map> providerProducts = {}; + Set loadingProviderProducts = {}; } class BatchController extends ChangeNotifier { @@ -111,6 +125,44 @@ class BatchController extends ChangeNotifier { } } + 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, @@ -215,41 +267,99 @@ class BatchController extends ChangeNotifier { } Future>> listBatchItems( - String batchId, - ) async { - model.isLoading = true; + String batchId, { + int page = 0, + int size = 20, + String? search, + String? status, + }) 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 (search != null && search.isNotEmpty) { + params['recipientName'] = search; + params['recipientPhone'] = search; + } + if (status != null && status.isNotEmpty) { + params['status'] = status; + } + final raw = await http.get( - '/public/group-batches/items?groupBatchId=$batchId&sort=createdAt,desc', + '/public/group-batches/items?${buildQueryParameters(params)}', ); final response = _unwrap(raw); - final List content; - if (response is Map && response.containsKey('content')) { - content = PageableModel.fromJson( - response as Map, - ).content; - } else if (response is List) { - content = response; - } else { - content = []; - } - model.batchItems = content + 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(); - model.batchItems = []; + 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, + }) async { + model.batchItemsSearchQuery = search ?? ''; + model.batchItemsStatusFilter = status; + await listBatchItems(batchId, page: 0, search: search, status: status); + } + + Future loadNextBatchItemsPage(String batchId) async { + if (model.batchItemsIsLoadingMore || + model.batchItemsCurrentPage + 1 >= model.batchItemsTotalPages) { + return; + } + await listBatchItems( + batchId, + page: model.batchItemsCurrentPage + 1, + search: model.batchItemsSearchQuery.isNotEmpty + ? model.batchItemsSearchQuery + : null, + status: model.batchItemsStatusFilter, + ); + } + Future> getBatch( String batchId, String userId, @@ -324,6 +434,35 @@ class BatchController extends ChangeNotifier { } } + Future>> updateBatchItems( + String batchId, + GroupBatchItemUpdateList updateList, + ) async { + model.isActing = true; + if (!_disposed) notifyListeners(); + try { + final raw = await http.put( + '/public/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 userId, diff --git a/lib/screens/groups/models/batch_item_update_model.dart b/lib/screens/groups/models/batch_item_update_model.dart new file mode 100644 index 0000000..d32a5c3 --- /dev/null +++ b/lib/screens/groups/models/batch_item_update_model.dart @@ -0,0 +1,46 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'batch_item_update_model.g.dart'; + +@JsonSerializable(explicitToJson: true) +class GroupBatchItemUpdateRequest { + final String? id; + final double? amount; + final String? recipientName; + final String? recipientPhone; + final String? billClientId; + final String? billName; + final String? billProductName; + final String? providerImage; + final String? providerLabel; + + const GroupBatchItemUpdateRequest({ + this.id, + this.amount, + this.recipientName, + this.recipientPhone, + this.billClientId, + this.billName, + this.billProductName, + this.providerImage, + this.providerLabel, + }); + + factory GroupBatchItemUpdateRequest.fromJson(Map json) => + _$GroupBatchItemUpdateRequestFromJson(json); + + Map toJson() => _$GroupBatchItemUpdateRequestToJson(this); +} + +@JsonSerializable(explicitToJson: true) +class GroupBatchItemUpdateList { + final List? items; + final String? userId; + + const GroupBatchItemUpdateList({this.items, this.userId}); + + factory GroupBatchItemUpdateList.fromJson(Map json) => + _$GroupBatchItemUpdateListFromJson(json); + + Map toJson() => _$GroupBatchItemUpdateListToJson(this); +} diff --git a/lib/screens/groups/models/batch_item_update_model.g.dart b/lib/screens/groups/models/batch_item_update_model.g.dart new file mode 100644 index 0000000..25deb7e --- /dev/null +++ b/lib/screens/groups/models/batch_item_update_model.g.dart @@ -0,0 +1,53 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'batch_item_update_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +GroupBatchItemUpdateRequest _$GroupBatchItemUpdateRequestFromJson( + Map json, +) => GroupBatchItemUpdateRequest( + id: json['id'] as String?, + amount: (json['amount'] as num?)?.toDouble(), + recipientName: json['recipientName'] as String?, + recipientPhone: json['recipientPhone'] as String?, + billClientId: json['billClientId'] as String?, + billName: json['billName'] as String?, + billProductName: json['billProductName'] as String?, + providerImage: json['providerImage'] as String?, + providerLabel: json['providerLabel'] as String?, +); + +Map _$GroupBatchItemUpdateRequestToJson( + GroupBatchItemUpdateRequest instance, +) => { + 'id': instance.id, + 'amount': instance.amount, + 'recipientName': instance.recipientName, + 'recipientPhone': instance.recipientPhone, + 'billClientId': instance.billClientId, + 'billName': instance.billName, + 'billProductName': instance.billProductName, + 'providerImage': instance.providerImage, + 'providerLabel': instance.providerLabel, +}; + +GroupBatchItemUpdateList _$GroupBatchItemUpdateListFromJson( + Map json, +) => GroupBatchItemUpdateList( + items: (json['items'] as List?) + ?.map( + (e) => GroupBatchItemUpdateRequest.fromJson(e as Map), + ) + .toList(), + userId: json['userId'] as String?, +); + +Map _$GroupBatchItemUpdateListToJson( + GroupBatchItemUpdateList instance, +) => { + 'items': instance.items?.map((e) => e.toJson()).toList(), + 'userId': instance.userId, +}; diff --git a/lib/screens/groups/models/group_batch_model.dart b/lib/screens/groups/models/group_batch_model.dart index 8f13e5b..ade6d91 100644 --- a/lib/screens/groups/models/group_batch_model.dart +++ b/lib/screens/groups/models/group_batch_model.dart @@ -55,6 +55,10 @@ class GroupBatchItem { final String? transactionId; final String? errorMessage; final String? createdAt; + final String? billName; + final String? billProductName; + final String? providerLabel; + final String? providerImage; const GroupBatchItem({ this.id, @@ -66,6 +70,10 @@ class GroupBatchItem { this.transactionId, this.errorMessage, this.createdAt, + this.billName, + this.billProductName, + this.providerLabel, + this.providerImage, }); factory GroupBatchItem.fromJson(Map json) => @@ -84,7 +92,6 @@ class CreateBatchRequest { final String? paymentProcessorLabel; final String? paymentProcessorName; final String? paymentProcessorImage; - final Map transactionTemplate; const CreateBatchRequest({ required this.recipientGroupId, @@ -95,7 +102,6 @@ class CreateBatchRequest { this.paymentProcessorLabel, this.paymentProcessorName, this.paymentProcessorImage, - required this.transactionTemplate, }); factory CreateBatchRequest.fromJson(Map json) => @@ -140,4 +146,4 @@ class GroupBatchUpdateRequest { _$GroupBatchUpdateRequestFromJson(json); Map toJson() => _$GroupBatchUpdateRequestToJson(this); -} +} \ No newline at end of file diff --git a/lib/screens/groups/models/group_batch_model.g.dart b/lib/screens/groups/models/group_batch_model.g.dart index 8caa231..95d3db7 100644 --- a/lib/screens/groups/models/group_batch_model.g.dart +++ b/lib/screens/groups/models/group_batch_model.g.dart @@ -54,6 +54,10 @@ GroupBatchItem _$GroupBatchItemFromJson(Map json) => transactionId: json['transactionId'] as String?, errorMessage: json['errorMessage'] as String?, createdAt: json['createdAt'] as String?, + billName: json['billName'] as String?, + billProductName: json['billProductName'] as String?, + providerLabel: json['providerLabel'] as String?, + providerImage: json['providerImage'] as String?, ); Map _$GroupBatchItemToJson(GroupBatchItem instance) => @@ -67,6 +71,10 @@ Map _$GroupBatchItemToJson(GroupBatchItem instance) => 'transactionId': instance.transactionId, 'errorMessage': instance.errorMessage, 'createdAt': instance.createdAt, + 'billName': instance.billName, + 'billProductName': instance.billProductName, + 'providerLabel': instance.providerLabel, + 'providerImage': instance.providerImage, }; CreateBatchRequest _$CreateBatchRequestFromJson(Map json) => @@ -79,7 +87,6 @@ CreateBatchRequest _$CreateBatchRequestFromJson(Map json) => paymentProcessorLabel: json['paymentProcessorLabel'] as String?, paymentProcessorName: json['paymentProcessorName'] as String?, paymentProcessorImage: json['paymentProcessorImage'] as String?, - transactionTemplate: json['transactionTemplate'] as Map, ); Map _$CreateBatchRequestToJson(CreateBatchRequest instance) => @@ -92,7 +99,6 @@ Map _$CreateBatchRequestToJson(CreateBatchRequest instance) => 'paymentProcessorLabel': instance.paymentProcessorLabel, 'paymentProcessorName': instance.paymentProcessorName, 'paymentProcessorImage': instance.paymentProcessorImage, - 'transactionTemplate': instance.transactionTemplate, }; BatchActionRequest _$BatchActionRequestFromJson(Map json) => diff --git a/lib/screens/groups/screens/batch_create_screen.dart b/lib/screens/groups/screens/batch_create_screen.dart index 5a97063..eb6b74b 100644 --- a/lib/screens/groups/screens/batch_create_screen.dart +++ b/lib/screens/groups/screens/batch_create_screen.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:qpay/screens/groups/models/group_batch_model.dart'; +import 'package:qpay/screens/groups/models/batch_item_update_model.dart'; import 'package:qpay/screens/groups/models/recipient_group_model.dart'; import 'package:qpay/models/responsive_policy.dart'; import 'package:qpay/screens/groups/controllers/batch_controller.dart'; -import 'package:qpay/screens/home/home_controller.dart'; -import 'package:qpay/screens/pay/pay_controller.dart'; +import 'package:qpay/screens/provider/provider_controller.dart'; import 'package:qpay/widgets/app_snack_bar.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -19,7 +19,8 @@ class BatchCreateScreen extends StatefulWidget { } class _BatchCreateScreenState extends State { - late BatchController controller; + late BatchController batchController; + late ProviderController providerController; final _formKey = GlobalKey(); final _descriptionController = TextEditingController(); final _amountController = TextEditingController(); @@ -30,22 +31,48 @@ class _BatchCreateScreenState extends State { @override void initState() { super.initState(); - controller = BatchController(context); + batchController = BatchController(context); + providerController = ProviderController(context); + // Sync listeners so the UI rebuilds when either controller changes + batchController.addListener(_onControllerChange); + providerController.addListener(_onControllerChange); _init(); } + void _onControllerChange() { + if (!mounted) return; + setState(() { + // When products are available and a selected product exists with a non-zero + // defaultAmount, populate the amount field (only if it's empty or was cleared). + if (providerController.model.products.isNotEmpty && + providerController.model.selectedProduct != null && + _amountController.text.isEmpty) { + final product = providerController.model.selectedProduct!; + if (product.defaultAmount > 0) { + _amountController.text = product.defaultAmount.toStringAsFixed(2); + } + } + }); + } + Future _init() async { final prefs = await SharedPreferences.getInstance(); if (prefs.containsKey('phone')) { _debitPhoneController.text = prefs.getString('phone')!; } - await Future.wait([controller.loadProviders()]); + await Future.wait([ + providerController.loadProviders(_currency), + batchController.loadProcessors(), + ]); if (mounted) setState(() => _loadingDropdowns = false); } @override void dispose() { - controller.dispose(); + batchController.removeListener(_onControllerChange); + providerController.removeListener(_onControllerChange); + batchController.dispose(); + providerController.dispose(); _descriptionController.dispose(); _amountController.dispose(); _debitPhoneController.dispose(); @@ -55,13 +82,6 @@ class _BatchCreateScreenState extends State { Future _submit() async { if (!_formKey.currentState!.validate()) return; - final selectedProvider = controller.model.selectedProvider; - - if (selectedProvider == null) { - AppSnackBar.show(context, 'Please select a bill provider.'); - return; - } - final amount = double.tryParse(_amountController.text.trim()); if (amount == null || amount <= 0) { AppSnackBar.show(context, 'Please enter a valid amount.'); @@ -71,53 +91,87 @@ class _BatchCreateScreenState extends State { final prefs = await SharedPreferences.getInstance(); final userId = prefs.getString('userId') ?? ''; - final transactionTemplate = { - 'type': 'CONFIRM', - 'billClientId': selectedProvider.clientId, - 'debitPhone': _debitPhoneController.text.trim(), - 'debitRef': 'Velocity', - 'debitCurrency': _currency, - 'billName': selectedProvider.name, - 'providerImage': selectedProvider.image, - 'providerLabel': selectedProvider.label, - 'creditName': '', - 'creditEmail': '', - 'productUid': '', - 'billProductName': '', - 'region': 'ZW', - }; - final req = CreateBatchRequest( recipientGroupId: widget.group.id ?? '', userId: userId, currency: _currency, description: _descriptionController.text.trim(), amount: amount, - transactionTemplate: transactionTemplate, ); - final result = await controller.createBatch(req); + // Step 1: Create the batch + final result = await batchController.createBatch(req); if (!mounted) return; if (result.isSuccess) { final batch = result.data!; + + // Step 2: Update batch items with selected provider & product + final selectedProvider = providerController.model.selectedProvider; + final selectedProduct = providerController.model.selectedProduct; + + if (selectedProvider != null && batch.id != null) { + await _updateBatchItems( + batch.id!, + selectedProvider, + selectedProduct, + userId, + ); + } + + if (!mounted) return; context.pushReplacement('/groups/${widget.group.id}/batches/${batch.id}'); } else { AppSnackBar.showError(context, result.error ?? 'Failed to create batch.'); } } + Future _updateBatchItems( + String batchId, + BillProvider provider, + BillProduct? product, + String userId, + ) async { + // Fetch the batch items so we can update them + final itemsResult = await batchController.listBatchItems(batchId); + if (!mounted || !itemsResult.isSuccess) return; + + if (itemsResult.data!.isEmpty) return; + + final updateItems = itemsResult.data!.map((item) { + return GroupBatchItemUpdateRequest( + id: item.id, + amount: item.amount, + recipientName: item.recipientName, + recipientPhone: item.recipientPhone, + providerImage: provider.image, + providerLabel: provider.label, + billClientId: provider.clientId, + billName: provider.name, + billProductName: product?.name ?? '', + ); + }).toList(); + + final updateList = GroupBatchItemUpdateList( + items: updateItems, + userId: userId, + ); + + await batchController.updateBatchItems(batchId, updateList); + } + @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('New Batch'), centerTitle: true), body: ListenableBuilder( - listenable: controller, + listenable: Listenable.merge([batchController, providerController]), builder: (context, _) { if (_loadingDropdowns) { return const Center(child: CircularProgressIndicator()); } - return Center( + return Align( + alignment: Alignment.topCenter, child: LayoutBuilder( builder: (context, constraints) { final width = constraints.maxWidth > ResponsivePolicy.md @@ -132,51 +186,28 @@ class _BatchCreateScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _buildSectionLabel('Bill Provider'), - const SizedBox(height: 8), - _buildProviderGrid(constraints), - const SizedBox(height: 16), - TextFormField( - controller: _descriptionController, - textInputAction: TextInputAction.next, - decoration: InputDecoration( - labelText: 'Description (optional)', - hintText: 'e.g. Monthly salaries for January', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - ), - const SizedBox(height: 16), + // Row 1: Description + Currency Row( children: [ Expanded( flex: 2, child: TextFormField( - controller: _amountController, - keyboardType: - const TextInputType.numberWithOptions( - decimal: true, - ), + controller: _descriptionController, textInputAction: TextInputAction.next, decoration: InputDecoration( - labelText: - 'Amount each recipient will receive', - hintText: 'Amount', + labelText: 'Description (optional)', + hintText: + 'e.g. Monthly salaries for January', border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), ), ), - validator: (v) => - (v == null || v.trim().isEmpty) - ? 'Required' - : null, ), ), const SizedBox(width: 12), Expanded( child: DropdownButtonFormField( - value: _currency, + initialValue: _currency, decoration: InputDecoration( labelText: 'Currency', border: OutlineInputBorder( @@ -194,32 +225,82 @@ class _BatchCreateScreenState extends State { ), ], onChanged: (v) { - if (v != null) - setState(() => _currency = v); + if (v != null) _updateCurrency(v); }, ), ), ], ), const SizedBox(height: 16), - TextFormField( - controller: _debitPhoneController, - keyboardType: TextInputType.phone, - textInputAction: TextInputAction.done, - decoration: InputDecoration( - labelText: 'Your Phone Number', - hintText: '263773000000', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), + // Row 2: Provider + Product + Row( + children: [ + Expanded(child: _buildProviderDropdown()), + const SizedBox(width: 12), + Expanded(child: _buildProductDropdown()), + ], + ), + const SizedBox(height: 16), + // Row 3: Amount + Phone + Row( + children: [ + Expanded( + flex: 2, + child: TextFormField( + controller: _amountController, + readOnly: providerController + .model + .products + .isNotEmpty, + keyboardType: + const TextInputType.numberWithOptions( + decimal: true, + ), + textInputAction: TextInputAction.next, + decoration: InputDecoration( + labelText: + 'Amount each recipient will receive', + hintText: + providerController + .model + .products + .isNotEmpty + ? 'Set by product' + : 'Amount', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + validator: (v) => + (v == null || v.trim().isEmpty) + ? 'Required' + : null, + ), ), - ), - validator: (v) => (v == null || v.trim().isEmpty) - ? 'Phone number is required' - : null, + const SizedBox(width: 12), + Expanded( + child: TextFormField( + controller: _debitPhoneController, + keyboardType: TextInputType.phone, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + labelText: 'Your Phone Number', + hintText: '263773000000', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + validator: (v) => + (v == null || v.trim().isEmpty) + ? 'Phone number is required' + : null, + ), + ), + ], ), const SizedBox(height: 32), ElevatedButton( - onPressed: controller.model.isActing + onPressed: batchController.model.isActing ? null : _submit, style: ElevatedButton.styleFrom( @@ -228,7 +309,7 @@ class _BatchCreateScreenState extends State { borderRadius: BorderRadius.circular(12), ), ), - child: controller.model.isActing + child: batchController.model.isActing ? const SizedBox( height: 20, width: 20, @@ -255,185 +336,156 @@ class _BatchCreateScreenState extends State { ); } - Widget _buildSectionLabel(String text) { - return Text( - text, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Colors.grey.shade700, - ), - ); - } + Widget _buildProviderDropdown() { + final providers = providerController.model.providers; + final isDark = Theme.of(context).brightness == Brightness.dark; - static const Map _categoryColorMap = { - 'ECONET': Color(0xFF1A73E8), - 'NETONE': Color.fromARGB(255, 230, 127, 0), - 'TELECEL': Color(0xFF7B1FA2), - 'ZESA': Color.fromARGB(255, 230, 58, 0), - }; - - Color _resolveProviderColor(BillProvider provider) { - final exact = _categoryColorMap[provider.category.toUpperCase()]; - if (exact != null) return exact; - - final labelMatch = _categoryColorMap[provider.label.toUpperCase()]; - if (labelMatch != null) return labelMatch; - - final name = provider.name.toUpperCase(); - for (final entry in _categoryColorMap.entries) { - if (name.contains(entry.key)) return entry.value; - } - - final hash = provider.clientId.hashCode; - final hue = (hash % 360).toDouble(); - return HSVColor.fromAHSV(1.0, hue, 0.75, 0.85).toColor(); - } - - Widget _buildProviderGrid(BoxConstraints constraints) { - if (controller.model.providers.isEmpty) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - decoration: BoxDecoration( - border: Border.all(color: Colors.black26), - borderRadius: BorderRadius.circular(12), - ), - child: Text( - 'No providers available', - style: TextStyle(color: Colors.grey.shade600), - ), + if (providerController.model.isLoadingProviders) { + return const SizedBox( + height: 56, + child: Center(child: CircularProgressIndicator(strokeWidth: 2)), ); } - final isDark = Theme.of(context).brightness == Brightness.dark; + if (providers.isEmpty) { + return const SizedBox( + height: 56, + child: Center(child: Text('No providers available')), + ); + } - return LayoutBuilder( - builder: (context, gridConstraints) { - // gridConstraints.maxWidth reflects the actual rendered width of this widget - // 3-column grid: subtract 2 gaps (10*2 = 20), then divide by 3 - final cardWidth = (gridConstraints.maxWidth - 20) / 3; - - return Wrap( - spacing: 10, - runSpacing: 10, - children: controller.model.providers - .map( - (provider) => _buildProviderCard(provider, isDark, cardWidth), - ) - .toList(), + return DropdownButtonFormField( + initialValue: providerController.model.selectedProvider, + isExpanded: true, + decoration: InputDecoration( + labelText: 'Provider', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 14, + ), + ), + items: providers.map((provider) { + return DropdownMenuItem( + value: provider, + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.asset( + 'assets/${provider.image}', + width: 24, + height: 24, + errorBuilder: (_, __, ___) => + const Icon(Icons.payment_rounded, size: 20), + ), + ), + const SizedBox(width: 8), + Flexible( + child: Text( + provider.name, + style: TextStyle( + fontSize: 13, + color: isDark ? Colors.white : Colors.black87, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), ); + }).toList(), + onChanged: (provider) { + if (provider != null) { + _amountController.clear(); + providerController.selectProvider(provider); + } }, ); } - Widget _buildProviderCard(BillProvider provider, bool isDark, double width) { - final isSelected = - controller.model.selectedProvider?.clientId == provider.clientId; - final accent = _resolveProviderColor(provider); + Widget _buildProductDropdown() { + final products = providerController.model.products; + final isDark = Theme.of(context).brightness == Brightness.dark; - return SizedBox( - width: width, - child: Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(12), - onTap: () => controller.selectProvider(provider), - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: isDark ? const Color(0xFF1E1E1E) : Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: isSelected - ? accent - : isDark - ? Colors.white.withValues(alpha: 0.1) - : Colors.black.withValues(alpha: 0.06), - width: isSelected ? 1.5 : 1, - ), - ), - child: Stack( - children: [ - Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - width: 28, - height: 28, - padding: const EdgeInsets.all(2), - decoration: BoxDecoration( - color: accent.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(8), - ), - child: Image( - image: AssetImage("assets/${provider.image}"), - errorBuilder: (_, __, ___) => Icon( - Icons.payment_rounded, - size: 14, - color: accent, - ), - ), - ), - ], - ), - const SizedBox(height: 8), - Text( - provider.name, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: isDark ? Colors.white : Colors.black87, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - Text( - provider.description, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w400, - color: isDark ? Colors.white54 : Colors.grey.shade500, - height: 1.3, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ], - ), - if (isSelected) - Positioned( - top: -2, - right: -2, - child: Container( - width: 18, - height: 18, - decoration: BoxDecoration( - color: accent, - shape: BoxShape.circle, - border: Border.all( - color: isDark - ? const Color(0xFF1E1E1E) - : Colors.white, - width: 1.5, - ), - ), - child: const Icon( - Icons.check_rounded, - size: 11, - color: Colors.white, - ), - ), - ), - ], - ), + if (providerController.model.selectedProvider == null) { + return InputDecorator( + decoration: InputDecoration( + labelText: 'Product', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 14, ), ), + child: Text( + 'Select a provider first', + style: TextStyle(fontSize: 13, color: Colors.grey.shade500), + ), + ); + } + + if (providerController.model.isLoadingProducts) { + return const SizedBox( + height: 56, + child: Center(child: CircularProgressIndicator(strokeWidth: 2)), + ); + } + + if (products.isEmpty) { + return InputDecorator( + decoration: InputDecoration( + labelText: 'Product', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 14, + ), + ), + child: Text( + 'No products', + style: TextStyle(fontSize: 13, color: Colors.grey.shade500), + ), + ); + } + + return DropdownButtonFormField( + value: providerController.model.selectedProduct, + isExpanded: true, + decoration: InputDecoration( + labelText: 'Product', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 14, + ), ), + items: products.map((product) { + return DropdownMenuItem( + value: product, + child: Text( + '${product.displayName.isNotEmpty ? product.displayName : product.name} - \$${product.defaultAmount}', + style: TextStyle( + fontSize: 13, + color: isDark ? Colors.white : Colors.black87, + ), + overflow: TextOverflow.ellipsis, + ), + ); + }).toList(), + onChanged: (product) { + if (product != null) { + providerController.selectProduct(product); + if (product.defaultAmount > 0) { + _amountController.text = product.defaultAmount.toStringAsFixed(2); + } + } + }, ); } + + void _updateCurrency(String v) { + setState(() => _currency = v); + providerController.loadProviders(v); + } } diff --git a/lib/screens/groups/screens/batch_detail_screen.dart b/lib/screens/groups/screens/batch_detail_screen.dart index bf6c5fa..ccc7e22 100644 --- a/lib/screens/groups/screens/batch_detail_screen.dart +++ b/lib/screens/groups/screens/batch_detail_screen.dart @@ -2,9 +2,11 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:qpay/screens/confirm/confirm_controller.dart'; +import 'package:qpay/screens/groups/models/batch_item_update_model.dart'; import 'package:qpay/screens/groups/models/group_batch_model.dart'; import 'package:qpay/models/responsive_policy.dart'; import 'package:qpay/screens/groups/controllers/batch_controller.dart'; +import 'package:qpay/screens/home/home_controller.dart'; import 'package:qpay/screens/pay/pay_controller.dart'; import 'package:qpay/widgets/app_snack_bar.dart'; import 'package:qpay/widgets/status_chip_widget.dart'; @@ -32,25 +34,25 @@ class _BatchDetailScreenState extends State GroupBatchUpdateRequest? _cachedUpdateReq; bool _showPollButton = false; String _userId = ''; - int _selectedTabIndex = 0; bool _initialLoading = true; String? _loadError; PaymentProcessor? _selectedProcessor; - late AnimationController _headerAnimController; - late Animation _headerSlideAnimation; - late Animation _headerFadeAnimation; + final TextEditingController _searchController = TextEditingController(); + final ScrollController _scrollController = ScrollController(); - late AnimationController _cardAnimController; - late Animation _cardSlideAnimation; - late Animation _cardFadeAnimation; + bool _editMode = false; + final Map _editedAmounts = {}; + final Map _editedProviders = {}; + final Map _editedProducts = {}; - late AnimationController _contentAnimController; - late Animation _contentSlideAnimation; - late Animation _contentFadeAnimation; + late AnimationController _entryController; + late Animation _entryFade; + late Animation _entrySlide; static const _terminalStatuses = {'SUCCESS', 'COMPLETE', 'FAILED'}; static const _successStatuses = {'SUCCESS', 'COMPLETE'}; + static const _editableStatuses = {'CREATED', 'CONFIRM_FAILED', 'CONFIRMING'}; GroupBatch? get _batch => controller.model.currentBatch; @@ -60,58 +62,23 @@ class _BatchDetailScreenState extends State controller = BatchController(context); controller.loadProcessors(); - _headerAnimController = AnimationController( + _entryController = AnimationController( + duration: const Duration(milliseconds: 220), vsync: this, - duration: const Duration(milliseconds: 600), - ); - _headerSlideAnimation = - Tween(begin: const Offset(0, -0.08), end: Offset.zero).animate( - CurvedAnimation( - parent: _headerAnimController, - curve: Curves.easeOutCubic, - ), - ); - _headerFadeAnimation = Tween(begin: 0.0, end: 1.0).animate( - CurvedAnimation(parent: _headerAnimController, curve: Curves.easeOut), ); + _entryFade = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation(parent: _entryController, curve: Curves.easeOut)); + _entrySlide = Tween( + begin: const Offset(0, 0.02), + end: Offset.zero, + ).animate(CurvedAnimation(parent: _entryController, curve: Curves.easeOut)); + _entryController.forward(); - _cardAnimController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 700), - ); - _cardSlideAnimation = - Tween(begin: const Offset(0, 0.06), end: Offset.zero).animate( - CurvedAnimation( - parent: _cardAnimController, - curve: Curves.easeOutCubic, - ), - ); - _cardFadeAnimation = Tween(begin: 0.0, end: 1.0).animate( - CurvedAnimation(parent: _cardAnimController, curve: Curves.easeOut), - ); - - _contentAnimController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 800), - ); - _contentSlideAnimation = - Tween(begin: const Offset(0, 0.08), end: Offset.zero).animate( - CurvedAnimation( - parent: _contentAnimController, - curve: Curves.easeOutCubic, - ), - ); - _contentFadeAnimation = Tween(begin: 0.0, end: 1.0).animate( - CurvedAnimation(parent: _contentAnimController, curve: Curves.easeOut), - ); - - _headerAnimController.forward(); - Future.delayed(const Duration(milliseconds: 150), () { - if (mounted) _cardAnimController.forward(); - }); - Future.delayed(const Duration(milliseconds: 350), () { - if (mounted) _contentAnimController.forward(); - }); + controller.addListener(() => setState(() {})); + _searchController.addListener(() => setState(() {})); + _scrollController.addListener(_onScroll); _init(); } @@ -138,11 +105,20 @@ class _BatchDetailScreenState extends State } } + void _onScroll() { + if (_scrollController.position.pixels >= + _scrollController.position.maxScrollExtent - 200) { + if (_batch?.id != null && _batch!.id!.isNotEmpty) { + controller.loadNextBatchItemsPage(_batch!.id!); + } + } + } + @override void dispose() { - _headerAnimController.dispose(); - _cardAnimController.dispose(); - _contentAnimController.dispose(); + _searchController.dispose(); + _scrollController.dispose(); + _entryController.dispose(); _confirmController?.dispose(); controller.dispose(); super.dispose(); @@ -152,12 +128,12 @@ class _BatchDetailScreenState extends State final batch = _batch; if (batch == null) return; - // Show the bottom sheet so the user can pick a processor and confirm. + // Step 1: Choose payment processor and update the batch with it before confirming final processor = await _showProcessorSelectorBottomSheet(); if (processor == null) return; // user dismissed _selectedProcessor = processor; - // Persist the selected processor on the batch before confirming + // Persist the selected payment processor on the batch before confirming final updateReq = GroupBatchUpdateRequest( id: batch.id, userId: _userId, @@ -175,6 +151,7 @@ class _BatchDetailScreenState extends State // Cache the update request so _onRequest can check the processor _cachedUpdateReq = updateReq; + // Step 2: Confirm the batch final result = await controller.confirmBatch(batch.id!, _userId); if (!mounted) return; if (!result.isSuccess) { @@ -727,6 +704,753 @@ class _BatchDetailScreenState extends State } } + void _toggleEditMode() async { + if (_editMode) { + // Exiting edit mode — discard pending edits + _clearEditState(); + return; + } + + // Entering edit mode — ensure providers are loaded + if (controller.model.providers.isEmpty) { + await controller.loadProviders(); + } + if (!mounted) return; + + // Initialize edit controllers with current item values + _clearEditState(); + for (final item in controller.model.batchItems) { + if (item.id != null) { + _editedAmounts[item.id!] = TextEditingController( + text: item.amount?.toStringAsFixed(2) ?? '', + ); + // Match provider by label from the item's providerLabel field, + // falling back to the first provider if no match is found + if (controller.model.providers.isNotEmpty) { + final matchedProvider = item.providerLabel != null + ? controller.model.providers.cast().firstWhere( + (p) => p?.label == item.providerLabel, + orElse: () => null, + ) + : null; + _editedProviders[item.id!] = + matchedProvider ?? controller.model.providers.first; + } + + // Match product by billProductName from the item + if (_editedProviders[item.id!] != null) { + final providerId = _editedProviders[item.id!]!.id; + _ensureProductsLoaded(providerId, item.id!); + } + } + } + + setState(() => _editMode = true); + } + + /// Ensures products are loaded for the given provider, then tries to match + /// the item's [billProductName] to a product. Called during edit mode init. + Future _ensureProductsLoaded(String providerId, String itemId) async { + final cached = controller.model.providerProducts[providerId]; + if (cached != null) { + // Already cached — match now + _matchProductForItem(itemId, cached); + return; + } + + // Load products for this provider + final result = await controller.loadProviderProducts(providerId); + if (!mounted || result.data == null) return; + + final item = controller.model.batchItems + .where((i) => i.id == itemId) + .firstOrNull; + if (item == null) return; + + _matchProductForItem(itemId, result.data!); + setState(() {}); + } + + void _matchProductForItem(String itemId, List products) { + final item = controller.model.batchItems + .where((i) => i.id == itemId) + .firstOrNull; + if (item == null) return; + + if (products.isEmpty) { + _editedProducts[itemId] = null; + return; + } + + final matchedProduct = + item.billProductName != null && item.billProductName!.isNotEmpty + ? products.cast().firstWhere( + (p) => + p?.name == item.billProductName || + p?.displayName == item.billProductName, + orElse: () => null, + ) + : null; + final selected = matchedProduct ?? products.first; + _editedProducts[itemId] = selected; + + // Update amount with the product's default amount + if (selected.defaultAmount > 0) { + final ctrl = _editedAmounts[itemId]; + if (ctrl != null) { + ctrl.text = selected.defaultAmount.toStringAsFixed(2); + } + } + } + + void _clearEditState() { + for (final controller in _editedAmounts.values) { + controller.dispose(); + } + _editedAmounts.clear(); + _editedProviders.clear(); + _editedProducts.clear(); + setState(() => _editMode = false); + } + + /// Shows a bottom sheet with provider & product dropdowns (2 columns) to apply + /// to all batch items. Returns both the chosen [BillProvider] and optional [BillProduct], + /// or `null` if dismissed. + Future<({BillProvider provider, BillProduct? product})?> + _showBatchProviderSelectorBottomSheet() async { + if (controller.model.providers.isEmpty) { + await controller.loadProviders(); + if (controller.model.providers.isEmpty) { + _showError('No providers available.'); + return null; + } + } + + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final allProviders = controller.model.providers; + + // Selected provider & product state inside the sheet + BillProvider? selectedProvider; + BillProduct? selectedProduct; + bool isLoadingProducts = false; + + return showModalBottomSheet< + ({BillProvider provider, BillProduct? product}) + >( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (sheetContext) { + return StatefulBuilder( + builder: (innerContext, setSheetState) { + // Resolve products based on selected provider + final providerProducts = selectedProvider != null + ? (controller.model.providerProducts[selectedProvider!.id] ?? + []) + : []; + + return Container( + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1A1A2E) : Colors.white, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(24), + ), + ), + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 12, + bottom: MediaQuery.of(innerContext).viewInsets.bottom + 24, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Text( + 'Apply to All Items', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: isDark ? Colors.white : Colors.black87, + ), + ), + const SizedBox(height: 6), + Text( + 'Choose provider & product for every batch item.', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Colors.grey.shade500, + ), + ), + const SizedBox(height: 18), + // Two-column: Provider + Product dropdowns + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Provider dropdown + Expanded( + child: _buildBatchProviderDropdown( + providers: allProviders, + selectedProvider: selectedProvider, + isDark: isDark, + theme: theme, + onChanged: (provider) { + setSheetState(() { + selectedProvider = provider; + selectedProduct = null; + isLoadingProducts = true; + }); + // Load products for the selected provider + if (!controller.model.providerProducts.containsKey( + provider.id, + )) { + controller.loadProviderProducts(provider.id).then( + (_) { + if (mounted) { + setSheetState(() { + isLoadingProducts = false; + }); + } + }, + ); + } else { + setSheetState(() { + isLoadingProducts = false; + }); + } + }, + ), + ), + const SizedBox(width: 8), + // Product dropdown + Expanded( + child: _buildBatchProductDropdown( + products: providerProducts, + selectedProduct: selectedProduct, + isLoading: isLoadingProducts, + isDark: isDark, + theme: theme, + onChanged: (product) { + setSheetState(() { + selectedProduct = product; + }); + }, + ), + ), + ], + ), + const SizedBox(height: 18), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () { + final provider = selectedProvider ?? allProviders.first; + Navigator.of( + innerContext, + ).pop((provider: provider, product: selectedProduct)); + }, + style: ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + elevation: 0, + ), + child: const Text( + 'Apply', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + ), + ); + }, + ); + }, + ); + } + + /// Builds the provider dropdown used in the batch-level bottom sheet. + Widget _buildBatchProviderDropdown({ + required List providers, + required BillProvider? selectedProvider, + required bool isDark, + required ThemeData theme, + required ValueChanged onChanged, + }) { + return SizedBox( + height: 48, + child: DropdownButtonFormField( + value: selectedProvider, + isExpanded: true, + hint: const Text('Select Provider'), + decoration: InputDecoration( + labelText: 'Provider', + labelStyle: TextStyle(fontSize: 12, color: Colors.grey.shade500), + filled: true, + fillColor: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.grey.shade50, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + isDense: true, + ), + items: providers.map((provider) { + return DropdownMenuItem( + value: provider, + child: Text( + provider.label, + style: TextStyle( + fontSize: 13, + color: isDark ? Colors.white : Colors.black87, + ), + overflow: TextOverflow.ellipsis, + ), + ); + }).toList(), + onChanged: (provider) { + if (provider != null) { + onChanged(provider); + } + }, + ), + ); + } + + /// Builds the product dropdown used in the batch-level bottom sheet. + Widget _buildBatchProductDropdown({ + required List products, + required BillProduct? selectedProduct, + required bool isLoading, + required bool isDark, + required ThemeData theme, + required ValueChanged onChanged, + }) { + if (isLoading) { + return SizedBox( + height: 48, + child: Row( + children: [ + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 8), + Text( + 'Loading...', + style: TextStyle(fontSize: 12, color: Colors.grey.shade500), + ), + ], + ), + ); + } + + if (products.isEmpty) { + return SizedBox( + height: 48, + child: InputDecorator( + decoration: InputDecoration( + labelText: 'Product', + labelStyle: TextStyle(fontSize: 12, color: Colors.grey.shade500), + filled: true, + fillColor: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.grey.shade50, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + isDense: true, + ), + child: Text( + 'No products', + style: TextStyle(fontSize: 13, color: Colors.grey.shade500), + ), + ), + ); + } + + return SizedBox( + height: 48, + child: DropdownButtonFormField( + value: selectedProduct ?? products.first, + isExpanded: true, + decoration: InputDecoration( + labelText: 'Product', + labelStyle: TextStyle(fontSize: 12, color: Colors.grey.shade500), + filled: true, + fillColor: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.grey.shade50, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + isDense: true, + ), + items: products.map((product) { + return DropdownMenuItem( + value: product, + child: Text( + product.displayName.isNotEmpty + ? product.displayName + : product.name, + style: TextStyle( + fontSize: 13, + color: isDark ? Colors.white : Colors.black87, + ), + overflow: TextOverflow.ellipsis, + ), + ); + }).toList(), + onChanged: (product) { + if (product != null) { + onChanged(product); + } + }, + ), + ); + } + + /// Shows a bottom sheet to pick a product and apply it to all batch items. + /// Loads products for the provider if needed. + /// Returns the chosen [BillProduct], or `null` if dismissed. + Future _showBatchProductSelectorBottomSheet( + BillProvider provider, + ) async { + // Load products for this provider if not already cached + if (!controller.model.providerProducts.containsKey(provider.id)) { + final result = await controller.loadProviderProducts(provider.id); + if (!mounted) return null; + if (!result.isSuccess) { + _showError(result.error ?? 'Failed to load products.'); + return null; + } + } + + final products = controller.model.providerProducts[provider.id] ?? []; + if (products.isEmpty) { + _showError('No products available for this provider.'); + return null; + } + + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + BillProduct? picked; + + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (sheetContext) { + return StatefulBuilder( + builder: (innerContext, setSheetState) { + return Container( + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1A1A2E) : Colors.white, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(24), + ), + ), + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 12, + bottom: MediaQuery.of(innerContext).viewInsets.bottom + 24, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Text( + 'Apply Product to All Items', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: isDark ? Colors.white : Colors.black87, + ), + ), + const SizedBox(height: 6), + Text( + 'Choose a product to set for every batch item.', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Colors.grey.shade500, + ), + ), + const SizedBox(height: 18), + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(innerContext).size.height * 0.4, + ), + child: SingleChildScrollView( + child: Column( + children: products.map((product) { + final isSelected = picked == product; + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: GestureDetector( + onTap: () { + setSheetState(() => picked = product); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 12, + ), + decoration: BoxDecoration( + color: isSelected + ? theme.colorScheme.primary.withValues( + alpha: 0.12, + ) + : (isDark + ? Colors.white.withValues( + alpha: 0.05, + ) + : Colors.grey.shade50), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: isSelected + ? theme.colorScheme.primary + : (isDark + ? Colors.white.withValues( + alpha: 0.08, + ) + : Colors.grey.shade200), + width: isSelected ? 2 : 1, + ), + ), + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: theme.colorScheme.primary + .withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.inventory_2_rounded, + size: 20, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + product.displayName.isNotEmpty + ? product.displayName + : product.name, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: isDark + ? Colors.white + : Colors.black87, + ), + ), + if (product.defaultAmount > 0) + Text( + '\$${product.defaultAmount.toStringAsFixed(2)}', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade500, + ), + ), + ], + ), + ), + Icon( + isSelected + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + color: isSelected + ? theme.colorScheme.primary + : Colors.grey.shade400, + ), + ], + ), + ), + ), + ); + }).toList(), + ), + ), + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () { + Navigator.of( + innerContext, + ).pop(picked ?? products.first); + }, + style: ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + elevation: 0, + ), + child: const Text( + 'Apply', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + ), + ); + }, + ); + }, + ); + } + + Future _saveEdits() async { + if (controller.model.isActing) return false; + final batch = _batch; + if (batch == null || batch.id == null) return false; + + // Check if any edited items are missing a provider label + final anyMissingProvider = controller.model.batchItems.any((item) { + if (item.id == null) return false; + final editedProvider = _editedProviders[item.id]; + return editedProvider == null || editedProvider.label.isEmpty; + }); + + // If any items lack a provider, prompt user to choose one (and optionally a product) for all items + if (anyMissingProvider) { + final result = await _showBatchProviderSelectorBottomSheet(); + if (!mounted) return false; + if (result == null) return false; + if (!mounted) return false; + + final chosenProvider = result.provider; + final chosenProduct = result.product; + + // Apply chosen provider to all items + for (final item in controller.model.batchItems) { + if (item.id != null) { + _editedProviders[item.id!] = chosenProvider; + if (chosenProduct != null) { + _editedProducts[item.id!] = chosenProduct; + } + } + } + + // Also ensure products are loaded for the chosen provider + if (!controller.model.providerProducts.containsKey(chosenProvider.id)) { + await controller.loadProviderProducts(chosenProvider.id); + if (!mounted) return false; + } + } + + final items = []; + for (final item in controller.model.batchItems) { + if (item.id == null) continue; + + final amountStr = _editedAmounts[item.id]?.text ?? ''; + final parsedAmount = double.tryParse(amountStr); + final provider = _editedProviders[item.id]; + final product = _editedProducts[item.id]; + + items.add( + GroupBatchItemUpdateRequest( + id: item.id, + amount: parsedAmount ?? item.amount, + recipientName: item.recipientName, + recipientPhone: item.recipientPhone, + providerImage: provider?.image, + providerLabel: provider?.label, + billClientId: provider?.clientId, + billName: provider?.name, + billProductName: + product?.name ?? + product?.displayName ?? + item.billProductName ?? + '', + ), + ); + } + + if (items.isEmpty) return false; + + final updateList = GroupBatchItemUpdateList(items: items, userId: _userId); + + final result = await controller.updateBatchItems(batch.id!, updateList); + if (!mounted) return false; + + _clearEditState(); + + if (result.isSuccess) { + // Refresh batch items to reflect updated data + await controller.listBatchItems(batch.id!); + } else { + AppSnackBar.showError(context, result.error ?? 'Failed to update items.'); + } + return true; + } + Future _onRefresh() async { setState(() { _initialLoading = true; @@ -739,6 +1463,105 @@ class _BatchDetailScreenState extends State AppSnackBar.showError(context, message); } + void _showFilterSheet() { + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (ctx) { + return StatefulBuilder( + builder: (context, setSheetState) { + return Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text( + 'Filter Items', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + TextButton( + onPressed: () { + setSheetState(() { + controller.model.batchItemsStatusFilter = null; + }); + }, + child: const Text('Clear All'), + ), + ], + ), + const SizedBox(height: 16), + DropdownButtonFormField( + initialValue: controller.model.batchItemsStatusFilter, + decoration: InputDecoration( + labelText: 'Status', + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + ), + items: const [ + DropdownMenuItem(value: null, child: Text('All')), + DropdownMenuItem( + value: 'PENDING', + child: Text('Pending'), + ), + DropdownMenuItem( + value: 'SUCCESS', + child: Text('Success'), + ), + DropdownMenuItem(value: 'FAILED', child: Text('Failed')), + ], + onChanged: (v) { + setSheetState( + () => controller.model.batchItemsStatusFilter = v, + ); + }, + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () { + Navigator.of(ctx).pop(); + final batchId = _batch?.id ?? ''; + if (batchId.isNotEmpty) { + controller.searchBatchItems( + batchId, + search: _searchController.text.isNotEmpty + ? _searchController.text + : null, + status: controller.model.batchItemsStatusFilter, + ); + } + }, + child: const Text('Apply Filters'), + ), + ), + const SizedBox(height: 8), + ], + ), + ); + }, + ); + }, + ); + } + // ────────────────────────────────────────────────────────────── // Build // ────────────────────────────────────────────────────────────── @@ -809,6 +1632,7 @@ class _BatchDetailScreenState extends State Widget _buildContent(GroupBatch batch, ThemeData theme, bool isDark) { return CustomScrollView( + controller: _scrollController, physics: const BouncingScrollPhysics(), slivers: [ SliverAppBar( @@ -849,36 +1673,20 @@ class _BatchDetailScreenState extends State width: constraints.maxWidth > ResponsivePolicy.md ? ResponsivePolicy.md.toDouble() : constraints.maxWidth, - child: Column( - children: [ - FadeTransition( - opacity: _headerFadeAnimation, - child: SlideTransition( - position: _headerSlideAnimation, - child: _buildBatchHeader(batch, theme, isDark), - ), + child: FadeTransition( + opacity: _entryFade, + child: SlideTransition( + position: _entrySlide, + child: Column( + children: [ + _buildCompactHeader(batch, theme, isDark), + const SizedBox(height: 12), + _buildActionBar(theme, isDark), + const SizedBox(height: 12), + _buildItemsList(batch, theme, isDark), + ], ), - const SizedBox(height: 16), - FadeTransition( - opacity: _cardFadeAnimation, - child: SlideTransition( - position: _cardSlideAnimation, - child: _buildSegmentedTabBar(theme, isDark), - ), - ), - const SizedBox(height: 14), - FadeTransition( - opacity: _contentFadeAnimation, - child: SlideTransition( - position: _contentSlideAnimation, - child: _buildSelectedTabContent( - batch, - theme, - isDark, - ), - ), - ), - ], + ), ), ), ); @@ -891,427 +1699,15 @@ class _BatchDetailScreenState extends State } // ────────────────────────────────────────────────────────────── - // Refresh Button (sits next to status pill; reloads the batch) + // Compact Header — concise row-based summary // ────────────────────────────────────────────────────────────── - Widget _buildRefreshButton(ThemeData theme, bool isDark) { - return Material( - color: Colors.transparent, - shape: const CircleBorder(), - child: InkWell( - customBorder: const CircleBorder(), - onTap: _onRefresh, - child: Tooltip( - message: 'Refresh', - child: Container( - width: 32, - height: 32, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: isDark - ? Colors.white.withValues(alpha: 0.08) - : Colors.grey.shade100, - border: Border.all( - color: isDark - ? Colors.white.withValues(alpha: 0.10) - : Colors.grey.shade300, - ), - ), - child: Icon( - Icons.refresh_rounded, - size: 16, - color: isDark ? Colors.white70 : Colors.grey.shade700, - ), - ), - ), - ), - ); - } - - // ────────────────────────────────────────────────────────────── - // Dotted Divider - // ────────────────────────────────────────────────────────────── - Widget _buildDottedDivider(bool isDark) { - return Row( - children: List.generate( - 40, - (index) => Expanded( - child: Container( - height: 1.5, - color: index.isEven - ? (isDark ? Colors.white24 : Colors.grey.shade300) - : Colors.transparent, - ), - ), - ), - ); - } - - // ────────────────────────────────────────────────────────────── - // Batch Header Card - // ────────────────────────────────────────────────────────────── - Widget _buildBatchHeader(GroupBatch batch, ThemeData theme, bool isDark) { - final primaryColor = theme.colorScheme.primary; + Widget _buildCompactHeader(GroupBatch batch, ThemeData theme, bool isDark) { final currency = batch.currency ?? 'USD'; final value = batch.value; final status = batch.status ?? 'UNKNOWN'; final createdAt = batch.createdAt; final parsedDate = createdAt != null ? DateTime.tryParse(createdAt) : null; - return Container( - width: double.infinity, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - primaryColor.withValues(alpha: 0.10), - primaryColor.withValues(alpha: 0.04), - primaryColor.withValues(alpha: 0.08), - ], - stops: const [0.0, 0.5, 1.0], - ), - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: primaryColor.withValues(alpha: 0.12), - width: 1, - ), - boxShadow: [ - BoxShadow( - color: primaryColor.withValues(alpha: 0.08), - blurRadius: 20, - offset: const Offset(0, 6), - ), - ], - ), - padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 20), - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - StatusChip(status: status), - const SizedBox(width: 8), - _buildRefreshButton(theme, isDark), - ], - ), - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - currency, - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w600, - color: isDark ? Colors.white70 : Colors.grey.shade600, - ), - ), - const SizedBox(width: 4), - Text( - value?.toStringAsFixed(2) ?? '—', - style: TextStyle( - fontSize: 42, - fontWeight: FontWeight.w700, - color: isDark ? Colors.white : Colors.black87, - height: 1.1, - ), - ), - ], - ), - const SizedBox(height: 4), - if (parsedDate != null) - Text( - DateFormat.yMMMd().add_jm().format(parsedDate), - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w400, - color: isDark ? Colors.white54 : Colors.grey.shade500, - ), - ), - const SizedBox(height: 16), - _buildDottedDivider(isDark), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [..._buildHeaderActionButtons(batch, theme)], - ), - ], - ), - ); - } - - // ────────────────────────────────────────────────────────────── - // Header Action Buttons - // ────────────────────────────────────────────────────────────── - List _buildHeaderActionButtons(GroupBatch batch, ThemeData theme) { - final status = batch.status?.toUpperCase() ?? ''; - final isActing = controller.model.isActing; - - final List buttons = []; - - if (status == 'CREATED') { - buttons.add( - _modernActionButton( - icon: Icons.check_circle_outline_rounded, - label: 'Confirm Items', - color: theme.colorScheme.primary, - isLoading: isActing, - onPressed: _onConfirm, - ), - ); - } else if (status == 'CONFIRMED_ALL' || status == 'REQUEST_FAILED') { - buttons.add( - _modernActionButton( - icon: Icons.send_rounded, - label: 'Push Payment Request', - color: const Color(0xFF10B981), - isLoading: isActing, - onPressed: _onRequest, - ), - ); - } else if (status == 'REQUESTED' || _showPollButton) { - buttons.add( - _modernActionButton( - icon: Icons.refresh_rounded, - label: 'Check Status', - color: const Color(0xFFF59E0B), - isLoading: isActing, - onPressed: _onPoll, - ), - ); - } - - if (status == 'COMPLETED') { - buttons.add( - _modernActionButton( - icon: Icons.download_rounded, - label: 'Download Report', - color: const Color(0xFF6366F1), - isLoading: isActing, - onPressed: _onDownloadReport, - ), - ); - } - - if (buttons.isEmpty) return [const SizedBox.shrink()]; - - return [const SizedBox(height: 14), ...buttons]; - } - - Widget _modernActionButton({ - required IconData icon, - required String label, - required Color color, - required bool isLoading, - required VoidCallback onPressed, - }) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 52, - height: 52, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - color.withValues(alpha: 0.15), - color.withValues(alpha: 0.05), - ], - ), - border: Border.all(color: color.withValues(alpha: 0.2), width: 1.5), - boxShadow: [ - BoxShadow( - color: color.withValues(alpha: 0.1), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - child: isLoading - ? Center( - child: SizedBox( - width: 22, - height: 22, - child: CircularProgressIndicator( - strokeWidth: 2.5, - valueColor: AlwaysStoppedAnimation(color), - ), - ), - ) - : Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(26), - onTap: onPressed, - child: Center(child: Icon(icon, color: color, size: 22)), - ), - ), - ), - const SizedBox(height: 6), - Text( - label, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: Colors.grey.shade600, - ), - ), - ], - ); - } - - // ────────────────────────────────────────────────────────────── - // Segmented Tab Bar - // ────────────────────────────────────────────────────────────── - Widget _buildSegmentedTabBar(ThemeData theme, bool isDark) { - final segments = ['Batch Details', 'Recipients']; - - return Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: isDark - ? Colors.white.withValues(alpha: 0.06) - : Colors.grey.shade100, - borderRadius: BorderRadius.circular(16), - ), - child: Row( - children: List.generate(segments.length, (index) { - final isSelected = _selectedTabIndex == index; - return Expanded( - child: GestureDetector( - onTap: () => setState(() => _selectedTabIndex = index), - child: AnimatedContainer( - duration: const Duration(milliseconds: 250), - curve: Curves.easeOutCubic, - padding: const EdgeInsets.symmetric(vertical: 10), - decoration: BoxDecoration( - color: isSelected - ? theme.colorScheme.primary - : Colors.transparent, - borderRadius: BorderRadius.circular(12), - boxShadow: isSelected - ? [ - BoxShadow( - color: theme.colorScheme.primary.withValues( - alpha: 0.3, - ), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ] - : null, - ), - child: Center( - child: Text( - segments[index], - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: isSelected ? Colors.white : Colors.grey.shade600, - ), - ), - ), - ), - ), - ); - }), - ), - ); - } - - // ────────────────────────────────────────────────────────────── - // Tab Content - // ────────────────────────────────────────────────────────────── - Widget _buildSelectedTabContent( - GroupBatch batch, - ThemeData theme, - bool isDark, - ) { - return AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeInCubic, - transitionBuilder: (child, animation) { - return FadeTransition( - opacity: animation, - child: SlideTransition( - position: Tween( - begin: const Offset(0, 0.04), - end: Offset.zero, - ).animate(animation), - child: child, - ), - ); - }, - child: KeyedSubtree( - key: ValueKey(_selectedTabIndex), - child: _selectedTabIndex == 0 - ? _buildBatchDetailsTab(batch, theme, isDark) - : _buildRecipientsTab(batch, theme, isDark), - ), - ); - } - - // ────────────────────────────────────────────────────────────── - // Batch Details Tab - // ────────────────────────────────────────────────────────────── - Widget _buildBatchDetailsTab(GroupBatch batch, ThemeData theme, bool isDark) { - final items = <_DetailItem>[ - _DetailItem( - icon: Icons.memory_rounded, - label: 'Processor', - value: batch.paymentProcessorName ?? '—', - ), - _DetailItem( - icon: Icons.group_rounded, - label: 'Group', - value: batch.recipientGroupId ?? '—', - ), - ]; - - if (batch.description != null && batch.description!.isNotEmpty) { - items.add( - _DetailItem( - icon: Icons.description_outlined, - label: 'Description', - value: batch.description!, - ), - ); - } - - if (batch.userId != null) { - items.add( - _DetailItem( - icon: Icons.person_rounded, - label: 'Created by', - value: batch.userId!, - ), - ); - } - - return Column( - children: [ - _buildInfoCard( - theme: theme, - isDark: isDark, - icon: Icons.info_outline_rounded, - title: 'Batch Details', - items: items, - ), - if (batch.count != null) ...[ - const SizedBox(height: 12), - _buildSummaryCard(batch, theme, isDark), - ], - ], - ); - } - - Widget _buildSummaryCard(GroupBatch batch, ThemeData theme, bool isDark) { - final primaryColor = theme.colorScheme.primary; - return Container( width: double.infinity, decoration: BoxDecoration( @@ -1332,103 +1728,543 @@ class _BatchDetailScreenState extends State ), ], ), - padding: const EdgeInsets.all(14), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Row 1: Status + Amount + Refresh Row( children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: primaryColor.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(10), - ), - child: Icon( - Icons.bar_chart_rounded, - size: 18, - color: primaryColor, - ), - ), - const SizedBox(width: 12), - Text( - 'Summary', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w700, - color: isDark ? Colors.white : Colors.black87, - letterSpacing: 0.5, + StatusChip(status: status), + const SizedBox(width: 8), + Expanded( + child: 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, + ), + ), + ], ), ), + _buildRefreshButton(theme, isDark), ], ), - const SizedBox(height: 12), - Container( - height: 1, - color: isDark - ? Colors.white.withValues(alpha: 0.06) - : Colors.grey.shade200, - ), - const SizedBox(height: 12), + const SizedBox(height: 6), + // Row 2: Date + Summary chips Row( children: [ - _countBadge('Total', batch.count!, Colors.blue, isDark), - const SizedBox(width: 6), - _countBadge( - 'Successful', - batch.successfulCount ?? 0, - Colors.green, - isDark, - ), - const SizedBox(width: 6), - _countBadge('Failed', batch.failedCount ?? 0, Colors.red, isDark), + if (parsedDate != null) + Text( + DateFormat.yMMMd().add_jm().format(parsedDate), + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w400, + color: isDark ? Colors.white38 : Colors.grey.shade500, + ), + ), + if (batch.count != null && parsedDate != null) + const SizedBox(width: 6), + if (batch.count != null) + Text( + '•', + style: TextStyle( + fontSize: 11, + color: isDark ? Colors.white24 : Colors.grey.shade400, + ), + ), + 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, + ), + ], + ), + ), + ), ], ), + // Row 3: Action buttons + const SizedBox(height: 10), + _buildHeaderActionButtons(batch, theme), + ], + ), + ); + } + + Widget _miniChip({ + required String label, + required int value, + required Color color, + required bool isDark, + }) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: color.withValues(alpha: 0.25)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '$value', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: color, + ), + ), + const SizedBox(width: 3), + Text( + label, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: color.withValues(alpha: 0.8), + ), + ), ], ), ); } - Widget _countBadge(String label, int value, Color color, bool isDark) { - return Expanded( - child: Container( - padding: const EdgeInsets.symmetric(vertical: 8), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: color.withValues(alpha: 0.2)), - ), - child: Column( - children: [ - Text( - '$value', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: color, + Widget _buildRefreshButton(ThemeData theme, bool isDark) { + return Material( + color: Colors.transparent, + shape: const CircleBorder(), + child: InkWell( + customBorder: const CircleBorder(), + onTap: _onRefresh, + child: Tooltip( + message: 'Refresh', + child: Container( + width: 30, + height: 30, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.grey.shade100, + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.10) + : Colors.grey.shade300, ), ), - const SizedBox(height: 2), - Text( - label, - style: TextStyle( - fontSize: 11, - color: color.withValues(alpha: 0.8), - fontWeight: FontWeight.w500, - ), + child: Icon( + Icons.refresh_rounded, + size: 15, + color: isDark ? Colors.white70 : Colors.grey.shade700, ), - ], + ), ), ), ); } // ────────────────────────────────────────────────────────────── - // Recipients Tab + // Header Action Buttons // ────────────────────────────────────────────────────────────── - Widget _buildRecipientsTab(GroupBatch batch, ThemeData theme, bool isDark) { - if (controller.model.isLoading) { + Widget _buildHeaderActionButtons(GroupBatch batch, ThemeData theme) { + final status = batch.status?.toUpperCase() ?? ''; + final isActing = controller.model.isActing; + final canConfirm = ['CREATED', 'CONFIRM_FAILED'].contains(status); + final canRequest = ['CONFIRMED_ALL', 'REQUEST_FAILED'].contains(status); + final canPoll = + ['REQUESTED', 'POLL_FAILED'].contains(status) || _showPollButton; + final canDownloadReport = status == 'COMPLETED'; + + final List buttons = []; + + if (canConfirm) { + buttons.add( + _inlineActionButton( + icon: Icons.check_circle_outline_rounded, + label: 'Confirm', + color: theme.colorScheme.primary, + isLoading: isActing, + onPressed: _onConfirm, + ), + ); + } else if (canRequest) { + buttons.add( + _inlineActionButton( + icon: Icons.send_rounded, + label: 'Push Payment', + color: const Color(0xFF10B981), + isLoading: isActing, + onPressed: _onRequest, + ), + ); + } else if (canPoll) { + buttons.add( + _inlineActionButton( + icon: Icons.refresh_rounded, + label: 'Check Status', + color: const Color(0xFFF59E0B), + isLoading: isActing, + onPressed: _onPoll, + ), + ); + } + + if (canDownloadReport) { + buttons.add( + _inlineActionButton( + icon: Icons.download_rounded, + label: 'Report', + color: const Color(0xFF6366F1), + isLoading: isActing, + onPressed: _onDownloadReport, + ), + ); + } + + if (buttons.isEmpty) return const SizedBox.shrink(); + + return Row( + children: [ + ...buttons.map( + (btn) => + Padding(padding: const EdgeInsets.only(right: 8), child: btn), + ), + ], + ); + } + + Widget _inlineActionButton({ + required IconData icon, + required String label, + required Color color, + required bool isLoading, + required VoidCallback onPressed, + }) { + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: isLoading ? null : onPressed, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: color.withValues(alpha: 0.3)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isLoading) + SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(color), + ), + ) + else + Icon(icon, size: 16, color: color), + const SizedBox(width: 6), + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ], + ), + ), + ), + ); + } + + // ────────────────────────────────────────────────────────────── + // Action Bar — search, filter, edit, refresh + // ────────────────────────────────────────────────────────────── + Widget _buildActionBar(ThemeData theme, bool isDark) { + final hasFilters = controller.model.batchItemsStatusFilter != null; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.only(right: 8.0), + child: TextFormField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Search by name or phone', + prefixIcon: const Icon(Icons.search, size: 20), + suffixIcon: _searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear, size: 18), + onPressed: () { + _searchController.clear(); + final batchId = _batch?.id ?? ''; + if (batchId.isNotEmpty) { + controller.searchBatchItems( + batchId, + status: + controller.model.batchItemsStatusFilter, + ); + } + }, + ) + : null, + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: theme.colorScheme.primary), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + ), + style: const TextStyle(fontSize: 14), + textInputAction: TextInputAction.search, + onFieldSubmitted: (value) { + final batchId = _batch?.id ?? ''; + if (batchId.isNotEmpty) { + controller.searchBatchItems( + batchId, + search: value.isNotEmpty ? value : null, + status: controller.model.batchItemsStatusFilter, + ); + } + }, + ), + ), + ), + if (_editMode) + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: _saveButton(theme), + ), + if (!_editMode && + _editableStatuses.contains(_batch?.status?.toUpperCase() ?? '')) + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: _actionIconButton( + icon: Icons.edit_rounded, + tooltip: 'Edit items', + isActive: false, + color: Colors.grey.shade700, + bgColor: Colors.grey.shade100, + onPressed: _toggleEditMode, + ), + ), + if (_editMode) + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: _actionIconButton( + icon: Icons.edit_off_rounded, + tooltip: 'Cancel item edits', + isActive: false, + color: Colors.grey.shade700, + bgColor: Colors.grey.shade100, + onPressed: _toggleEditMode, + ), + ), + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: _actionIconButton( + icon: Icons.filter_list, + tooltip: 'Filter items', + isActive: hasFilters, + color: hasFilters + ? theme.colorScheme.primary + : Colors.grey.shade700, + bgColor: hasFilters + ? theme.colorScheme.primary + : Colors.grey.shade100, + onPressed: _showFilterSheet, + ), + ), + ], + ), + if (hasFilters) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Wrap( + spacing: 6, + runSpacing: 6, + children: [ + if (controller.model.batchItemsStatusFilter != null) + _activeFilterChip( + 'Status: ${controller.model.batchItemsStatusFilter}', + () { + controller.model.batchItemsStatusFilter = null; + final batchId = _batch?.id ?? ''; + if (batchId.isNotEmpty) { + controller.searchBatchItems( + batchId, + search: _searchController.text.isNotEmpty + ? _searchController.text + : null, + ); + } + }, + theme, + ), + ], + ), + ), + ], + ); + } + + Widget _saveButton(ThemeData theme) { + final isSaving = controller.model.isActing; + return Container( + decoration: BoxDecoration( + color: isSaving + ? theme.colorScheme.primary.withValues(alpha: 0.1) + : Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + ), + child: IconButton( + icon: isSaving + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2.5, + valueColor: AlwaysStoppedAnimation( + theme.colorScheme.primary, + ), + ), + ) + : Icon(Icons.save_rounded, color: Colors.grey.shade700, size: 20), + tooltip: isSaving ? 'Saving...' : 'Save', + onPressed: isSaving + ? null + : () async { + final result = await _saveEdits(); + if (!result) { + // Handle save failure + } + }, + constraints: const BoxConstraints(minWidth: 42, minHeight: 42), + padding: EdgeInsets.zero, + ), + ); + } + + Widget _actionIconButton({ + required IconData icon, + required String tooltip, + required bool isActive, + required Color color, + required Color bgColor, + required VoidCallback onPressed, + }) { + return Container( + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(12), + ), + child: IconButton( + icon: Icon(icon, color: color, size: 20), + tooltip: tooltip, + onPressed: onPressed, + constraints: const BoxConstraints(minWidth: 42, minHeight: 42), + padding: EdgeInsets.zero, + ), + ); + } + + Widget _activeFilterChip( + String label, + VoidCallback onRemove, + ThemeData theme, + ) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: theme.colorScheme.primary.withAlpha(20), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: theme.colorScheme.primary.withAlpha(60)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.primary, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(width: 4), + InkWell( + onTap: onRemove, + borderRadius: BorderRadius.circular(10), + child: Padding( + padding: const EdgeInsets.all(2), + child: Icon( + Icons.close, + size: 14, + color: theme.colorScheme.primary, + ), + ), + ), + ], + ), + ); + } + + // ────────────────────────────────────────────────────────────── + // Items List + // ────────────────────────────────────────────────────────────── + Widget _buildItemsList(GroupBatch batch, ThemeData theme, bool isDark) { + if (controller.model.isLoading && controller.model.batchItems.isEmpty) { return const Center( child: Padding( padding: EdgeInsets.all(32), @@ -1447,7 +2283,7 @@ class _BatchDetailScreenState extends State Icon(Icons.inbox_outlined, size: 48, color: Colors.grey.shade400), const SizedBox(height: 12), Text( - 'No recipients yet.', + 'No items found.', style: TextStyle( fontSize: 15, color: Colors.grey.shade500, @@ -1460,15 +2296,34 @@ class _BatchDetailScreenState extends State ); } + // Show item count return Column( - children: controller.model.batchItems - .map( - (item) => Padding( - padding: const EdgeInsets.only(bottom: 8), - child: _buildItemCard(item, batch, theme, isDark), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (controller.model.batchItemsTotalElements > 0) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + '${controller.model.batchItems.length} of ${controller.model.batchItemsTotalElements} items', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Colors.grey.shade500, + ), ), - ) - .toList(), + ), + ...controller.model.batchItems.map( + (item) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _buildItemCard(item, batch, theme, isDark), + ), + ), + if (controller.model.batchItemsIsLoadingMore) + const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center(child: CircularProgressIndicator(strokeWidth: 2)), + ), + ], ); } @@ -1478,6 +2333,10 @@ class _BatchDetailScreenState extends State ThemeData theme, bool isDark, ) { + if (_editMode) { + return _buildEditableItemCard(item, batch, theme, isDark); + } + return Container( decoration: BoxDecoration( color: isDark ? const Color(0xFF1A1A2E) : Colors.white, @@ -1532,6 +2391,16 @@ class _BatchDetailScreenState extends State item.recipientPhone ?? '', style: TextStyle(fontSize: 12, color: Colors.grey.shade500), ), + if (item.billName != null || item.billProductName != null) + const SizedBox(height: 4), + if (item.billName != null || item.billProductName != null) + _buildBillInfoRow(item, isDark), + if (item.providerLabel != null && + item.providerLabel!.isNotEmpty) + const SizedBox(height: 3), + if (item.providerLabel != null && + item.providerLabel!.isNotEmpty) + _buildProviderBadge(item, isDark), ], ), ), @@ -1556,142 +2425,424 @@ class _BatchDetailScreenState extends State ); } - // ────────────────────────────────────────────────────────────── - // Info Card (shared - mirrors receipt pattern) - // ────────────────────────────────────────────────────────────── - Widget _buildInfoCard({ - required ThemeData theme, - required bool isDark, - required IconData icon, - required String title, - required List<_DetailItem> items, - }) { - return Container( - width: double.infinity, - decoration: BoxDecoration( - color: isDark ? const Color(0xFF1A1A2E) : Colors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: isDark - ? Colors.white.withValues(alpha: 0.06) - : Colors.grey.shade200, - width: 1, - ), - boxShadow: [ - BoxShadow( - color: isDark - ? Colors.black.withValues(alpha: 0.3) - : Colors.black.withValues(alpha: 0.04), - blurRadius: 16, - offset: const Offset(0, 4), + Widget _buildBillInfoRow(GroupBatchItem item, bool isDark) { + final parts = []; + if (item.billName != null && item.billName!.isNotEmpty) { + parts.addAll([ + WidgetSpan( + child: Icon( + Icons.receipt_rounded, + size: 12, + color: Colors.grey.shade500, ), - BoxShadow( - color: isDark - ? Colors.black.withValues(alpha: 0.15) - : Colors.black.withValues(alpha: 0.02), - blurRadius: 4, - offset: const Offset(0, 1), - ), - ], - ), - child: Padding( - padding: const EdgeInsets.all(14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: theme.colorScheme.primary.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(10), - ), - child: Icon(icon, size: 18, color: theme.colorScheme.primary), - ), - const SizedBox(width: 12), - Text( - title, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w700, - color: isDark ? Colors.white : Colors.black87, - letterSpacing: 0.5, - ), - ), - ], - ), - const SizedBox(height: 12), - Container( - height: 1, - color: isDark - ? Colors.white.withValues(alpha: 0.06) - : Colors.grey.shade200, - ), - const SizedBox(height: 12), - ...items.map( - (item) => Padding( - padding: const EdgeInsets.only(bottom: 10), - child: _buildDetailRow(item, theme, isDark), - ), - ), - ], ), - ), - ); + const WidgetSpan(child: SizedBox(width: 3)), + TextSpan( + text: item.billName, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: isDark ? Colors.white54 : Colors.grey.shade600, + ), + ), + ]); + } + if (item.billProductName != null && item.billProductName!.isNotEmpty) { + if (parts.isNotEmpty) { + parts.add( + TextSpan( + text: ' • ', + style: TextStyle(fontSize: 11, color: Colors.grey.shade400), + ), + ); + } + parts.add( + TextSpan( + text: item.billProductName, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w400, + color: isDark ? Colors.white38 : Colors.grey.shade500, + ), + ), + ); + } + return RichText(text: TextSpan(children: parts)); } - Widget _buildDetailRow(_DetailItem item, ThemeData theme, bool isDark) { + Widget _buildProviderBadge(GroupBatchItem item, bool isDark) { return Row( - crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, children: [ - Icon( - item.icon, - size: 16, - color: isDark ? Colors.white38 : Colors.grey.shade500, - ), - const SizedBox(width: 10), - Expanded( - flex: 2, - child: Text( - item.label, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: isDark ? Colors.white60 : Colors.grey.shade600, - ), - ), - ), - Expanded( - flex: 3, - child: Text( - item.value, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: isDark ? Colors.white : Colors.black87, - ), - textAlign: TextAlign.end, - softWrap: true, + Icon(Icons.store_rounded, size: 11, color: Colors.grey.shade400), + const SizedBox(width: 3), + Text( + item.providerLabel ?? '', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: isDark ? Colors.white38 : Colors.grey.shade500, ), ), ], ); } -} -// ────────────────────────────────────────────────────────────── -// Data class for detail items -// ────────────────────────────────────────────────────────────── -class _DetailItem { - final IconData icon; - final String label; - final String value; + /// Edit-mode card: provider dropdown in the middle + editable amount + product dropdown + Widget _buildEditableItemCard( + GroupBatchItem item, + GroupBatch batch, + ThemeData theme, + bool isDark, + ) { + final providers = controller.model.providers; + final itemId = item.id ?? ''; + final selectedProvider = _editedProviders[itemId]; + final amountController = _editedAmounts[itemId]; - const _DetailItem({ - required this.icon, - required this.label, - required this.value, - }); + // Get cached products for the selected provider + final providerProducts = selectedProvider != null + ? (controller.model.providerProducts[selectedProvider.id] ?? + []) + : []; + final selectedProduct = _editedProducts[itemId]; + + return Container( + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1A1A2E) : Colors.white, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: _editMode + ? theme.colorScheme.primary.withValues(alpha: 0.3) + : (isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.grey.shade200), + width: _editMode ? 1.5 : 1, + ), + boxShadow: [ + BoxShadow( + color: isDark + ? Colors.black.withValues(alpha: 0.2) + : Colors.black.withValues(alpha: 0.03), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + padding: const EdgeInsets.all(10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Top row: Avatar + Name + Amount input + Row( + children: [ + CircleAvatar( + radius: 16, + backgroundColor: theme.colorScheme.primary.withAlpha(25), + child: Text( + item.recipientName?.isNotEmpty == true + ? item.recipientName![0].toUpperCase() + : '?', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: theme.colorScheme.primary, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.recipientName ?? 'Unknown', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: isDark ? Colors.white : Colors.black87, + ), + ), + Text( + item.recipientPhone ?? '', + style: TextStyle( + fontSize: 11, + color: Colors.grey.shade500, + ), + ), + ], + ), + ), + // Editable amount field (readOnly when products exist — amount comes from product) + SizedBox( + width: 110, + height: 38, + child: TextFormField( + controller: amountController, + readOnly: providerProducts.isNotEmpty, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: isDark ? Colors.white : Colors.black87, + ), + decoration: InputDecoration( + prefixText: '${batch.currency ?? 'USD'} ', + prefixStyle: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: Colors.grey.shade500, + ), + filled: true, + fillColor: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.grey.shade50, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: theme.colorScheme.primary), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 8, + ), + isDense: true, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + // Provider + Product dropdown row (2 columns side by side) + if (providers.isNotEmpty) + Row( + children: [ + Expanded( + child: SizedBox( + height: 40, + child: DropdownButtonFormField( + value: selectedProvider ?? providers.first, + isExpanded: true, + decoration: InputDecoration( + labelText: 'Provider', + labelStyle: TextStyle( + fontSize: 12, + color: Colors.grey.shade500, + ), + filled: true, + fillColor: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.grey.shade50, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + isDense: true, + ), + items: providers.map((provider) { + return DropdownMenuItem( + value: provider, + child: Text( + provider.label, + style: TextStyle( + fontSize: 13, + color: isDark ? Colors.white : Colors.black87, + ), + overflow: TextOverflow.ellipsis, + ), + ); + }).toList(), + onChanged: (provider) { + if (provider != null && itemId.isNotEmpty) { + setState(() { + _editedProviders[itemId] = provider; + // Clear the product when provider changes + _editedProducts.remove(itemId); + }); + // Load products for the new provider + _onProviderChangedInEdit(itemId, provider); + } + }, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _buildEditProductDropdown( + itemId: itemId, + provider: selectedProvider ?? providers.first, + isDark: isDark, + theme: theme, + ), + ), + ], + ), + // Status chip on the bottom-right + if (item.status != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Align( + alignment: Alignment.centerRight, + child: StatusChip(status: item.status!), + ), + ), + ], + ), + ); + } + + /// Builds the product dropdown for an editable item card. + /// Shows a loading indicator if products are being fetched. + Widget _buildEditProductDropdown({ + required String itemId, + required BillProvider provider, + required bool isDark, + required ThemeData theme, + }) { + final isLoading = controller.model.loadingProviderProducts.contains( + provider.id, + ); + final products = + (controller.model.providerProducts[provider.id] ?? []); + final selectedProduct = _editedProducts[itemId]; + + if (isLoading) { + return SizedBox( + height: 40, + child: Row( + children: [ + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 8), + Text( + 'Loading products...', + style: TextStyle(fontSize: 12, color: Colors.grey.shade500), + ), + ], + ), + ); + } + + if (products.isEmpty) { + return SizedBox( + height: 40, + child: InputDecorator( + decoration: InputDecoration( + labelText: 'Product', + labelStyle: TextStyle(fontSize: 12, color: Colors.grey.shade500), + filled: true, + fillColor: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.grey.shade50, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + isDense: true, + ), + child: Text( + 'No products', + style: TextStyle(fontSize: 13, color: Colors.grey.shade500), + ), + ), + ); + } + + return SizedBox( + height: 40, + child: DropdownButtonFormField( + value: selectedProduct ?? products.first, + isExpanded: true, + decoration: InputDecoration( + labelText: 'Product', + labelStyle: TextStyle(fontSize: 12, color: Colors.grey.shade500), + filled: true, + fillColor: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.grey.shade50, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + isDense: true, + ), + items: products.map((product) { + return DropdownMenuItem( + value: product, + child: Text( + product.displayName.isNotEmpty + ? product.displayName + : product.name, + style: TextStyle( + fontSize: 13, + color: isDark ? Colors.white : Colors.black87, + ), + overflow: TextOverflow.ellipsis, + ), + ); + }).toList(), + onChanged: (product) { + if (product != null && itemId.isNotEmpty) { + setState(() { + _editedProducts[itemId] = product; + // Update amount field with product's default amount + if (product.defaultAmount > 0) { + final ctrl = _editedAmounts[itemId]; + if (ctrl != null) { + ctrl.text = product.defaultAmount.toStringAsFixed(2); + } + } + }); + } + }, + ), + ); + } + + /// Called when the provider changes for an item in edit mode. + /// Triggers product loading for the new provider and auto-updates amount. + void _onProviderChangedInEdit(String itemId, BillProvider provider) { + // If products are already cached, update the amount now + final cached = controller.model.providerProducts[provider.id]; + if (cached != null && cached.isNotEmpty) { + final ctrl = _editedAmounts[itemId]; + if (ctrl != null && cached.first.defaultAmount > 0) { + ctrl.text = cached.first.defaultAmount.toStringAsFixed(2); + } + return; + } + + // Load products and update amount when they arrive + controller.loadProviderProducts(provider.id).then((result) { + if (!mounted) return; + final products = result.data; + if (products != null && products.isNotEmpty) { + final ctrl = _editedAmounts[itemId]; + if (ctrl != null && products.first.defaultAmount > 0) { + ctrl.text = products.first.defaultAmount.toStringAsFixed(2); + } + setState(() {}); + } + }); + } } diff --git a/lib/screens/groups/screens/group_detail_screen.dart b/lib/screens/groups/screens/group_detail_screen.dart index 2860b3d..1a84eb3 100644 --- a/lib/screens/groups/screens/group_detail_screen.dart +++ b/lib/screens/groups/screens/group_detail_screen.dart @@ -34,11 +34,12 @@ class _GroupDetailScreenState extends State static const _statusFilterItems = >[ DropdownMenuItem(value: null, child: Text('All')), DropdownMenuItem(value: 'CREATED', child: Text('Created')), - DropdownMenuItem(value: 'PENDING', child: Text('Pending')), - DropdownMenuItem(value: 'CONFIRMED_ALL', child: Text('Confirmed')), + DropdownMenuItem(value: 'CONFIRMING', child: Text('Confirming')), + DropdownMenuItem(value: 'CONFIRMED_ALL', child: Text('Confirmed All')), DropdownMenuItem(value: 'REQUESTED', child: Text('Requested')), - DropdownMenuItem(value: 'PROCESSING', child: Text('Processing')), DropdownMenuItem(value: 'COMPLETED', child: Text('Completed')), + DropdownMenuItem(value: 'POLL_FAILED', child: Text('Poll Failed')), + DropdownMenuItem(value: 'CONFIRM_FAILED', child: Text('Confirm Failed')), DropdownMenuItem(value: 'FAILED', child: Text('Failed')), ]; diff --git a/lib/screens/home/home_screen.dart b/lib/screens/home/home_screen.dart index 0501927..cea0600 100644 --- a/lib/screens/home/home_screen.dart +++ b/lib/screens/home/home_screen.dart @@ -111,6 +111,8 @@ class _HomeScreenState extends State with TickerProviderStateMixin { prefs = await SharedPreferences.getInstance(); + if (!mounted) return; + if (prefs.getString("token") != null) { setState(() { _isLoggedIn = true; diff --git a/lib/screens/provider/provider_controller.dart b/lib/screens/provider/provider_controller.dart new file mode 100644 index 0000000..19a9484 --- /dev/null +++ b/lib/screens/provider/provider_controller.dart @@ -0,0 +1,182 @@ +import 'package:flutter/material.dart'; +import 'package:qpay/http/http.dart'; +import 'package:qpay/models/api_response.dart'; +import 'package:qpay/screens/home/home_controller.dart' as hc; +import 'package:qpay/screens/pay/pay_controller.dart' as pc; + +// Provide access to the shared types from one place. +typedef BillProvider = hc.BillProvider; +typedef BillProduct = pc.BillProduct; +typedef PaymentProcessor = pc.PaymentProcessor; + +class ProviderScreenModel { + List providers = []; + List products = []; + List processors = []; + BillProvider? selectedProvider; + BillProduct? selectedProduct; + PaymentProcessor? selectedProcessor; + bool isLoadingProviders = false; + bool isLoadingProducts = false; + bool isLoadingProcessors = false; + String? errorMessage; +} + +class ProviderController extends ChangeNotifier { + final ProviderScreenModel model = ProviderScreenModel(); + final Http http = Http(); + BuildContext context; + bool _disposed = false; + + ProviderController(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; + } + + // ── Providers ────────────────────────────────────────────── + + Future>> loadProviders(String currency) async { + model.isLoadingProviders = true; + model.errorMessage = null; + model.providers = []; + model.selectedProvider = null; + model.products = []; + model.selectedProduct = null; + if (!_disposed) notifyListeners(); + + 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(); + model.isLoadingProviders = false; + if (model.providers.isNotEmpty) { + // Use selectProvider to also reset products and load matching products + selectProvider(model.providers.first); + } else { + if (!_disposed) notifyListeners(); + } + return ApiResponse.success(model.providers); + } catch (e) { + logger.e(e); + model.errorMessage = e.toString(); + model.providers = []; + model.selectedProvider = null; + model.isLoadingProviders = false; + if (!_disposed) notifyListeners(); + return ApiResponse.failure( + "Problem fetching providers, are you connected to the internet?", + ); + } + } + + void selectProvider(BillProvider provider) { + model.selectedProvider = provider; + model.selectedProduct = null; + model.products = []; + if (!_disposed) notifyListeners(); + // Automatically load products for the selected provider + loadProducts(provider.id); + } + + // ── Products ─────────────────────────────────────────────── + + Future>> loadProducts(String providerId) async { + model.isLoadingProducts = true; + model.errorMessage = null; + model.selectedProduct = null; + model.products = []; + 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'] ?? []); + model.products = list + .map((e) => BillProduct.fromJson(e as Map)) + .toList(); + model.selectedProduct = model.products.isNotEmpty + ? model.products.first + : null; + model.isLoadingProducts = false; + if (!_disposed) notifyListeners(); + return ApiResponse.success(model.products); + } catch (e) { + logger.e(e); + model.errorMessage = e.toString(); + model.products = []; + model.selectedProduct = null; + model.isLoadingProducts = false; + if (!_disposed) notifyListeners(); + return ApiResponse.failure( + "Problem fetching products, are you connected to the internet?", + ); + } + } + + void selectProduct(BillProduct product) { + model.selectedProduct = product; + if (!_disposed) notifyListeners(); + } + + // ── Payment Processors ───────────────────────────────────── + + Future>> loadProcessors() async { + model.isLoadingProcessors = true; + model.errorMessage = null; + if (!_disposed) notifyListeners(); + + try { + final raw = await http.get('/public/payment-processors'); + final response = _unwrap(raw); + final List list = response is List + ? response + : (response['content'] ?? []); + model.processors = list + .map((e) => PaymentProcessor.fromJson(e as Map)) + .toList(); + if (model.processors.isNotEmpty && model.selectedProcessor == null) { + model.selectedProcessor = model.processors.first; + } + model.isLoadingProcessors = false; + if (!_disposed) notifyListeners(); + return ApiResponse.success(model.processors); + } catch (e) { + logger.e(e); + model.errorMessage = e.toString(); + model.isLoadingProcessors = false; + if (!_disposed) notifyListeners(); + return ApiResponse.failure( + "Problem fetching payment processors, are you connected to the internet?", + ); + } + } + + void selectProcessor(PaymentProcessor processor) { + model.selectedProcessor = processor; + if (!_disposed) notifyListeners(); + } +} diff --git a/lib/screens/transactions/transaction_model.dart b/lib/screens/transactions/transaction_model.dart index 5c7df91..84ae77d 100644 --- a/lib/screens/transactions/transaction_model.dart +++ b/lib/screens/transactions/transaction_model.dart @@ -60,16 +60,16 @@ class TransactionModel { final String reference; final String paymentProcessorLabel; final String? integrationProcessorLabel; - final String confirmationProcessorLabel; - final String providerLabel; + final String? confirmationProcessorLabel; + final String? providerLabel; final String debitPhone; final String debitCurrency; - final String debitRef; + final String? debitRef; final String? creditPhone; final String? creditAccount; final String? creditRef; final String? billClientId; - final String billName; + final String? billName; final String paymentProcessorName; final String? providerImage; final String? paymentProcessorImage; @@ -108,16 +108,16 @@ class TransactionModel { required this.reference, required this.paymentProcessorLabel, this.integrationProcessorLabel, - required this.confirmationProcessorLabel, - required this.providerLabel, + this.confirmationProcessorLabel, + this.providerLabel, required this.debitPhone, required this.debitCurrency, - required this.debitRef, + this.debitRef, this.creditPhone, this.creditAccount, this.creditRef, this.billClientId, - required this.billName, + this.billName, required this.paymentProcessorName, this.providerImage, this.paymentProcessorImage, diff --git a/lib/screens/transactions/transaction_model.g.dart b/lib/screens/transactions/transaction_model.g.dart index deb4174..150be64 100644 --- a/lib/screens/transactions/transaction_model.g.dart +++ b/lib/screens/transactions/transaction_model.g.dart @@ -27,16 +27,16 @@ TransactionModel _$TransactionModelFromJson(Map json) => reference: json['reference'] as String, paymentProcessorLabel: json['paymentProcessorLabel'] as String, integrationProcessorLabel: json['integrationProcessorLabel'] as String?, - confirmationProcessorLabel: json['confirmationProcessorLabel'] as String, - providerLabel: json['providerLabel'] as String, + confirmationProcessorLabel: json['confirmationProcessorLabel'] as String?, + providerLabel: json['providerLabel'] as String?, debitPhone: json['debitPhone'] as String, debitCurrency: json['debitCurrency'] as String, - debitRef: json['debitRef'] as String, + debitRef: json['debitRef'] as String?, creditPhone: json['creditPhone'] as String?, creditAccount: json['creditAccount'] as String?, creditRef: json['creditRef'] as String?, billClientId: json['billClientId'] as String?, - billName: json['billName'] as String, + billName: json['billName'] as String?, paymentProcessorName: json['paymentProcessorName'] as String, providerImage: json['providerImage'] as String?, paymentProcessorImage: json['paymentProcessorImage'] as String?,