diff --git a/lib/src/model/clock/clock_tool_controller.dart b/lib/src/model/clock/clock_tool_controller.dart index a3d83e4abd..0b45a35f5a 100755 --- a/lib/src/model/clock/clock_tool_controller.dart +++ b/lib/src/model/clock/clock_tool_controller.dart @@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:lichess_mobile/src/model/clock/chess_clock.dart'; +import 'package:lichess_mobile/src/model/clock/clock_tool_preferences.dart'; import 'package:lichess_mobile/src/model/common/service/sound_service.dart'; import 'package:lichess_mobile/src/model/common/time_increment.dart'; @@ -29,13 +30,11 @@ class ClockToolController extends Notifier { ClockSide.top: false, ClockSide.bottom: false, }; - late Duration _emergencyThreshold; @override ClockState build() { const time = Duration(minutes: 10); const increment = Duration.zero; - _emergencyThreshold = _calculateEmergencyThreshold(time); const options = ClockOptions( topTime: time, bottomTime: time, @@ -64,19 +63,20 @@ class ClockToolController extends Notifier { ); } - // Emergency threshold is set to 10% of the total time - Duration _calculateEmergencyThreshold(Duration initialTime) { - return Duration(milliseconds: (initialTime.inMilliseconds * 0.1).round()); - } - void onClockEmergency() { final activeSide = state.activeSide; if (activeSide == null) return; if (_hasPlayedLowTimeSound[activeSide]!) return; + final warning = ref.read(clockToolPreferencesProvider).lowTimeWarning; + final initialTime = activeSide == ClockSide.top + ? state.options.topTime + : state.options.bottomTime; + final threshold = warning.threshold(initialTime); + if (threshold == null) return; final activeSideTime = activeSide == ClockSide.top ? _clock.blackTime.value : _clock.whiteTime.value; - if (activeSideTime <= _emergencyThreshold) { + if (activeSideTime <= threshold) { ref.read(soundServiceProvider).play(Sound.lowTime); _hasPlayedLowTimeSound[activeSide] = true; } @@ -130,7 +130,6 @@ class ClockToolController extends Notifier { void updateOptions(TimeIncrement timeIncrement) { final options = ClockOptions.fromTimeIncrement(timeIncrement); - _emergencyThreshold = _calculateEmergencyThreshold(Duration(seconds: timeIncrement.time)); _hasPlayedLowTimeSound[ClockSide.top] = false; _hasPlayedLowTimeSound[ClockSide.bottom] = false; _clock.setTimes(blackTime: options.topTime, whiteTime: options.bottomTime); diff --git a/lib/src/model/clock/clock_tool_preferences.dart b/lib/src/model/clock/clock_tool_preferences.dart new file mode 100644 index 0000000000..ce70402327 --- /dev/null +++ b/lib/src/model/clock/clock_tool_preferences.dart @@ -0,0 +1,74 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:lichess_mobile/src/model/settings/preferences_storage.dart'; + +part 'clock_tool_preferences.freezed.dart'; +part 'clock_tool_preferences.g.dart'; + +final clockToolPreferencesProvider = NotifierProvider( + ClockToolPreferencesNotifier.new, +); + +class ClockToolPreferencesNotifier extends Notifier + with PreferencesStorage { + @override + @protected + final prefCategory = PrefCategory.clockTool; + + @override + @protected + ClockToolPrefs get defaults => ClockToolPrefs.defaults; + + @override + ClockToolPrefs fromJson(Map json) => ClockToolPrefs.fromJson(json); + + @override + ClockToolPrefs build() => fetch(); + + Future setLowTimeWarning(LowTimeWarning warning) { + return save(state.copyWith(lowTimeWarning: warning)); + } +} + +enum LowTimeWarning { + off, + tenPercent, + tenSeconds, + twentySeconds, + thirtySeconds; + + // TODO: translate + String get label => switch (this) { + .off => 'Off', + .tenPercent => '10%', + .tenSeconds => '10s', + .twentySeconds => '20s', + .thirtySeconds => '30s', + }; + + /// Returns the warning threshold given the player's [initialTime], + /// or null if the warning is disabled. + Duration? threshold(Duration initialTime) => switch (this) { + .off => null, + .tenPercent => + initialTime > Duration.zero ? Duration(milliseconds: initialTime.inMilliseconds ~/ 10) : null, + .tenSeconds => const Duration(seconds: 10), + .twentySeconds => const Duration(seconds: 20), + .thirtySeconds => const Duration(seconds: 30), + }; +} + +@Freezed(fromJson: true, toJson: true) +sealed class ClockToolPrefs with _$ClockToolPrefs implements Serializable { + const ClockToolPrefs._(); + + const factory ClockToolPrefs({ + @JsonKey(unknownEnumValue: LowTimeWarning.tenPercent, defaultValue: LowTimeWarning.tenPercent) + required LowTimeWarning lowTimeWarning, + }) = _ClockToolPrefs; + + static const defaults = ClockToolPrefs(lowTimeWarning: LowTimeWarning.tenPercent); + + factory ClockToolPrefs.fromJson(Map json) => _$ClockToolPrefsFromJson(json); +} diff --git a/lib/src/model/settings/preferences_storage.dart b/lib/src/model/settings/preferences_storage.dart index 753afb0ac8..4ae2b87958 100644 --- a/lib/src/model/settings/preferences_storage.dart +++ b/lib/src/model/settings/preferences_storage.dart @@ -31,7 +31,8 @@ enum PrefCategory { broadcast('preferences.broadcast'), engineEvaluation('preferences.engineEvaluation'), offlineComputerGame('preferences.offlineComputerGame'), - log('preferences.log'); + log('preferences.log'), + clockTool('preferences.clockTool'); const PrefCategory(this.storageKey); diff --git a/lib/src/view/clock/clock_settings.dart b/lib/src/view/clock/clock_settings.dart index fff9778fc2..2695b5d533 100644 --- a/lib/src/view/clock/clock_settings.dart +++ b/lib/src/view/clock/clock_settings.dart @@ -1,10 +1,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lichess_mobile/src/model/clock/clock_tool_controller.dart'; +import 'package:lichess_mobile/src/model/clock/clock_tool_preferences.dart'; import 'package:lichess_mobile/src/model/common/time_increment.dart'; import 'package:lichess_mobile/src/model/settings/general_preferences.dart'; +import 'package:lichess_mobile/src/styles/styles.dart'; import 'package:lichess_mobile/src/utils/l10n_context.dart'; import 'package:lichess_mobile/src/view/play/time_control_modal.dart'; +import 'package:lichess_mobile/src/widgets/adaptive_bottom_sheet.dart'; +import 'package:lichess_mobile/src/widgets/settings.dart'; const _iconSize = 38.0; const _kIconPadding = EdgeInsets.all(10.0); @@ -99,6 +103,12 @@ class ClockSettings extends ConsumerWidget { ), ), ), + Expanded( + child: RotatedBox( + quarterTurns: clockOrientation.quarterTurns, + child: _LowTimeWarningButton(iconSize: _iconSize, enabled: buttonsEnabled), + ), + ), Expanded( child: RotatedBox( quarterTurns: clockOrientation.quarterTurns, @@ -133,6 +143,127 @@ class ClockSettings extends ConsumerWidget { } } +class _LowTimeWarningButton extends ConsumerWidget { + const _LowTimeWarningButton({required this.iconSize, required this.enabled}); + + final double iconSize; + final bool enabled; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final warning = ref.watch(clockToolPreferencesProvider.select((p) => p.lowTimeWarning)); + + return IconButton( + padding: _kIconPadding, + iconSize: iconSize, + // TODO: translate + tooltip: 'Low time warning', + onPressed: enabled + ? () => showModalBottomSheet( + context: context, + builder: (_) => const _LowTimeWarningModal(), + ) + : null, + icon: Icon(warning == .off ? Icons.alarm_off : Icons.alarm_on), + ); + } +} + +class _LowTimeWarningModal extends ConsumerWidget { + const _LowTimeWarningModal(); + + static const _horizontalPadding = EdgeInsets.symmetric(horizontal: 16.0); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final current = ref.watch(clockToolPreferencesProvider.select((p) => p.lowTimeWarning)); + final notifier = ref.read(clockToolPreferencesProvider.notifier); + const options = LowTimeWarning.values; + + return BottomSheetScrollableContainer( + padding: const EdgeInsets.symmetric(vertical: 16.0), + children: [ + const Padding( + padding: _horizontalPadding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // TODO: translate + SettingsSectionTitle('Low time warning'), + ], + ), + ), + const SizedBox(height: 4.0), + Card.filled( + margin: Styles.horizontalBodyPadding.add(Styles.sectionBottomPadding), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 16.0), + child: Row( + children: [ + for (int i = 0; i < options.length; i++) ...[ + Expanded( + child: _LowTimeChip( + label: options[i].label, + selected: current == options[i], + onTap: () { + notifier.setLowTimeWarning(options[i]); + Navigator.of(context).pop(); + }, + ), + ), + if (i < options.length - 1) const SizedBox(width: 8), + ], + ], + ), + ), + ), + ], + ); + } +} + +class _LowTimeChip extends StatelessWidget { + const _LowTimeChip({required this.label, required this.selected, required this.onTap}); + + final String label; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final colorScheme = ColorScheme.of(context); + return Container( + decoration: BoxDecoration( + color: selected ? colorScheme.secondaryContainer : colorScheme.surfaceContainerLow, + borderRadius: const BorderRadius.all(Radius.circular(5.0)), + border: Border.fromBorderSide( + BorderSide( + color: selected ? colorScheme.secondaryContainer : Theme.of(context).dividerColor, + ), + ), + ), + child: InkWell( + borderRadius: const BorderRadius.all(Radius.circular(5.0)), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10.0), + child: Center( + child: Text( + label, + style: Styles.timeControl + .merge(Styles.bold) + .copyWith( + color: selected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ), + ); + } +} + class _PlayResumeButton extends ConsumerWidget { const _PlayResumeButton(this.iconSize); diff --git a/lib/src/view/clock/clock_tool_screen.dart b/lib/src/view/clock/clock_tool_screen.dart index db63b31457..7763a7cd19 100644 --- a/lib/src/view/clock/clock_tool_screen.dart +++ b/lib/src/view/clock/clock_tool_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lichess_mobile/src/model/clock/clock_tool_controller.dart'; +import 'package:lichess_mobile/src/model/clock/clock_tool_preferences.dart'; import 'package:lichess_mobile/src/model/common/time_increment.dart'; import 'package:lichess_mobile/src/styles/styles.dart'; import 'package:lichess_mobile/src/utils/immersive_mode.dart'; @@ -94,7 +95,7 @@ class _BodyState extends ConsumerState<_Body> { } } -class ClockTile extends ConsumerWidget { +class ClockTile extends ConsumerStatefulWidget { const ClockTile({ required this.playerType, required this.clockState, @@ -107,10 +108,70 @@ class ClockTile extends ConsumerWidget { final Orientation orientation; @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _ClockTileState(); +} + +class _ClockTileState extends ConsumerState with SingleTickerProviderStateMixin { + late AnimationController _blinkController; + bool _inEmergency = false; + + @override + void initState() { + super.initState(); + _blinkController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 700), + ); + widget.clockState.getDuration(widget.playerType).addListener(_onTimeChanged); + } + + @override + void didUpdateWidget(ClockTile oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.clockState.getDuration(oldWidget.playerType) != + widget.clockState.getDuration(widget.playerType)) { + oldWidget.clockState.getDuration(oldWidget.playerType).removeListener(_onTimeChanged); + widget.clockState.getDuration(widget.playerType).addListener(_onTimeChanged); + } + // Re-evaluate emergency when active side changes (e.g. after a tap). + _onTimeChanged(); + } + + @override + void dispose() { + widget.clockState.getDuration(widget.playerType).removeListener(_onTimeChanged); + _blinkController.dispose(); + super.dispose(); + } + + void _onTimeChanged() { + if (!mounted) return; + final warning = ref.read(clockToolPreferencesProvider).lowTimeWarning; + final initialTime = widget.playerType == ClockSide.top + ? widget.clockState.options.topTime + : widget.clockState.options.bottomTime; + final threshold = warning.threshold(initialTime); + final timeLeft = widget.clockState.getDuration(widget.playerType).value; + final isActive = widget.clockState.isActivePlayer(widget.playerType); + final isEmergency = + threshold != null && timeLeft > Duration.zero && timeLeft <= threshold && isActive; + if (isEmergency == _inEmergency) return; + _inEmergency = isEmergency; + if (isEmergency) { + _blinkController.repeat(reverse: true); + } else { + _blinkController.stop(); + _blinkController.value = 0.0; + } + } + + @override + Widget build(BuildContext context) { + final clockState = widget.clockState; + final playerType = widget.playerType; final colorScheme = ColorScheme.of(context); final activeColor = darken(colorScheme.primaryFixedDim, 0.25); - final backgroundColor = clockState.isFlagged(playerType) + final normalBackground = clockState.isFlagged(playerType) ? context.lichessColors.error.withValues(alpha: 0.7) : clockState.isPlayersTurn(playerType) ? activeColor @@ -125,13 +186,14 @@ class ClockTile extends ConsumerWidget { final clockStyle = ClockStyle( textColor: textColor, activeTextColor: Colors.white, - emergencyTextColor: Colors.white70, + emergencyTextColor: Colors.white, backgroundColor: Colors.transparent, activeBackgroundColor: Colors.transparent, emergencyBackgroundColor: Colors.transparent, ); final clockOrientation = ref.watch(clockToolControllerProvider).clockOrientation; + final emergencyColor = context.lichessColors.error; return AnimatedOpacity( opacity: clockState.paused ? 0.8 : 1.0, @@ -142,136 +204,144 @@ class ClockTile extends ConsumerWidget { ? clockOrientation.oppositeQuarterTurns : clockOrientation.quarterTurns) : clockOrientation.quarterTurns, - child: Stack( - alignment: Alignment.center, - fit: StackFit.expand, - children: [ - Material( - color: backgroundColor, - child: InkWell( - splashFactory: NoSplash.splashFactory, - onTapDown: !clockState.started - ? (_) { - ref.read(clockToolControllerProvider.notifier).start(playerType); - } - : clockState.isPlayersMoveAllowed(playerType) - ? (_) { - ref.read(clockToolControllerProvider.notifier).onTap(playerType); - } - : null, - child: Padding( - padding: const EdgeInsets.all(48), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Expanded( - child: FittedBox( - child: AnimatedCrossFade( - duration: const Duration(milliseconds: 300), - firstChild: _ClockDisplay( - clockState: clockState, - playerType: playerType, - clockStyle: clockStyle, - ), - secondChild: const Icon( - CupertinoIcons.flag_fill, - color: Colors.white70, + child: AnimatedBuilder( + animation: _blinkController, + builder: (context, child) { + final backgroundColor = _inEmergency + ? Color.lerp(normalBackground, emergencyColor, _blinkController.value)! + : normalBackground; + return Stack( + alignment: Alignment.center, + fit: StackFit.expand, + children: [ + Material( + color: backgroundColor, + child: InkWell( + splashFactory: NoSplash.splashFactory, + onTapDown: !clockState.started + ? (_) { + ref.read(clockToolControllerProvider.notifier).start(playerType); + } + : clockState.isPlayersMoveAllowed(playerType) + ? (_) { + ref.read(clockToolControllerProvider.notifier).onTap(playerType); + } + : null, + child: Padding( + padding: const EdgeInsets.all(48), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: FittedBox( + child: AnimatedCrossFade( + duration: const Duration(milliseconds: 300), + firstChild: _ClockDisplay( + clockState: clockState, + playerType: playerType, + clockStyle: clockStyle, + ), + secondChild: const Icon( + CupertinoIcons.flag_fill, + color: Colors.white70, + ), + crossFadeState: clockState.isFlagged(playerType) + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + ), ), - crossFadeState: clockState.isFlagged(playerType) - ? CrossFadeState.showSecond - : CrossFadeState.showFirst, ), - ), + ], ), - ], + ), ), ), - ), - ), - Positioned( - top: 24, - right: 24, - child: IgnorePointer( - child: Text( - '${context.l10n.stormMoves}: ${clockState.getMovesCount(playerType)}', - style: TextStyle( - fontSize: 13, - color: !clockState.paused && clockState.isPlayersTurn(playerType) - ? clockStyle.activeTextColor - : clockStyle.textColor, + Positioned( + top: 24, + right: 24, + child: IgnorePointer( + child: Text( + '${context.l10n.stormMoves}: ${clockState.getMovesCount(playerType)}', + style: TextStyle( + fontSize: 13, + color: !clockState.paused && clockState.isPlayersTurn(playerType) + ? clockStyle.activeTextColor + : clockStyle.textColor, + ), + ), ), ), - ), - ), - if (orientation == Orientation.portrait && clockOrientation.isPortrait) - Positioned( - top: 24, - left: 24, - child: IgnorePointer( - child: RotatedBox( - quarterTurns: 2, - child: _ClockDisplay( - clockState: clockState, - playerType: playerType, - clockStyle: clockStyle, + if (widget.orientation == Orientation.portrait && clockOrientation.isPortrait) + Positioned( + top: 24, + left: 24, + child: IgnorePointer( + child: RotatedBox( + quarterTurns: 2, + child: _ClockDisplay( + clockState: clockState, + playerType: playerType, + clockStyle: clockStyle, + ), + ), ), ), - ), - ), - Positioned( - bottom: MediaQuery.paddingOf(context).bottom + 48.0, - child: IgnorePointer( - ignoring: clockState.started, - child: AnimatedOpacity( - opacity: clockState.started ? 0 : 1.0, - duration: const Duration(milliseconds: 300), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - SemanticIconButton( - semanticsLabel: context.l10n.settingsSettings, - iconSize: 32, - icon: const Icon(Icons.tune), - color: clockStyle.textColor, - onPressed: clockState.started - ? null - : () => showModalBottomSheet( - context: context, - builder: (BuildContext context) => CustomClockSettings( - player: playerType, - clock: playerType == ClockSide.top - ? TimeIncrement.fromDurations( - clockState.options.topTime, - clockState.options.topIncrement, - ) - : TimeIncrement.fromDurations( - clockState.options.bottomTime, - clockState.options.bottomIncrement, - ), - onSubmit: (ClockSide player, TimeIncrement clock) { - Navigator.of(context).pop(); - ref - .read(clockToolControllerProvider.notifier) - .updateOptionsCustom(clock, player); - }, - ), - ), + Positioned( + bottom: MediaQuery.paddingOf(context).bottom + 48.0, + child: IgnorePointer( + ignoring: clockState.started, + child: AnimatedOpacity( + opacity: clockState.started ? 0 : 1.0, + duration: const Duration(milliseconds: 300), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SemanticIconButton( + semanticsLabel: context.l10n.settingsSettings, + iconSize: 32, + icon: const Icon(Icons.tune), + color: clockStyle.textColor, + onPressed: clockState.started + ? null + : () => showModalBottomSheet( + context: context, + builder: (BuildContext context) => CustomClockSettings( + player: playerType, + clock: playerType == ClockSide.top + ? TimeIncrement.fromDurations( + clockState.options.topTime, + clockState.options.topIncrement, + ) + : TimeIncrement.fromDurations( + clockState.options.bottomTime, + clockState.options.bottomIncrement, + ), + onSubmit: (ClockSide player, TimeIncrement clock) { + Navigator.of(context).pop(); + ref + .read(clockToolControllerProvider.notifier) + .updateOptionsCustom(clock, player); + }, + ), + ), + ), + if (clockState.options.hasIncrement(playerType)) ...[ + const SizedBox(width: 8), + Text( + '+${clockState.options.getIncrement(playerType)}', + style: TextStyle(fontSize: 28, color: clockStyle.textColor), + ), + ], + ], ), - if (clockState.options.hasIncrement(playerType)) ...[ - const SizedBox(width: 8), - Text( - '+${clockState.options.getIncrement(playerType)}', - style: TextStyle(fontSize: 28, color: clockStyle.textColor), - ), - ], - ], + ), ), ), - ), - ), - ], + ], + ); + }, ), ), );