Skip to content

Commit cfb5bd0

Browse files
committed
feat: support opening challenge and game deep links
- Challenge links now open the "challenge requests" screen and trigger the dialog to accept/decline the challenge - Open game links in TvScreen if the game is still ongoing - Open game links in AnalysisScreen if the game is finished Closes #2734
1 parent 3f3e88e commit cfb5bd0

File tree

11 files changed

+421
-187
lines changed

11 files changed

+421
-187
lines changed

android/app/src/main/AndroidManifest.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@
7777
<data android:pathPattern="/broadcast/.*/.*/........" />
7878
<data android:pathPattern="/broadcast/.*/.*/......../........" />
7979

80+
<!-- Game or challenge -->
81+
<data android:pathPattern="/........" />
82+
8083
<!-- Game with pov -->
8184
<data android:pathPattern="/......../black" />
8285
<data android:pathPattern="/......../white" />

apple-app-site-association

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
{ "/": "/tournament/????????", "comment": "Tournaments" },
1010
{ "/": "/broadcast/*/*/????????", "comment": "Broadcast rounds" },
1111
{ "/": "/broadcast/*/*/????????/????????", "comment": "Broadcast games" },
12+
{ "/": "/????????", "comment": "Games and challenges" },
1213
{ "/": "/????????/black", "comment": "Games (black perspective)" },
1314
{ "/": "/????????/white", "comment": "Games (white perspective)" }
1415
]

lib/src/app.dart

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ class _AppState extends ConsumerState<Application> {
117117
});
118118

119119
super.initState();
120-
_initAppLinks();
120+
_initAppLinks(ref);
121121
_initSharingIntent();
122122
}
123123

@@ -158,8 +158,8 @@ class _AppState extends ConsumerState<Application> {
158158
);
159159
}
160160

161-
Future<void> _initAppLinks() async {
162-
_linkSubscription = _appLinks.uriLinkStream.listen((uri) {
161+
Future<void> _initAppLinks(WidgetRef ref) async {
162+
_linkSubscription = _appLinks.uriLinkStream.listen((uri) async {
163163
// File links are handled by the sharing intent logic, so we can ignore them here.
164164
if (uri.scheme == 'file' || uri.scheme == 'content') {
165165
return;
@@ -170,7 +170,7 @@ class _AppState extends ConsumerState<Application> {
170170
}
171171
final context = _navigatorKey.currentContext;
172172
if (context != null && context.mounted) {
173-
handleAppLink(context, uri);
173+
await handleAppLink(context, uri, ref);
174174
}
175175
});
176176
}

lib/src/app_links.dart

Lines changed: 77 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import 'package:dartchess/dartchess.dart';
22
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
33
import 'package:flutter/widgets.dart';
4+
import 'package:flutter_riverpod/flutter_riverpod.dart';
45
import 'package:lichess_mobile/src/constants.dart';
56
import 'package:lichess_mobile/src/model/analysis/analysis_controller.dart';
7+
import 'package:lichess_mobile/src/model/challenge/challenge_repository.dart';
68
import 'package:lichess_mobile/src/model/common/id.dart';
9+
import 'package:lichess_mobile/src/model/game/game_repository.dart';
710
import 'package:lichess_mobile/src/model/puzzle/puzzle_angle.dart';
811
import 'package:lichess_mobile/src/model/user/user.dart';
912
import 'package:lichess_mobile/src/view/analysis/analysis_screen.dart';
@@ -12,15 +15,21 @@ import 'package:lichess_mobile/src/view/broadcast/broadcast_round_screen.dart';
1215
import 'package:lichess_mobile/src/view/puzzle/puzzle_screen.dart';
1316
import 'package:lichess_mobile/src/view/study/study_screen.dart';
1417
import 'package:lichess_mobile/src/view/tournament/tournament_screen.dart';
18+
import 'package:lichess_mobile/src/view/user/challenge_requests_screen.dart';
1519
import 'package:lichess_mobile/src/view/user/user_or_profile_screen.dart';
20+
import 'package:lichess_mobile/src/view/watch/tv_screen.dart';
1621
import 'package:linkify/linkify.dart';
1722
import 'package:logging/logging.dart';
1823
import 'package:url_launcher/url_launcher.dart';
1924

2025
final _logger = Logger('AppLinks');
2126

2227
/// Resolves an app link [Uri] to one or more corresponding [Route]s.
23-
List<Route<dynamic>>? resolveAppLinkUri(BuildContext context, Uri appLinkUri) {
28+
Future<List<Route<dynamic>>?> resolveAppLinkUri(
29+
BuildContext context,
30+
Uri appLinkUri,
31+
WidgetRef ref,
32+
) async {
2433
if (appLinkUri.pathSegments.isEmpty) return null;
2534
_logger.info('Resolving app link: $appLinkUri');
2635
switch (appLinkUri.pathSegments[0]) {
@@ -52,31 +61,79 @@ List<Route<dynamic>>? resolveAppLinkUri(BuildContext context, Uri appLinkUri) {
5261
PuzzleScreen.buildRoute(context, angle: PuzzleAngle.fromKey('mix'), puzzleId: PuzzleId(id)),
5362
];
5463
case _:
55-
final gameId = GameId(appLinkUri.pathSegments[0]);
56-
final orientation = appLinkUri.pathSegments.getOrNull(1);
57-
final int ply = int.tryParse(appLinkUri.fragment) ?? 0;
58-
// The game id can also be a challenge. Challenge by link is not supported yet so let's ignore it.
59-
if (gameId.isValid) {
60-
return [
61-
AnalysisScreen.buildRoute(
62-
context,
63-
AnalysisOptions.archivedGame(
64-
orientation: orientation == 'black' ? Side.black : Side.white,
65-
gameId: gameId,
66-
initialMoveCursor: ply,
67-
),
64+
final gameRoutes = await _tryResolveGameLink(context, ref, appLinkUri);
65+
if (gameRoutes != null) return gameRoutes;
66+
67+
if (!context.mounted) return null;
68+
69+
final challengeRoutes = await _tryResolveChallengeLink(context, ref, appLinkUri);
70+
if (challengeRoutes != null) return challengeRoutes;
71+
}
72+
73+
return null;
74+
}
75+
76+
Future<List<Route<dynamic>>?> _tryResolveChallengeLink(
77+
BuildContext context,
78+
WidgetRef ref,
79+
Uri appLinkUri,
80+
) async {
81+
try {
82+
final challengeId = ChallengeId(appLinkUri.pathSegments[0]);
83+
if (!challengeId.isValid) return null;
84+
final challenge = await ref.read(challengeRepositoryProvider).show(challengeId);
85+
if (!context.mounted) return null;
86+
return [ChallengeRequestsScreen.buildRoute(context, incomingChallenge: challenge)];
87+
} catch (e) {
88+
_logger.info('Not a challenge link: $e');
89+
}
90+
return null;
91+
}
92+
93+
Future<List<Route<dynamic>>?> _tryResolveGameLink(
94+
BuildContext context,
95+
WidgetRef ref,
96+
Uri appLinkUri,
97+
) async {
98+
try {
99+
final gameId = GameId(appLinkUri.pathSegments[0]);
100+
if (!gameId.isValid) return null;
101+
102+
final game = await ref.read(gameRepositoryProvider).getGame(gameId);
103+
final orientation = appLinkUri.pathSegments.getOrNull(1) == 'black' ? Side.black : Side.white;
104+
final int ply = int.tryParse(appLinkUri.fragment) ?? 0;
105+
106+
if (!context.mounted) return null;
107+
108+
if (game.finished) {
109+
return [
110+
AnalysisScreen.buildRoute(
111+
context,
112+
AnalysisOptions.archivedGame(
113+
orientation: orientation,
114+
gameId: gameId,
115+
initialMoveCursor: ply,
68116
),
69-
];
70-
}
117+
),
118+
];
119+
}
120+
121+
final user = game.playerOf(orientation).user;
122+
if (user != null) {
123+
return [TvScreen.buildRoute(context, gameId: gameId, user: user, orientation: orientation)];
124+
}
125+
} catch (e) {
126+
_logger.info('Not a game link: $e');
71127
}
72128

73129
return null;
74130
}
75131

76132
/// Handles an app link [Uri] by navigating to the corresponding screen(s).
77-
void handleAppLink(BuildContext context, Uri uri) {
78-
final routes = resolveAppLinkUri(context, uri);
133+
Future<void> handleAppLink(BuildContext context, Uri uri, WidgetRef ref) async {
134+
final routes = await resolveAppLinkUri(context, uri, ref);
79135
if (routes != null) {
136+
if (!context.mounted) return;
80137
for (final route in routes) {
81138
Navigator.of(context).push(route);
82139
}
@@ -88,11 +145,11 @@ void handleAppLink(BuildContext context, Uri uri) {
88145
const kLichessLinkifiers = [UrlLinkifier(), EmailLinkifier(), UserTagLinkifier()];
89146

90147
/// Handles link clicks in Linkify widgets throughout the app.
91-
void onLinkifyOpen(BuildContext context, LinkableElement link) {
148+
Future<void> onLinkifyOpen(BuildContext context, LinkableElement link, WidgetRef ref) async {
92149
if (link is UrlElement && link.url.startsWith(RegExp('https?:\\/\\/$kLichessHost'))) {
93150
// Handle Lichess links specifically
94151
final appLinkUri = Uri.parse(link.url);
95-
handleAppLink(context, appLinkUri);
152+
await handleAppLink(context, appLinkUri, ref);
96153
} else if (link.originText.startsWith('@')) {
97154
final username = link.originText.substring(1);
98155
Navigator.of(context).push(

lib/src/model/common/id.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ extension type const UserId(String value) implements StringId {
5151

5252
extension type const ChallengeId(String value) implements StringId {
5353
ChallengeId.fromJson(dynamic json) : this(json as String);
54+
55+
bool get isValid => RegExp(r'''[\w-]{8}''').hasMatch(value);
5456
}
5557

5658
extension type const BroadcastTournamentId(String value) implements StringId {}

lib/src/view/chat/chat_screen.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ class _MessageBubble extends ConsumerWidget {
210210
).push(UserOrProfileScreen.buildRoute(context, message.user!)),
211211
),
212212
Linkify(
213-
onOpen: (link) => onLinkifyOpen(context, link),
213+
onOpen: (link) async => await onLinkifyOpen(context, link, ref),
214214
linkifiers: kLichessLinkifiers,
215215
text: message.message,
216216
style: TextStyle(color: _textColor(context, brightness)),

lib/src/view/message/conversation_screen.dart

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,7 @@ class _DateBubble extends StatelessWidget {
289289
}
290290
}
291291

292-
class _MessageBubble extends StatelessWidget {
292+
class _MessageBubble extends ConsumerWidget {
293293
const _MessageBubble({
294294
required this.message,
295295
required this.isMe,
@@ -320,7 +320,7 @@ class _MessageBubble extends StatelessWidget {
320320
static const _inGroupRadius = Radius.circular(4);
321321

322322
@override
323-
Widget build(BuildContext context) {
323+
Widget build(BuildContext context, WidgetRef ref) {
324324
final time = DateFormat.Hm().format(message.date);
325325

326326
return ChatBubbleContextMenu(
@@ -365,7 +365,7 @@ class _MessageBubble extends StatelessWidget {
365365
crossAxisAlignment: CrossAxisAlignment.end,
366366
children: [
367367
Linkify(
368-
onOpen: (link) => onLinkifyOpen(context, link),
368+
onOpen: (link) async => await onLinkifyOpen(context, link, ref),
369369
linkifiers: kLichessLinkifiers,
370370
text: message.text,
371371
style: TextStyle(color: _textColor(context)),

0 commit comments

Comments
 (0)