Переглянути джерело

Reset theme buttons (#3137)

* feat: add translation for tooltip

* feat: add defaults for theme settings

* feat: refactor appearance view so that all theme settings can be reset

* chore: add keys for reset button

* chore: add tests for the reset button

* feat: register appearance test in runner

* chore: remove comment

* feat: add default file for appearance

* chore: move around files

* feat: make reset button respond to hover

* fix: incorrect use of resetTheme

* refactor: use maybeWhen

* fix: icon button style

* fix: rebase errors
Alex Wallen 1 рік тому
батько
коміт
a1f1559936
14 змінених файлів з 575 додано та 416 видалено
  1. 44 1
      frontend/appflowy_flutter/integration_test/appearance_settings_test.dart
  2. 4 0
      frontend/appflowy_flutter/integration_test/runner.dart
  3. 13 0
      frontend/appflowy_flutter/lib/workspace/application/appearance.dart
  4. 10 0
      frontend/appflowy_flutter/lib/workspace/application/appearance_defaults.dart
  5. 86 0
      frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/brightness_setting.dart
  6. 175 0
      frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/color_scheme.dart
  7. 144 0
      frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/font_family_setting.dart
  8. 0 0
      frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/levenshtein.dart
  9. 3 0
      frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/settings_appearance.dart
  10. 89 0
      frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/theme_setting_entry_template.dart
  11. 4 413
      frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance_view.dart
  12. 1 1
      frontend/appflowy_flutter/test/unit_test/algorithm/levenshtein_test.dart
  13. 1 1
      frontend/appflowy_flutter/test/widget_test/theme_font_family_setting_test.dart
  14. 1 0
      frontend/resources/translations/en.json

+ 44 - 1
frontend/appflowy_flutter/integration_test/appearance_settings_test.dart

@@ -1,5 +1,6 @@
+import 'package:appflowy/workspace/application/appearance_defaults.dart';
 import 'package:appflowy/workspace/application/settings/prelude.dart';
-import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance_view.dart';
+import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/settings_appearance.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_test/flutter_test.dart';
 import 'package:integration_test/integration_test.dart';
@@ -41,5 +42,47 @@ void main() {
 
       expect(find.textContaining('Abel'), findsOneWidget);
     });
+
+    testWidgets('reset the font family', (tester) async {
+      await tester.initializeAppFlowy();
+
+      await tester.tapGoButton();
+      tester.expectToSeeHomePage();
+      await tester.openSettings();
+
+      await tester.openSettingsPage(SettingsPage.appearance);
+
+      final dropDown = find.byKey(ThemeFontFamilySetting.popoverKey);
+      await tester.tap(dropDown);
+      await tester.pumpAndSettle();
+
+      final textField = find.byKey(ThemeFontFamilySetting.textFieldKey);
+      await tester.tap(textField);
+      await tester.pumpAndSettle();
+
+      await tester.enterText(textField, 'Abel');
+      await tester.pumpAndSettle();
+      final fontFamilyButton = find.byKey(const Key('Abel'));
+
+      expect(fontFamilyButton, findsOneWidget);
+      await tester.tap(fontFamilyButton);
+      await tester.pumpAndSettle();
+
+      // just switch the page and verify that the font family was set after that
+      await tester.openSettingsPage(SettingsPage.files);
+      await tester.openSettingsPage(SettingsPage.appearance);
+
+      final resetButton = find.byKey(ThemeFontFamilySetting.resetButtonkey);
+      await tester.tap(resetButton);
+      await tester.pumpAndSettle();
+
+      // just switch the page and verify that the font family was set after that
+      await tester.openSettingsPage(SettingsPage.files);
+      await tester.openSettingsPage(SettingsPage.appearance);
+
+      expect(find.textContaining(DefaultAppearanceSettings.kDefaultFontFamily),
+        findsOneWidget,
+      );
+    });
   });
 }

+ 4 - 0
frontend/appflowy_flutter/integration_test/runner.dart

@@ -18,6 +18,7 @@ import 'sidebar/sidebar_test_runner.dart' as sidebar_test_runner;
 import 'board/board_test_runner.dart' as board_test_runner;
 import 'tabs_test.dart' as tabs_test;
 import 'hotkeys_test.dart' as hotkeys_test;
+import 'appearance_settings_test.dart' as appearance_test_runner;
 
 /// The main task runner for all integration tests in AppFlowy.
 ///
@@ -59,6 +60,9 @@ void main() {
   // Others
   hotkeys_test.main();
 
+  // Appearance integration test
+  appearance_test_runner.main();
+
   // board_test.main();
   // empty_document_test.main();
   // smart_menu_test.main();

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

@@ -1,6 +1,7 @@
 import 'dart:async';
 
 import 'package:appflowy/user/application/user_settings_service.dart';
+import 'package:appflowy/workspace/application/appearance_defaults.dart';
 import 'package:appflowy_backend/log.dart';
 import 'package:appflowy_backend/protobuf/flowy-user/user_setting.pb.dart';
 import 'package:easy_localization/easy_localization.dart';
@@ -43,6 +44,10 @@ class AppearanceSettingsCubit extends Cubit<AppearanceSettingsState> {
     emit(state.copyWith(appTheme: await AppTheme.fromName(themeName)));
   }
 
+  /// Reset the current user selected theme back to the default
+  Future<void> resetTheme() =>
+      setTheme(DefaultAppearanceSettings.kDefaultThemeName);
+
   /// Update the theme mode in the user's settings and emit an updated state.
   void setThemeMode(ThemeMode themeMode) {
     _setting.themeMode = _themeModeToPB(themeMode);
@@ -50,6 +55,10 @@ class AppearanceSettingsCubit extends Cubit<AppearanceSettingsState> {
     emit(state.copyWith(themeMode: themeMode));
   }
 
+  /// Resets the current brightness setting
+  void resetThemeMode() =>
+      setThemeMode(DefaultAppearanceSettings.kDefaultThemeMode);
+
   /// Toggle the theme mode
   void toggleThemeMode() {
     final currentThemeMode = state.themeMode;
@@ -66,6 +75,10 @@ class AppearanceSettingsCubit extends Cubit<AppearanceSettingsState> {
     emit(state.copyWith(font: fontFamilyName));
   }
 
+  /// Resets the current font family for the user preferences
+  void resetFontFamily() =>
+      setFontFamily(DefaultAppearanceSettings.kDefaultFontFamily);
+
   /// 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) {

+ 10 - 0
frontend/appflowy_flutter/lib/workspace/application/appearance_defaults.dart

@@ -0,0 +1,10 @@
+import 'package:flowy_infra/theme.dart';
+import 'package:flutter/material.dart';
+
+/// A class for the default appearance settings for the app
+class DefaultAppearanceSettings {
+  static const kDefaultFontFamily = 'Poppins';
+  static const kDefaultThemeMode = ThemeMode.system;
+  static const kDefaultThemeName = "Default";
+  static const kDefaultTheme = BuiltInTheme.defaultTheme;
+}

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

@@ -0,0 +1,86 @@
+import 'dart:io';
+
+import 'package:appflowy/generated/flowy_svgs.g.dart';
+import 'package:appflowy/generated/locale_keys.g.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 BrightnessSetting extends StatelessWidget {
+  final ThemeMode currentThemeMode;
+  const BrightnessSetting({required this.currentThemeMode, super.key});
+
+  @override
+  Widget build(BuildContext context) => Tooltip(
+        richMessage: themeModeTooltipTextSpan(
+          context,
+          LocaleKeys.settings_appearance_themeMode_label.tr(),
+        ),
+        child: ThemeSettingEntryTemplateWidget(
+          label: LocaleKeys.settings_appearance_themeMode_label.tr(),
+          onResetRequested:
+              context.read<AppearanceSettingsCubit>().resetThemeMode,
+          trailing: [
+            ThemeValueDropDown(
+              currentValue: _themeModeLabelText(currentThemeMode),
+              popupBuilder: (_) => Column(
+                mainAxisSize: MainAxisSize.min,
+                children: [
+                  _themeModeItemButton(context, ThemeMode.light),
+                  _themeModeItemButton(context, ThemeMode.dark),
+                  _themeModeItemButton(context, ThemeMode.system),
+                ],
+              ),
+            ),
+          ],
+        ),
+      );
+
+  TextSpan themeModeTooltipTextSpan(BuildContext context, String hintText) =>
+      TextSpan(
+        children: [
+          TextSpan(
+            text: "$hintText\n",
+          ),
+          TextSpan(
+            text: Platform.isMacOS ? "⌘+Shift+L" : "Ctrl+Shift+L",
+          ),
+        ],
+      );
+
+  Widget _themeModeItemButton(BuildContext context, ThemeMode themeMode) {
+    return SizedBox(
+      height: 32,
+      child: FlowyButton(
+        text: FlowyText.medium(_themeModeLabelText(themeMode)),
+        rightIcon: currentThemeMode == themeMode
+            ? const FlowySvg(
+                FlowySvgs.check_s,
+              )
+            : null,
+        onTap: () {
+          if (currentThemeMode != themeMode) {
+            context.read<AppearanceSettingsCubit>().setThemeMode(themeMode);
+          }
+        },
+      ),
+    );
+  }
+
+  String _themeModeLabelText(ThemeMode themeMode) {
+    switch (themeMode) {
+      case (ThemeMode.light):
+        return LocaleKeys.settings_appearance_themeMode_light.tr();
+      case (ThemeMode.dark):
+        return LocaleKeys.settings_appearance_themeMode_dark.tr();
+      case (ThemeMode.system):
+        return LocaleKeys.settings_appearance_themeMode_system.tr();
+      default:
+        return "";
+    }
+  }
+}

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

@@ -0,0 +1,175 @@
+import 'package:appflowy/generated/flowy_svgs.g.dart';
+import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:appflowy/workspace/application/appearance.dart';
+import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/theme_setting_entry_template.dart';
+import 'package:appflowy/workspace/presentation/settings/widgets/theme_upload/theme_upload_view.dart';
+import 'package:appflowy_popover/appflowy_popover.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flowy_infra/plugins/bloc/dynamic_plugin_bloc.dart';
+import 'package:flowy_infra/plugins/bloc/dynamic_plugin_event.dart';
+import 'package:flowy_infra/plugins/bloc/dynamic_plugin_state.dart';
+import 'package:flowy_infra/theme.dart';
+import 'package:flowy_infra_ui/flowy_infra_ui.dart';
+import 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+
+class ColorSchemeSetting extends StatelessWidget {
+  const ColorSchemeSetting({
+    super.key,
+    required this.currentTheme,
+    required this.bloc,
+  });
+
+  final String currentTheme;
+  final DynamicPluginBloc bloc;
+
+  @override
+  Widget build(BuildContext context) {
+    return ThemeSettingEntryTemplateWidget(
+      label: LocaleKeys.settings_appearance_theme.tr(),
+      onResetRequested: context.read<AppearanceSettingsCubit>().resetTheme,
+      trailing: [
+        ColorSchemeUploadOverlayButton(bloc: bloc),
+        const SizedBox(width: 4),
+        ColorSchemeUploadPopover(currentTheme: currentTheme, bloc: bloc),
+      ],
+    );
+  }
+}
+
+class ColorSchemeUploadOverlayButton extends StatelessWidget {
+  const ColorSchemeUploadOverlayButton({super.key, required this.bloc});
+
+  final DynamicPluginBloc bloc;
+
+  @override
+  Widget build(BuildContext context) {
+    return FlowyIconButton(
+      width: 24,
+      icon: const FlowySvg(
+        FlowySvgs.folder_m,
+      ),
+      iconColorOnHover: Theme.of(context).colorScheme.onPrimary,
+      onPressed: () => Dialogs.show(
+        context,
+        child: BlocProvider<DynamicPluginBloc>.value(
+          value: bloc,
+          child: const FlowyDialog(
+            constraints: BoxConstraints(maxHeight: 300),
+            child: ThemeUploadWidget(),
+          ),
+        ),
+      ).then((value) {
+        if (value == null) return;
+        ScaffoldMessenger.of(context).showSnackBar(
+          SnackBar(
+            content: FlowyText.medium(
+              color: Theme.of(context).colorScheme.onPrimary,
+              LocaleKeys.settings_appearance_themeUpload_uploadSuccess.tr(),
+            ),
+          ),
+        );
+      }),
+    );
+  }
+}
+
+class ColorSchemeUploadPopover extends StatelessWidget {
+  const ColorSchemeUploadPopover({
+    super.key,
+    required this.currentTheme,
+    required this.bloc,
+  });
+
+  final String currentTheme;
+  final DynamicPluginBloc bloc;
+
+  @override
+  Widget build(BuildContext context) {
+    return AppFlowyPopover(
+      direction: PopoverDirection.bottomWithRightAligned,
+      child: FlowyTextButton(
+        currentTheme,
+        fontColor: Theme.of(context).colorScheme.onBackground,
+        fillColor: Colors.transparent,
+        onPressed: () {},
+      ),
+      popupBuilder: (BuildContext context) {
+        return IntrinsicWidth(
+          child: BlocBuilder<DynamicPluginBloc, DynamicPluginState>(
+            bloc: bloc..add(DynamicPluginEvent.load()),
+            buildWhen: (previous, current) => current is Ready,
+            builder: (context, state) {
+              return state.maybeWhen(
+                ready: (plugins) => Column(
+                  mainAxisSize: MainAxisSize.min,
+                  children: [
+                    ...AppTheme.builtins
+                        .map(
+                          (theme) => _themeItemButton(context, theme.themeName),
+                        )
+                        .toList(),
+                    if (plugins.isNotEmpty) ...[
+                      const Divider(),
+                      ...plugins
+                          .map((plugin) => plugin.theme)
+                          .whereType<AppTheme>()
+                          .map(
+                            (theme) => _themeItemButton(
+                              context,
+                              theme.themeName,
+                              false,
+                            ),
+                          )
+                          .toList()
+                    ],
+                  ],
+                ),
+                orElse: () => const SizedBox.shrink(),
+              );
+            },
+          ),
+        );
+      },
+    );
+  }
+
+  Widget _themeItemButton(
+    BuildContext context,
+    String theme, [
+    bool isBuiltin = true,
+  ]) {
+    return SizedBox(
+      height: 32,
+      child: Row(
+        children: [
+          Expanded(
+            child: FlowyButton(
+              text: FlowyText.medium(theme),
+              rightIcon: currentTheme == theme
+                  ? const FlowySvg(
+                      FlowySvgs.check_s,
+                    )
+                  : null,
+              onTap: () {
+                if (currentTheme != theme) {
+                  context.read<AppearanceSettingsCubit>().setTheme(theme);
+                }
+              },
+            ),
+          ),
+          if (!isBuiltin)
+            FlowyIconButton(
+              icon: const FlowySvg(
+                FlowySvgs.close_s,
+              ),
+              width: 20,
+              onPressed: () =>
+                  bloc.add(DynamicPluginEvent.removePlugin(name: theme)),
+            )
+        ],
+      ),
+    );
+  }
+}

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

@@ -0,0 +1,144 @@
+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:appflowy/workspace/application/appearance_defaults.dart';
+import 'package:collection/collection.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 'package:google_fonts/google_fonts.dart';
+
+import 'levenshtein.dart';
+import 'theme_setting_entry_template.dart';
+
+class ThemeFontFamilySetting extends StatefulWidget {
+  const ThemeFontFamilySetting({
+    super.key,
+    required this.currentFontFamily,
+  });
+
+  final String currentFontFamily;
+  static Key textFieldKey = const Key('FontFamilyTextField');
+  static Key resetButtonkey = const Key('FontFamilyResetButton');
+  static Key popoverKey = const Key('FontFamilyPopover');
+
+  @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 ThemeSettingEntryTemplateWidget(
+      label: LocaleKeys.settings_appearance_fontFamily_label.tr(),
+      resetButtonKey: ThemeFontFamilySetting.resetButtonkey,
+      onResetRequested: () {
+        context.read<AppearanceSettingsCubit>().resetFontFamily();
+        context
+            .read<DocumentAppearanceCubit>()
+            .syncFontFamily(DefaultAppearanceSettings.kDefaultFontFamily);
+      },
+      trailing: [
+        ThemeValueDropDown(
+          popoverKey: ThemeFontFamilySetting.popoverKey,
+          currentValue: parseFontFamilyName(widget.currentFontFamily),
+          onClose: () {
+            query.value = '';
+          },
+          popupBuilder: (_) => CustomScrollView(
+            shrinkWrap: true,
+            slivers: [
+              SliverPadding(
+                padding: const EdgeInsets.only(right: 8),
+                sliver: SliverToBoxAdapter(
+                  child: FlowyTextField(
+                    key: ThemeFontFamilySetting.textFieldKey,
+                    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(
+        key: Key(buttonFontFamily),
+        onHover: (_) => FocusScope.of(context).unfocus(),
+        text: FlowyText.medium(
+          parseFontFamilyName(style.fontFamily!),
+          fontFamily: style.fontFamily!,
+        ),
+        rightIcon:
+            buttonFontFamily == parseFontFamilyName(widget.currentFontFamily)
+                ? const FlowySvg(
+                    FlowySvgs.check_s,
+                  )
+                : null,
+        onTap: () {
+          if (parseFontFamilyName(widget.currentFontFamily) !=
+              buttonFontFamily) {
+            context
+                .read<AppearanceSettingsCubit>()
+                .setFontFamily(parseFontFamilyName(style.fontFamily!));
+            context
+                .read<DocumentAppearanceCubit>()
+                .syncFontFamily(parseFontFamilyName(style.fontFamily!));
+          }
+        },
+      ),
+    );
+  }
+}

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


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

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

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

@@ -0,0 +1,89 @@
+import 'package:appflowy/generated/flowy_svgs.g.dart';
+import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:appflowy_popover/appflowy_popover.dart';
+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({
+    super.key,
+    this.resetButtonKey,
+    required this.label,
+    this.trailing,
+    this.onResetRequested,
+  });
+
+  final String label;
+  final Key? resetButtonKey;
+  final List<Widget>? trailing;
+  final void Function()? onResetRequested;
+
+  @override
+  Widget build(BuildContext context) {
+    return Row(
+      children: [
+        Expanded(
+          child: FlowyText.medium(
+            label,
+            overflow: TextOverflow.ellipsis,
+          ),
+        ),
+        if (trailing != null) ...trailing!,
+        if (onResetRequested != null)
+          FlowyIconButton(
+            hoverColor: Theme.of(context).colorScheme.secondaryContainer,
+            key: resetButtonKey,
+            width: 24,
+            icon: FlowySvg(
+              FlowySvgs.reload_s,
+              color: Theme.of(context).iconTheme.color,
+            ),
+            iconColorOnHover: Theme.of(context).colorScheme.onPrimary,
+            tooltipText: LocaleKeys.settings_appearance_resetSetting.tr(),
+            onPressed: onResetRequested,
+          ),
+      ],
+    );
+  }
+}
+
+class ThemeValueDropDown extends StatefulWidget {
+  const ThemeValueDropDown({
+    super.key,
+    required this.currentValue,
+    required this.popupBuilder,
+    this.popoverKey,
+    this.onClose,
+  });
+
+  final String currentValue;
+  final Key? popoverKey;
+  final Widget Function(BuildContext) popupBuilder;
+  final void Function()? onClose;
+
+  @override
+  State<ThemeValueDropDown> createState() => _ThemeValueDropDownState();
+}
+
+class _ThemeValueDropDownState extends State<ThemeValueDropDown> {
+  @override
+  Widget build(BuildContext context) {
+    return AppFlowyPopover(
+      key: widget.popoverKey,
+      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,
+      ),
+    );
+  }
+}

+ 4 - 413
frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance_view.dart

@@ -1,23 +1,9 @@
-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: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/plugins/bloc/dynamic_plugin_bloc.dart';
-import 'package:flowy_infra/plugins/bloc/dynamic_plugin_event.dart';
-import 'package:flowy_infra/plugins/bloc/dynamic_plugin_state.dart';
-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';
-import 'dart:io';
+import 'settings_appearance/settings_appearance.dart';
 
 class SettingsAppearanceView extends StatelessWidget {
   const SettingsAppearanceView({Key? key}) : super(key: key);
@@ -32,7 +18,9 @@ class SettingsAppearanceView extends StatelessWidget {
             return Column(
               crossAxisAlignment: CrossAxisAlignment.center,
               children: [
-                BrightnessSetting(currentThemeMode: state.themeMode),
+                BrightnessSetting(
+                  currentThemeMode: state.themeMode,
+                ),
                 ColorSchemeSetting(
                   currentTheme: state.appTheme.themeName,
                   bloc: context.read<DynamicPluginBloc>(),
@@ -48,400 +36,3 @@ class SettingsAppearanceView extends StatelessWidget {
     );
   }
 }
-
-class ColorSchemeSetting extends StatelessWidget {
-  const ColorSchemeSetting({
-    super.key,
-    required this.currentTheme,
-    required this.bloc,
-  });
-
-  final String currentTheme;
-  final DynamicPluginBloc bloc;
-
-  @override
-  Widget build(BuildContext context) {
-    return Row(
-      children: [
-        Expanded(
-          child: FlowyText.medium(
-            LocaleKeys.settings_appearance_theme.tr(),
-            overflow: TextOverflow.ellipsis,
-          ),
-        ),
-        ThemeUploadOverlayButton(bloc: bloc),
-        const SizedBox(width: 4),
-        ThemeSelectionPopover(currentTheme: currentTheme, bloc: bloc),
-      ],
-    );
-  }
-}
-
-class ThemeUploadOverlayButton extends StatelessWidget {
-  const ThemeUploadOverlayButton({super.key, required this.bloc});
-
-  final DynamicPluginBloc bloc;
-
-  @override
-  Widget build(BuildContext context) {
-    return FlowyIconButton(
-      width: 24,
-      icon: const FlowySvg(FlowySvgs.folder_m),
-      iconColorOnHover: Theme.of(context).colorScheme.onPrimary,
-      onPressed: () => Dialogs.show(
-        context,
-        child: BlocProvider<DynamicPluginBloc>.value(
-          value: bloc,
-          child: const FlowyDialog(
-            constraints: BoxConstraints(maxHeight: 300),
-            child: ThemeUploadWidget(),
-          ),
-        ),
-      ).then((value) {
-        if (value == null) return;
-        ScaffoldMessenger.of(context).showSnackBar(
-          SnackBar(
-            content: FlowyText.medium(
-              color: Theme.of(context).colorScheme.onPrimary,
-              LocaleKeys.settings_appearance_themeUpload_uploadSuccess.tr(),
-            ),
-          ),
-        );
-      }),
-    );
-  }
-}
-
-class ThemeSelectionPopover extends StatelessWidget {
-  const ThemeSelectionPopover({
-    super.key,
-    required this.currentTheme,
-    required this.bloc,
-  });
-
-  final String currentTheme;
-  final DynamicPluginBloc bloc;
-
-  @override
-  Widget build(BuildContext context) {
-    return AppFlowyPopover(
-      direction: PopoverDirection.bottomWithRightAligned,
-      child: FlowyTextButton(
-        currentTheme,
-        fontColor: Theme.of(context).colorScheme.onBackground,
-        fillColor: Colors.transparent,
-        onPressed: () {},
-      ),
-      popupBuilder: (BuildContext context) {
-        return IntrinsicWidth(
-          child: BlocBuilder<DynamicPluginBloc, DynamicPluginState>(
-            bloc: bloc..add(DynamicPluginEvent.load()),
-            buildWhen: (previous, current) => current is Ready,
-            builder: (context, state) {
-              return state.when(
-                uninitialized: () => const SizedBox.shrink(),
-                processing: () => const SizedBox.shrink(),
-                compilationFailure: (message) => const SizedBox.shrink(),
-                deletionFailure: (message) => const SizedBox.shrink(),
-                deletionSuccess: () => const SizedBox.shrink(),
-                compilationSuccess: () => const SizedBox.shrink(),
-                ready: (plugins) => Column(
-                  mainAxisSize: MainAxisSize.min,
-                  children: [
-                    ...AppTheme.builtins
-                        .map(
-                          (theme) => _themeItemButton(context, theme.themeName),
-                        )
-                        .toList(),
-                    if (plugins.isNotEmpty) ...[
-                      const Divider(),
-                      ...plugins
-                          .map((plugin) => plugin.theme)
-                          .whereType<AppTheme>()
-                          .map(
-                            (theme) => _themeItemButton(
-                              context,
-                              theme.themeName,
-                              false,
-                            ),
-                          )
-                          .toList()
-                    ],
-                  ],
-                ),
-              );
-            },
-          ),
-        );
-      },
-    );
-  }
-
-  Widget _themeItemButton(
-    BuildContext context,
-    String theme, [
-    bool isBuiltin = true,
-  ]) {
-    return SizedBox(
-      height: 32,
-      child: Row(
-        children: [
-          Expanded(
-            child: FlowyButton(
-              text: FlowyText.medium(theme),
-              rightIcon: currentTheme == theme
-                  ? const FlowySvg(FlowySvgs.check_s)
-                  : null,
-              onTap: () {
-                if (currentTheme != theme) {
-                  context.read<AppearanceSettingsCubit>().setTheme(theme);
-                }
-              },
-            ),
-          ),
-          if (!isBuiltin)
-            FlowyIconButton(
-              icon: const FlowySvg(FlowySvgs.close_m),
-              width: 20,
-              onPressed: () =>
-                  bloc.add(DynamicPluginEvent.removePlugin(name: theme)),
-            )
-        ],
-      ),
-    );
-  }
-}
-
-class BrightnessSetting extends StatelessWidget {
-  final ThemeMode currentThemeMode;
-  const BrightnessSetting({required this.currentThemeMode, super.key});
-
-  @override
-  Widget build(BuildContext context) => Tooltip(
-        richMessage: themeModeTooltipTextSpan(
-          context,
-          LocaleKeys.settings_appearance_themeMode_label.tr(),
-        ),
-        child: 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),
-            ],
-          ),
-        ),
-      );
-
-  TextSpan themeModeTooltipTextSpan(BuildContext context, String hintText) =>
-      TextSpan(
-        children: [
-          TextSpan(
-            text: "$hintText\n",
-          ),
-          TextSpan(
-            text: Platform.isMacOS ? "⌘+Shift+L" : "Ctrl+Shift+L",
-          ),
-        ],
-      );
-
-  Widget _themeModeItemButton(BuildContext context, ThemeMode themeMode) {
-    return SizedBox(
-      height: 32,
-      child: FlowyButton(
-        text: FlowyText.medium(_themeModeLabelText(themeMode)),
-        rightIcon: currentThemeMode == themeMode
-            ? const FlowySvg(FlowySvgs.check_s)
-            : null,
-        onTap: () {
-          if (currentThemeMode != themeMode) {
-            context.read<AppearanceSettingsCubit>().setThemeMode(themeMode);
-          }
-        },
-      ),
-    );
-  }
-
-  String _themeModeLabelText(ThemeMode themeMode) {
-    switch (themeMode) {
-      case (ThemeMode.light):
-        return LocaleKeys.settings_appearance_themeMode_light.tr();
-      case (ThemeMode.dark):
-        return LocaleKeys.settings_appearance_themeMode_dark.tr();
-      case (ThemeMode.system):
-        return LocaleKeys.settings_appearance_themeMode_system.tr();
-      default:
-        return "";
-    }
-  }
-}
-
-class ThemeFontFamilySetting extends StatefulWidget {
-  const ThemeFontFamilySetting({
-    super.key,
-    required this.currentFontFamily,
-  });
-
-  final String currentFontFamily;
-  static Key textFieldKey = const Key('FontFamilyTextField');
-  static Key popoverKey = const Key('FontFamilyPopover');
-
-  @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(
-      popoverKey: ThemeFontFamilySetting.popoverKey,
-      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(
-                key: ThemeFontFamilySetting.textFieldKey,
-                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(
-        key: Key(buttonFontFamily),
-        onHover: (_) => FocusScope.of(context).unfocus(),
-        text: FlowyText.medium(
-          parseFontFamilyName(style.fontFamily!),
-          fontFamily: style.fontFamily!,
-        ),
-        rightIcon:
-            buttonFontFamily == parseFontFamilyName(widget.currentFontFamily)
-                ? const FlowySvg(FlowySvgs.check_s)
-                : 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.popoverKey,
-    this.onClose,
-  });
-
-  final String label;
-  final String currentValue;
-  final Key? popoverKey;
-  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(
-          key: widget.popoverKey,
-          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,
-          ),
-        ),
-      ],
-    );
-  }
-}

+ 1 - 1
frontend/appflowy_flutter/test/unit_test/algorithm/levenshtein_test.dart

@@ -1,4 +1,4 @@
-import 'package:appflowy/workspace/presentation/settings/widgets/levenshtein.dart';
+import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/levenshtein.dart';
 import 'package:flutter_test/flutter_test.dart';
 
 void main() {

+ 1 - 1
frontend/appflowy_flutter/test/widget_test/theme_font_family_setting_test.dart

@@ -1,6 +1,6 @@
 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:appflowy/workspace/presentation/settings/widgets/settings_appearance/font_family_setting.dart';
 import 'package:flowy_infra_ui/flowy_infra_ui.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_bloc/flutter_bloc.dart';

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

@@ -233,6 +233,7 @@
       "openHistoricalUser": "Click to open the anonymous account"
     },
     "appearance": {
+      "resetSetting": "Reset this setting",
       "fontFamily": {
         "label": "Font Family",
         "search": "Search"