diff --git a/.gitignore b/.gitignore
index a3670f8..1d5aa91 100644
--- a/.gitignore
+++ b/.gitignore
@@ -79,3 +79,4 @@ server full picture.txt
technical_debt_*.md
server_full_picture_2.txt
hivemind_backup.py
+UI_POSITIONING.md
diff --git a/android/app/src/main/res/drawable-hdpi/launch_image.png b/android/app/src/main/res/drawable-hdpi/launch_image.png
new file mode 100644
index 0000000..e9c791c
Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/launch_image.png differ
diff --git a/android/app/src/main/res/drawable-mdpi/launch_image.png b/android/app/src/main/res/drawable-mdpi/launch_image.png
new file mode 100644
index 0000000..99dcaf0
Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/launch_image.png differ
diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml
index f74085f..56d8f1d 100644
--- a/android/app/src/main/res/drawable-v21/launch_background.xml
+++ b/android/app/src/main/res/drawable-v21/launch_background.xml
@@ -1,12 +1,10 @@
-
-
-
-
-
+ android:src="@drawable/launch_image"
+ android:tileMode="disabled" />
+
diff --git a/android/app/src/main/res/drawable-xhdpi/launch_image.png b/android/app/src/main/res/drawable-xhdpi/launch_image.png
new file mode 100644
index 0000000..1ab5109
Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/launch_image.png differ
diff --git a/android/app/src/main/res/drawable-xxhdpi/launch_image.png b/android/app/src/main/res/drawable-xxhdpi/launch_image.png
new file mode 100644
index 0000000..587e12c
Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/launch_image.png differ
diff --git a/android/app/src/main/res/drawable-xxxhdpi/launch_image.png b/android/app/src/main/res/drawable-xxxhdpi/launch_image.png
new file mode 100644
index 0000000..5a9c569
Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/launch_image.png differ
diff --git a/android/app/src/main/res/drawable/ic_launcher_background.xml b/android/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..3f46c10
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,5 @@
+
+
+
+
diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml
index 19dc1a3..56d8f1d 100644
--- a/android/app/src/main/res/drawable/launch_background.xml
+++ b/android/app/src/main/res/drawable/launch_background.xml
@@ -1,12 +1,10 @@
-
-
-
-
-
+ android:src="@drawable/launch_image"
+ android:tileMode="disabled" />
+
diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..3ba4e35
--- /dev/null
+++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..3ba4e35
--- /dev/null
+++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
index 947e54f..6202f4d 100644
Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..72ebd9f
Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
index 947e54f..6202f4d 100644
Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
index 947e54f..1765306 100644
Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..26a4fec
Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
index 947e54f..1765306 100644
Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
index 947e54f..2eaec0b 100644
Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..4cbefb0
Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
index 947e54f..2eaec0b 100644
Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
index 947e54f..3cf98f3 100644
Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..930761d
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
index 947e54f..3cf98f3 100644
Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
index 947e54f..6cf2d9e 100644
Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..56e7a44
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
index 947e54f..6cf2d9e 100644
Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..94a8db0
--- /dev/null
+++ b/android/app/src/main/res/values/colors.xml
@@ -0,0 +1,4 @@
+
+
+ #0d1017
+
diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml
index 1f83a33..b0b909b 100644
--- a/android/app/src/main/res/values/styles.xml
+++ b/android/app/src/main/res/values/styles.xml
@@ -13,6 +13,6 @@
This Theme is only used starting with V2 of Flutter's Android embedding. -->
diff --git a/assets/brand.png b/assets/brand.png
new file mode 100644
index 0000000..3b5f36e
Binary files /dev/null and b/assets/brand.png differ
diff --git a/assets/logo.png b/assets/logo.png
index 7d8b450..32ffd0d 100644
Binary files a/assets/logo.png and b/assets/logo.png differ
diff --git a/lib/components/bottom_bar.dart b/lib/components/bottom_bar.dart
deleted file mode 100644
index e081c64..0000000
--- a/lib/components/bottom_bar.dart
+++ /dev/null
@@ -1,48 +0,0 @@
-import 'package:flutter/material.dart';
-import 'package:provider/provider.dart';
-import 'package:revoltvpn/logic/vpn_connection.dart';
-import 'package:revoltvpn/logic/session_timer.dart';
-import 'package:revoltvpn/logic/app_colors.dart';
-import 'package:revoltvpn/components/online_dot.dart';
-
-/// BottomBar is the bottom utility panel that surfaces server connectivity status.
-class BottomBar extends StatelessWidget {
- const BottomBar({super.key});
-
- @override
- Widget build(BuildContext context) {
- return Consumer2(
- builder: (context, vpn, timer, _) {
- final isConnected = vpn.status == VpnStatus.connected;
- // Health is based on whether we've successfully talked to the
- // server recently — not just whether the VPN tunnel is up.
- final serverHealthy = isConnected && timer.hasSyncedOnce;
-
- return Container(
- margin: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
- padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
- decoration: BoxDecoration(
- color: AppColors.bgCard,
- borderRadius: BorderRadius.circular(16),
- border: Border.all(
- color: AppColors.slate15,
- ),
- ),
- child: Row(
- children: [
- // Server online/offline chip
- OnlineDot(
- isOnline: serverHealthy,
- label: isConnected && !timer.hasSyncedOnce
- ? 'Syncing…'
- : serverHealthy
- ? 'Server Online'
- : 'Disconnected',
- ),
- ],
- ),
- );
- },
- );
- }
-}
diff --git a/lib/components/clock_display.dart b/lib/components/clock_display.dart
index ded6924..ea41570 100644
--- a/lib/components/clock_display.dart
+++ b/lib/components/clock_display.dart
@@ -37,7 +37,7 @@ class ClockDisplay extends StatelessWidget {
height: 32,
child: CircularProgressIndicator(
strokeWidth: 2,
- color: AppColors.cyan50,
+ color: AppColors.accent50,
),
),
const SizedBox(height: 10),
@@ -46,7 +46,7 @@ class ClockDisplay extends StatelessWidget {
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
- color: AppColors.cyan60,
+ color: AppColors.accent60,
letterSpacing: 1.5,
),
),
@@ -61,7 +61,7 @@ class ClockDisplay extends StatelessWidget {
letterSpacing: 2,
shadows: [
Shadow(
- color: AppColors.cyan50,
+ color: AppColors.accent50,
blurRadius: 16,
),
],
diff --git a/lib/components/connect_button.dart b/lib/components/connect_button.dart
index 7d85ecd..59229af 100644
--- a/lib/components/connect_button.dart
+++ b/lib/components/connect_button.dart
@@ -5,8 +5,8 @@ import 'package:revoltvpn/logic/app_colors.dart';
import 'package:revoltvpn/logic/vpn_connection.dart';
import 'package:revoltvpn/logic/session_timer.dart';
import 'package:revoltvpn/logic/ad_manager.dart';
-import 'package:revoltvpn/components/clock_display.dart';
+/// ConnectButton — circle with brand.png + pulsing glow when connected.
class ConnectButton extends StatefulWidget {
const ConnectButton({super.key});
@@ -16,232 +16,123 @@ class ConnectButton extends StatefulWidget {
class _ConnectButtonState extends State
with SingleTickerProviderStateMixin {
- late AnimationController _pulseController;
- late Animation _pulseAnim;
+ bool _busy = false;
+ AnimationController? _pulse;
+ Animation? _pulseAnim;
- bool _inCooldown = false;
+ double get _pulseValue => _pulseAnim?.value ?? 0.0;
@override
void initState() {
super.initState();
- _pulseController = AnimationController(
- vsync: this,
- duration: const Duration(milliseconds: 2000),
- );
- _pulseAnim = Tween(begin: 0.0, end: 1.0).animate(
- CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut),
- );
-
- // Listen to VpnConnection changes to drive the pulse animation
- // from outside build(), keeping build() pure.
+ 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.addPostFrameCallback((_) {
- context.read().addListener(_onVpnStateChanged);
+ context.read().addListener(_onVpnChanged);
});
}
@override
void dispose() {
- context.read().removeListener(_onVpnStateChanged);
- _pulseController.dispose();
+ context.read().removeListener(_onVpnChanged);
+ _pulse?.dispose();
super.dispose();
}
- void _onVpnStateChanged() {
- if (!mounted) return;
- final isConnected = context.read().status == VpnStatus.connected;
- if (isConnected && !_pulseController.isAnimating) {
- _pulseController.repeat(reverse: true);
- } else if (!isConnected && _pulseController.isAnimating) {
- _pulseController.stop();
- _pulseController.reset();
+ void _onVpnChanged() {
+ final vpn = context.read();
+ if (vpn.status == VpnStatus.connected) {
+ _pulse?.repeat(reverse: true);
+ } else {
+ _pulse?.stop();
+ _pulse?.reset();
}
}
- void _handleTap() async {
- if (_inCooldown) return;
+ Future _handleTap() async {
+ HapticFeedback.lightImpact();
final vpn = context.read();
final timer = context.read();
+ final ad = context.read();
- if (vpn.status == VpnStatus.connected) {
- // Disconnect — await the full teardown so the notification and UI
- // stay in sync with the actual tunnel state.
- HapticFeedback.lightImpact();
- timer.stop();
+ // Disconnect always works — even mid-connection
+ if (vpn.status == VpnStatus.connected || vpn.status == VpnStatus.connecting) {
+ _busy = false; // cancel any in-flight connect
await vpn.disconnect();
- // Short cooldown to prevent accidental immediate reconnect
- setState(() => _inCooldown = true);
- Future.delayed(const Duration(seconds: 2), () {
- if (mounted) setState(() => _inCooldown = false);
- });
- } else if (vpn.status == VpnStatus.disconnected ||
- vpn.status == VpnStatus.error) {
- HapticFeedback.lightImpact();
+ timer.stop();
+ return;
+ }
- // ── Show rewarded ad ──
- // In production the server creates the session via AdMob SSV callback
- // only after a real ad is watched. The debug bypass in HivemindService
- // handles session creation when adsEnabled is false.
- final adMgr = context.read();
- final adWatched = await adMgr.showAd('main_ad');
- if (AdManager.adsEnabled && !adWatched) {
- // User closed the ad before earning the reward — don't connect.
+ if (_busy) return; // already connecting, prevent double-tap
+
+ setState(() => _busy = true);
+ try {
+ final adWatched = await ad.showAd('main_ad');
+ if (!adWatched && AdManager.adsEnabled) {
+ setState(() => _busy = false);
return;
}
-
- final success = await vpn.connect();
- if (success) {
- timer.start('main_ad');
- // Only apply cooldown on success — failures clear immediately
- setState(() => _inCooldown = true);
- Future.delayed(const Duration(seconds: 5), () {
- if (mounted) setState(() => _inCooldown = false);
- });
- }
- // On failure, check if it's a permission issue and offer recovery.
- if (!success && mounted) {
- final msg = vpn.errorMessage ?? '';
- if (msg.contains('permission') || msg.contains('Permission')) {
- _showPermissionDialog();
- }
- }
+ final ok = await vpn.connect();
+ if (ok) timer.start('main_ad');
+ } finally {
+ if (mounted) setState(() => _busy = false);
}
}
- /// Shows a dialog guiding the user to grant the VPN permission in
- /// Android system settings, since there's no way to re-prompt the
- /// native VpnService dialog after the first denial.
- void _showPermissionDialog() {
- showDialog(
- context: context,
- builder: (ctx) => AlertDialog(
- backgroundColor: AppColors.bgDark,
- title: const Text('VPN Permission Required',
- style: TextStyle(color: AppColors.textWhite)),
- content: const Text(
- 'ReVoltVPN needs permission to set up a VPN connection.\n\n'
- 'Since the permission was denied once, Android won\'t ask again. '
- 'To grant it manually:\n\n'
- '1. Open your device Settings\n'
- '2. Go to Apps → ReVoltVPN\n'
- '3. Tap "VPN" and enable it\n'
- '4. Return to ReVoltVPN and tap connect',
- style: TextStyle(color: AppColors.textMuted),
- ),
- actions: [
- FilledButton(
- onPressed: () => Navigator.of(ctx).pop(),
- style: FilledButton.styleFrom(
- backgroundColor: AppColors.cyan,
- ),
- child: const Text('Got it',
- style: TextStyle(color: AppColors.bgDeep)),
- ),
- ],
- ),
- );
- }
-
@override
Widget build(BuildContext context) {
- return Consumer2(
- builder: (context, vpn, timer, _) {
+ return Consumer(
+ builder: (context, vpn, _) {
final isConnected = vpn.status == VpnStatus.connected;
- final isConnecting = vpn.status == VpnStatus.connecting;
+ final spinning = vpn.status == VpnStatus.connecting ||
+ vpn.status == VpnStatus.disconnecting ||
+ _busy;
return GestureDetector(
- onTap: (isConnecting || _inCooldown) ? null : _handleTap,
- child: AnimatedBuilder(
- animation: _pulseAnim,
- builder: (context, child) {
- final glowRadius = isConnected ? 20.0 + (_pulseAnim.value * 15.0) : 0.0;
- final glowAlpha = isConnected
- ? ((0.3 + _pulseAnim.value * 0.2) * 255).round()
- : 0;
-
- return Container(
- width: 260,
- height: 260,
- decoration: BoxDecoration(
- shape: BoxShape.circle,
- color: AppColors.bgDark,
- border: Border.all(
- color: _inCooldown
- ? Colors.grey.withValues(alpha: 0.5) // dynamic, OK inline
- : isConnected
- ? AppColors.cyan
- : isConnecting
- ? AppColors.cyan50
- : AppColors.slate50,
- width: isConnected && !_inCooldown ? 3 : 2,
- ),
- boxShadow: isConnected
- ? [
- BoxShadow(
- color: Color.fromARGB(glowAlpha, 0, 229, 255),
- blurRadius: glowRadius,
- spreadRadius: glowRadius * 0.3,
- ),
- ]
- : const [],
- ),
- child: Center(
- child: isConnected
- ? const ClockDisplay()
- : _buildDisconnectedIcon(vpn.status, _inCooldown),
- ),
- );
- },
- ),
+ onTap: _handleTap,
+ child: _pulseAnim != null
+ ? AnimatedBuilder(
+ animation: _pulseAnim!,
+ builder: (_, __) => _buildCircle(isConnected, spinning, _pulseValue),
+ )
+ : _buildCircle(isConnected, spinning, 0),
);
},
);
}
- Widget _buildDisconnectedIcon(VpnStatus status, bool inCooldown) {
- if (status == VpnStatus.connecting || status == VpnStatus.disconnecting) {
- return const SizedBox(
- width: 48,
- height: 48,
- child: CircularProgressIndicator(
- strokeWidth: 2.5,
- color: AppColors.cyan70,
- ),
- );
- }
- // Determine the right label for the disconnected state
- String label;
- IconData icon;
- Color tint;
+ Widget _buildCircle(bool isConnected, bool spinning, double pulse) {
+ final glowAlpha = isConnected ? ((0.3 + pulse * 0.25) * 255).round() : 0;
+ final glowRadius = isConnected ? 18.0 + (pulse * 14) : 0.0;
- if (inCooldown) {
- label = 'PLEASE WAIT';
- icon = Icons.hourglass_bottom;
- tint = Colors.grey; // dynamic, OK inline
- } else if (status == VpnStatus.error) {
- label = 'TRY AGAIN';
- icon = Icons.refresh;
- tint = AppColors.red; // soft red — something went wrong
- } else {
- label = 'TAP TO CONNECT';
- icon = Icons.power_settings_new;
- tint = AppColors.slate70;
- }
-
- return Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- Icon(icon, size: 64, color: tint),
- const SizedBox(height: 12),
- Text(
- label,
- style: TextStyle(
- color: tint,
- fontWeight: FontWeight.bold,
- letterSpacing: 1.5,
- ),
+ return Container(
+ width: 260, // <-- CHANGE ME: circle size
+ height: 260,
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ color: AppColors.bgDark,
+ border: Border.all(
+ color: isConnected ? AppColors.accent : AppColors.slate50,
+ width: isConnected ? 2.5 : 1.5,
),
- ],
+ boxShadow: isConnected
+ ? [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(0), // <-- CHANGE ME: brand.png padding from edge
+ child: Image.asset('assets/brand.png', fit: BoxFit.contain),
+ ),
);
}
}
diff --git a/lib/components/header.dart b/lib/components/header.dart
deleted file mode 100644
index 1499a99..0000000
--- a/lib/components/header.dart
+++ /dev/null
@@ -1,54 +0,0 @@
-import 'package:flutter/material.dart';
-import 'package:revoltvpn/logic/app_colors.dart';
-
-/// Header displays the ReVolt brand logo, title, and subtitle.
-///
-/// It represents the top branding portion of the main dashboard and is designed
-/// with clean proportions, consistent typography, and a modern cyberpunk-inspired color scheme.
-class Header extends StatelessWidget {
- const Header({super.key});
-
- @override
- Widget build(BuildContext context) {
- return Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- // Brand logo image with a fallback Icon in case the asset is missing
- Image.asset(
- 'assets/logo.png',
- width: 120, // Enlarged logo to stand out visually
- height: 120,
- errorBuilder: (context, error, stackTrace) => const Icon(
- Icons.shield,
- size: 120,
- color: AppColors.cyan,
- ),
- ),
- const SizedBox(height: 8), // Standard padding between the logo and the title
-
- // Brand primary name
- const Text(
- 'ReVolt',
- style: TextStyle(
- fontSize: 22,
- fontWeight: FontWeight.w800,
- letterSpacing: 8,
- color: AppColors.textWhite,
- ),
- ),
- const SizedBox(height: 2), // Close spacing for secondary information
-
- // Brand secondary label / purpose
- const Text(
- 'SECURE TUNNEL',
- style: TextStyle(
- fontSize: 12,
- fontWeight: FontWeight.w600,
- letterSpacing: 4,
- color: AppColors.slateAA, // Semi-transparent slate gray
- ),
- ),
- ],
- );
- }
-}
diff --git a/lib/components/online_dot.dart b/lib/components/online_dot.dart
index 74d8b07..44d2f76 100644
--- a/lib/components/online_dot.dart
+++ b/lib/components/online_dot.dart
@@ -49,7 +49,7 @@ class OnlineDot extends StatelessWidget {
fontSize: 13,
fontWeight: FontWeight.w500,
color: isOnline
- ? AppColors.textWhite80
+ ? AppColors.textDim
: AppColors.slate,
letterSpacing: 0.5,
),
diff --git a/lib/components/sidebar_contents/discord_link_button.dart b/lib/components/sidebar_contents/discord_link_button.dart
new file mode 100644
index 0000000..5f80021
--- /dev/null
+++ b/lib/components/sidebar_contents/discord_link_button.dart
@@ -0,0 +1,25 @@
+import 'package:flutter/material.dart';
+import 'package:url_launcher/url_launcher.dart';
+import 'package:revoltvpn/logic/app_colors.dart';
+
+class DiscordLinkButton extends StatelessWidget {
+ const DiscordLinkButton({super.key});
+
+ /// Public entry point for the sidebar or any other widget.
+ static Future launchDiscord() async {
+ final Uri url = Uri.parse('https://discord.gg/revoltvpn');
+ if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
+ debugPrint('Could not launch $url');
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return IconButton(
+ icon: const Icon(Icons.discord, color: AppColors.textWhite, size: 28),
+ onPressed: launchDiscord,
+ tooltip: 'Join Discord',
+ splashRadius: 24,
+ );
+ }
+}
diff --git a/lib/components/gdpr_button.dart b/lib/components/sidebar_contents/gdpr_button.dart
similarity index 90%
rename from lib/components/gdpr_button.dart
rename to lib/components/sidebar_contents/gdpr_button.dart
index 016d04b..0fa002f 100644
--- a/lib/components/gdpr_button.dart
+++ b/lib/components/sidebar_contents/gdpr_button.dart
@@ -13,7 +13,9 @@ import 'package:url_launcher/url_launcher.dart';
class GdprButton extends StatelessWidget {
const GdprButton({super.key});
- Future _showConsentForm() async {
+ /// Public entry point so the sidebar (or any other widget) can trigger
+ /// the consent flow without embedding an IconButton.
+ static Future showConsentForm() async {
try {
// If the form is already available, show it directly — no need to
// re-request consent info from Google's servers on every tap.
@@ -48,7 +50,7 @@ class GdprButton extends StatelessWidget {
}
}
- Future _loadAndShowForm() async {
+ static Future _loadAndShowForm() async {
final completer = Completer();
ConsentForm.loadConsentForm(
(ConsentForm form) {
@@ -71,7 +73,7 @@ class GdprButton extends StatelessWidget {
);
}
- void _fallbackToPrivacyPolicy() async {
+ static void _fallbackToPrivacyPolicy() async {
final Uri url = Uri.parse(
'https://github.com/esefxdz/ReVoltVPN/blob/main/PRIVACY_POLICY.md',
);
@@ -85,7 +87,7 @@ class GdprButton extends StatelessWidget {
return IconButton(
icon: const Icon(Icons.admin_panel_settings_outlined,
color: AppColors.textWhite, size: 28),
- onPressed: _showConsentForm,
+ onPressed: showConsentForm,
tooltip: 'Ad consent & privacy choices',
splashRadius: 24,
);
diff --git a/lib/components/info_button.dart b/lib/components/sidebar_contents/info_button.dart
similarity index 82%
rename from lib/components/info_button.dart
rename to lib/components/sidebar_contents/info_button.dart
index e8fc502..b5e4d25 100644
--- a/lib/components/info_button.dart
+++ b/lib/components/sidebar_contents/info_button.dart
@@ -5,7 +5,8 @@ import 'package:revoltvpn/logic/app_colors.dart';
class InfoButton extends StatelessWidget {
const InfoButton({super.key});
- Future _launchGitHub() async {
+ /// Public entry point for the sidebar or any other widget.
+ static Future launchGitHub() async {
final Uri url = Uri.parse('https://github.com/esefxdz/ReVoltVPN');
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
debugPrint('Could not launch $url');
@@ -16,7 +17,7 @@ class InfoButton extends StatelessWidget {
Widget build(BuildContext context) {
return IconButton(
icon: const Icon(Icons.help_outline, color: AppColors.textWhite, size: 28),
- onPressed: _launchGitHub,
+ onPressed: launchGitHub,
tooltip: 'About ReVoltVPN',
splashRadius: 24,
);
diff --git a/lib/components/privacy_policy_button.dart b/lib/components/sidebar_contents/privacy_policy_button.dart
similarity index 82%
rename from lib/components/privacy_policy_button.dart
rename to lib/components/sidebar_contents/privacy_policy_button.dart
index 851f909..3103e25 100644
--- a/lib/components/privacy_policy_button.dart
+++ b/lib/components/sidebar_contents/privacy_policy_button.dart
@@ -5,7 +5,8 @@ import 'package:revoltvpn/logic/app_colors.dart';
class PrivacyPolicyButton extends StatelessWidget {
const PrivacyPolicyButton({super.key});
- Future _launchPrivacyPolicy() async {
+ /// Public entry point for the sidebar or any other widget.
+ static Future launchPrivacyPolicy() async {
final Uri url = Uri.parse('https://github.com/esefxdz/ReVoltVPN/blob/main/PRIVACY_POLICY.md');
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
debugPrint('Could not launch $url');
@@ -16,7 +17,7 @@ class PrivacyPolicyButton extends StatelessWidget {
Widget build(BuildContext context) {
return IconButton(
icon: const Icon(Icons.privacy_tip_outlined, color: AppColors.textWhite, size: 28),
- onPressed: _launchPrivacyPolicy,
+ onPressed: launchPrivacyPolicy,
tooltip: 'Privacy Policy',
splashRadius: 24,
);
diff --git a/lib/components/sidebar_drawer.dart b/lib/components/sidebar_drawer.dart
new file mode 100644
index 0000000..2ed3157
--- /dev/null
+++ b/lib/components/sidebar_drawer.dart
@@ -0,0 +1,134 @@
+import 'package:flutter/material.dart';
+import 'package:revoltvpn/logic/app_colors.dart';
+import 'package:revoltvpn/components/sidebar_contents/gdpr_button.dart';
+import 'package:revoltvpn/components/sidebar_contents/privacy_policy_button.dart';
+import 'package:revoltvpn/components/sidebar_contents/info_button.dart';
+import 'package:revoltvpn/components/sidebar_contents/discord_link_button.dart';
+
+/// Right-edge drawer. Four fully-tappable rows — tap anywhere on a row
+/// to trigger its action and close the drawer.
+class SidebarDrawer extends StatelessWidget {
+ const SidebarDrawer({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return Drawer(
+ backgroundColor: AppColors.bgSurface,
+ width: 280,
+ child: SafeArea(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ // ── Header ──────────────────────────────────────────────────
+ Container(
+ padding: const EdgeInsets.fromLTRB(20, 24, 20, 20),
+ decoration: const BoxDecoration(
+ border: Border(
+ bottom: BorderSide(color: AppColors.slate15, width: 1),
+ ),
+ ),
+ child: Row(
+ children: [
+ Image.asset('assets/brand.png', width: 36, height: 36,
+ errorBuilder: (_, __, ___) =>
+ const Icon(Icons.shield, size: 36, color: AppColors.accent),
+ ),
+ const SizedBox(width: 12),
+ const Text('ReVolt', style: TextStyle(
+ fontSize: 20, fontWeight: FontWeight.w800,
+ letterSpacing: 4, color: AppColors.textWhite,
+ )),
+ const Spacer(),
+ IconButton(
+ icon: const Icon(Icons.close, color: AppColors.textDim, size: 22),
+ onPressed: () => Navigator.of(context).pop(),
+ splashRadius: 20, padding: EdgeInsets.zero,
+ constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
+ ),
+ ],
+ ),
+ ),
+
+ const SizedBox(height: 8),
+
+ // ── Ad Consent (fully tappable) ─────────────────────────────
+ _Row(
+ icon: Icons.admin_panel_settings_outlined,
+ label: 'Ad Consent',
+ onTap: () {
+ Navigator.of(context).pop();
+ GdprButton.showConsentForm();
+ },
+ ),
+
+ // ── Privacy Policy (fully tappable) ─────────────────────────
+ _Row(
+ icon: Icons.privacy_tip_outlined,
+ label: 'Privacy Policy',
+ onTap: () {
+ Navigator.of(context).pop();
+ PrivacyPolicyButton.launchPrivacyPolicy();
+ },
+ ),
+
+ // ── About (fully tappable) ──────────────────────────────────
+ _Row(
+ icon: Icons.help_outline,
+ label: 'About',
+ onTap: () {
+ Navigator.of(context).pop();
+ InfoButton.launchGitHub();
+ },
+ ),
+
+ // ── Discord (fully tappable) ────────────────────────────────
+ _Row(
+ icon: Icons.discord,
+ label: 'Discord',
+ onTap: () {
+ Navigator.of(context).pop();
+ DiscordLinkButton.launchDiscord();
+ },
+ ),
+
+ const Spacer(),
+
+ const Padding(
+ padding: EdgeInsets.all(20),
+ child: Text('ReVoltVPN · v1.0.6', textAlign: TextAlign.center,
+ style: TextStyle(color: AppColors.slate, fontSize: 11, letterSpacing: 1),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class _Row extends StatelessWidget {
+ final IconData icon;
+ final String label;
+ final VoidCallback onTap;
+
+ const _Row({required this.icon, required this.label, required this.onTap});
+
+ @override
+ Widget build(BuildContext context) {
+ return InkWell(
+ onTap: onTap,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
+ child: Row(
+ children: [
+ Icon(icon, color: AppColors.textWhite, size: 28),
+ const SizedBox(width: 16),
+ Text(label, style: const TextStyle(
+ color: AppColors.textWhite, fontSize: 15, fontWeight: FontWeight.w500,
+ )),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/components/speed_box.dart b/lib/components/speed_box.dart
new file mode 100644
index 0000000..336e316
--- /dev/null
+++ b/lib/components/speed_box.dart
@@ -0,0 +1,39 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+import 'package:revoltvpn/logic/app_colors.dart';
+import 'package:revoltvpn/logic/session_timer.dart';
+
+/// SpeedBox — a small card showing current network speed or "Idle".
+class SpeedBox extends StatelessWidget {
+ const SpeedBox({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return Consumer(
+ builder: (context, timer, _) {
+ final speed = timer.currentSpeedKBps > 0.5
+ ? '${timer.currentSpeedKBps.toStringAsFixed(1)} KB/s'
+ : 'Idle';
+
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
+ decoration: BoxDecoration(
+ color: AppColors.glassBg,
+ borderRadius: BorderRadius.circular(24),
+ border: Border.all(color: AppColors.glassBorder, width: 1),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Icon(Icons.speed, size: 16, color: AppColors.textDim),
+ const SizedBox(width: 8),
+ Text(speed, style: const TextStyle(
+ color: AppColors.textDim, fontSize: 13, fontWeight: FontWeight.w500,
+ )),
+ ],
+ ),
+ );
+ },
+ );
+ }
+}
diff --git a/lib/components/status_bar.dart b/lib/components/status_bar.dart
new file mode 100644
index 0000000..15cca79
--- /dev/null
+++ b/lib/components/status_bar.dart
@@ -0,0 +1,72 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+import 'package:revoltvpn/logic/app_colors.dart';
+import 'package:revoltvpn/logic/vpn_connection.dart';
+import 'package:revoltvpn/logic/session_timer.dart';
+import 'package:revoltvpn/components/online_dot.dart';
+
+/// StatusBar — unified bottom card combining the Finland connection badge
+/// and the server health indicator in one box.
+class StatusBar extends StatelessWidget {
+ const StatusBar({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return Consumer2(
+ builder: (context, vpn, timer, _) {
+ final isConnected = vpn.status == VpnStatus.connected;
+ final isConnecting = vpn.status == VpnStatus.connecting;
+ final serverHealthy = isConnected && timer.hasSyncedOnce;
+
+ return Container(
+ margin: const EdgeInsets.symmetric(horizontal: 24),
+ padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
+ decoration: BoxDecoration(
+ color: AppColors.bgCard,
+ borderRadius: BorderRadius.circular(16),
+ border: Border.all(color: AppColors.slate15),
+ ),
+ child: Row(
+ children: [
+ // ── Connection status (left) ─────────────────────────────
+ if (isConnected) ...[
+ Image.asset('assets/finland_flag_256.png', width: 20, height: 20),
+ const SizedBox(width: 8),
+ const Text('Helsinki, Finland', style: TextStyle(
+ color: AppColors.accent, fontWeight: FontWeight.w600,
+ fontSize: 13, letterSpacing: 0.5,
+ )),
+ ] else ...[
+ Icon(
+ isConnecting ? Icons.sync : Icons.shield_outlined,
+ size: 18,
+ color: isConnecting ? AppColors.accent : AppColors.slate70,
+ ),
+ const SizedBox(width: 8),
+ Text(
+ vpn.statusMessage.toUpperCase(),
+ style: TextStyle(
+ color: isConnecting ? AppColors.accent : AppColors.slate70,
+ fontWeight: FontWeight.w600, fontSize: 13, letterSpacing: 1,
+ ),
+ ),
+ ],
+
+ const Spacer(),
+
+ // ── Server health dot (right) ────────────────────────────
+ OnlineDot(
+ isOnline: serverHealthy,
+ label: isConnected && !timer.hasSyncedOnce
+ ? 'Syncing…'
+ : serverHealthy
+ ? 'Online'
+ : 'Offline',
+ ),
+ ],
+ ),
+ );
+ },
+ );
+ }
+}
diff --git a/lib/components/status_text.dart b/lib/components/status_text.dart
deleted file mode 100644
index 74cde9f..0000000
--- a/lib/components/status_text.dart
+++ /dev/null
@@ -1,91 +0,0 @@
-import 'package:flutter/material.dart';
-import 'package:provider/provider.dart';
-import 'package:revoltvpn/logic/app_colors.dart';
-import 'package:revoltvpn/logic/vpn_connection.dart';
-
-/// StatusText displays the status of the VPN tunnel.
-///
-/// When connected, it shows the Finnish flag along with the tunnel location ("Helsinki, Finland").
-/// When connecting or disconnected, it shows status messages with clean micro-animations.
-class StatusText extends StatelessWidget {
- const StatusText({super.key});
-
- @override
- Widget build(BuildContext context) {
- return Consumer(
- builder: (context, vpn, _) {
- final isConnected = vpn.status == VpnStatus.connected;
- final isConnecting = vpn.status == VpnStatus.connecting;
-
- return AnimatedSwitcher(
- duration: const Duration(milliseconds: 300),
- child: Container(
- key: ValueKey(vpn.status),
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
- decoration: BoxDecoration(
- color: AppColors.glassBg, // Subtle glass effect background
- borderRadius: BorderRadius.circular(24),
- border: Border.all(
- color: isConnected
- ? AppColors.cyanGlow // Glowing cyan border when connected
- : AppColors.glassBorder, // Muted translucent border when disconnected/connecting
- width: 1,
- ),
- ),
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- if (isConnected) ...[
- // Circular Finnish flag icon representing the active tunnel endpoint
- Image.asset(
- 'assets/finland_flag_256.png',
- width: 20,
- height: 20,
- ),
- const SizedBox(width: 8),
- const Text(
- 'Helsinki, Finland',
- style: TextStyle(
- color: AppColors.cyan,
- fontWeight: FontWeight.w600,
- fontSize: 13,
- letterSpacing: 0.5,
- ),
- ),
- ] else ...[
- if (isConnecting)
- const SizedBox(
- width: 14,
- height: 14,
- child: CircularProgressIndicator(
- strokeWidth: 1.5,
- color: AppColors.cyan,
- ),
- )
- else
- const Icon(
- Icons.shield_outlined,
- size: 18,
- color: AppColors.slateAA,
- ),
- const SizedBox(width: 8),
- Text(
- vpn.statusMessage.toUpperCase(),
- style: TextStyle(
- color: isConnecting
- ? AppColors.cyan
- : AppColors.slateAA,
- fontWeight: FontWeight.w600,
- fontSize: 13,
- letterSpacing: 1,
- ),
- ),
- ],
- ],
- ),
- ),
- );
- },
- );
- }
-}
diff --git a/lib/components/timer_box.dart b/lib/components/timer_box.dart
new file mode 100644
index 0000000..aa9708c
--- /dev/null
+++ b/lib/components/timer_box.dart
@@ -0,0 +1,34 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+import 'package:revoltvpn/logic/app_colors.dart';
+import 'package:revoltvpn/logic/session_timer.dart';
+
+/// TimerBox — a rectangular card showing the session countdown.
+class TimerBox extends StatelessWidget {
+ const TimerBox({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return Consumer(
+ builder: (context, timer, _) {
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 14),
+ decoration: BoxDecoration(
+ color: AppColors.bgCard,
+ borderRadius: BorderRadius.circular(16),
+ border: Border.all(color: AppColors.slate15),
+ ),
+ child: Text(
+ timer.hasSyncedOnce ? timer.formatted : '00:00:00',
+ style: const TextStyle(
+ fontSize: 32,
+ fontWeight: FontWeight.w700,
+ color: AppColors.textWhite,
+ letterSpacing: 2,
+ ),
+ ),
+ );
+ },
+ );
+ }
+}
diff --git a/lib/logic/app_colors.dart b/lib/logic/app_colors.dart
index 7799eb9..997e074 100644
--- a/lib/logic/app_colors.dart
+++ b/lib/logic/app_colors.dart
@@ -10,15 +10,11 @@ import 'package:flutter/material.dart';
abstract final class AppColors {
AppColors._();
- // ── Primary: Cyan ──────────────────────────────────────────────────────────
- static const Color cyan = Color(0xFF00E5FF);
- static const Color cyan15 = Color(0x2600E5FF); // 15 % opacity
- static const Color cyan25 = Color(0x4000E5FF); // 25 % opacity
- static const Color cyan50 = Color(0x8000E5FF); // 50 % opacity
- static const Color cyan60 = Color(0x9900E5FF); // 60 % opacity
- static const Color cyan70 = Color(0xB300E5FF); // 70 % opacity
- static const Color cyan05 = Color(0x0D00E5FF); // 5 % opacity
- static const Color cyanGlow = Color(0x8800E5FF); // glowing border
+ // ── Accent ────────────────────────────────────────────────────────────────
+ static const Color accent = Color(0xFFFFD600); // yellow
+ static const Color accent50 = Color(0x80FFD600); // 50 % opacity
+ static const Color accent60 = Color(0x99FFD600); // 60 % opacity
+ static const Color accent70 = Color(0xB3FFD600); // 70 % opacity
// ── Backgrounds ────────────────────────────────────────────────────────────
static const Color bgDeep = Color(0xFF0D1117); // intro / scaffold
@@ -32,13 +28,11 @@ abstract final class AppColors {
static const Color slate15 = Color(0x264A5568); // 15 % opacity
static const Color slate50 = Color(0x804A5568); // 50 % opacity
static const Color slate70 = Color(0xB34A5568); // 70 % opacity
- static const Color slateAA = Color(0xAA4A5568); // ~67 % (legacy usage)
// ── Text ───────────────────────────────────────────────────────────────────
static const Color textWhite = Colors.white;
static const Color textDim = Colors.white54;
- static const Color textWhite80 = Color(0xCCFFFFFF); // 80 % white
- static const Color textMuted = Color(0xFFA0AEC0); // dialog body text
+ static const Color textMuted = Color(0xFFA0AEC0); // dialog body text
// ── Semantic ───────────────────────────────────────────────────────────────
static const Color green = Color(0xFF00E676);
@@ -49,26 +43,4 @@ abstract final class AppColors {
static const Color glassBg = Color(0x22FFFFFF); // subtle glass background
static const Color glassBorder = Color(0x33FFFFFF); // muted translucent border
- // ── Intro screen snackbars ─────────────────────────────────────────────────
- static const Color snackSuccess = Color(0xFF006064);
- static const Color snackError = Color(0xFF5D4037);
-
- // ═══════════════════════════════════════════════════════════════════════════
- // FUTURE — Yellow palette (not yet wired into the UI)
- // ═══════════════════════════════════════════════════════════════════════════
- static const Color yellow = Color(0xFFFFD600);
- static const Color yellowDark = Color(0xFFFFAB00);
- static const Color yellowLight = Color(0xFFFFFF00);
- static const Color yellowFaded = Color(0x1AFFD600);
- static const Color yellowAmber = Color(0xFFFFC107);
- static const Color yellowMustard = Color(0xFFC6A700);
-
- // ═══════════════════════════════════════════════════════════════════════════
- // FUTURE — Black palette (not yet wired into the UI)
- // ═══════════════════════════════════════════════════════════════════════════
- static const Color black = Color(0xFF000000);
- static const Color blackOff = Color(0xFF0A0A0A);
- static const Color blackFaded = Color(0xAA000000);
- static const Color blackWarm = Color(0xFF121212);
- static const Color blackCharcoal = Color(0xFF1A1A1A);
}
diff --git a/lib/logic/vpn_connection.dart b/lib/logic/vpn_connection.dart
index e27598b..22ba4e9 100644
--- a/lib/logic/vpn_connection.dart
+++ b/lib/logic/vpn_connection.dart
@@ -17,6 +17,7 @@ enum VpnStatus {
}
class VpnConnection extends ChangeNotifier {
+ bool _cancelled = false;
VpnStatus _status = VpnStatus.disconnected;
VpnStatus get status => _status;
@@ -103,6 +104,7 @@ class VpnConnection extends ChangeNotifier {
if (_status == VpnStatus.connected || _status == VpnStatus.connecting) {
return false;
}
+ _cancelled = false;
if (!kIsWeb && !_initialized) {
_errorMessage = 'VPN service unavailable.';
@@ -147,7 +149,13 @@ class VpnConnection extends ChangeNotifier {
return false;
}
- // ── Step 2: Parse and start VLESS tunnel ───────────────────────────
+ // ── Step 2: Check for cancellation ────────────────────────────────
+ if (_cancelled) {
+ _setStatus(VpnStatus.disconnected, 'Tap to connect');
+ return false;
+ }
+
+ // ── Step 3: Parse and start VLESS tunnel ───────────────────────────
try {
final parsed = FlutterVless.parse(vlessUrl);
final config = parsed.getFullConfiguration();
@@ -193,6 +201,7 @@ class VpnConnection extends ChangeNotifier {
// ── Disconnect ────────────────────────────────────────────────────────────
Future disconnect() async {
+ _cancelled = true;
if (_status == VpnStatus.disconnected || _status == VpnStatus.disconnecting) {
return;
}
diff --git a/lib/main.dart b/lib/main.dart
index 4c26fec..8c13916 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -84,8 +84,8 @@ class ReVoltApp extends StatelessWidget {
brightness: Brightness.dark,
scaffoldBackgroundColor: AppColors.bgDeep,
colorScheme: const ColorScheme.dark(
- primary: AppColors.cyan,
- secondary: AppColors.cyan,
+ primary: AppColors.accent,
+ secondary: AppColors.accent,
surface: AppColors.bgSurface,
),
fontFamily: 'Roboto',
diff --git a/lib/screens/intro.dart b/lib/screens/intro.dart
index b1869ce..3c86017 100644
--- a/lib/screens/intro.dart
+++ b/lib/screens/intro.dart
@@ -60,43 +60,47 @@ class _IntroScreenState extends State with WidgetsBindingObserver {
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.bgDeep,
- body: Center(
- child: Column(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- Image.asset(
- 'assets/logo.png',
- width: 140,
- height: 140,
+ body: Column(
+ children: [
+ const Spacer(flex: 7),
+ // Image slightly below center
+ Center(
+ child: Image.asset(
+ 'assets/brand.png',
+ width: 200,
+ height: 200,
errorBuilder: (context, error, stackTrace) {
return const Icon(
Icons.shield,
- size: 140,
- color: AppColors.cyan,
+ size: 200,
+ color: AppColors.accent,
);
},
),
- const SizedBox(height: 30),
- const Text(
- 'REVOLT VPN',
- style: TextStyle(
- color: AppColors.textWhite,
- fontSize: 28,
- fontWeight: FontWeight.bold,
- letterSpacing: 2.0,
- ),
+ ),
+ // Spinner + text paired together below the image
+ const SizedBox(height: 8),
+ const SizedBox(
+ width: 40,
+ height: 40,
+ child: CircularProgressIndicator(
+ color: AppColors.accent,
+ strokeWidth: 3.0,
),
- const SizedBox(height: 60),
- const SizedBox(
- width: 40,
- height: 40,
- child: CircularProgressIndicator(
- color: AppColors.cyan,
- strokeWidth: 3.0,
- ),
+ ),
+ const SizedBox(height: 4),
+ const Text(
+ 'REVOLT VPN',
+ style: TextStyle(
+ color: AppColors.textWhite,
+ fontSize: 28,
+ fontWeight: FontWeight.bold,
+ letterSpacing: 2.0,
),
- ],
- ),
+ ),
+ const Spacer(flex: 5),
+ const SizedBox(height: 12),
+ ],
),
);
}
diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart
index 1a96a2c..e8947b8 100644
--- a/lib/screens/main_screen.dart
+++ b/lib/screens/main_screen.dart
@@ -1,21 +1,11 @@
import 'package:flutter/material.dart';
import 'package:revoltvpn/logic/app_colors.dart';
-import 'package:revoltvpn/components/header.dart';
-import 'package:revoltvpn/components/status_text.dart';
import 'package:revoltvpn/components/connect_button.dart';
-import 'package:revoltvpn/components/bottom_bar.dart';
-import 'package:revoltvpn/components/info_button.dart';
-import 'package:revoltvpn/components/privacy_policy_button.dart';
-import 'package:revoltvpn/components/gdpr_button.dart';
+import 'package:revoltvpn/components/sidebar_drawer.dart';
+import 'package:revoltvpn/components/timer_box.dart';
+import 'package:revoltvpn/components/speed_box.dart';
+import 'package:revoltvpn/components/status_bar.dart';
-/// MainScreen serves as the primary home screen for REVOLT VPN.
-///
-/// It provides a dark mode, cyberpunk-inspired visual layout displaying:
-/// - Logo and Branding at the top (`Header`)
-/// - Session countdown timer displaying remaining time (`ClockDisplay`)
-/// - Tap-to-connect action trigger (`ConnectButton`)
-/// - Connected status badge displaying tunnel information (`StatusText`)
-/// - Bottom bar showing server health status and voluntary support button (`BottomBar`)
class MainScreen extends StatelessWidget {
const MainScreen({super.key});
@@ -23,6 +13,7 @@ class MainScreen extends StatelessWidget {
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.bgDeep,
+ endDrawer: const SidebarDrawer(),
body: Container(
width: double.infinity,
height: double.infinity,
@@ -31,54 +22,51 @@ class MainScreen extends StatelessWidget {
image: DecorationImage(
image: AssetImage('assets/background.png'),
fit: BoxFit.cover,
- colorFilter: ColorFilter.mode(
- AppColors.bgOverlay, // Deep dark tint overlay to guarantee readability of overlay elements
- BlendMode.darken,
- ),
+ colorFilter: ColorFilter.mode(AppColors.bgOverlay, BlendMode.darken),
),
),
child: SafeArea(
child: Stack(
children: [
- // ── Main content column ──
- LayoutBuilder(
- builder: (context, constraints) {
- return SingleChildScrollView(
- physics: const BouncingScrollPhysics(),
- child: ConstrainedBox(
- constraints: BoxConstraints(minHeight: constraints.maxHeight),
- child: const IntrinsicHeight(
- child: Column(
- children: [
- SizedBox(height: 4),
- Header(),
- Spacer(flex: 1),
- ConnectButton(),
- SizedBox(height: 24),
- StatusText(),
- Spacer(flex: 3),
- BottomBar(),
- SizedBox(height: 16),
- ],
- ),
- ),
- ),
- );
- },
- ),
- // ── Info / About buttons (top-right) ──
- const Positioned(
- top: 4,
+ Positioned(
+ top: 0,
right: 4,
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- GdprButton(),
- PrivacyPolicyButton(),
- InfoButton(),
- ],
+ child: Builder(
+ builder: (ctx) => IconButton(
+ icon: const Icon(Icons.menu, color: AppColors.textWhite, size: 28),
+ onPressed: () => Scaffold.of(ctx).openEndDrawer(),
+ tooltip: 'Menu', splashRadius: 24,
+ ),
),
),
+
+ Positioned(
+ top: 100, // <-- CHANGE ME: timer distance from top
+ left: 0,
+ right: 0,
+ child: const Center(child: TimerBox()),
+ ),
+
+ Positioned(
+ top: 250, // <-- CHANGE ME: connect button from top
+ left: 0,
+ right: 0,
+ child: const Center(child: ConnectButton()),
+ ),
+
+ Positioned(
+ top: 520, // <-- CHANGE ME: speed box from top
+ left: 0,
+ right: 0,
+ child: const Center(child: SpeedBox()),
+ ),
+
+ Positioned(
+ bottom: 16, // <-- CHANGE ME: status bar from bottom
+ left: 0,
+ right: 0,
+ child: const StatusBar(),
+ ),
],
),
),
@@ -86,4 +74,3 @@ class MainScreen extends StatelessWidget {
);
}
}
-
diff --git a/pubspec.yaml b/pubspec.yaml
index a4897dd..836ed68 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -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: 1.0.5+9
+version: 1.0.6+10
environment:
sdk: ">=3.0.0 <4.0.0"