diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..ffd4ffa --- /dev/null +++ b/.gitea/workflows/build.yml @@ -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#\n \n android/key.properties < version.json </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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0775aad --- /dev/null +++ b/.gitignore @@ -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 diff --git a/lib/config.dart b/lib/config.dart new file mode 100644 index 0000000..3ef6550 --- /dev/null +++ b/lib/config.dart @@ -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 oidcScopes = ['openid', 'profile', 'email']; +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..79acf62 --- /dev/null +++ b/lib/main.dart @@ -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 createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + 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), + ); + } +} diff --git a/lib/models.dart b/lib/models.dart new file mode 100644 index 0000000..b24f551 --- /dev/null +++ b/lib/models.dart @@ -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 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)), + }; +} diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart new file mode 100644 index 0000000..b23063d --- /dev/null +++ b/lib/screens/login_screen.dart @@ -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 createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + bool _busy = false; + String? _error; + + Future _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'), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/servers_screen.dart b/lib/screens/servers_screen.dart new file mode 100644 index 0000000..d7d2925 --- /dev/null +++ b/lib/screens/servers_screen.dart @@ -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 createState() => _ServersScreenState(); +} + +class _ServersScreenState extends State { + List? _servers; + String? _error; + final _busy = {}; + + @override + void initState() { + super.initState(); + _refresh(); + _checkUpdate(); + } + + Future _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 _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 _checkUpdate() async { + final info = await UpdateService(widget.api.baseUrl).check(); + if (info == null || !mounted) return; + final go = await showDialog( + 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 _openSettings() async { + await Navigator.push(context, + MaterialPageRoute(builder: (_) => SettingsScreen(api: widget.api))); + _refresh(); + } + + Future _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( + future: widget.api.iconUrl(s.id), + builder: (_, snap) => ServerCard( + server: s, + iconUrl: snap.data ?? '', + busy: _busy.contains(s.id), + onToggle: () => _toggle(s), + ), + ); + }, + ); + } +} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart new file mode 100644 index 0000000..1766e71 --- /dev/null +++ b/lib/screens/settings_screen.dart @@ -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 createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends State { + 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 _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)), + ), + ], + ), + ); + } +} diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart new file mode 100644 index 0000000..3c567c9 --- /dev/null +++ b/lib/services/api_service.dart @@ -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 get baseUrl async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString('api_base') ?? Config.defaultApiBase; + } + + Future setBaseUrl(String url) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('api_base', url.trim().replaceAll(RegExp(r'/$'), '')); + } + + Future> _headers() async { + final token = await _auth.validToken(); + return {'Authorization': 'Bearer ${token ?? ''}', 'Accept': 'application/json'}; + } + + Future> 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)).toList(); + } + + Future start(String id) => _action(id, 'start'); + Future stop(String id) => _action(id, 'stop'); + + Future _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 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; +} diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart new file mode 100644 index 0000000..23ac3f8 --- /dev/null +++ b/lib/services/auth_service.dart @@ -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 get idToken => _storage.read(key: _kIdToken); + + Future isLoggedIn() async => (await idToken) != null; + + Future 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 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 logout() async => _storage.deleteAll(); +} diff --git a/lib/services/update_service.dart b/lib/services/update_service.dart new file mode 100644 index 0000000..a3b2059 --- /dev/null +++ b/lib/services/update_service.dart @@ -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 _baseUrl; + + Future 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; + 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 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; +} diff --git a/lib/theme.dart b/lib/theme.dart new file mode 100644 index 0000000..e7482e1 --- /dev/null +++ b/lib/theme.dart @@ -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), + ), + ), + ); + } +} diff --git a/lib/widgets/server_card.dart b/lib/widgets/server_card.dart new file mode 100644 index 0000000..380b384 --- /dev/null +++ b/lib/widgets/server_card.dart @@ -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'), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..662c96b --- /dev/null +++ b/pubspec.yaml @@ -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