completed velocity integration

This commit is contained in:
2026-05-27 19:01:24 +02:00
parent fec46fb6e9
commit 6fdd3183fb
29 changed files with 2760 additions and 2293 deletions

View File

@@ -4,6 +4,7 @@ import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.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/transaction_controller.dart';
import 'package:qpay/screens/transaction_controller.dart' as tc;
@@ -69,21 +70,9 @@ class PayController extends ChangeNotifier {
model.selectedProduct = transactionController.model.selectedProduct;
}
void _showErrorSnackBar(String? message) {
WidgetsBinding.instance.addPostFrameCallback((_) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message.toString()),
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.deepOrange,
),
);
});
}
// formData can come from a repeat transaction
// otherwise build it from elements of the normal flow
Future<void> doTransaction([tc.FormData? formData]) async {
Future<ApiResponse<Map<String, dynamic>>> doTransaction([tc.FormData? formData]) async {
try {
model.isLoading = true;
notifyListeners();
@@ -120,52 +109,59 @@ class PayController extends ChangeNotifier {
dynamic response = workflowResponse['body'];
model.isLoading = false;
notifyListeners();
if (response['status'] == 'SUCCESS') {
model.status = response['status'];
transactionController.updateConfirmationData(response);
return ApiResponse.success(Map<String, dynamic>.from(response));
} else {
model.status = response['status'];
model.errorMessage =
response['errorMessage'] ??
"Problem with the transaction. Please try again or contact support";
_showErrorSnackBar(model.errorMessage);
return ApiResponse.failure(model.errorMessage!);
}
} on DioException catch (e, s) {
logger.e(s);
_showErrorSnackBar("Network error. Please try again or contact support");
model.isLoading = false;
notifyListeners();
return ApiResponse.failure("Network error. Please try again or contact support");
} catch (e, s) {
logger.e(s);
_showErrorSnackBar(
model.isLoading = false;
notifyListeners();
return ApiResponse.failure(
"Problem processing that transaction. Please try again or contact support",
);
}
model.isLoading = false;
notifyListeners();
}
Future<void> getProducts(String providerId) async {
// model.products = getFakeProducts();
Future<ApiResponse<List<BillProduct>>> getProducts(String providerId) async {
try {
model.isLoading = true;
notifyListeners();
List<dynamic> response = await http.get(
'/public/providers/$providerId/products',
);
model.products = response.map((e) => BillProduct.fromJson(e)).toList();
model.isLoading = false;
notifyListeners();
return ApiResponse.success(model.products);
} catch (e) {
logger.e(e);
_showErrorSnackBar(
model.isLoading = false;
notifyListeners();
return ApiResponse.failure(
"Problem fetching products, are you connected to the internet?",
);
}
model.isLoading = false;
notifyListeners();
}
Future<void> getPaymentProcessors() async {
Future<ApiResponse<List<PaymentProcessor>>> getPaymentProcessors() async {
try {
model.isLoading = true;
model.paymentProcessors = getFakePaymentProcessors();
@@ -175,10 +171,14 @@ class PayController extends ChangeNotifier {
model.paymentProcessors =
response.map((e) => PaymentProcessor.fromJson(e)).toList();
model.isLoading = false;
notifyListeners();
return ApiResponse.success(model.paymentProcessors);
} catch (e) {
logger.e(e);
_showErrorSnackBar(
model.isLoading = false;
notifyListeners();
return ApiResponse.failure(
"Problem fetching payment processors, are you connected to the internet?",
);
}

View File

@@ -71,14 +71,7 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
listen: false,
);
_nameController.text =
transactionController.model.formData.creditName ?? '';
_emailController.text =
transactionController.model.formData.creditEmail ?? '';
_amountController.text = transactionController.model.formData.amount;
// Initialize phone number and country code
// updatePhoneNumber(transactionController.model.formData.creditPhone ?? '');
_setupData();
_controller = AnimationController(
vsync: this,
@@ -113,7 +106,6 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
}
_controller.forward();
_setupData();
}
_setupData() async {
@@ -121,27 +113,67 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
if (prefs.containsKey('phone')) {
updatePhoneNumber(prefs.getString('phone')!);
}
_nameController.text =
transactionController.model.formData.creditName ?? '';
_emailController.text =
transactionController.model.formData.creditEmail ?? '';
_amountController.text = transactionController.model.formData.amount;
_phoneController.text =
transactionController.model.formData.debitPhone ?? '';
}
Future<void> updatePhoneNumber(String phoneNumber) async {
String existingPhone = phoneNumber;
if (existingPhone.isNotEmpty) {
// Try to extract country code from existing phone number
for (var country in countryCodes) {
if (existingPhone.startsWith(country['code']!)) {
setState(() {
selectedCountryCode = country['code']!;
});
_phoneController.text = existingPhone.substring(
country['code']!.length,
);
break;
}
Future<void> updatePhoneNumber(String newNumber) async {
for (var country in countryCodes) {
if (newNumber.startsWith(country['code']!)) {
setState(() {
selectedCountryCode = country['code']!;
});
_phoneController.text = newNumber.substring(country['code']!.length);
break;
}
// If no country code found, use default and set the full number
if (_phoneController.text.isEmpty) {
_phoneController.text = existingPhone;
}
}
Future<void> _submitPayment(PaymentProcessor e, BuildContext context) async {
controller.updateAmount(_amountController.text);
if (!controller.model.selectedProvider!.requiresAmount) {
controller.updateAmount(
controller.model.selectedProduct!.defaultAmount.toString(),
);
}
String fullPhoneNumber = _phoneController.text;
controller.updatePhone(fullPhoneNumber);
if (showAdditionalRecipientDetails) {
controller.updateAdditionalRecipientDetails(
_nameController.text,
_emailController.text,
);
}
controller.updateSelectedPaymentProcessor(e);
transactionController.updateSelectedPaymentProcessor(e);
final apiResponse = await controller.doTransaction();
if (!apiResponse.isSuccess) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
controller.model.errorMessage ??
apiResponse.error ??
"Transaction failed",
),
behavior: SnackBarBehavior.floating,
width: 600,
backgroundColor: Colors.deepOrange,
),
);
}
} else if (controller.model.status == "SUCCESS" && context.mounted) {
context.push('/confirm');
}
}
@@ -197,77 +229,32 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
decoration: BoxDecoration(
border: Border.all(
color: Colors.black87.withAlpha(20),
_buildTransactionDetailsCard(
children: [
_buildDetailRow(
icon: Icons.business_outlined,
label: "Provider",
value:
controller
.model
.selectedProvider
?.name ??
"",
),
borderRadius: BorderRadius.circular(12),
),
padding: EdgeInsets.symmetric(
vertical: 10,
horizontal: 20,
),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
"TRANSACTIONS DETAILS",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 10),
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text(
"Provider",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
Text(
controller
.model
.selectedProvider
?.name ??
"",
style: TextStyle(fontSize: 16),
),
],
),
const SizedBox(height: 10),
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text(
controller
.model
.selectedProvider
?.accountFieldName ??
"",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
Text(
transactionController
.model
.formData
.creditAccount,
style: TextStyle(fontSize: 16),
),
],
),
],
),
_buildDetailRow(
icon: Icons.account_balance_outlined,
label:
controller
.model
.selectedProvider
?.accountFieldName ??
"",
value: transactionController
.model
.formData
.creditAccount,
),
],
),
SizedBox(height: 20),
InkWell(
@@ -313,7 +300,6 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
const SizedBox(height: 7),
_buildAmountField(),
const SizedBox(height: 20),
// _buildPayButton(controller),
...controller.model.paymentProcessors.map(
(e) => _buildPaymentProcessorItem(e, context),
),
@@ -343,9 +329,18 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Debit Phone Number',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
Row(
children: [
const Text(
'Debit Phone Number',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
),
SizedBox(width: 5),
const Text(
'(to be charged or notified)',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
if (!kIsWeb)
TextButton(
@@ -372,7 +367,9 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
width: 100,
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.primary.withOpacity(0.2),
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.2),
),
borderRadius: BorderRadius.circular(12),
),
@@ -427,7 +424,7 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
borderSide: BorderSide(
color: Theme.of(
context,
).colorScheme.primary.withOpacity(0.2),
).colorScheme.primary.withValues(alpha: 0.2),
),
),
),
@@ -435,7 +432,6 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
if (value == null || value.isEmpty) {
return 'Please enter a phone number';
}
// Remove any non-digit characters for validation
String digitsOnly = value.replaceAll(RegExp(r'[^\d]'), '');
if (digitsOnly.length < 7) {
return 'Phone number must be at least 7 digits';
@@ -479,7 +475,9 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary.withOpacity(0.2),
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.2),
),
),
),
@@ -526,7 +524,7 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
borderSide: BorderSide(
color: Theme.of(
context,
).colorScheme.primary.withOpacity(0.2),
).colorScheme.primary.withValues(alpha: 0.2),
),
),
),
@@ -558,7 +556,7 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
borderSide: BorderSide(
color: Theme.of(
context,
).colorScheme.primary.withOpacity(0.2),
).colorScheme.primary.withValues(alpha: 0.2),
),
),
),
@@ -586,7 +584,7 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
enabled: controller.model.isLoading,
child: DropdownButtonHideUnderline(
child: DropdownButtonFormField<String>(
value: controller.model.selectedProduct?.uid,
initialValue: controller.model.selectedProduct?.uid,
isExpanded: true,
icon: const Icon(Icons.arrow_drop_down),
decoration: InputDecoration(
@@ -633,6 +631,84 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
);
}
Widget _buildTransactionDetailsCard({required List<Widget> children}) {
return Container(
width: double.infinity,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.grey.shade200, width: 1),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.receipt_long_outlined,
size: 18,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(
"TRANSACTION DETAILS",
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: Theme.of(context).colorScheme.primary,
letterSpacing: 1.0,
),
),
],
),
const SizedBox(height: 16),
Divider(height: 1, color: Colors.grey.shade200),
const SizedBox(height: 16),
...children,
],
),
),
);
}
Widget _buildDetailRow({
required IconData icon,
required String label,
required String value,
}) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
children: [
Icon(icon, size: 16, color: Colors.grey.shade500),
const SizedBox(width: 10),
Text(
label,
style: TextStyle(fontSize: 14, color: Colors.grey.shade600),
),
const Spacer(),
Text(
value,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
],
),
);
}
Widget _buildPaymentProcessorItem(PaymentProcessor e, BuildContext context) {
return Column(
children: [
@@ -667,34 +743,7 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
if (_formKey.currentState!.validate()) {
if (controller.model.isLoading) return;
controller.updateAmount(_amountController.text);
if (!controller.model.selectedProvider!.requiresAmount) {
controller.updateAmount(
controller.model.selectedProduct!.defaultAmount
.toString(),
);
}
// Update phone number with country code
String fullPhoneNumber =
selectedCountryCode + _phoneController.text;
controller.updatePhone(fullPhoneNumber);
if (showAdditionalRecipientDetails) {
controller.updateAdditionalRecipientDetails(
_nameController.text,
_emailController.text,
);
}
controller.updateSelectedPaymentProcessor(e);
transactionController.updateSelectedPaymentProcessor(e);
await controller.doTransaction();
if (controller.model.status == "SUCCESS" &&
context.mounted) {
context.push('/confirm');
}
await _submitPayment(e, context);
}
},
child: ListTile(