diff --git a/lib/components/lightning.dart b/lib/components/lightning.dart new file mode 100644 index 0000000..850c61a --- /dev/null +++ b/lib/components/lightning.dart @@ -0,0 +1,233 @@ +import 'dart:async'; +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:revoltvpn/logic/app_colors.dart'; + +/// 2D lightning bolt — a jagged yellow line from top to bottom with glow. + +class LightningEffect extends StatefulWidget { + + final int minIntervalSec; + + final int maxIntervalSec; + + final double doubleChance; + + const LightningEffect({ + super.key, + this.minIntervalSec = 2, + this.maxIntervalSec = 10, + this.doubleChance = 0.2, + }); + + @override + State createState() => _LightningEffectState(); +} + +class _LightningEffectState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _ctrl; + Timer? _timer; + final _random = Random(); + + LightningBolt? _bolt; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 400)); + _scheduleNext(); + } + + @override + void dispose() { + _timer?.cancel(); + _ctrl.dispose(); + super.dispose(); + } + + void _scheduleNext() { + final secs = widget.minIntervalSec + + _random.nextInt(widget.maxIntervalSec - widget.minIntervalSec + 1); + _timer = Timer(Duration(seconds: secs), _strike); + } + + void _strike() { + if (!mounted) return; + _bolt = LightningBolt.random(_random); + _ctrl.forward(from: 0.0); + + if (_random.nextDouble() < widget.doubleChance) { + Future.delayed(const Duration(milliseconds: 120), () { + if (mounted) { + _bolt = LightningBolt.random(_random); + _ctrl.forward(from: 0.0); + } + }); + } + + _scheduleNext(); + } + + /// Sharp flash curve: quick on, quick off. No slow fade. + double _opacity(double t) { + if (t < 0.08) return t / 0.08; // fast ramp + return 1.0 - ((t - 0.08) / 0.92); // linear decay + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _ctrl, + builder: (_, child) { + final raw = _opacity(_ctrl.value); + if (raw <= 0.02 || _bolt == null) return const SizedBox.shrink(); + + return IgnorePointer( + child: LayoutBuilder( + builder: (ctx, constraints) { + final sz = Size(constraints.maxWidth, constraints.maxHeight); + return CustomPaint( + size: sz, + painter: _LightningPainter( + bolt: _bolt!, + opacity: raw, + ), + ); + }, + ), + ); + }, + ); + } +} + + +/// A single bolt defined by a list of 2D points (0.0–1.0 in width×height space). +class LightningBolt { + final List mainPath; + final List> branches; // 0–2 forks off the main path + + const LightningBolt({required this.mainPath, this.branches = const []}); + + /// Generate a random bolt. The path always starts near the top-center + /// and ends near the bottom, zigzagging left/right along the way. + factory LightningBolt.random(Random rng) { + final points = []; + + // Start: top, slight horizontal offset + double x = 0.5 + (rng.nextDouble() - 0.5) * 0.15; + points.add(Offset(x, 0.0)); + + // Zigzag down — 6–10 segments + final segments = 6 + rng.nextInt(5); + for (int i = 1; i < segments; i++) { + final y = i / segments; + // Random horizontal offset — each segment adds jitter + x += (rng.nextDouble() - 0.5) * 0.25; + x = x.clamp(0.05, 0.95); // stay on screen + points.add(Offset(x, y)); + } + + // End: bottom + points.add(Offset(x.clamp(0.1, 0.9), 1.0)); + + // Branches — 0–2 forks from random points along the main path + final branches = >[]; + final branchCount = rng.nextInt(3); + for (int b = 0; b < branchCount; b++) { + final branch = []; + final startIdx = 1 + rng.nextInt(points.length - 2); + final start = points[startIdx]; + branch.add(start); + + double bx = start.dx; + double by = start.dy; + final branchLen = 2 + rng.nextInt(3); + for (int j = 0; j < branchLen; j++) { + by += (0.05 + rng.nextDouble() * 0.12); + bx += (rng.nextDouble() - 0.5) * 0.3; + bx = bx.clamp(0.02, 0.98); + by = by.clamp(0.0, 1.0); + branch.add(Offset(bx, by)); + } + branches.add(branch); + } + + return LightningBolt(mainPath: points, branches: branches); + } +} + + +class _LightningPainter extends CustomPainter { + final LightningBolt bolt; + final double opacity; + + _LightningPainter({required this.bolt, required this.opacity}); + + @override + void paint(Canvas canvas, Size size) { + final toScreen = (Offset p) => Offset(p.dx * size.width, p.dy * size.height); + final scaledMain = bolt.mainPath.map(toScreen).toList(); + final scaledBranches = bolt.branches + .map((b) => b.map(toScreen).toList()) + .toList(); + + final baseColor = AppColors.accent.withAlpha((opacity * 255).round()); + final glowColor = AppColors.accent.withAlpha((opacity * 0.35 * 255).round()); + + // ── Glow layer (thick, blurry) ────────────── + final glowPaint = Paint() + ..color = glowColor + ..strokeWidth = 18 + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8) + ..style = PaintingStyle.stroke; + + _drawPath(canvas, scaledMain, scaledBranches, glowPaint); + + // ── Core bolt (thin, bright) ──────────────── + final corePaint = Paint() + ..color = baseColor + ..strokeWidth = 5 + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round + ..style = PaintingStyle.stroke; + + _drawPath(canvas, scaledMain, scaledBranches, corePaint); + + // ── Hot center (extra white core) ─────────── + if (opacity > 0.6) { + final hotPaint = Paint() + ..color = Colors.white.withAlpha((opacity * 180).round()) + ..strokeWidth = 2 + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round + ..style = PaintingStyle.stroke; + + _drawPath(canvas, scaledMain, scaledBranches, hotPaint); + } + } + + void _drawPath(Canvas canvas, List main, + List> branches, Paint paint) { + final mainPath = Path()..moveTo(main.first.dx, main.first.dy); + for (int i = 1; i < main.length; i++) { + mainPath.lineTo(main[i].dx, main[i].dy); + } + canvas.drawPath(mainPath, paint); + + for (final branch in branches) { + final bPath = Path()..moveTo(branch.first.dx, branch.first.dy); + for (int i = 1; i < branch.length; i++) { + bPath.lineTo(branch[i].dx, branch[i].dy); + } + canvas.drawPath(bPath, paint); + } + } + + @override + bool shouldRepaint(covariant _LightningPainter old) => + bolt != old.bolt || opacity != old.opacity; +} diff --git a/lib/components/rain.dart b/lib/components/rain.dart new file mode 100644 index 0000000..a7a1a15 --- /dev/null +++ b/lib/components/rain.dart @@ -0,0 +1,165 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; + +/// Subtle animated rain effect — sits behind all UI as a full-screen overlay. +/// +/// Usage (in a Stack, as the first child so it renders behind everything): + +class RainEffect extends StatefulWidget { + final int dropCount; + + final Color color; + + final double minLength; + + final double maxLength; + + final double windDrift; + + const RainEffect({ + super.key, + this.dropCount = 150, + this.color = const Color(0xFFE8EAF6), + this.minLength = 12, + this.maxLength = 44, + this.windDrift = 4.0, + }); + + @override + State createState() => _RainEffectState(); +} + +class _RainEffectState extends State + with SingleTickerProviderStateMixin { + late final Ticker _ticker; + final _drops = <_Drop>[]; + final _random = Random(); + Duration _last = Duration.zero; + + // ── Lifecycle ──────────────────────────────────────────────────────── + + @override + void initState() { + super.initState(); + for (int i = 0; i < widget.dropCount; i++) { + _drops.add(_Drop.spawn(_random, widget.minLength, widget.maxLength)); + } + _ticker = createTicker(_onTick)..start(); + } + + @override + void dispose() { + _ticker.dispose(); + super.dispose(); + } + + // ── Tick — move drops, recycle off-screen ones ────────────────────── + + void _onTick(Duration now) { + double dt = 0.016; // ~60 fps fallback on first tick + if (_last != Duration.zero) { + dt = (now - _last).inMicroseconds / 1000000.0; + dt = dt.clamp(0.0, 0.1); + } + _last = now; + + for (final d in _drops) { + d.y += d.speed * dt; + if (d.y > 1.08) { + d.spawn(_random, widget.minLength, widget.maxLength); + d.y = -0.06 - _random.nextDouble() * 0.08; + } + } + setState(() {}); + } + + // ── Build ──────────────────────────────────────────────────────────── + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _RainPainter( + drops: _drops, + color: widget.color, + windDrift: widget.windDrift, + ), + size: Size.infinite, + ); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Drop model +// ═══════════════════════════════════════════════════════════════════════════ + +class _Drop { + double x; + double y; + double speed; + double length; + double opacity; + + _Drop({ + required this.x, + required this.y, + required this.speed, + required this.length, + required this.opacity, + }); + + /// Create a drop at a random position anywhere on screen. + factory _Drop.spawn(Random r, double minLen, double maxLen) { + return _Drop( + x: r.nextDouble(), + y: r.nextDouble(), + speed: 0.30 + r.nextDouble() * 0.55, + length: minLen + r.nextDouble() * (maxLen - minLen), + opacity: 0.05 + r.nextDouble() * 0.12, + ); + } + + /// Re-randomise all properties (call when recycling a drop to the top). + void spawn(Random r, double minLen, double maxLen) { + x = r.nextDouble(); + speed = 0.30 + r.nextDouble() * 0.55; + length = minLen + r.nextDouble() * (maxLen - minLen); + opacity = 0.05 + r.nextDouble() * 0.12; + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Painter +// ═══════════════════════════════════════════════════════════════════════════ + +class _RainPainter extends CustomPainter { + final List<_Drop> drops; + final Color color; + final double windDrift; + + _RainPainter({ + required this.drops, + required this.color, + required this.windDrift, + }); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..strokeCap = StrokeCap.round + ..strokeWidth = 1.5; + + for (final d in drops) { + final sx = d.x * size.width; + final sy = d.y * size.height; + final ex = sx - windDrift; // slight leftward slant + final ey = sy + d.length; + + paint.color = color.withAlpha((d.opacity * 255).round()); + canvas.drawLine(Offset(sx, sy), Offset(ex, ey), paint); + } + } + + @override + bool shouldRepaint(_RainPainter old) => true; // always animating +} diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 2733f05..4f93753 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -6,6 +6,8 @@ import 'package:revoltvpn/components/timer_box.dart'; import 'package:revoltvpn/components/speed_box.dart'; import 'package:revoltvpn/components/status_bar.dart'; import 'package:revoltvpn/components/support_button.dart'; +import 'package:revoltvpn/components/rain.dart'; +import 'package:revoltvpn/components/lightning.dart'; class MainScreen extends StatelessWidget { const MainScreen({super.key}); @@ -29,6 +31,12 @@ class MainScreen extends StatelessWidget { child: SafeArea( child: Stack( children: [ + // ── Lightning — behind rain so drops stay visible during flash ── + const Positioned.fill(child: LightningEffect()), + + // ── Rain effect — behind all UI, baked into the background ── + const Positioned.fill(child: RainEffect()), + Positioned( top: 0, right: 4,