Compare commits

...

35 Commits

Author SHA1 Message Date
d54e517ca6 chore(core): remove unused Result type (dead code)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 15:33:51 +02:00
43b68d1352 docs: update architecture documentation for new layered design
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 15:24:33 +02:00
5c9cef99a7 test: fix default widget smoke test for new architecture
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 15:24:05 +02:00
ca77317a20 chore: remove legacy models/utils/api/database layers
Also moves stray lib/pages/quel-est-ce-pokemon.code-workspace to repo
root, and fixes two trivial test lint infos (curly_braces_in_flow_
control_structures, constant_identifier_names) to reach clean analyze.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 15:20:54 +02:00
dc7e681508 fix(presentation): recover from game-over back gesture, show final score, guard hint, constants
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 15:15:27 +02:00
e62c8a9034 refactor: wire main.dart to presentation layer
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 11:45:59 +02:00
2c21b80a03 refactor(presentation): guess page consumes gameProvider
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 11:45:20 +02:00
4ac13bd233 refactor(presentation): game over page uses repository + constant
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 11:43:48 +02:00
7fd6d16020 refactor(presentation): pokemon list consumes pokedexProvider
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 11:43:02 +02:00
d4c8936c80 refactor(presentation): main page as ConsumerWidget with nav provider
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 11:42:13 +02:00
439c0101f4 refactor: move UI into presentation/widgets and presentation/pages
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 11:41:41 +02:00
29d2c92b37 fix(presentation): resilient pokemon loading, web-safe invalidate, correct best-score persist
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 11:38:40 +02:00
4bca4b1ed5 feat(presentation): add game state Notifier
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 11:31:42 +02:00
2d87879af8 feat(presentation): add selected-tab navigation provider
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 11:31:16 +02:00
d531fcb2c8 feat(presentation): add pokedex AsyncNotifier provider
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 11:30:55 +02:00
f6a6ba2cd1 feat(presentation): add repository DI provider
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 11:30:30 +02:00
e2e310cae5 test(domain): cover skip bonus and invalid-guess paths in GameEngine
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 11:29:03 +02:00
e3a9831ce1 feat(domain): add pure GameEngine with TDD (removes pikachu cheat)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 11:22:03 +02:00
0d977e5cca feat(domain): add immutable GameState
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 11:20:46 +02:00
4bc3948fed feat(gitignore): add .claude/ to ignore list 2026-06-09 11:19:20 +02:00
6885771081 fix(data): race-safe DB open, graceful unknown types, explicit id error + fallback tests
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 11:18:56 +02:00
51c6ef904d feat(data): add PokemonRepository implementation + tests
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 11:13:06 +02:00
42bd9f7c20 feat(domain): add PokemonRepository interface
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 11:12:05 +02:00
cbc742b25a feat(data): add HTTP remote datasource
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 11:11:31 +02:00
96758f1b6b feat(data): add instance-based SQLite local datasource
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 11:10:25 +02:00
f2dcba0fe2 feat(data): add PokemonDto with single-source JSON parsing + tests
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 11:09:51 +02:00
2f051c8da6 refactor: point leaf components at domain Pokemon entity
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:57:51 +02:00
b1f67d3daa feat(presentation): add type colors helper
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:57:23 +02:00
8ca6405bc0 feat(domain): add pure Pokemon entity and PokemonType enum
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:56:59 +02:00
8f29f3578a refactor(core): keep logger pure-Dart and document Result subtypes
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:55:36 +02:00
2a3f489a2d feat(core): add AppLogger
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:50:31 +02:00
6b150945fa feat(core): add sealed Result type
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:50:16 +02:00
03dbdd723d feat(core): add centralized app constants
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:49:51 +02:00
ca769dca2d chore: add flutter_riverpod and wrap app in ProviderScope
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:48:53 +02:00
6f81384a06 chore: gitignore local superpowers docs
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:39:29 +02:00
38 changed files with 1717 additions and 1185 deletions

5
.gitignore vendored
View File

@ -1,3 +1,6 @@
# Superpowers brainstorming/specs/plans — local only, never commit
docs/superpowers/
# Miscellaneous
*.class
*.log
@ -45,3 +48,5 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
.claude/

View File

@ -2,31 +2,42 @@
## Overview
The application follows a modular structure separated by responsibilities (models, pages, components, services).
L'application suit une **Clean Architecture allégée** en trois couches, avec une règle de
dépendance stricte : les dépendances pointent vers l'intérieur. Le state management est assuré
par Riverpod (providers manuels).
## Layers
## Couches
### 1. Data Layer
### domain (Dart pur)
- **Entités** : `Pokemon`, immuable, sans dépendance Flutter/DB/API.
- **Repository (interface)** : `PokemonRepository` définit le contrat d'accès aux données.
- **Jeu** : `GameState` (état immuable) et `GameEngine` (règles pures, testables).
- **Models**: `Pokemon` class defines the data structure for a Pokemon, including serialization/deserialization logic.
- **API**: `PokemonApi` handles communication with the Tyradex REST API using the `http` package.
- **Database**: `PokedexDatabase` manages local persistence using SQLite (`sqflite`). It uses batch operations for performance during initial sync.
### data
- **DTO** : `PokemonDto` centralise tout le parsing JSON (API Tyradex + SQLite).
- **Datasources** : `PokemonLocalDataSource` (SQLite via sqflite), `PokemonRemoteDataSource` (HTTP).
- **Repository (impl)** : `PokemonRepositoryImpl` applique « DB locale d'abord, sinon API + cache ».
Sur le web, le datasource local est absent (`null`).
### 2. Business Logic & State
### presentation
- **Providers** : `pokemonRepositoryProvider` (DI), `pokedexProvider` (`AsyncNotifier`),
`gameProvider` (`Notifier<GameState>`), `selectedTabProvider` (onglet courant).
- **Pages** : `ConsumerWidget` / `ConsumerStatefulWidget` qui observent les providers.
- **Widgets** : éléments réutilisables (`PokemonImage`, `PokemonTile`, `PokemonTypeWidget`).
- **Thème** : `type_colors.dart` (couleur/format des types).
- **State Management**: Uses Flutter's `StatefulWidget` and `setState` for local page state.
- **Reactivity**: `ValueNotifier` in the database layer notifies the UI when data changes (e.g., catching a Pokemon updates the list).
- **Persistence**: `shared_preferences` is used for simple key-value storage like best scores.
## Flux de données
### 3. UI Layer
```
UI (Consumer) → Notifier → GameEngine (règles) + Repository (données)
→ DataSource (SQLite / HTTP) → DTO → Entity
```
- **Pages**: Top-level screens like `MainPage`, `PokemonListPage`, and `GuessPage`.
- **Components**: Reusable UI elements like `PokemonTile`.
- **Navigation**: Managed in `MainPage` using `IndexedStack` to preserve tab state across navigation.
Riverpod re-render automatiquement les consommateurs concernés. Plus de bus d'événements global
ni d'accès inter-pages via l'arbre de widgets.
## Data Flow
## Tests
1. At startup, the app checks the local database.
2. If the database is missing generations, it fetches the full list from Tyradex API and performs a batch insert.
3. User interactions (like a correct guess) update the local database.
4. The database triggers a notification, causing relevant UI components to refresh their view.
- `test/domain/game_engine_test.dart` : règles du jeu.
- `test/data/pokemon_dto_test.dart` : parsing.
- `test/data/pokemon_repository_test.dart` : logique du repository (datasources factices).

View File

@ -1,109 +0,0 @@
import '../models/pokemon.dart';
import '../utils/pokemon_type.dart';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:flutter/foundation.dart'; // Import for debugPrint
// Classe qui permet de récupérer les données des pokémons depuis l'API Tyradex
// On utilise la librairie http pour effectuer les requêtes
// On utilise la librairie dart:convert pour convertir les données JSON en objet Dart
class PokemonApi {
static const String baseUrl = 'tyradex.app';
static const String pokemonUrl = 'api/v1/pokemon';
static Future<Pokemon> getPokemon(int id) async {
print('API Call: Fetching Pokémon $id from Tyradex...');
// On utilise la méthode get de la classe http pour effectuer une requête GET
// On utilise Uri.https pour construire l'URL de la requête
var response = await http.get(Uri.https(baseUrl, "$pokemonUrl/$id"));
if (response.statusCode != 200) {
// Si le code de retour de la requête n'est pas 200, on lève une exception
throw Exception('Erreur lors de la récupération du pokémon $id, code de retour ${response.statusCode}');
}
// On utilise la méthode jsonDecode de la librairie dart:convert pour convertir le corps de la réponse en fichier JSON
var json = jsonDecode(response.body);
// Récupération du nom en français
String name = json['name']['fr'] ?? json['name']['en'] ?? 'unknown';
// Récupération des types (en français dans l'API Tyradex)
List types = json['types'] ?? [];
PokemonType type1 = types.isNotEmpty
? frenchTypeToEnum(types[0]['name'])
: PokemonType.unknown;
PokemonType? type2 = types.length > 1
? frenchTypeToEnum(types[1]['name'])
: null;
// Récupération des statistiques
Map<String, dynamic>? stats = json['stats'];
int hp = stats?['hp'] ?? 0;
int atk = stats?['atk'] ?? 0;
int def = stats?['def'] ?? 0;
int spd = stats?['vit'] ?? 0; // 'vit' est la clé pour la vitesse dans tyradex.app
// Récupération de la description
String? description = json['category'];
// On crée un objet Pokemon à partir du fichier JSON
return Pokemon(
name: name,
id: id,
type1: type1,
type2: type2,
hp: hp,
atk: atk,
def: def,
spd: spd,
description: description,
);
}
static Future<List<Pokemon>> getAllPokemon() async {
print('API Call: Fetching ALL Pokémon from Tyradex...');
final response = await http.get(Uri.https(baseUrl, pokemonUrl));
if (response.statusCode == 200) {
List<dynamic> jsonList = jsonDecode(response.body);
List<Pokemon> allPokemon = [];
for (var json in jsonList) {
// Skip default tyradex id 0 response which is generic typing
if(json['pokedex_id'] == 0) continue;
try {
String name = json['name']['fr'];
int id = json['pokedex_id'];
List<dynamic> types = json['types'] ?? [];
PokemonType type1 = frenchTypeToEnum(types[0]['name']);
PokemonType? type2 = types.length > 1 ? frenchTypeToEnum(types[1]['name']) : null;
Map<String, dynamic>? stats = json['stats'];
int hp = stats?['hp'] ?? 0;
int atk = stats?['atk'] ?? 0;
int def = stats?['def'] ?? 0;
int spd = stats?['vit'] ?? 0;
String? description = json['category'];
allPokemon.add(Pokemon(
name: name,
id: id,
type1: type1,
type2: type2,
hp: hp,
atk: atk,
def: def,
spd: spd,
description: description,
));
} catch (e) {
debugPrint("Failed parsing pokemon: ${json['name']} - $e");
}
}
return allPokemon;
} else {
throw Exception('Failed to load pokemon');
}
}
}

View File

@ -0,0 +1,40 @@
/// Constantes globales de l'application, centralisées pour éviter les valeurs en dur.
class AppConstants {
AppConstants._();
/// Nombre total de Pokémon gérés (jusqu'à la Gen 9).
static const int totalPokemon = 1025;
/// Points gagnés pour une bonne réponse normale.
static const int pointsNormal = 10;
/// Points gagnés pour une bonne réponse sur un Pokémon shiny.
static const int pointsShiny = 20;
/// Nombre de vies au début d'une partie.
static const int startingLives = 3;
/// Nombre de skips au début d'une partie.
static const int startingSkips = 3;
/// Nombre d'indices au début d'une partie.
static const int startingHints = 3;
/// Une bonne réponse tous les N donne un indice bonus.
static const int hintBonusEvery = 5;
/// Une bonne réponse tous les N donne un skip bonus.
static const int skipBonusEvery = 10;
/// Probabilité d'apparition d'un shiny : 1 chance sur N.
static const int shinyOdds = 10;
/// Hôte de l'API Tyradex.
static const String apiBaseUrl = 'tyradex.app';
/// Chemin de l'endpoint Pokémon.
static const String apiPokemonPath = 'api/v1/pokemon';
/// Clé SharedPreferences pour le meilleur score.
static const String prefsBestScore = 'best_score';
}

21
lib/core/logger.dart Normal file
View File

@ -0,0 +1,21 @@
import 'dart:developer' as dev;
/// Logger minimal de l'application. Remplace les appels directs à print().
/// Silencieux en release (les blocs assert sont retirés du build release).
class AppLogger {
AppLogger._();
static void info(String message) {
assert(() {
dev.log(message, name: 'INFO');
return true;
}());
}
static void error(String message, [Object? error, StackTrace? stackTrace]) {
assert(() {
dev.log(message, name: 'ERROR', error: error, stackTrace: stackTrace);
return true;
}());
}
}

View File

@ -0,0 +1,69 @@
import 'package:sqflite_common/sqflite.dart';
import '../../domain/entities/pokemon.dart';
import '../dto/pokemon_dto.dart';
/// Accès SQLite local au Pokédex. Schéma et migrations identiques à l'ancien PokedexDatabase.
class PokemonLocalDataSource {
Future<Database>? _dbFuture;
Future<Database> _getDb() {
return _dbFuture ??= openDatabase(
'pokedex.db',
version: 2,
onUpgrade: (db, oldVersion, newVersion) async {
if (oldVersion < 2) {
await db.execute('DROP TABLE IF EXISTS pokemon');
await db.execute(
'CREATE TABLE pokemon (id INTEGER PRIMARY KEY NOT NULL, name TEXT NOT NULL, type1 TEXT NOT NULL, type2 TEXT, hp INTEGER NOT NULL, atk INTEGER NOT NULL, def INTEGER NOT NULL, spd INTEGER NOT NULL, description TEXT, isCaught INTEGER NOT NULL DEFAULT 0, isSeen INTEGER NOT NULL DEFAULT 0)');
}
},
onCreate: (db, version) async {
await db.execute(
'CREATE TABLE IF NOT EXISTS pokemon (id INTEGER PRIMARY KEY NOT NULL, name TEXT NOT NULL, type1 TEXT NOT NULL, type2 TEXT, hp INTEGER NOT NULL, atk INTEGER NOT NULL, def INTEGER NOT NULL, spd INTEGER NOT NULL, description TEXT, isCaught INTEGER NOT NULL DEFAULT 0, isSeen INTEGER NOT NULL DEFAULT 0)');
},
);
}
Future<List<Pokemon>> getAll() async {
final db = await _getDb();
final rows = await db.query('pokemon');
return rows.map(PokemonDto.fromDb).toList();
}
Future<Pokemon?> getById(int id) async {
final db = await _getDb();
final rows = await db.query('pokemon', where: 'id = ?', whereArgs: [id]);
if (rows.isEmpty) return null;
return PokemonDto.fromDb(rows.first);
}
Future<void> saveAll(List<Pokemon> pokemons) async {
final db = await _getDb();
final batch = db.batch();
for (final p in pokemons) {
batch.insert('pokemon', PokemonDto.toDb(p),
conflictAlgorithm: ConflictAlgorithm.replace);
}
await batch.commit(noResult: true);
}
Future<void> update(Pokemon pokemon) async {
final db = await _getDb();
await db.update('pokemon', PokemonDto.toDb(pokemon),
where: 'id = ?', whereArgs: [pokemon.id]);
}
Future<int> caughtCount() async {
final db = await _getDb();
final result =
await db.rawQuery('SELECT COUNT(*) FROM pokemon WHERE isCaught = 1');
return result.isNotEmpty ? (result.first.values.first as int? ?? 0) : 0;
}
Future<int> seenCount() async {
final db = await _getDb();
final result =
await db.rawQuery('SELECT COUNT(*) FROM pokemon WHERE isSeen = 1');
return result.isNotEmpty ? (result.first.values.first as int? ?? 0) : 0;
}
}

View File

@ -0,0 +1,46 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../core/config/app_constants.dart';
import '../../core/logger.dart';
import '../../domain/entities/pokemon.dart';
import '../dto/pokemon_dto.dart';
/// Accès distant à l'API Tyradex.
class PokemonRemoteDataSource {
final http.Client _client;
PokemonRemoteDataSource({http.Client? client})
: _client = client ?? http.Client();
Future<Pokemon> getById(int id) async {
AppLogger.info('API: fetching Pokémon $id');
final response = await _client
.get(Uri.https(AppConstants.apiBaseUrl, '${AppConstants.apiPokemonPath}/$id'));
if (response.statusCode != 200) {
throw Exception(
'Erreur récupération du pokémon $id, code ${response.statusCode}');
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
return PokemonDto.fromTyradexJson(json, fallbackId: id);
}
Future<List<Pokemon>> getAll() async {
AppLogger.info('API: fetching ALL Pokémon');
final response = await _client
.get(Uri.https(AppConstants.apiBaseUrl, AppConstants.apiPokemonPath));
if (response.statusCode != 200) {
throw Exception('Failed to load pokemon (code ${response.statusCode})');
}
final List<dynamic> jsonList = jsonDecode(response.body);
final result = <Pokemon>[];
for (final json in jsonList) {
if (json['pokedex_id'] == 0) continue; // entrée générique Tyradex
try {
result.add(PokemonDto.fromTyradexJson(json as Map<String, dynamic>));
} catch (e, st) {
AppLogger.error('Parsing pokemon échoué: ${json['name']}', e, st);
}
}
return result;
}
}

View File

@ -0,0 +1,101 @@
import '../../domain/entities/pokemon.dart';
/// Conversion JSON <-> entité Pokemon. Unique endroit de parsing.
class PokemonDto {
PokemonDto._();
/// Mappe un nom de type français (API Tyradex) vers l'enum.
static PokemonType frenchTypeToEnum(String frenchType) {
const map = {
'Normal': PokemonType.normal,
'Combat': PokemonType.fighting,
'Vol': PokemonType.flying,
'Poison': PokemonType.poison,
'Sol': PokemonType.ground,
'Roche': PokemonType.rock,
'Insecte': PokemonType.bug,
'Spectre': PokemonType.ghost,
'Acier': PokemonType.steel,
'Feu': PokemonType.fire,
'Eau': PokemonType.water,
'Plante': PokemonType.grass,
'Électrik': PokemonType.electric,
'Psy': PokemonType.psychic,
'Glace': PokemonType.ice,
'Dragon': PokemonType.dragon,
'Ténèbres': PokemonType.dark,
'Fée': PokemonType.fairy,
};
return map[frenchType] ?? PokemonType.unknown;
}
/// Construit une entité depuis la réponse Tyradex (objet unique ou élément de liste).
/// [fallbackId] sert quand le JSON ne contient pas `pokedex_id`.
static Pokemon fromTyradexJson(Map<String, dynamic> json, {int? fallbackId}) {
final id = (json['pokedex_id'] as int?) ??
fallbackId ??
(throw ArgumentError('pokedex_id absent et fallbackId non fourni'));
final nameMap = json['name'] as Map<String, dynamic>?;
final name = nameMap?['fr'] ?? nameMap?['en'] ?? 'unknown';
final List types = json['types'] ?? [];
final type1 =
types.isNotEmpty ? frenchTypeToEnum(types[0]['name']) : PokemonType.unknown;
final type2 = types.length > 1 ? frenchTypeToEnum(types[1]['name']) : null;
final Map<String, dynamic>? stats = json['stats'];
return Pokemon(
name: name,
id: id,
type1: type1,
type2: type2,
hp: stats?['hp'] ?? 0,
atk: stats?['atk'] ?? 0,
def: stats?['def'] ?? 0,
spd: stats?['vit'] ?? 0, // 'vit' = vitesse chez Tyradex
description: json['category'],
);
}
/// Sérialise pour SQLite.
static Map<String, dynamic> toDb(Pokemon p) {
return {
'name': p.name,
'id': p.id,
'type1': p.type1.name,
'type2': p.type2?.name,
'hp': p.hp,
'atk': p.atk,
'def': p.def,
'spd': p.spd,
'description': p.description,
'isCaught': p.isCaught ? 1 : 0,
'isSeen': p.isSeen ? 1 : 0,
};
}
/// Reconstruit depuis une ligne SQLite.
static Pokemon fromDb(Map<String, dynamic> row) {
return Pokemon(
name: row['name'],
id: row['id'],
type1: PokemonType.values.firstWhere(
(e) => e.name == row['type1'],
orElse: () => PokemonType.unknown,
),
type2: row['type2'] != null
? PokemonType.values.firstWhere(
(e) => e.name == row['type2'],
orElse: () => PokemonType.unknown,
)
: null,
hp: row['hp'] ?? 0,
atk: row['atk'] ?? 0,
def: row['def'] ?? 0,
spd: row['spd'] ?? 0,
description: row['description'],
isCaught: row['isCaught'] == 1 || row['isCaught'] == true,
isSeen: row['isSeen'] == 1 || row['isSeen'] == true,
);
}
}

View File

@ -0,0 +1,63 @@
import '../../core/config/app_constants.dart';
import '../../core/logger.dart';
import '../../domain/entities/pokemon.dart';
import '../../domain/repositories/pokemon_repository.dart';
import '../datasources/pokemon_local_datasource.dart';
import '../datasources/pokemon_remote_datasource.dart';
/// Implémentation : DB locale d'abord, complétée/repliée sur l'API.
/// [local] vaut `null` sur le web (pas de SQLite).
class PokemonRepositoryImpl implements PokemonRepository {
final PokemonRemoteDataSource remote;
final PokemonLocalDataSource? local;
PokemonRepositoryImpl({required this.remote, this.local});
@override
Future<List<Pokemon>> getAll() async {
final localDs = local;
if (localDs == null) return remote.getAll();
final cached = await localDs.getAll();
if (cached.length >= AppConstants.totalPokemon) return cached;
try {
final remoteList = await remote.getAll();
await localDs.saveAll(remoteList);
return localDs.getAll();
} catch (e, st) {
AppLogger.error('Sync getAll échouée', e, st);
return cached;
}
}
@override
Future<Pokemon?> getById(int id) async {
final localDs = local;
if (localDs != null) {
final cached = await localDs.getById(id);
if (cached != null) return cached;
}
try {
final fetched = await remote.getById(id);
if (localDs != null) await localDs.saveAll([fetched]);
return fetched;
} catch (e, st) {
AppLogger.error('getById($id) échoué', e, st);
return null;
}
}
@override
Future<void> saveAll(List<Pokemon> pokemons) async =>
local?.saveAll(pokemons);
@override
Future<void> update(Pokemon pokemon) async => local?.update(pokemon);
@override
Future<int> caughtCount() async => (await local?.caughtCount()) ?? 0;
@override
Future<int> seenCount() async => (await local?.seenCount()) ?? 0;
}

View File

@ -1,112 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:sqflite_common/sqflite.dart';
import '../models/pokemon.dart';
// Permet de gérer la base de données
class PokedexDatabase {
static Database? database;
static final ValueNotifier<int> onDatabaseUpdate = ValueNotifier(0);
static Future<void> initDatabase() async {
database = await openDatabase(
"pokedex.db", // Nom de la base de données
version: 2, // Version de la base de données, permet de gérer les migrations
onUpgrade: (db, oldVersion, newVersion) async {
if (oldVersion < 2) {
await db.execute("DROP TABLE IF EXISTS pokemon");
await db.execute("CREATE TABLE pokemon (id INTEGER PRIMARY KEY NOT NULL, name TEXT NOT NULL, type1 TEXT NOT NULL, type2 TEXT, hp INTEGER NOT NULL, atk INTEGER NOT NULL, def INTEGER NOT NULL, spd INTEGER NOT NULL, description TEXT, isCaught INTEGER NOT NULL DEFAULT 0, isSeen INTEGER NOT NULL DEFAULT 0)");
}
},
onCreate: (db, version) async { // Fonction qui sera appelée lors de la création de la base de données
// Création de la table pokemon avec les colonnes...
await db.execute("CREATE TABLE IF NOT EXISTS pokemon (id INTEGER PRIMARY KEY NOT NULL, name TEXT NOT NULL, type1 TEXT NOT NULL, type2 TEXT, hp INTEGER NOT NULL, atk INTEGER NOT NULL, def INTEGER NOT NULL, spd INTEGER NOT NULL, description TEXT, isCaught INTEGER NOT NULL DEFAULT 0, isSeen INTEGER NOT NULL DEFAULT 0)");
},
);
}
// Méthode qui permet de récupérer la base de données
static Future<Database> getDatabase() async {
if (database == null) {
await initDatabase(); // On initialise la base de données si elle n'est pas encore initialisée
}
return database!;
}
// Méthode qui permet d'insérer un Pokémon dans la base de données
static Future<void> insertPokemon(Pokemon pokemon) async {
Database database = await getDatabase();
await database.insert(
'pokemon',
pokemon.toJson(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
onDatabaseUpdate.value++;
}
// Méthode qui permet d'insérer plusieurs Pokémon d'un coup (plus performant)
static Future<void> batchInsertPokemon(List<Pokemon> pokemonList) async {
Database db = await getDatabase();
Batch batch = db.batch();
for (var pokemon in pokemonList) {
batch.insert(
'pokemon',
pokemon.toJson(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
await batch.commit(noResult: true);
onDatabaseUpdate.value++;
}
// Méthode qui permet de récupérer la liste des pokémons dans la base de données
static Future<List<Pokemon>> getPokemonList() async {
Database database = await getDatabase();
var response = await database.query("pokemon");
return response.map((pokemon) => Pokemon.fromJson(pokemon)).toList();
}
// Méthode qui permet de supprimer un Pokémon de la base de données
static Future<void> deletePokemon(int id) async {
Database database = await getDatabase();
await database.delete("pokemon", where: "id = ?", whereArgs: [id]);
}
// Méthode qui permet de supprimer tous les pokémons de la base de données
static Future<void> deleteAllPokemon() async {
Database database = await getDatabase();
await database.delete("pokemon");
}
// Méthode qui permet de mettre à jour un Pokémon dans la base de données
static Future<void> updatePokemon(Pokemon pokemon) async {
Database database = await getDatabase();
await database.update("pokemon", pokemon.toJson(), where: "id = ?", whereArgs: [pokemon.id]);
onDatabaseUpdate.value++;
}
// Méthode qui permet de récupérer un Pokémon dans la base de données à partir de son ID
static Future<Pokemon?> getPokemon(int id) async {
Database database = await getDatabase();
List<Map<String, dynamic>> pokemonList = await database.query("pokemon", where: "id = ?", whereArgs: [id]);
if (pokemonList.isEmpty) {
return null;
}
return Pokemon.fromJson(pokemonList.first);
}
// Obtenir le nombre de pokémon attrapés
static Future<int> getCaughtCount() async {
Database database = await getDatabase();
var result = await database.rawQuery("SELECT COUNT(*) FROM pokemon WHERE isCaught = 1");
int count = result.isNotEmpty ? (result.first.values.first as int? ?? 0) : 0;
return count;
}
// Obtenir le nombre de pokémon vus
static Future<int> getSeenCount() async {
Database database = await getDatabase();
var result = await database.rawQuery("SELECT COUNT(*) FROM pokemon WHERE isSeen = 1");
int count = result.isNotEmpty ? (result.first.values.first as int? ?? 0) : 0;
return count;
}
}

View File

@ -0,0 +1,71 @@
/// Entité métier représentant un Pokémon. Pure : aucun import Flutter / DB / API.
class Pokemon {
final String name;
final int id;
final PokemonType type1;
final PokemonType? type2;
final int hp;
final int atk;
final int def;
final int spd;
final String? description;
final bool isCaught;
final bool isSeen;
const Pokemon({
required this.name,
required this.id,
required this.type1,
this.type2,
required this.hp,
required this.atk,
required this.def,
required this.spd,
this.description,
this.isCaught = false,
this.isSeen = false,
});
String get imageUrl =>
'https://raw.githubusercontent.com/Yarkis01/TyraDex/images/sprites/$id/regular.png';
String get shinyImageUrl =>
'https://raw.githubusercontent.com/Yarkis01/TyraDex/images/sprites/$id/shiny.png';
String get cryUrl => 'https://pokemoncries.com/cries/$id.mp3';
String get formatedName =>
name.isEmpty ? name : name[0].toUpperCase() + name.substring(1);
Pokemon copyWith({
String? name,
int? id,
PokemonType? type1,
PokemonType? type2,
int? hp,
int? atk,
int? def,
int? spd,
String? description,
bool? isCaught,
bool? isSeen,
}) {
return Pokemon(
name: name ?? this.name,
id: id ?? this.id,
type1: type1 ?? this.type1,
type2: type2 ?? this.type2,
hp: hp ?? this.hp,
atk: atk ?? this.atk,
def: def ?? this.def,
spd: spd ?? this.spd,
description: description ?? this.description,
isCaught: isCaught ?? this.isCaught,
isSeen: isSeen ?? this.isSeen,
);
}
}
/// Les différents types de Pokémon.
enum PokemonType {
normal, fighting, flying, poison, ground, rock, bug, ghost, steel, fire,
water, grass, electric, psychic, ice, dragon, dark, fairy, unknown, shadow
}

View File

@ -0,0 +1,97 @@
import '../../core/config/app_constants.dart';
import '../entities/pokemon.dart';
import 'game_state.dart';
/// Règles du jeu, pures (aucune IO, aucun widget). Entièrement testable.
class GameEngine {
const GameEngine();
/// Nouvelle partie : réinitialise tout sauf le bestScore.
GameState newGame(GameState s) {
return s.copyWith(
lives: AppConstants.startingLives,
skips: AppConstants.startingSkips,
hints: AppConstants.startingHints,
sessionCorrectCount: 0,
currentScore: 0,
status: GameStatus.loading,
isHintUsed: false,
);
}
/// Démarre une manche avec un nouveau Pokémon.
GameState startRound(GameState s, Pokemon p, {required bool isShiny}) {
return s.copyWith(
currentPokemon: p,
isShiny: isShiny,
isHintUsed: false,
status: GameStatus.playing,
);
}
/// Soumet une réponse. Renvoie le nouvel état + la nature du résultat.
GuessOutcome submitGuess(GameState s, String guess) {
final pokemon = s.currentPokemon;
if (pokemon == null || s.status != GameStatus.playing) {
return GuessOutcome(s, GuessResult.invalid);
}
final normalizedGuess = _normalize(guess.trim().toLowerCase());
final normalizedActual = _normalize(pokemon.name.toLowerCase());
if (normalizedGuess == normalizedActual) {
final newSession = s.sessionCorrectCount + 1;
final gained = s.isShiny ? AppConstants.pointsShiny : AppConstants.pointsNormal;
final newScore = s.currentScore + gained;
final hintBonus =
newSession % AppConstants.hintBonusEvery == 0 ? 1 : 0;
final skipBonus =
newSession % AppConstants.skipBonusEvery == 0 ? 1 : 0;
final newState = s.copyWith(
currentPokemon: pokemon.copyWith(isCaught: true, isSeen: true),
currentScore: newScore,
bestScore: newScore > s.bestScore ? newScore : s.bestScore,
sessionCorrectCount: newSession,
hints: s.hints + hintBonus,
skips: s.skips + skipBonus,
status: GameStatus.roundWon,
);
return GuessOutcome(newState, GuessResult.correct);
}
final remainingLives = s.lives - 1;
if (remainingLives <= 0) {
return GuessOutcome(
s.copyWith(lives: 0, status: GameStatus.gameOver),
GuessResult.gameOver,
);
}
return GuessOutcome(
s.copyWith(lives: remainingLives),
GuessResult.wrong,
);
}
/// Consomme un indice si disponible et non déjà utilisé.
GameState useHint(GameState s) {
if (s.isHintUsed || s.hints <= 0) return s;
return s.copyWith(isHintUsed: true, hints: s.hints - 1);
}
/// Consomme un skip si disponible.
GameState useSkip(GameState s) {
if (s.skips <= 0) return s;
return s.copyWith(skips: s.skips - 1);
}
static String _normalize(String input) {
const withDia = 'ÀÁÂÃÄÅàáâãäåÒÓÔÕÖØòóôõöøÈÉÊËèéêëÇçÌÍÎÏìíîïÙÚÛÜùúûüÿÑñ';
const withoutDia = 'AAAAAAaaaaaaOOOOOOooooooEEEEeeeeCcIIIIiiiiUUUUuuuuyNn';
var output = input;
for (var i = 0; i < withDia.length; i++) {
output = output.replaceAll(withDia[i], withoutDia[i]);
}
return output;
}
}

View File

@ -0,0 +1,68 @@
import '../../core/config/app_constants.dart';
import '../entities/pokemon.dart';
/// Phase courante de la partie.
enum GameStatus { loading, playing, roundWon, gameOver, error }
/// Résultat d'une soumission de réponse.
enum GuessResult { correct, wrong, gameOver, invalid }
/// État immuable d'une partie.
class GameState {
final Pokemon? currentPokemon;
final int lives;
final int skips;
final int hints;
final int sessionCorrectCount;
final int currentScore;
final int bestScore;
final bool isShiny;
final bool isHintUsed;
final GameStatus status;
const GameState({
this.currentPokemon,
this.lives = AppConstants.startingLives,
this.skips = AppConstants.startingSkips,
this.hints = AppConstants.startingHints,
this.sessionCorrectCount = 0,
this.currentScore = 0,
this.bestScore = 0,
this.isShiny = false,
this.isHintUsed = false,
this.status = GameStatus.loading,
});
GameState copyWith({
Pokemon? currentPokemon,
int? lives,
int? skips,
int? hints,
int? sessionCorrectCount,
int? currentScore,
int? bestScore,
bool? isShiny,
bool? isHintUsed,
GameStatus? status,
}) {
return GameState(
currentPokemon: currentPokemon ?? this.currentPokemon,
lives: lives ?? this.lives,
skips: skips ?? this.skips,
hints: hints ?? this.hints,
sessionCorrectCount: sessionCorrectCount ?? this.sessionCorrectCount,
currentScore: currentScore ?? this.currentScore,
bestScore: bestScore ?? this.bestScore,
isShiny: isShiny ?? this.isShiny,
isHintUsed: isHintUsed ?? this.isHintUsed,
status: status ?? this.status,
);
}
}
/// Issue d'un appel à GameEngine.submitGuess : nouvel état + nature du résultat.
class GuessOutcome {
final GameState state;
final GuessResult result;
const GuessOutcome(this.state, this.result);
}

View File

@ -0,0 +1,22 @@
import '../entities/pokemon.dart';
/// Contrat d'accès aux données Pokémon. L'UI et le domaine ne connaissent que cette interface.
abstract interface class PokemonRepository {
/// Tous les Pokémon (DB locale d'abord, complétée par l'API si nécessaire).
Future<List<Pokemon>> getAll();
/// Un Pokémon par id (DB d'abord, sinon API + mise en cache). `null` si introuvable.
Future<Pokemon?> getById(int id);
/// Insère/remplace une liste de Pokémon.
Future<void> saveAll(List<Pokemon> pokemons);
/// Met à jour un Pokémon existant.
Future<void> update(Pokemon pokemon);
/// Nombre de Pokémon attrapés.
Future<int> caughtCount();
/// Nombre de Pokémon vus.
Future<int> seenCount();
}

View File

@ -1,10 +1,11 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'pages/pokemon_detail.dart';
import 'pages/main_page.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'pages/game_over_page.dart';
import 'presentation/pages/main_page.dart';
import 'presentation/pages/pokemon_detail.dart';
import 'presentation/pages/game_over_page.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
@ -12,7 +13,7 @@ void main() {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
}
runApp(const MyApp());
runApp(const ProviderScope(child: MyApp()));
}
class MyApp extends StatelessWidget {
@ -21,7 +22,7 @@ class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Pokéguess', // Titre de l'application
title: 'Pokéguess',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFFD32F2F),
@ -35,14 +36,12 @@ class MyApp extends StatelessWidget {
),
useMaterial3: true,
),
debugShowCheckedModeBanner: false, // Permet de masquer la bannière "Debug"
// home a é enlevé pour être remplacé par la route "/"
debugShowCheckedModeBanner: false,
routes: {
'/': (context) => const MainPage(), // La route "/" est la page d'accueil avec BottomNav
'/pokemon-detail':(context) => const PokemonDetailPage(),
'/': (context) => const MainPage(),
'/pokemon-detail': (context) => const PokemonDetailPage(),
'/game-over': (context) => const GameOverPage(),
}
},
);
}
}

View File

@ -1,113 +0,0 @@
import 'package:flutter/foundation.dart' show kIsWeb; // Platform is not supported on web
import '../database/pokedex_database.dart';
import '../api/pokemon_api.dart';
import '../utils/pokemon_type.dart';
import 'package:flutter/material.dart';
// Classe représentant un Pokémon. Elle contient le nom, le numéro et les types du Pokémon.
// Elle contient aussi des propriétés calculées pour récupérer l'url de l'image du Pokémon, l'url de l'image shiny du Pokémon et l'url du cri du Pokémon.
class Pokemon {
String name;
int id;
PokemonType type1;
PokemonType? type2;
int hp;
int atk;
int def;
int spd;
String? description;
bool isCaught;
bool isSeen;
String get imageUrl => 'https://raw.githubusercontent.com/Yarkis01/TyraDex/images/sprites/$id/regular.png';
String get shinyImageUrl => 'https://raw.githubusercontent.com/Yarkis01/TyraDex/images/sprites/$id/shiny.png';
String get cryUrl => 'https://pokemoncries.com/cries/$id.mp3';
String get formatedName {
return name[0].toUpperCase() + name.substring(1);
}
Color get type1Color => typeToColor(type1);
Color get type2Color => typeToColor(type2 ?? PokemonType.unknown);
String get type1Formated => formatedTypeName(type1);
String get type2Formated => formatedTypeName(type2 ?? PokemonType.unknown);
Pokemon({
required this.name,
required this.id,
required this.type1,
this.type2, // Le type 2 n'est pas toujours présent
required this.hp,
required this.atk,
required this.def,
required this.spd,
this.description,
this.isCaught = false,
this.isSeen = false,
});
// Constructeur qui permet de créer un Pokémon à partir d'un fichier JSON récupéré depuis l'API.
// Sera aussi utilisé pour la récupération depuis la base de données
factory Pokemon.fromJson(Map<String, dynamic> json) {
return Pokemon(
name: json['name'],
id: json['id'],
// Parcours des valeurs de l'enum PokemonType et récupération de la première valeur qui correspond à la string 'PokemonType.${json['type1']}'
type1: PokemonType.values.firstWhere((element) => element.toString() == 'PokemonType.${json['type1']}'),
type2: json['type2'] != null ? PokemonType.values.firstWhere((element) => element.toString() == 'PokemonType.${json['type2']}') : null,
hp: json['hp'] ?? 0,
atk: json['atk'] ?? 0,
def: json['def'] ?? 0,
spd: json['spd'] ?? 0,
description: json['description'],
isCaught: json['isCaught'] == 1 || json['isCaught'] == true,
isSeen: json['isSeen'] == 1 || json['isSeen'] == true,
);
}
// Méthode qui permet de convertir un Pokémon en fichier JSON. Sera aussi utilisé pour l'insertion dans la base de données
Map<String, dynamic> toJson() {
return {
'name': name,
'id': id,
'type1': type1.toString().split('.').last, // On récupère la valeur de l'enum PokemonType sans le préfixe 'PokemonType.'
'type2': type2?.toString().split('.').last,
'hp': hp,
'atk': atk,
'def': def,
'spd': spd,
'description': description,
'isCaught': isCaught ? 1 : 0,
'isSeen': isSeen ? 1 : 0,
};
}
// Méthode qui permet de récupérer un Pokémon à partir de son ID
// Si le Pokémon n'est pas présent dans la base de données, on le récupère depuis l'API
static Future<Pokemon?> fromID(int id) async {
Pokemon? pokemon;
if (!kIsWeb) {
// La base de données n'est pas disponible sur le web
pokemon = await PokedexDatabase.getPokemon(id);
}
if (pokemon == null) {
try {
pokemon = await PokemonApi.getPokemon(id);
if (!kIsWeb) {
// On insère le Pokémon dans la base de données
await PokedexDatabase.insertPokemon(pokemon);
}
} catch (e) {
debugPrint(e.toString());
return null;
}
}
return pokemon;
}
}
// Enum qui représente les différents types de Pokémon
enum PokemonType {
normal, fighting, flying, poison, ground, rock, bug, ghost, steel, fire, water, grass, electric, psychic, ice, dragon, dark, fairy, unknown, shadow
}

View File

@ -1,455 +0,0 @@
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),
],
),
),
],
),
);
}
}

View File

@ -1,231 +0,0 @@
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,
),
),
),
),
);
}
}

View File

@ -1,15 +1,17 @@
import 'package:flutter/material.dart';
import '../database/pokedex_database.dart';
import '../components/pokemon_image.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/config/app_constants.dart';
import '../providers/repository_provider.dart';
import '../widgets/pokemon_image.dart';
class GameOverPage extends StatefulWidget {
class GameOverPage extends ConsumerStatefulWidget {
const GameOverPage({Key? key}) : super(key: key);
@override
State<GameOverPage> createState() => _GameOverPageState();
ConsumerState<GameOverPage> createState() => _GameOverPageState();
}
class _GameOverPageState extends State<GameOverPage> {
class _GameOverPageState extends ConsumerState<GameOverPage> {
int _seenCount = 0;
bool _isLoading = true;
@ -20,7 +22,7 @@ class _GameOverPageState extends State<GameOverPage> {
}
Future<void> _loadSeenCount() async {
int count = await PokedexDatabase.getSeenCount();
final count = await ref.read(pokemonRepositoryProvider).seenCount();
if (mounted) {
setState(() {
_seenCount = count;
@ -37,6 +39,7 @@ class _GameOverPageState extends State<GameOverPage> {
final String pokemonImage = args?['pokemonImage'] ?? '';
final String pokemonName = args?['pokemonName'] ?? 'Unknown';
final int streak = args?['streak'] ?? 0;
final int score = args?['score'] ?? 0;
// Pad streak with zeroes to 3 digits as in mockup (e.g. 004)
final String streakText = streak.toString().padLeft(3, '0');
@ -228,7 +231,36 @@ class _GameOverPageState extends State<GameOverPage> {
),
const SizedBox(height: 4),
Text(
"$_seenCount/1025", // Gen 9 total
"$_seenCount/${AppConstants.totalPokemon}",
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(
"SCORE",
style: TextStyle(
color: Colors.red,
fontSize: 10,
fontWeight: FontWeight.bold,
letterSpacing: 1,
),
),
const SizedBox(height: 4),
Text(
"$score",
style: const TextStyle(
color: Colors.black,
fontSize: 16,

View File

@ -0,0 +1,325 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/config/app_constants.dart';
import '../../domain/game/game_state.dart';
import '../providers/game_provider.dart';
import '../providers/navigation_provider.dart';
import '../widgets/pokemon_image.dart';
class GuessPage extends ConsumerStatefulWidget {
const GuessPage({Key? key}) : super(key: key);
@override
ConsumerState<GuessPage> createState() => _GuessPageState();
}
class _GuessPageState extends ConsumerState<GuessPage> {
final TextEditingController _guessController = TextEditingController();
bool _started = false;
@override
void initState() {
super.initState();
// Démarre la partie après le premier frame (le provider est prêt).
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_started) {
_started = true;
ref.read(gameProvider.notifier).startNewGame();
}
});
}
@override
void dispose() {
_guessController.dispose();
super.dispose();
}
Future<void> _onGuess() async {
final result = await ref.read(gameProvider.notifier).submitGuess(_guessController.text);
if (!mounted) return;
final state = ref.read(gameProvider);
switch (result) {
case GuessResult.correct:
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(state.isShiny
? '✨ SHINY! You caught ${state.currentPokemon!.formatedName}! (+20 pts) ✨'
: 'Correct! You caught ${state.currentPokemon!.formatedName}!'),
backgroundColor: state.isShiny ? Colors.amber[800] : Colors.green,
));
break;
case GuessResult.wrong:
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Wrong guess! Try again.'), backgroundColor: Colors.orange));
break;
case GuessResult.gameOver:
await _showGameOver();
break;
case GuessResult.invalid:
break;
}
_guessController.clear();
}
Future<void> _showGameOver() async {
final state = ref.read(gameProvider);
final playAgain = await Navigator.pushNamed(
context,
'/game-over',
arguments: {
'pokemonName': state.currentPokemon!.formatedName,
'score': state.currentScore,
'streak': state.sessionCorrectCount,
'pokemonImage': state.currentPokemon!.imageUrl,
},
) as bool?;
if (!mounted) return;
if (playAgain == false) {
ref.read(selectedTabProvider.notifier).set(0); // onglet LIST
}
// true (Try Again), false (Back to Pokédex) et null (geste retour système)
// relancent tous une nouvelle partie pour ne pas rester bloqué en game over.
await ref.read(gameProvider.notifier).startNewGame();
}
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) {
final state = ref.watch(gameProvider);
if (state.status == GameStatus.loading) {
return const Center(child: CircularProgressIndicator());
}
if (state.currentPokemon == null || state.status == GameStatus.error) {
return const Center(child: Text("Error loading Pokémon"));
}
final pokemon = state.currentPokemon!;
final isGuessed = state.status == GameStatus.roundWon;
return Container(
decoration: const BoxDecoration(color: Color(0xFFC8D1D8)),
child: Stack(
children: [
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),
),
),
),
SingleChildScrollView(
child: Column(
children: [
// Silhouette screen
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: state.isShiny ? pokemon.shinyImageUrl : pokemon.imageUrl,
fallbackUrl: pokemon.imageUrl,
fit: BoxFit.contain,
)
: PokemonImage(
imageUrl: state.isShiny ? pokemon.shinyImageUrl : pokemon.imageUrl,
fallbackUrl: pokemon.imageUrl,
fit: BoxFit.contain,
color: state.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(
state.isShiny ? "✨ SHINY POKÉMON DETECTED! ✨" : "WHO'S THAT POKÉMON?",
textAlign: TextAlign.center,
style: TextStyle(
color: state.isShiny ? Colors.yellow[400] : Colors.white,
fontSize: state.isShiny ? 18 : 22,
fontWeight: FontWeight.bold,
letterSpacing: 2,
),
),
)
],
),
),
// Lives
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(AppConstants.startingLives, (index) {
return Icon(
index < state.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 (state.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(pokemon.formatedName)}",
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: (_) => _onGuess(),
),
),
const SizedBox(height: 16),
if (isGuessed)
SizedBox(
width: double.infinity,
height: 60,
child: ElevatedButton(
onPressed: () => ref.read(gameProvider.notifier).loadNextPokemon(),
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: _onGuess,
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: (state.isHintUsed || state.hints <= 0)
? null
: () => ref.read(gameProvider.notifier).useHint(),
icon: const Icon(Icons.lightbulb, color: Colors.black87),
label: Text("HINT (${state.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: state.skips > 0
? () => ref.read(gameProvider.notifier).useSkip()
: null,
icon: const Icon(Icons.skip_next, color: Colors.black87),
label: Text("SKIP (${state.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
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("${state.currentScore}",
style: const TextStyle(fontSize: 32, fontWeight: FontWeight.bold, color: Color(0xFF3B6EE3))),
const Divider(height: 24),
Text("PERSONAL BEST: ${state.bestScore}",
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.black87)),
],
),
),
],
),
),
const SizedBox(height: 32),
],
),
),
],
),
);
}
}

View File

@ -1,46 +1,37 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/navigation_provider.dart';
import 'pokemon_list.dart';
import 'guess_page.dart';
class MainPage extends StatefulWidget {
class MainPage extends ConsumerWidget {
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")),
static const List<Widget> _pages = [
PokemonListPage(),
GuessPage(),
Center(child: Text("SYSTEM PAGE placeholder")),
];
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final currentIndex = ref.watch(selectedTabProvider);
return Scaffold(
backgroundColor: const Color(0xFF1B2333), // Dark blue background behind the pokedex
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), // Pokedex Red
color: const Color(0xFFD32F2F),
borderRadius: BorderRadius.circular(30),
border: Border.all(color: const Color(0xFFA12020), width: 4),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(26),
child: IndexedStack(
index: _currentIndex,
index: currentIndex,
children: _pages,
),
),
@ -53,28 +44,15 @@ class MainPageState extends State<MainPage> {
highlightColor: Colors.transparent,
),
child: BottomNavigationBar(
currentIndex: _currentIndex,
onTap: (index) {
setState(() {
_currentIndex = index;
});
},
currentIndex: currentIndex,
onTap: (index) => ref.read(selectedTabProvider.notifier).set(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',
),
BottomNavigationBarItem(icon: Icon(Icons.grid_view), label: 'LIST'),
BottomNavigationBarItem(icon: Icon(Icons.games), label: 'GUESS'),
BottomNavigationBarItem(icon: Icon(Icons.settings), label: 'SYSTEM'),
],
),
),

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import '../models/pokemon.dart';
import '../components/pokemon_type.dart';
import '../components/pokemon_image.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);

View File

@ -0,0 +1,169 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/entities/pokemon.dart';
import '../providers/pokedex_provider.dart';
import '../widgets/pokemon_tile.dart';
class PokemonListPage extends ConsumerStatefulWidget {
const PokemonListPage({Key? key}) : super(key: key);
@override
ConsumerState<PokemonListPage> createState() => _PokemonListPageState();
}
class _PokemonListPageState extends ConsumerState<PokemonListPage> {
String _filter = 'ALL'; // ALL, CAUGHT
final ScrollController _scrollController = ScrollController();
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
List<Pokemon> _applyFilter(List<Pokemon> all) {
if (_filter == 'CAUGHT') return all.where((p) => p.isCaught).toList();
return all;
}
@override
Widget build(BuildContext context) {
final pokedexAsync = ref.watch(pokedexProvider);
final caughtCount = ref.watch(caughtCountProvider);
return Container(
decoration: const BoxDecoration(color: Color(0xFFC8D1D8)),
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')} / ${pokedexAsync.valueOrNull?.length ?? 0}',
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: [
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),
),
),
),
pokedexAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
child: Text('Erreur de chargement\n$e',
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.black54)),
),
data: (all) {
final filtered = _applyFilter(all);
if (filtered.isEmpty) {
return 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)),
],
),
);
}
return ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(12),
itemCount: filtered.length,
itemBuilder: (context, index) => PokemonTile(filtered[index]),
);
},
),
],
),
),
// 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) {
setState(() => _filter = title);
if (_scrollController.hasClients) _scrollController.jumpTo(0);
}
},
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)),
),
),
);
}
}

View File

@ -0,0 +1,83 @@
import 'dart:math';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../core/config/app_constants.dart';
import '../../domain/game/game_engine.dart';
import '../../domain/game/game_state.dart';
import '../../core/logger.dart';
import 'pokedex_provider.dart';
import 'repository_provider.dart';
/// Orchestration de la partie : relie GameEngine (règles), le repository (données)
/// et SharedPreferences (best score).
class GameNotifier extends Notifier<GameState> {
static const _engine = GameEngine();
final _random = Random();
@override
GameState build() => const GameState();
Future<void> startNewGame() async {
state = _engine.newGame(state);
await _loadBestScore();
await loadNextPokemon();
}
Future<void> _loadBestScore() async {
final prefs = await SharedPreferences.getInstance();
state = state.copyWith(bestScore: prefs.getInt(AppConstants.prefsBestScore) ?? 0);
}
Future<void> loadNextPokemon() async {
state = state.copyWith(status: GameStatus.loading);
final repo = ref.read(pokemonRepositoryProvider);
final isShiny = _random.nextInt(AppConstants.shinyOdds) == 0;
final id = _random.nextInt(AppConstants.totalPokemon) + 1;
try {
final pokemon = await repo.getById(id);
if (pokemon == null) {
state = state.copyWith(status: GameStatus.error);
return;
}
state = _engine.startRound(state, pokemon, isShiny: isShiny);
} catch (e, st) {
AppLogger.error('loadNextPokemon a échoué', e, st);
state = state.copyWith(status: GameStatus.error);
}
}
Future<GuessResult> submitGuess(String guess) async {
final outcome = _engine.submitGuess(state, guess);
state = outcome.state;
if (outcome.result == GuessResult.correct) {
final repo = ref.read(pokemonRepositoryProvider);
final caught = state.currentPokemon;
if (caught != null) await repo.update(caught);
await _persistBestScore();
if (!kIsWeb) ref.invalidate(pokedexProvider); // rafraîchit la liste (pas de persistance sur web)
}
return outcome.result;
}
Future<void> _persistBestScore() async {
final prefs = await SharedPreferences.getInstance();
final stored = prefs.getInt(AppConstants.prefsBestScore) ?? 0;
if (state.bestScore > stored) {
await prefs.setInt(AppConstants.prefsBestScore, state.bestScore);
}
}
void useHint() => state = _engine.useHint(state);
Future<void> useSkip() async {
final updated = _engine.useSkip(state);
if (updated.skips == state.skips) return; // pas de skip dispo
state = updated;
await loadNextPokemon();
}
}
final gameProvider = NotifierProvider<GameNotifier, GameState>(GameNotifier.new);

View File

@ -0,0 +1,13 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// Index de l'onglet sélectionné dans la navigation principale.
/// Remplace le hack findAncestorStateOfType<MainPageState>.
class SelectedTabNotifier extends Notifier<int> {
@override
int build() => 0;
void set(int index) => state = index;
}
final selectedTabProvider =
NotifierProvider<SelectedTabNotifier, int>(SelectedTabNotifier.new);

View File

@ -0,0 +1,34 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/entities/pokemon.dart';
import 'repository_provider.dart';
/// Liste complète du Pokédex (triée par id), avec synchro initiale gérée par le repository.
class PokedexNotifier extends AsyncNotifier<List<Pokemon>> {
Future<List<Pokemon>> _load() async {
final repo = ref.read(pokemonRepositoryProvider);
final list = await repo.getAll();
list.sort((a, b) => a.id.compareTo(b.id));
return list;
}
@override
Future<List<Pokemon>> build() => _load();
/// Recharge la liste (ex. après avoir attrapé un Pokémon).
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(_load);
}
}
final pokedexProvider =
AsyncNotifierProvider<PokedexNotifier, List<Pokemon>>(PokedexNotifier.new);
/// Nombre de Pokémon attrapés, dérivé de la liste.
final caughtCountProvider = Provider<int>((ref) {
final async = ref.watch(pokedexProvider);
return async.maybeWhen(
data: (list) => list.where((p) => p.isCaught).length,
orElse: () => 0,
);
});

View File

@ -0,0 +1,13 @@
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/datasources/pokemon_local_datasource.dart';
import '../../data/datasources/pokemon_remote_datasource.dart';
import '../../data/repositories/pokemon_repository_impl.dart';
import '../../domain/repositories/pokemon_repository.dart';
/// Point d'injection unique du repository. Sur le web, pas de SQLite (local = null).
final pokemonRepositoryProvider = Provider<PokemonRepository>((ref) {
final remote = PokemonRemoteDataSource();
final local = kIsWeb ? null : PokemonLocalDataSource();
return PokemonRepositoryImpl(remote: remote, local: local);
});

View File

@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import '../../domain/entities/pokemon.dart';
/// Couleur d'affichage associée à un type de Pokémon.
Color typeToColor(PokemonType type) {
final Map<PokemonType, Color> map = {
PokemonType.normal: Colors.white,
PokemonType.fire: Colors.red,
PokemonType.water: Colors.blue,
PokemonType.electric: Colors.yellow,
PokemonType.grass: Colors.green,
PokemonType.ice: Colors.cyan,
PokemonType.fighting: Colors.orange,
PokemonType.poison: Colors.purple,
PokemonType.ground: Colors.brown,
PokemonType.flying: Colors.indigo,
PokemonType.psychic: Colors.pink,
PokemonType.bug: Colors.lightGreen,
PokemonType.rock: Colors.grey,
PokemonType.ghost: Colors.indigo,
PokemonType.dragon: Colors.indigo,
PokemonType.dark: Colors.black45,
PokemonType.steel: Colors.grey.shade600,
PokemonType.fairy: Colors.pinkAccent,
PokemonType.unknown: Colors.transparent,
PokemonType.shadow: Colors.transparent,
};
return map[type] ?? Colors.transparent;
}
/// Nom du type avec une majuscule initiale.
String formatedTypeName(PokemonType type) {
final typeName = type.name;
return typeName[0].toUpperCase() + typeName.substring(1);
}

View File

@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import '../models/pokemon.dart';
import '../../domain/entities/pokemon.dart';
import 'pokemon_image.dart';
class PokemonTile extends StatelessWidget {

View File

@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import '../models/pokemon.dart';
import '../utils/pokemon_type.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

View File

@ -1,60 +0,0 @@
import 'package:flutter/material.dart';
import '../models/pokemon.dart';
// Convertit un nom de type français (de l'API Tyradex) en PokemonType
PokemonType frenchTypeToEnum(String frenchType) {
const Map<String, PokemonType> frenchToEnglish = {
'Normal': PokemonType.normal,
'Combat': PokemonType.fighting,
'Vol': PokemonType.flying,
'Poison': PokemonType.poison,
'Sol': PokemonType.ground,
'Roche': PokemonType.rock,
'Insecte': PokemonType.bug,
'Spectre': PokemonType.ghost,
'Acier': PokemonType.steel,
'Feu': PokemonType.fire,
'Eau': PokemonType.water,
'Plante': PokemonType.grass,
'Électrik': PokemonType.electric,
'Psy': PokemonType.psychic,
'Glace': PokemonType.ice,
'Dragon': PokemonType.dragon,
'Ténèbres': PokemonType.dark,
'Fée': PokemonType.fairy,
};
return frenchToEnglish[frenchType] ?? PokemonType.unknown;
}
// Permet de mapper un type de Pokémon avec une couleur
Color typeToColor(PokemonType type) {
Map<PokemonType, Color> typeToColor = {
PokemonType.normal: Colors.white,
PokemonType.fire: Colors.red,
PokemonType.water: Colors.blue,
PokemonType.electric: Colors.yellow,
PokemonType.grass: Colors.green,
PokemonType.ice: Colors.cyan,
PokemonType.fighting: Colors.orange,
PokemonType.poison: Colors.purple,
PokemonType.ground: Colors.brown,
PokemonType.flying: Colors.indigo,
PokemonType.psychic: Colors.pink,
PokemonType.bug: Colors.lightGreen,
PokemonType.rock: Colors.grey,
PokemonType.ghost: Colors.indigo,
PokemonType.dragon: Colors.indigo,
PokemonType.dark: Colors.black45,
PokemonType.steel: Colors.grey.shade600,
PokemonType.fairy: Colors.pinkAccent,
PokemonType.unknown: Colors.transparent,
PokemonType.shadow: Colors.transparent,
};
return typeToColor[type] ?? Colors.transparent;
}
// Met le nom du type de Pokémon avec une majuscule
String formatedTypeName(PokemonType type) {
String typeName = type.toString().split('.').last.replaceAll('PokemonType.', '');
return typeName[0].toUpperCase() + typeName.substring(1);
}

View File

@ -26,6 +26,7 @@ dependencies:
cupertino_icons: ^1.0.2
google_fonts: ^8.0.2
shared_preferences: ^2.5.4
flutter_riverpod: ^2.5.0
dev_dependencies:
flutter_test:

View File

@ -0,0 +1,83 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pokeguess/domain/entities/pokemon.dart';
import 'package:pokeguess/data/dto/pokemon_dto.dart';
void main() {
group('PokemonDto.fromTyradexJson', () {
test('parse un Pokémon complet avec deux types', () {
final json = {
'pokedex_id': 6,
'name': {'fr': 'Dracaufeu', 'en': 'Charizard'},
'types': [
{'name': 'Feu'},
{'name': 'Vol'},
],
'stats': {'hp': 78, 'atk': 84, 'def': 78, 'vit': 100},
'category': 'Pokémon Flamme',
};
final p = PokemonDto.fromTyradexJson(json);
expect(p.id, 6);
expect(p.name, 'Dracaufeu');
expect(p.type1, PokemonType.fire);
expect(p.type2, PokemonType.flying);
expect(p.hp, 78);
expect(p.spd, 100);
expect(p.description, 'Pokémon Flamme');
});
test('utilise fallbackId quand pokedex_id absent', () {
final json = {
'name': {'fr': 'Bulbizarre'},
'types': [{'name': 'Plante'}],
'stats': {'hp': 45, 'atk': 49, 'def': 49, 'vit': 45},
'category': 'Pokémon Graine',
};
final p = PokemonDto.fromTyradexJson(json, fallbackId: 1);
expect(p.id, 1);
expect(p.type2, isNull);
expect(p.type1, PokemonType.grass);
});
test('type inconnu mappé sur PokemonType.unknown', () {
final json = {
'pokedex_id': 999,
'name': {'fr': 'Test'},
'types': [{'name': 'TypeInexistant'}],
'stats': {'hp': 1, 'atk': 1, 'def': 1, 'vit': 1},
};
final p = PokemonDto.fromTyradexJson(json);
expect(p.type1, PokemonType.unknown);
expect(p.description, isNull);
});
});
group('PokemonDto round-trip DB', () {
test('toDb puis fromDb reconstruit le Pokémon', () {
const original = Pokemon(
name: 'pikachu',
id: 25,
type1: PokemonType.electric,
type2: null,
hp: 35,
atk: 55,
def: 40,
spd: 90,
description: 'Souris',
isCaught: true,
isSeen: true,
);
final row = PokemonDto.toDb(original);
expect(row['type1'], 'electric');
expect(row['type2'], isNull);
expect(row['isCaught'], 1);
final restored = PokemonDto.fromDb(row);
expect(restored.name, 'pikachu');
expect(restored.id, 25);
expect(restored.type1, PokemonType.electric);
expect(restored.type2, isNull);
expect(restored.isCaught, true);
expect(restored.isSeen, true);
});
});
}

View File

@ -0,0 +1,127 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pokeguess/domain/entities/pokemon.dart';
import 'package:pokeguess/data/datasources/pokemon_local_datasource.dart';
import 'package:pokeguess/data/datasources/pokemon_remote_datasource.dart';
import 'package:pokeguess/data/repositories/pokemon_repository_impl.dart';
Pokemon _mk(int id, {bool caught = false}) => Pokemon(
name: 'p$id',
id: id,
type1: PokemonType.normal,
hp: 1, atk: 1, def: 1, spd: 1,
isCaught: caught,
);
class FakeLocal implements PokemonLocalDataSource {
final Map<int, Pokemon> store;
FakeLocal([Map<int, Pokemon>? init]) : store = init ?? {};
@override
Future<List<Pokemon>> getAll() async => store.values.toList();
@override
Future<Pokemon?> getById(int id) async => store[id];
@override
Future<void> saveAll(List<Pokemon> pokemons) async {
for (final p in pokemons) { store[p.id] = p; }
}
@override
Future<void> update(Pokemon pokemon) async => store[pokemon.id] = pokemon;
@override
Future<int> caughtCount() async =>
store.values.where((p) => p.isCaught).length;
@override
Future<int> seenCount() async => store.values.where((p) => p.isSeen).length;
}
class FakeRemote implements PokemonRemoteDataSource {
final List<Pokemon> all;
int getAllCalls = 0;
int getByIdCalls = 0;
FakeRemote(this.all);
@override
Future<List<Pokemon>> getAll() async {
getAllCalls++;
return all;
}
@override
Future<Pokemon> getById(int id) async {
getByIdCalls++;
return all.firstWhere((p) => p.id == id);
}
}
class ThrowingRemote implements PokemonRemoteDataSource {
@override
Future<List<Pokemon>> getAll() async => throw Exception('network');
@override
Future<Pokemon> getById(int id) async => throw Exception('network');
}
void main() {
group('getById', () {
test('retourne le cache local sans appeler l\'API', () async {
final local = FakeLocal({5: _mk(5)});
final remote = FakeRemote([_mk(5)]);
final repo = PokemonRepositoryImpl(remote: remote, local: local);
final p = await repo.getById(5);
expect(p?.id, 5);
expect(remote.getByIdCalls, 0);
});
test('va chercher sur l\'API et met en cache si absent en local', () async {
final local = FakeLocal();
final remote = FakeRemote([_mk(7)]);
final repo = PokemonRepositoryImpl(remote: remote, local: local);
final p = await repo.getById(7);
expect(p?.id, 7);
expect(remote.getByIdCalls, 1);
expect(await local.getById(7), isNotNull); // mis en cache
});
test('sans local (web), passe directement par l\'API', () async {
final remote = FakeRemote([_mk(9)]);
final repo = PokemonRepositoryImpl(remote: remote, local: null);
final p = await repo.getById(9);
expect(p?.id, 9);
expect(remote.getByIdCalls, 1);
});
});
group('getAll', () {
test('si le cache est complet, ne rappelle pas l\'API', () async {
final store = {for (var i = 1; i <= 1025; i++) i: _mk(i)};
final local = FakeLocal(store);
final remote = FakeRemote([]);
final repo = PokemonRepositoryImpl(remote: remote, local: local);
final list = await repo.getAll();
expect(list.length, 1025);
expect(remote.getAllCalls, 0);
});
test('si le cache est incomplet, synchronise depuis l\'API', () async {
final local = FakeLocal({1: _mk(1)});
final remote = FakeRemote([_mk(1), _mk(2), _mk(3)]);
final repo = PokemonRepositoryImpl(remote: remote, local: local);
final list = await repo.getAll();
expect(remote.getAllCalls, 1);
expect(list.length, 3);
});
});
group('repli sur erreur', () {
test('getById retourne null si l\'API échoue et le cache est vide', () async {
final repo = PokemonRepositoryImpl(remote: ThrowingRemote(), local: FakeLocal());
expect(await repo.getById(42), isNull);
});
test('getAll retourne le cache existant si la synchro API échoue', () async {
final local = FakeLocal({1: _mk(1)}); // cache incomplet -> tente l'API, qui échoue
final repo = PokemonRepositoryImpl(remote: ThrowingRemote(), local: local);
final list = await repo.getAll();
expect(list.length, 1); // repli sur le cache
});
});
}

View File

@ -0,0 +1,124 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pokeguess/core/config/app_constants.dart';
import 'package:pokeguess/domain/entities/pokemon.dart';
import 'package:pokeguess/domain/game/game_engine.dart';
import 'package:pokeguess/domain/game/game_state.dart';
const _engine = GameEngine();
Pokemon _poke(String name) => Pokemon(
name: name, id: 1, type1: PokemonType.normal,
hp: 1, atk: 1, def: 1, spd: 1,
);
GameState _playing(String name, {bool shiny = false}) => _engine.startRound(
const GameState(), _poke(name), isShiny: shiny);
void main() {
test('newGame réinitialise vies/score/session, garde le bestScore', () {
const s = GameState(
lives: 1, currentScore: 50, sessionCorrectCount: 7, bestScore: 99);
final r = _engine.newGame(s);
expect(r.lives, 3);
expect(r.currentScore, 0);
expect(r.sessionCorrectCount, 0);
expect(r.bestScore, 99);
expect(r.status, GameStatus.loading);
});
test('startRound place le Pokémon et passe en playing', () {
final r = _playing('pikachu', shiny: true);
expect(r.currentPokemon?.name, 'pikachu');
expect(r.isShiny, true);
expect(r.isHintUsed, false);
expect(r.status, GameStatus.playing);
});
test('bonne réponse: +10 points et roundWon', () {
final o = _engine.submitGuess(_playing('pikachu'), 'pikachu');
expect(o.result, GuessResult.correct);
expect(o.state.currentScore, 10);
expect(o.state.sessionCorrectCount, 1);
expect(o.state.status, GameStatus.roundWon);
expect(o.state.currentPokemon?.isCaught, true);
});
test('bonne réponse shiny: +20 points', () {
final o = _engine.submitGuess(_playing('pikachu', shiny: true), 'pikachu');
expect(o.state.currentScore, 20);
});
test('comparaison insensible aux accents et à la casse', () {
final o = _engine.submitGuess(_playing('Dracaufeu'), 'dracaufeu');
expect(o.result, GuessResult.correct);
final o2 = _engine.submitGuess(_playing('Électhor'), 'electhor');
expect(o2.result, GuessResult.correct);
});
test('mauvaise réponse: -1 vie, reste playing tant qu\'il reste des vies', () {
final o = _engine.submitGuess(_playing('pikachu'), 'salameche');
expect(o.result, GuessResult.wrong);
expect(o.state.lives, 2);
expect(o.state.status, GameStatus.playing);
});
test('mauvaise réponse à 1 vie: gameOver', () {
const start = GameState(lives: 1, status: GameStatus.playing);
final round = _engine.startRound(start, _poke('pikachu'), isShiny: false);
final o = _engine.submitGuess(round, 'faux');
expect(o.result, GuessResult.gameOver);
expect(o.state.lives, 0);
expect(o.state.status, GameStatus.gameOver);
});
test('le cheat "pikachu" n\'existe plus: faux nom sur un autre Pokémon = wrong', () {
final o = _engine.submitGuess(_playing('bulbizarre'), 'pikachu');
expect(o.result, GuessResult.wrong);
});
test('bonus: indice tous les 5, skip tous les 10', () {
var s = const GameState(sessionCorrectCount: 4, status: GameStatus.playing);
s = _engine.startRound(s, _poke('pikachu'), isShiny: false);
final o = _engine.submitGuess(s, 'pikachu'); // 5e bonne réponse
expect(o.state.sessionCorrectCount, 5);
expect(o.state.hints, appConstantsHints + 1);
});
test('useHint consomme un indice', () {
final s = _playing('pikachu');
final r = _engine.useHint(s);
expect(r.isHintUsed, true);
expect(r.hints, 2);
});
test('useHint sans indice restant ne change rien', () {
final s = _playing('pikachu').copyWith(hints: 0);
final r = _engine.useHint(s);
expect(r.isHintUsed, false);
expect(r.hints, 0);
});
test('useSkip décrémente les skips', () {
final s = _playing('pikachu');
final r = _engine.useSkip(s);
expect(r.skips, 2);
});
test('bonus: skip tous les 10 bonnes réponses', () {
var s = const GameState(sessionCorrectCount: 9, status: GameStatus.playing);
s = _engine.startRound(s, _poke('pikachu'), isShiny: false);
final o = _engine.submitGuess(s, 'pikachu'); // 10e bonne réponse
expect(o.state.sessionCorrectCount, 10);
expect(o.state.skips, AppConstants.startingSkips + 1);
});
test('submitGuess renvoie invalid quand la manche n\'est pas en cours', () {
const s = GameState(status: GameStatus.loading); // aucun pokemon, pas en playing
final o = _engine.submitGuess(s, 'pikachu');
expect(o.result, GuessResult.invalid);
expect(identical(o.state, s), true);
});
}
/// Valeur attendue de hints au démarrage (miroir d'AppConstants.startingHints = 3).
const appConstantsHints = 3;

View File

@ -1,29 +1,11 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pokeguess/main.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
testWidgets('L\'app démarre sans crash', (WidgetTester tester) async {
await tester.pumpWidget(const ProviderScope(child: MyApp()));
expect(find.byType(MaterialApp), findsOneWidget);
});
}