initial commit

This commit is contained in:
esefxdz committed 2026-07-26 10:30:44 +03:00
1 parent d106356ca6
commit a863656758
50 files changed
+508 -514

No files matched your search

+1
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 547 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 547 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 547 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 547 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 547 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 547 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 547 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 547 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 547 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

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>
+1 -1
View File
@@ -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>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 313 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 289 KiB

After

Width:  |  Height:  |  Size: 492 KiB

-48
View File
@@ -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',
),
],
),
);
},
);
}
}
+3 -3
View File
@@ -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,
), ),
], ],
+73 -182
View File
@@ -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,
),
),
],
);
} }
} }
-54
View File
@@ -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
),
),
],
);
}
}
+1 -1
View File
@@ -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,
); );
+134
View File
@@ -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,
)),
],
),
),
);
}
}
+39
View File
@@ -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,
)),
],
),
);
},
);
}
}
+72
View File
@@ -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',
),
],
),
);
},
);
}
}
-91
View File
@@ -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,
),
),
],
],
),
),
);
},
);
}
}
+34
View File
@@ -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,
),
),
);
},
);
}
}
+5 -33
View File
@@ -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);
} }
+10 -1
View File
@@ -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;
} }
+2 -2
View File
@@ -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',
+24 -20
View File
@@ -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,
),
),
], ],
), ),
),
); );
} }
} }
+42 -55
View File
@@ -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 {
); );
} }
} }
+1 -1
View File
@@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # 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"