From 4e0e13c6f85240420d1d6fa6c82cc8739d00acdd Mon Sep 17 00:00:00 2001 From: Tom Praschan <13141438+tom-anders@users.noreply.github.com> Date: Tue, 31 Mar 2026 10:19:56 +0200 Subject: [PATCH] feat: support creating challenge links (#2828) Closes #2015 Closes #856 --- lib/src/app_links_service.dart | 2 +- lib/src/model/challenge/challenge.dart | 3 +- .../challenge/challenge_preferences.dart | 5 +- .../model/challenge/challenge_repository.dart | 31 +- .../model/challenge/challenge_service.dart | 26 +- lib/src/model/lobby/create_game_service.dart | 20 +- .../board_editor/board_editor_screen.dart | 5 +- lib/src/view/game/game_loading_board.dart | 384 +++++++++++++----- lib/src/view/game/game_screen.dart | 19 +- lib/src/view/game/game_screen_providers.dart | 60 ++- .../play/create_challenge_bottom_sheet.dart | 13 +- lib/src/view/play/create_game_widget.dart | 5 +- lib/src/view/play/play_menu.dart | 21 +- lib/src/view/user/online_bots_screen.dart | 2 +- lib/src/view/user/user_screen.dart | 2 +- lib/src/widgets/board.dart | 65 ++- pubspec.lock | 16 + pubspec.yaml | 1 + test/app_links_service_test.dart | 3 +- .../challenge/challenge_repository_test.dart | 34 ++ .../challenge/challenge_service_test.dart | 76 +++- test/view/game/game_screen_test.dart | 58 ++- .../create_challenge_bottom_sheet_test.dart | 2 +- 23 files changed, 642 insertions(+), 211 deletions(-) diff --git a/lib/src/app_links_service.dart b/lib/src/app_links_service.dart index b46e1e42e..2145626ee 100644 --- a/lib/src/app_links_service.dart +++ b/lib/src/app_links_service.dart @@ -122,7 +122,7 @@ class AppLinksService { final challenge = await ref.read(challengeRepositoryProvider).show(challengeId); if (!context.mounted) return false; - ref.read(challengeServiceProvider).showConfirmDialog(context, challenge); + ref.read(challengeServiceProvider).showConfirmDialog(context, challenge, fromLink: true); return true; } catch (e, st) { diff --git a/lib/src/model/challenge/challenge.dart b/lib/src/model/challenge/challenge.dart index 7c1d61b1d..c377535ea 100644 --- a/lib/src/model/challenge/challenge.dart +++ b/lib/src/model/challenge/challenge.dart @@ -111,7 +111,8 @@ sealed class ChallengeRequest with _$ChallengeRequest, BaseChallenge implements const ChallengeRequest._(); const factory ChallengeRequest({ - required LightUser destUser, + // If null, it's an open challenge that anyone (even anonymous users) can accept. + LightUser? destUser, required Variant variant, required ChallengeTimeControlType timeControl, ({Duration time, Duration increment})? clock, diff --git a/lib/src/model/challenge/challenge_preferences.dart b/lib/src/model/challenge/challenge_preferences.dart index 69ee6daaa..429c9b8df 100644 --- a/lib/src/model/challenge/challenge_preferences.dart +++ b/lib/src/model/challenge/challenge_preferences.dart @@ -90,14 +90,15 @@ sealed class ChallengePrefs with _$ChallengePrefs implements Serializable { ? variant != Variant.fromPosition : timeControl == ChallengeTimeControlType.correspondence && variant == Variant.standard; - ChallengeRequest makeRequest(LightUser destUser, [String? initialFen]) { + ChallengeRequest makeRequest(User? challengingUser, LightUser? destUser, [String? initialFen]) { return ChallengeRequest( destUser: destUser, variant: variant, timeControl: timeControl, clock: timeControl == ChallengeTimeControlType.clock ? clock : null, days: timeControl == ChallengeTimeControlType.correspondence ? days : null, - rated: isRatedAllowed && rated, + // Anonymous users cannot create rated challenges. + rated: challengingUser != null && isRatedAllowed && rated, sideChoice: sideChoice, initialFen: initialFen, ); diff --git a/lib/src/model/challenge/challenge_repository.dart b/lib/src/model/challenge/challenge_repository.dart index 173922d7e..8cdc57d9b 100644 --- a/lib/src/model/challenge/challenge_repository.dart +++ b/lib/src/model/challenge/challenge_repository.dart @@ -1,9 +1,12 @@ import 'dart:async'; +import 'dart:math'; +import 'package:dartchess/dartchess.dart'; import 'package:deep_pick/deep_pick.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lichess_mobile/src/model/challenge/challenge.dart'; +import 'package:lichess_mobile/src/model/common/game.dart'; import 'package:lichess_mobile/src/model/common/id.dart'; import 'package:lichess_mobile/src/network/aggregator.dart'; import 'package:lichess_mobile/src/network/http.dart'; @@ -40,17 +43,33 @@ class ChallengeRepository { return client.readJson(uri, mapper: Challenge.fromServerJson); } - Future create(ChallengeRequest challenge) { - final uri = Uri(path: '/api/challenge/${challenge.destUser.id}'); - return client.postReadJson( + Future create(ChallengeRequest challengeReq) async { + final uri = Uri(path: '/api/challenge/${challengeReq.destUser?.id ?? 'open'}'); + final challenge = await client.postReadJson( uri, - body: challenge.toRequestBody, + body: challengeReq.toRequestBody, mapper: Challenge.fromServerJson, ); + + // The API doesn't directly allow us to create an open challenge and also join it in one step, + // so after having created the challenge, we immediately accept it if it's an open challenge. + if (challengeReq.destUser == null) { + final side = switch (challengeReq.sideChoice) { + SideChoice.white => Side.white, + SideChoice.black => Side.black, + SideChoice.random => Side.values[Random().nextInt(Side.values.length)], + }; + await accept(challenge.id, side: side); + } + + return challenge; } - Future accept(ChallengeId id) async { - final uri = Uri(path: '/api/challenge/$id/accept'); + Future accept(ChallengeId id, {Side? side}) async { + final uri = Uri( + path: '/api/challenge/$id/accept', + queryParameters: {if (side != null) 'color': side.name}, + ); await client.postRead(uri); } diff --git a/lib/src/model/challenge/challenge_service.dart b/lib/src/model/challenge/challenge_service.dart index 4c11e0361..857b484f8 100644 --- a/lib/src/model/challenge/challenge_service.dart +++ b/lib/src/model/challenge/challenge_service.dart @@ -203,7 +203,12 @@ class ChallengeService { ); } - void showConfirmDialog(BuildContext context, Challenge challenge, {String? title}) { + void showConfirmDialog( + BuildContext context, + Challenge challenge, { + String? title, + bool fromLink = false, + }) { showAdaptiveActionSheet( context: context, title: challenge.challenger != null && challenge.variant.isPlaySupported @@ -220,12 +225,19 @@ class ChallengeService { isDefaultAction: true, onPressed: () async => await acceptChallenge(challenge.id), ), - BottomSheetAction( - makeLabel: (context) => Text(context.l10n.decline), - leading: Icon(Icons.clear, color: context.lichessColors.error), - isDestructiveAction: true, - onPressed: () => showDeclineDialog(context, challenge.id), - ), + if (fromLink && Theme.of(context).platform != TargetPlatform.iOS) + BottomSheetAction( + makeLabel: (context) => Text(context.l10n.cancel), + leading: const Icon(Icons.close), + onPressed: () {}, + ) + else if (!fromLink) + BottomSheetAction( + makeLabel: (context) => Text(context.l10n.decline), + leading: Icon(Icons.clear, color: context.lichessColors.error), + isDestructiveAction: true, + onPressed: () => showDeclineDialog(context, challenge.id), + ), ], ); } diff --git a/lib/src/model/lobby/create_game_service.dart b/lib/src/model/lobby/create_game_service.dart index e02410b49..cab58ec5c 100644 --- a/lib/src/model/lobby/create_game_service.dart +++ b/lib/src/model/lobby/create_game_service.dart @@ -119,23 +119,29 @@ class CreateGameService { } /// Create a new real time challenge. - /// - /// Will listen to the challenge socket and await the response from the destinated user. - Future newRealTimeChallenge(ChallengeRequest challengeReq) async { + Future newRealTimeChallenge(ChallengeRequest challengeReq) async { assert(challengeReq.timeControl == ChallengeTimeControlType.clock); if (_challengeConnection != null) { throw StateError('Already creating a challenge.'); } + return await challengeRepository.create(challengeReq); + } + + /// Wait for a response to a [challenge]. + /// + /// Will listen to the challenge socket and await the response from the destinated user. + Future waitForChallengeResponse(Challenge challenge) { + assert(challenge.timeControl == ChallengeTimeControlType.clock); + if (_challengeConnection != null) { + throw StateError('Already creating a challenge.'); + } + // ensure the pending connection is closed in any case final completer = Completer()..future.whenComplete(dispose); try { - _log.info('Creating new challenge game'); - - final challenge = await challengeRepository.create(challengeReq); - final socketPool = ref.read(socketPoolProvider); final socketClient = socketPool.open( Uri( diff --git a/lib/src/view/board_editor/board_editor_screen.dart b/lib/src/view/board_editor/board_editor_screen.dart index 82107a39b..6a35a70e3 100644 --- a/lib/src/view/board_editor/board_editor_screen.dart +++ b/lib/src/view/board_editor/board_editor_screen.dart @@ -347,7 +347,10 @@ class _BottomBar extends ConsumerWidget { isScrollControlled: true, useRootNavigator: true, builder: (context) { - return CreateChallengeBottomSheet(user, positionFen: editorState.fen); + return CreateChallengeBottomSheet( + user: user, + positionFen: editorState.fen, + ); }, ); }, diff --git a/lib/src/view/game/game_loading_board.dart b/lib/src/view/game/game_loading_board.dart index 291e6d5f7..e1dfb0357 100644 --- a/lib/src/view/game/game_loading_board.dart +++ b/lib/src/view/game/game_loading_board.dart @@ -1,13 +1,17 @@ import 'package:dartchess/dartchess.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lichess_mobile/src/constants.dart'; import 'package:lichess_mobile/src/model/challenge/challenge.dart'; import 'package:lichess_mobile/src/model/common/chess.dart'; import 'package:lichess_mobile/src/model/game/game_board_params.dart'; import 'package:lichess_mobile/src/model/lobby/game_seek.dart'; import 'package:lichess_mobile/src/model/lobby/lobby_numbers.dart'; +import 'package:lichess_mobile/src/model/settings/brightness.dart'; import 'package:lichess_mobile/src/utils/l10n_context.dart'; +import 'package:lichess_mobile/src/utils/share.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'; @@ -16,6 +20,8 @@ import 'package:lichess_mobile/src/widgets/feedback.dart'; import 'package:lichess_mobile/src/widgets/game_layout.dart'; import 'package:lichess_mobile/src/widgets/shimmer.dart'; import 'package:lichess_mobile/src/widgets/user.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:share_plus/share_plus.dart'; class LobbyScreenLoadingContent extends StatefulWidget { const LobbyScreenLoadingContent(this.seek, this.cancelGameCreation); @@ -37,44 +43,41 @@ class _LobbyScreenLoadingContentState extends State { orientation: Side.white, boardParams: GameBoardParams.emptyBoard, moves: const [], - boardOverlay: Card( - color: Theme.of(context).dialogTheme.backgroundColor, - elevation: 2.0, - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text(context.l10n.mobileWaitingForOpponentToJoin), - const SizedBox(height: 26.0), - Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Icon(widget.seek.perf.icon, color: DefaultTextStyle.of(context).style.color), - const SizedBox(width: 8.0), - Text( - widget.seek.timeIncrement?.display ?? - '${context.l10n.daysPerTurn}: ${widget.seek.days}', - style: TextTheme.of(context).titleLarge, - ), - ], - ), - //Do not show rating range if the default values (-500, +500) are used - if (widget.seek.ratingRange != null && - !(widget.seek.ratingRange!.$1 + 1000 == widget.seek.ratingRange!.$2)) ...[ - const SizedBox(height: 8.0), - RatingPrefAware( - child: Text( - '${widget.seek.ratingRange!.$1}-${widget.seek.ratingRange!.$2}', - style: TextTheme.of(context).titleMedium, - ), + boardOverlay: _BoardOverlayCard( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text(context.l10n.mobileWaitingForOpponentToJoin), + const SizedBox(height: 26.0), + Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon(widget.seek.perf.icon, color: DefaultTextStyle.of(context).style.color), + const SizedBox(width: 8.0), + Text( + widget.seek.timeIncrement?.display ?? + '${context.l10n.daysPerTurn}: ${widget.seek.days}', + style: TextTheme.of(context).titleLarge, ), ], - const SizedBox(height: 16.0), - _LobbyNumbers(), + ), + //Do not show rating range if the default values (-500, +500) are used + if (widget.seek.ratingRange != null && + !(widget.seek.ratingRange!.$1 + 1000 == widget.seek.ratingRange!.$2)) ...[ + const SizedBox(height: 8.0), + RatingPrefAware( + child: Text( + '${widget.seek.ratingRange!.$1}-${widget.seek.ratingRange!.$2}', + style: TextTheme.of(context).titleMedium, + ), + ), ], - ), + const SizedBox(height: 16.0), + _LobbyNumbers(), + ], ), ), userActionsBar: BottomBar( @@ -117,17 +120,17 @@ class _LobbyScreenLoadingContentState extends State { } } -class ChallengeLoadingContent extends StatefulWidget { - const ChallengeLoadingContent(this.challenge, this.cancelChallenge); +class UserChallengeLoadingContent extends StatefulWidget { + const UserChallengeLoadingContent(this.challenge, this.cancelChallenge); final ChallengeRequest challenge; final Future Function() cancelChallenge; @override - State createState() => _ChallengeLoadingContentState(); + State createState() => _UserChallengeLoadingContentState(); } -class _ChallengeLoadingContentState extends State { +class _UserChallengeLoadingContentState extends State { Future? _cancelChallengeFuture; @override @@ -137,39 +140,34 @@ class _ChallengeLoadingContentState extends State { orientation: Side.white, boardParams: GameBoardParams.emptyBoard, moves: const [], - boardOverlay: Card( - color: Theme.of(context).dialogTheme.backgroundColor, - elevation: 2.0, - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text(context.l10n.waitingForOpponent), - const SizedBox(height: 16.0), - UserFullNameWidget( - user: widget.challenge.destUser, - style: TextTheme.of(context).titleLarge, - ), - const SizedBox(height: 16.0), - Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - widget.challenge.perf.icon, - color: DefaultTextStyle.of(context).style.color, - ), - const SizedBox(width: 8.0), - Text( - widget.challenge.timeIncrement?.display ?? - '${context.l10n.daysPerTurn}: ${widget.challenge.days}', - style: TextTheme.of(context).titleLarge, - ), - ], - ), - ], - ), + boardOverlay: _BoardOverlayCard( + padding: const EdgeInsets.all(40.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text(context.l10n.waitingForOpponent), + const SizedBox(height: 16.0), + UserFullNameWidget( + user: widget.challenge.destUser, + style: TextTheme.of(context).titleLarge, + ), + const SizedBox(height: 16.0), + Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon(widget.challenge.perf.icon, color: DefaultTextStyle.of(context).style.color), + const SizedBox(width: 8.0), + Text( + widget.challenge.timeIncrement?.display ?? + '${context.l10n.daysPerTurn}: ${widget.challenge.days}', + style: TextTheme.of(context).titleLarge, + ), + ], + ), + ], ), ), userActionsBar: BottomBar( @@ -212,6 +210,163 @@ class _ChallengeLoadingContentState extends State { } } +class OpenChallengeLoadingContent extends ConsumerStatefulWidget { + const OpenChallengeLoadingContent(this.challenge, this.cancelChallenge); + + final Challenge challenge; + final Future Function() cancelChallenge; + + @override + ConsumerState createState() => _OpenChallengeLoadingContentState(); +} + +class _OpenChallengeLoadingContentState extends ConsumerState { + Future? _cancelChallengeFuture; + + @override + Widget build(BuildContext context) { + final challengeLink = 'https://$kLichessHost/${widget.challenge.id}'; + + final qrColor = ref.watch(currentBrightnessProvider) == Brightness.dark + ? Colors.white + : Colors.black; + + return Column( + children: [ + Expanded( + child: SafeArea( + child: GameLayout( + orientation: Side.white, + boardParams: GameBoardParams.emptyBoard, + moves: const [], + boardOverlay: _BoardOverlayCard( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + spacing: 12.0, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + widget.challenge.perf.icon, + color: DefaultTextStyle.of(context).style.color, + ), + const SizedBox(width: 8.0), + Text( + widget.challenge.timeIncrement?.display ?? + '${context.l10n.daysPerTurn}: ${widget.challenge.days}', + style: TextTheme.of(context).titleLarge, + ), + ], + ), + Text(context.l10n.toInviteSomeoneToPlayGiveThisUrl), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8.0), + ), + child: GestureDetector( + onTap: () async { + await Clipboard.setData(ClipboardData(text: challengeLink)); + if (!context.mounted) return; + showSnackBar(context, 'Copied.'); // TODO l10n + }, + child: Text.rich( + TextSpan( + children: [ + const WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Icon(Icons.copy), + ), + TextSpan(text: ' $challengeLink'), + ], + ), + style: TextTheme.of(context).bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ), + + Text(context.l10n.theFirstPersonToComeOnThisUrlWillPlayWithYou), + + Container( + padding: const EdgeInsets.all(4.0), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8.0), + ), + child: QrImageView( + data: challengeLink, + eyeStyle: QrEyeStyle(color: qrColor, eyeShape: QrEyeShape.square), + dataModuleStyle: QrDataModuleStyle( + color: qrColor, + dataModuleShape: QrDataModuleShape.square, + ), + version: QrVersions.auto, + size: 120.0, + padding: const EdgeInsets.all(12.0), + ), + ), + + Text(context.l10n.orLetYourOpponentScanQrCode), + ], + ), + ), + ), + ), + ), + BottomBar( + children: [ + BottomBarButton( + label: 'Share challenge URL', // TODO l10n + onTap: () => launchShareDialog(context, ShareParams(text: challengeLink)), + showLabel: true, + icon: Icons.share, + ), + FutureBuilder( + future: _cancelChallengeFuture, + builder: (context, snapshot) { + return BottomBarButton( + onTap: snapshot.connectionState == ConnectionState.waiting + ? null + : () async { + setState(() { + _cancelChallengeFuture = widget.cancelChallenge(); + }); + try { + await _cancelChallengeFuture; + } catch (_) { + if (context.mounted) { + showSnackBar( + context, + 'Error cancelling challenge', + type: SnackBarType.error, + ); + } + } + if (context.mounted) { + Navigator.of(context, rootNavigator: true).pop(); + } + }, + label: context.l10n.cancel, + showLabel: true, + icon: CupertinoIcons.xmark, + ); + }, + ), + ], + ), + ], + ); + } +} + class StandaloneGameLoadingContent extends StatelessWidget { const StandaloneGameLoadingContent({this.position, this.userActionsBar, super.key}); @@ -261,6 +416,7 @@ class LoadingPlayerWidget extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, children: [ Flexible( flex: 6, @@ -350,45 +506,36 @@ class ChallengeDeclinedBoard extends StatelessWidget { orientation: Side.white, boardParams: GameBoardParams.emptyBoard, moves: const [], - boardOverlay: Card( - color: Theme.of(context).dialogTheme.backgroundColor, - elevation: 2.0, - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - context.l10n.challengeChallengeDeclined, - style: TextTheme.of(context).titleMedium, - ), - const SizedBox(height: 8.0), - Divider(height: 26.0, thickness: 0.0, color: textColor), - Text(declineReason, style: const TextStyle(fontStyle: FontStyle.italic)), - Divider(height: 26.0, thickness: 0.0, color: textColor), - if (challenge.destUser != null) - Align( - alignment: Alignment.centerRight, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Text(' — '), - UserFullNameWidget(user: challenge.destUser?.user), - if (challenge.destUser?.lagRating != null) ...[ - const SizedBox(width: 6.0), - LagIndicator( - lagRating: challenge.destUser!.lagRating!, - size: 13.0, - ), - ], - ], - ), - ), - ], + boardOverlay: _BoardOverlayCard( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + context.l10n.challengeChallengeDeclined, + style: TextTheme.of(context).titleMedium, ), - ), + const SizedBox(height: 8.0), + Divider(height: 26.0, thickness: 0.0, color: textColor), + Text(declineReason, style: const TextStyle(fontStyle: FontStyle.italic)), + Divider(height: 26.0, thickness: 0.0, color: textColor), + if (challenge.destUser != null) + Align( + alignment: Alignment.centerRight, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text(' — '), + UserFullNameWidget(user: challenge.destUser?.user), + if (challenge.destUser?.lagRating != null) ...[ + const SizedBox(width: 6.0), + LagIndicator(lagRating: challenge.destUser!.lagRating!, size: 13.0), + ], + ], + ), + ), + ], ), ), ), @@ -435,3 +582,20 @@ class _LobbyNumbers extends ConsumerWidget { } } } + +class _BoardOverlayCard extends StatelessWidget { + const _BoardOverlayCard({this.padding, required this.child}); + + final EdgeInsetsGeometry? padding; + + final Widget child; + + @override + Widget build(BuildContext context) { + return Card( + color: Theme.of(context).dialogTheme.backgroundColor, + elevation: 2.0, + child: Padding(padding: padding ?? const EdgeInsets.all(30.0), child: child), + ); + } +} diff --git a/lib/src/view/game/game_screen.dart b/lib/src/view/game/game_screen.dart index dd6cf8ce0..3b837a3a1 100644 --- a/lib/src/view/game/game_screen.dart +++ b/lib/src/view/game/game_screen.dart @@ -219,6 +219,23 @@ class _GameScreenState extends ConsumerState { ) : body, ); + case AsyncData(value: OpenChallengeCreatedState(:final challenge)): + return Scaffold( + resizeToAvoidBottomInset: false, + appBar: AppBar( + leading: const SocketPingRatingIcon(), + title: _ChallengeGameTitle( + challenge: (widget.source as UserChallengeSource).challengeRequest, + ), + ), + body: PopScope( + canPop: false, + child: OpenChallengeLoadingContent( + challenge, + ref.read(createGameServiceProvider).cancelChallenge, + ), + ), + ); case AsyncError(error: final e, stackTrace: final s): debugPrint('SEVERE: [GameScreen] could not create game; $e\n$s'); @@ -247,7 +264,7 @@ class _GameScreenState extends ConsumerState { seek, () => ref.read(createGameServiceProvider).cancelSeek(), ), - UserChallengeSource(:final challengeRequest) => ChallengeLoadingContent( + UserChallengeSource(:final challengeRequest) => UserChallengeLoadingContent( challengeRequest, () => ref.read(createGameServiceProvider).cancelChallenge(), ), diff --git a/lib/src/view/game/game_screen_providers.dart b/lib/src/view/game/game_screen_providers.dart index d4b1de57e..20d27cf74 100644 --- a/lib/src/view/game/game_screen_providers.dart +++ b/lib/src/view/game/game_screen_providers.dart @@ -33,6 +33,23 @@ sealed class GameCreatedState with _$GameCreatedState implements GameScreenState const factory GameCreatedState(GameFullId createdGameId) = _GameCreatedState; } +/// An open challenge has been created but not yet accepted. +/// We're waiting for someone to accept it via the challenge link. +@freezed +sealed class OpenChallengeCreatedState with _$OpenChallengeCreatedState implements GameScreenState { + const OpenChallengeCreatedState._(); + + const factory OpenChallengeCreatedState(Challenge challenge) = _OpenChallengeCreatedState; +} + +/// We challenged another user and are currently waiting for them to accept or decline. +@freezed +sealed class UserChallengeCreatedState with _$UserChallengeCreatedState implements GameScreenState { + const UserChallengeCreatedState._(); + + const factory UserChallengeCreatedState(Challenge challenge) = _UserChallengeCreatedState; +} + /// A real time challenge has been declined. @freezed sealed class ChallengeDeclinedState with _$ChallengeDeclinedState implements GameScreenState { @@ -106,31 +123,40 @@ class GameScreenLoaderNotifier extends AsyncNotifier { final GameScreenSource source; @override - Future build() { + Future build() async { final service = ref.watch(createGameServiceProvider); - return switch (source) { - LobbySource(:final seek) => - service + switch (source) { + case LobbySource(:final seek): + return service .newLobbyGame(seek) .then( (data) => switch (data) { GameSeekCreated(:final fullId) => GameCreatedState(fullId), GameSeekCancelled() => const SeekCancelledState(), }, - ), - UserChallengeSource(:final challengeRequest) => - service - .newRealTimeChallenge(challengeRequest) - .then( - (data) => switch (data) { - ChallengeResponseAccepted(:final gameFullId) => GameCreatedState(gameFullId), - ChallengeResponseDeclined() => ChallengeDeclinedState(data), - ChallengeResponseCancelled() => const ChallengeCancelledState(), - }, - ), - ExistingGameSource(:final id) => Future.value(GameCreatedState(id)), - }; + ); + case UserChallengeSource(:final challengeRequest): + { + final challenge = await service.newRealTimeChallenge(challengeRequest); + service + .waitForChallengeResponse(challenge) + .then( + (data) => state = AsyncValue.data(switch (data) { + ChallengeResponseAccepted(:final gameFullId) => GameCreatedState(gameFullId), + ChallengeResponseDeclined() => ChallengeDeclinedState(data), + ChallengeResponseCancelled() => const ChallengeCancelledState(), + }), + ); + return Future.value( + challenge.destUser != null + ? UserChallengeCreatedState(challenge) + : OpenChallengeCreatedState(challenge), + ); + } + case ExistingGameSource(:final id): + return Future.value(GameCreatedState(id)); + } } /// Search for a new opponent (lobby only). diff --git a/lib/src/view/play/create_challenge_bottom_sheet.dart b/lib/src/view/play/create_challenge_bottom_sheet.dart index 01dcf37d2..1ddd2a3b1 100644 --- a/lib/src/view/play/create_challenge_bottom_sheet.dart +++ b/lib/src/view/play/create_challenge_bottom_sheet.dart @@ -27,9 +27,9 @@ import 'package:lichess_mobile/src/widgets/user.dart'; import 'package:lichess_mobile/src/widgets/variant_app_bar_title.dart'; class CreateChallengeBottomSheet extends ConsumerStatefulWidget { - const CreateChallengeBottomSheet(this.user, {this.positionFen}); + const CreateChallengeBottomSheet({this.user, this.positionFen}); - final LightUser user; + final LightUser? user; final String? positionFen; @override @@ -315,6 +315,7 @@ class _CreateChallengeBottomSheetState extends ConsumerState route is! ModalBottomSheetRoute); + showModalBottomSheet( + context: context, + isScrollControlled: true, + useRootNavigator: true, + builder: (context) { + return const CreateChallengeBottomSheet(user: null); + }, + ); + }, + leading: const Icon(Icons.person), + title: Text(context.l10n.challengeAFriend), + ), ListTile( enabled: isOnline, onTap: () { @@ -50,10 +67,6 @@ class PlayMenu extends ConsumerWidget { leading: const Icon(LichessIcons.tournament_cup), title: Text(context.l10n.arenaArenaTournaments), ), - ], - ), - _Section( - children: [ ListTile( onTap: () { // Pops the play bottom sheet diff --git a/lib/src/view/user/online_bots_screen.dart b/lib/src/view/user/online_bots_screen.dart index 06cd47c77..4441f96b8 100644 --- a/lib/src/view/user/online_bots_screen.dart +++ b/lib/src/view/user/online_bots_screen.dart @@ -161,7 +161,7 @@ void _challengeBot(User bot, {required BuildContext context, required WidgetRef isScrollControlled: true, useRootNavigator: true, builder: (context) { - return CreateChallengeBottomSheet(bot.lightUser); + return CreateChallengeBottomSheet(user: bot.lightUser); }, ); } diff --git a/lib/src/view/user/user_screen.dart b/lib/src/view/user/user_screen.dart index 4b5658d31..efed3a01d 100644 --- a/lib/src/view/user/user_screen.dart +++ b/lib/src/view/user/user_screen.dart @@ -67,7 +67,7 @@ class UserScreen extends ConsumerStatefulWidget { isScrollControlled: true, useRootNavigator: true, builder: (context) { - return CreateChallengeBottomSheet(user.lightUser); + return CreateChallengeBottomSheet(user: user.lightUser); }, ); } diff --git a/lib/src/widgets/board.dart b/lib/src/widgets/board.dart index 6f62fc942..f8f792c6d 100644 --- a/lib/src/widgets/board.dart +++ b/lib/src/widgets/board.dart @@ -44,34 +44,23 @@ class BoardWidget extends StatelessWidget { explosionSquares: explosionSquares, ); - if (boardOverlay != null) { - return SizedBox.square( - dimension: size, - child: Stack( - children: [ - board, - SizedBox.square( - dimension: size, - child: Center( - child: SizedBox( - width: (size / 8) * 6.6, - height: (size / 8) * 4.6, - child: boardOverlay, - ), - ), + final overlay = boardOverlay ?? (error != null ? _ErrorWidget(errorMessage: error!) : null); + + if (overlay != null) { + return Stack( + clipBehavior: Clip.none, + children: [ + board, + Positioned( + left: 16.0, + right: 16.0, + top: 0, + bottom: 0, + child: Center( + child: OverflowBox(maxHeight: double.infinity, child: overlay), ), - ], - ), - ); - } else if (error != null) { - return SizedBox.square( - dimension: size, - child: Stack( - children: [ - board, - _ErrorWidget(errorMessage: error!, boardSize: size), - ], - ), + ), + ], ); } @@ -80,26 +69,18 @@ class BoardWidget extends StatelessWidget { } class _ErrorWidget extends StatelessWidget { - const _ErrorWidget({required this.errorMessage, required this.boardSize}); - final double boardSize; + const _ErrorWidget({required this.errorMessage}); final String errorMessage; @override Widget build(BuildContext context) { - return SizedBox.square( - dimension: boardSize, - child: Center( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: const BorderRadius.all(Radius.circular(10.0)), - ), - child: Padding(padding: const EdgeInsets.all(10.0), child: Text(errorMessage)), - ), - ), + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: const BorderRadius.all(Radius.circular(10.0)), ), + padding: const EdgeInsets.all(10.0), + child: Text(errorMessage), ); } } diff --git a/pubspec.lock b/pubspec.lock index 5d849600c..dec28098a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1223,6 +1223,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + qr_flutter: + dependency: "direct main" + description: + name: qr_flutter + sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097" + url: "https://pub.dev" + source: hosted + version: "4.1.0" quick_actions: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index fea35890e..30a59f60d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -66,6 +66,7 @@ dependencies: path_provider: ^2.1.5 popover: ^0.4.0 pub_semver: ^2.1.4 + qr_flutter: ^4.1.0 quick_actions: ^1.1.0 receive_sharing_intent: git: diff --git a/test/app_links_service_test.dart b/test/app_links_service_test.dart index b72c41037..43a54407c 100644 --- a/test/app_links_service_test.dart +++ b/test/app_links_service_test.dart @@ -275,7 +275,8 @@ void main() { expect(find.text('Thibault challenges you: ♚ Black • Rated • 5+0'), findsOneWidget); expect(find.text('Accept'), findsOneWidget); - expect(find.text('Decline'), findsOneWidget); + // challenges from link cannot be declined + expect(find.text('Cancel'), findsOneWidget); }); }); } diff --git a/test/model/challenge/challenge_repository_test.dart b/test/model/challenge/challenge_repository_test.dart index 3bb71b786..ec71bf2a3 100644 --- a/test/model/challenge/challenge_repository_test.dart +++ b/test/model/challenge/challenge_repository_test.dart @@ -3,6 +3,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:http/testing.dart'; import 'package:lichess_mobile/src/model/challenge/challenge.dart'; import 'package:lichess_mobile/src/model/challenge/challenge_repository.dart'; +import 'package:lichess_mobile/src/model/common/chess.dart'; +import 'package:lichess_mobile/src/model/common/game.dart'; import 'package:lichess_mobile/src/model/common/id.dart'; import '../../test_container.dart'; @@ -42,6 +44,38 @@ void main() { expect(result, isA()); }); + + test('create open challenge', () async { + var acceptCalled = false; + final mockClient = MockClient((request) { + if (request.url.path == '/api/challenge/open') { + return mockResponse(challenge, 200); + } + + // When creating an open challenge, request should be immediately accepted with the chosen color. + if (request.url.path == '/api/challenge/H9fIRZUk/accept') { + expect(request.url.queryParameters['color'], equals('black')); + acceptCalled = true; + return mockResponse('', 200); + } + return mockResponse('', 404); + }); + + final container = await lichessClientContainer(mockClient); + final repo = container.read(challengeRepositoryProvider); + final result = await repo.create( + const ChallengeRequest( + destUser: null, + variant: Variant.standard, + timeControl: ChallengeTimeControlType.clock, + rated: false, + sideChoice: SideChoice.black, + ), + ); + + expect(result, isA()); + expect(acceptCalled, isTrue); + }); }); } diff --git a/test/model/challenge/challenge_service_test.dart b/test/model/challenge/challenge_service_test.dart index 88e033b08..fa0e40209 100644 --- a/test/model/challenge/challenge_service_test.dart +++ b/test/model/challenge/challenge_service_test.dart @@ -31,14 +31,17 @@ class NotificationDisplayMock extends Mock implements FlutterLocalNotificationsP class MockChallengeRepository extends Mock implements ChallengeRepository {} class _ShowConfirmDialogWidget extends ConsumerWidget { - const _ShowConfirmDialogWidget({required this.challenge}); + const _ShowConfirmDialogWidget({required this.challenge, this.fromLink = false}); final Challenge challenge; + final bool fromLink; @override Widget build(BuildContext context, WidgetRef ref) { return ElevatedButton( - onPressed: () => ref.read(challengeServiceProvider).showConfirmDialog(context, challenge), + onPressed: () => ref + .read(challengeServiceProvider) + .showConfirmDialog(context, challenge, fromLink: fromLink), child: const Text('Open Dialog'), ); } @@ -372,6 +375,75 @@ void main() { expect(find.text('Accept'), findsOneWidget); expect(find.text('Decline'), findsOneWidget); }, variant: kPlatformVariant); + + testWidgets( + 'fromLink: shows Cancel instead of Decline on Android', + (tester) async { + const challenge = Challenge( + id: ChallengeId('H9fIRZUk'), + status: ChallengeStatus.created, + challenger: ( + user: LightUser(id: UserId('bot1'), name: 'Bot1', isOnline: true), + rating: 1500, + provisionalRating: null, + lagRating: null, + ), + variant: Variant.standard, + rated: true, + speed: Speed.rapid, + timeControl: ChallengeTimeControlType.clock, + clock: (time: Duration(seconds: 600), increment: Duration.zero), + sideChoice: SideChoice.random, + ); + + final app = await makeTestProviderScopeApp( + tester, + home: const _ShowConfirmDialogWidget(challenge: challenge, fromLink: true), + ); + await tester.pumpWidget(app); + await tester.tap(find.text('Open Dialog')); + await tester.pumpAndSettle(); + + expect(find.text('Cancel'), findsOneWidget); + expect(find.text('Decline'), findsNothing); + }, + variant: const TargetPlatformVariant({TargetPlatform.android}), + ); + + testWidgets( + 'fromLink: shows no Decline or Cancel action on iOS', + (tester) async { + const challenge = Challenge( + id: ChallengeId('H9fIRZUk'), + status: ChallengeStatus.created, + challenger: ( + user: LightUser(id: UserId('bot1'), name: 'Bot1', isOnline: true), + rating: 1500, + provisionalRating: null, + lagRating: null, + ), + variant: Variant.standard, + rated: true, + speed: Speed.rapid, + timeControl: ChallengeTimeControlType.clock, + clock: (time: Duration(seconds: 600), increment: Duration.zero), + sideChoice: SideChoice.random, + ); + + final app = await makeTestProviderScopeApp( + tester, + home: const _ShowConfirmDialogWidget(challenge: challenge, fromLink: true), + ); + await tester.pumpWidget(app); + await tester.tap(find.text('Open Dialog')); + await tester.pumpAndSettle(); + + expect(find.text('Decline'), findsNothing); + // The built-in CupertinoActionSheet cancel button is present + expect(find.text('Cancel'), findsOneWidget); + }, + variant: const TargetPlatformVariant({TargetPlatform.iOS}), + ); }); group('showDeclineDialog', () { diff --git a/test/view/game/game_screen_test.dart b/test/view/game/game_screen_test.dart index 358449c30..e534e1871 100644 --- a/test/view/game/game_screen_test.dart +++ b/test/view/game/game_screen_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:chessground/chessground.dart'; @@ -8,15 +9,20 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/misc.dart' show Override, ProviderOrFamily; import 'package:flutter_test/flutter_test.dart'; import 'package:http/testing.dart'; -import 'package:lichess_mobile/src/model/account/account_preferences.dart'; +import 'package:lichess_mobile/src/constants.dart'; +import 'package:lichess_mobile/src/model/account/account_preferences.dart' hide Challenge; +import 'package:lichess_mobile/src/model/challenge/challenge.dart'; import 'package:lichess_mobile/src/model/common/chess.dart'; +import 'package:lichess_mobile/src/model/common/game.dart'; import 'package:lichess_mobile/src/model/common/id.dart'; import 'package:lichess_mobile/src/model/common/service/sound_service.dart'; import 'package:lichess_mobile/src/model/common/socket.dart'; +import 'package:lichess_mobile/src/model/common/speed.dart'; import 'package:lichess_mobile/src/model/game/game.dart'; import 'package:lichess_mobile/src/model/game/game_controller.dart'; import 'package:lichess_mobile/src/model/game/game_socket_events.dart'; import 'package:lichess_mobile/src/model/game/game_status.dart'; +import 'package:lichess_mobile/src/model/lobby/create_game_service.dart'; import 'package:lichess_mobile/src/model/lobby/game_seek.dart'; import 'package:lichess_mobile/src/model/settings/board_preferences.dart'; import 'package:lichess_mobile/src/model/settings/preferences_storage.dart'; @@ -30,6 +36,7 @@ import 'package:lichess_mobile/src/widgets/bottom_bar.dart'; import 'package:lichess_mobile/src/widgets/clock.dart'; import 'package:lichess_mobile/src/widgets/pockets.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:qr_flutter/qr_flutter.dart'; import 'package:wakelock_plus_platform_interface/messages.g.dart'; import '../../model/game/game_socket_example_data.dart'; @@ -50,6 +57,8 @@ final client = MockClient((request) { class MockSoundService extends Mock implements SoundService {} +class MockCreateGameService extends Mock implements CreateGameService {} + void main() { const testGameFullId = GameFullId('qVChCOTcHSeW'); final testGameSocketUri = GameController.socketUri(testGameFullId); @@ -182,6 +191,53 @@ void main() { reason: 'board position should not change', ); }); + + testWidgets('displays game link for open challenge', (WidgetTester tester) async { + const challengeRequest = ChallengeRequest( + destUser: null, + variant: Variant.standard, + timeControl: ChallengeTimeControlType.clock, + rated: true, + sideChoice: SideChoice.white, + ); + final challenge = Challenge( + sideChoice: challengeRequest.sideChoice, + id: const ChallengeId('challengeId'), + variant: challengeRequest.variant, + timeControl: challengeRequest.timeControl, + rated: challengeRequest.rated, + speed: Speed.blitz, + status: ChallengeStatus.created, + ); + + final createGameService = MockCreateGameService(); + when( + () => createGameService.newRealTimeChallenge(challengeRequest), + ).thenAnswer((_) async => challenge); + when( + () => createGameService.waitForChallengeResponse(challenge), + ).thenAnswer((_) => Completer().future); + + final app = await makeTestProviderScopeApp( + tester, + home: const GameScreen(source: UserChallengeSource(challengeRequest)), + overrides: { + createGameServiceProvider: createGameServiceProvider.overrideWith( + (_) => createGameService, + ), + }, + ); + await tester.pumpWidget(app); + + await tester.pumpAndSettle(); + + expect(find.byType(Chessboard), findsOneWidget); + expect(find.byType(PieceWidget), findsNothing); + expect(find.text('To invite someone to play, give this URL'), findsOneWidget); + expect(find.text('Or let your opponent scan this QR code'), findsOneWidget); + expect(find.byType(QrImageView), findsOneWidget); + expect(find.textContaining('https://$kLichessHost/${challenge.id.value}'), findsOneWidget); + }); }); group('Plays sound for', () { diff --git a/test/view/play/create_challenge_bottom_sheet_test.dart b/test/view/play/create_challenge_bottom_sheet_test.dart index e33a81360..964adc953 100644 --- a/test/view/play/create_challenge_bottom_sheet_test.dart +++ b/test/view/play/create_challenge_bottom_sheet_test.dart @@ -193,7 +193,7 @@ class _TestBottomSheetOpener extends StatelessWidget { context: context, isScrollControlled: true, useRootNavigator: true, - builder: (_) => CreateChallengeBottomSheet(user), + builder: (_) => CreateChallengeBottomSheet(user: user), ), child: const Text('Open'), ),