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
+125 -158

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