import 'dart:async'; import 'package:flutter/material.dart'; import 'package:stacked/stacked.dart'; import '../../about/external/data/Health.dart'; import '../../about/internal/application/ServerProfile.dart'; import '../../about/internal/application/ServiceConfig.dart'; import '../../about/internal/application/TextType.dart'; import '../../designs/StatCard.dart'; import '../../designs/text/Text.dart'; import '../../utils/Colors.dart'; import 'ConnectHealth.dart'; import 'Health.dart'; import 'ViewHealth.dart'; class HealthState extends State implements ConnectHealth { ViewHealth? _model; final Map _results = {}; bool _running = false; bool _auto = false; Timer? _timer; List get _servers => widget.servers; int get _totalServices => _servers.fold(0, (n, s) => n + s.services.length); @override void dispose() { _timer?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { return ViewModelBuilder.reactive( viewModelBuilder: () => ViewHealth(context, this), onViewModelReady: _onReady, builder: (context, viewModel, child) => Scaffold( appBar: AppBar( title: text('Health', 20, TextType.Bold, color: context.jp.text), actions: [ Row( children: [ text('Auto', 13, TextType.Regular, color: context.jp.textDim), Switch(value: _auto, onChanged: _onToggleAuto), ], ), IconButton( tooltip: 'Run checks', onPressed: _running ? null : _onRunChecks, icon: _running ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.refresh), ), const SizedBox(width: 4), ], ), body: ListView( padding: const EdgeInsets.fromLTRB(16, 16, 16, 40), children: [ _statRow(), const SizedBox(height: 20), if (_totalServices == 0) _empty() else for (final server in _servers) if (server.services.isNotEmpty) _serverGroup(server), ], ), ), ); } void _onReady(ViewHealth model) { _model = model; // onViewModelReady fires mid-build; a synchronous setState from the // first load would dirty this State while it is still being built. WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _initiate(); }); } Future _initiate() async { _model?.runSweep(_servers); } int _count(HealthStatus s) => _results.values.where((r) => r.status == s).length; // ── named handlers ───────────────────────────────────────────────────────── void _onRunChecks() => _model?.runSweep(_servers); void _onToggleAuto(bool v) { setState(() => _auto = v); _timer?.cancel(); if (v) { _timer = Timer.periodic( const Duration(seconds: 30), (_) => _model?.runSweep(_servers), ); } } // ── ConnectHealth ────────────────────────────────────────────────────────── @override void onSweepStarted() { if (!mounted) return; setState(() { _running = true; for (final server in _servers) { for (final service in server.services) { _results[service.id] = HealthResult.checking(); } } }); } @override void onServiceChecked(String serviceId, HealthResult result) { if (!mounted) return; setState(() => _results[serviceId] = result); } @override void onSweepFinished() { if (!mounted) return; setState(() => _running = false); } // ── screen-only widgets ──────────────────────────────────────────────────── Widget _statRow() { return LayoutBuilder( builder: (context, c) { const gap = 12.0; final cols = c.maxWidth >= 640 ? 3 : 1; final w = (c.maxWidth - gap * (cols - 1)) / cols; final cards = [ StatCard( icon: Icons.favorite, label: 'Healthy', value: '${_count(HealthStatus.healthy)}', color: PeachTheme.seed, ), StatCard( icon: Icons.heart_broken, label: 'Unhealthy', value: '${_count(HealthStatus.unhealthy)}', color: PeachTheme.danger, ), StatCard( icon: Icons.dns, label: 'Services', value: '$_totalServices', color: PeachTheme.accent, ), ]; return Wrap( spacing: gap, runSpacing: gap, children: [for (final card in cards) SizedBox(width: w, child: card)], ); }, ); } Widget _empty() => Container( padding: const EdgeInsets.symmetric(vertical: 40), alignment: Alignment.center, decoration: PeachTheme.panel(context), child: text( 'No services to monitor yet.', 14, TextType.Regular, color: context.jp.textDim, ), ); Widget _serverGroup(ServerProfile server) { final jp = context.jp; return Padding( padding: const EdgeInsets.only(bottom: 18), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ const Icon(Icons.dns, size: 15, color: PeachTheme.seed), const SizedBox(width: 8), text(server.name, 15, TextType.Bold, color: jp.text), const SizedBox(width: 8), text(server.host, 12, TextType.Regular, color: jp.textFaint), ], ), const SizedBox(height: 10), Container( decoration: PeachTheme.panel(context, radius: 16), child: Column( children: [ for (var i = 0; i < server.services.length; i++) ...[ if (i > 0) Divider(height: 1, color: jp.border), _serviceRow(server.services[i]), ], ], ), ), ], ), ); } Widget _serviceRow(ServiceConfig svc) { final jp = context.jp; final result = _results[svc.id]; final (color, icon, label) = _style(result?.status ?? HealthStatus.unknown); return Padding( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), child: Row( children: [ Icon( svc.isWeb ? Icons.language : Icons.settings_suggest, size: 18, color: jp.textDim, ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ text( svc.name, 14, TextType.Regular, color: jp.text, maxLines: 1, overflow: TextOverflow.ellipsis, ), text( '${svc.healthType.label}' '${result != null ? ' · ${result.detail}' : ''}', 11.5, TextType.Light, color: jp.textFaint, maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), ), const SizedBox(width: 10), if (result?.status == HealthStatus.checking) const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) else Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( color: color.withValues(alpha: 0.16), borderRadius: BorderRadius.circular(20), border: Border.all(color: color.withValues(alpha: 0.45)), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 12, color: color), const SizedBox(width: 5), text(label, 11, TextType.Bold, color: color), ], ), ), ], ), ); } (Color, IconData, String) _style(HealthStatus s) => switch (s) { HealthStatus.healthy => (PeachTheme.seed, Icons.check_circle, 'HEALTHY'), HealthStatus.unhealthy => (PeachTheme.danger, Icons.error, 'DOWN'), HealthStatus.disabled => (context.jp.textFaint, Icons.remove_circle, 'OFF'), HealthStatus.checking => (PeachTheme.accent, Icons.sync, 'CHECK'), HealthStatus.unknown => (PeachTheme.warn, Icons.help, 'UNKNOWN'), }; }