diff --git a/src/ui/flutter_app/lib/game_page/services/engine/ext_move.dart b/src/ui/flutter_app/lib/game_page/services/engine/ext_move.dart index bfd8d4085..5b4b1e71e 100644 --- a/src/ui/flutter_app/lib/game_page/services/engine/ext_move.dart +++ b/src/ui/flutter_app/lib/game_page/services/engine/ext_move.dart @@ -42,6 +42,7 @@ class ExtMove extends PgnNodeData { ExtMove( this.move, { this.boardLayout, + this.moveIndex, super.nags, super.startingComments, super.comments, @@ -67,6 +68,9 @@ class ExtMove extends PgnNodeData { /// The board layout after the move. String? boardLayout; + /// The move index. + int? moveIndex; + static const String _logTag = "[Move]"; /// 'from' square if type==move; otherwise -1. diff --git a/src/ui/flutter_app/lib/game_page/widgets/dialogs/move_list_dialog.dart b/src/ui/flutter_app/lib/game_page/widgets/dialogs/move_list_dialog.dart index b4eaba8ea..438a16925 100644 --- a/src/ui/flutter_app/lib/game_page/widgets/dialogs/move_list_dialog.dart +++ b/src/ui/flutter_app/lib/game_page/widgets/dialogs/move_list_dialog.dart @@ -20,7 +20,7 @@ class MoveListDialog extends StatelessWidget { Widget build(BuildContext context) { final GameController controller = GameController(); String? fen; - List mergedMoves = _getMergedMoves(controller); + List mergedMoves = getMergedMoves(controller); if (mergedMoves.isNotEmpty) { // If the first token is a PGN/FEN tag block, separate it out. if (mergedMoves[0].isNotEmpty) { @@ -183,217 +183,6 @@ class MoveListDialog extends StatelessWidget { return baseStyle; } - /// Tokenize the move history string, treating annotation blocks `{...}` as single tokens. - /// Filter out: - /// 1) Empty/whitespace-only tokens - /// 2) Tokens that match "digits + '.' + optional whitespace" - List _lexTokens(String moveHistoryText) { - final List tokens = []; - int i = 0; - - while (i < moveHistoryText.length) { - final String c = moveHistoryText[i]; - - // (A) If we encounter '{', collect until the matching '}' as one annotation token. - if (c == '{') { - final int start = i; - i++; - int braceLevel = 1; - while (i < moveHistoryText.length && braceLevel > 0) { - if (moveHistoryText[i] == '{') { - braceLevel++; - } else if (moveHistoryText[i] == '}') { - braceLevel--; - } - i++; - } - final String block = moveHistoryText.substring(start, i).trim(); - if (block.isNotEmpty) { - tokens.add(block); - } - continue; - } - - // (B) Skip whitespace characters directly. - if (RegExp(r'\s').hasMatch(c)) { - i++; - continue; - } - - // If we encounter '(' or ')', treat them as single tokens, - // so that we can later handle variations. - if (c == '(' || c == ')') { - tokens.add(c); - i++; - continue; - } - - // (C) Otherwise, collect a normal token until whitespace or '{'. - final int start = i; - while (i < moveHistoryText.length) { - final String cc = moveHistoryText[i]; - // Stop if we hit '{', '(', ')' or any whitespace. - if (cc == '{' || cc == '(' || cc == ')' || RegExp(r'\s').hasMatch(cc)) { - break; - } - i++; - } - - // Extract and trim the token. - final String rawToken = moveHistoryText.substring(start, i); - final String trimmed = rawToken.trim(); - - // 1) Skip if the token is empty or whitespace-only. - if (trimmed.isEmpty) { - continue; - } - // 2) Skip if the token matches "digits + '.' + optional whitespace". - // Example matches: "1.", "12.", "3. " - if (RegExp(r'^\d+\.\s*$').hasMatch(trimmed)) { - continue; - } - - // If it doesn't match any skip rules, add it to our tokens list. - tokens.add(trimmed); - } - - return tokens; - } - - /// 2) Merge tokens: - /// - Combine multiple "x" captures into one move (e.g. "d6-d5" + "xd7" => "d6-d5xd7"). - /// - If a new capture token appears, discard previous annotations. - /// - Handle NAG tokens (like !, ?, !?, ?!, !!, ??) with **no space** before them, - /// so the final move looks like "d4!" or "d4!?" etc. - /// - Only one space precedes the "{...}" comment block if there are comments. - List _mergeMoves(List tokens) { - final List results = []; - - TempMove? current; - - // Flush current move to results. - void finalizeCurrent() { - if (current != null && current!.moveText.isNotEmpty) { - // Construct final string: moveText + (NAGs attached) + optional " {comments...}" - final StringBuffer sb = StringBuffer(current!.moveText); - - // If we have NAGs, append them directly with no space before them. - // e.g., if nags = ["!", "?"] => moveText + "!?" - if (current!.nags.isNotEmpty) { - sb.write(current!.nags.join()); - } - - // If we have comments, add exactly one space before the {..} block. - if (current!.comments.isNotEmpty) { - final String joinedComments = - current!.comments.map(_stripBraces).join(' '); - sb.write(' {$joinedComments}'); - } - - results.add(sb.toString()); - } - current = null; - } - - // Check if token is a typical NAG. - bool isNAG(String token) { - const List nagTokens = ['!', '?', '!!', '??', '!?', '?!']; - return nagTokens.contains(token); - } - - for (final String token in tokens) { - // (A) If it's an annotation block { ... }, store inside comments. - if (token.startsWith('{') && token.endsWith('}')) { - current ??= TempMove(); - final String inside = _stripOuterBraces(token).trim(); - current!.comments.add(inside); - continue; - } - - // (B) If this token is a typical NAG (e.g., !, ?, !?, ?!, !!, ??), - // attach it directly to the move text with no preceding space. - if (isNAG(token)) { - current ??= TempMove(); - current!.nags.add(token); - continue; - } - - // (C) If token starts with 'x', treat it as a capture. - if (token.startsWith('x')) { - if (current == null) { - current = TempMove() - ..moveText = token - ..hasX = true; - } else { - // If previous move did not have 'x', discard previous annotations/NAGs. - if (!current!.hasX) { - current!.comments.clear(); - current!.nags.clear(); // Discard NAGs if a new 'x' appears - } - // Merge capture into the moveText - current!.moveText += token; - current!.hasX = true; - } - continue; - } - - // (D) If the token is '(' or ')', treat it as a standalone bracket token. - // Finalize current move first, then store the bracket directly. - if (token == '(' || token == ')') { - finalizeCurrent(); - // Directly add parentheses token to results - results.add(token); - continue; - } - - // (E) Otherwise, this is a new move token; finalize the previous one first. - finalizeCurrent(); - current = TempMove()..moveText = token; - } - - // Finalize the last move if any. - finalizeCurrent(); - return results; - } - - /// Strip outer braces from an annotation block like "{...}". - String _stripOuterBraces(String block) { - if (block.startsWith('{') && block.endsWith('}') && block.length >= 2) { - return block.substring(1, block.length - 1); - } - return block; - } - - /// Remove all braces inside the text to avoid nested braces issues. - String _stripBraces(String text) { - return text.replaceAll('{', '').replaceAll('}', ''); - } - - /// Merge all moves, preserving optional [FEN] block (if present) as the first item. - List _getMergedMoves(GameController controller) { - final String moveHistoryText = controller.gameRecorder.moveHistoryText; - final List mergedMoves = []; - String remainingText = moveHistoryText; - - // If the string starts with '[', treat it as FEN/PGN tag block. - if (remainingText.startsWith('[')) { - final int bracketEnd = remainingText.lastIndexOf(']') + 1; - if (bracketEnd > 0) { - mergedMoves.add(remainingText.substring(0, bracketEnd)); - remainingText = remainingText.substring(bracketEnd).trim(); - } - } - - // (1) Lexical split (treat { ... } as single token). - final List rawTokens = _lexTokens(remainingText); - - // (2) Merge tokens (capture merges, NAG merges, annotation merges). - final List moves = _mergeMoves(rawTokens); - - mergedMoves.addAll(moves); - return mergedMoves; - } - Widget _buildMoveListItem( BuildContext context, List mergedMoves, @@ -573,3 +362,214 @@ class MoveListDialog extends StatelessWidget { ); } } + +/// Tokenize the move history string, treating annotation blocks `{...}` as single tokens. +/// Filter out: +/// 1) Empty/whitespace-only tokens +/// 2) Tokens that match "digits + '.' + optional whitespace" +List lexTokens(String moveHistoryText) { + final List tokens = []; + int i = 0; + + while (i < moveHistoryText.length) { + final String c = moveHistoryText[i]; + + // (A) If we encounter '{', collect until the matching '}' as one annotation token. + if (c == '{') { + final int start = i; + i++; + int braceLevel = 1; + while (i < moveHistoryText.length && braceLevel > 0) { + if (moveHistoryText[i] == '{') { + braceLevel++; + } else if (moveHistoryText[i] == '}') { + braceLevel--; + } + i++; + } + final String block = moveHistoryText.substring(start, i).trim(); + if (block.isNotEmpty) { + tokens.add(block); + } + continue; + } + + // (B) Skip whitespace characters directly. + if (RegExp(r'\s').hasMatch(c)) { + i++; + continue; + } + + // If we encounter '(' or ')', treat them as single tokens, + // so that we can later handle variations. + if (c == '(' || c == ')') { + tokens.add(c); + i++; + continue; + } + + // (C) Otherwise, collect a normal token until whitespace or '{'. + final int start = i; + while (i < moveHistoryText.length) { + final String cc = moveHistoryText[i]; + // Stop if we hit '{', '(', ')' or any whitespace. + if (cc == '{' || cc == '(' || cc == ')' || RegExp(r'\s').hasMatch(cc)) { + break; + } + i++; + } + + // Extract and trim the token. + final String rawToken = moveHistoryText.substring(start, i); + final String trimmed = rawToken.trim(); + + // 1) Skip if the token is empty or whitespace-only. + if (trimmed.isEmpty) { + continue; + } + // 2) Skip if the token matches "digits + '.' + optional whitespace". + // Example matches: "1.", "12.", "3. " + if (RegExp(r'^\d+\.\s*$').hasMatch(trimmed)) { + continue; + } + + // If it doesn't match any skip rules, add it to our tokens list. + tokens.add(trimmed); + } + + return tokens; +} + +/// Remove all braces inside the text to avoid nested braces issues. +String stripBraces(String text) { + return text.replaceAll('{', '').replaceAll('}', ''); +} + +/// Strip outer braces from an annotation block like "{...}". +String stripOuterBraces(String block) { + if (block.startsWith('{') && block.endsWith('}') && block.length >= 2) { + return block.substring(1, block.length - 1); + } + return block; +} + +/// 2) Merge tokens: +/// - Combine multiple "x" captures into one move (e.g. "d6-d5" + "xd7" => "d6-d5xd7"). +/// - If a new capture token appears, discard previous annotations. +/// - Handle NAG tokens (like !, ?, !?, ?!, !!, ??) with **no space** before them, +/// so the final move looks like "d4!" or "d4!?" etc. +/// - Only one space precedes the "{...}" comment block if there are comments. +List mergeMoves(List tokens) { + final List results = []; + + TempMove? current; + + // Flush current move to results. + void finalizeCurrent() { + if (current != null && current!.moveText.isNotEmpty) { + // Construct final string: moveText + (NAGs attached) + optional " {comments...}" + final StringBuffer sb = StringBuffer(current!.moveText); + + // If we have NAGs, append them directly with no space before them. + // e.g., if nags = ["!", "?"] => moveText + "!?" + if (current!.nags.isNotEmpty) { + sb.write(current!.nags.join()); + } + + // If we have comments, add exactly one space before the {..} block. + if (current!.comments.isNotEmpty) { + final String joinedComments = + current!.comments.map(stripBraces).join(' '); + sb.write(' {$joinedComments}'); + } + + results.add(sb.toString()); + } + current = null; + } + + // Check if token is a typical NAG. + bool isNAG(String token) { + const List nagTokens = ['!', '?', '!!', '??', '!?', '?!']; + return nagTokens.contains(token); + } + + for (final String token in tokens) { + // (A) If it's an annotation block { ... }, store inside comments. + if (token.startsWith('{') && token.endsWith('}')) { + current ??= TempMove(); + final String inside = stripOuterBraces(token).trim(); + current!.comments.add(inside); + continue; + } + + // (B) If this token is a typical NAG (e.g., !, ?, !?, ?!, !!, ??), + // attach it directly to the move text with no preceding space. + if (isNAG(token)) { + current ??= TempMove(); + current!.nags.add(token); + continue; + } + + // (C) If token starts with 'x', treat it as a capture. + if (token.startsWith('x')) { + if (current == null) { + current = TempMove() + ..moveText = token + ..hasX = true; + } else { + // If previous move did not have 'x', discard previous annotations/NAGs. + if (!current!.hasX) { + current!.comments.clear(); + current!.nags.clear(); // Discard NAGs if a new 'x' appears + } + // Merge capture into the moveText + current!.moveText += token; + current!.hasX = true; + } + continue; + } + + // (D) If the token is '(' or ')', treat it as a standalone bracket token. + // Finalize current move first, then store the bracket directly. + if (token == '(' || token == ')') { + finalizeCurrent(); + // Directly add parentheses token to results + results.add(token); + continue; + } + + // (E) Otherwise, this is a new move token; finalize the previous one first. + finalizeCurrent(); + current = TempMove()..moveText = token; + } + + // Finalize the last move if any. + finalizeCurrent(); + return results; +} + +/// Merge all moves, preserving optional [FEN] block (if present) as the first item. +List getMergedMoves(GameController controller) { + final String moveHistoryText = controller.gameRecorder.moveHistoryText; + final List mergedMoves = []; + String remainingText = moveHistoryText; + + // If the string starts with '[', treat it as FEN/PGN tag block. + if (remainingText.startsWith('[')) { + final int bracketEnd = remainingText.lastIndexOf(']') + 1; + if (bracketEnd > 0) { + mergedMoves.add(remainingText.substring(0, bracketEnd)); + remainingText = remainingText.substring(bracketEnd).trim(); + } + } + + // (1) Lexical split (treat { ... } as single token). + final List rawTokens = lexTokens(remainingText); + + // (2) Merge tokens (capture merges, NAG merges, annotation merges). + final List moves = mergeMoves(rawTokens); + + mergedMoves.addAll(moves); + return mergedMoves; +} diff --git a/src/ui/flutter_app/lib/game_page/widgets/mini_board.dart b/src/ui/flutter_app/lib/game_page/widgets/mini_board.dart index ee49f0f65..c215c868d 100644 --- a/src/ui/flutter_app/lib/game_page/widgets/mini_board.dart +++ b/src/ui/flutter_app/lib/game_page/widgets/mini_board.dart @@ -2,35 +2,203 @@ import 'dart:math' as math; +import 'package:fluentui_system_icons/fluentui_system_icons.dart'; import 'package:flutter/material.dart'; import '../../shared/database/database.dart'; import '../services/mill.dart'; +import 'game_page.dart'; /// MiniBoard widget displays a small Nine Men's Morris board given a board layout string. -/// Now it also accepts an optional [extMove] to highlight the last move. -class MiniBoard extends StatelessWidget { +/// When the board is tapped, an overlay navigation icon (FluentIcons.arrow_undo_48_regular) +/// appears in the center and "breathes" (pulses). Tapping the icon triggers navigation +/// to the corresponding move. Only one MiniBoard at a time can display the icon. +class MiniBoard extends StatefulWidget { const MiniBoard({ super.key, required this.boardLayout, - this.extMove, // Optional: used to highlight the last move + this.extMove, + this.onNavigateMove, // Callback when navigation icon is tapped. }); final String boardLayout; final ExtMove? extMove; + /// Optional callback invoked after navigating the move (if you still want it). + /// If not needed, you can remove or refactor. + final VoidCallback? onNavigateMove; + + @override + MiniBoardState createState() => MiniBoardState(); +} + +class MiniBoardState extends State + with SingleTickerProviderStateMixin { + // A static reference to track which MiniBoard is currently active (i.e. showing the icon). + static MiniBoardState? _activeBoard; + + // Flag to control the visibility of the navigation icon overlay. + bool _showNavigationIcon = false; + + // Animation controller to produce the "breathing" (pulsing) effect. + late AnimationController _pulseController; + + // This animation will be used to scale the icon in and out. + late Animation _scaleAnimation; + + @override + void initState() { + super.initState(); + // Initialize the animation controller for a 1-second cycle, repeating forward and reverse. + _pulseController = AnimationController( + vsync: this, + duration: const Duration(seconds: 1), + )..repeat(reverse: true); + + // Create a tween to scale between 0.9 and 1.1, applying a smooth curve. + _scaleAnimation = Tween(begin: 0.9, end: 1.1).animate( + CurvedAnimation( + parent: _pulseController, + curve: Curves.easeInOut, + ), + ); + } + + @override + void dispose() { + // If this instance is the active board, reset the static reference. + if (_activeBoard == this) { + _activeBoard = null; + } + _pulseController.dispose(); + super.dispose(); + } + + /// Utility method to hide the navigation icon of the previously active board if it is still mounted. + static void _hidePreviousActiveBoard() { + // If another board was active, hide its icon if it is still mounted. + // This prevents setState on a disposed widget. + if (_activeBoard != null && _activeBoard!.mounted) { + _activeBoard!.setState(() { + _activeBoard!._showNavigationIcon = false; + }); + } + // Clear the reference so no invalid setState() calls can happen afterward. + _activeBoard = null; + } + + /// When the board is tapped, show the navigation icon on this board + /// and hide it on any previously active board. + void _handleBoardTap() { + // Hide the icon on the previously active board if needed. + if (_activeBoard != this) { + _hidePreviousActiveBoard(); + } + // Make this board the active one. + _activeBoard = this; + + setState(() { + _showNavigationIcon = true; + }); + } + + /// When navigation icon is tapped, import partial moves up to this extMove's index, + /// then jump to that position on the main line. + /// + /// Implementation references move_list_dialog.dart's _importGame logic: + /// - Build PGN substring up to clickedIndex + /// - Call ImportService.import(...) + /// - Then HistoryNavigator.takeBackAll(...), stepForwardAll(...). + void _handleNavigationIconTap() { + final ExtMove? em = widget.extMove; + if (em != null && em.moveIndex != null && em.moveIndex! >= 0) { + final int clickedIndex = em.moveIndex!; + + // 1) Collect mergedMoves from the current GameController + final GameController controller = GameController(); + List mergedMoves = getMergedMoves(controller); + + // 2) Detect if there's a leading fen block + String? fen; + if (mergedMoves.isNotEmpty && mergedMoves[0].startsWith('[')) { + fen = mergedMoves[0]; + mergedMoves = mergedMoves.sublist(1); + } + + // 3) Partial PGN up to (clickedIndex + 1) + String ml = mergedMoves.sublist(0, clickedIndex + 1).join(' '); + if (fen != null) { + ml = '$fen $ml'; + } + + // 4) Import the PGN + try { + ImportService.import(ml); + } catch (exception) { + // If import fails, you can show a tip or revert + // For example: + final String tip = "Cannot import partial moves: $ml"; + GameController().headerTipNotifier.showTip(tip); + // Then optionally return + return; + } + + // 5) Rebuild from scratch: + HistoryNavigator.takeBackAll(context, pop: false); + HistoryNavigator.stepForwardAll(context, pop: false); + } + + // Hide the icon after navigating if desired + setState(() { + _showNavigationIcon = false; + }); + + // If your parent still wants to handle callback + widget.onNavigateMove?.call(); + + // Close the page + Navigator.pop(context); + } + @override Widget build(BuildContext context) { - // Constrain to a square aspect ratio so the board doesn't overflow. return AspectRatio( aspectRatio: 1.0, - child: Container( - color: DB().colorSettings.boardBackgroundColor, - child: CustomPaint( - painter: MiniBoardPainter( - boardLayout: boardLayout, - extMove: extMove, - ), + child: GestureDetector( + // Tapping anywhere on the board shows the navigation icon (and hides icons on other boards). + onTap: _handleBoardTap, + child: Stack( + children: [ + // The board background and drawing are rendered by CustomPaint. + Container( + color: DB().colorSettings.boardBackgroundColor, + child: CustomPaint( + painter: MiniBoardPainter( + boardLayout: widget.boardLayout, + extMove: widget.extMove, + ), + child: Container(), // Ensures the CustomPaint has a size. + ), + ), + // Display the navigation icon overlay in the center only if: + // 1. _showNavigationIcon is true (board is active) + // 2. extMove is provided (there is a move to navigate to) + if (_showNavigationIcon && widget.extMove != null) + Center( + child: ScaleTransition( + scale: _scaleAnimation, + child: IconButton( + // Use the Fluent UI arrow icon. + icon: Icon( + FluentIcons.arrow_undo_48_regular, + color: DB().colorSettings.boardLineColor, + size: 48.0, + ), + onPressed: _handleNavigationIconTap, + ), + ), + ), + ], ), ), ); diff --git a/src/ui/flutter_app/lib/game_page/widgets/moves_list_page.dart b/src/ui/flutter_app/lib/game_page/widgets/moves_list_page.dart index a601c340c..3443c2a7a 100644 --- a/src/ui/flutter_app/lib/game_page/widgets/moves_list_page.dart +++ b/src/ui/flutter_app/lib/game_page/widgets/moves_list_page.dart @@ -68,6 +68,24 @@ class MovesListPageState extends State { _allNodes ..clear() ..addAll(GameController().gameRecorder.mainlineNodes); + + int currentMoveIndex = 0; // Initialize moveIndex for the first node + + for (int i = 0; i < _allNodes.length; i++) { + final PgnNode node = _allNodes[i]; + + if (i == 0) { + // First node always gets moveIndex 0 + node.data?.moveIndex = currentMoveIndex; + } else if (node.data?.type == MoveType.remove) { + // TODO: WAR: If it's a remove type, use the previous node's moveIndex + node.data?.moveIndex = _allNodes[i - 1].data?.moveIndex; + } else { + // Otherwise, increment the previous node's moveIndex + currentMoveIndex = (_allNodes[i - 1].data?.moveIndex ?? 0) + 1; + node.data?.moveIndex = currentMoveIndex; + } + } } /// Helper method to load a game, then refresh.