feat: app Flutter GameManager (OIDC pocket-id, contrôle serveurs, MOTD live, auto-update) + CI APK signée

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
neckfire
2026-07-13 09:13:55 +02:00
co-authored by Claude Opus 4.8
parent 2c5476b1db
commit 3286640b37
14 changed files with 969 additions and 0 deletions
+95
View File
@@ -0,0 +1,95 @@
name: Build APK
on:
push:
branches: [main]
workflow_dispatch: {}
env:
FLUTTER_IMAGE: ghcr.io/cirruslabs/flutter:3.24.5
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build signed APK (Flutter)
run: |
set -e
RUN="${{ github.run_number }}"
NAME="1.0.${RUN}"
echo "${{ secrets.ANDROID_KEYSTORE_B64 }}" | base64 -d > "${PWD}/gamemanager.keystore"
docker run --rm -v "${PWD}:/app" -w /app \
-e KSPW="${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" \
-e ALIAS="${{ secrets.ANDROID_KEY_ALIAS }}" \
-e BUILD_NAME="${NAME}" -e BUILD_NUMBER="${RUN}" \
"${FLUTTER_IMAGE}" bash -euxc '
git config --global --add safe.directory /app
flutter create --platforms=android --org nfteam --project-name gamemanager .
# Permissions Android (internet + auto-install APK)
MAN=android/app/src/main/AndroidManifest.xml
sed -i "s#<application#<uses-permission android:name=\"android.permission.INTERNET\"/>\n <uses-permission android:name=\"android.permission.REQUEST_INSTALL_PACKAGES\"/>\n <application#" "$MAN"
# Signature release + schéma de redirection OIDC (patch build.gradle)
cat > android/key.properties <<EOF
storePassword=${KSPW}
keyPassword=${KSPW}
keyAlias=${ALIAS}
storeFile=/app/gamemanager.keystore
EOF
python3 - <<PY
import re
p="android/app/build.gradle"; s=open(p).read()
# charge key.properties en tête
if "keystoreProperties" not in s:
s = ("def keystoreProperties = new Properties()\n"
"def keystorePropertiesFile = rootProject.file(\"key.properties\")\n"
"if (keystorePropertiesFile.exists()) { keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) }\n\n") + s
# schéma OIDC pour flutter_appauth
s = s.replace("defaultConfig {",
"defaultConfig {\n manifestPlaceholders += [appAuthRedirectScheme: \"nfteam.gamemanager\"]", 1)
# bloc signingConfigs.release
if "signingConfigs {" not in s:
s = re.sub(r"(buildTypes\s*\{)",
"signingConfigs {\n release {\n"
" keyAlias keystoreProperties[\"keyAlias\"]\n"
" keyPassword keystoreProperties[\"keyPassword\"]\n"
" storeFile file(keystoreProperties[\"storeFile\"])\n"
" storePassword keystoreProperties[\"storePassword\"]\n"
" }\n }\n \\1", s, count=1)
# release utilise la clé release
s = s.replace("signingConfig signingConfigs.debug", "signingConfig signingConfigs.release")
open(p,"w").write(s)
print("build.gradle patché")
PY
flutter pub get
flutter build apk --release --build-name="${BUILD_NAME}" --build-number="${BUILD_NUMBER}"
'
cp build/app/outputs/flutter-apk/app-release.apk "gamemanager.apk"
cat > version.json <<EOF
{"version":"1.0.${RUN}","version_code":${RUN},"notes":"Build CI #${RUN}"}
EOF
- name: Publish Gitea release
run: |
set -e
RUN="${{ github.run_number }}"; TAG="v1.0.${RUN}"
API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
AUTH="Authorization: token ${{ secrets.GITHUB_TOKEN }}"
REL=$(curl -s -X POST "$API/releases" -H "$AUTH" -H 'Content-Type: application/json' \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"GameManager ${TAG}\",\"target_commitish\":\"main\"}")
ID=$(echo "$REL" | python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
curl -s -X POST "$API/releases/$ID/assets?name=gamemanager.apk" -H "$AUTH" \
-F "attachment=@gamemanager.apk;type=application/vnd.android.package-archive" >/dev/null
curl -s -X POST "$API/releases/$ID/assets?name=version.json" -H "$AUTH" \
-F "attachment=@version.json;type=application/json" >/dev/null
echo "Release ${TAG} publiée"
- name: Notify ntfy
if: always()
run: |
if [ "${{ job.status }}" = "success" ]; then E="white_check_mark"; P="default"; else E="rotating_light"; P="high"; fi
curl -s -H "Authorization: Bearer ${{ secrets.NTFY_TOKEN }}" -H "Title: gamemanager APK — ${{ job.status }}" \
-H "Priority: ${P}" -H "Tags: ${E}" \
-d "Build APK #${{ github.run_number }} : ${{ job.status }}" \
"${{ secrets.NTFY_URL }}/${{ secrets.NTFY_TOPIC }}" || true
+15
View File
@@ -0,0 +1,15 @@
# Flutter/Dart
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
build/
.pub-cache/
.pub/
# Android généré par la CI (flutter create)
android/
ios/
linux/ macos/ windows/ web/
# Secrets / signature
gamemanager.keystore
android/key.properties
*.apk
+10
View File
@@ -0,0 +1,10 @@
/// Valeurs par défaut de l'app (surchargeables dans l'écran Réglages).
class Config {
static const String defaultApiBase = 'https://gamemanager-api.nfteam.ovh';
// OIDC pocket-id (client public + PKCE)
static const String oidcIssuer = 'https://pocket.nfteam.ovh';
static const String oidcClientId = '83678fb3-9268-42c1-8dcb-0fb141692a6d';
static const String oidcRedirect = 'nfteam.gamemanager://oidc';
static const List<String> oidcScopes = ['openid', 'profile', 'email'];
}
+65
View File
@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import 'screens/login_screen.dart';
import 'screens/servers_screen.dart';
import 'services/api_service.dart';
import 'services/auth_service.dart';
import 'theme.dart';
void main() => runApp(const GameManagerApp());
class GameManagerApp extends StatelessWidget {
const GameManagerApp({super.key});
@override
Widget build(BuildContext context) {
final auth = AuthService();
final api = ApiService(auth);
return MaterialApp(
title: 'GameManager',
debugShowCheckedModeBanner: false,
theme: AppTheme.light(),
darkTheme: AppTheme.dark(),
themeMode: ThemeMode.system,
home: AuthGate(auth: auth, api: api),
);
}
}
/// Aiguille vers la connexion ou la liste des serveurs selon l'état d'auth.
class AuthGate extends StatefulWidget {
const AuthGate({super.key, required this.auth, required this.api});
final AuthService auth;
final ApiService api;
@override
State<AuthGate> createState() => _AuthGateState();
}
class _AuthGateState extends State<AuthGate> {
bool? _loggedIn;
@override
void initState() {
super.initState();
widget.auth.isLoggedIn().then((v) => setState(() => _loggedIn = v));
}
@override
Widget build(BuildContext context) {
if (_loggedIn == null) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
if (_loggedIn!) {
return ServersScreen(
auth: widget.auth,
api: widget.api,
onLoggedOut: () => setState(() => _loggedIn = false),
);
}
return LoginScreen(
auth: widget.auth,
onLoggedIn: () => setState(() => _loggedIn = true),
);
}
}
+70
View File
@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
/// Un serveur de jeu tel que renvoyé par l'API `/api/servers`.
class GameServer {
final String id;
final String name;
final String game;
final String? subtitle;
final String? connect;
final bool onDemand;
final String status; // stopped | starting | running | online | partial
final int? players;
final int? maxPlayers;
final String? motd;
final bool hasIcon;
const GameServer({
required this.id,
required this.name,
required this.game,
this.subtitle,
this.connect,
this.onDemand = false,
required this.status,
this.players,
this.maxPlayers,
this.motd,
this.hasIcon = false,
});
factory GameServer.fromJson(Map<String, dynamic> json) => GameServer(
id: json['id'] as String,
name: json['name'] as String,
game: json['game'] as String,
subtitle: json['subtitle'] as String?,
connect: json['connect'] as String?,
onDemand: json['on_demand'] as bool? ?? false,
status: json['status'] as String? ?? 'stopped',
players: json['players'] as int?,
maxPlayers: json['max_players'] as int?,
motd: json['motd'] as String?,
hasIcon: json['has_icon'] as bool? ?? false,
);
bool get isUp => status == 'online' || status == 'running';
bool get isBusy => status == 'starting' || status == 'partial';
/// Couleur d'accent thématique par jeu (visuel sans dépendance externe).
({Color start, Color end, IconData icon}) get theme {
final g = game.toLowerCase();
if (g.contains('minecraft')) {
final medieval = name.toLowerCase().contains('mmc') || name.toLowerCase().contains('medieval');
return medieval
? (start: const Color(0xFF7B5E3B), end: const Color(0xFF3E2C1A), icon: Icons.castle)
: (start: const Color(0xFF4CAF50), end: const Color(0xFF1B5E20), icon: Icons.grass);
}
if (g.contains('cycle')) {
return (start: const Color(0xFF00BCD4), end: const Color(0xFF1A237E), icon: Icons.rocket_launch);
}
return (start: const Color(0xFF7E57C2), end: const Color(0xFF311B92), icon: Icons.sports_esports);
}
({String label, Color color}) get statusBadge => switch (status) {
'online' => (label: 'En ligne', color: const Color(0xFF34C759)),
'running' => (label: 'Démarré', color: const Color(0xFF30B0C7)),
'starting' => (label: 'Démarrage…', color: const Color(0xFFFF9F0A)),
'partial' => (label: 'Partiel', color: const Color(0xFFFF9F0A)),
_ => (label: 'Éteint', color: const Color(0xFF8E8E93)),
};
}
+86
View File
@@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
import '../services/auth_service.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key, required this.auth, required this.onLoggedIn});
final AuthService auth;
final VoidCallback onLoggedIn;
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
bool _busy = false;
String? _error;
Future<void> _login() async {
setState(() {
_busy = true;
_error = null;
});
try {
if (await widget.auth.login()) {
widget.onLoggedIn();
} else {
setState(() => _error = 'Connexion annulée.');
}
} catch (e) {
setState(() => _error = 'Échec de la connexion : $e');
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Scaffold(
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [scheme.primary.withValues(alpha: 0.25), scheme.surface],
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(28),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.sports_esports, size: 72, color: scheme.primary),
const SizedBox(height: 20),
Text('GameManager',
style: Theme.of(context).textTheme.displaySmall?.copyWith(
fontWeight: FontWeight.w900, letterSpacing: -1)),
const SizedBox(height: 6),
Text('Pilote tes serveurs de jeu',
style: TextStyle(fontSize: 16, color: scheme.onSurfaceVariant)),
const SizedBox(height: 40),
if (_error != null) ...[
Text(_error!, style: TextStyle(color: scheme.error)),
const SizedBox(height: 16),
],
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _busy ? null : _login,
icon: _busy
? const SizedBox(
width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.login),
label: Text(_busy ? 'Connexion…' : 'Se connecter avec pocket-id'),
),
),
],
),
),
),
),
);
}
}
+155
View File
@@ -0,0 +1,155 @@
import 'package:flutter/material.dart';
import '../models.dart';
import '../services/api_service.dart';
import '../services/auth_service.dart';
import '../services/update_service.dart';
import '../widgets/server_card.dart';
import 'settings_screen.dart';
class ServersScreen extends StatefulWidget {
const ServersScreen({
super.key,
required this.auth,
required this.api,
required this.onLoggedOut,
});
final AuthService auth;
final ApiService api;
final VoidCallback onLoggedOut;
@override
State<ServersScreen> createState() => _ServersScreenState();
}
class _ServersScreenState extends State<ServersScreen> {
List<GameServer>? _servers;
String? _error;
final _busy = <String>{};
@override
void initState() {
super.initState();
_refresh();
_checkUpdate();
}
Future<void> _refresh() async {
try {
final servers = await widget.api.servers();
if (mounted) setState(() {
_servers = servers;
_error = null;
});
} on UnauthorizedException {
await widget.auth.logout();
widget.onLoggedOut();
} catch (e) {
if (mounted) setState(() => _error = '$e');
}
}
Future<void> _toggle(GameServer s) async {
setState(() => _busy.add(s.id));
try {
s.isUp ? await widget.api.stop(s.id) : await widget.api.start(s.id);
await Future.delayed(const Duration(seconds: 2));
await _refresh();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e')));
}
} finally {
if (mounted) setState(() => _busy.remove(s.id));
}
}
Future<void> _checkUpdate() async {
final info = await UpdateService(widget.api.baseUrl).check();
if (info == null || !mounted) return;
final go = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text('Mise à jour ${info.version}'),
content: Text(info.notes.isEmpty ? 'Une nouvelle version est disponible.' : info.notes),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Plus tard')),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('Installer')),
],
),
);
if (go == true) _applyUpdate(info);
}
void _applyUpdate(UpdateInfo info) {
UpdateService(widget.api.baseUrl).apply(info).listen((_) {}, onError: (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('MAJ échouée : $e')));
}
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Téléchargement de la mise à jour…')),
);
}
Future<void> _openSettings() async {
await Navigator.push(context,
MaterialPageRoute(builder: (_) => SettingsScreen(api: widget.api)));
_refresh();
}
Future<void> _logout() async {
await widget.auth.logout();
widget.onLoggedOut();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Serveurs'),
actions: [
IconButton(onPressed: _openSettings, icon: const Icon(Icons.settings_outlined)),
IconButton(onPressed: _logout, icon: const Icon(Icons.logout)),
],
),
body: RefreshIndicator(
onRefresh: _refresh,
child: _buildBody(),
),
);
}
Widget _buildBody() {
if (_error != null && _servers == null) {
return ListView(children: [
const SizedBox(height: 120),
Icon(Icons.cloud_off, size: 60, color: Theme.of(context).colorScheme.outline),
const SizedBox(height: 12),
Center(child: Text('Connexion à l\'API impossible.\n$_error', textAlign: TextAlign.center)),
const SizedBox(height: 12),
Center(child: FilledButton(onPressed: _refresh, child: const Text('Réessayer'))),
]);
}
if (_servers == null) {
return const Center(child: CircularProgressIndicator());
}
return ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
itemCount: _servers!.length,
separatorBuilder: (_, __) => const SizedBox(height: 14),
itemBuilder: (_, i) {
final s = _servers![i];
return FutureBuilder<String>(
future: widget.api.iconUrl(s.id),
builder: (_, snap) => ServerCard(
server: s,
iconUrl: snap.data ?? '',
busy: _busy.contains(s.id),
onToggle: () => _toggle(s),
),
);
},
);
}
}
+64
View File
@@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import '../services/api_service.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key, required this.api});
final ApiService api;
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
final _controller = TextEditingController();
String _version = '';
@override
void initState() {
super.initState();
widget.api.baseUrl.then((v) => _controller.text = v);
PackageInfo.fromPlatform()
.then((i) => setState(() => _version = '${i.version} (build ${i.buildNumber})'));
}
Future<void> _save() async {
await widget.api.setBaseUrl(_controller.text);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Enregistré')));
Navigator.pop(context);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Réglages')),
body: ListView(
padding: const EdgeInsets.all(20),
children: [
Text('Adresse de l\'API',
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)),
const SizedBox(height: 8),
TextField(
controller: _controller,
keyboardType: TextInputType.url,
autocorrect: false,
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'https://gamemanager-api.nfteam.ovh',
),
),
const SizedBox(height: 20),
FilledButton(onPressed: _save, child: const Text('Enregistrer')),
const SizedBox(height: 40),
Center(
child: Text('GameManager · v$_version',
style: TextStyle(color: Theme.of(context).colorScheme.outline)),
),
],
),
);
}
}
+62
View File
@@ -0,0 +1,62 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import '../config.dart';
import '../models.dart';
import 'auth_service.dart';
/// Client de l'API GameManager (Bearer JWT pocket-id).
class ApiService {
ApiService(this._auth);
final AuthService _auth;
Future<String> get baseUrl async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString('api_base') ?? Config.defaultApiBase;
}
Future<void> setBaseUrl(String url) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('api_base', url.trim().replaceAll(RegExp(r'/$'), ''));
}
Future<Map<String, String>> _headers() async {
final token = await _auth.validToken();
return {'Authorization': 'Bearer ${token ?? ''}', 'Accept': 'application/json'};
}
Future<List<GameServer>> servers() async {
final base = await baseUrl;
final res = await http.get(Uri.parse('$base/api/servers'), headers: await _headers());
if (res.statusCode == 401) throw UnauthorizedException();
if (res.statusCode != 200) throw ApiException('Erreur ${res.statusCode}');
final list = jsonDecode(res.body) as List;
return list.map((e) => GameServer.fromJson(e as Map<String, dynamic>)).toList();
}
Future<void> start(String id) => _action(id, 'start');
Future<void> stop(String id) => _action(id, 'stop');
Future<void> _action(String id, String action) async {
final base = await baseUrl;
final res = await http.post(
Uri.parse('$base/api/servers/$id/$action'),
headers: await _headers(),
);
if (res.statusCode == 401) throw UnauthorizedException();
if (res.statusCode >= 400) throw ApiException('Action refusée (${res.statusCode})');
}
Future<String> iconUrl(String id) async => '${await baseUrl}/api/servers/$id/icon';
}
class UnauthorizedException implements Exception {}
class ApiException implements Exception {
ApiException(this.message);
final String message;
@override
String toString() => message;
}
+70
View File
@@ -0,0 +1,70 @@
import 'package:flutter_appauth/flutter_appauth.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../config.dart';
/// Authentification OIDC pocket-id (Authorization Code + PKCE, client public).
/// Le jeton d'identité (JWT) est stocké de façon sécurisée et envoyé à l'API.
class AuthService {
final _appAuth = const FlutterAppAuth();
final _storage = const FlutterSecureStorage();
static const _kIdToken = 'id_token';
static const _kRefresh = 'refresh_token';
static const _kExpiry = 'access_expiry';
Future<String?> get idToken => _storage.read(key: _kIdToken);
Future<bool> isLoggedIn() async => (await idToken) != null;
Future<bool> login() async {
final result = await _appAuth.authorizeAndExchangeCode(
AuthorizationTokenRequest(
Config.oidcClientId,
Config.oidcRedirect,
issuer: Config.oidcIssuer,
scopes: Config.oidcScopes,
promptValues: ['login'],
),
);
if (result.idToken == null) return false;
await _storage.write(key: _kIdToken, value: result.idToken);
await _storage.write(key: _kRefresh, value: result.refreshToken);
await _storage.write(
key: _kExpiry,
value: result.accessTokenExpirationDateTime?.toIso8601String(),
);
return true;
}
/// Rafraîchit le jeton si expiré (via refresh_token), renvoie l'id_token courant.
Future<String?> validToken() async {
final expiry = await _storage.read(key: _kExpiry);
final refresh = await _storage.read(key: _kRefresh);
if (expiry != null && refresh != null) {
final exp = DateTime.tryParse(expiry);
if (exp != null && exp.isBefore(DateTime.now().add(const Duration(seconds: 30)))) {
try {
final r = await _appAuth.token(TokenRequest(
Config.oidcClientId,
Config.oidcRedirect,
issuer: Config.oidcIssuer,
refreshToken: refresh,
scopes: Config.oidcScopes,
));
if (r.idToken != null) {
await _storage.write(key: _kIdToken, value: r.idToken);
await _storage.write(key: _kRefresh, value: r.refreshToken ?? refresh);
await _storage.write(
key: _kExpiry,
value: r.accessTokenExpirationDateTime?.toIso8601String(),
);
}
} catch (_) {/* on garde l'ancien token, l'API renverra 401 si mort */}
}
}
return idToken;
}
Future<void> logout() async => _storage.deleteAll();
}
+43
View File
@@ -0,0 +1,43 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:ota_update/ota_update.dart';
import 'package:package_info_plus/package_info_plus.dart';
/// Vérifie s'il existe une version plus récente (API `/version`) et, si oui,
/// télécharge + installe l'APK (hors Play Store).
class UpdateService {
UpdateService(this._baseUrl);
final Future<String> _baseUrl;
Future<UpdateInfo?> check() async {
try {
final base = await _baseUrl;
final res = await http.get(Uri.parse('$base/version')).timeout(const Duration(seconds: 6));
if (res.statusCode != 200) return null;
final data = jsonDecode(res.body) as Map<String, dynamic>;
final remote = data['version_code'] as int? ?? 0;
final info = await PackageInfo.fromPlatform();
final current = int.tryParse(info.buildNumber) ?? 0;
if (remote > current) {
return UpdateInfo(
version: data['version'] as String? ?? '?',
apkUrl: data['apk_url'] as String? ?? '$base/download/apk',
notes: data['notes'] as String? ?? '',
);
}
} catch (_) {/* pas de réseau / pas d'update → on ignore */}
return null;
}
/// Lance le téléchargement + l'installation ; renvoie le flux de progression.
Stream<OtaEvent> apply(UpdateInfo info) =>
OtaUpdate().execute(info.apkUrl, destinationFilename: 'gamemanager.apk');
}
class UpdateInfo {
UpdateInfo({required this.version, required this.apkUrl, required this.notes});
final String version;
final String apkUrl;
final String notes;
}
+42
View File
@@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
/// Thèmes Material 3 (clair + sombre) avec un accent unifié.
class AppTheme {
static const _seed = Color(0xFF5E5CE6);
static ThemeData light() => _base(Brightness.light);
static ThemeData dark() => _base(Brightness.dark);
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,
centerTitle: false,
elevation: 0,
titleTextStyle: TextStyle(
color: scheme.onSurface,
fontSize: 24,
fontWeight: FontWeight.w800,
letterSpacing: -0.5,
),
),
cardTheme: CardThemeData(
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22)),
elevation: 0,
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 14),
textStyle: const TextStyle(fontWeight: FontWeight.w700, fontSize: 15),
),
),
);
}
}
+168
View File
@@ -0,0 +1,168 @@
import 'package:flutter/material.dart';
import '../models.dart';
class ServerCard extends StatelessWidget {
const ServerCard({
super.key,
required this.server,
required this.iconUrl,
required this.busy,
required this.onToggle,
});
final GameServer server;
final String iconUrl;
final bool busy;
final VoidCallback onToggle;
@override
Widget build(BuildContext context) {
final t = server.theme;
final badge = server.statusBadge;
return Card(
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [t.start, t.end],
),
),
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),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(server.name,
style: const TextStyle(
color: Colors.white,
fontSize: 19,
fontWeight: FontWeight.w800),
maxLines: 1,
overflow: TextOverflow.ellipsis),
if (server.subtitle != null)
Text(server.subtitle!,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.75), fontSize: 13)),
],
),
),
_StatusPill(label: badge.label, color: badge.color, pulse: server.isBusy),
],
),
const SizedBox(height: 16),
Row(
children: [
if (server.status == 'online') ...[
const Icon(Icons.people, color: Colors.white70, size: 18),
const SizedBox(width: 5),
Text('${server.players ?? 0}/${server.maxPlayers ?? 0}',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600)),
const SizedBox(width: 14),
],
if (server.connect != null)
Expanded(
child: Text(server.connect!,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.7),
fontFamily: 'monospace',
fontSize: 12),
maxLines: 1,
overflow: TextOverflow.ellipsis),
)
else
const Spacer(),
_ToggleButton(up: server.isUp, busy: busy, onToggle: onToggle),
],
),
],
),
),
);
}
}
class _Icon extends StatelessWidget {
const _Icon({required this.server, required this.iconUrl, required this.fallback});
final GameServer server;
final String iconUrl;
final IconData fallback;
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
width: 52,
height: 52,
child: (server.hasIcon)
? Image.network(iconUrl,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _fallbackBox())
: _fallbackBox(),
),
);
}
Widget _fallbackBox() => Container(
color: Colors.white.withValues(alpha: 0.15),
child: Icon(fallback, color: Colors.white, size: 28),
);
}
class _StatusPill extends StatelessWidget {
const _StatusPill({required this.label, required this.color, required this.pulse});
final String label;
final Color color;
final bool pulse;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.22),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: color, width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(width: 7, height: 7, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
const SizedBox(width: 6),
Text(label, style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w700)),
],
),
);
}
}
class _ToggleButton extends StatelessWidget {
const _ToggleButton({required this.up, required this.busy, required this.onToggle});
final bool up;
final bool busy;
final VoidCallback onToggle;
@override
Widget build(BuildContext context) {
return FilledButton.icon(
onPressed: busy ? null : onToggle,
style: FilledButton.styleFrom(
backgroundColor: up ? Colors.white.withValues(alpha: 0.9) : Colors.white,
foregroundColor: up ? const Color(0xFFB3261E) : const Color(0xFF1B5E20),
),
icon: busy
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
: Icon(up ? Icons.stop : Icons.play_arrow),
label: Text(up ? 'Éteindre' : 'Démarrer'),
);
}
}
+24
View File
@@ -0,0 +1,24 @@
name: gamemanager
description: Gestion des serveurs de jeu du homelab nfteam.
publish_to: "none"
version: 1.0.0+1
environment:
sdk: ">=3.5.0 <4.0.0"
dependencies:
flutter:
sdk: flutter
http: ^1.2.2
flutter_appauth: ^8.0.0
flutter_secure_storage: ^9.2.2
shared_preferences: ^2.3.2
package_info_plus: ^8.1.1
ota_update: ^6.0.0
cupertino_icons: ^1.0.8
dev_dependencies:
flutter_lints: ^5.0.0
flutter:
uses-material-design: true