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,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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user