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", "type": "dart",
"deviceId": "chrome", "deviceId": "chrome",
"args": [ "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=http://192.168.100.85:6950/api
# BASE_URL=https://peakapi.qantra.co.zw/api
BASE_URL=http://localhost:6950/api BASE_URL=http://localhost:6950/api
CLIENTID=77433712483-ng7pntvcpf6tnjccriuqm8dbna8vvp3b.apps.googleusercontent.com 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 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/material.dart';
import 'package:flutter_web_plugins/flutter_web_plugins.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:qpay/navbar.dart'; import 'package:qpay/navbar.dart';
import 'package:qpay/screens/confirm/confirm_screen.dart'; import 'package:qpay/screens/confirm/confirm_screen.dart';
import 'package:qpay/screens/gateway/gateway_redirect.dart'; import 'package:qpay/screens/gateway/gateway_redirect.dart';
import 'package:qpay/screens/gateway/gateway_screen.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/integration/integration_screen.dart';
import 'package:qpay/screens/onboarding/complete_screen.dart'; import 'package:qpay/screens/onboarding/complete_screen.dart';
import 'package:qpay/screens/onboarding/landing/landing_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/onboarding/verify/verify_screen.dart';
import 'package:qpay/screens/pay/pay_screen.dart'; import 'package:qpay/screens/pay/pay_screen.dart';
import 'package:qpay/screens/poll/poll_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/receipt/receipt_screen.dart';
import 'package:qpay/screens/recipient/recipients_screen.dart'; import 'package:qpay/screens/recipient/recipients_screen.dart';
import 'package:qpay/screens/transaction_controller.dart'; import 'package:qpay/screens/transaction_controller.dart';
import 'screens/gateway/gateway_web_screen.dart'; import 'package:qpay/screens/profile_screen.dart';
import 'screens/home/home_screen.dart'; import 'package:qpay/theme/app_theme.dart';
import 'screens/history/history_screen.dart';
import 'screens/profile_screen.dart';
import 'screens/splash_screen.dart';
import 'theme/app_theme.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.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 { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
usePathUrlStrategy();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
await dotenv.load(fileName: resolveEnvFile(appEnv)); await dotenv.load(fileName: resolveEnvFile(appEnv));
@@ -71,37 +103,54 @@ class MyApp extends StatefulWidget {
} }
class _MyAppState extends State<MyApp> { class _MyAppState extends State<MyApp> {
bool _showSplash = true;
void _onSplashComplete() {
setState(() {
_showSplash = false;
});
}
@override @override
Widget build(BuildContext context) { 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( return MaterialApp.router(
title: 'Velocity', title: 'Velocity',
theme: AppTheme.lightTheme, theme: AppTheme.lightTheme,
themeMode: ThemeMode.light, // Force light mode regardless of OS setting themeMode: ThemeMode.light,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
routerConfig: GoRouter( routerConfig: GoRouter(
initialLocation: '/', 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: [ 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( ShellRoute(
builder: (context, state, child) { builder: (context, state, child) {
// If the current location is an onboarding route, don't show the navbar
final location = GoRouterState.of(context).uri.toString(); final location = GoRouterState.of(context).uri.toString();
if (location.startsWith('/onboarding/')) { if (location.startsWith('/onboarding/')) {
return child; return child;
@@ -137,6 +186,12 @@ class _MyAppState extends State<MyApp> {
path: '/gateway-redirect', path: '/gateway-redirect',
builder: (context, state) => const GatewayRedirectScreen(), builder: (context, state) => const GatewayRedirectScreen(),
), ),
GoRoute(
path: '/poll/:id',
builder: (context, state) => PollScreen(
transactionId: state.pathParameters['id'],
),
),
GoRoute( GoRoute(
path: '/poll', path: '/poll',
builder: (context, state) => const PollScreen(), 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/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:qpay/models/api_response.dart';
import 'package:qpay/screens/transaction_controller.dart'; import 'package:qpay/screens/transaction_controller.dart';
import '../../http/http.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 { try {
model.isLoading = true; model.isLoading = true;
notifyListeners(); notifyListeners();
@@ -77,33 +77,30 @@ class ConfirmController extends ChangeNotifier {
dynamic response = workflowResponse['body']; dynamic response = workflowResponse['body'];
if (response['status'] != 'FAILED') {
model.status = response['status'];
transactionController.updateReceiptData(response);
model.isLoading = false; model.isLoading = false;
notifyListeners(); notifyListeners();
return response;
if (response['status'] != 'FAILED') {
model.status = response['status'];
transactionController.updateReceiptData(response);
return ApiResponse.success(Map<String, dynamic>.from(response));
} else { } else {
model.status = response['status']; model.status = response['status'];
model.errorMessage = response['errorMessage']; model.errorMessage = response['errorMessage'];
_showErrorSnackBar(response['errorMessage']); return ApiResponse.failure(response['errorMessage'] ?? "Transaction failed");
} }
} catch (e) { } catch (e) {
logger.e(e); logger.e(e);
model.errorMessage = e.toString(); model.errorMessage = e.toString();
_showErrorSnackBar( model.isLoading = false;
notifyListeners();
return ApiResponse.failure(
"Problem completing the transaction, please try again.", "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) { while (!model.isCancelled) {
// Check again after delay in case it was cancelled during the delay // Check again after delay in case it was cancelled during the delay
if (model.isCancelled) break; if (model.isCancelled) break;
@@ -117,7 +114,7 @@ class ConfirmController extends ChangeNotifier {
transactionController.updateReceiptData(response); transactionController.updateReceiptData(response);
model.isCancelled = true; model.isCancelled = true;
notifyListeners(); notifyListeners();
break; return ApiResponse.success(Map<String, dynamic>.from(response));
} }
} catch (e) { } catch (e) {
logger.e(e); logger.e(e);
@@ -130,6 +127,7 @@ class ConfirmController extends ChangeNotifier {
// Wait 5 seconds before the next poll // Wait 5 seconds before the next poll
await Future.delayed(const Duration(seconds: 5)); await Future.delayed(const Duration(seconds: 5));
} }
return ApiResponse.failure("Polling cancelled");
} }
@override @override

View File

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

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'dart:async'; // Add Timer import import 'dart:async';
import 'package:qpay/http/http.dart'; import 'package:qpay/http/http.dart';
import 'package:qpay/models/api_response.dart';
import 'package:qpay/screens/transaction_controller.dart'; import 'package:qpay/screens/transaction_controller.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -50,10 +51,11 @@ class GatewayController extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> poll(String uid) async { Future<ApiResponse<Map<String, dynamic>>> poll(String uid) async {
startLoading(); startLoading();
try { try {
dynamic workflowResponse = await http.get('/public/transaction/poll/$uid'); dynamic workflowResponse =
await http.get('/public/transaction/poll/$uid');
logger.i(workflowResponse.toString()); logger.i(workflowResponse.toString());
@@ -63,41 +65,65 @@ class GatewayController extends ChangeNotifier {
transactionController.model.paymentStatus = response['paymentStatus']; transactionController.model.paymentStatus = response['paymentStatus'];
finishLoading(); finishLoading();
if(model.status == 'SUCCESS') { if (model.status == 'SUCCESS') {
transactionController.updateReceiptData(response); transactionController.updateReceiptData(response);
} }
notifyListeners(); notifyListeners();
return ApiResponse.success(Map<String, dynamic>.from(response));
} catch (e) { } catch (e) {
finishLoading(); finishLoading();
model.status = 'FAILED';
model.errorMessage = "Network error. Please try again or contact support";
logger.e(e); logger.e(e);
_showErrorSnackBar( notifyListeners();
return ApiResponse.failure(
"Network error. Please try again or contact support", "Network error. Please try again or contact support",
); );
} }
} }
Future<void> pollTransaction(String uid) async { Future<ApiResponse<Map<String, dynamic>>> pollTransaction(String uid) async {
// only poll for 5 minutes // poll up to 30 times (~5 minutes at 10-second intervals)
int max = 30; int maxAttempts = 30;
while (!model.isCancelled) { int attempt = 0;
max++;
// Check cancellation flag instead of true while (!model.isCancelled && attempt < maxAttempts) {
// Wait 5 seconds before the next poll attempt++;
// Wait 10 seconds before each poll request
await Future.delayed(const Duration(seconds: 10)); await Future.delayed(const Duration(seconds: 10));
// Check again after delay in case it was cancelled during the delay // Check again after delay in case it was cancelled during the delay
if (model.isCancelled) break; 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.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 // 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 { _pollTimer = Timer.periodic(const Duration(seconds: 5), (timer) async {
if (model.isCancelled) { if (model.isCancelled) {
timer.cancel(); 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 @override

View File

@@ -1,15 +1,8 @@
import 'dart:js_interop';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:qpay/interop.dart';
import 'package:qpay/models/responsive_policy.dart'; import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/gateway/gateway_controller.dart'; import 'package:qpay/screens/gateway/gateway_controller.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:url_launcher/url_launcher.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 { class GatewayRedirectScreen extends StatefulWidget {
const GatewayRedirectScreen({super.key}); const GatewayRedirectScreen({super.key});

View File

@@ -1,12 +1,8 @@
import 'dart:js_interop';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:qpay/interop.dart';
import 'package:qpay/models/responsive_policy.dart'; import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/gateway/gateway_controller.dart'; import 'package:qpay/screens/gateway/gateway_controller.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:web/web.dart' as web; import 'package:web/web.dart' as web;
class GatewayWebScreen extends StatefulWidget { class GatewayWebScreen extends StatefulWidget {
@@ -17,12 +13,11 @@ class GatewayWebScreen extends StatefulWidget {
} }
class _GatewayWebScreenState extends State<GatewayWebScreen> { class _GatewayWebScreenState extends State<GatewayWebScreen> {
late WebViewController _webViewController;
late GatewayController controller; late GatewayController controller;
late String url; late String url;
String? sessionID;
bool integrationStarted = false; bool integrationStarted = false;
bool redirecting = false;
@override @override
void initState() { void initState() {
@@ -32,115 +27,40 @@ class _GatewayWebScreenState extends State<GatewayWebScreen> {
url = controller.transactionController.model.receiptData?['targetUrl']; url = controller.transactionController.model.receiptData?['targetUrl'];
} }
// A function to set up the ResizeObserver void _redirectToGateway() {
void setupAttachmentObserver(web.Element element) { if (redirecting) return;
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),
),
);
setState(() { 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((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
context.go('/poll'); 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 @override
@@ -161,88 +81,95 @@ class _GatewayWebScreenState extends State<GatewayWebScreen> {
}, },
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
) )
: SizedBox.shrink(), : const SizedBox.shrink(),
), ),
body: ListenableBuilder( body: ListenableBuilder(
listenable: controller, listenable: controller,
builder: (context, child) { builder: (context, child) {
return LayoutBuilder( return Center(
child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
return SingleChildScrollView( return SizedBox(
child: Center( width: constraints.maxWidth > ResponsivePolicy.md
child: SizedBox( ? ResponsivePolicy.md.toDouble()
width: ResponsivePolicy.md.toDouble(), : constraints.maxWidth.toDouble(),
child: SingleChildScrollView(
child: Padding( child: Padding(
padding: const EdgeInsets.all(20.0), padding: const EdgeInsets.all(20.0),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Container( const SizedBox(height: 40),
decoration: BoxDecoration(
border: Border.all( // Info icon
color: Colors.black87.withAlpha(20), Icon(
Icons.lock_outline,
size: 64,
color: Theme.of(context).colorScheme.primary,
), ),
borderRadius: BorderRadius.circular(12), const SizedBox(height: 24),
),
padding: EdgeInsets.symmetric(
vertical: 10,
horizontal: 20,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text( Text(
'Once done on this page, check transaction status', 'You are about to leave this app to complete your payment on a secure payment gateway.',
style: TextStyle(fontSize: 16), style: const TextStyle(fontSize: 18),
textAlign: TextAlign.center,
), ),
InkWell( const SizedBox(height: 16),
onTap: () {
context.push('/poll'); Text(
}, 'After completing payment, you will be automatically redirected back to this app to see your transaction status.',
borderRadius: BorderRadius.circular(12), style: TextStyle(
child: Container( fontSize: 14,
padding: EdgeInsets.symmetric( color: Colors.grey[600],
horizontal: 12,
vertical: 6,
), ),
decoration: BoxDecoration( textAlign: TextAlign.center,
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
), ),
child: Text(
'Check status', const SizedBox(height: 40),
style: TextStyle(fontSize: 12),
Image.asset(
'assets/velocity-animation.gif',
width: 150,
), ),
),
), const SizedBox(height: 40),
],
),
),
SizedBox(height: 10),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
height: constraints.maxHeight + 300, child: ElevatedButton(
child: HtmlElementView.fromTagName( onPressed: redirecting
tagName: 'div', ? null
onElementCreated: (element) { : _redirectToGateway,
final divElement = style: ElevatedButton.styleFrom(
element as web.HTMLDivElement; padding: const EdgeInsets.symmetric(
divElement.id = "embed-target"; vertical: 16,
setupAttachmentObserver(element);
},
), ),
), ),
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:flutter/material.dart';
import 'package:qpay/http/http.dart'; import 'package:qpay/http/http.dart';
import 'package:qpay/models/api_response.dart';
import 'package:qpay/models/pageable_model.dart'; import 'package:qpay/models/pageable_model.dart';
class HistoryModel { class HistoryModel {
@@ -17,17 +18,7 @@ class HistoryController extends ChangeNotifier {
HistoryController(this.context); HistoryController(this.context);
void _showErrorSnackBar(String? message) { Future<ApiResponse<List<Map<String, dynamic>>>> getTransactions(Map<String, String> params) async {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message.toString()),
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.deepOrange,
),
);
}
Future<void> getTransactions(Map<String, String> params) async {
model.isLoading = true; model.isLoading = true;
if (params['page'] == '0') { if (params['page'] == '0') {
model.transactions = getFakeTransactions(); model.transactions = getFakeTransactions();
@@ -51,16 +42,19 @@ class HistoryController extends ChangeNotifier {
model.pageableModel!.content model.pageableModel!.content
.map((e) => e as Map<String, dynamic>) .map((e) => e as Map<String, dynamic>)
.toList(); .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; model.isLoading = false;
notifyListeners(); 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:flutter/material.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:qpay/http/http.dart'; import 'package:qpay/http/http.dart';
import 'package:qpay/models/api_response.dart';
import 'package:qpay/models/pageable_model.dart'; import 'package:qpay/models/pageable_model.dart';
part 'home_controller.freezed.dart'; part 'home_controller.freezed.dart';
@@ -93,24 +94,13 @@ class HomeController extends ChangeNotifier {
super.dispose(); super.dispose();
} }
void _showErrorSnackBar(String? message) { Future<ApiResponse<List<Account>>> getAccounts() async {
WidgetsBinding.instance.addPostFrameCallback((_) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message.toString()),
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.deepOrange,
),
);
});
}
Future<void> getAccounts() async {
model.accounts = getFakeAccounts(); model.accounts = getFakeAccounts();
if (!_disposed) notifyListeners(); if (!_disposed) notifyListeners();
return ApiResponse.success(model.accounts);
} }
Future<void> getProviders() async { Future<ApiResponse<List<BillProvider>>> getProviders() async {
try { try {
model.filterableProviders = getFakeProviders(); model.filterableProviders = getFakeProviders();
model.transactions = getFakeTransactions(); model.transactions = getFakeTransactions();
@@ -125,19 +115,22 @@ class HomeController extends ChangeNotifier {
if (model.providers.isNotEmpty) { if (model.providers.isNotEmpty) {
updateSelectedProvider(model.providers[0]); 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; model.isLoading = false;
if (!_disposed) notifyListeners(); 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) { void updateSelectedProvider(BillProvider provider) {
@@ -148,9 +141,8 @@ class HomeController extends ChangeNotifier {
if (!_disposed) notifyListeners(); if (!_disposed) notifyListeners();
} }
Future<void> getTransactions(String userId) async { Future<ApiResponse<List<Map<String, dynamic>>>> getTransactions(String userId) async {
model.isLoading = true; model.isLoading = true;
// model.transactions = getFakeTransactions();
if (!_disposed) notifyListeners(); if (!_disposed) notifyListeners();
try { try {
@@ -163,20 +155,23 @@ class HomeController extends ChangeNotifier {
model.transactions = pageableModel.content model.transactions = pageableModel.content
.map((e) => e as Map<String, dynamic>) .map((e) => e as Map<String, dynamic>)
.toList(); .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; model.isLoading = false;
if (!_disposed) notifyListeners(); 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 // activate skeletonizer
model.categories = getFakeCategoryData(); model.categories = getFakeCategoryData();
model.isLoading = true; model.isLoading = true;
@@ -190,20 +185,23 @@ class HomeController extends ChangeNotifier {
if (model.categories.isNotEmpty) { if (model.categories.isNotEmpty) {
updateSelectedCategory(model.categories[0]); 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; model.isLoading = false;
if (!_disposed) notifyListeners(); 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) { if (category == null) {
model.filterableProviders = model.providers; model.filterableProviders = model.providers;
if (!_disposed) notifyListeners(); if (!_disposed) notifyListeners();

View File

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

View File

@@ -1,9 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/transaction_controller.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'; import 'integration_controller.dart';
@@ -18,8 +17,6 @@ class _IntegrationScreenState extends State<IntegrationScreen> {
late IntegrationController controller; late IntegrationController controller;
late TransactionController transactionController; late TransactionController transactionController;
bool integrating = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -32,19 +29,6 @@ class _IntegrationScreenState extends State<IntegrationScreen> {
controller.doIntegration(); 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 @override
void dispose() { void dispose() {
controller.dispose(); controller.dispose();
@@ -56,6 +40,10 @@ class _IntegrationScreenState extends State<IntegrationScreen> {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Completing your transaction'), title: const Text('Completing your transaction'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/'),
),
), ),
body: ListenableBuilder( body: ListenableBuilder(
listenable: controller, listenable: controller,
@@ -66,60 +54,18 @@ class _IntegrationScreenState extends State<IntegrationScreen> {
}); });
} }
return Container( return StepLoadingWidget(
padding: EdgeInsets.all(50), currentStep: 2,
child: Center( title: 'Updating Account',
child: LayoutBuilder( subtitle:
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\'ve successfully collected your funds. We are now ' 'We\'ve successfully collected your funds. We are now '
'updating your billing account. This won\'t take long', 'updating your billing account. This won\'t take long.',
style: TextStyle(fontSize: 20), status: controller.model.status,
textAlign: TextAlign.center, isLoading: controller.model.isLoading,
), errorMessage: 'We failed to update your billing account',
SizedBox(height: 50,), onRetry: () {
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: () {
controller.doIntegration(); controller.doIntegration();
}, },
),
),
],
),
],
),
],
),
);
}
),
),
); );
}, },
), ),

View File

@@ -1,6 +1,7 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:qpay/http/http.dart'; import 'package:qpay/http/http.dart';
import 'package:qpay/models/api_response.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
class LoginScreenModel { class LoginScreenModel {
@@ -20,19 +21,7 @@ class LoginController extends ChangeNotifier {
LoginController(this.context); LoginController(this.context);
void _showErrorSnackBar(String? message) { Future<ApiResponse<LoginScreenModel>> login(String username, String password) async {
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 {
try { try {
model.isLoading = true; model.isLoading = true;
notifyListeners(); notifyListeners();
@@ -45,6 +34,9 @@ class LoginController extends ChangeNotifier {
}); });
logger.i(response.toString()); logger.i(response.toString());
model.isLoading = false;
notifyListeners();
if (response.containsKey('token')) { if (response.containsKey('token')) {
model.status = 'SUCCESS'; model.status = 'SUCCESS';
@@ -57,26 +49,25 @@ class LoginController extends ChangeNotifier {
prefs.setString('userId', response['uuid']); prefs.setString('userId', response['uuid']);
prefs.setString('initials', response['firstName'].substring(0, 1) + response['lastName'].substring(0, 1)); prefs.setString('initials', response['firstName'].substring(0, 1) + response['lastName'].substring(0, 1));
return model; return ApiResponse.success(model);
} else { } else {
model.errorMessage = model.errorMessage =
response['errorMessage'] ?? response['errorMessage'] ??
"Problem with the transaction. Please try again or contact support"; "Problem with the transaction. Please try again or contact support";
_showErrorSnackBar(model.errorMessage); return ApiResponse.failure(model.errorMessage!);
} }
} on DioException catch (e, s) { } on DioException catch (e, s) {
logger.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) { } catch (e, s) {
logger.e(s); logger.e(s);
_showErrorSnackBar( model.isLoading = false;
notifyListeners();
return ApiResponse.failure(
"Problem processing that transaction. Please try again or contact support", "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:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:qpay/abstracts/AbstractController.dart'; import 'package:qpay/abstracts/AbstractController.dart';
import 'package:qpay/http/http.dart'; import 'package:qpay/http/http.dart';
import 'package:qpay/models/api_response.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../onboarding_controller.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(); prefs = await SharedPreferences.getInstance();
Map user = { Map user = {
@@ -62,19 +61,26 @@ class PhoneController extends AbstractController {
if(response['state'] == 'failed') { if(response['state'] == 'failed') {
model.errorMessage = response['message']; model.errorMessage = response['message'];
showErrorSnackBar(model.errorMessage); showErrorSnackBar(model.errorMessage);
model.isLoading = false;
notifyListeners();
return ApiResponse.failure(response['message'] ?? "Registration failed");
} }
Map body = response['body']; Map body = response['body'];
body['workflowId'] = response['workflowId']; body['workflowId'] = response['workflowId'];
onboardingController.updateModel(body); onboardingController.updateModel(body);
model.isLoading = false;
notifyListeners();
return ApiResponse.success(Map<String, dynamic>.from(body));
} catch (e) { } catch (e) {
logger.e(e); logger.e(e);
showErrorSnackBar( showErrorSnackBar(
"Problem during registration, please try again or contact support?", "Problem during registration, please try again or contact support?",
); );
}
model.isLoading = false; model.isLoading = false;
notifyListeners(); 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: Center(
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final maxWidth = constraints.maxWidth > ResponsivePolicy.md final maxWidth =
constraints.maxWidth > ResponsivePolicy.md
? ResponsivePolicy.md.toDouble() ? ResponsivePolicy.md.toDouble()
: constraints.maxWidth; : constraints.maxWidth;
return SizedBox( return SizedBox(
@@ -101,10 +102,7 @@ class _PhoneScreenState extends State<PhoneScreen> {
style: TextStyle( style: TextStyle(
fontSize: 30, fontSize: 30,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Theme color: Theme.of(context).colorScheme.primary,
.of(context)
.colorScheme
.primary,
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
@@ -119,7 +117,9 @@ class _PhoneScreenState extends State<PhoneScreen> {
_buildPhoneField(), _buildPhoneField(),
SizedBox(height: 30), SizedBox(height: 30),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 25), padding: const EdgeInsets.symmetric(
horizontal: 25,
),
child: Skeletonizer( child: Skeletonizer(
enabled: phoneController.model.isLoading, enabled: phoneController.model.isLoading,
child: Row( child: Row(
@@ -127,18 +127,25 @@ class _PhoneScreenState extends State<PhoneScreen> {
Expanded( Expanded(
child: ElevatedButton( child: ElevatedButton(
onPressed: () async { onPressed: () async {
if (_formKey.currentState!.validate()) { if (_formKey.currentState!
.validate()) {
String fullPhoneNumber = String fullPhoneNumber =
selectedCountryCode + _phoneController.text; _phoneController.text;
phoneController.updatePhone(fullPhoneNumber); phoneController.updatePhone(
fullPhoneNumber,
);
await phoneController.register(); await phoneController.register();
if(phoneController.model.status != 'failed') { if (phoneController.model.status !=
'failed') {
context.go('/onboarding/verify'); context.go('/onboarding/verify');
} }
} }
}, },
child: Text('Send Code', style: TextStyle(fontSize: 16)), child: Text(
'Send Code',
style: TextStyle(fontSize: 16),
),
), ),
), ),
SizedBox(width: 16), SizedBox(width: 16),
@@ -162,19 +169,22 @@ class _PhoneScreenState extends State<PhoneScreen> {
onPressed: () { onPressed: () {
context.go('/'); 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, isExpanded: true,
icon: const Icon(Icons.arrow_drop_down), icon: const Icon(Icons.arrow_drop_down),
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
items: items: countryCodes.map((country) {
countryCodes.map((country) {
return DropdownMenuItem<String>( return DropdownMenuItem<String>(
value: country['code'], value: country['code'],
child: Row( 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:provider/provider.dart';
import 'package:qpay/abstracts/AbstractController.dart'; import 'package:qpay/abstracts/AbstractController.dart';
import 'package:qpay/http/http.dart'; import 'package:qpay/http/http.dart';
import 'package:qpay/models/api_response.dart';
import 'package:qpay/screens/onboarding/onboarding_controller.dart'; import 'package:qpay/screens/onboarding/onboarding_controller.dart';
class VerifyModel { class VerifyModel {
@@ -30,7 +31,7 @@ class VerifyController extends AbstractController {
notifyListeners(); notifyListeners();
} }
Future<void> verifyOtp () async { Future<ApiResponse<Map<String, dynamic>>> verifyOtp () async {
Map user = { Map user = {
"username": onboardingController.model.googleUser?.email, "username": onboardingController.model.googleUser?.email,
"otpCode": model.code, "otpCode": model.code,
@@ -47,22 +48,25 @@ class VerifyController extends AbstractController {
user user
); );
model.status = response['state']; model.status = response['state'];
model.isLoading = false;
notifyListeners();
if(response['state'] == 'failed') { if(response['state'] == 'failed') {
model.errorMessage = response['message']; model.errorMessage = response['message'];
showErrorSnackBar(model.errorMessage); return ApiResponse.failure(response['message'] ?? "Verification failed");
} }
return ApiResponse.success(Map<String, dynamic>.from(response));
} catch (e) { } catch (e) {
logger.e(e); logger.e(e);
showErrorSnackBar( model.isLoading = false;
notifyListeners();
return ApiResponse.failure(
"Problem during registration, please try again or contact support?", "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 = { Map user = {
"username": onboardingController.model.googleUser?.email, "username": onboardingController.model.googleUser?.email,
"phone": onboardingController.model.phone, "phone": onboardingController.model.phone,
@@ -78,18 +82,21 @@ class VerifyController extends AbstractController {
user user
); );
model.status = response['state']; model.status = response['state'];
model.isLoading = false;
notifyListeners();
if(response['state'] == 'failed') { if(response['state'] == 'failed') {
model.errorMessage = response['message']; model.errorMessage = response['message'];
showErrorSnackBar(model.errorMessage); return ApiResponse.failure(response['message'] ?? "Resend failed");
} }
return ApiResponse.success(Map<String, dynamic>.from(response));
} catch (e) { } catch (e) {
logger.e(e); logger.e(e);
showErrorSnackBar( model.isLoading = false;
notifyListeners();
return ApiResponse.failure(
"Problem during registration, please try again or contact support?", "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:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:qpay/http/http.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/home/home_controller.dart' as hc;
import 'package:qpay/screens/transaction_controller.dart'; import 'package:qpay/screens/transaction_controller.dart';
import 'package:qpay/screens/transaction_controller.dart' as tc; import 'package:qpay/screens/transaction_controller.dart' as tc;
@@ -69,21 +70,9 @@ class PayController extends ChangeNotifier {
model.selectedProduct = transactionController.model.selectedProduct; 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 // formData can come from a repeat transaction
// otherwise build it from elements of the normal flow // 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 { try {
model.isLoading = true; model.isLoading = true;
notifyListeners(); notifyListeners();
@@ -120,52 +109,59 @@ class PayController extends ChangeNotifier {
dynamic response = workflowResponse['body']; dynamic response = workflowResponse['body'];
model.isLoading = false;
notifyListeners();
if (response['status'] == 'SUCCESS') { if (response['status'] == 'SUCCESS') {
model.status = response['status']; model.status = response['status'];
transactionController.updateConfirmationData(response); transactionController.updateConfirmationData(response);
return ApiResponse.success(Map<String, dynamic>.from(response));
} else { } else {
model.status = response['status']; model.status = response['status'];
model.errorMessage = model.errorMessage =
response['errorMessage'] ?? response['errorMessage'] ??
"Problem with the transaction. Please try again or contact support"; "Problem with the transaction. Please try again or contact support";
_showErrorSnackBar(model.errorMessage); return ApiResponse.failure(model.errorMessage!);
} }
} on DioException catch (e, s) { } on DioException catch (e, s) {
logger.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) { } catch (e, s) {
logger.e(s); logger.e(s);
_showErrorSnackBar( model.isLoading = false;
notifyListeners();
return ApiResponse.failure(
"Problem processing that transaction. Please try again or contact support", "Problem processing that transaction. Please try again or contact support",
); );
} }
model.isLoading = false;
notifyListeners();
} }
Future<void> getProducts(String providerId) async { Future<ApiResponse<List<BillProduct>>> getProducts(String providerId) async {
// model.products = getFakeProducts();
try { try {
model.isLoading = true; model.isLoading = true;
notifyListeners();
List<dynamic> response = await http.get( List<dynamic> response = await http.get(
'/public/providers/$providerId/products', '/public/providers/$providerId/products',
); );
model.products = response.map((e) => BillProduct.fromJson(e)).toList(); 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; model.isLoading = false;
notifyListeners(); 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 { try {
model.isLoading = true; model.isLoading = true;
model.paymentProcessors = getFakePaymentProcessors(); model.paymentProcessors = getFakePaymentProcessors();
@@ -175,10 +171,14 @@ class PayController extends ChangeNotifier {
model.paymentProcessors = model.paymentProcessors =
response.map((e) => PaymentProcessor.fromJson(e)).toList(); response.map((e) => PaymentProcessor.fromJson(e)).toList();
model.isLoading = false;
notifyListeners(); notifyListeners();
return ApiResponse.success(model.paymentProcessors);
} catch (e) { } catch (e) {
logger.e(e); logger.e(e);
_showErrorSnackBar( model.isLoading = false;
notifyListeners();
return ApiResponse.failure(
"Problem fetching payment processors, are you connected to the internet?", "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, listen: false,
); );
_nameController.text = _setupData();
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 ?? '');
_controller = AnimationController( _controller = AnimationController(
vsync: this, vsync: this,
@@ -113,7 +106,6 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
} }
_controller.forward(); _controller.forward();
_setupData();
} }
_setupData() async { _setupData() async {
@@ -121,27 +113,67 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
if (prefs.containsKey('phone')) { if (prefs.containsKey('phone')) {
updatePhoneNumber(prefs.getString('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 { Future<void> updatePhoneNumber(String newNumber) async {
String existingPhone = phoneNumber;
if (existingPhone.isNotEmpty) {
// Try to extract country code from existing phone number
for (var country in countryCodes) { for (var country in countryCodes) {
if (existingPhone.startsWith(country['code']!)) { if (newNumber.startsWith(country['code']!)) {
setState(() { setState(() {
selectedCountryCode = country['code']!; selectedCountryCode = country['code']!;
}); });
_phoneController.text = existingPhone.substring( _phoneController.text = newNumber.substring(country['code']!.length);
country['code']!.length,
);
break; 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( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container( _buildTransactionDetailsCard(
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: [ children: [
Text( _buildDetailRow(
"TRANSACTIONS DETAILS", icon: Icons.business_outlined,
style: TextStyle( label: "Provider",
fontSize: 16, value:
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 10),
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text(
"Provider",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
Text(
controller controller
.model .model
.selectedProvider .selectedProvider
?.name ?? ?.name ??
"", "",
style: TextStyle(fontSize: 16),
), ),
], _buildDetailRow(
), icon: Icons.account_balance_outlined,
const SizedBox(height: 10), label:
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text(
controller controller
.model .model
.selectedProvider .selectedProvider
?.accountFieldName ?? ?.accountFieldName ??
"", "",
style: TextStyle( value: transactionController
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
Text(
transactionController
.model .model
.formData .formData
.creditAccount, .creditAccount,
style: TextStyle(fontSize: 16),
), ),
], ],
), ),
],
),
),
SizedBox(height: 20), SizedBox(height: 20),
InkWell( InkWell(
customBorder: RoundedRectangleBorder( customBorder: RoundedRectangleBorder(
@@ -313,7 +300,6 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
const SizedBox(height: 7), const SizedBox(height: 7),
_buildAmountField(), _buildAmountField(),
const SizedBox(height: 20), const SizedBox(height: 20),
// _buildPayButton(controller),
...controller.model.paymentProcessors.map( ...controller.model.paymentProcessors.map(
(e) => _buildPaymentProcessorItem(e, context), (e) => _buildPaymentProcessorItem(e, context),
), ),
@@ -342,11 +328,20 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
children: [ children: [
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [ children: [
const Text( const Text(
'Debit Phone Number', 'Debit Phone Number',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500), 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) if (!kIsWeb)
TextButton( TextButton(
onPressed: () async { onPressed: () async {
@@ -372,7 +367,9 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
width: 100, width: 100,
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all( 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), borderRadius: BorderRadius.circular(12),
), ),
@@ -427,7 +424,7 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
borderSide: BorderSide( borderSide: BorderSide(
color: Theme.of( color: Theme.of(
context, 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) { if (value == null || value.isEmpty) {
return 'Please enter a phone number'; return 'Please enter a phone number';
} }
// Remove any non-digit characters for validation
String digitsOnly = value.replaceAll(RegExp(r'[^\d]'), ''); String digitsOnly = value.replaceAll(RegExp(r'[^\d]'), '');
if (digitsOnly.length < 7) { if (digitsOnly.length < 7) {
return 'Phone number must be at least 7 digits'; return 'Phone number must be at least 7 digits';
@@ -479,7 +475,9 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: BorderSide( 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( borderSide: BorderSide(
color: Theme.of( color: Theme.of(
context, 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( borderSide: BorderSide(
color: Theme.of( color: Theme.of(
context, 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, enabled: controller.model.isLoading,
child: DropdownButtonHideUnderline( child: DropdownButtonHideUnderline(
child: DropdownButtonFormField<String>( child: DropdownButtonFormField<String>(
value: controller.model.selectedProduct?.uid, initialValue: controller.model.selectedProduct?.uid,
isExpanded: true, isExpanded: true,
icon: const Icon(Icons.arrow_drop_down), icon: const Icon(Icons.arrow_drop_down),
decoration: InputDecoration( 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) { Widget _buildPaymentProcessorItem(PaymentProcessor e, BuildContext context) {
return Column( return Column(
children: [ children: [
@@ -667,34 +743,7 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
if (_formKey.currentState!.validate()) { if (_formKey.currentState!.validate()) {
if (controller.model.isLoading) return; if (controller.model.isLoading) return;
controller.updateAmount(_amountController.text); await _submitPayment(e, context);
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');
}
} }
}, },
child: ListTile( child: ListTile(

View File

@@ -1,13 +1,16 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:provider/provider.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/gateway/gateway_controller.dart';
import 'package:qpay/screens/transaction_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 { class PollScreen extends StatefulWidget {
const PollScreen({super.key}); final String? transactionId;
const PollScreen({super.key, this.transactionId});
@override @override
State<PollScreen> createState() => _PollScreenState(); State<PollScreen> createState() => _PollScreenState();
@@ -17,8 +20,6 @@ class _PollScreenState extends State<PollScreen> {
late GatewayController controller; late GatewayController controller;
late TransactionController transactionController; late TransactionController transactionController;
bool integrating = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -28,21 +29,35 @@ class _PollScreenState extends State<PollScreen> {
listen: false, listen: false,
); );
controller.pollTransaction(transactionController.model.confirmationData['id']); _startPolling();
} }
void _showErrorSnackBar(String? message) { void _startPolling() {
WidgetsBinding.instance.addPostFrameCallback((_) { // Priority 1: transaction ID passed via route param (/poll/:id)
ScaffoldMessenger.of(context).showSnackBar( String? transactionId = widget.transactionId;
SnackBar(
content: Text(message.toString()), // Priority 2: localStorage (for when gateway redirect causes page reload
behavior: SnackBarBehavior.floating, // and the route param path is not available due to hub redirect override)
backgroundColor: Colors.deepOrange, 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 @override
void dispose() { void dispose() {
@@ -55,6 +70,10 @@ class _PollScreenState extends State<PollScreen> {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Completing your transaction'), title: const Text('Completing your transaction'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/'),
),
), ),
body: ListenableBuilder( body: ListenableBuilder(
listenable: controller, listenable: controller,
@@ -65,60 +84,19 @@ class _PollScreenState extends State<PollScreen> {
}); });
} }
return Container( return StepLoadingWidget(
padding: EdgeInsets.all(50), currentStep: 1,
child: Center( title: 'Verifying Payment',
child: LayoutBuilder( subtitle:
builder: (context, constraints) { 'We\'re checking with our gateway if your transaction was '
return SizedBox( 'successful. This won\'t take long.',
width: double.parse(constraints.maxWidth > ResponsivePolicy.md ? status: controller.model.status,
ResponsivePolicy.md.toString() : isLoading: controller.model.isLoading,
constraints.maxWidth.toString()), errorMessage: 'We failed to check your transaction status',
child: Column( onRetry: () {
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: () {
controller.model.isCancelled = false; 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:shared_preferences/shared_preferences.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:qpay/http/http.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/pay/pay_controller.dart' as pc;
import 'package:qpay/screens/transaction_controller.dart' as tc; import 'package:qpay/screens/transaction_controller.dart' as tc;
import 'package:qpay/screens/home/home_controller.dart' as hc; 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) { void setLoading(bool value) {
model.isLoading = value; model.isLoading = value;
notifyListeners(); notifyListeners();
} }
Future<void> doIntegration(String id) async { Future<ApiResponse<Map<String, dynamic>>> doIntegration(String id) async {
try { try {
model.isLoading = true; model.isLoading = true;
model.status = ''; model.status = '';
notifyListeners(); notifyListeners();
dynamic workflowResponse = await http.get( dynamic workflowResponse = await http.get(
'/public/transaction/integration/$id' '/public/transaction/integration/$id',
); );
logger.i(workflowResponse.toString()); logger.i(workflowResponse.toString());
dynamic response = workflowResponse['body']; dynamic response = workflowResponse['body'];
model.isLoading = false;
if (response['status'] == 'SUCCESS') { if (response['status'] == 'SUCCESS') {
model.status = response['status']; model.status = response['status'];
transactionController.updateReceiptData(response); transactionController.updateReceiptData(response);
notifyListeners();
return ApiResponse.success(Map<String, dynamic>.from(response));
} else { } else {
model.status = response['status']; model.status = response['status'];
model.errorMessage = response['errorMessage']; model.errorMessage = response['errorMessage'];
_showErrorSnackBar(response['errorMessage']);
}
notifyListeners(); notifyListeners();
return ApiResponse.failure(
response['errorMessage'] ?? "Integration failed",
);
}
} catch (e) { } catch (e) {
model.isLoading = false; model.isLoading = false;
model.errorMessage = e.toString(); model.errorMessage = e.toString();
notifyListeners(); notifyListeners();
return ApiResponse.failure(e.toString());
} }
} }
@@ -115,27 +109,28 @@ class ReceiptController extends ChangeNotifier {
.model .model
.formData = transactionController.model.formData.copyWith( .formData = transactionController.model.formData.copyWith(
amount: model.receiptData?['amount'].toStringAsFixed(2) ?? '', amount: model.receiptData?['amount'].toStringAsFixed(2) ?? '',
creditPhone: model.receiptData?['creditPhone'] ?? '', debitPhone: model.receiptData?['debitPhone'] ?? '',
paymentProcessorLabel: model.receiptData?['paymentProcessorLabel'] ?? '', paymentProcessorLabel: model.receiptData?['paymentProcessorLabel'] ?? '',
paymentProcessorName: model.receiptData?['paymentProcessorName'] ?? '', paymentProcessorName: model.receiptData?['paymentProcessorName'] ?? '',
id: null id: null,
); );
setLoading(false); setLoading(false);
context.push('/make-payment'); context.push('/make-payment');
} }
Future<void> getReceiptData(String id) async { Future<ApiResponse<Map<String, dynamic>>> getReceiptData(String id) async {
setLoading(true); setLoading(true);
try { try {
Map<String, dynamic> response = await http.get('/public/transaction/$id'); Map<String, dynamic> response = await http.get('/public/transaction/$id');
model.receiptData = response; model.receiptData = response;
setLoading(false);
return ApiResponse.success(response);
} catch (e) { } catch (e) {
_showErrorSnackBar( setLoading(false);
return ApiResponse.failure(
"Problem fetching transaction data, are you connected to the internet?", "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:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:qpay/http/http.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/home/home_controller.dart' as hc;
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:qpay/screens/transaction_controller.dart'; import 'package:qpay/screens/transaction_controller.dart';
@@ -46,19 +47,7 @@ class RecipientsController extends ChangeNotifier {
).model.selectedProvider; ).model.selectedProvider;
} }
void _showErrorSnackBar(String? message) { Future<ApiResponse<List<Recipient>>> getRecipients([Map<String, String>? params]) async {
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 {
model.errorMessage = null; model.errorMessage = null;
model.isLoading = true; model.isLoading = true;
model.recipients = getDummyRecipients(); model.recipients = getDummyRecipients();
@@ -71,18 +60,19 @@ class RecipientsController extends ChangeNotifier {
); );
model.recipients.clear(); model.recipients.clear();
model.recipients.addAll(response.map((e) => Recipient.fromJson(e))); model.recipients.addAll(response.map((e) => Recipient.fromJson(e)));
model.isLoading = false;
notifyListeners();
return ApiResponse.success(List<Recipient>.from(model.recipients));
} catch (e) { } catch (e) {
_showErrorSnackBar(
"Problem fetching recipients, are you connected to the internet?",
);
model.errorMessage = e.toString(); model.errorMessage = e.toString();
model.recipients = []; model.recipients = [];
logger.e(e); logger.e(e);
notifyListeners();
}
model.isLoading = false; model.isLoading = false;
notifyListeners(); notifyListeners();
return ApiResponse.failure(
"Problem fetching recipients, are you connected to the internet?",
);
}
} }
String buildQueryParameters(Map<String, String> params) { String buildQueryParameters(Map<String, String> params) {

View File

@@ -89,43 +89,18 @@ class _RecipientsScreenState extends State<RecipientsScreen>
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
return SizedBox( return SizedBox(
width: double.parse(constraints.maxWidth > ResponsivePolicy.md ? width: double.parse(
ResponsivePolicy.md.toString() : constraints.maxWidth > ResponsivePolicy.md
constraints.maxWidth.toString()), ? ResponsivePolicy.md.toString()
: constraints.maxWidth.toString(),
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container( _buildTransactionDetailsCard(
decoration: BoxDecoration( label: "Provider",
border: Border.all(color: Colors.black87.withAlpha(20)), value:
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(
controller.model.selectedProvider?.name ?? "", controller.model.selectedProvider?.name ?? "",
style: TextStyle(fontSize: 16),
),
],
),
],
),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
_buildAccountField(controller), _buildAccountField(controller),
@@ -144,21 +119,27 @@ class _RecipientsScreenState extends State<RecipientsScreen>
phone = _accountController.text; phone = _accountController.text;
} }
transactionController.model.formData = transactionController.model.formData =
transactionController.model.formData.copyWith( transactionController.model.formData
creditAccount: _accountController.text, .copyWith(
creditAccount:
_accountController.text,
creditPhone: phone, creditPhone: phone,
); );
context.push("/make-payment"); context.push("/make-payment");
}, },
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: backgroundColor: Theme.of(
Theme.of(context).colorScheme.primary, context,
foregroundColor: ).colorScheme.primary,
Theme.of(context).colorScheme.onPrimary, foregroundColor: Theme.of(
context,
).colorScheme.onPrimary,
), ),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment:
crossAxisAlignment: CrossAxisAlignment.center, MainAxisAlignment.center,
crossAxisAlignment:
CrossAxisAlignment.center,
children: [ children: [
Text( Text(
"Use as new recipient", "Use as new recipient",
@@ -177,14 +158,17 @@ class _RecipientsScreenState extends State<RecipientsScreen>
const SizedBox(height: 20), const SizedBox(height: 20),
Text( Text(
"Recent Recipients", "Recent Recipients",
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
_buildRecipientItems(controller), _buildRecipientItems(controller),
], ],
), ),
); );
} },
), ),
), ),
), ),
@@ -204,14 +188,16 @@ class _RecipientsScreenState extends State<RecipientsScreen>
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text(fieldName, style: TextStyle(fontSize: 16)), Text(fieldName, style: TextStyle(fontSize: 16)),
if(!kIsWeb) if (!kIsWeb)
TextButton( TextButton(
onPressed: () async { onPressed: () async {
if (await FlutterContacts.requestPermission()) { if (await FlutterContacts.requestPermission()) {
final contact = await FlutterContacts.openExternalPick(); final contact = await FlutterContacts.openExternalPick();
if (contact != null) { if (contact != null) {
_accountController.text = contact.phones.first.number; _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( borderSide: BorderSide(
color: Theme.of( color: Theme.of(
context, 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) { Widget _buildRecipientItems(RecipientsController controller) {
if (controller.model.recipients.isEmpty && !controller.model.isLoading) { 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( return ListView.separated(
crossAxisAlignment: CrossAxisAlignment.start, shrinkWrap: true,
children: [ physics: const NeverScrollableScrollPhysics(),
...controller.model.recipients.map( itemCount: controller.model.recipients.length,
(recipient) => Skeletonizer( 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, enabled: controller.model.isLoading,
child: SlideTransition(
position:
_alignListAnimations[animIdx.clamp(
0,
_alignListAnimations.length - 1,
)],
child: Material(
color: Colors.transparent,
child: InkWell( child: InkWell(
onTap: () { onTap: () {
String account = recipient.account ?? ''; String account = recipient.account ?? '';
@@ -313,95 +332,304 @@ class _RecipientsScreenState extends State<RecipientsScreen>
creditPhone: recipient.phoneNumber ?? '', creditPhone: recipient.phoneNumber ?? '',
creditEmail: recipient.email ?? '', creditEmail: recipient.email ?? '',
providerLabel: providerLabel:
transactionController.model.formData.providerLabel.isEmpty transactionController
.model
.formData
.providerLabel
.isEmpty
? recipient.latestProviderLabel ?? '' ? recipient.latestProviderLabel ?? ''
: transactionController.model.formData.providerLabel, : transactionController.model.formData.providerLabel,
); );
context.push("/make-payment"); context.push("/make-payment");
}, },
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(16),
child: Container( 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( decoration: BoxDecoration(
color: Theme.of( color: Colors.white,
context, borderRadius: BorderRadius.circular(16),
).colorScheme.primary.withValues(alpha: 0.1), border: Border.all(color: Colors.grey.shade200, width: 1),
shape: BoxShape.circle, boxShadow: [
), BoxShadow(
child: Text( color: Colors.black.withValues(alpha: 0.04),
recipient.initials ?? 'PK', blurRadius: 8,
style: TextStyle( offset: const Offset(0, 2),
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),
), ),
], ],
), ),
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 && if ((recipient.phoneNumber != null &&
recipient.phoneNumber!.isNotEmpty) || recipient.phoneNumber!.isNotEmpty) ||
(recipient.email != null && (recipient.email != null &&
recipient.email!.isNotEmpty)) recipient.email!.isNotEmpty))
Row( Row(
spacing: 5, spacing: 8,
children: [ children: [
if (recipient.phoneNumber != null && if (recipient.phoneNumber != null &&
recipient.phoneNumber!.isNotEmpty) recipient.phoneNumber!.isNotEmpty)
Text( _buildContactChip(
recipient.phoneNumber ?? '', Icons.phone_outlined,
style: TextStyle( recipient.phoneNumber!,
fontWeight: FontWeight.bold, Colors.grey.shade600,
),
), ),
if (recipient.email != null && if (recipient.email != null &&
recipient.email!.isNotEmpty) recipient.email!.isNotEmpty)
Text( _buildContactChip(
recipient.email ?? '', Icons.email_outlined,
style: TextStyle( recipient.email!,
fontWeight: FontWeight.normal, Colors.grey.shade500,
),
),
],
),
if (recipient.name != null &&
recipient.name!.isNotEmpty)
Text(
recipient.name ?? '',
style: TextStyle(fontWeight: FontWeight.normal),
), ),
], ],
), ),
], ],
), ),
), ),
// 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:flutter/material.dart';
import 'package:gif_view/gif_view.dart'; import 'package:gif_view/gif_view.dart';
import 'package:qpay/main.dart';
class SplashScreen extends StatefulWidget { class SplashScreen extends StatefulWidget {
final VoidCallback onSplashComplete; const SplashScreen({super.key});
const SplashScreen({super.key, required this.onSplashComplete});
@override @override
State<SplashScreen> createState() => _SplashScreenState(); State<SplashScreen> createState() => _SplashScreenState();
@@ -57,14 +56,15 @@ class _SplashScreenState extends State<SplashScreen>
// Start fade out animation // Start fade out animation
await _fadeController.reverse(); 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) { if (mounted) {
widget.onSplashComplete(); splashState.finish();
} }
} catch (e) { } catch (e) {
// If there's an error, still complete the splash screen // If there's an error, still complete the splash screen
if (mounted) { 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 source: sdk
version: "0.0.0" version: "0.0.0"
flutter_web_plugins: flutter_web_plugins:
dependency: transitive dependency: "direct main"
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"

View File

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

View File

@@ -34,6 +34,19 @@
<title>Velocity</title> <title>Velocity</title>
<link rel="manifest" href="manifest.json"> <link rel="manifest" href="manifest.json">
<script type="text/javascript"> <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) { function configure(sessionID) {
if (typeof Checkout === 'undefined') { if (typeof Checkout === 'undefined') {
throw new Error('Checkout SDK not loaded.'); throw new Error('Checkout SDK not loaded.');