diff --git a/lib/src/model/account/account_preferences.dart b/lib/src/model/account/account_preferences.dart new file mode 100644 index 0000000000..2c4493dba3 --- /dev/null +++ b/lib/src/model/account/account_preferences.dart @@ -0,0 +1,319 @@ +import 'package:flutter/widgets.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:result_extensions/result_extensions.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; + +import 'package:lichess_mobile/src/utils/l10n_context.dart'; +import 'package:lichess_mobile/src/model/auth/auth_session.dart'; + +import 'account_repository.dart'; + +part 'account_preferences.g.dart'; + +typedef AccountPrefState = ({ + BooleanPref premove, + AutoQueen autoQueen, + AutoThreefold autoThreefold, + Takeback takeback, + Moretime moretime, + BooleanPref confirmResign, + SubmitMove submitMove, +}); + +/// Get the account preferences for the current user. +/// +/// The result is cached for the lifetime of the app, until refreshed. +/// If the server returns an error, default values are returned. +@Riverpod(keepAlive: true) +class AccountPreferences extends _$AccountPreferences { + @override + Future build() async { + final session = ref.watch(authSessionProvider); + + if (session == null) { + return null; + } + + return _repo.getPreferences().fold( + (value) => value, + (_, __) => ( + premove: const BooleanPref(true), + autoQueen: AutoQueen.premove, + autoThreefold: AutoThreefold.always, + takeback: Takeback.always, + moretime: Moretime.always, + confirmResign: const BooleanPref(true), + submitMove: SubmitMove({ + SubmitMoveChoice.correspondence, + }), + ), + ); + } + + Future setPremove(BooleanPref value) => _setPref('premove', value); + Future setTakeback(Takeback value) => _setPref('takeback', value); + Future setAutoQueen(AutoQueen value) => _setPref('autoQueen', value); + Future setAutoThreefold(AutoThreefold value) => + _setPref('autoThreefold', value); + Future setMoretime(Moretime value) => _setPref('moretime', value); + Future setConfirmResign(BooleanPref value) => + _setPref('confirmResign', value); + Future setSubmitMove(SubmitMove value) => _setPref('submitMove', value); + + Future _setPref(String key, AccountPref value) async { + await _repo.setPreference(key, value); + ref.invalidateSelf(); + } + + AccountRepository get _repo => ref.read(accountRepositoryProvider); +} + +abstract class AccountPref { + T get value; + String get toFormData; +} + +class BooleanPref implements AccountPref { + const BooleanPref(this.value); + + @override + final bool value; + + @override + String get toFormData => value ? '1' : '0'; + + static BooleanPref fromInt(int value) { + switch (value) { + case 1: + return const BooleanPref(true); + case 0: + return const BooleanPref(false); + default: + throw Exception('Invalid value for BooleanPref'); + } + } +} + +enum AutoQueen implements AccountPref { + never(1), + premove(2), + always(3); + + const AutoQueen(this.value); + + @override + final int value; + + @override + String get toFormData => value.toString(); + + String label(BuildContext context) { + switch (this) { + case AutoQueen.never: + return context.l10n.never; + case AutoQueen.premove: + return context.l10n.preferencesWhenPremoving; + case AutoQueen.always: + return context.l10n.always; + } + } + + static AutoQueen fromInt(int value) { + switch (value) { + case 1: + return AutoQueen.never; + case 2: + return AutoQueen.premove; + case 3: + return AutoQueen.always; + default: + throw Exception('Invalid value for AutoQueen'); + } + } +} + +enum AutoThreefold implements AccountPref { + never(1), + time(2), + always(3); + + const AutoThreefold(this.value); + + @override + final int value; + + @override + String get toFormData => value.toString(); + + String label(BuildContext context) { + switch (this) { + case AutoThreefold.never: + return context.l10n.never; + case AutoThreefold.time: + return context.l10n.preferencesWhenTimeRemainingLessThanThirtySeconds; + case AutoThreefold.always: + return context.l10n.always; + } + } + + static AutoThreefold fromInt(int value) { + switch (value) { + case 1: + return AutoThreefold.never; + case 2: + return AutoThreefold.time; + case 3: + return AutoThreefold.always; + default: + throw Exception('Invalid value for AutoThreefold'); + } + } +} + +enum Takeback implements AccountPref { + never(1), + casual(2), + always(3); + + const Takeback(this.value); + + @override + final int value; + + @override + String get toFormData => value.toString(); + + String label(BuildContext context) { + switch (this) { + case Takeback.never: + return context.l10n.never; + case Takeback.casual: + return context.l10n.preferencesInCasualGamesOnly; + case Takeback.always: + return context.l10n.always; + } + } + + static Takeback fromInt(int value) { + switch (value) { + case 1: + return Takeback.never; + case 2: + return Takeback.casual; + case 3: + return Takeback.always; + default: + throw Exception('Invalid value for Takeback'); + } + } +} + +enum Moretime implements AccountPref { + never(1), + casual(2), + always(3); + + const Moretime(this.value); + + @override + final int value; + + @override + String get toFormData => value.toString(); + + String label(BuildContext context) { + switch (this) { + case Moretime.never: + return context.l10n.never; + case Moretime.casual: + return context.l10n.preferencesInCasualGamesOnly; + case Moretime.always: + return context.l10n.always; + } + } + + static Moretime fromInt(int value) { + switch (value) { + case 1: + return Moretime.never; + case 2: + return Moretime.casual; + case 3: + return Moretime.always; + default: + throw Exception('Invalid value for Moretime'); + } + } +} + +class SubmitMove implements AccountPref { + SubmitMove(Iterable choices) + : choices = ISet(choices.toSet()); + + final ISet choices; + + @override + int get value => choices.fold(0, (acc, choice) => acc | choice.value); + + @override + String get toFormData => value.toString(); + + String label(BuildContext context) { + if (choices.isEmpty) { + return context.l10n.never; + } + + return choices.map((choice) => choice.label(context)).join(', '); + } + + factory SubmitMove.fromInt(int value) => SubmitMove( + SubmitMoveChoice.values + .where((choice) => _bitPresent(value, choice.value)), + ); +} + +enum SubmitMoveChoice { + unlimited(1), + correspondence(2), + classical(4), + rapid(8), + blitz(16); + + const SubmitMoveChoice(this.value); + + final int value; + + String label(BuildContext context) { + switch (this) { + case SubmitMoveChoice.unlimited: + return context.l10n.unlimited; + case SubmitMoveChoice.correspondence: + return context.l10n.correspondence; + case SubmitMoveChoice.classical: + return context.l10n.classical; + case SubmitMoveChoice.rapid: + return context.l10n.rapid; + case SubmitMoveChoice.blitz: + return 'Blitz'; + } + } + + static SubmitMoveChoice fromInt(int value) { + switch (value) { + case 1: + return SubmitMoveChoice.unlimited; + case 2: + return SubmitMoveChoice.correspondence; + case 4: + return SubmitMoveChoice.classical; + case 8: + return SubmitMoveChoice.rapid; + case 16: + return SubmitMoveChoice.blitz; + default: + throw Exception('Invalid value for SubmitMoveChoice'); + } + } +} + +bool _bitPresent(int anInt, int bit) => (anInt & bit) == bit; diff --git a/lib/src/model/account/account_repository.dart b/lib/src/model/account/account_repository.dart index bee812d4ae..56274dba22 100644 --- a/lib/src/model/account/account_repository.dart +++ b/lib/src/model/account/account_repository.dart @@ -1,6 +1,7 @@ import 'package:logging/logging.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:result_extensions/result_extensions.dart'; +import 'package:deep_pick/deep_pick.dart'; import 'package:lichess_mobile/src/constants.dart'; import 'package:lichess_mobile/src/model/auth/auth_session.dart'; @@ -9,6 +10,8 @@ import 'package:lichess_mobile/src/model/user/user.dart'; import 'package:lichess_mobile/src/utils/json.dart'; import 'package:lichess_mobile/src/utils/riverpod.dart'; +import 'account_preferences.dart'; + part 'account_repository.g.dart'; @Riverpod(keepAlive: true) @@ -56,4 +59,53 @@ class AccountRepository { ), ); } + + FutureResult getPreferences() { + return _apiClient + .get(Uri.parse('$kLichessHost/api/account/preferences')) + .then( + (result) => result.flatMap( + (response) => readJsonObject( + response, + mapper: (Map json) { + return _accountPreferencesFromPick( + pick(json, 'prefs').required(), + ); + }, + logger: _log, + ), + ), + ); + } + + FutureResult setPreference(String prefKey, AccountPref pref) { + return _apiClient.post( + Uri.parse('$kLichessHost/api/account/preferences/$prefKey'), + body: {prefKey: pref.toFormData}, + ); + } +} + +AccountPrefState _accountPreferencesFromPick(RequiredPick pick) { + return ( + premove: BooleanPref(pick('premove').asBoolOrThrow()), + autoQueen: AutoQueen.fromInt( + pick('autoQueen').asIntOrThrow(), + ), + autoThreefold: AutoThreefold.fromInt( + pick('autoThreefold').asIntOrThrow(), + ), + takeback: Takeback.fromInt( + pick('takeback').asIntOrThrow(), + ), + moretime: Moretime.fromInt( + pick('moretime').asIntOrThrow(), + ), + confirmResign: BooleanPref.fromInt( + pick('confirmResign').asIntOrThrow(), + ), + submitMove: SubmitMove.fromInt( + pick('submitMove').asIntOrThrow(), + ), + ); } diff --git a/lib/src/model/game/game.dart b/lib/src/model/game/game.dart index 48f679193f..ac734d3867 100644 --- a/lib/src/model/game/game.dart +++ b/lib/src/model/game/game.dart @@ -7,6 +7,7 @@ import 'package:lichess_mobile/src/model/common/id.dart'; import 'package:lichess_mobile/src/model/common/perf.dart'; import 'package:lichess_mobile/src/model/common/speed.dart'; import 'package:lichess_mobile/src/model/common/time_increment.dart'; +import 'package:lichess_mobile/src/model/account/account_preferences.dart'; import 'player.dart'; import 'game_status.dart'; @@ -61,10 +62,13 @@ class PlayableGame with _$PlayableGame, BaseGame, IndexableSteps { required Player white, required Player black, required GameStatus status, + required bool moretimeable, + required bool takebackable, /// The side that the current player is playing as. This is null if viewing /// the game as a spectator. Side? youAre, + GamePrefs? prefs, PlayableClockData? clock, bool? boosted, bool? isThreefoldRepetition, @@ -101,12 +105,13 @@ class PlayableGame with _$PlayableGame, BaseGame, IndexableSteps { !hasAI; bool get rematchable => meta.rules == null || !meta.rules!.contains(GameRule.noRematch); - bool get takebackable => + bool get canTakeback => + takebackable && playable && lastPosition.fullmoves >= 2 && !(player?.proposingTakeback == true) && !(opponent?.proposingTakeback == true); - bool get moretimeable => playable && clock != null; + bool get canGiveTime => moretimeable && playable && clock != null; bool get canClaimWin => opponent?.isGone == true && @@ -115,6 +120,13 @@ class PlayableGame with _$PlayableGame, BaseGame, IndexableSteps { (meta.rules == null || !meta.rules!.contains(GameRule.noClaimWin)); } +typedef GamePrefs = ({ + bool enablePremove, + AutoQueen autoQueen, + bool confirmResign, + bool submitMove, +}); + enum GameSource { lobby, friend, @@ -136,7 +148,6 @@ enum GameSource { enum GameRule { noAbort, noRematch, - noGiveTime, noClaimWin, unknown; diff --git a/lib/src/model/game/game_ctrl.dart b/lib/src/model/game/game_ctrl.dart index b1b97dc8db..daf356fdf4 100644 --- a/lib/src/model/game/game_ctrl.dart +++ b/lib/src/model/game/game_ctrl.dart @@ -13,6 +13,8 @@ import 'package:lichess_mobile/src/model/common/id.dart'; import 'package:lichess_mobile/src/model/common/socket.dart'; import 'package:lichess_mobile/src/model/common/service/move_feedback.dart'; import 'package:lichess_mobile/src/model/common/service/sound_service.dart'; +import 'package:lichess_mobile/src/model/account/account_preferences.dart'; +import 'package:lichess_mobile/src/model/analysis/analysis_ctrl.dart'; import 'package:lichess_mobile/src/model/game/game.dart'; import 'package:lichess_mobile/src/model/game/game_status.dart'; import 'package:lichess_mobile/src/model/game/game_socket_events.dart'; @@ -90,28 +92,75 @@ class GameCtrl extends _$GameCtrl { diff: MaterialDiff.fromBoard(newPos.board), ); + final shouldConfirmMove = curState.shouldConfirmMove && isPremove != true; + state = AsyncValue.data( curState.copyWith( game: curState.game.copyWith( steps: curState.game.steps.add(newStep), ), stepCursor: curState.stepCursor + 1, - stopClockWaitingForServerAck: true, + stopClockWaitingForServerAck: !shouldConfirmMove, + moveToConfirm: shouldConfirmMove ? move : null, ), ); - _sendMove( - move, - isPremove: isPremove ?? false, + _playMoveFeedback(sanMove, skipAnimationDelay: isDrop ?? false); + + if (!shouldConfirmMove) { + _sendMoveToSocket( + move, + isPremove: isPremove ?? false, + hasClock: curState.game.clock != null, + // same logic as web client + // we want to send client lag only at the beginning of the game when the clock is not running yet + withLag: + curState.game.clock != null && curState.activeClockSide == null, + ); + } + } + + /// Called if the player cancels the move when confirm move preference is enabled + void cancelMove() { + final curState = state.requireValue; + if (curState.game.steps.isEmpty) { + assert(false, 'game steps cannot be empty on cancel move'); + return; + } + state = AsyncValue.data( + curState.copyWith( + game: curState.game.copyWith( + steps: curState.game.steps.removeLast(), + ), + stepCursor: curState.stepCursor - 1, + moveToConfirm: null, + ), + ); + } + + /// Called if the player confirms the move when confirm move preference is enabled + void confirmMove() { + final curState = state.requireValue; + final moveToConfirm = curState.moveToConfirm; + if (moveToConfirm == null) { + assert(false, 'moveToConfirm must not be null on confirm move'); + return; + } + + state = AsyncValue.data( + curState.copyWith( + stopClockWaitingForServerAck: true, + moveToConfirm: null, + ), + ); + _sendMoveToSocket( + moveToConfirm, + isPremove: false, hasClock: curState.game.clock != null, // same logic as web client // we want to send client lag only at the beginning of the game when the clock is not running yet withLag: curState.game.clock != null && curState.activeClockSide == null, ); - - _playMoveFeedback(sanMove, skipAnimationDelay: isDrop ?? false); - - _transientMoveTimer = Timer(const Duration(seconds: 10), _resyncGameData); } /// Set or unset a premove. @@ -165,6 +214,16 @@ class GameCtrl extends _$GameCtrl { } } + void toggleMoveConfirmation() { + final curState = state.requireValue; + state = AsyncValue.data( + curState.copyWith( + moveConfirmSettingOverride: + !(curState.moveConfirmSettingOverride ?? true), + ), + ); + } + void onFlag() { _onFlagThrottler(() { if (state.hasValue) { @@ -226,7 +285,7 @@ class GameCtrl extends _$GameCtrl { _socket.send('rematch-no', null); } - void _sendMove( + void _sendMoveToSocket( Move move, { required bool isPremove, required bool hasClock, @@ -249,6 +308,8 @@ class GameCtrl extends _$GameCtrl { ackable: true, withLag: hasClock && (moveTime == null || withLag), ); + + _transientMoveTimer = Timer(const Duration(seconds: 10), _resyncGameData); } /// Move feedback while playing @@ -669,10 +730,25 @@ class GameCtrlState with _$GameCtrlState { required bool stopClockWaitingForServerAck, cg.Move? premove, + /// Game only setting to override the account preference + bool? moveConfirmSettingOverride, + + /// Set if confirm move preference is enabled and player played a move + Move? moveToConfirm, + /// Game full id used to redirect to the new game of the rematch GameFullId? redirectGameId, }) = _GameCtrlState; + // preferences + bool get canPremove => game.prefs?.enablePremove ?? true; + bool get canAutoQueen => game.prefs?.autoQueen == AutoQueen.always; + bool get canAutoQueenOnPremove => game.prefs?.autoQueen == AutoQueen.premove; + bool get shouldConfirmResignAndDrawOffer => game.prefs?.confirmResign ?? true; + bool get shouldConfirmMove => + moveConfirmSettingOverride ?? game.prefs?.submitMove ?? false; + + // game state bool get isReplaying => stepCursor < game.steps.length - 1; bool get canGoForward => stepCursor < game.steps.length - 1; bool get canGoBackward => stepCursor > 0; @@ -727,10 +803,18 @@ class GameCtrlState with _$GameCtrlState { if (game.status == GameStatus.started) { final pos = game.lastPosition; if (pos.fullmoves > 1) { - return pos.turn; + return moveToConfirm != null ? pos.turn.opposite : pos.turn; } } return null; } + + AnalysisOptions get analysisOptions => AnalysisOptions( + isLocalEvaluationAllowed: true, + variant: game.meta.variant, + steps: game.steps, + orientation: game.youAre ?? Side.white, + id: game.meta.id, + ); } diff --git a/lib/src/model/game/game_socket_events.dart b/lib/src/model/game/game_socket_events.dart index 522cd9ac12..81232ab803 100644 --- a/lib/src/model/game/game_socket_events.dart +++ b/lib/src/model/game/game_socket_events.dart @@ -7,6 +7,7 @@ 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'; import 'package:lichess_mobile/src/model/common/speed.dart'; +import 'package:lichess_mobile/src/model/account/account_preferences.dart'; import 'package:lichess_mobile/src/model/game/game.dart'; import 'package:lichess_mobile/src/model/game/game_status.dart'; import 'package:lichess_mobile/src/model/game/material_diff.dart'; @@ -70,7 +71,10 @@ PlayableGame _playableGameFromPick(RequiredPick pick) { winner: pick('game', 'winner').asSideOrNull(), boosted: pick('game', 'boosted').asBoolOrNull(), isThreefoldRepetition: pick('game', 'threefold').asBoolOrNull(), + moretimeable: pick('moretimeable').asBoolOrFalse(), + takebackable: pick('takebackable').asBoolOrFalse(), youAre: pick('youAre').asSideOrNull(), + prefs: pick('prefs').letOrNull(_gamePrefsFromPick), expiration: pick('expiration').letOrNull( (it) { final idle = it('idleMillis').asDurationFromMilliSecondsOrThrow(); @@ -108,6 +112,15 @@ PlayableGameMeta _playableGameMetaFromPick(RequiredPick pick) { ); } +GamePrefs _gamePrefsFromPick(RequiredPick pick) { + return ( + enablePremove: pick('enablePremove').asBoolOrFalse(), + autoQueen: AutoQueen.fromInt(pick('autoQueen').asIntOrThrow()), + confirmResign: pick('confirmResign').asBoolOrFalse(), + submitMove: pick('submitMove').asBoolOrFalse(), + ); +} + Player _playerFromUserGamePick(RequiredPick pick) { return Player( id: pick('user', 'id').asUserIdOrNull(), diff --git a/lib/src/view/analysis/analysis_screen.dart b/lib/src/view/analysis/analysis_screen.dart index 984bbbe78c..e52c9a24f3 100644 --- a/lib/src/view/analysis/analysis_screen.dart +++ b/lib/src/view/analysis/analysis_screen.dart @@ -62,8 +62,8 @@ class AnalysisScreen extends ConsumerWidget { SettingsButton( onPressed: () => showAdaptiveBottomSheet( context: context, - showDragHandle: true, - builder: (_) => _Preferences(ctrlProvider), + isScrollControlled: true, + builder: (_) => _AnalysisSettings(ctrlProvider), ), ), ], @@ -85,8 +85,8 @@ class AnalysisScreen extends ConsumerWidget { SettingsButton( onPressed: () => showAdaptiveBottomSheet( context: context, - showDragHandle: true, - builder: (_) => _Preferences(ctrlProvider), + isScrollControlled: true, + builder: (_) => _AnalysisSettings(ctrlProvider), ), ), ], @@ -670,8 +670,8 @@ class _StockfishInfo extends ConsumerWidget { } } -class _Preferences extends ConsumerWidget { - const _Preferences(this.ctrlProvider); +class _AnalysisSettings extends ConsumerWidget { + const _AnalysisSettings(this.ctrlProvider); final AnalysisCtrlProvider ctrlProvider; @@ -683,17 +683,12 @@ class _Preferences extends ConsumerWidget { generalPreferencesProvider.select((pref) => pref.isSoundEnabled), ); - return SafeArea( + return ModalSheetScaffold( + title: Text(context.l10n.analysisOptions), child: ListView( shrinkWrap: true, children: [ - Padding( - padding: Styles.bodyPadding, - child: Text( - context.l10n.analysisOptions, - style: Styles.title, - ), - ), + const SizedBox(height: 8.0), SwitchSettingTile( title: Text(context.l10n.toggleLocalEvaluation), value: prefs.enableLocalEvaluation, @@ -705,12 +700,39 @@ class _Preferences extends ConsumerWidget { } : null, ), - Opacity( - opacity: state.isEngineAvailable ? 1.0 : 0.5, - child: PlatformListTile( + PlatformListTile( + title: Text.rich( + TextSpan( + text: '${context.l10n.multipleLines}: ', + style: const TextStyle( + fontWeight: FontWeight.normal, + ), + children: [ + TextSpan( + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + ), + text: prefs.numEvalLines.toString(), + ), + ], + ), + ), + subtitle: NonLinearSlider( + value: prefs.numEvalLines, + values: const [1, 2, 3], + onChangeEnd: state.isEngineAvailable + ? (value) => ref + .read(ctrlProvider.notifier) + .setNumEvalLines(value.toInt()) + : null, + ), + ), + if (maxEngineCores > 1) + PlatformListTile( title: Text.rich( TextSpan( - text: '${context.l10n.multipleLines}: ', + text: '${context.l10n.cpus}: ', style: const TextStyle( fontWeight: FontWeight.normal, ), @@ -720,54 +742,21 @@ class _Preferences extends ConsumerWidget { fontWeight: FontWeight.bold, fontSize: 18, ), - text: prefs.numEvalLines.toString(), + text: prefs.numEngineCores.toString(), ), ], ), ), subtitle: NonLinearSlider( - value: prefs.numEvalLines, - values: const [1, 2, 3], + value: prefs.numEngineCores, + values: List.generate(maxEngineCores, (index) => index + 1), onChangeEnd: state.isEngineAvailable ? (value) => ref .read(ctrlProvider.notifier) - .setNumEvalLines(value.toInt()) + .setEngineCores(value.toInt()) : null, ), ), - ), - if (maxEngineCores > 1) - Opacity( - opacity: state.isEngineAvailable ? 1.0 : 0.5, - child: PlatformListTile( - title: Text.rich( - TextSpan( - text: '${context.l10n.cpus}: ', - style: const TextStyle( - fontWeight: FontWeight.normal, - ), - children: [ - TextSpan( - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 18, - ), - text: prefs.numEngineCores.toString(), - ), - ], - ), - ), - subtitle: NonLinearSlider( - value: prefs.numEngineCores, - values: List.generate(maxEngineCores, (index) => index + 1), - onChangeEnd: state.isEngineAvailable - ? (value) => ref - .read(ctrlProvider.notifier) - .setEngineCores(value.toInt()) - : null, - ), - ), - ), SwitchSettingTile( title: Text(context.l10n.bestMoveArrow), value: prefs.showBestMoveArrow, diff --git a/lib/src/view/game/archived_game_screen.dart b/lib/src/view/game/archived_game_screen.dart index 15c8b36c98..0afb168137 100644 --- a/lib/src/view/game/archived_game_screen.dart +++ b/lib/src/view/game/archived_game_screen.dart @@ -242,7 +242,6 @@ class _BottomBar extends ConsumerWidget { onPressed: ref.read(gameCursorProvider(gameData.id)).hasValue ? () => pushPlatformRoute( context, - fullscreenDialog: true, builder: (context) => AnalysisScreen( title: context.l10n.gameAnalysis, options: AnalysisOptions( diff --git a/lib/src/view/game/game_screen.dart b/lib/src/view/game/game_screen.dart index e79864f8f1..49a65a9a05 100644 --- a/lib/src/view/game/game_screen.dart +++ b/lib/src/view/game/game_screen.dart @@ -8,15 +8,11 @@ import 'package:chessground/chessground.dart' as cg; import 'package:lichess_mobile/src/model/auth/auth_socket.dart'; import 'package:lichess_mobile/src/model/account/account_repository.dart'; -import 'package:lichess_mobile/src/model/analysis/analysis_ctrl.dart'; import 'package:lichess_mobile/src/model/game/game_ctrl.dart'; import 'package:lichess_mobile/src/model/game/game_status.dart'; import 'package:lichess_mobile/src/model/game/game_repository_providers.dart'; import 'package:lichess_mobile/src/model/lobby/lobby_game.dart'; import 'package:lichess_mobile/src/model/lobby/game_seek.dart'; -import 'package:lichess_mobile/src/model/settings/play_preferences.dart'; -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/model/user/user_repository_providers.dart'; import 'package:lichess_mobile/src/styles/styles.dart'; import 'package:lichess_mobile/src/utils/navigation.dart'; @@ -29,7 +25,6 @@ import 'package:lichess_mobile/src/widgets/board_table.dart'; import 'package:lichess_mobile/src/widgets/countdown_clock.dart'; import 'package:lichess_mobile/src/widgets/player.dart'; import 'package:lichess_mobile/src/widgets/yes_no_dialog.dart'; -import 'package:lichess_mobile/src/widgets/settings.dart'; import 'package:lichess_mobile/src/utils/immersive_mode.dart'; import 'package:lichess_mobile/src/utils/wakelock.dart'; import 'package:lichess_mobile/src/utils/l10n_context.dart'; @@ -37,7 +32,8 @@ import 'package:lichess_mobile/src/utils/chessground_compat.dart'; import 'game_screen_providers.dart'; import 'ping_rating.dart'; -import 'game_loader.dart'; +import 'lobby_game_loading_board.dart'; +import 'game_settings.dart'; import 'status_l10n.dart'; final RouteObserver> gameRouteObserver = @@ -84,7 +80,6 @@ class _GameScreenState extends ConsumerState Widget build(BuildContext context) { final gameProvider = lobbyGameProvider(widget.seek); final gameId = ref.watch(gameProvider); - final playPrefs = ref.watch(playPreferencesProvider); return gameId.when( data: (id) { @@ -100,62 +95,58 @@ class _GameScreenState extends ConsumerState return PlatformWidget( androidBuilder: (context) => _androidBuilder( context: context, - playPrefs: playPrefs, body: body, gameState: state, + ctrlProvider: ctrlProvider, ), iosBuilder: (context) => _iosBuilder( context: context, - playPrefs: playPrefs, body: body, gameState: state, + ctrlProvider: ctrlProvider, ), ); }, - loading: () => _loadingContent(playPrefs), + loading: () => _loadingContent(), error: (e, s) { debugPrint( 'SEVERE: [GameScreen] could not load game data; $e\n$s', ); - return _errorContent(playPrefs); + return _errorContent(); }, ); }, - loading: () => _loadingContent(playPrefs), + loading: () => _loadingContent(), error: (e, s) { debugPrint( 'SEVERE: [GameScreen] could not create game; $e\n$s', ); - return _errorContent(playPrefs); + return _errorContent(); }, ); } - Widget _loadingContent(PlayPrefs playPrefs) { + Widget _loadingContent() { return PlatformWidget( androidBuilder: (context) => _androidBuilder( context: context, - playPrefs: playPrefs, - body: GameLoader(widget.seek), + body: LobbyGameLoadingBoard(widget.seek), ), iosBuilder: (context) => _iosBuilder( context: context, - playPrefs: playPrefs, - body: GameLoader(widget.seek), + body: LobbyGameLoadingBoard(widget.seek), ), ); } - Widget _errorContent(PlayPrefs playPrefs) { + Widget _errorContent() { return PlatformWidget( androidBuilder: (context) => _androidBuilder( context: context, - playPrefs: playPrefs, body: const CreateGameError(), ), iosBuilder: (context) => _iosBuilder( context: context, - playPrefs: playPrefs, body: const CreateGameError(), ), ); @@ -163,8 +154,8 @@ class _GameScreenState extends ConsumerState Widget _androidBuilder({ required BuildContext context, - required PlayPrefs playPrefs, required Widget body, + GameCtrlProvider? ctrlProvider, GameCtrlState? gameState, }) { return Scaffold( @@ -180,8 +171,8 @@ class _GameScreenState extends ConsumerState SettingsButton( onPressed: () => showAdaptiveBottomSheet( context: context, - showDragHandle: true, - builder: (_) => const _Preferences(), + isScrollControlled: true, + builder: (_) => GameSettings(ctrlProvider), ), ), ], @@ -192,8 +183,8 @@ class _GameScreenState extends ConsumerState Widget _iosBuilder({ required BuildContext context, - required PlayPrefs playPrefs, required Widget body, + GameCtrlProvider? ctrlProvider, GameCtrlState? gameState, }) { return CupertinoPageScaffold( @@ -209,8 +200,8 @@ class _GameScreenState extends ConsumerState trailing: SettingsButton( onPressed: () => showAdaptiveBottomSheet( context: context, - showDragHandle: true, - builder: (_) => const _Preferences(), + isScrollControlled: true, + builder: (_) => GameSettings(ctrlProvider), ), ), ), @@ -257,42 +248,15 @@ class _Body extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - ref.listen(ctrlProvider, (prev, state) { - if (prev?.hasValue == true && state.hasValue) { - if (prev!.requireValue.game.playable == true && - state.requireValue.game.playable == false) { - Timer(const Duration(milliseconds: 500), () { - showAdaptiveDialog( - context: context, - builder: (context) => _GameEndDialog( - ctrlProvider: ctrlProvider, - gameProvider: gameProvider, - ), - barrierDismissible: true, - ); - }); - } - - if (!prev.requireValue.game.canClaimWin && - state.requireValue.game.canClaimWin) { - showAdaptiveDialog( - context: context, - builder: (context) => _ClaimWinDialog( - ctrlProvider: ctrlProvider, - ), - barrierDismissible: true, - ); - } - - if (state.requireValue.redirectGameId != null) { - // Be sure to pop any dialogs that might be on top of the game screen. - Navigator.of(context).popUntil((route) => route is! RawDialogRoute); - ref - .read(gameProvider.notifier) - .rematch(state.requireValue.redirectGameId!); - } - } - }); + ref.listen( + ctrlProvider, + (prev, state) => _stateListener( + prev, + state, + context: context, + ref: ref, + ), + ); final position = gameState.game.positionAt(gameState.stepCursor); final sideToMove = position.turn; @@ -305,6 +269,17 @@ class _Body extends ConsumerWidget { timeToMove: sideToMove == Side.black ? gameState.timeToMove : null, shouldLinkToUserProfile: youAre != Side.black, mePlaying: youAre == Side.black, + confirmMoveCallbacks: + youAre == Side.black && gameState.moveToConfirm != null + ? ( + confirm: () { + ref.read(ctrlProvider.notifier).confirmMove(); + }, + cancel: () { + ref.read(ctrlProvider.notifier).cancelMove(); + }, + ) + : null, clock: gameState.game.clock != null ? CountdownClock( duration: gameState.game.clock!.black, @@ -324,6 +299,17 @@ class _Body extends ConsumerWidget { timeToMove: sideToMove == Side.white ? gameState.timeToMove : null, shouldLinkToUserProfile: youAre != Side.white, mePlaying: youAre == Side.white, + confirmMoveCallbacks: + youAre == Side.white && gameState.moveToConfirm != null + ? ( + confirm: () { + ref.read(ctrlProvider.notifier).confirmMove(); + }, + cancel: () { + ref.read(ctrlProvider.notifier).cancelMove(); + }, + ) + : null, clock: gameState.game.clock != null ? CountdownClock( duration: gameState.game.clock!.white, @@ -347,6 +333,10 @@ class _Body extends ConsumerWidget { child: SafeArea( bottom: false, child: BoardTable( + boardSettingsOverrides: BoardSettingsOverrides( + autoQueenPromotion: gameState.canAutoQueen, + autoQueenPromotionOnPremove: gameState.canAutoQueenOnPremove, + ), onMove: (move, {isDrop, isPremove}) { ref.read(ctrlProvider.notifier).onUserMove( Move.fromUci(move.uci)!, @@ -354,9 +344,11 @@ class _Body extends ConsumerWidget { isDrop: isDrop, ); }, - onPremove: (move) { - ref.read(ctrlProvider.notifier).setPremove(move); - }, + onPremove: gameState.canPremove + ? (move) { + ref.read(ctrlProvider.notifier).setPremove(move); + } + : null, boardData: cg.BoardData( interactableSide: gameState.game.playable && !gameState.isReplaying @@ -403,64 +395,53 @@ class _Body extends ConsumerWidget { child: content, ); } -} -class _Preferences extends ConsumerWidget { - const _Preferences(); + void _stateListener( + AsyncValue? prev, + AsyncValue state, { + required BuildContext context, + required WidgetRef ref, + }) { + if (prev?.hasValue == true && state.hasValue) { + // If the game is no longer playable, show the game end dialog. + if (prev!.requireValue.game.playable == true && + state.requireValue.game.playable == false) { + Timer(const Duration(milliseconds: 500), () { + if (context.mounted) { + showAdaptiveDialog( + context: context, + builder: (context) => _GameEndDialog( + ctrlProvider: ctrlProvider, + gameProvider: gameProvider, + ), + barrierDismissible: true, + ); + } + }); + } - @override - Widget build(BuildContext context, WidgetRef ref) { - final isSoundEnabled = ref.watch( - generalPreferencesProvider.select( - (prefs) => prefs.isSoundEnabled, - ), - ); - final boardPrefs = ref.watch(boardPreferencesProvider); - - return SafeArea( - child: ListView( - shrinkWrap: true, - children: [ - Padding( - padding: Styles.bodyPadding, - child: Text( - context.l10n.preferencesPreferences, - style: Styles.title, - ), - ), - SwitchSettingTile( - title: Text(context.l10n.sound), - value: isSoundEnabled, - onChanged: (value) { - ref - .read(generalPreferencesProvider.notifier) - .toggleSoundEnabled(); - }, - ), - SwitchSettingTile( - title: const Text('Haptic feedback'), - value: boardPrefs.hapticFeedback, - onChanged: (value) { - ref - .read(boardPreferencesProvider.notifier) - .toggleHapticFeedback(); - }, - ), - SwitchSettingTile( - title: Text( - context.l10n.preferencesPieceAnimation, - maxLines: 2, + // Opponent is gone long enough to show the claim win dialog. + if (!prev.requireValue.game.canClaimWin && + state.requireValue.game.canClaimWin) { + if (context.mounted) { + showAdaptiveDialog( + context: context, + builder: (context) => _ClaimWinDialog( + ctrlProvider: ctrlProvider, ), - value: boardPrefs.pieceAnimation, - onChanged: (value) { - ref - .read(boardPreferencesProvider.notifier) - .togglePieceAnimation(); - }, - ), - ], - ), - ); + barrierDismissible: true, + ); + } + } + + if (state.requireValue.redirectGameId != null) { + // Be sure to pop any dialogs that might be on top of the game screen. + Navigator.of(context).popUntil((route) => route is! RawDialogRoute); + ref + .read(gameProvider.notifier) + .rematch(state.requireValue.redirectGameId!); + } + } } } @@ -498,20 +479,12 @@ class _GameBottomBar extends ConsumerWidget { if (gameState.game.finished) BottomBarButton( label: context.l10n.gameAnalysis, - highlighted: true, shortLabel: 'Analysis', icon: Icons.biotech, onTap: () => pushPlatformRoute( context, - fullscreenDialog: true, builder: (_) => AnalysisScreen( - options: AnalysisOptions( - isLocalEvaluationAllowed: true, - variant: gameState.game.meta.variant, - steps: gameState.game.steps, - orientation: gameState.game.youAre ?? Side.white, - id: gameState.game.meta.id, - ), + options: gameState.analysisOptions, title: context.l10n.gameAnalysis, ), ), @@ -641,7 +614,7 @@ class _GameBottomBar extends ConsumerWidget { ref.read(ctrlProvider.notifier).abortGame(); }, ), - if (gameState.game.clock != null && gameState.game.moretimeable) + if (gameState.game.clock != null && gameState.game.canGiveTime) BottomSheetAction( label: Text( context.l10n.giveNbSeconds( @@ -652,7 +625,7 @@ class _GameBottomBar extends ConsumerWidget { ref.read(ctrlProvider.notifier).moreTime(); }, ), - if (gameState.game.takebackable) + if (gameState.game.canTakeback) BottomSheetAction( label: Text(context.l10n.takeback), onPressed: (context) { @@ -678,33 +651,33 @@ class _GameBottomBar extends ConsumerWidget { else if (gameState.canOfferDraw) BottomSheetAction( label: Text(context.l10n.offerDraw), - onPressed: (context) { - ref.read(ctrlProvider.notifier).offerOrAcceptDraw(); - }, + onPressed: gameState.shouldConfirmResignAndDrawOffer + ? (context) => _showConfirmDialog( + context, + description: Text(context.l10n.offerDraw), + onConfirm: () { + ref.read(ctrlProvider.notifier).offerOrAcceptDraw(); + }, + ) + : (context) { + ref.read(ctrlProvider.notifier).offerOrAcceptDraw(); + }, ), if (gameState.game.resignable) BottomSheetAction( label: Text(context.l10n.resign), dismissOnPress: false, - onPressed: (context) async { - await Navigator.of(context).maybePop(); - if (context.mounted) { - final result = await showAdaptiveDialog( - context: context, - builder: (context) => YesNoDialog( - title: const Text('Are you sure?'), - content: Text(context.l10n.resignTheGame), - onYes: () { - return Navigator.of(context).pop(true); - }, - onNo: () => Navigator.of(context).pop(false), - ), - ); - if (result == true) { - ref.read(ctrlProvider.notifier).resignGame(); - } - } - }, + onPressed: gameState.shouldConfirmResignAndDrawOffer + ? (context) => _showConfirmDialog( + context, + description: Text(context.l10n.resignTheGame), + onConfirm: () { + ref.read(ctrlProvider.notifier).resignGame(); + }, + ) + : (context) { + ref.read(ctrlProvider.notifier).resignGame(); + }, ), if (gameState.game.canClaimWin) ...[ BottomSheetAction( @@ -750,6 +723,30 @@ class _GameBottomBar extends ConsumerWidget { ], ); } + + Future _showConfirmDialog( + BuildContext context, { + required Widget description, + required VoidCallback onConfirm, + }) async { + await Navigator.of(context).maybePop(); + if (context.mounted) { + final result = await showAdaptiveDialog( + context: context, + builder: (context) => YesNoDialog( + title: const Text('Are you sure?'), + content: description, + onYes: () { + return Navigator.of(context).pop(true); + }, + onNo: () => Navigator.of(context).pop(false), + ), + ); + if (result == true) { + onConfirm(); + } + } + } } class _GameEndDialog extends ConsumerStatefulWidget { @@ -820,7 +817,7 @@ class _GameEndDialogState extends ConsumerState<_GameEndDialog> { ), textAlign: TextAlign.center, ), - const SizedBox(height: 24.0), + const SizedBox(height: 16.0), if (gameState.game.player?.offeringRematch == true) SecondaryButton( semanticsLabel: context.l10n.cancelRematchOffer, @@ -843,7 +840,6 @@ class _GameEndDialogState extends ConsumerState<_GameEndDialog> { glowing: gameState.game.opponent?.offeringRematch == true, child: Text(context.l10n.rematch), ), - const SizedBox(height: 8.0), SecondaryButton( semanticsLabel: context.l10n.newOpponent, onPressed: _activateButtons @@ -856,6 +852,17 @@ class _GameEndDialogState extends ConsumerState<_GameEndDialog> { : null, child: Text(context.l10n.newOpponent), ), + SecondaryButton( + semanticsLabel: context.l10n.analysis, + onPressed: () => pushPlatformRoute( + context, + builder: (_) => AnalysisScreen( + options: gameState.analysisOptions, + title: context.l10n.gameAnalysis, + ), + ), + child: Text(context.l10n.analysis), + ), ], ); diff --git a/lib/src/view/game/game_settings.dart b/lib/src/view/game/game_settings.dart new file mode 100644 index 0000000000..c0e9263dc2 --- /dev/null +++ b/lib/src/view/game/game_settings.dart @@ -0,0 +1,89 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:lichess_mobile/src/model/game/game_ctrl.dart'; +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/widgets/adaptive_bottom_sheet.dart'; +import 'package:lichess_mobile/src/widgets/settings.dart'; +import 'package:lichess_mobile/src/utils/l10n_context.dart'; + +class GameSettings extends ConsumerWidget { + const GameSettings(this.ctrlProvider, {super.key}); + + final GameCtrlProvider? ctrlProvider; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isSoundEnabled = ref.watch( + generalPreferencesProvider.select( + (prefs) => prefs.isSoundEnabled, + ), + ); + final boardPrefs = ref.watch(boardPreferencesProvider); + final gameState = ctrlProvider != null + ? ref.watch(ctrlProvider!) + : const AsyncValue.loading(); + + return ModalSheetScaffold( + title: Text(context.l10n.settingsSettings), + child: ListView( + shrinkWrap: true, + children: [ + SwitchSettingTile( + title: Text(context.l10n.sound), + value: isSoundEnabled, + onChanged: (value) { + ref + .read(generalPreferencesProvider.notifier) + .toggleSoundEnabled(); + }, + ), + SwitchSettingTile( + title: const Text('Haptic feedback'), + value: boardPrefs.hapticFeedback, + onChanged: (value) { + ref + .read(boardPreferencesProvider.notifier) + .toggleHapticFeedback(); + }, + ), + SwitchSettingTile( + title: Text( + context.l10n.preferencesPieceAnimation, + maxLines: 2, + ), + value: boardPrefs.pieceAnimation, + onChanged: (value) { + ref + .read(boardPreferencesProvider.notifier) + .togglePieceAnimation(); + }, + ), + gameState.when( + data: (data) { + if (data.game.prefs?.submitMove == true) { + return SwitchSettingTile( + title: Text( + context.l10n.preferencesMoveConfirmation, + maxLines: 2, + ), + value: data.shouldConfirmMove, + onChanged: (value) { + ref.read(ctrlProvider!.notifier).toggleMoveConfirmation(); + }, + ); + } else { + return const SizedBox.shrink(); + } + }, + loading: () => const SizedBox.shrink(), + error: (e, s) => const SizedBox.shrink(), + ), + const SizedBox(height: 16.0), + ], + ), + ); + } +} diff --git a/lib/src/view/game/game_loader.dart b/lib/src/view/game/lobby_game_loading_board.dart similarity index 97% rename from lib/src/view/game/game_loader.dart rename to lib/src/view/game/lobby_game_loading_board.dart index 4f116530d8..78b69a771b 100644 --- a/lib/src/view/game/game_loader.dart +++ b/lib/src/view/game/lobby_game_loading_board.dart @@ -15,7 +15,7 @@ import 'package:lichess_mobile/src/widgets/board_table.dart'; import 'package:lichess_mobile/src/styles/styles.dart'; import 'package:lichess_mobile/src/utils/l10n_context.dart'; -part 'game_loader.g.dart'; +part 'lobby_game_loading_board.g.dart'; @riverpod Stream<({int nbPlayers, int nbGames})> lobbyNumbers( @@ -34,13 +34,13 @@ Stream<({int nbPlayers, int nbGames})> lobbyNumbers( } } -class GameLoader extends ConsumerWidget { - const GameLoader(this.seek); +class LobbyGameLoadingBoard extends StatelessWidget { + const LobbyGameLoadingBoard(this.seek); final GameSeek seek; @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { return Column( children: [ Expanded( diff --git a/lib/src/view/play/play_screen.dart b/lib/src/view/play/play_screen.dart index 4ae378b325..9c1c0409a4 100644 --- a/lib/src/view/play/play_screen.dart +++ b/lib/src/view/play/play_screen.dart @@ -159,6 +159,7 @@ class _TimeControlButton extends ConsumerWidget { final double screenHeight = MediaQuery.sizeOf(context).height; showAdaptiveBottomSheet( context: context, + isScrollControlled: true, constraints: BoxConstraints( maxHeight: screenHeight - (screenHeight / 10), ), diff --git a/lib/src/view/play/time_control_modal.dart b/lib/src/view/play/time_control_modal.dart index c6454af242..a0a36f14fd 100644 --- a/lib/src/view/play/time_control_modal.dart +++ b/lib/src/view/play/time_control_modal.dart @@ -175,8 +175,6 @@ class _ChoiceChip extends StatelessWidget { @override Widget build(BuildContext context) { - final cupertinoBrightness = - CupertinoTheme.maybeBrightnessOf(context) ?? Brightness.light; switch (defaultTargetPlatform) { case TargetPlatform.android: return ChoiceChip( @@ -188,9 +186,8 @@ class _ChoiceChip extends StatelessWidget { case TargetPlatform.iOS: return Container( decoration: BoxDecoration( - color: cupertinoBrightness == Brightness.light - ? CupertinoColors.systemBackground - : CupertinoColors.tertiarySystemBackground.resolveFrom(context), + color: CupertinoColors.secondarySystemGroupedBackground + .resolveFrom(context), borderRadius: const BorderRadius.all(Radius.circular(5.0)), border: selected ? Border.fromBorderSide( diff --git a/lib/src/view/settings/account_preferences_screen.dart b/lib/src/view/settings/account_preferences_screen.dart new file mode 100644 index 0000000000..4704a80cbd --- /dev/null +++ b/lib/src/view/settings/account_preferences_screen.dart @@ -0,0 +1,409 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:lichess_mobile/src/widgets/platform.dart'; +import 'package:lichess_mobile/src/widgets/list.dart'; +import 'package:lichess_mobile/src/widgets/adaptive_choice_picker.dart'; +import 'package:lichess_mobile/src/widgets/settings.dart'; +import 'package:lichess_mobile/src/model/account/account_preferences.dart'; +import 'package:lichess_mobile/src/utils/l10n_context.dart'; +import 'package:lichess_mobile/src/utils/navigation.dart'; + +class AccountPreferencesScreen extends StatelessWidget { + const AccountPreferencesScreen({super.key}); + + @override + Widget build(BuildContext context) { + return PlatformWidget( + androidBuilder: _androidBuilder, + iosBuilder: _iosBuilder, + ); + } + + Widget _androidBuilder(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Account preferences'), + ), + body: _Body(), + ); + } + + Widget _iosBuilder(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar(), + child: _Body(), + ); + } +} + +class _Body extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final accountPrefs = ref.watch(accountPreferencesProvider); + + return accountPrefs.when( + data: (data) { + if (data == null) { + return const Center( + child: Text('You must be logged in to view this page.'), + ); + } + + return SafeArea( + child: ListView( + children: [ + ListSection( + header: Text( + context.l10n.preferencesGameBehavior, + ), + hasLeading: false, + children: [ + SwitchSettingTile( + title: Text( + context.l10n.preferencesPremovesPlayingDuringOpponentTurn, + maxLines: 2, + ), + value: data.premove.value, + onChanged: (value) { + ref + .read(accountPreferencesProvider.notifier) + .setPremove(BooleanPref(value)); + }, + ), + SwitchSettingTile( + title: Text( + context.l10n.preferencesConfirmResignationAndDrawOffers, + maxLines: 2, + ), + value: data.confirmResign.value, + onChanged: (value) { + ref + .read(accountPreferencesProvider.notifier) + .setConfirmResign(BooleanPref(value)); + }, + ), + SettingsListTile( + settingsLabel: Text( + context.l10n.preferencesTakebacksWithOpponentApproval, + maxLines: 2, + ), + settingsValue: data.takeback.label(context), + showCupertinoTrailingValue: false, + onTap: () { + if (defaultTargetPlatform == TargetPlatform.android) { + showChoicePicker( + context, + choices: Takeback.values, + selectedItem: data.takeback, + labelBuilder: (t) => Text(t.label(context)), + onSelectedItemChanged: (Takeback? value) { + ref + .read(accountPreferencesProvider.notifier) + .setTakeback(value ?? data.takeback); + }, + ); + } else { + pushPlatformRoute( + context, + title: context + .l10n.preferencesTakebacksWithOpponentApproval, + builder: (context) => const TakebackSettingsScreen(), + ); + } + }, + ), + SettingsListTile( + settingsLabel: Text( + context.l10n.preferencesPromoteToQueenAutomatically, + maxLines: 2, + ), + settingsValue: data.autoQueen.label(context), + showCupertinoTrailingValue: false, + onTap: () { + if (defaultTargetPlatform == TargetPlatform.android) { + showChoicePicker( + context, + choices: AutoQueen.values, + selectedItem: data.autoQueen, + labelBuilder: (t) => Text(t.label(context)), + onSelectedItemChanged: (AutoQueen? value) { + ref + .read(accountPreferencesProvider.notifier) + .setAutoQueen(value ?? data.autoQueen); + }, + ); + } else { + pushPlatformRoute( + context, + title: context + .l10n.preferencesPromoteToQueenAutomatically, + builder: (context) => const AutoQueenSettingsScreen(), + ); + } + }, + ), + SettingsListTile( + settingsLabel: Text( + context.l10n + .preferencesClaimDrawOnThreefoldRepetitionAutomatically, + maxLines: 2, + ), + settingsValue: data.autoThreefold.label(context), + showCupertinoTrailingValue: false, + onTap: () { + if (defaultTargetPlatform == TargetPlatform.android) { + showChoicePicker( + context, + choices: AutoThreefold.values, + selectedItem: data.autoThreefold, + labelBuilder: (t) => Text(t.label(context)), + onSelectedItemChanged: (AutoThreefold? value) { + ref + .read(accountPreferencesProvider.notifier) + .setAutoThreefold(value ?? data.autoThreefold); + }, + ); + } else { + pushPlatformRoute( + context, + title: context.l10n + .preferencesClaimDrawOnThreefoldRepetitionAutomatically, + builder: (context) => + const AutoThreefoldSettingsScreen(), + ); + } + }, + ), + SettingsListTile( + settingsLabel: Text( + context.l10n.preferencesMoveConfirmation, + maxLines: 2, + ), + settingsValue: data.submitMove.label(context), + showCupertinoTrailingValue: false, + onTap: () { + showMultipleChoicesPicker( + context, + choices: SubmitMoveChoice.values, + selectedItems: data.submitMove.choices, + labelBuilder: (t) => Text(t.label(context)), + ).then((value) { + if (value != null) { + ref + .read(accountPreferencesProvider.notifier) + .setSubmitMove(SubmitMove(value)); + } + }); + }, + ), + ], + ), + ListSection( + header: Text( + context.l10n.preferencesChessClock, + ), + hasLeading: false, + children: [ + SettingsListTile( + settingsLabel: Text( + context.l10n.preferencesGiveMoreTime, + maxLines: 2, + ), + settingsValue: data.moretime.label(context), + showCupertinoTrailingValue: false, + onTap: () { + if (defaultTargetPlatform == TargetPlatform.android) { + showChoicePicker( + context, + choices: Moretime.values, + selectedItem: data.moretime, + labelBuilder: (t) => Text(t.label(context)), + onSelectedItemChanged: (Moretime? value) { + ref + .read(accountPreferencesProvider.notifier) + .setMoretime(value ?? data.moretime); + }, + ); + } else { + pushPlatformRoute( + context, + title: context.l10n.preferencesGiveMoreTime, + builder: (context) => const MoretimeSettingsScreen(), + ); + } + }, + ), + ], + ), + ], + ), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (err, stack) => Center(child: Text(err.toString())), + ); + } +} + +class TakebackSettingsScreen extends ConsumerWidget { + const TakebackSettingsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final accountPrefs = ref.watch(accountPreferencesProvider); + return accountPrefs.when( + data: (data) { + if (data == null) { + return const Center( + child: Text('You must be logged in to view this page.'), + ); + } + + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar(), + child: SafeArea( + child: ListView( + children: [ + ChoicePicker( + choices: Takeback.values, + selectedItem: data.takeback, + titleBuilder: (t) => Text(t.label(context)), + onSelectedItemChanged: (Takeback? v) { + ref + .read(accountPreferencesProvider.notifier) + .setTakeback(v ?? data.takeback); + }, + ), + ], + ), + ), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (err, stack) => Center(child: Text(err.toString())), + ); + } +} + +class AutoQueenSettingsScreen extends ConsumerWidget { + const AutoQueenSettingsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final accountPrefs = ref.watch(accountPreferencesProvider); + return accountPrefs.when( + data: (data) { + if (data == null) { + return const Center( + child: Text('You must be logged in to view this page.'), + ); + } + + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar(), + child: SafeArea( + child: ListView( + children: [ + ChoicePicker( + choices: AutoQueen.values, + selectedItem: data.autoQueen, + titleBuilder: (t) => Text(t.label(context)), + onSelectedItemChanged: (AutoQueen? v) { + ref + .read(accountPreferencesProvider.notifier) + .setAutoQueen(v ?? data.autoQueen); + }, + ), + ], + ), + ), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (err, stack) => Center(child: Text(err.toString())), + ); + } +} + +class AutoThreefoldSettingsScreen extends ConsumerWidget { + const AutoThreefoldSettingsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final accountPrefs = ref.watch(accountPreferencesProvider); + return accountPrefs.when( + data: (data) { + if (data == null) { + return const Center( + child: Text('You must be logged in to view this page.'), + ); + } + + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar(), + child: SafeArea( + child: ListView( + children: [ + ChoicePicker( + choices: AutoThreefold.values, + selectedItem: data.autoThreefold, + titleBuilder: (t) => Text(t.label(context)), + onSelectedItemChanged: (AutoThreefold? v) { + ref + .read(accountPreferencesProvider.notifier) + .setAutoThreefold(v ?? data.autoThreefold); + }, + ), + ], + ), + ), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (err, stack) => Center(child: Text(err.toString())), + ); + } +} + +class MoretimeSettingsScreen extends ConsumerWidget { + const MoretimeSettingsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final accountPrefs = ref.watch(accountPreferencesProvider); + return accountPrefs.when( + data: (data) { + if (data == null) { + return const Center( + child: Text('You must be logged in to view this page.'), + ); + } + + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar(), + child: SafeArea( + child: ListView( + children: [ + ChoicePicker( + choices: Moretime.values, + selectedItem: data.moretime, + titleBuilder: (t) => Text(t.label(context)), + onSelectedItemChanged: (Moretime? v) { + ref + .read(accountPreferencesProvider.notifier) + .setMoretime(v ?? data.moretime); + }, + ), + ], + ), + ), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (err, stack) => Center(child: Text(err.toString())), + ); + } +} diff --git a/lib/src/view/settings/settings_screen.dart b/lib/src/view/settings/settings_screen.dart index a0d51ef6ae..ff26813fbc 100644 --- a/lib/src/view/settings/settings_screen.dart +++ b/lib/src/view/settings/settings_screen.dart @@ -27,6 +27,7 @@ import './sound_settings_screen.dart'; import './piece_set_screen.dart'; import './board_theme_screen.dart'; import './board_settings_screen.dart'; +import './account_preferences_screen.dart'; class SettingsScreen extends StatelessWidget { const SettingsScreen({super.key}); @@ -85,7 +86,7 @@ class _Body extends ConsumerWidget { children: [ SettingsListTile( icon: const Icon(Icons.music_note), - settingsLabel: context.l10n.sound, + settingsLabel: Text(context.l10n.sound), settingsValue: soundThemeL10n(context, soundTheme), onTap: () { if (defaultTargetPlatform == TargetPlatform.android) { @@ -115,7 +116,7 @@ class _Body extends ConsumerWidget { ), SettingsListTile( icon: const Icon(Icons.brightness_medium), - settingsLabel: context.l10n.background, + settingsLabel: Text(context.l10n.background), settingsValue: ThemeModeScreen.themeTitle(context, themeMode), onTap: () { if (defaultTargetPlatform == TargetPlatform.android) { @@ -140,7 +141,7 @@ class _Body extends ConsumerWidget { ), SettingsListTile( icon: const Icon(LichessIcons.chess_board), - settingsLabel: context.l10n.boardTheme, + settingsLabel: Text(context.l10n.boardTheme), settingsValue: boardPrefs.boardTheme.label, onTap: () { pushPlatformRoute( @@ -152,7 +153,7 @@ class _Body extends ConsumerWidget { ), SettingsListTile( icon: const Icon(LichessIcons.chess_knight), - settingsLabel: context.l10n.pieceSet, + settingsLabel: Text(context.l10n.pieceSet), settingsValue: boardPrefs.pieceSet.label, onTap: () { pushPlatformRoute( @@ -162,12 +163,6 @@ class _Body extends ConsumerWidget { ); }, ), - ], - ), - ListSection( - hasLeading: true, - showDivider: true, - children: [ PlatformListTile( leading: const Icon(LichessIcons.chess_board), title: const Text('Chessboard'), @@ -184,6 +179,27 @@ class _Body extends ConsumerWidget { ), ], ), + if (userSession != null) + ListSection( + hasLeading: true, + showDivider: true, + children: [ + PlatformListTile( + leading: const Icon(Icons.person), + title: const Text('Account preferences'), + trailing: defaultTargetPlatform == TargetPlatform.iOS + ? const CupertinoListTileChevron() + : null, + onTap: () { + pushPlatformRoute( + context, + title: 'Account preferences', + builder: (context) => const AccountPreferencesScreen(), + ); + }, + ), + ], + ), if (userSession != null) if (authController.isLoading) const ListSection( diff --git a/lib/src/view/user/recent_games.dart b/lib/src/view/user/recent_games.dart index c2c74263c9..53ad1aec45 100644 --- a/lib/src/view/user/recent_games.dart +++ b/lib/src/view/user/recent_games.dart @@ -119,9 +119,12 @@ class RecentGames extends ConsumerWidget { }, error: (error, stackTrace) { debugPrint( - 'SEVERE: [UserScreen] could not load user games; $error\n$stackTrace', + 'SEVERE: [RecentGames] could not recent games; $error\n$stackTrace', + ); + return Padding( + padding: Styles.bodySectionPadding, + child: const Text('Could not load recent games.'), ); - return const Text('Could not load games.'); }, loading: () => Shimmer( child: ShimmerLoading( diff --git a/lib/src/widgets/adaptive_bottom_sheet.dart b/lib/src/widgets/adaptive_bottom_sheet.dart index 259866a7c8..07560f9e68 100644 --- a/lib/src/widgets/adaptive_bottom_sheet.dart +++ b/lib/src/widgets/adaptive_bottom_sheet.dart @@ -2,6 +2,9 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:lichess_mobile/src/utils/l10n_context.dart'; +import 'package:lichess_mobile/src/widgets/buttons.dart'; + /// A modal bottom sheet that adapts to the platform (Android/iOS). Future showAdaptiveBottomSheet({ required BuildContext context, @@ -9,6 +12,7 @@ Future showAdaptiveBottomSheet({ bool isDismissible = true, bool useRootNavigator = true, bool useSafeArea = true, + bool isScrollControlled = false, bool? showDragHandle, BoxConstraints? constraints, }) async { @@ -17,12 +21,20 @@ Future showAdaptiveBottomSheet({ isDismissible: isDismissible, enableDrag: isDismissible, showDragHandle: showDragHandle, - isScrollControlled: true, + isScrollControlled: isScrollControlled, useRootNavigator: useRootNavigator, useSafeArea: useSafeArea, + shape: defaultTargetPlatform == TargetPlatform.iOS + ? const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(10.0), + ), + ) + : null, + constraints: constraints, backgroundColor: defaultTargetPlatform == TargetPlatform.iOS ? CupertinoDynamicColor.resolve( - CupertinoColors.secondarySystemBackground, + CupertinoColors.tertiarySystemGroupedBackground, context, ) : null, @@ -30,3 +42,85 @@ Future showAdaptiveBottomSheet({ builder: builder, ); } + +/// A full-screen modal bottom sheet widget with an header and a body. +/// +/// This is typically used with [showAdaptiveBottomSheet]. +/// +/// The [child] widget can be a scrollable widget, in that case you must set +/// `isScrollControlled` to true when calling [showAdaptiveBottomSheet]. +class ModalSheetScaffold extends StatelessWidget { + const ModalSheetScaffold({ + required this.title, + required this.child, + super.key, + }); + + final Widget title; + final Widget child; + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Column( + children: [ + if (defaultTargetPlatform == TargetPlatform.iOS) + Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(10.0), + topRight: Radius.circular(10.0), + ), + color: CupertinoTheme.of(context).barBackgroundColor, + ), + height: kMinInteractiveDimensionCupertino, + child: Container( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: CupertinoDynamicColor.resolve( + CupertinoColors.separator, + context, + ), + width: 0.0, + ), + ), + ), + child: NavigationToolbar( + leading: CupertinoIconButton( + semanticsLabel: context.l10n.close, + icon: const Icon(CupertinoIcons.clear), + padding: EdgeInsets.zero, + onPressed: () => Navigator.of(context).maybePop(), + ), + middle: DefaultTextStyle.merge( + child: title, + style: + CupertinoTheme.of(context).textTheme.navTitleTextStyle, + ), + ), + ), + ) + else + SizedBox( + height: kToolbarHeight, + child: NavigationToolbar( + leading: IconButton( + icon: const Icon(Icons.close), + color: Theme.of(context).iconTheme.color, + onPressed: () => Navigator.of(context).maybePop(), + ), + middle: DefaultTextStyle.merge( + child: title, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + ), + Expanded( + child: child, + ), + ], + ), + ); + } +} diff --git a/lib/src/widgets/adaptive_choice_picker.dart b/lib/src/widgets/adaptive_choice_picker.dart index 89f751beb0..eea76366f5 100644 --- a/lib/src/widgets/adaptive_choice_picker.dart +++ b/lib/src/widgets/adaptive_choice_picker.dart @@ -81,3 +81,65 @@ Future showChoicePicker( throw Exception('Unexpected platform $defaultTargetPlatform'); } } + +Future?> showMultipleChoicesPicker( + BuildContext context, { + required Iterable choices, + required Iterable selectedItems, + required Widget Function(T choice) labelBuilder, +}) { + return showAdaptiveDialog>( + context: context, + builder: (context) { + Set items = {...selectedItems}; + return AlertDialog.adaptive( + contentPadding: const EdgeInsets.only(top: 12), + scrollable: true, + content: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Column( + mainAxisSize: MainAxisSize.min, + children: choices.map((choice) { + return CheckboxListTile.adaptive( + title: labelBuilder(choice), + value: items.contains(choice), + onChanged: (value) { + if (value != null) { + setState(() { + items = value + ? items.union({choice}) + : items.difference({choice}); + }); + } + }, + ); + }).toList(growable: false), + ); + }, + ), + actions: defaultTargetPlatform == TargetPlatform.iOS + ? [ + CupertinoDialogAction( + onPressed: () => Navigator.of(context).pop(), + child: Text(context.l10n.cancel), + ), + CupertinoDialogAction( + isDefaultAction: true, + child: const Text('OK'), + onPressed: () => Navigator.of(context).pop(items), + ), + ] + : [ + TextButton( + child: Text(context.l10n.cancel), + onPressed: () => Navigator.of(context).pop(), + ), + TextButton( + child: const Text('OK'), + onPressed: () => Navigator.of(context).pop(items), + ), + ], + ); + }, + ); +} diff --git a/lib/src/widgets/board_table.dart b/lib/src/widgets/board_table.dart index d092bfc455..5bdd7bbda0 100644 --- a/lib/src/widgets/board_table.dart +++ b/lib/src/widgets/board_table.dart @@ -129,9 +129,7 @@ class BoardTable extends ConsumerWidget { ); final settings = boardSettingsOverrides != null - ? defaultSettings.copyWith( - animationDuration: boardSettingsOverrides!.animationDuration, - ) + ? boardSettingsOverrides!.merge(defaultSettings) : defaultSettings; final board = Board( @@ -290,9 +288,21 @@ class BoardTable extends ConsumerWidget { class BoardSettingsOverrides { const BoardSettingsOverrides({ this.animationDuration, + this.autoQueenPromotion, + this.autoQueenPromotionOnPremove, }); final Duration? animationDuration; + final bool? autoQueenPromotion; + final bool? autoQueenPromotionOnPremove; + + BoardSettings merge(BoardSettings settings) { + return settings.copyWith( + animationDuration: animationDuration, + autoQueenPromotion: autoQueenPromotion, + autoQueenPromotionOnPremove: autoQueenPromotionOnPremove, + ); + } } enum MoveListType { inline, stacked } diff --git a/lib/src/widgets/buttons.dart b/lib/src/widgets/buttons.dart index 76e99f4fc1..6175070dfb 100644 --- a/lib/src/widgets/buttons.dart +++ b/lib/src/widgets/buttons.dart @@ -666,13 +666,22 @@ class PlatformIconButton extends StatelessWidget { required this.icon, required this.semanticsLabel, required this.onTap, - this.highlighted = true, - }); + this.highlighted = false, + this.color, + this.iconSize, + this.padding, + }) : assert( + color == null || !highlighted, + 'Cannot provide both color and highlighted', + ); final IconData icon; final String semanticsLabel; final VoidCallback? onTap; final bool highlighted; + final Color? color; + final double? iconSize; + final EdgeInsetsGeometry? padding; @override Widget build(BuildContext context) { @@ -685,7 +694,9 @@ class PlatformIconButton extends StatelessWidget { onPressed: onTap, icon: Icon(icon), tooltip: semanticsLabel, - color: highlighted ? themeData.colorScheme.primary : null, + color: highlighted ? themeData.colorScheme.primary : color, + iconSize: iconSize, + padding: padding, ), ); case TargetPlatform.iOS: @@ -697,9 +708,11 @@ class PlatformIconButton extends StatelessWidget { child: CupertinoIconButton( onPressed: onTap, semanticsLabel: semanticsLabel, + padding: padding, icon: Icon( icon, - color: highlighted ? themeData.primaryColor : null, + color: highlighted ? themeData.primaryColor : color, + size: iconSize, ), ), ); diff --git a/lib/src/widgets/non_linear_slider.dart b/lib/src/widgets/non_linear_slider.dart index 9005e197e7..a62721f4ec 100644 --- a/lib/src/widgets/non_linear_slider.dart +++ b/lib/src/widgets/non_linear_slider.dart @@ -42,37 +42,43 @@ class _NonLinearSliderState extends State { @override Widget build(BuildContext context) { - return Slider.adaptive( - value: _index.toDouble(), - min: 0, - max: widget.values.length.toDouble() - 1, - divisions: widget.values.length - 1, - label: widget.labelBuilder?.call(widget.values[_index]) ?? - widget.values[_index].toString(), - onChanged: widget.onChangeEnd != null - ? (double value) { - final currentIndex = _index; - final newIndex = value.toInt(); - setState(() { - _index = newIndex; - }); - - // iOS doesn't show a label when sliding, so we need to manually - // call the callback when the value changes. - if (defaultTargetPlatform == TargetPlatform.iOS && - currentIndex != newIndex && - widget.onChangeEnd != null) { - WidgetsBinding.instance.addPostFrameCallback((_) { - widget.onChangeEnd?.call(widget.values[_index]); + return Opacity( + opacity: defaultTargetPlatform != TargetPlatform.iOS || + widget.onChangeEnd != null + ? 1 + : 0.5, + child: Slider.adaptive( + value: _index.toDouble(), + min: 0, + max: widget.values.length.toDouble() - 1, + divisions: widget.values.length - 1, + label: widget.labelBuilder?.call(widget.values[_index]) ?? + widget.values[_index].toString(), + onChanged: widget.onChangeEnd != null + ? (double value) { + final currentIndex = _index; + final newIndex = value.toInt(); + setState(() { + _index = newIndex; }); + + // iOS doesn't show a label when sliding, so we need to manually + // call the callback when the value changes. + if (defaultTargetPlatform == TargetPlatform.iOS && + currentIndex != newIndex && + widget.onChangeEnd != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + widget.onChangeEnd?.call(widget.values[_index]); + }); + } } - } - : null, - onChangeEnd: (double value) { - if (defaultTargetPlatform != TargetPlatform.iOS) { - widget.onChangeEnd?.call(widget.values[_index]); - } - }, + : null, + onChangeEnd: (double value) { + if (defaultTargetPlatform != TargetPlatform.iOS) { + widget.onChangeEnd?.call(widget.values[_index]); + } + }, + ), ); } } diff --git a/lib/src/widgets/player.dart b/lib/src/widgets/player.dart index de5aef9a1f..1fe71437c1 100644 --- a/lib/src/widgets/player.dart +++ b/lib/src/widgets/player.dart @@ -13,6 +13,7 @@ import 'package:lichess_mobile/src/utils/navigation.dart'; import 'package:lichess_mobile/src/utils/l10n_context.dart'; import 'package:lichess_mobile/src/utils/layout.dart'; import 'package:lichess_mobile/src/view/user/user_screen.dart'; +import 'package:lichess_mobile/src/widgets/buttons.dart'; /// A widget to display player information above/below the chess board. /// @@ -22,6 +23,7 @@ class BoardPlayer extends StatelessWidget { required this.player, this.clock, this.materialDiff, + this.confirmMoveCallbacks, this.timeToMove, this.shouldLinkToUserProfile = true, this.mePlaying = false, @@ -32,6 +34,10 @@ class BoardPlayer extends StatelessWidget { final Player player; final Widget? clock; final MaterialDiffSide? materialDiff; + + /// if confirm move preference is enabled, used to display confirmation buttons + final ({VoidCallback confirm, VoidCallback cancel})? confirmMoveCallbacks; + final bool shouldLinkToUserProfile; final bool mePlaying; final bool zenMode; @@ -150,7 +156,17 @@ class BoardPlayer extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.center, children: [ - if (!zenMode || timeToMove != null) + if (mePlaying && confirmMoveCallbacks != null) + Expanded( + child: Padding( + padding: const EdgeInsets.only(right: 20), + child: ConfirmMove( + onConfirm: confirmMoveCallbacks!.confirm, + onCancel: confirmMoveCallbacks!.cancel, + ), + ), + ) + else if (!zenMode || timeToMove != null) Expanded( child: Padding( padding: const EdgeInsets.only(right: 20), @@ -176,6 +192,49 @@ class BoardPlayer extends StatelessWidget { } } +class ConfirmMove extends StatelessWidget { + const ConfirmMove({ + required this.onConfirm, + required this.onCancel, + super.key, + }); + + final VoidCallback onConfirm; + final VoidCallback onCancel; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + PlatformIconButton( + icon: CupertinoIcons.xmark_rectangle_fill, + color: LichessColors.red, + iconSize: 35, + semanticsLabel: context.l10n.cancel, + padding: const EdgeInsets.all(10), + onTap: onCancel, + ), + Flexible( + child: Text( + context.l10n.confirmMove, + overflow: TextOverflow.ellipsis, + maxLines: 2, + ), + ), + PlatformIconButton( + icon: CupertinoIcons.checkmark_rectangle_fill, + color: LichessColors.green, + iconSize: 35, + semanticsLabel: context.l10n.accept, + padding: const EdgeInsets.all(10), + onTap: onConfirm, + ), + ], + ); + } +} + class MoveExpiration extends StatefulWidget { const MoveExpiration({ required this.timeToMove, diff --git a/lib/src/widgets/settings.dart b/lib/src/widgets/settings.dart index 46d66eed9a..68a5d318de 100644 --- a/lib/src/widgets/settings.dart +++ b/lib/src/widgets/settings.dart @@ -12,20 +12,29 @@ class SettingsListTile extends StatelessWidget { required this.settingsLabel, required this.settingsValue, required this.onTap, + this.showCupertinoTrailingValue = true, super.key, }); final Icon? icon; - final String settingsLabel; + + /// The label of the settings value, typically a [Text] widget. + final Widget settingsLabel; + final String settingsValue; final void Function() onTap; + /// Whether to show the value in the trailing position on iOS. + /// + /// True by default, can be disabled for long settings names. + final bool showCupertinoTrailingValue; + @override Widget build(BuildContext context) { final tile = PlatformListTile( leading: icon, - title: Text(settingsLabel), - additionalInfo: Text(settingsValue), + title: settingsLabel, + additionalInfo: showCupertinoTrailingValue ? Text(settingsValue) : null, subtitle: defaultTargetPlatform == TargetPlatform.android ? Text( settingsValue, @@ -78,7 +87,7 @@ class SwitchSettingTile extends StatelessWidget { } } -/// A platform agnostic choice picker. +/// A platform agnostic choice picker class ChoicePicker extends StatelessWidget { const ChoicePicker({ super.key, diff --git a/lib/src/widgets/yes_no_dialog.dart b/lib/src/widgets/yes_no_dialog.dart index 7dd42c7efd..e4f12b70d8 100644 --- a/lib/src/widgets/yes_no_dialog.dart +++ b/lib/src/widgets/yes_no_dialog.dart @@ -7,16 +7,18 @@ import 'package:lichess_mobile/src/utils/l10n_context.dart'; class YesNoDialog extends StatelessWidget { const YesNoDialog({ super.key, - required this.title, - required this.content, + this.title, + this.content, required this.onYes, required this.onNo, + this.alignment, }); - final Widget title; - final Widget content; + final Widget? title; + final Widget? content; final VoidCallback onYes; final VoidCallback onNo; + final AlignmentGeometry? alignment; @override Widget build(BuildContext context) { @@ -39,6 +41,7 @@ class YesNoDialog extends StatelessWidget { return AlertDialog( title: title, content: content, + alignment: alignment, actions: [ TextButton( onPressed: onNo, diff --git a/test/model/account/account_repository_test.dart b/test/model/account/account_repository_test.dart new file mode 100644 index 0000000000..3c4c257ec2 --- /dev/null +++ b/test/model/account/account_repository_test.dart @@ -0,0 +1,84 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:async/async.dart'; +import 'package:logging/logging.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:http/http.dart' as http; + +import 'package:lichess_mobile/src/model/auth/auth_client.dart'; +import 'package:lichess_mobile/src/constants.dart'; +import 'package:lichess_mobile/src/model/account/account_repository.dart'; +import 'package:lichess_mobile/src/model/account/account_preferences.dart'; + +class MockAuthClient extends Mock implements AuthClient {} + +class MockLogger extends Mock implements Logger {} + +void main() { + final mockLogger = MockLogger(); + final mockAuthClient = MockAuthClient(); + final repo = AccountRepository(apiClient: mockAuthClient, logger: mockLogger); + + setUpAll(() { + reset(mockAuthClient); + }); + + group('AccountRepository', () { + test('getPreferences', () async { + const response = ''' +{ + "prefs": { + "dark": true, + "transp": false, + "bgImg": "http://example.com", + "is3d": false, + "theme": "blue", + "pieceSet": "cburnett", + "theme3d": "Black-White-Aluminium", + "pieceSet3d": "Basic", + "soundSet": "silent", + "blindfold": 0, + "autoQueen": 2, + "autoThreefold": 2, + "takeback": 3, + "moretime": 3, + "clockTenths": 1, + "clockBar": true, + "clockSound": true, + "premove": true, + "animation": 2, + "captured": true, + "follow": true, + "highlight": true, + "destination": true, + "coords": 2, + "replay": 2, + "challenge": 4, + "message": 3, + "coordColor": 2, + "submitMove": 4, + "confirmResign": 1, + "insightShare": 1, + "keyboardMove": 0, + "zen": 0, + "moveEvent": 2, + "rookCastle": 1 + }, + "language": "en-GB" +} +'''; + + when( + () => mockAuthClient + .get(Uri.parse('$kLichessHost/api/account/preferences')), + ).thenAnswer( + (_) async => Result.value(http.Response(response, 200)), + ); + + final result = await repo.getPreferences(); + + expect(result.isValue, true); + + expect(result.asValue!.value.autoQueen, AutoQueen.premove); + }); + }); +}