diff --git a/assets/city.png b/assets/city.png new file mode 100644 index 0000000..9b82e7d Binary files /dev/null and b/assets/city.png differ diff --git a/lib/screens/accounts/account_provider.dart b/lib/screens/accounts/account_provider.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/screens/confirm/confirm_controller.dart b/lib/screens/confirm/confirm_controller.dart index 8b9d82c..fed2a7d 100644 --- a/lib/screens/confirm/confirm_controller.dart +++ b/lib/screens/confirm/confirm_controller.dart @@ -1,3 +1,4 @@ +import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -54,19 +55,21 @@ class ConfirmController extends ChangeNotifier { notifyListeners(); // todo: find out if we still need to update these fields - transactionController + transactionController.model.formData = transactionController .model - .formData = transactionController.model.formData.copyWith( - type: "REQUEST", - trace: transactionController.model.confirmationData['trace'], - id: transactionController.model.confirmationData['id'], - authType: transactionController.model.selectedPaymentProcessor.authType, - ); + .formData + .copyWith( + type: "REQUEST", + trace: transactionController.model.confirmationData['trace'], + id: transactionController.model.confirmationData['id'], + authType: + transactionController.model.selectedPaymentProcessor.authType, + ); dynamic workflowResponse = await http.get( '/public/transaction/request/' - '${transactionController.model.confirmationData['id']}/' - '${transactionController.model.selectedPaymentProcessor.authType}' + '${transactionController.model.confirmationData['id']}/' + '${transactionController.model.selectedPaymentProcessor.authType}', ); logger.i(workflowResponse.toString()); @@ -82,7 +85,9 @@ class ConfirmController extends ChangeNotifier { } else { model.status = response['status']; model.errorMessage = response['errorMessage']; - return ApiResponse.failure(response['errorMessage'] ?? "Transaction failed"); + return ApiResponse.failure( + response['errorMessage'] ?? "Transaction failed", + ); } } catch (e) { logger.e(e); @@ -125,6 +130,50 @@ class ConfirmController extends ChangeNotifier { return ApiResponse.failure("Polling cancelled"); } + Future>> updateVelocityTransactionOtp( + String id, + Map transaction, + ) async { + try { + model.isLoading = true; + notifyListeners(); + + dynamic response = await http.put( + '/public/transaction/$id/velocity-otp', + transaction, + ); + + model.isLoading = false; + notifyListeners(); + + if (response['status'] == 'SUCCESS') { + return ApiResponse.success(Map.from(response)); + } else { + model.errorMessage = + response['errorMessage'] ?? + "Problem updating transaction. Please try again or contact support"; + return ApiResponse.failure(model.errorMessage!); + } + } on DioException catch (e, s) { + if (kDebugMode) { + logger.e(e); + logger.e(s); + } + model.isLoading = false; + notifyListeners(); + return ApiResponse.failure( + "Network error. Please try again or contact support", + ); + } catch (e, s) { + logger.e(s); + model.isLoading = false; + notifyListeners(); + return ApiResponse.failure( + "Problem updating that transaction. Please try again or contact support", + ); + } + } + @override void dispose() { // TODO: implement dispose diff --git a/lib/screens/confirm/confirm_screen.dart b/lib/screens/confirm/confirm_screen.dart index fe6b14a..b479e07 100644 --- a/lib/screens/confirm/confirm_screen.dart +++ b/lib/screens/confirm/confirm_screen.dart @@ -78,6 +78,41 @@ class _ConfirmScreenState extends State if (!mounted) return; + // Check if the selected payment processor is a wallet (e.g. Velocity) + if (controller + .transactionController + .model + .selectedPaymentProcessor + .label == + 'WALLET') { + final otp = await _showOtpBottomSheet(); + if (otp == null) return; // User cancelled or dismissed the bottom sheet + + if (!mounted) return; + + final transactionId = + controller.transactionController.model.confirmationData['id']; + final otpResponse = await controller.updateVelocityTransactionOtp( + transactionId, + {'verificationCode': otp}, + ); + + if (!otpResponse.isSuccess) { + if (context.mounted) { + AppSnackBar.showError( + context, + controller.model.errorMessage ?? + otpResponse.error ?? + "OTP verification failed", + ); + } + return; + } + + if (!mounted) return; + } + + // Proceed with normal flow after OTP step (or skip if not WALLET) if (controller .transactionController .model @@ -99,6 +134,101 @@ class _ConfirmScreenState extends State } } + Future _showOtpBottomSheet() { + final otpController = TextEditingController(); + final formKey = GlobalKey(); + + return showModalBottomSheet( + context: context, + isDismissible: false, + enableDrag: false, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) { + return Padding( + padding: EdgeInsets.only( + left: 24, + right: 24, + top: 24, + bottom: MediaQuery.of(context).viewInsets.bottom + 24, + ), + child: Form( + key: formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 20), + Text( + 'OTP Verification', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(height: 8), + Text( + 'Please enter the OTP sent to your mobile/email', + style: TextStyle(fontSize: 14, color: Colors.grey.shade600), + ), + const SizedBox(height: 20), + TextFormField( + controller: otpController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: 'Enter OTP', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Please enter the OTP'; + } + return null; + }, + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () { + if (formKey.currentState!.validate()) { + Navigator.of(context).pop(otpController.text.trim()); + } + }, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text( + 'Submit OTP', + style: TextStyle(fontSize: 16), + ), + ), + ), + ], + ), + ), + ); + }, + ); + } + @override Widget build(BuildContext context) { return Scaffold( diff --git a/lib/screens/gateway/gateway_controller.dart b/lib/screens/gateway/gateway_controller.dart index 126e547..1e085bc 100644 --- a/lib/screens/gateway/gateway_controller.dart +++ b/lib/screens/gateway/gateway_controller.dart @@ -49,8 +49,9 @@ class GatewayController extends ChangeNotifier { Future>> poll(String uid) async { startLoading(); try { - dynamic workflowResponse = - await http.get('/public/transaction/poll/$uid'); + dynamic workflowResponse = await http.get( + '/public/transaction/poll/$uid', + ); logger.i(workflowResponse.toString()); @@ -62,6 +63,10 @@ class GatewayController extends ChangeNotifier { finishLoading(); if (model.status == 'SUCCESS') { transactionController.updateReceiptData(response); + } else { + model.status = 'FAILED'; + model.errorMessage = response['errorMessage']; + return ApiResponse.failure(response['errorMessage']); } notifyListeners(); return ApiResponse.success(Map.from(response)); @@ -79,7 +84,7 @@ class GatewayController extends ChangeNotifier { Future>> pollTransaction(String uid) async { // poll up to 30 times (~5 minutes at 10-second intervals) - int maxAttempts = 30; + int maxAttempts = 15; int attempt = 0; while (!model.isCancelled && attempt < maxAttempts) { @@ -103,6 +108,9 @@ class GatewayController extends ChangeNotifier { // Otherwise (e.g. PENDING), continue polling } else { // Network error — show failure UI and stop + model.isCancelled = true; + model.status = 'FAILED'; + model.errorMessage = result.error; return result; } } @@ -118,7 +126,8 @@ class GatewayController extends ChangeNotifier { // Alternative method using Timer instead of while loop Future>> pollTransactionWithTimer( - String uid) async { + String uid, + ) async { _pollTimer = Timer.periodic(const Duration(seconds: 5), (timer) async { if (model.isCancelled) { timer.cancel(); @@ -145,7 +154,8 @@ class GatewayController extends ChangeNotifier { // This returns immediately; the caller should use the timer callback approach. // Consider refactoring this to use poll(String uid) directly for a consistent API. return ApiResponse.failure( - "Polling via timer started; use a different flow for the result"); + "Polling via timer started; use a different flow for the result", + ); } @override @@ -154,4 +164,4 @@ class GatewayController extends ChangeNotifier { _pollTimer?.cancel(); // Cancel timer if it exists super.dispose(); } -} \ No newline at end of file +} diff --git a/lib/screens/pay/pay_screen.dart b/lib/screens/pay/pay_screen.dart index 208e839..ce2322c 100644 --- a/lib/screens/pay/pay_screen.dart +++ b/lib/screens/pay/pay_screen.dart @@ -306,8 +306,38 @@ class _PayScreenState extends State with TickerProviderStateMixin { const SizedBox(height: 7), _buildAmountField(), const SizedBox(height: 20), - ...controller.model.paymentProcessors.map( - (e) => _buildPaymentProcessorItem(e, context), + LayoutBuilder( + builder: (context, wrapConstraints) { + final effectiveWidth = + constraints.maxWidth > + ResponsivePolicy.md + ? ResponsivePolicy.md.toDouble() + : constraints.maxWidth; + final columns = + effectiveWidth > ResponsivePolicy.sm + ? 2 + : 1; + final itemWidth = + (effectiveWidth - (columns - 1) * 10) / + columns; + return Wrap( + spacing: 10, + runSpacing: 10, + children: controller + .model + .paymentProcessors + .map( + (e) => SizedBox( + width: itemWidth, + child: _buildPaymentProcessorItem( + e, + context, + ), + ), + ) + .toList(), + ); + }, ), ], ), @@ -716,77 +746,73 @@ class _PayScreenState extends State with TickerProviderStateMixin { } Widget _buildPaymentProcessorItem(PaymentProcessor e, BuildContext context) { - return Column( - children: [ - Skeletonizer( - enabled: controller.model.isLoading, - child: SlideTransition( - position: - _animations[controller.model.paymentProcessors.indexOf(e)], - child: Container( - decoration: BoxDecoration( - border: Border.all(color: Colors.black87.withAlpha(20)), - borderRadius: BorderRadius.circular(12), - gradient: LinearGradient( - colors: [ - Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.1), - Theme.of( - context, - ).colorScheme.tertiary.withValues(alpha: 0.05), - ], - stops: const [0.3, 0.7], - begin: Alignment.topRight, - end: Alignment.bottomLeft, - ), - ), - child: InkWell( - customBorder: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - onTap: () async { - if (_formKey.currentState!.validate()) { - if (controller.model.isLoading) return; + final rawIndex = controller.model.paymentProcessors.indexOf(e); + final animation = rawIndex < _animations.length + ? _animations[rawIndex] + : _animations.isNotEmpty + ? _animations.last + : Tween( + begin: Offset.zero, + end: Offset.zero, + ).animate(_controller); + return Skeletonizer( + enabled: controller.model.isLoading, + child: SlideTransition( + position: animation, + child: Container( + decoration: BoxDecoration( + border: Border.all(color: Colors.black87.withAlpha(20)), + borderRadius: BorderRadius.circular(12), + gradient: LinearGradient( + colors: [ + Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), + Theme.of(context).colorScheme.tertiary.withValues(alpha: 0.05), + ], + stops: const [0.3, 0.7], + begin: Alignment.topRight, + end: Alignment.bottomLeft, + ), + ), + child: InkWell( + customBorder: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + onTap: () async { + if (_formKey.currentState!.validate()) { + if (controller.model.isLoading) return; - await _submitPayment(e, context); - } - }, - child: ListTile( - leading: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.1), - shape: BoxShape.circle, - ), - child: Image(image: AssetImage("assets/${e.image}")), - ), - title: Text( - e.name, - style: TextStyle(fontWeight: FontWeight.bold), - ), - subtitle: Text( - e.description, - style: TextStyle( - fontWeight: FontWeight.normal, - fontSize: 10, - ), - ), - trailing: Icon( - Icons.chevron_right, - color: Theme.of( - context, - ).colorScheme.secondary.withValues(alpha: 0.7), - ), + await _submitPayment(e, context); + } + }, + child: ListTile( + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.1), + shape: BoxShape.circle, ), + child: Image(image: AssetImage("assets/${e.image}")), + ), + title: Text( + e.name, + style: TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text( + e.description, + style: TextStyle(fontWeight: FontWeight.normal, fontSize: 10), + ), + trailing: Icon( + Icons.chevron_right, + color: Theme.of( + context, + ).colorScheme.secondary.withValues(alpha: 0.7), ), ), ), ), - SizedBox(height: 10), - ], + ), ); } } diff --git a/lib/screens/poll/poll_screen.dart b/lib/screens/poll/poll_screen.dart index 982ab61..3ec6d14 100644 --- a/lib/screens/poll/poll_screen.dart +++ b/lib/screens/poll/poll_screen.dart @@ -92,7 +92,9 @@ class _PollScreenState extends State { 'successful. This won\'t take long.', status: controller.model.status, isLoading: controller.model.isLoading, - errorMessage: 'We failed to check your transaction status', + errorMessage: + controller.model.errorMessage ?? + 'We failed to check your transaction status', onRetry: () { controller.model.isCancelled = false; _startPolling();