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"