Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 160 additions & 0 deletions lib/src/model/analysis/custom_pgn_analysis.dart
Original file line number Diff line number Diff line change
@@ -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<ParsedPgnGame> parsedGames,
@Default(IListConst([])) IList<String> 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, CustomPgnAnalysisState>(
CustomPgnAnalysisNotifier.new,
name: 'CustomPgnAnalysisProvider',
);

class CustomPgnAnalysisNotifier extends Notifier<CustomPgnAnalysisState> {
@override
CustomPgnAnalysisState build() {
return const CustomPgnAnalysisState();
}

/// Load PGN files and parse them
Future<void> loadPgnFiles(List<String> fileNames, List<String> pgnContents) async {
final List<ParsedPgnGame> validGames = [];
final List<String> 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<Shape> getMoveSuggestionsAsShapes(
Position currentPosition,
List<Move> moveHistory,
Color arrowColor,
) {
final suggestions = <Move>[];

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<Shape>()
.toISet();
}

/// Find move suggestions for a given position (for display purposes)
List<MoveSuggestion> findMoveSuggestions(Position currentPosition, List<Move> moveHistory) {
final suggestions = <MoveSuggestion>[];

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<Move> 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();
}
}
69 changes: 69 additions & 0 deletions lib/src/view/analysis/analysis_screen.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<void> _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 = <String>[];
final pgnContents = <String>[];

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<void> _showShareMenu(BuildContext context, WidgetRef ref) {
final analysisState = ref.read(analysisControllerProvider(options)).requireValue;
return showAdaptiveActionSheet(
Expand Down
34 changes: 34 additions & 0 deletions lib/src/view/analysis/game_analysis_board.dart
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -35,6 +39,36 @@ class _GameAnalysisBoardState
analysisState.isServerAnalysisEnabled &&
analysisPrefs.showAnnotations;

@override
ISet<Shape> 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 = <Move>[];
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)
Expand Down
49 changes: 48 additions & 1 deletion lib/src/view/more/load_position_screen.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -115,6 +130,38 @@ class _BodyState extends State<_Body> {
}
}

Future<void> _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;
Expand Down
8 changes: 8 additions & 0 deletions pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down