feat: support creating challenge links (#2828)

Closes #2015
Closes #856
This commit is contained in:
Tom Praschan
2026-03-31 10:19:56 +02:00
committed by GitHub
parent 9ff5d29e38
commit 4e0e13c6f8
23 changed files with 642 additions and 211 deletions
+1 -1
View File
@@ -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) {
+2 -1
View File
@@ -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,
@@ -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,
);
@@ -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<Challenge> create(ChallengeRequest challenge) {
final uri = Uri(path: '/api/challenge/${challenge.destUser.id}');
return client.postReadJson(
Future<Challenge> 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<void> accept(ChallengeId id) async {
final uri = Uri(path: '/api/challenge/$id/accept');
Future<void> 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);
}
+19 -7
View File
@@ -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<void>(
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),
),
],
);
}
+13 -7
View File
@@ -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<ChallengeResponse> newRealTimeChallenge(ChallengeRequest challengeReq) async {
Future<Challenge> 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<ChallengeResponse> 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<ChallengeResponse>()..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(
@@ -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,
);
},
);
},
+274 -110
View File
@@ -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<LobbyScreenLoadingContent> {
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<LobbyScreenLoadingContent> {
}
}
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<void> Function() cancelChallenge;
@override
State<ChallengeLoadingContent> createState() => _ChallengeLoadingContentState();
State<UserChallengeLoadingContent> createState() => _UserChallengeLoadingContentState();
}
class _ChallengeLoadingContentState extends State<ChallengeLoadingContent> {
class _UserChallengeLoadingContentState extends State<UserChallengeLoadingContent> {
Future<void>? _cancelChallengeFuture;
@override
@@ -137,39 +140,34 @@ class _ChallengeLoadingContentState extends State<ChallengeLoadingContent> {
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<ChallengeLoadingContent> {
}
}
class OpenChallengeLoadingContent extends ConsumerStatefulWidget {
const OpenChallengeLoadingContent(this.challenge, this.cancelChallenge);
final Challenge challenge;
final Future<void> Function() cancelChallenge;
@override
ConsumerState<OpenChallengeLoadingContent> createState() => _OpenChallengeLoadingContentState();
}
class _OpenChallengeLoadingContentState extends ConsumerState<OpenChallengeLoadingContent> {
Future<void>? _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),
);
}
}
+18 -1
View File
@@ -219,6 +219,23 @@ class _GameScreenState extends ConsumerState<GameScreen> {
)
: 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<GameScreen> {
seek,
() => ref.read(createGameServiceProvider).cancelSeek(),
),
UserChallengeSource(:final challengeRequest) => ChallengeLoadingContent(
UserChallengeSource(:final challengeRequest) => UserChallengeLoadingContent(
challengeRequest,
() => ref.read(createGameServiceProvider).cancelChallenge(),
),
+43 -17
View File
@@ -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<GameScreenState> {
final GameScreenSource source;
@override
Future<GameScreenState> build() {
Future<GameScreenState> 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).
@@ -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<CreateChallengeBott
context,
source: UserChallengeSource(
preferences.makeRequest(
account,
widget.user,
preferences.variant != Variant.fromPosition
? null
@@ -333,6 +334,7 @@ class _CreateChallengeBottomSheetState extends ConsumerState<CreateChallengeBott
_pendingCorrespondenceChallenge = createGameService
.newCorrespondenceChallenge(
preferences.makeRequest(
account,
widget.user,
preferences.variant != Variant.fromPosition
? null
@@ -403,7 +405,12 @@ class _CreateChallengeBottomSheetState extends ConsumerState<CreateChallengeBott
}
: null,
},
child: Text(context.l10n.challengeChallengeToPlay, style: Styles.bold),
child: Text(
widget.user != null
? context.l10n.challengeChallengeToPlay
: context.l10n.challengeAFriend,
style: Styles.bold,
),
),
);
},
+3 -2
View File
@@ -194,7 +194,8 @@ class CreateGameWidget extends ConsumerWidget {
],
),
],
FilledButton(
FilledButton.icon(
icon: const Icon(Icons.groups),
onPressed: isOnline
? () {
// Pops the play bottom sheet
@@ -214,7 +215,7 @@ class CreateGameWidget extends ConsumerWidget {
);
}
: null,
child: Text(context.l10n.createAGame),
label: Text(context.l10n.createLobbyGame),
),
],
);
+17 -4
View File
@@ -7,6 +7,7 @@ import 'package:lichess_mobile/src/utils/l10n_context.dart';
import 'package:lichess_mobile/src/view/offline_computer/offline_computer_game_screen.dart';
import 'package:lichess_mobile/src/view/over_the_board/over_the_board_screen.dart';
import 'package:lichess_mobile/src/view/play/correspondence_challenges_screen.dart';
import 'package:lichess_mobile/src/view/play/create_challenge_bottom_sheet.dart';
import 'package:lichess_mobile/src/view/play/create_game_widget.dart';
import 'package:lichess_mobile/src/view/tournament/tournament_list_screen.dart';
import 'package:lichess_mobile/src/widgets/list.dart';
@@ -26,6 +27,22 @@ class PlayMenu extends ConsumerWidget {
),
_Section(
children: [
ListTile(
onTap: () {
// Pops the play bottom sheet
Navigator.of(context).popUntil((route) => route is! ModalBottomSheetRoute);
showModalBottomSheet<void>(
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
+1 -1
View File
@@ -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);
},
);
}
+1 -1
View File
@@ -67,7 +67,7 @@ class UserScreen extends ConsumerStatefulWidget {
isScrollControlled: true,
useRootNavigator: true,
builder: (context) {
return CreateChallengeBottomSheet(user.lightUser);
return CreateChallengeBottomSheet(user: user.lightUser);
},
);
}
+23 -42
View File
@@ -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),
);
}
}
+16
View File
@@ -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:
+1
View File
@@ -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:
+2 -1
View File
@@ -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);
});
});
}
@@ -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<Challenge>());
});
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<Challenge>());
expect(acceptCalled, isTrue);
});
});
}
@@ -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', () {
+57 -1
View File
@@ -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<ChallengeResponse>().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', () {
@@ -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'),
),