initial commit
No files matched your search
@@ -79,3 +79,4 @@ server full picture.txt
|
|||||||
technical_debt_*.md
|
technical_debt_*.md
|
||||||
server_full_picture_2.txt
|
server_full_picture_2.txt
|
||||||
hivemind_backup.py
|
hivemind_backup.py
|
||||||
|
UI_POSITIONING.md
|
||||||
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -1,12 +1,10 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Modify this file to customize your launch splash screen -->
|
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:drawable="?android:colorBackground" />
|
<item android:drawable="@color/splash_bg" />
|
||||||
|
<item>
|
||||||
<!-- You can insert your own image assets here -->
|
|
||||||
<!-- <item>
|
|
||||||
<bitmap
|
<bitmap
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:src="@mipmap/launch_image" />
|
android:src="@drawable/launch_image"
|
||||||
</item> -->
|
android:tileMode="disabled" />
|
||||||
|
</item>
|
||||||
</layer-list>
|
</layer-list>
|
||||||
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 172 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="#0d1017" />
|
||||||
|
</shape>
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Modify this file to customize your launch splash screen -->
|
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:drawable="@android:color/black" />
|
<item android:drawable="@color/splash_bg" />
|
||||||
|
<item>
|
||||||
<!-- You can insert your own image assets here -->
|
|
||||||
<!-- <item>
|
|
||||||
<bitmap
|
<bitmap
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:src="@mipmap/launch_image" />
|
android:src="@drawable/launch_image"
|
||||||
</item> -->
|
android:tileMode="disabled" />
|
||||||
|
</item>
|
||||||
</layer-list>
|
</layer-list>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
|
Before Width: | Height: | Size: 547 KiB After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 547 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 547 KiB After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 547 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 547 KiB After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 547 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 547 KiB After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 547 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 547 KiB After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 547 KiB After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="splash_bg">#0d1017</color>
|
||||||
|
</resources>
|
||||||
@@ -13,6 +13,6 @@
|
|||||||
|
|
||||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
<item name="android:windowBackground">@android:color/white</item>
|
<item name="android:windowBackground">@color/splash_bg</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
After Width: | Height: | Size: 313 KiB |
|
Before Width: | Height: | Size: 289 KiB After Width: | Height: | Size: 492 KiB |
@@ -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<VpnConnection, SessionTimer>(
|
|
||||||
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',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -37,7 +37,7 @@ class ClockDisplay extends StatelessWidget {
|
|||||||
height: 32,
|
height: 32,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
color: AppColors.cyan50,
|
color: AppColors.accent50,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
@@ -46,7 +46,7 @@ class ClockDisplay extends StatelessWidget {
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.cyan60,
|
color: AppColors.accent60,
|
||||||
letterSpacing: 1.5,
|
letterSpacing: 1.5,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -61,7 +61,7 @@ class ClockDisplay extends StatelessWidget {
|
|||||||
letterSpacing: 2,
|
letterSpacing: 2,
|
||||||
shadows: [
|
shadows: [
|
||||||
Shadow(
|
Shadow(
|
||||||
color: AppColors.cyan50,
|
color: AppColors.accent50,
|
||||||
blurRadius: 16,
|
blurRadius: 16,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import 'package:revoltvpn/logic/app_colors.dart';
|
|||||||
import 'package:revoltvpn/logic/vpn_connection.dart';
|
import 'package:revoltvpn/logic/vpn_connection.dart';
|
||||||
import 'package:revoltvpn/logic/session_timer.dart';
|
import 'package:revoltvpn/logic/session_timer.dart';
|
||||||
import 'package:revoltvpn/logic/ad_manager.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 {
|
class ConnectButton extends StatefulWidget {
|
||||||
const ConnectButton({super.key});
|
const ConnectButton({super.key});
|
||||||
|
|
||||||
@@ -16,232 +16,123 @@ class ConnectButton extends StatefulWidget {
|
|||||||
|
|
||||||
class _ConnectButtonState extends State<ConnectButton>
|
class _ConnectButtonState extends State<ConnectButton>
|
||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin {
|
||||||
late AnimationController _pulseController;
|
bool _busy = false;
|
||||||
late Animation<double> _pulseAnim;
|
AnimationController? _pulse;
|
||||||
|
Animation<double>? _pulseAnim;
|
||||||
|
|
||||||
bool _inCooldown = false;
|
double get _pulseValue => _pulseAnim?.value ?? 0.0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_pulseController = AnimationController(
|
final pulse = AnimationController(vsync: this, duration: const Duration(milliseconds: 2000));
|
||||||
vsync: this,
|
_pulse = pulse;
|
||||||
duration: const Duration(milliseconds: 2000),
|
_pulseAnim = Tween(begin: 0.0, end: 1.0).animate(CurvedAnimation(parent: pulse, curve: Curves.easeInOut));
|
||||||
);
|
|
||||||
_pulseAnim = Tween<double>(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.
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
context.read<VpnConnection>().addListener(_onVpnStateChanged);
|
context.read<VpnConnection>().addListener(_onVpnChanged);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
context.read<VpnConnection>().removeListener(_onVpnStateChanged);
|
context.read<VpnConnection>().removeListener(_onVpnChanged);
|
||||||
_pulseController.dispose();
|
_pulse?.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onVpnStateChanged() {
|
void _onVpnChanged() {
|
||||||
if (!mounted) return;
|
final vpn = context.read<VpnConnection>();
|
||||||
final isConnected = context.read<VpnConnection>().status == VpnStatus.connected;
|
if (vpn.status == VpnStatus.connected) {
|
||||||
if (isConnected && !_pulseController.isAnimating) {
|
_pulse?.repeat(reverse: true);
|
||||||
_pulseController.repeat(reverse: true);
|
} else {
|
||||||
} else if (!isConnected && _pulseController.isAnimating) {
|
_pulse?.stop();
|
||||||
_pulseController.stop();
|
_pulse?.reset();
|
||||||
_pulseController.reset();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleTap() async {
|
Future<void> _handleTap() async {
|
||||||
if (_inCooldown) return;
|
HapticFeedback.lightImpact();
|
||||||
|
|
||||||
final vpn = context.read<VpnConnection>();
|
final vpn = context.read<VpnConnection>();
|
||||||
final timer = context.read<SessionTimer>();
|
final timer = context.read<SessionTimer>();
|
||||||
|
final ad = context.read<AdManager>();
|
||||||
|
|
||||||
if (vpn.status == VpnStatus.connected) {
|
// Disconnect always works — even mid-connection
|
||||||
// Disconnect — await the full teardown so the notification and UI
|
if (vpn.status == VpnStatus.connected || vpn.status == VpnStatus.connecting) {
|
||||||
// stay in sync with the actual tunnel state.
|
_busy = false; // cancel any in-flight connect
|
||||||
HapticFeedback.lightImpact();
|
|
||||||
timer.stop();
|
|
||||||
await vpn.disconnect();
|
await vpn.disconnect();
|
||||||
// Short cooldown to prevent accidental immediate reconnect
|
timer.stop();
|
||||||
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();
|
|
||||||
|
|
||||||
// ── 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<AdManager>();
|
|
||||||
final adWatched = await adMgr.showAd('main_ad');
|
|
||||||
if (AdManager.adsEnabled && !adWatched) {
|
|
||||||
// User closed the ad before earning the reward — don't connect.
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final success = await vpn.connect();
|
if (_busy) return; // already connecting, prevent double-tap
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shows a dialog guiding the user to grant the VPN permission in
|
setState(() => _busy = true);
|
||||||
/// Android system settings, since there's no way to re-prompt the
|
try {
|
||||||
/// native VpnService dialog after the first denial.
|
final adWatched = await ad.showAd('main_ad');
|
||||||
void _showPermissionDialog() {
|
if (!adWatched && AdManager.adsEnabled) {
|
||||||
showDialog(
|
setState(() => _busy = false);
|
||||||
context: context,
|
return;
|
||||||
builder: (ctx) => AlertDialog(
|
}
|
||||||
backgroundColor: AppColors.bgDark,
|
final ok = await vpn.connect();
|
||||||
title: const Text('VPN Permission Required',
|
if (ok) timer.start('main_ad');
|
||||||
style: TextStyle(color: AppColors.textWhite)),
|
} finally {
|
||||||
content: const Text(
|
if (mounted) setState(() => _busy = false);
|
||||||
'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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Consumer2<VpnConnection, SessionTimer>(
|
return Consumer<VpnConnection>(
|
||||||
builder: (context, vpn, timer, _) {
|
builder: (context, vpn, _) {
|
||||||
final isConnected = vpn.status == VpnStatus.connected;
|
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(
|
return GestureDetector(
|
||||||
onTap: (isConnecting || _inCooldown) ? null : _handleTap,
|
onTap: _handleTap,
|
||||||
child: AnimatedBuilder(
|
child: _pulseAnim != null
|
||||||
animation: _pulseAnim,
|
? AnimatedBuilder(
|
||||||
builder: (context, child) {
|
animation: _pulseAnim!,
|
||||||
final glowRadius = isConnected ? 20.0 + (_pulseAnim.value * 15.0) : 0.0;
|
builder: (_, __) => _buildCircle(isConnected, spinning, _pulseValue),
|
||||||
final glowAlpha = isConnected
|
)
|
||||||
? ((0.3 + _pulseAnim.value * 0.2) * 255).round()
|
: _buildCircle(isConnected, spinning, 0),
|
||||||
: 0;
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
width: 260,
|
width: 260, // <-- CHANGE ME: circle size
|
||||||
height: 260,
|
height: 260,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
color: AppColors.bgDark,
|
color: AppColors.bgDark,
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: _inCooldown
|
color: isConnected ? AppColors.accent : AppColors.slate50,
|
||||||
? Colors.grey.withValues(alpha: 0.5) // dynamic, OK inline
|
width: isConnected ? 2.5 : 1.5,
|
||||||
: isConnected
|
|
||||||
? AppColors.cyan
|
|
||||||
: isConnecting
|
|
||||||
? AppColors.cyan50
|
|
||||||
: AppColors.slate50,
|
|
||||||
width: isConnected && !_inCooldown ? 3 : 2,
|
|
||||||
),
|
),
|
||||||
boxShadow: isConnected
|
boxShadow: isConnected
|
||||||
? [
|
? [BoxShadow(color: Color.fromARGB(glowAlpha, 255, 214, 0), blurRadius: glowRadius, spreadRadius: glowRadius * 0.3)]
|
||||||
BoxShadow(
|
: [],
|
||||||
color: Color.fromARGB(glowAlpha, 0, 229, 255),
|
|
||||||
blurRadius: glowRadius,
|
|
||||||
spreadRadius: glowRadius * 0.3,
|
|
||||||
),
|
),
|
||||||
]
|
clipBehavior: Clip.hardEdge,
|
||||||
: const [],
|
child: spinning
|
||||||
|
? const Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 36, height: 36,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2.5, color: AppColors.accent70),
|
||||||
),
|
),
|
||||||
child: Center(
|
)
|
||||||
child: isConnected
|
: Padding(
|
||||||
? const ClockDisplay()
|
padding: const EdgeInsets.all(0), // <-- CHANGE ME: brand.png padding from edge
|
||||||
: _buildDisconnectedIcon(vpn.status, _inCooldown),
|
child: Image.asset('assets/brand.png', fit: BoxFit.contain),
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -49,7 +49,7 @@ class OnlineDot extends StatelessWidget {
|
|||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: isOnline
|
color: isOnline
|
||||||
? AppColors.textWhite80
|
? AppColors.textDim
|
||||||
: AppColors.slate,
|
: AppColors.slate,
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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<void> 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,9 @@ import 'package:url_launcher/url_launcher.dart';
|
|||||||
class GdprButton extends StatelessWidget {
|
class GdprButton extends StatelessWidget {
|
||||||
const GdprButton({super.key});
|
const GdprButton({super.key});
|
||||||
|
|
||||||
Future<void> _showConsentForm() async {
|
/// Public entry point so the sidebar (or any other widget) can trigger
|
||||||
|
/// the consent flow without embedding an IconButton.
|
||||||
|
static Future<void> showConsentForm() async {
|
||||||
try {
|
try {
|
||||||
// If the form is already available, show it directly — no need to
|
// If the form is already available, show it directly — no need to
|
||||||
// re-request consent info from Google's servers on every tap.
|
// re-request consent info from Google's servers on every tap.
|
||||||
@@ -48,7 +50,7 @@ class GdprButton extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadAndShowForm() async {
|
static Future<void> _loadAndShowForm() async {
|
||||||
final completer = Completer<void>();
|
final completer = Completer<void>();
|
||||||
ConsentForm.loadConsentForm(
|
ConsentForm.loadConsentForm(
|
||||||
(ConsentForm form) {
|
(ConsentForm form) {
|
||||||
@@ -71,7 +73,7 @@ class GdprButton extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _fallbackToPrivacyPolicy() async {
|
static void _fallbackToPrivacyPolicy() async {
|
||||||
final Uri url = Uri.parse(
|
final Uri url = Uri.parse(
|
||||||
'https://github.com/esefxdz/ReVoltVPN/blob/main/PRIVACY_POLICY.md',
|
'https://github.com/esefxdz/ReVoltVPN/blob/main/PRIVACY_POLICY.md',
|
||||||
);
|
);
|
||||||
@@ -85,7 +87,7 @@ class GdprButton extends StatelessWidget {
|
|||||||
return IconButton(
|
return IconButton(
|
||||||
icon: const Icon(Icons.admin_panel_settings_outlined,
|
icon: const Icon(Icons.admin_panel_settings_outlined,
|
||||||
color: AppColors.textWhite, size: 28),
|
color: AppColors.textWhite, size: 28),
|
||||||
onPressed: _showConsentForm,
|
onPressed: showConsentForm,
|
||||||
tooltip: 'Ad consent & privacy choices',
|
tooltip: 'Ad consent & privacy choices',
|
||||||
splashRadius: 24,
|
splashRadius: 24,
|
||||||
);
|
);
|
||||||
@@ -5,7 +5,8 @@ import 'package:revoltvpn/logic/app_colors.dart';
|
|||||||
class InfoButton extends StatelessWidget {
|
class InfoButton extends StatelessWidget {
|
||||||
const InfoButton({super.key});
|
const InfoButton({super.key});
|
||||||
|
|
||||||
Future<void> _launchGitHub() async {
|
/// Public entry point for the sidebar or any other widget.
|
||||||
|
static Future<void> launchGitHub() async {
|
||||||
final Uri url = Uri.parse('https://github.com/esefxdz/ReVoltVPN');
|
final Uri url = Uri.parse('https://github.com/esefxdz/ReVoltVPN');
|
||||||
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
|
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
|
||||||
debugPrint('Could not launch $url');
|
debugPrint('Could not launch $url');
|
||||||
@@ -16,7 +17,7 @@ class InfoButton extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return IconButton(
|
return IconButton(
|
||||||
icon: const Icon(Icons.help_outline, color: AppColors.textWhite, size: 28),
|
icon: const Icon(Icons.help_outline, color: AppColors.textWhite, size: 28),
|
||||||
onPressed: _launchGitHub,
|
onPressed: launchGitHub,
|
||||||
tooltip: 'About ReVoltVPN',
|
tooltip: 'About ReVoltVPN',
|
||||||
splashRadius: 24,
|
splashRadius: 24,
|
||||||
);
|
);
|
||||||
@@ -5,7 +5,8 @@ import 'package:revoltvpn/logic/app_colors.dart';
|
|||||||
class PrivacyPolicyButton extends StatelessWidget {
|
class PrivacyPolicyButton extends StatelessWidget {
|
||||||
const PrivacyPolicyButton({super.key});
|
const PrivacyPolicyButton({super.key});
|
||||||
|
|
||||||
Future<void> _launchPrivacyPolicy() async {
|
/// Public entry point for the sidebar or any other widget.
|
||||||
|
static Future<void> launchPrivacyPolicy() async {
|
||||||
final Uri url = Uri.parse('https://github.com/esefxdz/ReVoltVPN/blob/main/PRIVACY_POLICY.md');
|
final Uri url = Uri.parse('https://github.com/esefxdz/ReVoltVPN/blob/main/PRIVACY_POLICY.md');
|
||||||
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
|
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
|
||||||
debugPrint('Could not launch $url');
|
debugPrint('Could not launch $url');
|
||||||
@@ -16,7 +17,7 @@ class PrivacyPolicyButton extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return IconButton(
|
return IconButton(
|
||||||
icon: const Icon(Icons.privacy_tip_outlined, color: AppColors.textWhite, size: 28),
|
icon: const Icon(Icons.privacy_tip_outlined, color: AppColors.textWhite, size: 28),
|
||||||
onPressed: _launchPrivacyPolicy,
|
onPressed: launchPrivacyPolicy,
|
||||||
tooltip: 'Privacy Policy',
|
tooltip: 'Privacy Policy',
|
||||||
splashRadius: 24,
|
splashRadius: 24,
|
||||||
);
|
);
|
||||||
@@ -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,
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SessionTimer>(
|
||||||
|
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,
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<VpnConnection, SessionTimer>(
|
||||||
|
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',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<VpnConnection>(
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<SessionTimer>(
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,15 +10,11 @@ import 'package:flutter/material.dart';
|
|||||||
abstract final class AppColors {
|
abstract final class AppColors {
|
||||||
AppColors._();
|
AppColors._();
|
||||||
|
|
||||||
// ── Primary: Cyan ──────────────────────────────────────────────────────────
|
// ── Accent ────────────────────────────────────────────────────────────────
|
||||||
static const Color cyan = Color(0xFF00E5FF);
|
static const Color accent = Color(0xFFFFD600); // yellow
|
||||||
static const Color cyan15 = Color(0x2600E5FF); // 15 % opacity
|
static const Color accent50 = Color(0x80FFD600); // 50 % opacity
|
||||||
static const Color cyan25 = Color(0x4000E5FF); // 25 % opacity
|
static const Color accent60 = Color(0x99FFD600); // 60 % opacity
|
||||||
static const Color cyan50 = Color(0x8000E5FF); // 50 % opacity
|
static const Color accent70 = Color(0xB3FFD600); // 70 % 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
|
|
||||||
|
|
||||||
// ── Backgrounds ────────────────────────────────────────────────────────────
|
// ── Backgrounds ────────────────────────────────────────────────────────────
|
||||||
static const Color bgDeep = Color(0xFF0D1117); // intro / scaffold
|
static const Color bgDeep = Color(0xFF0D1117); // intro / scaffold
|
||||||
@@ -32,12 +28,10 @@ abstract final class AppColors {
|
|||||||
static const Color slate15 = Color(0x264A5568); // 15 % opacity
|
static const Color slate15 = Color(0x264A5568); // 15 % opacity
|
||||||
static const Color slate50 = Color(0x804A5568); // 50 % opacity
|
static const Color slate50 = Color(0x804A5568); // 50 % opacity
|
||||||
static const Color slate70 = Color(0xB34A5568); // 70 % opacity
|
static const Color slate70 = Color(0xB34A5568); // 70 % opacity
|
||||||
static const Color slateAA = Color(0xAA4A5568); // ~67 % (legacy usage)
|
|
||||||
|
|
||||||
// ── Text ───────────────────────────────────────────────────────────────────
|
// ── Text ───────────────────────────────────────────────────────────────────
|
||||||
static const Color textWhite = Colors.white;
|
static const Color textWhite = Colors.white;
|
||||||
static const Color textDim = Colors.white54;
|
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 ───────────────────────────────────────────────────────────────
|
// ── Semantic ───────────────────────────────────────────────────────────────
|
||||||
@@ -49,26 +43,4 @@ abstract final class AppColors {
|
|||||||
static const Color glassBg = Color(0x22FFFFFF); // subtle glass background
|
static const Color glassBg = Color(0x22FFFFFF); // subtle glass background
|
||||||
static const Color glassBorder = Color(0x33FFFFFF); // muted translucent border
|
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);
|
|
||||||
}
|
}
|
||||||
@@ -17,6 +17,7 @@ enum VpnStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class VpnConnection extends ChangeNotifier {
|
class VpnConnection extends ChangeNotifier {
|
||||||
|
bool _cancelled = false;
|
||||||
VpnStatus _status = VpnStatus.disconnected;
|
VpnStatus _status = VpnStatus.disconnected;
|
||||||
VpnStatus get status => _status;
|
VpnStatus get status => _status;
|
||||||
|
|
||||||
@@ -103,6 +104,7 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
if (_status == VpnStatus.connected || _status == VpnStatus.connecting) {
|
if (_status == VpnStatus.connected || _status == VpnStatus.connecting) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
_cancelled = false;
|
||||||
|
|
||||||
if (!kIsWeb && !_initialized) {
|
if (!kIsWeb && !_initialized) {
|
||||||
_errorMessage = 'VPN service unavailable.';
|
_errorMessage = 'VPN service unavailable.';
|
||||||
@@ -147,7 +149,13 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
return false;
|
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 {
|
try {
|
||||||
final parsed = FlutterVless.parse(vlessUrl);
|
final parsed = FlutterVless.parse(vlessUrl);
|
||||||
final config = parsed.getFullConfiguration();
|
final config = parsed.getFullConfiguration();
|
||||||
@@ -193,6 +201,7 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
|
|
||||||
// ── Disconnect ────────────────────────────────────────────────────────────
|
// ── Disconnect ────────────────────────────────────────────────────────────
|
||||||
Future<void> disconnect() async {
|
Future<void> disconnect() async {
|
||||||
|
_cancelled = true;
|
||||||
if (_status == VpnStatus.disconnected || _status == VpnStatus.disconnecting) {
|
if (_status == VpnStatus.disconnected || _status == VpnStatus.disconnecting) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,8 +84,8 @@ class ReVoltApp extends StatelessWidget {
|
|||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
scaffoldBackgroundColor: AppColors.bgDeep,
|
scaffoldBackgroundColor: AppColors.bgDeep,
|
||||||
colorScheme: const ColorScheme.dark(
|
colorScheme: const ColorScheme.dark(
|
||||||
primary: AppColors.cyan,
|
primary: AppColors.accent,
|
||||||
secondary: AppColors.cyan,
|
secondary: AppColors.accent,
|
||||||
surface: AppColors.bgSurface,
|
surface: AppColors.bgSurface,
|
||||||
),
|
),
|
||||||
fontFamily: 'Roboto',
|
fontFamily: 'Roboto',
|
||||||
|
|||||||
@@ -60,23 +60,35 @@ class _IntroScreenState extends State<IntroScreen> with WidgetsBindingObserver {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppColors.bgDeep,
|
backgroundColor: AppColors.bgDeep,
|
||||||
body: Center(
|
body: Column(
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
children: [
|
||||||
Image.asset(
|
const Spacer(flex: 7),
|
||||||
'assets/logo.png',
|
// Image slightly below center
|
||||||
width: 140,
|
Center(
|
||||||
height: 140,
|
child: Image.asset(
|
||||||
|
'assets/brand.png',
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
errorBuilder: (context, error, stackTrace) {
|
errorBuilder: (context, error, stackTrace) {
|
||||||
return const Icon(
|
return const Icon(
|
||||||
Icons.shield,
|
Icons.shield,
|
||||||
size: 140,
|
size: 200,
|
||||||
color: AppColors.cyan,
|
color: AppColors.accent,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 30),
|
),
|
||||||
|
// 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: 4),
|
||||||
const Text(
|
const Text(
|
||||||
'REVOLT VPN',
|
'REVOLT VPN',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -86,18 +98,10 @@ class _IntroScreenState extends State<IntroScreen> with WidgetsBindingObserver {
|
|||||||
letterSpacing: 2.0,
|
letterSpacing: 2.0,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 60),
|
const Spacer(flex: 5),
|
||||||
const SizedBox(
|
const SizedBox(height: 12),
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
color: AppColors.cyan,
|
|
||||||
strokeWidth: 3.0,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,21 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:revoltvpn/logic/app_colors.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/connect_button.dart';
|
||||||
import 'package:revoltvpn/components/bottom_bar.dart';
|
import 'package:revoltvpn/components/sidebar_drawer.dart';
|
||||||
import 'package:revoltvpn/components/info_button.dart';
|
import 'package:revoltvpn/components/timer_box.dart';
|
||||||
import 'package:revoltvpn/components/privacy_policy_button.dart';
|
import 'package:revoltvpn/components/speed_box.dart';
|
||||||
import 'package:revoltvpn/components/gdpr_button.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 {
|
class MainScreen extends StatelessWidget {
|
||||||
const MainScreen({super.key});
|
const MainScreen({super.key});
|
||||||
|
|
||||||
@@ -23,6 +13,7 @@ class MainScreen extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppColors.bgDeep,
|
backgroundColor: AppColors.bgDeep,
|
||||||
|
endDrawer: const SidebarDrawer(),
|
||||||
body: Container(
|
body: Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: double.infinity,
|
height: double.infinity,
|
||||||
@@ -31,54 +22,51 @@ class MainScreen extends StatelessWidget {
|
|||||||
image: DecorationImage(
|
image: DecorationImage(
|
||||||
image: AssetImage('assets/background.png'),
|
image: AssetImage('assets/background.png'),
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
colorFilter: ColorFilter.mode(
|
colorFilter: ColorFilter.mode(AppColors.bgOverlay, BlendMode.darken),
|
||||||
AppColors.bgOverlay, // Deep dark tint overlay to guarantee readability of overlay elements
|
|
||||||
BlendMode.darken,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
// ── Main content column ──
|
Positioned(
|
||||||
LayoutBuilder(
|
top: 0,
|
||||||
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,
|
|
||||||
right: 4,
|
right: 4,
|
||||||
child: Row(
|
child: Builder(
|
||||||
mainAxisSize: MainAxisSize.min,
|
builder: (ctx) => IconButton(
|
||||||
children: [
|
icon: const Icon(Icons.menu, color: AppColors.textWhite, size: 28),
|
||||||
GdprButton(),
|
onPressed: () => Scaffold.of(ctx).openEndDrawer(),
|
||||||
PrivacyPolicyButton(),
|
tooltip: 'Menu', splashRadius: 24,
|
||||||
InfoButton(),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
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 {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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.
|
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
|
||||||
# Read more about iOS versioning at
|
# Read more about iOS versioning at
|
||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
version: 1.0.5+9
|
version: 1.0.6+10
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=3.0.0 <4.0.0"
|
sdk: ">=3.0.0 <4.0.0"
|
||||||
|
|||||||