completed velocity integration
This commit is contained in:
3
.vscode/launch.json
vendored
3
.vscode/launch.json
vendored
@@ -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"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
107
lib/main.dart
107
lib/main.dart
@@ -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(),
|
||||||
|
|||||||
21
lib/models/api_response.dart
Normal file
21
lib/models/api_response.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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});
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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?",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
},
|
},
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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?");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -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> {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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?",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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();
|
||||||
},
|
},
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
407
lib/widgets/step_loading_widget.dart
Normal file
407
lib/widgets/step_loading_widget.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.');
|
||||||
|
|||||||
Reference in New Issue
Block a user