diff --git a/lib/src/model/common/game.dart b/lib/src/model/common/game.dart index 14a4bd9950..024ced5571 100644 --- a/lib/src/model/common/game.dart +++ b/lib/src/model/common/game.dart @@ -2,13 +2,13 @@ import 'package:lichess_mobile/l10n/l10n.dart'; /// Represents the choice of a side as a player: white, black or random. enum SideChoice { - random, white, + random, black; String label(AppLocalizations l10n) => switch (this) { - SideChoice.random => l10n.randomColor, SideChoice.white => l10n.white, + SideChoice.random => l10n.randomColor, SideChoice.black => l10n.black, }; } diff --git a/lib/src/model/coordinate_training/coordinate_training_controller.dart b/lib/src/model/coordinate_training/coordinate_training_controller.dart index 5c33e7d829..f0e7bf5d93 100644 --- a/lib/src/model/coordinate_training/coordinate_training_controller.dart +++ b/lib/src/model/coordinate_training/coordinate_training_controller.dart @@ -3,6 +3,8 @@ import 'dart:math'; import 'package:dartchess/dartchess.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:lichess_mobile/src/model/common/game.dart'; +import 'package:lichess_mobile/src/model/coordinate_training/coordinate_training_preferences.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'coordinate_training_controller.freezed.dart'; @@ -23,7 +25,12 @@ class CoordinateTrainingController extends _$CoordinateTrainingController { ref.onDispose(() { _updateTimer?.cancel(); }); - return const CoordinateTrainingState(); + final sideChoice = ref.watch( + coordinateTrainingPreferencesProvider.select((value) => value.sideChoice), + ); + return CoordinateTrainingState( + orientation: _getOrientation(sideChoice), + ); } void startTraining(Duration? timeLimit) { @@ -53,15 +60,34 @@ class CoordinateTrainingController extends _$CoordinateTrainingController { void _finishTraining() { // TODO save score in local storage here (and display high score and/or average score in UI) - - stopTraining(); + final orientation = _getOrientation( + ref.read(coordinateTrainingPreferencesProvider).sideChoice, + ); + _updateTimer?.cancel(); + state = CoordinateTrainingState( + lastGuess: state.lastGuess, + lastScore: state.score, + orientation: orientation, + ); } - void stopTraining() { + void abortTraining() { + final orientation = _getOrientation( + ref.read(coordinateTrainingPreferencesProvider).sideChoice, + ); _updateTimer?.cancel(); - state = const CoordinateTrainingState(); + state = CoordinateTrainingState(orientation: orientation); } + Side _getOrientation(SideChoice choice) => switch (choice) { + SideChoice.white => Side.white, + SideChoice.black => Side.black, + SideChoice.random => _randomSide(), + }; + + /// Generate a random side + Side _randomSide() => Side.values[Random().nextInt(Side.values.length)]; + /// Generate a random square that is not the same as the [previous] square Square _randomCoord({Square? previous}) { while (true) { @@ -100,6 +126,8 @@ class CoordinateTrainingState with _$CoordinateTrainingState { @Default(null) Duration? timeLimit, @Default(null) Duration? elapsed, @Default(null) Guess? lastGuess, + required Side orientation, + @Default(null) int? lastScore, }) = _CoordinateTrainingState; bool get trainingActive => elapsed != null; diff --git a/lib/src/view/coordinate_training/coordinate_training_screen.dart b/lib/src/view/coordinate_training/coordinate_training_screen.dart index 1166ca03bb..0136f7ac00 100644 --- a/lib/src/view/coordinate_training/coordinate_training_screen.dart +++ b/lib/src/view/coordinate_training/coordinate_training_screen.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:math'; import 'package:chessground/chessground.dart'; import 'package:dartchess/dartchess.dart'; @@ -19,6 +18,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/bottom_bar_button.dart'; import 'package:lichess_mobile/src/widgets/buttons.dart'; +import 'package:lichess_mobile/src/widgets/filter.dart'; import 'package:lichess_mobile/src/widgets/list.dart'; import 'package:lichess_mobile/src/widgets/platform_alert_dialog.dart'; import 'package:lichess_mobile/src/widgets/platform_scaffold.dart'; @@ -29,11 +29,22 @@ class CoordinateTrainingScreen extends StatelessWidget { @override Widget build(BuildContext context) { - return const PlatformScaffold( + return PlatformScaffold( appBar: PlatformAppBar( - title: Text('Coordinate Training'), // TODO l10n once script works + title: const Text('Coordinate Training'), // TODO l10n once script works + actions: [ + AppBarIconButton( + icon: const Icon(Icons.settings), + semanticsLabel: context.l10n.settingsSettings, + onPressed: () => showAdaptiveBottomSheet( + context: context, + builder: (BuildContext context) => + const _CoordinateTrainingMenu(), + ), + ), + ], ), - body: _Body(), + body: const _Body(), ); } } @@ -46,28 +57,10 @@ class _Body extends ConsumerStatefulWidget { } class _BodyState extends ConsumerState<_Body> { - late Side orientation; - Square? highlightLastGuess; Timer? highlightTimer; - void _setOrientation(SideChoice choice) { - setState(() { - orientation = switch (choice) { - SideChoice.white => Side.white, - SideChoice.black => Side.black, - SideChoice.random => Side.values[Random().nextInt(Side.values.length)], - }; - }); - } - - @override - void initState() { - super.initState(); - _setOrientation(ref.read(coordinateTrainingPreferencesProvider).sideChoice); - } - @override void dispose() { super.dispose(); @@ -103,6 +96,7 @@ class _BodyState extends ConsumerState<_Body> { }.lock; return SafeArea( + bottom: false, child: Column( children: [ Expanded( @@ -142,44 +136,54 @@ class _BodyState extends ConsumerState<_Body> { _TrainingBoard( boardSize: boardSize, isTablet: isTablet, - orientation: orientation, + orientation: trainingState.orientation, squareHighlights: squareHighlights, onGuess: _onGuess, ), ], ), if (trainingState.trainingActive) + _ScoreAndTrainingButton( + scoreSize: boardSize / 8, + score: trainingState.score, + onPressed: ref + .read( + coordinateTrainingControllerProvider.notifier, + ) + .abortTraining, + label: 'Abort Training', + ) + else if (trainingState.lastScore != null) + _ScoreAndTrainingButton( + scoreSize: boardSize / 8, + score: trainingState.lastScore!, + onPressed: () { + ref + .read( + coordinateTrainingControllerProvider.notifier, + ) + .startTraining( + trainingPrefs.timeChoice.duration, + ); + }, + label: 'New Training', + ) + else Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - _Score( - score: trainingState.score, - size: boardSize / 8, - color: trainingState.lastGuess == Guess.incorrect - ? context.lichessColors.error - : context.lichessColors.good, - ), - FatButton( - semanticsLabel: 'Abort Training', - onPressed: ref + child: Center( + child: _Button( + onPressed: () { + ref .read( coordinateTrainingControllerProvider .notifier, ) - .stopTraining, - child: const Text( - 'Abort Training', - style: Styles.bold, - ), - ), - ], - ), - ) - else - Expanded( - child: _Settings( - onSideChoiceSelected: _setOrientation, + .startTraining( + trainingPrefs.timeChoice.duration, + ); + }, + label: 'Start Training', + ), ), ), ], @@ -187,24 +191,21 @@ class _BodyState extends ConsumerState<_Body> { }, ), ), - BottomBar( - children: [ - BottomBarButton( - label: context.l10n.menu, - onTap: () => showAdaptiveBottomSheet( - context: context, - builder: (BuildContext context) => - const _CoordinateTrainingMenu(), + if (!trainingState.trainingActive) + BottomBar( + children: [ + BottomBarButton( + label: context.l10n.menu, + onTap: () => _coordinateTrainingSettingsBuilder(context), + icon: Icons.tune, ), - icon: Icons.tune, - ), - BottomBarButton( - icon: Icons.info_outline, - label: context.l10n.aboutX('Coordinate Training'), - onTap: () => _coordinateTrainingInfoDialogBuilder(context), - ), - ], - ), + BottomBarButton( + icon: Icons.info_outline, + label: context.l10n.aboutX('Coordinate Training'), + onTap: () => _coordinateTrainingInfoDialogBuilder(context), + ), + ], + ), ], ), ); @@ -242,7 +243,7 @@ class _TimeBar extends StatelessWidget { @override Widget build(BuildContext context) { return Align( - alignment: Alignment.center, + alignment: Alignment.centerLeft, child: SizedBox( width: maxWidth * (timeFractionElapsed ?? 0.0), height: 15.0, @@ -264,12 +265,17 @@ class _CoordinateTrainingMenu extends ConsumerWidget { return BottomSheetScrollableContainer( padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 8.0), children: [ - ListSection( - header: Text( - context.l10n.preferencesDisplay, - style: Styles.sectionTitle, - ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + context.l10n.preferencesDisplay, + style: Styles.sectionTitle, + ), + ), SwitchSettingTile( title: const Text('Show Coordinates'), value: trainingPrefs.showCoordinates, @@ -291,6 +297,44 @@ class _CoordinateTrainingMenu extends ConsumerWidget { } } +class _ScoreAndTrainingButton extends ConsumerWidget { + const _ScoreAndTrainingButton({ + required this.scoreSize, + required this.score, + required this.onPressed, + required this.label, + }); + + final double scoreSize; + final int score; + final VoidCallback onPressed; + final String label; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final trainingState = ref.watch(coordinateTrainingControllerProvider); + + return Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _Score( + score: score, + size: scoreSize, + color: trainingState.lastGuess == Guess.incorrect + ? context.lichessColors.error + : context.lichessColors.good, + ), + _Button( + label: label, + onPressed: onPressed, + ), + ], + ), + ); + } +} + class _Score extends StatelessWidget { const _Score({ required this.size, @@ -335,83 +379,74 @@ class _Score extends StatelessWidget { } } -class _Settings extends ConsumerStatefulWidget { - const _Settings({ - required this.onSideChoiceSelected, +class _Button extends StatelessWidget { + const _Button({ + required this.onPressed, + required this.label, }); - final void Function(SideChoice) onSideChoiceSelected; + final VoidCallback onPressed; + final String label; @override - ConsumerState<_Settings> createState() => _SettingsState(); + Widget build(BuildContext context) { + return FatButton( + semanticsLabel: label, + onPressed: onPressed, + child: Text( + label, + style: Styles.bold, + ), + ); + } } -class _SettingsState extends ConsumerState<_Settings> { +class SettingsBottomSheet extends ConsumerWidget { + const SettingsBottomSheet(); + @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final trainingPrefs = ref.watch(coordinateTrainingPreferencesProvider); - return Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, + return BottomSheetScrollableContainer( + padding: const EdgeInsets.all(16.0), children: [ - PlatformListTile( - title: Text(context.l10n.side), - trailing: Padding( - padding: Styles.horizontalBodyPadding, - child: Wrap( - spacing: 8.0, - children: SideChoice.values.map((choice) { - return ChoiceChip( - label: Text(choice.label(context.l10n)), - selected: trainingPrefs.sideChoice == choice, - showCheckmark: false, - onSelected: (selected) { - widget.onSideChoiceSelected(choice); - ref - .read(coordinateTrainingPreferencesProvider.notifier) - .setSideChoice(choice); - }, - ); - }).toList(), - ), - ), - ), - PlatformListTile( - title: Text(context.l10n.time), - trailing: Padding( - padding: Styles.horizontalBodyPadding, - child: Wrap( - spacing: 8.0, - children: TimeChoice.values.map((choice) { - return ChoiceChip( - label: choice.label(context.l10n), - selected: trainingPrefs.timeChoice == choice, - showCheckmark: false, - onSelected: (selected) { - if (selected) { - ref - .read( - coordinateTrainingPreferencesProvider.notifier, - ) - .setTimeChoice(choice); - } - }, - ); - }).toList(), - ), - ), + Filter( + filterName: context.l10n.side, + filterType: FilterType.singleChoice, + choices: SideChoice.values, + showCheckmark: false, + choiceSelected: (choice) => trainingPrefs.sideChoice == choice, + choiceLabel: (choice) => Text(choice.label(context.l10n)), + onSelected: (choice, selected) { + if (selected) { + ref + .read( + coordinateTrainingPreferencesProvider.notifier, + ) + .setSideChoice(choice); + } + }, ), - FatButton( - semanticsLabel: 'Start Training', - onPressed: () => ref - .read(coordinateTrainingControllerProvider.notifier) - .startTraining(trainingPrefs.timeChoice.duration), - child: const Text( - // TODO l10n once script works - 'Start Training', - style: Styles.bold, - ), + const SizedBox(height: 12.0), + const PlatformDivider(thickness: 1, indent: 0), + const SizedBox(height: 12.0), + Filter( + filterName: context.l10n.time, + filterType: FilterType.singleChoice, + choices: TimeChoice.values, + showCheckmark: false, + choiceSelected: (choice) => trainingPrefs.timeChoice == choice, + choiceLabel: (choice) => choice.label(context.l10n), + onSelected: (choice, selected) { + if (selected) { + ref + .read( + coordinateTrainingPreferencesProvider.notifier, + ) + .setTimeChoice(choice); + } + }, ), ], ); @@ -490,6 +525,13 @@ class _TrainingBoardState extends ConsumerState<_TrainingBoard> { } } +Future _coordinateTrainingSettingsBuilder(BuildContext context) { + return showAdaptiveBottomSheet( + context: context, + builder: (BuildContext context) => const SettingsBottomSheet(), + ); +} + Future _coordinateTrainingInfoDialogBuilder(BuildContext context) { return showAdaptiveDialog( context: context, diff --git a/lib/src/view/user/game_history_screen.dart b/lib/src/view/user/game_history_screen.dart index 973d16a5f4..921c60258a 100644 --- a/lib/src/view/user/game_history_screen.dart +++ b/lib/src/view/user/game_history_screen.dart @@ -294,8 +294,8 @@ class _FilterGamesState extends ConsumerState<_FilterGames> { choices: Side.values, choiceSelected: (choice) => filter.side == choice, choiceLabel: (t) => switch (t) { - Side.white => context.l10n.white, - Side.black => context.l10n.black, + Side.white => Text(context.l10n.white), + Side.black => Text(context.l10n.black), }, onSelected: (value, selected) => setState( () { @@ -338,7 +338,7 @@ class _FilterGamesState extends ConsumerState<_FilterGames> { filterType: FilterType.multipleChoice, choices: choices, choiceSelected: (choice) => filter.perfs.contains(choice), - choiceLabel: (t) => t.shortTitle, + choiceLabel: (t) => Text(t.shortTitle), onSelected: (value, selected) => setState( () { filter = filter.copyWith( diff --git a/lib/src/widgets/filter.dart b/lib/src/widgets/filter.dart index af26cfdc6f..b39084c51e 100644 --- a/lib/src/widgets/filter.dart +++ b/lib/src/widgets/filter.dart @@ -14,6 +14,7 @@ class Filter extends StatelessWidget { required this.filterName, required this.filterType, required this.choices, + this.showCheckmark = true, required this.choiceSelected, required this.choiceLabel, required this.onSelected, @@ -28,11 +29,14 @@ class Filter extends StatelessWidget { /// The choices that will be displayed. final Iterable choices; + /// Whether to show a checkmark next to selected choices. + final bool showCheckmark; + /// Called to determine whether a choice is currently selected. final bool Function(T choice) choiceSelected; - /// Determines label to display for the given choice. - final String Function(T choice) choiceLabel; + /// Determines label to display for the given choice. + final Widget Function(T choice) choiceLabel; /// Called when a choice is selected or deselected. final void Function(T value, bool selected) onSelected; @@ -52,14 +56,16 @@ class Filter extends StatelessWidget { .map( (choice) => switch (filterType) { FilterType.singleChoice => ChoiceChip( - label: Text(choiceLabel(choice)), + label: choiceLabel(choice), selected: choiceSelected(choice), onSelected: (value) => onSelected(choice, value), + showCheckmark: showCheckmark, ), FilterType.multipleChoice => FilterChip( - label: Text(choiceLabel(choice)), + label: choiceLabel(choice), selected: choiceSelected(choice), onSelected: (value) => onSelected(choice, value), + showCheckmark: showCheckmark, ), }, )