From 67b7145d4d585eaadb194fdebd96b77f412a1915 Mon Sep 17 00:00:00 2001 From: Vincent Velociter Date: Fri, 14 Feb 2025 15:24:08 +0100 Subject: [PATCH 1/5] WIP on playban --- lib/src/model/user/user.dart | 12 ++++++++++++ lib/src/utils/json.dart | 21 +++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/lib/src/model/user/user.dart b/lib/src/model/user/user.dart index a4da047aaf..70786d0f76 100644 --- a/lib/src/model/user/user.dart +++ b/lib/src/model/user/user.dart @@ -56,6 +56,11 @@ extension LightUserExtension on Pick { } } +@freezed +class TemporaryBan with _$TemporaryBan { + const factory TemporaryBan({required DateTime date, required Duration duration}) = _TemporaryBan; +} + @freezed class User with _$User { const User._(); @@ -81,6 +86,7 @@ class User with _$User { bool? following, bool? blocking, bool? canChallenge, + TemporaryBan? playban, }) = _User; LightUser get lightUser => @@ -116,6 +122,12 @@ class User with _$User { following: pick('following').asBoolOrNull(), blocking: pick('blocking').asBoolOrNull(), canChallenge: pick('canChallenge').asBoolOrNull(), + playban: pick('playban').letOrNull((p) { + return TemporaryBan( + date: p('date').asDateTimeFromMillisecondsOrThrow(), + duration: p('mins').asDurationFromMinutesOrThrow(), + ); + }), ); } } diff --git a/lib/src/utils/json.dart b/lib/src/utils/json.dart index cea4664d8c..696a6075b2 100644 --- a/lib/src/utils/json.dart +++ b/lib/src/utils/json.dart @@ -89,6 +89,27 @@ extension TimeExtension on Pick { } } + /// Matches a Duration from minutes. + Duration asDurationFromMinutesOrThrow() { + final value = required().value; + if (value is Duration) { + return value; + } + if (value is int) { + return Duration(minutes: value); + } + throw PickException("value $value at $debugParsingExit can't be casted to Duration"); + } + + Duration? asDurationFromMinutesOrNull() { + if (value == null) return null; + try { + return asDurationFromMinutesOrThrow(); + } catch (_) { + return null; + } + } + /// Matches a Duration from seconds Duration asDurationFromSecondsOrThrow() { final value = required().value; From 28a4e485e3dc2f2afcff8ce718685595c6ff3db5 Mon Sep 17 00:00:00 2001 From: Vincent Velociter Date: Tue, 18 Feb 2025 16:47:38 +0100 Subject: [PATCH 2/5] More work on playban message --- lib/src/app.dart | 2 + lib/src/model/account/account_repository.dart | 25 +---- lib/src/model/account/account_service.dart | 86 +++++++++++++++ lib/src/model/game/game_controller.dart | 1 + .../notifications/notification_service.dart | 3 + .../model/notifications/notifications.dart | 50 +++++++++ lib/src/model/user/user.dart | 2 + .../view/account/game_bookmarks_screen.dart | 2 +- lib/src/view/game/archived_game_screen.dart | 2 +- lib/src/view/play/create_game_options.dart | 16 +-- lib/src/view/play/play_screen.dart | 21 ++-- lib/src/view/play/playban.dart | 100 ++++++++++++++++++ lib/src/view/play/quick_game_button.dart | 6 +- lib/src/view/play/quick_game_matrix.dart | 11 +- lib/src/view/user/game_history_screen.dart | 2 +- 15 files changed, 288 insertions(+), 41 deletions(-) create mode 100644 lib/src/model/account/account_service.dart create mode 100644 lib/src/view/play/playban.dart diff --git a/lib/src/app.dart b/lib/src/app.dart index c72aa57b53..f445efac7e 100644 --- a/lib/src/app.dart +++ b/lib/src/app.dart @@ -7,6 +7,7 @@ import 'package:lichess_mobile/l10n/l10n.dart'; import 'package:lichess_mobile/src/app_links.dart'; import 'package:lichess_mobile/src/constants.dart'; import 'package:lichess_mobile/src/model/account/account_repository.dart'; +import 'package:lichess_mobile/src/model/account/account_service.dart'; import 'package:lichess_mobile/src/model/challenge/challenge_service.dart'; import 'package:lichess_mobile/src/model/common/preloaded_data.dart'; import 'package:lichess_mobile/src/model/correspondence/correspondence_service.dart'; @@ -78,6 +79,7 @@ class _AppState extends ConsumerState { // Start services ref.read(notificationServiceProvider).start(); ref.read(challengeServiceProvider).start(); + ref.read(accountServiceProvider).start(); // Listen for connectivity changes and perform actions accordingly. ref.listenManual(connectivityChangesProvider, (prev, current) async { diff --git a/lib/src/model/account/account_repository.dart b/lib/src/model/account/account_repository.dart index 118f6564f7..3537ac657a 100644 --- a/lib/src/model/account/account_repository.dart +++ b/lib/src/model/account/account_repository.dart @@ -54,26 +54,6 @@ Future> ongoingGames(Ref ref) async { ); } -@Riverpod(keepAlive: true) -AccountService accountService(Ref ref) { - return AccountService(ref); -} - -class AccountService { - const AccountService(this._ref); - - final Ref _ref; - - Future setGameBookmark(GameId id, {required bool bookmark}) async { - final session = _ref.read(authSessionProvider); - if (session == null) return; - - await _ref.withClient((client) => AccountRepository(client).bookmark(id, bookmark: bookmark)); - - _ref.invalidate(accountProvider); - } -} - class AccountRepository { AccountRepository(this.client); @@ -81,7 +61,10 @@ class AccountRepository { final Logger _log = Logger('AccountRepository'); Future getProfile() { - return client.readJson(Uri(path: '/api/account'), mapper: User.fromServerJson); + return client.readJson( + Uri(path: '/api/account', queryParameters: {'playban': '1'}), + mapper: User.fromServerJson, + ); } Future saveProfile(Map profile) async { diff --git a/lib/src/model/account/account_service.dart b/lib/src/model/account/account_service.dart new file mode 100644 index 0000000000..c07794610c --- /dev/null +++ b/lib/src/model/account/account_service.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart' show Navigator, Text, showAdaptiveDialog; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lichess_mobile/src/model/account/account_repository.dart'; +import 'package:lichess_mobile/src/model/auth/auth_session.dart'; +import 'package:lichess_mobile/src/model/common/id.dart'; +import 'package:lichess_mobile/src/model/notifications/notification_service.dart'; +import 'package:lichess_mobile/src/model/notifications/notifications.dart' show PlaybanNotification; +import 'package:lichess_mobile/src/model/user/user.dart' show TemporaryBan, User; +import 'package:lichess_mobile/src/navigation.dart' show currentNavigatorKeyProvider; +import 'package:lichess_mobile/src/network/http.dart'; +import 'package:lichess_mobile/src/view/play/playban.dart'; +import 'package:lichess_mobile/src/widgets/platform_alert_dialog.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'account_service.g.dart'; + +@Riverpod(keepAlive: true) +AccountService accountService(Ref ref) { + final service = AccountService(ref); + ref.onDispose(() { + service.dispose(); + }); + return service; +} + +class AccountService { + AccountService(this._ref); + + ProviderSubscription>? _subscription; + DateTime? _lastPlaybanNotificationDate; + final Ref _ref; + + void start() { + _subscription = _ref.listen(accountProvider, (_, account) { + final playban = account.valueOrNull?.playban; + + // TODO save date in prefs + + if (playban != null && _lastPlaybanNotificationDate != playban.date) { + _lastPlaybanNotificationDate = playban.date; + _ref.read(notificationServiceProvider).show(PlaybanNotification(playban)); + } else if (playban == null && _lastPlaybanNotificationDate != null) { + _ref + .read(notificationServiceProvider) + .cancel(_lastPlaybanNotificationDate!.toIso8601String().hashCode); + _lastPlaybanNotificationDate = null; + } + }); + } + + void dispose() { + _subscription?.close(); + } + + Future onPlaybanNotificationResponse(TemporaryBan playban) async { + final context = _ref.read(currentNavigatorKeyProvider).currentContext; + if (context == null || !context.mounted) return; + + return showAdaptiveDialog( + context: context, + barrierDismissible: true, + builder: (context) { + return PlatformAlertDialog( + content: PlaybanMessage(playban: playban, centerText: true), + actions: [ + PlatformDialogAction( + child: const Text('OK'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ); + }, + ); + } + + Future setGameBookmark(GameId id, {required bool bookmark}) async { + final session = _ref.read(authSessionProvider); + if (session == null) return; + + await _ref.withClient((client) => AccountRepository(client).bookmark(id, bookmark: bookmark)); + + _ref.invalidate(accountProvider); + } +} diff --git a/lib/src/model/game/game_controller.dart b/lib/src/model/game/game_controller.dart index 25930238a3..d4379324fc 100644 --- a/lib/src/model/game/game_controller.dart +++ b/lib/src/model/game/game_controller.dart @@ -11,6 +11,7 @@ import 'package:flutter/widgets.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:lichess_mobile/src/model/account/account_preferences.dart'; import 'package:lichess_mobile/src/model/account/account_repository.dart'; +import 'package:lichess_mobile/src/model/account/account_service.dart'; import 'package:lichess_mobile/src/model/analysis/analysis_controller.dart'; import 'package:lichess_mobile/src/model/clock/chess_clock.dart'; import 'package:lichess_mobile/src/model/common/chess.dart'; diff --git a/lib/src/model/notifications/notification_service.dart b/lib/src/model/notifications/notification_service.dart index 01f7119b51..3c5dda96f6 100644 --- a/lib/src/model/notifications/notification_service.dart +++ b/lib/src/model/notifications/notification_service.dart @@ -8,6 +8,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lichess_mobile/l10n/l10n.dart'; import 'package:lichess_mobile/src/binding.dart'; import 'package:lichess_mobile/src/localizations.dart'; +import 'package:lichess_mobile/src/model/account/account_service.dart'; import 'package:lichess_mobile/src/model/auth/auth_session.dart'; import 'package:lichess_mobile/src/model/challenge/challenge_service.dart'; import 'package:lichess_mobile/src/model/common/preloaded_data.dart'; @@ -183,6 +184,8 @@ class NotificationService { _ref.read(correspondenceServiceProvider).onNotificationResponse(gameFullId); case ChallengeNotification(challenge: final challenge): _ref.read(challengeServiceProvider).onNotificationResponse(response.actionId, challenge); + case PlaybanNotification(playban: final playban): + _ref.read(accountServiceProvider).onPlaybanNotificationResponse(playban); } } diff --git a/lib/src/model/notifications/notifications.dart b/lib/src/model/notifications/notifications.dart index 771887faa8..9819f00507 100644 --- a/lib/src/model/notifications/notifications.dart +++ b/lib/src/model/notifications/notifications.dart @@ -1,11 +1,14 @@ import 'dart:convert'; +import 'package:deep_pick/deep_pick.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:lichess_mobile/l10n/l10n.dart'; import 'package:lichess_mobile/src/model/challenge/challenge.dart'; import 'package:lichess_mobile/src/model/common/id.dart'; import 'package:lichess_mobile/src/model/game/playable_game.dart'; +import 'package:lichess_mobile/src/model/user/user.dart' show TemporaryBan; +import 'package:lichess_mobile/src/utils/json.dart'; import 'package:meta/meta.dart'; /// FCM Messages @@ -150,12 +153,59 @@ sealed class LocalNotification { return CorresGameUpdateNotification.fromJson(json); case 'challenge': return ChallengeNotification.fromJson(json); + case 'playban': + return PlaybanNotification.fromJson(json); default: throw ArgumentError('Unknown notification channel: $channel'); } } } +class PlaybanNotification extends LocalNotification { + const PlaybanNotification(this.playban); + + final TemporaryBan playban; + + factory PlaybanNotification.fromJson(Map json) { + final p = pick(json).required(); + final playban = TemporaryBan( + date: p('date').asDateTimeFromMillisecondsOrThrow(), + duration: p('minutes').asDurationFromMinutesOrThrow(), + ); + return PlaybanNotification(playban); + } + + @override + String get channelId => 'playban'; + + @override + int get id => playban.date.toIso8601String().hashCode; + + @override + Map get _concretePayload => { + 'minutes': playban.duration.inMinutes, + 'date': playban.date.millisecondsSinceEpoch, + }; + + @override + String title(AppLocalizations l10n) => l10n.sorry; + + @override + String body(AppLocalizations l10n) => l10n.weHadToTimeYouOutForAWhile; + + @override + NotificationDetails details(AppLocalizations l10n) => NotificationDetails( + android: AndroidNotificationDetails( + channelId, + l10n.weHadToTimeYouOutForAWhile, + importance: Importance.max, + priority: Priority.max, + autoCancel: false, + ), + iOS: DarwinNotificationDetails(threadIdentifier: channelId), + ); +} + /// A notification for a correspondence game update. /// /// This notification is shown when a correspondence game is updated on the server diff --git a/lib/src/model/user/user.dart b/lib/src/model/user/user.dart index 70786d0f76..c62260032c 100644 --- a/lib/src/model/user/user.dart +++ b/lib/src/model/user/user.dart @@ -86,6 +86,7 @@ class User with _$User { bool? following, bool? blocking, bool? canChallenge, + bool? kid, TemporaryBan? playban, }) = _User; @@ -122,6 +123,7 @@ class User with _$User { following: pick('following').asBoolOrNull(), blocking: pick('blocking').asBoolOrNull(), canChallenge: pick('canChallenge').asBoolOrNull(), + kid: pick('kid').asBoolOrNull(), playban: pick('playban').letOrNull((p) { return TemporaryBan( date: p('date').asDateTimeFromMillisecondsOrThrow(), diff --git a/lib/src/view/account/game_bookmarks_screen.dart b/lib/src/view/account/game_bookmarks_screen.dart index be2cb3499b..0410013c1d 100644 --- a/lib/src/view/account/game_bookmarks_screen.dart +++ b/lib/src/view/account/game_bookmarks_screen.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; -import 'package:lichess_mobile/src/model/account/account_repository.dart'; +import 'package:lichess_mobile/src/model/account/account_service.dart'; import 'package:lichess_mobile/src/model/analysis/analysis_controller.dart'; import 'package:lichess_mobile/src/model/game/game_bookmarks.dart'; import 'package:lichess_mobile/src/styles/styles.dart'; diff --git a/lib/src/view/game/archived_game_screen.dart b/lib/src/view/game/archived_game_screen.dart index 0107ff0f7c..7a95f583a2 100644 --- a/lib/src/view/game/archived_game_screen.dart +++ b/lib/src/view/game/archived_game_screen.dart @@ -3,7 +3,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; -import 'package:lichess_mobile/src/model/account/account_repository.dart'; +import 'package:lichess_mobile/src/model/account/account_service.dart'; import 'package:lichess_mobile/src/model/analysis/analysis_controller.dart'; import 'package:lichess_mobile/src/model/auth/auth_session.dart'; import 'package:lichess_mobile/src/model/common/id.dart'; diff --git a/lib/src/view/play/create_game_options.dart b/lib/src/view/play/create_game_options.dart index daba1fe4bd..97f9db7984 100644 --- a/lib/src/view/play/create_game_options.dart +++ b/lib/src/view/play/create_game_options.dart @@ -18,6 +18,7 @@ class CreateGameOptions extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final isOnline = ref.watch(connectivityChangesProvider).valueOrNull?.isOnline ?? false; + final isPlayban = ref.watch(accountProvider).valueOrNull?.playban != null; return Column( children: [ @@ -25,7 +26,7 @@ class CreateGameOptions extends ConsumerWidget { children: [ _CreateGamePlatformButton( onTap: - isOnline + isOnline && !isPlayban ? () { ref.invalidate(accountProvider); Navigator.of(context).push(CreateCustomGameScreen.buildRoute(context)); @@ -93,11 +94,14 @@ class _CreateGamePlatformButton extends StatelessWidget { @override Widget build(BuildContext context) { return Theme.of(context).platform == TargetPlatform.iOS - ? PlatformListTile( - leading: Icon(icon, size: 28), - trailing: const CupertinoListTileChevron(), - title: Text(label, style: Styles.mainListTileTitle), - onTap: onTap, + ? Opacity( + opacity: onTap == null ? 0.5 : 1.0, + child: PlatformListTile( + leading: Icon(icon, size: 28), + trailing: const CupertinoListTileChevron(), + title: Text(label, style: Styles.mainListTileTitle), + onTap: onTap, + ), ) : FilledButton.tonalIcon(onPressed: onTap, icon: Icon(icon), label: Text(label)); } diff --git a/lib/src/view/play/play_screen.dart b/lib/src/view/play/play_screen.dart index a03860b1da..2650aae238 100644 --- a/lib/src/view/play/play_screen.dart +++ b/lib/src/view/play/play_screen.dart @@ -1,13 +1,16 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lichess_mobile/src/model/account/account_repository.dart'; import 'package:lichess_mobile/src/styles/styles.dart'; import 'package:lichess_mobile/src/utils/l10n_context.dart'; import 'package:lichess_mobile/src/utils/navigation.dart'; import 'package:lichess_mobile/src/view/play/create_game_options.dart'; +import 'package:lichess_mobile/src/view/play/playban.dart'; import 'package:lichess_mobile/src/view/play/quick_game_button.dart'; import 'package:lichess_mobile/src/widgets/platform_scaffold.dart'; -class PlayScreen extends StatelessWidget { +class PlayScreen extends ConsumerWidget { const PlayScreen(); static Route buildRoute(BuildContext context) { @@ -15,15 +18,19 @@ class PlayScreen extends StatelessWidget { } @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final playban = ref.watch(accountProvider).valueOrNull?.playban; + return PlatformScaffold( appBarTitle: Text(context.l10n.play), - body: const SafeArea( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, + body: Center( + child: ListView( + shrinkWrap: true, children: [ - Padding(padding: Styles.horizontalBodyPadding, child: QuickGameButton()), - CreateGameOptions(), + if (playban != null) + Padding(padding: Styles.bodySectionPadding, child: PlaybanMessage(playban: playban)), + const Padding(padding: Styles.horizontalBodyPadding, child: QuickGameButton()), + const CreateGameOptions(), ], ), ), diff --git a/lib/src/view/play/playban.dart b/lib/src/view/play/playban.dart new file mode 100644 index 0000000000..49b80a4d0b --- /dev/null +++ b/lib/src/view/play/playban.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; +import 'package:lichess_mobile/src/model/user/user.dart'; +import 'package:lichess_mobile/src/utils/l10n_context.dart'; +import 'package:lichess_mobile/src/widgets/clock.dart' show CountdownClockBuilder; + +class PlaybanMessage extends StatelessWidget { + const PlaybanMessage({required this.playban, this.centerText = false, super.key}); + + final TemporaryBan playban; + final bool centerText; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Align( + alignment: centerText ? Alignment.center : Alignment.topLeft, + child: Text( + context.l10n.sorry, + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 22.0), + ), + ), + const SizedBox(height: 6.0), + Align( + alignment: centerText ? Alignment.center : Alignment.topLeft, + child: Text(context.l10n.weHadToTimeYouOutForAWhile), + ), + const SizedBox(height: 16.0), + CountdownClockBuilder( + timeLeft: playban.duration, + clockUpdatedAt: playban.date, + active: true, + tickInterval: const Duration(seconds: 1), + builder: + (BuildContext context, Duration timeleft) => Center( + child: Text( + context.l10n.timeagoNbMinutesRemaining(timeleft.inMinutes), + style: const TextStyle(fontWeight: FontWeight.w600), + textAlign: TextAlign.center, + ), + ), + ), + const SizedBox(height: 16.0), + Align( + alignment: centerText ? Alignment.center : Alignment.topLeft, + child: Text(context.l10n.why, style: const TextStyle(fontSize: 20.0)), + ), + const SizedBox(height: 6.0), + Text(context.l10n.pleasantChessExperience), + Text(context.l10n.goodPractice), + Text(context.l10n.potentialProblem), + const SizedBox(height: 6.0), + Align( + alignment: centerText ? Alignment.center : Alignment.topLeft, + child: Text(context.l10n.howToAvoidThis, style: const TextStyle(fontSize: 20.0)), + ), + Text.rich( + TextSpan( + children: [ + const TextSpan(text: '\u2022', style: TextStyle(height: 1.25, fontSize: 20.0)), + const TextSpan(text: ' '), + TextSpan(text: context.l10n.playEveryGame, style: const TextStyle(height: 1.25)), + ], + ), + ), + Text.rich( + TextSpan( + children: [ + const TextSpan(text: '\u2022', style: TextStyle(height: 1.25, fontSize: 20.0)), + const TextSpan(text: ' '), + TextSpan(text: context.l10n.tryToWin, style: const TextStyle(height: 1.25)), + ], + ), + ), + Text.rich( + TextSpan( + children: [ + const TextSpan(text: '\u2022', style: TextStyle(height: 1.25, fontSize: 20.0)), + const TextSpan(text: ' '), + TextSpan(text: context.l10n.resignLostGames, style: const TextStyle(height: 1.25)), + ], + ), + ), + const SizedBox(height: 10.0), + Text.rich( + TextSpan( + text: context.l10n.temporaryInconvenience, + children: [ + const TextSpan(text: ' '), + TextSpan(text: context.l10n.wishYouGreatGames), + const TextSpan(text: ' '), + TextSpan(text: context.l10n.thankYouForReading), + ], + ), + ), + ], + ); + } +} diff --git a/lib/src/view/play/quick_game_button.dart b/lib/src/view/play/quick_game_button.dart index 9ec2260353..0db427220f 100644 --- a/lib/src/view/play/quick_game_button.dart +++ b/lib/src/view/play/quick_game_button.dart @@ -2,6 +2,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lichess_mobile/src/constants.dart'; +import 'package:lichess_mobile/src/model/account/account_repository.dart'; import 'package:lichess_mobile/src/model/auth/auth_session.dart'; import 'package:lichess_mobile/src/model/lobby/game_seek.dart'; import 'package:lichess_mobile/src/model/lobby/game_setup_preferences.dart'; @@ -20,6 +21,7 @@ class QuickGameButton extends ConsumerWidget { final playPrefs = ref.watch(gameSetupPreferencesProvider); final session = ref.watch(authSessionProvider); final isOnline = ref.watch(connectivityChangesProvider).valueOrNull?.isOnline ?? false; + final isPlayban = ref.watch(accountProvider).valueOrNull?.playban != null; return Row( children: [ @@ -71,7 +73,7 @@ class QuickGameButton extends ConsumerWidget { ? CupertinoButton.tinted( padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 16.0), onPressed: - isOnline + isOnline && !isPlayban ? () { Navigator.of(context, rootNavigator: true).push( GameScreen.buildRoute( @@ -88,7 +90,7 @@ class QuickGameButton extends ConsumerWidget { ) : FilledButton( onPressed: - isOnline + isOnline && !isPlayban ? () { Navigator.of(context, rootNavigator: true).push( GameScreen.buildRoute( diff --git a/lib/src/view/play/quick_game_matrix.dart b/lib/src/view/play/quick_game_matrix.dart index ac065ef099..24f1323d28 100644 --- a/lib/src/view/play/quick_game_matrix.dart +++ b/lib/src/view/play/quick_game_matrix.dart @@ -1,6 +1,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lichess_mobile/src/model/account/account_repository.dart'; import 'package:lichess_mobile/src/model/auth/auth_session.dart'; import 'package:lichess_mobile/src/model/common/speed.dart'; import 'package:lichess_mobile/src/model/common/time_increment.dart'; @@ -10,19 +11,25 @@ import 'package:lichess_mobile/src/styles/styles.dart'; import 'package:lichess_mobile/src/utils/l10n_context.dart'; import 'package:lichess_mobile/src/view/game/game_screen.dart'; import 'package:lichess_mobile/src/view/play/create_custom_game_screen.dart'; +import 'package:lichess_mobile/src/view/play/playban.dart'; import 'package:lichess_mobile/src/widgets/buttons.dart'; const _kMatrixSpacing = 8.0; -class QuickGameMatrix extends StatelessWidget { +class QuickGameMatrix extends ConsumerWidget { const QuickGameMatrix(); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final playban = ref.watch(accountProvider).valueOrNull?.playban; final brightness = Theme.of(context).brightness; final logoColor = brightness == Brightness.light ? const Color(0x0F000000) : const Color(0x80FFFFFF); + if (playban != null) { + return PlaybanMessage(playban: playban); + } + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/src/view/user/game_history_screen.dart b/lib/src/view/user/game_history_screen.dart index e5c6b1a3c4..091e5eeff6 100644 --- a/lib/src/view/user/game_history_screen.dart +++ b/lib/src/view/user/game_history_screen.dart @@ -2,7 +2,7 @@ import 'package:dartchess/dartchess.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; -import 'package:lichess_mobile/src/model/account/account_repository.dart'; +import 'package:lichess_mobile/src/model/account/account_service.dart'; import 'package:lichess_mobile/src/model/analysis/analysis_controller.dart'; import 'package:lichess_mobile/src/model/auth/auth_session.dart'; import 'package:lichess_mobile/src/model/common/perf.dart'; From 1f74089bdc76db09f07fc022daa599d65b3587ae Mon Sep 17 00:00:00 2001 From: Vincent Velociter Date: Tue, 18 Feb 2025 18:48:55 +0100 Subject: [PATCH 3/5] Store playban date in local storage --- lib/src/model/account/account_service.dart | 28 +++++++++++++++------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/lib/src/model/account/account_service.dart b/lib/src/model/account/account_service.dart index c07794610c..f2c418cbae 100644 --- a/lib/src/model/account/account_service.dart +++ b/lib/src/model/account/account_service.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart' show Navigator, Text, showAdaptiveDialog; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lichess_mobile/src/binding.dart' show LichessBinding; import 'package:lichess_mobile/src/model/account/account_repository.dart'; import 'package:lichess_mobile/src/model/auth/auth_session.dart'; import 'package:lichess_mobile/src/model/common/id.dart'; @@ -27,27 +28,38 @@ class AccountService { AccountService(this._ref); ProviderSubscription>? _subscription; - DateTime? _lastPlaybanNotificationDate; final Ref _ref; + static const _storageKey = 'account.playban_notification_date'; + void start() { + final prefs = LichessBinding.instance.sharedPreferences; + _subscription = _ref.listen(accountProvider, (_, account) { final playban = account.valueOrNull?.playban; + final storedDate = prefs.getString(_storageKey); + final lastPlaybanNotificationDate = storedDate != null ? DateTime.parse(storedDate) : null; - // TODO save date in prefs - - if (playban != null && _lastPlaybanNotificationDate != playban.date) { - _lastPlaybanNotificationDate = playban.date; + if (playban != null && lastPlaybanNotificationDate != playban.date) { + _savePlaybanNotificationDate(playban.date); _ref.read(notificationServiceProvider).show(PlaybanNotification(playban)); - } else if (playban == null && _lastPlaybanNotificationDate != null) { + } else if (playban == null && lastPlaybanNotificationDate != null) { _ref .read(notificationServiceProvider) - .cancel(_lastPlaybanNotificationDate!.toIso8601String().hashCode); - _lastPlaybanNotificationDate = null; + .cancel(lastPlaybanNotificationDate.toIso8601String().hashCode); + _clearPlaybanNotificationDate(); } }); } + void _savePlaybanNotificationDate(DateTime date) { + LichessBinding.instance.sharedPreferences.setString(_storageKey, date.toIso8601String()); + } + + void _clearPlaybanNotificationDate() { + LichessBinding.instance.sharedPreferences.remove(_storageKey); + } + void dispose() { _subscription?.close(); } From d985965572b595f9b8dc1b4955e6e05708b01185 Mon Sep 17 00:00:00 2001 From: Vincent Velociter Date: Tue, 18 Feb 2025 18:49:08 +0100 Subject: [PATCH 4/5] Fix some playban issues --- lib/src/model/notifications/notifications.dart | 4 ++-- lib/src/view/play/playban.dart | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/src/model/notifications/notifications.dart b/lib/src/model/notifications/notifications.dart index 9819f00507..ce4d18edca 100644 --- a/lib/src/model/notifications/notifications.dart +++ b/lib/src/model/notifications/notifications.dart @@ -161,6 +161,7 @@ sealed class LocalNotification { } } +/// A notification show to the user when they are banned temporarily from playing. class PlaybanNotification extends LocalNotification { const PlaybanNotification(this.playban); @@ -197,10 +198,9 @@ class PlaybanNotification extends LocalNotification { NotificationDetails details(AppLocalizations l10n) => NotificationDetails( android: AndroidNotificationDetails( channelId, - l10n.weHadToTimeYouOutForAWhile, + 'playban', importance: Importance.max, priority: Priority.max, - autoCancel: false, ), iOS: DarwinNotificationDetails(threadIdentifier: channelId), ); diff --git a/lib/src/view/play/playban.dart b/lib/src/view/play/playban.dart index 49b80a4d0b..f4a36af1d4 100644 --- a/lib/src/view/play/playban.dart +++ b/lib/src/view/play/playban.dart @@ -12,6 +12,7 @@ class PlaybanMessage extends StatelessWidget { @override Widget build(BuildContext context) { return Column( + mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Align( From 9c0024a58c9b32b39653094c6f742e6a3cf2ef21 Mon Sep 17 00:00:00 2001 From: Vincent Velociter Date: Wed, 19 Feb 2025 12:01:24 +0100 Subject: [PATCH 5/5] Refactor services --- lib/src/app.dart | 1 + lib/src/model/account/account_service.dart | 27 +++- .../model/challenge/challenge_service.dart | 17 +- .../correspondence_service.dart | 43 ++++- .../notifications/notification_service.dart | 88 ++++++----- .../model/notifications/notifications.dart | 6 + .../correspondence_service_test.dart | 148 ++++++++++++++++++ .../notification_service_test.dart | 134 +--------------- 8 files changed, 279 insertions(+), 185 deletions(-) create mode 100644 test/model/correspondence/correspondence_service_test.dart diff --git a/lib/src/app.dart b/lib/src/app.dart index f445efac7e..ccf99b328d 100644 --- a/lib/src/app.dart +++ b/lib/src/app.dart @@ -80,6 +80,7 @@ class _AppState extends ConsumerState { ref.read(notificationServiceProvider).start(); ref.read(challengeServiceProvider).start(); ref.read(accountServiceProvider).start(); + ref.read(correspondenceServiceProvider).start(); // Listen for connectivity changes and perform actions accordingly. ref.listenManual(connectivityChangesProvider, (prev, current) async { diff --git a/lib/src/model/account/account_service.dart b/lib/src/model/account/account_service.dart index f2c418cbae..1cd51d546c 100644 --- a/lib/src/model/account/account_service.dart +++ b/lib/src/model/account/account_service.dart @@ -1,11 +1,15 @@ +import 'dart:async'; + import 'package:flutter/material.dart' show Navigator, Text, showAdaptiveDialog; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lichess_mobile/src/binding.dart' show LichessBinding; import 'package:lichess_mobile/src/model/account/account_repository.dart'; import 'package:lichess_mobile/src/model/auth/auth_session.dart'; import 'package:lichess_mobile/src/model/common/id.dart'; import 'package:lichess_mobile/src/model/notifications/notification_service.dart'; -import 'package:lichess_mobile/src/model/notifications/notifications.dart' show PlaybanNotification; +import 'package:lichess_mobile/src/model/notifications/notifications.dart' + show LocalNotification, PlaybanNotification; import 'package:lichess_mobile/src/model/user/user.dart' show TemporaryBan, User; import 'package:lichess_mobile/src/navigation.dart' show currentNavigatorKeyProvider; import 'package:lichess_mobile/src/network/http.dart'; @@ -27,7 +31,9 @@ AccountService accountService(Ref ref) { class AccountService { AccountService(this._ref); - ProviderSubscription>? _subscription; + ProviderSubscription>? _accountProviderSubscription; + StreamSubscription<(NotificationResponse, LocalNotification)>? _notificationResponseSubscription; + final Ref _ref; static const _storageKey = 'account.playban_notification_date'; @@ -35,7 +41,7 @@ class AccountService { void start() { final prefs = LichessBinding.instance.sharedPreferences; - _subscription = _ref.listen(accountProvider, (_, account) { + _accountProviderSubscription = _ref.listen(accountProvider, (_, account) { final playban = account.valueOrNull?.playban; final storedDate = prefs.getString(_storageKey); final lastPlaybanNotificationDate = storedDate != null ? DateTime.parse(storedDate) : null; @@ -50,6 +56,16 @@ class AccountService { _clearPlaybanNotificationDate(); } }); + + _notificationResponseSubscription = NotificationService.responseStream.listen((data) { + final (_, notification) = data; + switch (notification) { + case PlaybanNotification(:final playban): + _onPlaybanNotificationResponse(playban); + case _: + break; + } + }); } void _savePlaybanNotificationDate(DateTime date) { @@ -61,10 +77,11 @@ class AccountService { } void dispose() { - _subscription?.close(); + _accountProviderSubscription?.close(); + _notificationResponseSubscription?.cancel(); } - Future onPlaybanNotificationResponse(TemporaryBan playban) async { + Future _onPlaybanNotificationResponse(TemporaryBan playban) async { final context = _ref.read(currentNavigatorKeyProvider).currentContext; if (context == null || !context.mounted) return; diff --git a/lib/src/model/challenge/challenge_service.dart b/lib/src/model/challenge/challenge_service.dart index 9566b76657..a94d02f1ba 100644 --- a/lib/src/model/challenge/challenge_service.dart +++ b/lib/src/model/challenge/challenge_service.dart @@ -4,6 +4,7 @@ import 'package:collection/collection.dart'; import 'package:deep_pick/deep_pick.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter/widgets.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lichess_mobile/src/model/challenge/challenge.dart'; import 'package:lichess_mobile/src/model/challenge/challenge_repository.dart'; @@ -38,6 +39,7 @@ class ChallengeService { ChallengesList? _previous; StreamSubscription? _socketSubscription; + StreamSubscription<(NotificationResponse, LocalNotification)>? _notificationResponseSubscription; /// The stream of challenge events that are received from the server. static Stream get stream => @@ -49,9 +51,19 @@ class ChallengeService { return (inward: inward.lock, outward: outward.lock); }).whereNotNull(); - /// Start listening to challenge events from the server. + /// Start listening to events. void start() { _socketSubscription = stream.listen(_onSocketEvent); + + _notificationResponseSubscription = NotificationService.responseStream.listen((data) { + final (response, notification) = data; + switch (notification) { + case ChallengeNotification(:final challenge): + _onNotificationResponse(response.actionId, challenge); + case _: + break; + } + }); } void _onSocketEvent(ChallengesList current) { @@ -90,10 +102,11 @@ class ChallengeService { /// Stop listening to challenge events from the server. void dispose() { _socketSubscription?.cancel(); + _notificationResponseSubscription?.cancel(); } /// Handle a local notification response when the app is in the foreground. - Future onNotificationResponse(String? actionid, Challenge challenge) async { + Future _onNotificationResponse(String? actionid, Challenge challenge) async { final challengeId = challenge.id; switch (actionid) { diff --git a/lib/src/model/correspondence/correspondence_service.dart b/lib/src/model/correspondence/correspondence_service.dart index 4fe2b70cf5..9bfef46da5 100644 --- a/lib/src/model/correspondence/correspondence_service.dart +++ b/lib/src/model/correspondence/correspondence_service.dart @@ -16,6 +16,8 @@ import 'package:lichess_mobile/src/model/correspondence/offline_correspondence_g import 'package:lichess_mobile/src/model/game/game_repository.dart'; import 'package:lichess_mobile/src/model/game/game_socket_events.dart'; import 'package:lichess_mobile/src/model/game/playable_game.dart'; +import 'package:lichess_mobile/src/model/notifications/notification_service.dart'; +import 'package:lichess_mobile/src/model/notifications/notifications.dart'; import 'package:lichess_mobile/src/navigation.dart'; import 'package:lichess_mobile/src/network/http.dart'; import 'package:lichess_mobile/src/network/socket.dart'; @@ -27,7 +29,9 @@ part 'correspondence_service.g.dart'; @Riverpod(keepAlive: true) CorrespondenceService correspondenceService(Ref ref) { - return CorrespondenceService(Logger('CorrespondenceService'), ref: ref); + final service = CorrespondenceService(Logger('CorrespondenceService'), ref: ref); + ref.onDispose(() => service.dispose()); + return service; } /// Services that manages correspondence games. @@ -37,8 +41,41 @@ class CorrespondenceService { final Ref ref; final Logger _log; + StreamSubscription? _notificationResponseSubscription; + StreamSubscription? _fcmSubscription; + + void start() { + _fcmSubscription = NotificationService.fcmMessageStream.listen((data) { + final (message: fcmMessage, fromBackground: fromBackground) = data; + switch (fcmMessage) { + case CorresGameUpdateFcmMessage(fullId: final fullId, game: final game): + if (game != null) { + _onServerUpdateEvent(fullId, game, fromBackground: fromBackground); + } + + case _: + break; + } + }); + + _notificationResponseSubscription = NotificationService.responseStream.listen((data) { + final (_, notification) = data; + switch (notification) { + case CorresGameUpdateNotification(:final fullId): + _onNotificationResponse(fullId); + case _: + break; + } + }); + } + + void dispose() { + _fcmSubscription?.cancel(); + _notificationResponseSubscription?.cancel(); + } + /// Handles a notification response that caused the app to open. - Future onNotificationResponse(GameFullId fullId) async { + Future _onNotificationResponse(GameFullId fullId) async { final context = ref.read(currentNavigatorKeyProvider).currentContext; if (context == null || !context.mounted) return; @@ -185,7 +222,7 @@ class CorrespondenceService { } /// Handles a game update event from the server. - Future onServerUpdateEvent( + Future _onServerUpdateEvent( GameFullId fullId, PlayableGame game, { required bool fromBackground, diff --git a/lib/src/model/notifications/notification_service.dart b/lib/src/model/notifications/notification_service.dart index 3c5dda96f6..4a4de3ed8e 100644 --- a/lib/src/model/notifications/notification_service.dart +++ b/lib/src/model/notifications/notification_service.dart @@ -8,11 +8,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lichess_mobile/l10n/l10n.dart'; import 'package:lichess_mobile/src/binding.dart'; import 'package:lichess_mobile/src/localizations.dart'; -import 'package:lichess_mobile/src/model/account/account_service.dart'; import 'package:lichess_mobile/src/model/auth/auth_session.dart'; -import 'package:lichess_mobile/src/model/challenge/challenge_service.dart'; import 'package:lichess_mobile/src/model/common/preloaded_data.dart'; -import 'package:lichess_mobile/src/model/correspondence/correspondence_service.dart'; import 'package:lichess_mobile/src/model/notifications/notifications.dart'; import 'package:lichess_mobile/src/network/connectivity.dart'; import 'package:lichess_mobile/src/network/http.dart'; @@ -38,13 +35,20 @@ NotificationService notificationService(Ref ref) { return service; } +/// Received FCM message and whether it was from the background. +typedef ReceivedFcmMessage = ({FcmMessage message, bool fromBackground}); + +/// A [NotificationResponse] and the associated [LocalNotification]. +typedef ParsedLocalNotification = (NotificationResponse response, LocalNotification notification); + /// A service that manages notifications. /// /// This service is responsible for handling incoming messages from the Firebase /// Cloud Messaging service and showing notifications. /// -/// It also listens for notification interaction responses and dispatches them to the -/// appropriate services. +/// It broadcasts the parsed incoming FCM messages to the [fcmMessageStream]. +/// +/// It also listens for notification interaction responses and dispatches them to the [responseStream]. class NotificationService { NotificationService(this._ref); @@ -57,9 +61,23 @@ class NotificationService { ProviderSubscription>? _connectivitySubscription; /// The stream controller for notification responses. - static final StreamController _responseStreamController = + static final StreamController _responseStreamController = + StreamController.broadcast(); + + /// The stream of notification responses. + /// + /// A notification response is dispatched when a notification has been interacted with. + static Stream get responseStream => _responseStreamController.stream; + + /// The stream controller for FCM messages. + static final StreamController _fcmMessageStreamController = StreamController.broadcast(); + /// The stream of FCM messages. + /// + /// A FCM message is dispatched when a message is received from the Firebase Cloud Messaging service. + static Stream get fcmMessageStream => _fcmMessageStreamController.stream; + /// The stream subscription for notification responses. StreamSubscription? _responseStreamSubscription; @@ -132,11 +150,6 @@ class NotificationService { // Handle any other interaction that caused the app to open when in background. LichessBinding.instance.firebaseMessagingOnMessageOpenedApp.listen(_handleFcmMessageOpenedApp); - - // start listening for notification responses - _responseStreamSubscription = _responseStreamController.stream.listen( - _dispatchNotificationResponse, - ); } /// Shows a notification. @@ -170,30 +183,21 @@ class NotificationService { _responseStreamSubscription?.cancel(); } - /// Dispatch a notification response to the appropriate service according to the notification type. - void _dispatchNotificationResponse(NotificationResponse response) { + /// Function called by the notification plugin when a notification has been tapped on. + static void onDidReceiveNotificationResponse(NotificationResponse response) { + _logger.fine('received local notification ${response.id} response in foreground.'); + final rawPayload = response.payload; - if (rawPayload == null) return; + if (rawPayload == null) { + _logger.warning('Received a notification response with no payload.'); + return; + } final json = jsonDecode(rawPayload) as Map; final notification = LocalNotification.fromJson(json); - switch (notification) { - case CorresGameUpdateNotification(fullId: final gameFullId): - _ref.read(correspondenceServiceProvider).onNotificationResponse(gameFullId); - case ChallengeNotification(challenge: final challenge): - _ref.read(challengeServiceProvider).onNotificationResponse(response.actionId, challenge); - case PlaybanNotification(playban: final playban): - _ref.read(accountServiceProvider).onPlaybanNotificationResponse(playban); - } - } - - /// Function called by the notification plugin when a notification has been tapped on. - static void onDidReceiveNotificationResponse(NotificationResponse response) { - _logger.fine('received local notification ${response.id} response in foreground.'); - - _responseStreamController.add(response); + _responseStreamController.add((response, notification)); } /// Handle an FCM message that caused the application to open @@ -201,8 +205,16 @@ class NotificationService { final parsedMessage = FcmMessage.fromRemoteMessage(message); switch (parsedMessage) { - case CorresGameUpdateFcmMessage(fullId: final fullId): - _ref.read(correspondenceServiceProvider).onNotificationResponse(fullId); + case final CorresGameUpdateFcmMessage corresMessage: + final notification = CorresGameUpdateNotification.fromFcmMessage(corresMessage); + _responseStreamController.add(( + NotificationResponse( + notificationResponseType: NotificationResponseType.selectedNotification, + id: notification.id, + payload: jsonEncode(notification.payload), + ), + notification, + )); // TODO: handle other notification types case UnhandledFcmMessage(data: final data): @@ -238,18 +250,10 @@ class NotificationService { final parsedMessage = FcmMessage.fromRemoteMessage(message); - switch (parsedMessage) { - case CorresGameUpdateFcmMessage( - fullId: final fullId, - game: final game, - notification: final notification, - ): - if (game != null) { - await _ref - .read(correspondenceServiceProvider) - .onServerUpdateEvent(fullId, game, fromBackground: fromBackground); - } + _fcmMessageStreamController.add((message: parsedMessage, fromBackground: fromBackground)); + switch (parsedMessage) { + case CorresGameUpdateFcmMessage(fullId: final fullId, notification: final notification): if (fromBackground == false && notification != null) { await show(CorresGameUpdateNotification(fullId, notification.title!, notification.body!)); } diff --git a/lib/src/model/notifications/notifications.dart b/lib/src/model/notifications/notifications.dart index ce4d18edca..24f7925fd0 100644 --- a/lib/src/model/notifications/notifications.dart +++ b/lib/src/model/notifications/notifications.dart @@ -230,6 +230,12 @@ class CorresGameUpdateNotification extends LocalNotification { return CorresGameUpdateNotification(gameId, title, body); } + factory CorresGameUpdateNotification.fromFcmMessage(CorresGameUpdateFcmMessage message) { + final title = message.notification?.title ?? ''; + final body = message.notification?.body ?? ''; + return CorresGameUpdateNotification(message.fullId, title, body); + } + @override String get channelId => 'corresGameUpdate'; diff --git a/test/model/correspondence/correspondence_service_test.dart b/test/model/correspondence/correspondence_service_test.dart new file mode 100644 index 0000000000..df6c0a917b --- /dev/null +++ b/test/model/correspondence/correspondence_service_test.dart @@ -0,0 +1,148 @@ +import 'package:fake_async/fake_async.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lichess_mobile/src/model/common/id.dart'; +import 'package:lichess_mobile/src/model/correspondence/correspondence_game_storage.dart'; +import 'package:lichess_mobile/src/model/correspondence/correspondence_service.dart'; +import 'package:lichess_mobile/src/model/correspondence/offline_correspondence_game.dart'; +import 'package:lichess_mobile/src/model/notifications/notification_service.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../binding.dart'; +import '../../test_container.dart'; +import '../auth/fake_session_storage.dart'; + +class NotificationDisplayMock extends Mock implements FlutterLocalNotificationsPlugin {} + +class CorrespondenceGameStorageMock extends Mock implements CorrespondenceGameStorage {} + +class OfflineCorrespondenceGameFake extends Fake implements OfflineCorrespondenceGame {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final notificationDisplayMock = NotificationDisplayMock(); + final correspondenceGameStorageMock = CorrespondenceGameStorageMock(); + + setUpAll(() { + registerFallbackValue(OfflineCorrespondenceGameFake()); + }); + + tearDown(() { + reset(notificationDisplayMock); + reset(correspondenceGameStorageMock); + }); + + const fullId = GameFullId('Fn9UvVKFsopx'); + + test('FCM game data message will update the game', () async { + when( + () => + notificationDisplayMock.show(any(), any(), any(), any(), payload: any(named: 'payload')), + ).thenAnswer((_) => Future.value()); + + when(() => correspondenceGameStorageMock.save(any())).thenAnswer((_) => Future.value()); + + final container = await makeContainer( + userSession: fakeSession, + overrides: [ + correspondenceGameStorageProvider.overrideWith((_) => correspondenceGameStorageMock), + notificationDisplayProvider.overrideWithValue(notificationDisplayMock), + ], + ); + + final notificationService = container.read(notificationServiceProvider); + final correspondenceService = container.read(correspondenceServiceProvider); + + fakeAsync((async) { + notificationService.start(); + correspondenceService.start(); + async.flushMicrotasks(); + + testBinding.firebaseMessaging.onMessage.add( + const RemoteMessage( + data: { + 'lichess.type': 'gameMove', + 'lichess.fullId': 'Fn9UvVKFsopx', + 'lichess.round': + '{"game":{"id":"Fn9UvVKF","variant":{"key":"standard","name":"Standard","short":"Std"},"speed":"bullet","perf":"bullet","rated":true,"fen":"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1","turns":0,"source":"lobby","status":{"id":20,"name":"started"},"createdAt":1706204482969,"pgn":""},"white":{"user":{"name":"chabrot","id":"chabrot"},"rating":1801},"black":{"user":{"name":"veloce","id":"veloce"},"rating":1798},"socket":0,"expiration":{"idleMillis":67,"millisToMove":20000},"clock":{"running":false,"initial":120,"increment":1,"white":120,"black":120,"emerg":15,"moretime":15},"takebackable":true,"youAre":"black","prefs":{"autoQueen":2,"zen":2,"confirmResign":true,"enablePremove":true},"chat":{"lines":[]}}', + }, + notification: RemoteNotification( + title: 'It is your turn!', + body: 'Dr-Alaakour played a move', + ), + ), + ); + + async.flushMicrotasks(); + + verify( + () => notificationDisplayMock.show( + any(), + any(), + any(), + any(), + payload: any(named: 'payload'), + ), + ).called(1); + + verify( + () => correspondenceGameStorageMock.save( + any(that: isA().having((g) => g.fullId, 'fullId', fullId)), + ), + ).called(1); + }); + }); + + test('FCM game data message without notification', () async { + when(() => correspondenceGameStorageMock.save(any())).thenAnswer((_) => Future.value()); + + final container = await makeContainer( + userSession: fakeSession, + overrides: [ + correspondenceGameStorageProvider.overrideWith((_) => correspondenceGameStorageMock), + notificationDisplayProvider.overrideWith((_) => notificationDisplayMock), + ], + ); + + final notificationService = container.read(notificationServiceProvider); + final correspondenceService = container.read(correspondenceServiceProvider); + + FakeAsync().run((async) { + notificationService.start(); + correspondenceService.start(); + + async.flushMicrotasks(); + + testBinding.firebaseMessaging.onMessage.add( + const RemoteMessage( + data: { + 'lichess.type': 'gameMove', + 'lichess.fullId': 'Fn9UvVKFsopx', + 'lichess.round': + '{"game":{"id":"Fn9UvVKF","variant":{"key":"standard","name":"Standard","short":"Std"},"speed":"bullet","perf":"bullet","rated":true,"fen":"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1","turns":0,"source":"lobby","status":{"id":20,"name":"started"},"createdAt":1706204482969,"pgn":""},"white":{"user":{"name":"chabrot","id":"chabrot"},"rating":1801},"black":{"user":{"name":"veloce","id":"veloce"},"rating":1798},"socket":0,"expiration":{"idleMillis":67,"millisToMove":20000},"clock":{"running":false,"initial":120,"increment":1,"white":120,"black":120,"emerg":15,"moretime":15},"takebackable":true,"youAre":"black","prefs":{"autoQueen":2,"zen":2,"confirmResign":true,"enablePremove":true},"chat":{"lines":[]}}', + }, + ), + ); + + async.flushMicrotasks(); + + verify( + () => correspondenceGameStorageMock.save( + any(that: isA().having((g) => g.fullId, 'fullId', fullId)), + ), + ).called(1); + + verifyNever( + () => notificationDisplayMock.show( + any(), + any(), + any(), + any(), + payload: any(named: 'payload'), + ), + ); + }); + }); +} diff --git a/test/model/notifications/notification_service_test.dart b/test/model/notifications/notification_service_test.dart index 776d059fbe..85bcae9d68 100644 --- a/test/model/notifications/notification_service_test.dart +++ b/test/model/notifications/notification_service_test.dart @@ -136,7 +136,7 @@ void main() { }); }); - group('Correspondence game update notifications', () { + group('FCM Correspondence notifications', () { test('FCM message with associated notification will show it in foreground', () async { final container = await makeContainer( userSession: fakeSession, @@ -202,137 +202,5 @@ void main() { ); }); }); - - test('FCM game data message will update the game', () async { - final container = await makeContainer( - userSession: fakeSession, - overrides: [ - lichessClientProvider.overrideWith((ref) => LichessClient(registerMockClient, ref)), - notificationDisplayProvider.overrideWith((_) => notificationDisplayMock), - correspondenceServiceProvider.overrideWith((_) => correspondenceServiceMock), - ], - ); - - final notificationService = container.read(notificationServiceProvider); - - const fullId = GameFullId('Fn9UvVKFsopx'); - - when( - () => correspondenceServiceMock.onServerUpdateEvent( - fullId, - any(that: isA()), - fromBackground: false, - ), - ).thenAnswer((_) => Future.value()); - - when( - () => notificationDisplayMock.show( - any(), - any(), - any(), - any(), - payload: any(named: 'payload'), - ), - ).thenAnswer((_) => Future.value()); - - FakeAsync().run((async) { - notificationService.start(); - - async.flushMicrotasks(); - - testBinding.firebaseMessaging.onMessage.add( - const RemoteMessage( - data: { - 'lichess.type': 'gameMove', - 'lichess.fullId': 'Fn9UvVKFsopx', - 'lichess.round': - '{"game":{"id":"Fn9UvVKF","variant":{"key":"standard","name":"Standard","short":"Std"},"speed":"bullet","perf":"bullet","rated":true,"fen":"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1","turns":0,"source":"lobby","status":{"id":20,"name":"started"},"createdAt":1706204482969,"pgn":""},"white":{"user":{"name":"chabrot","id":"chabrot"},"rating":1801},"black":{"user":{"name":"veloce","id":"veloce"},"rating":1798},"socket":0,"expiration":{"idleMillis":67,"millisToMove":20000},"clock":{"running":false,"initial":120,"increment":1,"white":120,"black":120,"emerg":15,"moretime":15},"takebackable":true,"youAre":"black","prefs":{"autoQueen":2,"zen":2,"confirmResign":true,"enablePremove":true},"chat":{"lines":[]}}', - }, - notification: RemoteNotification( - title: 'It is your turn!', - body: 'Dr-Alaakour played a move', - ), - ), - ); - - async.flushMicrotasks(); - - verify( - () => correspondenceServiceMock.onServerUpdateEvent( - fullId, - any(that: isA()), - fromBackground: false, - ), - ).called(1); - - verify( - () => notificationDisplayMock.show( - any(), - any(), - any(), - any(), - payload: any(named: 'payload'), - ), - ).called(1); - }); - }); - - test('FCM game data message without notification', () async { - final container = await makeContainer( - userSession: fakeSession, - overrides: [ - lichessClientProvider.overrideWith((ref) => LichessClient(registerMockClient, ref)), - notificationDisplayProvider.overrideWith((_) => notificationDisplayMock), - correspondenceServiceProvider.overrideWith((_) => correspondenceServiceMock), - ], - ); - - final notificationService = container.read(notificationServiceProvider); - - when( - () => correspondenceServiceMock.onServerUpdateEvent( - any(that: isA()), - any(that: isA()), - fromBackground: false, - ), - ).thenAnswer((_) => Future.value()); - - FakeAsync().run((async) { - notificationService.start(); - - async.flushMicrotasks(); - - testBinding.firebaseMessaging.onMessage.add( - const RemoteMessage( - data: { - 'lichess.type': 'gameMove', - 'lichess.fullId': 'Fn9UvVKFsopx', - 'lichess.round': - '{"game":{"id":"Fn9UvVKF","variant":{"key":"standard","name":"Standard","short":"Std"},"speed":"bullet","perf":"bullet","rated":true,"fen":"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1","turns":0,"source":"lobby","status":{"id":20,"name":"started"},"createdAt":1706204482969,"pgn":""},"white":{"user":{"name":"chabrot","id":"chabrot"},"rating":1801},"black":{"user":{"name":"veloce","id":"veloce"},"rating":1798},"socket":0,"expiration":{"idleMillis":67,"millisToMove":20000},"clock":{"running":false,"initial":120,"increment":1,"white":120,"black":120,"emerg":15,"moretime":15},"takebackable":true,"youAre":"black","prefs":{"autoQueen":2,"zen":2,"confirmResign":true,"enablePremove":true},"chat":{"lines":[]}}', - }, - ), - ); - - async.flushMicrotasks(); - - verify( - () => correspondenceServiceMock.onServerUpdateEvent( - any(that: isA()), - any(that: isA()), - fromBackground: false, - ), - ).called(1); - - verifyNever( - () => notificationDisplayMock.show( - any(), - any(), - any(), - any(), - payload: any(named: 'payload'), - ), - ); - }); - }); }); }