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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user