diff --git a/lib/core/config/app_constants.dart b/lib/core/config/app_constants.dart index d5e3542..85fd53d 100644 --- a/lib/core/config/app_constants.dart +++ b/lib/core/config/app_constants.dart @@ -37,4 +37,20 @@ class AppConstants { /// Clé SharedPreferences pour le meilleur score. static const String prefsBestScore = 'best_score'; + + /// Clé SharedPreferences pour le filtre de générations. + static const String prefsGenFilter = 'gen_filter'; + + /// Plages d'IDs Pokémon par génération [min, max] (inclusif). + static const List<(int, int)> genRanges = [ + (1, 151), // Gen 1 + (152, 251), // Gen 2 + (252, 386), // Gen 3 + (387, 493), // Gen 4 + (494, 649), // Gen 5 + (650, 721), // Gen 6 + (722, 809), // Gen 7 + (810, 905), // Gen 8 + (906, 1025), // Gen 9 + ]; } diff --git a/lib/main.dart b/lib/main.dart index ec60e2f..878f6e5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,6 +6,7 @@ import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'presentation/pages/main_page.dart'; import 'presentation/pages/pokemon_detail.dart'; import 'presentation/pages/game_over_page.dart'; +import 'presentation/providers/theme_provider.dart'; void main() { WidgetsFlutterBinding.ensureInitialized(); @@ -16,17 +17,20 @@ void main() { runApp(const ProviderScope(child: MyApp())); } -class MyApp extends StatelessWidget { +class MyApp extends ConsumerWidget { const MyApp({super.key}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final paletteIndex = ref.watch(themeProvider); + final palette = appPalettes[paletteIndex]; + return MaterialApp( title: 'Pokéguess', theme: ThemeData( colorScheme: ColorScheme.fromSeed( - seedColor: const Color(0xFFD32F2F), - surface: const Color(0xFF1B2333), + seedColor: palette.primary, + surface: palette.surface, ), textTheme: GoogleFonts.vt323TextTheme( Theme.of(context).textTheme, diff --git a/lib/presentation/pages/guess_page.dart b/lib/presentation/pages/guess_page.dart index 762880e..943a229 100644 --- a/lib/presentation/pages/guess_page.dart +++ b/lib/presentation/pages/guess_page.dart @@ -2,6 +2,7 @@ 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/gen_filter_provider.dart'; import '../providers/game_provider.dart'; import '../providers/navigation_provider.dart'; import '../widgets/pokemon_image.dart'; @@ -16,6 +17,7 @@ class GuessPage extends ConsumerStatefulWidget { class _GuessPageState extends ConsumerState { final TextEditingController _guessController = TextEditingController(); bool _started = false; + bool _genFilterOpen = false; @override void initState() { @@ -170,6 +172,49 @@ class _GuessPageState extends ConsumerState { ], ), ), + // Gen filter + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + children: [ + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () => setState(() => _genFilterOpen = !_genFilterOpen), + icon: Icon( + _genFilterOpen ? Icons.expand_less : Icons.filter_list, + color: const Color(0xFF1B2333), + ), + label: const Text( + 'GEN FILTER', + style: TextStyle( + color: Color(0xFF1B2333), + fontWeight: FontWeight.bold, + fontSize: 16, + letterSpacing: 2, + ), + ), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Color(0xFF1B2333), width: 2), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), + ), + ), + ), + AnimatedSize( + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + child: _genFilterOpen + ? _GenFilterPanel( + selectedGens: ref.watch(genFilterProvider), + onToggle: (i) => ref.read(genFilterProvider.notifier).toggle(i), + ) + : const SizedBox.shrink(), + ), + ], + ), + ), + const SizedBox(height: 12), // Lives Row( mainAxisAlignment: MainAxisAlignment.center, @@ -323,3 +368,73 @@ class _GuessPageState extends ConsumerState { ); } } + +class _GenFilterPanel extends StatelessWidget { + final Set selectedGens; + final void Function(int) onToggle; + + const _GenFilterPanel({required this.selectedGens, required this.onToggle}); + + static const _genNames = ['Gen I', 'Gen II', 'Gen III', 'Gen IV', 'Gen V', 'Gen VI', 'Gen VII', 'Gen VIII', 'Gen IX']; + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(top: 4), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: const Color(0xFF1B2333), width: 2), + ), + child: Column( + children: List.generate(AppConstants.genRanges.length, (i) { + final range = AppConstants.genRanges[i]; + final isSelected = selectedGens.contains(i); + final isLast = i == AppConstants.genRanges.length - 1; + return InkWell( + onTap: () => onToggle(i), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: isSelected ? const Color(0xFF1B2333).withAlpha(12) : Colors.transparent, + border: isLast ? null : Border(bottom: BorderSide(color: Colors.grey.shade200)), + ), + child: Row( + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: 20, + height: 20, + decoration: BoxDecoration( + color: isSelected ? const Color(0xFF3B6EE3) : Colors.transparent, + border: Border.all( + color: isSelected ? const Color(0xFF3B6EE3) : Colors.grey.shade400, + width: 2, + ), + borderRadius: BorderRadius.circular(3), + ), + child: isSelected ? const Icon(Icons.check, size: 13, color: Colors.white) : null, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + _genNames[i], + style: TextStyle( + fontSize: 16, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + color: isSelected ? const Color(0xFF1B2333) : Colors.black54, + ), + ), + ), + Text( + '#${range.$1}–#${range.$2}', + style: TextStyle(fontSize: 12, color: Colors.grey.shade500), + ), + ], + ), + ), + ); + }), + ), + ); + } +} diff --git a/lib/presentation/pages/main_page.dart b/lib/presentation/pages/main_page.dart index d32aaa4..487a196 100644 --- a/lib/presentation/pages/main_page.dart +++ b/lib/presentation/pages/main_page.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../providers/navigation_provider.dart'; +import '../providers/theme_provider.dart'; import 'pokemon_list.dart'; import 'guess_page.dart'; +import 'system_page.dart'; class MainPage extends ConsumerWidget { const MainPage({Key? key}) : super(key: key); @@ -10,23 +12,27 @@ class MainPage extends ConsumerWidget { static const List _pages = [ PokemonListPage(), GuessPage(), - Center(child: Text("SYSTEM PAGE placeholder")), + SystemPage(), ]; @override Widget build(BuildContext context, WidgetRef ref) { final currentIndex = ref.watch(selectedTabProvider); + final palette = appPalettes[ref.watch(themeProvider)]; + final primaryDark = HSLColor.fromColor(palette.primary) + .withLightness((HSLColor.fromColor(palette.primary).lightness - 0.1).clamp(0.0, 1.0)) + .toColor(); return Scaffold( - backgroundColor: const Color(0xFF1B2333), + backgroundColor: palette.surface, body: SafeArea( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 20.0), child: Container( decoration: BoxDecoration( - color: const Color(0xFFD32F2F), + color: palette.primary, borderRadius: BorderRadius.circular(30), - border: Border.all(color: const Color(0xFFA12020), width: 4), + border: Border.all(color: primaryDark, width: 4), ), child: ClipRRect( borderRadius: BorderRadius.circular(26), @@ -47,7 +53,7 @@ class MainPage extends ConsumerWidget { currentIndex: currentIndex, onTap: (index) => ref.read(selectedTabProvider.notifier).set(index), type: BottomNavigationBarType.fixed, - selectedItemColor: const Color(0xFFD32F2F), + selectedItemColor: palette.primary, unselectedItemColor: Colors.grey, items: const [ BottomNavigationBarItem(icon: Icon(Icons.grid_view), label: 'LIST'), diff --git a/lib/presentation/pages/system_page.dart b/lib/presentation/pages/system_page.dart new file mode 100644 index 0000000..7a279e7 --- /dev/null +++ b/lib/presentation/pages/system_page.dart @@ -0,0 +1,260 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../../core/config/app_constants.dart'; +import '../providers/pokedex_provider.dart'; +import '../providers/theme_provider.dart'; + +class SystemPage extends ConsumerWidget { + const SystemPage({super.key}); + + Future _loadBestScore() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getInt(AppConstants.prefsBestScore) ?? 0; + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final paletteIndex = ref.watch(themeProvider); + final palette = appPalettes[paletteIndex]; + final pokedexAsync = ref.watch(pokedexProvider); + + return Container( + color: const Color(0xFFC8D1D8), + child: Column( + children: [ + _Header(primaryColor: palette.primary), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _SectionTitle(text: 'STATISTIQUES', color: palette.primary), + const SizedBox(height: 8), + pokedexAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, __) => const Text('Erreur de chargement'), + data: (pokemons) { + final total = pokemons.length; + final caught = pokemons.where((p) => p.isCaught).length; + final seen = pokemons.where((p) => p.isSeen).length; + final pct = total > 0 ? (caught / total * 100).toStringAsFixed(1) : '0.0'; + return FutureBuilder( + future: _loadBestScore(), + builder: (context, snap) { + final best = snap.data ?? 0; + return _StatsGrid( + primaryColor: palette.primary, + items: [ + _StatItem(label: 'Meilleur score', value: '$best', icon: Icons.emoji_events), + _StatItem(label: 'Attrapés', value: '$caught / $total', icon: Icons.catching_pokemon), + _StatItem(label: 'Vus', value: '$seen / $total', icon: Icons.visibility), + _StatItem(label: 'Complétion', value: '$pct%', icon: Icons.pie_chart), + ], + ); + }, + ); + }, + ), + const SizedBox(height: 24), + _SectionTitle(text: 'PALETTE DE COULEURS', color: palette.primary), + const SizedBox(height: 8), + _PalettePicker( + selectedIndex: paletteIndex, + onSelect: (i) => ref.read(themeProvider.notifier).setPalette(i), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +class _Header extends StatelessWidget { + final Color primaryColor; + const _Header({required this.primaryColor}); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 16), + decoration: BoxDecoration( + color: primaryColor, + border: Border(bottom: BorderSide(color: primaryColor.withAlpha(180), width: 3)), + ), + child: const Text( + 'SYSTÈME', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + fontSize: 28, + fontWeight: FontWeight.bold, + letterSpacing: 4, + ), + ), + ); + } +} + +class _SectionTitle extends StatelessWidget { + final String text; + final Color color; + const _SectionTitle({required this.text, required this.color}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Container(width: 4, height: 20, color: color), + const SizedBox(width: 8), + Text( + text, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: color, + letterSpacing: 2, + ), + ), + ], + ); + } +} + +class _StatItem { + final String label; + final String value; + final IconData icon; + const _StatItem({required this.label, required this.value, required this.icon}); +} + +class _StatsGrid extends StatelessWidget { + final Color primaryColor; + final List<_StatItem> items; + const _StatsGrid({required this.primaryColor, required this.items}); + + @override + Widget build(BuildContext context) { + return GridView.count( + crossAxisCount: 2, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisSpacing: 12, + mainAxisSpacing: 12, + childAspectRatio: 1.4, + children: items.map((item) => _StatCard(item: item, color: primaryColor)).toList(), + ); + } +} + +class _StatCard extends StatelessWidget { + final _StatItem item; + final Color color; + const _StatCard({required this.item, required this.color}); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: color.withAlpha(80), width: 2), + boxShadow: [ + BoxShadow(color: Colors.black.withAlpha(25), blurRadius: 4, offset: const Offset(0, 2)), + ], + ), + padding: const EdgeInsets.all(12), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(item.icon, color: color, size: 28), + const SizedBox(height: 6), + Text( + item.value, + style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: color), + ), + Text( + item.label, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 12, color: Colors.black54), + ), + ], + ), + ); + } +} + +class _PalettePicker extends StatelessWidget { + final int selectedIndex; + final void Function(int) onSelect; + const _PalettePicker({required this.selectedIndex, required this.onSelect}); + + @override + Widget build(BuildContext context) { + return Column( + children: List.generate(appPalettes.length, (i) { + final p = appPalettes[i]; + final isSelected = i == selectedIndex; + return GestureDetector( + onTap: () => onSelect(i), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: isSelected ? p.primary : Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected ? p.primary : Colors.grey.shade300, + width: isSelected ? 3 : 1.5, + ), + boxShadow: isSelected + ? [BoxShadow(color: p.primary.withAlpha(80), blurRadius: 8, offset: const Offset(0, 3))] + : [], + ), + child: Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: p.primary, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + ), + const SizedBox(width: 8), + Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: p.surface, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 1.5), + ), + ), + const SizedBox(width: 16), + Text( + p.name, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: isSelected ? Colors.white : Colors.black87, + ), + ), + const Spacer(), + if (isSelected) + const Icon(Icons.check_circle, color: Colors.white, size: 22), + ], + ), + ), + ); + }), + ); + } +} diff --git a/lib/presentation/providers/game_provider.dart b/lib/presentation/providers/game_provider.dart index 4adadaf..ce3a182 100644 --- a/lib/presentation/providers/game_provider.dart +++ b/lib/presentation/providers/game_provider.dart @@ -6,6 +6,7 @@ import '../../core/config/app_constants.dart'; import '../../domain/game/game_engine.dart'; import '../../domain/game/game_state.dart'; import '../../core/logger.dart'; +import 'gen_filter_provider.dart'; import 'pokedex_provider.dart'; import 'repository_provider.dart'; @@ -34,7 +35,7 @@ class GameNotifier extends Notifier { final repo = ref.read(pokemonRepositoryProvider); final isShiny = _random.nextInt(AppConstants.shinyOdds) == 0; - final id = _random.nextInt(AppConstants.totalPokemon) + 1; + final id = ref.read(genFilterProvider.notifier).randomId(_random); try { final pokemon = await repo.getById(id); if (pokemon == null) { diff --git a/lib/presentation/providers/gen_filter_provider.dart b/lib/presentation/providers/gen_filter_provider.dart new file mode 100644 index 0000000..5a95765 --- /dev/null +++ b/lib/presentation/providers/gen_filter_provider.dart @@ -0,0 +1,54 @@ +import 'dart:math'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../../core/config/app_constants.dart'; + +class GenFilterNotifier extends Notifier> { + @override + Set build() { + _load(); + // Default: all gens enabled + return Set.from(List.generate(AppConstants.genRanges.length, (i) => i)); + } + + Future _load() async { + final prefs = await SharedPreferences.getInstance(); + final saved = prefs.getStringList(AppConstants.prefsGenFilter); + if (saved != null && saved.isNotEmpty) { + state = saved.map(int.parse).toSet(); + } + } + + Future toggle(int genIndex) async { + final next = Set.from(state); + if (next.contains(genIndex)) { + // Prevent deselecting the last gen + if (next.length == 1) return; + next.remove(genIndex); + } else { + next.add(genIndex); + } + state = next; + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList( + AppConstants.prefsGenFilter, + state.map((i) => i.toString()).toList(), + ); + } + + /// Returns a random Pokémon ID within the currently enabled gens. + int randomId(Random random) { + final ranges = state.map((i) => AppConstants.genRanges[i]).toList(); + final totalPool = ranges.fold(0, (sum, r) => sum + (r.$2 - r.$1 + 1)); + var pick = random.nextInt(totalPool); + for (final r in ranges) { + final size = r.$2 - r.$1 + 1; + if (pick < size) return r.$1 + pick; + pick -= size; + } + return 1; + } +} + +final genFilterProvider = + NotifierProvider>(GenFilterNotifier.new); diff --git a/lib/presentation/providers/theme_provider.dart b/lib/presentation/providers/theme_provider.dart new file mode 100644 index 0000000..abd0ae3 --- /dev/null +++ b/lib/presentation/providers/theme_provider.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class AppPalette { + final String name; + final Color primary; + final Color surface; + + const AppPalette({ + required this.name, + required this.primary, + required this.surface, + }); +} + +const List appPalettes = [ + AppPalette(name: 'Pokédex Rouge', primary: Color(0xFFD32F2F), surface: Color(0xFF1B2333)), + AppPalette(name: 'Océan Bleu', primary: Color(0xFF1565C0), surface: Color(0xFF0D1B2A)), + AppPalette(name: 'Forêt Verte', primary: Color(0xFF2E7D32), surface: Color(0xFF1A2B1A)), + AppPalette(name: 'Foudre Jaune', primary: Color(0xFFF9A825), surface: Color(0xFF1C1A00)), + AppPalette(name: 'Ombre Violette',primary: Color(0xFF6A1B9A), surface: Color(0xFF1A0A2B)), +]; + +const String _prefsPaletteIndex = 'palette_index'; + +class ThemeNotifier extends Notifier { + @override + int build() { + _loadSaved(); + return 0; + } + + Future _loadSaved() async { + final prefs = await SharedPreferences.getInstance(); + final idx = prefs.getInt(_prefsPaletteIndex) ?? 0; + state = idx.clamp(0, appPalettes.length - 1); + } + + Future setPalette(int index) async { + state = index.clamp(0, appPalettes.length - 1); + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_prefsPaletteIndex, state); + } + + AppPalette get current => appPalettes[state]; +} + +final themeProvider = NotifierProvider(ThemeNotifier.new);