overall ui bugfixes and hivemind improvements

This commit is contained in:
esefxdz committed 2026-05-31 13:16:46 +03:00
1 parent a80a23e008
commit 8f32429e82
10 files changed
+96 -129

No files matched your search

+5 -2
View File
@@ -2,13 +2,13 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:paladinvpn/logic/vpn_connection.dart'; import 'package:paladinvpn/logic/vpn_connection.dart';
import 'package:paladinvpn/components/online_dot.dart'; import 'package:paladinvpn/components/online_dot.dart';
import 'package:paladinvpn/components/watch_ad_button.dart';
/// BottomBar is the bottom utility panel that surfaces server connectivity status /// BottomBar is the bottom utility panel that surfaces server connectivity status
/// and offers the user an option to support the server when the VPN tunnel is active. /// and offers the user an option to support the server when the VPN tunnel is active.
class BottomBar extends StatelessWidget { class BottomBar extends StatelessWidget {
const BottomBar({super.key}); const BottomBar({super.key});
// Dark card background color for high contrast overlay
static const Color _cardBg = Color(0xFF1E2533); static const Color _cardBg = Color(0xFF1E2533);
@override @override
@@ -29,8 +29,11 @@ class BottomBar extends StatelessWidget {
), ),
child: Row( child: Row(
children: [ children: [
// Displays the server online/offline chip // Server online/offline chip
OnlineDot(isOnline: isOnline), OnlineDot(isOnline: isOnline),
const Spacer(),
// Support button — only visible while connected
if (isOnline) const WatchAdButton(),
], ],
), ),
); );
+42 -56
View File
@@ -3,34 +3,28 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:paladinvpn/logic/session_timer.dart'; import 'package:paladinvpn/logic/session_timer.dart';
/// ClockDisplay shows a large circular progress ring and the session countdown timer. /// ClockDisplay renders inside the ConnectButton circle when the VPN is active.
/// ///
/// The timer text glows electric cyan when a session is active and dims to slate gray when idle. /// Shows a circular quota-progress arc, a glowing countdown timer, and the
/// current transfer speed — all sized to fit within the 260px button.
class ClockDisplay extends StatelessWidget { class ClockDisplay extends StatelessWidget {
const ClockDisplay({super.key}); const ClockDisplay({super.key});
static const Color _cyanGlow = Color(0xFF00E5FF); static const Color _cyanGlow = Color(0xFF00E5FF);
static const Color _dimSlate = Color(0xFF4A5568);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Consumer<SessionTimer>( return Consumer<SessionTimer>(
builder: (context, timer, _) { builder: (context, timer, _) {
final isLive = timer.isRunning; return SizedBox(
final color = isLive ? _cyanGlow : _dimSlate; width: 200,
height: 200,
return Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 220,
height: 220,
child: Stack( child: Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
// Background track ring representing the maximum capacity of the countdown // Background quota track ring
const CustomPaint( const CustomPaint(
size: Size(220, 220), size: Size(200, 200),
painter: _ArcPainter( painter: _ArcPainter(
progress: 1.0, progress: 1.0,
color: Color.fromRGBO(74, 85, 104, 0.2), color: Color.fromRGBO(74, 85, 104, 0.2),
@@ -38,13 +32,12 @@ class ClockDisplay extends StatelessWidget {
), ),
), ),
// Live progress ring showing current remaining time relative to the session limit // Live quota progress ring
if (isLive)
TweenAnimationBuilder<double>( TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: timer.progress), tween: Tween(begin: 0, end: timer.progress),
duration: const Duration(milliseconds: 400), duration: const Duration(milliseconds: 400),
builder: (context, value, _) => CustomPaint( builder: (context, value, _) => CustomPaint(
size: const Size(220, 220), size: const Size(200, 200),
painter: _ArcPainter( painter: _ArcPainter(
progress: value, progress: value,
color: _cyanGlow, color: _cyanGlow,
@@ -54,59 +47,54 @@ class ClockDisplay extends StatelessWidget {
), ),
), ),
// Monospaced digital clock display for session countdown // Center content: countdown + speed
AnimatedDefaultTextStyle( Column(
duration: const Duration(milliseconds: 300), mainAxisSize: MainAxisSize.min,
style: TextStyle( children: [
fontFamily: 'RobotoMono', // Glowing countdown timer
fontSize: 48, Text(
fontWeight: FontWeight.w300, timer.formatted,
letterSpacing: 4, style: const TextStyle(
color: color, fontSize: 40,
shadows: isLive fontWeight: FontWeight.w800,
? const [ color: Colors.white,
letterSpacing: 2,
shadows: [
Shadow( Shadow(
color: Color.fromRGBO(0, 229, 255, 0.6), color: Color.fromRGBO(0, 229, 255, 0.5),
blurRadius: 20, blurRadius: 16,
),
Shadow(
color: Color.fromRGBO(0, 229, 255, 0.3),
blurRadius: 40,
),
]
: const [],
),
child: Text(timer.formatted),
), ),
], ],
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 6),
// Subtitle state label below the timer // Speed indicator
AnimatedSwitcher( Row(
duration: const Duration(milliseconds: 200), mainAxisSize: MainAxisSize.min,
child: Text( children: [
isLive ? 'SESSION ACTIVE' : 'STANDBY', const Icon(Icons.speed, color: Colors.white54, size: 14),
key: ValueKey(isLive), const SizedBox(width: 4),
style: TextStyle( Text(
'${timer.currentSpeedKbps.toStringAsFixed(1)} KB/s',
style: const TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w600, color: Colors.white54,
letterSpacing: 3, fontWeight: FontWeight.w500,
color: isLive
? const Color.fromRGBO(0, 229, 255, 0.7)
: const Color.fromRGBO(74, 85, 104, 0.7),
),
), ),
), ),
], ],
),
],
),
],
),
); );
}, },
); );
} }
} }
/// A custom painter to render a circular progress arc or background track ring.
class _ArcPainter extends CustomPainter { class _ArcPainter extends CustomPainter {
final double progress; final double progress;
final Color color; final Color color;
@@ -125,7 +113,6 @@ class _ArcPainter extends CustomPainter {
final rect = Rect.fromLTWH(0, 0, size.width, size.height) final rect = Rect.fromLTWH(0, 0, size.width, size.height)
.deflate(strokeWidth / 2); .deflate(strokeWidth / 2);
// Optional blurred background paint to create an electric neon glow effect
if (glow) { if (glow) {
final glowPaint = Paint() final glowPaint = Paint()
..color = const Color.fromRGBO(0, 229, 255, 0.15) ..color = const Color.fromRGBO(0, 229, 255, 0.15)
@@ -136,7 +123,6 @@ class _ArcPainter extends CustomPainter {
canvas.drawArc(rect, -pi / 2, 2 * pi * progress, false, glowPaint); canvas.drawArc(rect, -pi / 2, 2 * pi * progress, false, glowPaint);
} }
// Core progress stroke paint
final paint = Paint() final paint = Paint()
..color = color ..color = color
..style = PaintingStyle.stroke ..style = PaintingStyle.stroke
+22 -59
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:paladinvpn/logic/vpn_connection.dart'; import 'package:paladinvpn/logic/vpn_connection.dart';
import 'package:paladinvpn/logic/session_timer.dart'; import 'package:paladinvpn/logic/session_timer.dart';
import 'package:paladinvpn/components/clock_display.dart';
class ConnectButton extends StatefulWidget { class ConnectButton extends StatefulWidget {
const ConnectButton({super.key}); const ConnectButton({super.key});
@@ -30,26 +31,38 @@ class _ConnectButtonState extends State<ConnectButton>
_pulseAnim = Tween<double>(begin: 0.0, end: 1.0).animate( _pulseAnim = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut), CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut),
); );
// Listen to VpnConnection changes to drive the pulse animation
// from outside build(), keeping build() pure.
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<VpnConnection>().addListener(_onVpnStateChanged);
});
} }
@override @override
void dispose() { void dispose() {
context.read<VpnConnection>().removeListener(_onVpnStateChanged);
_pulseController.dispose(); _pulseController.dispose();
super.dispose(); super.dispose();
} }
void _onVpnStateChanged() {
if (!mounted) return;
final isConnected = context.read<VpnConnection>().status == VpnStatus.connected;
if (isConnected && !_pulseController.isAnimating) {
_pulseController.repeat(reverse: true);
} else if (!isConnected && _pulseController.isAnimating) {
_pulseController.stop();
_pulseController.reset();
}
}
void _handleTap() async { void _handleTap() async {
if (_inCooldown) return; if (_inCooldown) return;
setState(() { setState(() => _inCooldown = true);
_inCooldown = true;
});
Future.delayed(const Duration(seconds: 5), () { Future.delayed(const Duration(seconds: 5), () {
if (mounted) { if (mounted) setState(() => _inCooldown = false);
setState(() {
_inCooldown = false;
});
}
}); });
final vpn = context.read<VpnConnection>(); final vpn = context.read<VpnConnection>();
@@ -58,14 +71,11 @@ class _ConnectButtonState extends State<ConnectButton>
if (vpn.status == VpnStatus.connected) { if (vpn.status == VpnStatus.connected) {
vpn.disconnect(); vpn.disconnect();
timer.stop(); timer.stop();
_pulseController.stop();
_pulseController.reset();
} else if (vpn.status == VpnStatus.disconnected || } else if (vpn.status == VpnStatus.disconnected ||
vpn.status == VpnStatus.error) { vpn.status == VpnStatus.error) {
final success = await vpn.connect(); final success = await vpn.connect();
if (success) { if (success) {
timer.start('main_ad'); timer.start('main_ad');
_pulseController.repeat(reverse: true);
} }
} }
} }
@@ -77,13 +87,6 @@ class _ConnectButtonState extends State<ConnectButton>
final isConnected = vpn.status == VpnStatus.connected; final isConnected = vpn.status == VpnStatus.connected;
final isConnecting = vpn.status == VpnStatus.connecting; final isConnecting = vpn.status == VpnStatus.connecting;
if (isConnected && !_pulseController.isAnimating) {
_pulseController.repeat(reverse: true);
} else if (!isConnected && !isConnecting && _pulseController.isAnimating) {
_pulseController.stop();
_pulseController.reset();
}
return GestureDetector( return GestureDetector(
onTap: (isConnecting || _inCooldown) ? null : _handleTap, onTap: (isConnecting || _inCooldown) ? null : _handleTap,
child: AnimatedBuilder( child: AnimatedBuilder(
@@ -122,7 +125,7 @@ class _ConnectButtonState extends State<ConnectButton>
), ),
child: Center( child: Center(
child: isConnected child: isConnected
? _buildConnectedStats(timer) ? const ClockDisplay()
: _buildDisconnectedIcon(vpn.status, _inCooldown), : _buildDisconnectedIcon(vpn.status, _inCooldown),
), ),
); );
@@ -164,44 +167,4 @@ class _ConnectButtonState extends State<ConnectButton>
], ],
); );
} }
Widget _buildConnectedStats(SessionTimer timer) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
// Countdown Timer
Text(
timer.formatted,
style: const TextStyle(
fontSize: 48,
fontWeight: FontWeight.w800,
color: Colors.white,
letterSpacing: 2,
),
),
const SizedBox(height: 8),
// Speed indicator
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.speed, color: Colors.white70, size: 16),
const SizedBox(width: 4),
Text(
'${timer.currentSpeedKbps.toStringAsFixed(1)} KB/s',
style: const TextStyle(
fontSize: 14,
color: Colors.white70,
fontWeight: FontWeight.w500,
),
),
],
),
],
);
}
} }
+1 -1
View File
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
class InfoButton extends StatelessWidget { class InfoButton extends StatelessWidget {
const InfoButton({Key? key}) : super(key: key); const InfoButton({super.key});
Future<void> _launchGitHub() async { Future<void> _launchGitHub() async {
final Uri url = Uri.parse('https://github.com/esefxdz/PaladinVPN'); final Uri url = Uri.parse('https://github.com/esefxdz/PaladinVPN');
+13 -6
View File
@@ -52,13 +52,20 @@ class StatusText extends StatelessWidget {
), ),
), ),
] else ...[ ] else ...[
// Dynamic status icon (spinning arrows for loading, outlined shield for idle) if (isConnecting)
Icon( const SizedBox(
isConnecting ? Icons.autorenew : Icons.shield_outlined, width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 1.5,
color: Color(0xFF00E5FF),
),
)
else
const Icon(
Icons.shield_outlined,
size: 18, size: 18,
color: isConnecting color: Color(0xAA4A5568),
? const Color(0xFF00E5FF)
: const Color(0xAA4A5568),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
+4 -3
View File
@@ -94,8 +94,8 @@ class AdManager extends ChangeNotifier {
final keys = await CryptoService.getOrCreateKeys(); final keys = await CryptoService.getOrCreateKeys();
final pubKey = keys['publicKey']!; final pubKey = keys['publicKey']!;
// 3. Attach custom data to the ad so Google sends it back to our Hivemind server // 3. Build SSV options to send to our Hivemind server via Google's callback
_rewardedAd!.serverSideVerificationOptions = ServerSideVerificationOptions( final ssvOptions = ServerSideVerificationOptions(
customData: jsonEncode({ customData: jsonEncode({
'device_id': deviceId, 'device_id': deviceId,
'public_key': pubKey, 'public_key': pubKey,
@@ -130,7 +130,8 @@ class AdManager extends ChangeNotifier {
}, },
); );
// 5. Show the ad // 5. Show the ad with SSV options
_rewardedAd!.setServerSideOptions(ssvOptions);
await _rewardedAd!.show( await _rewardedAd!.show(
onUserEarnedReward: (AdWithoutView ad, RewardItem reward) { onUserEarnedReward: (AdWithoutView ad, RewardItem reward) {
debugPrint('[AdManager] Reward earned: ${reward.amount} ${reward.type}'); debugPrint('[AdManager] Reward earned: ${reward.amount} ${reward.type}');
+1
View File
@@ -182,6 +182,7 @@ class SessionTimer extends ChangeNotifier {
@override @override
void dispose() { void dispose() {
_timer?.cancel(); _timer?.cancel();
VpnNotificationManager.cancel();
super.dispose(); super.dispose();
} }
} }
+1 -1
View File
@@ -156,7 +156,7 @@ class VpnConnection extends ChangeNotifier {
return false; return false;
} }
// ── Step 2: Start the WireGuard tunnel with the fetched config ───────── // ── Step 3: Start the WireGuard tunnel with the fetched config ─────────
try { try {
// Re-initialize to ensure VPN permission is granted before starting // Re-initialize to ensure VPN permission is granted before starting
await _wireguard.initialize(interfaceName: 'Paladin0'); await _wireguard.initialize(interfaceName: 'Paladin0');
+4
View File
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import 'package:paladinvpn/logic/vpn_connection.dart'; import 'package:paladinvpn/logic/vpn_connection.dart';
import 'package:paladinvpn/logic/session_timer.dart'; import 'package:paladinvpn/logic/session_timer.dart';
import 'package:paladinvpn/logic/ad_manager.dart';
import 'package:paladinvpn/screens/main_screen.dart'; import 'package:paladinvpn/screens/main_screen.dart';
import 'package:paladinvpn/screens/intro.dart'; import 'package:paladinvpn/screens/intro.dart';
@@ -53,6 +54,9 @@ class PaladinApp extends StatelessWidget {
update: (_, vpn, prev) => update: (_, vpn, prev) =>
prev ?? SessionTimer(vpnConnection: vpn), prev ?? SessionTimer(vpnConnection: vpn),
), ),
// Manages rewarded ad lifecycle — preloads on startup.
ChangeNotifierProvider(create: (_) => AdManager()),
], ],
child: MaterialApp( child: MaterialApp(
title: 'Paladin VPN', title: 'Paladin VPN',
+3 -1
View File
@@ -447,7 +447,9 @@ def admob_callback():
if not device_id or not client_pubkey: if not device_id or not client_pubkey:
return "Invalid custom_data payload", 400 return "Invalid custom_data payload", 400
success, msg = _start_or_extend_session(device_id, client_pubkey, 'main_ad') ad_type = custom_data.get('ad_type', 'main_ad')
success, msg = _start_or_extend_session(device_id, client_pubkey, ad_type)
return ("OK", 200) if success else (msg, 500) return ("OK", 200) if success else (msg, 500)
except Exception as e: except Exception as e: