diff --git a/build.yaml b/build.yaml index 0a4e41a64..1aa92272c 100644 --- a/build.yaml +++ b/build.yaml @@ -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 diff --git a/lib/src/model/challenge/challenge_service.dart b/lib/src/model/challenge/challenge_service.dart index c38a8c0a7..7ce9379c1 100644 --- a/lib/src/model/challenge/challenge_service.dart +++ b/lib/src/model/challenge/challenge_service.dart @@ -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 _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), diff --git a/lib/src/model/correspondence/correspondence_service.dart b/lib/src/model/correspondence/correspondence_service.dart index 667fd7e66..b5764897e 100644 --- a/lib/src/model/correspondence/correspondence_service.dart +++ b/lib/src/model/correspondence/correspondence_service.dart @@ -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. diff --git a/lib/src/model/lobby/create_game_service.dart b/lib/src/model/lobby/create_game_service.dart index 91860f9cd..a289fe1f4 100644 --- a/lib/src/model/lobby/create_game_service.dart +++ b/lib/src/model/lobby/create_game_service.dart @@ -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); diff --git a/lib/src/quick_actions.dart b/lib/src/quick_actions.dart index 13460fa45..839ae6093 100644 --- a/lib/src/quick_actions.dart +++ b/lib/src/quick_actions.dart @@ -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( diff --git a/lib/src/view/game/game_body.dart b/lib/src/view/game/game_body.dart index 0f6757a4e..395c2a349 100644 --- a/lib/src/view/game/game_body.dart +++ b/lib/src/view/game/game_body.dart @@ -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( 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( context: context, - builder: (context) => _ClaimWinDialog(id: loadedGame.gameId), + builder: (context) => _ClaimWinDialog(id: gameId), barrierDismissible: true, ); } diff --git a/lib/src/view/game/game_common_widgets.dart b/lib/src/view/game/game_common_widgets.dart index 3d5218ef0..f64766173 100644 --- a/lib/src/view/game/game_common_widgets.dart +++ b/lib/src/view/game/game_common_widgets.dart @@ -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( diff --git a/lib/src/view/game/game_loading_board.dart b/lib/src/view/game/game_loading_board.dart index 4fb6d31cb..2a6ee4bb0 100644 --- a/lib/src/view/game/game_loading_board.dart +++ b/lib/src/view/game/game_loading_board.dart @@ -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 { } 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 [], diff --git a/lib/src/view/game/game_screen.dart b/lib/src/view/game/game_screen.dart index 8a1589de4..17941fcdb 100644 --- a/lib/src/view/game/game_screen.dart +++ b/lib/src/view/game/game_screen.dart @@ -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 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 createState() => _GameScreenState(); } -enum _GameSource { lobby, challenge, game } - class _GameScreenState extends ConsumerState { final _whiteClockKey = GlobalKey(debugLabel: 'whiteClockOnGameScreen'); final _blackClockKey = GlobalKey(debugLabel: 'blackClockOnGameScreen'); @@ -111,31 +71,34 @@ class _GameScreenState extends ConsumerState { @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 { // 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 { 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 { 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), ); diff --git a/lib/src/view/game/game_screen_providers.dart b/lib/src/view/game/game_screen_providers.dart index 968b23397..75bed2ea5 100644 --- a/lib/src/view/game/game_screen_providers.dart +++ b/lib/src/view/game/game_screen_providers.dart @@ -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 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 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 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)); } } diff --git a/lib/src/view/home/home_tab_screen.dart b/lib/src/view/home/home_tab_screen.dart index e25aca71e..0286d7614 100644 --- a/lib/src/view/home/home_tab_screen.dart +++ b/lib/src/view/home/home_tab_screen.dart @@ -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, + ), ), ); }, diff --git a/lib/src/view/play/challenge_odd_bots_screen.dart b/lib/src/view/play/challenge_odd_bots_screen.dart index be80ba561..c2298db48 100644 --- a/lib/src/view/play/challenge_odd_bots_screen.dart +++ b/lib/src/view/play/challenge_odd_bots_screen.dart @@ -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, ), ), ); diff --git a/lib/src/view/play/correspondence_challenges_screen.dart b/lib/src/view/play/correspondence_challenges_screen.dart index e4e6cce37..dced48a84 100644 --- a/lib/src/view/play/correspondence_challenges_screen.dart +++ b/lib/src/view/play/correspondence_challenges_screen.dart @@ -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 Navigator.of( context, rootNavigator: true, - ).push(GameScreen.buildRoute(context, initialGameId: gameFullId)); + ).push(GameScreen.buildRoute(context, source: ExistingGameSource(gameFullId))); } case 'reload_seeks': diff --git a/lib/src/view/play/create_challenge_bottom_sheet.dart b/lib/src/view/play/create_challenge_bottom_sheet.dart index 21c244c6c..fb5e8049f 100644 --- a/lib/src/view/play/create_challenge_bottom_sheet.dart +++ b/lib/src/view/play/create_challenge_bottom_sheet.dart @@ -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 with RouteA Navigator.of( context, rootNavigator: true, - ).push(GameScreen.buildRoute(context, initialGameId: currentGameId)); + ).push(GameScreen.buildRoute(context, source: ExistingGameSource(currentGameId))); } }, ); diff --git a/lib/src/view/user/challenge_requests_screen.dart b/lib/src/view/user/challenge_requests_screen.dart index c11b4a510..d7549b7b0 100644 --- a/lib/src/view/user/challenge_requests_screen.dart +++ b/lib/src/view/user/challenge_requests_screen.dart @@ -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 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 declineChallenge(ChallengeDeclineReason? reason) async { diff --git a/test/view/game/game_screen_test.dart b/test/view/game/game_screen_test.dart index 2ce815b06..615e5716f 100644 --- a/test/view/game/game_screen_test.dart +++ b/test/view/game/game_screen_test.dart @@ -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 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 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,