imrpoving tran flow

This commit is contained in:
2025-10-31 09:47:58 +02:00
parent bcc9d629c1
commit 52c8290625
9 changed files with 327 additions and 82 deletions

View File

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

View File

@@ -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<void> 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<void> 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);
}
}

View File

@@ -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<GatewayScreen> {
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<GatewayScreen> {
},
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));

View File

@@ -45,7 +45,6 @@ class IntegrationController extends ChangeNotifier {
Future<void> 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) {

View File

@@ -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<IntegrationScreen> {
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<TransactionController>(
context,
listen: false,
);
controller.doIntegration();
}
@override
void didChangeDependencies() async {
super.didChangeDependencies();
if(integrating) return;
String status = Provider.of<TransactionController>(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<IntegrationScreen> {
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();
},
)
],
),
),
),
);
},
);
},
}
),
);
}

View File

@@ -11,6 +11,7 @@ class ReceiptModel {
Map<String, dynamic>? receiptData;
bool isLoading = false;
String? errorMessage;
String status = '';
}
class ReceiptController extends ChangeNotifier {
@@ -45,6 +46,36 @@ class ReceiptController extends ChangeNotifier {
notifyListeners();
}
Future<void> 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<void> repeatTransaction(BuildContext context) async {
setLoading(true);
// fetch provider & update tran controller

View File

@@ -36,8 +36,8 @@ class _ReceiptScreenState extends State<ReceiptScreen>
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<ReceiptScreen>
_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<ReceiptScreen>
),
],
),
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<ReceiptScreen>
),
],
),
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<ReceiptScreen>
padding: const EdgeInsets.all(10.0),
child: Column(
children: [
Skeletonizer(
enabled:
receiptController
@@ -553,6 +603,44 @@ class _ReceiptScreenState extends State<ReceiptScreen>
],
),
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

View File

@@ -40,7 +40,7 @@ class TransactionModel {
userId: '',
providerLabel: '',
);
String paymentStatus = 'PENDING';
String? errorMessage;
}