Browse Source

[feat] Allow user to select any Google Font (#2895)

* chore: add label for font selection drop down

* chore: add method to set font family

* feat: add drop down to setting appearance view

* feat: add fontFamily to document appearance cubit

* feat: add bloc provider to root for document appearance style

* feat: syncFont family from setting appearance dialog

* feat: plumbing for font style in editor

* fix: add blocprovider before pushing overlay

* chore: add kv_keys

* fix: use fontFamily in document appearance cubit

* fix: remove bloc providers because bloc is supplied in ancestor

* fix: remove unecessary bloc provider

* chore: add constraints to popover

* chore: add translation for search box

* feat: add levenshtein for string sort

* feat: add search bar view

* refactor: levenshtein

* chore: add tests for levenshtein algorithm

* feat: add unit tests for appearance cubit

* fix: analyzer warnings

* feat: sort by ascending if query is empty

* chore: add test for the font family setting widget

* feat: make comparison case insensitive

* feat: lazy load with listview.builder

Co-authored-by: Yijing Huang <[email protected]>

* fix: fonts loaded on open application

* fix: checkmark doesn't show

* fix: try catch before getFont

* fix: clear text editing value on close

* fix: remove autofocus for search text field

* chore: add tests

* feat: use sliver protocol

Co-authored-by: Yijing Huang <[email protected]>

* fix: avoid using intrinsic height

Co-authored-by: Yijing Huang <[email protected]>

* fix: extra paren caused build failure

* feat: switch order of font family setting

---------

Co-authored-by: Yijing Huang <[email protected]>
Alex Wallen 1 year ago
parent
commit
323cb3b60f

+ 4 - 0
frontend/appflowy_flutter/assets/translations/en.json

@@ -186,6 +186,10 @@
       "open": "Open Settings"
     },
     "appearance": {
+      "fontFamily": {
+        "label": "Font Family",
+        "search": "Search"
+      },
       "themeMode": {
         "label": "Theme Mode",
         "light": "Light Mode",

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

@@ -18,4 +18,9 @@ class KVKeys {
   /// The value is a json string with the following format:
   ///   {'height': 600.0, 'width': 800.0}
   static const String windowSize = 'windowSize';
+
+  static const String kDocumentAppearanceFontSize =
+      'kDocumentAppearanceFontSize';
+  static const String kDocumentAppearanceFontFamily =
+      'kDocumentAppearanceFontFamily';
 }

+ 0 - 2
frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/row_document.dart

@@ -3,7 +3,6 @@ import 'package:appflowy/plugins/database_view/grid/application/row/row_document
 import 'package:appflowy/plugins/document/application/doc_bloc.dart';
 import 'package:appflowy/plugins/document/presentation/editor_page.dart';
 import 'package:appflowy/plugins/document/presentation/editor_style.dart';
-import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart';
 import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
 import 'package:easy_localization/easy_localization.dart';
 import 'package:flowy_infra_ui/widget/error_page.dart';
@@ -86,7 +85,6 @@ class _RowEditorState extends State<RowEditor> {
   Widget build(BuildContext context) {
     return MultiBlocProvider(
       providers: [
-        BlocProvider(create: (_) => DocumentAppearanceCubit()),
         BlocProvider.value(value: documentBloc),
       ],
       child: BlocBuilder<DocumentBloc, DocumentState>(

+ 9 - 27
frontend/appflowy_flutter/lib/plugins/document/document.dart

@@ -39,8 +39,6 @@ class DocumentPluginBuilder extends PluginBuilder {
 
 class DocumentPlugin extends Plugin<int> {
   late PluginType _pluginType;
-  final DocumentAppearanceCubit _documentAppearanceCubit =
-      DocumentAppearanceCubit();
 
   @override
   final ViewPluginNotifier notifier;
@@ -52,20 +50,12 @@ class DocumentPlugin extends Plugin<int> {
     Key? key,
   }) : notifier = ViewPluginNotifier(view: view) {
     _pluginType = pluginType;
-    _documentAppearanceCubit.fetch();
-  }
-
-  @override
-  void dispose() {
-    _documentAppearanceCubit.close();
-    super.dispose();
   }
 
   @override
   PluginWidgetBuilder get widgetBuilder {
     return DocumentPluginWidgetBuilder(
       notifier: notifier,
-      documentAppearanceCubit: _documentAppearanceCubit,
     );
   }
 
@@ -81,11 +71,9 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder
   final ViewPluginNotifier notifier;
   ViewPB get view => notifier.view;
   int? deletedViewIndex;
-  DocumentAppearanceCubit documentAppearanceCubit;
 
   DocumentPluginWidgetBuilder({
     required this.notifier,
-    required this.documentAppearanceCubit,
     Key? key,
   });
 
@@ -102,17 +90,14 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder
       });
     });
 
-    return BlocProvider.value(
-      value: documentAppearanceCubit,
-      child: BlocBuilder<DocumentAppearanceCubit, DocumentAppearance>(
-        builder: (_, state) {
-          return DocumentPage(
-            view: view,
-            onDeleted: () => context?.onDeleted(view, deletedViewIndex),
-            key: ValueKey(view.id),
-          );
-        },
-      ),
+    return BlocBuilder<DocumentAppearanceCubit, DocumentAppearance>(
+      builder: (_, state) {
+        return DocumentPage(
+          view: view,
+          onDeleted: () => context?.onDeleted(view, deletedViewIndex),
+          key: ValueKey(view.id),
+        );
+      },
     );
   }
 
@@ -128,10 +113,7 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder
           view: view,
         ),
         const SizedBox(width: 10),
-        BlocProvider.value(
-          value: documentAppearanceCubit,
-          child: const DocumentMoreButton(),
-        ),
+        const DocumentMoreButton(),
       ],
     );
   }

+ 35 - 20
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart

@@ -28,30 +28,32 @@ class EditorStyleCustomizer {
   EditorStyle desktop() {
     final theme = Theme.of(context);
     final fontSize = context.read<DocumentAppearanceCubit>().state.fontSize;
+    final fontFamily = context.read<DocumentAppearanceCubit>().state.fontFamily;
     return EditorStyle.desktop(
       padding: padding,
       backgroundColor: theme.colorScheme.surface,
       cursorColor: theme.colorScheme.primary,
       textStyleConfiguration: TextStyleConfiguration(
-        text: TextStyle(
-          fontFamily: 'Poppins',
+        text: baseTextStyle(fontFamily).copyWith(
           fontSize: fontSize,
           color: theme.colorScheme.onBackground,
           height: 1.5,
         ),
-        bold: const TextStyle(
-          fontFamily: 'Poppins-Bold',
+        bold: baseTextStyle(fontFamily).copyWith(
           fontWeight: FontWeight.w600,
         ),
-        italic: const TextStyle(fontStyle: FontStyle.italic),
-        underline: const TextStyle(decoration: TextDecoration.underline),
-        strikethrough: const TextStyle(decoration: TextDecoration.lineThrough),
-        href: TextStyle(
+        italic: baseTextStyle(fontFamily).copyWith(fontStyle: FontStyle.italic),
+        underline: baseTextStyle(fontFamily)
+            .copyWith(decoration: TextDecoration.underline),
+        strikethrough:
+            baseTextStyle(fontFamily)
+            .copyWith(decoration: TextDecoration.lineThrough),
+        href: baseTextStyle(fontFamily).copyWith(
           color: theme.colorScheme.primary,
           decoration: TextDecoration.underline,
         ),
         code: GoogleFonts.robotoMono(
-          textStyle: TextStyle(
+          textStyle: baseTextStyle(fontFamily).copyWith(
             fontSize: fontSize,
             fontWeight: FontWeight.normal,
             color: Colors.red,
@@ -66,30 +68,33 @@ class EditorStyleCustomizer {
   EditorStyle mobile() {
     final theme = Theme.of(context);
     final fontSize = context.read<DocumentAppearanceCubit>().state.fontSize;
+    final fontFamily = context.read<DocumentAppearanceCubit>().state.fontFamily;
+
     return EditorStyle.desktop(
       padding: padding,
       backgroundColor: theme.colorScheme.surface,
       cursorColor: theme.colorScheme.primary,
       textStyleConfiguration: TextStyleConfiguration(
-        text: TextStyle(
-          fontFamily: 'poppins',
+        text: baseTextStyle(fontFamily).copyWith(
           fontSize: fontSize,
           color: theme.colorScheme.onBackground,
           height: 1.5,
         ),
-        bold: const TextStyle(
-          fontFamily: 'poppins-Bold',
+        bold: baseTextStyle(fontFamily).copyWith(
           fontWeight: FontWeight.w600,
         ),
-        italic: const TextStyle(fontStyle: FontStyle.italic),
-        underline: const TextStyle(decoration: TextDecoration.underline),
-        strikethrough: const TextStyle(decoration: TextDecoration.lineThrough),
-        href: TextStyle(
+        italic: baseTextStyle(fontFamily).copyWith(fontStyle: FontStyle.italic),
+        underline: baseTextStyle(fontFamily)
+            .copyWith(decoration: TextDecoration.underline),
+        strikethrough:
+            baseTextStyle(fontFamily)
+            .copyWith(decoration: TextDecoration.lineThrough),
+        href: baseTextStyle(fontFamily).copyWith(
           color: theme.colorScheme.primary,
           decoration: TextDecoration.underline,
         ),
         code: GoogleFonts.robotoMono(
-          textStyle: TextStyle(
+          textStyle: baseTextStyle(fontFamily).copyWith(
             fontSize: fontSize,
             fontWeight: FontWeight.normal,
             color: Colors.red,
@@ -119,8 +124,8 @@ class EditorStyleCustomizer {
   TextStyle codeBlockStyleBuilder() {
     final theme = Theme.of(context);
     final fontSize = context.read<DocumentAppearanceCubit>().state.fontSize;
-    return TextStyle(
-      fontFamily: 'poppins',
+    final fontFamily = context.read<DocumentAppearanceCubit>().state.fontFamily;
+    return baseTextStyle(fontFamily).copyWith(
       fontSize: fontSize,
       height: 1.5,
       color: theme.colorScheme.onBackground,
@@ -157,6 +162,16 @@ class EditorStyleCustomizer {
     );
   }
 
+  TextStyle baseTextStyle(String fontFamily) {
+    try {
+      return GoogleFonts.getFont(
+        fontFamily,
+      );
+    } on Exception {
+      return GoogleFonts.getFont('Poppins');
+    }
+  }
+
   InlineSpan customizeAttributeDecorator(
     TextInsert textInsert,
     TextSpan textSpan,

+ 33 - 10
frontend/appflowy_flutter/lib/plugins/document/presentation/more/cubit/document_appearance_cubit.dart

@@ -1,30 +1,37 @@
+import 'package:appflowy/core/config/kv_keys.dart';
 import 'package:bloc/bloc.dart';
 import 'package:shared_preferences/shared_preferences.dart';
 
-const String _kDocumentAppearanceFontSize = 'kDocumentAppearanceFontSize';
-
 class DocumentAppearance {
   const DocumentAppearance({
     required this.fontSize,
+    required this.fontFamily,
   });
 
   final double fontSize;
-  // Will be supported...
-  // final String fontName;
+  final String fontFamily;
 
-  DocumentAppearance copyWith({double? fontSize}) {
+  DocumentAppearance copyWith({
+    double? fontSize,
+    String? fontFamily,
+  }) {
     return DocumentAppearance(
       fontSize: fontSize ?? this.fontSize,
+      fontFamily: fontFamily ?? this.fontFamily,
     );
   }
 }
 
 class DocumentAppearanceCubit extends Cubit<DocumentAppearance> {
-  DocumentAppearanceCubit() : super(const DocumentAppearance(fontSize: 16.0));
+  DocumentAppearanceCubit()
+      : super(const DocumentAppearance(fontSize: 16.0, fontFamily: 'Poppins'));
 
-  void fetch() async {
+  Future<void> fetch() async {
     final prefs = await SharedPreferences.getInstance();
-    final fontSize = prefs.getDouble(_kDocumentAppearanceFontSize) ?? 16.0;
+    final fontSize =
+        prefs.getDouble(KVKeys.kDocumentAppearanceFontSize) ?? 16.0;
+    final fontFamily =
+        prefs.getString(KVKeys.kDocumentAppearanceFontFamily) ?? 'Poppins';
 
     if (isClosed) {
       return;
@@ -33,13 +40,14 @@ class DocumentAppearanceCubit extends Cubit<DocumentAppearance> {
     emit(
       state.copyWith(
         fontSize: fontSize,
+        fontFamily: fontFamily,
       ),
     );
   }
 
-  void syncFontSize(double fontSize) async {
+  Future<void> syncFontSize(double fontSize) async {
     final prefs = await SharedPreferences.getInstance();
-    prefs.setDouble(_kDocumentAppearanceFontSize, fontSize);
+    prefs.setDouble(KVKeys.kDocumentAppearanceFontSize, fontSize);
 
     if (isClosed) {
       return;
@@ -51,4 +59,19 @@ class DocumentAppearanceCubit extends Cubit<DocumentAppearance> {
       ),
     );
   }
+
+  Future<void> syncFontFamily(String fontFamily) async {
+    final prefs = await SharedPreferences.getInstance();
+    prefs.setString(KVKeys.kDocumentAppearanceFontFamily, fontFamily);
+
+    if (isClosed) {
+      return;
+    }
+
+    emit(
+      state.copyWith(
+        fontFamily: fontFamily,
+      ),
+    );
+  }
 }

+ 8 - 2
frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart

@@ -1,3 +1,4 @@
+import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart';
 import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
 import 'package:easy_localization/easy_localization.dart';
 import 'package:flowy_infra_ui/flowy_infra_ui.dart';
@@ -79,8 +80,13 @@ class ApplicationWidget extends StatelessWidget {
     final cubit = AppearanceSettingsCubit(appearanceSetting)
       ..readLocaleWhenAppLaunch(context);
 
-    return BlocProvider(
-      create: (context) => cubit,
+    return MultiBlocProvider(
+      providers: [
+        BlocProvider.value(value: cubit),
+        BlocProvider<DocumentAppearanceCubit>(
+          create: (_) => DocumentAppearanceCubit()..fetch(),
+        ),
+      ],
       child: BlocBuilder<AppearanceSettingsCubit, AppearanceSettingsState>(
         builder: (context, state) => MaterialApp(
           builder: overlayManagerBuilder(),

+ 24 - 3
frontend/appflowy_flutter/lib/workspace/application/appearance.dart

@@ -10,6 +10,7 @@ import 'package:flowy_infra/theme_extension.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_bloc/flutter_bloc.dart';
 import 'package:freezed_annotation/freezed_annotation.dart';
+import 'package:google_fonts/google_fonts.dart';
 
 part 'appearance.freezed.dart';
 
@@ -49,6 +50,14 @@ class AppearanceSettingsCubit extends Cubit<AppearanceSettingsState> {
     emit(state.copyWith(themeMode: themeMode));
   }
 
+  /// Update selected font in the user's settings and emit an updated state
+  /// with the font name.
+  void setFontFamily(String fontFamilyName) {
+    _setting.font = fontFamilyName;
+    _saveAppearanceSettings();
+    emit(state.copyWith(font: fontFamilyName));
+  }
+
   /// 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) {
@@ -341,14 +350,24 @@ class AppearanceSettingsState with _$AppearanceSettingsState {
   }
 
   TextStyle _getFontStyle({
-    String? fontFamily,
+    required String fontFamily,
     double? fontSize,
     FontWeight? fontWeight,
     Color? fontColor,
     double? letterSpacing,
     double? lineHeight,
-  }) =>
-      TextStyle(
+  }) {
+    try {
+      return GoogleFonts.getFont(
+        fontFamily,
+        fontSize: fontSize ?? FontSizes.s12,
+        color: fontColor,
+        fontWeight: fontWeight ?? FontWeight.w500,
+        letterSpacing: (fontSize ?? FontSizes.s12) * (letterSpacing ?? 0.005),
+        height: lineHeight,
+      );
+    } catch (e) {
+      return TextStyle(
         fontFamily: fontFamily,
         fontSize: fontSize ?? FontSizes.s12,
         color: fontColor,
@@ -357,6 +376,8 @@ class AppearanceSettingsState with _$AppearanceSettingsState {
         letterSpacing: (fontSize ?? FontSizes.s12) * (letterSpacing ?? 0.005),
         height: lineHeight,
       );
+    }
+  }
 
   TextTheme _getTextTheme({
     required String fontFamily,

+ 5 - 1
frontend/appflowy_flutter/lib/workspace/presentation/home/menu/menu_user.dart

@@ -1,3 +1,4 @@
+import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart';
 import 'package:appflowy/startup/startup.dart';
 import 'package:appflowy/util/color_generator/color_generator.dart';
 import 'package:appflowy/workspace/application/menu/menu_user_bloc.dart';
@@ -103,7 +104,10 @@ class MenuUser extends StatelessWidget {
           showDialog(
             context: context,
             builder: (context) {
-              return SettingsDialog(userProfile);
+              return BlocProvider<DocumentAppearanceCubit>.value(
+                value: BlocProvider.of<DocumentAppearanceCubit>(context),
+                child: SettingsDialog(userProfile),
+              );
             },
           );
         },

+ 26 - 0
frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/levenshtein.dart

@@ -0,0 +1,26 @@
+import 'dart:math';
+
+int levenshtein(String s, String t, {bool caseSensitive = true}) {
+  if (!caseSensitive) {
+    s = s.toLowerCase();
+    t = t.toLowerCase();
+  }
+
+  if (s == t) return 0;
+
+  final v0 = List<int>.generate(t.length + 1, (i) => i);
+  final v1 = List<int>.filled(t.length + 1, 0);
+
+  for (var i = 0; i < s.length; i++) {
+    v1[0] = i + 1;
+
+    for (var j = 0; j < t.length; j++) {
+      final cost = (s[i] == t[j]) ? 0 : 1;
+      v1[j + 1] = min(v1[j] + 1, min(v0[j + 1] + 1, v0[j] + cost));
+    }
+
+    v0.setAll(0, v1);
+  }
+
+  return v1[t.length];
+}

+ 177 - 30
frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance_view.dart

@@ -1,8 +1,10 @@
 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:appflowy/workspace/presentation/settings/widgets/theme_upload/theme_upload_view.dart';
 import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
 import 'package:appflowy_popover/appflowy_popover.dart';
+import 'package:collection/collection.dart';
 import 'package:easy_localization/easy_localization.dart';
 import 'package:flowy_infra/image.dart';
 import 'package:flowy_infra/plugins/bloc/dynamic_plugin_bloc.dart';
@@ -12,6 +14,9 @@ import 'package:flowy_infra/theme.dart';
 import 'package:flowy_infra_ui/flowy_infra_ui.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:google_fonts/google_fonts.dart';
+
+import 'levenshtein.dart';
 
 class SettingsAppearanceView extends StatelessWidget {
   const SettingsAppearanceView({Key? key}) : super(key: key);
@@ -31,6 +36,9 @@ class SettingsAppearanceView extends StatelessWidget {
                   currentTheme: state.appTheme.themeName,
                   bloc: context.read<DynamicPluginBloc>(),
                 ),
+                ThemeFontFamilySetting(
+                  currentFontFamily: state.font,
+                ),
               ],
             );
           },
@@ -209,36 +217,17 @@ class BrightnessSetting extends StatelessWidget {
 
   @override
   Widget build(BuildContext context) {
-    return Row(
-      children: [
-        Expanded(
-          child: FlowyText.medium(
-            LocaleKeys.settings_appearance_themeMode_label.tr(),
-            overflow: TextOverflow.ellipsis,
-          ),
-        ),
-        AppFlowyPopover(
-          direction: PopoverDirection.bottomWithRightAligned,
-          child: FlowyTextButton(
-            _themeModeLabelText(currentThemeMode),
-            fontColor: Theme.of(context).colorScheme.onBackground,
-            fillColor: Colors.transparent,
-            onPressed: () {},
-          ),
-          popupBuilder: (BuildContext context) {
-            return IntrinsicWidth(
-              child: Column(
-                mainAxisSize: MainAxisSize.min,
-                children: [
-                  _themeModeItemButton(context, ThemeMode.light),
-                  _themeModeItemButton(context, ThemeMode.dark),
-                  _themeModeItemButton(context, ThemeMode.system),
-                ],
-              ),
-            );
-          },
-        ),
-      ],
+    return ThemeSettingDropDown(
+      label: LocaleKeys.settings_appearance_themeMode_label.tr(),
+      currentValue: _themeModeLabelText(currentThemeMode),
+      popupBuilder: (_) => Column(
+        mainAxisSize: MainAxisSize.min,
+        children: [
+          _themeModeItemButton(context, ThemeMode.light),
+          _themeModeItemButton(context, ThemeMode.dark),
+          _themeModeItemButton(context, ThemeMode.system),
+        ],
+      ),
     );
   }
 
@@ -272,3 +261,161 @@ class BrightnessSetting extends StatelessWidget {
     }
   }
 }
+
+class ThemeFontFamilySetting extends StatefulWidget {
+  const ThemeFontFamilySetting({
+    super.key,
+    required this.currentFontFamily,
+  });
+
+  final String currentFontFamily;
+
+  @override
+  State<ThemeFontFamilySetting> createState() => _ThemeFontFamilySettingState();
+}
+
+class _ThemeFontFamilySettingState extends State<ThemeFontFamilySetting> {
+  final List<String> availableFonts = GoogleFonts.asMap().keys.toList();
+  final ValueNotifier<String> query = ValueNotifier('');
+
+  @override
+  Widget build(BuildContext context) {
+    return ThemeSettingDropDown(
+      label: LocaleKeys.settings_appearance_fontFamily_label.tr(),
+      currentValue: parseFontFamilyName(widget.currentFontFamily),
+      onClose: () {
+        query.value = '';
+      },
+      popupBuilder: (_) => CustomScrollView(
+        shrinkWrap: true,
+        slivers: [
+          SliverPadding(
+            padding: const EdgeInsets.only(right: 8),
+            sliver: SliverToBoxAdapter(
+              child: FlowyTextField(
+                hintText: LocaleKeys.settings_appearance_fontFamily_search.tr(),
+                autoFocus: false,
+                debounceDuration: const Duration(milliseconds: 300),
+                onChanged: (value) {
+                  query.value = value;
+                },
+              ),
+            ),
+          ),
+          const SliverToBoxAdapter(
+            child: SizedBox(height: 4),
+          ),
+          ValueListenableBuilder(
+            valueListenable: query,
+            builder: (context, value, child) {
+              var displayed = availableFonts;
+              if (value.isNotEmpty) {
+                displayed = availableFonts
+                    .where(
+                      (font) => font
+                          .toLowerCase()
+                          .contains(value.toLowerCase().toString()),
+                    )
+                    .sorted((a, b) => levenshtein(a, b))
+                    .toList();
+              }
+              return SliverFixedExtentList.builder(
+                itemBuilder: (context, index) => _fontFamilyItemButton(
+                  context,
+                  GoogleFonts.getFont(displayed[index]),
+                ),
+                itemCount: displayed.length,
+                itemExtent: 32,
+              );
+            },
+          ),
+        ],
+      ),
+    );
+  }
+
+  String parseFontFamilyName(String fontFamilyName) {
+    final camelCase = RegExp('(?<=[a-z])[A-Z]');
+    return fontFamilyName
+        .replaceAll('_regular', '')
+        .replaceAllMapped(camelCase, (m) => ' ${m.group(0)}');
+  }
+
+  Widget _fontFamilyItemButton(BuildContext context, TextStyle style) {
+    final buttonFontFamily = parseFontFamilyName(style.fontFamily!);
+    return SizedBox(
+      key: UniqueKey(),
+      height: 32,
+      child: FlowyButton(
+        text: FlowyText.medium(
+          parseFontFamilyName(style.fontFamily!),
+          fontFamily: style.fontFamily!,
+        ),
+        rightIcon:
+            buttonFontFamily == parseFontFamilyName(widget.currentFontFamily)
+                ? const FlowySvg(name: 'grid/checkmark')
+                : null,
+        onTap: () {
+          if (parseFontFamilyName(widget.currentFontFamily) !=
+              buttonFontFamily) {
+            context
+                .read<AppearanceSettingsCubit>()
+                .setFontFamily(parseFontFamilyName(style.fontFamily!));
+            context
+                .read<DocumentAppearanceCubit>()
+                .syncFontFamily(parseFontFamilyName(style.fontFamily!));
+          }
+        },
+      ),
+    );
+  }
+}
+
+class ThemeSettingDropDown extends StatefulWidget {
+  const ThemeSettingDropDown({
+    super.key,
+    required this.label,
+    required this.currentValue,
+    required this.popupBuilder,
+    this.onClose,
+  });
+
+  final String label;
+  final String currentValue;
+  final Widget Function(BuildContext) popupBuilder;
+  final void Function()? onClose;
+
+  @override
+  State<ThemeSettingDropDown> createState() => _ThemeSettingDropDownState();
+}
+
+class _ThemeSettingDropDownState extends State<ThemeSettingDropDown> {
+  @override
+  Widget build(BuildContext context) {
+    return Row(
+      children: [
+        Expanded(
+          child: FlowyText.medium(
+            widget.label,
+            overflow: TextOverflow.ellipsis,
+          ),
+        ),
+        AppFlowyPopover(
+          direction: PopoverDirection.bottomWithRightAligned,
+          popupBuilder: widget.popupBuilder,
+          constraints: const BoxConstraints(
+            minWidth: 80,
+            maxWidth: 160,
+            maxHeight: 400,
+          ),
+          onClose: widget.onClose,
+          child: FlowyTextButton(
+            widget.currentValue,
+            fontColor: Theme.of(context).colorScheme.onBackground,
+            fillColor: Colors.transparent,
+          ),
+        ),
+      ],
+    );
+  }
+}

+ 63 - 0
frontend/appflowy_flutter/test/bloc_test/app_setting_test/document_appearance_test.dart

@@ -0,0 +1,63 @@
+import 'package:appflowy/core/config/kv_keys.dart';
+import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart';
+import 'package:flutter/widgets.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+void main() {
+  WidgetsFlutterBinding.ensureInitialized();
+  group('DocumentAppearanceCubit', () {
+    late SharedPreferences preferences;
+    late DocumentAppearanceCubit cubit;
+
+    setUpAll(() async {
+      SharedPreferences.setMockInitialValues({});
+    });
+
+    setUp(() async {
+      preferences = await SharedPreferences.getInstance();
+      cubit = DocumentAppearanceCubit();
+    });
+
+    tearDown(() async {
+      await preferences.clear();
+      await cubit.close();
+    });
+
+    test('Initial state', () {
+      expect(cubit.state.fontSize, 16.0);
+      expect(cubit.state.fontFamily, 'Poppins');
+    });
+
+    test('Fetch document appearance from SharedPreferences', () async {
+      await preferences.setDouble(KVKeys.kDocumentAppearanceFontSize, 18.0);
+      await preferences.setString(
+        KVKeys.kDocumentAppearanceFontFamily,
+        'Arial',
+      );
+
+      await cubit.fetch();
+
+      expect(cubit.state.fontSize, 18.0);
+      expect(cubit.state.fontFamily, 'Arial');
+    });
+
+    test('Sync font size to SharedPreferences', () async {
+      await cubit.syncFontSize(20.0);
+
+      final fontSize =
+          preferences.getDouble(KVKeys.kDocumentAppearanceFontSize);
+      expect(fontSize, 20.0);
+      expect(cubit.state.fontSize, 20.0);
+    });
+
+    test('Sync font family to SharedPreferences', () async {
+      await cubit.syncFontFamily('Helvetica');
+
+      final fontFamily =
+          preferences.getString(KVKeys.kDocumentAppearanceFontFamily);
+      expect(fontFamily, 'Helvetica');
+      expect(cubit.state.fontFamily, 'Helvetica');
+    });
+  });
+}

+ 34 - 0
frontend/appflowy_flutter/test/unit_test/algorithm/levenshtein_test.dart

@@ -0,0 +1,34 @@
+import 'package:appflowy/workspace/presentation/settings/widgets/levenshtein.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+  test('Levenshtein distance between identical strings', () {
+    final distance = levenshtein('abc', 'abc');
+    expect(distance, 0);
+  });
+
+  test('Levenshtein distance between strings of different lengths', () {
+    final distance = levenshtein('kitten', 'sitting');
+    expect(distance, 3);
+  });
+
+  test('Levenshtein distance between case-insensitive strings', () {
+    final distance = levenshtein('Hello', 'hello', caseSensitive: false);
+    expect(distance, 0);
+  });
+
+  test('Levenshtein distance between strings with substitutions', () {
+    final distance = levenshtein('kitten', 'smtten');
+    expect(distance, 2);
+  });
+
+  test('Levenshtein distance between strings with deletions', () {
+    final distance = levenshtein('kitten', 'kiten');
+    expect(distance, 1);
+  });
+
+  test('Levenshtein distance between strings with insertions', () {
+    final distance = levenshtein('kitten', 'kitxten');
+    expect(distance, 1);
+  });
+}

+ 46 - 0
frontend/appflowy_flutter/test/unit_test/editor/editor_style_test.dart

@@ -0,0 +1,46 @@
+import 'package:appflowy/plugins/document/presentation/editor_style.dart';
+import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:google_fonts/google_fonts.dart';
+import 'package:mocktail/mocktail.dart';
+
+class MockDocumentAppearanceCubit extends Mock
+    implements DocumentAppearanceCubit {}
+
+class MockBuildContext extends Mock implements BuildContext {}
+
+void main() {
+  WidgetsFlutterBinding.ensureInitialized();
+  group('EditorStyleCustomizer', () {
+    late EditorStyleCustomizer editorStyleCustomizer;
+    late MockBuildContext mockBuildContext;
+
+    setUp(() {
+      mockBuildContext = MockBuildContext();
+      editorStyleCustomizer = EditorStyleCustomizer(
+        context: mockBuildContext,
+        padding: EdgeInsets.zero,
+      );
+    });
+
+    test('baseTextStyle should return the expected TextStyle', () {
+      const fontFamily = 'Roboto';
+      final result = editorStyleCustomizer.baseTextStyle(fontFamily);
+      expect(result, isA<TextStyle>());
+      expect(result.fontFamily, 'Roboto_regular');
+    });
+
+    test(
+        'baseTextStyle should return the default TextStyle when an exception occurs',
+        () {
+      const garbage = 'Garbage';
+      final result = editorStyleCustomizer.baseTextStyle(garbage);
+      expect(result, isA<TextStyle>());
+      expect(
+        result.fontFamily,
+        GoogleFonts.getFont('Poppins').fontFamily,
+      );
+    });
+  });
+}

+ 92 - 0
frontend/appflowy_flutter/test/widget_test/theme_font_family_setting_test.dart

@@ -0,0 +1,92 @@
+import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart';
+import 'package:appflowy/workspace/application/appearance.dart';
+import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance_view.dart';
+import 'package:flowy_infra_ui/flowy_infra_ui.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mocktail/mocktail.dart';
+
+class MockAppearanceSettingsCubit extends Mock
+    implements AppearanceSettingsCubit {}
+
+class MockDocumentAppearanceCubit extends Mock
+    implements DocumentAppearanceCubit {}
+
+class MockAppearanceSettingsState extends Mock
+    implements AppearanceSettingsState {}
+
+class MockDocumentAppearance extends Mock implements DocumentAppearance {}
+
+void main() {
+  late MockAppearanceSettingsCubit appearanceSettingsCubit;
+  late MockDocumentAppearanceCubit documentAppearanceCubit;
+
+  setUp(() {
+    appearanceSettingsCubit = MockAppearanceSettingsCubit();
+    when(() => appearanceSettingsCubit.stream).thenAnswer(
+      (_) => Stream.fromIterable([MockAppearanceSettingsState()]),
+    );
+    documentAppearanceCubit = MockDocumentAppearanceCubit();
+    when(() => documentAppearanceCubit.stream).thenAnswer(
+      (_) => Stream.fromIterable([MockDocumentAppearance()]),
+    );
+  });
+
+  testWidgets('ThemeFontFamilySetting updates font family on selection',
+      (WidgetTester tester) async {
+    await tester.pumpWidget(
+      MultiBlocProvider(
+        providers: [
+          BlocProvider<AppearanceSettingsCubit>.value(
+            value: appearanceSettingsCubit,
+          ),
+          BlocProvider<DocumentAppearanceCubit>.value(
+            value: documentAppearanceCubit,
+          ),
+        ],
+        child: MaterialApp(
+          home: MultiBlocProvider(
+            providers: [
+              BlocProvider<AppearanceSettingsCubit>.value(
+                value: appearanceSettingsCubit,
+              ),
+              BlocProvider<DocumentAppearanceCubit>.value(
+                value: documentAppearanceCubit,
+              ),
+            ],
+            child: const Scaffold(
+              body: ThemeFontFamilySetting(
+                currentFontFamily: 'Poppins',
+              ),
+            ),
+          ),
+        ),
+      ),
+    );
+
+    final popover = find.byType(AppFlowyPopover);
+    await tester.tap(popover);
+    await tester.pumpAndSettle();
+
+    // Verify the initial font family
+    expect(find.text('Poppins'), findsAtLeastNWidgets(1));
+    when(() => appearanceSettingsCubit.setFontFamily(any<String>()))
+        .thenAnswer((_) async {});
+    verifyNever(() => appearanceSettingsCubit.setFontFamily(any<String>()));
+    when(() => documentAppearanceCubit.syncFontFamily(any<String>()))
+        .thenAnswer((_) async {});
+    verifyNever(() => documentAppearanceCubit.syncFontFamily(any<String>()));
+
+    // Tap on a different font family
+    final abel = find.textContaining('Abel');
+    await tester.tap(abel);
+    await tester.pumpAndSettle();
+
+    // Verify that the font family is updated
+    verify(() => appearanceSettingsCubit.setFontFamily(any<String>()))
+        .called(1);
+    verify(() => documentAppearanceCubit.syncFontFamily(any<String>()))
+        .called(1);
+  });
+}