import 'dart:math' as math; import 'dart:ui' show lerpDouble; import 'package:flutter/material.dart'; /// Centralized tuning for the animated background. /// /// Update values here to make the effect calmer or stronger without touching /// the painter logic below. class _BackgroundAnimationConfig { static const Duration cycleDuration = Duration(seconds: 18); static const Duration stateTransitionDuration = Duration(milliseconds: 650); // Bottom glow pulse. static const double glowPulseSpeed = 5.6; static const double glowOnBaseIntensity = 0.98; static const double glowOnPulseAmplitude = 0.18; static const double glowOffIntensity = 0.46; static const double glowCenterY = 1.05; static const double glowRadiusOn = 0.74; static const double glowRadiusOff = 0.50; static const double glowBlurOn = 94; static const double glowBlurOff = 78; static const _ThemeValue glowCenterAlpha = _ThemeValue( dark: 0.70, light: 0.52, ); static const _ThemeValue glowOuterAlpha = _ThemeValue( dark: 0.34, light: 0.22, ); static const _ThemeColor glowBaseColor = _ThemeColor( dark: Color(0xFF1E6BFF), light: Color(0xFF123A8F), ); // Burning plume (rendered only when VPN is active). static const int plumeLayers = 3; static const double plumeBottomY = 1.01; static const double plumeLayerPhaseOffset = 1.2; static const double plumeFlickerBase = 0.90; static const double plumeFlickerAmpA = 0.06; static const double plumeFlickerAmpB = 0.03; static const double plumeFlickerFreqA = 7.5; static const double plumeFlickerFreqB = 15.0; static const double plumeFlickerPhaseB = 0.7; static const double plumeFlickerMin = 0.84; static const double plumeFlickerMax = 1.03; static const double plumeWidthBase = 0.26; static const double plumeWidthLayerStep = 0.050; static const double plumeHeightBase = 0.30; static const double plumeHeightLayerStep = 0.060; static const double plumeAlphaBase = 0.46; static const double plumeAlphaLayerStep = 0.10; static const double plumeBlurBase = 22; static const double plumeBlurLayerStep = 3.5; static const _ThemeValue plumeBaseAlpha = _ThemeValue( dark: 0.48, light: 0.38, ); static const _ThemeValue plumeCoreAlpha = _ThemeValue( dark: 0.36, light: 0.28, ); static const _ThemeColor plumeBaseColor = _ThemeColor( dark: Color(0xFF2C7FFF), light: Color(0xFF0F3DA8), ); static const _ThemeColor plumeCoreColor = _ThemeColor( dark: Color(0xFF7AB3FF), light: Color(0xFF2C5EC2), ); // Small energy core near the bottom center. static const double orbRadius = 0.07; static const double orbCenterY = 0.985; static const double orbVerticalMotion = 1.2; static const double orbMotionSpeed = 3.2; static const double orbBlur = 42; static const double orbCoreAlpha = 0.42; static const double orbOuterAlpha = 0.22; } /// Theme-aware scalar value for dark and light modes. class _ThemeValue { const _ThemeValue({required this.dark, required this.light}); final double dark; final double light; double resolve(bool isDark) => isDark ? dark : light; } /// Theme-aware color for dark and light modes. class _ThemeColor { const _ThemeColor({required this.dark, required this.light}); final Color dark; final Color light; Color resolve(bool isDark) => isDark ? dark : light; } class AbstractAnimatedBackground extends StatefulWidget { const AbstractAnimatedBackground({super.key, required this.isVpnActive}); final bool isVpnActive; @override State createState() => _AbstractAnimatedBackgroundState(); } class _AbstractAnimatedBackgroundState extends State with TickerProviderStateMixin { late final AnimationController _controller = AnimationController( vsync: this, duration: _BackgroundAnimationConfig.cycleDuration, )..repeat(); late final AnimationController _stateController = AnimationController( vsync: this, duration: _BackgroundAnimationConfig.stateTransitionDuration, value: widget.isVpnActive ? 1 : 0, ); @override void didUpdateWidget(covariant AbstractAnimatedBackground oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.isVpnActive == widget.isVpnActive) { return; } if (widget.isVpnActive) { _stateController.forward(); } else { _stateController.reverse(); } } @override void dispose() { _controller.dispose(); _stateController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final bool isDark = Theme.of(context).brightness == Brightness.dark; return RepaintBoundary( child: AnimatedBuilder( animation: Listenable.merge([_controller, _stateController]), builder: (context, child) { return CustomPaint( painter: _AbstractBackgroundPainter( progress: _controller.value, isDark: isDark, vpnBlend: _stateController.value, ), child: const SizedBox.expand(), ); }, ), ); } } class _AbstractBackgroundPainter extends CustomPainter { _AbstractBackgroundPainter({ required this.progress, required this.isDark, required this.vpnBlend, }); final double progress; final bool isDark; final double vpnBlend; @override void paint(Canvas canvas, Size size) { _drawBottomGlow(canvas, size); if (vpnBlend > 0.001) { _drawBurningPlume(canvas, size); } } void _drawBottomGlow(Canvas canvas, Size size) { final double pulse = (math.sin( 2 * math.pi * (progress * _BackgroundAnimationConfig.glowPulseSpeed), ) + 1) / 2; final double onIntensity = _BackgroundAnimationConfig.glowOnBaseIntensity + (_BackgroundAnimationConfig.glowOnPulseAmplitude * pulse); final double intensity = lerpDouble( _BackgroundAnimationConfig.glowOffIntensity, onIntensity, vpnBlend, )!; final Color glowBase = _BackgroundAnimationConfig.glowBaseColor.resolve( isDark, ); final Offset center = Offset( size.width / 2, size.height * _BackgroundAnimationConfig.glowCenterY, ); final double radius = size.shortestSide * lerpDouble( _BackgroundAnimationConfig.glowRadiusOff, _BackgroundAnimationConfig.glowRadiusOn, vpnBlend, )!; final Paint paint = Paint() ..maskFilter = MaskFilter.blur( BlurStyle.normal, lerpDouble( _BackgroundAnimationConfig.glowBlurOff, _BackgroundAnimationConfig.glowBlurOn, vpnBlend, )!, ) ..shader = RadialGradient( colors: [ glowBase.withValues( alpha: _BackgroundAnimationConfig.glowCenterAlpha.resolve(isDark) * intensity, ), glowBase.withValues( alpha: _BackgroundAnimationConfig.glowOuterAlpha.resolve(isDark) * intensity, ), Colors.transparent, ], ).createShader(Rect.fromCircle(center: center, radius: radius)); canvas.drawCircle(center, radius, paint); } void _drawBurningPlume(Canvas canvas, Size size) { final double t = progress * 2 * math.pi; final double plumeBlend = Curves.easeOutCubic.transform(vpnBlend); final double flicker = _BackgroundAnimationConfig.plumeFlickerBase + (_BackgroundAnimationConfig.plumeFlickerAmpA * math.sin(t * _BackgroundAnimationConfig.plumeFlickerFreqA)) + (_BackgroundAnimationConfig.plumeFlickerAmpB * math.sin( t * _BackgroundAnimationConfig.plumeFlickerFreqB + _BackgroundAnimationConfig.plumeFlickerPhaseB, )); final Color base = _BackgroundAnimationConfig.plumeBaseColor.resolve( isDark, ); final Color core = _BackgroundAnimationConfig.plumeCoreColor.resolve( isDark, ); final double centerX = size.width / 2; final double bottomY = size.height * _BackgroundAnimationConfig.plumeBottomY; for (int i = 0; i < _BackgroundAnimationConfig.plumeLayers; i++) { final double layer = i.toDouble(); final double layerShift = t + (layer * _BackgroundAnimationConfig.plumeLayerPhaseOffset); final double width = size.width * (_BackgroundAnimationConfig.plumeWidthBase - (layer * _BackgroundAnimationConfig.plumeWidthLayerStep)); final double height = size.height * (_BackgroundAnimationConfig.plumeHeightBase - (layer * _BackgroundAnimationConfig.plumeHeightLayerStep)) * flicker.clamp( _BackgroundAnimationConfig.plumeFlickerMin, _BackgroundAnimationConfig.plumeFlickerMax, ); final double topY = bottomY - height; final double swayA = math.sin(layerShift * 1.05) * (9 - layer * 1.8); final double swayB = math.cos(layerShift * 1.35 + 0.5) * (11 - layer * 2.0); final double swayC = math.sin(layerShift * 1.15 + 1.1) * (8 - layer * 1.6); final Path plume = Path() ..moveTo(centerX - width, bottomY) ..quadraticBezierTo( centerX - width * 0.8 + swayA, bottomY - height * 0.36, centerX + swayB, topY, ) ..quadraticBezierTo( centerX + width * 0.8 + swayC, bottomY - height * 0.34, centerX + width, bottomY, ) ..close(); final Rect plumeBounds = Rect.fromLTWH( centerX - width, topY, width * 2, height, ); final double alphaScale = (_BackgroundAnimationConfig.plumeAlphaBase - layer * _BackgroundAnimationConfig.plumeAlphaLayerStep) * flicker * plumeBlend; final Paint paint = Paint() ..maskFilter = MaskFilter.blur( BlurStyle.normal, _BackgroundAnimationConfig.plumeBlurBase - (layer * _BackgroundAnimationConfig.plumeBlurLayerStep), ) ..shader = LinearGradient( begin: Alignment.bottomCenter, end: Alignment.topCenter, colors: [ base.withValues( alpha: _BackgroundAnimationConfig.plumeBaseAlpha.resolve(isDark) * alphaScale, ), core.withValues( alpha: _BackgroundAnimationConfig.plumeCoreAlpha.resolve(isDark) * alphaScale, ), Colors.transparent, ], stops: const [0.0, 0.55, 1.0], ).createShader(plumeBounds); canvas.drawPath(plume, paint); } final double orbRadius = size.shortestSide * _BackgroundAnimationConfig.orbRadius; final Offset orbCenter = Offset( centerX, size.height * _BackgroundAnimationConfig.orbCenterY + (_BackgroundAnimationConfig.orbVerticalMotion * math.sin(t * _BackgroundAnimationConfig.orbMotionSpeed)), ); final Paint orbPaint = Paint() ..maskFilter = const MaskFilter.blur( BlurStyle.normal, _BackgroundAnimationConfig.orbBlur, ) ..shader = RadialGradient( colors: [ core.withValues( alpha: _BackgroundAnimationConfig.orbCoreAlpha * flicker * plumeBlend, ), base.withValues( alpha: _BackgroundAnimationConfig.orbOuterAlpha * flicker * plumeBlend, ), Colors.transparent, ], ).createShader(Rect.fromCircle(center: orbCenter, radius: orbRadius)); canvas.drawCircle(orbCenter, orbRadius, orbPaint); } @override bool shouldRepaint(covariant _AbstractBackgroundPainter oldDelegate) { return oldDelegate.progress != progress || oldDelegate.isDark != isDark || oldDelegate.vpnBlend != vpnBlend; } }