diff --git a/lib/main.dart b/lib/main.dart index c04e4e8423..0ec26f1d80 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -26,6 +26,10 @@ Future main() async { await lichessBinding.preloadSharedPreferences(); + if (defaultTargetPlatform == TargetPlatform.android) { + await androidDisplayInitialization(widgetsBinding); + } + await preloadPieceImages(); await setupFirstLaunch(); @@ -38,18 +42,7 @@ Future main() async { await lichessBinding.initializeFirebase(); - if (defaultTargetPlatform == TargetPlatform.android) { - await androidDisplayInitialization(widgetsBinding); - } - - runApp( - ProviderScope( - observers: [ - ProviderLogger(), - ], - child: const AppInitializationScreen(), - ), - ); + runApp(ProviderScope(observers: [ProviderLogger()], child: const AppInitializationScreen())); } Future migrateSharedPreferences() async { diff --git a/lib/src/app.dart b/lib/src/app.dart index 158855a519..a7740b476f 100644 --- a/lib/src/app.dart +++ b/lib/src/app.dart @@ -139,13 +139,22 @@ class _AppState extends ConsumerState { final dynamicColorScheme = brightness == Brightness.light ? fixedLightScheme : fixedDarkScheme; - final colorScheme = - generalPrefs.systemColors && dynamicColorScheme != null - ? dynamicColorScheme - : ColorScheme.fromSeed( + final ColorScheme colorScheme = switch (generalPrefs.appThemeSeed) { + AppThemeSeed.color => ColorScheme.fromSeed( + seedColor: generalPrefs.customThemeSeed ?? kDefaultSeedColor, + brightness: brightness, + ), + AppThemeSeed.board => ColorScheme.fromSeed( + seedColor: boardTheme.colors.darkSquare, + brightness: brightness, + ), + AppThemeSeed.system => + dynamicColorScheme ?? + ColorScheme.fromSeed( seedColor: boardTheme.colors.darkSquare, brightness: brightness, - ); + ), + }; final cupertinoThemeData = CupertinoThemeData( primaryColor: colorScheme.primary, diff --git a/lib/src/constants.dart b/lib/src/constants.dart index dc0d78eeea..811cbe7051 100644 --- a/lib/src/constants.dart +++ b/lib/src/constants.dart @@ -37,6 +37,8 @@ const kClueLessDeviation = 230; // UI +const kDefaultSeedColor = Color.fromARGB(255, 191, 128, 29); + const kGoldenRatio = 1.61803398875; /// Flex golden ratio base (flex has to be an int). diff --git a/lib/src/init.dart b/lib/src/init.dart index 145117567f..655a4a5d31 100644 --- a/lib/src/init.dart +++ b/lib/src/init.dart @@ -12,6 +12,7 @@ import 'package:lichess_mobile/src/db/secure_storage.dart'; import 'package:lichess_mobile/src/model/notifications/notification_service.dart'; import 'package:lichess_mobile/src/model/notifications/notifications.dart'; import 'package:lichess_mobile/src/model/settings/board_preferences.dart'; +import 'package:lichess_mobile/src/model/settings/general_preferences.dart'; import 'package:lichess_mobile/src/model/settings/preferences_storage.dart'; import 'package:lichess_mobile/src/utils/chessboard.dart'; import 'package:lichess_mobile/src/utils/color_palette.dart'; @@ -31,6 +32,11 @@ Future setupFirstLaunch() async { final appVersion = Version.parse(pInfo.version); final installedVersion = prefs.getString('installed_version'); + // TODO remove this migration code after a few releases + if (installedVersion != null && Version.parse(installedVersion) <= Version(0, 13, 9)) { + _migrateThemeSettings(); + } + if (installedVersion == null || Version.parse(installedVersion) != appVersion) { prefs.setString('installed_version', appVersion.canonicalizedVersion); } @@ -48,6 +54,30 @@ Future setupFirstLaunch() async { } } +Future _migrateThemeSettings() async { + if (getCorePalette() == null) { + return; + } + final prefs = LichessBinding.instance.sharedPreferences; + try { + final stored = LichessBinding.instance.sharedPreferences.getString( + PrefCategory.general.storageKey, + ); + if (stored == null) { + return; + } + final generalPrefs = GeneralPrefs.fromJson(jsonDecode(stored) as Map); + final migrated = generalPrefs.copyWith( + appThemeSeed: + // ignore: deprecated_member_use_from_same_package + generalPrefs.systemColors == true ? AppThemeSeed.system : AppThemeSeed.board, + ); + await prefs.setString(PrefCategory.general.storageKey, jsonEncode(migrated.toJson())); + } catch (e) { + _logger.warning('Failed to migrate theme settings: $e'); + } +} + Future initializeLocalNotifications(Locale locale) async { final l10n = await AppLocalizations.delegate.load(locale); await FlutterLocalNotificationsPlugin().initialize( @@ -85,19 +115,10 @@ Future preloadPieceImages() async { /// /// This is meant to be called once during app initialization. Future androidDisplayInitialization(WidgetsBinding widgetsBinding) async { - final prefs = LichessBinding.instance.sharedPreferences; - - // On android 12+ get core palette and set the board theme to system if it is not set + // On android 12+ set core palette and make system board try { await DynamicColorPlugin.getCorePalette().then((value) { setCorePalette(value); - - if (getCorePalette() != null && prefs.getString(PrefCategory.board.storageKey) == null) { - prefs.setString( - PrefCategory.board.storageKey, - jsonEncode(BoardPrefs.defaults.copyWith(boardTheme: BoardTheme.system)), - ); - } }); } catch (e) { _logger.fine('Device does not support core palette: $e'); diff --git a/lib/src/model/settings/board_preferences.dart b/lib/src/model/settings/board_preferences.dart index 65dd2b3f1b..3b8335e8fd 100644 --- a/lib/src/model/settings/board_preferences.dart +++ b/lib/src/model/settings/board_preferences.dart @@ -11,6 +11,9 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'board_preferences.freezed.dart'; part 'board_preferences.g.dart'; +const kBoardDefaultBrightnessFilter = 1.0; +const kBoardDefaultHueFilter = 0.0; + @riverpod class BoardPreferences extends _$BoardPreferences with PreferencesStorage { // ignore: avoid_public_notifier_properties @@ -94,12 +97,17 @@ class BoardPreferences extends _$BoardPreferences with PreferencesStorage setShapeColor(ShapeColor shapeColor) { return save(state.copyWith(shapeColor: shapeColor)); } + + Future adjustColors({double? brightness, double? hue}) { + return save(state.copyWith(brightness: brightness ?? state.brightness, hue: hue ?? state.hue)); + } } @Freezed(fromJson: true, toJson: true) class BoardPrefs with _$BoardPrefs implements Serializable { const BoardPrefs._(); + @Assert('brightness >= 0.2 && brightness <= 1.4, hue >= 0.0 && hue <= 360.0') const factory BoardPrefs({ required PieceSet pieceSet, required BoardTheme boardTheme, @@ -126,6 +134,8 @@ class BoardPrefs with _$BoardPrefs implements Serializable { @JsonKey(defaultValue: ShapeColor.green, unknownEnumValue: ShapeColor.green) required ShapeColor shapeColor, @JsonKey(defaultValue: false) required bool showBorder, + @JsonKey(defaultValue: kBoardDefaultBrightnessFilter) required double brightness, + @JsonKey(defaultValue: kBoardDefaultBrightnessFilter) required double hue, }) = _BoardPrefs; static const defaults = BoardPrefs( @@ -145,12 +155,19 @@ class BoardPrefs with _$BoardPrefs implements Serializable { dragTargetKind: DragTargetKind.circle, shapeColor: ShapeColor.green, showBorder: false, + brightness: kBoardDefaultBrightnessFilter, + hue: kBoardDefaultHueFilter, ); + bool get hasColorAdjustments => + brightness != kBoardDefaultBrightnessFilter || hue != kBoardDefaultHueFilter; + ChessboardSettings toBoardSettings() { return ChessboardSettings( pieceAssets: pieceSet.assets, colorScheme: boardTheme.colors, + brightness: brightness, + hue: hue, border: showBorder ? BoardBorder(color: darken(boardTheme.colors.darkSquare, 0.2), width: 16.0) diff --git a/lib/src/model/settings/general_preferences.dart b/lib/src/model/settings/general_preferences.dart index 70f18c3e69..a612c4fa82 100644 --- a/lib/src/model/settings/general_preferences.dart +++ b/lib/src/model/settings/general_preferences.dart @@ -1,8 +1,8 @@ -import 'dart:ui' show Locale; +import 'dart:ui' show Color, Locale; import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:lichess_mobile/src/model/settings/board_preferences.dart'; import 'package:lichess_mobile/src/model/settings/preferences_storage.dart'; +import 'package:lichess_mobile/src/utils/json.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'general_preferences.freezed.dart'; @@ -26,7 +26,7 @@ class GeneralPreferences extends _$GeneralPreferences with PreferencesStorage setThemeMode(BackgroundThemeMode themeMode) { + Future setBackgroundThemeMode(BackgroundThemeMode themeMode) { return save(state.copyWith(themeMode: themeMode)); } @@ -46,38 +46,17 @@ class GeneralPreferences extends _$GeneralPreferences with PreferencesStorage toggleSystemColors() async { - await save(state.copyWith(systemColors: !state.systemColors)); - if (state.systemColors == false) { - final boardTheme = ref.read(boardPreferencesProvider).boardTheme; - if (boardTheme == BoardTheme.system) { - await ref.read(boardPreferencesProvider.notifier).setBoardTheme(BoardTheme.brown); - } - } else { - await ref.read(boardPreferencesProvider.notifier).setBoardTheme(BoardTheme.system); - } + Future toggleCustomTheme() async { + await save(state.copyWith(customThemeEnabled: !state.customThemeEnabled)); } -} -Map? _localeToJson(Locale? locale) { - return locale != null - ? { - 'languageCode': locale.languageCode, - 'countryCode': locale.countryCode, - 'scriptCode': locale.scriptCode, - } - : null; -} + Future setCustomThemeSeed(Color? color) { + return save(state.copyWith(customThemeSeed: color)); + } -Locale? _localeFromJson(Map? json) { - if (json == null) { - return null; + Future setAppThemeSeed(AppThemeSeed seed) { + return save(state.copyWith(appThemeSeed: seed)); } - return Locale.fromSubtags( - languageCode: json['languageCode'] as String, - countryCode: json['countryCode'] as String?, - scriptCode: json['scriptCode'] as String?, - ); } @Freezed(fromJson: true, toJson: true) @@ -89,11 +68,20 @@ class GeneralPrefs with _$GeneralPrefs implements Serializable { @JsonKey(unknownEnumValue: SoundTheme.standard) required SoundTheme soundTheme, @JsonKey(defaultValue: 0.8) required double masterVolume, - /// Should enable system color palette (android 12+ only) - @JsonKey(defaultValue: true) required bool systemColors, + /// Should enable custom theme + @JsonKey(defaultValue: false) required bool customThemeEnabled, + + /// Custom theme seed color + @ColorConverter() Color? customThemeSeed, + + @Deprecated('Use appThemeSeed instead') bool? systemColors, + + /// App theme seed + @JsonKey(unknownEnumValue: AppThemeSeed.board, defaultValue: AppThemeSeed.board) + required AppThemeSeed appThemeSeed, /// Locale to use in the app, use system locale if null - @JsonKey(toJson: _localeToJson, fromJson: _localeFromJson) Locale? locale, + @LocaleConverter() Locale? locale, }) = _GeneralPrefs; static const defaults = GeneralPrefs( @@ -101,7 +89,8 @@ class GeneralPrefs with _$GeneralPrefs implements Serializable { isSoundEnabled: true, soundTheme: SoundTheme.standard, masterVolume: 0.8, - systemColors: true, + customThemeEnabled: false, + appThemeSeed: AppThemeSeed.board, ); factory GeneralPrefs.fromJson(Map json) { @@ -109,6 +98,17 @@ class GeneralPrefs with _$GeneralPrefs implements Serializable { } } +enum AppThemeSeed { + /// The app theme is based on the user's system theme (only available on Android 10+). + system, + + /// The app theme is based on the chessboard. + board, + + /// The app theme is based on a specific color. + color, +} + /// Describes the background theme of the app. enum BackgroundThemeMode { /// Use either the light or dark theme based on what the user has selected in diff --git a/lib/src/utils/json.dart b/lib/src/utils/json.dart index 2bc6852ebe..cea4664d8c 100644 --- a/lib/src/utils/json.dart +++ b/lib/src/utils/json.dart @@ -1,6 +1,57 @@ +import 'dart:ui' show Color, Locale; + import 'package:deep_pick/deep_pick.dart'; +import 'package:json_annotation/json_annotation.dart'; import 'package:lichess_mobile/src/model/common/uci.dart'; +class LocaleConverter implements JsonConverter?> { + const LocaleConverter(); + + @override + Locale? fromJson(Map? json) { + if (json == null) { + return null; + } + return Locale.fromSubtags( + languageCode: json['languageCode'] as String, + countryCode: json['countryCode'] as String?, + scriptCode: json['scriptCode'] as String?, + ); + } + + @override + Map? toJson(Locale? locale) { + return locale != null + ? { + 'languageCode': locale.languageCode, + 'countryCode': locale.countryCode, + 'scriptCode': locale.scriptCode, + } + : null; + } +} + +class ColorConverter implements JsonConverter?> { + const ColorConverter(); + + @override + Color? fromJson(Map? json) { + return json != null + ? Color.from( + alpha: json['a'] as double, + red: json['r'] as double, + green: json['g'] as double, + blue: json['b'] as double, + ) + : null; + } + + @override + Map? toJson(Color? color) { + return color != null ? {'a': color.a, 'r': color.r, 'g': color.g, 'b': color.b} : null; + } +} + extension UciExtension on Pick { /// Matches a UciCharPair from a string. UciCharPair asUciCharPairOrThrow() { diff --git a/lib/src/view/settings/account_preferences_screen.dart b/lib/src/view/settings/account_preferences_screen.dart index 83c65bfa7a..10031c8be6 100644 --- a/lib/src/view/settings/account_preferences_screen.dart +++ b/lib/src/view/settings/account_preferences_screen.dart @@ -113,11 +113,7 @@ class _AccountPreferencesScreenState extends ConsumerState ref .read(generalPreferencesProvider.notifier) - .setThemeMode(value ?? BackgroundThemeMode.system); + .setBackgroundThemeMode(value ?? BackgroundThemeMode.system); return SafeArea( child: ListView( diff --git a/lib/src/view/settings/board_theme_screen.dart b/lib/src/view/settings/board_theme_screen.dart index 6e3e128a1f..902f6e98a3 100644 --- a/lib/src/view/settings/board_theme_screen.dart +++ b/lib/src/view/settings/board_theme_screen.dart @@ -2,9 +2,8 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.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/utils/color_palette.dart'; import 'package:lichess_mobile/src/utils/l10n_context.dart'; -import 'package:lichess_mobile/src/utils/system.dart'; import 'package:lichess_mobile/src/widgets/list.dart'; import 'package:lichess_mobile/src/widgets/platform.dart'; @@ -30,18 +29,10 @@ class _Body extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final boardTheme = ref.watch(boardPreferencesProvider.select((p) => p.boardTheme)); - final hasSystemColors = ref.watch(generalPreferencesProvider.select((p) => p.systemColors)); - - final androidVersion = ref.watch(androidVersionProvider).whenOrNull(data: (v) => v); + final hasSystemColors = getCorePalette() != null; final choices = - BoardTheme.values - .where( - (t) => - t != BoardTheme.system || - (hasSystemColors && androidVersion != null && androidVersion.sdkInt >= 31), - ) - .toList(); + BoardTheme.values.where((t) => t != BoardTheme.system || hasSystemColors).toList(); void onChanged(BoardTheme? value) => ref.read(boardPreferencesProvider.notifier).setBoardTheme(value ?? BoardTheme.brown); diff --git a/lib/src/view/settings/settings_tab_screen.dart b/lib/src/view/settings/settings_tab_screen.dart index f1dbdc0cfb..6396d7287d 100644 --- a/lib/src/view/settings/settings_tab_screen.dart +++ b/lib/src/view/settings/settings_tab_screen.dart @@ -8,7 +8,6 @@ import 'package:lichess_mobile/src/model/account/account_repository.dart'; import 'package:lichess_mobile/src/model/auth/auth_controller.dart'; import 'package:lichess_mobile/src/model/auth/auth_session.dart'; import 'package:lichess_mobile/src/model/common/preloaded_data.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/navigation.dart'; import 'package:lichess_mobile/src/styles/lichess_icons.dart'; @@ -82,7 +81,6 @@ class _Body extends ConsumerWidget { }); final generalPrefs = ref.watch(generalPreferencesProvider); - final boardPrefs = ref.watch(boardPreferencesProvider); final authController = ref.watch(authControllerProvider); final userSession = ref.watch(authSessionProvider); final packageInfo = ref.read(preloadedDataProvider).requireValue.packageInfo; @@ -211,7 +209,7 @@ class _Body extends ConsumerWidget { onSelectedItemChanged: (BackgroundThemeMode? value) => ref .read(generalPreferencesProvider.notifier) - .setThemeMode(value ?? BackgroundThemeMode.system), + .setBackgroundThemeMode(value ?? BackgroundThemeMode.system), ); } else { pushPlatformRoute( @@ -222,10 +220,13 @@ class _Body extends ConsumerWidget { } }, ), - SettingsListTile( - icon: const Icon(Icons.palette_outlined), - settingsLabel: Text(context.l10n.mobileTheme), - settingsValue: '${boardPrefs.boardTheme.label} / ${boardPrefs.pieceSet.label}', + PlatformListTile( + leading: const Icon(Icons.palette_outlined), + title: Text(context.l10n.mobileTheme), + trailing: + Theme.of(context).platform == TargetPlatform.iOS + ? const CupertinoListTileChevron() + : null, onTap: () { pushPlatformRoute(context, title: 'Theme', builder: (context) => const ThemeScreen()); }, diff --git a/lib/src/view/settings/theme_screen.dart b/lib/src/view/settings/theme_screen.dart index 2e1307a8e0..ac1741be72 100644 --- a/lib/src/view/settings/theme_screen.dart +++ b/lib/src/view/settings/theme_screen.dart @@ -1,21 +1,28 @@ -import 'dart:math' as math; +import 'dart:ui' show ImageFilter; import 'package:chessground/chessground.dart'; import 'package:dartchess/dartchess.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_colorpicker/flutter_colorpicker.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lichess_mobile/src/constants.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/styles/lichess_icons.dart'; +import 'package:lichess_mobile/src/styles/styles.dart'; +import 'package:lichess_mobile/src/utils/color_palette.dart'; import 'package:lichess_mobile/src/utils/l10n_context.dart'; import 'package:lichess_mobile/src/utils/navigation.dart'; -import 'package:lichess_mobile/src/utils/system.dart'; +import 'package:lichess_mobile/src/utils/screen.dart'; import 'package:lichess_mobile/src/view/settings/board_theme_screen.dart'; import 'package:lichess_mobile/src/view/settings/piece_set_screen.dart'; +import 'package:lichess_mobile/src/widgets/adaptive_action_sheet.dart'; import 'package:lichess_mobile/src/widgets/adaptive_choice_picker.dart'; +import 'package:lichess_mobile/src/widgets/buttons.dart'; import 'package:lichess_mobile/src/widgets/list.dart'; -import 'package:lichess_mobile/src/widgets/platform_scaffold.dart'; +import 'package:lichess_mobile/src/widgets/platform.dart'; +import 'package:lichess_mobile/src/widgets/platform_alert_dialog.dart'; import 'package:lichess_mobile/src/widgets/settings.dart'; class ThemeScreen extends StatelessWidget { @@ -23,7 +30,20 @@ class ThemeScreen extends StatelessWidget { @override Widget build(BuildContext context) { - return PlatformScaffold(appBar: const PlatformAppBar(title: Text('Theme')), body: _Body()); + return PlatformWidget( + androidBuilder: (context) => const Scaffold(body: _Body()), + iosBuilder: + (context) => CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + automaticBackgroundVisibility: false, + backgroundColor: Styles.cupertinoAppBarColor + .resolveFrom(context) + .withValues(alpha: 0.0), + border: null, + ), + child: const _Body(), + ), + ); } } @@ -36,139 +56,440 @@ switch (shapeColor) { ShapeColor.yellow => 'Yellow', }; -class _Body extends ConsumerWidget { +class _Body extends ConsumerStatefulWidget { + const _Body(); + + @override + ConsumerState<_Body> createState() => _BodyState(); +} + +class _BodyState extends ConsumerState<_Body> { + late double brightness; + late double hue; + + double headerOpacity = 0; + + bool openAdjustColorSection = false; + @override - Widget build(BuildContext context, WidgetRef ref) { + void initState() { + super.initState(); + final boardPrefs = ref.read(boardPreferencesProvider); + brightness = boardPrefs.brightness; + hue = boardPrefs.hue; + } + + bool handleScrollNotification(ScrollNotification notification) { + if (notification is ScrollUpdateNotification && notification.depth == 0) { + final ScrollMetrics metrics = notification.metrics; + double scrollExtent = 0.0; + switch (metrics.axisDirection) { + case AxisDirection.up: + scrollExtent = metrics.extentAfter; + case AxisDirection.down: + scrollExtent = metrics.extentBefore; + case AxisDirection.right: + case AxisDirection.left: + break; + } + + final opacity = scrollExtent > 0.0 ? 1.0 : 0.0; + + if (opacity != headerOpacity) { + setState(() { + headerOpacity = opacity; + }); + } + } + return false; + } + + void _showColorPicker() { + final generalPrefs = ref.read(generalPreferencesProvider); + showAdaptiveDialog( + context: context, + barrierDismissible: false, + builder: (context) { + bool useDefault = generalPrefs.customThemeSeed == null; + Color color = generalPrefs.customThemeSeed ?? kDefaultSeedColor; + return StatefulBuilder( + builder: (context, setState) { + return PlatformAlertDialog( + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + HueRingPicker( + enableAlpha: false, + colorPickerHeight: 200, + displayThumbColor: false, + portraitOnly: true, + pickerColor: color, + onColorChanged: (c) { + setState(() { + useDefault = false; + color = c; + }); + }, + ), + SecondaryButton( + semanticsLabel: 'Default color', + onPressed: + !useDefault + ? () { + setState(() { + useDefault = true; + color = kDefaultSeedColor; + }); + } + : null, + child: const Text('Default color'), + ), + SecondaryButton( + semanticsLabel: context.l10n.cancel, + onPressed: () { + Navigator.of(context).pop(false); + }, + child: Text(context.l10n.cancel), + ), + SecondaryButton( + semanticsLabel: context.l10n.ok, + onPressed: () { + if (useDefault) { + Navigator.of(context).pop(null); + } else { + Navigator.of(context).pop(color); + } + }, + child: Text(context.l10n.ok), + ), + ], + ), + ), + ); + }, + ); + }, + ).then((color) { + if (color != false) { + ref.read(generalPreferencesProvider.notifier).setCustomThemeSeed(color as Color?); + } + }); + } + + @override + Widget build(BuildContext context) { final generalPrefs = ref.watch(generalPreferencesProvider); final boardPrefs = ref.watch(boardPreferencesProvider); - final androidVersionAsync = ref.watch(androidVersionProvider); - const horizontalPadding = 16.0; + final bool hasAjustedColors = + brightness != kBoardDefaultBrightnessFilter || hue != kBoardDefaultHueFilter; - return ListView( - children: [ - LayoutBuilder( - builder: (context, constraints) { - final double boardSize = math.min( - 400, - constraints.biggest.shortestSide - horizontalPadding * 2, - ); - return Padding( - padding: const EdgeInsets.symmetric(horizontal: horizontalPadding, vertical: 16), - child: Center( - child: Chessboard.fixed( - size: boardSize, - orientation: Side.white, - lastMove: const NormalMove(from: Square.e2, to: Square.e4), - fen: 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1', - shapes: - { - Circle(color: boardPrefs.shapeColor.color, orig: Square.fromName('b8')), - Arrow( - color: boardPrefs.shapeColor.color, - orig: Square.fromName('b8'), - dest: Square.fromName('c6'), + final boardSize = isTabletOrLarger(context) ? 350.0 : 200.0; + + final backgroundColor = Styles.cupertinoAppBarColor.resolveFrom(context); + + return NotificationListener( + onNotification: handleScrollNotification, + child: CustomScrollView( + slivers: [ + if (Theme.of(context).platform == TargetPlatform.iOS) + PinnedHeaderSliver( + child: ClipRect( + child: BackdropFilter( + enabled: backgroundColor.alpha != 0xFF, + filter: ImageFilter.blur(sigmaX: 10.0, sigmaY: 10.0), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + decoration: ShapeDecoration( + color: headerOpacity == 1.0 ? backgroundColor : backgroundColor.withAlpha(0), + shape: LinearBorder.bottom( + side: BorderSide( + color: + headerOpacity == 1.0 ? const Color(0x4D000000) : Colors.transparent, + width: 0.0, ), - }.lock, - settings: boardPrefs.toBoardSettings().copyWith( - borderRadius: const BorderRadius.all(Radius.circular(4.0)), - boxShadow: boardShadows, + ), + ), + padding: + Styles.bodyPadding + + EdgeInsets.only(top: MediaQuery.paddingOf(context).top), + child: _BoardPreview( + size: boardSize, + boardPrefs: boardPrefs, + brightness: brightness, + hue: hue, + ), ), ), ), - ); - }, - ), - ListSection( - hasLeading: true, - children: [ - if (Theme.of(context).platform == TargetPlatform.android) - androidVersionAsync.maybeWhen( - data: - (version) => - version != null && version.sdkInt >= 31 - ? SwitchSettingTile( - leading: const Icon(Icons.colorize_outlined), - title: Text(context.l10n.mobileSystemColors), - value: generalPrefs.systemColors, - onChanged: (value) { - ref.read(generalPreferencesProvider.notifier).toggleSystemColors(); - }, - ) - : const SizedBox.shrink(), - orElse: () => const SizedBox.shrink(), + ) + else + SliverAppBar( + pinned: true, + title: const Text('Theme'), + bottom: PreferredSize( + preferredSize: Size.fromHeight(boardSize + 16.0), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: _BoardPreview( + size: boardSize, + boardPrefs: boardPrefs, + brightness: brightness, + hue: hue, + ), + ), ), - SettingsListTile( - icon: const Icon(LichessIcons.chess_board), - settingsLabel: Text(context.l10n.board), - settingsValue: boardPrefs.boardTheme.label, - onTap: () { - pushPlatformRoute( - context, - title: context.l10n.board, - builder: (context) => const BoardThemeScreen(), - ); - }, - ), - SettingsListTile( - icon: const Icon(LichessIcons.chess_pawn), - settingsLabel: Text(context.l10n.pieceSet), - settingsValue: boardPrefs.pieceSet.label, - onTap: () { - pushPlatformRoute( - context, - title: context.l10n.pieceSet, - builder: (context) => const PieceSetScreen(), - ); - }, - ), - SettingsListTile( - icon: const Icon(LichessIcons.arrow_full_upperright), - settingsLabel: const Text('Shape color'), - settingsValue: shapeColorL10n(context, boardPrefs.shapeColor), - onTap: () { - showChoicePicker( - context, - choices: ShapeColor.values, - selectedItem: boardPrefs.shapeColor, - labelBuilder: - (t) => Text.rich( - TextSpan( - children: [ - TextSpan(text: shapeColorL10n(context, t)), - const TextSpan(text: ' '), - WidgetSpan(child: Container(width: 15, height: 15, color: t.color)), - ], - ), - ), - onSelectedItemChanged: (ShapeColor? value) { - ref - .read(boardPreferencesProvider.notifier) - .setShapeColor(value ?? ShapeColor.green); - }, - ); - }, - ), - SwitchSettingTile( - leading: const Icon(Icons.location_on), - title: Text(context.l10n.preferencesBoardCoordinates), - value: boardPrefs.coordinates, - onChanged: (value) { - ref.read(boardPreferencesProvider.notifier).toggleCoordinates(); - }, - ), - SwitchSettingTile( - // TODO translate - leading: const Icon(Icons.border_outer), - title: const Text('Show border'), - value: boardPrefs.showBorder, - onChanged: (value) { - ref.read(boardPreferencesProvider.notifier).toggleBorder(); - }, ), - ], + SliverList.list( + children: [ + ListSection( + hasLeading: true, + children: [ + SettingsListTile( + icon: const Icon(LichessIcons.chess_board), + settingsLabel: Text(context.l10n.board), + settingsValue: boardPrefs.boardTheme.label, + onTap: () { + pushPlatformRoute( + context, + title: context.l10n.board, + builder: (context) => const BoardThemeScreen(), + ); + }, + ), + SettingsListTile( + icon: const Icon(LichessIcons.chess_pawn), + settingsLabel: Text(context.l10n.pieceSet), + settingsValue: boardPrefs.pieceSet.label, + onTap: () { + pushPlatformRoute( + context, + title: context.l10n.pieceSet, + builder: (context) => const PieceSetScreen(), + ); + }, + ), + SettingsListTile( + icon: const Icon(LichessIcons.arrow_full_upperright), + settingsLabel: const Text('Shape color'), + settingsValue: shapeColorL10n(context, boardPrefs.shapeColor), + onTap: () { + showChoicePicker( + context, + choices: ShapeColor.values, + selectedItem: boardPrefs.shapeColor, + labelBuilder: + (t) => Text.rich( + TextSpan( + children: [ + TextSpan(text: shapeColorL10n(context, t)), + const TextSpan(text: ' '), + WidgetSpan( + child: Container(width: 15, height: 15, color: t.color), + ), + ], + ), + ), + onSelectedItemChanged: (ShapeColor? value) { + ref + .read(boardPreferencesProvider.notifier) + .setShapeColor(value ?? ShapeColor.green); + }, + ); + }, + ), + SwitchSettingTile( + leading: const Icon(Icons.location_on), + title: Text(context.l10n.preferencesBoardCoordinates), + value: boardPrefs.coordinates, + onChanged: (value) { + ref.read(boardPreferencesProvider.notifier).toggleCoordinates(); + }, + ), + SwitchSettingTile( + // TODO translate + leading: const Icon(Icons.border_outer), + title: const Text('Show border'), + value: boardPrefs.showBorder, + onChanged: (value) { + ref.read(boardPreferencesProvider.notifier).toggleBorder(); + }, + ), + ], + ), + ListSection( + header: SettingsSectionTitle(context.l10n.advancedSettings), + hasLeading: true, + children: [ + PlatformListTile( + leading: const Icon(Icons.brightness_6), + title: Slider.adaptive( + min: 0.2, + max: 1.4, + value: brightness, + onChanged: (value) { + setState(() { + brightness = value; + }); + }, + onChangeEnd: (value) { + ref + .read(boardPreferencesProvider.notifier) + .adjustColors(brightness: brightness); + }, + ), + ), + PlatformListTile( + leading: const Icon(Icons.invert_colors), + title: Slider.adaptive( + min: 0.0, + max: 360.0, + value: hue, + onChanged: (value) { + setState(() { + hue = value; + }); + }, + onChangeEnd: (value) { + ref.read(boardPreferencesProvider.notifier).adjustColors(hue: hue); + }, + ), + ), + PlatformListTile( + leading: Opacity( + opacity: hasAjustedColors ? 1.0 : 0.5, + child: const Icon(Icons.cancel), + ), + title: Opacity( + opacity: hasAjustedColors ? 1.0 : 0.5, + child: Text(context.l10n.boardReset), + ), + onTap: + hasAjustedColors + ? () { + setState(() { + brightness = kBoardDefaultBrightnessFilter; + hue = kBoardDefaultHueFilter; + }); + ref + .read(boardPreferencesProvider.notifier) + .adjustColors(brightness: brightness, hue: hue); + } + : null, + ), + PlatformListTile( + leading: const Icon(Icons.colorize_outlined), + title: const Text('App theme'), + trailing: switch (generalPrefs.appThemeSeed) { + AppThemeSeed.board => Text(context.l10n.board), + AppThemeSeed.system => Text(context.l10n.mobileSystemColors), + AppThemeSeed.color => + generalPrefs.customThemeSeed != null + ? Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: generalPrefs.customThemeSeed, + shape: BoxShape.circle, + ), + ) + : Container( + width: 20, + height: 20, + decoration: const BoxDecoration( + color: kDefaultSeedColor, + shape: BoxShape.circle, + ), + ), + }, + onTap: () { + showAdaptiveActionSheet( + context: context, + actions: + AppThemeSeed.values + .where((t) => t != AppThemeSeed.system || getCorePalette() != null) + .map( + (t) => BottomSheetAction( + makeLabel: + (context) => switch (t) { + AppThemeSeed.board => Text(context.l10n.board), + AppThemeSeed.system => Text( + context.l10n.mobileSystemColors, + ), + AppThemeSeed.color => const Text('Custom color'), + }, + onPressed: (context) { + ref + .read(generalPreferencesProvider.notifier) + .setAppThemeSeed(t); + + if (t == AppThemeSeed.color) { + _showColorPicker(); + } + }, + dismissOnPress: true, + ), + ) + .toList(), + ); + }, + ), + ], + ), + ], + ), + const SliverSafeArea( + top: false, + sliver: SliverToBoxAdapter(child: SizedBox(height: 16.0)), + ), + ], + ), + ); + } +} + +class _BoardPreview extends StatelessWidget { + const _BoardPreview({ + required this.size, + required this.boardPrefs, + required this.brightness, + required this.hue, + }); + + final BoardPrefs boardPrefs; + final double brightness; + final double hue; + final double size; + + @override + Widget build(BuildContext context) { + return Center( + child: BrightnessHueFilter( + brightness: brightness, + hue: hue, + child: Chessboard.fixed( + size: size, + orientation: Side.white, + lastMove: const NormalMove(from: Square.e2, to: Square.e4), + fen: 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1', + shapes: + { + Circle(color: boardPrefs.shapeColor.color, orig: Square.fromName('b8')), + Arrow( + color: boardPrefs.shapeColor.color, + orig: Square.fromName('b8'), + dest: Square.fromName('c6'), + ), + }.lock, + settings: boardPrefs.toBoardSettings().copyWith( + brightness: kBoardDefaultBrightnessFilter, + hue: kBoardDefaultHueFilter, + borderRadius: const BorderRadius.all(Radius.circular(4.0)), + boxShadow: boardShadows, + ), ), - ], + ), ); } } diff --git a/lib/src/widgets/board_carousel_item.dart b/lib/src/widgets/board_carousel_item.dart index a6e12f65c1..a8b1a0ba3b 100644 --- a/lib/src/widgets/board_carousel_item.dart +++ b/lib/src/widgets/board_carousel_item.dart @@ -51,60 +51,64 @@ class BoardCarouselItem extends ConsumerWidget { return LayoutBuilder( builder: (context, constraints) { final boardSize = constraints.biggest.shortestSide - _kBoardCarouselItemMargin.horizontal; - final card = PlatformCard( - color: backgroundColor, - margin: - Theme.of(context).platform == TargetPlatform.iOS - ? EdgeInsets.zero - : _kBoardCarouselItemMargin, - child: AdaptiveInkWell( - splashColor: splashColor, - borderRadius: BorderRadius.circular(10), - onTap: onTap, - child: Stack( - children: [ - ShaderMask( - blendMode: BlendMode.dstOut, - shaderCallback: (bounds) { - return LinearGradient( - begin: Alignment.center, - end: Alignment.bottomCenter, - colors: [ - backgroundColor.withValues(alpha: 0.25), - backgroundColor.withValues(alpha: 1.0), - ], - stops: const [0.3, 1.00], - tileMode: TileMode.clamp, - ).createShader(bounds); - }, - child: SizedBox( - height: boardSize, - child: Chessboard.fixed( - size: boardSize, - fen: fen, - orientation: orientation, - lastMove: lastMove, - settings: ChessboardSettings( - enableCoordinates: false, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(10.0), - topRight: Radius.circular(10.0), + final card = BrightnessHueFilter( + hue: boardPrefs.hue, + brightness: boardPrefs.brightness, + child: PlatformCard( + color: backgroundColor, + margin: + Theme.of(context).platform == TargetPlatform.iOS + ? EdgeInsets.zero + : _kBoardCarouselItemMargin, + child: AdaptiveInkWell( + splashColor: splashColor, + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Stack( + children: [ + ShaderMask( + blendMode: BlendMode.dstOut, + shaderCallback: (bounds) { + return LinearGradient( + begin: Alignment.center, + end: Alignment.bottomCenter, + colors: [ + backgroundColor.withValues(alpha: 0.25), + backgroundColor.withValues(alpha: 1.0), + ], + stops: const [0.3, 1.00], + tileMode: TileMode.clamp, + ).createShader(bounds); + }, + child: SizedBox( + height: boardSize, + child: Chessboard.fixed( + size: boardSize, + fen: fen, + orientation: orientation, + lastMove: lastMove, + settings: ChessboardSettings( + enableCoordinates: false, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(10.0), + topRight: Radius.circular(10.0), + ), + pieceAssets: boardPrefs.pieceSet.assets, + colorScheme: boardPrefs.boardTheme.colors, ), - pieceAssets: boardPrefs.pieceSet.assets, - colorScheme: boardPrefs.boardTheme.colors, ), ), ), - ), - Positioned( - left: 0, - bottom: 8, - child: DefaultTextStyle.merge( - style: const TextStyle(color: Colors.white), - child: description, + Positioned( + left: 0, + bottom: 8, + child: DefaultTextStyle.merge( + style: const TextStyle(color: Colors.white), + child: description, + ), ), - ), - ], + ], + ), ), ), ); diff --git a/lib/src/widgets/board_preview.dart b/lib/src/widgets/board_preview.dart index 531bc0a6d9..0f74802a2a 100644 --- a/lib/src/widgets/board_preview.dart +++ b/lib/src/widgets/board_preview.dart @@ -89,12 +89,14 @@ class _SmallBoardPreviewState extends ConsumerState { orientation: widget.orientation, lastMove: widget.lastMove as NormalMove?, settings: ChessboardSettings( + pieceAssets: boardPrefs.pieceSet.assets, + colorScheme: boardPrefs.boardTheme.colors, + brightness: boardPrefs.brightness, + hue: boardPrefs.hue, enableCoordinates: false, borderRadius: const BorderRadius.all(Radius.circular(4.0)), boxShadow: boardShadows, animationDuration: const Duration(milliseconds: 150), - pieceAssets: boardPrefs.pieceSet.assets, - colorScheme: boardPrefs.boardTheme.colors, ), ), const SizedBox(width: 10.0), diff --git a/lib/src/widgets/board_thumbnail.dart b/lib/src/widgets/board_thumbnail.dart index 0607cad0dc..c13fa9e49f 100644 --- a/lib/src/widgets/board_thumbnail.dart +++ b/lib/src/widgets/board_thumbnail.dart @@ -83,6 +83,8 @@ class _BoardThumbnailState extends ConsumerState { animationDuration: widget.animationDuration!, pieceAssets: boardPrefs.pieceSet.assets, colorScheme: boardPrefs.boardTheme.colors, + hue: boardPrefs.hue, + brightness: boardPrefs.brightness, ), ) : StaticChessboard( @@ -95,6 +97,8 @@ class _BoardThumbnailState extends ConsumerState { boxShadow: boardShadows, pieceAssets: boardPrefs.pieceSet.assets, colorScheme: boardPrefs.boardTheme.colors, + hue: boardPrefs.hue, + brightness: boardPrefs.brightness, ); final maybeTappableBoard = diff --git a/lib/src/widgets/settings.dart b/lib/src/widgets/settings.dart index b9daf76648..b553c49b03 100644 --- a/lib/src/widgets/settings.dart +++ b/lib/src/widgets/settings.dart @@ -71,6 +71,7 @@ class SwitchSettingTile extends StatelessWidget { required this.value, this.onChanged, this.leading, + this.padding, super.key, }); @@ -79,10 +80,12 @@ class SwitchSettingTile extends StatelessWidget { final bool value; final void Function(bool value)? onChanged; final Widget? leading; + final EdgeInsetsGeometry? padding; @override Widget build(BuildContext context) { return PlatformListTile( + padding: padding, leading: leading, title: _SettingsTitle(title: title), subtitle: subtitle, diff --git a/pubspec.lock b/pubspec.lock index 2319ddc62f..decbb5c858 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -202,10 +202,10 @@ packages: dependency: "direct main" description: name: chessground - sha256: "118e11871baa08022be827087bc90b82f0bda535d504278787f9717ad949132b" + sha256: "6ae599b48e8802e75d3b27e71816cb04c74667f54266d5d22f75f7fb11503c47" url: "https://pub.dev" source: hosted - version: "6.1.0" + version: "6.2.2" ci: dependency: transitive description: @@ -555,6 +555,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.4.1" + flutter_colorpicker: + dependency: "direct main" + description: + name: flutter_colorpicker + sha256: "969de5f6f9e2a570ac660fb7b501551451ea2a1ab9e2097e89475f60e07816ea" + url: "https://pub.dev" + source: hosted + version: "1.1.0" flutter_displaymode: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 72f8e3d380..9306efaf4d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,7 +14,7 @@ dependencies: async: ^2.10.0 auto_size_text: ^3.0.0 cached_network_image: ^3.2.2 - chessground: ^6.1.0 + chessground: ^6.2.2 clock: ^1.1.1 collection: ^1.17.0 connectivity_plus: ^6.0.2 @@ -34,6 +34,7 @@ dependencies: flutter: sdk: flutter flutter_appauth: ^8.0.0+1 + flutter_colorpicker: ^1.1.0 flutter_displaymode: ^0.6.0 flutter_layout_grid: ^2.0.1 flutter_linkify: ^6.0.0