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