mirror of
https://github.com/lichess-org/mobile.git
synced 2026-05-26 13:50:52 +00:00
feat: support creating challenge links (#2828)
Closes #2015 Closes #856
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user