completed velocity integration
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:async'; // Add Timer import
|
||||
import 'dart:async';
|
||||
import 'package:qpay/http/http.dart';
|
||||
import 'package:qpay/models/api_response.dart';
|
||||
import 'package:qpay/screens/transaction_controller.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
@@ -50,10 +51,11 @@ class GatewayController extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> poll(String uid) async {
|
||||
Future<ApiResponse<Map<String, dynamic>>> 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());
|
||||
|
||||
@@ -63,41 +65,65 @@ class GatewayController extends ChangeNotifier {
|
||||
transactionController.model.paymentStatus = response['paymentStatus'];
|
||||
|
||||
finishLoading();
|
||||
if(model.status == 'SUCCESS') {
|
||||
if (model.status == 'SUCCESS') {
|
||||
transactionController.updateReceiptData(response);
|
||||
}
|
||||
notifyListeners();
|
||||
return ApiResponse.success(Map<String, dynamic>.from(response));
|
||||
} catch (e) {
|
||||
finishLoading();
|
||||
model.status = 'FAILED';
|
||||
model.errorMessage = "Network error. Please try again or contact support";
|
||||
logger.e(e);
|
||||
_showErrorSnackBar(
|
||||
notifyListeners();
|
||||
return ApiResponse.failure(
|
||||
"Network error. Please try again or contact support",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> pollTransaction(String uid) async {
|
||||
// only poll for 5 minutes
|
||||
int max = 30;
|
||||
while (!model.isCancelled) {
|
||||
max++;
|
||||
// Check cancellation flag instead of true
|
||||
// Wait 5 seconds before the next poll
|
||||
Future<ApiResponse<Map<String, dynamic>>> pollTransaction(String uid) async {
|
||||
// poll up to 30 times (~5 minutes at 10-second intervals)
|
||||
int maxAttempts = 30;
|
||||
int attempt = 0;
|
||||
|
||||
while (!model.isCancelled && attempt < maxAttempts) {
|
||||
attempt++;
|
||||
|
||||
// Wait 10 seconds before each poll request
|
||||
await Future.delayed(const Duration(seconds: 10));
|
||||
|
||||
// Check again after delay in case it was cancelled during the delay
|
||||
if (model.isCancelled) break;
|
||||
|
||||
await poll(uid);
|
||||
ApiResponse<Map<String, dynamic>> result = await poll(uid);
|
||||
|
||||
if(max > 30) {
|
||||
model.isCancelled = true;
|
||||
// Only stop on success or failure
|
||||
if (result.isSuccess) {
|
||||
String status = model.status;
|
||||
// If status is SUCCESS, we're done
|
||||
if (status == 'SUCCESS') {
|
||||
return result;
|
||||
}
|
||||
// Otherwise (e.g. PENDING), continue polling
|
||||
} else {
|
||||
// Network error — show failure UI and stop
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Timed out after max attempts
|
||||
model.isCancelled = true;
|
||||
model.status = 'FAILED';
|
||||
model.errorMessage = 'We failed to check your transaction status';
|
||||
finishLoading();
|
||||
notifyListeners();
|
||||
return ApiResponse.failure("Polling timed out");
|
||||
}
|
||||
|
||||
// Alternative method using Timer instead of while loop
|
||||
void pollTransactionWithTimer(String uid) {
|
||||
Future<ApiResponse<Map<String, dynamic>>> pollTransactionWithTimer(
|
||||
String uid) async {
|
||||
_pollTimer = Timer.periodic(const Duration(seconds: 5), (timer) async {
|
||||
if (model.isCancelled) {
|
||||
timer.cancel();
|
||||
@@ -121,6 +147,10 @@ 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");
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -129,4 +159,4 @@ class GatewayController extends ChangeNotifier {
|
||||
_pollTimer?.cancel(); // Cancel timer if it exists
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,8 @@
|
||||
import 'dart:js_interop';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:qpay/interop.dart';
|
||||
import 'package:qpay/models/responsive_policy.dart';
|
||||
import 'package:qpay/screens/gateway/gateway_controller.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
import 'package:web/web.dart' as web;
|
||||
|
||||
class GatewayRedirectScreen extends StatefulWidget {
|
||||
const GatewayRedirectScreen({super.key});
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import 'dart:js_interop';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:qpay/interop.dart';
|
||||
import 'package:qpay/models/responsive_policy.dart';
|
||||
import 'package:qpay/screens/gateway/gateway_controller.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
import 'package:web/web.dart' as web;
|
||||
|
||||
class GatewayWebScreen extends StatefulWidget {
|
||||
@@ -17,12 +13,11 @@ class GatewayWebScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _GatewayWebScreenState extends State<GatewayWebScreen> {
|
||||
late WebViewController _webViewController;
|
||||
late GatewayController controller;
|
||||
late String url;
|
||||
String? sessionID;
|
||||
|
||||
bool integrationStarted = false;
|
||||
bool redirecting = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -32,115 +27,40 @@ class _GatewayWebScreenState extends State<GatewayWebScreen> {
|
||||
url = controller.transactionController.model.receiptData?['targetUrl'];
|
||||
}
|
||||
|
||||
// A function to set up the ResizeObserver
|
||||
void setupAttachmentObserver(web.Element element) {
|
||||
final observer = web.ResizeObserver(
|
||||
(JSArray entries, JSAny observer) {
|
||||
// The element has been laid out (and thus attached to the DOM)
|
||||
onElementAttached(element);
|
||||
void _redirectToGateway() {
|
||||
if (redirecting) return;
|
||||
|
||||
// Disconnect the observer once the action is done
|
||||
(observer as web.ResizeObserver).disconnect();
|
||||
}.toJS,
|
||||
setState(() {
|
||||
redirecting = true;
|
||||
});
|
||||
|
||||
bool simulate = bool.parse(
|
||||
dotenv.env['SIMULATE_PAYMENT_SUCCESS'] ?? 'false',
|
||||
);
|
||||
|
||||
observer.observe(element);
|
||||
}
|
||||
|
||||
// Called after `element` is attached to the DOM.
|
||||
void onElementAttached(web.Element element) {
|
||||
// Your code to execute after the element is in the DOM goes here.
|
||||
print('Element with ID ${element.id} is attached to the DOM.');
|
||||
// You can now safely query the DOM or call JavaScript functions that rely
|
||||
// on the element being present.
|
||||
|
||||
Future.delayed(const Duration(milliseconds: 50), () {
|
||||
initIframeView();
|
||||
});
|
||||
}
|
||||
|
||||
void initIframeView() async {
|
||||
await ensureCheckoutScriptLoaded();
|
||||
|
||||
final uri = Uri.parse(url);
|
||||
// 2. The pathSegments property returns a list of the parts of the path.
|
||||
// For "checkout/pay/SESSION...", the segments are ["checkout", "pay", "SESSION..."].
|
||||
final pathSegments = uri.pathSegments;
|
||||
// 3. Check if there are any segments and return the last one,
|
||||
// which is the session ID in this URL structure.
|
||||
if (pathSegments.isNotEmpty) {
|
||||
sessionID = pathSegments.last;
|
||||
|
||||
configure(sessionID!);
|
||||
}
|
||||
showEmbeddedPage();
|
||||
|
||||
Future.delayed(const Duration(seconds: 10), () async {
|
||||
web.HTMLIFrameElement element = fetchIframeFromDom(
|
||||
"hc-comms-layer-iframe",
|
||||
)!;
|
||||
print(element);
|
||||
element.onload = (JSAny event) {
|
||||
// This is called every time a new page loads in the iframe
|
||||
print(event);
|
||||
|
||||
try {
|
||||
final newLocation = element.contentWindow?.location.href;
|
||||
print('Iframe navigated to: $newLocation');
|
||||
// Perform actions based on the new URL
|
||||
|
||||
_navigateWhenDone(newLocation!);
|
||||
} catch (e) {
|
||||
// Handle potential (though unlikely for same-origin) security errors
|
||||
print('Could not access iframe location: $e');
|
||||
}
|
||||
}.toJS;
|
||||
|
||||
bool simulate = bool.parse(dotenv.env['SIMULATE_PAYMENT_SUCCESS']!);
|
||||
if (simulate) {
|
||||
await Future.delayed(Duration(milliseconds: 100));
|
||||
_navigateWhenDone("/checkout/receipt/");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
web.HTMLIFrameElement? fetchIframeFromDom(String id) {
|
||||
final element = web.window.document.querySelector('#$id');
|
||||
if (element is web.HTMLIFrameElement) {
|
||||
print('Found iframe in DOM: ${element.id}');
|
||||
print(element.toString());
|
||||
return element;
|
||||
}
|
||||
|
||||
print('Could not find iframe with id: $id in the DOM.');
|
||||
return null;
|
||||
}
|
||||
|
||||
void _navigateWhenDone(String url) {
|
||||
if (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: 10),
|
||||
),
|
||||
);
|
||||
|
||||
setState(() {
|
||||
integrationStarted = true;
|
||||
});
|
||||
|
||||
if (simulate) {
|
||||
// In simulation mode, just go to polling
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.go('/poll');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Save the transaction ID to localStorage so index.html can redirect
|
||||
// the user back to /poll/:id when the gateway returns to this app.
|
||||
final transactionId =
|
||||
controller.transactionController.model.confirmationData['id'];
|
||||
if (transactionId != null) {
|
||||
web.window.localStorage.setItem(
|
||||
'pendingTransactionId',
|
||||
transactionId.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
// Navigate the current browser tab to the payment gateway URL.
|
||||
// After payment, the gateway will redirect back to this app's host.
|
||||
// index.html then reads the saved transaction ID and navigates to
|
||||
// /poll/:id so the app can immediately start polling for the result.
|
||||
web.window.location.href = url;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -161,91 +81,98 @@ class _GatewayWebScreenState extends State<GatewayWebScreen> {
|
||||
},
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
)
|
||||
: SizedBox.shrink(),
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
body: ListenableBuilder(
|
||||
listenable: controller,
|
||||
builder: (context, child) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return SingleChildScrollView(
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: ResponsivePolicy.md.toDouble(),
|
||||
return Center(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return SizedBox(
|
||||
width: constraints.maxWidth > ResponsivePolicy.md
|
||||
? ResponsivePolicy.md.toDouble()
|
||||
: constraints.maxWidth.toDouble(),
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Colors.black87.withAlpha(20),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: 10,
|
||||
horizontal: 20,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Once done on this page, check transaction status',
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
context.push('/poll');
|
||||
},
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'Check status',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
|
||||
// Info icon
|
||||
Icon(
|
||||
Icons.lock_outline,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
SizedBox(height: 10),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
Text(
|
||||
'You are about to leave this app to complete your payment on a secure payment gateway.',
|
||||
style: const TextStyle(fontSize: 18),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Text(
|
||||
'After completing payment, you will be automatically redirected back to this app to see your transaction status.',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
|
||||
Image.asset(
|
||||
'assets/velocity-animation.gif',
|
||||
width: 150,
|
||||
),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: constraints.maxHeight + 300,
|
||||
child: HtmlElementView.fromTagName(
|
||||
tagName: 'div',
|
||||
onElementCreated: (element) {
|
||||
final divElement =
|
||||
element as web.HTMLDivElement;
|
||||
divElement.id = "embed-target";
|
||||
setupAttachmentObserver(element);
|
||||
},
|
||||
child: ElevatedButton(
|
||||
onPressed: redirecting
|
||||
? null
|
||||
: _redirectToGateway,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 16,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
redirecting
|
||||
? 'Redirecting...'
|
||||
: 'Proceed to Payment Gateway',
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.go('/poll');
|
||||
},
|
||||
child: const Text('Check transaction status'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user