diff --git a/frontend/appflowy_flutter/lib/core/config/kv_keys.dart b/frontend/appflowy_flutter/lib/core/config/kv_keys.dart index 264d40ed07..7f49f7ee28 100644 --- a/frontend/appflowy_flutter/lib/core/config/kv_keys.dart +++ b/frontend/appflowy_flutter/lib/core/config/kv_keys.dart @@ -24,6 +24,10 @@ class KVKeys { 'kDocumentAppearanceFontFamily'; static const String kDocumentAppearanceDefaultTextDirection = 'kDocumentAppearanceDefaultTextDirection'; + static const String kDocumentAppearanceCursorColor = + 'kDocumentAppearanceCursorColor'; + static const String kDocumentAppearanceSelectionColor = + 'kDocumentAppearanceSelectionColor'; /// The key for saving the expanded views /// diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart index 7b558968bb..b6be097f23 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart @@ -6,6 +6,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_too import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart'; import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; import 'package:appflowy/util/google_font_family_extension.dart'; +import 'package:appflowy/workspace/application/appearance_defaults.dart'; import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide Log; import 'package:collection/collection.dart'; @@ -39,15 +40,18 @@ class EditorStyleCustomizer { EditorStyle desktop() { final theme = Theme.of(context); - final fontSize = context.read().state.fontSize; - final fontFamily = context.read().state.fontFamily; - final defaultTextDirection = - context.read().state.defaultTextDirection; + final appearance = context.read().state; + final fontSize = appearance.fontSize; + final fontFamily = appearance.fontFamily; final codeFontSize = max(0.0, fontSize - 2); + return EditorStyle.desktop( padding: padding, - cursorColor: theme.colorScheme.primary, - defaultTextDirection: defaultTextDirection, + cursorColor: appearance.cursorColor ?? + DefaultAppearanceSettings.getDefaultDocumentCursorColor(context), + selectionColor: appearance.selectionColor ?? + DefaultAppearanceSettings.getDefaultDocumentSelectionColor(context), + defaultTextDirection: appearance.defaultTextDirection, textStyleConfiguration: TextStyleConfiguration( text: baseTextStyle(fontFamily).copyWith( fontSize: fontSize, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/more/cubit/document_appearance_cubit.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/more/cubit/document_appearance_cubit.dart index 558f683686..cb6c06ed9b 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/more/cubit/document_appearance_cubit.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/more/cubit/document_appearance_cubit.dart @@ -1,28 +1,49 @@ import 'package:appflowy/core/config/kv_keys.dart'; +import 'package:appflowy/util/color_to_hex_string.dart'; import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; import 'package:bloc/bloc.dart'; +import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; class DocumentAppearance { const DocumentAppearance({ required this.fontSize, required this.fontFamily, + this.cursorColor, + this.selectionColor, this.defaultTextDirection, }); final double fontSize; final String fontFamily; + final Color? cursorColor; + final Color? selectionColor; final String? defaultTextDirection; + /// For nullable fields (like `cursorColor`), + /// use the corresponding `isNull` flag (like `cursorColorIsNull`) to explicitly set the field to `null`. + /// + /// This is necessary because simply passing `null` as the value does not distinguish between wanting to + /// set the field to `null` and not wanting to update the field at all. DocumentAppearance copyWith({ double? fontSize, String? fontFamily, + Color? cursorColor, + Color? selectionColor, String? defaultTextDirection, + bool cursorColorIsNull = false, + bool selectionColorIsNull = false, + bool textDirectionIsNull = false, }) { return DocumentAppearance( fontSize: fontSize ?? this.fontSize, fontFamily: fontFamily ?? this.fontFamily, - defaultTextDirection: defaultTextDirection, + cursorColor: cursorColorIsNull ? null : cursorColor ?? this.cursorColor, + selectionColor: + selectionColorIsNull ? null : selectionColor ?? this.selectionColor, + defaultTextDirection: textDirectionIsNull + ? null + : defaultTextDirection ?? this.defaultTextDirection, ); } } @@ -45,6 +66,16 @@ class DocumentAppearanceCubit extends Cubit { final defaultTextDirection = prefs.getString(KVKeys.kDocumentAppearanceDefaultTextDirection); + final cursorColorString = + prefs.getString(KVKeys.kDocumentAppearanceCursorColor); + final selectionColorString = + prefs.getString(KVKeys.kDocumentAppearanceSelectionColor); + final cursorColor = + cursorColorString != null ? Color(int.parse(cursorColorString)) : null; + final selectionColor = selectionColorString != null + ? Color(int.parse(selectionColorString)) + : null; + if (isClosed) { return; } @@ -53,7 +84,12 @@ class DocumentAppearanceCubit extends Cubit { state.copyWith( fontSize: fontSize, fontFamily: fontFamily, + cursorColor: cursorColor, + selectionColor: selectionColor, defaultTextDirection: defaultTextDirection, + cursorColorIsNull: cursorColor == null, + selectionColorIsNull: selectionColor == null, + textDirectionIsNull: defaultTextDirection == null, ), ); } @@ -106,6 +142,55 @@ class DocumentAppearanceCubit extends Cubit { emit( state.copyWith( defaultTextDirection: direction, + textDirectionIsNull: direction == null, + ), + ); + } + + Future syncCursorColor(Color? cursorColor) async { + final prefs = await SharedPreferences.getInstance(); + + if (cursorColor == null) { + prefs.remove(KVKeys.kDocumentAppearanceCursorColor); + } else { + prefs.setString( + KVKeys.kDocumentAppearanceCursorColor, + cursorColor.toHexString(), + ); + } + + if (isClosed) { + return; + } + + emit( + state.copyWith( + cursorColor: cursorColor, + cursorColorIsNull: cursorColor == null, + ), + ); + } + + Future syncSelectionColor(Color? selectionColor) async { + final prefs = await SharedPreferences.getInstance(); + + if (selectionColor == null) { + prefs.remove(KVKeys.kDocumentAppearanceSelectionColor); + } else { + prefs.setString( + KVKeys.kDocumentAppearanceSelectionColor, + selectionColor.toHexString(), + ); + } + + if (isClosed) { + return; + } + + emit( + state.copyWith( + selectionColor: selectionColor, + selectionColorIsNull: selectionColor == null, ), ); } diff --git a/frontend/appflowy_flutter/lib/util/color_to_hex_string.dart b/frontend/appflowy_flutter/lib/util/color_to_hex_string.dart new file mode 100644 index 0000000000..b7fcbb9443 --- /dev/null +++ b/frontend/appflowy_flutter/lib/util/color_to_hex_string.dart @@ -0,0 +1,8 @@ +import 'package:flutter/material.dart'; + +extension ColorExtensionn on Color { + /// return a hex string in 0xff000000 format + String toHexString() { + return '0x${value.toRadixString(16).padLeft(8, '0')}'; + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/appearance_defaults.dart b/frontend/appflowy_flutter/lib/workspace/application/appearance_defaults.dart index 2ea692ac84..5faed08e79 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/appearance_defaults.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/appearance_defaults.dart @@ -7,4 +7,12 @@ class DefaultAppearanceSettings { static const kDefaultThemeMode = ThemeMode.system; static const kDefaultThemeName = "Default"; static const kDefaultTheme = BuiltInTheme.defaultTheme; + + static Color getDefaultDocumentCursorColor(BuildContext context) { + return Theme.of(context).colorScheme.primary; + } + + static Color getDefaultDocumentSelectionColor(BuildContext context) { + return Theme.of(context).colorScheme.primary.withOpacity(0.2); + } } diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/appearance_cubit.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/appearance_cubit.dart index 259572a536..5db1e7603d 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/appearance_cubit.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/appearance_cubit.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/user_settings_service.dart'; +import 'package:appflowy/util/color_to_hex_string.dart'; import 'package:appflowy/workspace/application/appearance_defaults.dart'; import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; import 'package:appflowy_backend/log.dart'; @@ -48,6 +49,20 @@ class AppearanceSettingsCubit extends Cubit { dateTimeSettings.dateFormat, dateTimeSettings.timeFormat, dateTimeSettings.timezoneId, + appearanceSettings.documentSetting.cursorColor.isEmpty + ? null + : Color( + int.parse( + appearanceSettings.documentSetting.cursorColor, + ), + ), + appearanceSettings.documentSetting.selectionColor.isEmpty + ? null + : Color( + int.parse( + appearanceSettings.documentSetting.selectionColor, + ), + ), ), ); @@ -107,6 +122,34 @@ class AppearanceSettingsCubit extends Cubit { void resetFontFamily() => setFontFamily(DefaultAppearanceSettings.kDefaultFontFamily); + /// Update document cursor color in the apperance settings and emit an updated state. + void setDocumentCursorColor(Color color) { + _appearanceSettings.documentSetting.cursorColor = color.toHexString(); + _saveAppearanceSettings(); + emit(state.copyWith(documentCursorColor: color)); + } + + /// Reset document cursor color in the apperance settings + void resetDocumentCursorColor() { + _appearanceSettings.documentSetting.cursorColor = ''; + _saveAppearanceSettings(); + emit(state.copyWith(documentCursorColor: null)); + } + + /// Update document selection color in the apperance settings and emit an updated state. + void setDocumentSelectionColor(Color color) { + _appearanceSettings.documentSetting.selectionColor = color.toHexString(); + _saveAppearanceSettings(); + emit(state.copyWith(documentSelectionColor: color)); + } + + /// Reset document selection color in the apperance settings + void resetDocumentSelectionColor() { + _appearanceSettings.documentSetting.selectionColor = ''; + _saveAppearanceSettings(); + emit(state.copyWith(documentSelectionColor: null)); + } + /// Updates the current locale and notify the listeners the locale was /// changed. Fallback to [en] locale if [newLocale] is not supported. void setLocale(BuildContext context, Locale newLocale) { @@ -308,6 +351,8 @@ class AppearanceSettingsState with _$AppearanceSettingsState { required UserDateFormatPB dateFormat, required UserTimeFormatPB timeFormat, required String timezoneId, + required Color? documentCursorColor, + required Color? documentSelectionColor, }) = _AppearanceSettingsState; factory AppearanceSettingsState.initial( @@ -323,6 +368,8 @@ class AppearanceSettingsState with _$AppearanceSettingsState { UserDateFormatPB dateFormat, UserTimeFormatPB timeFormat, String timezoneId, + Color? documentCursorColor, + Color? documentSelectionColor, ) { return AppearanceSettingsState( appTheme: appTheme, @@ -337,6 +384,8 @@ class AppearanceSettingsState with _$AppearanceSettingsState { dateFormat: dateFormat, timeFormat: timeFormat, timezoneId: timezoneId, + documentCursorColor: documentCursorColor, + documentSelectionColor: documentSelectionColor, ); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/brightness_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/brightness_setting.dart index 50b6b343df..ff456a2af2 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/brightness_setting.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/brightness_setting.dart @@ -18,12 +18,12 @@ class BrightnessSetting extends StatelessWidget { @override Widget build(BuildContext context) { - return ThemeSettingEntryTemplateWidget( + return FlowySettingListTile( label: LocaleKeys.settings_appearance_themeMode_label.tr(), hint: hintText, onResetRequested: context.read().resetThemeMode, trailing: [ - ThemeValueDropDown( + FlowySettingValueDropDown( currentValue: currentThemeMode.labelText, popupBuilder: (context) => Column( mainAxisSize: MainAxisSize.min, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/color_scheme.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/color_scheme.dart index b2008075e4..88b5ccb676 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/color_scheme.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/color_scheme.dart @@ -27,7 +27,7 @@ class ColorSchemeSetting extends StatelessWidget { @override Widget build(BuildContext context) { - return ThemeSettingEntryTemplateWidget( + return FlowySettingListTile( label: LocaleKeys.settings_appearance_theme.tr(), onResetRequested: context.read().resetTheme, trailing: [ diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/create_file_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/create_file_setting.dart index 7a98e65b3d..d45bc44b27 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/create_file_setting.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/create_file_setting.dart @@ -16,7 +16,7 @@ class CreateFileSettings extends StatelessWidget { @override Widget build(BuildContext context) { - return ThemeSettingEntryTemplateWidget( + return FlowySettingListTile( label: LocaleKeys.settings_appearance_showNamingDialogWhenCreatingPage.tr(), trailing: [ diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/date_format_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/date_format_setting.dart index 87bf347c44..3f78e35478 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/date_format_setting.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/date_format_setting.dart @@ -18,10 +18,10 @@ class DateFormatSetting extends StatelessWidget { final UserDateFormatPB currentFormat; @override - Widget build(BuildContext context) => ThemeSettingEntryTemplateWidget( + Widget build(BuildContext context) => FlowySettingListTile( label: LocaleKeys.settings_appearance_dateFormat_label.tr(), trailing: [ - ThemeValueDropDown( + FlowySettingValueDropDown( currentValue: _formatLabel(currentFormat), popupBuilder: (_) => Column( mainAxisSize: MainAxisSize.min, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/direction_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/direction_setting.dart index 561b6dc555..d65feb24ed 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/direction_setting.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/direction_setting.dart @@ -20,11 +20,11 @@ class LayoutDirectionSetting extends StatelessWidget { @override Widget build(BuildContext context) { - return ThemeSettingEntryTemplateWidget( + return FlowySettingListTile( label: LocaleKeys.settings_appearance_layoutDirection_label.tr(), hint: LocaleKeys.settings_appearance_layoutDirection_hint.tr(), trailing: [ - ThemeValueDropDown( + FlowySettingValueDropDown( key: const ValueKey('layout_direction_option_button'), currentValue: _layoutDirectionLabelText(currentLayoutDirection), popupBuilder: (context) => Column( @@ -83,11 +83,11 @@ class TextDirectionSetting extends StatelessWidget { final AppFlowyTextDirection? currentTextDirection; @override - Widget build(BuildContext context) => ThemeSettingEntryTemplateWidget( + Widget build(BuildContext context) => FlowySettingListTile( label: LocaleKeys.settings_appearance_textDirection_label.tr(), hint: LocaleKeys.settings_appearance_textDirection_hint.tr(), trailing: [ - ThemeValueDropDown( + FlowySettingValueDropDown( currentValue: _textDirectionLabelText(currentTextDirection), popupBuilder: (context) => Column( mainAxisSize: MainAxisSize.min, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/document_color_setting_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/document_color_setting_button.dart new file mode 100644 index 0000000000..203d42e1f5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/document_color_setting_button.dart @@ -0,0 +1,266 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/util/color_to_hex_string.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/utils/hex_opacity_string_extension.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart'; +import 'package:flowy_infra_ui/widget/rounded_button.dart'; +import 'package:flutter/material.dart'; + +class DocumentColorSettingButton extends StatelessWidget { + const DocumentColorSettingButton({ + super.key, + required this.currentColor, + required this.previewWidgetBuilder, + required this.dialogTitle, + required this.onApply, + }); + + /// current color from backend + final Color currentColor; + + /// Build a preview widget with the given color + /// It shows both on the [DocumentColorSettingButton] and [_DocumentColorSettingDialog] + final Widget Function(Color? color) previewWidgetBuilder; + + final String dialogTitle; + + final void Function(Color selectedColorOnDialog) onApply; + + @override + Widget build(BuildContext context) { + return FlowyButton( + margin: const EdgeInsets.all(8), + text: previewWidgetBuilder.call(currentColor), + hoverColor: Theme.of(context).colorScheme.secondaryContainer, + expandText: false, + onTap: () => Dialogs.show( + context, + child: _DocumentColorSettingDialog( + currentColor: currentColor, + previewWidgetBuilder: previewWidgetBuilder, + dialogTitle: dialogTitle, + onApply: onApply, + ), + ), + ); + } +} + +class _DocumentColorSettingDialog extends StatefulWidget { + const _DocumentColorSettingDialog({ + required this.currentColor, + required this.previewWidgetBuilder, + required this.dialogTitle, + required this.onApply, + }); + + final Color currentColor; + + final Widget Function(Color?) previewWidgetBuilder; + + final String dialogTitle; + + final void Function(Color selectedColorOnDialog) onApply; + + @override + State<_DocumentColorSettingDialog> createState() => + DocumentColorSettingDialogState(); +} + +class DocumentColorSettingDialogState + extends State<_DocumentColorSettingDialog> { + /// The color displayed in the dialog. + /// It is `null` when the user didn't enter a valid color value. + late Color? selectedColorOnDialog; + late String currentColorHexString; + late TextEditingController hexController; + late TextEditingController opacityController; + final _formKey = GlobalKey(debugLabel: 'colorSettingForm'); + + void updateSelectedColor() { + if (_formKey.currentState!.validate()) { + setState(() { + final colorValue = int.tryParse( + hexController.text.combineHexWithOpacity(opacityController.text), + ); + // colorValue has been validated in the _ColorSettingTextField for hex value and it won't be null as this point + selectedColorOnDialog = Color(colorValue!); + }); + } + } + + @override + void initState() { + super.initState(); + selectedColorOnDialog = widget.currentColor; + currentColorHexString = widget.currentColor.toHexString(); + hexController = TextEditingController( + text: currentColorHexString.extractHex(), + ); + opacityController = TextEditingController( + text: currentColorHexString.extractOpacity(), + ); + } + + @override + void dispose() { + hexController.dispose(); + opacityController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FlowyDialog( + constraints: const BoxConstraints(maxWidth: 360, maxHeight: 320), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + const Spacer(), + FlowyText(widget.dialogTitle), + const VSpace(8), + SizedBox( + width: 100, + height: 40, + child: Center( + child: widget.previewWidgetBuilder( + selectedColorOnDialog, + ), + ), + ), + const VSpace(8), + SizedBox( + height: 160, + child: Form( + key: _formKey, + child: Column( + children: [ + _ColorSettingTextField( + controller: hexController, + labelText: LocaleKeys.editor_hexValue.tr(), + hintText: '6fc9e7', + onFieldSubmitted: (_) => updateSelectedColor(), + validator: (hexValue) => validateHexValue( + hexValue, + opacityController.text, + ), + ), + const VSpace(8), + _ColorSettingTextField( + controller: opacityController, + labelText: LocaleKeys.editor_opacity.tr(), + hintText: '50', + onFieldSubmitted: (_) => updateSelectedColor(), + validator: (value) => validateOpacityValue(value), + ), + ], + ), + ), + ), + const VSpace(8), + RoundedTextButton( + title: LocaleKeys.settings_appearance_documentSettings_apply.tr(), + width: 100, + height: 30, + onPressed: () { + if (_formKey.currentState!.validate()) { + if (selectedColorOnDialog != null && + selectedColorOnDialog != widget.currentColor) { + widget.onApply.call(selectedColorOnDialog!); + } + } else { + // error message will be shown below the text field + return; + } + Navigator.of(context).pop(); + }, + ), + ], + ), + ), + ); + } +} + +class _ColorSettingTextField extends StatelessWidget { + const _ColorSettingTextField({ + required this.controller, + required this.labelText, + required this.hintText, + required this.onFieldSubmitted, + required this.validator, + }); + + final TextEditingController controller; + final String labelText; + final String hintText; + + final void Function(String) onFieldSubmitted; + final String? Function(String?)? validator; + + @override + Widget build(BuildContext context) { + final style = Theme.of(context); + return TextFormField( + controller: controller, + decoration: InputDecoration( + labelText: labelText, + hintText: hintText, + border: OutlineInputBorder( + borderSide: BorderSide( + color: style.colorScheme.outline, + ), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: style.colorScheme.outline, + ), + ), + ), + style: style.textTheme.bodyMedium, + onFieldSubmitted: onFieldSubmitted, + validator: validator, + autovalidateMode: AutovalidateMode.onUserInteraction, + ); + } +} + +String? validateHexValue( + String? hexValue, + String opacityValue, +) { + if (hexValue == null || hexValue.isEmpty) { + return LocaleKeys.settings_appearance_documentSettings_hexEmptyError.tr(); + } + if (hexValue.length != 6) { + return LocaleKeys.settings_appearance_documentSettings_hexLengthError.tr(); + } + + if (validateOpacityValue(opacityValue) == null) { + final colorValue = + int.tryParse(hexValue.combineHexWithOpacity(opacityValue)); + + if (colorValue == null) { + return LocaleKeys.settings_appearance_documentSettings_hexInvalidError + .tr(); + } + } + + return null; +} + +String? validateOpacityValue(String? value) { + if (value == null || value.isEmpty) { + return LocaleKeys.settings_appearance_documentSettings_opacityEmptyError + .tr(); + } + + final opacityInt = int.tryParse(value); + if (opacityInt == null || opacityInt > 100 || opacityInt <= 0) { + return LocaleKeys.settings_appearance_documentSettings_opacityRangeError + .tr(); + } + return null; +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/document_cursor_color_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/document_cursor_color_setting.dart new file mode 100644 index 0000000000..11276a95f4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/document_cursor_color_setting.dart @@ -0,0 +1,82 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart'; +import 'package:appflowy/workspace/application/appearance_defaults.dart'; +import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/document_color_setting_button.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/theme_setting_entry_template.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class DocumentCursorColorSetting extends StatelessWidget { + const DocumentCursorColorSetting({ + super.key, + required this.currentCursorColor, + }); + + final Color currentCursorColor; + + @override + Widget build(BuildContext context) { + final label = + LocaleKeys.settings_appearance_documentSettings_cursorColor.tr(); + return FlowySettingListTile( + label: label, + resetButtonKey: const Key('DocumentCursorColorResetButton'), + onResetRequested: () { + context.read().resetDocumentCursorColor(); + context.read().syncCursorColor(null); + }, + trailing: [ + DocumentColorSettingButton( + key: const Key('DocumentCursorColorSettingButton'), + currentColor: currentCursorColor, + previewWidgetBuilder: (color) => _CursorColorValueWidget( + cursorColor: color ?? + DefaultAppearanceSettings.getDefaultDocumentCursorColor( + context, + ), + ), + dialogTitle: label, + onApply: (selectedColorOnDialog) { + context + .read() + .setDocumentCursorColor(selectedColorOnDialog); + // update the state of document appearance cubit with latest cursor color + context + .read() + .syncCursorColor(selectedColorOnDialog); + }, + ), + ], + ); + } +} + +class _CursorColorValueWidget extends StatelessWidget { + const _CursorColorValueWidget({ + required this.cursorColor, + }); + + final Color cursorColor; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + color: cursorColor, + width: 2, + height: 16, + ), + FlowyText( + LocaleKeys.appName.tr(), + // To avoid the text color changes when it is hovered in dark mode + color: Theme.of(context).colorScheme.onBackground, + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/document_selection_color_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/document_selection_color_setting.dart new file mode 100644 index 0000000000..2a10c70521 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/document_selection_color_setting.dart @@ -0,0 +1,85 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart'; +import 'package:appflowy/workspace/application/appearance_defaults.dart'; +import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/document_color_setting_button.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/theme_setting_entry_template.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class DocumentSelectionColorSetting extends StatelessWidget { + const DocumentSelectionColorSetting({ + super.key, + required this.currentSelectionColor, + }); + + final Color currentSelectionColor; + + @override + Widget build(BuildContext context) { + final label = + LocaleKeys.settings_appearance_documentSettings_selectionColor.tr(); + + return FlowySettingListTile( + label: label, + resetButtonKey: const Key('DocumentSelectionColorResetButton'), + onResetRequested: () { + context.read().resetDocumentSelectionColor(); + context.read().syncSelectionColor(null); + }, + trailing: [ + DocumentColorSettingButton( + currentColor: currentSelectionColor, + previewWidgetBuilder: (color) => _SelectionColorValueWidget( + selectionColor: color ?? + DefaultAppearanceSettings.getDefaultDocumentSelectionColor( + context, + ), + ), + dialogTitle: label, + onApply: (selectedColorOnDialog) { + context + .read() + .setDocumentSelectionColor(selectedColorOnDialog); + // update the state of document appearance cubit with latest selection color + context + .read() + .syncSelectionColor(selectedColorOnDialog); + }, + ), + ], + ); + } +} + +class _SelectionColorValueWidget extends StatelessWidget { + const _SelectionColorValueWidget({ + required this.selectionColor, + }); + + final Color selectionColor; + + @override + Widget build(BuildContext context) { + // To avoid the text color changes when it is hovered in dark mode + final textColor = Theme.of(context).colorScheme.onBackground; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + color: selectionColor, + child: FlowyText( + LocaleKeys.settings_appearance_documentSettings_app.tr(), + color: textColor, + ), + ), + FlowyText( + LocaleKeys.settings_appearance_documentSettings_flowy.tr(), + color: textColor, + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/font_family_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/font_family_setting.dart index 0006973d17..ce0c9ac079 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/font_family_setting.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/font_family_setting.dart @@ -33,7 +33,7 @@ class ThemeFontFamilySetting extends StatefulWidget { class _ThemeFontFamilySettingState extends State { @override Widget build(BuildContext context) { - return ThemeSettingEntryTemplateWidget( + return FlowySettingListTile( label: LocaleKeys.settings_appearance_fontFamily_label.tr(), resetButtonKey: ThemeFontFamilySetting.resetButtonkey, onResetRequested: () { @@ -91,7 +91,7 @@ class _FontFamilyDropDownState extends State { @override Widget build(BuildContext context) { - return ThemeValueDropDown( + return FlowySettingValueDropDown( popoverKey: ThemeFontFamilySetting.popoverKey, popoverController: widget.popoverController, currentValue: parseFontFamilyName(widget.currentFontFamily), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/settings_appearance.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/settings_appearance.dart index 17c346996c..d4385631d6 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/settings_appearance.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/settings_appearance.dart @@ -2,3 +2,5 @@ export 'brightness_setting.dart'; export 'font_family_setting.dart'; export 'color_scheme.dart'; export 'direction_setting.dart'; +export 'document_cursor_color_setting.dart'; +export 'document_selection_color_setting.dart'; diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/theme_setting_entry_template.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/theme_setting_entry_template.dart index 03481a6a95..2a4a76cdf9 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/theme_setting_entry_template.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/theme_setting_entry_template.dart @@ -5,9 +5,10 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; -class ThemeSettingEntryTemplateWidget extends StatelessWidget { - const ThemeSettingEntryTemplateWidget({ +class FlowySettingListTile extends StatelessWidget { + const FlowySettingListTile({ super.key, + this.resetTooltipText, this.resetButtonKey, required this.label, this.hint, @@ -17,6 +18,7 @@ class ThemeSettingEntryTemplateWidget extends StatelessWidget { final String label; final String? hint; + final String? resetTooltipText; final Key? resetButtonKey; final List? trailing; final void Function()? onResetRequested; @@ -57,7 +59,8 @@ class ThemeSettingEntryTemplateWidget extends StatelessWidget { color: Theme.of(context).iconTheme.color, ), iconColorOnHover: Theme.of(context).colorScheme.onPrimary, - tooltipText: LocaleKeys.settings_appearance_resetSetting.tr(), + tooltipText: resetTooltipText ?? + LocaleKeys.settings_appearance_resetSetting.tr(), onPressed: onResetRequested, ), ], @@ -65,8 +68,8 @@ class ThemeSettingEntryTemplateWidget extends StatelessWidget { } } -class ThemeValueDropDown extends StatefulWidget { - const ThemeValueDropDown({ +class FlowySettingValueDropDown extends StatefulWidget { + const FlowySettingValueDropDown({ super.key, required this.currentValue, required this.popupBuilder, @@ -86,10 +89,11 @@ class ThemeValueDropDown extends StatefulWidget { final Offset? offset; @override - State createState() => _ThemeValueDropDownState(); + State createState() => + _FlowySettingValueDropDownState(); } -class _ThemeValueDropDownState extends State { +class _FlowySettingValueDropDownState extends State { @override Widget build(BuildContext context) { return AppFlowyPopover( diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/time_format_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/time_format_setting.dart index b9f710186c..e4ffff7461 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/time_format_setting.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/time_format_setting.dart @@ -18,10 +18,10 @@ class TimeFormatSetting extends StatelessWidget { final UserTimeFormatPB currentFormat; @override - Widget build(BuildContext context) => ThemeSettingEntryTemplateWidget( + Widget build(BuildContext context) => FlowySettingListTile( label: LocaleKeys.settings_appearance_timeFormat_label.tr(), trailing: [ - ThemeValueDropDown( + FlowySettingValueDropDown( currentValue: _formatLabel(currentFormat), popupBuilder: (_) => Column( mainAxisSize: MainAxisSize.min, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance_view.dart index 5ae79395cb..a4e5924d1a 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance_view.dart @@ -1,3 +1,4 @@ +import 'package:appflowy/workspace/application/appearance_defaults.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/create_file_setting.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/date_format_setting.dart'; @@ -33,6 +34,20 @@ class SettingsAppearanceView extends StatelessWidget { currentFontFamily: state.font, ), const Divider(), + DocumentCursorColorSetting( + currentCursorColor: state.documentCursorColor ?? + DefaultAppearanceSettings.getDefaultDocumentCursorColor( + context, + ), + ), + DocumentSelectionColorSetting( + currentSelectionColor: state.documentSelectionColor ?? + DefaultAppearanceSettings + .getDefaultDocumentSelectionColor( + context, + ), + ), + const Divider(), LayoutDirectionSetting( currentLayoutDirection: state.layoutDirection, ), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_notifications_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_notifications_view.dart index 0d74175f1a..0986cac82a 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_notifications_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_notifications_view.dart @@ -17,7 +17,7 @@ class SettingsNotificationsView extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - ThemeSettingEntryTemplateWidget( + FlowySettingListTile( label: LocaleKeys .settings_notifications_enableNotifications_label .tr(), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/utils/hex_opacity_string_extension.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/utils/hex_opacity_string_extension.dart new file mode 100644 index 0000000000..537de2768c --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/utils/hex_opacity_string_extension.dart @@ -0,0 +1,19 @@ +extension HexOpacityExtension on String { + /// Only used in a valid color String like '0xff00bcf0' + String extractHex() { + return substring(4); + } + + /// Only used in a valid color String like '0xff00bcf0' + String extractOpacity() { + final opacityString = substring(2, 4); + final opacityInt = int.parse(opacityString, radix: 16) / 2.55; + return opacityInt.toStringAsFixed(0); + } + + /// Apply on the hex string like '00bcf0', with opacity like '100' + String combineHexWithOpacity(String opacity) { + final opacityInt = (int.parse(opacity) * 2.55).round().toRadixString(16); + return '0x$opacityInt$this'; + } +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/default_colorscheme.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/default_colorscheme.dart index c473e65ee3..ac8b04100d 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/default_colorscheme.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/default_colorscheme.dart @@ -85,7 +85,7 @@ class DefaultColorScheme extends FlowyColorScheme { shader1: _darkShader1, shader2: _darkShader2, shader3: _darkShader3, - shader4: const Color(0xff7C8CA5), + shader4: const Color(0xff505469), shader5: _darkShader5, shader6: _darkShader6, shader7: _white, diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 1c62c33f8a..13406f18b7 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -308,7 +308,7 @@ } }, "appearance": { - "resetSetting": "Reset this setting", + "resetSetting": "Reset", "fontFamily": { "label": "Font Family", "search": "Search" @@ -319,6 +319,18 @@ "dark": "Dark Mode", "system": "Adapt to System" }, + "documentSettings": { + "cursorColor": "Document cursor color", + "selectionColor": "Document selection color", + "hexEmptyError": "Hex color cannot be empty", + "hexLengthError": "Hex value must be 6 digits long", + "hexInvalidError": "Invalid hex value", + "opacityEmptyError": "Opacity cannot be empty", + "opacityRangeError": "Opacity must be between 1 and 100", + "app": "App", + "flowy": "Flowy", + "apply": "Apply" + }, "layoutDirection": { "label": "Layout Direction", "hint": "Control the flow of content on your screen, from left to right or right to left.", diff --git a/frontend/rust-lib/flowy-user/src/entities/user_setting.rs b/frontend/rust-lib/flowy-user/src/entities/user_setting.rs index caaf524381..7be2764675 100644 --- a/frontend/rust-lib/flowy-user/src/entities/user_setting.rs +++ b/frontend/rust-lib/flowy-user/src/entities/user_setting.rs @@ -64,6 +64,10 @@ pub struct AppearanceSettingsPB { #[pb(index = 11)] #[serde(default)] pub text_direction: TextDirectionPB, + + #[pb(index = 12)] + #[serde(default)] + pub document_setting: DocumentSettingsPB, } const DEFAULT_RESET_VALUE: fn() -> bool = || APPEARANCE_RESET_AS_DEFAULT; @@ -110,6 +114,24 @@ impl std::default::Default for LocaleSettingsPB { } } +#[derive(ProtoBuf, Serialize, Deserialize, Debug, Clone)] +pub struct DocumentSettingsPB { + #[pb(index = 1, one_of)] + pub cursor_color: Option, + + #[pb(index = 2, one_of)] + pub selection_color: Option, +} + +impl std::default::Default for DocumentSettingsPB { + fn default() -> Self { + Self { + cursor_color: None, + selection_color: None, + } + } +} + pub const APPEARANCE_DEFAULT_THEME: &str = "Default"; pub const APPEARANCE_DEFAULT_FONT: &str = "Poppins"; pub const APPEARANCE_DEFAULT_MONOSPACE_FONT: &str = "SF Mono"; @@ -131,6 +153,7 @@ impl std::default::Default for AppearanceSettingsPB { menu_offset: APPEARANCE_DEFAULT_MENU_OFFSET, layout_direction: LayoutDirectionPB::default(), text_direction: TextDirectionPB::default(), + document_setting: DocumentSettingsPB::default(), } } }