import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:url_launcher/url_launcher.dart'; import '../models.dart'; import '../services/api_service.dart'; /// Détail d'un serveur : bannière (image importée ou thème), statut, connexion, /// stats d'utilisation (CPU/RAM/réseau) et gestion de l'image (import/suppression). class ServerDetailScreen extends StatefulWidget { const ServerDetailScreen({super.key, required this.server, required this.api}); final GameServer server; final ApiService api; @override State createState() => _ServerDetailScreenState(); } class _ServerDetailScreenState extends State { GameServer get s => _server; late GameServer _server = widget.server; Map? _stats; Uint8List? _image; bool _busy = false; bool _imgBusy = false; @override void initState() { super.initState(); _load(); } Future _load() async { final results = await Future.wait([ widget.api.stats(s.id).catchError((_) => {'running': false}), widget.api.imageBytes(s.id).catchError((_) => null), widget.api.server(s.id).catchError((_) => _server), ]); if (!mounted) return; setState(() { _stats = results[0] as Map; _image = results[1] as Uint8List?; _server = results[2] as GameServer; }); } Future _toggle() async { setState(() => _busy = true); try { s.isUp ? await widget.api.stop(s.id) : await widget.api.start(s.id); await Future.delayed(const Duration(seconds: 2)); await _load(); } catch (e) { _snack('$e'); } finally { if (mounted) setState(() => _busy = false); } } Future _pickImage() async { final x = await ImagePicker().pickImage(source: ImageSource.gallery, maxWidth: 1600); if (x == null) return; setState(() => _imgBusy = true); try { await widget.api.uploadImage(s.id, x.path); final bytes = await widget.api.imageBytes(s.id); if (mounted) setState(() => _image = bytes); } catch (e) { _snack('$e'); } finally { if (mounted) setState(() => _imgBusy = false); } } Future _deleteImage() async { setState(() => _imgBusy = true); try { await widget.api.deleteImage(s.id); if (mounted) setState(() => _image = null); } catch (e) { _snack('$e'); } finally { if (mounted) setState(() => _imgBusy = false); } } Future _confirmDelete() async { final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Supprimer ce serveur ?'), content: Text('« ${s.name} » sera retiré de l\'app (le conteneur Docker n\'est pas touché).'), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Annuler')), FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('Supprimer')), ], ), ); if (ok != true) return; try { await widget.api.deleteServer(s.id); if (mounted) Navigator.pop(context); } catch (e) { _snack('$e'); } } void _snack(String m) { if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(m))); } @override Widget build(BuildContext context) { final t = s.theme; final badge = s.statusBadge; return Scaffold( appBar: AppBar( title: Text(s.name), actions: [ if (s.custom) IconButton( tooltip: 'Supprimer', icon: const Icon(Icons.delete_outline), onPressed: _confirmDelete, ), ], ), body: RefreshIndicator( onRefresh: _load, child: ListView( children: [ _banner(t), Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ _Pill(label: badge.label, color: badge.color), const SizedBox(width: 8), _Pill(label: s.isPreprod ? 'Preprod' : 'Production', color: s.isPreprod ? const Color(0xFFFF9F0A) : const Color(0xFF34C759)), const Spacer(), if (s.web && s.openUrl != null) OutlinedButton.icon( onPressed: () => launchUrl(Uri.parse(s.openUrl!), mode: LaunchMode.externalApplication), icon: const Icon(Icons.open_in_new, size: 18), label: const Text('Ouvrir'), ), ], ), const SizedBox(height: 16), _toggleButton(), const SizedBox(height: 20), if (s.connect != null) _info(Icons.link, 'Connexion', s.connect!), if (s.status == 'online') _info(Icons.people, 'Joueurs', '${s.players ?? 0} / ${s.maxPlayers ?? 0}'), if (s.motd != null) _info(Icons.chat_bubble_outline, 'MOTD', s.motd!), const SizedBox(height: 20), _statsCard(), const SizedBox(height: 20), _imageCard(), ], ), ), ], ), ), ); } Widget _banner(({Color start, Color end, IconData icon}) t) { return SizedBox( height: 190, width: double.infinity, child: _image != null ? Image.memory(_image!, fit: BoxFit.cover) : DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [t.start, t.end]), ), child: Center(child: Icon(t.icon, size: 72, color: Colors.white70)), ), ); } Widget _toggleButton() { return SizedBox( width: double.infinity, child: FilledButton.icon( onPressed: _busy ? null : _toggle, style: FilledButton.styleFrom( backgroundColor: s.isUp ? const Color(0xFFB3261E) : const Color(0xFF1B5E20), padding: const EdgeInsets.symmetric(vertical: 14), ), icon: _busy ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) : Icon(s.isUp ? Icons.stop : Icons.play_arrow), label: Text(s.isUp ? 'Éteindre le serveur' : 'Démarrer le serveur'), ), ); } Widget _info(IconData icon, String label, String value) { return Padding( padding: const EdgeInsets.only(bottom: 12), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: Theme.of(context).textTheme.labelMedium), Text(value, style: Theme.of(context).textTheme.bodyLarge), ], ), ), ], ), ); } Widget _statsCard() { final st = _stats; return Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Utilisation', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 12), if (st == null) const Center(child: Padding(padding: EdgeInsets.all(8), child: CircularProgressIndicator())) else if (st['running'] != true) const Text('Serveur éteint — pas de stats.') else ...[ _gauge('CPU', (st['cpu_percent'] as num?)?.toDouble() ?? 0, '${st['cpu_percent']} %'), const SizedBox(height: 12), _gauge('RAM', (st['mem_percent'] as num?)?.toDouble() ?? 0, '${st['mem_used_mb']} Mo${st['mem_limit_mb'] != null ? ' / ${_fmtMb(st['mem_limit_mb'])}' : ''}'), const SizedBox(height: 12), Text('Réseau : ↓ ${st['net_rx_mb']} Mo ↑ ${st['net_tx_mb']} Mo', style: Theme.of(context).textTheme.bodyMedium), ], ], ), ), ); } String _fmtMb(dynamic mb) { final v = (mb as num).toDouble(); return v >= 1024 ? '${(v / 1024).toStringAsFixed(1)} Go' : '${v.round()} Mo'; } Widget _gauge(String label, double percent, String value) { final p = (percent / 100).clamp(0.0, 1.0); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(label, style: const TextStyle(fontWeight: FontWeight.w600)), Text(value, style: Theme.of(context).textTheme.bodyMedium), ], ), const SizedBox(height: 6), ClipRRect( borderRadius: BorderRadius.circular(6), child: LinearProgressIndicator(value: p, minHeight: 8), ), ], ); } Widget _imageCard() { return Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Image', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 6), Text('Screenshot ou visuel du serveur (stocké sur RustFS).', style: Theme.of(context).textTheme.bodySmall), const SizedBox(height: 12), Row( children: [ FilledButton.tonalIcon( onPressed: _imgBusy ? null : _pickImage, icon: _imgBusy ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.upload), label: Text(_image == null ? 'Importer' : 'Remplacer'), ), const SizedBox(width: 8), if (_image != null) OutlinedButton.icon( onPressed: _imgBusy ? null : _deleteImage, icon: const Icon(Icons.delete_outline), label: const Text('Supprimer'), ), ], ), ], ), ), ); } } class _Pill extends StatelessWidget { const _Pill({required this.label, required this.color}); final String label; final Color color; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( color: color.withOpacity(0.18), borderRadius: BorderRadius.circular(999), border: Border.all(color: color), ), child: Text(label, style: TextStyle(color: color, fontSize: 12, fontWeight: FontWeight.w700)), ); } }