diff --git a/CHANGELOG.md b/CHANGELOG.md index e364dd4..4b76b08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 3.3.0 + +- Add a `BoardEditor` widget, intended to be used as the basis for a board editor like lichess.org/editor + ## 3.2.0 - Add `pieceShiftMethod` to `BoardSetttings`, with possible values: `either` (default), `drag`, or `tapTwoSquares`. diff --git a/example/lib/board_editor_page.dart b/example/lib/board_editor_page.dart new file mode 100644 index 0000000..7706d85 --- /dev/null +++ b/example/lib/board_editor_page.dart @@ -0,0 +1,159 @@ +import 'package:board_example/board_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:chessground/chessground.dart'; +import 'package:dartchess/dartchess.dart' as dc; +import 'package:collection/collection.dart'; + +class BoardEditorPage extends StatefulWidget { + const BoardEditorPage({super.key}); + + @override + State createState() => _BoardEditorPageState(); +} + +class _BoardEditorPageState extends State { + Pieces pieces = readFen(dc.kInitialFEN); + + Piece? pieceToAddOnTap; + bool deleteOnTap = false; + + @override + Widget build(BuildContext context) { + final double screenWidth = MediaQuery.of(context).size.width; + + const PieceSet pieceSet = PieceSet.merida; + + final settings = BoardEditorSettings( + pieceAssets: pieceSet.assets, + colorScheme: BoardTheme.blue.colors, + enableCoordinates: true, + ); + final boardEditor = BoardEditor( + size: screenWidth, + orientation: Side.white, + pieces: pieces, + settings: settings, + onTappedSquare: (squareId) => setState(() { + if (deleteOnTap) { + pieces.remove(squareId); + } else if (pieceToAddOnTap != null) { + pieces[squareId] = pieceToAddOnTap!; + } + }), + onDiscardedPiece: (squareId) => setState(() { + pieces.remove(squareId); + }), + onDroppedPiece: (origin, destination, piece) => setState(() { + pieces[destination] = piece; + if (origin != null) { + pieces.remove(origin); + } + }), + ); + + makePieceMenu(side) => PieceMenu( + side: side, + pieceSet: pieceSet, + squareSize: boardEditor.squareSize, + settings: settings, + selectedPiece: pieceToAddOnTap, + pieceTapped: (role) => setState(() { + pieceToAddOnTap = Piece(role: role, color: side); + deleteOnTap = false; + }), + deleteSelected: deleteOnTap, + deleteTapped: () => setState(() { + pieceToAddOnTap = null; + deleteOnTap = !deleteOnTap; + }), + ); + + return Scaffold( + appBar: AppBar( + title: const Text('Board Editor'), + ), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + makePieceMenu(Side.white), + boardEditor, + makePieceMenu(Side.black), + Text('FEN: ${writeFen(pieces)}'), + ], + ), + ), + ); + } +} + +class PieceMenu extends StatelessWidget { + const PieceMenu({ + super.key, + required this.side, + required this.pieceSet, + required this.squareSize, + required this.selectedPiece, + required this.deleteSelected, + required this.settings, + required this.pieceTapped, + required this.deleteTapped, + }); + + final Side side; + final PieceSet pieceSet; + final double squareSize; + final Piece? selectedPiece; + final bool deleteSelected; + final BoardEditorSettings settings; + final Function(Role role) pieceTapped; + final Function() deleteTapped; + + @override + Widget build(BuildContext context) { + return Container( + color: Colors.grey, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ...Role.values.mapIndexed( + (i, role) { + final piece = Piece(role: role, color: side); + final pieceWidget = PieceWidget( + piece: piece, + size: squareSize, + pieceAssets: pieceSet.assets, + ); + + return Container( + color: + selectedPiece == piece ? Colors.blue : Colors.transparent, + child: GestureDetector( + onTap: () => pieceTapped(role), + child: Draggable( + data: piece, + feedback: PieceDragFeedback( + piece: piece, + pieceAssets: pieceSet.assets, + squareSize: squareSize, + ), + child: pieceWidget), + ), + ); + }, + ).toList(), + Container( + color: deleteSelected ? Colors.red : Colors.transparent, + child: GestureDetector( + onTap: () => deleteTapped(), + child: Icon( + Icons.delete, + size: squareSize, + ), + ), + ), + ], + ), + ); + } +} diff --git a/example/lib/main.dart b/example/lib/main.dart index f638116..1d51ca9 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,4 +1,5 @@ import 'dart:math'; +import 'package:board_example/board_editor_page.dart'; import 'package:flutter/material.dart'; import 'package:chessground/chessground.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; @@ -77,10 +78,10 @@ class _HomePageState extends State { return Scaffold( appBar: AppBar( - title: playMode == Mode.botPlay - ? const Text('Random Bot') - : const Text('Free Play'), - ), + title: switch (playMode) { + Mode.botPlay => const Text('Random Bot'), + Mode.freePlay => const Text('Free Play'), + }), drawer: Drawer( child: ListView( children: [ @@ -105,6 +106,17 @@ class _HomePageState extends State { Navigator.pop(context); }, ), + ListTile( + title: const Text('Board Editor'), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const BoardEditorPage(), + ), + ); + }, + ), ListTile( title: const Text('Board Thumbnails'), onTap: () { diff --git a/example/pubspec.lock b/example/pubspec.lock index 8d33af3..6cc4a71 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -31,7 +31,7 @@ packages: path: ".." relative: true source: path - version: "3.2.0" + version: "3.3.0" clock: dependency: transitive description: diff --git a/lib/chessground.dart b/lib/chessground.dart index 49519e3..38c50d3 100644 --- a/lib/chessground.dart +++ b/lib/chessground.dart @@ -6,12 +6,15 @@ library chessground; export 'src/board_color_scheme.dart'; export 'src/draw_shape_options.dart'; export 'src/board_data.dart'; +export 'src/board_editor_settings.dart'; export 'src/board_settings.dart'; export 'src/fen.dart'; export 'src/models.dart'; export 'src/piece_set.dart'; export 'src/premove.dart'; export 'src/widgets/board.dart'; +export 'src/widgets/board_editor.dart'; +export 'src/widgets/drag.dart'; export 'src/widgets/highlight.dart'; export 'src/widgets/piece.dart'; export 'src/widgets/background.dart'; diff --git a/lib/src/board_editor_settings.dart b/lib/src/board_editor_settings.dart new file mode 100644 index 0000000..517ffe2 --- /dev/null +++ b/lib/src/board_editor_settings.dart @@ -0,0 +1,94 @@ +import 'package:flutter/widgets.dart'; + +import 'board_color_scheme.dart'; +import 'models.dart'; +import 'piece_set.dart'; + +/// Board editor settings that control the theme, behavior and purpose of the board editor. +/// +/// This is meant for fixed settings that don't change while editing the board. Sensible +/// defaults are provided. +@immutable +class BoardEditorSettings { + const BoardEditorSettings({ + // theme + this.colorScheme = BoardColorScheme.brown, + this.pieceAssets = PieceSet.cburnettAssets, + // visual settings + this.borderRadius = BorderRadius.zero, + this.boxShadow = const [], + this.enableCoordinates = true, + this.dragFeedbackSize = 2.0, + this.dragFeedbackOffset = const Offset(0.0, -1.0), + }); + + /// Theme of the board + final BoardColorScheme colorScheme; + + /// Piece set + final PieceAssets pieceAssets; + + /// Border radius of the board + final BorderRadiusGeometry borderRadius; + + /// Box shadow of the board + final List boxShadow; + + /// Whether to show board coordinates + final bool enableCoordinates; + + // Scale up factor for the piece currently under drag + final double dragFeedbackSize; + + // Offset for the piece currently under drag + final Offset dragFeedbackOffset; + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is BoardEditorSettings && + other.colorScheme == colorScheme && + other.pieceAssets == pieceAssets && + other.borderRadius == borderRadius && + other.boxShadow == boxShadow && + other.enableCoordinates == enableCoordinates && + other.dragFeedbackSize == dragFeedbackSize && + other.dragFeedbackOffset == dragFeedbackOffset; + } + + @override + int get hashCode => Object.hash( + colorScheme, + pieceAssets, + borderRadius, + boxShadow, + enableCoordinates, + dragFeedbackSize, + dragFeedbackOffset, + ); + + BoardEditorSettings copyWith({ + BoardColorScheme? colorScheme, + PieceAssets? pieceAssets, + BorderRadiusGeometry? borderRadius, + List? boxShadow, + bool? enableCoordinates, + double? dragFeedbackSize, + Offset? dragFeedbackOffset, + }) { + return BoardEditorSettings( + colorScheme: colorScheme ?? this.colorScheme, + pieceAssets: pieceAssets ?? this.pieceAssets, + borderRadius: borderRadius ?? this.borderRadius, + boxShadow: boxShadow ?? this.boxShadow, + enableCoordinates: enableCoordinates ?? this.enableCoordinates, + dragFeedbackSize: dragFeedbackSize ?? this.dragFeedbackSize, + dragFeedbackOffset: dragFeedbackOffset ?? this.dragFeedbackOffset, + ); + } +} diff --git a/lib/src/fen.dart b/lib/src/fen.dart index a57c040..578e050 100644 --- a/lib/src/fen.dart +++ b/lib/src/fen.dart @@ -39,6 +39,39 @@ Pieces readFen(String fen) { return pieces; } +/// Convert the pieces to the board part of a FEN string +String writeFen(Pieces pieces) { + final buffer = StringBuffer(); + int empty = 0; + for (int rank = 7; rank >= 0; rank--) { + for (int file = 0; file < 8; file++) { + final piece = pieces[Coord(x: file, y: rank).squareId]; + if (piece == null) { + empty++; + } else { + if (empty > 0) { + buffer.write(empty.toString()); + empty = 0; + } + buffer.write( + piece.color == Side.white + ? piece.role.letter.toUpperCase() + : piece.role.letter.toLowerCase(), + ); + } + + if (file == 7) { + if (empty > 0) { + buffer.write(empty.toString()); + empty = 0; + } + if (rank != 0) buffer.write('/'); + } + } + } + return buffer.toString(); +} + const _roles = { 'p': Role.pawn, 'r': Role.rook, diff --git a/lib/src/widgets/board.dart b/lib/src/widgets/board.dart index 1acca75..66001a6 100644 --- a/lib/src/widgets/board.dart +++ b/lib/src/widgets/board.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:chessground/src/widgets/drag.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; @@ -672,7 +673,6 @@ class _BoardState extends State { void _onDragStart(PointerEvent origin) { final squareId = widget.localOffset2SquareId(origin.localPosition); final piece = squareId != null ? pieces[squareId] : null; - final feedbackSize = widget.squareSize * widget.settings.dragFeedbackSize; if (squareId != null && piece != null && (_isMovable(piece) || _isPremovable(piece))) { @@ -693,18 +693,12 @@ class _BoardState extends State { shape: BoxShape.circle, ), ), - pieceFeedback: Transform.translate( - offset: Offset( - ((widget.settings.dragFeedbackOffset.dx - 1) * feedbackSize) / 2, - ((widget.settings.dragFeedbackOffset.dy - 1) * feedbackSize) / 2, - ), - child: PieceWidget( - piece: piece, - size: feedbackSize, - pieceAssets: widget.settings.pieceAssets, - blindfoldMode: widget.settings.blindfoldMode, - upsideDown: _isUpsideDown(piece), - ), + pieceFeedback: PieceDragFeedback( + piece: piece, + squareSize: widget.squareSize, + pieceAssets: widget.settings.pieceAssets, + size: widget.settings.dragFeedbackSize, + offset: widget.settings.dragFeedbackOffset - const Offset(0.5, 0.5), ), ); } diff --git a/lib/src/widgets/board_editor.dart b/lib/src/widgets/board_editor.dart new file mode 100644 index 0000000..8af3820 --- /dev/null +++ b/lib/src/widgets/board_editor.dart @@ -0,0 +1,155 @@ +import 'package:chessground/src/widgets/drag.dart'; +import 'package:flutter/widgets.dart'; + +import 'piece.dart'; +import 'positioned_square.dart'; +import '../models.dart'; +import '../board_editor_settings.dart'; + +/// A chessboard widget where pieces can be dragged around freely (including dragging piece off and onto the board). +/// +/// This widget can be used as the basis for a fully fledged board editor, similar to https://lichess.org/editor. +class BoardEditor extends StatefulWidget { + const BoardEditor({ + super.key, + required this.size, + required this.orientation, + required this.pieces, + this.settings = const BoardEditorSettings(), + this.onTappedSquare, + this.onDroppedPiece, + this.onDiscardedPiece, + }); + + /// Visual size of the board. + final double size; + + double get squareSize => size / 8; + + /// The pieces to display on the board. + final Pieces pieces; + + /// Settings that control the appearance of the board editor. + final BoardEditorSettings settings; + + /// Side by which the board is oriented. + final Side orientation; + + /// Called when the given [square] was tapped. + final void Function(SquareId square)? onTappedSquare; + + /// Called when a [piece] has been dragged to a new [destination] square. + /// + /// If [origin] is not `null`, the piece was dragged from that square of the board editor. + /// Otherwise, it was dragged from outside the board editor. + /// Each square of the board is a [DragTarget], so to drop your own piece widgets + /// onto the board, put them in a [Draggable] and set the data to the piece you want to drop. + final void Function(SquareId? origin, SquareId destination, Piece piece)? + onDroppedPiece; + + /// Called when a piece that was originally at the given [square] was dragged off the board. + final void Function(SquareId square)? onDiscardedPiece; + + @override + State createState() => _BoardEditorState(); +} + +class _BoardEditorState extends State { + SquareId? draggedPieceOrigin; + + @override + Widget build(BuildContext context) { + final List pieceWidgets = allSquares.map((squareId) { + final piece = widget.pieces[squareId]; + + return PositionedSquare( + key: ValueKey('$squareId-${piece?.kind.name ?? 'empty'}'), + size: widget.squareSize, + orientation: widget.orientation, + squareId: squareId, + child: GestureDetector( + onTap: () => widget.onTappedSquare?.call(squareId), + child: DragTarget( + hitTestBehavior: HitTestBehavior.opaque, + builder: (context, candidateData, rejectedData) { + return Stack( + children: [ + // Show a drop target if a piece is dragged over the square + if (candidateData.isNotEmpty) + Transform.scale( + scale: 2, + child: Container( + decoration: const BoxDecoration( + color: Color(0x33000000), + shape: BoxShape.circle, + ), + ), + ), + if (piece != null) + Draggable( + hitTestBehavior: HitTestBehavior.translucent, + data: piece, + feedback: PieceDragFeedback( + piece: piece, + squareSize: widget.squareSize, + size: widget.settings.dragFeedbackSize, + offset: widget.settings.dragFeedbackOffset, + pieceAssets: widget.settings.pieceAssets, + ), + childWhenDragging: const SizedBox.shrink(), + onDragStarted: () => draggedPieceOrigin = squareId, + onDraggableCanceled: (_, __) { + widget.onDiscardedPiece?.call(squareId); + draggedPieceOrigin = null; + }, + child: PieceWidget( + piece: piece, + size: widget.squareSize, + pieceAssets: widget.settings.pieceAssets, + ), + ), + ], + ); + }, + onAcceptWithDetails: (details) { + widget.onDroppedPiece?.call( + draggedPieceOrigin, + squareId, + details.data, + ); + draggedPieceOrigin = null; + }, + ), + ), + ); + }).toList(); + + final background = widget.settings.enableCoordinates + ? widget.orientation == Side.white + ? widget.settings.colorScheme.whiteCoordBackground + : widget.settings.colorScheme.blackCoordBackground + : widget.settings.colorScheme.background; + + return SizedBox.square( + dimension: widget.size, + child: Stack( + clipBehavior: Clip.none, + children: [ + if (widget.settings.boxShadow.isNotEmpty || + widget.settings.borderRadius != BorderRadius.zero) + Container( + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration( + borderRadius: widget.settings.borderRadius, + boxShadow: widget.settings.boxShadow, + ), + child: background, + ) + else + background, + ...pieceWidgets, + ], + ), + ); + } +} diff --git a/lib/src/widgets/drag.dart b/lib/src/widgets/drag.dart new file mode 100644 index 0000000..739c8b5 --- /dev/null +++ b/lib/src/widgets/drag.dart @@ -0,0 +1,46 @@ +import 'package:flutter/widgets.dart'; + +import 'piece.dart'; +import '../models.dart'; + +/// The [Piece] to show under the pointer when a drag is under way. +/// +/// You can use this to drag pieces onto a [BoardEditor] with the same appearance as when the pieces on the board are dragged. +class PieceDragFeedback extends StatelessWidget { + const PieceDragFeedback({ + super.key, + required this.piece, + required this.squareSize, + required this.pieceAssets, + this.size = 2, + this.offset = const Offset(0.0, -1.0), + }); + + /// The piece that is being dragged. + final Piece piece; + + /// Size of a square on the board. + final double squareSize; + + /// Size of the feedback widget in units of [squareSize]. + final double size; + + /// Offset the feedback widget from the pointer position. + final Offset offset; + + /// Piece set + final PieceAssets pieceAssets; + + @override + Widget build(BuildContext context) { + final feedbackSize = squareSize * size; + return Transform.translate( + offset: (offset - const Offset(0.5, 0.5)) * feedbackSize / 2, + child: PieceWidget( + piece: piece, + size: feedbackSize, + pieceAssets: pieceAssets, + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 4cd9c5e..b0624ee 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: chessground description: Chess board UI developed for lichess.org. It has no chess logic inside so it can be used for chess variants. -version: 3.2.0 +version: 3.3.0 repository: https://github.com/lichess-org/flutter-chessground funding: - https://lichess.org/patron diff --git a/test/widgets/board_editor_test.dart b/test/widgets/board_editor_test.dart new file mode 100644 index 0000000..dbec49f --- /dev/null +++ b/test/widgets/board_editor_test.dart @@ -0,0 +1,203 @@ +import 'package:flutter/material.dart'; +import 'package:dartchess/dartchess.dart' as dc; +import 'package:flutter_test/flutter_test.dart'; +import 'package:chessground/chessground.dart'; + +const boardSize = 200.0; +const squareSize = boardSize / 8; + +void main() { + group('BoardEditor', () { + testWidgets('empty board has no pieces', (WidgetTester tester) async { + await tester.pumpWidget(buildBoard(pieces: {})); + expect(find.byType(BoardEditor), findsOneWidget); + expect(find.byType(PieceWidget), findsNothing); + + for (final square in allSquares) { + expect(find.byKey(Key('$square-empty')), findsOneWidget); + } + }); + + testWidgets('displays pieces on the correct squares', + (WidgetTester tester) async { + await tester.pumpWidget( + buildBoard( + pieces: { + 'a1': Piece.whiteKing, + 'b2': Piece.whiteQueen, + 'c3': Piece.whiteRook, + 'd4': Piece.whiteBishop, + 'e5': Piece.whiteKnight, + 'f6': Piece.whitePawn, + 'a2': Piece.blackKing, + 'a3': Piece.blackQueen, + 'a4': Piece.blackRook, + 'a5': Piece.blackBishop, + 'a6': Piece.blackKnight, + 'a7': Piece.blackPawn, + }, + ), + ); + expect(find.byKey(const Key('a1-whiteKing')), findsOneWidget); + expect(find.byKey(const Key('b2-whiteQueen')), findsOneWidget); + expect(find.byKey(const Key('c3-whiteRook')), findsOneWidget); + expect(find.byKey(const Key('d4-whiteBishop')), findsOneWidget); + expect(find.byKey(const Key('e5-whiteKnight')), findsOneWidget); + expect(find.byKey(const Key('f6-whitePawn')), findsOneWidget); + + expect(find.byKey(const Key('a2-blackKing')), findsOneWidget); + expect(find.byKey(const Key('a3-blackQueen')), findsOneWidget); + expect(find.byKey(const Key('a4-blackRook')), findsOneWidget); + expect(find.byKey(const Key('a5-blackBishop')), findsOneWidget); + expect(find.byKey(const Key('a6-blackKnight')), findsOneWidget); + expect(find.byKey(const Key('a7-blackPawn')), findsOneWidget); + + expect(find.byType(PieceWidget), findsNWidgets(12)); + }); + + testWidgets('tapping a square triggers the onTappedSquare callback', + (WidgetTester tester) async { + for (final orientation in Side.values) { + SquareId? tappedSquare; + await tester.pumpWidget( + buildBoard( + pieces: {}, + onTappedSquare: (square) => tappedSquare = square, + orientation: orientation, + ), + ); + + await tester.tapAt(squareOffset('a1', orientation: orientation)); + expect(tappedSquare, 'a1'); + + await tester.tapAt(squareOffset('g8', orientation: orientation)); + expect(tappedSquare, 'g8'); + } + }); + + testWidgets('dragging pieces to a new sqaure calls onDroppedPiece', + (WidgetTester tester) async { + (SquareId? origin, SquareId? destination, Piece? piece) callbackParams = + (null, null, null); + + await tester.pumpWidget( + buildBoard( + pieces: readFen(dc.kInitialFEN), + onDroppedPiece: (o, d, p) => callbackParams = (o, d, p), + ), + ); + + // Drag an empty square => nothing happens + await tester.dragFrom( + squareOffset('e4'), + const Offset(0, -(squareSize * 2)), + ); + await tester.pumpAndSettle(); + expect(callbackParams, (null, null, null)); + + // Play e2-e4 (legal move) + await tester.dragFrom( + squareOffset('e2'), + const Offset(0, -(squareSize * 2)), + ); + await tester.pumpAndSettle(); + expect(callbackParams, ('e2', 'e4', Piece.whitePawn)); + + // Capture our own piece (illegal move) + await tester.dragFrom( + squareOffset('a1'), + const Offset(squareSize, 0), + ); + expect(callbackParams, ('a1', 'b1', Piece.whiteRook)); + }); + + testWidgets('dragging a piece onto the board calls onDroppedPiece', + (WidgetTester tester) async { + (SquareId? origin, SquareId? destination, Piece? piece) callbackParams = + (null, null, null); + + await tester.pumpWidget( + MaterialApp( + home: Column( + children: [ + BoardEditor( + size: boardSize, + orientation: Side.white, + pieces: const {}, + onDroppedPiece: (o, d, p) { + callbackParams = (o, d, p); + }, + ), + Draggable( + key: const Key('new piece'), + hitTestBehavior: HitTestBehavior.translucent, + data: Piece.whitePawn, + feedback: const SizedBox.shrink(), + child: PieceWidget( + piece: Piece.whitePawn, + size: squareSize, + pieceAssets: PieceSet.merida.assets, + ), + ), + ], + ), + ), + ); + + final pieceDraggable = find.byKey(const Key('new piece')); + final pieceCenter = tester.getCenter(pieceDraggable); + + final newSquareCenter = + tester.getCenter(find.byKey(const Key('e2-empty'))); + + await tester.drag( + pieceDraggable, + newSquareCenter - pieceCenter, + ); + expect(callbackParams, (null, 'e2', Piece.whitePawn)); + }); + + testWidgets('dragging a piece off the board calls onDiscardedPiece', + (WidgetTester tester) async { + SquareId? discardedSquare; + await tester.pumpWidget( + buildBoard( + pieces: readFen(dc.kInitialFEN), + onDiscardedPiece: (square) => discardedSquare = square, + ), + ); + + await tester.dragFrom( + squareOffset('e1'), + const Offset(0, squareSize), + ); + await tester.pumpAndSettle(); + expect(discardedSquare, 'e1'); + }); + }); +} + +Widget buildBoard({ + required Pieces pieces, + Side orientation = Side.white, + void Function(SquareId square)? onTappedSquare, + void Function(SquareId? origin, SquareId destination, Piece piece)? + onDroppedPiece, + void Function(SquareId square)? onDiscardedPiece, +}) { + return MaterialApp( + home: BoardEditor( + size: boardSize, + orientation: orientation, + pieces: pieces, + onTappedSquare: onTappedSquare, + onDiscardedPiece: onDiscardedPiece, + onDroppedPiece: onDroppedPiece, + ), + ); +} + +Offset squareOffset(SquareId id, {Side orientation = Side.white}) { + final o = Coord.fromSquareId(id).offset(orientation, squareSize); + return Offset(o.dx + squareSize / 2, o.dy + squareSize / 2); +}