Skip to content
5 changes: 5 additions & 0 deletions lib/src/model/game/over_the_board_game.dart
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ abstract class OverTheBoardGame with _$OverTheBoardGame, BaseGame, IndexableStep
@override
GameId get id => const GameId('--------');

bool get abortable => playable && lastPosition.fullmoves <= 1;

bool get resignable => playable && !abortable;
bool get drawable => playable && lastPosition.fullmoves >= 2;

@Assert('steps.isNotEmpty')
factory OverTheBoardGame({
@JsonKey(fromJson: stepsFromJson, toJson: stepsToJson) required IList<GameStep> steps,
Expand Down
17 changes: 17 additions & 0 deletions lib/src/model/over_the_board/over_the_board_game_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,16 @@ class OverTheBoardGameController extends _$OverTheBoardGameController {
state = OverTheBoardGameState.fromVariant(state.game.meta.variant, state.game.meta.speed);
}

void resign() {
state = state.copyWith(
game: state.game.copyWith(status: GameStatus.resign, winner: state.turn.opposite),
);
}

void draw() {
state = state.copyWith(game: state.game.copyWith(status: GameStatus.draw));
}

void makeMove(NormalMove move) {
if (isPromotionPawnMove(state.currentPosition, move)) {
state = state.copyWith(promotionMove: move);
Expand All @@ -58,6 +68,13 @@ class OverTheBoardGameController extends _$OverTheBoardGameController {
stepCursor: state.stepCursor + 1,
);

// check for threefold repetition
if (state.game.steps.count((p) => p.position.board == newStep.position.board) == 3) {
state = state.copyWith(game: state.game.copyWith(isThreefoldRepetition: true));
} else {
state = state.copyWith(game: state.game.copyWith(isThreefoldRepetition: false));
}

if (state.currentPosition.isCheckmate) {
state = state.copyWith(
game: state.game.copyWith(status: GameStatus.mate, winner: state.turn.opposite),
Expand Down
119 changes: 110 additions & 9 deletions lib/src/view/over_the_board/over_the_board_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,27 @@ import 'package:dartchess/dartchess.dart';
import 'package:flutter/cupertino.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/over_the_board/over_the_board_clock.dart';
import 'package:lichess_mobile/src/model/over_the_board/over_the_board_game_controller.dart';
import 'package:lichess_mobile/src/model/settings/board_preferences.dart';
import 'package:lichess_mobile/src/model/settings/over_the_board_preferences.dart';
import 'package:lichess_mobile/src/utils/immersive_mode.dart';
import 'package:lichess_mobile/src/utils/l10n_context.dart';
import 'package:lichess_mobile/src/utils/navigation.dart';
import 'package:lichess_mobile/src/utils/string.dart';
import 'package:lichess_mobile/src/view/analysis/analysis_screen.dart';
import 'package:lichess_mobile/src/view/game/game_player.dart';
import 'package:lichess_mobile/src/view/game/game_result_dialog.dart';
import 'package:lichess_mobile/src/view/over_the_board/configure_over_the_board_game.dart';
import 'package:lichess_mobile/src/widgets/adaptive_action_sheet.dart';
import 'package:lichess_mobile/src/widgets/board_table.dart';
import 'package:lichess_mobile/src/widgets/bottom_bar.dart';
import 'package:lichess_mobile/src/widgets/bottom_bar_button.dart';
import 'package:lichess_mobile/src/widgets/buttons.dart';
import 'package:lichess_mobile/src/widgets/clock.dart';
import 'package:lichess_mobile/src/widgets/platform_scaffold.dart';
import 'package:lichess_mobile/src/widgets/yes_no_dialog.dart';

class OverTheBoardScreen extends StatelessWidget {
const OverTheBoardScreen({super.key});
Expand Down Expand Up @@ -95,6 +100,31 @@ class _BodyState extends ConsumerState<_Body> {
}
});
}

if (previous?.game.isThreefoldRepetition == false &&
newGameState.game.isThreefoldRepetition == true) {
Timer(const Duration(milliseconds: 500), () {
if (context.mounted) {
ref.read(overTheBoardClockProvider.notifier).pause();
showAdaptiveDialog<void>(
context: context,
builder:
(context) => YesNoDialog(
title: Text(context.l10n.threefoldRepetition),
content: const Text('Accept draw?'),
onYes: () {
Navigator.pop(context);
ref.read(overTheBoardGameControllerProvider.notifier).draw();
},
onNo: () {
Navigator.pop(context);
ref.read(overTheBoardClockProvider.notifier).resume(previous!.turn);
},
),
);
}
});
}
});

return WakelockWidget(
Expand Down Expand Up @@ -183,15 +213,11 @@ class _BottomBar extends ConsumerWidget {
return PlatformBottomBar(
children: [
BottomBarButton(
label: 'Configure game',
onTap: () => showConfigureGameSheet(context, isDismissible: true),
icon: Icons.add,
),
BottomBarButton(
key: const Key('flip-button'),
label: context.l10n.flipBoard,
onTap: onFlipBoard,
icon: CupertinoIcons.arrow_2_squarepath,
label: context.l10n.menu,
onTap: () {
_showOtbGameMenu(context, ref);
},
icon: Icons.menu,
),
if (!clock.timeIncrement.isInfinite)
BottomBarButton(
Expand Down Expand Up @@ -241,6 +267,81 @@ class _BottomBar extends ConsumerWidget {
],
);
}

Future<void> _showOtbGameMenu(BuildContext context, WidgetRef ref) {
final gameState = ref.read(overTheBoardGameControllerProvider);
return showAdaptiveActionSheet(
context: context,
actions: [
BottomSheetAction(
makeLabel: (context) => const Text('New game'),
onPressed: () => showConfigureGameSheet(context, isDismissible: true),
),
if (gameState.game.finished)
BottomSheetAction(
makeLabel: (context) => Text(context.l10n.analysis),
onPressed:
() => Navigator.of(context).push(
AnalysisScreen.buildRoute(
context,
AnalysisOptions(
orientation: Side.white,
standalone: (
pgn: gameState.game.makePgn(),
isComputerAnalysisAllowed: true,
variant: gameState.game.meta.variant,
),
),
),
),
),
BottomSheetAction(
makeLabel: (context) => Text(context.l10n.flipBoard),
onPressed: onFlipBoard,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't there already a bottom bar button for this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, there is. I'll remove the option from the menu. This will leave the hamburger menu with only one option (New game) at the start of a game, making the menu pointless for that state. However, when the game starts and the Resign and Draw options come into play, the hamburger menu is needed. So if it's fine by you, I'll make the bottom bar button switch between the plus icon and the menu icon as required.

Alternatively, we could just leave it as is (regular online games have a resign button in the bottom bar AND in the menu. Also I'm lazy haha)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah that makes sense, got it! I think switching between hamburger/plus icon sounds good, but I'm also ok with leaving it like it is is right now.

@veloce has the final word on that as the maintainer, so let's wait for his opinion

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the flip button that important so it needs to be in the bottom bar? Because we can also remove it from the bottom bar.

If not I prefer leaving the hamburger menu all the time and not switching between icons.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

),
if (gameState.game.drawable)
BottomSheetAction(
makeLabel: (context) => Text(context.l10n.offerDraw),
onPressed: () {
final offerer = gameState.turn.name.capitalize();
showAdaptiveDialog<void>(
context: context,
builder:
(context) => YesNoDialog(
title: Text('${context.l10n.draw}?'),
content: Text('$offerer offers draw. Does opponent accept?'),
onYes: () {
Navigator.pop(context);
ref.read(overTheBoardGameControllerProvider.notifier).draw();
},
onNo: () => Navigator.pop(context),
),
);
},
),
if (gameState.game.resignable)
BottomSheetAction(
makeLabel: (context) => Text(context.l10n.resign),
onPressed: () {
final offerer = gameState.turn.name.capitalize();
showAdaptiveDialog<void>(
context: context,
builder:
(context) => YesNoDialog(
title: Text('${context.l10n.resign}?'),
content: Text('Are you sure you want to resign as $offerer?'),
onYes: () {
Navigator.pop(context);
ref.read(overTheBoardGameControllerProvider.notifier).resign();
},
onNo: () => Navigator.pop(context),
),
);
},
),
],
);
}
}

class _Player extends ConsumerWidget {
Expand Down