From 323cb3b60f1ba7bdb8235ceed7eaa42217cd3471 Mon Sep 17 00:00:00 2001 From: Alex Wallen Date: Tue, 4 Jul 2023 14:30:38 -0700 Subject: [PATCH] [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 * 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 * fix: avoid using intrinsic height Co-authored-by: Yijing Huang * fix: extra paren caused build failure * feat: switch order of font family setting --------- Co-authored-by: Yijing Huang --- .../assets/translations/en.json | 4 + .../lib/core/config/kv_keys.dart | 5 + .../widgets/row/row_document.dart | 2 - .../lib/plugins/document/document.dart | 36 +-- .../document/presentation/editor_style.dart | 55 +++-- .../more/cubit/document_appearance_cubit.dart | 45 +++- .../lib/startup/tasks/app_widget.dart | 10 +- .../lib/workspace/application/appearance.dart | 27 ++- .../presentation/home/menu/menu_user.dart | 6 +- .../settings/widgets/levenshtein.dart | 26 +++ .../widgets/settings_appearance_view.dart | 207 +++++++++++++++--- .../document_appearance_test.dart | 63 ++++++ .../unit_test/algorithm/levenshtein_test.dart | 34 +++ .../unit_test/editor/editor_style_test.dart | 46 ++++ .../theme_font_family_setting_test.dart | 92 ++++++++ 15 files changed, 562 insertions(+), 96 deletions(-) create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/levenshtein.dart create mode 100644 frontend/appflowy_flutter/test/bloc_test/app_setting_test/document_appearance_test.dart create mode 100644 frontend/appflowy_flutter/test/unit_test/algorithm/levenshtein_test.dart create mode 100644 frontend/appflowy_flutter/test/unit_test/editor/editor_style_test.dart create mode 100644 frontend/appflowy_flutter/test/widget_test/theme_font_family_setting_test.dart diff --git a/frontend/appflowy_flutter/assets/translations/en.json b/frontend/appflowy_flutter/assets/translations/en.json index 4097f67c37..fb885a1f6c 100644 --- a/frontend/appflowy_flutter/assets/translations/en.json +++ b/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", diff --git a/frontend/appflowy_flutter/lib/core/config/kv_keys.dart b/frontend/appflowy_flutter/lib/core/config/kv_keys.dart index 9962980e50..e6a21910ea 100644 --- a/frontend/appflowy_flutter/lib/core/config/kv_keys.dart +++ b/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'; } diff --git a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/row_document.dart b/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/row_document.dart index 20ad2bc402..9aa9c2c10a 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_view/widgets/row/row_document.dart +++ b/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 { Widget build(BuildContext context) { return MultiBlocProvider( providers: [ - BlocProvider(create: (_) => DocumentAppearanceCubit()), BlocProvider.value(value: documentBloc), ], child: BlocBuilder( diff --git a/frontend/appflowy_flutter/lib/plugins/document/document.dart b/frontend/appflowy_flutter/lib/plugins/document/document.dart index f1ae0ca431..85b0de6602 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/document.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/document.dart @@ -39,8 +39,6 @@ class DocumentPluginBuilder extends PluginBuilder { class DocumentPlugin extends Plugin { late PluginType _pluginType; - final DocumentAppearanceCubit _documentAppearanceCubit = - DocumentAppearanceCubit(); @override final ViewPluginNotifier notifier; @@ -52,20 +50,12 @@ class DocumentPlugin extends Plugin { 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( - builder: (_, state) { - return DocumentPage( - view: view, - onDeleted: () => context?.onDeleted(view, deletedViewIndex), - key: ValueKey(view.id), - ); - }, - ), + return BlocBuilder( + 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(), ], ); } 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 c2c0b34094..a52589c735 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart +++ b/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().state.fontSize; + final fontFamily = context.read().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().state.fontSize; + final fontFamily = context.read().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().state.fontSize; - return TextStyle( - fontFamily: 'poppins', + final fontFamily = context.read().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, 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 df27ce90fc..00ba3d90bd 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,30 +1,53 @@ +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 { - DocumentAppearanceCubit() : super(const DocumentAppearance(fontSize: 16.0)); + DocumentAppearanceCubit() + : super(const DocumentAppearance(fontSize: 16.0, fontFamily: 'Poppins')); - void fetch() async { + Future 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; + } + + emit( + state.copyWith( + fontSize: fontSize, + fontFamily: fontFamily, + ), + ); + } + + Future syncFontSize(double fontSize) async { + final prefs = await SharedPreferences.getInstance(); + prefs.setDouble(KVKeys.kDocumentAppearanceFontSize, fontSize); if (isClosed) { return; @@ -37,9 +60,9 @@ class DocumentAppearanceCubit extends Cubit { ); } - void syncFontSize(double fontSize) async { + Future syncFontFamily(String fontFamily) async { final prefs = await SharedPreferences.getInstance(); - prefs.setDouble(_kDocumentAppearanceFontSize, fontSize); + prefs.setString(KVKeys.kDocumentAppearanceFontFamily, fontFamily); if (isClosed) { return; @@ -47,7 +70,7 @@ class DocumentAppearanceCubit extends Cubit { emit( state.copyWith( - fontSize: fontSize, + fontFamily: fontFamily, ), ); } diff --git a/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart b/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart index c08a60dd2c..5c4ea62276 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart +++ b/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( + create: (_) => DocumentAppearanceCubit()..fetch(), + ), + ], child: BlocBuilder( builder: (context, state) => MaterialApp( builder: overlayManagerBuilder(), diff --git a/frontend/appflowy_flutter/lib/workspace/application/appearance.dart b/frontend/appflowy_flutter/lib/workspace/application/appearance.dart index a37d3e18a0..173becca93 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/appearance.dart +++ b/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 { 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, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/menu_user.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/menu_user.dart index 25f0f58bfc..65182ce873 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/menu_user.dart +++ b/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.value( + value: BlocProvider.of(context), + child: SettingsDialog(userProfile), + ); }, ); }, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/levenshtein.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/levenshtein.dart new file mode 100644 index 0000000000..b8b8eafecc --- /dev/null +++ b/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.generate(t.length + 1, (i) => i); + final v1 = List.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]; +} 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 7aeac6de22..bc26b4c00f 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,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(), ), + 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 createState() => _ThemeFontFamilySettingState(); +} + +class _ThemeFontFamilySettingState extends State { + final List availableFonts = GoogleFonts.asMap().keys.toList(); + final ValueNotifier 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() + .setFontFamily(parseFontFamilyName(style.fontFamily!)); + context + .read() + .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 createState() => _ThemeSettingDropDownState(); +} + +class _ThemeSettingDropDownState extends State { + @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, + ), + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/test/bloc_test/app_setting_test/document_appearance_test.dart b/frontend/appflowy_flutter/test/bloc_test/app_setting_test/document_appearance_test.dart new file mode 100644 index 0000000000..ccece67e93 --- /dev/null +++ b/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'); + }); + }); +} diff --git a/frontend/appflowy_flutter/test/unit_test/algorithm/levenshtein_test.dart b/frontend/appflowy_flutter/test/unit_test/algorithm/levenshtein_test.dart new file mode 100644 index 0000000000..d4de02391a --- /dev/null +++ b/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); + }); +} diff --git a/frontend/appflowy_flutter/test/unit_test/editor/editor_style_test.dart b/frontend/appflowy_flutter/test/unit_test/editor/editor_style_test.dart new file mode 100644 index 0000000000..6d0c65da60 --- /dev/null +++ b/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()); + 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()); + expect( + result.fontFamily, + GoogleFonts.getFont('Poppins').fontFamily, + ); + }); + }); +} diff --git a/frontend/appflowy_flutter/test/widget_test/theme_font_family_setting_test.dart b/frontend/appflowy_flutter/test/widget_test/theme_font_family_setting_test.dart new file mode 100644 index 0000000000..9df4f36306 --- /dev/null +++ b/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.value( + value: appearanceSettingsCubit, + ), + BlocProvider.value( + value: documentAppearanceCubit, + ), + ], + child: MaterialApp( + home: MultiBlocProvider( + providers: [ + BlocProvider.value( + value: appearanceSettingsCubit, + ), + BlocProvider.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())) + .thenAnswer((_) async {}); + verifyNever(() => appearanceSettingsCubit.setFontFamily(any())); + when(() => documentAppearanceCubit.syncFontFamily(any())) + .thenAnswer((_) async {}); + verifyNever(() => documentAppearanceCubit.syncFontFamily(any())); + + // 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())) + .called(1); + verify(() => documentAppearanceCubit.syncFontFamily(any())) + .called(1); + }); +}