diff --git a/README.md b/README.md
index ce0e8ab..a982f6e 100644
--- a/README.md
+++ b/README.md
@@ -40,7 +40,8 @@ flutter run --dart-define=APP_ENV=live
```bash
flutter build web --dart-define=APP_ENV=live
-flutter build apk --dart-define=APP_ENV=live
+flutter build apk --dart-define=APP_ENV=live --dart-define=BASE_URL=https://payapi.velocityafrica.net/api
+flutter build appdundle --dart-define=APP_ENV=live --dart-define=BASE_URL=https://payapi.velocityafrica.net/api
```
### Quick pre-release checks
diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml
index 7d4b295..629f7bf 100644
--- a/android/app/src/main/res/drawable-v21/launch_background.xml
+++ b/android/app/src/main/res/drawable-v21/launch_background.xml
@@ -1,18 +1,5 @@
-
+
-
-
-
- -
-
-
-
-
+
diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml
index 3d0d907..629f7bf 100644
--- a/android/app/src/main/res/drawable/launch_background.xml
+++ b/android/app/src/main/res/drawable/launch_background.xml
@@ -1,17 +1,5 @@
-
+
-
-
- -
-
-
-
diff --git a/lib/models/api_response.dart b/lib/models/api_response.dart
index 664bead..d5e5e3c 100644
--- a/lib/models/api_response.dart
+++ b/lib/models/api_response.dart
@@ -3,11 +3,7 @@ class ApiResponse {
final String? error;
final int? statusCode;
- const ApiResponse({
- this.data,
- this.error,
- this.statusCode,
- });
+ const ApiResponse({this.data, this.error, this.statusCode});
bool get isSuccess => error == null && data != null;
@@ -18,4 +14,4 @@ class ApiResponse {
factory ApiResponse.failure(String error, {int? statusCode}) {
return ApiResponse(error: error, statusCode: statusCode);
}
-}
\ No newline at end of file
+}
diff --git a/lib/screens/confirm/confirm_screen.dart b/lib/screens/confirm/confirm_screen.dart
index 11b1d37..00b9e59 100644
--- a/lib/screens/confirm/confirm_screen.dart
+++ b/lib/screens/confirm/confirm_screen.dart
@@ -7,6 +7,9 @@ import 'package:qpay/screens/accounts/account_provider.dart';
import 'package:qpay/screens/confirm/confirm_controller.dart';
import 'package:qpay/widgets/app_snack_bar.dart';
import 'package:skeletonizer/skeletonizer.dart';
+import 'package:url_launcher/url_launcher.dart';
+
+import '../transactions/transaction_model.dart';
class ConfirmScreen extends StatefulWidget {
const ConfirmScreen({super.key});
@@ -122,7 +125,23 @@ class _ConfirmScreenState extends State
.authType ==
"WEB") {
if (kIsWeb) {
- context.push('/gateway-web');
+ final txData = TransactionModel.fromJson(response.data!);
+ // VMC is the only method that requires redirect for now
+ if (txData.targetUrl != null) {
+ final proceed = await _showLeavingSiteBottomSheet();
+ if (proceed != true) return;
+
+ if (!mounted) return;
+ await launchUrl(
+ Uri.parse(txData.targetUrl!),
+ mode: LaunchMode
+ .externalApplication, // always open in external browser
+ );
+ }
+
+ // sent user to another tab and navigate to polling page to wait for response
+ if (!mounted) return;
+ context.push('/poll/${txData.id}');
} else {
context.push('/gateway');
}
@@ -304,6 +323,134 @@ class _ConfirmScreenState extends State
);
}
+ /// Shows a bottom sheet informing the user they are about to leave this site.
+ /// Returns `true` if the user chooses to proceed, `null` if they dismiss or cancel.
+ Future _showLeavingSiteBottomSheet() async {
+ final theme = Theme.of(context);
+ final isDark = theme.brightness == Brightness.dark;
+
+ return showModalBottomSheet(
+ context: context,
+ isScrollControlled: true,
+ backgroundColor: Colors.transparent,
+ builder: (sheetContext) {
+ return Padding(
+ padding: EdgeInsets.only(
+ bottom: MediaQuery.of(sheetContext).viewInsets.bottom,
+ ),
+ child: Container(
+ decoration: BoxDecoration(
+ color: isDark ? const Color(0xFF1A1A2E) : Colors.white,
+ borderRadius: const BorderRadius.vertical(
+ top: Radius.circular(24),
+ ),
+ ),
+ padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Center(
+ child: Container(
+ width: 40,
+ height: 4,
+ margin: const EdgeInsets.only(bottom: 16),
+ decoration: BoxDecoration(
+ color: Colors.grey.shade300,
+ borderRadius: BorderRadius.circular(2),
+ ),
+ ),
+ ),
+ Center(
+ child: Container(
+ width: 56,
+ height: 56,
+ decoration: BoxDecoration(
+ color: Colors.orange.withValues(alpha: 0.1),
+ shape: BoxShape.circle,
+ ),
+ child: const Icon(
+ Icons.open_in_new_rounded,
+ size: 28,
+ color: Colors.orange,
+ ),
+ ),
+ ),
+ const SizedBox(height: 16),
+ Text(
+ 'You\'re leaving QPay',
+ style: TextStyle(
+ fontSize: 20,
+ fontWeight: FontWeight.w700,
+ color: isDark ? Colors.white : Colors.black87,
+ ),
+ textAlign: TextAlign.center,
+ ),
+ const SizedBox(height: 8),
+ Text(
+ 'You are about to be redirected to an external payment page. If nothing happens after the redirect, please make sure pop-ups are allowed in your browser settings.',
+ style: TextStyle(
+ fontSize: 13,
+ fontWeight: FontWeight.w500,
+ color: Colors.grey.shade500,
+ ),
+ textAlign: TextAlign.center,
+ ),
+ const SizedBox(height: 24),
+ SizedBox(
+ width: double.infinity,
+ child: ElevatedButton(
+ onPressed: () {
+ Navigator.of(sheetContext).pop(true);
+ },
+ style: ElevatedButton.styleFrom(
+ backgroundColor: theme.colorScheme.primary,
+ foregroundColor: Colors.white,
+ padding: const EdgeInsets.symmetric(vertical: 14),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(14),
+ ),
+ elevation: 0,
+ ),
+ child: const Text(
+ 'Proceed',
+ style: TextStyle(
+ fontSize: 15,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ),
+ ),
+ const SizedBox(height: 8),
+ SizedBox(
+ width: double.infinity,
+ child: OutlinedButton(
+ onPressed: () {
+ Navigator.of(sheetContext).pop(null);
+ },
+ style: OutlinedButton.styleFrom(
+ padding: const EdgeInsets.symmetric(vertical: 14),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(14),
+ ),
+ ),
+ child: const Text(
+ 'Cancel',
+ style: TextStyle(
+ fontSize: 15,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ },
+ );
+ }
+
@override
Widget build(BuildContext context) {
return Scaffold(
diff --git a/lib/screens/gateway/gateway_controller.dart b/lib/screens/gateway/gateway_controller.dart
index f303adf..32026bd 100644
--- a/lib/screens/gateway/gateway_controller.dart
+++ b/lib/screens/gateway/gateway_controller.dart
@@ -8,10 +8,13 @@ import 'package:provider/provider.dart';
class GatewayModel {
bool isLoading = false;
- String status = '';
+ String status = 'PENDING';
+ String pollStatus = 'PENDING';
String? errorMessage;
bool isCancelled = false;
int count = 0;
+ int pollAttempt = 0;
+ int pollMaxAttempts = 20;
}
class GatewayController extends ChangeNotifier {
@@ -84,19 +87,26 @@ class GatewayController extends ChangeNotifier {
Future>> pollTransaction(String uid) async {
// poll up to 15 times (~45 seconds at 3-second intervals)
- int maxAttempts = 15;
int attempt = 0;
- while (!model.isCancelled && attempt < maxAttempts) {
+ model.pollAttempt = 0;
+ notifyListeners();
+
+ while (!model.isCancelled && attempt < model.pollMaxAttempts) {
attempt++;
+ model.pollAttempt = attempt;
+ notifyListeners();
ApiResponse