diff --git a/lib/src/model/analysis/custom_pgn_analysis.dart b/lib/src/model/analysis/custom_pgn_analysis.dart new file mode 100644 index 0000000000..33962df87a --- /dev/null +++ b/lib/src/model/analysis/custom_pgn_analysis.dart @@ -0,0 +1,160 @@ +import 'package:chessground/chessground.dart'; +import 'package:collection/collection.dart'; +import 'package:dartchess/dartchess.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:lichess_mobile/src/model/common/node.dart'; + +part 'custom_pgn_analysis.freezed.dart'; + +/// Represents a parsed PGN game with its move tree +@freezed +sealed class ParsedPgnGame with _$ParsedPgnGame { + const factory ParsedPgnGame({ + required String fileName, + required Root moveTree, + required String rawPgn, + }) = _ParsedPgnGame; +} + +/// State for custom PGN analysis +@freezed +sealed class CustomPgnAnalysisState with _$CustomPgnAnalysisState { + const factory CustomPgnAnalysisState({ + @Default(IListConst([])) IList parsedGames, + @Default(IListConst([])) IList failedFiles, + @Default(false) bool isEngineDisabled, + }) = _CustomPgnAnalysisState; +} + +/// Represents a matched move suggestion from PGN files +@freezed +sealed class MoveSuggestion with _$MoveSuggestion { + const factory MoveSuggestion({ + required Move move, + required String san, + required String fromGame, + }) = _MoveSuggestion; +} + +/// Provider for custom PGN analysis state +final customPgnAnalysisProvider = + NotifierProvider( + CustomPgnAnalysisNotifier.new, + name: 'CustomPgnAnalysisProvider', + ); + +class CustomPgnAnalysisNotifier extends Notifier { + @override + CustomPgnAnalysisState build() { + return const CustomPgnAnalysisState(); + } + + /// Load PGN files and parse them + Future loadPgnFiles(List fileNames, List pgnContents) async { + final List validGames = []; + final List failed = []; + + for (int i = 0; i < fileNames.length; i++) { + try { + final pgnGame = PgnGame.parsePgn(pgnContents[i]); + final moveTree = Root.fromPgnGame(pgnGame); + + // Validate that the game has at least one move + if (moveTree.children.isEmpty) { + failed.add(fileNames[i]); + continue; + } + + validGames.add( + ParsedPgnGame(fileName: fileNames[i], moveTree: moveTree, rawPgn: pgnContents[i]), + ); + } catch (e) { + failed.add(fileNames[i]); + } + } + + state = state.copyWith( + parsedGames: validGames.toIList(), + failedFiles: failed.toIList(), + isEngineDisabled: validGames.isNotEmpty, + ); + } + + /// Find move suggestions for a given position and return them as shapes (arrows) + ISet getMoveSuggestionsAsShapes( + Position currentPosition, + List moveHistory, + Color arrowColor, + ) { + final suggestions = []; + + for (final game in state.parsedGames) { + // Try to match the current move sequence + final matchedMove = _findMatchInGame(game, currentPosition, moveHistory); + if (matchedMove != null && !suggestions.contains(matchedMove)) { + suggestions.add(matchedMove); + } + } + + // Convert moves to arrow shapes + return suggestions + .map((move) { + if (move is NormalMove) { + return Arrow(color: arrowColor, orig: move.from, dest: move.to); + } + return null; + }) + .whereType() + .toISet(); + } + + /// Find move suggestions for a given position (for display purposes) + List findMoveSuggestions(Position currentPosition, List moveHistory) { + final suggestions = []; + + for (final game in state.parsedGames) { + // Try to match the current move sequence + final matchedMove = _findMatchInGame(game, currentPosition, moveHistory); + if (matchedMove != null) { + final san = currentPosition.makeSan(matchedMove).$2; + suggestions.add(MoveSuggestion(move: matchedMove, san: san, fromGame: game.fileName)); + } + } + + return suggestions; + } + + /// Find a matching move in a specific game + Move? _findMatchInGame(ParsedPgnGame game, Position currentPosition, List moveHistory) { + if (moveHistory.isEmpty) { + // At the start position, suggest the first move of the game + final firstMove = game.moveTree.children.firstOrNull; + return firstMove?.sanMove.move; + } + + // Traverse the game tree following the move history + Node currentNode = game.moveTree; + for (final move in moveHistory) { + final nextNode = currentNode.children.firstWhereOrNull((child) => child.sanMove.move == move); + + if (nextNode == null) { + // This game doesn't match the current move sequence + return null; + } + + currentNode = nextNode; + } + + // Found a match! Now get the next move if it exists + final nextMove = currentNode.children.firstOrNull; + return nextMove?.sanMove.move; + } + + /// Clear all loaded PGN files + void clear() { + state = const CustomPgnAnalysisState(); + } +} diff --git a/lib/src/view/analysis/analysis_screen.dart b/lib/src/view/analysis/analysis_screen.dart index b82e983f7d..018542f7ac 100644 --- a/lib/src/view/analysis/analysis_screen.dart +++ b/lib/src/view/analysis/analysis_screen.dart @@ -1,10 +1,12 @@ import 'package:dartchess/dartchess.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lichess_mobile/src/constants.dart'; import 'package:lichess_mobile/src/model/analysis/analysis_controller.dart'; import 'package:lichess_mobile/src/model/analysis/analysis_preferences.dart'; +import 'package:lichess_mobile/src/model/analysis/custom_pgn_analysis.dart'; import 'package:lichess_mobile/src/model/analysis/opening_service.dart'; import 'package:lichess_mobile/src/model/auth/auth_controller.dart'; import 'package:lichess_mobile/src/model/common/chess.dart'; @@ -610,10 +612,77 @@ class _BottomBar extends ConsumerWidget { makeLabel: (context) => Text(context.l10n.studyShareAndExport), onPressed: () => _showShareMenu(context, ref), ), + if (analysisState.isComputerAnalysisAllowed) + BottomSheetAction( + makeLabel: (context) => const Text('Load custom PGN files'), + onPressed: () => _loadCustomPgnFiles(context, ref), + ), + if (analysisState.isComputerAnalysisAllowed && + ref.read(customPgnAnalysisProvider).parsedGames.isNotEmpty) + BottomSheetAction( + makeLabel: (context) => const Text('Clear PGN files'), + onPressed: () { + ref.read(customPgnAnalysisProvider.notifier).clear(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('PGN files cleared'), duration: Duration(seconds: 2)), + ); + }, + ), ], ); } + // DOES NOT SUPPORT MULTIPLE GAMES IN THE SAME .pgn FILE + Future _loadCustomPgnFiles(BuildContext context, WidgetRef ref) async { + try { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['pgn'], + allowMultiple: true, + withData: true, + ); + + if (result != null && result.files.isNotEmpty && context.mounted) { + final fileNames = []; + final pgnContents = []; + + for (final file in result.files) { + if (file.bytes != null) { + fileNames.add(file.name); + pgnContents.add(String.fromCharCodes(file.bytes!)); + } + } + + if (fileNames.isNotEmpty) { + await ref.read(customPgnAnalysisProvider.notifier).loadPgnFiles(fileNames, pgnContents); + + if (context.mounted) { + final state = ref.read(customPgnAnalysisProvider); + final loadedCount = state.parsedGames.length; + final failedCount = state.failedFiles.length; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + loadedCount > 0 + ? 'Loaded $loadedCount PGN file(s)${failedCount > 0 ? '. Failed: $failedCount' : '. Arrows will show suggested moves.'}' + : 'All files failed to load', + ), + duration: const Duration(seconds: 3), + ), + ); + } + } + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Error loading files: $e'))); + } + } + } + Future _showShareMenu(BuildContext context, WidgetRef ref) { final analysisState = ref.read(analysisControllerProvider(options)).requireValue; return showAdaptiveActionSheet( diff --git a/lib/src/view/analysis/game_analysis_board.dart b/lib/src/view/analysis/game_analysis_board.dart index 5c9c0c91bc..346084cde6 100644 --- a/lib/src/view/analysis/game_analysis_board.dart +++ b/lib/src/view/analysis/game_analysis_board.dart @@ -1,7 +1,11 @@ +import 'package:chessground/chessground.dart'; import 'package:dartchess/dartchess.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lichess_mobile/src/model/analysis/analysis_controller.dart'; import 'package:lichess_mobile/src/model/analysis/analysis_preferences.dart'; +import 'package:lichess_mobile/src/model/analysis/custom_pgn_analysis.dart'; import 'package:lichess_mobile/src/view/analysis/analysis_board.dart'; class GameAnalysisBoard extends AnalysisBoard { @@ -35,6 +39,36 @@ class _GameAnalysisBoardState analysisState.isServerAnalysisEnabled && analysisPrefs.showAnnotations; + @override + ISet get extraShapes { + final customPgnState = ref.watch(customPgnAnalysisProvider); + + // If no PGN files are loaded, return empty set + if (customPgnState.parsedGames.isEmpty) { + return ISet(); + } + + // Build move history from current position + final moves = []; + if (analysisState.currentPath.size > 0) { + for (final node in analysisState.root.mainline) { + moves.add(node.sanMove.move); + if (node.position.fen == analysisState.currentNode.position.fen) { + break; + } + } + } + + // Get shapes from custom PGN analysis (purple/magenta arrows to distinguish from engine) + return ref + .read(customPgnAnalysisProvider.notifier) + .getMoveSuggestionsAsShapes( + analysisState.currentPosition, + moves, + const Color(0x99CC00FF), // Purple with transparency + ); + } + @override void onUserMove(NormalMove move) => ref .read(analysisControllerProvider(widget.options).notifier) diff --git a/lib/src/view/more/load_position_screen.dart b/lib/src/view/more/load_position_screen.dart index 745998f155..4aa2a53358 100644 --- a/lib/src/view/more/load_position_screen.dart +++ b/lib/src/view/more/load_position_screen.dart @@ -1,4 +1,5 @@ import 'package:dartchess/dartchess.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:lichess_mobile/src/model/analysis/analysis_controller.dart'; @@ -69,7 +70,21 @@ class _BodyState extends State<_Body> { hintText: '${context.l10n.pasteTheFenStringHere}\n\u2014\n${context.l10n.pasteThePgnStringHere}', hintMaxLines: 3, - suffixIcon: const Icon(Icons.paste), + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.upload_file), + onPressed: _pickPgnFile, + tooltip: 'Upload PGN file', + ), + IconButton( + icon: const Icon(Icons.paste), + onPressed: _getClipboardData, + tooltip: 'Paste from clipboard', + ), + ], + ), ), controller: _controller, readOnly: true, @@ -115,6 +130,38 @@ class _BodyState extends State<_Body> { } } + Future _pickPgnFile() async { + try { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['pgn'], + withData: true, + ); + + if (result != null && result.files.single.bytes != null) { + final content = String.fromCharCodes(result.files.single.bytes!); + + // Validate that it's actually a PGN file + try { + PgnGame.parsePgn(content); + _controller.text = content; + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Invalid PGN file: $e'), backgroundColor: Colors.red), + ); + } + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error loading file: $e'), backgroundColor: Colors.red), + ); + } + } + } + ({String fen, AnalysisOptions options})? get parsedInput { if (textInput == null || textInput!.trim().isEmpty) { return null; diff --git a/pubspec.lock b/pubspec.lock index a531a60876..c4ef7b8242 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -465,6 +465,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810 + url: "https://pub.dev" + source: hosted + version: "8.3.7" file_selector_linux: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index cf2bcbaf22..00ac54eeb9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -27,6 +27,7 @@ dependencies: device_info_plus: ^12.1.0 dynamic_system_colors: ^1.8.0 fast_immutable_collections: ^11.0.0 + file_picker: ^8.1.6 firebase_core: ^4.0.0 firebase_crashlytics: ^5.0.0 firebase_messaging: ^16.0.0