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
+664 -209

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
* text=auto eol=lf
*.bat text eol=crlf
*.ps1 text eol=crlf
*.png binary
*.jpg binary
*.jpeg binary
*.webp binary
*.jks binary
*.keystore binary
*.jar binary
+60 -51
View File
@@ -5,63 +5,24 @@
# everything else is excluded by extension or directory rather than by being
# listed individually. A new file is therefore ignored by default instead of
# needing to be named here, and this file does not enumerate what it protects.
#
# Machine-local exclusions live in .git/info/exclude, which is never committed.
# =============================================================================
# ── OS & editor ────────────────────────────────────────────────────────────
*.swp
.DS_Store
.atom/
.history
.svn/
.java-version
.idea/
.vscode/
# ── Flutter / Dart build output ────────────────────────────────────────────
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.pub-cache/
.pub/
build/
**/build/
local_packages/*/build/
local_packages/*/.dart_tool/
*.dart_tool/
**/doc/api/
**/ios/Flutter/.last_build_id
# ── Android build output ───────────────────────────────────────────────────
**/android/**/gradle-wrapper.jar
**/android/.gradle
**/android/captures/
**/android/gradlew
**/android/gradlew.bat
**/android/local.properties
**/android/**/GeneratedPluginRegistrant.java
**/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java
**/android/.cxx/
**/android/app/build/
**/android/app/crashlytics-build.properties
# ── Python bytecode ────────────────────────────────────────────────────────
__pycache__/
*.pyc
# ── Credentials & machine-local config ─────────────────────────────────────
# ── Secrets ────────────────────────────────────────────────────────────────
*.keystore
*.jks
android/key.properties
android/admob.properties
# Real IPs and IDs. app_config.example.dart is the committed template.
lib/logic/app_config.dart
devtools_options.yaml
# ── File types the shipped app does not contain ────────────────────────────
# Server code, infrastructure configs, captures, archives and logs. None of
# these extensions are tracked, so a blanket rule is safe and stays safe.
# Server code, infrastructure configs, captures, archives, dumps and logs.
# None of these extensions are tracked, so a blanket rule is safe and stays
# safe — it covers files that do not exist yet.
*.py
*.pyc
*.json
*.conf
*.sh
@@ -81,10 +42,58 @@ devtools_options.yaml
!/CONTRIBUTING.md
!/PRIVACY_POLICY.md
# ── Local working directories ──────────────────────────────────────────────
# Working material that is not part of the app. Needs a committed rule rather
# than a local-only one, because it can hold source-file types the extension
# rules above deliberately do not cover.
# ── Working directories ────────────────────────────────────────────────────
# Material that is not part of the app. These need directory rules rather
# than type rules, because they can hold source-file types the rules above
# deliberately do not cover.
.local/
internal/
hivemind/
scraped/
backup/
backups/
lib/logic/backup/
# ── Flutter / Dart build output ────────────────────────────────────────────
.dart_tool/
*.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.pub-cache/
.pub/
build/
**/build/
local_packages/*/build/
local_packages/*/.dart_tool/
**/doc/api/
**/ios/Flutter/.last_build_id
# ── Android build output ───────────────────────────────────────────────────
**/android/.gradle
**/android/.cxx/
**/android/app/build/
**/android/captures/
**/android/gradlew
**/android/gradlew.bat
**/android/local.properties
**/android/**/gradle-wrapper.jar
**/android/**/GeneratedPluginRegistrant.java
**/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java
**/android/app/crashlytics-build.properties
# ── Editors, tooling & OS ──────────────────────────────────────────────────
.claude/
.codewhale/
.idea/
.vscode/
.atom/
.history
.svn/
.java-version
.DS_Store
*.swp
__pycache__/
devtools_options.yaml
#why are you even looking at this file? it's a secret. go away.
whatsthis
+71
View File
@@ -5,6 +5,77 @@ Versions follow [semver](https://semver.org/): MAJOR.MINOR.PATCH
---
## [3.3.0] — Unreleased
### Added
- Session countdown in the VPN notification, updated each second while connected.
### Fixed
- **Session-expiry desync** — the app could show "Online" with no internet after
the server ended the session. The countdown no longer freezes when a poll fails.
- **Notification Disconnect button** disappeared a second after connecting.
Re-posting the foreground notification replaces it wholesale, so the action,
colour and tap-intent flutter_vless sets are now rebuilt rather than dropped.
- **Speed readout** divided the byte delta by the nominal poll interval instead of
the real time between polls — wrong by up to 6x around every background switch.
- **Settings toggles** now apply at launch. Rain, lightning and haptics were only
read from disk when Settings mounted, so an effect turned off came back — ticker
and all — on every relaunch, and haptic feedback did nothing until Settings had
been opened at least once.
- **Haptic feedback** now fires `mediumImpact()` instead of `lightImpact()` (which
is imperceptible on many Android devices), wrapped best-effort so a platform
vibration failure cannot fail a connect/disconnect, with a preview tap when the
toggle is switched on.
- First-launch disclosure dialog said data was linked to "your account". It is
linked to a randomly generated device ID, and the policy is in the sidebar, not
on a website.
- Sidebar "Settings" and "Check for updates" used the drawer's context after
`Navigator.pop()` — a defunct context. They now capture the root navigator (and
messenger) before closing the drawer. *(Fix ported from Naveax's review.)*
### Performance
- Rain overlay ticks only while enabled, repaints through a scoped `Listenable`
instead of rebuilding every frame, and precomputes drop colours.
- Lightning no longer fires strikes while disabled.
- Connect button pulse pauses when backgrounded; brand image hoisted out of the
per-frame rebuild.
- Server poll drops to 30 s while backgrounded (5 s foreground), with an immediate
re-sync on resume. The 1 s countdown keeps running.
- Health probe runs only while disconnected, and pauses in the background.
- Speed and support widgets rebuild on value change instead of every second.
- Notification channel is created once per process, not once per second.
### Changed
- VLESS engine initializes under the splash screen; the intro waits on
`VpnConnection.ready` (8 s cap) before showing the main screen.
- AdMob SDK init moved off the blocking pre-`runApp()` path so startup cannot hang
on an unreachable Google endpoint.
- `app_config.example.dart` drops the dead bootstrap fields and documents
`hivemindApiPublic` in their place.
- `.gitignore` reorganized around type and directory rules; the enumerated
filename list is gone (redundant with the type rules) and `.claude/` is now
ignored.
### Security
- Update checker pins the release URL to this repo's path on `github.com`, not just
the host. Anything else falls back to the releases page.
- Notification timer updates are gated on a live tunnel, so a late tick cannot leave
an ongoing notification with no foreground service behind it.
- Dropped `READ_EXTERNAL_STORAGE`, which `flutter_vless` pulls in but the app never uses
(least privilege).
- Pinned the Gradle 8.14 distribution SHA-256; `validateDistributionUrl` enabled
(supply-chain integrity).
### Build & tooling (ported from Naveax)
- Removed the deprecated `android.enableR8` flag.
- Added `analysis_options.yaml` (flutter_lints, `local_packages/**` excluded, `avoid_print`).
- Added `.gitattributes` to pin LF line endings repo-wide.
- Credit: reviewed and ported from **Naveax**.
### Docs
- Privacy policy: disclosed that the server sees the connecting IP, corrected the
"stored in memory" claim, and documented consent being changeable from the sidebar.
## [3.2.2] — 2026-08-08
### Fixed
+6 -2
View File
@@ -15,9 +15,13 @@ We collect the minimum required to operate the service:
- A randomly generated device ID created on your phone. This is not linked to your identity in any way.
- Your connection duration and data usage for the current session, linked to your device ID. This is used solely to enforce the per-session quota (see below).
## What our server sees
To start, check, or refresh your session, the app contacts our API directly rather than through the VPN tunnel. Our server therefore sees the IP address you are connecting from, alongside your device ID, for as long as the app is running. This is a necessary consequence of how sessions are issued and kept alive — it is separate from your browsing traffic, which is carried inside the tunnel and is described below.
## Session quotas
Each session lasts up to **2 hours** or **10 GB** of data — whichever comes first. When your session expires or hits the data cap, the tunnel disconnects. You can start a new session by watching another ad. Session data (duration and bytes transferred) is stored in memory and deleted when your session ends.
Each session lasts up to **2 hours** or **10 GB** of data — whichever comes first. When your session expires or hits the data cap, the tunnel disconnects. You can start a new session by watching another ad. Session records (duration and bytes transferred) are held on our server for the life of your session and removed when it ends.
Support ads (the "Support us" button) extend an active session by 30 minutes and add extra data allowance.
@@ -34,7 +38,7 @@ We keep a short in-memory log buffer (last 200 lines) for debugging and server h
## Ads & Consent
ReVoltVPN uses Google AdMob to display rewarded video ads. On first launch, users in the European Economic Area (EEA) and United Kingdom are shown a consent dialog (provided by Google's User Messaging Platform) that lets them choose whether to allow personalized ads. This consent choice is stored on your device and can be changed at any time by reinstalling the app. Google may collect data as part of ad delivery. Please refer to Google's Privacy Policy for details.
ReVoltVPN uses Google AdMob to display rewarded video ads. Where required — for users in the European Economic Area (EEA) and United Kingdom — a consent dialog provided by Google's User Messaging Platform is shown before the first ad is requested, letting you choose whether to allow personalized ads. This choice is stored on your device and can be reviewed at any time from the "Ad Consent" entry in the sidebar menu. Google may collect data as part of ad delivery. Please refer to Google's Privacy Policy for details.
## Your traffic
+10
View File
@@ -0,0 +1,10 @@
include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- local_packages/**
- whatsthis/**
linter:
rules:
avoid_print: true
+24 -20
View File
@@ -1,19 +1,25 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<!-- VpnService provides its own system notification; SPECIAL_USE is not needed -->
<!-- Required by url_launcher on Android 11+ to open external browser -->
<queries>
<intent>
<action android:name="android.intent.action.VIEW"/>
<data android:scheme="https"/>
</intent>
</queries>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<application
<!-- flutter_vless 1.1.5 declares this legacy permission. ReVolt never needs it. -->
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
tools:node="remove"/>
<queries>
<intent>
<action android:name="android.intent.action.VIEW"/>
<data android:scheme="https"/>
</intent>
</queries>
<application
android:label="Revolt VPN"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
@@ -21,7 +27,6 @@
android:allowBackup="false"
android:extractNativeLibs="true">
<!-- AdMob App ID — injected at build time from android/admob.properties -->
<meta-data
android:name="com.google.android.gms.ads.APPLICATION_ID"
android:value="${ADMOB_APP_ID}"/>
@@ -35,17 +40,16 @@
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<meta-data
android:name="flutterEmbedding"
android:value="2" />
android:value="2"/>
</application>
</manifest>
@@ -1,6 +1,16 @@
package com.paladinvpn.app
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.github.tfox.flutter_vless.xray.service.XrayVPNService
import com.github.tfox.flutter_vless.xray.utils.AppConfigs
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
@@ -11,6 +21,11 @@ class MainActivity : FlutterActivity() {
// Must match the string in lib/logic/updater.dart.
private val installerChannel = "com.revoltvpn.app/installer"
// Must match lib/logic/notification_service.dart.
private val notificationChannel = "com.revoltvpn.app/notification"
private var notificationChannelReady = false
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
@@ -22,6 +37,102 @@ class MainActivity : FlutterActivity() {
result.notImplemented()
}
}
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, notificationChannel)
.setMethodCallHandler { call, result ->
if (call.method == "updateNotificationText") {
val text = call.argument<String>("text") ?: ""
val title = call.argument<String>("title") ?: "Revolt VPN"
val action = call.argument<String>("disconnectLabel") ?: "Disconnect"
updateVpnNotification(title, text, action)
result.success(null)
} else {
result.notImplemented()
}
}
}
/**
* Sets the session countdown as the VPN notification's content text.
*
* notify() is per-package, so re-posting flutter_vless's notification id
* replaces it wholesale — every field the plugin set has to be rebuilt
* here or it is dropped, the Disconnect action most of all. Keep in sync
* with XrayCoreManager.showNotification().
*/
private fun updateVpnNotification(title: String, text: String, actionLabel: String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED
) {
return
}
ensureNotificationChannel()
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
// Same intent shape as the plugin, so a tap brings the running task
// forward instead of starting a second one.
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
launchIntent?.action = "FROM_DISCONNECT_BTN"
launchIntent?.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or
Intent.FLAG_ACTIVITY_CLEAR_TOP or
Intent.FLAG_ACTIVITY_NEW_TASK
// Tapping "Disconnect" stops the tunnel without opening the app.
val stopIntent = Intent(this, XrayVPNService::class.java)
stopIntent.putExtra(
"COMMAND",
AppConfigs.V2RAY_SERVICE_COMMANDS.STOP_SERVICE
)
val stopPendingIntent = PendingIntent.getService(this, 0, stopIntent, flags)
val builder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.notification_icon)
.setContentTitle(title)
.setContentText(text)
.addAction(0, actionLabel, stopPendingIntent)
.setColorized(true)
.setColor(0xFF0D1117.toInt())
.setOngoing(true)
.setOnlyAlertOnce(true)
.setSilent(true)
.setShowWhen(false)
.setPriority(NotificationCompat.PRIORITY_LOW)
if (launchIntent != null) {
builder.setContentIntent(
PendingIntent.getActivity(this, 0, launchIntent, flags)
)
}
try {
NotificationManagerCompat.from(this).notify(NOTIFICATION_ID, builder.build())
} catch (_: Exception) {
// The foreground notification may not be up yet — the update is cosmetic.
}
}
/** A binder round-trip, and the caller runs once a second — so, once per process. */
private fun ensureNotificationChannel() {
if (notificationChannelReady) return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channel = NotificationChannel(
NOTIFICATION_CHANNEL_ID,
"Revolt VPN Background Service",
NotificationManager.IMPORTANCE_LOW
)
channel.setShowBadge(false)
manager.createNotificationChannel(channel)
}
notificationChannelReady = true
}
/**
@@ -39,4 +150,10 @@ class MainActivity : FlutterActivity() {
} catch (e: Exception) {
null
}
companion object {
// Must match flutter_vless's XrayCoreManager.
private const val NOTIFICATION_ID = 1
private const val NOTIFICATION_CHANNEL_ID = "XRAY_SERVICE_CHANNEL"
}
}
-3
View File
@@ -1,8 +1,5 @@
org.gradle.jvmargs=-Xmx1536M
android.enableR8=true
android.useAndroidX=true
android.enableJetifier=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
+3
View File
@@ -3,3 +3,6 @@ distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
distributionSha256Sum=efe9a3d147d948d7528a9887fa35abcf24ca1a43ad06439996490f77569b02d1
networkTimeout=10000
validateDistributionUrl=true
+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) {
+33 -17
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,17 +45,33 @@ 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) {
@@ -68,23 +85,23 @@ class _RainEffectState extends State<RainEffect>
for (final d in _drops) {
d.y += d.speed * dt;
if (d.y > 1.08) {
d.spawn(_random, widget.minLength, widget.maxLength);
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,
);
@@ -100,33 +117,33 @@ class _Drop {
double y;
double speed;
double length;
double opacity;
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();
}
+3 -18
View File
@@ -8,26 +8,11 @@ abstract final class AppConfig {
// ── Server ───────────────────────────────────────────────────────────
/// Your server's public IP. The VPN tunnel is pinned to this.
static const String serverIp = '0.0.0.0'; // ◄── REPLACE
// ── Bootstrap (first-connect config fetch) ────────────────────────────
/// Hardcoded Reality config. App connects with this, fetches the real
/// per-session VLESS URL through the tunnel, then reconnects.
static String get bootstrapVlessUrl =>
'vless://$bootstrapUuid@$serverIp:8443'
'?security=reality&type=xhttp&path=/revolt'
'&pbk=$realityPbk&sni=www.github.com&sid=$realitySid&fp=chrome'
'#ReVoltVPN';
/// Generate with `xray uuid` on the server. Must match xray_config_reality.json.
static const String bootstrapUuid = '00000000-0000-0000-0000-000000000000'; // ◄── REPLACE
/// Server's Reality public key (from `xray x25519`).
static const String realityPbk = 'REPLACE_WITH_YOUR_PUBLIC_KEY';
/// Must be in xray_config_reality.json shortIds[].
static const String realitySid = 'abc123';
static const String hivemindApiPublic = 'https://YOUR_DOMAIN/api'; // ◄── REPLACE
// ── Reality (VPN tunnel) ──────────────────────────────────────────────
// Credentials (public key, shortId) come from /session/status, not from
// here. Only the transport parameters below are compiled in.
/// The server picks a random SNI per session from REALITY_SNI_POOL.
/// The client does NOT hardcode one — server is always the source of truth.
+37
View File
@@ -0,0 +1,37 @@
import 'package:flutter/services.dart';
// ── lib/logic/notification_service.dart ─────────────────────────────────────
// Bridge to the native VPN notification, which flutter_vless owns. This only
// refreshes its content text with the session countdown, so a failure here is
// never worth propagating to the session timer.
class NotificationService {
NotificationService._();
static const MethodChannel _channel =
MethodChannel('com.revoltvpn.app/notification');
static String? _lastText;
/// Sets the notification's content text to [text].
///
/// The native side rebuilds the whole notification, so skip the round-trip
/// when the text has not changed.
static Future<void> updateTimer(String text) async {
if (text == _lastText) return;
_lastText = text;
try {
await _channel.invokeMethod<void>('updateNotificationText', {
'title': 'Revolt VPN',
'text': text,
'disconnectLabel': 'Disconnect',
});
} catch (_) {
// Cosmetic.
}
}
/// Forces the next connect to re-post even if the countdown resumes on the
/// same value.
static void reset() => _lastText = null;
}
+3 -3
View File
@@ -5,9 +5,9 @@ import 'package:shared_preferences/shared_preferences.dart';
// Server data model and ChangeNotifier — shared by StatusBar (shows current
// server name/flag) and SelectionScreen (lets user pick a server).
//
// Each server is defined by its tunnel IP/port. Reality keys (public key,
// shortId, fingerprint) come from AppConfig — all servers share the same
// Reality keypair and bootstrap UUID (same operator, different locations).
// Each server is defined by its tunnel IP/port. Reality credentials (public
// key, shortId) come from /session/status per session; only the transport
// parameters live in AppConfig.
//
// When multi-server ships:
// - _all becomes a fetched list (from /api/servers or bundled config)
+55 -11
View File
@@ -1,14 +1,16 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:revoltvpn/logic/hivemind_service.dart';
import 'package:revoltvpn/logic/app_config.dart';
import 'package:revoltvpn/logic/vpn_connection.dart';
import 'package:revoltvpn/logic/crypto_service.dart';
import 'package:revoltvpn/logic/notification_service.dart';
class SessionTimer extends ChangeNotifier {
class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
Timer? _timer;
int _tickCount = 0;
bool _appBackgrounded = false;
final VpnConnection vpnConnection;
@@ -25,11 +27,14 @@ class SessionTimer extends ChangeNotifier {
int _offlineSeconds = 0;
static const int _pollIntervalSeconds = 5;
static const int _backgroundPollIntervalSeconds = 30;
int _lastUsedBytes = 0;
double _currentSpeedKBps = 0.0;
int _lastUsedBytes = 0;
double _currentSpeedKBps = 0.0;
DateTime? _lastSyncAt;
SessionTimer({required this.vpnConnection}) {
WidgetsBinding.instance.addObserver(this);
vpnConnection.addListener(_onVpnConnectionChanged);
}
@@ -77,11 +82,14 @@ class SessionTimer extends ChangeNotifier {
_usedBytes = 0;
_lastUsedBytes = 0;
_currentSpeedKBps = 0.0;
_lastSyncAt = null;
NotificationService.reset();
_tickCount = 0;
_hasSyncedOnce = false;
_consecutiveFailures = 0;
_offlineSeconds = 0;
_isDisconnecting = false;
_appBackgrounded = false;
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 1), _tick);
@@ -92,10 +100,9 @@ class SessionTimer extends ChangeNotifier {
void _tick(Timer t) {
if (_isDisconnecting) return;
if (_hasSyncedOnce && _consecutiveFailures < _maxConsecutiveFailures) {
if (_remainingSeconds > 0) {
_remainingSeconds--;
}
// A local clock: poll failures are about the network, not the clock.
if (_hasSyncedOnce && _remainingSeconds > 0) {
_remainingSeconds--;
}
if (_consecutiveFailures >= _maxConsecutiveFailures) {
@@ -112,11 +119,22 @@ class SessionTimer extends ChangeNotifier {
}
_tickCount++;
if (_tickCount % _pollIntervalSeconds == 0) {
final pollInterval = _appBackgrounded
? _backgroundPollIntervalSeconds
: _pollIntervalSeconds;
if (_tickCount % pollInterval == 0) {
_syncWithHivemind();
}
notifyListeners();
// Gated on a live tunnel. The plugin cancels the notification when the
// service stops, and re-posting after that leaves an ongoing notification
// the user cannot swipe away below Android 14.
if (_hasSyncedOnce &&
vpnConnection.status == VpnStatus.connected) {
NotificationService.updateTimer(formatted);
}
}
Future<void> disconnect({String reason = 'User requested'}) async {
@@ -133,6 +151,8 @@ class SessionTimer extends ChangeNotifier {
_currentSpeedKBps = 0.0;
_remainingSeconds = 0;
_hasSyncedOnce = false;
_lastSyncAt = null;
NotificationService.reset();
notifyListeners();
await vpnConnection.disconnect();
@@ -163,9 +183,17 @@ class SessionTimer extends ChangeNotifier {
_remainingSeconds = data['expires_in_seconds'] ?? _remainingSeconds;
_usedBytes = data['used_bytes'] ?? _usedBytes;
// Measure the real gap between polls: the interval is 5 s in the
// foreground, 30 s in the background, and arbitrary on a resume sync.
final now = DateTime.now();
final lastSync = _lastSyncAt;
_lastSyncAt = now;
final int deltaBytes = _usedBytes - _lastUsedBytes;
if (_hasSyncedOnce && deltaBytes > 0) {
_currentSpeedKBps = (deltaBytes / _pollIntervalSeconds) / 1000;
if (_hasSyncedOnce && deltaBytes > 0 && lastSync != null) {
final elapsed = now.difference(lastSync).inMilliseconds / 1000.0;
_currentSpeedKBps =
elapsed > 0 ? (deltaBytes / elapsed) / 1000 : _currentSpeedKBps;
} else if (!_hasSyncedOnce) {
_currentSpeedKBps = 0.0;
}
@@ -200,6 +228,7 @@ class SessionTimer extends ChangeNotifier {
}
void _resumeTicking() {
_lastSyncAt = null;
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 1), _tick);
_isDisconnecting = false;
@@ -207,8 +236,23 @@ class SessionTimer extends ChangeNotifier {
notifyListeners();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.detached) {
// Keep ticking, but slow the poll — the 5 s one wakes the radio.
_appBackgrounded = true;
} else if (state == AppLifecycleState.resumed) {
_appBackgrounded = false;
if (_timer != null && _timer!.isActive) {
_syncWithHivemind();
}
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
vpnConnection.removeListener(_onVpnConnectionChanged);
_timer?.cancel();
super.dispose();
+29 -9
View File
@@ -61,8 +61,24 @@ class GitHubUpdater {
}
}
static bool _isTrustedReleaseUrl(String url) {
final uri = Uri.tryParse(url);
if (uri == null || uri.scheme != 'https' || uri.host != 'github.com') {
return false;
}
// Host alone is not enough: anyone can host a release on github.com.
return uri.path
.startsWith('/${AppConfig.githubOwner}/${AppConfig.githubRepo}/');
}
static Future<bool> open() async {
final url = _cachedDownloadUrl ?? AppConfig.githubReleasesUrl;
final cached = _cachedDownloadUrl;
final url = (cached != null && _isTrustedReleaseUrl(cached))
? cached
: AppConfig.githubReleasesUrl;
if (cached != null && url != cached) {
debugPrint('[Updater] Rejected untrusted release URL: $cached');
}
try {
return await launchUrl(Uri.parse(url),
mode: LaunchMode.externalApplication);
@@ -115,32 +131,35 @@ class Updater {
return '$prefix$v${isLocal ? ' (installed)' : ''}';
}
static Future<void> check(BuildContext context) async {
static Future<void> check({
required NavigatorState navigator,
required ScaffoldMessengerState messenger,
}) async {
final info = await PackageInfo.fromPlatform();
final localVersion = info.version;
if (localVersion.isEmpty) {
_showSnackBar(context, 'Could not determine app version',
_showSnackBar(messenger, 'Could not determine app version',
isError: true);
return;
}
final latestVersion = await GitHubUpdater.fetchLatestVersion();
if (latestVersion == null) {
_showSnackBar(context, 'Could not reach GitHub', isError: true);
_showSnackBar(messenger, 'Could not reach GitHub', isError: true);
return;
}
if (_compareVersions(localVersion, latestVersion) >= 0) {
_showSnackBar(context, 'Up to date');
_showSnackBar(messenger, 'Up to date');
return;
}
final source = await installSource;
final storeName = source == InstallSource.playStore ? 'Play Store' : 'GitHub';
if (!context.mounted) return;
if (!navigator.mounted) return;
showDialog(
context: context,
context: navigator.context,
builder: (ctx) => AlertDialog(
backgroundColor: AppColors.bgCard,
title: const Text('Update available',
@@ -171,9 +190,10 @@ class Updater {
);
}
static void _showSnackBar(BuildContext context, String message,
static void _showSnackBar(ScaffoldMessengerState messenger, String message,
{bool isError = false}) {
ScaffoldMessenger.of(context).showSnackBar(
if (!messenger.mounted) return;
messenger.showSnackBar(
SnackBar(
content: Row(
children: [
+28 -4
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_vless/flutter_vless.dart';
import 'package:revoltvpn/logic/hivemind_service.dart';
@@ -11,7 +12,7 @@ enum VpnStatus {
error,
}
class VpnConnection extends ChangeNotifier {
class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
bool _cancelled = false;
VpnStatus _status = VpnStatus.disconnected;
VpnStatus get status => _status;
@@ -36,6 +37,7 @@ class VpnConnection extends ChangeNotifier {
Future<void> get ready => _readyCompleter.future;
VpnConnection() {
WidgetsBinding.instance.addObserver(this);
_init();
}
@@ -71,9 +73,7 @@ class VpnConnection extends ChangeNotifier {
debugPrint('[VPN] VLESS init error (expected on emulator): $e');
}
_checkHealth();
_healthTimer =
Timer.periodic(const Duration(seconds: 30), (_) => _checkHealth());
_startHealthPolling();
try {
final coreVersion = await _vless.getCoreVersion();
@@ -251,13 +251,37 @@ class VpnConnection extends ChangeNotifier {
notifyListeners();
}
void _startHealthPolling() {
_healthTimer?.cancel();
_checkHealthIfIdle();
_healthTimer = Timer.periodic(
const Duration(seconds: 30), (_) => _checkHealthIfIdle());
}
Future<void> _checkHealthIfIdle() async {
// A live tunnel already proves reachability.
if (_status == VpnStatus.connected) return;
await _checkHealth();
}
Future<void> _checkHealth() async {
_serverReachable = await HivemindService.checkHealth();
notifyListeners();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.detached) {
_healthTimer?.cancel();
} else if (state == AppLifecycleState.resumed) {
_startHealthPolling();
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_healthTimer?.cancel();
if (_status == VpnStatus.connected || _status == VpnStatus.connecting) {
try {
+14
View File
@@ -4,6 +4,9 @@ import 'package:provider/provider.dart';
import 'package:revoltvpn/logic/app_colors.dart';
import 'package:revoltvpn/logic/vpn_connection.dart';
import 'package:revoltvpn/screens/main_screen.dart';
import 'package:revoltvpn/screens/settings/in_settings/rain.dart';
import 'package:revoltvpn/screens/settings/in_settings/lightning.dart';
import 'package:revoltvpn/screens/settings/in_settings/haptic.dart';
class IntroScreen extends StatefulWidget {
const IntroScreen({super.key});
@@ -42,6 +45,11 @@ class _IntroScreenState extends State<IntroScreen> with WidgetsBindingObserver {
final elapsed = Stopwatch()..start();
final vpn = context.read<VpnConnection>();
// The overlays read these in initState, so they must land before
// MainScreen mounts.
final settingsPrefs = Future.wait(
[loadRainPref(), loadLightningPref(), loadHapticPref()]);
try {
await vpn.ready.timeout(_bootTimeout);
} on TimeoutException {
@@ -51,6 +59,12 @@ class _IntroScreenState extends State<IntroScreen> with WidgetsBindingObserver {
debugPrint('[Boot] Engine init error: $e');
}
try {
await settingsPrefs;
} catch (e) {
debugPrint('[Boot] Settings prefs load failed, using defaults: $e');
}
if (!mounted) return;
setState(() => _status = 'Ready');
+38 -5
View File
@@ -1,9 +1,44 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:revoltvpn/logic/app_colors.dart';
// A plain bool, not a ValueNotifier: this is only read at the moment of a tap,
// so nothing has to react to it changing. Default: false.
bool hapticEnabled = false;
/// Hydrates [hapticEnabled] from disk. Called at boot — the toggle tile below
/// only mounts once Settings is opened.
Future<void> loadHapticPref() async {
final prefs = await SharedPreferences.getInstance();
hapticEnabled = prefs.getBool('haptic_feedback_enabled') ?? false;
}
/// Haptic calls are deliberately best-effort: a vibration failure must never
/// block or fail a VPN connect/disconnect. `mediumImpact` is used instead of
/// `lightImpact` — the latter is imperceptible on many Android devices.
class Haptics {
Haptics._();
static Future<void> connectionChanged() async {
if (!hapticEnabled) return;
try {
await HapticFeedback.mediumImpact();
} catch (e) {
debugPrint('[Haptics] Feedback unavailable: $e');
}
}
static Future<void> previewEnabled() async {
try {
await HapticFeedback.selectionClick();
} catch (e) {
debugPrint('[Haptics] Preview unavailable: $e');
}
}
}
class HapticFeedbackTile extends StatefulWidget {
const HapticFeedbackTile({super.key});
@@ -20,12 +55,9 @@ class _HapticFeedbackTileState extends State<HapticFeedbackTile> {
_load();
}
// Loads persisted state and syncs the top-level bool.
Future<void> _load() async {
final prefs = await SharedPreferences.getInstance();
final value = prefs.getBool('haptic_feedback_enabled') ?? false;
hapticEnabled = value;
if (mounted) setState(() => _on = value);
await loadHapticPref();
if (mounted) setState(() => _on = hapticEnabled);
}
// Writes to SharedPreferences AND updates the live bool so the connect
@@ -34,6 +66,7 @@ class _HapticFeedbackTileState extends State<HapticFeedbackTile> {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('haptic_feedback_enabled', value);
hapticEnabled = value;
if (value) unawaited(Haptics.previewEnabled());
setState(() => _on = value);
}
@@ -4,12 +4,13 @@ import 'package:revoltvpn/logic/app_colors.dart';
// ── lib/screens/settings/in_settings/lightning.dart ─────────────────────────
// Toggle for the lightning strike overlay on the main screen.
//
// Same architecture as haptic.dart: exports a top-level bool read synchronously
// by lib/components/lightning.dart. Default: true (lightning is on — opt-out).
// lib/components/lightning.dart reads this in its build method.
bool lightningEnabled = true;
final ValueNotifier<bool> lightningEnabled = ValueNotifier(true);
Future<void> loadLightningPref() async {
final prefs = await SharedPreferences.getInstance();
lightningEnabled.value = prefs.getBool('lightning_effect_enabled') ?? true;
}
class LightningToggleTile extends StatefulWidget {
const LightningToggleTile({super.key});
@@ -28,16 +29,14 @@ class _LightningToggleTileState extends State<LightningToggleTile> {
}
Future<void> _load() async {
final prefs = await SharedPreferences.getInstance();
final value = prefs.getBool('lightning_effect_enabled') ?? true;
lightningEnabled = value;
if (mounted) setState(() => _on = value);
await loadLightningPref();
if (mounted) setState(() => _on = lightningEnabled.value);
}
Future<void> _toggle(bool value) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('lightning_effect_enabled', value);
lightningEnabled = value;
lightningEnabled.value = value;
setState(() => _on = value);
}
+9 -10
View File
@@ -4,12 +4,13 @@ import 'package:revoltvpn/logic/app_colors.dart';
// ── lib/screens/settings/in_settings/rain.dart ──────────────────────────────
// Toggle for the animated rain overlay on the main screen.
//
// Same architecture as haptic.dart: exports a top-level bool read synchronously
// by lib/components/rain.dart. Default: true (rain is on — opt-out).
// lib/components/rain.dart reads this in its build method.
bool rainEnabled = true;
final ValueNotifier<bool> rainEnabled = ValueNotifier(true);
Future<void> loadRainPref() async {
final prefs = await SharedPreferences.getInstance();
rainEnabled.value = prefs.getBool('rain_effect_enabled') ?? true;
}
class RainToggleTile extends StatefulWidget {
const RainToggleTile({super.key});
@@ -28,16 +29,14 @@ class _RainToggleTileState extends State<RainToggleTile> {
}
Future<void> _load() async {
final prefs = await SharedPreferences.getInstance();
final value = prefs.getBool('rain_effect_enabled') ?? true;
rainEnabled = value;
if (mounted) setState(() => _on = value);
await loadRainPref();
if (mounted) setState(() => _on = rainEnabled.value);
}
Future<void> _toggle(bool value) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('rain_effect_enabled', value);
rainEnabled = value;
rainEnabled.value = value;
setState(() => _on = value);
}
+1 -1
View File
@@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 3.2.2+25
version: 3.3.0+26
environment:
sdk: ">=3.0.0 <4.0.0"