diff --git a/lib/src/app.dart b/lib/src/app.dart index 6818629f09..d2c1e8cd51 100644 --- a/lib/src/app.dart +++ b/lib/src/app.dart @@ -15,7 +15,6 @@ import 'package:lichess_mobile/src/model/settings/board_preferences.dart'; import 'package:lichess_mobile/src/model/settings/general_preferences.dart'; import 'package:lichess_mobile/src/navigation.dart'; import 'package:lichess_mobile/src/network/connectivity.dart'; -import 'package:lichess_mobile/src/network/http.dart'; import 'package:lichess_mobile/src/network/socket.dart'; import 'package:lichess_mobile/src/theme.dart'; import 'package:lichess_mobile/src/utils/navigation.dart'; @@ -62,30 +61,8 @@ class _AppState extends ConsumerState { /// Whether the app has checked for online status for the first time. bool _firstTimeOnlineCheck = false; - AppLifecycleListener? _appLifecycleListener; - - DateTime? _pausedAt; - @override void initState() { - _appLifecycleListener = AppLifecycleListener( - onPause: () { - _pausedAt = DateTime.now(); - }, - onRestart: () async { - // Invalidate ongoing games if the app was paused for more than 10 minutes. - // In theory we shouldn't need to do this, because correspondence games are updated by - // fcm messages, but in practice it's not always reliable. - // See also: [CorrespondenceService]. - final online = await isOnline(ref.read(defaultClientProvider)); - if (online && - _pausedAt != null && - DateTime.now().difference(_pausedAt!) >= const Duration(minutes: 10)) { - ref.invalidate(ongoingGamesProvider); - } - }, - ); - // Start services ref.read(notificationServiceProvider).start(); ref.read(challengeServiceProvider).start(); @@ -124,12 +101,6 @@ class _AppState extends ConsumerState { super.initState(); } - @override - void dispose() { - _appLifecycleListener?.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { final generalPrefs = ref.watch(generalPreferencesProvider); diff --git a/lib/src/db/database.dart b/lib/src/db/database.dart index 20b6c644ca..3923b3b93c 100644 --- a/lib/src/db/database.dart +++ b/lib/src/db/database.dart @@ -14,7 +14,7 @@ const kLichessDatabaseName = 'lichess_mobile.db'; const puzzleTTL = Duration(days: 60); const corresGameTTL = Duration(days: 60); const gameTTL = Duration(days: 90); -const chatReadMessagesTTL = Duration(days: 60); +const chatReadMessagesTTL = Duration(days: 180); const httpLogTTL = Duration(days: 7); const kStorageAnonId = '**anonymous**'; diff --git a/lib/src/model/chat/chat.dart b/lib/src/model/chat/chat.dart new file mode 100644 index 0000000000..f75a8d381e --- /dev/null +++ b/lib/src/model/chat/chat.dart @@ -0,0 +1,94 @@ +import 'package:deep_pick/deep_pick.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:lichess_mobile/src/model/common/id.dart'; +import 'package:lichess_mobile/src/model/user/user.dart'; + +part 'chat.freezed.dart'; + +typedef ChatData = ({IList lines, bool writeable}); + +ChatData chatDataFromPick(RequiredPick pick) { + return ( + lines: pick('lines').asListOrThrow((it) => ChatMessage.fromPick(it)).toIList(), + writeable: pick('writeable').asBoolOrTrue(), + ); +} + +@freezed +class ChatMessage with _$ChatMessage { + const ChatMessage._(); + + const factory ChatMessage({ + required String message, + required String? username, + required bool troll, + required bool deleted, + required bool patron, + String? flair, + String? title, + }) = _ChatMessage; + + LightUser? get user => + username != null + ? LightUser( + id: UserId.fromUserName(username!), + name: username!, + title: title, + flair: flair, + isPatron: patron, + ) + : null; + + factory ChatMessage.fromJson(Map json) => + ChatMessage.fromPick(pick(json).required()); + + factory ChatMessage.fromPick(RequiredPick pick) { + return ChatMessage( + message: pick('t').asStringOrThrow(), + username: pick('u').asStringOrNull(), + troll: pick('r').asBoolOrNull() ?? false, + deleted: pick('d').asBoolOrNull() ?? false, + patron: pick('p').asBoolOrNull() ?? false, + flair: pick('f').asStringOrNull(), + title: pick('title').asStringOrNull(), + ); + } + + bool get isSpam => spamRegex.hasMatch(message) || followMeRegex.hasMatch(message); +} + +final RegExp spamRegex = RegExp( + [ + 'xcamweb.com', + '(^|[^i])chess-bot', + 'chess-cheat', + 'coolteenbitch', + 'letcafa.webcam', + 'tinyurl.com/', + 'wooga.info/', + 'bit.ly/', + 'wbt.link/', + 'eb.by/', + '001.rs/', + 'shr.name/', + 'u.to/', + '.3-a.net', + '.ssl443.org', + '.ns02.us', + '.myftp.info', + '.flinkup.com', + '.serveusers.com', + 'badoogirls.com', + 'hide.su', + 'wyon.de', + 'sexdatingcz.club', + 'qps.ru', + 'tiny.cc/', + 'trasderk.blogspot.com', + 't.ly/', + 'shorturl.at/', + ].map((url) => url.replaceAll('.', '\\.').replaceAll('/', '\\/')).join('|'), +); + +final followMeRegex = RegExp('follow me|join my team', caseSensitive: false); diff --git a/lib/src/model/chat/chat_controller.dart b/lib/src/model/chat/chat_controller.dart new file mode 100644 index 0000000000..de8f739849 --- /dev/null +++ b/lib/src/model/chat/chat_controller.dart @@ -0,0 +1,174 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:lichess_mobile/src/db/database.dart'; +import 'package:lichess_mobile/src/model/auth/auth_session.dart'; +import 'package:lichess_mobile/src/model/chat/chat.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/game/game_controller.dart'; +import 'package:lichess_mobile/src/model/tournament/tournament_controller.dart'; +import 'package:lichess_mobile/src/model/user/user.dart'; +import 'package:lichess_mobile/src/network/socket.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:sqflite/sqflite.dart'; + +part 'chat_controller.freezed.dart'; +part 'chat_controller.g.dart'; + +const _tableName = 'chat_read_messages'; +String _storeKey(StringId id) => 'chat.$id'; + +@immutable +sealed class ChatOptions { + const ChatOptions(); + + StringId get id; + LightUser? get opponent; + bool get isPublic; + bool get writeable; + + @override + String toString() => + 'ChatOptions(id: $id, opponent: $opponent, isPublic: $isPublic, writeable: $writeable)'; +} + +@freezed +class GameChatOptions extends ChatOptions with _$GameChatOptions { + const GameChatOptions._(); + const factory GameChatOptions({required GameFullId id, required LightUser? opponent}) = + _GameChatOptions; + + @override + bool get isPublic => false; + + @override + bool get writeable => true; +} + +@freezed +class TournamentChatOptions extends ChatOptions with _$TournamentChatOptions { + const TournamentChatOptions._(); + const factory TournamentChatOptions({required TournamentId id, required bool writeable}) = + _TournamentChatOptions; + + @override + LightUser? get opponent => null; + + @override + bool get isPublic => true; +} + +/// A provider that gets the chat unread messages +@riverpod +Future chatUnread(Ref ref, ChatOptions options) async { + return ref.watch(chatControllerProvider(options).selectAsync((s) => s.unreadMessages)); +} + +const IList _kEmptyMessages = IListConst([]); + +@riverpod +class ChatController extends _$ChatController { + StreamSubscription? _subscription; + + LightUser? get _me => ref.read(authSessionProvider)?.user; + + @override + Future build(ChatOptions options) async { + _subscription?.cancel(); + _subscription = socketGlobalStream.listen(_handleSocketEvent); + + ref.onDispose(() { + _subscription?.cancel(); + }); + + final initialMessages = await switch (options) { + GameChatOptions(:final id) => ref.watch( + gameControllerProvider(id).selectAsync((s) => s.game.chat?.lines), + ), + TournamentChatOptions(:final id) => ref.watch( + tournamentControllerProvider(id).selectAsync((s) => s.tournament.chat?.lines), + ), + }; + + final filteredMessages = _selectMessages(initialMessages ?? _kEmptyMessages); + final readMessagesCount = await _getReadMessagesCount(); + + return ChatState( + messages: filteredMessages, + unreadMessages: math.max(0, filteredMessages.length - readMessagesCount), + ); + } + + /// Sends a message to the chat. + void postMessage(String message) { + ref.read(socketPoolProvider).currentClient.send('talk', message); + } + + /// Resets the unread messages count to 0 and saves the number of read messages. + Future markMessagesAsRead() async { + if (state.hasValue) { + await _setReadMessagesCount(state.requireValue.messages.length); + } + state = state.whenData((s) => s.copyWith(unreadMessages: 0)); + } + + IList _selectMessages(IList all) { + return all + .where( + (m) => + !m.deleted && (!m.troll || m.username?.toLowerCase() == _me?.id.value) && !m.isSpam, + ) + .toIList(); + } + + Future _getReadMessagesCount() async { + final db = await ref.read(databaseProvider.future); + final result = await db.query( + _tableName, + columns: ['nbRead'], + where: 'id = ?', + whereArgs: [_storeKey(options.id)], + ); + return result.firstOrNull?['nbRead'] as int? ?? 0; + } + + Future _setReadMessagesCount(int count) async { + final db = await ref.read(databaseProvider.future); + await db.insert(_tableName, { + 'id': _storeKey(options.id), + 'lastModified': DateTime.now().toIso8601String(), + 'nbRead': count, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + + void _handleSocketEvent(SocketEvent event) { + if (!state.hasValue) return; + + if (event.topic == 'message') { + final data = event.data as Map; + final message = ChatMessage.fromJson(data); + state = state.whenData((s) { + final oldMessages = s.messages; + final newMessages = _selectMessages(oldMessages.add(message)); + final newUnread = newMessages.length - oldMessages.length; + if (options.isPublic == false && newUnread > 0) { + ref.read(soundServiceProvider).play(Sound.confirmation, volume: 0.5); + } + return s.copyWith(messages: newMessages, unreadMessages: s.unreadMessages + newUnread); + }); + } + } +} + +@freezed +class ChatState with _$ChatState { + const ChatState._(); + + const factory ChatState({required IList messages, required int unreadMessages}) = + _ChatState; +} diff --git a/lib/src/model/game/chat_controller.dart b/lib/src/model/game/chat_controller.dart deleted file mode 100644 index 455217a457..0000000000 --- a/lib/src/model/game/chat_controller.dart +++ /dev/null @@ -1,170 +0,0 @@ -import 'dart:async'; - -import 'package:deep_pick/deep_pick.dart'; -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:lichess_mobile/src/db/database.dart'; -import 'package:lichess_mobile/src/model/common/id.dart'; -import 'package:lichess_mobile/src/model/common/socket.dart'; -import 'package:lichess_mobile/src/model/game/game_controller.dart'; -import 'package:lichess_mobile/src/network/socket.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:sqflite/sqflite.dart'; - -part 'chat_controller.freezed.dart'; -part 'chat_controller.g.dart'; - -const _tableName = 'chat_read_messages'; -String _storeKey(GameFullId id) => 'game.$id'; - -@riverpod -class ChatController extends _$ChatController { - StreamSubscription? _subscription; - - late SocketClient _socketClient; - - @override - Future build(GameFullId id) async { - _socketClient = ref.read(socketPoolProvider).open(GameController.socketUri(id)); - - _subscription?.cancel(); - _subscription = _socketClient.stream.listen(_handleSocketEvent); - - ref.onDispose(() { - _subscription?.cancel(); - }); - - final messages = await _socketClient.stream - .firstWhere((event) => event.topic == 'full') - .then( - (event) => pick(event.data, 'chat', 'lines').asListOrNull(_messageFromPick)?.toIList(), - ); - - final readMessagesCount = await _getReadMessagesCount(); - - return ChatState( - messages: messages ?? IList(), - unreadMessages: (messages?.length ?? 0) - readMessagesCount, - ); - } - - /// Sends a message to the chat. - void sendMessage(String message) { - _socketClient.send('talk', message); - } - - /// Resets the unread messages count to 0 and saves the number of read messages. - Future markMessagesAsRead() async { - if (state.hasValue) { - await _setReadMessagesCount(state.requireValue.messages.length); - } - state = state.whenData((s) => s.copyWith(unreadMessages: 0)); - } - - Future _getReadMessagesCount() async { - final db = await ref.read(databaseProvider.future); - final result = await db.query( - _tableName, - columns: ['nbRead'], - where: 'id = ?', - whereArgs: [_storeKey(id)], - ); - return result.firstOrNull?['nbRead'] as int? ?? 0; - } - - Future _setReadMessagesCount(int count) async { - final db = await ref.read(databaseProvider.future); - await db.insert(_tableName, { - 'id': _storeKey(id), - 'lastModified': DateTime.now().toIso8601String(), - 'nbRead': count, - }, conflictAlgorithm: ConflictAlgorithm.replace); - } - - Future _setMessages(IList messages) async { - final readMessagesCount = await _getReadMessagesCount(); - - state = state.whenData( - (s) => s.copyWith(messages: messages, unreadMessages: messages.length - readMessagesCount), - ); - } - - void _addMessage(Message message) { - state = state.whenData( - (s) => s.copyWith(messages: s.messages.add(message), unreadMessages: s.unreadMessages + 1), - ); - } - - void _handleSocketEvent(SocketEvent event) { - if (!state.hasValue) return; - - if (event.topic == 'full') { - final messages = pick(event.data, 'chat', 'lines').asListOrNull(_messageFromPick)?.toIList(); - if (messages != null) { - _setMessages(messages); - } - } else if (event.topic == 'message') { - final data = event.data as Map; - final message = _messageFromPick(RequiredPick(data)); - _addMessage(message); - } - } -} - -@freezed -class ChatState with _$ChatState { - const ChatState._(); - - const factory ChatState({required IList messages, required int unreadMessages}) = - _ChatState; -} - -typedef Message = ({String? username, String message, bool troll, bool deleted}); - -Message _messageFromPick(RequiredPick pick) { - return ( - message: pick('t').asStringOrThrow(), - username: pick('u').asStringOrNull(), - troll: pick('r').asBoolOrNull() ?? false, - deleted: pick('d').asBoolOrNull() ?? false, - ); -} - -bool isSpam(Message message) { - return spamRegex.hasMatch(message.message) || followMeRegex.hasMatch(message.message); -} - -final RegExp spamRegex = RegExp( - [ - 'xcamweb.com', - '(^|[^i])chess-bot', - 'chess-cheat', - 'coolteenbitch', - 'letcafa.webcam', - 'tinyurl.com/', - 'wooga.info/', - 'bit.ly/', - 'wbt.link/', - 'eb.by/', - '001.rs/', - 'shr.name/', - 'u.to/', - '.3-a.net', - '.ssl443.org', - '.ns02.us', - '.myftp.info', - '.flinkup.com', - '.serveusers.com', - 'badoogirls.com', - 'hide.su', - 'wyon.de', - 'sexdatingcz.club', - 'qps.ru', - 'tiny.cc/', - 'trasderk.blogspot.com', - 't.ly/', - 'shorturl.at/', - ].map((url) => url.replaceAll('.', '\\.').replaceAll('/', '\\/')).join('|'), -); - -final followMeRegex = RegExp('follow me|join my team', caseSensitive: false); diff --git a/lib/src/model/game/game_controller.dart b/lib/src/model/game/game_controller.dart index 297b801072..82e94a914f 100644 --- a/lib/src/model/game/game_controller.dart +++ b/lib/src/model/game/game_controller.dart @@ -12,6 +12,7 @@ import 'package:lichess_mobile/src/binding.dart'; import 'package:lichess_mobile/src/model/account/account_preferences.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/chat/chat_controller.dart'; import 'package:lichess_mobile/src/model/clock/chess_clock.dart'; import 'package:lichess_mobile/src/model/common/chess.dart'; import 'package:lichess_mobile/src/model/common/id.dart'; @@ -1076,4 +1077,12 @@ class GameState with _$GameState { isComputerAnalysisAllowed: false, ), ); + + GameChatOptions? get chatOptions => + isZenModeActive || game.meta.tournament != null + ? null + : GameChatOptions( + id: gameFullId, + opponent: game.youAre != null ? game.playerOf(game.youAre!.opposite).user : null, + ); } diff --git a/lib/src/model/game/playable_game.dart b/lib/src/model/game/playable_game.dart index 15ea0f5f13..b8910a9818 100644 --- a/lib/src/model/game/playable_game.dart +++ b/lib/src/model/game/playable_game.dart @@ -3,6 +3,7 @@ import 'package:deep_pick/deep_pick.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:lichess_mobile/src/model/account/account_preferences.dart'; +import 'package:lichess_mobile/src/model/chat/chat.dart'; import 'package:lichess_mobile/src/model/common/chess.dart'; import 'package:lichess_mobile/src/model/common/eval.dart'; import 'package:lichess_mobile/src/model/common/id.dart'; @@ -58,6 +59,7 @@ class PlayableGame with _$PlayableGame, BaseGame, IndexableSteps implements Base bool? boosted, bool? isThreefoldRepetition, ({Duration idle, Duration timeToMove, DateTime movedAt})? expiration, + ChatData? chat, /// The game id of the next game if a rematch has been accepted. GameId? rematch, @@ -234,6 +236,7 @@ PlayableGame _playableGameFromPick(RequiredPick pick) { movedAt: DateTime.now().subtract(idle), ); }), + chat: pick('chat').letOrNull((p) => chatDataFromPick(p)), rematch: pick('game', 'rematch').asGameIdOrNull(), ); } diff --git a/lib/src/model/tournament/tournament.dart b/lib/src/model/tournament/tournament.dart index a780da7eeb..67fc7cc5ac 100644 --- a/lib/src/model/tournament/tournament.dart +++ b/lib/src/model/tournament/tournament.dart @@ -3,6 +3,7 @@ import 'package:deep_pick/deep_pick.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter/material.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:lichess_mobile/src/model/chat/chat.dart'; import 'package:lichess_mobile/src/model/common/chess.dart'; import 'package:lichess_mobile/src/model/common/id.dart'; import 'package:lichess_mobile/src/model/common/perf.dart'; @@ -222,10 +223,16 @@ class Tournament with _$Tournament { required StandingPage? standing, required Verdicts verdicts, required String? reloadEndpoint, + required int socketVersion, + ({String text, String author})? quote, + DateTime? startsAt, + TournamentStats? stats, + ChatData? chat, }) = _Tournament; - factory Tournament.fromServerJson(Map json) => - _tournamentFromPick(pick(json).required()); + factory Tournament.fromServerJson(Map json) { + return _tournamentFromPick(pick(json).required()); + } Tournament updateFromPartialServerJson(Map json) => _updateTournamentFromPartialPick(this, pick(json).required()); @@ -234,9 +241,11 @@ class Tournament with _$Tournament { Tournament _tournamentFromPick(RequiredPick pick) { return Tournament( id: pick('id').asTournamentIdOrThrow(), + socketVersion: pick('socketVersion').asIntOrThrow(), meta: _tournamentMetaFromPick(pick), featuredGame: pick('featured').asFeaturedGameOrNull(), description: pick('description').asStringOrNull(), + startsAt: pick('startsAt').letOrNull((p) => DateTime.parse(p.asStringOrThrow())), isFinished: pick('isFinished').asBoolOrNull(), isStarted: pick('isStarted').asBoolOrNull(), timeToStart: pick( @@ -251,6 +260,11 @@ Tournament _tournamentFromPick(RequiredPick pick) { berserkable: pick('berserkable').asBoolOrFalse(), verdicts: pick('verdicts').asVerdictsOrThrow(), reloadEndpoint: pick('reloadEndpoint').asStringOrNull(), + stats: pick('stats').letOrNull((p) => TournamentStats._fromPick(p)), + chat: pick('chat').letOrNull((p) => chatDataFromPick(p)), + quote: pick( + 'quote', + ).letOrNull((p) => (text: p('text').asStringOrThrow(), author: p('author').asStringOrThrow())), ); } @@ -384,3 +398,38 @@ FeaturedGame _featuredGameFromPick(RequiredPick pick) { ), ); } + +@freezed +class TournamentStats with _$TournamentStats { + const TournamentStats._(); + + const factory TournamentStats({ + required int nbMoves, + required int nbGames, + required int nbDraws, + required int nbBerserks, + required int nbBlackWins, + required int nbWhiteWins, + required int averageRating, + }) = _TournamentStats; + + factory TournamentStats.fromServerJson(Map json) => + TournamentStats._fromPick(pick(json).required()); + + factory TournamentStats._fromPick(RequiredPick pick) { + return TournamentStats( + nbMoves: pick('moves').asIntOrThrow(), + nbGames: pick('games').asIntOrThrow(), + nbDraws: pick('draws').asIntOrThrow(), + nbBerserks: pick('berserks').asIntOrThrow(), + nbBlackWins: pick('blackWins').asIntOrThrow(), + nbWhiteWins: pick('whiteWins').asIntOrThrow(), + averageRating: pick('averageRating').asIntOrThrow(), + ); + } + + int get drawRate => ((nbDraws / nbGames) * 100).round(); + int get berserkRate => ((nbBerserks / nbGames) * 100).round(); + int get blackWinRate => ((nbBlackWins / nbGames) * 100).round(); + int get whiteWinRate => ((nbWhiteWins / nbGames) * 100).round(); +} diff --git a/lib/src/model/tournament/tournament_controller.dart b/lib/src/model/tournament/tournament_controller.dart index 47b74d3fe2..fa6f7ac857 100644 --- a/lib/src/model/tournament/tournament_controller.dart +++ b/lib/src/model/tournament/tournament_controller.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:lichess_mobile/src/model/chat/chat_controller.dart'; import 'package:lichess_mobile/src/model/common/id.dart'; import 'package:lichess_mobile/src/model/common/socket.dart'; import 'package:lichess_mobile/src/model/tournament/tournament.dart'; @@ -26,7 +27,7 @@ class TournamentController extends _$TournamentController { // so we manually have to set this timer to schedule a reload once we can join again. Timer? _pauseDelayTimer; - Timer? _startingTimer; + Timer? _reloadTimer; static Uri socketUri(TournamentId id) => Uri(path: '/tournament/$id/socket/v6'); @@ -37,23 +38,21 @@ class TournamentController extends _$TournamentController { ref.onDispose(() { _socketSubscription?.cancel(); _pauseDelayTimer?.cancel(); - _startingTimer?.cancel(); + _reloadTimer?.cancel(); }); - listenToSocketEvents(); - final tournament = await ref.read(tournamentRepositoryProvider).getTournament(id); - if (tournament.timeToStart != null) { - _startingTimer?.cancel(); - _startingTimer = Timer(tournament.timeToStart!.$1, () async { + _socketClient = _socketPool.open(socketUri(id), version: tournament.socketVersion); + _socketSubscription?.cancel(); + _socketSubscription = _socketClient!.stream.listen(_handleSocketEvent); + + final countdown = tournament.timeToStart ?? tournament.timeToFinish; + if (countdown != null && countdown.$1 > Duration.zero) { + _reloadTimer?.cancel(); + _reloadTimer = Timer(countdown.$1, () { if (state.hasValue) { - final tour = await ref - .read(tournamentRepositoryProvider) - .getTournament(state.requireValue.tournament.id); - state = AsyncData( - TournamentState(tournament: tour, standingsPage: state.requireValue.standingsPage), - ); + ref.invalidateSelf(); } }); } @@ -63,16 +62,11 @@ class TournamentController extends _$TournamentController { return TournamentState(tournament: tournament, standingsPage: 1); } - /// Start listening to the tournament socket events. - void listenToSocketEvents() { - _socketClient = _socketPool.open(socketUri(id)); - _socketSubscription?.cancel(); - _socketSubscription = _socketClient!.stream.listen(_handleSocketEvent); - } - - /// Stop listening to the tournament socket events. - void stopListeningToSocketEvents() { - _socketSubscription?.cancel(); + void onFocusRegained() { + final currentClient = ref.read(socketPoolProvider).currentClient; + if (currentClient.route != _socketClient?.route) { + ref.invalidateSelf(); + } } void _watchFeaturedGameIfChanged({required GameId? previous, required GameId? current}) { @@ -218,7 +212,10 @@ class TournamentState with _$TournamentState { FeaturedGame? get featuredGame => tournament.featuredGame; - bool get canJoin => tournament.me?.pauseDelay == null && tournament.verdicts.accepted; + bool get canJoin => + tournament.me?.pauseDelay == null && + tournament.verdicts.accepted && + tournament.isFinished != true; int get firstRankOfPage => (standingsPage - 1) * kStandingsPageSize + 1; bool get hasPreviousPage => standingsPage > 1; @@ -229,4 +226,9 @@ class TournamentState with _$TournamentState { /// True if the user has joined the tournament and is not withdrawn. bool get joined => tournament.me != null && tournament.me!.withdraw != true; + + ChatOptions? get chatOptions => + tournament.chat != null + ? TournamentChatOptions(id: tournament.id, writeable: tournament.chat!.writeable) + : null; } diff --git a/lib/src/model/tournament/tournament_repository.dart b/lib/src/model/tournament/tournament_repository.dart index 53e6ca6ac4..4d31b6061b 100644 --- a/lib/src/model/tournament/tournament_repository.dart +++ b/lib/src/model/tournament/tournament_repository.dart @@ -1,7 +1,8 @@ +import 'dart:io' show File; + 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:http/http.dart'; import 'package:http/http.dart' as http; import 'package:lichess_mobile/src/model/common/id.dart'; import 'package:lichess_mobile/src/model/tournament/tournament.dart'; @@ -12,13 +13,14 @@ part 'tournament_repository.g.dart'; @Riverpod(keepAlive: true) TournamentRepository tournamentRepository(Ref ref) { - return TournamentRepository(ref.read(lichessClientProvider)); + return TournamentRepository(ref.read(lichessClientProvider), ref); } class TournamentRepository { - TournamentRepository(this.client); + TournamentRepository(this.client, Ref ref) : _ref = ref; - final Client client; + final Ref _ref; + final http.Client client; Future> featured() { return client.readJson( @@ -44,12 +46,21 @@ class TournamentRepository { Future getTournament(TournamentId id) { return client.readJson( - Uri(path: '/api/tournament/$id'), + Uri(path: '/api/tournament/$id', queryParameters: {'chat': '1', 'socketVersion': '1'}), headers: {'Accept': 'application/json'}, mapper: (Map json) => Tournament.fromServerJson(json), ); } + Future downloadTournamentGames(TournamentId id, File file, {UserId? userId}) { + final client = _ref.read(defaultClientProvider); + return downloadFile( + client, + lichessUri('/api/tournament/$id/games', userId != null ? {'player': userId.value} : null), + file, + ); + } + Future reload(Tournament tournament, {required int standingsPage}) { return client.readJson( Uri( diff --git a/lib/src/network/http.dart b/lib/src/network/http.dart index 307556a7b3..d7359fb286 100644 --- a/lib/src/network/http.dart +++ b/lib/src/network/http.dart @@ -192,7 +192,9 @@ String makeUserAgent(PackageInfo info, BaseDeviceInfo deviceInfo, String sri, Li } /// Downloads a file from the given [url] and saves it to the [file]. -Future downloadFile( +/// +/// Returns true if the download was successful, false otherwise. +Future downloadFile( Client client, Uri url, File file, { @@ -223,6 +225,9 @@ Future downloadFile( debugPrint('Failed to save file: $e'); } } + + final length = await file.length(); + return length > 0; } /// A [Client] that intercepts all requests, responses, and errors using the provided callbacks. diff --git a/lib/src/utils/navigation.dart b/lib/src/utils/navigation.dart index 70bb439571..549c89886c 100644 --- a/lib/src/utils/navigation.dart +++ b/lib/src/utils/navigation.dart @@ -37,6 +37,11 @@ Route buildScreenRoute( BuildContext context, { required Widget screen, bool fullscreenDialog = false, + RouteSettings? settings, }) { - return MaterialScreenRoute(screen: screen, fullscreenDialog: fullscreenDialog); + return MaterialScreenRoute( + screen: screen, + fullscreenDialog: fullscreenDialog, + settings: settings, + ); } diff --git a/lib/src/utils/share.dart b/lib/src/utils/share.dart index 1f7a2e33a9..e113a92da1 100644 --- a/lib/src/utils/share.dart +++ b/lib/src/utils/share.dart @@ -3,34 +3,20 @@ import 'package:share_plus/share_plus.dart'; /// Shares the given [uri], [files], or [text]. /// -/// Using this function is recommended over calling [Share.share] directly +/// Using this function is recommended over calling [SharePlus.instance.share] directly /// in order to make it work on iPads. -Future launchShareDialog( - BuildContext context, { - - /// The uri to share. - Uri? uri, - - /// The files to share. - List? files, - - /// The subject. - String? subject, - - /// The text to share. - String? text, -}) { - assert(uri != null || files != null || text != null); +Future launchShareDialog(BuildContext context, ShareParams params) { final box = context.findRenderObject() as RenderBox?; final origin = box != null ? box.localToGlobal(Offset.zero) & box.size : null; - if (uri != null) { - return Share.shareUri(uri); - } else if (files != null) { - return Share.shareXFiles(files, subject: subject, text: text, sharePositionOrigin: origin); - } else if (text != null) { - return Share.share(text, subject: subject, sharePositionOrigin: origin); - } - - throw ArgumentError('uri, files, or text must be provided'); + return SharePlus.instance.share( + ShareParams( + uri: params.uri, + files: params.files, + text: params.text, + subject: params.subject, + fileNameOverrides: params.fileNameOverrides, + sharePositionOrigin: origin, + ), + ); } diff --git a/lib/src/view/account/edit_profile_screen.dart b/lib/src/view/account/edit_profile_screen.dart index d31db5e8a9..800a0ddc4e 100644 --- a/lib/src/view/account/edit_profile_screen.dart +++ b/lib/src/view/account/edit_profile_screen.dart @@ -11,7 +11,6 @@ import 'package:lichess_mobile/src/utils/l10n_context.dart'; import 'package:lichess_mobile/src/utils/lichess_assets.dart'; import 'package:lichess_mobile/src/utils/navigation.dart'; import 'package:lichess_mobile/src/view/user/countries.dart'; -import 'package:lichess_mobile/src/widgets/buttons.dart'; import 'package:lichess_mobile/src/widgets/emoji_picker/widget.dart'; import 'package:lichess_mobile/src/widgets/feedback.dart'; import 'package:lichess_mobile/src/widgets/platform_alert_dialog.dart'; @@ -422,8 +421,7 @@ class _EditProfileFormState extends ConsumerState<_EditProfileForm> { child: FutureBuilder( future: _pendingSaveProfile, builder: (context, snapshot) { - return FatButton( - semanticsLabel: context.l10n.apply, + return FilledButton( onPressed: snapshot.connectionState == ConnectionState.waiting ? null diff --git a/lib/src/view/analysis/analysis_screen.dart b/lib/src/view/analysis/analysis_screen.dart index 8721999323..2a18e374b4 100644 --- a/lib/src/view/analysis/analysis_screen.dart +++ b/lib/src/view/analysis/analysis_screen.dart @@ -28,6 +28,7 @@ import 'package:lichess_mobile/src/widgets/buttons.dart'; import 'package:lichess_mobile/src/widgets/feedback.dart'; import 'package:lichess_mobile/src/widgets/misc.dart'; import 'package:logging/logging.dart'; +import 'package:share_plus/share_plus.dart'; final _logger = Logger('AnalysisScreen'); @@ -342,7 +343,7 @@ class _BottomBar extends ConsumerWidget { makeLabel: (context) => Text(context.l10n.mobileSharePositionAsFEN), onPressed: () { final analysisState = ref.read(analysisControllerProvider(options)).requireValue; - launchShareDialog(context, text: analysisState.currentPosition.fen); + launchShareDialog(context, ShareParams(text: analysisState.currentPosition.fen)); }, ), if (options.gameId != null) @@ -362,8 +363,11 @@ class _BottomBar extends ConsumerWidget { if (context.mounted) { launchShareDialog( context, - files: [image], - subject: context.l10n.puzzleFromGameLink(lichessUri('/$gameId').toString()), + ShareParams( + files: [image], + fileNameOverrides: ['$gameId.gif'], + subject: context.l10n.puzzleFromGameLink(lichessUri('/$gameId').toString()), + ), ); } } catch (e) { diff --git a/lib/src/view/analysis/analysis_share_screen.dart b/lib/src/view/analysis/analysis_share_screen.dart index dcbedf3eaa..c154fb1339 100644 --- a/lib/src/view/analysis/analysis_share_screen.dart +++ b/lib/src/view/analysis/analysis_share_screen.dart @@ -11,7 +11,7 @@ import 'package:lichess_mobile/src/utils/l10n_context.dart'; import 'package:lichess_mobile/src/utils/navigation.dart'; import 'package:lichess_mobile/src/utils/share.dart'; import 'package:lichess_mobile/src/widgets/adaptive_choice_picker.dart'; -import 'package:lichess_mobile/src/widgets/buttons.dart'; +import 'package:share_plus/share_plus.dart'; final _dateFormatter = DateFormat('yyyy.MM.dd'); @@ -161,15 +161,16 @@ class _EditPgnTagsFormState extends ConsumerState<_EditPgnTagsForm> { padding: const EdgeInsets.all(24.0), child: Builder( builder: (context) { - return FatButton( - semanticsLabel: 'Share PGN', + return FilledButton( onPressed: () { launchShareDialog( context, - text: - ref - .read(analysisControllerProvider(widget.options).notifier) - .makeExportPgn(), + ShareParams( + text: + ref + .read(analysisControllerProvider(widget.options).notifier) + .makeExportPgn(), + ), ); }, child: Text(context.l10n.mobileShareGamePGN), diff --git a/lib/src/view/analysis/server_analysis.dart b/lib/src/view/analysis/server_analysis.dart index 9bc09ba108..4b33816375 100644 --- a/lib/src/view/analysis/server_analysis.dart +++ b/lib/src/view/analysis/server_analysis.dart @@ -13,7 +13,6 @@ import 'package:lichess_mobile/src/model/analysis/server_analysis_service.dart'; import 'package:lichess_mobile/src/model/auth/auth_session.dart'; import 'package:lichess_mobile/src/utils/l10n_context.dart'; import 'package:lichess_mobile/src/utils/string.dart'; -import 'package:lichess_mobile/src/widgets/buttons.dart'; import 'package:lichess_mobile/src/widgets/feedback.dart'; class ServerAnalysisSummary extends ConsumerWidget { @@ -44,11 +43,10 @@ class ServerAnalysisSummary extends ConsumerWidget { const Spacer(), Text(context.l10n.computerAnalysisDisabled), if (canShowGameSummary) - SecondaryButton( + FilledButton.tonal( onPressed: () { ref.read(ctrlProvider.notifier).toggleComputerAnalysis(); }, - semanticsLabel: context.l10n.enable, child: Text(context.l10n.enable), ), const Spacer(), @@ -174,8 +172,7 @@ class ServerAnalysisSummary extends ConsumerWidget { return FutureBuilder( future: pendingRequest, builder: (context, snapshot) { - return SecondaryButton( - semanticsLabel: context.l10n.requestAComputerAnalysis, + return FilledButton.tonal( onPressed: ref.watch(authSessionProvider) == null ? () { diff --git a/lib/src/view/board_editor/board_editor_screen.dart b/lib/src/view/board_editor/board_editor_screen.dart index d1f1b4b413..ede3683b65 100644 --- a/lib/src/view/board_editor/board_editor_screen.dart +++ b/lib/src/view/board_editor/board_editor_screen.dart @@ -20,6 +20,7 @@ import 'package:lichess_mobile/src/widgets/adaptive_bottom_sheet.dart'; import 'package:lichess_mobile/src/widgets/bottom_bar.dart'; import 'package:lichess_mobile/src/widgets/buttons.dart'; import 'package:lichess_mobile/src/widgets/platform.dart'; +import 'package:share_plus/share_plus.dart'; class BoardEditorScreen extends ConsumerWidget { const BoardEditorScreen({super.key, this.initialFen}); @@ -40,7 +41,7 @@ class BoardEditorScreen extends ConsumerWidget { actions: [ SemanticIconButton( semanticsLabel: context.l10n.mobileSharePositionAsFEN, - onPressed: () => launchShareDialog(context, text: boardEditorState.fen), + onPressed: () => launchShareDialog(context, ShareParams(text: boardEditorState.fen)), icon: const PlatformShareIcon(), ), ], diff --git a/lib/src/view/broadcast/broadcast_game_screen.dart b/lib/src/view/broadcast/broadcast_game_screen.dart index 81323c63b5..783984c4a1 100644 --- a/lib/src/view/broadcast/broadcast_game_screen.dart +++ b/lib/src/view/broadcast/broadcast_game_screen.dart @@ -35,6 +35,7 @@ import 'package:lichess_mobile/src/widgets/buttons.dart'; import 'package:lichess_mobile/src/widgets/clock.dart'; import 'package:lichess_mobile/src/widgets/feedback.dart'; import 'package:lichess_mobile/src/widgets/pgn.dart'; +import 'package:share_plus/share_plus.dart'; class BroadcastGameScreen extends ConsumerStatefulWidget { final BroadcastTournamentId? tournamentId; @@ -570,7 +571,9 @@ class _BroadcastGameBottomBar extends ConsumerWidget { onPressed: () { launchShareDialog( context, - uri: lichessUri('/broadcast/$tournamentSlug/$roundSlug/$roundId/$gameId'), + ShareParams( + uri: lichessUri('/broadcast/$tournamentSlug/$roundSlug/$roundId/$gameId'), + ), ); }, ), @@ -582,7 +585,7 @@ class _BroadcastGameBottomBar extends ConsumerWidget { (client) => BroadcastRepository(client).getGamePgn(roundId, gameId), ); if (context.mounted) { - launchShareDialog(context, text: pgn); + launchShareDialog(context, ShareParams(text: pgn)); } } catch (e) { if (context.mounted) { @@ -599,7 +602,10 @@ class _BroadcastGameBottomBar extends ConsumerWidget { .read(gameShareServiceProvider) .chapterGif(roundId, gameId); if (context.mounted) { - launchShareDialog(context, files: [gif]); + launchShareDialog( + context, + ShareParams(fileNameOverrides: ['$gameId.gif'], files: [gif]), + ); } } catch (e) { debugPrint(e.toString()); diff --git a/lib/src/view/broadcast/broadcast_share_menu.dart b/lib/src/view/broadcast/broadcast_share_menu.dart index c6db9126ad..283fe00e2f 100644 --- a/lib/src/view/broadcast/broadcast_share_menu.dart +++ b/lib/src/view/broadcast/broadcast_share_menu.dart @@ -3,6 +3,7 @@ import 'package:lichess_mobile/src/model/broadcast/broadcast.dart'; import 'package:lichess_mobile/src/network/http.dart'; import 'package:lichess_mobile/src/utils/share.dart'; import 'package:lichess_mobile/src/widgets/adaptive_action_sheet.dart'; +import 'package:share_plus/share_plus.dart'; Future showBroadcastShareMenu( BuildContext context, @@ -15,7 +16,7 @@ Future showBroadcastShareMenu( onPressed: () { launchShareDialog( context, - uri: lichessUri('/broadcast/${broadcast.tour.slug}/${broadcast.tour.id}'), + ShareParams(uri: lichessUri('/broadcast/${broadcast.tour.slug}/${broadcast.tour.id}')), ); }, ), @@ -24,8 +25,10 @@ Future showBroadcastShareMenu( onPressed: () { launchShareDialog( context, - uri: lichessUri( - '/broadcast/${broadcast.tour.slug}/${broadcast.round.slug}/${broadcast.round.id}', + ShareParams( + uri: lichessUri( + '/broadcast/${broadcast.tour.slug}/${broadcast.round.slug}/${broadcast.round.id}', + ), ), ); }, @@ -35,8 +38,10 @@ Future showBroadcastShareMenu( onPressed: () { launchShareDialog( context, - uri: lichessUri( - '/broadcast/${broadcast.tour.slug}/${broadcast.round.slug}/${broadcast.round.id}.pgn', + ShareParams( + uri: lichessUri( + '/broadcast/${broadcast.tour.slug}/${broadcast.round.slug}/${broadcast.round.id}.pgn', + ), ), ); }, diff --git a/lib/src/view/game/message_screen.dart b/lib/src/view/chat/chat_screen.dart similarity index 50% rename from lib/src/view/game/message_screen.dart rename to lib/src/view/chat/chat_screen.dart index cf0d5bbe58..afc096c593 100644 --- a/lib/src/view/game/message_screen.dart +++ b/lib/src/view/chat/chat_screen.dart @@ -1,35 +1,57 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.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/game/chat_controller.dart'; -import 'package:lichess_mobile/src/model/user/user.dart'; +import 'package:lichess_mobile/src/model/chat/chat.dart'; +import 'package:lichess_mobile/src/model/chat/chat_controller.dart'; import 'package:lichess_mobile/src/navigation.dart'; import 'package:lichess_mobile/src/utils/l10n_context.dart'; import 'package:lichess_mobile/src/utils/navigation.dart'; +import 'package:lichess_mobile/src/widgets/bottom_bar.dart'; import 'package:lichess_mobile/src/widgets/buttons.dart'; +import 'package:lichess_mobile/src/widgets/user_full_name.dart'; -class MessageScreen extends ConsumerStatefulWidget { - final GameFullId id; - final Widget title; - final LightUser? me; +class ChatBottomBarButton extends ConsumerWidget { + final ChatOptions options; - const MessageScreen({required this.id, required this.title, this.me}); + const ChatBottomBarButton({required this.options, super.key}); - static Route buildRoute( - BuildContext context, { - required GameFullId id, - required Widget title, - LightUser? me, - }) { - return buildScreenRoute(context, screen: MessageScreen(id: id, title: title, me: me)); + @override + Widget build(BuildContext context, WidgetRef ref) { + final chatUnread = ref.watch(chatUnreadProvider(options)); + + return BottomBarButton( + label: context.l10n.chatRoom, + onTap: () { + Navigator.of(context).push(ChatScreen.buildRoute(context, options: options)); + }, + icon: Icons.chat_bubble_outline, + badgeLabel: switch (chatUnread) { + AsyncData(:final value) => + value > 0 + ? value < 10 + ? value.toString() + : '9+' + : null, + _ => null, + }, + ); + } +} + +class ChatScreen extends ConsumerStatefulWidget { + final ChatOptions options; + + const ChatScreen({required this.options}); + + static Route buildRoute(BuildContext context, {required ChatOptions options}) { + return buildScreenRoute(context, screen: ChatScreen(options: options)); } @override - ConsumerState createState() => _MessageScreenState(); + ConsumerState createState() => _ChatScreenState(); } -class _MessageScreenState extends ConsumerState with RouteAware { +class _ChatScreenState extends ConsumerState with RouteAware { @override void didChangeDependencies() { super.didChangeDependencies(); @@ -47,73 +69,69 @@ class _MessageScreenState extends ConsumerState with RouteAware { @override void didPop() { - ref.read(chatControllerProvider(widget.id).notifier).markMessagesAsRead(); + ref.read(chatControllerProvider(widget.options).notifier).markMessagesAsRead(); super.didPop(); } @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: widget.title, centerTitle: true), - body: _Body(me: widget.me, id: widget.id), - ); - } -} - -class _Body extends ConsumerWidget { - final GameFullId id; - final LightUser? me; - - const _Body({required this.id, required this.me}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final chatStateAsync = ref.watch(chatControllerProvider(id)); - - return Column( - mainAxisSize: MainAxisSize.max, - children: [ - Expanded( - child: GestureDetector( - onTap: () => FocusScope.of(context).unfocus(), - child: chatStateAsync.when( - data: (chatState) { - final selectedMessages = - chatState.messages.where((m) => !m.troll && !m.deleted && !isSpam(m)).toList(); - final messagesCount = selectedMessages.length; - return ListView.builder( - // remove the automatic bottom padding of the ListView, which on iOS - // corresponds to the safe area insets - // and which is here taken care of by the _ChatBottomBar - padding: MediaQuery.of(context).padding.copyWith(bottom: 0), - reverse: true, - itemCount: messagesCount, - itemBuilder: (context, index) { - final message = selectedMessages[messagesCount - index - 1]; - return (message.username == 'lichess') - ? _MessageAction(message: message.message) - : (message.username == me?.name) - ? _MessageBubble(you: true, message: message.message) - : _MessageBubble(you: false, message: message.message); - }, - ); - }, - loading: () => const Center(child: CircularProgressIndicator.adaptive()), - error: (error, _) => Center(child: Text(error.toString())), - ), + final session = ref.watch(authSessionProvider); + switch (ref.watch(chatControllerProvider(widget.options))) { + case AsyncData(:final value): + return Scaffold( + appBar: AppBar( + title: + widget.options.isPublic + ? Text(context.l10n.chatRoom) + : widget.options.opponent == null + ? Text(context.l10n.chatRoom) + : UserFullNameWidget(user: widget.options.opponent), + centerTitle: true, ), - ), - _ChatBottomBar(id: id), - ], - ); + body: Column( + children: [ + Expanded( + child: GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: ListView.builder( + // remove the automatic bottom padding of the ListView which is here taken care + // of by the _ChatBottomBar + padding: MediaQuery.of(context).padding.copyWith(bottom: 0), + reverse: true, + itemCount: value.messages.length, + itemBuilder: (context, index) { + final message = value.messages[value.messages.length - index - 1]; + return (message.username == 'lichess') + ? _MessageAction(message: message.message) + : (message.username == session?.user.name) + ? _MessageBubble(you: true, message: message) + : _MessageBubble( + you: false, + message: message, + showUsername: widget.options.isPublic, + ); + }, + ), + ), + ), + if (widget.options.writeable) _ChatBottomBar(options: widget.options), + ], + ), + ); + case AsyncError(:final error): + return Scaffold(body: Center(child: Text(error.toString()))); + case _: + return const Scaffold(body: Center(child: CircularProgressIndicator.adaptive())); + } } } class _MessageBubble extends ConsumerWidget { - final bool you; - final String message; + const _MessageBubble({required this.you, required this.message, this.showUsername = false}); - const _MessageBubble({required this.you, required this.message}); + final bool you; + final ChatMessage message; + final bool showUsername; Color _bubbleColor(BuildContext context, Brightness brightness) => you ? ColorScheme.of(context).secondary : ColorScheme.of(context).surfaceContainerLow; @@ -137,7 +155,21 @@ class _MessageBubble extends ConsumerWidget { borderRadius: BorderRadius.circular(16.0), color: _bubbleColor(context, brightness), ), - child: Text(message, style: TextStyle(color: _textColor(context, brightness))), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showUsername && message.user != null) + UserFullNameWidget( + user: message.user, + style: TextStyle( + fontWeight: FontWeight.bold, + color: _textColor(context, brightness), + ), + ), + Text(message.message, style: TextStyle(color: _textColor(context, brightness))), + ], + ), ), ), ); @@ -168,8 +200,8 @@ class _MessageAction extends StatelessWidget { } class _ChatBottomBar extends ConsumerStatefulWidget { - final GameFullId id; - const _ChatBottomBar({required this.id}); + final ChatOptions options; + const _ChatBottomBar({required this.options}); @override ConsumerState createState() => _ChatBottomBarState(); @@ -195,8 +227,8 @@ class _ChatBottomBarState extends ConsumerState<_ChatBottomBar> { session != null && value.text.isNotEmpty ? () { ref - .read(chatControllerProvider(widget.id).notifier) - .sendMessage(_textController.text); + .read(chatControllerProvider(widget.options).notifier) + .postMessage(_textController.text); _textController.clear(); } : null, diff --git a/lib/src/view/clock/custom_clock_settings.dart b/lib/src/view/clock/custom_clock_settings.dart index 695fed61ef..e20e91900b 100644 --- a/lib/src/view/clock/custom_clock_settings.dart +++ b/lib/src/view/clock/custom_clock_settings.dart @@ -5,7 +5,6 @@ import 'package:lichess_mobile/src/model/lobby/game_setup_preferences.dart'; import 'package:lichess_mobile/src/styles/styles.dart'; import 'package:lichess_mobile/src/utils/l10n_context.dart'; import 'package:lichess_mobile/src/widgets/adaptive_bottom_sheet.dart'; -import 'package:lichess_mobile/src/widgets/buttons.dart'; import 'package:lichess_mobile/src/widgets/list.dart'; import 'package:lichess_mobile/src/widgets/non_linear_slider.dart'; @@ -43,8 +42,7 @@ class _CustomClockSettingsState extends State { ), Padding( padding: Styles.horizontalBodyPadding, - child: FatButton( - semanticsLabel: context.l10n.apply, + child: FilledButton( child: Text(context.l10n.apply), onPressed: () => widget.onSubmit(widget.player, TimeIncrement(time, increment)), ), diff --git a/lib/src/view/coordinate_training/coordinate_training_screen.dart b/lib/src/view/coordinate_training/coordinate_training_screen.dart index 9243a579b9..ba08f53e2c 100644 --- a/lib/src/view/coordinate_training/coordinate_training_screen.dart +++ b/lib/src/view/coordinate_training/coordinate_training_screen.dart @@ -347,11 +347,7 @@ class _Button extends StatelessWidget { @override Widget build(BuildContext context) { - return FatButton( - semanticsLabel: label, - onPressed: onPressed, - child: Text(label, style: Styles.bold), - ); + return FilledButton(onPressed: onPressed, child: Text(label, style: Styles.bold)); } } diff --git a/lib/src/view/game/game_body.dart b/lib/src/view/game/game_body.dart index 967e67fb8d..d35bcd808c 100644 --- a/lib/src/view/game/game_body.dart +++ b/lib/src/view/game/game_body.dart @@ -8,9 +8,7 @@ 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/common/id.dart'; -import 'package:lichess_mobile/src/model/common/service/sound_service.dart'; import 'package:lichess_mobile/src/model/common/speed.dart'; -import 'package:lichess_mobile/src/model/game/chat_controller.dart'; import 'package:lichess_mobile/src/model/game/game_controller.dart'; import 'package:lichess_mobile/src/model/game/game_preferences.dart'; import 'package:lichess_mobile/src/model/game/playable_game.dart'; @@ -21,12 +19,12 @@ import 'package:lichess_mobile/src/utils/gestures_exclusion.dart'; import 'package:lichess_mobile/src/utils/immersive_mode.dart'; import 'package:lichess_mobile/src/utils/l10n_context.dart'; import 'package:lichess_mobile/src/view/analysis/analysis_screen.dart'; +import 'package:lichess_mobile/src/view/chat/chat_screen.dart'; import 'package:lichess_mobile/src/view/game/correspondence_clock_widget.dart'; import 'package:lichess_mobile/src/view/game/game_loading_board.dart'; import 'package:lichess_mobile/src/view/game/game_player.dart'; import 'package:lichess_mobile/src/view/game/game_result_dialog.dart'; import 'package:lichess_mobile/src/view/game/game_screen_providers.dart'; -import 'package:lichess_mobile/src/view/game/message_screen.dart'; import 'package:lichess_mobile/src/view/tournament/tournament_screen.dart'; import 'package:lichess_mobile/src/widgets/adaptive_action_sheet.dart'; import 'package:lichess_mobile/src/widgets/board_table.dart'; @@ -34,7 +32,6 @@ import 'package:lichess_mobile/src/widgets/bottom_bar.dart'; import 'package:lichess_mobile/src/widgets/buttons.dart'; import 'package:lichess_mobile/src/widgets/clock.dart'; import 'package:lichess_mobile/src/widgets/platform_alert_dialog.dart'; -import 'package:lichess_mobile/src/widgets/user_full_name.dart'; import 'package:lichess_mobile/src/widgets/yes_no_dialog.dart'; /// Game body for the [GameScreen]. @@ -95,8 +92,6 @@ class GameBody extends ConsumerWidget { final boardPreferences = ref.watch(boardPreferencesProvider); final gamePrefs = ref.watch(gamePreferencesProvider); final blindfoldMode = gamePrefs.blindfoldMode ?? false; - final enableChat = gamePrefs.enableChat ?? false; - final kidModeAsync = ref.watch(kidModeProvider); switch (ref.watch(ctrlProvider)) { case AsyncError(error: final e, stackTrace: final s): @@ -105,14 +100,6 @@ class GameBody extends ConsumerWidget { child: LoadGameError('Sorry, we could not load the game. Please try again later.'), ); case AsyncData(value: final gameState, isRefreshing: false): - final isChatEnabled = - enableChat && !gameState.isZenModeActive && kidModeAsync.valueOrNull == false; - if (isChatEnabled) { - ref.listen( - chatControllerProvider(loadedGame.gameId), - (prev, state) => _chatListener(prev, state, context: context, ref: ref), - ); - } final youAre = gameState.game.youAre ?? Side.white; final archivedBlackClock = gameState.game.archivedBlackClockAt(gameState.stepCursor); final archivedWhiteClock = gameState.game.archivedWhiteClockAt(gameState.stepCursor); @@ -370,18 +357,6 @@ class GameBody extends ConsumerWidget { } } - void _chatListener( - AsyncValue? prev, - AsyncValue state, { - required BuildContext context, - required WidgetRef ref, - }) { - if (prev == null || !prev.hasValue || !state.hasValue) return; - if (state.requireValue.unreadMessages > prev.requireValue.unreadMessages) { - ref.read(soundServiceProvider).play(Sound.confirmation, volume: 0.5); - } - } - void _stateListener( AsyncValue? prev, AsyncValue state, { @@ -389,6 +364,16 @@ class GameBody extends ConsumerWidget { required WidgetRef ref, }) { if (state.hasValue) { + if (prev?.valueOrNull?.isZenModeActive == true && + state.requireValue.isZenModeActive == false) { + if (context.mounted) { + // when Zen mode is disabled, reload chat data + ref + .read(gameControllerProvider(loadedGame.gameId).notifier) + .onToggleChat(state.requireValue.chatOptions != null); + } + } + // If the game is no longer playable, show the game end dialog. // We want to show it only once, whether the game is already finished on // first load or not. @@ -462,34 +447,16 @@ class _GameBottomBar extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final ongoingGames = ref.watch(ongoingGamesProvider); final gamePrefs = ref.watch(gamePreferencesProvider); - final gameStateAsync = ref.watch(gameControllerProvider(id)); - final chatStateAsync = - gamePrefs.enableChat == true ? ref.watch(chatControllerProvider(id)) : null; final kidModeAsync = ref.watch(kidModeProvider); - return BottomBar( - children: gameStateAsync.when( - data: (gameState) { - final isChatEnabled = - chatStateAsync != null && - !gameState.isZenModeActive && - kidModeAsync.valueOrNull == false; - - final chatUnreadLabel = - isChatEnabled - ? chatStateAsync.maybeWhen( - data: - (s) => - s.unreadMessages > 0 - ? (s.unreadMessages < 10) - ? s.unreadMessages.toString() - : '9+' - : null, - orElse: () => null, - ) - : null; - - return [ + switch (ref.watch(gameControllerProvider(id))) { + case AsyncData(value: final gameState): + final canShowChat = + gamePrefs.enableChat == true && + gameState.chatOptions != null && + kidModeAsync.valueOrNull == false; + return BottomBar( + children: [ BottomBarButton( label: context.l10n.menu, onTap: () { @@ -497,20 +464,6 @@ class _GameBottomBar extends ConsumerWidget { }, icon: Icons.menu, ), - if (!gameState.game.playable) - BottomBarButton( - label: context.l10n.mobileShowResult, - onTap: () { - showAdaptiveDialog( - context: context, - builder: - (context) => - GameResultDialog(id: id, onNewOpponentCallback: onNewOpponentCallback), - barrierDismissible: true, - ); - }, - icon: Icons.info_outline, - ), if (gameState.canBerserk) BottomBarButton( label: context.l10n.arenaBerserk, @@ -593,13 +546,17 @@ class _GameBottomBar extends ConsumerWidget { ) else if (gameState.game.finished) BottomBarButton( - label: context.l10n.analysis, - icon: Icons.biotech, + label: context.l10n.mobileShowResult, onTap: () { - Navigator.of( - context, - ).push(AnalysisScreen.buildRoute(context, gameState.analysisOptions)); + showAdaptiveDialog( + context: context, + builder: + (context) => + GameResultDialog(id: id, onNewOpponentCallback: onNewOpponentCallback), + barrierDismissible: true, + ); }, + icon: Icons.info_outline, ) else BottomBarButton( @@ -620,25 +577,7 @@ class _GameBottomBar extends ConsumerWidget { : null, icon: Icons.flag_outlined, ), - if (kidModeAsync.valueOrNull == false) - BottomBarButton( - label: context.l10n.chat, - onTap: - isChatEnabled - ? () { - Navigator.of(context).push( - MessageScreen.buildRoute( - context, - title: UserFullNameWidget(user: gameState.game.opponent?.user), - me: gameState.game.me?.user, - id: id, - ), - ); - } - : null, - icon: Icons.chat_bubble_outline, - badgeLabel: chatUnreadLabel, - ), + if (canShowChat) ChatBottomBarButton(options: gameState.chatOptions!), RepeatButton( onLongPress: gameState.canGoBackward ? () => _moveBackward(ref) : null, child: BottomBarButton( @@ -661,12 +600,11 @@ class _GameBottomBar extends ConsumerWidget { gameState.game.sideToMove == gameState.game.youAre, ), ), - ]; - }, - loading: () => [], - error: (e, s) => [], - ), - ); + ], + ); + case _: + return const BottomBar(children: []); + } } void _moveForward(WidgetRef ref) { @@ -688,7 +626,8 @@ class _GameBottomBar extends ConsumerWidget { ref.read(isBoardTurnedProvider.notifier).toggle(); }, ), - if (gameState.game.playable && gameState.game.meta.speed == Speed.correspondence) + if (gameState.game.playable && gameState.game.meta.speed == Speed.correspondence || + gameState.game.finished) BottomSheetAction( makeLabel: (context) => Text(context.l10n.analysis), onPressed: () { diff --git a/lib/src/view/game/game_common_widgets.dart b/lib/src/view/game/game_common_widgets.dart index 8301ba0d39..448d952bb0 100644 --- a/lib/src/view/game/game_common_widgets.dart +++ b/lib/src/view/game/game_common_widgets.dart @@ -14,6 +14,7 @@ import 'package:lichess_mobile/src/view/game/game_screen.dart'; import 'package:lichess_mobile/src/widgets/feedback.dart'; import 'package:lichess_mobile/src/widgets/platform.dart'; import 'package:lichess_mobile/src/widgets/platform_context_menu_button.dart'; +import 'package:share_plus/share_plus.dart'; /// Opens a game screen for the given [LightExportedGame]. /// @@ -145,7 +146,7 @@ List makeFinishedGameShareContextMenuActions( icon: const PlatformShareIcon(), label: context.l10n.mobileShareGameURL, onPressed: () { - launchShareDialog(context, uri: lichessUri('/$gameId/${orientation.name}')); + launchShareDialog(context, ShareParams(uri: lichessUri('/$gameId/${orientation.name}'))); }, ), ContextMenuAction( @@ -155,7 +156,14 @@ List makeFinishedGameShareContextMenuActions( try { final (gif, game) = await ref.read(gameShareServiceProvider).gameGif(gameId, orientation); if (context.mounted) { - launchShareDialog(context, files: [gif], subject: game.shareTitle(context.l10n)); + launchShareDialog( + context, + ShareParams( + fileNameOverrides: ['$gameId.gif'], + files: [gif], + subject: game.shareTitle(context.l10n), + ), + ); } } catch (e) { debugPrint(e.toString()); @@ -172,7 +180,7 @@ List makeFinishedGameShareContextMenuActions( try { final pgn = await ref.read(gameShareServiceProvider).annotatedPgn(gameId); if (context.mounted) { - launchShareDialog(context, text: pgn); + launchShareDialog(context, ShareParams(text: pgn)); } } catch (e) { if (context.mounted) { @@ -188,7 +196,7 @@ List makeFinishedGameShareContextMenuActions( try { final pgn = await ref.read(gameShareServiceProvider).rawPgn(gameId); if (context.mounted) { - launchShareDialog(context, text: pgn); + launchShareDialog(context, ShareParams(text: pgn)); } } catch (e) { if (context.mounted) { diff --git a/lib/src/view/game/game_list_tile.dart b/lib/src/view/game/game_list_tile.dart index 8e410ba2f2..52aca60abc 100644 --- a/lib/src/view/game/game_list_tile.dart +++ b/lib/src/view/game/game_list_tile.dart @@ -24,6 +24,7 @@ import 'package:lichess_mobile/src/widgets/adaptive_bottom_sheet.dart'; import 'package:lichess_mobile/src/widgets/board_thumbnail.dart'; import 'package:lichess_mobile/src/widgets/feedback.dart'; import 'package:lichess_mobile/src/widgets/user_full_name.dart'; +import 'package:share_plus/share_plus.dart'; final _dateFormatter = DateFormat.yMMMd().add_Hm(); @@ -299,7 +300,10 @@ class GameContextMenu extends ConsumerWidget { if (!isTabletOrLarger(context)) ...[ BottomSheetContextMenuAction( onPressed: () { - launchShareDialog(context, uri: lichessUri('/${game.id}/${orientation.name}')); + launchShareDialog( + context, + ShareParams(uri: lichessUri('/${game.id}/${orientation.name}')), + ); }, icon: Theme.of(context).platform == TargetPlatform.iOS ? Icons.ios_share : Icons.share, child: Text(context.l10n.mobileShareGameURL), @@ -315,9 +319,12 @@ class GameContextMenu extends ConsumerWidget { if (context.mounted) { launchShareDialog( context, - files: [gif], - subject: - '${game.perf.title} • ${context.l10n.resVsX(game.white.fullName(context.l10n), game.black.fullName(context.l10n))}', + ShareParams( + files: [gif], + fileNameOverrides: ['${game.id}.gif'], + subject: + '${game.perf.title} • ${context.l10n.resVsX(game.white.fullName(context.l10n), game.black.fullName(context.l10n))}', + ), ); } } catch (e) { @@ -335,7 +342,7 @@ class GameContextMenu extends ConsumerWidget { try { final pgn = await ref.read(gameShareServiceProvider).annotatedPgn(game.id); if (context.mounted) { - launchShareDialog(context, text: pgn); + launchShareDialog(context, ShareParams(text: pgn)); } } catch (e) { if (context.mounted) { @@ -352,7 +359,7 @@ class GameContextMenu extends ConsumerWidget { try { final pgn = await ref.read(gameShareServiceProvider).rawPgn(game.id); if (context.mounted) { - launchShareDialog(context, text: pgn); + launchShareDialog(context, ShareParams(text: pgn)); } } catch (e) { if (context.mounted) { diff --git a/lib/src/view/game/game_result_dialog.dart b/lib/src/view/game/game_result_dialog.dart index f973a60514..89562510cc 100644 --- a/lib/src/view/game/game_result_dialog.dart +++ b/lib/src/view/game/game_result_dialog.dart @@ -12,10 +12,11 @@ import 'package:lichess_mobile/src/model/game/game_controller.dart'; import 'package:lichess_mobile/src/model/game/game_status.dart'; import 'package:lichess_mobile/src/model/game/over_the_board_game.dart'; import 'package:lichess_mobile/src/model/game/playable_game.dart'; +import 'package:lichess_mobile/src/model/tournament/tournament_controller.dart'; +import 'package:lichess_mobile/src/styles/styles.dart'; import 'package:lichess_mobile/src/utils/l10n_context.dart'; import 'package:lichess_mobile/src/view/analysis/analysis_screen.dart'; import 'package:lichess_mobile/src/view/game/status_l10n.dart'; -import 'package:lichess_mobile/src/widgets/buttons.dart'; class GameResultDialog extends ConsumerStatefulWidget { const GameResultDialog({required this.id, required this.onNewOpponentCallback, super.key}); @@ -74,23 +75,32 @@ class _GameResultDialogState extends ConsumerState { children: [ const Padding( padding: EdgeInsets.only(bottom: 15.0), + // TODO l10n child: Text('Your opponent has offered a rematch', textAlign: TextAlign.center), ), Padding( padding: const EdgeInsets.only(bottom: 15.0), child: Row( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ - FatButton( - semanticsLabel: context.l10n.rematch, - child: const Text('Accept rematch'), + FilledButton.icon( + icon: const Icon(Icons.check), + style: FilledButton.styleFrom( + foregroundColor: context.lichessColors.good, + backgroundColor: context.lichessColors.good.withValues(alpha: 0.3), + ), + label: Text(context.l10n.accept), onPressed: () { ref.read(ctrlProvider.notifier).proposeOrAcceptRematch(); }, ), - SecondaryButton( - semanticsLabel: context.l10n.rematch, - child: const Text('Decline'), + FilledButton.icon( + icon: const Icon(Icons.close), + style: FilledButton.styleFrom( + foregroundColor: context.lichessColors.error, + backgroundColor: context.lichessColors.error.withValues(alpha: 0.3), + ), + label: Text(context.l10n.decline), onPressed: () { ref.read(ctrlProvider.notifier).declineRematch(); }, @@ -106,16 +116,14 @@ class _GameResultDialogState extends ConsumerState { : CrossFadeState.showFirst, ), if (gameState.game.me?.offeringRematch == true) - SecondaryButton( - semanticsLabel: context.l10n.cancelRematchOffer, + FilledButton.tonal( onPressed: () { ref.read(ctrlProvider.notifier).declineRematch(); }, child: Text(context.l10n.cancelRematchOffer, textAlign: TextAlign.center), ) else if (gameState.canOfferRematch) - SecondaryButton( - semanticsLabel: context.l10n.rematch, + FilledButton( onPressed: _activateButtons && gameState.game.opponent?.onGame == true && @@ -127,8 +135,7 @@ class _GameResultDialogState extends ConsumerState { child: Text(context.l10n.rematch, textAlign: TextAlign.center), ), if (gameState.canGetNewOpponent) - SecondaryButton( - semanticsLabel: context.l10n.newOpponent, + FilledButton.tonal( onPressed: _activateButtons ? () { @@ -138,9 +145,9 @@ class _GameResultDialogState extends ConsumerState { : null, child: Text(context.l10n.newOpponent, textAlign: TextAlign.center), ), - if (gameState.tournament?.isOngoing == true) - SecondaryButton( - semanticsLabel: context.l10n.backToTournament, + if (gameState.tournament?.isOngoing == true) ...[ + FilledButton.icon( + icon: const Icon(Icons.play_arrow), onPressed: () { // Close the dialog Navigator.of(context).popUntil((route) => route is! PopupRoute); @@ -149,11 +156,27 @@ class _GameResultDialogState extends ConsumerState { Navigator.of(context).pop(); // Pop the screen after frame }); }, - child: Text(context.l10n.backToTournament, textAlign: TextAlign.center), + label: Text(context.l10n.backToTournament, textAlign: TextAlign.center), ), + FilledButton.icon( + icon: const Icon(Icons.pause), + onPressed: () { + // Pause the tournament + ref + .read(tournamentControllerProvider(gameState.tournament!.id).notifier) + .joinOrPause(); + // Close the dialog + Navigator.of(context).popUntil((route) => route is! PopupRoute); + // Close the game screen + WidgetsBinding.instance.addPostFrameCallback((_) { + Navigator.of(context).pop(); // Pop the screen after frame + }); + }, + label: Text(context.l10n.pause, textAlign: TextAlign.center), + ), + ], if (gameState.game.userAnalysable) - SecondaryButton( - semanticsLabel: context.l10n.analysis, + FilledButton.tonal( onPressed: () { Navigator.of( context, @@ -199,13 +222,11 @@ class OverTheBoardGameResultDialog extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ GameResult(game: game), - SecondaryButton( - semanticsLabel: context.l10n.rematch, + FilledButton( onPressed: onRematch, child: Text(context.l10n.rematch, textAlign: TextAlign.center), ), - SecondaryButton( - semanticsLabel: context.l10n.analysis, + FilledButton.tonal( onPressed: () { Navigator.of(context).push( AnalysisScreen.buildRoute( diff --git a/lib/src/view/game/game_screen.dart b/lib/src/view/game/game_screen.dart index 39f2ede98b..ee648859c4 100644 --- a/lib/src/view/game/game_screen.dart +++ b/lib/src/view/game/game_screen.dart @@ -2,18 +2,15 @@ import 'package:dartchess/dartchess.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/challenge/challenge.dart'; import 'package:lichess_mobile/src/model/common/id.dart'; import 'package:lichess_mobile/src/model/common/time_increment.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_filter.dart'; -import 'package:lichess_mobile/src/model/game/game_history.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/lobby/game_setup_preferences.dart'; -import 'package:lichess_mobile/src/navigation.dart'; import 'package:lichess_mobile/src/network/http.dart'; import 'package:lichess_mobile/src/utils/duration.dart'; import 'package:lichess_mobile/src/utils/l10n_context.dart'; @@ -111,35 +108,11 @@ class GameScreen extends ConsumerStatefulWidget { enum _GameSource { lobby, challenge, game } -class _GameScreenState extends ConsumerState with RouteAware { +class _GameScreenState extends ConsumerState { final _whiteClockKey = GlobalKey(debugLabel: 'whiteClockOnGameScreen'); final _blackClockKey = GlobalKey(debugLabel: 'blackClockOnGameScreen'); final _boardKey = GlobalKey(debugLabel: 'boardOnGameScreen'); - @override - void didChangeDependencies() { - super.didChangeDependencies(); - final route = ModalRoute.of(context); - if (route != null && route is PageRoute) { - rootNavPageRouteObserver.subscribe(this, route); - } - } - - @override - void dispose() { - rootNavPageRouteObserver.unsubscribe(this); - super.dispose(); - } - - @override - void didPop() { - if (mounted && (widget.source == _GameSource.lobby || widget.source == _GameSource.challenge)) { - ref.invalidate(myRecentGamesProvider); - ref.invalidate(accountProvider); - } - super.didPop(); - } - @override Widget build(BuildContext context) { final provider = currentGameProvider( diff --git a/lib/src/view/home/home_tab_screen.dart b/lib/src/view/home/home_tab_screen.dart index 3b58939a67..19d8bab3ce 100644 --- a/lib/src/view/home/home_tab_screen.dart +++ b/lib/src/view/home/home_tab_screen.dart @@ -17,6 +17,7 @@ import 'package:lichess_mobile/src/navigation.dart'; import 'package:lichess_mobile/src/network/connectivity.dart'; import 'package:lichess_mobile/src/styles/lichess_icons.dart'; import 'package:lichess_mobile/src/styles/styles.dart'; +import 'package:lichess_mobile/src/utils/focus_detector.dart'; import 'package:lichess_mobile/src/utils/l10n.dart'; import 'package:lichess_mobile/src/utils/l10n_context.dart'; import 'package:lichess_mobile/src/utils/screen.dart'; @@ -52,6 +53,8 @@ class HomeTabScreen extends ConsumerStatefulWidget { class _HomeScreenState extends ConsumerState with RouteAware { final _refreshKey = GlobalKey(); + DateTime? _focusLostAt; + bool wasOnline = true; bool hasRefreshed = false; @@ -117,38 +120,52 @@ class _HomeScreenState extends ConsumerState with RouteAware { nbOfGames: nbOfGames, ); - return Scaffold( - appBar: AppBar( - title: const Text('lichess.org'), - leading: const AccountIconButton(), - actions: [ - IconButton( - onPressed: () { - ref.read(editModeProvider.notifier).state = !isEditing; - }, - icon: Icon(isEditing ? Icons.save_outlined : Icons.app_registration), - tooltip: isEditing ? 'Save' : 'Edit', - ), - const _ChallengeScreenButton(), - const _PlayerScreenButton(), - ], - ), - body: RefreshIndicator.adaptive( - key: _refreshKey, - onRefresh: () => _refreshData(isOnline: status.isOnline), - child: ListView(controller: homeScrollController, children: widgets), + return FocusDetector( + onFocusLost: () { + _focusLostAt = DateTime.now(); + }, + onFocusRegained: () { + if (context.mounted && _focusLostAt != null) { + final duration = DateTime.now().difference(_focusLostAt!); + if (duration.inSeconds < 60) { + return; + } + _refreshData(isOnline: status.isOnline); + } + }, + child: Scaffold( + appBar: AppBar( + title: const Text('lichess.org'), + leading: const AccountIconButton(), + actions: [ + IconButton( + onPressed: () { + ref.read(editModeProvider.notifier).state = !isEditing; + }, + icon: Icon(isEditing ? Icons.save_outlined : Icons.app_registration), + tooltip: isEditing ? 'Save' : 'Edit', + ), + const _ChallengeScreenButton(), + const _PlayerScreenButton(), + ], + ), + body: RefreshIndicator.adaptive( + key: _refreshKey, + onRefresh: () => _refreshData(isOnline: status.isOnline), + child: ListView(controller: homeScrollController, children: widgets), + ), + bottomSheet: const OfflineBanner(), + floatingActionButton: + isTablet + ? null + : FloatingActionButton.extended( + onPressed: () { + Navigator.of(context).push(PlayScreen.buildRoute(context)); + }, + icon: const Icon(Icons.add), + label: Text(context.l10n.play), + ), ), - bottomSheet: const OfflineBanner(), - floatingActionButton: - isTablet - ? null - : FloatingActionButton.extended( - onPressed: () { - Navigator.of(context).push(PlayScreen.buildRoute(context)); - }, - icon: const Icon(Icons.add), - label: Text(context.l10n.play), - ), ); }, error: (_, _) => const CenterLoadingIndicator(), @@ -225,8 +242,7 @@ class _HomeScreenState extends ConsumerState with RouteAware { if (Theme.of(context).platform != TargetPlatform.iOS && (session == null || session.user.isPatron != true)) ...[ Center( - child: SecondaryButton( - semanticsLabel: context.l10n.patronDonate, + child: FilledButton.tonal( onPressed: () { launchUrl(Uri.parse('https://lichess.org/patron')); }, @@ -236,8 +252,7 @@ class _HomeScreenState extends ConsumerState with RouteAware { const SizedBox(height: 16.0), ], Center( - child: SecondaryButton( - semanticsLabel: context.l10n.aboutX('Lichess...'), + child: FilledButton.tonal( onPressed: () { launchUrl(Uri.parse('https://lichess.org/about')); }, @@ -323,6 +338,7 @@ class _HomeScreenState extends ConsumerState with RouteAware { ref.refresh(myRecentGamesProvider.future), if (isOnline) ref.refresh(accountProvider.future), if (isOnline) ref.refresh(ongoingGamesProvider.future), + if (isOnline) ref.refresh(featuredTournamentsProvider.future), ]); } } @@ -334,8 +350,7 @@ class _SignInWidget extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final authController = ref.watch(authControllerProvider); - return SecondaryButton( - semanticsLabel: context.l10n.signIn, + return FilledButton.tonal( onPressed: authController.isLoading ? null @@ -422,12 +437,7 @@ class _HelloWidget extends ConsumerWidget { const iconSize = 24.0; - // fetch the account user to be sure we have the latest data (flair, etc.) - final accountUser = ref - .watch(accountProvider) - .maybeWhen(data: (data) => data?.lightUser, orElse: () => null); - - final user = accountUser ?? session?.user; + final user = session?.user; return MediaQuery.withClampedTextScaling( maxScaleFactor: 1.3, diff --git a/lib/src/view/over_the_board/configure_over_the_board_game.dart b/lib/src/view/over_the_board/configure_over_the_board_game.dart index 7b2a0ac3b0..388c096c18 100644 --- a/lib/src/view/over_the_board/configure_over_the_board_game.dart +++ b/lib/src/view/over_the_board/configure_over_the_board_game.dart @@ -10,7 +10,6 @@ import 'package:lichess_mobile/src/styles/styles.dart'; import 'package:lichess_mobile/src/utils/l10n_context.dart'; import 'package:lichess_mobile/src/widgets/adaptive_bottom_sheet.dart'; import 'package:lichess_mobile/src/widgets/adaptive_choice_picker.dart'; -import 'package:lichess_mobile/src/widgets/buttons.dart'; import 'package:lichess_mobile/src/widgets/list.dart'; import 'package:lichess_mobile/src/widgets/non_linear_slider.dart'; import 'package:lichess_mobile/src/widgets/settings.dart'; @@ -132,7 +131,7 @@ class _ConfigureOverTheBoardGameSheetState extends ConsumerState<_ConfigureOverT ), Padding( padding: Styles.horizontalBodyPadding, - child: SecondaryButton( + child: FilledButton( onPressed: () { ref.read(overTheBoardClockProvider.notifier).setupClock(timeIncrement); ref @@ -140,7 +139,6 @@ class _ConfigureOverTheBoardGameSheetState extends ConsumerState<_ConfigureOverT .startNewGame(chosenVariant, timeIncrement); Navigator.pop(context); }, - semanticsLabel: context.l10n.play, child: Text(context.l10n.play, style: Styles.bold), ), ), diff --git a/lib/src/view/play/challenge_odd_bots_screen.dart b/lib/src/view/play/challenge_odd_bots_screen.dart index 4730162775..59448baec4 100644 --- a/lib/src/view/play/challenge_odd_bots_screen.dart +++ b/lib/src/view/play/challenge_odd_bots_screen.dart @@ -13,7 +13,6 @@ import 'package:lichess_mobile/src/utils/l10n_context.dart'; import 'package:lichess_mobile/src/utils/navigation.dart'; import 'package:lichess_mobile/src/view/game/game_screen.dart'; import 'package:lichess_mobile/src/widgets/board_thumbnail.dart'; -import 'package:lichess_mobile/src/widgets/buttons.dart'; import 'package:lichess_mobile/src/widgets/non_linear_slider.dart'; class ChallengeOddBotsScreen extends StatelessWidget { @@ -255,8 +254,7 @@ class _ChallengeBodyState extends ConsumerState<_ChallengeBody> { const SizedBox(height: 20), Padding( padding: const EdgeInsets.symmetric(horizontal: 20.0), - child: FatButton( - semanticsLabel: context.l10n.challengeChallengeToPlay, + child: FilledButton( onPressed: fen != null ? () { diff --git a/lib/src/view/play/create_challenge_screen.dart b/lib/src/view/play/create_challenge_screen.dart index dccdc0e86b..45ed604e8e 100644 --- a/lib/src/view/play/create_challenge_screen.dart +++ b/lib/src/view/play/create_challenge_screen.dart @@ -22,7 +22,6 @@ import 'package:lichess_mobile/src/view/game/game_screen.dart'; import 'package:lichess_mobile/src/view/user/challenge_requests_screen.dart'; import 'package:lichess_mobile/src/widgets/adaptive_choice_picker.dart'; import 'package:lichess_mobile/src/widgets/board_preview.dart'; -import 'package:lichess_mobile/src/widgets/buttons.dart'; import 'package:lichess_mobile/src/widgets/expanded_section.dart'; import 'package:lichess_mobile/src/widgets/feedback.dart'; import 'package:lichess_mobile/src/widgets/non_linear_slider.dart'; @@ -329,8 +328,7 @@ class _ChallengeBodyState extends ConsumerState<_ChallengeBody> { builder: (context, snapshot) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 20.0), - child: FatButton( - semanticsLabel: context.l10n.challengeChallengeToPlay, + child: FilledButton( onPressed: timeControl == ChallengeTimeControlType.clock ? isValidTimeControl && isValidPosition diff --git a/lib/src/view/play/create_custom_game_screen.dart b/lib/src/view/play/create_custom_game_screen.dart index 786622a463..09e44f757a 100644 --- a/lib/src/view/play/create_custom_game_screen.dart +++ b/lib/src/view/play/create_custom_game_screen.dart @@ -25,7 +25,6 @@ import 'package:lichess_mobile/src/view/play/challenge_list_item.dart'; import 'package:lichess_mobile/src/view/play/common_play_widgets.dart'; import 'package:lichess_mobile/src/widgets/adaptive_action_sheet.dart'; import 'package:lichess_mobile/src/widgets/adaptive_choice_picker.dart'; -import 'package:lichess_mobile/src/widgets/buttons.dart'; import 'package:lichess_mobile/src/widgets/expanded_section.dart'; import 'package:lichess_mobile/src/widgets/feedback.dart'; import 'package:lichess_mobile/src/widgets/list.dart'; @@ -445,8 +444,7 @@ class _CreateGameBodyState extends ConsumerState<_CreateGameBody> { builder: (context, snapshot) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 20.0), - child: FatButton( - semanticsLabel: context.l10n.createAGame, + child: FilledButton( onPressed: timeControl == TimeControl.realTime ? isValidTimeControl diff --git a/lib/src/view/play/time_control_modal.dart b/lib/src/view/play/time_control_modal.dart index bf399167d0..f3983d4f78 100644 --- a/lib/src/view/play/time_control_modal.dart +++ b/lib/src/view/play/time_control_modal.dart @@ -7,7 +7,6 @@ import 'package:lichess_mobile/src/styles/lichess_icons.dart'; import 'package:lichess_mobile/src/styles/styles.dart'; import 'package:lichess_mobile/src/utils/l10n_context.dart'; import 'package:lichess_mobile/src/widgets/adaptive_bottom_sheet.dart'; -import 'package:lichess_mobile/src/widgets/buttons.dart'; import 'package:lichess_mobile/src/widgets/non_linear_slider.dart'; import 'package:lichess_mobile/src/widgets/settings.dart'; @@ -180,9 +179,8 @@ class TimeControlModal extends ConsumerWidget { ), ], ), - FatButton( + FilledButton( onPressed: custom.isInfinite ? null : () => onSelected(custom), - semanticsLabel: 'OK', child: Text(context.l10n.mobileOkButton, style: Styles.bold), ), ], diff --git a/lib/src/view/puzzle/puzzle_screen.dart b/lib/src/view/puzzle/puzzle_screen.dart index 98d32b5dc3..95537a0ed3 100644 --- a/lib/src/view/puzzle/puzzle_screen.dart +++ b/lib/src/view/puzzle/puzzle_screen.dart @@ -45,6 +45,7 @@ import 'package:lichess_mobile/src/widgets/bottom_bar.dart'; import 'package:lichess_mobile/src/widgets/buttons.dart'; import 'package:lichess_mobile/src/widgets/list.dart'; import 'package:lichess_mobile/src/widgets/settings.dart'; +import 'package:share_plus/share_plus.dart'; class PuzzleScreen extends ConsumerStatefulWidget { /// Creates a new puzzle screen. @@ -562,7 +563,7 @@ class _BottomBarState extends ConsumerState<_BottomBar> { onPressed: () { launchShareDialog( context, - text: lichessUri('/training/${puzzleState.puzzle.puzzle.id}').toString(), + ShareParams(text: lichessUri('/training/${puzzleState.puzzle.puzzle.id}').toString()), ); }, ), diff --git a/lib/src/view/puzzle/storm_screen.dart b/lib/src/view/puzzle/storm_screen.dart index af0d3944a2..d2462035b1 100644 --- a/lib/src/view/puzzle/storm_screen.dart +++ b/lib/src/view/puzzle/storm_screen.dart @@ -629,8 +629,7 @@ class _RunStatsPopupState extends ConsumerState<_RunStatsPopup> { const SizedBox(height: 10.0), Padding( padding: Styles.horizontalBodyPadding, - child: FatButton( - semanticsLabel: context.l10n.stormPlayAgain, + child: FilledButton( onPressed: () { ref.invalidate(stormProvider); Navigator.of(context).pop(); diff --git a/lib/src/view/puzzle/streak_screen.dart b/lib/src/view/puzzle/streak_screen.dart index 2f830b92b0..1c7630b57d 100644 --- a/lib/src/view/puzzle/streak_screen.dart +++ b/lib/src/view/puzzle/streak_screen.dart @@ -25,6 +25,7 @@ import 'package:lichess_mobile/src/widgets/board_table.dart'; import 'package:lichess_mobile/src/widgets/bottom_bar.dart'; import 'package:lichess_mobile/src/widgets/platform_alert_dialog.dart'; import 'package:lichess_mobile/src/widgets/yes_no_dialog.dart'; +import 'package:share_plus/share_plus.dart'; class StreakScreen extends StatelessWidget { const StreakScreen({super.key}); @@ -240,7 +241,9 @@ class _BottomBar extends ConsumerWidget { onTap: () { launchShareDialog( context, - text: lichessUri('/training/${puzzleState.puzzle.puzzle.id}').toString(), + ShareParams( + text: lichessUri('/training/${puzzleState.puzzle.puzzle.id}').toString(), + ), ); }, label: 'Share this puzzle', diff --git a/lib/src/view/study/study_bottom_bar.dart b/lib/src/view/study/study_bottom_bar.dart index bd93c9df70..57a7232e2b 100644 --- a/lib/src/view/study/study_bottom_bar.dart +++ b/lib/src/view/study/study_bottom_bar.dart @@ -18,6 +18,7 @@ import 'package:lichess_mobile/src/widgets/bottom_bar.dart'; import 'package:lichess_mobile/src/widgets/buttons.dart'; import 'package:lichess_mobile/src/widgets/feedback.dart'; import 'package:lichess_mobile/src/widgets/platform.dart'; +import 'package:share_plus/share_plus.dart'; class StudyBottomBar extends ConsumerWidget { const StudyBottomBar({required this.id}); @@ -366,7 +367,10 @@ Future _showStudyMenu(StudyId id, BuildContext context, WidgetRef ref) { BottomSheetAction( makeLabel: (context) => Text(context.l10n.studyStudyUrl), onPressed: () { - launchShareDialog(context, uri: lichessUri('/study/${state.study.id}')); + launchShareDialog( + context, + ShareParams(uri: lichessUri('/study/${state.study.id}')), + ); }, ), BottomSheetAction( @@ -374,7 +378,9 @@ Future _showStudyMenu(StudyId id, BuildContext context, WidgetRef ref) { onPressed: () { launchShareDialog( context, - uri: lichessUri('/study/${state.study.id}/${state.study.chapter.id}'), + ShareParams( + uri: lichessUri('/study/${state.study.id}/${state.study.chapter.id}'), + ), ); }, ), @@ -387,7 +393,7 @@ Future _showStudyMenu(StudyId id, BuildContext context, WidgetRef ref) { .read(studyRepositoryProvider) .getStudyPgn(state.study.id); if (context.mounted) { - launchShareDialog(context, text: pgn); + launchShareDialog(context, ShareParams(text: pgn)); } } catch (e) { if (context.mounted) { @@ -399,7 +405,7 @@ Future _showStudyMenu(StudyId id, BuildContext context, WidgetRef ref) { BottomSheetAction( makeLabel: (context) => Text(context.l10n.studyChapterPgn), onPressed: () { - launchShareDialog(context, text: state.pgn); + launchShareDialog(context, ShareParams(text: state.pgn)); }, ), if (state.currentPosition != null) @@ -417,9 +423,11 @@ Future _showStudyMenu(StudyId id, BuildContext context, WidgetRef ref) { if (context.mounted) { launchShareDialog( context, - files: [image], - subject: context.l10n.puzzleFromGameLink( - lichessUri('/study/${state.study.id}').toString(), + ShareParams( + files: [image], + subject: context.l10n.puzzleFromGameLink( + lichessUri('/study/${state.study.id}').toString(), + ), ), ); } @@ -440,8 +448,13 @@ Future _showStudyMenu(StudyId id, BuildContext context, WidgetRef ref) { if (context.mounted) { launchShareDialog( context, - files: [gif], - subject: context.l10n.studyChapterX(state.study.currentChapterMeta.name), + ShareParams( + files: [gif], + fileNameOverrides: ['${state.study.chapter.id}.gif'], + subject: context.l10n.studyChapterX( + state.study.currentChapterMeta.name, + ), + ), ); } } catch (e) { diff --git a/lib/src/view/tools/load_position_screen.dart b/lib/src/view/tools/load_position_screen.dart index 88f5daa027..56b52ed202 100644 --- a/lib/src/view/tools/load_position_screen.dart +++ b/lib/src/view/tools/load_position_screen.dart @@ -8,7 +8,6 @@ import 'package:lichess_mobile/src/utils/l10n_context.dart'; import 'package:lichess_mobile/src/utils/navigation.dart'; import 'package:lichess_mobile/src/view/analysis/analysis_screen.dart'; import 'package:lichess_mobile/src/view/board_editor/board_editor_screen.dart'; -import 'package:lichess_mobile/src/widgets/buttons.dart'; class LoadPositionScreen extends StatelessWidget { const LoadPositionScreen({super.key}); @@ -77,8 +76,7 @@ class _BodyState extends State<_Body> { padding: Styles.bodySectionBottomPadding, child: Column( children: [ - FatButton( - semanticsLabel: context.l10n.analysis, + FilledButton( onPressed: parsedInput != null ? () => Navigator.of( @@ -89,8 +87,7 @@ class _BodyState extends State<_Body> { child: Text(context.l10n.analysis), ), const SizedBox(height: 16.0), - FatButton( - semanticsLabel: context.l10n.boardEditor, + FilledButton( onPressed: parsedInput != null ? () => Navigator.of(context, rootNavigator: true).push( diff --git a/lib/src/view/tournament/tournament_list_screen.dart b/lib/src/view/tournament/tournament_list_screen.dart index ba1df895c1..0d7b1769eb 100644 --- a/lib/src/view/tournament/tournament_list_screen.dart +++ b/lib/src/view/tournament/tournament_list_screen.dart @@ -256,7 +256,7 @@ class _TournamentListItem extends StatelessWidget { children: [ Text( '${_hourMinuteFormat.format(tournament.startsAt)} - ${_hourMinuteFormat.format(tournament.finishesAt)}', - style: const TextStyle(fontSize: 14), + style: const TextStyle(fontSize: 14, fontFeatures: [FontFeature.tabularFigures()]), ), Text.rich( TextSpan( diff --git a/lib/src/view/tournament/tournament_screen.dart b/lib/src/view/tournament/tournament_screen.dart index 2d707ac4a5..e0ef68d05c 100644 --- a/lib/src/view/tournament/tournament_screen.dart +++ b/lib/src/view/tournament/tournament_screen.dart @@ -1,9 +1,11 @@ +import 'dart:io'; import 'dart:math'; -import 'package:dartchess/dartchess.dart'; +import 'package:dartchess/dartchess.dart' hide File; import 'package:fast_immutable_collections/fast_immutable_collections.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/auth/auth_controller.dart'; import 'package:lichess_mobile/src/model/auth/auth_session.dart'; @@ -11,6 +13,8 @@ import 'package:lichess_mobile/src/model/common/id.dart'; import 'package:lichess_mobile/src/model/game/game_history.dart'; import 'package:lichess_mobile/src/model/tournament/tournament.dart'; import 'package:lichess_mobile/src/model/tournament/tournament_controller.dart'; +import 'package:lichess_mobile/src/model/tournament/tournament_repository.dart'; +import 'package:lichess_mobile/src/model/user/user.dart'; import 'package:lichess_mobile/src/navigation.dart'; import 'package:lichess_mobile/src/styles/lichess_colors.dart'; import 'package:lichess_mobile/src/styles/lichess_icons.dart'; @@ -20,6 +24,8 @@ import 'package:lichess_mobile/src/utils/duration.dart'; import 'package:lichess_mobile/src/utils/focus_detector.dart'; import 'package:lichess_mobile/src/utils/l10n_context.dart'; import 'package:lichess_mobile/src/utils/navigation.dart'; +import 'package:lichess_mobile/src/utils/share.dart'; +import 'package:lichess_mobile/src/view/chat/chat_screen.dart'; import 'package:lichess_mobile/src/view/game/game_screen.dart'; import 'package:lichess_mobile/src/widgets/board_thumbnail.dart'; import 'package:lichess_mobile/src/widgets/bottom_bar.dart'; @@ -28,14 +34,22 @@ import 'package:lichess_mobile/src/widgets/clock.dart'; import 'package:lichess_mobile/src/widgets/feedback.dart'; import 'package:lichess_mobile/src/widgets/misc.dart'; import 'package:lichess_mobile/src/widgets/user_full_name.dart'; +import 'package:path_provider/path_provider.dart' show getTemporaryDirectory; +import 'package:share_plus/share_plus.dart'; class TournamentScreen extends ConsumerStatefulWidget { const TournamentScreen({required this.id}); final TournamentId id; + static const String routeName = '/tournament'; + static Route buildRoute(BuildContext context, TournamentId id) { - return buildScreenRoute(context, screen: TournamentScreen(id: id)); + return buildScreenRoute( + context, + screen: TournamentScreen(id: id), + settings: const RouteSettings(name: routeName), + ); } @override @@ -76,6 +90,9 @@ class _TournamentScreenState extends ConsumerState with RouteA tournamentControllerProvider(widget.id).select((value) => value.valueOrNull?.currentGame), (prevGameId, currentGameId) { if (prevGameId != currentGameId && currentGameId != null) { + Navigator.of( + context, + ).popUntil((route) => route.settings.name == TournamentScreen.routeName); Navigator.of( context, rootNavigator: true, @@ -109,15 +126,13 @@ class _Body extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final session = ref.watch(authSessionProvider); final timeLeft = state.tournament.timeToStart ?? state.tournament.timeToFinish; return FocusDetector( onFocusRegained: () { - ref.read(tournamentControllerProvider(id).notifier).listenToSocketEvents(); - }, - onFocusLost: () { if (context.mounted) { - ref.read(tournamentControllerProvider(id).notifier).stopListeningToSocketEvents(); + ref.read(tournamentControllerProvider(id).notifier).onFocusRegained(); } }, child: Scaffold( @@ -186,11 +201,36 @@ class _Body extends ConsumerWidget { const SizedBox(height: 16), _Standing(state), const SizedBox(height: 16), - if (state.tournament.featuredGame != null) + if (state.tournament.isStarted != true) + _TournamentHelp(state: state) + else if (state.tournament.isFinished == true) + _TournamentCompleteWidget(state: state) + else if (state.tournament.featuredGame != null) _FeaturedGame(state.tournament.featuredGame!), + if (session != null && state.joined) const SizedBox(height: 35), ], ), ), + bottomSheet: + session != null && state.joined && state.tournament.isFinished != true + ? Material( + child: Container( + height: 35, + width: double.infinity, + color: context.lichessColors.good, + child: Padding( + padding: Styles.horizontalBodyPadding, + child: Center( + child: Text( + context.l10n.standByX(session.user.name), + style: const TextStyle(color: Colors.white), + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + ) + : const SizedBox.shrink(), bottomNavigationBar: _BottomBar(state), ), ); @@ -208,6 +248,174 @@ class _Title extends StatelessWidget { } } +class _TournamentHelp extends StatelessWidget { + const _TournamentHelp({required this.state}); + + final TournamentState state; + + @override + Widget build(BuildContext context) { + return Padding( + padding: Styles.bodySectionPadding, + child: Column( + children: [ + // show a famous chess quote if available + if (state.tournament.quote != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric(vertical: 8.0), + decoration: BoxDecoration( + border: Border( + top: BorderSide(color: Theme.of(context).dividerColor, width: 1), + bottom: BorderSide(color: Theme.of(context).dividerColor, width: 1), + ), + ), + child: Text( + state.tournament.quote!.text, + style: const TextStyle(fontStyle: FontStyle.italic), + ), + ), + const SizedBox(height: 4), + Align( + alignment: Alignment.centerRight, + child: Text( + '\u2014 ${state.tournament.quote!.author}', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + ], + ), + ), + Padding( + padding: Styles.verticalBodyPadding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(context.l10n.arenaIsItRated, style: Styles.sectionTitle), + const SizedBox(height: 10), + if (state.tournament.meta.rated) + Text(context.l10n.arenaIsRated) + else + Text(context.l10n.arenaIsNotRated), + ], + ), + ), + Padding( + padding: Styles.verticalBodyPadding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(context.l10n.arenaHowAreScoresCalculated, style: Styles.sectionTitle), + const SizedBox(height: 10), + Text(context.l10n.arenaHowAreScoresCalculatedAnswer), + ], + ), + ), + if (state.tournament.berserkable) + Padding( + padding: Styles.verticalBodyPadding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(context.l10n.arenaBerserk, style: Styles.sectionTitle), + const SizedBox(height: 10), + Text(context.l10n.arenaBerserkAnswer), + ], + ), + ), + Padding( + padding: Styles.verticalBodyPadding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(context.l10n.arenaHowIsTheWinnerDecided, style: Styles.sectionTitle), + const SizedBox(height: 10), + Text(context.l10n.arenaHowIsTheWinnerDecidedAnswer), + ], + ), + ), + Padding( + padding: Styles.verticalBodyPadding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(context.l10n.arenaHowDoesPairingWork, style: Styles.sectionTitle), + const SizedBox(height: 10), + Text(context.l10n.arenaHowDoesPairingWorkAnswer), + ], + ), + ), + Padding( + padding: Styles.verticalBodyPadding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(context.l10n.arenaHowDoesItEnd, style: Styles.sectionTitle), + const SizedBox(height: 10), + Text(context.l10n.arenaHowDoesItEndAnswer), + ], + ), + ), + Padding( + padding: Styles.verticalBodyPadding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(context.l10n.arenaOtherRules, style: Styles.sectionTitle), + const SizedBox(height: 10), + Text(context.l10n.arenaThereIsACountdown), + const SizedBox(height: 6), + Text(context.l10n.arenaDrawingWithinNbMoves(10)), + const SizedBox(height: 6), + Text(context.l10n.arenaDrawStreakStandard('30')), + const SizedBox(height: 6), + Text(context.l10n.arenaDrawStreakVariants), + DataTable( + dataRowMinHeight: 40, + dataRowMaxHeight: 70, + columns: [ + DataColumn(label: Text(context.l10n.arenaVariant)), + DataColumn( + label: Expanded( + child: Text( + context.l10n.arenaMinimumGameLength, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + rows: const [ + DataRow( + cells: [DataCell(Text('Standard, Chess960, Horde')), DataCell(Text('30'))], + ), + DataRow( + cells: [ + DataCell(Text('Antichess, Crazyhouse, King of the Hill')), + DataCell(Text('20')), + ], + ), + DataRow( + cells: [ + DataCell(Text('Three-check, Atomic, Racing Kings')), + DataCell(Text('10')), + ], + ), + ], + ), + ], + ), + ), + ], + ), + ); + } +} + class _Standing extends ConsumerWidget { const _Standing(this.state); @@ -532,6 +740,158 @@ class _FeaturedGamePlayer extends StatelessWidget { } } +class _TournamentCompleteWidget extends ConsumerWidget { + const _TournamentCompleteWidget({required this.state}); + + final TournamentState state; + + static const _headerStyle = TextStyle(); + static const _valueStyle = TextStyle(fontWeight: FontWeight.bold); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final session = ref.watch(authSessionProvider); + + final stats = state.tournament.stats; + if (stats == null) { + return const SizedBox.shrink(); + } + + return Card( + child: Padding( + padding: Styles.bodySectionPadding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(context.l10n.tournamentComplete, style: Styles.sectionTitle), + const SizedBox(height: 10), + DataTable( + headingRowHeight: 0, + dividerThickness: 0, + horizontalMargin: 0, + columns: const [ + DataColumn(label: SizedBox.shrink()), + DataColumn(label: SizedBox.shrink()), + ], + rows: [ + DataRow( + cells: [ + DataCell(Text(context.l10n.averageElo, style: _headerStyle)), + DataCell(Text(stats.averageRating.toString(), style: _valueStyle)), + ], + ), + DataRow( + cells: [ + DataCell(Text(context.l10n.gamesPlayed, style: _headerStyle)), + DataCell(Text(stats.nbGames.toString(), style: _valueStyle)), + ], + ), + DataRow( + cells: [ + DataCell(Text(context.l10n.movesPlayed, style: _headerStyle)), + DataCell(Text(stats.nbMoves.toString(), style: _valueStyle)), + ], + ), + DataRow( + cells: [ + DataCell(Text(context.l10n.whiteWins, style: _headerStyle)), + DataCell(Text('${stats.whiteWinRate}%', style: _valueStyle)), + ], + ), + DataRow( + cells: [ + DataCell(Text(context.l10n.blackWins, style: _headerStyle)), + DataCell(Text('${stats.blackWinRate}%', style: _valueStyle)), + ], + ), + DataRow( + cells: [ + DataCell(Text(context.l10n.drawRate, style: _headerStyle)), + DataCell(Text('${stats.drawRate}%', style: _valueStyle)), + ], + ), + DataRow( + cells: [ + DataCell(Text(context.l10n.arenaBerserkRate, style: _headerStyle)), + DataCell(Text('${stats.berserkRate}%', style: _valueStyle)), + ], + ), + ], + ), + const SizedBox(height: 10), + LoadingButtonBuilder( + fetchData: () => _downloadGames(ref), + builder: (context, snapshot, fetchData) { + return ListTile( + leading: const Icon(Icons.download), + title: Text(context.l10n.downloadAllGames), + enabled: snapshot.connectionState != ConnectionState.waiting, + onTap: () async { + final file = await fetchData(); + if (context.mounted) { + launchShareDialog( + context, + ShareParams( + fileNameOverrides: [ + 'lichess_tournament_${state.tournament.startsAt}.${state.tournament.id}.pgn', + ], + files: [XFile(file.path, mimeType: 'text/plain')], + ), + ); + } + }, + ); + }, + ), + if (session != null) + LoadingButtonBuilder( + fetchData: () => _downloadGames(ref, session.user), + builder: (context, snapshot, fetchData) { + return ListTile( + leading: const Icon(Icons.download), + title: const Text('Download my games'), + enabled: snapshot.connectionState != ConnectionState.waiting, + onTap: () async { + final file = await fetchData(); + if (context.mounted) { + launchShareDialog( + context, + ShareParams( + fileNameOverrides: [ + 'lichess_tournament_${state.tournament.startsAt}.${state.tournament.id}_${session.user.name}.pgn', + ], + files: [XFile(file.path, mimeType: 'text/plain')], + ), + ); + } + }, + ); + }, + ), + ], + ), + ), + ); + } + + static final _dateFormat = DateFormat('yyyy-MM-dd'); + + Future _downloadGames(WidgetRef ref, [LightUser? player]) async { + final tempDir = await getTemporaryDirectory(); + final file = File( + '${tempDir.path}/lichess_tournament${state.tournament.startsAt != null ? '_${_dateFormat.format(state.tournament.startsAt!)}' : ''}_${state.tournament.id}${player?.name != null ? '_${player?.name}' : ''}.pgn', + ); + final res = await ref + .read(tournamentRepositoryProvider) + .downloadTournamentGames(state.tournament.id, file, userId: player?.id); + if (res) { + return file; + } else { + throw Exception('Failed to download tournament games'); + } + } +} + class _BottomBar extends ConsumerStatefulWidget { const _BottomBar(this.state); @@ -546,7 +906,8 @@ class _BottomBarState extends ConsumerState<_BottomBar> { @override Widget build(BuildContext context) { - final isLoggedIn = ref.watch(authSessionProvider)?.user.id != null; + final session = ref.watch(authSessionProvider); + final kidModeAsync = ref.watch(kidModeProvider); ref.listen( tournamentControllerProvider(widget.state.id).select((value) => value.valueOrNull?.joined), @@ -561,7 +922,10 @@ class _BottomBarState extends ConsumerState<_BottomBar> { return BottomBar( children: [ - if (isLoggedIn) + if (widget.state.chatOptions != null && kidModeAsync.valueOrNull == false) + ChatBottomBarButton(options: widget.state.chatOptions!), + + if (widget.state.tournament.isFinished != true && session != null) joinOrLeaveInProgress ? const Center(child: CircularProgressIndicator.adaptive()) : BottomBarButton( @@ -580,7 +944,7 @@ class _BottomBarState extends ConsumerState<_BottomBar> { } : null, ) - else + else if (widget.state.tournament.isFinished != true) BottomBarButton( label: context.l10n.signIn, showLabel: true, diff --git a/lib/src/widgets/buttons.dart b/lib/src/widgets/buttons.dart index b3baa1fc11..3780ab2825 100644 --- a/lib/src/widgets/buttons.dart +++ b/lib/src/widgets/buttons.dart @@ -3,60 +3,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -/// Platform agnostic button which is used for important actions. -class FatButton extends StatelessWidget { - const FatButton({ - required this.semanticsLabel, - required this.child, - required this.onPressed, - super.key, - }); - - final String semanticsLabel; - final VoidCallback? onPressed; - final Widget child; - - @override - Widget build(BuildContext context) { - return Semantics( - container: true, - enabled: true, - button: true, - label: semanticsLabel, - excludeSemantics: true, - child: FilledButton(onPressed: onPressed, child: child), - ); - } -} - -/// Platform agnostic button meant for medium importance actions. -class SecondaryButton extends StatelessWidget { - const SecondaryButton({ - required this.semanticsLabel, - required this.child, - required this.onPressed, - this.textStyle, - super.key, - }); - - final String semanticsLabel; - final VoidCallback? onPressed; - final Widget child; - final TextStyle? textStyle; - - @override - Widget build(BuildContext context) { - return Semantics( - container: true, - enabled: true, - button: true, - label: semanticsLabel, - excludeSemantics: true, - child: FilledButton.tonal(onPressed: onPressed, child: child), - ); - } -} - /// Icon button with mandatory semantics. class SemanticIconButton extends StatelessWidget { const SemanticIconButton({ @@ -226,3 +172,44 @@ class _RepeatButtonState extends State { ); } } + +class LoadingButtonBuilder extends StatefulWidget { + const LoadingButtonBuilder({required this.builder, required this.fetchData, super.key}); + + final Future Function() fetchData; + + final Widget Function( + BuildContext context, + AsyncSnapshot snapshot, + Future Function() fetchData, + ) + builder; + + @override + State> createState() => _LoadingButtonBuilderState(); +} + +class _LoadingButtonBuilderState extends State> { + Future? _future; + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _future, + builder: (context, snapshot) { + return widget.builder(context, snapshot, () async { + final future = widget.fetchData(); + setState(() { + _future = future; + }); + try { + await future; + } finally { + _future = null; + } + return future; + }); + }, + ); + } +} diff --git a/lib/src/widgets/feedback.dart b/lib/src/widgets/feedback.dart index 6429770084..416d89ae19 100644 --- a/lib/src/widgets/feedback.dart +++ b/lib/src/widgets/feedback.dart @@ -188,11 +188,7 @@ class FullScreenRetryRequest extends StatelessWidget { children: [ Text(context.l10n.mobileSomethingWentWrong, style: Styles.sectionTitle), const SizedBox(height: 10), - SecondaryButton( - onPressed: onRetry, - semanticsLabel: context.l10n.retry, - child: Text(context.l10n.retry), - ), + FilledButton.tonal(onPressed: onRetry, child: Text(context.l10n.retry)), ], ), ); diff --git a/pubspec.lock b/pubspec.lock index 8f8d4a2cd7..6d02fd54e8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1353,18 +1353,18 @@ packages: dependency: "direct main" description: name: share_plus - sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da + sha256: b2961506569e28948d75ec346c28775bb111986bb69dc6a20754a457e3d97fa0 url: "https://pub.dev" source: hosted - version: "10.1.4" + version: "11.0.0" share_plus_platform_interface: dependency: transitive description: name: share_plus_platform_interface - sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b + sha256: "1032d392bc5d2095a77447a805aa3f804d2ae6a4d5eef5e6ebb3bd94c1bc19ef" url: "https://pub.dev" source: hosted - version: "5.0.2" + version: "6.0.0" shared_preferences: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 3a26edfad4..bcecdc39c8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -65,7 +65,7 @@ dependencies: pub_semver: ^2.1.4 result_extensions: ^0.1.0 riverpod_annotation: ^2.3.0 - share_plus: ^10.0.0 + share_plus: ^11.0.0 shared_preferences: ^2.1.0 signal_strength_indicator: ^0.4.1 sound_effect: ^0.1.0 diff --git a/test/view/game/game_screen_test.dart b/test/view/game/game_screen_test.dart index 1d7453bd3b..c817f80d9f 100644 --- a/test/view/game/game_screen_test.dart +++ b/test/view/game/game_screen_test.dart @@ -706,7 +706,7 @@ void main() { overrides: [soundServiceProvider.overrideWith((_) => mockSoundService)], ); sendServerSocketMessages(testGameSocketUri, [ - '{"t":"message","d":{"u":"Magnus","t":"Hello!"}}', + '{"t":"message","d":{"u":"Steven","t":"Hello!"}}', ]); await tester.pump(); verify( @@ -726,7 +726,7 @@ void main() { overrides: [soundServiceProvider.overrideWith((_) => mockSoundService)], ); sendServerSocketMessages(testGameSocketUri, [ - '{"t":"message","d":{"u":"Magnus","t":"Hello!"}}', + '{"t":"message","d":{"u":"Steven","t":"Hello!"}}', ]); await tester.pump(); verifyNever(() => mockSoundService.play(Sound.confirmation)); diff --git a/test/view/puzzle/streak_screen_test.dart b/test/view/puzzle/streak_screen_test.dart index e43339211f..8a9fb5b657 100644 --- a/test/view/puzzle/streak_screen_test.dart +++ b/test/view/puzzle/streak_screen_test.dart @@ -5,7 +5,6 @@ import 'package:http/testing.dart'; import 'package:lichess_mobile/src/network/http.dart'; import 'package:lichess_mobile/src/utils/navigation.dart'; import 'package:lichess_mobile/src/view/puzzle/streak_screen.dart'; -import 'package:lichess_mobile/src/widgets/buttons.dart'; import '../../test_helpers.dart'; import '../../test_provider_scope.dart'; @@ -228,8 +227,7 @@ void main() { builder: (context) => Scaffold( appBar: AppBar(title: const Text('Test Streak Screen')), - body: FatButton( - semanticsLabel: 'Start Streak', + body: FilledButton( child: const Text('Start Streak'), onPressed: () => Navigator.of( diff --git a/test/view/tournament/tournament_screen_test.dart b/test/view/tournament/tournament_screen_test.dart index 904d67f87a..72a4e11630 100644 --- a/test/view/tournament/tournament_screen_test.dart +++ b/test/view/tournament/tournament_screen_test.dart @@ -117,6 +117,7 @@ String makeTournamentJson({ }, $kFeaturedGame, "id": "82QbxlJb", + "socketVersion": 0, "createdBy": "lichess", "startsAt": "2025-04-01T17:00:25Z", "system": "arena", @@ -144,6 +145,10 @@ String makeTournamentJson({ "minRatedGames": { "nb": 20 }, + "chat": { + "lines": [], + "writeable": true + }, "reloadEndpoint": "https://http.lichess.org/tournament/82QbxlJb" } ''';