improving VMC flow

This commit is contained in:
2026-06-29 15:10:37 +02:00
parent 17db608dbc
commit 55921f6ee3
14 changed files with 231 additions and 83 deletions

View File

@@ -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

View File

@@ -1,18 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<!-- Remove this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- App Logo -->
<item
android:width="100dp"
android:height="100dp"
android:gravity="center">
<bitmap
android:gravity="fill"
android:src="@mipmap/ic_launcher"
android:mipMap="true"/>
</item>
<item android:drawable="@color/splash_background" />
</layer-list>

View File

@@ -1,17 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<!-- Remove this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/splash_background" />
<!-- App Logo -->
<item
android:width="100dp"
android:height="100dp"
android:gravity="center">
<bitmap
android:gravity="fill"
android:src="@mipmap/ic_launcher"
android:mipMap="true"/>
</item>
</layer-list>

View File

@@ -3,11 +3,7 @@ class ApiResponse<T> {
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<T> {
factory ApiResponse.failure(String error, {int? statusCode}) {
return ApiResponse(error: error, statusCode: statusCode);
}
}
}

View File

@@ -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<ConfirmScreen>
.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<ConfirmScreen>
);
}
/// 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<bool?> _showLeavingSiteBottomSheet() async {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
return showModalBottomSheet<bool>(
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(

View File

@@ -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<ApiResponse<Map<String, dynamic>>> 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<Map<String, dynamic>> result = await poll(uid);
// Only stop on success or failure
if (result.isSuccess) {
String status = model.status;
// If status is SUCCESS, we're done
if (status == 'SUCCESS') {
if (model.status == 'SUCCESS') {
model.isCancelled = true;
model.pollStatus = model.status;
finishLoading();
notifyListeners();
return result;
}
@@ -107,18 +117,15 @@ class GatewayController extends ChangeNotifier {
} else {
await Future.delayed(const Duration(seconds: 3));
if (model.isCancelled) break;
// Network error — show failure UI and stop
model.isCancelled = true;
model.status = 'FAILED';
model.errorMessage = result.error;
return result;
model.status = 'PENDING';
}
notifyListeners();
}
// Timed out after max attempts
model.isCancelled = true;
model.status = 'FAILED';
model.pollStatus = 'FAILED';
model.errorMessage = 'We failed to check your transaction status';
finishLoading();
notifyListeners();

View File

@@ -304,7 +304,7 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
.account_balance_wallet_outlined,
label: "City Wallet Balance",
value:
'\$${cityWallet!.balance.toStringAsFixed(2)}',
'\$${cityWallet.balance.toStringAsFixed(2)}',
valueStyle: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,

View File

@@ -78,7 +78,7 @@ class _PollScreenState extends State<PollScreen> {
body: ListenableBuilder(
listenable: controller,
builder: (context, child) {
if (controller.model.status == 'SUCCESS') {
if (controller.model.pollStatus == 'SUCCESS') {
WidgetsBinding.instance.addPostFrameCallback((_) {
context.go('/integration');
});
@@ -90,13 +90,17 @@ class _PollScreenState extends State<PollScreen> {
subtitle:
'We\'re checking with our gateway if your transaction was '
'successful. This won\'t take long.',
status: controller.model.status,
status: controller.model.pollStatus,
isLoading: controller.model.isLoading,
errorMessage:
controller.model.errorMessage ??
'We failed to check your transaction status',
pollAttempt: controller.model.pollAttempt,
pollMaxAttempts: controller.model.pollMaxAttempts,
onRetry: () {
controller.model.isCancelled = false;
controller.model.pollStatus = 'PENDING';
controller.model.pollAttempt = 0;
_startPolling();
},
);

View File

@@ -13,8 +13,6 @@ import 'package:skeletonizer/skeletonizer.dart';
import 'package:qpay/screens/recipient/recipients_controller.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../onboarding/auth_provider.dart';
class RecipientsScreen extends StatefulWidget {
const RecipientsScreen({super.key});
@@ -87,6 +85,26 @@ class _RecipientsScreenState extends State<RecipientsScreen>
super.dispose();
}
_applyRecipient(Recipient recipient) {
String account = recipient.account ?? '';
if (!transactionController.model.selectedProvider!.requiresAccount) {
account = recipient.phoneNumber ?? '';
}
transactionController.model.formData = transactionController.model.formData
.copyWith(
creditAccount: account,
creditName: recipient.name ?? '',
creditPhone: recipient.phoneNumber ?? '',
creditEmail: recipient.email ?? '',
providerLabel:
transactionController.model.formData.providerLabel.isEmpty
? recipient.latestProviderLabel ?? ''
: transactionController.model.formData.providerLabel,
);
if (!mounted) return;
context.push("/make-payment");
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -330,32 +348,7 @@ class _RecipientsScreenState extends State<RecipientsScreen>
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
String account = recipient.account ?? '';
if (!transactionController
.model
.selectedProvider!
.requiresAccount) {
account = recipient.phoneNumber ?? '';
}
transactionController
.model
.formData = transactionController.model.formData.copyWith(
creditAccount: account,
creditName: recipient.name ?? '',
creditPhone: recipient.phoneNumber ?? '',
creditEmail: recipient.email ?? '',
providerLabel:
transactionController
.model
.formData
.providerLabel
.isEmpty
? recipient.latestProviderLabel ?? ''
: transactionController.model.formData.providerLabel,
);
context.push("/make-payment");
},
onTap: () => _applyRecipient(recipient),
borderRadius: BorderRadius.circular(16),
child: Container(
decoration: BoxDecoration(

View File

@@ -95,6 +95,7 @@ class TransactionModel {
final String? creditEmail;
final String? productUid;
final String? targetUrl;
final String? redirectUrl;
final List<Map<String, dynamic>>? additionalData;
const TransactionModel({
@@ -145,6 +146,7 @@ class TransactionModel {
this.creditEmail,
this.productUid,
this.targetUrl,
this.redirectUrl,
this.additionalData,
});

View File

@@ -62,6 +62,7 @@ TransactionModel _$TransactionModelFromJson(Map<String, dynamic> json) =>
creditEmail: json['creditEmail'] as String?,
productUid: json['productUid'] as String?,
targetUrl: json['targetUrl'] as String?,
redirectUrl: json['redirectUrl'] as String?,
additionalData: const AdditionalDataJsonConverter().fromJson(
json['additionalData'] as List?,
),
@@ -120,6 +121,7 @@ Map<String, dynamic> _$TransactionModelToJson(
'creditEmail': instance.creditEmail,
'productUid': instance.productUid,
'targetUrl': instance.targetUrl,
'redirectUrl': instance.redirectUrl,
'additionalData': const AdditionalDataJsonConverter().toJson(
instance.additionalData,
),

View File

@@ -59,9 +59,9 @@ class _WorkspaceScreenState extends State<WorkspaceScreen>
_loadWorkspaces();
} else {
// if not logged in then create temp workspace
SharedPreferences.getInstance().then((prefs) {
prefs.setString('workspaceId', const Uuid().v4());
});
if (prefs.getString("workspaceId") == null) {
await prefs.setString("workspaceId", const Uuid().v4());
}
_navigateToHome();
}
}

View File

@@ -11,6 +11,8 @@ class StepLoadingWidget extends StatefulWidget {
final bool isLoading;
final String? errorMessage;
final VoidCallback? onRetry;
final int pollAttempt;
final int pollMaxAttempts;
const StepLoadingWidget({
super.key,
@@ -21,6 +23,8 @@ class StepLoadingWidget extends StatefulWidget {
required this.isLoading,
this.errorMessage,
this.onRetry,
this.pollAttempt = 0,
this.pollMaxAttempts = 0,
});
@override
@@ -144,7 +148,24 @@ class _StepLoadingWidgetState extends State<StepLoadingWidget>
textAlign: TextAlign.center,
),
const SizedBox(height: 50),
// Poll attempt counter
if (widget.pollMaxAttempts > 0 &&
widget.status != 'SUCCESS' &&
widget.status != 'FAILED')
Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(
'Attempt ${widget.pollAttempt} of ${widget.pollMaxAttempts}',
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade500,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 36),
// Error state with retry
if (widget.status == 'FAILED')

View File

@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1
version: 1.0.1+3
environment:
sdk: ^3.9.2