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

3
.vscode/launch.json vendored
View File

@@ -10,7 +10,8 @@
"type": "dart",
"deviceId": "chrome",
"args": [
"--dart-define=APP_ENV=test"
"--dart-define=APP_ENV=test",
"--web-port=9005"
]
},

View File

@@ -1,6 +1,7 @@
# BASE_URL=http://192.168.100.85:6950/api
# BASE_URL=https://peakapi.qantra.co.zw/api
BASE_URL=http://localhost:6950/api
CLIENTID=77433712483-ng7pntvcpf6tnjccriuqm8dbna8vvp3b.apps.googleusercontent.com
SIMULATE_PAYMENT_SUCCESS=true
SIMULATE_PAYMENT_SUCCESS=false
GATEWAY_SCRIPT_URL=https://test-gateway.mastercard.com/static/checkout/checkout.min.js

View File

@@ -1,10 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter_web_plugins/flutter_web_plugins.dart';
import 'package:provider/provider.dart';
import 'package:go_router/go_router.dart';
import 'package:qpay/navbar.dart';
import 'package:qpay/screens/confirm/confirm_screen.dart';
import 'package:qpay/screens/gateway/gateway_redirect.dart';
import 'package:qpay/screens/gateway/gateway_screen.dart';
import 'package:qpay/screens/gateway/gateway_web_screen.dart';
import 'package:qpay/screens/home/home_screen.dart';
import 'package:qpay/screens/history/history_screen.dart';
import 'package:qpay/screens/integration/integration_screen.dart';
import 'package:qpay/screens/onboarding/complete_screen.dart';
import 'package:qpay/screens/onboarding/landing/landing_screen.dart';
@@ -16,15 +20,12 @@ import 'package:qpay/screens/onboarding/register/register_screen.dart';
import 'package:qpay/screens/onboarding/verify/verify_screen.dart';
import 'package:qpay/screens/pay/pay_screen.dart';
import 'package:qpay/screens/poll/poll_screen.dart';
import 'package:qpay/screens/splash_screen.dart';
import 'package:qpay/screens/receipt/receipt_screen.dart';
import 'package:qpay/screens/recipient/recipients_screen.dart';
import 'package:qpay/screens/transaction_controller.dart';
import 'screens/gateway/gateway_web_screen.dart';
import 'screens/home/home_screen.dart';
import 'screens/history/history_screen.dart';
import 'screens/profile_screen.dart';
import 'screens/splash_screen.dart';
import 'theme/app_theme.dart';
import 'package:qpay/screens/profile_screen.dart';
import 'package:qpay/theme/app_theme.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';
@@ -43,8 +44,39 @@ String resolveEnvFile(String env) {
}
}
/// Tracks whether the splash animation is complete so that
/// GoRouter's redirect can show the splash first, then release
/// navigation to the originally intended URL.
class SplashState extends ChangeNotifier {
bool _complete = false;
/// The route the user originally intended to visit before being
/// redirected to /splash. Set by the redirect callback on first
/// invocation.
String? _intendedRoute;
bool get complete => _complete;
String? get intendedRoute => _intendedRoute;
/// Called by the redirect to store the intended destination and
/// return the splash route.
String? guard(String attemptedRoute) {
if (_complete) return null;
// Avoid saving the splash route itself.
if (attemptedRoute == '/splash') return null;
_intendedRoute = attemptedRoute;
return '/splash';
}
void finish() {
_complete = true;
notifyListeners();
}
}
final SplashState splashState = SplashState();
void main() async {
WidgetsFlutterBinding.ensureInitialized();
usePathUrlStrategy();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
await dotenv.load(fileName: resolveEnvFile(appEnv));
@@ -71,37 +103,54 @@ class MyApp extends StatefulWidget {
}
class _MyAppState extends State<MyApp> {
bool _showSplash = true;
void _onSplashComplete() {
setState(() {
_showSplash = false;
});
}
@override
Widget build(BuildContext context) {
if (_showSplash) {
return MaterialApp(
title: 'Velocity',
theme: AppTheme.lightTheme,
themeMode: ThemeMode.light,
home: SplashScreen(onSplashComplete: _onSplashComplete),
debugShowCheckedModeBanner: false,
);
}
return MaterialApp.router(
title: 'Velocity',
theme: AppTheme.lightTheme,
themeMode: ThemeMode.light, // Force light mode regardless of OS setting
themeMode: ThemeMode.light,
debugShowCheckedModeBanner: false,
routerConfig: GoRouter(
initialLocation: '/',
refreshListenable: splashState,
errorBuilder: (context, state) => Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Page not found', style: TextStyle(fontSize: 20)),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => context.go('/'),
child: const Text('Go Home'),
),
],
),
),
),
redirect: (context, state) {
// Before splash is complete: redirect everything to /splash,
// but save the original intended destination.
final guardResult = splashState.guard(state.uri.path);
if (guardResult != null) return guardResult;
// Once splash is complete: if we're still on /splash, navigate
// to the intended destination (or home as fallback).
if (splashState.complete && state.uri.path == '/splash') {
return splashState.intendedRoute ?? '/';
}
return null;
},
routes: [
// Splash route sits outside the ShellRoute so it renders
// without the bottom nav bar or side rail.
GoRoute(
path: '/splash',
builder: (context, state) => const SplashScreen(),
),
ShellRoute(
builder: (context, state, child) {
// If the current location is an onboarding route, don't show the navbar
final location = GoRouterState.of(context).uri.toString();
if (location.startsWith('/onboarding/')) {
return child;
@@ -137,6 +186,12 @@ class _MyAppState extends State<MyApp> {
path: '/gateway-redirect',
builder: (context, state) => const GatewayRedirectScreen(),
),
GoRoute(
path: '/poll/:id',
builder: (context, state) => PollScreen(
transactionId: state.pathParameters['id'],
),
),
GoRoute(
path: '/poll',
builder: (context, state) => const PollScreen(),

View File

@@ -0,0 +1,21 @@
class ApiResponse<T> {
final T? data;
final String? error;
final int? statusCode;
const ApiResponse({
this.data,
this.error,
this.statusCode,
});
bool get isSuccess => error == null && data != null;
factory ApiResponse.success(T data, {int? statusCode}) {
return ApiResponse(data: data, statusCode: statusCode);
}
factory ApiResponse.failure(String error, {int? statusCode}) {
return ApiResponse(error: error, statusCode: statusCode);
}
}

View File

@@ -1,8 +1,8 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:qpay/models/api_response.dart';
import 'package:qpay/screens/transaction_controller.dart';
import '../../http/http.dart';
@@ -53,7 +53,7 @@ class ConfirmController extends ChangeNotifier {
});
}
Future<Map<String, dynamic>?> doTransaction() async {
Future<ApiResponse<Map<String, dynamic>>> doTransaction() async {
try {
model.isLoading = true;
notifyListeners();
@@ -77,33 +77,30 @@ class ConfirmController extends ChangeNotifier {
dynamic response = workflowResponse['body'];
if (response['status'] != 'FAILED') {
model.status = response['status'];
transactionController.updateReceiptData(response);
model.isLoading = false;
notifyListeners();
return response;
if (response['status'] != 'FAILED') {
model.status = response['status'];
transactionController.updateReceiptData(response);
return ApiResponse.success(Map<String, dynamic>.from(response));
} else {
model.status = response['status'];
model.errorMessage = response['errorMessage'];
_showErrorSnackBar(response['errorMessage']);
return ApiResponse.failure(response['errorMessage'] ?? "Transaction failed");
}
} catch (e) {
logger.e(e);
model.errorMessage = e.toString();
_showErrorSnackBar(
model.isLoading = false;
notifyListeners();
return ApiResponse.failure(
"Problem completing the transaction, please try again.",
);
}
model.isLoading = false;
notifyListeners();
return null;
}
Future<void> pollTransaction(String uid) async {
Future<ApiResponse<Map<String, dynamic>>> pollTransaction(String uid) async {
while (!model.isCancelled) {
// Check again after delay in case it was cancelled during the delay
if (model.isCancelled) break;
@@ -117,7 +114,7 @@ class ConfirmController extends ChangeNotifier {
transactionController.updateReceiptData(response);
model.isCancelled = true;
notifyListeners();
break;
return ApiResponse.success(Map<String, dynamic>.from(response));
}
} catch (e) {
logger.e(e);
@@ -130,6 +127,7 @@ class ConfirmController extends ChangeNotifier {
// Wait 5 seconds before the next poll
await Future.delayed(const Duration(seconds: 5));
}
return ApiResponse.failure("Polling cancelled");
}
@override

View File

@@ -40,10 +40,8 @@ class _ConfirmScreenState extends State<ConfirmScreen>
),
);
_slideAnimation = Tween<Offset>(
begin: const Offset(0, 0.1),
end: Offset.zero,
).animate(
_slideAnimation =
Tween<Offset>(begin: const Offset(0, 0.1), end: Offset.zero).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.0, 0.5, curve: Curves.easeOut),
@@ -65,17 +63,34 @@ class _ConfirmScreenState extends State<ConfirmScreen>
if (controller.model.isLoading) return;
final response = await controller.doTransaction();
if (response!['status'] == 'FAILED') {
if (!response.isSuccess) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
controller.model.errorMessage ??
response.error ??
"Transaction failed",
),
behavior: SnackBarBehavior.floating,
width: 600,
backgroundColor: Colors.deepOrange,
),
);
}
return;
}
if (!mounted) return;
if (controller.transactionController.model.selectedPaymentProcessor.authType == "WEB") {
if (controller
.transactionController
.model
.selectedPaymentProcessor
.authType ==
"WEB") {
if (kIsWeb) {
// the one we use here depends on when mpgs embedded bug will be fixed
context.push('/gateway-web');
// context.push('/gateway-redirect');
} else {
context.push('/gateway');
}
@@ -89,13 +104,11 @@ class _ConfirmScreenState extends State<ConfirmScreen>
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
leading:
context.canPop()
leading: context.canPop()
? IconButton(
onPressed: () {
context.pop();
@@ -124,245 +137,123 @@ class _ConfirmScreenState extends State<ConfirmScreen>
child: LayoutBuilder(
builder: (context, constraints) {
return SizedBox(
width: double.parse(constraints.maxWidth > ResponsivePolicy.md ?
ResponsivePolicy.md.toString() :
constraints.maxWidth.toString()),
width: double.parse(
constraints.maxWidth > ResponsivePolicy.md
? ResponsivePolicy.md.toString()
: constraints.maxWidth.toString(),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: EdgeInsets.all(15),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(
context,
).colorScheme.primary.withOpacity(0.3),
),
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
// Provider Details Card
_buildInfoCard(
icon: Icons.business_outlined,
title: "PROVIDER DETAILS",
children: [
Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
width: 1.0,
color:
Theme.of(context).colorScheme.primary,
),
),
),
padding: EdgeInsets.only(
bottom: 4.0,
), // Adjust spacing
child: Text(
"PROVIDER DETAILS",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
...controller
.transactionController
.model
.confirmationData["additionalData"]
.map<Widget>((data) {
return Column(
children: [
const SizedBox(height: 10),
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text(
data["name"] ?? "",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
Text(
data["value"] ?? "",
style: TextStyle(fontSize: 16),
),
],
),
],
return _buildDetailRow(
icon: Icons.info_outline,
label: data["name"] ?? "",
value: data["value"] ?? "",
);
})
.toList(),
const SizedBox(height: 30),
Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
width: 1.0,
color:
Theme.of(context).colorScheme.primary,
),
),
),
padding: EdgeInsets.only(
bottom: 4.0,
), // Adjust spacing
child: Text(
"TRANSACTIONS DETAILS",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 20),
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
// Transaction Details Card
_buildInfoCard(
icon: Icons.receipt_long_outlined,
title: "TRANSACTION DETAILS",
children: [
Text(
"Amount",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
Text(
controller
_buildDetailRow(
icon: Icons.monetization_on_outlined,
label: "Amount",
value: controller
.transactionController
.model
.confirmationData["amount"]
.toString(),
style: TextStyle(fontSize: 16),
),
],
),
const SizedBox(height: 10),
if (controller
.transactionController
.model
.confirmationData["charge"] !=
0)
Column(
children: [
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text(
"Our Charge",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
Text(
controller
_buildDetailRow(
icon: Icons.percent_outlined,
label: "Our Charge",
value: controller
.transactionController
.model
.confirmationData["charge"]
.toString(),
style: TextStyle(fontSize: 16),
),
],
),
const SizedBox(height: 10),
],
),
if (controller
.transactionController
.model
.confirmationData["gatewayCharge"] !=
0)
Column(
children: [
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text(
"Gateway Charge",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
Text(
controller
_buildDetailRow(
icon: Icons.account_balance_outlined,
label: "Gateway Charge",
value: controller
.transactionController
.model
.confirmationData["gatewayCharge"]
.toString(),
style: TextStyle(fontSize: 16),
),
],
),
const SizedBox(height: 10),
],
),
if (controller
.transactionController
.model
.confirmationData["tax"] !=
0)
Column(
children: [
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text(
"Tax",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
Text(
controller
_buildDetailRow(
icon: Icons.receipt_outlined,
label: "Tax",
value: controller
.transactionController
.model
.confirmationData["tax"]
.toString(),
style: TextStyle(fontSize: 16),
),
],
const Divider(
height: 1,
color: Colors.grey,
),
const SizedBox(height: 10),
],
),
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text(
"Total Amount",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
Text(
controller
const SizedBox(height: 12),
_buildDetailRow(
icon: Icons.summarize_outlined,
label: "Total Amount",
value: controller
.transactionController
.model
.confirmationData["totalAmount"]
.toString(),
style: TextStyle(fontSize: 16),
valueStyle: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: Theme.of(
context,
).colorScheme.primary,
),
),
],
),
const SizedBox(height: 5),
Text(
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(
"NB: Your payment provider may charge additional fees.",
style: TextStyle(
fontSize: 11,
color: Colors.red,
color: Colors.red.shade600,
),
),
],
),
),
const SizedBox(height: 20),
_buildPaymentProcessorButton(),
@@ -378,7 +269,7 @@ class _ConfirmScreenState extends State<ConfirmScreen>
],
),
);
}
},
),
),
),
@@ -391,6 +282,91 @@ class _ConfirmScreenState extends State<ConfirmScreen>
);
}
Widget _buildInfoCard({
required IconData icon,
required String title,
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(
icon,
size: 18,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(
title,
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,
TextStyle? valueStyle,
}) {
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:
valueStyle ??
TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
],
),
);
}
Widget _buildPaymentProcessorButton() {
return Skeletonizer(
enabled: controller.model.isLoading,

View File

@@ -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());
@@ -67,37 +69,61 @@ class GatewayController extends ChangeNotifier {
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) {
// 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

View File

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

View File

@@ -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);
// Disconnect the observer once the action is done
(observer as web.ResizeObserver).disconnect();
}.toJS,
);
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),
),
);
void _redirectToGateway() {
if (redirecting) return;
setState(() {
integrationStarted = true;
redirecting = true;
});
bool simulate = bool.parse(
dotenv.env['SIMULATE_PAYMENT_SUCCESS'] ?? 'false',
);
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,88 +81,95 @@ 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(
return Center(
child: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
child: Center(
child: SizedBox(
width: ResponsivePolicy.md.toDouble(),
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),
const SizedBox(height: 40),
// Info icon
Icon(
Icons.lock_outline,
size: 64,
color: Theme.of(context).colorScheme.primary,
),
borderRadius: BorderRadius.circular(12),
),
padding: EdgeInsets.symmetric(
vertical: 10,
horizontal: 20,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const SizedBox(height: 24),
Text(
'Once done on this page, check transaction status',
style: TextStyle(fontSize: 16),
'You are about to leave this app to complete your payment on a secure payment gateway.',
style: const TextStyle(fontSize: 18),
textAlign: TextAlign.center,
),
InkWell(
onTap: () {
context.push('/poll');
},
borderRadius: BorderRadius.circular(12),
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
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],
),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
textAlign: TextAlign.center,
),
child: Text(
'Check status',
style: TextStyle(fontSize: 12),
const SizedBox(height: 40),
Image.asset(
'assets/velocity-animation.gif',
width: 150,
),
),
),
],
),
),
SizedBox(height: 10),
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'),
),
],
),
),
),
),
);
},
),
);
},
),

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:qpay/http/http.dart';
import 'package:qpay/models/api_response.dart';
import 'package:qpay/models/pageable_model.dart';
class HistoryModel {
@@ -17,17 +18,7 @@ class HistoryController extends ChangeNotifier {
HistoryController(this.context);
void _showErrorSnackBar(String? message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message.toString()),
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.deepOrange,
),
);
}
Future<void> getTransactions(Map<String, String> params) async {
Future<ApiResponse<List<Map<String, dynamic>>>> getTransactions(Map<String, String> params) async {
model.isLoading = true;
if (params['page'] == '0') {
model.transactions = getFakeTransactions();
@@ -51,16 +42,19 @@ class HistoryController extends ChangeNotifier {
model.pageableModel!.content
.map((e) => e as Map<String, dynamic>)
.toList();
} catch (e) {
logger.e(e);
_showErrorSnackBar(
"Problem fetching transactions, are you connected to the internet?",
);
model.errorMessage = e.toString();
model.transactions = [];
} finally {
model.isLoading = false;
notifyListeners();
return ApiResponse.success(List<Map<String, dynamic>>.from(model.transactions));
} catch (e) {
logger.e(e);
model.errorMessage = e.toString();
model.transactions = [];
model.isLoading = false;
notifyListeners();
return ApiResponse.failure(
"Problem fetching transactions, are you connected to the internet?",
);
}
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:qpay/http/http.dart';
import 'package:qpay/models/api_response.dart';
import 'package:qpay/models/pageable_model.dart';
part 'home_controller.freezed.dart';
@@ -93,24 +94,13 @@ class HomeController extends ChangeNotifier {
super.dispose();
}
void _showErrorSnackBar(String? message) {
WidgetsBinding.instance.addPostFrameCallback((_) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message.toString()),
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.deepOrange,
),
);
});
}
Future<void> getAccounts() async {
Future<ApiResponse<List<Account>>> getAccounts() async {
model.accounts = getFakeAccounts();
if (!_disposed) notifyListeners();
return ApiResponse.success(model.accounts);
}
Future<void> getProviders() async {
Future<ApiResponse<List<BillProvider>>> getProviders() async {
try {
model.filterableProviders = getFakeProviders();
model.transactions = getFakeTransactions();
@@ -125,19 +115,22 @@ class HomeController extends ChangeNotifier {
if (model.providers.isNotEmpty) {
updateSelectedProvider(model.providers[0]);
}
} catch (e, s) {
logger.e(e);
logger.e(s);
_showErrorSnackBar(
"Problem fetching providers, are you connected to the internet?",
);
model.errorMessage = e.toString();
model.providers = [];
model.filterableProviders = [];
}
model.isLoading = false;
if (!_disposed) notifyListeners();
return ApiResponse.success(model.providers);
} catch (e, s) {
logger.e(e);
logger.e(s);
model.errorMessage = e.toString();
model.providers = [];
model.filterableProviders = [];
model.isLoading = false;
if (!_disposed) notifyListeners();
return ApiResponse.failure(
"Problem fetching providers, are you connected to the internet?",
);
}
}
void updateSelectedProvider(BillProvider provider) {
@@ -148,9 +141,8 @@ class HomeController extends ChangeNotifier {
if (!_disposed) notifyListeners();
}
Future<void> getTransactions(String userId) async {
Future<ApiResponse<List<Map<String, dynamic>>>> getTransactions(String userId) async {
model.isLoading = true;
// model.transactions = getFakeTransactions();
if (!_disposed) notifyListeners();
try {
@@ -163,20 +155,23 @@ class HomeController extends ChangeNotifier {
model.transactions = pageableModel.content
.map((e) => e as Map<String, dynamic>)
.toList();
} catch (e) {
logger.e(e);
_showErrorSnackBar(
"Problem fetching transactions, are you connected to the internet?",
);
model.errorMessage = e.toString();
model.transactions = [];
} finally {
model.isLoading = false;
if (!_disposed) notifyListeners();
return ApiResponse.success(model.transactions);
} catch (e) {
logger.e(e);
model.errorMessage = e.toString();
model.transactions = [];
model.isLoading = false;
if (!_disposed) notifyListeners();
return ApiResponse.failure(
"Problem fetching transactions, are you connected to the internet?",
);
}
}
Future<void> getCategories() async {
Future<ApiResponse<List<Category>>> getCategories() async {
// activate skeletonizer
model.categories = getFakeCategoryData();
model.isLoading = true;
@@ -190,20 +185,23 @@ class HomeController extends ChangeNotifier {
if (model.categories.isNotEmpty) {
updateSelectedCategory(model.categories[0]);
}
} on Exception catch (e) {
logger.e(e);
_showErrorSnackBar(
"Problem fetching categories, are you connected to the internet?",
);
model.errorMessage = e.toString();
model.categories = [];
} finally {
model.isLoading = false;
if (!_disposed) notifyListeners();
return ApiResponse.success(model.categories);
} on Exception catch (e) {
logger.e(e);
model.errorMessage = e.toString();
model.categories = [];
model.isLoading = false;
if (!_disposed) notifyListeners();
return ApiResponse.failure(
"Problem fetching categories, are you connected to the internet?",
);
}
}
Future<void> updateSelectedCategory(Category? category) async {
void updateSelectedCategory(Category? category) {
if (category == null) {
model.filterableProviders = model.providers;
if (!_disposed) notifyListeners();

View File

@@ -1,8 +1,8 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:qpay/http/http.dart';
import 'package:qpay/models/api_response.dart';
import 'package:qpay/screens/gateway/gateway_controller.dart';
import 'package:qpay/screens/transaction_controller.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -34,19 +34,7 @@ class IntegrationController extends ChangeNotifier {
gatewayController = GatewayController(context);
}
void _showErrorSnackBar(String? message) {
WidgetsBinding.instance.addPostFrameCallback((_) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message.toString()),
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.deepOrange,
),
);
});
}
Future<void> doIntegration() async {
Future<ApiResponse<Map<String, dynamic>>> doIntegration() async {
try {
model.isLoading = true;
notifyListeners();
@@ -75,10 +63,13 @@ class IntegrationController extends ChangeNotifier {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_disposed && context.mounted) context.go('/receipt');
});
return ApiResponse.success(Map<String, dynamic>.from(response));
} catch (e) {
model.isLoading = false;
model.errorMessage = e.toString();
if (!_disposed) notifyListeners();
return ApiResponse.failure(e.toString());
}
}

View File

@@ -1,9 +1,8 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/transaction_controller.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:qpay/widgets/step_loading_widget.dart';
import 'integration_controller.dart';
@@ -18,8 +17,6 @@ class _IntegrationScreenState extends State<IntegrationScreen> {
late IntegrationController controller;
late TransactionController transactionController;
bool integrating = false;
@override
void initState() {
super.initState();
@@ -32,19 +29,6 @@ class _IntegrationScreenState extends State<IntegrationScreen> {
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();
@@ -56,6 +40,10 @@ class _IntegrationScreenState extends State<IntegrationScreen> {
return Scaffold(
appBar: AppBar(
title: const Text('Completing your transaction'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/'),
),
),
body: ListenableBuilder(
listenable: controller,
@@ -66,60 +54,18 @@ class _IntegrationScreenState extends State<IntegrationScreen> {
});
}
return Container(
padding: EdgeInsets.all(50),
child: Center(
child: LayoutBuilder(
builder: (context, constraints) {
return SizedBox(
width: double.parse(constraints.maxWidth > ResponsivePolicy.md ?
ResponsivePolicy.md.toString() :
constraints.maxWidth.toString()),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(height: 75),
Column(
children: [
Text(
return StepLoadingWidget(
currentStep: 2,
title: 'Updating Account',
subtitle:
'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,
),
SizedBox(height: 50,),
Image.asset('assets/velocity-animation.gif', width: 150),
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: () {
'updating your billing account. This won\'t take long.',
status: controller.model.status,
isLoading: controller.model.isLoading,
errorMessage: 'We failed to update your billing account',
onRetry: () {
controller.doIntegration();
},
),
),
],
),
],
),
],
),
);
}
),
),
);
},
),

View File

@@ -1,6 +1,7 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:qpay/http/http.dart';
import 'package:qpay/models/api_response.dart';
import 'package:shared_preferences/shared_preferences.dart';
class LoginScreenModel {
@@ -20,19 +21,7 @@ class LoginController extends ChangeNotifier {
LoginController(this.context);
void _showErrorSnackBar(String? message) {
WidgetsBinding.instance.addPostFrameCallback((_) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message.toString()),
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.deepOrange,
),
);
});
}
Future<LoginScreenModel> login(String username, String password) async {
Future<ApiResponse<LoginScreenModel>> login(String username, String password) async {
try {
model.isLoading = true;
notifyListeners();
@@ -45,6 +34,9 @@ class LoginController extends ChangeNotifier {
});
logger.i(response.toString());
model.isLoading = false;
notifyListeners();
if (response.containsKey('token')) {
model.status = 'SUCCESS';
@@ -57,26 +49,25 @@ class LoginController extends ChangeNotifier {
prefs.setString('userId', response['uuid']);
prefs.setString('initials', response['firstName'].substring(0, 1) + response['lastName'].substring(0, 1));
return model;
return ApiResponse.success(model);
} else {
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(e.response?.data['errors'][0] ?? "Unknown error");
model.isLoading = false;
notifyListeners();
return ApiResponse.failure(e.response?.data['errors'][0] ?? "Unknown error");
} 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();
return model;
}
}

View File

@@ -1,9 +1,8 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:qpay/abstracts/AbstractController.dart';
import 'package:qpay/http/http.dart';
import 'package:qpay/models/api_response.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../onboarding_controller.dart';
@@ -37,7 +36,7 @@ class PhoneController extends AbstractController {
}
Future<void> register() async {
Future<ApiResponse<Map<String, dynamic>>> register() async {
prefs = await SharedPreferences.getInstance();
Map user = {
@@ -62,19 +61,26 @@ class PhoneController extends AbstractController {
if(response['state'] == 'failed') {
model.errorMessage = response['message'];
showErrorSnackBar(model.errorMessage);
model.isLoading = false;
notifyListeners();
return ApiResponse.failure(response['message'] ?? "Registration failed");
}
Map body = response['body'];
body['workflowId'] = response['workflowId'];
onboardingController.updateModel(body);
model.isLoading = false;
notifyListeners();
return ApiResponse.success(Map<String, dynamic>.from(body));
} catch (e) {
logger.e(e);
showErrorSnackBar(
"Problem during registration, please try again or contact support?",
);
}
model.isLoading = false;
notifyListeners();
return ApiResponse.failure("Problem during registration, please try again or contact support?");
}
}
}

View File

@@ -86,7 +86,8 @@ class _PhoneScreenState extends State<PhoneScreen> {
child: Center(
child: LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.maxWidth > ResponsivePolicy.md
final maxWidth =
constraints.maxWidth > ResponsivePolicy.md
? ResponsivePolicy.md.toDouble()
: constraints.maxWidth;
return SizedBox(
@@ -101,10 +102,7 @@ class _PhoneScreenState extends State<PhoneScreen> {
style: TextStyle(
fontSize: 30,
fontWeight: FontWeight.bold,
color: Theme
.of(context)
.colorScheme
.primary,
color: Theme.of(context).colorScheme.primary,
),
textAlign: TextAlign.center,
),
@@ -119,7 +117,9 @@ class _PhoneScreenState extends State<PhoneScreen> {
_buildPhoneField(),
SizedBox(height: 30),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 25),
padding: const EdgeInsets.symmetric(
horizontal: 25,
),
child: Skeletonizer(
enabled: phoneController.model.isLoading,
child: Row(
@@ -127,18 +127,25 @@ class _PhoneScreenState extends State<PhoneScreen> {
Expanded(
child: ElevatedButton(
onPressed: () async {
if (_formKey.currentState!.validate()) {
if (_formKey.currentState!
.validate()) {
String fullPhoneNumber =
selectedCountryCode + _phoneController.text;
_phoneController.text;
phoneController.updatePhone(fullPhoneNumber);
phoneController.updatePhone(
fullPhoneNumber,
);
await phoneController.register();
if(phoneController.model.status != 'failed') {
if (phoneController.model.status !=
'failed') {
context.go('/onboarding/verify');
}
}
},
child: Text('Send Code', style: TextStyle(fontSize: 16)),
child: Text(
'Send Code',
style: TextStyle(fontSize: 16),
),
),
),
SizedBox(width: 16),
@@ -162,19 +169,22 @@ class _PhoneScreenState extends State<PhoneScreen> {
onPressed: () {
context.go('/');
},
child: Text('Continue as guest?', style: TextStyle(fontSize: 14)),
child: Text(
'Continue as guest?',
style: TextStyle(fontSize: 14),
),
),
],
),
);
}
},
),
),
),
),
),
);
}
},
);
}
@@ -201,8 +211,7 @@ class _PhoneScreenState extends State<PhoneScreen> {
isExpanded: true,
icon: const Icon(Icons.arrow_drop_down),
padding: const EdgeInsets.symmetric(horizontal: 8),
items:
countryCodes.map((country) {
items: countryCodes.map((country) {
return DropdownMenuItem<String>(
value: country['code'],
child: Row(
@@ -272,5 +281,4 @@ class _PhoneScreenState extends State<PhoneScreen> {
],
);
}
}

View File

@@ -2,6 +2,7 @@ import 'package:flutter/cupertino.dart';
import 'package:provider/provider.dart';
import 'package:qpay/abstracts/AbstractController.dart';
import 'package:qpay/http/http.dart';
import 'package:qpay/models/api_response.dart';
import 'package:qpay/screens/onboarding/onboarding_controller.dart';
class VerifyModel {
@@ -30,7 +31,7 @@ class VerifyController extends AbstractController {
notifyListeners();
}
Future<void> verifyOtp () async {
Future<ApiResponse<Map<String, dynamic>>> verifyOtp () async {
Map user = {
"username": onboardingController.model.googleUser?.email,
"otpCode": model.code,
@@ -47,22 +48,25 @@ class VerifyController extends AbstractController {
user
);
model.status = response['state'];
model.isLoading = false;
notifyListeners();
if(response['state'] == 'failed') {
model.errorMessage = response['message'];
showErrorSnackBar(model.errorMessage);
return ApiResponse.failure(response['message'] ?? "Verification failed");
}
return ApiResponse.success(Map<String, dynamic>.from(response));
} catch (e) {
logger.e(e);
showErrorSnackBar(
model.isLoading = false;
notifyListeners();
return ApiResponse.failure(
"Problem during registration, please try again or contact support?",
);
}
model.isLoading = false;
notifyListeners();
}
Future<void> resendOtp () async {
Future<ApiResponse<Map<String, dynamic>>> resendOtp () async {
Map user = {
"username": onboardingController.model.googleUser?.email,
"phone": onboardingController.model.phone,
@@ -78,18 +82,21 @@ class VerifyController extends AbstractController {
user
);
model.status = response['state'];
model.isLoading = false;
notifyListeners();
if(response['state'] == 'failed') {
model.errorMessage = response['message'];
showErrorSnackBar(model.errorMessage);
return ApiResponse.failure(response['message'] ?? "Resend failed");
}
return ApiResponse.success(Map<String, dynamic>.from(response));
} catch (e) {
logger.e(e);
showErrorSnackBar(
model.isLoading = false;
notifyListeners();
return ApiResponse.failure(
"Problem during registration, please try again or contact support?",
);
}
model.isLoading = false;
notifyListeners();
}
}

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();
} catch (e) {
logger.e(e);
_showErrorSnackBar(
"Problem fetching products, are you connected to the internet?",
);
}
model.isLoading = false;
notifyListeners();
return ApiResponse.success(model.products);
} catch (e) {
logger.e(e);
model.isLoading = false;
notifyListeners();
return ApiResponse.failure(
"Problem fetching products, are you connected to the internet?",
);
}
}
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
Future<void> updatePhoneNumber(String newNumber) async {
for (var country in countryCodes) {
if (existingPhone.startsWith(country['code']!)) {
if (newNumber.startsWith(country['code']!)) {
setState(() {
selectedCountryCode = country['code']!;
});
_phoneController.text = existingPhone.substring(
country['code']!.length,
);
_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,78 +229,33 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
decoration: BoxDecoration(
border: Border.all(
color: Colors.black87.withAlpha(20),
),
borderRadius: BorderRadius.circular(12),
),
padding: EdgeInsets.symmetric(
vertical: 10,
horizontal: 20,
),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
_buildTransactionDetailsCard(
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(
_buildDetailRow(
icon: Icons.business_outlined,
label: "Provider",
value:
controller
.model
.selectedProvider
?.name ??
"",
style: TextStyle(fontSize: 16),
),
],
),
const SizedBox(height: 10),
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text(
_buildDetailRow(
icon: Icons.account_balance_outlined,
label:
controller
.model
.selectedProvider
?.accountFieldName ??
"",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
Text(
transactionController
value: transactionController
.model
.formData
.creditAccount,
style: TextStyle(fontSize: 16),
),
],
),
],
),
),
SizedBox(height: 20),
InkWell(
customBorder: RoundedRectangleBorder(
@@ -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),
),
@@ -342,11 +328,20 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
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(
onPressed: () async {
@@ -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(

View File

@@ -1,13 +1,16 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/gateway/gateway_controller.dart';
import 'package:qpay/screens/transaction_controller.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:qpay/widgets/step_loading_widget.dart';
import 'package:web/web.dart' as web;
class PollScreen extends StatefulWidget {
const PollScreen({super.key});
final String? transactionId;
const PollScreen({super.key, this.transactionId});
@override
State<PollScreen> createState() => _PollScreenState();
@@ -17,8 +20,6 @@ class _PollScreenState extends State<PollScreen> {
late GatewayController controller;
late TransactionController transactionController;
bool integrating = false;
@override
void initState() {
super.initState();
@@ -28,21 +29,35 @@ class _PollScreenState extends State<PollScreen> {
listen: false,
);
controller.pollTransaction(transactionController.model.confirmationData['id']);
_startPolling();
}
void _showErrorSnackBar(String? message) {
WidgetsBinding.instance.addPostFrameCallback((_) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message.toString()),
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.deepOrange,
),
);
});
void _startPolling() {
// Priority 1: transaction ID passed via route param (/poll/:id)
String? transactionId = widget.transactionId;
// Priority 2: localStorage (for when gateway redirect causes page reload
// and the route param path is not available due to hub redirect override)
if (transactionId == null && kIsWeb) {
transactionId = web.window.localStorage.getItem('pendingTransactionId');
web.window.localStorage.removeItem('pendingTransactionId');
}
// Priority 3: in-memory confirmation data (normal SPA navigation)
if (transactionId == null) {
try {
transactionId =
transactionController.model.confirmationData['id'] as String?;
} catch (_) {
// confirmationData is late and may not be initialized
// after a page reload -- ignore
}
}
if (transactionId != null && transactionId.isNotEmpty) {
controller.pollTransaction(transactionId);
}
}
@override
void dispose() {
@@ -55,6 +70,10 @@ class _PollScreenState extends State<PollScreen> {
return Scaffold(
appBar: AppBar(
title: const Text('Completing your transaction'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/'),
),
),
body: ListenableBuilder(
listenable: controller,
@@ -65,60 +84,19 @@ class _PollScreenState extends State<PollScreen> {
});
}
return Container(
padding: EdgeInsets.all(50),
child: Center(
child: LayoutBuilder(
builder: (context, constraints) {
return SizedBox(
width: double.parse(constraints.maxWidth > ResponsivePolicy.md ?
ResponsivePolicy.md.toString() :
constraints.maxWidth.toString()),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(height: 75),
Column(
children: [
Text(
'We\'re checking with our gateway if your transaction was successful. This won\'t take long',
style: TextStyle(fontSize: 20),
textAlign: TextAlign.center,
),
SizedBox(height: 50,),
Image.asset('assets/velocity-animation.gif', width: 150),
SizedBox(height: 50),
if(controller.model.status == 'FAILED')
Column(
children: [
Text(
'We failed to check your transaction status',
style: TextStyle(fontSize: 20),
textAlign: TextAlign.center,
),
SizedBox(height: 20,),
Skeletonizer(
enabled: controller.model.isLoading,
child: OutlinedButton(
child: Text('Retry'),
onPressed: () {
return StepLoadingWidget(
currentStep: 1,
title: 'Verifying Payment',
subtitle:
'We\'re checking with our gateway if your transaction was '
'successful. This won\'t take long.',
status: controller.model.status,
isLoading: controller.model.isLoading,
errorMessage: 'We failed to check your transaction status',
onRetry: () {
controller.model.isCancelled = false;
controller.pollTransaction(transactionController.model.confirmationData['id']);
_startPolling();
},
),
),
],
),
],
),
],
),
);
}
),
),
);
},
),

View File

@@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:provider/provider.dart';
import 'package:qpay/http/http.dart';
import 'package:qpay/models/api_response.dart';
import 'package:qpay/screens/pay/pay_controller.dart' as pc;
import 'package:qpay/screens/transaction_controller.dart' as tc;
import 'package:qpay/screens/home/home_controller.dart' as hc;
@@ -29,50 +30,43 @@ class ReceiptController extends ChangeNotifier {
);
}
void _showErrorSnackBar(String? message) {
WidgetsBinding.instance.addPostFrameCallback((_) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message.toString()),
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.deepOrange,
),
);
});
}
void setLoading(bool value) {
model.isLoading = value;
notifyListeners();
}
Future<void> doIntegration(String id) async {
Future<ApiResponse<Map<String, dynamic>>> doIntegration(String id) async {
try {
model.isLoading = true;
model.status = '';
notifyListeners();
dynamic workflowResponse = await http.get(
'/public/transaction/integration/$id'
'/public/transaction/integration/$id',
);
logger.i(workflowResponse.toString());
dynamic response = workflowResponse['body'];
model.isLoading = false;
if (response['status'] == 'SUCCESS') {
model.status = response['status'];
transactionController.updateReceiptData(response);
notifyListeners();
return ApiResponse.success(Map<String, dynamic>.from(response));
} else {
model.status = response['status'];
model.errorMessage = response['errorMessage'];
_showErrorSnackBar(response['errorMessage']);
}
notifyListeners();
return ApiResponse.failure(
response['errorMessage'] ?? "Integration failed",
);
}
} catch (e) {
model.isLoading = false;
model.errorMessage = e.toString();
notifyListeners();
return ApiResponse.failure(e.toString());
}
}
@@ -115,27 +109,28 @@ class ReceiptController extends ChangeNotifier {
.model
.formData = transactionController.model.formData.copyWith(
amount: model.receiptData?['amount'].toStringAsFixed(2) ?? '',
creditPhone: model.receiptData?['creditPhone'] ?? '',
debitPhone: model.receiptData?['debitPhone'] ?? '',
paymentProcessorLabel: model.receiptData?['paymentProcessorLabel'] ?? '',
paymentProcessorName: model.receiptData?['paymentProcessorName'] ?? '',
id: null
id: null,
);
setLoading(false);
context.push('/make-payment');
}
Future<void> getReceiptData(String id) async {
Future<ApiResponse<Map<String, dynamic>>> getReceiptData(String id) async {
setLoading(true);
try {
Map<String, dynamic> response = await http.get('/public/transaction/$id');
model.receiptData = response;
setLoading(false);
return ApiResponse.success(response);
} catch (e) {
_showErrorSnackBar(
setLoading(false);
return ApiResponse.failure(
"Problem fetching transaction data, are you connected to the internet?",
);
} finally {
setLoading(false);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.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:freezed_annotation/freezed_annotation.dart';
import 'package:qpay/screens/transaction_controller.dart';
@@ -46,19 +47,7 @@ class RecipientsController extends ChangeNotifier {
).model.selectedProvider;
}
void _showErrorSnackBar(String? message) {
WidgetsBinding.instance.addPostFrameCallback((_) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message.toString()),
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.deepOrange,
),
);
});
}
Future<void> getRecipients([Map<String, String>? params]) async {
Future<ApiResponse<List<Recipient>>> getRecipients([Map<String, String>? params]) async {
model.errorMessage = null;
model.isLoading = true;
model.recipients = getDummyRecipients();
@@ -71,18 +60,19 @@ class RecipientsController extends ChangeNotifier {
);
model.recipients.clear();
model.recipients.addAll(response.map((e) => Recipient.fromJson(e)));
model.isLoading = false;
notifyListeners();
return ApiResponse.success(List<Recipient>.from(model.recipients));
} catch (e) {
_showErrorSnackBar(
"Problem fetching recipients, are you connected to the internet?",
);
model.errorMessage = e.toString();
model.recipients = [];
logger.e(e);
notifyListeners();
}
model.isLoading = false;
notifyListeners();
return ApiResponse.failure(
"Problem fetching recipients, are you connected to the internet?",
);
}
}
String buildQueryParameters(Map<String, String> params) {

View File

@@ -89,43 +89,18 @@ class _RecipientsScreenState extends State<RecipientsScreen>
child: LayoutBuilder(
builder: (context, constraints) {
return SizedBox(
width: double.parse(constraints.maxWidth > ResponsivePolicy.md ?
ResponsivePolicy.md.toString() :
constraints.maxWidth.toString()),
width: double.parse(
constraints.maxWidth > ResponsivePolicy.md
? ResponsivePolicy.md.toString()
: constraints.maxWidth.toString(),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
decoration: BoxDecoration(
border: Border.all(color: Colors.black87.withAlpha(20)),
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(
_buildTransactionDetailsCard(
label: "Provider",
value:
controller.model.selectedProvider?.name ?? "",
style: TextStyle(fontSize: 16),
),
],
),
],
),
),
const SizedBox(height: 20),
_buildAccountField(controller),
@@ -144,21 +119,27 @@ class _RecipientsScreenState extends State<RecipientsScreen>
phone = _accountController.text;
}
transactionController.model.formData =
transactionController.model.formData.copyWith(
creditAccount: _accountController.text,
transactionController.model.formData
.copyWith(
creditAccount:
_accountController.text,
creditPhone: phone,
);
context.push("/make-payment");
},
style: ElevatedButton.styleFrom(
backgroundColor:
Theme.of(context).colorScheme.primary,
foregroundColor:
Theme.of(context).colorScheme.onPrimary,
backgroundColor: Theme.of(
context,
).colorScheme.primary,
foregroundColor: Theme.of(
context,
).colorScheme.onPrimary,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment:
MainAxisAlignment.center,
crossAxisAlignment:
CrossAxisAlignment.center,
children: [
Text(
"Use as new recipient",
@@ -177,14 +158,17 @@ class _RecipientsScreenState extends State<RecipientsScreen>
const SizedBox(height: 20),
Text(
"Recent Recipients",
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 10),
_buildRecipientItems(controller),
],
),
);
}
},
),
),
),
@@ -211,7 +195,9 @@ class _RecipientsScreenState extends State<RecipientsScreen>
final contact = await FlutterContacts.openExternalPick();
if (contact != null) {
_accountController.text = contact.phones.first.number;
controller.updateCreditAccount(contact.phones.first.number);
controller.updateCreditAccount(
contact.phones.first.number,
);
}
}
},
@@ -242,7 +228,7 @@ class _RecipientsScreenState extends State<RecipientsScreen>
borderSide: BorderSide(
color: Theme.of(
context,
).colorScheme.primary.withOpacity(0.2),
).colorScheme.primary.withValues(alpha: 0.2),
),
),
),
@@ -287,15 +273,48 @@ class _RecipientsScreenState extends State<RecipientsScreen>
Widget _buildRecipientItems(RecipientsController controller) {
if (controller.model.recipients.isEmpty && !controller.model.isLoading) {
return Center(child: Text("No recipients yet"));
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 40),
child: Column(
children: [
Icon(Icons.people_outline, size: 64, color: Colors.grey.shade400),
const SizedBox(height: 16),
Text(
"No recipients yet",
style: TextStyle(
fontSize: 16,
color: Colors.grey.shade600,
fontWeight: FontWeight.w500,
),
),
],
),
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...controller.model.recipients.map(
(recipient) => Skeletonizer(
return ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: controller.model.recipients.length,
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (context, index) {
final recipient = controller.model.recipients[index];
final animIdx = index < _alignListAnimations.length
? index
: _alignListAnimations.length - 1;
return Skeletonizer(
enabled: controller.model.isLoading,
child: SlideTransition(
position:
_alignListAnimations[animIdx.clamp(
0,
_alignListAnimations.length - 1,
)],
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
String account = recipient.account ?? '';
@@ -313,95 +332,304 @@ class _RecipientsScreenState extends State<RecipientsScreen>
creditPhone: recipient.phoneNumber ?? '',
creditEmail: recipient.email ?? '',
providerLabel:
transactionController.model.formData.providerLabel.isEmpty
transactionController
.model
.formData
.providerLabel
.isEmpty
? recipient.latestProviderLabel ?? ''
: transactionController.model.formData.providerLabel,
);
context.push("/make-payment");
},
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(16),
child: Container(
padding: EdgeInsets.all(5),
child: SlideTransition(
position: _alignListAnimations[0],
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Text(
recipient.initials ?? 'PK',
style: TextStyle(
fontSize: 25,
fontWeight: FontWeight.bold,
),
),
),
SizedBox(width: 20),
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
recipient.latestProviderLabel ?? '',
style: TextStyle(fontWeight: FontWeight.bold),
),
SizedBox(width: 5),
Text(
recipient.account ?? '',
style: TextStyle(fontWeight: FontWeight.normal),
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.symmetric(
vertical: 14,
horizontal: 16,
),
child: Row(
children: [
// Avatar
Container(
width: 50,
height: 50,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Theme.of(context).colorScheme.primary,
Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.7),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.25),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
alignment: Alignment.center,
child: Text(
recipient.initials ?? 'PK',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
const SizedBox(width: 16),
// Details
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Name
if (recipient.name != null &&
recipient.name!.isNotEmpty)
Text(
recipient.name ?? '',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (recipient.name != null &&
recipient.name!.isNotEmpty)
const SizedBox(height: 4),
// Account number and provider label row
Row(
children: [
if (recipient.latestProviderLabel != null &&
recipient.latestProviderLabel!.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(6),
),
child: Text(
recipient.latestProviderLabel ?? '',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Theme.of(
context,
).colorScheme.primary,
),
),
),
if (recipient.latestProviderLabel != null &&
recipient
.latestProviderLabel!
.isNotEmpty &&
recipient.account != null &&
recipient.account!.isNotEmpty)
const SizedBox(width: 6),
if (recipient.account != null &&
recipient.account!.isNotEmpty)
Expanded(
child: Text(
recipient.account ?? '',
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade700,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 4),
// Phone / Email row
if ((recipient.phoneNumber != null &&
recipient.phoneNumber!.isNotEmpty) ||
(recipient.email != null &&
recipient.email!.isNotEmpty))
Row(
spacing: 5,
spacing: 8,
children: [
if (recipient.phoneNumber != null &&
recipient.phoneNumber!.isNotEmpty)
Text(
recipient.phoneNumber ?? '',
style: TextStyle(
fontWeight: FontWeight.bold,
),
_buildContactChip(
Icons.phone_outlined,
recipient.phoneNumber!,
Colors.grey.shade600,
),
if (recipient.email != null &&
recipient.email!.isNotEmpty)
Text(
recipient.email ?? '',
style: TextStyle(
fontWeight: FontWeight.normal,
),
),
],
),
if (recipient.name != null &&
recipient.name!.isNotEmpty)
Text(
recipient.name ?? '',
style: TextStyle(fontWeight: FontWeight.normal),
_buildContactChip(
Icons.email_outlined,
recipient.email!,
Colors.grey.shade500,
),
],
),
],
),
),
// Chevron
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: Colors.grey.shade100,
shape: BoxShape.circle,
),
child: Icon(
Icons.arrow_forward_ios,
size: 12,
color: Colors.grey.shade400,
),
),
],
),
),
),
),
),
),
);
},
);
}
Widget _buildContactChip(IconData icon, String text, Color color) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 13, color: color),
const SizedBox(width: 3),
Flexible(
child: Text(
text,
style: TextStyle(fontSize: 12, color: color),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
);
}
Widget _buildTransactionDetailsCard({
required String label,
String? value,
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),
if (children != null)
...children
else
_buildDetailRow(
icon: Icons.business_outlined,
label: label,
value: value ?? '',
),
],
),
),
);
}
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,
),
),
],
),
);
}
}

View File

@@ -1,10 +1,9 @@
import 'package:flutter/material.dart';
import 'package:gif_view/gif_view.dart';
import 'package:qpay/main.dart';
class SplashScreen extends StatefulWidget {
final VoidCallback onSplashComplete;
const SplashScreen({super.key, required this.onSplashComplete});
const SplashScreen({super.key});
@override
State<SplashScreen> createState() => _SplashScreenState();
@@ -57,14 +56,15 @@ class _SplashScreenState extends State<SplashScreen>
// Start fade out animation
await _fadeController.reverse();
// Call the completion callback
// Mark splash complete so GoRouter redirect releases navigation
// to the originally intended route (e.g. /poll/:id).
if (mounted) {
widget.onSplashComplete();
splashState.finish();
}
} catch (e) {
// If there's an error, still complete the splash screen
if (mounted) {
widget.onSplashComplete();
splashState.finish();
}
}
}

View File

@@ -0,0 +1,407 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:qpay/models/responsive_policy.dart';
import 'package:skeletonizer/skeletonizer.dart';
class StepLoadingWidget extends StatefulWidget {
final int currentStep; // 1 or 2
final String title;
final String subtitle;
final String status; // '', 'SUCCESS', 'FAILED'
final bool isLoading;
final String? errorMessage;
final VoidCallback? onRetry;
const StepLoadingWidget({
super.key,
required this.currentStep,
required this.title,
required this.subtitle,
required this.status,
required this.isLoading,
this.errorMessage,
this.onRetry,
});
@override
State<StepLoadingWidget> createState() => _StepLoadingWidgetState();
}
class _StepLoadingWidgetState extends State<StepLoadingWidget>
with TickerProviderStateMixin {
late AnimationController _pulseController;
late Animation<double> _pulseAnimation;
late AnimationController _spinController;
@override
void initState() {
super.initState();
// Pulse animation for the outer ring
_pulseController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
)..repeat(reverse: true);
_pulseAnimation = Tween<double>(begin: 0.85, end: 1.15).animate(
CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut),
);
// Spin animation for the arc
_spinController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
)..repeat();
}
@override
void dispose() {
_pulseController.dispose();
_spinController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final primaryColor = theme.colorScheme.primary;
return Container(
padding: const EdgeInsets.all(50),
child: Center(
child: LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.maxWidth > ResponsivePolicy.md
? ResponsivePolicy.md.toDouble()
: constraints.maxWidth;
return SizedBox(
width: maxWidth,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(height: 60),
// Step indicator
_StepIndicator(
currentStep: widget.currentStep,
status: widget.status,
primaryColor: primaryColor,
),
const SizedBox(height: 60),
// Animated loader
if (widget.status != 'FAILED' &&
widget.status != 'SUCCESS')
AnimatedBuilder(
animation: Listenable.merge(
[_pulseController, _spinController]),
builder: (context, child) {
return SizedBox(
width: 120,
height: 120,
child: CustomPaint(
painter: _LoadingPainter(
pulseValue: _pulseAnimation.value,
spinValue: _spinController.value,
color: primaryColor,
),
),
);
},
),
if (widget.status == 'SUCCESS')
_buildCheckIcon(primaryColor),
if (widget.status == 'FAILED')
_buildErrorIcon(theme),
const SizedBox(height: 40),
// Title
Text(
widget.title,
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w600,
color: theme.colorScheme.tertiary,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
// Subtitle
Text(
widget.subtitle,
style: TextStyle(
fontSize: 16,
color: Colors.grey.shade600,
height: 1.4,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 50),
// Error state with retry
if (widget.status == 'FAILED')
Column(
children: [
Text(
widget.errorMessage ??
'Something went wrong',
style: TextStyle(
fontSize: 16,
color: theme.colorScheme.error,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
Skeletonizer(
enabled: widget.isLoading,
child: OutlinedButton.icon(
icon: const Icon(Icons.refresh, size: 20),
label: const Text('Retry'),
onPressed: widget.onRetry,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 14,
),
side: BorderSide(
color: primaryColor,
width: 1.5,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
],
),
],
),
);
},
),
),
);
}
Widget _buildCheckIcon(Color primaryColor) {
return Container(
width: 120,
height: 120,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: primaryColor.withValues(alpha: 0.1),
),
child: Icon(
Icons.check_circle_rounded,
size: 80,
color: primaryColor,
),
);
}
Widget _buildErrorIcon(ThemeData theme) {
return Container(
width: 120,
height: 120,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: theme.colorScheme.error.withValues(alpha: 0.1),
),
child: Icon(
Icons.error_outline_rounded,
size: 80,
color: theme.colorScheme.error,
),
);
}
}
class _StepIndicator extends StatelessWidget {
final int currentStep;
final String status;
final Color primaryColor;
const _StepIndicator({
required this.currentStep,
required this.status,
required this.primaryColor,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isSuccess = status == 'SUCCESS';
return Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.grey.shade200),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildStep(
number: '1',
label: 'Verify Payment',
isActive: currentStep == 1,
isCompleted: currentStep > 1 || (currentStep == 1 && isSuccess),
theme: theme,
),
Container(
width: 60,
height: 2,
margin: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: currentStep >= 2
? primaryColor
: Colors.grey.shade300,
borderRadius: BorderRadius.circular(1),
),
),
_buildStep(
number: '2',
label: 'Update Account',
isActive: currentStep == 2,
isCompleted: currentStep == 2 && isSuccess,
theme: theme,
),
],
),
);
}
Widget _buildStep({
required String number,
required String label,
required bool isActive,
required bool isCompleted,
required ThemeData theme,
}) {
final Color circleColor;
final Widget circleChild;
if (isCompleted) {
circleColor = theme.colorScheme.primary;
circleChild = const Icon(Icons.check, size: 16, color: Colors.white);
} else if (isActive) {
circleColor = theme.colorScheme.primary;
circleChild = Text(
number,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 14,
),
);
} else {
circleColor = Colors.grey.shade300;
circleChild = Text(
number,
style: TextStyle(
color: Colors.grey.shade600,
fontWeight: FontWeight.bold,
fontSize: 14,
),
);
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: circleColor,
),
child: Center(child: circleChild),
),
const SizedBox(height: 6),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
color: isActive
? theme.colorScheme.tertiary
: Colors.grey.shade500,
),
),
],
);
}
}
class _LoadingPainter extends CustomPainter {
final double pulseValue;
final double spinValue;
final Color color;
_LoadingPainter({
required this.pulseValue,
required this.spinValue,
required this.color,
});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final baseRadius = size.width / 2 - 8;
// Draw pulsing outer ring
final pulseRadius = baseRadius * pulseValue;
final pulsePaint = Paint()
..color = color.withValues(alpha: 0.15)
..style = PaintingStyle.fill;
canvas.drawCircle(center, pulseRadius, pulsePaint);
// Draw ring outline
final ringPaint = Paint()
..color = color.withValues(alpha: 0.25)
..style = PaintingStyle.stroke
..strokeWidth = 3;
canvas.drawCircle(center, baseRadius, ringPaint);
// Draw spinning arc
final arcPaint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 3.5
..strokeCap = StrokeCap.round;
final arcStart = spinValue * 2 * pi;
final arcSweep = pi * 1.2;
canvas.drawArc(
Rect.fromCircle(center: center, radius: baseRadius),
arcStart,
arcSweep,
false,
arcPaint,
);
// Draw inner dot
final dotPaint = Paint()
..color = color
..style = PaintingStyle.fill;
canvas.drawCircle(center, 6, dotPaint);
}
@override
bool shouldRepaint(_LoadingPainter oldDelegate) {
return oldDelegate.pulseValue != pulseValue ||
oldDelegate.spinValue != spinValue;
}
}

View File

@@ -292,7 +292,7 @@ packages:
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"

View File

@@ -59,6 +59,8 @@ dependencies:
html: ^0.15.6
web: ^1.1.1
google_sign_in_web: ^1.1.0
flutter_web_plugins:
sdk: flutter
dev_dependencies:
flutter_test:

View File

@@ -34,6 +34,19 @@
<title>Velocity</title>
<link rel="manifest" href="manifest.json">
<script type="text/javascript">
// If there's a pending transaction ID stored before redirecting to the
// payment gateway, forward to /poll/:id so Flutter can start polling
// for the result immediately.
(function() {
var txId = localStorage.getItem('pendingTransactionId');
if (txId) {
// The gateway redirected back to us. Clear the pending ID and
// navigate to /poll/:id using the path-based URL strategy.
localStorage.removeItem('pendingTransactionId');
window.location.replace('/poll/' + encodeURIComponent(txId));
}
})();
function configure(sessionID) {
if (typeof Checkout === 'undefined') {
throw new Error('Checkout SDK not loaded.');