refactor(guess): split guess_page into focused sub-widgets (<150 lines)

Extract GuessSilhouette, GenFilterSection, LivesRow, GuessInputSection,
ScoreBoard and a shared ScanlineOverlay. guess_page.dart 440 -> 149 lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 11:44:55 +02:00
co-authored by Claude Opus 4.8
parent d12b01b4ab
commit cb9e214aa2
7 changed files with 451 additions and 322 deletions
@@ -0,0 +1,132 @@
import 'package:flutter/material.dart';
import '../../../core/config/app_constants.dart';
/// Bouton "GEN FILTER" et son panneau dépliable de sélection des générations.
class GenFilterSection extends StatelessWidget {
final bool isOpen;
final VoidCallback onToggleOpen;
final Set<int> selectedGens;
final void Function(int) onToggle;
const GenFilterSection({
super.key,
required this.isOpen,
required this.onToggleOpen,
required this.selectedGens,
required this.onToggle,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
children: [
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: onToggleOpen,
icon: Icon(
isOpen ? Icons.expand_less : Icons.filter_list,
color: const Color(0xFF1B2333),
),
label: const Text(
'GEN FILTER',
style: TextStyle(
color: Color(0xFF1B2333),
fontWeight: FontWeight.bold,
fontSize: 16,
letterSpacing: 2,
),
),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Color(0xFF1B2333), width: 2),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
),
),
),
AnimatedSize(
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
child: isOpen
? _GenFilterPanel(selectedGens: selectedGens, onToggle: onToggle)
: const SizedBox.shrink(),
),
],
),
);
}
}
class _GenFilterPanel extends StatelessWidget {
final Set<int> selectedGens;
final void Function(int) onToggle;
const _GenFilterPanel({required this.selectedGens, required this.onToggle});
static const _genNames = [
'Gen I', 'Gen II', 'Gen III', 'Gen IV', 'Gen V', 'Gen VI', 'Gen VII', 'Gen VIII', 'Gen IX'
];
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(top: 4),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: const Color(0xFF1B2333), width: 2),
),
child: Column(
children: List.generate(AppConstants.genRanges.length, (i) {
final range = AppConstants.genRanges[i];
final isSelected = selectedGens.contains(i);
final isLast = i == AppConstants.genRanges.length - 1;
return InkWell(
onTap: () => onToggle(i),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: isSelected ? const Color(0xFF1B2333).withAlpha(12) : Colors.transparent,
border: isLast ? null : Border(bottom: BorderSide(color: Colors.grey.shade200)),
),
child: Row(
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: 20,
height: 20,
decoration: BoxDecoration(
color: isSelected ? const Color(0xFF3B6EE3) : Colors.transparent,
border: Border.all(
color: isSelected ? const Color(0xFF3B6EE3) : Colors.grey.shade400,
width: 2,
),
borderRadius: BorderRadius.circular(3),
),
child: isSelected ? const Icon(Icons.check, size: 13, color: Colors.white) : null,
),
const SizedBox(width: 12),
Expanded(
child: Text(
_genNames[i],
style: TextStyle(
fontSize: 16,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? const Color(0xFF1B2333) : Colors.black54,
),
),
),
Text(
'#${range.$1}#${range.$2}',
style: TextStyle(fontSize: 12, color: Colors.grey.shade500),
),
],
),
),
);
}),
),
);
}
}
@@ -0,0 +1,136 @@
import 'package:flutter/material.dart';
/// Section de saisie : indice optionnel, champ de réponse et boutons d'action
/// (Guess / Continue / Hint / Skip). Purement présentationnelle : tout passe par les callbacks.
class GuessInputSection extends StatelessWidget {
final TextEditingController controller;
final String pokemonName;
final bool isHintUsed;
final bool isGuessed;
final int hints;
final int skips;
final VoidCallback onGuess;
final VoidCallback onContinue;
final VoidCallback? onHint;
final VoidCallback? onSkip;
const GuessInputSection({
super.key,
required this.controller,
required this.pokemonName,
required this.isHintUsed,
required this.isGuessed,
required this.hints,
required this.skips,
required this.onGuess,
required this.onContinue,
required this.onHint,
required this.onSkip,
});
/// Masque le nom en ne laissant visibles que la première et la dernière lettre.
String _maskedName(String name) {
if (name.length <= 2) return name; // trop court pour masquer utilement
return '${name[0]}${List.filled(name.length - 2, '_').join()}${name[name.length - 1]}';
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
"IDENTIFICATION INPUT",
style: TextStyle(color: Colors.black54, fontSize: 12, fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
if (isHintUsed)
Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: Colors.amber[100],
border: Border.all(color: Colors.amber[600]!, width: 2),
borderRadius: BorderRadius.circular(8),
),
child: Text(
"HINT: ${_maskedName(pokemonName)}",
textAlign: TextAlign.center,
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.amber[900], letterSpacing: 4),
),
),
Container(
decoration: BoxDecoration(color: Colors.white, border: Border.all(color: Colors.grey[400]!)),
child: TextField(
controller: controller,
style: const TextStyle(fontSize: 24, letterSpacing: 1.5),
decoration: const InputDecoration(
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
border: InputBorder.none,
hintText: 'Enter Pokémon name...',
),
onSubmitted: (_) => onGuess(),
),
),
const SizedBox(height: 16),
if (isGuessed)
_bigButton(label: "CONTINUE", color: Colors.green, onPressed: onContinue)
else ...[
_bigButton(label: "GUESS!", color: const Color(0xFF3B6EE3), onPressed: onGuess),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _actionButton(icon: Icons.lightbulb, label: "HINT ($hints)", color: Colors.amber, onPressed: onHint),
),
const SizedBox(width: 8),
Expanded(
child: _actionButton(icon: Icons.skip_next, label: "SKIP ($skips)", color: Colors.grey[400]!, onPressed: onSkip),
),
],
),
],
],
),
);
}
Widget _bigButton({required String label, required Color color, required VoidCallback onPressed}) {
return SizedBox(
width: double.infinity,
height: 60,
child: ElevatedButton(
onPressed: onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: color,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
),
child: Text(
label,
style: const TextStyle(fontSize: 24, color: Colors.white, fontWeight: FontWeight.bold, letterSpacing: 2),
),
),
);
}
Widget _actionButton({
required IconData icon,
required String label,
required Color color,
required VoidCallback? onPressed,
}) {
return ElevatedButton.icon(
onPressed: onPressed,
icon: Icon(icon, color: Colors.black87),
label: Text(label, style: const TextStyle(color: Colors.black87, fontWeight: FontWeight.bold, fontSize: 18)),
style: ElevatedButton.styleFrom(
backgroundColor: color,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
),
);
}
}
@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import '../../../domain/entities/pokemon.dart';
import '../pokemon_image.dart';
/// Écran bleu affichant la silhouette (jeu en cours) ou l'image révélée (manche gagnée).
class GuessSilhouette extends StatelessWidget {
final Pokemon pokemon;
final bool isShiny;
final bool isGuessed;
const GuessSilhouette({
super.key,
required this.pokemon,
required this.isShiny,
required this.isGuessed,
});
@override
Widget build(BuildContext context) {
final imageUrl = isShiny ? pokemon.shinyImageUrl : pokemon.imageUrl;
return Container(
height: 250,
width: double.infinity,
margin: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF3B6EE3),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF1B2333), width: 8),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: isGuessed
? PokemonImage(
imageUrl: imageUrl,
fallbackUrl: pokemon.imageUrl,
fit: BoxFit.contain,
)
: PokemonImage(
imageUrl: imageUrl,
fallbackUrl: pokemon.imageUrl,
fit: BoxFit.contain,
color: isShiny ? Colors.yellow[700]! : Colors.black,
colorBlendMode: BlendMode.srcIn,
),
),
),
Container(
color: const Color(0xFF1B2333),
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
isShiny ? "✨ SHINY POKÉMON DETECTED! ✨" : "WHO'S THAT POKÉMON?",
textAlign: TextAlign.center,
style: TextStyle(
color: isShiny ? Colors.yellow[400] : Colors.white,
fontSize: isShiny ? 18 : 22,
fontWeight: FontWeight.bold,
letterSpacing: 2,
),
),
),
],
),
);
}
}
@@ -0,0 +1,22 @@
import 'package:flutter/material.dart';
import '../../../core/config/app_constants.dart';
/// Rangée de cœurs représentant les vies restantes.
class LivesRow extends StatelessWidget {
final int lives;
const LivesRow({super.key, required this.lives});
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(AppConstants.startingLives, (index) {
return Icon(
index < lives ? Icons.favorite : Icons.favorite_border,
color: Colors.red,
size: 32,
);
}),
);
}
}
@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
/// Encart affichant le score courant et le meilleur score personnel.
class ScoreBoard extends StatelessWidget {
final int currentScore;
final int bestScore;
const ScoreBoard({super.key, required this.currentScore, required this.bestScore});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white.withAlpha(153),
border: Border.all(color: Colors.black12),
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
const Text(
"CURRENT SCORE",
style: TextStyle(color: Colors.black54, fontSize: 14, fontWeight: FontWeight.bold),
),
Text(
"$currentScore",
style: const TextStyle(fontSize: 32, fontWeight: FontWeight.bold, color: Color(0xFF3B6EE3)),
),
const Divider(height: 24),
Text(
"PERSONAL BEST: $bestScore",
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.black87),
),
],
),
);
}
}
@@ -0,0 +1,21 @@
import 'package:flutter/material.dart';
/// Effet rétro de scanlines superposé en fond. À placer directement dans un [Stack].
class ScanlineOverlay extends StatelessWidget {
const ScanlineOverlay({super.key});
@override
Widget build(BuildContext context) {
return Positioned.fill(
child: ListView.builder(
itemCount: 100,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) => Container(
height: 4,
margin: const EdgeInsets.only(bottom: 4),
color: Colors.black.withAlpha(2),
),
),
);
}
}