Bläddra i källkod

feat: text and layout direction settings (#3247)

* feat: text and layout direction settings

Added ltr|rtl|auto direction button to appflowy toolbar.
Introduced layout and default direction settings.

* chore: formate code

* feat: added hint for direction settings

* fix: flutter analyze

---------

Co-authored-by: Lucas.Xu <[email protected]>
Mohammad Zolfaghari 1 år sedan
förälder
incheckning
9565173baf

+ 2 - 0
frontend/appflowy_flutter/lib/core/config/kv_keys.dart

@@ -29,6 +29,8 @@ class KVKeys {
       'kDocumentAppearanceFontSize';
   static const String kDocumentAppearanceFontFamily =
       'kDocumentAppearanceFontFamily';
+  static const String kDocumentAppearanceDefaultTextDirection =
+      'kDocumentAppearanceDefaultTextDirection';
 
   /// The key for saving the expanded views
   ///

+ 12 - 1
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart

@@ -2,6 +2,7 @@ import 'package:appflowy/plugins/document/application/doc_bloc.dart';
 import 'package:appflowy/plugins/document/presentation/editor_plugins/inline_page/inline_page_reference.dart';
 import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
 import 'package:appflowy/plugins/document/presentation/editor_style.dart';
+import 'package:appflowy/workspace/application/appearance.dart';
 import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_service.dart';
 import 'package:appflowy_editor/appflowy_editor.dart';
 import 'package:collection/collection.dart';
@@ -74,6 +75,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
     alignToolbarItem,
     buildTextColorItem(),
     buildHighlightColorItem(),
+    ...textDirectionItems
   ];
 
   late final List<SelectionMenuItem> slashMenuItems;
@@ -175,13 +177,22 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
       footer: const VSpace(200),
     );
 
+    final layoutDirection =
+        context.read<AppearanceSettingsCubit>().state.layoutDirection ==
+                LayoutDirection.rtlLayout
+            ? TextDirection.rtl
+            : TextDirection.ltr;
+
     return Center(
       child: FloatingToolbar(
         style: styleCustomizer.floatingToolbarStyleBuilder(),
         items: toolbarItems,
         editorState: widget.editorState,
         editorScrollController: editorScrollController,
-        child: editor,
+        child: Directionality(
+          textDirection: layoutDirection,
+          child: editor,
+        ),
       ),
     );
   }

+ 4 - 0
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart

@@ -30,9 +30,13 @@ class EditorStyleCustomizer {
     final theme = Theme.of(context);
     final fontSize = context.read<DocumentAppearanceCubit>().state.fontSize;
     final fontFamily = context.read<DocumentAppearanceCubit>().state.fontFamily;
+    final defaultTextDirection =
+        context.read<DocumentAppearanceCubit>().state.defaultTextDirection;
+
     return EditorStyle.desktop(
       padding: padding,
       cursorColor: theme.colorScheme.primary,
+      defaultTextDirection: defaultTextDirection,
       textStyleConfiguration: TextStyleConfiguration(
         text: baseTextStyle(fontFamily).copyWith(
           fontSize: fontSize,

+ 29 - 0
frontend/appflowy_flutter/lib/plugins/document/presentation/more/cubit/document_appearance_cubit.dart

@@ -6,18 +6,22 @@ class DocumentAppearance {
   const DocumentAppearance({
     required this.fontSize,
     required this.fontFamily,
+    this.defaultTextDirection,
   });
 
   final double fontSize;
   final String fontFamily;
+  final String? defaultTextDirection;
 
   DocumentAppearance copyWith({
     double? fontSize,
     String? fontFamily,
+    String? defaultTextDirection,
   }) {
     return DocumentAppearance(
       fontSize: fontSize ?? this.fontSize,
       fontFamily: fontFamily ?? this.fontFamily,
+      defaultTextDirection: defaultTextDirection,
     );
   }
 }
@@ -32,6 +36,8 @@ class DocumentAppearanceCubit extends Cubit<DocumentAppearance> {
         prefs.getDouble(KVKeys.kDocumentAppearanceFontSize) ?? 16.0;
     final fontFamily =
         prefs.getString(KVKeys.kDocumentAppearanceFontFamily) ?? 'Poppins';
+    final defaultTextDirection =
+        prefs.getString(KVKeys.kDocumentAppearanceDefaultTextDirection);
 
     if (isClosed) {
       return;
@@ -41,6 +47,7 @@ class DocumentAppearanceCubit extends Cubit<DocumentAppearance> {
       state.copyWith(
         fontSize: fontSize,
         fontFamily: fontFamily,
+        defaultTextDirection: defaultTextDirection,
       ),
     );
   }
@@ -74,4 +81,26 @@ class DocumentAppearanceCubit extends Cubit<DocumentAppearance> {
       ),
     );
   }
+
+  Future<void> syncDefaultTextDirection(String? direction) async {
+    final prefs = await SharedPreferences.getInstance();
+    if (direction == null) {
+      prefs.remove(KVKeys.kDocumentAppearanceDefaultTextDirection);
+    } else {
+      prefs.setString(
+        KVKeys.kDocumentAppearanceDefaultTextDirection,
+        direction,
+      );
+    }
+
+    if (isClosed) {
+      return;
+    }
+
+    emit(
+      state.copyWith(
+        defaultTextDirection: direction,
+      ),
+    );
+  }
 }

+ 71 - 0
frontend/appflowy_flutter/lib/workspace/application/appearance.dart

@@ -34,6 +34,8 @@ class AppearanceSettingsCubit extends Cubit<AppearanceSettingsState> {
             setting.themeMode,
             setting.font,
             setting.monospaceFont,
+            setting.layoutDirection,
+            setting.textDirection,
             setting.locale,
             setting.isMenuCollapsed,
             setting.menuOffset,
@@ -71,6 +73,19 @@ class AppearanceSettingsCubit extends Cubit<AppearanceSettingsState> {
     );
   }
 
+  void setLayoutDirection(LayoutDirection layoutDirection) {
+    _setting.layoutDirection = layoutDirection.toLayoutDirectionPB();
+    _saveAppearanceSettings();
+    emit(state.copyWith(layoutDirection: layoutDirection));
+  }
+
+  void setTextDirection(AppFlowyTextDirection? textDirection) {
+    _setting.textDirection =
+        textDirection?.toTextDirectionPB() ?? TextDirectionPB.FALLBACK;
+    _saveAppearanceSettings();
+    emit(state.copyWith(textDirection: textDirection));
+  }
+
   /// Update selected font in the user's settings and emit an updated state
   /// with the font name.
   void setFontFamily(String fontFamilyName) {
@@ -192,6 +207,56 @@ ThemeModePB _themeModeToPB(ThemeMode themeMode) {
   }
 }
 
+enum LayoutDirection {
+  ltrLayout,
+  rtlLayout;
+
+  static LayoutDirection fromLayoutDirectionPB(
+    LayoutDirectionPB layoutDirectionPB,
+  ) =>
+      layoutDirectionPB == LayoutDirectionPB.RTLLayout
+          ? LayoutDirection.rtlLayout
+          : LayoutDirection.ltrLayout;
+
+  LayoutDirectionPB toLayoutDirectionPB() => this == LayoutDirection.rtlLayout
+      ? LayoutDirectionPB.RTLLayout
+      : LayoutDirectionPB.LTRLayout;
+}
+
+enum AppFlowyTextDirection {
+  ltr,
+  rtl,
+  auto;
+
+  static AppFlowyTextDirection? fromTextDirectionPB(
+    TextDirectionPB? textDirectionPB,
+  ) {
+    switch (textDirectionPB) {
+      case TextDirectionPB.LTR:
+        return AppFlowyTextDirection.ltr;
+      case TextDirectionPB.RTL:
+        return AppFlowyTextDirection.rtl;
+      case TextDirectionPB.AUTO:
+        return AppFlowyTextDirection.auto;
+      default:
+        return null;
+    }
+  }
+
+  TextDirectionPB toTextDirectionPB() {
+    switch (this) {
+      case AppFlowyTextDirection.ltr:
+        return TextDirectionPB.LTR;
+      case AppFlowyTextDirection.rtl:
+        return TextDirectionPB.RTL;
+      case AppFlowyTextDirection.auto:
+        return TextDirectionPB.AUTO;
+      default:
+        return TextDirectionPB.FALLBACK;
+    }
+  }
+}
+
 @freezed
 class AppearanceSettingsState with _$AppearanceSettingsState {
   const AppearanceSettingsState._();
@@ -201,6 +266,8 @@ class AppearanceSettingsState with _$AppearanceSettingsState {
     required ThemeMode themeMode,
     required String font,
     required String monospaceFont,
+    required LayoutDirection layoutDirection,
+    required AppFlowyTextDirection? textDirection,
     required Locale locale,
     required bool isMenuCollapsed,
     required double menuOffset,
@@ -211,6 +278,8 @@ class AppearanceSettingsState with _$AppearanceSettingsState {
     ThemeModePB themeModePB,
     String font,
     String monospaceFont,
+    LayoutDirectionPB layoutDirectionPB,
+    TextDirectionPB? textDirectionPB,
     LocaleSettingsPB localePB,
     bool isMenuCollapsed,
     double menuOffset,
@@ -219,6 +288,8 @@ class AppearanceSettingsState with _$AppearanceSettingsState {
       appTheme: appTheme,
       font: font,
       monospaceFont: monospaceFont,
+      layoutDirection: LayoutDirection.fromLayoutDirectionPB(layoutDirectionPB),
+      textDirection: AppFlowyTextDirection.fromTextDirectionPB(textDirectionPB),
       themeMode: _themeModeFromPB(themeModePB),
       locale: Locale(localePB.languageCode, localePB.countryCode),
       isMenuCollapsed: isMenuCollapsed,

+ 139 - 0
frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/direction_setting.dart

@@ -0,0 +1,139 @@
+import 'package:appflowy/generated/flowy_svgs.g.dart';
+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.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';
+
+import 'theme_setting_entry_template.dart';
+
+class LayoutDirectionSetting extends StatelessWidget {
+  const LayoutDirectionSetting({
+    super.key,
+    required this.currentLayoutDirection,
+  });
+
+  final LayoutDirection currentLayoutDirection;
+
+  @override
+  Widget build(BuildContext context) {
+    return ThemeSettingEntryTemplateWidget(
+      label: LocaleKeys.settings_appearance_layoutDirection_label.tr(),
+      hint: LocaleKeys.settings_appearance_layoutDirection_hint.tr(),
+      trailing: [
+        ThemeValueDropDown(
+          currentValue: _layoutDirectionLabelText(currentLayoutDirection),
+          popupBuilder: (_) => Column(
+            mainAxisSize: MainAxisSize.min,
+            children: [
+              _layoutDirectionItemButton(context, LayoutDirection.ltrLayout),
+              _layoutDirectionItemButton(context, LayoutDirection.rtlLayout),
+            ],
+          ),
+        )
+      ],
+    );
+  }
+
+  Widget _layoutDirectionItemButton(
+    BuildContext context,
+    LayoutDirection direction,
+  ) {
+    return SizedBox(
+      height: 32,
+      child: FlowyButton(
+        text: FlowyText.medium(_layoutDirectionLabelText(direction)),
+        rightIcon: currentLayoutDirection == direction
+            ? const FlowySvg(FlowySvgs.check_s)
+            : null,
+        onTap: () {
+          if (currentLayoutDirection != direction) {
+            context
+                .read<AppearanceSettingsCubit>()
+                .setLayoutDirection(direction);
+          }
+        },
+      ),
+    );
+  }
+
+  String _layoutDirectionLabelText(LayoutDirection direction) {
+    switch (direction) {
+      case (LayoutDirection.ltrLayout):
+        return LocaleKeys.settings_appearance_layoutDirection_ltr.tr();
+      case (LayoutDirection.rtlLayout):
+        return LocaleKeys.settings_appearance_layoutDirection_rtl.tr();
+      default:
+        return '';
+    }
+  }
+}
+
+class TextDirectionSetting extends StatelessWidget {
+  const TextDirectionSetting({
+    super.key,
+    required this.currentTextDirection,
+  });
+
+  final AppFlowyTextDirection? currentTextDirection;
+
+  @override
+  Widget build(BuildContext context) => ThemeSettingEntryTemplateWidget(
+        label: LocaleKeys.settings_appearance_textDirection_label.tr(),
+        hint: LocaleKeys.settings_appearance_textDirection_hint.tr(),
+        trailing: [
+          ThemeValueDropDown(
+            currentValue: _textDirectionLabelText(currentTextDirection),
+            popupBuilder: (_) => Column(
+              mainAxisSize: MainAxisSize.min,
+              children: [
+                _textDirectionItemButton(context, null),
+                _textDirectionItemButton(context, AppFlowyTextDirection.ltr),
+                _textDirectionItemButton(context, AppFlowyTextDirection.rtl),
+                _textDirectionItemButton(context, AppFlowyTextDirection.auto),
+              ],
+            ),
+          )
+        ],
+      );
+
+  Widget _textDirectionItemButton(
+    BuildContext context,
+    AppFlowyTextDirection? textDirection,
+  ) {
+    return SizedBox(
+      height: 32,
+      child: FlowyButton(
+        text: FlowyText.medium(_textDirectionLabelText(textDirection)),
+        rightIcon: currentTextDirection == textDirection
+            ? const FlowySvg(FlowySvgs.check_s)
+            : null,
+        onTap: () {
+          if (currentTextDirection != textDirection) {
+            context
+                .read<AppearanceSettingsCubit>()
+                .setTextDirection(textDirection);
+            context
+                .read<DocumentAppearanceCubit>()
+                .syncDefaultTextDirection(textDirection?.name);
+          }
+        },
+      ),
+    );
+  }
+
+  String _textDirectionLabelText(AppFlowyTextDirection? textDirection) {
+    switch (textDirection) {
+      case (AppFlowyTextDirection.ltr):
+        return LocaleKeys.settings_appearance_textDirection_ltr.tr();
+      case (AppFlowyTextDirection.rtl):
+        return LocaleKeys.settings_appearance_textDirection_rtl.tr();
+      case (AppFlowyTextDirection.auto):
+        return LocaleKeys.settings_appearance_textDirection_auto.tr();
+      default:
+        return LocaleKeys.settings_appearance_textDirection_fallback.tr();
+    }
+  }
+}

+ 1 - 0
frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/settings_appearance.dart

@@ -1,3 +1,4 @@
 export 'brightness_setting.dart';
 export 'font_family_setting.dart';
 export 'color_scheme.dart';
+export 'direction_setting.dart';

+ 19 - 3
frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/theme_setting_entry_template.dart

@@ -10,11 +10,13 @@ class ThemeSettingEntryTemplateWidget extends StatelessWidget {
     super.key,
     this.resetButtonKey,
     required this.label,
+    this.hint,
     this.trailing,
     this.onResetRequested,
   });
 
   final String label;
+  final String? hint;
   final Key? resetButtonKey;
   final List<Widget>? trailing;
   final void Function()? onResetRequested;
@@ -24,9 +26,23 @@ class ThemeSettingEntryTemplateWidget extends StatelessWidget {
     return Row(
       children: [
         Expanded(
-          child: FlowyText.medium(
-            label,
-            overflow: TextOverflow.ellipsis,
+          child: Column(
+            crossAxisAlignment: CrossAxisAlignment.start,
+            children: [
+              FlowyText.medium(
+                label,
+                overflow: TextOverflow.ellipsis,
+              ),
+              if (hint != null)
+                Padding(
+                  padding: const EdgeInsets.only(bottom: 4),
+                  child: FlowyText.regular(
+                    hint!,
+                    fontSize: 10,
+                    color: Theme.of(context).hintColor,
+                  ),
+                ),
+            ],
           ),
         ),
         if (trailing != null) ...trailing!,

+ 6 - 0
frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance_view.dart

@@ -28,6 +28,12 @@ class SettingsAppearanceView extends StatelessWidget {
                 ThemeFontFamilySetting(
                   currentFontFamily: state.font,
                 ),
+                LayoutDirectionSetting(
+                  currentLayoutDirection: state.layoutDirection,
+                ),
+                TextDirectionSetting(
+                  currentTextDirection: state.textDirection,
+                ),
               ],
             );
           },

+ 14 - 0
frontend/resources/translations/en.json

@@ -263,6 +263,20 @@
         "dark": "Dark Mode",
         "system": "Adapt to System"
       },
+      "layoutDirection": {
+        "label": "Layout Direction",
+        "hint": "To start aligning elements from left or right of the screen.",
+        "ltr": "LTR",
+        "rtl": "RTL"
+      },
+      "textDirection": {
+        "label": "Default text direction",
+        "hint": "Default text direction when the text direction is not set on the element.",
+        "ltr": "LTR",
+        "rtl": "RTL",
+        "auto": "AUTO",
+        "fallback": "Same as layout direction"
+      },
       "themeUpload": {
         "button": "Upload",
         "description": "Upload your own AppFlowy theme using the button below.",

+ 28 - 0
frontend/rust-lib/flowy-user/src/entities/user_setting.rs

@@ -50,6 +50,16 @@ pub struct AppearanceSettingsPB {
   #[pb(index = 9)]
   #[serde(default)]
   pub menu_offset: f64,
+
+  #[pb(index = 10)]
+  #[serde(default)]
+  pub layout_direction: LayoutDirectionPB,
+
+  // If the value is FALLBACK which is the default value then it will fall back
+  // to layout direction and it will use that as default text direction.
+  #[pb(index = 11)]
+  #[serde(default)]
+  pub text_direction: TextDirectionPB,
 }
 
 const DEFAULT_RESET_VALUE: fn() -> bool = || APPEARANCE_RESET_AS_DEFAULT;
@@ -62,6 +72,22 @@ pub enum ThemeModePB {
   System = 2,
 }
 
+#[derive(ProtoBuf_Enum, Serialize, Deserialize, Clone, Debug, Default)]
+pub enum LayoutDirectionPB {
+  #[default]
+  LTRLayout = 0,
+  RTLLayout = 1,
+}
+
+#[derive(ProtoBuf_Enum, Serialize, Deserialize, Clone, Debug, Default)]
+pub enum TextDirectionPB {
+  LTR = 0,
+  RTL = 1,
+  AUTO = 2,
+  #[default]
+  FALLBACK = 3,
+}
+
 #[derive(ProtoBuf, Serialize, Deserialize, Debug, Clone)]
 pub struct LocaleSettingsPB {
   #[pb(index = 1)]
@@ -99,6 +125,8 @@ impl std::default::Default for AppearanceSettingsPB {
       setting_key_value: HashMap::default(),
       is_menu_collapsed: APPEARANCE_DEFAULT_IS_MENU_COLLAPSED,
       menu_offset: APPEARANCE_DEFAULT_MENU_OFFSET,
+      layout_direction: LayoutDirectionPB::default(),
+      text_direction: TextDirectionPB::default(),
     }
   }
 }