refactor(app): remove unsupported runtime and UI state
This commit is contained in:
1 parent
9ac236a981
commit
0ed7bf27b5
19 files changed
+173
-381
No files matched your search
@@ -19,6 +19,7 @@ class _ConnectButtonState extends State<ConnectButton>
|
||||
DateTime _lastTap = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
AnimationController? _pulse;
|
||||
Animation<double>? _pulseAnim;
|
||||
VpnConnection? _vpn;
|
||||
|
||||
double get _pulseValue => _pulseAnim?.value ?? 0.0;
|
||||
|
||||
@@ -35,20 +36,25 @@ class _ConnectButtonState extends State<ConnectButton>
|
||||
);
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) context.read<VpnConnection>().addListener(_onVpnChanged);
|
||||
if (!mounted || _vpn != null) return;
|
||||
final vpn = context.read<VpnConnection>();
|
||||
_vpn = vpn;
|
||||
vpn.addListener(_onVpnChanged);
|
||||
_onVpnChanged();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
context.read<VpnConnection>().removeListener(_onVpnChanged);
|
||||
_vpn?.removeListener(_onVpnChanged);
|
||||
_pulse?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onVpnChanged() {
|
||||
final vpn = context.read<VpnConnection>();
|
||||
final vpn = _vpn;
|
||||
if (vpn == null) return;
|
||||
if (vpn.status == VpnStatus.connected) {
|
||||
_pulse?.repeat(reverse: true);
|
||||
} else {
|
||||
@@ -60,14 +66,12 @@ class _ConnectButtonState extends State<ConnectButton>
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (!mounted) return;
|
||||
if (state == AppLifecycleState.paused ||
|
||||
state == AppLifecycleState.detached) {
|
||||
if (state != AppLifecycleState.resumed) {
|
||||
_pulse?.stop();
|
||||
} else if (state == AppLifecycleState.resumed) {
|
||||
final vpn = context.read<VpnConnection>();
|
||||
if (vpn.status == VpnStatus.connected) {
|
||||
_pulse?.repeat(reverse: true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (_vpn?.status == VpnStatus.connected) {
|
||||
_pulse?.repeat(reverse: true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,23 +19,28 @@ class DataDisclosureDialog {
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
await showDialog(
|
||||
final accepted = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false, // Must tap Continue — no tap-outside dismiss.
|
||||
builder: (_) => _DisclosureDialog(
|
||||
onContinue: () {
|
||||
prefs.setBool(_key, true);
|
||||
Navigator.of(context).pop(); // Dismiss the dialog.
|
||||
},
|
||||
),
|
||||
builder: (_) => const _DisclosureDialog(),
|
||||
);
|
||||
if (accepted != true) return;
|
||||
|
||||
// Persist only after the user action is complete. If storage fails, do not
|
||||
// invent an accepted state; the disclosure will simply reappear later.
|
||||
try {
|
||||
final saved = await prefs.setBool(_key, true);
|
||||
if (!saved) {
|
||||
debugPrint('[Disclosure] Preference write was rejected');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[Disclosure] Failed to persist acknowledgement: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _DisclosureDialog extends StatelessWidget {
|
||||
final VoidCallback onContinue;
|
||||
|
||||
const _DisclosureDialog({required this.onContinue});
|
||||
const _DisclosureDialog();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -49,7 +54,6 @@ class _DisclosureDialog extends StatelessWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Title
|
||||
const Text(
|
||||
'Before you connect',
|
||||
style: TextStyle(
|
||||
@@ -59,8 +63,6 @@ class _DisclosureDialog extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Body
|
||||
const Text(
|
||||
'Revolt VPN creates a secure VPN tunnel to protect your '
|
||||
'traffic. To enforce your data/time quota, we log your '
|
||||
@@ -76,12 +78,10 @@ class _DisclosureDialog extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Continue button — full-width, accent
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: onContinue,
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.accent,
|
||||
foregroundColor: AppColors.bgDeep,
|
||||
|
||||
@@ -5,13 +5,9 @@ import 'package:revoltvpn/logic/app_colors.dart';
|
||||
import 'package:revoltvpn/screens/settings/in_settings/lightning.dart';
|
||||
|
||||
/// 2D lightning bolt — a jagged yellow line from top to bottom with glow.
|
||||
|
||||
class LightningEffect extends StatefulWidget {
|
||||
|
||||
final int minIntervalSec;
|
||||
|
||||
final int maxIntervalSec;
|
||||
|
||||
final double doubleChance;
|
||||
|
||||
const LightningEffect({
|
||||
@@ -26,26 +22,49 @@ class LightningEffect extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _LightningEffectState extends State<LightningEffect>
|
||||
with SingleTickerProviderStateMixin {
|
||||
with SingleTickerProviderStateMixin, WidgetsBindingObserver {
|
||||
late final AnimationController _ctrl;
|
||||
Timer? _timer;
|
||||
final _random = Random();
|
||||
bool _appActive = true;
|
||||
|
||||
LightningBolt? _bolt;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 400));
|
||||
_ctrl = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 400),
|
||||
);
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
final lifecycle = WidgetsBinding.instance.lifecycleState;
|
||||
_appActive = lifecycle == null || lifecycle == AppLifecycleState.resumed;
|
||||
lightningEnabled.addListener(_onEnabledChanged);
|
||||
if (_appActive && lightningEnabled.value) _scheduleNext();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
_appActive = state == AppLifecycleState.resumed;
|
||||
if (!_appActive) {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_ctrl.stop();
|
||||
_bolt = null;
|
||||
if (mounted) setState(() {});
|
||||
return;
|
||||
}
|
||||
|
||||
if (lightningEnabled.value) _scheduleNext();
|
||||
}
|
||||
|
||||
void _onEnabledChanged() {
|
||||
if (lightningEnabled.value) {
|
||||
if (lightningEnabled.value && _appActive) {
|
||||
_scheduleNext();
|
||||
} else {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_ctrl.stop();
|
||||
_bolt = null;
|
||||
}
|
||||
@@ -54,6 +73,7 @@ class _LightningEffectState extends State<LightningEffect>
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
lightningEnabled.removeListener(_onEnabledChanged);
|
||||
_timer?.cancel();
|
||||
_ctrl.dispose();
|
||||
@@ -61,19 +81,23 @@ class _LightningEffectState extends State<LightningEffect>
|
||||
}
|
||||
|
||||
void _scheduleNext() {
|
||||
_timer?.cancel();
|
||||
if (!mounted || !_appActive || !lightningEnabled.value) return;
|
||||
|
||||
final secs = widget.minIntervalSec +
|
||||
_random.nextInt(widget.maxIntervalSec - widget.minIntervalSec + 1);
|
||||
_timer = Timer(Duration(seconds: secs), _strike);
|
||||
}
|
||||
|
||||
void _strike() {
|
||||
if (!mounted || !lightningEnabled.value) return;
|
||||
_timer = null;
|
||||
if (!mounted || !_appActive || !lightningEnabled.value) return;
|
||||
_bolt = LightningBolt.random(_random);
|
||||
_ctrl.forward(from: 0.0);
|
||||
|
||||
if (_random.nextDouble() < widget.doubleChance) {
|
||||
Future.delayed(const Duration(milliseconds: 120), () {
|
||||
if (mounted && lightningEnabled.value) {
|
||||
if (mounted && _appActive && lightningEnabled.value) {
|
||||
_bolt = LightningBolt.random(_random);
|
||||
_ctrl.forward(from: 0.0);
|
||||
}
|
||||
@@ -83,10 +107,10 @@ class _LightningEffectState extends State<LightningEffect>
|
||||
_scheduleNext();
|
||||
}
|
||||
|
||||
/// Sharp flash curve: quick on, quick off. No slow fade.
|
||||
/// Sharp flash curve: quick on, quick off. No slow fade.
|
||||
double _opacity(double t) {
|
||||
if (t < 0.08) return t / 0.08; // fast ramp
|
||||
return 1.0 - ((t - 0.08) / 0.92); // linear decay
|
||||
if (t < 0.08) return t / 0.08;
|
||||
return 1.0 - ((t - 0.08) / 0.92);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -117,37 +141,31 @@ class _LightningEffectState extends State<LightningEffect>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// A single bolt defined by a list of 2D points (0.0–1.0 in width×height space).
|
||||
class LightningBolt {
|
||||
final List<Offset> mainPath;
|
||||
final List<List<Offset>> branches; // 0–2 forks off the main path
|
||||
final List<List<Offset>> branches;
|
||||
|
||||
const LightningBolt({required this.mainPath, this.branches = const []});
|
||||
|
||||
/// Generate a random bolt. The path always starts near the top-center
|
||||
/// Generate a random bolt. The path always starts near the top-center
|
||||
/// and ends near the bottom, zigzagging left/right along the way.
|
||||
factory LightningBolt.random(Random rng) {
|
||||
final points = <Offset>[];
|
||||
|
||||
// Start: top, slight horizontal offset
|
||||
double x = 0.5 + (rng.nextDouble() - 0.5) * 0.15;
|
||||
points.add(Offset(x, 0.0));
|
||||
|
||||
// Zigzag down — 6–10 segments
|
||||
final segments = 6 + rng.nextInt(5);
|
||||
for (int i = 1; i < segments; i++) {
|
||||
final y = i / segments;
|
||||
// Random horizontal offset — each segment adds jitter
|
||||
x += (rng.nextDouble() - 0.5) * 0.25;
|
||||
x = x.clamp(0.05, 0.95); // stay on screen
|
||||
x = x.clamp(0.05, 0.95);
|
||||
points.add(Offset(x, y));
|
||||
}
|
||||
|
||||
// End: bottom
|
||||
points.add(Offset(x.clamp(0.1, 0.9), 1.0));
|
||||
|
||||
// Branches — 0–2 forks from random points along the main path
|
||||
final branches = <List<Offset>>[];
|
||||
final branchCount = rng.nextInt(3);
|
||||
for (int b = 0; b < branchCount; b++) {
|
||||
@@ -160,7 +178,7 @@ class LightningBolt {
|
||||
double by = start.dy;
|
||||
final branchLen = 2 + rng.nextInt(3);
|
||||
for (int j = 0; j < branchLen; j++) {
|
||||
by += (0.05 + rng.nextDouble() * 0.12);
|
||||
by += 0.05 + rng.nextDouble() * 0.12;
|
||||
bx += (rng.nextDouble() - 0.5) * 0.3;
|
||||
bx = bx.clamp(0.02, 0.98);
|
||||
by = by.clamp(0.0, 1.0);
|
||||
@@ -173,7 +191,6 @@ class LightningBolt {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _LightningPainter extends CustomPainter {
|
||||
final LightningBolt bolt;
|
||||
final double opacity;
|
||||
@@ -182,7 +199,8 @@ class _LightningPainter extends CustomPainter {
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final toScreen = (Offset p) => Offset(p.dx * size.width, p.dy * size.height);
|
||||
Offset toScreen(Offset p) => Offset(p.dx * size.width, p.dy * size.height);
|
||||
|
||||
final scaledMain = bolt.mainPath.map(toScreen).toList();
|
||||
final scaledBranches = bolt.branches
|
||||
.map((b) => b.map(toScreen).toList())
|
||||
@@ -191,7 +209,6 @@ class _LightningPainter extends CustomPainter {
|
||||
final baseColor = AppColors.accent.withAlpha((opacity * 255).round());
|
||||
final glowColor = AppColors.accent.withAlpha((opacity * 0.35 * 255).round());
|
||||
|
||||
// Glow
|
||||
final glowPaint = Paint()
|
||||
..color = glowColor
|
||||
..strokeWidth = 18
|
||||
@@ -202,7 +219,6 @@ class _LightningPainter extends CustomPainter {
|
||||
|
||||
_drawPath(canvas, scaledMain, scaledBranches, glowPaint);
|
||||
|
||||
// Core
|
||||
final corePaint = Paint()
|
||||
..color = baseColor
|
||||
..strokeWidth = 5
|
||||
@@ -212,7 +228,6 @@ class _LightningPainter extends CustomPainter {
|
||||
|
||||
_drawPath(canvas, scaledMain, scaledBranches, corePaint);
|
||||
|
||||
// Hot center
|
||||
if (opacity > 0.6) {
|
||||
final hotPaint = Paint()
|
||||
..color = Colors.white.withAlpha((opacity * 180).round())
|
||||
@@ -225,8 +240,12 @@ class _LightningPainter extends CustomPainter {
|
||||
}
|
||||
}
|
||||
|
||||
void _drawPath(Canvas canvas, List<Offset> main,
|
||||
List<List<Offset>> branches, Paint paint) {
|
||||
void _drawPath(
|
||||
Canvas canvas,
|
||||
List<Offset> main,
|
||||
List<List<Offset>> branches,
|
||||
Paint paint,
|
||||
) {
|
||||
final mainPath = Path()..moveTo(main.first.dx, main.first.dy);
|
||||
for (int i = 1; i < main.length; i++) {
|
||||
mainPath.lineTo(main[i].dx, main[i].dy);
|
||||
|
||||
@@ -14,8 +14,8 @@ class DiscordLinkButton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.discord, color: AppColors.textWhite, size: 28),
|
||||
return const IconButton(
|
||||
icon: Icon(Icons.discord, color: AppColors.textWhite, size: 28),
|
||||
onPressed: launchDiscord,
|
||||
tooltip: 'Join Discord',
|
||||
splashRadius: 24,
|
||||
|
||||
@@ -76,8 +76,8 @@ class GdprButton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.admin_panel_settings_outlined,
|
||||
return const IconButton(
|
||||
icon: Icon(Icons.admin_panel_settings_outlined,
|
||||
color: AppColors.textWhite, size: 28),
|
||||
onPressed: showConsentForm,
|
||||
tooltip: 'Ad consent & privacy choices',
|
||||
|
||||
@@ -14,8 +14,8 @@ class InfoButton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.help_outline, color: AppColors.textWhite, size: 28),
|
||||
return const IconButton(
|
||||
icon: Icon(Icons.help_outline, color: AppColors.textWhite, size: 28),
|
||||
onPressed: launchGitHub,
|
||||
tooltip: 'About ReVoltVPN',
|
||||
splashRadius: 24,
|
||||
|
||||
@@ -6,7 +6,9 @@ class PrivacyPolicyButton extends StatelessWidget {
|
||||
const PrivacyPolicyButton({super.key});
|
||||
|
||||
static Future<void> launchPrivacyPolicy() async {
|
||||
final Uri url = Uri.parse('https://github.com/esefxdz/ReVoltVPN/blob/main/PRIVACY_POLICY.md');
|
||||
final Uri url = Uri.parse(
|
||||
'https://github.com/esefxdz/ReVoltVPN/blob/main/PRIVACY_POLICY.md',
|
||||
);
|
||||
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
|
||||
debugPrint('Could not launch $url');
|
||||
}
|
||||
@@ -14,8 +16,12 @@ class PrivacyPolicyButton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.privacy_tip_outlined, color: AppColors.textWhite, size: 28),
|
||||
return const IconButton(
|
||||
icon: Icon(
|
||||
Icons.privacy_tip_outlined,
|
||||
color: AppColors.textWhite,
|
||||
size: 28,
|
||||
),
|
||||
onPressed: launchPrivacyPolicy,
|
||||
tooltip: 'Privacy Policy',
|
||||
splashRadius: 24,
|
||||
|
||||
@@ -14,8 +14,8 @@ class WebsiteButton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.language, color: AppColors.textWhite, size: 28),
|
||||
return const IconButton(
|
||||
icon: Icon(Icons.language, color: AppColors.textWhite, size: 28),
|
||||
onPressed: launchWebsite,
|
||||
tooltip: 'Website',
|
||||
splashRadius: 24,
|
||||
|
||||
@@ -1,91 +1,58 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:revoltvpn/components/online_dot.dart';
|
||||
import 'package:revoltvpn/logic/app_colors.dart';
|
||||
import 'package:revoltvpn/logic/vpn_connection.dart';
|
||||
import 'package:revoltvpn/logic/session_timer.dart';
|
||||
import 'package:revoltvpn/logic/server_list.dart';
|
||||
import 'package:revoltvpn/components/online_dot.dart';
|
||||
import 'package:revoltvpn/screens/server_selector/selection_screen.dart';
|
||||
|
||||
class StatusBar extends StatelessWidget {
|
||||
const StatusBar({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer2<VpnConnection, SessionTimer>(
|
||||
builder: (context, vpn, timer, _) {
|
||||
return Consumer<VpnConnection>(
|
||||
builder: (context, vpn, _) {
|
||||
final isConnected = vpn.status == VpnStatus.connected;
|
||||
final isConnecting = vpn.status == VpnStatus.connecting;
|
||||
final serverHealthy =
|
||||
vpn.serverReachable || (isConnected && timer.hasSyncedOnce);
|
||||
final sessionOrTunnelHealthy = timer.hasSyncedOnce || vpn.serverReachable;
|
||||
|
||||
final statusLabel = isConnected
|
||||
? (sessionOrTunnelHealthy ? 'Online' : 'Syncing…')
|
||||
: (isConnecting
|
||||
? 'Connecting…'
|
||||
: (vpn.serverReachable ? 'Ready' : 'Offline'));
|
||||
|
||||
final server = context.watch<ServerList>().selected;
|
||||
final statusLabel = switch (vpn.status) {
|
||||
VpnStatus.connected => 'Connected',
|
||||
VpnStatus.connecting => 'Connecting…',
|
||||
VpnStatus.disconnecting => 'Disconnecting…',
|
||||
VpnStatus.error => 'Error',
|
||||
VpnStatus.disconnected => vpn.serverReachable ? 'Ready' : 'Offline',
|
||||
};
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
PageRouteBuilder(
|
||||
transitionDuration: const Duration(milliseconds: 300),
|
||||
pageBuilder: (_, __, ___) => const SelectionScreen(),
|
||||
transitionsBuilder: (_, animation, __, child) {
|
||||
return SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(1.0, 0.0),
|
||||
end: Offset.zero,
|
||||
).animate(
|
||||
CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: Curves.easeOut,
|
||||
),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.bgCard,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.slate15),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/finland_flag_256.png',
|
||||
width: 20,
|
||||
height: 20,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.bgCard,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.slate15),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Image.asset(server.flagAsset, width: 20, height: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
server.name,
|
||||
style: const TextStyle(
|
||||
color: AppColors.accent,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Helsinki, Finland',
|
||||
style: TextStyle(
|
||||
color: AppColors.accent,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.chevron_right,
|
||||
color: AppColors.textDim,
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@@ -100,7 +67,7 @@ class StatusBar extends StatelessWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
OnlineDot(
|
||||
isOnline: serverHealthy,
|
||||
isOnline: isConnected,
|
||||
label: statusLabel,
|
||||
),
|
||||
const Spacer(),
|
||||
|
||||
Reference in new issue
Block a user