completed batch feature

This commit is contained in:
Prince
2026-06-17 00:55:20 +02:00
parent 31f403aa10
commit cc4b02f3c9
14 changed files with 2654 additions and 989 deletions

View File

@@ -58,6 +58,19 @@ class Http {
return response.data;
}
Future<dynamic> patch(String url, dynamic data) async {
Map<String, String> 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<dynamic> put(String url, dynamic data) async {
Map<String, String> headers = await getHeaders();

View File

@@ -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<ApiResponse<AccountModel>> fetchAccount(String phoneNumber) async {
_isLoadingAccount = true;

View File

@@ -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<GroupBatch> batches = [];
List<GroupBatchItem> batchItems = [];
@@ -27,6 +29,18 @@ class BatchScreenModel {
String? currencyFilter;
bool selectMode = false;
Set<String> 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<String, List<BillProduct>> providerProducts = {};
Set<String> loadingProviderProducts = {};
}
class BatchController extends ChangeNotifier {
@@ -111,6 +125,44 @@ class BatchController extends ChangeNotifier {
}
}
Future<ApiResponse<List<BillProduct>>> 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<dynamic> list = response is List
? response
: (response['content'] ?? []);
final products = list
.map((e) => BillProduct.fromJson(e as Map<String, dynamic>))
.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<ApiResponse<List<GroupBatch>>> listBatches(
String groupId, {
int page = 0,
@@ -215,41 +267,99 @@ class BatchController extends ChangeNotifier {
}
Future<ApiResponse<List<GroupBatchItem>>> 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 = <String, String>{
'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<dynamic> content;
if (response is Map && response.containsKey('content')) {
content = PageableModel.fromJson(
response as Map<String, dynamic>,
).content;
} else if (response is List) {
content = response;
} else {
content = [];
}
model.batchItems = content
final pageableModel = PageableModel.fromJson(
response as Map<String, dynamic>,
);
final newItems = pageableModel.content
.map((e) => GroupBatchItem.fromJson(e as Map<String, dynamic>))
.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<void> searchBatchItems(
String batchId, {
String? search,
String? status,
}) async {
model.batchItemsSearchQuery = search ?? '';
model.batchItemsStatusFilter = status;
await listBatchItems(batchId, page: 0, search: search, status: status);
}
Future<void> 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<ApiResponse<GroupBatch>> getBatch(
String batchId,
String userId,
@@ -324,6 +434,35 @@ class BatchController extends ChangeNotifier {
}
}
Future<ApiResponse<List<GroupBatchItem>>> 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<dynamic>)
.map((e) => GroupBatchItem.fromJson(e as Map<String, dynamic>))
.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<ApiResponse<GroupBatch>> confirmBatch(
String batchId,
String userId,

View File

@@ -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<String, dynamic> json) =>
_$GroupBatchItemUpdateRequestFromJson(json);
Map<String, dynamic> toJson() => _$GroupBatchItemUpdateRequestToJson(this);
}
@JsonSerializable(explicitToJson: true)
class GroupBatchItemUpdateList {
final List<GroupBatchItemUpdateRequest>? items;
final String? userId;
const GroupBatchItemUpdateList({this.items, this.userId});
factory GroupBatchItemUpdateList.fromJson(Map<String, dynamic> json) =>
_$GroupBatchItemUpdateListFromJson(json);
Map<String, dynamic> toJson() => _$GroupBatchItemUpdateListToJson(this);
}

View File

@@ -0,0 +1,53 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'batch_item_update_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
GroupBatchItemUpdateRequest _$GroupBatchItemUpdateRequestFromJson(
Map<String, dynamic> 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<String, dynamic> _$GroupBatchItemUpdateRequestToJson(
GroupBatchItemUpdateRequest instance,
) => <String, dynamic>{
'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<String, dynamic> json,
) => GroupBatchItemUpdateList(
items: (json['items'] as List<dynamic>?)
?.map(
(e) => GroupBatchItemUpdateRequest.fromJson(e as Map<String, dynamic>),
)
.toList(),
userId: json['userId'] as String?,
);
Map<String, dynamic> _$GroupBatchItemUpdateListToJson(
GroupBatchItemUpdateList instance,
) => <String, dynamic>{
'items': instance.items?.map((e) => e.toJson()).toList(),
'userId': instance.userId,
};

View File

@@ -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<String, dynamic> json) =>
@@ -84,7 +92,6 @@ class CreateBatchRequest {
final String? paymentProcessorLabel;
final String? paymentProcessorName;
final String? paymentProcessorImage;
final Map<String, dynamic> 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<String, dynamic> json) =>
@@ -140,4 +146,4 @@ class GroupBatchUpdateRequest {
_$GroupBatchUpdateRequestFromJson(json);
Map<String, dynamic> toJson() => _$GroupBatchUpdateRequestToJson(this);
}
}

View File

@@ -54,6 +54,10 @@ GroupBatchItem _$GroupBatchItemFromJson(Map<String, dynamic> 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<String, dynamic> _$GroupBatchItemToJson(GroupBatchItem instance) =>
@@ -67,6 +71,10 @@ Map<String, dynamic> _$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<String, dynamic> json) =>
@@ -79,7 +87,6 @@ CreateBatchRequest _$CreateBatchRequestFromJson(Map<String, dynamic> json) =>
paymentProcessorLabel: json['paymentProcessorLabel'] as String?,
paymentProcessorName: json['paymentProcessorName'] as String?,
paymentProcessorImage: json['paymentProcessorImage'] as String?,
transactionTemplate: json['transactionTemplate'] as Map<String, dynamic>,
);
Map<String, dynamic> _$CreateBatchRequestToJson(CreateBatchRequest instance) =>
@@ -92,7 +99,6 @@ Map<String, dynamic> _$CreateBatchRequestToJson(CreateBatchRequest instance) =>
'paymentProcessorLabel': instance.paymentProcessorLabel,
'paymentProcessorName': instance.paymentProcessorName,
'paymentProcessorImage': instance.paymentProcessorImage,
'transactionTemplate': instance.transactionTemplate,
};
BatchActionRequest _$BatchActionRequestFromJson(Map<String, dynamic> json) =>

View File

@@ -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<BatchCreateScreen> {
late BatchController controller;
late BatchController batchController;
late ProviderController providerController;
final _formKey = GlobalKey<FormState>();
final _descriptionController = TextEditingController();
final _amountController = TextEditingController();
@@ -30,22 +31,48 @@ class _BatchCreateScreenState extends State<BatchCreateScreen> {
@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<void> _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<BatchCreateScreen> {
Future<void> _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<BatchCreateScreen> {
final prefs = await SharedPreferences.getInstance();
final userId = prefs.getString('userId') ?? '';
final transactionTemplate = <String, dynamic>{
'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<void> _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<BatchCreateScreen> {
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<String>(
value: _currency,
initialValue: _currency,
decoration: InputDecoration(
labelText: 'Currency',
border: OutlineInputBorder(
@@ -194,32 +225,82 @@ class _BatchCreateScreenState extends State<BatchCreateScreen> {
),
],
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<BatchCreateScreen> {
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<BatchCreateScreen> {
);
}
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<String, Color> _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<BillProvider>(
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<BillProvider>(
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<BillProduct>(
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<BillProduct>(
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);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -34,11 +34,12 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
static const _statusFilterItems = <DropdownMenuItem<String?>>[
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')),
];

View File

@@ -111,6 +111,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
prefs = await SharedPreferences.getInstance();
if (!mounted) return;
if (prefs.getString("token") != null) {
setState(() {
_isLoggedIn = true;

View File

@@ -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<BillProvider> providers = [];
List<BillProduct> products = [];
List<PaymentProcessor> 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<ApiResponse<List<BillProvider>>> 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<dynamic> content;
if (response is Map && response.containsKey('content')) {
content = response['content'] as List<dynamic>;
} else if (response is List) {
content = response;
} else {
content = [];
}
model.providers = content
.map((e) => BillProvider.fromJson(e as Map<String, dynamic>))
.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<ApiResponse<List<BillProduct>>> 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<dynamic> list = response is List
? response
: (response['content'] ?? []);
model.products = list
.map((e) => BillProduct.fromJson(e as Map<String, dynamic>))
.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<ApiResponse<List<PaymentProcessor>>> 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<dynamic> list = response is List
? response
: (response['content'] ?? []);
model.processors = list
.map((e) => PaymentProcessor.fromJson(e as Map<String, dynamic>))
.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();
}
}

View File

@@ -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,

View File

@@ -27,16 +27,16 @@ TransactionModel _$TransactionModelFromJson(Map<String, dynamic> 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?,