Initial commit (Security Hardened Slate)
This commit is contained in:
commit
157f60aad2
65 files changed
+3735
No files matched your search
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:paladinvpn/logic/vpn_connection.dart';
|
||||
import 'package:paladinvpn/components/online_dot.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
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<VpnConnection>(
|
||||
builder: (context, vpn, _) {
|
||||
final isOnline = vpn.status == VpnStatus.connected;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: _cardBg,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: const Color.fromRGBO(74, 85, 104, 0.15),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Displays the server online/offline chip
|
||||
OnlineDot(isOnline: isOnline),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import 'dart:math';
|
||||
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.
|
||||
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 Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 220,
|
||||
height: 220,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
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,
|
||||
),
|
||||
),
|
||||
|
||||
// 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 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;
|
||||
final double strokeWidth;
|
||||
final bool glow;
|
||||
|
||||
const _ArcPainter({
|
||||
required this.progress,
|
||||
required this.color,
|
||||
this.strokeWidth = 4,
|
||||
this.glow = false,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
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)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = strokeWidth + 8
|
||||
..strokeCap = StrokeCap.round
|
||||
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8);
|
||||
canvas.drawArc(rect, -pi / 2, 2 * pi * progress, false, glowPaint);
|
||||
}
|
||||
|
||||
// Core progress stroke paint
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = strokeWidth
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
canvas.drawArc(rect, -pi / 2, 2 * pi * progress, false, paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _ArcPainter old) =>
|
||||
old.progress != progress || old.color != color;
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:paladinvpn/logic/vpn_connection.dart';
|
||||
import 'package:paladinvpn/logic/session_timer.dart';
|
||||
|
||||
class ConnectButton extends StatefulWidget {
|
||||
const ConnectButton({super.key});
|
||||
|
||||
@override
|
||||
State<ConnectButton> createState() => _ConnectButtonState();
|
||||
}
|
||||
|
||||
class _ConnectButtonState extends State<ConnectButton>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _pulseController;
|
||||
late Animation<double> _pulseAnim;
|
||||
|
||||
static const Color _cyanGlow = Color(0xFF00E5FF);
|
||||
static const Color _bgDark = Color(0xFF1A202C);
|
||||
|
||||
bool _inCooldown = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pulseController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 2000),
|
||||
);
|
||||
_pulseAnim = Tween<double>(begin: 0.0, end: 1.0).animate(
|
||||
CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pulseController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleTap() async {
|
||||
if (_inCooldown) return;
|
||||
|
||||
setState(() {
|
||||
_inCooldown = true;
|
||||
});
|
||||
Future.delayed(const Duration(seconds: 5), () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_inCooldown = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
final vpn = context.read<VpnConnection>();
|
||||
final timer = context.read<SessionTimer>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
|
||||
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(
|
||||
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: _bgDark,
|
||||
border: Border.all(
|
||||
color: _inCooldown
|
||||
? Colors.grey.withValues(alpha: 0.5)
|
||||
: isConnected
|
||||
? _cyanGlow
|
||||
: isConnecting
|
||||
? const Color.fromRGBO(0, 229, 255, 0.5)
|
||||
: const Color.fromRGBO(74, 85, 104, 0.5),
|
||||
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
|
||||
? _buildConnectedStats(timer)
|
||||
: _buildDisconnectedIcon(vpn.status, _inCooldown),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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: Color.fromRGBO(0, 229, 255, 0.7),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
inCooldown ? Icons.hourglass_bottom : Icons.power_settings_new,
|
||||
size: 64,
|
||||
color: inCooldown ? Colors.grey : const Color.fromRGBO(74, 85, 104, 0.7),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
inCooldown ? 'PLEASE WAIT' : 'TAP TO CONNECT',
|
||||
style: TextStyle(
|
||||
color: inCooldown ? Colors.grey : const Color.fromRGBO(74, 85, 104, 0.9),
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Header displays the Paladin 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});
|
||||
|
||||
// Cyber-paladin cyan glow accent color used as a fallback for the shield icon
|
||||
static const Color _cyanGlow = Color(0xFF00E5FF);
|
||||
|
||||
@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: _cyanGlow,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8), // Standard padding between the logo and the title
|
||||
|
||||
// Brand primary name
|
||||
const Text(
|
||||
'Paladin',
|
||||
style: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 8,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
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: Color(0xAA4A5568), // Semi-transparent slate gray
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class InfoButton extends StatelessWidget {
|
||||
const InfoButton({Key? key}) : super(key: key);
|
||||
|
||||
Future<void> _launchGitHub() async {
|
||||
final Uri url = Uri.parse('https://github.com/esefxdz/PaladinVPN');
|
||||
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
|
||||
debugPrint('Could not launch $url');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.help_outline, color: Colors.white, size: 28),
|
||||
onPressed: _launchGitHub,
|
||||
tooltip: 'About PaladinVPN',
|
||||
splashRadius: 24,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// OnlineDot displays a glowing green dot when the server is online/connected,
|
||||
/// and a muted gray dot when disconnected.
|
||||
class OnlineDot extends StatelessWidget {
|
||||
/// Represents if the VPN tunnel session is active (online).
|
||||
final bool isOnline;
|
||||
|
||||
const OnlineDot({
|
||||
super.key,
|
||||
required this.isOnline,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Glowing status dot: Green when active, muted gray when inactive.
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: isOnline ? const Color(0xFF00E676) : const Color(0xFF4A5568),
|
||||
boxShadow: isOnline
|
||||
? const [
|
||||
BoxShadow(
|
||||
color: Color.fromRGBO(0, 230, 118, 0.5),
|
||||
blurRadius: 6,
|
||||
),
|
||||
]
|
||||
: const [],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
|
||||
// Dynamic status text label
|
||||
Text(
|
||||
isOnline ? 'Server Online' : 'Disconnected',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: isOnline
|
||||
? const Color.fromRGBO(255, 255, 255, 0.8)
|
||||
: const Color(0xFF4A5568),
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class PrivacyPolicyButton extends StatelessWidget {
|
||||
const PrivacyPolicyButton({super.key});
|
||||
|
||||
Future<void> _launchPrivacyPolicy() async {
|
||||
final Uri url = Uri.parse('https://github.com/esefxdz/PaladinVPN/blob/main/PRIVACY_POLICY.md');
|
||||
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
|
||||
debugPrint('Could not launch $url');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.privacy_tip_outlined, color: Colors.white, size: 28),
|
||||
onPressed: _launchPrivacyPolicy,
|
||||
tooltip: 'Privacy Policy',
|
||||
splashRadius: 24,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:paladinvpn/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: const Color(0x22FFFFFF), // Subtle glass effect background
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: isConnected
|
||||
? const Color(0x8800E5FF) // Glowing cyan border when connected
|
||||
: const Color(0x33FFFFFF), // 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: Color(0xFF00E5FF),
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
] 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),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
vpn.statusMessage.toUpperCase(),
|
||||
style: TextStyle(
|
||||
color: isConnecting
|
||||
? const Color(0xFF00E5FF)
|
||||
: const Color(0xAA4A5568),
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:paladinvpn/logic/session_timer.dart';
|
||||
import 'package:paladinvpn/logic/ad_manager.dart';
|
||||
|
||||
/// WatchAdButton is a voluntary option allowing the user to watch a rewarded video ad
|
||||
/// and extend their active VPN tunnel session by 30 minutes as a reward.
|
||||
class WatchAdButton extends StatelessWidget {
|
||||
const WatchAdButton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () async {
|
||||
final success = await context.read<AdManager>().showAd('bonus_ad');
|
||||
if (success && context.mounted) {
|
||||
context.read<SessionTimer>().addBonusTime(); // Calls Hivemind API for 30m + 1GB
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text(
|
||||
'+30 minutes added!',
|
||||
style: TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
backgroundColor: const Color(0xFF006064),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0x1A00E5FF), Color(0x0D00E5FF)],
|
||||
),
|
||||
border: Border.all(
|
||||
color: const Color.fromRGBO(0, 229, 255, 0.25),
|
||||
),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('❤️', style: TextStyle(fontSize: 14)),
|
||||
SizedBox(width: 6),
|
||||
Text(
|
||||
'Support +30m',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF00E5FF),
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
||||
import 'package:paladinvpn/logic/crypto_service.dart';
|
||||
import 'package:paladinvpn/logic/hivemind_service.dart';
|
||||
import 'package:paladinvpn/logic/app_config.dart';
|
||||
|
||||
/// AdManager manages the lifecycle of rewarded video ads.
|
||||
///
|
||||
/// It acts as a strict gatekeeper: users MUST watch an ad to connect.
|
||||
/// If an ad is not ready when they click connect, it forces them to wait
|
||||
/// while it loads. If they dismiss the ad early, they do not get the connection.
|
||||
class AdManager extends ChangeNotifier {
|
||||
RewardedAd? _rewardedAd;
|
||||
|
||||
bool _isAdLoaded = false;
|
||||
bool get isAdLoaded => _isAdLoaded;
|
||||
|
||||
bool _isAdLoading = false;
|
||||
bool get isAdLoading => _isAdLoading;
|
||||
|
||||
Completer<bool>? _loadCompleter;
|
||||
|
||||
// Google-provided test ad unit
|
||||
static String get _adUnitId => AppConfig.adUnitId;
|
||||
|
||||
AdManager() {
|
||||
_preloadAd();
|
||||
}
|
||||
|
||||
/// Internal helper to load an ad and return a Future when it's ready
|
||||
Future<bool> _preloadAd() async {
|
||||
// If already loaded, return instantly
|
||||
if (_isAdLoaded) return true;
|
||||
|
||||
// If we're already loading one right now, wait for that same request to finish
|
||||
if (_isAdLoading) return _loadCompleter?.future ?? Future.value(false);
|
||||
|
||||
_isAdLoading = true;
|
||||
_loadCompleter = Completer<bool>();
|
||||
notifyListeners();
|
||||
|
||||
if (kIsWeb) {
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
_isAdLoaded = true;
|
||||
_isAdLoading = false;
|
||||
notifyListeners();
|
||||
_loadCompleter?.complete(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
RewardedAd.load(
|
||||
adUnitId: _adUnitId,
|
||||
request: const AdRequest(),
|
||||
rewardedAdLoadCallback: RewardedAdLoadCallback(
|
||||
onAdLoaded: (ad) {
|
||||
_rewardedAd = ad;
|
||||
_isAdLoaded = true;
|
||||
_isAdLoading = false;
|
||||
notifyListeners();
|
||||
if (!(_loadCompleter?.isCompleted ?? true)) {
|
||||
_loadCompleter?.complete(true);
|
||||
}
|
||||
},
|
||||
onAdFailedToLoad: (error) {
|
||||
debugPrint('Rewarded ad failed to load: ${error.message}');
|
||||
_isAdLoaded = false;
|
||||
_isAdLoading = false;
|
||||
notifyListeners();
|
||||
if (!(_loadCompleter?.isCompleted ?? true)) {
|
||||
_loadCompleter?.complete(false);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return _loadCompleter!.future;
|
||||
}
|
||||
|
||||
/// Displays the rewarded video ad to the user.
|
||||
Future<bool> showAd(String adType) async {
|
||||
// 1. Ensure the ad is fully loaded before trying to show it
|
||||
if (!_isAdLoaded || _rewardedAd == null) {
|
||||
final loaded = await _preloadAd();
|
||||
if (!loaded || _rewardedAd == null) {
|
||||
debugPrint('[AdManager] Cannot show ad, failed to load.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fetch device credentials for Server-Side Verification (SSV)
|
||||
final deviceId = await CryptoService.getDeviceId();
|
||||
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(
|
||||
customData: jsonEncode({
|
||||
'device_id': deviceId,
|
||||
'public_key': pubKey,
|
||||
'ad_type': adType,
|
||||
}),
|
||||
);
|
||||
|
||||
Completer<bool> rewardCompleter = Completer<bool>();
|
||||
|
||||
// 4. Setup full screen callbacks to handle dismissal and errors
|
||||
_rewardedAd!.fullScreenContentCallback = FullScreenContentCallback(
|
||||
onAdShowedFullScreenContent: (ad) => debugPrint('[AdManager] Ad showing.'),
|
||||
onAdDismissedFullScreenContent: (ad) {
|
||||
debugPrint('[AdManager] Ad dismissed.');
|
||||
ad.dispose();
|
||||
_isAdLoaded = false;
|
||||
_rewardedAd = null;
|
||||
// Start loading the next ad immediately
|
||||
_preloadAd();
|
||||
if (!rewardCompleter.isCompleted) {
|
||||
rewardCompleter.complete(false); // User closed before earning reward
|
||||
}
|
||||
},
|
||||
onAdFailedToShowFullScreenContent: (ad, error) {
|
||||
debugPrint('[AdManager] Ad failed to show: $error');
|
||||
ad.dispose();
|
||||
_isAdLoaded = false;
|
||||
_rewardedAd = null;
|
||||
if (!rewardCompleter.isCompleted) {
|
||||
rewardCompleter.complete(false);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 5. Show the ad
|
||||
await _rewardedAd!.show(
|
||||
onUserEarnedReward: (AdWithoutView ad, RewardItem reward) {
|
||||
debugPrint('[AdManager] Reward earned: ${reward.amount} ${reward.type}');
|
||||
// The SSV callback will hit our server independently. We just need to signal the UI
|
||||
// that the ad was successfully completed.
|
||||
if (!rewardCompleter.isCompleted) {
|
||||
rewardCompleter.complete(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return rewardCompleter.future;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_rewardedAd?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// =============================================================================
|
||||
// app_config.example.dart — TEMPLATE FOR SERVER SETTINGS
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Rename this file to `app_config.dart` and fill in your real values below.
|
||||
// Do not commit your real `app_config.dart` to GitHub!
|
||||
// =============================================================================
|
||||
|
||||
class AppConfig {
|
||||
// ── STEP 1: Paste your Hetzner server IP here ─────────────────────────────
|
||||
static const String serverIp = '127.0.0.1'; // REPLACE WITH REAL IP
|
||||
|
||||
// ── STEP 2: WireGuard UDP traffic port (default: 51820) ───────────────────
|
||||
static const String wgPort = '4433';
|
||||
|
||||
// ── STEP 3: AmneziaWG DPI Obfuscation Settings ───────────────────────────
|
||||
// These parameters must match exactly what your amnezia-wg-easy server expects.
|
||||
static const int awgJc = 4;
|
||||
static const int awgJmin = 20;
|
||||
static const int awgJmax = 100;
|
||||
static const int awgS1 = 15;
|
||||
static const int awgS2 = 23;
|
||||
static const int awgH1 = 1;
|
||||
static const int awgH2 = 2;
|
||||
static const int awgH3 = 3;
|
||||
static const int awgH4 = 4;
|
||||
|
||||
// ── STEP 4: AdMob Rewarded Video ID ──────────────────────────────────────
|
||||
static const String adUnitId = 'ca-app-pub-0000000000000000/0000000000'; // REPLACE WITH REAL AD UNIT
|
||||
|
||||
// ── STEP 5: Network & Tunnel Settings ────────────────────────────────────
|
||||
static const String dnsServers = '1.1.1.1, 1.0.0.1';
|
||||
static const String allowedIps = '0.0.0.0/0, ::/0';
|
||||
static const int mtu = 1280;
|
||||
static const int persistentKeepalive = 25;
|
||||
static const int hivemindPort = 5000;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Nothing below this line needs to be changed.
|
||||
// ── STEP 1: Hivemind Server API ──────────────────────────────────────────
|
||||
static String get hivemindApiBase => 'https://yourdomain.duckdns.org/api'; // REPLACE WITH REAL DOMAIN
|
||||
|
||||
/// The full server address passed to the WireGuard tunnel (IP:port).
|
||||
static String get serverEndpoint => '$serverIp:$wgPort';
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'dart:convert';
|
||||
import 'package:cryptography/cryptography.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
class CryptoService {
|
||||
static const String _privKeyPref = 'wg_private_key';
|
||||
static const String _pubKeyPref = 'wg_public_key';
|
||||
static const String _deviceIdPref = 'device_uuid';
|
||||
|
||||
static const _storage = FlutterSecureStorage();
|
||||
|
||||
// ── Get or create the persistent device UUID ──────────────────────────────
|
||||
static Future<String> getDeviceId() async {
|
||||
final existing = await _storage.read(key: _deviceIdPref);
|
||||
if (existing != null) return existing;
|
||||
|
||||
final newId = const Uuid().v4();
|
||||
await _storage.write(key: _deviceIdPref, value: newId);
|
||||
return newId;
|
||||
}
|
||||
|
||||
// ── Get or create the X25519 keypair for WireGuard ────────────────────────
|
||||
static Future<Map<String, String>> getOrCreateKeys() async {
|
||||
String? privBase64 = await _storage.read(key: _privKeyPref);
|
||||
String? pubBase64 = await _storage.read(key: _pubKeyPref);
|
||||
|
||||
if (privBase64 != null && pubBase64 != null) {
|
||||
return {'privateKey': privBase64, 'publicKey': pubBase64};
|
||||
}
|
||||
|
||||
final algorithm = X25519();
|
||||
final keyPair = await algorithm.newKeyPair();
|
||||
|
||||
final privateKeyBytes = await keyPair.extractPrivateKeyBytes();
|
||||
final publicKey = await keyPair.extractPublicKey();
|
||||
|
||||
privBase64 = base64Encode(privateKeyBytes);
|
||||
pubBase64 = base64Encode(publicKey.bytes);
|
||||
|
||||
await _storage.write(key: _privKeyPref, value: privBase64);
|
||||
await _storage.write(key: _pubKeyPref, value: pubBase64);
|
||||
|
||||
return {'privateKey': privBase64, 'publicKey': pubBase64};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:paladinvpn/logic/app_config.dart';
|
||||
import 'package:paladinvpn/logic/crypto_service.dart';
|
||||
|
||||
class HivemindService {
|
||||
/// Polls the Hivemind server until the session is ready or timeout occurs.
|
||||
/// Returns the WireGuard config string.
|
||||
static Future<String> fetchConfigWithPolling() async {
|
||||
final deviceId = await CryptoService.getDeviceId();
|
||||
final keys = await CryptoService.getOrCreateKeys();
|
||||
final privKey = keys['privateKey']!;
|
||||
|
||||
final url = Uri.parse('${AppConfig.hivemindApiBase}/session/status?device_id=$deviceId');
|
||||
|
||||
// ── TEMPORARY AD BYPASS HACK FOR TESTING ──
|
||||
// Since you disabled the crypto signature check on the Python server,
|
||||
// we can just send a fake "Ad Watched" ping from the app directly.
|
||||
try {
|
||||
final customData = jsonEncode({
|
||||
'device_id': deviceId,
|
||||
'public_key': keys['publicKey']!,
|
||||
'ad_type': 'main_ad',
|
||||
});
|
||||
final fakeAdmobPingUrl = Uri.parse(
|
||||
'${AppConfig.hivemindApiBase}/admob/callback?signature=test&key_id=test&custom_data=${Uri.encodeComponent(customData)}');
|
||||
await http.get(fakeAdmobPingUrl).timeout(const Duration(seconds: 2));
|
||||
} catch (_) {}
|
||||
// ──────────────────────────────────────────
|
||||
|
||||
int attempts = 0;
|
||||
while (attempts < 15) {
|
||||
try {
|
||||
final response = await http.get(url).timeout(const Duration(seconds: 3));
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
|
||||
// Debugging log to see exactly what the server returned
|
||||
print('[HivemindService] Polling response: ${response.body}');
|
||||
|
||||
if (data['active'] == true && data['client_ip'] != null && data['server_pubkey'] != null) {
|
||||
final clientIp = data['client_ip'];
|
||||
final serverPubkey = data['server_pubkey'];
|
||||
|
||||
// Construct the WireGuard config locally
|
||||
// I CHANGED IPS MAY BREAK STUFF
|
||||
final configText = '''
|
||||
[Interface]
|
||||
PrivateKey = $privKey
|
||||
Address = $clientIp/24
|
||||
DNS = ${AppConfig.dnsServers}
|
||||
MTU = ${AppConfig.mtu}
|
||||
Jc = ${AppConfig.awgJc}
|
||||
Jmin = ${AppConfig.awgJmin}
|
||||
Jmax = ${AppConfig.awgJmax}
|
||||
S1 = ${AppConfig.awgS1}
|
||||
S2 = ${AppConfig.awgS2}
|
||||
H1 = ${AppConfig.awgH1}
|
||||
H2 = ${AppConfig.awgH2}
|
||||
H3 = ${AppConfig.awgH3}
|
||||
H4 = ${AppConfig.awgH4}
|
||||
|
||||
[Peer]
|
||||
PublicKey = $serverPubkey
|
||||
AllowedIPs = ${AppConfig.allowedIps}
|
||||
Endpoint = ${AppConfig.serverEndpoint}
|
||||
PersistentKeepalive = ${AppConfig.persistentKeepalive}
|
||||
''';
|
||||
return configText;
|
||||
}
|
||||
} else {
|
||||
print('[HivemindService] Non-200 status: ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
print('[HivemindService] Polling exception: $e');
|
||||
// Ignore network errors and keep polling
|
||||
}
|
||||
|
||||
attempts++;
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
}
|
||||
|
||||
throw Exception("Session not activated. S2S callback failed or timed out.");
|
||||
}
|
||||
|
||||
/// Disconnects the session (cleans up on server)
|
||||
static Future<void> disconnectAndCleanup() async {
|
||||
final deviceId = await CryptoService.getDeviceId();
|
||||
final url = Uri.parse('${AppConfig.hivemindApiBase}/session/stop');
|
||||
try {
|
||||
await http.post(
|
||||
url,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'device_id': deviceId}),
|
||||
).timeout(const Duration(seconds: 3));
|
||||
} catch (_) {
|
||||
// Best effort cleanup
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:paladinvpn/logic/app_config.dart';
|
||||
import 'package:paladinvpn/logic/vpn_connection.dart';
|
||||
import 'package:paladinvpn/logic/crypto_service.dart';
|
||||
|
||||
/// SessionTimer acts as the client-side session authority for the VPN tunnel.
|
||||
///
|
||||
/// It connects to the Hivemind API on the server to sync the true remaining
|
||||
/// time, data quotas, speed, and throttling status.
|
||||
class SessionTimer extends ChangeNotifier {
|
||||
Timer? _timer;
|
||||
int _tickCount = 0;
|
||||
|
||||
final VpnConnection vpnConnection;
|
||||
|
||||
// ── Session State ──
|
||||
int _remainingSeconds = 0;
|
||||
int _quotaBytes = 0;
|
||||
int _usedBytes = 0;
|
||||
bool _isThrottled = false;
|
||||
|
||||
// ── Speed State ──
|
||||
int _lastUsedBytes = 0;
|
||||
double _currentSpeedKbps = 0.0; // Total speed (RX+TX)
|
||||
|
||||
SessionTimer({required this.vpnConnection});
|
||||
|
||||
int get remaining => _remainingSeconds;
|
||||
bool get isRunning => _timer != null && _timer!.isActive;
|
||||
bool get isExpired => _remainingSeconds <= 0 && !isRunning;
|
||||
|
||||
int get quotaBytes => _quotaBytes;
|
||||
int get usedBytes => _usedBytes;
|
||||
int get remainingBytes => _quotaBytes > _usedBytes ? _quotaBytes - _usedBytes : 0;
|
||||
bool get isThrottled => _isThrottled;
|
||||
double get currentSpeedKbps => _currentSpeedKbps;
|
||||
|
||||
String get formatted {
|
||||
final h = (_remainingSeconds ~/ 3600).toString().padLeft(2, '0');
|
||||
final m = ((_remainingSeconds % 3600) ~/ 60).toString().padLeft(2, '0');
|
||||
final s = (_remainingSeconds % 60).toString().padLeft(2, '0');
|
||||
return '$h:$m:$s';
|
||||
}
|
||||
|
||||
String get formattedDataRemaining {
|
||||
if (_quotaBytes == 0) return '0.00 GB';
|
||||
final gb = remainingBytes / (1024 * 1024 * 1024);
|
||||
return '${gb.toStringAsFixed(2)} GB';
|
||||
}
|
||||
|
||||
double get progress {
|
||||
// Max duration is generally 60 or 90 minutes. We'll base the ring purely on data usage or just standard time
|
||||
// For UI simplicity, we'll return data progress:
|
||||
if (_quotaBytes == 0) return 0.0;
|
||||
return (_quotaBytes - _usedBytes) / _quotaBytes;
|
||||
}
|
||||
|
||||
Future<void> start(String adType) async {
|
||||
_remainingSeconds = 3600; // optimistic start
|
||||
_quotaBytes = 2 * 1024 * 1024 * 1024;
|
||||
_usedBytes = 0;
|
||||
_lastUsedBytes = 0;
|
||||
_currentSpeedKbps = 0.0;
|
||||
_isThrottled = false;
|
||||
_tickCount = 0;
|
||||
|
||||
_timer?.cancel();
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), _tick);
|
||||
|
||||
// Kick off an immediate sync so the UI shows real data right away
|
||||
_syncWithHivemind();
|
||||
}
|
||||
|
||||
void _tick(Timer t) {
|
||||
if (_remainingSeconds > 0) {
|
||||
_remainingSeconds--;
|
||||
}
|
||||
|
||||
_tickCount++;
|
||||
// Poll the server every 3 seconds to get live stats
|
||||
if (_tickCount % 3 == 0) {
|
||||
_syncWithHivemind();
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _syncWithHivemind() async {
|
||||
try {
|
||||
final deviceId = await CryptoService.getDeviceId();
|
||||
final url = Uri.parse('${AppConfig.hivemindApiBase}/session/status?device_id=$deviceId');
|
||||
final response = await http.get(url).timeout(const Duration(seconds: 3));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
|
||||
final bool active = data['active'] ?? false;
|
||||
if (!active) {
|
||||
// Server killed the session!
|
||||
stop();
|
||||
vpnConnection.disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
_remainingSeconds = data['expires_in_seconds'] ?? _remainingSeconds;
|
||||
_quotaBytes = data['quota_bytes'] ?? _quotaBytes;
|
||||
_usedBytes = data['used_bytes'] ?? _usedBytes;
|
||||
_isThrottled = data['is_throttled'] ?? false;
|
||||
|
||||
// Calculate speed over the polling interval
|
||||
final int deltaBytes = _usedBytes - _lastUsedBytes;
|
||||
if (_lastUsedBytes > 0 && deltaBytes > 0) {
|
||||
_currentSpeedKbps = (deltaBytes / 3) / 1024;
|
||||
} else {
|
||||
_currentSpeedKbps = 0.0;
|
||||
}
|
||||
_lastUsedBytes = _usedBytes;
|
||||
|
||||
notifyListeners();
|
||||
} else if (response.statusCode == 404) {
|
||||
// Peer not found on server (expired/deleted natively)
|
||||
stop();
|
||||
vpnConnection.disconnect();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Hivemind sync error: $e');
|
||||
// If we can't reach the server, just let the local timer run
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> addBonusTime() async {
|
||||
final ip = vpnConnection.internalIp;
|
||||
if (ip != null) {
|
||||
try {
|
||||
final deviceId = await CryptoService.getDeviceId();
|
||||
await http.post(
|
||||
Uri.parse('${AppConfig.hivemindApiBase}/session/start'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'ip': ip,
|
||||
'device_id': deviceId,
|
||||
'ad_type': 'bonus_ad'
|
||||
}),
|
||||
).timeout(const Duration(seconds: 5));
|
||||
|
||||
// Immediately sync to fetch the updated limits and un-throttled status
|
||||
_syncWithHivemind();
|
||||
} catch (e) {
|
||||
debugPrint('Failed to add bonus time with Hivemind: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void stop() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_remainingSeconds = 0;
|
||||
_currentSpeedKbps = 0.0;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// =============================================================================
|
||||
// vpn_connection.dart — WireGuard Tunnel Manager
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// This file manages the WireGuard tunnel on the Android device.
|
||||
// It is the bridge between the UI (the big connect button) and the actual
|
||||
// VPN tunnel running inside the Android operating system.
|
||||
//
|
||||
// HOW IT WORKS:
|
||||
// 1. When connect() is called, it first asks WgApiService to fetch the config
|
||||
// file for this device from the server (see wg_api_service.dart).
|
||||
// 2. It passes that config text to the wireguard_flutter plugin, which handles
|
||||
// all the native Android VPN stuff automatically.
|
||||
// 3. It listens to the native tunnel state and updates the UI accordingly.
|
||||
//
|
||||
// You never need to edit this file.
|
||||
// Server settings → app_config.dart
|
||||
// Server communication → wg_api_service.dart
|
||||
// =============================================================================
|
||||
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:wireguard_flutter/wireguard_flutter.dart';
|
||||
import 'package:paladinvpn/logic/app_config.dart';
|
||||
import 'package:paladinvpn/logic/hivemind_service.dart';
|
||||
|
||||
// ── VPN States ────────────────────────────────────────────────────────────────
|
||||
// These are the possible states the tunnel can be in.
|
||||
// The UI uses these to decide what to display (spinner, shield icon, etc.)
|
||||
enum VpnStatus {
|
||||
disconnected, // No tunnel active — idle state
|
||||
connecting, // Fetching config from server + starting tunnel
|
||||
connected, // Tunnel is up and routing traffic
|
||||
disconnecting, // Tearing down the tunnel
|
||||
error, // Something went wrong (server unreachable, etc.)
|
||||
}
|
||||
|
||||
// ── VpnConnection ─────────────────────────────────────────────────────────────
|
||||
// This is a "ChangeNotifier" — it works like a Python observable/event emitter.
|
||||
// Whenever the state changes, it automatically notifies all widgets that are
|
||||
// listening, and they re-render themselves. You never call setState() manually.
|
||||
class VpnConnection extends ChangeNotifier {
|
||||
// Current tunnel state (starts disconnected)
|
||||
VpnStatus _status = VpnStatus.disconnected;
|
||||
VpnStatus get status => _status;
|
||||
|
||||
// Short message shown to the user under the connect button
|
||||
String _statusMessage = 'Tap to connect';
|
||||
String get statusMessage => _statusMessage;
|
||||
|
||||
// Optional error message shown if something goes wrong
|
||||
String? _errorMessage;
|
||||
String? get errorMessage => _errorMessage;
|
||||
|
||||
// The internal WireGuard IP assigned to this device (e.g. 10.8.0.2)
|
||||
// Populated after a successful config fetch and used by SessionTimer.
|
||||
String? internalIp;
|
||||
|
||||
// The wireguard_flutter plugin instance — this is what actually talks to Android
|
||||
final _wireguard = WireGuardFlutter.instance;
|
||||
|
||||
// Subscription to the native tunnel state stream
|
||||
StreamSubscription? _stageSub;
|
||||
|
||||
// ── Initialization ────────────────────────────────────────────────────────
|
||||
// Called automatically when the app starts (via Provider in main.dart)
|
||||
VpnConnection() {
|
||||
_init();
|
||||
}
|
||||
|
||||
Future<void> _init() async {
|
||||
// WireGuard doesn't work in a web browser — skip on web
|
||||
if (kIsWeb) return;
|
||||
|
||||
try {
|
||||
// Register our tunnel interface with Android.
|
||||
// 'Paladin0' is just the name of the network interface — like "eth0"
|
||||
await _wireguard.initialize(interfaceName: 'Paladin0');
|
||||
} catch (e) {
|
||||
debugPrint('WireGuard init error (expected on emulator): $e');
|
||||
}
|
||||
|
||||
// Start listening to native tunnel state changes from Android.
|
||||
// When Android reports the tunnel went up/down, _mapStage() is called.
|
||||
try {
|
||||
_stageSub = _wireguard.vpnStageSnapshot.listen(_mapStage);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ── Native State Mapping ──────────────────────────────────────────────────
|
||||
// Android sends back low-level states like "authenticating", "preparing", etc.
|
||||
// This function translates those into our simpler 5-state enum above.
|
||||
void _mapStage(VpnStage stage) {
|
||||
switch (stage) {
|
||||
case VpnStage.connected:
|
||||
_setStatus(VpnStatus.connected, 'Secured');
|
||||
case VpnStage.disconnected:
|
||||
_setStatus(VpnStatus.disconnected, 'Tap to connect');
|
||||
case VpnStage.connecting:
|
||||
case VpnStage.waitingConnection:
|
||||
case VpnStage.authenticating:
|
||||
case VpnStage.preparing:
|
||||
case VpnStage.reconnect:
|
||||
_setStatus(VpnStatus.connecting, 'Establishing tunnel…');
|
||||
case VpnStage.disconnecting:
|
||||
case VpnStage.exiting:
|
||||
_setStatus(VpnStatus.disconnecting, 'Tearing down…');
|
||||
default:
|
||||
_setStatus(VpnStatus.disconnected, 'Tap to connect');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Connect ───────────────────────────────────────────────────────────────
|
||||
// This is the main function — called after the user watches the rewarded ad.
|
||||
//
|
||||
// Step 1: Call the WG-Easy API to get the .conf for this device
|
||||
// Step 2: Pass that .conf to the WireGuard plugin to start the tunnel
|
||||
//
|
||||
// Returns true if the connection was successful, false if it failed.
|
||||
// The connect button uses this to decide whether to start the timer.
|
||||
Future<bool> connect() async {
|
||||
// Don't do anything if we're already connected or currently connecting
|
||||
if (_status == VpnStatus.connected || _status == VpnStatus.connecting) return false;
|
||||
|
||||
_setStatus(VpnStatus.connecting, 'Fetching config…');
|
||||
_errorMessage = null;
|
||||
|
||||
// ── Web / emulator fallback ────────────────────────────────────────────
|
||||
if (kIsWeb) {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
_setStatus(VpnStatus.connected, 'Secured (dev mode)');
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ── Step 2: Fetch config from Hivemind ──
|
||||
String configText;
|
||||
try {
|
||||
configText = await HivemindService.fetchConfigWithPolling();
|
||||
|
||||
// Parse the internal IP (e.g. "Address = 10.8.0.2/24" or "/32")
|
||||
final regex = RegExp(r'Address\s*=\s*([0-9\.]+)/');
|
||||
final match = regex.firstMatch(configText);
|
||||
if (match != null) {
|
||||
internalIp = match.group(1);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Hivemind API error: $e');
|
||||
// Set the exact error as the status message so the user can see it on screen
|
||||
String errorMsg = e.toString().replaceAll('Exception: ', '');
|
||||
if (errorMsg.length > 30) {
|
||||
errorMsg = '${errorMsg.substring(0, 30)}...'; // keep it somewhat short for the UI
|
||||
}
|
||||
_errorMessage = e.toString();
|
||||
_setStatus(VpnStatus.error, errorMsg);
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Step 2: Start the WireGuard tunnel with the fetched config ─────────
|
||||
try {
|
||||
// Re-initialize to ensure VPN permission is granted before starting
|
||||
await _wireguard.initialize(interfaceName: 'Paladin0');
|
||||
await _wireguard.startVpn(
|
||||
serverAddress: AppConfig.serverEndpoint,
|
||||
wgQuickConfig: configText,
|
||||
providerBundleIdentifier: 'com.paladinvpn.app.WireGuardProvider',
|
||||
);
|
||||
_setStatus(VpnStatus.connected, 'Secured');
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('WireGuard tunnel error: $e');
|
||||
_errorMessage = 'Tunnel failed to start.\nTry reconnecting.';
|
||||
_setStatus(VpnStatus.error, 'Tunnel error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Disconnect ────────────────────────────────────────────────────────────
|
||||
// Tears down the active tunnel and returns Android to normal networking.
|
||||
Future<void> disconnect() async {
|
||||
if (_status == VpnStatus.disconnected) return;
|
||||
|
||||
_setStatus(VpnStatus.disconnecting, 'Tearing down…');
|
||||
|
||||
if (kIsWeb) {
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
_setStatus(VpnStatus.disconnected, 'Tap to connect');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await _wireguard.stopVpn();
|
||||
} catch (e) {
|
||||
debugPrint('WireGuard stop error: $e');
|
||||
}
|
||||
|
||||
// Clean up the peer from the server so we don't leave dead connections lying around
|
||||
await HivemindService.disconnectAndCleanup();
|
||||
|
||||
_setStatus(VpnStatus.disconnected, 'Tap to connect');
|
||||
}
|
||||
|
||||
// ── Internal state updater ────────────────────────────────────────────────
|
||||
// Updates state and triggers a UI re-render (like calling setState() in Python/Qt)
|
||||
void _setStatus(VpnStatus s, String msg) {
|
||||
_status = s;
|
||||
_statusMessage = msg;
|
||||
notifyListeners(); // tells all listening widgets to rebuild
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stageSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:paladinvpn/logic/vpn_connection.dart';
|
||||
import 'package:paladinvpn/logic/session_timer.dart';
|
||||
import 'package:paladinvpn/screens/main_screen.dart';
|
||||
import 'package:paladinvpn/screens/intro.dart';
|
||||
|
||||
/// The entry point for the Paladin VPN application.
|
||||
void main() {
|
||||
// Ensure that the Flutter engine is fully initialized before using platform channels
|
||||
// (e.g., for SystemChrome or MobileAds).
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Lock the application to portrait orientation since the dashboard is a single-screen utility app
|
||||
SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.portraitUp,
|
||||
]);
|
||||
|
||||
// Configure a stealth system bar layout: transparent status bar with light icons,
|
||||
// blending seamlessly into the dark background gradient.
|
||||
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: Brightness.light,
|
||||
systemNavigationBarColor: Color(0xFF0D1117), // Deep space black
|
||||
systemNavigationBarIconBrightness: Brightness.light,
|
||||
));
|
||||
|
||||
// Inflate the root widget
|
||||
runApp(const PaladinApp());
|
||||
}
|
||||
|
||||
/// The root application widget wrapping the app in necessary state providers and themes.
|
||||
class PaladinApp extends StatelessWidget {
|
||||
const PaladinApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// MultiProvider injects global state controllers down the widget tree.
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
// Manages the WireGuard tunnel lifecycle
|
||||
ChangeNotifierProvider(create: (_) => VpnConnection()),
|
||||
|
||||
// Manages the 1-hour session clock.
|
||||
// Uses ProxyProvider to automatically inject the VpnConnection,
|
||||
// allowing the timer to auto-disconnect the VPN when time expires.
|
||||
ChangeNotifierProxyProvider<VpnConnection, SessionTimer>(
|
||||
create: (ctx) => SessionTimer(
|
||||
vpnConnection: ctx.read<VpnConnection>(),
|
||||
),
|
||||
update: (_, vpn, prev) =>
|
||||
prev ?? SessionTimer(vpnConnection: vpn),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
title: 'Paladin VPN',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: _buildDarkTheme(),
|
||||
home: const IntroScreen(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Constructs the global dark theme used across the application.
|
||||
ThemeData _buildDarkTheme() {
|
||||
const cyanAccent = Color(0xFF00E5FF);
|
||||
const bgDark = Color(0xFF0D1117);
|
||||
|
||||
return ThemeData(
|
||||
brightness: Brightness.dark,
|
||||
scaffoldBackgroundColor: bgDark,
|
||||
colorScheme: const ColorScheme.dark(
|
||||
primary: cyanAccent,
|
||||
secondary: cyanAccent,
|
||||
surface: Color(0xFF151C28),
|
||||
),
|
||||
fontFamily: 'Roboto',
|
||||
snackBarTheme: SnackBarThemeData(
|
||||
backgroundColor: const Color(0xFF1E2533),
|
||||
contentTextStyle: const TextStyle(color: Colors.white),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
useMaterial3: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:paladinvpn/screens/main_screen.dart';
|
||||
|
||||
class IntroScreen extends StatefulWidget {
|
||||
const IntroScreen({super.key});
|
||||
|
||||
@override
|
||||
State<IntroScreen> createState() => _IntroScreenState();
|
||||
}
|
||||
|
||||
class _IntroScreenState extends State<IntroScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Wait for 2.5 seconds then smoothly transition to the main screen
|
||||
Timer(const Duration(milliseconds: 2500), () {
|
||||
if (mounted) {
|
||||
Navigator.of(context).pushReplacement(
|
||||
PageRouteBuilder(
|
||||
transitionDuration: const Duration(milliseconds: 800),
|
||||
pageBuilder: (_, __, ___) => const MainScreen(),
|
||||
transitionsBuilder: (_, animation, __, child) {
|
||||
return FadeTransition(opacity: animation, child: child);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF0D1117), // Deep space black background
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// App Logo
|
||||
Image.asset(
|
||||
'assets/logo.png',
|
||||
width: 140,
|
||||
height: 140,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
// Fallback icon if logo.png is missing or misconfigured
|
||||
return const Icon(
|
||||
Icons.shield,
|
||||
size: 140,
|
||||
color: Color(0xFF00E5FF),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
|
||||
// PaladinVPN Text
|
||||
const Text(
|
||||
'PALADIN VPN',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 2.0,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 60),
|
||||
|
||||
// Loading Circle
|
||||
const SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: CircularProgressIndicator(
|
||||
color: Color(0xFF00E5FF),
|
||||
strokeWidth: 3.0,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:paladinvpn/components/header.dart';
|
||||
import 'package:paladinvpn/components/status_text.dart';
|
||||
import 'package:paladinvpn/components/connect_button.dart';
|
||||
import 'package:paladinvpn/components/bottom_bar.dart';
|
||||
import 'package:paladinvpn/components/info_button.dart';
|
||||
import 'package:paladinvpn/components/privacy_policy_button.dart';
|
||||
|
||||
/// MainScreen serves as the primary home screen for Paladin 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});
|
||||
|
||||
// Background base color
|
||||
static const Color _bgTop = Color(0xFF0D1117);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: _bgTop,
|
||||
body: Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
decoration: const BoxDecoration(
|
||||
color: _bgTop,
|
||||
image: DecorationImage(
|
||||
image: AssetImage('assets/background.png'),
|
||||
fit: BoxFit.cover,
|
||||
colorFilter: ColorFilter.mode(
|
||||
Color(0xAA0D1117), // Deep dark tint overlay to guarantee readability of overlay elements
|
||||
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,
|
||||
right: 4,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
PrivacyPolicyButton(),
|
||||
InfoButton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user