Files
neckfireandClaude Opus 4.8 fb4ab95f13
Build APK / build (push) Successful in 2m49s
feat(ui): la carte reprend l'image importée en fond (voile pour lisibilité)
- 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>
2026-07-13 13:00:10 +02:00

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'),
);
}
}