import 'dart:async'; import 'dart:math'; import 'package:flutter/material.dart'; import '../models/app_version.dart'; import '../widgets/update_dialog.dart'; import '../theme/m3_motion.dart'; /// Simple binary rain animation with a label for the update check splash. enum UpdateCheckStatus { checking, noInternet, upToDate, updateFound, error } /// Simple binary rain animation with a label for the update check splash. class UpdateCheckingScreen extends StatefulWidget { const UpdateCheckingScreen({ super.key, this.onCompleted, this.checkForUpdates, }); /// Called once the check completes (success, no internet, etc.). final void Function(AppUpdateInfo? info)? onCompleted; /// Optional update check implementation. /// /// If null, the screen will simulate a check and mark it as up-to-date. final Future Function()? checkForUpdates; @override State createState() => _UpdateCheckingScreenState(); } class _UpdateCheckingScreenState extends State with SingleTickerProviderStateMixin { static const int cols = 14; static const int minDrops = 8; static const double minSpeed = 2.0; static const double maxSpeed = 4.5; static const double rainHeight = 150.0; static const double labelZoneHeight = 32.0; static const double rainContainerHeight = rainHeight + labelZoneHeight; static const double cloudWidth = 220.0; final List<_Drop> _drops = []; final List<_Spark> _sparks = []; Timer? _timer; final Random _rng = Random(); late final AnimationController _cloudController; double _labelGlow = 0.0; // 0..1 UpdateCheckStatus _status = UpdateCheckStatus.checking; bool _hasShownUpdateDialog = false; AppUpdateInfo? _info; @override void initState() { super.initState(); _cloudController = AnimationController( vsync: this, duration: const Duration(seconds: 3), )..repeat(reverse: true); _timer = Timer.periodic(const Duration(milliseconds: 50), (_) { _tick(); }); _runUpdateCheck(); } String get _statusLabel { switch (_status) { case UpdateCheckStatus.checking: return 'Checking for updates...'; case UpdateCheckStatus.noInternet: return 'No internet connection'; case UpdateCheckStatus.upToDate: if ((_info?.currentBuildNumber.isNotEmpty) == true) { return 'Up to date (${_info!.currentBuildNumber})'; } return 'App is already up to date'; case UpdateCheckStatus.updateFound: final current = _info?.currentBuildNumber ?? ''; final latest = _info?.latestVersion?.versionCode ?? ''; if (current.isNotEmpty && latest.isNotEmpty) { return 'Update found ($current → $latest)'; } return 'Update found!'; case UpdateCheckStatus.error: return 'Unable to check updates'; } } Future _runUpdateCheck() async { // Allow the rain animation to play for a short time before completing. await Future.delayed(const Duration(milliseconds: 900)); try { final info = await (widget.checkForUpdates?.call() ?? Future.value( AppUpdateInfo( currentBuildNumber: '', latestVersion: null, isUpdateAvailable: false, isForceUpdate: false, ), )); if (!mounted) return; setState(() { _info = info; _status = info.isUpdateAvailable ? UpdateCheckStatus.updateFound : UpdateCheckStatus.upToDate; }); // If an update is found, show the update dialog from here so it is // guaranteed to appear even if the parent wrapper isn't ready. if (info.isUpdateAvailable && !_hasShownUpdateDialog) { _hasShownUpdateDialog = true; WidgetsBinding.instance.addPostFrameCallback((_) async { if (!mounted) return; try { await _showM3Dialog( context: context, barrierDismissible: !info.isForceUpdate, builder: (_) => UpdateDialog(info: info), ); } catch (_) {} }); } // Let the UI show the final status briefly before continuing. await Future.delayed(const Duration(milliseconds: 400)); WidgetsBinding.instance.addPostFrameCallback((_) { widget.onCompleted?.call(info); }); } catch (_) { if (!mounted) return; setState(() { _status = UpdateCheckStatus.error; }); WidgetsBinding.instance.addPostFrameCallback((_) { widget.onCompleted?.call(null); }); } // Brief pause to let user read the final state. await Future.delayed(const Duration(milliseconds: 850)); if (!mounted) return; // If no completion callback is provided, try to close this screen. if (widget.onCompleted == null) { Navigator.of(context).maybePop(); } } Future _showM3Dialog({ required BuildContext context, required WidgetBuilder builder, bool barrierDismissible = true, }) { return showGeneralDialog( context: context, barrierDismissible: barrierDismissible, barrierColor: Colors.black54, transitionDuration: M3Motion.standard, pageBuilder: (context, animation, secondaryAnimation) => builder(context), transitionBuilder: (context, animation, secondaryAnimation, child) { // Use a Material-3–style fade-through transition. return _M3DialogFadeThrough( animation: animation, secondaryAnimation: secondaryAnimation, child: child, ); }, ); } void _tick() { setState(() { // Keep at least a couple drops near the label so the rain looks like it // is actually hitting the text. final labelTop = rainHeight - 8; final dropsNearLabel = _drops.where((d) => d.y > labelTop - 14).length; final neededDrops = max(0, 2 - dropsNearLabel); for (var i = 0; i < neededDrops; i++) { _drops.add( _Drop( col: _rng.nextInt(cols), y: labelTop - 10 - _rng.nextDouble() * 8, speed: _rng.nextDouble() * (maxSpeed - minSpeed) + minSpeed, ), ); } // Add occasional fresh drops from the top. if (_drops.length < minDrops || _rng.nextDouble() < 0.2) { _drops.add( _Drop( col: _rng.nextInt(cols), y: 0.0, speed: _rng.nextDouble() * (maxSpeed - minSpeed) + minSpeed, ), ); } // Remove drops once they reach the label area so they don't fall below. _drops.removeWhere((d) => d.y > labelTop); // Update drops and trigger label glow when they reach the label area. var glowTriggered = false; for (final d in _drops) { d.y += d.speed; if (!glowTriggered && d.y > labelTop - 4) { glowTriggered = true; } } // If glow triggered, emit sparkles around the label. if (glowTriggered) { _labelGlow = 1.0; for (var i = 0; i < 3; i++) { _sparks.add( _Spark( x: _rng.nextDouble() * cloudWidth, y: rainHeight + 12, size: _rng.nextDouble() * 4 + 2, life: 12, ), ); } } // decay glow + update sparks _labelGlow = (_labelGlow - 0.06).clamp(0.0, 1.0); _sparks.removeWhere((s) => s.life <= 0); for (final s in _sparks) { s.life -= 1; s.y -= 0.8; s.alpha = s.life / 12.0; } }); } @override void dispose() { _timer?.cancel(); _cloudController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return Scaffold( backgroundColor: cs.surface, body: SafeArea( child: Center( child: SizedBox( width: cloudWidth, child: Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ SizedBox( height: 120, child: Stack( alignment: Alignment.center, children: [ Positioned( top: 0, child: Hero( tag: 'tasq-logo', child: Image.asset( 'assets/tasq_ico.png', height: 74, width: 74, ), ), ), Positioned( bottom: 0, child: AnimatedBuilder( animation: _cloudController, builder: (context, child) { final dx = sin(_cloudController.value * 2 * pi) * 3; final opacity = 0.65 + 0.12 * sin(_cloudController.value * 2 * pi); return Transform.translate( offset: Offset(dx, 0), child: Opacity( opacity: opacity.clamp(0, 1), child: child, ), ); }, child: Image.asset( 'assets/clouds.png', width: cloudWidth, height: 92, fit: BoxFit.contain, ), ), ), ], ), ), const SizedBox(height: 4), SizedBox( width: cloudWidth, height: rainContainerHeight, child: Stack( alignment: Alignment.center, children: [ CustomPaint( size: Size.infinite, painter: _BinaryRainPainter( _drops, cols, cs.primary, glow: _labelGlow, ), ), Positioned( bottom: 0, left: 0, right: 0, child: Center( child: AnimatedSwitcher( duration: const Duration(milliseconds: 300), transitionBuilder: (child, animation) => FadeTransition( opacity: animation, child: child, ), child: Text( _statusLabel, key: ValueKey(_status), style: Theme.of(context).textTheme.titleMedium ?.copyWith( color: cs.onSurface.withAlpha( (0.75 * 255).round(), ), shadows: [ Shadow( blurRadius: 10 * _labelGlow, color: cs.primary.withAlpha( (0.6 * 255 * _labelGlow).round(), ), ), ], ), ), ), ), ), Positioned.fill( child: CustomPaint( painter: _SparkPainter(_sparks, cs.primary), ), ), ], ), ), const SizedBox(height: 10), ], ), ), ), ), ); } } class _Drop { int col; double y; double speed; _Drop({required this.col, required this.y, required this.speed}); } class _Spark { double x; double y; double size; double alpha; int life; _Spark({ required this.x, required this.y, required this.size, required this.life, }) : alpha = 1.0; } class _M3DialogFadeThrough extends StatelessWidget { const _M3DialogFadeThrough({ required this.animation, required this.secondaryAnimation, required this.child, }); final Animation animation; final Animation secondaryAnimation; final Widget child; @override Widget build(BuildContext context) { return AnimatedBuilder( animation: Listenable.merge([animation, secondaryAnimation]), builder: (context, _) { final t = animation.value; final st = secondaryAnimation.value; // Incoming: fade in from t=0.3..1.0 final enterT = ((t - 0.3) / 0.7).clamp(0.0, 1.0); final enterOpacity = M3Motion.expressiveDecelerate.transform(enterT); // Outgoing: fade out in first 35% of the secondary animation. final exitOpacity = (1.0 - (st / 0.35).clamp(0.0, 1.0)); final slideY = 0.02 * (1.0 - enterT); return Opacity( opacity: exitOpacity, child: Transform.translate( offset: Offset(0, slideY * 100), child: Opacity(opacity: enterOpacity, child: child), ), ); }, ); } } class _BinaryRainPainter extends CustomPainter { static const double fontSize = 16; final List<_Drop> drops; final int cols; final Color textColor; final double glow; _BinaryRainPainter(this.drops, this.cols, this.textColor, {this.glow = 0.0}); @override void paint(Canvas canvas, Size size) { final textStyle = TextStyle( color: textColor, fontSize: fontSize, fontFeatures: const [FontFeature.tabularFigures()], ); final cellW = size.width / cols; for (final d in drops) { final text = (Random().nextBool() ? '1' : '0'); final tp = TextPainter( text: TextSpan(text: text, style: textStyle), textAlign: TextAlign.center, textDirection: TextDirection.ltr, )..layout(); final x = d.col * cellW + (cellW - tp.width) / 2; final y = d.y; tp.paint(canvas, Offset(x, y)); } // glow overlay (subtle) if (glow > 0) { final glowPaint = Paint() ..color = textColor.withValues(alpha: (glow * 0.2).clamp(0.0, 1.0)); canvas.drawRect( Rect.fromLTWH(0, size.height - 24, size.width, 24), glowPaint, ); } } @override bool shouldRepaint(covariant _BinaryRainPainter old) => true; } class _SparkPainter extends CustomPainter { final List<_Spark> sparks; final Color color; _SparkPainter(this.sparks, this.color); @override void paint(Canvas canvas, Size size) { for (final s in sparks) { final paint = Paint() ..color = color.withValues(alpha: s.alpha.clamp(0.0, 1.0)); canvas.drawCircle(Offset(s.x, s.y), s.size, paint); } } @override bool shouldRepaint(covariant _SparkPainter old) => true; }