diff --git a/assets/.env b/assets/.env index a69f659..d978b67 100644 --- a/assets/.env +++ b/assets/.env @@ -1,9 +1,10 @@ ; BASE_URL=https://peakapi.qantra.co.zw/api -BASE_URL=http://192.168.100.26:6950/api -; BASE_URL=http://10.69.5.204:6950/api +; BASE_URL=http://192.168.100.26:6950/api +BASE_URL=http://10.69.5.204:6950/api ; BASE_URL=http://192.168.1.164:6950/api ; BASE_URL=http://192.168.120.160:6950/api ; BASE_URL=http://10.10.2.92:6950/api ; BASE_URL=http://172.20.5.105:6950/api ; BASE_URL=http://10.10.3.92:6950/api -CLIENTID=77433712483-ng7pntvcpf6tnjccriuqm8dbna8vvp3b.apps.googleusercontent.com \ No newline at end of file +CLIENTID=77433712483-ng7pntvcpf6tnjccriuqm8dbna8vvp3b.apps.googleusercontent.com +SIMULATE_PAYMENT_SUCCESS=false \ No newline at end of file diff --git a/lib/http/http.dart b/lib/http/http.dart index ec8094d..8bcd3e5 100644 --- a/lib/http/http.dart +++ b/lib/http/http.dart @@ -11,8 +11,9 @@ class Http { Http() { dio.options.baseUrl = baseUrl; - dio.options.connectTimeout = const Duration(seconds: 60); - dio.options.receiveTimeout = const Duration(seconds: 60); + dio.options.connectTimeout = const Duration(seconds: 120); + dio.options.receiveTimeout = const Duration(seconds: 120); + dio.options.sendTimeout = const Duration(seconds: 120); dio.interceptors.add( LogInterceptor( request: true, diff --git a/lib/screens/gateway/gateway_controller.dart b/lib/screens/gateway/gateway_controller.dart index dd6c86a..57f4e95 100644 --- a/lib/screens/gateway/gateway_controller.dart +++ b/lib/screens/gateway/gateway_controller.dart @@ -9,6 +9,7 @@ class GatewayModel { String status = ''; String? errorMessage; bool isCancelled = false; + int count = 0; } class GatewayController extends ChangeNotifier { @@ -40,6 +41,7 @@ class GatewayController extends ChangeNotifier { void startLoading() { model.isLoading = true; + model.status = ''; notifyListeners(); } @@ -48,6 +50,36 @@ class GatewayController extends ChangeNotifier { notifyListeners(); } + Future poll(String uid) async { + startLoading(); + try { + dynamic workflowResponse = await http.get('/public/transaction/poll/$uid'); + + logger.i(workflowResponse.toString()); + + dynamic response = workflowResponse['body']; + + model.status = response['status']; + transactionController.model.paymentStatus = response['status']; + transactionController.updateReceiptData(response); + + if(model.status == 'PENDING') { + _showErrorSnackBar('Transaction status still pending'); + } + + if(model.status != 'FAILED') { + _showErrorSnackBar(response['errorMessage'].isEmpty ? 'Transaction failed' : response['errorMessage']); + } + notifyListeners(); + } catch (e) { + logger.e(e); + _showErrorSnackBar( + "Network error. Please try again or contact support", + ); + } + finishLoading(); + } + Future pollTransaction(String uid) async { while (!model.isCancelled) { // Check cancellation flag instead of true @@ -57,25 +89,7 @@ class GatewayController extends ChangeNotifier { // Check again after delay in case it was cancelled during the delay if (model.isCancelled) break; - try { - dynamic workflowResponse = await http.get('/public/transaction/poll/$uid'); - - logger.i(workflowResponse.toString()); - - dynamic response = workflowResponse['body']; - - if (response['status'] == 'SUCCESS') { - model.status = response['status']; - transactionController.updateReceiptData(response); - notifyListeners(); - break; - } - } catch (e) { - logger.e(e); - _showErrorSnackBar( - "Network error. Please try again or contact support", - ); - } + await poll(uid); } } diff --git a/lib/screens/gateway/gateway_screen.dart b/lib/screens/gateway/gateway_screen.dart index 31e98ea..47165b9 100644 --- a/lib/screens/gateway/gateway_screen.dart +++ b/lib/screens/gateway/gateway_screen.dart @@ -1,4 +1,7 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:go_router/go_router.dart'; import 'package:qpay/screens/gateway/gateway_controller.dart'; import 'package:webview_flutter/webview_flutter.dart'; @@ -15,14 +18,20 @@ class _GatewayScreenState extends State { late WebViewController _webViewController; late GatewayController controller; + bool integrationStarted = false; + @override void initState() { super.initState(); _initWebView(widget.url); controller = GatewayController(context); - controller.pollTransaction( - controller.transactionController.model.receiptData?['id'], - ); + + bool simulate = bool.parse(dotenv.env['SIMULATE_PAYMENT_SUCCESS']!); + if(simulate) { + sleep(Duration(seconds: 10)); + String url = widget.url.replaceAll("/pay/", "/receipt/"); + _webViewController.loadRequest(Uri.parse(url)); + } } void _initWebView(String url) { @@ -42,6 +51,33 @@ class _GatewayScreenState extends State { }, onHttpError: (HttpResponseError error) {}, onWebResourceError: (WebResourceError error) {}, + onUrlChange: (UrlChange url) async { + print("URL Changed to ${url.url!}"); + + if(url.url!.contains("/checkout/receipt/")){ + if(integrationStarted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text("Payment was successful, please wait while we process your order."), + behavior: SnackBarBehavior.floating, + backgroundColor: Colors.black87, + duration: const Duration(seconds: 5), + ), + ); + + setState(() { + integrationStarted = true; + }); + await controller.poll(controller.transactionController.model.receiptData?['id']); + + WidgetsBinding.instance.addPostFrameCallback((_) { + context.go('/integration'); + }); + } + } ), ) ..loadRequest(Uri.parse(url)); diff --git a/lib/screens/integration/integration_controller.dart b/lib/screens/integration/integration_controller.dart index 1d338f4..798761b 100644 --- a/lib/screens/integration/integration_controller.dart +++ b/lib/screens/integration/integration_controller.dart @@ -45,7 +45,6 @@ class IntegrationController extends ChangeNotifier { Future doIntegration() async { try { model.isLoading = true; - model.status = ''; notifyListeners(); dynamic workflowResponse = await http.get( @@ -64,6 +63,7 @@ class IntegrationController extends ChangeNotifier { model.errorMessage = response['errorMessage']; _showErrorSnackBar(response['errorMessage']); } + model.isLoading = false; notifyListeners(); } catch (e) { diff --git a/lib/screens/integration/integration_screen.dart b/lib/screens/integration/integration_screen.dart index e9b1b17..c866780 100644 --- a/lib/screens/integration/integration_screen.dart +++ b/lib/screens/integration/integration_screen.dart @@ -1,5 +1,9 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; +import 'package:qpay/screens/gateway/gateway_controller.dart'; +import 'package:qpay/screens/transaction_controller.dart'; +import 'package:skeletonizer/skeletonizer.dart'; import 'integration_controller.dart'; @@ -12,15 +16,51 @@ class IntegrationScreen extends StatefulWidget { class _IntegrationScreenState extends State { late IntegrationController controller; + late TransactionController transactionController; + late GatewayController gatewayController; + + bool integrating = false; @override void initState() { super.initState(); controller = IntegrationController(context); + gatewayController = GatewayController(context); + transactionController = Provider.of( + context, + listen: false, + ); - controller.doIntegration(); } + @override + void didChangeDependencies() async { + super.didChangeDependencies(); + + if(integrating) return; + + String status = Provider.of(context).model.paymentStatus; + if(status == 'SUCCESS') { + setState(() { + integrating = true; + }); + await controller.doIntegration(); + } + } + + void _showErrorSnackBar(String? message) { + WidgetsBinding.instance.addPostFrameCallback((_) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message.toString()), + behavior: SnackBarBehavior.floating, + backgroundColor: Colors.deepOrange, + ), + ); + }); + } + + @override void dispose() { controller.dispose(); @@ -34,57 +74,91 @@ class _IntegrationScreenState extends State { title: const Text('Completing your transaction'), ), body: ListenableBuilder( - listenable: controller, + listenable: gatewayController, builder: (context, child) { - if (controller.model.status == 'SUCCESS') { - WidgetsBinding.instance.addPostFrameCallback((_) { - context.go('/receipt'); - }); - } + return ListenableBuilder( + listenable: controller, + builder: (context, child) { + if (controller.model.status == 'SUCCESS') { + WidgetsBinding.instance.addPostFrameCallback((_) { + context.go('/receipt'); + }); + } - return Container( - padding: EdgeInsets.all(50), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - SizedBox(height: 75), - SizedBox(height: 5), - Text( - 'We\'ve successfully collected your funds', - style: TextStyle(fontSize: 22), - textAlign: TextAlign.center, + return Container( + padding: EdgeInsets.all(50), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox(height: 75), + + if(transactionController.model.paymentStatus != 'SUCCESS') + Column( + children: [ + Text( + 'We failed to check your payment status', + style: TextStyle(fontSize: 20), + textAlign: TextAlign.center, + ), + SizedBox(height: 50,), + Skeletonizer( + enabled: gatewayController.model.isLoading, + child: OutlinedButton( + child: Text('Check latest status'), + onPressed: () { + gatewayController.poll(transactionController.model.receiptData?['id']); + }, + ), + ), + ], + ), + + if(transactionController.model.paymentStatus == 'SUCCESS') + Column( + children: [ + Text( + 'We\'ve successfully collected your funds. We are now ' + 'updating your billing account. This won\'t take long', + style: TextStyle(fontSize: 20), + textAlign: TextAlign.center, + ), + Image.asset('assets/peak-animation.gif', width: 150), + SizedBox( + width: 50, + child: LinearProgressIndicator(), + ), + SizedBox(height: 50), + if(controller.model.status == 'FAILED') + Column( + children: [ + Text( + 'We failed to update your billing account', + style: TextStyle(fontSize: 20), + textAlign: TextAlign.center, + ), + SizedBox(height: 20,), + Skeletonizer( + enabled: controller.model.isLoading, + child: OutlinedButton( + child: Text('Retry'), + onPressed: () { + controller.doIntegration(); + }, + ), + ), + ], + ), + ], + ), + ], ), - Image.asset('assets/peak-animation.gif', width: 150), - SizedBox( - width: 50, - child: LinearProgressIndicator(), - ), - SizedBox(height: 50), - Text( - 'We are now updating your billing account', - style: TextStyle(fontSize: 20), - textAlign: TextAlign.center, - ), - Text( - 'This won\'t take long', - style: TextStyle(fontSize: 20), - textAlign: TextAlign.center, - ), - SizedBox(height: 30,), - if(controller.model.status == 'FAILED') - OutlinedButton( - child: Text('Retry'), - onPressed: () { - controller.doIntegration(); - }, - ) - ], - ), - ), + ), + ); + }, ); - }, + } ), ); } diff --git a/lib/screens/receipt/receipt_controller.dart b/lib/screens/receipt/receipt_controller.dart index 9a46a1e..8d7b8c0 100644 --- a/lib/screens/receipt/receipt_controller.dart +++ b/lib/screens/receipt/receipt_controller.dart @@ -11,6 +11,7 @@ class ReceiptModel { Map? receiptData; bool isLoading = false; String? errorMessage; + String status = ''; } class ReceiptController extends ChangeNotifier { @@ -45,6 +46,36 @@ class ReceiptController extends ChangeNotifier { notifyListeners(); } + Future doIntegration(String id) async { + try { + model.isLoading = true; + model.status = ''; + notifyListeners(); + + dynamic workflowResponse = await http.get( + '/public/transaction/integration/$id' + ); + logger.i(workflowResponse.toString()); + + dynamic response = workflowResponse['body']; + + if (response['status'] == 'SUCCESS') { + model.status = response['status']; + transactionController.updateReceiptData(response); + } else { + model.status = response['status']; + model.errorMessage = response['errorMessage']; + _showErrorSnackBar(response['errorMessage']); + } + notifyListeners(); + + } catch (e) { + model.isLoading = false; + model.errorMessage = e.toString(); + notifyListeners(); + } + } + Future repeatTransaction(BuildContext context) async { setLoading(true); // fetch provider & update tran controller diff --git a/lib/screens/receipt/receipt_screen.dart b/lib/screens/receipt/receipt_screen.dart index 29fd43b..17d43ef 100644 --- a/lib/screens/receipt/receipt_screen.dart +++ b/lib/screens/receipt/receipt_screen.dart @@ -36,8 +36,8 @@ class _ReceiptScreenState extends State context, listen: false, ); - gatewayController = GatewayController(context); receiptController = ReceiptController(context); + gatewayController = GatewayController(context); receiptController.getReceiptData( transactionController.model.receiptData?["id"], @@ -85,20 +85,28 @@ class _ReceiptScreenState extends State _retry() async { receiptController.setLoading(true); - await gatewayController.pollTransaction(receiptController.model.receiptData?["id"]); - if(gatewayController.model.status == "SUCCESS"){ + await receiptController.doIntegration(receiptController.model.receiptData?["id"]); + if(receiptController.model.status == "SUCCESS"){ receiptController.getReceiptData(transactionController.model.receiptData?["id"]); } receiptController.setLoading(false); } + _check() async { + receiptController.setLoading(true); + await gatewayController.poll(receiptController.model.receiptData?["id"]); + if(gatewayController.model.status == "SUCCESS"){ + await _retry(); + } + receiptController.setLoading(false); + } + @override void dispose() { // TODO: implement dispose super.dispose(); _controller.dispose(); _tabController.dispose(); - gatewayController.dispose(); receiptController.dispose(); } @@ -376,7 +384,7 @@ class _ReceiptScreenState extends State ), ], ), - if(receiptController.model.receiptData?["integrationStatus"] == "PENDING" && + if(receiptController.model.receiptData?["integrationStatus"] != "SUCCESS" && receiptController.model.receiptData?["paymentStatus"] == "SUCCESS") Column( children: [ @@ -418,6 +426,47 @@ class _ReceiptScreenState extends State ), ], ), + if(receiptController.model.receiptData?["pollingStatus"] != "SUCCESS") + Column( + children: [ + Container( + width: 50, + height: 50, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white, + border: Border.all( + color: Theme.of(context) + .colorScheme + .primary + .withOpacity(0.3), + width: 1, + ), + ), + child: IconButton( + onPressed: () { + _check(); + }, + icon: Icon( + Icons.price_check, + color: + Theme.of( + context, + ).colorScheme.primary, + size: 24, + ), + ), + ), + SizedBox(height: 8), + Text( + "Check status", + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ), ], ), ], @@ -479,6 +528,7 @@ class _ReceiptScreenState extends State padding: const EdgeInsets.all(10.0), child: Column( children: [ + Skeletonizer( enabled: receiptController @@ -553,6 +603,44 @@ class _ReceiptScreenState extends State ], ), const SizedBox(height: 10), + if(receiptController.model.receiptData?["integrationStatus"] != "SUCCESS" && + receiptController.model.receiptData?["paymentStatus"] == "SUCCESS") + Skeletonizer( + enabled: + receiptController + .model + .isLoading, + child: Row( + mainAxisAlignment: + MainAxisAlignment + .spaceBetween, + children: [ + Text( + "Additional action", + style: TextStyle( + fontSize: 16, + fontWeight: + FontWeight.bold, + ), + ), + Expanded( + child: Text("Billing account update failed, please tap on retry to try again", + style: TextStyle( + fontSize: 16, + color: Theme.of( + context, + ).colorScheme.primary, + ), + textAlign: TextAlign.end, + softWrap: true, + + ), + ), + ], + ), + ), + SizedBox(height: 10,), + Skeletonizer( enabled: receiptController diff --git a/lib/screens/transaction_controller.dart b/lib/screens/transaction_controller.dart index 6f8b950..0c1525d 100644 --- a/lib/screens/transaction_controller.dart +++ b/lib/screens/transaction_controller.dart @@ -40,7 +40,7 @@ class TransactionModel { userId: '', providerLabel: '', ); - + String paymentStatus = 'PENDING'; String? errorMessage; }