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:
co-authored by
Claude Opus 4.8
parent
2c5476b1db
commit
3286640b37
@@ -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'];
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user