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> 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(); diff --git a/lib/screens/pay/pay_screen.dart b/lib/screens/pay/pay_screen.dart index 0d0ff6d..e72fd2b 100644 --- a/lib/screens/pay/pay_screen.dart +++ b/lib/screens/pay/pay_screen.dart @@ -304,7 +304,7 @@ class _PayScreenState extends State 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, diff --git a/lib/screens/poll/poll_screen.dart b/lib/screens/poll/poll_screen.dart index 0a84319..5a920ed 100644 --- a/lib/screens/poll/poll_screen.dart +++ b/lib/screens/poll/poll_screen.dart @@ -78,7 +78,7 @@ class _PollScreenState extends State { 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 { 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(); }, ); diff --git a/lib/screens/recipient/recipients_screen.dart b/lib/screens/recipient/recipients_screen.dart index 30d00ef..44313bb 100644 --- a/lib/screens/recipient/recipients_screen.dart +++ b/lib/screens/recipient/recipients_screen.dart @@ -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 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 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( diff --git a/lib/screens/transactions/transaction_model.dart b/lib/screens/transactions/transaction_model.dart index 22db389..0f3b0a2 100644 --- a/lib/screens/transactions/transaction_model.dart +++ b/lib/screens/transactions/transaction_model.dart @@ -95,6 +95,7 @@ class TransactionModel { final String? creditEmail; final String? productUid; final String? targetUrl; + final String? redirectUrl; final List>? additionalData; const TransactionModel({ @@ -145,6 +146,7 @@ class TransactionModel { this.creditEmail, this.productUid, this.targetUrl, + this.redirectUrl, this.additionalData, }); diff --git a/lib/screens/transactions/transaction_model.g.dart b/lib/screens/transactions/transaction_model.g.dart index 1e83909..11b7572 100644 --- a/lib/screens/transactions/transaction_model.g.dart +++ b/lib/screens/transactions/transaction_model.g.dart @@ -62,6 +62,7 @@ TransactionModel _$TransactionModelFromJson(Map 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 _$TransactionModelToJson( 'creditEmail': instance.creditEmail, 'productUid': instance.productUid, 'targetUrl': instance.targetUrl, + 'redirectUrl': instance.redirectUrl, 'additionalData': const AdditionalDataJsonConverter().toJson( instance.additionalData, ), diff --git a/lib/screens/workspaces/workspace_screen.dart b/lib/screens/workspaces/workspace_screen.dart index 49b4979..8c1f312 100644 --- a/lib/screens/workspaces/workspace_screen.dart +++ b/lib/screens/workspaces/workspace_screen.dart @@ -59,9 +59,9 @@ class _WorkspaceScreenState extends State _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(); } } diff --git a/lib/widgets/step_loading_widget.dart b/lib/widgets/step_loading_widget.dart index 076be52..ea627ca 100644 --- a/lib/widgets/step_loading_widget.dart +++ b/lib/widgets/step_loading_widget.dart @@ -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 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') diff --git a/pubspec.yaml b/pubspec.yaml index 47dd18e..47b3d76 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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