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/screens/splash/splash_provider.dart'; import 'package:qpay/routes.dart'; import 'package:url_launcher/url_launcher.dart'; class SplashScreen extends StatefulWidget { const SplashScreen({super.key}); @override State createState() => _SplashScreenState(); } class _SplashScreenState extends State with TickerProviderStateMixin { late AnimationController _fadeController; late AnimationController _scaleController; late Animation _fadeAnimation; late Animation _scaleAnimation; bool _bottomSheetShown = false; @override void initState() { super.initState(); // Initialize animation controllers _fadeController = AnimationController( duration: const Duration(milliseconds: 1000), vsync: this, ); _scaleController = AnimationController( duration: const Duration(milliseconds: 800), vsync: this, ); // Create animations _fadeAnimation = Tween(begin: 0.0, end: 1.0).animate( CurvedAnimation(parent: _fadeController, curve: Curves.easeInOut), ); _scaleAnimation = Tween(begin: 0.8, end: 1.0).animate( CurvedAnimation(parent: _scaleController, curve: Curves.elasticOut), ); // 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 { try { // Start fade in and scale animations _fadeController.forward(); _scaleController.forward(); // 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(); // 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) { final provider = context.read(); if (provider.status != SplashCheckStatus.updateAvailable) { splashState.finish(); } } } catch (e) { // If there's an error, still complete the splash screen if (mounted) { splashState.finish(); } } } 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 Consumer( builder: (context, splashProvider, _) { final statusText = splashProvider.isRefreshing ? 'Clearing cache and preparing the latest version. ' 'This may take a few seconds...' : message; return Text( statusText, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Colors.grey.shade600, ), ); }, ), const SizedBox(height: 28), // Refresh button Consumer( builder: (context, splashProvider, _) { final refreshing = splashProvider.isRefreshing; return SizedBox( width: double.infinity, child: ElevatedButton( onPressed: refreshing ? null : () { splashProvider.clearCacheAndRefresh(); }, style: ElevatedButton.styleFrom( backgroundColor: Colors.orange.shade700, foregroundColor: Colors.white, disabledBackgroundColor: Colors.orange.shade400, disabledForegroundColor: Colors.white70, padding: const EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), child: refreshing ? const Row( mainAxisAlignment: MainAxisAlignment.center, children: [ SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation( Colors.white, ), ), ), SizedBox(width: 12), Text( 'Refreshing...', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, ), ), ], ) : 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 = '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(); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.white, body: AnimatedBuilder( animation: Listenable.merge([_fadeController, _scaleController]), builder: (context, child) { return FadeTransition( opacity: _fadeAnimation, child: ScaleTransition( scale: _scaleAnimation, child: Center( child: SizedBox( width: 200, child: ClipRRect( borderRadius: BorderRadius.circular(20), child: GifView.asset( 'assets/velocity-animation.gif', width: MediaQuery.of(context).size.width, height: MediaQuery.of(context).size.height, frameRate: 30, fit: BoxFit.contain, ), ), ), ), ), ); }, ), ); } }