diff --git a/lib/src/app.dart b/lib/src/app.dart index 0cb59123d7..016035bfa4 100644 --- a/lib/src/app.dart +++ b/lib/src/app.dart @@ -17,6 +17,7 @@ import 'package:lichess_mobile/src/navigation.dart'; import 'package:lichess_mobile/src/network/connectivity.dart'; import 'package:lichess_mobile/src/network/http.dart'; import 'package:lichess_mobile/src/network/socket.dart'; +import 'package:lichess_mobile/src/styles/lichess_colors.dart'; import 'package:lichess_mobile/src/styles/styles.dart'; import 'package:lichess_mobile/src/utils/screen.dart'; @@ -147,13 +148,27 @@ class _AppState extends ConsumerState { final dynamicColorScheme = brightness == Brightness.light ? fixedLightScheme : fixedDarkScheme; - final colorScheme = - generalPrefs.systemColors && dynamicColorScheme != null - ? dynamicColorScheme - : ColorScheme.fromSeed( - seedColor: boardTheme.colors.darkSquare, - brightness: brightness, - ); + ColorScheme colorScheme; + if (generalPrefs.customThemeEnabled) { + if (generalPrefs.customThemeSeed != null) { + colorScheme = ColorScheme.fromSeed( + seedColor: generalPrefs.customThemeSeed!, + brightness: brightness, + ); + } else if (dynamicColorScheme != null) { + colorScheme = dynamicColorScheme; + } else { + colorScheme = ColorScheme.fromSeed( + seedColor: LichessColors.primary[500]!, + brightness: brightness, + ); + } + } else { + colorScheme = ColorScheme.fromSeed( + seedColor: boardTheme.colors.darkSquare, + brightness: brightness, + ); + } final cupertinoThemeData = CupertinoThemeData( primaryColor: colorScheme.primary, diff --git a/lib/src/init.dart b/lib/src/init.dart index 788d8040b4..5f500bfbbe 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'; @@ -95,14 +96,23 @@ Future androidDisplayInitialization(WidgetsBinding widgetsBinding) async { 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), - ), - ); + if (getCorePalette() != null) { + if (prefs.getString(PrefCategory.general.storageKey) == null) { + prefs.setString( + PrefCategory.general.storageKey, + jsonEncode( + GeneralPrefs.defaults.copyWith(customThemeEnabled: true), + ), + ); + } + if (prefs.getString(PrefCategory.board.storageKey) == null) { + prefs.setString( + PrefCategory.board.storageKey, + jsonEncode( + BoardPrefs.defaults.copyWith(boardTheme: BoardTheme.system), + ), + ); + } } }); } catch (e) { diff --git a/lib/src/model/settings/general_preferences.dart b/lib/src/model/settings/general_preferences.dart index 3e87d9c6cf..f59a0fca7c 100644 --- a/lib/src/model/settings/general_preferences.dart +++ b/lib/src/model/settings/general_preferences.dart @@ -1,8 +1,9 @@ -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'; @@ -28,7 +29,7 @@ class GeneralPreferences extends _$GeneralPreferences return fetch(); } - Future setThemeMode(BackgroundThemeMode themeMode) { + Future setBackgroundThemeMode(BackgroundThemeMode themeMode) { return save(state.copyWith(themeMode: themeMode)); } @@ -48,9 +49,9 @@ class GeneralPreferences extends _$GeneralPreferences return save(state.copyWith(masterVolume: volume)); } - Future toggleSystemColors() async { - await save(state.copyWith(systemColors: !state.systemColors)); - if (state.systemColors == false) { + Future toggleCustomTheme() async { + await save(state.copyWith(customThemeEnabled: !state.customThemeEnabled)); + if (state.customThemeEnabled == false) { final boardTheme = ref.read(boardPreferencesProvider).boardTheme; if (boardTheme == BoardTheme.system) { await ref @@ -63,27 +64,10 @@ class GeneralPreferences extends _$GeneralPreferences .setBoardTheme(BoardTheme.system); } } -} - -Map? _localeToJson(Locale? locale) { - return locale != null - ? { - 'languageCode': locale.languageCode, - 'countryCode': locale.countryCode, - 'scriptCode': locale.scriptCode, - } - : null; -} -Locale? _localeFromJson(Map? json) { - if (json == null) { - return null; + Future setCustomThemeSeed(Color? color) { + return save(state.copyWith(customThemeSeed: color)); } - return Locale.fromSubtags( - languageCode: json['languageCode'] as String, - countryCode: json['countryCode'] as String?, - scriptCode: json['scriptCode'] as String?, - ); } @Freezed(fromJson: true, toJson: true) @@ -99,11 +83,14 @@ class GeneralPrefs with _$GeneralPrefs implements Serializable { 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, /// 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( @@ -111,7 +98,7 @@ class GeneralPrefs with _$GeneralPrefs implements Serializable { isSoundEnabled: true, soundTheme: SoundTheme.standard, masterVolume: 0.8, - systemColors: true, + customThemeEnabled: true, ); factory GeneralPrefs.fromJson(Map json) { diff --git a/lib/src/utils/color_palette.dart b/lib/src/utils/color_palette.dart index 4ecbb07e12..8b861920de 100644 --- a/lib/src/utils/color_palette.dart +++ b/lib/src/utils/color_palette.dart @@ -48,6 +48,12 @@ void setCorePalette(CorePalette? palette) { } } +Color? getCorePalettePrimary() { + return _corePalette?.primary != null + ? Color(_corePalette!.primary.get(50)) + : null; +} + /// Get the core palette if available (android 12+ only). CorePalette? getCorePalette() { return _corePalette; diff --git a/lib/src/utils/json.dart b/lib/src/utils/json.dart index b8c013b980..dc3061749b 100644 --- a/lib/src/utils/json.dart +++ b/lib/src/utils/json.dart @@ -1,6 +1,64 @@ +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/puzzle/puzzle_tab_screen.dart b/lib/src/view/puzzle/puzzle_tab_screen.dart index 43645c36b5..c45caa8263 100644 --- a/lib/src/view/puzzle/puzzle_tab_screen.dart +++ b/lib/src/view/puzzle/puzzle_tab_screen.dart @@ -423,9 +423,6 @@ class _PuzzleMenuListTile extends StatelessWidget { leading: Icon( icon, size: Styles.mainListTileIconSize, - color: Theme.of(context).platform == TargetPlatform.iOS - ? CupertinoTheme.of(context).primaryColor - : Theme.of(context).colorScheme.primary, ), title: Text(title, style: Styles.mainListTileTitle), subtitle: Text(subtitle, maxLines: 3), diff --git a/lib/src/view/settings/account_preferences_screen.dart b/lib/src/view/settings/account_preferences_screen.dart index f6797646d3..f624fea006 100644 --- a/lib/src/view/settings/account_preferences_screen.dart +++ b/lib/src/view/settings/account_preferences_screen.dart @@ -129,7 +129,6 @@ class _AccountPreferencesScreenState subtitle: Text( context.l10n.preferencesExplainShowPlayerRatings, maxLines: 5, - textAlign: TextAlign.justify, ), value: data.showRatings.value, onChanged: isLoading diff --git a/lib/src/view/settings/app_background_mode_screen.dart b/lib/src/view/settings/app_background_mode_screen.dart index e24084c562..f4bc2924cf 100644 --- a/lib/src/view/settings/app_background_mode_screen.dart +++ b/lib/src/view/settings/app_background_mode_screen.dart @@ -52,7 +52,7 @@ class _Body extends ConsumerWidget { void onChanged(BackgroundThemeMode? value) => 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 c005919905..361adc81a3 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'; @@ -40,20 +39,11 @@ class _Body extends ConsumerWidget { 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), + (t) => t != BoardTheme.system || hasSystemColors, ) .toList(); diff --git a/lib/src/view/settings/settings_tab_screen.dart b/lib/src/view/settings/settings_tab_screen.dart index af1101463b..3e75b75f18 100644 --- a/lib/src/view/settings/settings_tab_screen.dart +++ b/lib/src/view/settings/settings_tab_screen.dart @@ -222,7 +222,9 @@ class _Body extends ConsumerWidget { Text(AppBackgroundModeScreen.themeTitle(context, t)), onSelectedItemChanged: (BackgroundThemeMode? value) => ref .read(generalPreferencesProvider.notifier) - .setThemeMode(value ?? BackgroundThemeMode.system), + .setBackgroundThemeMode( + value ?? BackgroundThemeMode.system, + ), ); } else { pushPlatformRoute( diff --git a/lib/src/view/settings/theme_screen.dart b/lib/src/view/settings/theme_screen.dart index 2e16832dc7..33ce24440a 100644 --- a/lib/src/view/settings/theme_screen.dart +++ b/lib/src/view/settings/theme_screen.dart @@ -3,18 +3,22 @@ import 'package:chessground/chessground.dart'; import 'package:dartchess/dartchess.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.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_colors.dart'; import 'package:lichess_mobile/src/styles/lichess_icons.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/view/settings/board_theme_screen.dart'; import 'package:lichess_mobile/src/view/settings/piece_set_screen.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_alert_dialog.dart'; import 'package:lichess_mobile/src/widgets/platform_scaffold.dart'; import 'package:lichess_mobile/src/widgets/settings.dart'; @@ -47,7 +51,6 @@ class _Body extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final generalPrefs = ref.watch(generalPreferencesProvider); final boardPrefs = ref.watch(boardPreferencesProvider); - final androidVersionAsync = ref.watch(androidVersionProvider); const horizontalPadding = 16.0; @@ -56,7 +59,7 @@ class _Body extends ConsumerWidget { LayoutBuilder( builder: (context, constraints) { final double boardSize = math.min( - 400, + 250, constraints.biggest.shortestSide - horizontalPadding * 2, ); return Padding( @@ -95,22 +98,145 @@ class _Body extends ConsumerWidget { 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) { + SwitchSettingTile( + leading: const Icon(Icons.colorize_outlined), + padding: Theme.of(context).platform == TargetPlatform.iOS + ? const EdgeInsets.symmetric(horizontal: 14, vertical: 8) + : null, + title: const Text('Custom theme'), + // TODO translate + subtitle: const Text( + 'Configure your own app theme using a seed color. Disable to use the chessboard theme.', + maxLines: 3, + ), + value: generalPrefs.customThemeEnabled, + onChanged: (value) { + ref + .read(generalPreferencesProvider.notifier) + .toggleCustomTheme(); + }, + ), + AnimatedCrossFade( + duration: const Duration(milliseconds: 300), + crossFadeState: generalPrefs.customThemeEnabled + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + firstChild: const SizedBox.shrink(), + secondChild: ListSection( + margin: EdgeInsets.zero, + cupertinoBorderRadius: BorderRadius.zero, + cupertinoClipBehavior: Clip.none, + children: [ + PlatformListTile( + leading: const Icon(Icons.color_lens), + title: const Text('Seed color'), + trailing: generalPrefs.customThemeSeed != null + ? Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: generalPrefs.customThemeSeed, + shape: BoxShape.circle, + ), + ) + : getCorePalette() != null + ? Text(context.l10n.mobileSystemColors) + : Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: LichessColors.primary[500], + shape: BoxShape.circle, + ), + ), + onTap: () { + showAdaptiveDialog( + context: context, + barrierDismissible: false, + builder: (context) { + final defaultColor = getCorePalettePrimary() ?? + LichessColors.primary[500]!; + bool useDefault = + generalPrefs.customThemeSeed == null; + Color color = + generalPrefs.customThemeSeed ?? defaultColor; + return StatefulBuilder( + builder: (context, setState) { + return PlatformAlertDialog( + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ColorPicker( + enableAlpha: false, + pickerColor: color, + onColorChanged: (c) { + setState(() { + useDefault = false; + color = c; + }); + }, + ), + SecondaryButton( + semanticsLabel: getCorePalette() != null + ? context.l10n.mobileSystemColors + : 'Default color', + onPressed: !useDefault + ? () { + setState(() { + useDefault = true; + color = defaultColor; + }); + } + : null, + child: Text( + getCorePalette() != null + ? context.l10n.mobileSystemColors + : '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) - .toggleSystemColors(); - }, - ) - : const SizedBox.shrink(), - orElse: () => const SizedBox.shrink(), + .setCustomThemeSeed(color as Color?); + } + }); + }, + ), + ], ), + ), + ], + ), + ListSection( + hasLeading: true, + children: [ SettingsListTile( icon: const Icon(LichessIcons.chess_board), settingsLabel: Text(context.l10n.board), diff --git a/lib/src/widgets/settings.dart b/lib/src/widgets/settings.dart index 2100a27873..a15ee65379 100644 --- a/lib/src/widgets/settings.dart +++ b/lib/src/widgets/settings.dart @@ -74,6 +74,7 @@ class SwitchSettingTile extends StatelessWidget { required this.value, this.onChanged, this.leading, + this.padding, super.key, }); @@ -82,10 +83,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 1844c28a42..33246390af 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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 9c5eb9c2e4..68b317a50d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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