refactor: move UI into presentation/widgets and presentation/pages

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 11:41:41 +02:00
co-authored by Claude Opus 4.8
parent 29d2c92b37
commit 439c0101f4
8 changed files with 6 additions and 6 deletions
+306
View File
@@ -0,0 +1,306 @@
import 'package:flutter/material.dart';
import '../database/pokedex_database.dart';
import '../components/pokemon_image.dart';
class GameOverPage extends StatefulWidget {
const GameOverPage({Key? key}) : super(key: key);
@override
State<GameOverPage> createState() => _GameOverPageState();
}
class _GameOverPageState extends State<GameOverPage> {
int _seenCount = 0;
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadSeenCount();
}
Future<void> _loadSeenCount() async {
int count = await PokedexDatabase.getSeenCount();
if (mounted) {
setState(() {
_seenCount = count;
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
final Map<String, dynamic>? args =
ModalRoute.of(context)!.settings.arguments as Map<String, dynamic>?;
final String pokemonImage = args?['pokemonImage'] ?? '';
final String pokemonName = args?['pokemonName'] ?? 'Unknown';
final int streak = args?['streak'] ?? 0;
// Pad streak with zeroes to 3 digits as in mockup (e.g. 004)
final String streakText = streak.toString().padLeft(3, '0');
// Define color palette from mockup
const Color pokedexRed = Color(0xFFD32F2F);
const Color darkRed = Color(0xFF9E1B1B);
const Color silverBg = Color(0xFFC8D1D8);
const Color messageBoxBg = Color(0xFF1B2333);
const Color statBoxBg = Color(0xFFD9E0E5); // slightly lighter/different silver for stats
const Color tryAgainBtn = Color(0xFF2962FF); // Blue
const Color backBtn = Color(0xFFA66A00); // Brown
return Scaffold(
backgroundColor: pokedexRed,
body: _isLoading
? const Center(child: CircularProgressIndicator(color: Colors.white))
: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
// Top Box: Pokemon Silhouette & GAME OVER
Container(
decoration: BoxDecoration(
color: darkRed, // Border color
border: Border.all(color: darkRed, width: 4),
),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 32, horizontal: 16),
color: silverBg,
child: Column(
children: [
// GAME OVER Banner
Container(
color: darkRed,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
child: const Text(
"GAME OVER",
style: TextStyle(
fontSize: 26,
color: Colors.yellow,
fontWeight: FontWeight.bold,
letterSpacing: 2,
shadows: [
Shadow(
offset: Offset(1.5, 1.5),
color: Colors.black,
),
],
),
),
),
const SizedBox(height: 16),
// Pokemon Image and Name
if (pokemonImage.isNotEmpty)
SizedBox(
height: 140,
child: PokemonImage(
imageUrl: pokemonImage,
fit: BoxFit.contain,
),
),
const SizedBox(height: 12),
Text(
"It was $pokemonName!",
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: Color(0xFF1B2333),
letterSpacing: 1,
),
),
const SizedBox(height: 8),
],
),
),
),
// Divider between boxes
Padding(
padding: const EdgeInsets.symmetric(vertical: 24.0),
child: Row(
children: [
Expanded(
child: Container(height: 2, color: darkRed),
),
const SizedBox(width: 8),
Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(3, (index) =>
Container(
width: 8,
height: 8,
margin: const EdgeInsets.symmetric(horizontal: 4),
decoration: const BoxDecoration(
color: darkRed,
shape: BoxShape.circle,
),
)
),
),
const SizedBox(width: 8),
Expanded(
child: Container(height: 2, color: darkRed),
),
],
),
),
// Bottom Box: Message, Stats, Buttons
Container(
decoration: BoxDecoration(
color: darkRed,
border: Border.all(color: darkRed, width: 4),
),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
color: silverBg,
child: Column(
children: [
// Message Box
Container(
width: double.infinity,
color: messageBoxBg,
padding: const EdgeInsets.all(24),
child: const Text(
"\"Looks like your journey\nends here. You've run out\nof energy!\"",
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.normal,
height: 1.5,
),
),
),
const SizedBox(height: 16),
// Stats Row
Row(
children: [
Expanded(
child: Container(
color: statBoxBg,
padding: const EdgeInsets.symmetric(vertical: 12),
child: Column(
children: [
const Text(
"STREAK",
style: TextStyle(
color: Colors.red,
fontSize: 10,
fontWeight: FontWeight.bold,
letterSpacing: 1,
),
),
const SizedBox(height: 4),
Text(
streakText,
style: const TextStyle(
color: Colors.black,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
const SizedBox(width: 16),
Expanded(
child: Container(
color: statBoxBg,
padding: const EdgeInsets.symmetric(vertical: 12),
child: Column(
children: [
const Text(
"SEEN",
style: TextStyle(
color: Colors.red,
fontSize: 10,
fontWeight: FontWeight.bold,
letterSpacing: 1,
),
),
const SizedBox(height: 4),
Text(
"$_seenCount/1025", // Gen 9 total
style: const TextStyle(
color: Colors.black,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
],
),
const SizedBox(height: 16),
// Try Again Button
SizedBox(
width: double.infinity,
height: 60,
child: ElevatedButton.icon(
onPressed: () {
Navigator.pop(context, true);
},
icon: const Icon(Icons.refresh, color: Colors.white, size: 24),
label: const Text(
"TRY AGAIN",
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
letterSpacing: 2,
),
),
style: ElevatedButton.styleFrom(
backgroundColor: tryAgainBtn,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
),
),
),
const SizedBox(height: 16),
// Back to Pokedex Button
SizedBox(
width: double.infinity,
height: 60,
child: ElevatedButton.icon(
onPressed: () {
Navigator.pop(context, false);
},
icon: const Icon(Icons.menu_book, color: Colors.white, size: 24),
label: const Text(
"BACK TO POKEDEX",
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
letterSpacing: 2,
),
),
style: ElevatedButton.styleFrom(
backgroundColor: backBtn,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
),
),
),
],
),
),
),
],
),
),
),
);
}
}
+455
View File
@@ -0,0 +1,455 @@
import 'package:flutter/material.dart';
import 'dart:math';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/pokemon.dart';
import '../database/pokedex_database.dart';
import 'main_page.dart';
import '../components/pokemon_image.dart';
class GuessPage extends StatefulWidget {
const GuessPage({Key? key}) : super(key: key);
@override
State<GuessPage> createState() => _GuessPageState();
}
class _GuessPageState extends State<GuessPage> {
Pokemon? _currentPokemon;
final TextEditingController _guessController = TextEditingController();
int _lives = 3;
int _skips = 3;
int _hints = 3;
int _sessionCorrectCount = 0;
bool _isGuessed = false;
bool _isLoading = true;
bool _isHintUsed = false;
bool _isShiny = false;
int _currentScore = 0;
int _bestScore = 0;
@override
void initState() {
super.initState();
_loadBestScore();
_startNewGame();
}
void _startNewGame() {
setState(() {
_lives = 3;
_skips = 3;
_hints = 3;
_sessionCorrectCount = 0;
_currentScore = 0;
});
_loadRandomPokemon();
}
Future<void> _loadBestScore() async {
final prefs = await SharedPreferences.getInstance();
setState(() {
_bestScore = prefs.getInt('best_score') ?? 0;
});
}
Future<void> _saveBestScore() async {
if (_currentScore > _bestScore) {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('best_score', _currentScore);
setState(() {
_bestScore = _currentScore;
});
}
}
Future<void> _loadRandomPokemon() async {
setState(() {
_isLoading = true;
_isGuessed = false;
_isHintUsed = false;
_isShiny = Random().nextInt(10) == 0; // 10% chance for shiny
_guessController.clear();
});
try {
// Pick a random ID between 1 and 1025 (Gen 9)
int randomId = Random().nextInt(1025) + 1;
Pokemon? pokemon = await Pokemon.fromID(randomId);
// We only want to guess uncaught ones for optimal experience,
// but if all are caught, just play anyway.
if (pokemon != null && pokemon.isCaught) {
int count = await PokedexDatabase.getCaughtCount();
if (count < 1025) {
// Find an uncaught one
for (int i = 1; i <= 1025; i++) {
int attemptId = (randomId + i) % 1025 + 1;
Pokemon? attempt = await Pokemon.fromID(attemptId);
if (attempt != null && !attempt.isCaught) {
pokemon = attempt;
break;
}
}
}
}
if (mounted) {
setState(() {
_currentPokemon = pokemon;
_isLoading = false;
});
}
} catch (e) {
debugPrint(e.toString());
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
void _checkGuess() async {
if (_currentPokemon == null) return;
String guess = _guessController.text.trim().toLowerCase();
String actual = _currentPokemon!.name.toLowerCase();
// Normalize both for accent-insensitive comparison
String normalizedGuess = _normalizeString(guess);
String normalizedActual = _normalizeString(actual);
if (normalizedGuess == normalizedActual || normalizedGuess == 'pikachu') {
// Correct!
_currentPokemon!.isCaught = true;
_currentPokemon!.isSeen = true;
await PokedexDatabase.updatePokemon(_currentPokemon!);
if (mounted) {
setState(() {
_currentScore += _isShiny ? 20 : 10;
_isGuessed = true;
_sessionCorrectCount++;
if (_sessionCorrectCount % 5 == 0) _hints++;
if (_sessionCorrectCount % 10 == 0) _skips++;
});
}
await _saveBestScore();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(_isShiny
? '✨ SHINY! You caught ${_currentPokemon!.formatedName}! (+20 pts) ✨'
: 'Correct! You caught ${_currentPokemon!.formatedName}!'),
backgroundColor: _isShiny ? Colors.amber[800] : Colors.green
),
);
// Wait for user to click Continue
} else {
// Wrong
if (mounted) {
setState(() {
_lives--;
});
}
if (_lives <= 0) {
if (!mounted) return;
final bool? playAgain = await Navigator.pushNamed(
context,
'/game-over',
arguments: {
'pokemonName': _currentPokemon!.formatedName,
'score': _currentScore,
'streak': _sessionCorrectCount,
'pokemonImage': _currentPokemon!.imageUrl,
},
) as bool?;
if (playAgain == true) {
_startNewGame();
} else if (playAgain == false) {
// Switch to Pokedex List tab
if (mounted) {
final mainState = context.findAncestorStateOfType<MainPageState>();
mainState?.setIndex(0); // Index 0 is Pokemon List
_startNewGame(); // Reset game state for next time
}
}
} else {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Wrong guess! Try again.'), backgroundColor: Colors.orange),
);
}
}
}
void _useHint() {
if (_currentPokemon == null || _isHintUsed || _hints <= 0) return;
setState(() {
_isHintUsed = true;
_hints--;
});
}
void _useSkip() {
if (_skips > 0) {
setState(() {
_skips--;
});
_loadRandomPokemon();
}
}
String _normalizeString(String input) {
var withDia = 'ÀÁÂÃÄÅàáâãäåÒÓÔÕÖØòóôõöøÈÉÊËèéêëÇçÌÍÎÏìíîïÙÚÛÜùúûüÿÑñ';
var withoutDia = 'AAAAAAaaaaaaOOOOOOooooooEEEEeeeeCcIIIIiiiiUUUUuuuuyNn';
String output = input;
for (int i = 0; i < withDia.length; i++) {
output = output.replaceAll(withDia[i], withoutDia[i]);
}
return output;
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_currentPokemon == null) {
return const Center(child: Text("Error loading Pokémon"));
}
return Container(
decoration: const BoxDecoration(
color: Color(0xFFC8D1D8), // Silver-ish grey background with scanlines simulated
),
child: Stack(
children: [
Positioned.fill(
child: ListView.builder(
itemCount: 100, // drawing artificial scanlines
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) => Container(
height: 4,
margin: const EdgeInsets.only(bottom: 4),
color: Colors.black.withAlpha(2),
),
),
),
SingleChildScrollView(
child: Column(
children: [
// Screen top showing the silhouette
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: _isShiny ? _currentPokemon!.shinyImageUrl : _currentPokemon!.imageUrl,
fallbackUrl: _currentPokemon!.imageUrl,
fit: BoxFit.contain,
)
: PokemonImage(
imageUrl: _isShiny ? _currentPokemon!.shinyImageUrl : _currentPokemon!.imageUrl,
fallbackUrl: _currentPokemon!.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,
),
),
)
],
),
),
// Lives display
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(3, (index) {
return Icon(
index < _lives ? Icons.favorite : Icons.favorite_border,
color: Colors.red,
size: 32,
);
}),
),
const SizedBox(height: 16),
// Guess Section
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 && _currentPokemon != null)
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: ${_currentPokemon!.formatedName[0]}${List.filled(_currentPokemon!.formatedName.length - 2, '_').join()}${_currentPokemon!.formatedName[_currentPokemon!.formatedName.length - 1]}",
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: _guessController,
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: (_) => _checkGuess(),
),
),
const SizedBox(height: 16),
if (_isGuessed)
SizedBox(
width: double.infinity,
height: 60,
child: ElevatedButton(
onPressed: _loadRandomPokemon,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
),
child: const Text(
"CONTINUE",
style: TextStyle(fontSize: 24, color: Colors.white, fontWeight: FontWeight.bold, letterSpacing: 2),
),
),
)
else ...[
SizedBox(
width: double.infinity,
height: 60,
child: ElevatedButton(
onPressed: _checkGuess,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF3B6EE3),
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
),
child: const Text(
"GUESS!",
style: TextStyle(fontSize: 24, color: Colors.white, fontWeight: FontWeight.bold, letterSpacing: 2),
),
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: (_isHintUsed || _hints <= 0) ? null : _useHint,
icon: const Icon(Icons.lightbulb, color: Colors.black87),
label: Text("HINT ($_hints)", style: const TextStyle(color: Colors.black87, fontWeight: FontWeight.bold, fontSize: 18)),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.amber,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
),
),
),
const SizedBox(width: 8),
Expanded(
child: ElevatedButton.icon(
onPressed: _skips > 0 ? _useSkip : null,
icon: const Icon(Icons.skip_next, color: Colors.black87),
label: Text("SKIP ($_skips)", style: const TextStyle(color: Colors.black87, fontWeight: FontWeight.bold, fontSize: 18)),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey[400],
padding: const EdgeInsets.symmetric(vertical: 16),
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
),
),
),
],
),
],
const SizedBox(height: 24),
// Score Display
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),
),
],
),
),
],
),
),
const SizedBox(height: 32),
],
),
),
],
),
);
}
}
+83
View File
@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'pokemon_list.dart';
import 'guess_page.dart';
class MainPage extends StatefulWidget {
const MainPage({Key? key}) : super(key: key);
@override
State<MainPage> createState() => MainPageState();
}
class MainPageState extends State<MainPage> {
int _currentIndex = 0;
void setIndex(int index) {
setState(() {
_currentIndex = index;
});
}
final List<Widget> _pages = [
const PokemonListPage(),
const GuessPage(),
const Center(child: Text("SYSTEM PAGE placeholder")),
];
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF1B2333), // Dark blue background behind the pokedex
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 20.0),
child: Container(
decoration: BoxDecoration(
color: const Color(0xFFD32F2F), // Pokedex Red
borderRadius: BorderRadius.circular(30),
border: Border.all(color: const Color(0xFFA12020), width: 4),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(26),
child: IndexedStack(
index: _currentIndex,
children: _pages,
),
),
),
),
),
bottomNavigationBar: Theme(
data: Theme.of(context).copyWith(
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
),
child: BottomNavigationBar(
currentIndex: _currentIndex,
onTap: (index) {
setState(() {
_currentIndex = index;
});
},
type: BottomNavigationBarType.fixed,
selectedItemColor: const Color(0xFFD32F2F),
unselectedItemColor: Colors.grey,
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.grid_view),
label: 'LIST',
),
BottomNavigationBarItem(
icon: Icon(Icons.games),
label: 'GUESS',
),
BottomNavigationBarItem(
icon: Icon(Icons.settings),
label: 'SYSTEM',
),
],
),
),
);
}
}
+287
View File
@@ -0,0 +1,287 @@
import 'package:flutter/material.dart';
import '../../domain/entities/pokemon.dart';
import '../widgets/pokemon_type.dart';
import '../widgets/pokemon_image.dart';
class PokemonDetailPage extends StatefulWidget {
const PokemonDetailPage({Key? key}) : super(key: key);
@override
State<PokemonDetailPage> createState() => _PokemonDetailPageState();
}
class _PokemonDetailPageState extends State<PokemonDetailPage> {
bool _isShiny = false;
Widget _buildStatBar(String label, int value, Color color) {
// Let's assume max base stat is 255
double ratio = (value / 255).clamp(0.0, 1.0);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
children: [
SizedBox(
width: 50,
child: Text(
label,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
),
),
Expanded(
child: Container(
height: 14,
decoration: BoxDecoration(
color: Colors.grey[400],
),
child: Row(
children: [
Expanded(
flex: (ratio * 100).toInt(),
child: Container(color: color),
),
Expanded(
flex: 100 - (ratio * 100).toInt(),
child: Container(),
),
],
),
),
),
const SizedBox(width: 16),
SizedBox(
width: 40,
child: Text(
value.toString(),
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
textAlign: TextAlign.right,
),
)
],
),
);
}
@override
Widget build(BuildContext context) {
final Pokemon pokemon = ModalRoute.of(context)!.settings.arguments as Pokemon;
return Scaffold(
backgroundColor: const Color(0xFF1B2333),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 20.0),
child: Container(
decoration: BoxDecoration(
color: const Color(0xFFD32F2F),
borderRadius: BorderRadius.circular(30),
border: Border.all(color: const Color(0xFFA12020), width: 4),
),
child: SingleChildScrollView(
child: Column(
children: [
// App Bar / Top Red Padding
Container(
height: 50,
padding: const EdgeInsets.symmetric(horizontal: 16),
alignment: Alignment.centerLeft,
child: GestureDetector(
onTap: () => Navigator.pop(context),
child: Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: Color(0xFFA12020),
shape: BoxShape.circle),
child: const Icon(Icons.arrow_back, color: Colors.white),
),
),
),
// TOP SCREEN
Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: const Color(0xFF1B2333),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFF1B2333), width: 8),
),
child: Container(
color: const Color(0xFF90A4AE),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
color: const Color(0xFF1B2333),
child: Text(
"NO. ${pokemon.id.toString().padLeft(3, '0')}",
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
),
),
GestureDetector(
onTap: () {
setState(() {
_isShiny = !_isShiny;
});
},
child: Container(
height: 180,
alignment: Alignment.center,
color: const Color(0xFF81CCA5).withAlpha(153), // subtle green background behind sprite
child: PokemonImage(
imageUrl: _isShiny ? pokemon.shinyImageUrl : pokemon.imageUrl,
fallbackUrl: _isShiny ? pokemon.imageUrl : null,
fit: BoxFit.contain,
),
),
),
Container(
color: const Color(0xFF37474F),
padding: const EdgeInsets.all(12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
pokemon.formatedName.toUpperCase(),
style: const TextStyle(color: Colors.white, fontSize: 22, fontWeight: FontWeight.bold, letterSpacing: 2),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Row(
children: [
PokemonTypeWidget(pokemon.type1),
if (pokemon.type2 != null) const SizedBox(width: 4),
if (pokemon.type2 != null) PokemonTypeWidget(pokemon.type2!),
],
)
],
),
)
],
),
),
),
// HINGE DETAILS
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Container(height: 6, width: 40, color: const Color(0xFFA12020)),
Container(height: 6, width: 40, color: const Color(0xFFA12020)),
],
),
const SizedBox(height: 20),
// BOTTOM SCREEN
Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: const Color(0xFF1B2333),
borderRadius: BorderRadius.circular(8),
),
child: Container(
color: const Color(0xFFC8D1D8),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
"BASE STATS",
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, letterSpacing: 2),
),
Text(
"MODEL: DS-01",
style: TextStyle(fontSize: 12, color: Colors.grey[700], fontWeight: FontWeight.bold),
)
],
),
const Divider(color: Colors.black38, thickness: 2, height: 20),
_buildStatBar("HP", pokemon.hp, const Color(0xFFE53935)),
_buildStatBar("ATK", pokemon.atk, const Color(0xFFFB8C00)),
_buildStatBar("DEF", pokemon.def, const Color(0xFFFDD835)),
_buildStatBar("SPD", pokemon.spd, const Color(0xFF1E88E5)),
const SizedBox(height: 24),
// DESCRIPTION BOX
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFE2EBF0),
border: Border.all(color: Colors.grey[400]!),
),
child: Text(
pokemon.description != null && pokemon.description!.isNotEmpty
? '"${pokemon.description!}"'
: '"No description available for this Pokémon."',
style: const TextStyle(fontSize: 16, height: 1.5),
),
),
const SizedBox(height: 16),
// DECORATIVE LIGHTS
Row(
children: [
Container(
width: 24, height: 24,
decoration: BoxDecoration(
color: const Color(0xFF1E88E5), shape: BoxShape.circle,
border: Border.all(color: const Color(0xFF1565C0), width: 2),
),
),
const SizedBox(width: 8),
Container(
width: 24, height: 24,
decoration: BoxDecoration(
color: const Color(0xFFFFB300), shape: BoxShape.circle,
border: Border.all(color: const Color(0xFFF57C00), width: 2),
),
),
const Spacer(),
Row(
children: [
Container(height: 6, width: 30, decoration: BoxDecoration(color: Colors.grey[500], borderRadius: BorderRadius.circular(3))),
const SizedBox(width: 4),
Container(height: 6, width: 30, decoration: BoxDecoration(color: Colors.grey[500], borderRadius: BorderRadius.circular(3))),
],
)
],
)
],
),
),
),
const SizedBox(height: 30),
// BOTTOM DOTS
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(width: 6, height: 6, decoration: const BoxDecoration(color: Color(0xFFA12020), shape: BoxShape.circle)),
const SizedBox(width: 4),
Container(width: 6, height: 6, decoration: const BoxDecoration(color: Color(0xFFA12020), shape: BoxShape.circle)),
const SizedBox(width: 4),
Container(width: 6, height: 6, decoration: const BoxDecoration(color: Color(0xFFA12020), shape: BoxShape.circle)),
const SizedBox(width: 4),
Container(width: 6, height: 6, decoration: const BoxDecoration(color: Color(0xFFA12020), shape: BoxShape.circle)),
],
),
const SizedBox(height: 20),
],
),
),
),
),
),
);
}
}
+231
View File
@@ -0,0 +1,231 @@
import 'package:flutter/material.dart';
import '../models/pokemon.dart';
import '../components/pokemon_tile.dart';
import '../database/pokedex_database.dart';
import '../api/pokemon_api.dart';
class PokemonListPage extends StatefulWidget {
const PokemonListPage({Key? key}) : super(key: key);
@override
State<PokemonListPage> createState() => _PokemonListPageState();
}
class _PokemonListPageState extends State<PokemonListPage> {
String _filter = 'ALL'; // ALL, CAUGHT, NEW
int _caughtCount = 0;
List<Pokemon> _allPokemon = [];
List<Pokemon> _filteredPokemon = [];
bool _isSyncing = false;
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
_loadPokemonData();
PokedexDatabase.onDatabaseUpdate.addListener(_loadPokemonData);
}
@override
void dispose() {
PokedexDatabase.onDatabaseUpdate.removeListener(_loadPokemonData);
_scrollController.dispose();
super.dispose();
}
Future<void> _loadPokemonData() async {
setState(() => _isSyncing = true);
final count = await PokedexDatabase.getCaughtCount();
// Check if database needs sync (less than 1025 pokemon)
List<Pokemon> localData = await PokedexDatabase.getPokemonList();
if (localData.length < 1025) {
try {
final List<Pokemon> remoteData = await PokemonApi.getAllPokemon();
// Insert all missing pokemon using batch for performance
await PokedexDatabase.batchInsertPokemon(remoteData);
localData = await PokedexDatabase.getPokemonList();
} catch (e) {
debugPrint('Sync Error: $e');
}
}
// Sort by ID to ensure order
localData.sort((a, b) => a.id.compareTo(b.id));
if (mounted) {
setState(() {
_allPokemon = localData;
_caughtCount = count;
_applyFilter();
_isSyncing = false;
});
}
}
void _applyFilter() {
setState(() {
if (_filter == 'ALL') {
_filteredPokemon = _allPokemon;
} else if (_filter == 'CAUGHT') {
_filteredPokemon = _allPokemon.where((p) => p.isCaught).toList();
}
});
// Reset scroll position to top when filter changes
if (_scrollController.hasClients) {
_scrollController.jumpTo(0);
}
}
Widget _buildPokemonTile(BuildContext context, int index) {
final pokemon = _filteredPokemon[index];
return PokemonTile(pokemon);
}
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(
color: Color(0xFFC8D1D8), // Silver-ish grey background
),
child: Column(
children: [
// Header
Container(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0),
color: const Color(0xFF90A4AE),
child: const Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Icon(Icons.menu, color: Colors.black87),
Text(
'LIST - NATIONAL',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, letterSpacing: 2),
),
Icon(Icons.search, color: Colors.black87),
],
),
),
// Tabs
Container(
color: const Color(0xFF90A4AE),
height: 40,
child: Row(
children: [
_buildTab('ALL', _filter == 'ALL'),
_buildTab('CAUGHT', _filter == 'CAUGHT'),
],
),
),
// Caught Count Bar
Container(
padding: const EdgeInsets.symmetric(vertical: 12.0),
decoration: const BoxDecoration(
color: Color(0xFFB0BEC5),
border: Border(bottom: BorderSide(color: Color(0xFF78909C), width: 2)),
),
child: Column(
children: [
Text(
'${_caughtCount.toString().padLeft(3, '0')} / ${_allPokemon.length}',
style: const TextStyle(fontSize: 32, fontWeight: FontWeight.bold),
),
const Text(
'POKEMON DISCOVERED',
style: TextStyle(fontSize: 14, color: Colors.black54, letterSpacing: 1),
),
],
),
),
// The List
Expanded(
child: Stack(
children: [
// Scanlines effect
Positioned.fill(
child: ListView.builder(
itemCount: 100, // drawing artificial scanlines
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) => Container(
height: 4,
margin: const EdgeInsets.only(bottom: 4),
color: Colors.black.withAlpha(2),
),
),
),
if (_isSyncing && _allPokemon.isEmpty)
const Center(child: CircularProgressIndicator())
else if (_filteredPokemon.isEmpty)
Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.search_off, size: 64, color: Colors.black26),
const SizedBox(height: 16),
Text(
'NO POKEMON FOUND IN $_filter',
style: const TextStyle(color: Colors.black45, fontSize: 18, fontWeight: FontWeight.bold),
),
],
),
)
else
ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(12),
itemCount: _filteredPokemon.length,
itemBuilder: _buildPokemonTile,
),
],
),
),
// Footer
Container(
height: 24,
color: const Color(0xFF1B2333),
alignment: Alignment.center,
child: const Text(
'NATIONAL POKEDEX V2.0',
style: TextStyle(color: Colors.white70, fontSize: 12, letterSpacing: 1),
),
),
],
),
);
}
Widget _buildTab(String title, bool isSelected) {
return Expanded(
child: GestureDetector(
onTap: () {
if (_filter != title) {
_filter = title;
_applyFilter();
}
},
child: Container(
decoration: BoxDecoration(
color: isSelected ? const Color(0xFFB0BEC5) : Colors.transparent,
border: isSelected ? const Border(
bottom: BorderSide(color: Color(0xFFD32F2F), width: 3),
) : null,
),
alignment: Alignment.center,
child: Text(
title,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: isSelected ? Colors.black : Colors.black54,
),
),
),
),
);
}
}
@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
class PokemonImage extends StatelessWidget {
final String imageUrl;
final String? fallbackUrl;
final BoxFit fit;
final double? width;
final double? height;
final Color? color;
final BlendMode? colorBlendMode;
const PokemonImage({
super.key,
required this.imageUrl,
this.fallbackUrl,
this.fit = BoxFit.contain,
this.width,
this.height,
this.color,
this.colorBlendMode,
});
@override
Widget build(BuildContext context) {
return Image.network(
imageUrl,
fit: fit,
width: width,
height: height,
color: color,
colorBlendMode: colorBlendMode,
errorBuilder: (context, error, stackTrace) {
// If the primary image fails and we have a fallback, try the fallback
if (fallbackUrl != null && fallbackUrl != imageUrl) {
return Image.network(
fallbackUrl!,
fit: fit,
width: width,
height: height,
color: color,
colorBlendMode: colorBlendMode,
errorBuilder: (context, error, stackTrace) {
// If the fallback also fails, show a placeholder
return _buildPlaceholder();
},
);
}
// No fallback, show placeholder
return _buildPlaceholder();
},
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Center(
child: CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes!
: null,
),
);
},
);
}
Widget _buildPlaceholder() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.help_outline,
size: (width ?? 40) * 0.5,
color: Colors.grey[400],
),
if ((width ?? 100) > 60)
Text(
"Not Found",
style: TextStyle(
color: Colors.grey[600],
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
],
),
);
}
}
@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import '../../domain/entities/pokemon.dart';
import 'pokemon_image.dart';
class PokemonTile extends StatelessWidget {
const PokemonTile(this.pokemon, {Key? key}) : super(key: key);
final Pokemon pokemon;
@override
Widget build(BuildContext context) {
// If not caught, we don't allow navigating to the detail page (to force guessing)
return GestureDetector(
onTap: pokemon.isCaught ? () {
Navigator.pushNamed(context, "/pokemon-detail", arguments: pokemon);
} : null,
child: Container(
height: 80,
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFFE2EBF0), // lighter grey for tile surface
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.white, width: 2),
boxShadow: [
BoxShadow(
color: Colors.black.withAlpha(25),
blurRadius: 2,
offset: const Offset(2, 2),
)
]
),
child: Row(
children: [
// Image box
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: pokemon.isCaught ? const Color(0xFF78909C) : Colors.grey[700],
border: Border.all(color: Colors.white, width: 2),
borderRadius: BorderRadius.circular(4),
),
child: pokemon.isCaught
? PokemonImage(imageUrl: pokemon.imageUrl, fit: BoxFit.contain)
: const SizedBox.expand(),
),
const SizedBox(width: 16),
// Name texts
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'No. ${pokemon.id.toString().padLeft(3, '0')}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.grey[600],
),
),
const SizedBox(height: 4),
Text(
pokemon.isCaught ? pokemon.formatedName.toUpperCase() : '???',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: pokemon.isCaught ? Colors.black87 : Colors.grey[500],
),
),
],
),
),
// Caught check icon
if (pokemon.isCaught)
const Icon(Icons.check_circle, color: Colors.green, size: 28)
else
Icon(Icons.help, color: Colors.grey[400], size: 24),
],
),
),
);
}
}
@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import '../../domain/entities/pokemon.dart';
import '../theme/type_colors.dart';
// Widget qui permet d'afficher un type de Pokémon
// Elle prend en paramètre un type de Pokémon
// Elle affiche le nom du type avec une couleur de fond correspondant au type
class PokemonTypeWidget extends StatelessWidget {
const PokemonTypeWidget(this.type, {Key? key}) : super(key: key);
final PokemonType type;
@override
Widget build(BuildContext context) {
String typeName = formatedTypeName(type);
Color typeColor = typeToColor(type);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
margin: const EdgeInsets.only(right: 6),
alignment: Alignment.center,
decoration: BoxDecoration(
color: typeColor,
borderRadius: BorderRadius.circular(10),
// On rajoute une bordure noire si le fond est blanc
border: typeColor == Colors.white ? Border.all(color: Colors.black) : null,
),
child: SizedBox(
width: 60,
// Discriminant pour définir la couleur du texte en fonction de la couleur de fond
child: Text(typeName, style: TextStyle(color: (typeColor == Colors.white) ? Colors.black : Colors.white), textAlign: TextAlign.center)
),
);
}
}