chore: release 3.3.0 (sidebar nav fix, notification countdown, hardening)

Co-authored-by: Naveax <[email protected]>
This commit is contained in:
esefxdzandNaveax committed 2026-08-30 14:01:52 +03:00
1 parent 4eb1fca666
commit 62104d5730
28 files changed
+670 -215

No files matched your search

+52 -19
View File
@@ -1,5 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:revoltvpn/logic/app_colors.dart';
import 'package:revoltvpn/logic/vpn_connection.dart';
@@ -15,7 +15,7 @@ class ConnectButton extends StatefulWidget {
}
class _ConnectButtonState extends State<ConnectButton>
with SingleTickerProviderStateMixin {
with SingleTickerProviderStateMixin, WidgetsBindingObserver {
bool _busy = false;
DateTime _lastTap = DateTime.now();
AnimationController? _pulse;
@@ -29,6 +29,7 @@ class _ConnectButtonState extends State<ConnectButton>
final pulse = AnimationController(vsync: this, duration: const Duration(milliseconds: 2000));
_pulse = pulse;
_pulseAnim = Tween(begin: 0.0, end: 1.0).animate(CurvedAnimation(parent: pulse, curve: Curves.easeInOut));
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<VpnConnection>().addListener(_onVpnChanged);
});
@@ -36,6 +37,7 @@ class _ConnectButtonState extends State<ConnectButton>
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
context.read<VpnConnection>().removeListener(_onVpnChanged);
_pulse?.dispose();
super.dispose();
@@ -51,6 +53,20 @@ class _ConnectButtonState extends State<ConnectButton>
}
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (!mounted) return;
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.detached) {
_pulse?.stop();
} else if (state == AppLifecycleState.resumed) {
final vpn = context.read<VpnConnection>();
if (vpn.status == VpnStatus.connected) {
_pulse?.repeat(reverse: true);
}
}
}
Future<void> _handleTap() async {
final now = DateTime.now();
@@ -65,7 +81,7 @@ class _ConnectButtonState extends State<ConnectButton>
if (vpn.status == VpnStatus.connected || vpn.status == VpnStatus.connecting) {
_busy = false;
await timer.disconnect();
if (hapticEnabled) HapticFeedback.lightImpact();
unawaited(Haptics.connectionChanged());
return;
}
@@ -85,7 +101,7 @@ class _ConnectButtonState extends State<ConnectButton>
final ok = await vpn.connect();
if (ok) {
timer.start();
if (hapticEnabled) HapticFeedback.lightImpact();
unawaited(Haptics.connectionChanged());
}
} finally {
if (mounted) setState(() => _busy = false);
@@ -105,15 +121,36 @@ class _ConnectButtonState extends State<ConnectButton>
child: _pulseAnim != null
? AnimatedBuilder(
animation: _pulseAnim!,
builder: (_, __) => _buildCircle(isConnected, spinning, _pulseValue),
child: _buildInner(spinning),
builder: (_, child) =>
_buildCircle(isConnected, _pulseValue, child),
)
: _buildCircle(isConnected, spinning, 0),
: _buildCircle(isConnected, 0, _buildInner(spinning)),
);
},
);
}
Widget _buildCircle(bool isConnected, bool spinning, double pulse) {
Widget _buildInner(bool spinning) {
if (spinning) {
return const Center(
child: SizedBox(
width: 36,
height: 36,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: AppColors.accent70,
),
),
);
}
return Padding(
padding: const EdgeInsets.all(14),
child: Image.asset('assets/brand.png', fit: BoxFit.contain),
);
}
Widget _buildCircle(bool isConnected, double pulse, Widget? child) {
final glowAlpha = isConnected ? ((0.3 + pulse * 0.25) * 255).round() : 0;
final glowRadius = isConnected ? 18.0 + (pulse * 14) : 0.0;
@@ -128,21 +165,17 @@ class _ConnectButtonState extends State<ConnectButton>
width: isConnected ? 2.5 : 1.5,
),
boxShadow: isConnected
? [BoxShadow(color: Color.fromARGB(glowAlpha, 255, 214, 0), blurRadius: glowRadius, spreadRadius: glowRadius * 0.3)]
? [
BoxShadow(
color: Color.fromARGB(glowAlpha, 255, 214, 0),
blurRadius: glowRadius,
spreadRadius: glowRadius * 0.3,
)
]
: [],
),
clipBehavior: Clip.hardEdge,
child: spinning
? const Center(
child: SizedBox(
width: 36, height: 36,
child: CircularProgressIndicator(strokeWidth: 2.5, color: AppColors.accent70),
),
)
: Padding(
padding: const EdgeInsets.all(14),
child: Image.asset('assets/brand.png', fit: BoxFit.contain),
),
child: child,
);
}
}
+5 -4
View File
@@ -64,10 +64,11 @@ class _DisclosureDialog extends StatelessWidget {
const Text(
'Revolt VPN creates a secure VPN tunnel to protect your '
'traffic. To enforce your data/time quota, we log your '
'connection duration and data usage, linked to your '
'account. We do not log or monitor the content of your '
'traffic. We do not share your data with third parties.'
'If you have more questions, visit our website.',
'connection duration and data usage, linked to a randomly '
'generated device ID — not to your identity. We do not log '
'or monitor the content of your traffic. We do not share '
'your data with third parties. The full policy is in the '
'sidebar menu under "Privacy Policy".',
style: TextStyle(
color: AppColors.textMuted,
fontSize: 14,
+17 -4
View File
@@ -37,11 +37,24 @@ class _LightningEffectState extends State<LightningEffect>
void initState() {
super.initState();
_ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 400));
_scheduleNext();
lightningEnabled.addListener(_onEnabledChanged);
if (lightningEnabled.value) _scheduleNext();
}
void _onEnabledChanged() {
if (lightningEnabled.value) {
_scheduleNext();
} else {
_timer?.cancel();
_ctrl.stop();
_bolt = null;
}
if (mounted) setState(() {});
}
@override
void dispose() {
lightningEnabled.removeListener(_onEnabledChanged);
_timer?.cancel();
_ctrl.dispose();
super.dispose();
@@ -54,13 +67,13 @@ class _LightningEffectState extends State<LightningEffect>
}
void _strike() {
if (!mounted) return;
if (!mounted || !lightningEnabled.value) return;
_bolt = LightningBolt.random(_random);
_ctrl.forward(from: 0.0);
if (_random.nextDouble() < widget.doubleChance) {
Future.delayed(const Duration(milliseconds: 120), () {
if (mounted) {
if (mounted && lightningEnabled.value) {
_bolt = LightningBolt.random(_random);
_ctrl.forward(from: 0.0);
}
@@ -78,7 +91,7 @@ class _LightningEffectState extends State<LightningEffect>
@override
Widget build(BuildContext context) {
if (!lightningEnabled) return const SizedBox.shrink();
if (!lightningEnabled.value) return const SizedBox.shrink();
return AnimatedBuilder(
animation: _ctrl,
builder: (_, child) {
+39 -23
View File
@@ -36,6 +36,7 @@ class _RainEffectState extends State<RainEffect>
late final Ticker _ticker;
final _drops = <_Drop>[];
final _random = Random();
final _repaint = ValueNotifier<int>(0);
Duration _last = Duration.zero;
// Lifecycle
@@ -44,47 +45,63 @@ class _RainEffectState extends State<RainEffect>
void initState() {
super.initState();
for (int i = 0; i < widget.dropCount; i++) {
_drops.add(_Drop.spawn(_random, widget.minLength, widget.maxLength));
_drops.add(
_Drop.spawn(_random, widget.minLength, widget.maxLength, widget.color));
}
_ticker = createTicker(_onTick)..start();
_ticker = createTicker(_onTick);
rainEnabled.addListener(_onEnabledChanged);
if (rainEnabled.value) _ticker.start();
}
@override
void dispose() {
rainEnabled.removeListener(_onEnabledChanged);
_ticker.dispose();
_repaint.dispose();
super.dispose();
}
void _onEnabledChanged() {
if (rainEnabled.value) {
// Ticker elapsed restarts at zero; a stale _last makes the first dt negative.
_last = Duration.zero;
_ticker.start();
} else {
_ticker.stop();
}
if (mounted) setState(() {});
}
// Tick
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);
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;
d.spawn(_random, widget.minLength, widget.maxLength, widget.color);
d.y = -0.06 - _random.nextDouble() * 0.08;
}
}
setState(() {});
_repaint.value++;
}
// Build
@override
Widget build(BuildContext context) {
if (!rainEnabled) return const SizedBox.shrink();
if (!rainEnabled.value) return const SizedBox.shrink();
return CustomPaint(
painter: _RainPainter(
drops: _drops,
color: widget.color,
windDrift: widget.windDrift,
repaint: _repaint,
),
size: Size.infinite,
);
@@ -96,37 +113,37 @@ class _RainEffectState extends State<RainEffect>
// ═══════════════════════════════════════════════════════════════════════════
class _Drop {
double x;
double y;
double speed;
double length;
double opacity;
double x;
double y;
double speed;
double length;
Color color;
_Drop({
required this.x,
required this.y,
required this.speed,
required this.length,
required this.opacity,
required this.color,
});
/// Create a drop at a random position anywhere on screen.
factory _Drop.spawn(Random r, double minLen, double maxLen) {
factory _Drop.spawn(Random r, double minLen, double maxLen, Color base) {
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,
color: base.withAlpha(((0.05 + r.nextDouble() * 0.12) * 255).round()),
);
}
/// Re-randomise all properties (call when recycling a drop to the top).
void spawn(Random r, double minLen, double maxLen) {
void spawn(Random r, double minLen, double maxLen, Color base) {
x = r.nextDouble();
speed = 0.30 + r.nextDouble() * 0.55;
length = minLen + r.nextDouble() * (maxLen - minLen);
opacity = 0.05 + r.nextDouble() * 0.12;
color = base.withAlpha(((0.05 + r.nextDouble() * 0.12) * 255).round());
}
}
@@ -136,14 +153,13 @@ class _Drop {
class _RainPainter extends CustomPainter {
final List<_Drop> drops;
final Color color;
final double windDrift;
_RainPainter({
required this.drops,
required this.color,
required this.windDrift,
});
required Listenable repaint,
}) : super(repaint: repaint);
@override
void paint(Canvas canvas, Size size) {
@@ -157,11 +173,11 @@ class _RainPainter extends CustomPainter {
final ex = sx - windDrift; // slight leftward slant
final ey = sy + d.length;
paint.color = color.withAlpha((d.opacity * 255).round());
paint.color = d.color;
canvas.drawLine(Offset(sx, sy), Offset(ex, ey), paint);
}
}
@override
bool shouldRepaint(_RainPainter old) => true; // always animating
bool shouldRepaint(_RainPainter old) => false; // repaint listenable drives frames
}
+13 -9
View File
@@ -12,9 +12,7 @@ class SettingsButton extends StatelessWidget {
// Opens the settings page sliding in from the right — matches the
// sidebar end-drawer animation so it feels like one continuous motion.
static void open(BuildContext context) {
Navigator.of(context).push(
PageRouteBuilder(
static PageRoute<void> _route() => PageRouteBuilder<void>(
transitionDuration: const Duration(milliseconds: 300),
reverseTransitionDuration: const Duration(milliseconds: 250),
pageBuilder: (_, __, ___) => const SettingsScreen(),
@@ -23,15 +21,21 @@ class SettingsButton extends StatelessWidget {
position: Tween<Offset>(
begin: const Offset(1.0, 0.0),
end: Offset.zero,
).animate(CurvedAnimation(
parent: animation,
curve: Curves.easeOut,
)),
).animate(
CurvedAnimation(parent: animation, curve: Curves.easeOut),
),
child: child,
);
},
),
);
);
static void openWithNavigator(NavigatorState navigator) {
if (!navigator.mounted) return;
navigator.push(_route());
}
static void open(BuildContext context) {
openWithNavigator(Navigator.of(context, rootNavigator: true));
}
@override
+8 -2
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:revoltvpn/logic/app_colors.dart';
@@ -126,8 +127,9 @@ class _SidebarDrawerState extends State<SidebarDrawer> {
icon: Icons.settings,
label: 'Settings',
onTap: () {
final navigator = Navigator.of(context, rootNavigator: true);
Navigator.of(context).pop();
SettingsButton.open(context);
SettingsButton.openWithNavigator(navigator);
},
),
@@ -135,8 +137,12 @@ class _SidebarDrawerState extends State<SidebarDrawer> {
icon: Icons.system_update_alt,
label: 'Check for updates',
onTap: () {
final navigator = Navigator.of(context, rootNavigator: true);
final messenger = ScaffoldMessenger.of(context);
Navigator.of(context).pop();
Updater.check(context);
unawaited(
Updater.check(navigator: navigator, messenger: messenger),
);
},
),
+5 -4
View File
@@ -8,10 +8,11 @@ class SpeedBox extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Consumer<SessionTimer>(
builder: (context, timer, _) {
final speed = timer.currentSpeedKBps > 0.5
? '${timer.currentSpeedKBps.toStringAsFixed(1)} KB/s'
return Selector<SessionTimer, double>(
selector: (_, timer) => timer.currentSpeedKBps,
builder: (context, speedKBps, _) {
final speed = speedKBps > 0.5
? '${speedKBps.toStringAsFixed(1)} KB/s'
: 'Idle';
return Container(
+4 -3
View File
@@ -34,9 +34,10 @@ class _SupportButtonState extends State<SupportButton> {
@override
Widget build(BuildContext context) {
return Consumer<SessionTimer>(
builder: (context, timer, _) {
if (!timer.isRunning && !timer.hasSyncedOnce) {
return Selector<SessionTimer, bool>(
selector: (_, timer) => timer.isRunning || timer.hasSyncedOnce,
builder: (context, visible, _) {
if (!visible) {
return const SizedBox.shrink();
}