393 lines
12 KiB
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;
|
|
}
|
|
}
|