refactor: use @freezed unions to improve readability of GameScreen code

This commit is contained in:
tom-anders
2025-08-24 22:54:10 +02:00
parent e9f95ee980
commit 21a7ee5112
20 changed files with 292 additions and 289 deletions
+1
View File
@@ -9,6 +9,7 @@ targets:
generate_for:
- lib/src/model/**/*.dart
- lib/src/**/*_models.dart
- lib/src/**/*_providers.dart
options:
from_json: false
to_json: false
+12 -8
View File
@@ -16,8 +16,10 @@ import 'package:lichess_mobile/src/styles/styles.dart';
import 'package:lichess_mobile/src/tab_scaffold.dart' show currentNavigatorKeyProvider;
import 'package:lichess_mobile/src/utils/l10n_context.dart';
import 'package:lichess_mobile/src/view/game/game_screen.dart';
import 'package:lichess_mobile/src/view/game/game_screen_providers.dart';
import 'package:lichess_mobile/src/view/user/challenge_requests_screen.dart';
import 'package:lichess_mobile/src/widgets/adaptive_action_sheet.dart';
import 'package:lichess_mobile/src/widgets/feedback.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:stream_transform/stream_transform.dart';
@@ -111,8 +113,7 @@ class ChallengeService {
switch (actionid) {
case 'accept':
final fullId = await acceptChallenge(challengeId);
_goToGameScreen(fullId);
await _acceptChallenge(challengeId);
case 'decline':
final context = ref.read(currentNavigatorKeyProvider).currentContext;
@@ -139,10 +140,16 @@ class ChallengeService {
return await challengeRepo.show(id).then((challenge) => challenge.gameFullId);
}
void _goToGameScreen(GameFullId? fullId) {
Future<void> _acceptChallenge(ChallengeId id) async {
final fullId = await acceptChallenge(id);
final context = ref.read(currentNavigatorKeyProvider).currentContext;
if (context == null || !context.mounted) return;
if (fullId == null) {
return showSnackBar(context, 'Failed to accept challenge', type: SnackBarType.error);
}
final rootNavState = Navigator.of(context, rootNavigator: true);
if (rootNavState.canPop()) {
rootNavState.popUntil((route) => route.isFirst);
@@ -151,7 +158,7 @@ class ChallengeService {
Navigator.of(
context,
rootNavigator: true,
).push(GameScreen.buildRoute(context, initialGameId: fullId));
).push(GameScreen.buildRoute(context, source: ExistingGameSource(fullId)));
}
void _showDeclineDialog(BuildContext context, ChallengeId id) {
@@ -187,10 +194,7 @@ class ChallengeService {
makeLabel: (context) => Text(context.l10n.accept),
leading: Icon(Icons.check, color: context.lichessColors.good),
isDefaultAction: true,
onPressed: () async {
final fullId = await acceptChallenge(challenge.id);
_goToGameScreen(fullId);
},
onPressed: () async => await _acceptChallenge(challenge.id),
),
BottomSheetAction(
makeLabel: (context) => Text(context.l10n.decline),
@@ -22,6 +22,7 @@ import 'package:lichess_mobile/src/network/http.dart';
import 'package:lichess_mobile/src/network/socket.dart';
import 'package:lichess_mobile/src/tab_scaffold.dart' show currentNavigatorKeyProvider;
import 'package:lichess_mobile/src/view/game/game_screen.dart';
import 'package:lichess_mobile/src/view/game/game_screen_providers.dart';
import 'package:logging/logging.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
@@ -87,7 +88,7 @@ class CorrespondenceService {
Navigator.of(
context,
rootNavigator: true,
).push(GameScreen.buildRoute(context, initialGameId: fullId));
).push(GameScreen.buildRoute(context, source: ExistingGameSource(fullId)));
}
/// Syncs offline correspondence games with the server.
+22 -15
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:deep_pick/deep_pick.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:lichess_mobile/src/model/account/account_repository.dart';
import 'package:lichess_mobile/src/model/challenge/challenge.dart';
import 'package:lichess_mobile/src/model/challenge/challenge_repository.dart';
@@ -16,12 +17,19 @@ import 'package:logging/logging.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'create_game_service.g.dart';
part 'create_game_service.freezed.dart';
typedef ChallengeResponse = ({
GameFullId? gameFullId,
Challenge? challenge,
ChallengeDeclineReason? declineReason,
});
@freezed
sealed class ChallengeResponse with _$ChallengeResponse {
const ChallengeResponse._();
factory ChallengeResponse.accepted({required GameFullId gameFullId}) = ChallengeAcceptedResponse;
factory ChallengeResponse.declined({
required Challenge challenge,
required ChallengeDeclineReason? declineReason,
}) = ChallengeDeclinedResponse;
}
/// A provider for the [CreateGameService].
@riverpod
@@ -159,17 +167,16 @@ class CreateGameService {
try {
final updatedChallenge = await challengeRepository.show(challenge.id);
if (updatedChallenge.gameFullId != null) {
completer.complete((
gameFullId: updatedChallenge.gameFullId,
challenge: null,
declineReason: null,
));
completer.complete(
ChallengeResponse.accepted(gameFullId: updatedChallenge.gameFullId!),
);
} else if (updatedChallenge.status == ChallengeStatus.declined) {
completer.complete((
gameFullId: null,
challenge: challenge,
declineReason: updatedChallenge.declineReason,
));
completer.complete(
ChallengeResponse.declined(
challenge: challenge,
declineReason: updatedChallenge.declineReason,
),
);
}
} catch (e) {
_log.warning('Failed to reload challenge', e);
+2 -1
View File
@@ -12,6 +12,7 @@ import 'package:lichess_mobile/src/model/puzzle/puzzle_angle.dart';
import 'package:lichess_mobile/src/model/puzzle/puzzle_theme.dart';
import 'package:lichess_mobile/src/tab_scaffold.dart';
import 'package:lichess_mobile/src/view/game/game_screen.dart';
import 'package:lichess_mobile/src/view/game/game_screen_providers.dart';
import 'package:lichess_mobile/src/view/puzzle/puzzle_screen.dart';
import 'package:quick_actions/quick_actions.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
@@ -50,7 +51,7 @@ class QuickActionService {
Navigator.of(
context,
rootNavigator: true,
).push(GameScreen.buildRoute(context, seek: recentSeeks[index]));
).push(GameScreen.buildRoute(context, source: LobbySource(recentSeeks[index])));
}
} else if (shortcutType == 'play_puzzles') {
Navigator.of(
+21 -18
View File
@@ -36,6 +36,8 @@ import 'package:lichess_mobile/src/widgets/game_layout.dart';
import 'package:lichess_mobile/src/widgets/platform_alert_dialog.dart';
import 'package:lichess_mobile/src/widgets/yes_no_dialog.dart';
typedef LoadingPosition = ({String? fen, Move? lastMove, Side? orientation});
/// Game body for the [GameScreen].
///
/// This widget is responsible for displaying the board, the clocks, the players,
@@ -47,7 +49,8 @@ import 'package:lichess_mobile/src/widgets/yes_no_dialog.dart';
/// prevent the user from going back to the previous screen.
class GameBody extends ConsumerWidget {
const GameBody({
required this.loadedGame,
required this.gameId,
this.loadingPosition,
required this.whiteClockKey,
required this.blackClockKey,
required this.onLoadGameCallback,
@@ -55,7 +58,9 @@ class GameBody extends ConsumerWidget {
required this.boardKey,
});
final LoadedGame loadedGame;
final GameFullId gameId;
final LoadingPosition? loadingPosition;
/// [GlobalKey] for the white clock.
///
@@ -84,7 +89,7 @@ class GameBody extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final ctrlProvider = gameControllerProvider(loadedGame.gameId);
final ctrlProvider = gameControllerProvider(gameId);
ref.listen(
ctrlProvider,
@@ -280,7 +285,7 @@ class GameBody extends ConsumerWidget {
},
zenMode: gameState.isZenModeActive,
userActionsBar: _GameBottomBar(
id: loadedGame.gameId,
id: gameId,
onLoadGameCallback: onLoadGameCallback,
onNewOpponentCallback: onNewOpponentCallback,
),
@@ -290,22 +295,22 @@ class GameBody extends ConsumerWidget {
case AsyncData(:final value, isRefreshing: true):
return StandaloneGameLoadingContent(
fen: value.game.lastPosition.fen,
lastMove: value.game.moveAt(value.stepCursor) as NormalMove?,
orientation: value.game.youAre,
position: (
fen: value.game.lastPosition.fen,
lastMove: value.game.moveAt(value.stepCursor),
orientation: value.game.youAre,
),
userActionsBar: _GameBottomBar(
id: loadedGame.gameId,
id: gameId,
onLoadGameCallback: onLoadGameCallback,
onNewOpponentCallback: onNewOpponentCallback,
),
);
case final _:
return StandaloneGameLoadingContent(
fen: loadedGame.lastFen,
lastMove: loadedGame.lastMove,
orientation: loadedGame.side,
position: loadingPosition,
userActionsBar: _GameBottomBar(
id: loadedGame.gameId,
id: gameId,
onLoadGameCallback: onLoadGameCallback,
onNewOpponentCallback: onNewOpponentCallback,
),
@@ -325,7 +330,7 @@ class GameBody extends ConsumerWidget {
if (context.mounted) {
// when Zen mode is disabled, reload chat data
ref
.read(gameControllerProvider(loadedGame.gameId).notifier)
.read(gameControllerProvider(gameId).notifier)
.onToggleChat(state.requireValue.chatOptions != null);
}
}
@@ -339,10 +344,8 @@ class GameBody extends ConsumerWidget {
if (context.mounted) {
showAdaptiveDialog<void>(
context: context,
builder: (context) => GameResultDialog(
id: loadedGame.gameId,
onNewOpponentCallback: onNewOpponentCallback,
),
builder: (context) =>
GameResultDialog(id: gameId, onNewOpponentCallback: onNewOpponentCallback),
barrierDismissible: true,
);
}
@@ -372,7 +375,7 @@ class GameBody extends ConsumerWidget {
if (context.mounted) {
showAdaptiveDialog<void>(
context: context,
builder: (context) => _ClaimWinDialog(id: loadedGame.gameId),
builder: (context) => _ClaimWinDialog(id: gameId),
barrierDismissible: true,
);
}
+7 -4
View File
@@ -10,6 +10,7 @@ import 'package:lichess_mobile/src/utils/l10n_context.dart';
import 'package:lichess_mobile/src/utils/share.dart';
import 'package:lichess_mobile/src/view/analysis/analysis_screen.dart';
import 'package:lichess_mobile/src/view/game/game_screen.dart';
import 'package:lichess_mobile/src/view/game/game_screen_providers.dart';
import 'package:lichess_mobile/src/widgets/feedback.dart';
import 'package:lichess_mobile/src/widgets/platform_context_menu_button.dart';
import 'package:share_plus/share_plus.dart';
@@ -32,10 +33,12 @@ void openGameScreen(
game.fullId != null
? GameScreen.buildRoute(
context,
initialGameId: game.fullId,
loadingOrientation: orientation,
loadingFen: loadingFen,
loadingLastMove: loadingLastMove,
source: ExistingGameSource(game.fullId!),
loadingPosition: (
fen: loadingFen,
lastMove: loadingLastMove,
orientation: orientation,
),
lastMoveAt: lastMoveAt,
)
: AnalysisScreen.buildRoute(
+6 -13
View File
@@ -9,6 +9,7 @@ import 'package:lichess_mobile/src/model/lobby/lobby_numbers.dart';
import 'package:lichess_mobile/src/utils/l10n_context.dart';
import 'package:lichess_mobile/src/utils/string.dart';
import 'package:lichess_mobile/src/view/account/rating_pref_aware.dart';
import 'package:lichess_mobile/src/view/game/game_body.dart';
import 'package:lichess_mobile/src/widgets/bottom_bar.dart';
import 'package:lichess_mobile/src/widgets/feedback.dart';
import 'package:lichess_mobile/src/widgets/game_layout.dart';
@@ -237,17 +238,9 @@ class _ChallengeLoadingContentState extends State<ChallengeLoadingContent> {
}
class StandaloneGameLoadingContent extends StatelessWidget {
const StandaloneGameLoadingContent({
this.fen,
this.lastMove,
this.orientation,
this.userActionsBar,
super.key,
});
const StandaloneGameLoadingContent({this.position, this.userActionsBar, super.key});
final String? fen;
final Side? orientation;
final Move? lastMove;
final LoadingPosition? position;
final Widget? userActionsBar;
@override
@@ -255,9 +248,9 @@ class StandaloneGameLoadingContent extends StatelessWidget {
return Shimmer(
child: SafeArea(
child: GameLayout(
orientation: orientation ?? Side.white,
fen: fen ?? kEmptyFen,
lastMove: lastMove as NormalMove?,
orientation: position?.orientation ?? Side.white,
fen: position?.fen ?? kEmptyFen,
lastMove: position?.lastMove as NormalMove?,
topTable: const LoadingPlayerWidget(),
bottomTable: const LoadingPlayerWidget(),
moves: const [],
+93 -133
View File
@@ -33,68 +33,30 @@ import 'package:lichess_mobile/src/widgets/shimmer.dart';
/// Screen to play a game, or to show a challenge or to show current user's past games.
///
/// The screen can be created in three ways:
/// - From the lobby, to play a game with a random opponent: using a [GameSeek] as [seek].
/// - From a challenge, to accept or decline a challenge: using a [ChallengeRequest] as [challenge].
/// - From a game id, to show a game that is already in progress: using a [GameFullId] as [initialGameId].
/// - From the lobby, to play a game with a random opponent: using [CurrentGameSource.lobby].
/// - From a challenge, to accept or decline a challenge: using a [CurrentGameSource.userChallenge].
/// - From a game id, to show a game that is already in progress: using [CurrentGameSource.loadedGame].
///
/// The screen will show a loading board while the game is being created.
class GameScreen extends ConsumerStatefulWidget {
const GameScreen({
this.seek,
this.initialGameId,
this.challenge,
this.loadingFen,
this.loadingLastMove,
this.loadingOrientation,
this.lastMoveAt,
super.key,
}) : assert(
initialGameId != null || seek != null || challenge != null,
'Either a seek, a challenge or an initial game id must be provided.',
);
const GameScreen({required this.source, this.loadingPosition, this.lastMoveAt, super.key});
final GameSeek? seek;
final GameFullId? initialGameId;
final ChallengeRequest? challenge;
final GameScreenSource source;
final String? loadingFen;
final Move? loadingLastMove;
final Side? loadingOrientation;
final LoadingPosition? loadingPosition;
/// The date of the last move played in the game. If null, the game is in progress.
final DateTime? lastMoveAt;
_GameSource get source {
if (initialGameId != null) {
return _GameSource.game;
} else if (challenge != null) {
return _GameSource.challenge;
} else {
return _GameSource.lobby;
}
}
static Route<dynamic> buildRoute(
BuildContext context, {
GameSeek? seek,
GameFullId? initialGameId,
ChallengeRequest? challenge,
String? loadingFen,
Move? loadingLastMove,
Side? loadingOrientation,
required GameScreenSource source,
LoadingPosition? loadingPosition,
DateTime? lastMoveAt,
}) {
return buildScreenRoute(
context,
screen: GameScreen(
seek: seek,
initialGameId: initialGameId,
challenge: challenge,
loadingFen: loadingFen,
loadingLastMove: loadingLastMove,
loadingOrientation: loadingOrientation,
lastMoveAt: lastMoveAt,
),
screen: GameScreen(source: source, loadingPosition: loadingPosition, lastMoveAt: lastMoveAt),
);
}
@@ -102,8 +64,6 @@ class GameScreen extends ConsumerStatefulWidget {
ConsumerState<GameScreen> createState() => _GameScreenState();
}
enum _GameSource { lobby, challenge, game }
class _GameScreenState extends ConsumerState<GameScreen> {
final _whiteClockKey = GlobalKey(debugLabel: 'whiteClockOnGameScreen');
final _blackClockKey = GlobalKey(debugLabel: 'blackClockOnGameScreen');
@@ -111,31 +71,34 @@ class _GameScreenState extends ConsumerState<GameScreen> {
@override
Widget build(BuildContext context) {
final provider = currentGameProvider(
seek: widget.seek,
challenge: widget.challenge,
game: widget.initialGameId != null
? (
gameId: widget.initialGameId!,
lastFen: widget.loadingFen,
lastMove: widget.loadingLastMove,
side: widget.loadingOrientation,
)
: null,
);
final provider = currentGameProvider(widget.source);
final boardPreferences = ref.watch(boardPreferencesProvider);
switch (ref.watch(provider)) {
case AsyncData(:final value):
final (game: loadedGame, challenge: challenge, declineReason: declineReason) = value;
case AsyncData(
value: ChallengeDeclinedState(
response: ChallengeDeclinedResponse(:final challenge, :final declineReason),
),
):
return Scaffold(
resizeToAvoidBottomInset: false,
appBar: AppBar(
title: _ChallengeGameTitle(
challenge: (widget.source as UserChallengeSource).challengeRequest,
),
),
body: ChallengeDeclinedBoard(
challenge: challenge,
declineReason: declineReason != null
? declineReason.label(context.l10n)
: ChallengeDeclineReason.generic.label(context.l10n),
),
);
case AsyncData(value: GameCreatedState(:final createdGameId)):
final isRealTimePlayingGame =
(loadedGame != null
? ref.watch(isRealTimePlayableGameProvider(loadedGame.gameId))
: const AsyncValue.data(true))
.valueOrNull ??
false;
ref.watch(isRealTimePlayableGameProvider(createdGameId)).valueOrNull ?? false;
final socketUri = loadedGame != null ? GameController.socketUri(loadedGame.gameId) : null;
final socketUri = GameController.socketUri(createdGameId);
final body = PopScope(
canPop: isRealTimePlayingGame != true,
@@ -143,41 +106,38 @@ class _GameScreenState extends ConsumerState<GameScreen> {
// view padding can change on Android when immersive mode is enabled, so to prevent any
// board vertical shift, we set `maintainBottomViewPadding` to true.
maintainBottomViewPadding: true,
child: loadedGame != null
? GameBody(
loadedGame: loadedGame,
whiteClockKey: _whiteClockKey,
blackClockKey: _blackClockKey,
boardKey: _boardKey,
onLoadGameCallback: (id) {
if (mounted) {
ref.read(provider.notifier).loadGame(id);
}
},
onNewOpponentCallback: (game) {
if (!mounted) return;
child: GameBody(
gameId: createdGameId,
// Only show the initial loading position if this is still the game that the GameScreen
// was created for. This will not be the case when searching for a new opponent after the game.
loadingPosition: switch (widget.source) {
ExistingGameSource(:final id) when id == createdGameId => widget.loadingPosition,
_ => null,
},
whiteClockKey: _whiteClockKey,
blackClockKey: _blackClockKey,
boardKey: _boardKey,
onLoadGameCallback: (id) {
if (mounted) {
ref.read(provider.notifier).loadGame(id);
}
},
onNewOpponentCallback: (game) {
if (!mounted) return;
if (widget.source == _GameSource.lobby) {
ref.read(provider.notifier).newOpponent();
} else {
final savedSetup = ref.read(gameSetupPreferencesProvider);
Navigator.of(context, rootNavigator: true).pushReplacement(
GameScreen.buildRoute(
context,
seek: GameSeek.newOpponentFromGame(game, savedSetup),
),
);
}
},
)
: widget.challenge != null && challenge != null
? ChallengeDeclinedBoard(
challenge: challenge,
declineReason: declineReason != null
? declineReason.label(context.l10n)
: ChallengeDeclineReason.generic.label(context.l10n),
)
: const LoadGameError('Could not create the game.'),
if (widget.source is LobbySource) {
ref.read(provider.notifier).newOpponent();
} else {
final savedSetup = ref.read(gameSetupPreferencesProvider);
Navigator.of(context, rootNavigator: true).pushReplacement(
GameScreen.buildRoute(
context,
source: LobbySource(GameSeek.newOpponentFromGame(game, savedSetup)),
),
);
}
},
),
),
);
@@ -185,15 +145,8 @@ class _GameScreenState extends ConsumerState<GameScreen> {
resizeToAvoidBottomInset: false,
appBar: AppBar(
leading: isRealTimePlayingGame ? SocketPingRatingIcon(socketUri: socketUri) : null,
title: loadedGame != null
? _StandaloneGameTitle(id: loadedGame.gameId, lastMoveAt: widget.lastMoveAt)
: widget.seek != null
? _LobbyGameTitle(seek: widget.seek!)
: widget.challenge != null
? _ChallengeGameTitle(challenge: widget.challenge!)
: const SizedBox.shrink(),
actions: [if (loadedGame != null) _GameMenu(gameId: loadedGame.gameId)],
title: _StandaloneGameTitle(id: createdGameId, lastMoveAt: widget.lastMoveAt),
actions: [_GameMenu(gameId: createdGameId)],
),
body: Theme.of(context).platform == TargetPlatform.android
? AndroidGesturesExclusionWidget(
@@ -216,36 +169,43 @@ class _GameScreenState extends ConsumerState<GameScreen> {
resizeToAvoidBottomInset: false,
appBar: AppBar(
leading: const SocketPingRatingIcon(),
title: widget.seek != null
? _LobbyGameTitle(seek: widget.seek!)
: widget.challenge != null
? _ChallengeGameTitle(challenge: widget.challenge!)
: const SizedBox.shrink(),
title: switch (widget.source) {
LobbySource(:final seek) => _LobbyGameTitle(seek: seek),
UserChallengeSource(:final challengeRequest) => _ChallengeGameTitle(
challenge: challengeRequest,
),
_ => const SizedBox.shrink(),
},
),
body: PopScope(child: message),
);
case _:
final loadingBoard = widget.seek != null
? LobbyScreenLoadingContent(
widget.seek!,
() => ref.read(createGameServiceProvider).cancelSeek(),
)
: widget.challenge != null
? ChallengeLoadingContent(
widget.challenge!,
() => ref.read(createGameServiceProvider).cancelChallenge(),
)
: const StandaloneGameLoadingContent(userActionsBar: BottomBar.empty());
final loadingBoard = switch (widget.source) {
LobbySource(:final seek) => LobbyScreenLoadingContent(
seek,
() => ref.read(createGameServiceProvider).cancelSeek(),
),
UserChallengeSource(:final challengeRequest) => ChallengeLoadingContent(
challengeRequest,
() => ref.read(createGameServiceProvider).cancelChallenge(),
),
ExistingGameSource() => StandaloneGameLoadingContent(
position: widget.loadingPosition,
userActionsBar: const BottomBar.empty(),
),
};
return Scaffold(
resizeToAvoidBottomInset: false,
appBar: AppBar(
leading: const SocketPingRatingIcon(),
title: widget.seek != null
? _LobbyGameTitle(seek: widget.seek!)
: widget.challenge != null
? _ChallengeGameTitle(challenge: widget.challenge!)
: const SizedBox.shrink(),
title: switch (widget.source) {
LobbySource(:final seek) => _LobbyGameTitle(seek: seek),
UserChallengeSource(:final challengeRequest) => _ChallengeGameTitle(
challenge: challengeRequest,
),
_ => const SizedBox.shrink(),
},
),
body: PopScope(canPop: false, child: loadingBoard),
);
+63 -58
View File
@@ -1,5 +1,6 @@
import 'package:dartchess/dartchess.dart' show Move, Side;
import 'package:dartchess/dartchess.dart' show Side;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:lichess_mobile/src/model/challenge/challenge.dart';
import 'package:lichess_mobile/src/model/common/id.dart';
import 'package:lichess_mobile/src/model/common/speed.dart';
@@ -10,13 +11,52 @@ import 'package:lichess_mobile/src/model/lobby/game_seek.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'game_screen_providers.g.dart';
part 'game_screen_providers.freezed.dart';
typedef LoadedGame = ({GameFullId gameId, String? lastFen, Move? lastMove, Side? side});
typedef CurrentGameState = ({
LoadedGame? game,
Challenge? challenge,
ChallengeDeclineReason? declineReason,
});
sealed class CurrentGameState {}
/// This is used in the following cases:
/// - A game that had already been created is loaded.
/// - A game has been created from a lobby seek.
/// - A challenge has been accepted and a game has been created from it.
@freezed
sealed class GameCreatedState with _$GameCreatedState implements CurrentGameState {
const GameCreatedState._();
const factory GameCreatedState(GameFullId createdGameId) = _GameCreatedState;
}
/// A real time challenge has been declined.
@freezed
sealed class ChallengeDeclinedState with _$ChallengeDeclinedState implements CurrentGameState {
const ChallengeDeclinedState._();
const factory ChallengeDeclinedState(ChallengeDeclinedResponse response) =
_ChallengeDeclinedState;
}
sealed class GameScreenSource {}
@freezed
sealed class ExistingGameSource with _$ExistingGameSource implements GameScreenSource {
const ExistingGameSource._();
const factory ExistingGameSource(GameFullId id) = _ExistingGameSource;
}
@freezed
sealed class LobbySource with _$LobbySource implements GameScreenSource {
const LobbySource._();
const factory LobbySource(GameSeek seek) = _LobbySource;
}
@freezed
sealed class UserChallengeSource with _$UserChallengeSource implements GameScreenSource {
const UserChallengeSource._();
const factory UserChallengeSource(ChallengeRequest challengeRequest) = _UserChallengeSource;
}
/// A provider that returns the currently loaded [GameFullId] for the [GameScreen].
///
@@ -25,71 +65,36 @@ typedef CurrentGameState = ({
@riverpod
class CurrentGame extends _$CurrentGame {
@override
Future<CurrentGameState> build({
GameSeek? seek,
ChallengeRequest? challenge,
({GameFullId gameId, String? lastFen, Move? lastMove, Side? side})? game,
}) {
assert(
game != null || seek != null || challenge != null,
'Either a seek, challenge or a game id must be provided.',
);
Future<CurrentGameState> build(GameScreenSource source) {
final service = ref.watch(createGameServiceProvider);
if (seek != null) {
return service
.newLobbyGame(seek)
.then(
(id) => (
game: (gameId: id, lastFen: null, lastMove: null, side: null),
challenge: null,
declineReason: null,
return switch (source) {
LobbySource(:final seek) => service.newLobbyGame(seek).then((id) => GameCreatedState(id)),
UserChallengeSource(:final challengeRequest) =>
service
.newRealTimeChallenge(challengeRequest)
.then(
(data) => switch (data) {
ChallengeAcceptedResponse(:final gameFullId) => GameCreatedState(gameFullId),
ChallengeDeclinedResponse() => ChallengeDeclinedState(data),
},
),
);
} else if (challenge != null) {
return service
.newRealTimeChallenge(challenge)
.then(
(data) => (
game: data.gameFullId != null
? (gameId: data.gameFullId!, lastFen: null, lastMove: null, side: null)
: null,
challenge: data.challenge,
declineReason: data.declineReason,
),
);
}
return Future.value((game: game!, challenge: null, declineReason: null));
ExistingGameSource(:final id) => Future.value(GameCreatedState(id)),
};
}
/// Search for a new opponent (lobby only).
Future<void> newOpponent() async {
if (seek != null) {
if (source case LobbySource(:final seek)) {
final service = ref.read(createGameServiceProvider);
state = const AsyncValue.loading();
state = AsyncValue.data(
await service
.newLobbyGame(seek!)
.then(
(id) => (
game: (gameId: id, lastFen: null, lastMove: null, side: null),
challenge: null,
declineReason: null,
),
),
);
state = AsyncValue.data(await service.newLobbyGame(seek).then((id) => GameCreatedState(id)));
}
}
/// Load a game from its id.
void loadGame(GameFullId id) {
state = AsyncValue.data((
game: (gameId: id, lastFen: null, lastMove: null, side: null),
challenge: null,
declineReason: null,
));
state = AsyncValue.data(GameCreatedState(id));
}
}
+7 -4
View File
@@ -33,6 +33,7 @@ import 'package:lichess_mobile/src/view/account/account_drawer.dart';
import 'package:lichess_mobile/src/view/account/profile_screen.dart';
import 'package:lichess_mobile/src/view/correspondence/offline_correspondence_game_screen.dart';
import 'package:lichess_mobile/src/view/game/game_screen.dart';
import 'package:lichess_mobile/src/view/game/game_screen_providers.dart';
import 'package:lichess_mobile/src/view/game/offline_correspondence_games_screen.dart';
import 'package:lichess_mobile/src/view/home/games_carousel.dart';
import 'package:lichess_mobile/src/view/play/ongoing_games_screen.dart';
@@ -683,10 +684,12 @@ class _OngoingGamesCarousel extends ConsumerWidget {
Navigator.of(context, rootNavigator: true).push(
GameScreen.buildRoute(
context,
initialGameId: game.fullId,
loadingFen: game.fen,
loadingOrientation: game.orientation,
loadingLastMove: game.lastMove,
source: ExistingGameSource(game.fullId),
loadingPosition: (
fen: game.fen,
orientation: game.orientation,
lastMove: game.lastMove,
),
),
);
},
@@ -13,6 +13,7 @@ import 'package:lichess_mobile/src/styles/styles.dart';
import 'package:lichess_mobile/src/utils/l10n_context.dart';
import 'package:lichess_mobile/src/utils/navigation.dart';
import 'package:lichess_mobile/src/view/game/game_screen.dart';
import 'package:lichess_mobile/src/view/game/game_screen_providers.dart';
import 'package:lichess_mobile/src/widgets/board_thumbnail.dart';
import 'package:lichess_mobile/src/widgets/non_linear_slider.dart';
@@ -269,17 +270,19 @@ class _ChallengeBodyState extends ConsumerState<_ChallengeBody> {
Navigator.of(context, rootNavigator: true).push(
GameScreen.buildRoute(
context,
challenge: ChallengeRequest(
destUser: widget.bot,
variant: Variant.fromPosition,
timeControl: ChallengeTimeControlType.clock,
clock: (
time: Duration(seconds: seconds),
increment: Duration(seconds: incrementSeconds),
source: UserChallengeSource(
ChallengeRequest(
destUser: widget.bot,
variant: Variant.fromPosition,
timeControl: ChallengeTimeControlType.clock,
clock: (
time: Duration(seconds: seconds),
increment: Duration(seconds: incrementSeconds),
),
rated: false,
sideChoice: sideChoice,
initialFen: fen,
),
rated: false,
sideChoice: sideChoice,
initialFen: fen,
),
),
);
@@ -12,6 +12,7 @@ import 'package:lichess_mobile/src/network/socket.dart';
import 'package:lichess_mobile/src/utils/l10n_context.dart';
import 'package:lichess_mobile/src/utils/navigation.dart';
import 'package:lichess_mobile/src/view/game/game_screen.dart';
import 'package:lichess_mobile/src/view/game/game_screen_providers.dart';
import 'package:lichess_mobile/src/view/play/challenge_list_item.dart';
import 'package:lichess_mobile/src/view/play/create_correspondence_game_bottom_sheet.dart';
import 'package:lichess_mobile/src/widgets/adaptive_action_sheet.dart';
@@ -51,7 +52,7 @@ class _ChallengesBodyState extends ConsumerState<CorrespondenceChallengesScreen>
Navigator.of(
context,
rootNavigator: true,
).push(GameScreen.buildRoute(context, initialGameId: gameFullId));
).push(GameScreen.buildRoute(context, source: ExistingGameSource(gameFullId)));
}
case 'reload_seeks':
@@ -17,6 +17,7 @@ import 'package:lichess_mobile/src/model/user/user.dart';
import 'package:lichess_mobile/src/styles/styles.dart';
import 'package:lichess_mobile/src/utils/l10n_context.dart';
import 'package:lichess_mobile/src/view/game/game_screen.dart';
import 'package:lichess_mobile/src/view/game/game_screen_providers.dart';
import 'package:lichess_mobile/src/widgets/adaptive_bottom_sheet.dart';
import 'package:lichess_mobile/src/widgets/adaptive_choice_picker.dart';
import 'package:lichess_mobile/src/widgets/board_preview.dart';
@@ -321,11 +322,13 @@ class _CreateChallengeBottomSheetState extends ConsumerState<CreateChallengeBott
Navigator.of(context, rootNavigator: true).push(
GameScreen.buildRoute(
context,
challenge: preferences.makeRequest(
widget.user,
preferences.variant != Variant.fromPosition
? null
: fromPositionFenInput,
source: UserChallengeSource(
preferences.makeRequest(
widget.user,
preferences.variant != Variant.fromPosition
? null
: fromPositionFenInput,
),
),
),
);
+7 -4
View File
@@ -8,6 +8,7 @@ import 'package:lichess_mobile/src/model/lobby/game_setup_preferences.dart';
import 'package:lichess_mobile/src/network/connectivity.dart';
import 'package:lichess_mobile/src/utils/l10n_context.dart';
import 'package:lichess_mobile/src/view/game/game_screen.dart';
import 'package:lichess_mobile/src/view/game/game_screen_providers.dart';
import 'package:lichess_mobile/src/view/play/common_play_widgets.dart';
import 'package:lichess_mobile/src/view/play/time_control_modal.dart';
import 'package:lichess_mobile/src/widgets/adaptive_bottom_sheet.dart';
@@ -165,10 +166,12 @@ class CreateGameWidget extends ConsumerWidget {
return;
}
Navigator.of(
context,
rootNavigator: true,
).push(GameScreen.buildRoute(context, seek: GameSeek.custom(playPrefs, account)));
Navigator.of(context, rootNavigator: true).push(
GameScreen.buildRoute(
context,
source: LobbySource(GameSeek.custom(playPrefs, account)),
),
);
}
: null,
child: Text(context.l10n.createAGame),
+7 -4
View File
@@ -6,6 +6,7 @@ import 'package:lichess_mobile/src/utils/l10n.dart';
import 'package:lichess_mobile/src/utils/l10n_context.dart';
import 'package:lichess_mobile/src/utils/navigation.dart';
import 'package:lichess_mobile/src/view/game/game_screen.dart';
import 'package:lichess_mobile/src/view/game/game_screen_providers.dart';
import 'package:lichess_mobile/src/widgets/board_preview.dart';
import 'package:lichess_mobile/src/widgets/user_full_name.dart';
@@ -89,10 +90,12 @@ class OngoingGamePreview extends ConsumerWidget {
Navigator.of(context, rootNavigator: true).push(
GameScreen.buildRoute(
context,
initialGameId: game.fullId,
loadingFen: game.fen,
loadingOrientation: game.orientation,
loadingLastMove: game.lastMove,
source: ExistingGameSource(game.fullId),
loadingPosition: (
fen: game.fen,
orientation: game.orientation,
lastMove: game.lastMove,
),
),
);
},
+2 -1
View File
@@ -10,6 +10,7 @@ import 'package:lichess_mobile/src/network/connectivity.dart';
import 'package:lichess_mobile/src/styles/styles.dart';
import 'package:lichess_mobile/src/utils/l10n_context.dart';
import 'package:lichess_mobile/src/view/game/game_screen.dart';
import 'package:lichess_mobile/src/view/game/game_screen_providers.dart';
import 'package:lichess_mobile/src/view/play/play_bottom_sheet.dart';
import 'package:lichess_mobile/src/view/play/playban.dart';
@@ -95,7 +96,7 @@ class _SectionChoices extends ConsumerWidget {
Navigator.of(context, rootNavigator: true).push(
GameScreen.buildRoute(
context,
seek: GameSeek.fastPairing(choice, session),
source: LobbySource(GameSeek.fastPairing(choice, session)),
),
);
}
@@ -30,6 +30,7 @@ import 'package:lichess_mobile/src/utils/share.dart';
import 'package:lichess_mobile/src/view/analysis/analysis_screen.dart';
import 'package:lichess_mobile/src/view/chat/chat_screen.dart';
import 'package:lichess_mobile/src/view/game/game_screen.dart';
import 'package:lichess_mobile/src/view/game/game_screen_providers.dart';
import 'package:lichess_mobile/src/view/user/user_screen.dart';
import 'package:lichess_mobile/src/view/watch/tv_screen.dart';
import 'package:lichess_mobile/src/widgets/board_thumbnail.dart';
@@ -102,7 +103,7 @@ class _TournamentScreenState extends ConsumerState<TournamentScreen> with RouteA
Navigator.of(
context,
rootNavigator: true,
).push(GameScreen.buildRoute(context, initialGameId: currentGameId));
).push(GameScreen.buildRoute(context, source: ExistingGameSource(currentGameId)));
}
},
);
@@ -11,6 +11,7 @@ import 'package:lichess_mobile/src/styles/styles.dart';
import 'package:lichess_mobile/src/utils/l10n_context.dart';
import 'package:lichess_mobile/src/utils/navigation.dart';
import 'package:lichess_mobile/src/view/game/game_screen.dart';
import 'package:lichess_mobile/src/view/game/game_screen_providers.dart';
import 'package:lichess_mobile/src/view/play/challenge_list_item.dart';
import 'package:lichess_mobile/src/widgets/adaptive_action_sheet.dart';
import 'package:lichess_mobile/src/widgets/feedback.dart';
@@ -85,10 +86,13 @@ class _ChallengeListItem extends ConsumerWidget {
Future<void> acceptChallenge() async {
final fullId = await ref.read(challengeServiceProvider).acceptChallenge(challenge.id);
if (!context.mounted) return;
if (fullId == null) {
return showSnackBar(context, 'Failed to accept challenge', type: SnackBarType.error);
}
Navigator.of(
context,
rootNavigator: true,
).push(GameScreen.buildRoute(context, initialGameId: fullId));
).push(GameScreen.buildRoute(context, source: ExistingGameSource(fullId)));
}
Future<void> declineChallenge(ChallengeDeclineReason? reason) async {
+10 -7
View File
@@ -21,6 +21,7 @@ import 'package:lichess_mobile/src/network/http.dart';
import 'package:lichess_mobile/src/network/socket.dart';
import 'package:lichess_mobile/src/styles/lichess_icons.dart';
import 'package:lichess_mobile/src/view/game/game_screen.dart';
import 'package:lichess_mobile/src/view/game/game_screen_providers.dart';
import 'package:lichess_mobile/src/widgets/bottom_bar.dart';
import 'package:lichess_mobile/src/widgets/clock.dart';
import 'package:mocktail/mocktail.dart';
@@ -47,7 +48,7 @@ void main() {
testWidgets('a game directly with initialGameId', (WidgetTester tester) async {
final app = await makeTestProviderScopeApp(
tester,
home: const GameScreen(initialGameId: testGameFullId),
home: const GameScreen(source: ExistingGameSource(testGameFullId)),
overrides: [lichessClientProvider.overrideWith((ref) => LichessClient(client, ref))],
);
await tester.pumpWidget(app);
@@ -95,7 +96,9 @@ void main() {
final app = await makeTestProviderScopeApp(
tester,
home: const GameScreen(
seek: GameSeek(clock: (Duration(minutes: 3), Duration(seconds: 2)), rated: true),
source: LobbySource(
GameSeek(clock: (Duration(minutes: 3), Duration(seconds: 2)), rated: true),
),
),
overrides: [lichessClientProvider.overrideWith((ref) => LichessClient(client, ref))],
);
@@ -334,7 +337,7 @@ void main() {
testWidgets('displays tournament info', (WidgetTester tester) async {
final app = await makeTestProviderScopeApp(
tester,
home: const GameScreen(initialGameId: GameFullId('qVChCOTcHSeW')),
home: const GameScreen(source: ExistingGameSource(GameFullId('qVChCOTcHSeW'))),
overrides: [lichessClientProvider.overrideWith((ref) => LichessClient(client, ref))],
);
await tester.pumpWidget(app);
@@ -356,7 +359,7 @@ void main() {
testWidgets('supports berserking', (WidgetTester tester) async {
final app = await makeTestProviderScopeApp(
tester,
home: const GameScreen(initialGameId: GameFullId('qVChCOTcHSeW')),
home: const GameScreen(source: ExistingGameSource(GameFullId('qVChCOTcHSeW'))),
overrides: [lichessClientProvider.overrideWith((ref) => LichessClient(client, ref))],
);
await tester.pumpWidget(app);
@@ -690,7 +693,7 @@ void main() {
final app = await makeTestProviderScopeApp(
tester,
home: const GameScreen(initialGameId: gameFullId),
home: const GameScreen(source: ExistingGameSource(gameFullId)),
overrides: [lichessClientProvider.overrideWith((ref) => LichessClient(mockClient, ref))],
);
await tester.pumpWidget(app);
@@ -854,7 +857,7 @@ Future<void> createTestGame(
const gameFullId = GameFullId('qVChCOTcHSeW');
final app = await makeTestProviderScopeApp(
tester,
home: const GameScreen(initialGameId: gameFullId),
home: const GameScreen(source: ExistingGameSource(gameFullId)),
defaultPreferences: defaultPreferences,
overrides: [
lichessClientProvider.overrideWith((ref) => LichessClient(client, ref)),
@@ -897,7 +900,7 @@ Future<void> loadFinishedTestGame(
final gameFullId = GameFullId('${gameId.value}test');
final app = await makeTestProviderScopeApp(
tester,
home: GameScreen(initialGameId: gameFullId),
home: GameScreen(source: ExistingGameSource(gameFullId)),
overrides: [
lichessClientProvider.overrideWith((ref) => LichessClient(client, ref)),
...?overrides,