Files
vpn/lib/widgets/abstract_animated_background.dart

393 lines
12 KiB
Dart

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<AbstractAnimatedBackground> createState() =>
_AbstractAnimatedBackgroundState();
}
class _AbstractAnimatedBackgroundState extends State<AbstractAnimatedBackground>
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;
}
}