diff --git a/assets/.env.live b/assets/.env.live index 0993040..285bd32 100644 --- a/assets/.env.live +++ b/assets/.env.live @@ -2,4 +2,3 @@ BASE_URL=https://payapi.velocityafrica.net/api CLIENTID=77433712483-ng7pntvcpf6tnjccriuqm8dbna8vvp3b.apps.googleusercontent.com SIMULATE_PAYMENT_SUCCESS=false GATEWAY_SCRIPT_URL=https://na.gateway.mastercard.com/static/checkout/checkout.min.js - diff --git a/assets/.env.test b/assets/.env.test index 3dfacdc..97b267c 100644 --- a/assets/.env.test +++ b/assets/.env.test @@ -4,4 +4,3 @@ BASE_URL=http://localhost:6950/api CLIENTID=77433712483-ng7pntvcpf6tnjccriuqm8dbna8vvp3b.apps.googleusercontent.com SIMULATE_PAYMENT_SUCCESS=false GATEWAY_SCRIPT_URL=https://test-gateway.mastercard.com/static/checkout/checkout.min.js - diff --git a/lib/http/http.dart b/lib/http/http.dart index e5d0701..6896afa 100644 --- a/lib/http/http.dart +++ b/lib/http/http.dart @@ -45,6 +45,31 @@ class Http { return response.data; } + /// Returns the raw [Response] object (including status code) + /// instead of just the response body. + Future getRaw(String url) async { + Map headers = await getHeaders(); + + final response = await dio.get( + baseUrl + url, + options: Options(headers: headers), + ); + logger.i(response.data.toString()); + return response; + } + + /// Issues a POST request without authentication headers and returns + /// the raw [Response] object. + Future postRaw(String url, {dynamic data}) async { + final response = await dio.post( + baseUrl + url, + data: data, + options: Options(headers: {'Content-Type': 'application/json'}), + ); + logger.i(response.data.toString()); + return response; + } + Future post(String url, dynamic data) async { Map headers = {'Content-Type': 'application/json'}; diff --git a/lib/main.dart b/lib/main.dart index cc09c4f..00cb26d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,6 +6,7 @@ import 'package:qpay/routes.dart'; import 'package:qpay/url_strategy.dart'; import 'package:qpay/screens/transaction_controller.dart'; import 'package:qpay/screens/onboarding/onboarding_controller.dart'; +import 'package:qpay/providers/splash_provider.dart'; import 'package:qpay/theme/app_theme.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:firebase_core/firebase_core.dart'; @@ -49,6 +50,9 @@ void main() async { ChangeNotifierProvider( create: (_) => OnboardingController(), ), + ChangeNotifierProvider( + create: (_) => SplashProvider(), + ), ], child: const MyApp(), ), diff --git a/lib/providers/splash_provider.dart b/lib/providers/splash_provider.dart new file mode 100644 index 0000000..36863d5 --- /dev/null +++ b/lib/providers/splash_provider.dart @@ -0,0 +1,75 @@ +import 'package:flutter/foundation.dart'; +import 'package:qpay/http/http.dart'; +import 'package:qpay/browser.dart'; + +const appBuildNumber = 2; + +/// Describes the result of the build-number check performed during splash. +enum SplashCheckStatus { checking, noUpdate, updateAvailable } + +class SplashProvider extends ChangeNotifier { + final Http _http = Http(); + + SplashCheckStatus _status = SplashCheckStatus.checking; + Map? _updatePayload; + + SplashCheckStatus get status => _status; + Map? get updatePayload => _updatePayload; + + /// The store / download URL from the server payload, or `null` if + /// the server didn't include one. + String? get downloadUrl => _updatePayload?['downloadUrl']?.toString(); + + /// Calls the server endpoint to see whether a newer app build exists. + /// + /// * 204 → no new build — proceed with normal app loading. + /// * 200 → a new build is available — the splash screen will show a + /// non-dismissible bottom sheet offering the user a hard refresh. + Future checkVersion() async { + _status = SplashCheckStatus.checking; + notifyListeners(); + + try { + final buildNumber = appBuildNumber; + final response = await _http.getRaw( + '/public/app-version/check/app?currentBuildNumber=$buildNumber', + ); + + if (response.statusCode == 204) { + _status = SplashCheckStatus.noUpdate; + } else if (response.statusCode == 200) { + _status = SplashCheckStatus.updateAvailable; + _updatePayload = response.data is Map + ? response.data as Map + : null; + } else { + // Unexpected status code – don't block the user. + _status = SplashCheckStatus.noUpdate; + } + } catch (_) { + // Network / server errors should not prevent the app from loading. + _status = SplashCheckStatus.noUpdate; + } + + notifyListeners(); + } + + /// 1. Sends a POST request to clear the Cloudflare cache. + /// 2. Waits 5 seconds to give the CDN time to propagate. + /// 3. Performs a hard refresh by reloading the current URL, which + /// forces the browser to re-fetch all cached assets. + Future clearCacheAndRefresh() async { + try { + await _http.postRaw('/public/cache/cloudflare/clear'); + } catch (_) { + // Proceed with refresh even if the cache-clear request fails. + } + + // Allow Cloudflare a few seconds to flush its edge cache. + await Future.delayed(const Duration(seconds: 5)); + + // Hard-refresh: navigate to the current location which triggers a + // full page reload on web (and restarts the app on other platforms). + Browser.location.href = Browser.location.href; + } +} diff --git a/lib/screens/groups/models/group_batch_model.dart b/lib/screens/groups/models/group_batch_model.dart index ade6d91..062d369 100644 --- a/lib/screens/groups/models/group_batch_model.dart +++ b/lib/screens/groups/models/group_batch_model.dart @@ -50,6 +50,7 @@ class GroupBatchItem { final String? groupBatchId; final String? recipientName; final String? recipientPhone; + final String? recipientAccount; final double? amount; final String? status; final String? transactionId; @@ -65,6 +66,7 @@ class GroupBatchItem { this.groupBatchId, this.recipientName, this.recipientPhone, + this.recipientAccount, this.amount, this.status, this.transactionId, @@ -146,4 +148,4 @@ class GroupBatchUpdateRequest { _$GroupBatchUpdateRequestFromJson(json); Map toJson() => _$GroupBatchUpdateRequestToJson(this); -} \ No newline at end of file +} diff --git a/lib/screens/groups/models/group_batch_model.g.dart b/lib/screens/groups/models/group_batch_model.g.dart index 95d3db7..bb6cd31 100644 --- a/lib/screens/groups/models/group_batch_model.g.dart +++ b/lib/screens/groups/models/group_batch_model.g.dart @@ -49,6 +49,7 @@ GroupBatchItem _$GroupBatchItemFromJson(Map json) => groupBatchId: json['groupBatchId'] as String?, recipientName: json['recipientName'] as String?, recipientPhone: json['recipientPhone'] as String?, + recipientAccount: json['recipientAccount'] as String?, amount: (json['amount'] as num?)?.toDouble(), status: json['status'] as String?, transactionId: json['transactionId'] as String?, @@ -66,6 +67,7 @@ Map _$GroupBatchItemToJson(GroupBatchItem instance) => 'groupBatchId': instance.groupBatchId, 'recipientName': instance.recipientName, 'recipientPhone': instance.recipientPhone, + 'recipientAccount': instance.recipientAccount, 'amount': instance.amount, 'status': instance.status, 'transactionId': instance.transactionId, diff --git a/lib/screens/groups/screens/batch_detail_screen.dart b/lib/screens/groups/screens/batch_detail_screen.dart index a7ee691..98ab6d8 100644 --- a/lib/screens/groups/screens/batch_detail_screen.dart +++ b/lib/screens/groups/screens/batch_detail_screen.dart @@ -2388,19 +2388,13 @@ class _BatchDetailScreenState extends State ), const SizedBox(height: 2), Text( - item.recipientPhone ?? '', + item.recipientAccount ?? '', style: TextStyle(fontSize: 12, color: Colors.grey.shade500), ), if (item.billName != null || item.billProductName != null) const SizedBox(height: 4), if (item.billName != null || item.billProductName != null) _buildBillInfoRow(item, isDark), - if (item.providerLabel != null && - item.providerLabel!.isNotEmpty) - const SizedBox(height: 3), - if (item.providerLabel != null && - item.providerLabel!.isNotEmpty) - _buildProviderBadge(item, isDark), ], ), ), @@ -2429,14 +2423,6 @@ class _BatchDetailScreenState extends State final parts = []; if (item.billName != null && item.billName!.isNotEmpty) { parts.addAll([ - WidgetSpan( - child: Icon( - Icons.receipt_rounded, - size: 12, - color: Colors.grey.shade500, - ), - ), - const WidgetSpan(child: SizedBox(width: 3)), TextSpan( text: item.billName, style: TextStyle( diff --git a/lib/screens/groups/screens/group_create_screen.dart b/lib/screens/groups/screens/group_create_screen.dart index b9a43ff..211894b 100644 --- a/lib/screens/groups/screens/group_create_screen.dart +++ b/lib/screens/groups/screens/group_create_screen.dart @@ -49,12 +49,13 @@ class _GroupCreateScreenState extends State { if (parts.length >= 2) { final name = parts[0].trim(); final account = parts[1].trim(); + final phone = parts[2].trim(); if (name.isNotEmpty && account.isNotEmpty) { members.add( Recipient( name: name, account: account, - phoneNumber: account, + phoneNumber: phone, latestProviderLabel: '', ), ); diff --git a/lib/screens/splash_screen.dart b/lib/screens/splash_screen.dart index d56b96b..88ddc48 100644 --- a/lib/screens/splash_screen.dart +++ b/lib/screens/splash_screen.dart @@ -1,6 +1,10 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:gif_view/gif_view.dart'; +import 'package:provider/provider.dart'; +import 'package:qpay/providers/splash_provider.dart'; import 'package:qpay/routes.dart'; +import 'package:url_launcher/url_launcher.dart'; class SplashScreen extends StatefulWidget { const SplashScreen({super.key}); @@ -16,6 +20,8 @@ class _SplashScreenState extends State late Animation _fadeAnimation; late Animation _scaleAnimation; + bool _bottomSheetShown = false; + @override void initState() { super.initState(); @@ -40,8 +46,28 @@ class _SplashScreenState extends State CurvedAnimation(parent: _scaleController, curve: Curves.elasticOut), ); - // Start animations + // Start animations and version check _startAnimations(); + + // Kick off the version check via the provider (mounted-checked + // inside a post-frame callback so the widget is in the tree). + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + context.read().addListener(_onStatusChanged); + context.read().checkVersion(); + } + }); + } + + void _onStatusChanged() { + if (!mounted) return; + final provider = context.read(); + + if (provider.status == SplashCheckStatus.updateAvailable) { + _showUpdateBottomSheet(); + } + // For noUpdate we do nothing here — the animation will call + // splashState.finish() when it completes naturally. } void _startAnimations() async { @@ -50,16 +76,20 @@ class _SplashScreenState extends State _fadeController.forward(); _scaleController.forward(); - // Wait for 3 seconds (adjust as needed) + // Let the splash branding play for at least 1.25 s. await Future.delayed(const Duration(milliseconds: 1250)); // Start fade out animation await _fadeController.reverse(); - // Mark splash complete so GoRouter redirect releases navigation - // to the originally intended route (e.g. /poll/:id). + // After the animation completes, release navigation to the + // originally intended route UNLESS an update is available + // (in which case the bottom sheet keeps the user on splash). if (mounted) { - splashState.finish(); + final provider = context.read(); + if (provider.status != SplashCheckStatus.updateAvailable) { + splashState.finish(); + } } } catch (e) { // If there's an error, still complete the splash screen @@ -69,8 +99,208 @@ class _SplashScreenState extends State } } + void _showUpdateBottomSheet() { + // Prevent showing the sheet more than once. + if (_bottomSheetShown) return; + _bottomSheetShown = true; + + final provider = context.read(); + final payload = provider.updatePayload; + final version = payload?['version']?.toString() ?? 'a newer'; + + if (kIsWeb) { + _showWebUpdateSheet(provider, version); + } else { + _showMobileUpdateSheet(provider, version); + } + } + + /// Web: clear Cloudflare cache, wait 5 s, then hard-refresh the page + /// so the browser picks up the new deployment. + void _showWebUpdateSheet(SplashProvider provider, String version) { + final message = + 'A new version of the app is available. Please refresh to get the latest update.'; + + showModalBottomSheet( + context: context, + isDismissible: false, + enableDrag: false, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (sheetContext) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Icon + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.orange.shade50, + shape: BoxShape.circle, + ), + child: Icon( + Icons.system_update_rounded, + color: Colors.orange.shade700, + size: 40, + ), + ), + const SizedBox(height: 20), + + // Title + Text( + 'Update Available', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 12), + + // Message + Text( + message, + textAlign: TextAlign.center, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: Colors.grey.shade600), + ), + const SizedBox(height: 28), + + // Refresh button + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () { + provider.clearCacheAndRefresh(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.orange.shade700, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text( + 'Refresh to Update', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ), + ); + }, + ); + } + + /// Mobile (Android / iOS): open the app store / download URL so the + /// user can install the latest version. No cache clearing needed. + void _showMobileUpdateSheet(SplashProvider provider, String version) { + final message = + provider.updatePayload?['message']?.toString() ?? + 'A new version of the app is available. Please download the latest version from the store.'; + final downloadUrl = provider.downloadUrl; + + showModalBottomSheet( + context: context, + isDismissible: false, + enableDrag: false, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (sheetContext) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Icon + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.orange.shade50, + shape: BoxShape.circle, + ), + child: Icon( + Icons.system_update_rounded, + color: Colors.orange.shade700, + size: 40, + ), + ), + const SizedBox(height: 20), + + // Title + Text( + 'Update Available', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 12), + + // Message + Text( + 'Version $version is now available. $message', + textAlign: TextAlign.center, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: Colors.grey.shade600), + ), + const SizedBox(height: 28), + + // Download button + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () { + if (downloadUrl != null && downloadUrl.isNotEmpty) { + launchUrl( + Uri.parse(downloadUrl), + mode: LaunchMode.externalApplication, + ); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.orange.shade700, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text( + 'Download Update', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ), + ); + }, + ); + } + @override void dispose() { + try { + context.read().removeListener(_onStatusChanged); + } catch (_) {} _fadeController.dispose(); _scaleController.dispose(); super.dispose(); @@ -108,4 +338,4 @@ class _SplashScreenState extends State ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/transaction_item_widget.dart b/lib/widgets/transaction_item_widget.dart index 843f2a0..cf6982e 100644 --- a/lib/widgets/transaction_item_widget.dart +++ b/lib/widgets/transaction_item_widget.dart @@ -141,21 +141,47 @@ class _TransactionItemWidgetState extends State { ], ), const SizedBox(height: 4), - Text( - widget.transaction['createdAt'] != null - ? timeago.format( - DateTime.parse( - widget.transaction['createdAt'], - ), - ) - : '', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w400, - color: isDark - ? Colors.white54 - : Colors.grey.shade500, - ), + Row( + children: [ + if (widget.transaction['billProductName'] != + null) ...[ + Text( + widget.transaction['billProductName'], + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w400, + color: isDark + ? Colors.white54 + : Colors.grey.shade500, + ), + ), + Text( + ' • ', + style: TextStyle( + fontSize: 11, + color: isDark + ? Colors.white24 + : Colors.grey.shade400, + ), + ), + ], + Text( + widget.transaction['createdAt'] != null + ? timeago.format( + DateTime.parse( + widget.transaction['createdAt'], + ), + ) + : '', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w400, + color: isDark + ? Colors.white54 + : Colors.grey.shade500, + ), + ), + ], ), ], ),