completed batch feature
This commit is contained in:
@@ -58,6 +58,19 @@ class Http {
|
|||||||
return response.data;
|
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 {
|
Future<dynamic> put(String url, dynamic data) async {
|
||||||
Map<String, String> headers = await getHeaders();
|
Map<String, String> headers = await getHeaders();
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,20 @@ class AccountProvider extends ChangeNotifier {
|
|||||||
bool get isLoadingStatement => _isLoadingStatement;
|
bool get isLoadingStatement => _isLoadingStatement;
|
||||||
String? get accountError => _accountError;
|
String? get accountError => _accountError;
|
||||||
String? get statementError => _statementError;
|
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 {
|
Future<ApiResponse<AccountModel>> fetchAccount(String phoneNumber) async {
|
||||||
_isLoadingAccount = true;
|
_isLoadingAccount = true;
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import 'package:qpay/screens/home/home_controller.dart';
|
|||||||
import 'package:qpay/screens/pay/pay_controller.dart';
|
import 'package:qpay/screens/pay/pay_controller.dart';
|
||||||
import 'package:qpay/screens/transactions/transaction_model.dart';
|
import 'package:qpay/screens/transactions/transaction_model.dart';
|
||||||
|
|
||||||
|
import '../models/batch_item_update_model.dart';
|
||||||
|
|
||||||
class BatchScreenModel {
|
class BatchScreenModel {
|
||||||
List<GroupBatch> batches = [];
|
List<GroupBatch> batches = [];
|
||||||
List<GroupBatchItem> batchItems = [];
|
List<GroupBatchItem> batchItems = [];
|
||||||
@@ -27,6 +29,18 @@ class BatchScreenModel {
|
|||||||
String? currencyFilter;
|
String? currencyFilter;
|
||||||
bool selectMode = false;
|
bool selectMode = false;
|
||||||
Set<String> selectedBatchIds = {};
|
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 {
|
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(
|
Future<ApiResponse<List<GroupBatch>>> listBatches(
|
||||||
String groupId, {
|
String groupId, {
|
||||||
int page = 0,
|
int page = 0,
|
||||||
@@ -215,41 +267,99 @@ class BatchController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<ApiResponse<List<GroupBatchItem>>> listBatchItems(
|
Future<ApiResponse<List<GroupBatchItem>>> listBatchItems(
|
||||||
String batchId,
|
String batchId, {
|
||||||
) async {
|
int page = 0,
|
||||||
model.isLoading = true;
|
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();
|
if (!_disposed) notifyListeners();
|
||||||
|
|
||||||
try {
|
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(
|
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 response = _unwrap(raw);
|
||||||
final List<dynamic> content;
|
final pageableModel = PageableModel.fromJson(
|
||||||
if (response is Map && response.containsKey('content')) {
|
response as Map<String, dynamic>,
|
||||||
content = PageableModel.fromJson(
|
);
|
||||||
response as Map<String, dynamic>,
|
|
||||||
).content;
|
final newItems = pageableModel.content
|
||||||
} else if (response is List) {
|
|
||||||
content = response;
|
|
||||||
} else {
|
|
||||||
content = [];
|
|
||||||
}
|
|
||||||
model.batchItems = content
|
|
||||||
.map((e) => GroupBatchItem.fromJson(e as Map<String, dynamic>))
|
.map((e) => GroupBatchItem.fromJson(e as Map<String, dynamic>))
|
||||||
.toList();
|
.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.isLoading = false;
|
||||||
|
model.batchItemsIsLoadingMore = false;
|
||||||
|
|
||||||
if (!_disposed) notifyListeners();
|
if (!_disposed) notifyListeners();
|
||||||
return ApiResponse.success(model.batchItems);
|
return ApiResponse.success(model.batchItems);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.e(e);
|
logger.e(e);
|
||||||
model.errorMessage = e.toString();
|
model.errorMessage = e.toString();
|
||||||
model.batchItems = [];
|
if (page == 0) {
|
||||||
|
model.batchItems = [];
|
||||||
|
}
|
||||||
model.isLoading = false;
|
model.isLoading = false;
|
||||||
|
model.batchItemsIsLoadingMore = false;
|
||||||
if (!_disposed) notifyListeners();
|
if (!_disposed) notifyListeners();
|
||||||
return ApiResponse.failure('Failed to load batch items');
|
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(
|
Future<ApiResponse<GroupBatch>> getBatch(
|
||||||
String batchId,
|
String batchId,
|
||||||
String userId,
|
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(
|
Future<ApiResponse<GroupBatch>> confirmBatch(
|
||||||
String batchId,
|
String batchId,
|
||||||
String userId,
|
String userId,
|
||||||
|
|||||||
46
lib/screens/groups/models/batch_item_update_model.dart
Normal file
46
lib/screens/groups/models/batch_item_update_model.dart
Normal 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);
|
||||||
|
}
|
||||||
53
lib/screens/groups/models/batch_item_update_model.g.dart
Normal file
53
lib/screens/groups/models/batch_item_update_model.g.dart
Normal 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,
|
||||||
|
};
|
||||||
@@ -55,6 +55,10 @@ class GroupBatchItem {
|
|||||||
final String? transactionId;
|
final String? transactionId;
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
final String? createdAt;
|
final String? createdAt;
|
||||||
|
final String? billName;
|
||||||
|
final String? billProductName;
|
||||||
|
final String? providerLabel;
|
||||||
|
final String? providerImage;
|
||||||
|
|
||||||
const GroupBatchItem({
|
const GroupBatchItem({
|
||||||
this.id,
|
this.id,
|
||||||
@@ -66,6 +70,10 @@ class GroupBatchItem {
|
|||||||
this.transactionId,
|
this.transactionId,
|
||||||
this.errorMessage,
|
this.errorMessage,
|
||||||
this.createdAt,
|
this.createdAt,
|
||||||
|
this.billName,
|
||||||
|
this.billProductName,
|
||||||
|
this.providerLabel,
|
||||||
|
this.providerImage,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory GroupBatchItem.fromJson(Map<String, dynamic> json) =>
|
factory GroupBatchItem.fromJson(Map<String, dynamic> json) =>
|
||||||
@@ -84,7 +92,6 @@ class CreateBatchRequest {
|
|||||||
final String? paymentProcessorLabel;
|
final String? paymentProcessorLabel;
|
||||||
final String? paymentProcessorName;
|
final String? paymentProcessorName;
|
||||||
final String? paymentProcessorImage;
|
final String? paymentProcessorImage;
|
||||||
final Map<String, dynamic> transactionTemplate;
|
|
||||||
|
|
||||||
const CreateBatchRequest({
|
const CreateBatchRequest({
|
||||||
required this.recipientGroupId,
|
required this.recipientGroupId,
|
||||||
@@ -95,7 +102,6 @@ class CreateBatchRequest {
|
|||||||
this.paymentProcessorLabel,
|
this.paymentProcessorLabel,
|
||||||
this.paymentProcessorName,
|
this.paymentProcessorName,
|
||||||
this.paymentProcessorImage,
|
this.paymentProcessorImage,
|
||||||
required this.transactionTemplate,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
factory CreateBatchRequest.fromJson(Map<String, dynamic> json) =>
|
factory CreateBatchRequest.fromJson(Map<String, dynamic> json) =>
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ GroupBatchItem _$GroupBatchItemFromJson(Map<String, dynamic> json) =>
|
|||||||
transactionId: json['transactionId'] as String?,
|
transactionId: json['transactionId'] as String?,
|
||||||
errorMessage: json['errorMessage'] as String?,
|
errorMessage: json['errorMessage'] as String?,
|
||||||
createdAt: json['createdAt'] 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) =>
|
Map<String, dynamic> _$GroupBatchItemToJson(GroupBatchItem instance) =>
|
||||||
@@ -67,6 +71,10 @@ Map<String, dynamic> _$GroupBatchItemToJson(GroupBatchItem instance) =>
|
|||||||
'transactionId': instance.transactionId,
|
'transactionId': instance.transactionId,
|
||||||
'errorMessage': instance.errorMessage,
|
'errorMessage': instance.errorMessage,
|
||||||
'createdAt': instance.createdAt,
|
'createdAt': instance.createdAt,
|
||||||
|
'billName': instance.billName,
|
||||||
|
'billProductName': instance.billProductName,
|
||||||
|
'providerLabel': instance.providerLabel,
|
||||||
|
'providerImage': instance.providerImage,
|
||||||
};
|
};
|
||||||
|
|
||||||
CreateBatchRequest _$CreateBatchRequestFromJson(Map<String, dynamic> json) =>
|
CreateBatchRequest _$CreateBatchRequestFromJson(Map<String, dynamic> json) =>
|
||||||
@@ -79,7 +87,6 @@ CreateBatchRequest _$CreateBatchRequestFromJson(Map<String, dynamic> json) =>
|
|||||||
paymentProcessorLabel: json['paymentProcessorLabel'] as String?,
|
paymentProcessorLabel: json['paymentProcessorLabel'] as String?,
|
||||||
paymentProcessorName: json['paymentProcessorName'] as String?,
|
paymentProcessorName: json['paymentProcessorName'] as String?,
|
||||||
paymentProcessorImage: json['paymentProcessorImage'] as String?,
|
paymentProcessorImage: json['paymentProcessorImage'] as String?,
|
||||||
transactionTemplate: json['transactionTemplate'] as Map<String, dynamic>,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$CreateBatchRequestToJson(CreateBatchRequest instance) =>
|
Map<String, dynamic> _$CreateBatchRequestToJson(CreateBatchRequest instance) =>
|
||||||
@@ -92,7 +99,6 @@ Map<String, dynamic> _$CreateBatchRequestToJson(CreateBatchRequest instance) =>
|
|||||||
'paymentProcessorLabel': instance.paymentProcessorLabel,
|
'paymentProcessorLabel': instance.paymentProcessorLabel,
|
||||||
'paymentProcessorName': instance.paymentProcessorName,
|
'paymentProcessorName': instance.paymentProcessorName,
|
||||||
'paymentProcessorImage': instance.paymentProcessorImage,
|
'paymentProcessorImage': instance.paymentProcessorImage,
|
||||||
'transactionTemplate': instance.transactionTemplate,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
BatchActionRequest _$BatchActionRequestFromJson(Map<String, dynamic> json) =>
|
BatchActionRequest _$BatchActionRequestFromJson(Map<String, dynamic> json) =>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:qpay/screens/groups/models/group_batch_model.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/screens/groups/models/recipient_group_model.dart';
|
||||||
import 'package:qpay/models/responsive_policy.dart';
|
import 'package:qpay/models/responsive_policy.dart';
|
||||||
import 'package:qpay/screens/groups/controllers/batch_controller.dart';
|
import 'package:qpay/screens/groups/controllers/batch_controller.dart';
|
||||||
import 'package:qpay/screens/home/home_controller.dart';
|
import 'package:qpay/screens/provider/provider_controller.dart';
|
||||||
import 'package:qpay/screens/pay/pay_controller.dart';
|
|
||||||
import 'package:qpay/widgets/app_snack_bar.dart';
|
import 'package:qpay/widgets/app_snack_bar.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
@@ -19,7 +19,8 @@ class BatchCreateScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _BatchCreateScreenState extends State<BatchCreateScreen> {
|
class _BatchCreateScreenState extends State<BatchCreateScreen> {
|
||||||
late BatchController controller;
|
late BatchController batchController;
|
||||||
|
late ProviderController providerController;
|
||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
final _descriptionController = TextEditingController();
|
final _descriptionController = TextEditingController();
|
||||||
final _amountController = TextEditingController();
|
final _amountController = TextEditingController();
|
||||||
@@ -30,22 +31,48 @@ class _BatchCreateScreenState extends State<BatchCreateScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.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();
|
_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 {
|
Future<void> _init() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
if (prefs.containsKey('phone')) {
|
if (prefs.containsKey('phone')) {
|
||||||
_debitPhoneController.text = prefs.getString('phone')!;
|
_debitPhoneController.text = prefs.getString('phone')!;
|
||||||
}
|
}
|
||||||
await Future.wait([controller.loadProviders()]);
|
await Future.wait([
|
||||||
|
providerController.loadProviders(_currency),
|
||||||
|
batchController.loadProcessors(),
|
||||||
|
]);
|
||||||
if (mounted) setState(() => _loadingDropdowns = false);
|
if (mounted) setState(() => _loadingDropdowns = false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
controller.dispose();
|
batchController.removeListener(_onControllerChange);
|
||||||
|
providerController.removeListener(_onControllerChange);
|
||||||
|
batchController.dispose();
|
||||||
|
providerController.dispose();
|
||||||
_descriptionController.dispose();
|
_descriptionController.dispose();
|
||||||
_amountController.dispose();
|
_amountController.dispose();
|
||||||
_debitPhoneController.dispose();
|
_debitPhoneController.dispose();
|
||||||
@@ -55,13 +82,6 @@ class _BatchCreateScreenState extends State<BatchCreateScreen> {
|
|||||||
Future<void> _submit() async {
|
Future<void> _submit() async {
|
||||||
if (!_formKey.currentState!.validate()) return;
|
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());
|
final amount = double.tryParse(_amountController.text.trim());
|
||||||
if (amount == null || amount <= 0) {
|
if (amount == null || amount <= 0) {
|
||||||
AppSnackBar.show(context, 'Please enter a valid amount.');
|
AppSnackBar.show(context, 'Please enter a valid amount.');
|
||||||
@@ -71,53 +91,87 @@ class _BatchCreateScreenState extends State<BatchCreateScreen> {
|
|||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final userId = prefs.getString('userId') ?? '';
|
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(
|
final req = CreateBatchRequest(
|
||||||
recipientGroupId: widget.group.id ?? '',
|
recipientGroupId: widget.group.id ?? '',
|
||||||
userId: userId,
|
userId: userId,
|
||||||
currency: _currency,
|
currency: _currency,
|
||||||
description: _descriptionController.text.trim(),
|
description: _descriptionController.text.trim(),
|
||||||
amount: amount,
|
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 (!mounted) return;
|
||||||
|
|
||||||
if (result.isSuccess) {
|
if (result.isSuccess) {
|
||||||
final batch = result.data!;
|
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}');
|
context.pushReplacement('/groups/${widget.group.id}/batches/${batch.id}');
|
||||||
} else {
|
} else {
|
||||||
AppSnackBar.showError(context, result.error ?? 'Failed to create batch.');
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('New Batch'), centerTitle: true),
|
appBar: AppBar(title: const Text('New Batch'), centerTitle: true),
|
||||||
body: ListenableBuilder(
|
body: ListenableBuilder(
|
||||||
listenable: controller,
|
listenable: Listenable.merge([batchController, providerController]),
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
if (_loadingDropdowns) {
|
if (_loadingDropdowns) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
return Center(
|
return Align(
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final width = constraints.maxWidth > ResponsivePolicy.md
|
final width = constraints.maxWidth > ResponsivePolicy.md
|
||||||
@@ -132,51 +186,28 @@ class _BatchCreateScreenState extends State<BatchCreateScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
_buildSectionLabel('Bill Provider'),
|
// Row 1: Description + Currency
|
||||||
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(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 2,
|
flex: 2,
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
controller: _amountController,
|
controller: _descriptionController,
|
||||||
keyboardType:
|
|
||||||
const TextInputType.numberWithOptions(
|
|
||||||
decimal: true,
|
|
||||||
),
|
|
||||||
textInputAction: TextInputAction.next,
|
textInputAction: TextInputAction.next,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText:
|
labelText: 'Description (optional)',
|
||||||
'Amount each recipient will receive',
|
hintText:
|
||||||
hintText: 'Amount',
|
'e.g. Monthly salaries for January',
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
validator: (v) =>
|
|
||||||
(v == null || v.trim().isEmpty)
|
|
||||||
? 'Required'
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: DropdownButtonFormField<String>(
|
child: DropdownButtonFormField<String>(
|
||||||
value: _currency,
|
initialValue: _currency,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Currency',
|
labelText: 'Currency',
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
@@ -194,32 +225,82 @@ class _BatchCreateScreenState extends State<BatchCreateScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
onChanged: (v) {
|
onChanged: (v) {
|
||||||
if (v != null)
|
if (v != null) _updateCurrency(v);
|
||||||
setState(() => _currency = v);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextFormField(
|
// Row 2: Provider + Product
|
||||||
controller: _debitPhoneController,
|
Row(
|
||||||
keyboardType: TextInputType.phone,
|
children: [
|
||||||
textInputAction: TextInputAction.done,
|
Expanded(child: _buildProviderDropdown()),
|
||||||
decoration: InputDecoration(
|
const SizedBox(width: 12),
|
||||||
labelText: 'Your Phone Number',
|
Expanded(child: _buildProductDropdown()),
|
||||||
hintText: '263773000000',
|
],
|
||||||
border: OutlineInputBorder(
|
),
|
||||||
borderRadius: BorderRadius.circular(12),
|
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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 12),
|
||||||
validator: (v) => (v == null || v.trim().isEmpty)
|
Expanded(
|
||||||
? 'Phone number is required'
|
child: TextFormField(
|
||||||
: null,
|
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),
|
const SizedBox(height: 32),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: controller.model.isActing
|
onPressed: batchController.model.isActing
|
||||||
? null
|
? null
|
||||||
: _submit,
|
: _submit,
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
@@ -228,7 +309,7 @@ class _BatchCreateScreenState extends State<BatchCreateScreen> {
|
|||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: controller.model.isActing
|
child: batchController.model.isActing
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
height: 20,
|
height: 20,
|
||||||
width: 20,
|
width: 20,
|
||||||
@@ -255,185 +336,156 @@ class _BatchCreateScreenState extends State<BatchCreateScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSectionLabel(String text) {
|
Widget _buildProviderDropdown() {
|
||||||
return Text(
|
final providers = providerController.model.providers;
|
||||||
text,
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: Colors.grey.shade700,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static const Map<String, Color> _categoryColorMap = {
|
if (providerController.model.isLoadingProviders) {
|
||||||
'ECONET': Color(0xFF1A73E8),
|
return const SizedBox(
|
||||||
'NETONE': Color.fromARGB(255, 230, 127, 0),
|
height: 56,
|
||||||
'TELECEL': Color(0xFF7B1FA2),
|
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||||
'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),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
return DropdownButtonFormField<BillProvider>(
|
||||||
builder: (context, gridConstraints) {
|
initialValue: providerController.model.selectedProvider,
|
||||||
// gridConstraints.maxWidth reflects the actual rendered width of this widget
|
isExpanded: true,
|
||||||
// 3-column grid: subtract 2 gaps (10*2 = 20), then divide by 3
|
decoration: InputDecoration(
|
||||||
final cardWidth = (gridConstraints.maxWidth - 20) / 3;
|
labelText: 'Provider',
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
return Wrap(
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
spacing: 10,
|
horizontal: 12,
|
||||||
runSpacing: 10,
|
vertical: 14,
|
||||||
children: controller.model.providers
|
),
|
||||||
.map(
|
),
|
||||||
(provider) => _buildProviderCard(provider, isDark, cardWidth),
|
items: providers.map((provider) {
|
||||||
)
|
return DropdownMenuItem<BillProvider>(
|
||||||
.toList(),
|
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) {
|
Widget _buildProductDropdown() {
|
||||||
final isSelected =
|
final products = providerController.model.products;
|
||||||
controller.model.selectedProvider?.clientId == provider.clientId;
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
final accent = _resolveProviderColor(provider);
|
|
||||||
|
|
||||||
return SizedBox(
|
if (providerController.model.selectedProvider == null) {
|
||||||
width: width,
|
return InputDecorator(
|
||||||
child: Material(
|
decoration: InputDecoration(
|
||||||
color: Colors.transparent,
|
labelText: 'Product',
|
||||||
child: InkWell(
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
borderRadius: BorderRadius.circular(12),
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
onTap: () => controller.selectProvider(provider),
|
horizontal: 12,
|
||||||
child: AnimatedContainer(
|
vertical: 14,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
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
@@ -34,11 +34,12 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
|
|||||||
static const _statusFilterItems = <DropdownMenuItem<String?>>[
|
static const _statusFilterItems = <DropdownMenuItem<String?>>[
|
||||||
DropdownMenuItem(value: null, child: Text('All')),
|
DropdownMenuItem(value: null, child: Text('All')),
|
||||||
DropdownMenuItem(value: 'CREATED', child: Text('Created')),
|
DropdownMenuItem(value: 'CREATED', child: Text('Created')),
|
||||||
DropdownMenuItem(value: 'PENDING', child: Text('Pending')),
|
DropdownMenuItem(value: 'CONFIRMING', child: Text('Confirming')),
|
||||||
DropdownMenuItem(value: 'CONFIRMED_ALL', child: Text('Confirmed')),
|
DropdownMenuItem(value: 'CONFIRMED_ALL', child: Text('Confirmed All')),
|
||||||
DropdownMenuItem(value: 'REQUESTED', child: Text('Requested')),
|
DropdownMenuItem(value: 'REQUESTED', child: Text('Requested')),
|
||||||
DropdownMenuItem(value: 'PROCESSING', child: Text('Processing')),
|
|
||||||
DropdownMenuItem(value: 'COMPLETED', child: Text('Completed')),
|
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')),
|
DropdownMenuItem(value: 'FAILED', child: Text('Failed')),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
|
|
||||||
prefs = await SharedPreferences.getInstance();
|
prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
if (prefs.getString("token") != null) {
|
if (prefs.getString("token") != null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoggedIn = true;
|
_isLoggedIn = true;
|
||||||
|
|||||||
182
lib/screens/provider/provider_controller.dart
Normal file
182
lib/screens/provider/provider_controller.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,16 +60,16 @@ class TransactionModel {
|
|||||||
final String reference;
|
final String reference;
|
||||||
final String paymentProcessorLabel;
|
final String paymentProcessorLabel;
|
||||||
final String? integrationProcessorLabel;
|
final String? integrationProcessorLabel;
|
||||||
final String confirmationProcessorLabel;
|
final String? confirmationProcessorLabel;
|
||||||
final String providerLabel;
|
final String? providerLabel;
|
||||||
final String debitPhone;
|
final String debitPhone;
|
||||||
final String debitCurrency;
|
final String debitCurrency;
|
||||||
final String debitRef;
|
final String? debitRef;
|
||||||
final String? creditPhone;
|
final String? creditPhone;
|
||||||
final String? creditAccount;
|
final String? creditAccount;
|
||||||
final String? creditRef;
|
final String? creditRef;
|
||||||
final String? billClientId;
|
final String? billClientId;
|
||||||
final String billName;
|
final String? billName;
|
||||||
final String paymentProcessorName;
|
final String paymentProcessorName;
|
||||||
final String? providerImage;
|
final String? providerImage;
|
||||||
final String? paymentProcessorImage;
|
final String? paymentProcessorImage;
|
||||||
@@ -108,16 +108,16 @@ class TransactionModel {
|
|||||||
required this.reference,
|
required this.reference,
|
||||||
required this.paymentProcessorLabel,
|
required this.paymentProcessorLabel,
|
||||||
this.integrationProcessorLabel,
|
this.integrationProcessorLabel,
|
||||||
required this.confirmationProcessorLabel,
|
this.confirmationProcessorLabel,
|
||||||
required this.providerLabel,
|
this.providerLabel,
|
||||||
required this.debitPhone,
|
required this.debitPhone,
|
||||||
required this.debitCurrency,
|
required this.debitCurrency,
|
||||||
required this.debitRef,
|
this.debitRef,
|
||||||
this.creditPhone,
|
this.creditPhone,
|
||||||
this.creditAccount,
|
this.creditAccount,
|
||||||
this.creditRef,
|
this.creditRef,
|
||||||
this.billClientId,
|
this.billClientId,
|
||||||
required this.billName,
|
this.billName,
|
||||||
required this.paymentProcessorName,
|
required this.paymentProcessorName,
|
||||||
this.providerImage,
|
this.providerImage,
|
||||||
this.paymentProcessorImage,
|
this.paymentProcessorImage,
|
||||||
|
|||||||
@@ -27,16 +27,16 @@ TransactionModel _$TransactionModelFromJson(Map<String, dynamic> json) =>
|
|||||||
reference: json['reference'] as String,
|
reference: json['reference'] as String,
|
||||||
paymentProcessorLabel: json['paymentProcessorLabel'] as String,
|
paymentProcessorLabel: json['paymentProcessorLabel'] as String,
|
||||||
integrationProcessorLabel: json['integrationProcessorLabel'] as String?,
|
integrationProcessorLabel: json['integrationProcessorLabel'] as String?,
|
||||||
confirmationProcessorLabel: json['confirmationProcessorLabel'] as String,
|
confirmationProcessorLabel: json['confirmationProcessorLabel'] as String?,
|
||||||
providerLabel: json['providerLabel'] as String,
|
providerLabel: json['providerLabel'] as String?,
|
||||||
debitPhone: json['debitPhone'] as String,
|
debitPhone: json['debitPhone'] as String,
|
||||||
debitCurrency: json['debitCurrency'] as String,
|
debitCurrency: json['debitCurrency'] as String,
|
||||||
debitRef: json['debitRef'] as String,
|
debitRef: json['debitRef'] as String?,
|
||||||
creditPhone: json['creditPhone'] as String?,
|
creditPhone: json['creditPhone'] as String?,
|
||||||
creditAccount: json['creditAccount'] as String?,
|
creditAccount: json['creditAccount'] as String?,
|
||||||
creditRef: json['creditRef'] as String?,
|
creditRef: json['creditRef'] as String?,
|
||||||
billClientId: json['billClientId'] as String?,
|
billClientId: json['billClientId'] as String?,
|
||||||
billName: json['billName'] as String,
|
billName: json['billName'] as String?,
|
||||||
paymentProcessorName: json['paymentProcessorName'] as String,
|
paymentProcessorName: json['paymentProcessorName'] as String,
|
||||||
providerImage: json['providerImage'] as String?,
|
providerImage: json['providerImage'] as String?,
|
||||||
paymentProcessorImage: json['paymentProcessorImage'] as String?,
|
paymentProcessorImage: json['paymentProcessorImage'] as String?,
|
||||||
|
|||||||
Reference in New Issue
Block a user