Build APK / build (push) Successful in 2m49s
- ServerCard: fond = image (MemoryImage) + scrim dégradé si présente, sinon dégradé thématique ; vignette d'icône masquée quand image de fond. - servers_screen: fetch des bytes image (cache par id, vidé au refresh) → passe à la carte ; plus de FutureBuilder iconUrl inutile. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
250 lines
8.0 KiB
Dart
250 lines
8.0 KiB
Dart
import 'dart:typed_data';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
|
|
import '../models.dart';
|
|
|
|
class ServerCard extends StatelessWidget {
|
|
const ServerCard({
|
|
super.key,
|
|
required this.server,
|
|
required this.iconUrl,
|
|
required this.busy,
|
|
required this.onToggle,
|
|
this.onTap,
|
|
this.imageBytes,
|
|
});
|
|
|
|
final GameServer server;
|
|
final String iconUrl;
|
|
final bool busy;
|
|
final VoidCallback onToggle;
|
|
final VoidCallback? onTap;
|
|
final Uint8List? imageBytes;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = server.theme;
|
|
final badge = server.statusBadge;
|
|
final hasImg = imageBytes != null;
|
|
return Card(
|
|
clipBehavior: Clip.antiAlias,
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
child: Ink(
|
|
decoration: hasImg
|
|
? BoxDecoration(
|
|
image: DecorationImage(image: MemoryImage(imageBytes!), fit: BoxFit.cover),
|
|
)
|
|
: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: [t.start, t.end],
|
|
),
|
|
),
|
|
child: Container(
|
|
decoration: hasImg
|
|
? const BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [Color(0x66000000), Color(0xCC000000)],
|
|
),
|
|
)
|
|
: null,
|
|
padding: const EdgeInsets.all(18),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
if (!hasImg) ...[
|
|
_Icon(server: server, iconUrl: iconUrl, fallback: t.icon),
|
|
const SizedBox(width: 14),
|
|
],
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Flexible(
|
|
child: Text(server.name,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 19,
|
|
fontWeight: FontWeight.w800),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis),
|
|
),
|
|
if (server.isPreprod) ...[
|
|
const SizedBox(width: 8),
|
|
const _Tag(label: 'PREPROD'),
|
|
],
|
|
],
|
|
),
|
|
if (server.subtitle != null)
|
|
Text(server.subtitle!,
|
|
style: TextStyle(
|
|
color: Colors.white.withOpacity(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.withOpacity(0.7),
|
|
fontFamily: 'monospace',
|
|
fontSize: 12),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis),
|
|
)
|
|
else
|
|
const Spacer(),
|
|
if (server.web && server.openUrl != null) ...[
|
|
_OpenButton(url: server.openUrl!),
|
|
const SizedBox(width: 8),
|
|
],
|
|
_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 && iconUrl.isNotEmpty)
|
|
? Image.network(iconUrl,
|
|
fit: BoxFit.cover,
|
|
errorBuilder: (_, __, ___) => _fallbackBox())
|
|
: _fallbackBox(),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _fallbackBox() => Container(
|
|
color: Colors.white.withOpacity(0.15),
|
|
child: Icon(fallback, color: Colors.white, size: 28),
|
|
);
|
|
}
|
|
|
|
class _Tag extends StatelessWidget {
|
|
const _Tag({required this.label});
|
|
final String label;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: Colors.black.withOpacity(0.28),
|
|
borderRadius: BorderRadius.circular(6),
|
|
border: Border.all(color: Colors.white.withOpacity(0.5), width: 1),
|
|
),
|
|
child: Text(label,
|
|
style: const TextStyle(
|
|
color: Colors.white, fontSize: 10, fontWeight: FontWeight.w800, letterSpacing: 0.6)),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _OpenButton extends StatelessWidget {
|
|
const _OpenButton({required this.url});
|
|
final String url;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return OutlinedButton.icon(
|
|
onPressed: () => launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication),
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: Colors.white,
|
|
side: BorderSide(color: Colors.white.withOpacity(0.7)),
|
|
),
|
|
icon: const Icon(Icons.open_in_new, size: 18),
|
|
label: const Text('Ouvrir'),
|
|
);
|
|
}
|
|
}
|
|
|
|
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.withOpacity(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.withOpacity(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'),
|
|
);
|
|
}
|
|
}
|