2 Commits
Author SHA1 Message Date
neckfireandClaude Opus 4.8 30786f9706 feat: ajout de serveurs depuis l'app + charte flamme sombre
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>
2026-07-13 13:33:22 +02:00
neckfireandClaude Opus 4.8 fb4ab95f13 feat(ui): la carte reprend l'image importée en fond (voile pour lisibilité)
Build APK / build (push) Successful in 2m49s
- ServerCard: fond = image (MemoryImage) + scrim dégradé si présente, sinon
  dégradé thématique ; vignette d'icône masquée quand image de fond.
- servers_screen: fetch des bytes image (cache par id, vidé au refresh) → passe
  à la carte ; plus de FutureBuilder iconUrl inutile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 13:00:10 +02:00
8 changed files with 489 additions and 27 deletions
+2 -2
View File
@@ -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),
);
}
+27
View File
@@ -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<String, dynamic> 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<String, dynamic> 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;
}
}
+230
View File
@@ -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.')),
],
),
),
],
);
}
}
+32 -1
View File
@@ -87,6 +87,27 @@ class _ServerDetailScreenState extends State<ServerDetailScreen> {
}
}
Future<void> _confirmDelete() async {
final ok = await showDialog<bool>(
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<ServerDetailScreen> {
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(
+28 -3
View File
@@ -1,3 +1,5 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import '../models.dart';
@@ -5,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';
@@ -27,6 +30,15 @@ class _ServersScreenState extends State<ServersScreen> {
List<GameServer>? _servers;
String? _error;
final _busy = <String>{};
final _imgCache = <String, Uint8List?>{};
Future<Uint8List?> _image(GameServer s) async {
if (!s.hasImage) return null;
if (_imgCache.containsKey(s.id)) return _imgCache[s.id];
final b = await widget.api.imageBytes(s.id).catchError((_) => null);
_imgCache[s.id] = b;
return b;
}
@override
void initState() {
@@ -41,6 +53,7 @@ class _ServersScreenState extends State<ServersScreen> {
if (mounted) setState(() {
_servers = servers;
_error = null;
_imgCache.clear(); // recharge les images (elles ont pu changer)
});
} on UnauthorizedException {
await widget.auth.logout();
@@ -147,10 +160,21 @@ class _ServersScreenState extends State<ServersScreen> {
),
),
body: _buildBody(),
floatingActionButton: FloatingActionButton.extended(
onPressed: _openAdd,
icon: const Icon(Icons.add),
label: const Text('Ajouter'),
),
),
);
}
Future<void> _openAdd() async {
final added = await Navigator.push<bool>(context,
MaterialPageRoute(builder: (_) => AddServerScreen(api: widget.api)));
if (added == true) _refresh();
}
Widget _buildBody() {
if (_error != null && _servers == null) {
return ListView(children: [
@@ -191,11 +215,12 @@ class _ServersScreenState extends State<ServersScreen> {
Widget _card(GameServer s) => Padding(
padding: const EdgeInsets.only(bottom: 14),
child: FutureBuilder<String>(
future: widget.api.iconUrl(s.id),
child: FutureBuilder<Uint8List?>(
future: _image(s),
builder: (_, snap) => ServerCard(
server: s,
iconUrl: snap.data ?? '',
iconUrl: '',
imageBytes: snap.data,
busy: _busy.contains(s.id),
onToggle: () => _toggle(s),
onTap: () => _openDetail(s),
+41
View File
@@ -45,6 +45,47 @@ class ApiService {
return GameServer.fromJson(jsonDecode(res.body) as Map<String, dynamic>);
}
Future<List<DockerContainer>> 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<String, dynamic>))
.toList();
}
Future<GameServer> createServer(Map<String, dynamic> 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<String, dynamic>);
}
Future<void> 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<String, dynamic>)['detail'] as String?;
} catch (_) {
return null;
}
}
Future<void> start(String id) => _action(id, 'start');
Future<void> stop(String id) => _action(id, 'stop');
+97 -11
View File
@@ -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),
);
}
}
+32 -10
View File
@@ -1,3 +1,5 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
@@ -11,6 +13,7 @@ class ServerCard extends StatelessWidget {
required this.busy,
required this.onToggle,
this.onTap,
this.imageBytes,
});
final GameServer server;
@@ -18,31 +21,49 @@ class ServerCard extends StatelessWidget {
final bool busy;
final VoidCallback onToggle;
final VoidCallback? onTap;
final Uint8List? imageBytes;
@override
Widget build(BuildContext context) {
final t = server.theme;
final badge = server.statusBadge;
final hasImg = imageBytes != null;
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Ink(
decoration: hasImg
? BoxDecoration(
image: DecorationImage(image: MemoryImage(imageBytes!), fit: BoxFit.cover),
)
: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [t.start, t.end],
),
),
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [t.start, t.end],
),
),
decoration: hasImg
? const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x66000000), Color(0xCC000000)],
),
)
: null,
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
_Icon(server: server, iconUrl: iconUrl, fallback: t.icon),
const SizedBox(width: 14),
if (!hasImg) ...[
_Icon(server: server, iconUrl: iconUrl, fallback: t.icon),
const SizedBox(width: 14),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -107,6 +128,7 @@ class ServerCard extends StatelessWidget {
),
),
),
),
);
}
}
@@ -124,7 +146,7 @@ class _Icon extends StatelessWidget {
child: SizedBox(
width: 52,
height: 52,
child: (server.hasIcon)
child: (server.hasIcon && iconUrl.isNotEmpty)
? Image.network(iconUrl,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _fallbackBox())