feat: ajout de serveurs depuis l'app + charte flamme sombre
Build APK / build (push) Successful in 2m49s
Build APK / build (push) Successful in 2m49s
- backend: GET /api/containers (liste lisible), POST/DELETE /api/servers (serveurs custom persistés en /data/servers.json, mergés aux intégrés), champ custom dans le statut. - app: écran AddServer (form + sélecteur multi-conteneurs filtrable), FAB Ajouter, suppression depuis le détail (serveurs custom only). - thème: palette flamme/chaud sombre (anthracite + orange→rouge), remplace le Material violet/teal par défaut (scaffold, appbar, tabs, boutons, FAB, champs). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
fb4ab95f13
commit
30786f9706
@@ -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<AddServerScreen> createState() => _AddServerScreenState();
|
||||
}
|
||||
|
||||
class _AddServerScreenState extends State<AddServerScreen> {
|
||||
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 = <String>{};
|
||||
|
||||
List<DockerContainer>? _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<void> _loadContainers() async {
|
||||
try {
|
||||
final list = await widget.api.containers();
|
||||
if (mounted) setState(() => _containers = list);
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _error = '$e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _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<String>(
|
||||
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.')),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user