diff --git a/lib/main.dart b/lib/main.dart index 79acf62..cf8e387 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -18,9 +18,9 @@ class GameManagerApp extends StatelessWidget { return MaterialApp( title: 'GameManager', debugShowCheckedModeBanner: false, - theme: AppTheme.light(), + theme: AppTheme.dark(), darkTheme: AppTheme.dark(), - themeMode: ThemeMode.system, + themeMode: ThemeMode.dark, home: AuthGate(auth: auth, api: api), ); } diff --git a/lib/models.dart b/lib/models.dart index 0b43401..ebf2d35 100644 --- a/lib/models.dart +++ b/lib/models.dart @@ -17,6 +17,7 @@ class GameServer { final String? motd; final bool hasIcon; final bool hasImage; + final bool custom; const GameServer({ required this.id, @@ -34,6 +35,7 @@ class GameServer { this.motd, this.hasIcon = false, this.hasImage = false, + this.custom = false, }); factory GameServer.fromJson(Map json) => GameServer( @@ -52,6 +54,7 @@ class GameServer { motd: json['motd'] as String?, hasIcon: json['has_icon'] as bool? ?? false, hasImage: json['has_image'] as bool? ?? false, + custom: json['custom'] as bool? ?? false, ); bool get isUp => status == 'online' || status == 'running'; @@ -84,3 +87,27 @@ class GameServer { _ => (label: 'Éteint', color: const Color(0xFF8E8E93)), }; } + +/// Conteneur Docker (pour lier un serveur depuis l'app). +class DockerContainer { + DockerContainer({required this.name, required this.image, required this.state}); + final String name; + final String image; + final String state; + + factory DockerContainer.fromJson(Map j) => DockerContainer( + name: j['name'] as String, + image: (j['image'] as String?) ?? '', + state: (j['state'] as String?) ?? '', + ); + + bool get running => state == 'running'; + + /// Image sans le registre ni le digest, pour la lisibilité. + /// ex: git.nfteam.ovh/neckfire/the-cycle:preprod → the-cycle:preprod + String get prettyImage { + var s = image.split('/').last; + if (s.length > 34) s = '${s.substring(0, 33)}…'; + return s; + } +} diff --git a/lib/screens/add_server_screen.dart b/lib/screens/add_server_screen.dart new file mode 100644 index 0000000..c07b8db --- /dev/null +++ b/lib/screens/add_server_screen.dart @@ -0,0 +1,230 @@ +import 'package:flutter/material.dart'; + +import '../models.dart'; +import '../services/api_service.dart'; + +/// Formulaire d'ajout d'un serveur : infos + sélection du/des conteneur(s) +/// Docker liés (liste lisible : nom + image courte + état). +class AddServerScreen extends StatefulWidget { + const AddServerScreen({super.key, required this.api}); + final ApiService api; + + @override + State createState() => _AddServerScreenState(); +} + +class _AddServerScreenState extends State { + final _name = TextEditingController(); + final _game = TextEditingController(); + final _subtitle = TextEditingController(); + final _connect = TextEditingController(); + final _openUrl = TextEditingController(); + final _minecraft = TextEditingController(); + final _search = TextEditingController(); + + String _env = 'prod'; + bool _web = false; + bool _onDemand = false; + final _selected = {}; + + List? _containers; + String? _error; + bool _saving = false; + + @override + void initState() { + super.initState(); + _loadContainers(); + _search.addListener(() => setState(() {})); + } + + @override + void dispose() { + for (final c in [_name, _game, _subtitle, _connect, _openUrl, _minecraft, _search]) { + c.dispose(); + } + super.dispose(); + } + + Future _loadContainers() async { + try { + final list = await widget.api.containers(); + if (mounted) setState(() => _containers = list); + } catch (e) { + if (mounted) setState(() => _error = '$e'); + } + } + + Future _save() async { + if (_name.text.trim().isEmpty || _selected.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Nom et au moins un conteneur requis.')), + ); + return; + } + setState(() => _saving = true); + try { + await widget.api.createServer({ + 'name': _name.text.trim(), + 'game': _game.text.trim(), + 'subtitle': _subtitle.text.trim(), + 'env': _env, + 'containers': _selected.toList(), + 'connect': _web ? _openUrl.text.trim() : _connect.text.trim(), + 'web': _web, + 'open_url': _web ? _openUrl.text.trim() : null, + 'minecraft': _minecraft.text.trim(), + 'on_demand': _onDemand, + }); + if (mounted) Navigator.pop(context, true); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e'))); + setState(() => _saving = false); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Nouveau serveur')), + body: ListView( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 100), + children: [ + TextField( + controller: _name, + textCapitalization: TextCapitalization.words, + decoration: const InputDecoration(labelText: 'Nom *', hintText: 'ex: Valheim'), + ), + const SizedBox(height: 12), + TextField( + controller: _game, + decoration: const InputDecoration(labelText: 'Jeu', hintText: 'ex: Valheim'), + ), + const SizedBox(height: 12), + TextField( + controller: _subtitle, + decoration: const InputDecoration(labelText: 'Sous-titre', hintText: 'ex: serveur privé'), + ), + const SizedBox(height: 16), + _envSelector(), + const SizedBox(height: 16), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Jeu web (bouton « Ouvrir »)'), + value: _web, + onChanged: (v) => setState(() => _web = v), + ), + if (_web) + TextField( + controller: _openUrl, + keyboardType: TextInputType.url, + decoration: const InputDecoration(labelText: 'URL du jeu', hintText: 'https://…'), + ) + else ...[ + TextField( + controller: _connect, + decoration: const InputDecoration(labelText: 'Adresse de connexion', hintText: 'ex: jeu.nfteam.ovh'), + ), + const SizedBox(height: 12), + TextField( + controller: _minecraft, + decoration: const InputDecoration( + labelText: 'Minecraft host:port (MOTD, optionnel)', + hintText: 'ex: 192.168.1.136:25565', + ), + ), + ], + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Serveur à la demande'), + value: _onDemand, + onChanged: (v) => setState(() => _onDemand = v), + ), + const Divider(height: 28), + Text('Conteneur(s) lié(s) *', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + _containerPicker(), + ], + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: _saving ? null : _save, + icon: _saving + ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.check), + label: const Text('Créer'), + ), + ); + } + + Widget _envSelector() { + return SegmentedButton( + segments: const [ + ButtonSegment(value: 'prod', label: Text('Production'), icon: Icon(Icons.public)), + ButtonSegment(value: 'preprod', label: Text('Preprod'), icon: Icon(Icons.science_outlined)), + ], + selected: {_env}, + onSelectionChanged: (s) => setState(() => _env = s.first), + ); + } + + Widget _containerPicker() { + if (_error != null) { + return Text('Impossible de charger les conteneurs.\n$_error'); + } + if (_containers == null) { + return const Padding(padding: EdgeInsets.all(20), child: Center(child: CircularProgressIndicator())); + } + final q = _search.text.toLowerCase(); + final list = _containers! + .where((c) => q.isEmpty || c.name.toLowerCase().contains(q) || c.image.toLowerCase().contains(q)) + .toList(); + return Column( + children: [ + TextField( + controller: _search, + decoration: const InputDecoration( + prefixIcon: Icon(Icons.search), + hintText: 'Filtrer les conteneurs…', + isDense: true, + ), + ), + const SizedBox(height: 8), + if (_selected.isNotEmpty) + Align( + alignment: Alignment.centerLeft, + child: Text('${_selected.length} sélectionné(s)', + style: Theme.of(context).textTheme.labelMedium), + ), + Card( + child: Column( + children: [ + for (final c in list) + CheckboxListTile( + dense: true, + value: _selected.contains(c.name), + onChanged: (v) => setState(() { + v == true ? _selected.add(c.name) : _selected.remove(c.name); + }), + title: Text(c.name, style: const TextStyle(fontWeight: FontWeight.w600)), + subtitle: Text(c.prettyImage, + maxLines: 1, overflow: TextOverflow.ellipsis, + style: const TextStyle(fontFamily: 'monospace', fontSize: 11)), + secondary: Container( + width: 10, height: 10, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: c.running ? const Color(0xFF34C759) : const Color(0xFF8E8E93), + ), + ), + ), + if (list.isEmpty) + const Padding(padding: EdgeInsets.all(16), child: Text('Aucun conteneur.')), + ], + ), + ), + ], + ); + } +} diff --git a/lib/screens/server_detail_screen.dart b/lib/screens/server_detail_screen.dart index 70ad353..55fca47 100644 --- a/lib/screens/server_detail_screen.dart +++ b/lib/screens/server_detail_screen.dart @@ -87,6 +87,27 @@ class _ServerDetailScreenState extends State { } } + 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))); } @@ -96,7 +117,17 @@ class _ServerDetailScreenState extends State { final t = s.theme; final badge = s.statusBadge; return Scaffold( - appBar: AppBar(title: Text(s.name)), + 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( diff --git a/lib/screens/servers_screen.dart b/lib/screens/servers_screen.dart index b57338e..a6f187f 100644 --- a/lib/screens/servers_screen.dart +++ b/lib/screens/servers_screen.dart @@ -7,6 +7,7 @@ import '../services/api_service.dart'; import '../services/auth_service.dart'; import '../services/update_service.dart'; import '../widgets/server_card.dart'; +import 'add_server_screen.dart'; import 'server_detail_screen.dart'; import 'settings_screen.dart'; @@ -159,10 +160,21 @@ class _ServersScreenState extends State { ), ), body: _buildBody(), + floatingActionButton: FloatingActionButton.extended( + onPressed: _openAdd, + icon: const Icon(Icons.add), + label: const Text('Ajouter'), + ), ), ); } + Future _openAdd() async { + final added = await Navigator.push(context, + MaterialPageRoute(builder: (_) => AddServerScreen(api: widget.api))); + if (added == true) _refresh(); + } + Widget _buildBody() { if (_error != null && _servers == null) { return ListView(children: [ diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 7b6f77c..7161f80 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -45,6 +45,47 @@ class ApiService { return GameServer.fromJson(jsonDecode(res.body) as Map); } + Future> containers() async { + final base = await baseUrl; + final res = await http.get(Uri.parse('$base/api/containers'), headers: await _headers()); + if (res.statusCode == 401) throw UnauthorizedException(); + if (res.statusCode != 200) throw ApiException('Erreur ${res.statusCode}'); + return (jsonDecode(res.body) as List) + .map((e) => DockerContainer.fromJson(e as Map)) + .toList(); + } + + Future createServer(Map payload) async { + final base = await baseUrl; + final res = await http.post( + Uri.parse('$base/api/servers'), + headers: {...await _headers(), 'Content-Type': 'application/json'}, + body: jsonEncode(payload), + ); + if (res.statusCode == 401) throw UnauthorizedException(); + if (res.statusCode >= 400) { + throw ApiException(_msg(res.body) ?? 'Création refusée (${res.statusCode})'); + } + return GameServer.fromJson(jsonDecode(res.body) as Map); + } + + Future deleteServer(String id) async { + final base = await baseUrl; + final res = await http.delete(Uri.parse('$base/api/servers/$id'), headers: await _headers()); + if (res.statusCode == 401) throw UnauthorizedException(); + if (res.statusCode >= 400) { + throw ApiException(_msg(res.body) ?? 'Suppression refusée (${res.statusCode})'); + } + } + + String? _msg(String body) { + try { + return (jsonDecode(body) as Map)['detail'] as String?; + } catch (_) { + return null; + } + } + Future start(String id) => _action(id, 'start'); Future stop(String id) => _action(id, 'stop'); diff --git a/lib/theme.dart b/lib/theme.dart index 7afcd7e..01d44a7 100644 --- a/lib/theme.dart +++ b/lib/theme.dart @@ -1,42 +1,128 @@ import 'package:flutter/material.dart'; -/// Thèmes Material 3 (clair + sombre) avec un accent unifié. +/// Thème « flamme / chaud sombre » — anthracite + accents orange→rouge. +/// Remplace la palette Material par défaut (violet/teal). class AppTheme { - static const _seed = Color(0xFF5E5CE6); + // Palette + static const bg = Color(0xFF14110F); // fond anthracite + static const surface1 = Color(0xFF1F1B18); // cartes / champs + static const surface2 = Color(0xFF262119); // éléments surélevés + static const flame = Color(0xFFFF7A18); // orange accent + static const ember = Color(0xFFFF3D00); // rouge braise + static const ink = Color(0xFFF5EFE9); // texte principal + static const inkMuted = Color(0xFFA89F97); // texte secondaire - static ThemeData light() => _base(Brightness.light); - static ThemeData dark() => _base(Brightness.dark); + static ThemeData light() => dark(); // app pensée sombre → même thème + static ThemeData dark() { + const scheme = ColorScheme( + brightness: Brightness.dark, + primary: flame, + onPrimary: Color(0xFF230F00), + primaryContainer: Color(0xFF3A2410), + onPrimaryContainer: Color(0xFFFFD9B8), + secondary: ember, + onSecondary: Colors.white, + secondaryContainer: Color(0xFF4A1A0A), + onSecondaryContainer: Color(0xFFFFD5C8), + tertiary: Color(0xFFFFB74D), + onTertiary: Color(0xFF241400), + error: Color(0xFFFF6B5E), + onError: Color(0xFF3A0A05), + surface: bg, + onSurface: ink, + surfaceContainerLowest: Color(0xFF0F0D0B), + surfaceContainerLow: surface1, + surfaceContainer: surface1, + surfaceContainerHigh: surface2, + surfaceContainerHighest: Color(0xFF2E2820), + onSurfaceVariant: inkMuted, + outline: Color(0xFF4A433D), + outlineVariant: Color(0xFF332E29), + inverseSurface: ink, + onInverseSurface: bg, + shadow: Colors.black, + scrim: Colors.black, + ); - static ThemeData _base(Brightness brightness) { - final scheme = ColorScheme.fromSeed(seedColor: _seed, brightness: brightness); return ThemeData( useMaterial3: true, colorScheme: scheme, - scaffoldBackgroundColor: scheme.surface, - appBarTheme: AppBarTheme( - backgroundColor: scheme.surface, - foregroundColor: scheme.onSurface, + scaffoldBackgroundColor: bg, + canvasColor: bg, + appBarTheme: const AppBarTheme( + backgroundColor: bg, + foregroundColor: ink, centerTitle: false, elevation: 0, + scrolledUnderElevation: 0, titleTextStyle: TextStyle( - color: scheme.onSurface, + color: ink, fontSize: 24, fontWeight: FontWeight.w800, letterSpacing: -0.5, ), ), cardTheme: CardTheme( + color: surface1, clipBehavior: Clip.antiAlias, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22)), elevation: 0, ), + tabBarTheme: const TabBarTheme( + labelColor: flame, + unselectedLabelColor: inkMuted, + indicatorColor: flame, + dividerColor: Colors.transparent, + labelStyle: TextStyle(fontWeight: FontWeight.w700), + ), filledButtonTheme: FilledButtonThemeData( style: FilledButton.styleFrom( + backgroundColor: flame, + foregroundColor: const Color(0xFF230F00), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 14), textStyle: const TextStyle(fontWeight: FontWeight.w700, fontSize: 15), ), ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: ink, + side: const BorderSide(color: Color(0xFF4A433D)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + ), + ), + floatingActionButtonTheme: const FloatingActionButtonThemeData( + backgroundColor: flame, + foregroundColor: Color(0xFF230F00), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: surface1, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide(color: Color(0xFF332E29)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide(color: flame, width: 2), + ), + labelStyle: const TextStyle(color: inkMuted), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: flame, + linearTrackColor: Color(0xFF332E29), + ), + dividerTheme: const DividerThemeData(color: Color(0xFF332E29)), + snackBarTheme: const SnackBarThemeData( + backgroundColor: surface2, + contentTextStyle: TextStyle(color: ink), + ), + dialogTheme: const DialogTheme(backgroundColor: surface1), + chipTheme: const ChipThemeData(backgroundColor: surface2), ); } }