소스 검색

feat: Dynamically Load Themes in AppFlowy (#2670)

* feat: dynamic theme plugin (init)

* feat: provide fallback color if plugin becomes out of date (transparent)

* feat: use applicationDocumentsDirectory to store plugins

* chore: remove json files

* fix: add toJson to resolve analyzer errors

* fix: analyzer (unused imports)

* feat: add code generation scripts for freezed files (call recursively in packages)

* fix: revert changes to dry generation

* feat: call directly into script

* refactor: scripts try to be stateless :)

* fix: path to code generation in toml

* fix: generate script permissions

* fix: path not correct in generate.sh

* feat: modify execution permissions before executing scripts

* chore: switch order of build_runner and easy_localizations

* fix: fs is not valid duckscript cmd

* chore: clean build_runner before executing

* chore: upgrade freezed and build_runner attempt to resolve InvalidType error

* fix: use exec cmd.exe to chmod

* feat: add task to generate all files

* chore: remove redundant task (Code Gen)

* chore: remove json_annoation to dev_dependencies

* fix: dropped & between commands

* chore: rename file and class to FlowyDynamicPlugin

* fix: dependency hell

* fix: json annotation in colorscheme

* fix: analyzer warnings

* fix: duckscript runner for code generator

* fix: try without setting file permissions

* chore: move file picker to infra

* chore: restructure project directory

* feat: add BLoC components for consumers

* chore: update dependencies in pubspec.yaml file

* fix: file picker imports

* feat: add new translations for features

* feat: add new widgets to render upload

* fix: import

* feat: add text overflow

* feat: use animated switcher

* chore: export FileType

* fix: directory was not created, only files were copied

* chore: separate some logic

* feat: add saveFile to FilePickerService

* fix: analyzer error with unused imports

* feat: add translations for uploading

* feat: add builtins property to apptheme

* feat: add theme preview widget

* fix: upload widgets need to fill whole space and account for overflow

* refactor: do not watch file system for changes

* feat: add deletion confirmation dialog

* feat: add form factor resolution for dyanmic layout

* feat: trigger rebuild only when plugins are loaded

* feat: make all methods static

* chore: remove TODO comment, requires further design

* chore: move models to subfolder

* fix: references to plugin service instance

* fix: rebase errors

* fix: more rebasing errors

* feat: remove multiple themes from one plugin

* refactor: use pattern to resolve widget in settings_appearance_view

* refactor: remove commented code

* feat: add translations

* fix: import error

* refactor: separate concerns a bit more

* fix: bug in toJson serialization code

* feat: add package to use represent memory files

* fix: analyzer warnings

* chore: add translation

* chore: remove unused exceptions

* chore: use join

* chore: add documentation

* feat: add tests on theme

* fix: fix scripts for macOS

* feat: use appFlowyDocumentDirectory

* fix: remove unused import

* fix: imports

* feat: allow plugin service to be passed

* fix: theme tests

* feat: separate themes by built-in and plugin

* fix: rebase change name of appFlowyDocumentDirectory

* chore: add test to check that initial state falls back to initial theme

* chore: theme upload preview widget

* chore: rename to brightness setting

* refactor: appearance for settings appearance view

* feat: change show dialog api and use it

* fix: handle plugin compilation exception when incorrect format supplied

* fix: style of theme upload

* fix: always change state so that ui updates

* chore: style of loading widget

* fix: analyzer errors

* feat: add learn more button to documentation

---------

Co-authored-by: Yijing Huang <[email protected]>
Co-authored-by: nathan <[email protected]>
Alex Wallen 1 년 전
부모
커밋
8dfbfe3c42
50개의 변경된 파일1412개의 추가작업 그리고 129개의 파일을 삭제
  1. 21 5
      frontend/.vscode/tasks.json
  2. 13 1
      frontend/appflowy_flutter/assets/translations/en.json
  3. 6 8
      frontend/appflowy_flutter/integration_test/util/mock/mock_file_picker.dart
  4. 1 1
      frontend/appflowy_flutter/lib/plugins/document/document_page.dart
  5. 1 1
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/link_to_page_widget.dart
  6. 2 3
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/custom_cover_picker_bloc.dart
  7. 1 1
      frontend/appflowy_flutter/lib/plugins/document/presentation/share/share_button.dart
  8. 2 2
      frontend/appflowy_flutter/lib/startup/deps_resolver.dart
  9. 1 1
      frontend/appflowy_flutter/lib/user/presentation/folder/folder_widget.dart
  10. 3 3
      frontend/appflowy_flutter/lib/workspace/application/appearance.dart
  11. 1 2
      frontend/appflowy_flutter/lib/workspace/presentation/home/menu/app/header/import/import_panel.dart
  12. 160 46
      frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance_view.dart
  13. 1 1
      frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_file_customize_location_view.dart
  14. 2 2
      frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_file_exporter_widget.dart
  15. 59 0
      frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_confirm_delete_dialog.dart
  16. 41 0
      frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_button.dart
  17. 40 0
      frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_decoration.dart
  18. 41 0
      frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_failure_widget.dart
  19. 34 0
      frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_loading_widget.dart
  20. 79 0
      frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_view.dart
  21. 90 0
      frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/upload_new_theme_widget.dart
  22. 19 0
      frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/utils/form_factor.dart
  23. 14 10
      frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/colorscheme.dart
  24. 6 4
      frontend/appflowy_flutter/packages/flowy_infra/lib/file_picker/file_picker_impl.dart
  25. 3 0
      frontend/appflowy_flutter/packages/flowy_infra/lib/file_picker/file_picker_service.dart
  26. 71 0
      frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/bloc/dynamic_plugin_bloc.dart
  27. 11 0
      frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/bloc/dynamic_plugin_event.dart
  28. 22 0
      frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/bloc/dynamic_plugin_state.dart
  29. 13 0
      frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/service/location_service.dart
  30. 5 0
      frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/service/models/exceptions.dart
  31. 137 0
      frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/service/models/flowy_dynamic_plugin.dart
  32. 31 0
      frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/service/models/plugin_type.dart
  33. 102 0
      frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/service/plugin_service.dart
  34. 44 7
      frontend/appflowy_flutter/packages/flowy_infra/lib/theme.dart
  35. 17 0
      frontend/appflowy_flutter/packages/flowy_infra/lib/utils/color_converter.dart
  36. 13 2
      frontend/appflowy_flutter/packages/flowy_infra/pubspec.yaml
  37. 3 2
      frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/dialog/styled_dialogs.dart
  38. 27 3
      frontend/appflowy_flutter/pubspec.lock
  39. 1 1
      frontend/appflowy_flutter/pubspec.yaml
  40. 9 0
      frontend/appflowy_flutter/test/bloc_test/app_setting_test/appearance_test.dart
  41. 80 0
      frontend/appflowy_flutter/test/unit_test/theme/theme_test.dart
  42. 38 0
      frontend/scripts/code_generation/freezed/generate_freezed.cmd
  43. 37 0
      frontend/scripts/code_generation/freezed/generate_freezed.sh
  44. 27 0
      frontend/scripts/code_generation/generate.cmd
  45. 27 0
      frontend/scripts/code_generation/generate.sh
  46. 26 0
      frontend/scripts/code_generation/language_files/generate_language_files.cmd
  47. 26 0
      frontend/scripts/code_generation/language_files/generate_language_files.sh
  48. 0 5
      frontend/scripts/generate_language_files.cmd
  49. 0 6
      frontend/scripts/generate_language_files.sh
  50. 4 12
      frontend/scripts/makefile/flutter.toml

+ 21 - 5
frontend/.vscode/tasks.json

@@ -118,15 +118,31 @@
     {
       "label": "AF: Generate Freezed Files",
       "type": "shell",
-      "command": "dart run build_runner build -d",
+      "command": "sh ./scripts/code_generation/freezed/generate_freezed.sh",
       "options": {
-        "cwd": "${workspaceFolder}/appflowy_flutter"
-      }
+        "cwd": "${workspaceFolder}"
+      },
+      "group": {
+        "kind": "build",
+        "isDefault": true
+      },
+      "windows": {
+        "options": {
+          "shell": {
+            "executable": "cmd.exe",
+            "args": [
+              "/d",
+              "/c",
+              ".\\scripts\\code_generation\\freezed\\generate_freezed.cmd"
+            ]
+          }
+        }
+      },
     },
     {
       "label": "AF: Generate Language Files",
       "type": "shell",
-      "command": "sh ./scripts/generate_language_files.sh",
+      "command": "sh ./scripts/code_generation/language_files/generate_language_files.sh",
       "windows": {
         "options": {
           "shell": {
@@ -134,7 +150,7 @@
             "args": [
               "/d",
               "/c",
-              ".\\scripts\\generate_language_files.cmd"
+              ".\\scripts\\code_generation\\language_files\\generate_language_files.cmd"
             ]
           }
         }

+ 13 - 1
frontend/appflowy_flutter/assets/translations/en.json

@@ -192,7 +192,19 @@
         "dark": "Dark Mode",
         "system": "Adapt to System"
       },
-      "theme": "Theme"
+      "themeUpload": {
+        "button": "Upload",
+        "description": "Upload your own AppFlowy theme using the button below.",
+        "failure": "The theme that was uploaded had an invalid format.",
+        "loading": "Please wait while we validate and upload your theme...",
+        "uploadSuccess": "Your theme was uploaded successfully",
+        "deletionFailure": "Failed to delete the theme. Try to delete it manually.",
+        "filePickerDialogTitle": "Choose a .flowy_plugin file",
+        "urlUploadFailure": "Failed to open url: {}"
+      },
+      "theme": "Theme",
+      "builtInsLabel": "Built-in Themes",
+      "pluginsLabel": "Plugins"
     },
     "files": {
       "copy": "Copy",

+ 6 - 8
frontend/appflowy_flutter/integration_test/util/mock/mock_file_picker.dart

@@ -1,6 +1,5 @@
 import 'package:appflowy/startup/startup.dart';
-import 'package:appflowy/util/file_picker/file_picker_service.dart';
-import 'package:file_picker/file_picker.dart' as fp;
+import 'package:flowy_infra/file_picker/file_picker_service.dart';
 
 class MockFilePicker implements FilePickerService {
   MockFilePicker({
@@ -21,7 +20,7 @@ class MockFilePicker implements FilePickerService {
     String? dialogTitle,
     String? fileName,
     String? initialDirectory,
-    fp.FileType type = fp.FileType.any,
+    FileType type = FileType.any,
     List<String>? allowedExtensions,
     bool lockParentWindow = false,
   }) {
@@ -32,18 +31,17 @@ class MockFilePicker implements FilePickerService {
   Future<FilePickerResult?> pickFiles({
     String? dialogTitle,
     String? initialDirectory,
-    fp.FileType type = fp.FileType.any,
+    FileType type = FileType.any,
     List<String>? allowedExtensions,
-    Function(fp.FilePickerStatus p1)? onFileLoading,
+    Function(FilePickerStatus p1)? onFileLoading,
     bool allowCompression = true,
     bool allowMultiple = false,
     bool withData = false,
     bool withReadStream = false,
     bool lockParentWindow = false,
   }) {
-    final platformFiles = mockPaths
-        .map((e) => fp.PlatformFile(path: e, name: '', size: 0))
-        .toList();
+    final platformFiles =
+        mockPaths.map((e) => PlatformFile(path: e, name: '', size: 0)).toList();
     return Future.value(
       FilePickerResult(
         platformFiles,

+ 1 - 1
frontend/appflowy_flutter/lib/plugins/document/document_page.dart

@@ -10,12 +10,12 @@ import 'package:appflowy/plugins/document/presentation/editor_style.dart';
 import 'package:appflowy/plugins/document/presentation/export_page_widget.dart';
 import 'package:appflowy/startup/startup.dart';
 import 'package:appflowy/util/base64_string.dart';
-import 'package:appflowy/util/file_picker/file_picker_service.dart';
 import 'package:appflowy_backend/protobuf/flowy-document2/protobuf.dart'
     hide DocumentEvent;
 import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
 import 'package:appflowy_editor/appflowy_editor.dart';
 import 'package:easy_localization/easy_localization.dart';
+import 'package:flowy_infra/file_picker/file_picker_service.dart';
 import 'package:flowy_infra_ui/flowy_infra_ui.dart';
 import 'package:flowy_infra_ui/widget/error_page.dart';
 import 'package:flutter/material.dart';

+ 1 - 1
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/link_to_page_widget.dart

@@ -46,7 +46,7 @@ void showLinkToPageMenu(
             linkToPageMenuEntry.remove();
           } on FlowyError catch (e) {
             Dialogs.show(
-              FlowyErrorPage.message(
+              child: FlowyErrorPage.message(
                 e.msg,
                 howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(),
               ),

+ 2 - 3
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/custom_cover_picker_bloc.dart

@@ -1,12 +1,11 @@
 import 'dart:io';
 import 'package:appflowy/generated/locale_keys.g.dart';
 import 'package:appflowy/startup/startup.dart';
-import 'package:appflowy/util/file_picker/file_picker_service.dart';
 import 'package:appflowy/workspace/application/settings/prelude.dart';
 import 'package:easy_localization/easy_localization.dart';
-import 'package:file_picker/file_picker.dart' as fp;
 
 import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
+import 'package:flowy_infra/file_picker/file_picker_service.dart';
 import 'package:freezed_annotation/freezed_annotation.dart';
 import 'package:flutter_bloc/flutter_bloc.dart';
 import 'package:dartz/dartz.dart';
@@ -121,7 +120,7 @@ class CoverImagePickerBloc
     final result = await getIt<FilePickerService>().pickFiles(
       dialogTitle: LocaleKeys.document_plugins_cover_addLocalImage.tr(),
       allowMultiple: false,
-      type: fp.FileType.image,
+      type: FileType.image,
       allowedExtensions: allowedExtensions,
     );
     if (result != null && result.files.isNotEmpty) {

+ 1 - 1
frontend/appflowy_flutter/lib/plugins/document/presentation/share/share_button.dart

@@ -1,13 +1,13 @@
 import 'package:appflowy/generated/locale_keys.g.dart';
 import 'package:appflowy/startup/startup.dart';
 import 'package:appflowy/plugins/document/application/share_bloc.dart';
-import 'package:appflowy/util/file_picker/file_picker_service.dart';
 import 'package:appflowy/workspace/application/view/view_listener.dart';
 import 'package:appflowy/workspace/presentation/home/toast.dart';
 import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
 import 'package:appflowy_backend/protobuf/flowy-document2/entities.pb.dart';
 import 'package:appflowy_popover/appflowy_popover.dart';
 import 'package:easy_localization/easy_localization.dart';
+import 'package:flowy_infra/file_picker/file_picker_service.dart';
 import 'package:flowy_infra_ui/widget/rounded_button.dart';
 import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
 import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';

+ 2 - 2
frontend/appflowy_flutter/lib/startup/deps_resolver.dart

@@ -11,8 +11,8 @@ import 'package:appflowy/user/application/auth/auth_service.dart';
 import 'package:appflowy/user/application/auth/supabase_auth_service.dart';
 import 'package:appflowy/user/application/user_listener.dart';
 import 'package:appflowy/user/application/user_service.dart';
-import 'package:appflowy/util/file_picker/file_picker_impl.dart';
-import 'package:appflowy/util/file_picker/file_picker_service.dart';
+import 'package:flowy_infra/file_picker/file_picker_impl.dart';
+import 'package:flowy_infra/file_picker/file_picker_service.dart';
 import 'package:appflowy/plugins/document/application/prelude.dart';
 import 'package:appflowy/workspace/application/user/prelude.dart';
 import 'package:appflowy/workspace/application/workspace/prelude.dart';

+ 1 - 1
frontend/appflowy_flutter/lib/user/presentation/folder/folder_widget.dart

@@ -1,8 +1,8 @@
 import 'dart:io';
 
-import 'package:appflowy/util/file_picker/file_picker_service.dart';
 import 'package:appflowy/workspace/application/settings/prelude.dart';
 import 'package:easy_localization/easy_localization.dart';
+import 'package:flowy_infra/file_picker/file_picker_service.dart';
 import 'package:flowy_infra/image.dart';
 import 'package:flowy_infra/size.dart';
 import 'package:flowy_infra_ui/flowy_infra_ui.dart';

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

@@ -36,10 +36,10 @@ class AppearanceSettingsCubit extends Cubit<AppearanceSettingsState> {
 
   /// Update selected theme in the user's settings and emit an updated state
   /// with the AppTheme named [themeName].
-  void setTheme(String themeName) {
+  Future<void> setTheme(String themeName) async {
     _setting.theme = themeName;
     _saveAppearanceSettings();
-    emit(state.copyWith(appTheme: AppTheme.fromName(themeName)));
+    emit(state.copyWith(appTheme: await AppTheme.fromName(themeName)));
   }
 
   /// Update the theme mode in the user's settings and emit an updated state.
@@ -182,7 +182,7 @@ class AppearanceSettingsState with _$AppearanceSettingsState {
     double menuOffset,
   ) {
     return AppearanceSettingsState(
-      appTheme: AppTheme.fromName(themeName),
+      appTheme: AppTheme.fallback,
       font: font,
       monospaceFont: monospaceFont,
       themeMode: _themeModeFromPB(themeModePB),

+ 1 - 2
frontend/appflowy_flutter/lib/workspace/presentation/home/menu/app/header/import/import_panel.dart

@@ -5,12 +5,11 @@ import 'dart:typed_data';
 import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart';
 import 'package:appflowy/plugins/document/presentation/editor_plugins/migration/editor_migration.dart';
 import 'package:appflowy/startup/startup.dart';
-import 'package:appflowy/util/file_picker/file_picker_service.dart';
 import 'package:appflowy/workspace/application/settings/share/import_service.dart';
 import 'package:appflowy/workspace/presentation/home/menu/app/header/import/import_type.dart';
 import 'package:appflowy_backend/protobuf/flowy-folder2/protobuf.dart';
+import 'package:flowy_infra/file_picker/file_picker_service.dart';
 import 'package:appflowy_editor/appflowy_editor.dart';
-import 'package:file_picker/file_picker.dart';
 import 'package:flowy_infra_ui/flowy_infra_ui.dart';
 import 'package:flowy_infra_ui/style_widget/container.dart';
 import 'package:flutter/material.dart';

+ 160 - 46
frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance_view.dart

@@ -1,8 +1,13 @@
 import 'package:appflowy/generated/locale_keys.g.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:easy_localization/easy_localization.dart';
 import 'package:flowy_infra/image.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';
@@ -14,28 +19,37 @@ class SettingsAppearanceView extends StatelessWidget {
   @override
   Widget build(BuildContext context) {
     return SingleChildScrollView(
-      child: BlocBuilder<AppearanceSettingsCubit, AppearanceSettingsState>(
-        builder: (context, state) {
-          return Column(
-            crossAxisAlignment: CrossAxisAlignment.start,
-            children: [
-              ThemeModeSetting(currentThemeMode: state.themeMode),
-              ThemeSetting(currentTheme: state.appTheme.themeName),
-            ],
-          );
-        },
+      child: BlocProvider<DynamicPluginBloc>(
+        create: (_) => DynamicPluginBloc(),
+        child: BlocBuilder<AppearanceSettingsCubit, AppearanceSettingsState>(
+          builder: (context, state) {
+            return Column(
+              crossAxisAlignment: CrossAxisAlignment.center,
+              children: [
+                BrightnessSetting(currentThemeMode: state.themeMode),
+                ColorSchemeSetting(
+                  currentTheme: state.appTheme.themeName,
+                  bloc: context.read<DynamicPluginBloc>(),
+                ),
+              ],
+            );
+          },
+        ),
       ),
     );
   }
 }
 
-class ThemeSetting extends StatelessWidget {
-  final String currentTheme;
-  const ThemeSetting({
+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(
@@ -46,52 +60,152 @@ class ThemeSetting extends StatelessWidget {
             overflow: TextOverflow.ellipsis,
           ),
         ),
-        AppFlowyPopover(
-          direction: PopoverDirection.bottomWithRightAligned,
-          child: FlowyTextButton(
-            currentTheme,
-            fontColor: Theme.of(context).colorScheme.onBackground,
-            fillColor: Colors.transparent,
-            onPressed: () {},
+        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(name: 'folder'),
+      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(),
           ),
-          popupBuilder: (BuildContext context) {
-            return IntrinsicWidth(
-              child: Column(
-                mainAxisSize: MainAxisSize.min,
-                children: [
-                  _themeItemButton(context, BuiltInTheme.defaultTheme),
-                  _themeItemButton(context, BuiltInTheme.dandelion),
-                  _themeItemButton(context, BuiltInTheme.lavender),
-                ],
-              ),
-            );
-          },
         ),
-      ],
+      ).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(),
+            ),
+          ),
+        );
+      }),
     );
   }
+}
 
-  Widget _themeItemButton(BuildContext context, String theme) {
+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: FlowyButton(
-        text: FlowyText.medium(theme),
-        rightIcon: currentTheme == theme
-            ? const FlowySvg(name: 'grid/checkmark')
-            : null,
-        onTap: () {
-          if (currentTheme != theme) {
-            context.read<AppearanceSettingsCubit>().setTheme(theme);
-          }
-        },
+      child: Row(
+        children: [
+          Expanded(
+            child: FlowyButton(
+              text: FlowyText.medium(theme),
+              rightIcon: currentTheme == theme
+                  ? const FlowySvg(name: 'grid/checkmark')
+                  : null,
+              onTap: () {
+                if (currentTheme != theme) {
+                  context.read<AppearanceSettingsCubit>().setTheme(theme);
+                }
+              },
+            ),
+          ),
+          if (!isBuiltin)
+            FlowyIconButton(
+              icon: const FlowySvg(name: 'home/close'),
+              width: 20,
+              onPressed: () =>
+                  bloc.add(DynamicPluginEvent.removePlugin(name: theme)),
+            )
+        ],
       ),
     );
   }
 }
 
-class ThemeModeSetting extends StatelessWidget {
+class BrightnessSetting extends StatelessWidget {
   final ThemeMode currentThemeMode;
-  const ThemeModeSetting({required this.currentThemeMode, super.key});
+  const BrightnessSetting({required this.currentThemeMode, super.key});
 
   @override
   Widget build(BuildContext context) {

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

@@ -1,7 +1,7 @@
 import 'dart:io';
 
 import 'package:appflowy/startup/entry_point.dart';
-import 'package:appflowy/util/file_picker/file_picker_service.dart';
+import 'package:flowy_infra/file_picker/file_picker_service.dart';
 import 'package:appflowy/workspace/application/settings/settings_location_cubit.dart';
 import 'package:flowy_infra/image.dart';
 import 'package:flowy_infra_ui/style_widget/hover.dart';

+ 2 - 2
frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_file_exporter_widget.dart

@@ -1,8 +1,9 @@
 import 'dart:io';
 
 import 'package:appflowy/startup/startup.dart';
-import 'package:appflowy/util/file_picker/file_picker_service.dart';
 import 'package:appflowy/workspace/application/export/document_exporter.dart';
+import 'package:appflowy_backend/dispatch/dispatch.dart';
+import 'package:flowy_infra/file_picker/file_picker_service.dart';
 import 'package:appflowy/workspace/application/settings/settings_file_exporter_cubit.dart';
 import 'package:appflowy/workspace/application/settings/share/export_service.dart';
 import 'package:appflowy_backend/log.dart';
@@ -10,7 +11,6 @@ import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
 import 'package:dartz/dartz.dart' as dartz;
 import 'package:easy_localization/easy_localization.dart';
 import 'package:flowy_infra_ui/flowy_infra_ui.dart' hide WidgetBuilder;
-import 'package:appflowy_backend/dispatch/dispatch.dart';
 import 'package:appflowy_backend/protobuf/flowy-error/errors.pbserver.dart';
 import 'package:appflowy_backend/protobuf/flowy-folder2/workspace.pb.dart';
 import 'package:flutter/material.dart';

+ 59 - 0
frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_confirm_delete_dialog.dart

@@ -0,0 +1,59 @@
+import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flowy_infra/theme.dart';
+import 'package:flowy_infra_ui/flowy_infra_ui.dart';
+import 'package:flutter/material.dart';
+
+import 'theme_upload_view.dart';
+
+class ThemeConfirmDeleteDialog extends StatelessWidget {
+  const ThemeConfirmDeleteDialog({
+    super.key,
+    required this.theme,
+  });
+
+  final AppTheme theme;
+
+  void onConfirm(BuildContext context) => Navigator.of(context).pop(true);
+  void onCancel(BuildContext context) => Navigator.of(context).pop(false);
+
+  @override
+  Widget build(BuildContext context) {
+    return FlowyDialog(
+      padding: EdgeInsets.zero,
+      constraints: const BoxConstraints.tightFor(
+        width: 300,
+        height: 100,
+      ),
+      title: FlowyText.regular(
+        LocaleKeys.document_plugins_cover_alertDialogConfirmation.tr(),
+        textAlign: TextAlign.center,
+      ),
+      child: Row(
+        mainAxisAlignment: MainAxisAlignment.end,
+        children: [
+          SizedBox(
+            width: ThemeUploadWidget.buttonSize.width,
+            child: FlowyButton(
+              text: FlowyText.semibold(
+                LocaleKeys.button_OK.tr(),
+                fontSize: ThemeUploadWidget.buttonFontSize,
+              ),
+              onTap: () => onConfirm(context),
+            ),
+          ),
+          SizedBox(
+            width: ThemeUploadWidget.buttonSize.width,
+            child: FlowyButton(
+              text: FlowyText.semibold(
+                LocaleKeys.button_Cancel.tr(),
+                fontSize: ThemeUploadWidget.buttonFontSize,
+              ),
+              onTap: () => onCancel(context),
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+}

+ 41 - 0
frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_button.dart

@@ -0,0 +1,41 @@
+import 'package:appflowy/generated/locale_keys.g.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_ui/flowy_infra_ui.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+
+import 'theme_upload_view.dart';
+
+class ThemeUploadButton extends StatelessWidget {
+  const ThemeUploadButton({super.key, this.color});
+
+  final Color? color;
+
+  @override
+  Widget build(BuildContext context) {
+    return SizedBox.fromSize(
+      size: ThemeUploadWidget.buttonSize,
+      child: FlowyButton(
+        decoration: BoxDecoration(
+          borderRadius: BorderRadius.circular(8),
+          color: color ?? Theme.of(context).colorScheme.primary,
+        ),
+        hoverColor: color,
+        text: Row(
+          mainAxisAlignment: MainAxisAlignment.center,
+          children: [
+            FlowyText.medium(
+              fontSize: ThemeUploadWidget.buttonFontSize,
+              color: Theme.of(context).colorScheme.onPrimary,
+              LocaleKeys.settings_appearance_themeUpload_button.tr(),
+            ),
+          ],
+        ),
+        onTap: () => BlocProvider.of<DynamicPluginBloc>(context)
+            .add(DynamicPluginEvent.addPlugin()),
+      ),
+    );
+  }
+}

+ 40 - 0
frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_decoration.dart

@@ -0,0 +1,40 @@
+import 'package:dotted_border/dotted_border.dart';
+import 'package:flutter/material.dart';
+
+import 'theme_upload_view.dart';
+
+class ThemeUploadDecoration extends StatelessWidget {
+  const ThemeUploadDecoration({super.key, required this.child});
+
+  final Widget child;
+
+  @override
+  Widget build(BuildContext context) {
+    return Container(
+      decoration: BoxDecoration(
+        borderRadius: BorderRadius.circular(ThemeUploadWidget.borderRadius),
+        color: Theme.of(context).colorScheme.surface,
+        border: Border.all(
+          color: Theme.of(context).colorScheme.onBackground.withOpacity(
+                ThemeUploadWidget.fadeOpacity,
+              ),
+        ),
+      ),
+      padding: ThemeUploadWidget.padding,
+      child: DottedBorder(
+        borderType: BorderType.RRect,
+        strokeWidth: 1,
+        dashPattern: const [6, 6],
+        color: Theme.of(context)
+            .colorScheme
+            .onBackground
+            .withOpacity(ThemeUploadWidget.fadeOpacity),
+        radius: const Radius.circular(ThemeUploadWidget.borderRadius),
+        child: ClipRRect(
+          borderRadius: BorderRadius.circular(ThemeUploadWidget.borderRadius),
+          child: child,
+        ),
+      ),
+    );
+  }
+}

+ 41 - 0
frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_failure_widget.dart

@@ -0,0 +1,41 @@
+import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flowy_infra/image.dart';
+import 'package:flowy_infra_ui/style_widget/text.dart';
+import 'package:flutter/material.dart';
+
+import 'theme_upload_button.dart';
+import 'theme_upload_view.dart';
+
+class ThemeUploadFailureWidget extends StatelessWidget {
+  const ThemeUploadFailureWidget({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return Container(
+      color: Theme.of(context)
+          .colorScheme
+          .error
+          .withOpacity(ThemeUploadWidget.fadeOpacity),
+      constraints: const BoxConstraints.expand(),
+      padding: ThemeUploadWidget.padding,
+      child: Column(
+        mainAxisSize: MainAxisSize.max,
+        mainAxisAlignment: MainAxisAlignment.center,
+        children: [
+          svgWidget(
+            'home/close',
+            size: ThemeUploadWidget.iconSize,
+            color: Theme.of(context).colorScheme.onBackground,
+          ),
+          FlowyText.medium(
+            LocaleKeys.settings_appearance_themeUpload_failure.tr(),
+            overflow: TextOverflow.ellipsis,
+          ),
+          ThemeUploadWidget.elementSpacer,
+          ThemeUploadButton(color: Theme.of(context).colorScheme.error),
+        ],
+      ),
+    );
+  }
+}

+ 34 - 0
frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_loading_widget.dart

@@ -0,0 +1,34 @@
+import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:appflowy/workspace/presentation/settings/widgets/theme_upload/theme_upload_view.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flowy_infra_ui/flowy_infra_ui.dart';
+import 'package:flutter/material.dart';
+
+class ThemeUploadLoadingWidget extends StatelessWidget {
+  const ThemeUploadLoadingWidget({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return Container(
+      padding: ThemeUploadWidget.padding,
+      color: Theme.of(context)
+          .colorScheme
+          .background
+          .withOpacity(ThemeUploadWidget.fadeOpacity),
+      constraints: const BoxConstraints.expand(),
+      child: Column(
+        mainAxisSize: MainAxisSize.max,
+        mainAxisAlignment: MainAxisAlignment.center,
+        children: [
+          CircularProgressIndicator(
+            color: Theme.of(context).colorScheme.primary,
+          ),
+          ThemeUploadWidget.elementSpacer,
+          FlowyText.regular(
+            LocaleKeys.settings_appearance_themeUpload_loading.tr(),
+          ),
+        ],
+      ),
+    );
+  }
+}

+ 79 - 0
frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_view.dart

@@ -0,0 +1,79 @@
+import 'package:flowy_infra/plugins/bloc/dynamic_plugin_bloc.dart';
+import 'package:flowy_infra/plugins/bloc/dynamic_plugin_state.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+
+import 'theme_upload_decoration.dart';
+import 'theme_upload_failure_widget.dart';
+import 'theme_upload_loading_widget.dart';
+import 'upload_new_theme_widget.dart';
+
+class ThemeUploadWidget extends StatefulWidget {
+  const ThemeUploadWidget({super.key});
+
+  static const double borderRadius = 8;
+  static const double buttonFontSize = 14;
+  static const Size buttonSize = Size(72, 28);
+  static const EdgeInsets padding = EdgeInsets.all(12.0);
+  static const Size iconSize = Size.square(48);
+  static const Widget elementSpacer = SizedBox(height: 12);
+  static const double fadeOpacity = 0.5;
+  static const Duration fadeDuration = Duration(milliseconds: 750);
+
+  @override
+  State<ThemeUploadWidget> createState() => _ThemeUploadWidgetState();
+}
+
+class _ThemeUploadWidgetState extends State<ThemeUploadWidget> {
+  void listen(BuildContext context, DynamicPluginState state) {
+    setState(() {
+      state.when(
+        uninitialized: () => null,
+        ready: (plugins) {
+          child =
+              const UploadNewThemeWidget(key: Key('upload_new_theme_widget'));
+        },
+        deletionSuccess: () {
+          child =
+              const UploadNewThemeWidget(key: Key('upload_new_theme_widget'));
+        },
+        processing: () {
+          child = const ThemeUploadLoadingWidget(
+            key: Key('upload_theme_loading_widget'),
+          );
+        },
+        compilationFailure: (path) {
+          child = const ThemeUploadFailureWidget(
+            key: Key('upload_theme_failure_widget'),
+          );
+        },
+        compilationSuccess: () {
+          if (Navigator.of(context).canPop()) {
+            Navigator.of(context)
+                .pop(const DynamicPluginState.compilationSuccess());
+          }
+        },
+        deletionFailure: (path) {},
+      );
+    });
+  }
+
+  Widget child =
+      const UploadNewThemeWidget(key: Key('upload_new_theme_widget'));
+
+  @override
+  Widget build(BuildContext context) {
+    return BlocListener<DynamicPluginBloc, DynamicPluginState>(
+      listener: listen,
+      child: ThemeUploadDecoration(
+        child: Center(
+          child: AnimatedSwitcher(
+            duration: ThemeUploadWidget.fadeDuration,
+            switchInCurve: Curves.easeInOutCubicEmphasized,
+            child: child,
+          ),
+        ),
+      ),
+    );
+  }
+}

+ 90 - 0
frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/upload_new_theme_widget.dart

@@ -0,0 +1,90 @@
+import 'package:appflowy/generated/locale_keys.g.dart';
+import 'package:appflowy/workspace/presentation/settings/widgets/theme_upload/theme_upload_button.dart';
+import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flowy_infra/image.dart';
+import 'package:flowy_infra_ui/flowy_infra_ui.dart';
+import 'package:flowy_infra_ui/widget/error_page.dart';
+import 'package:flutter/material.dart';
+import 'package:url_launcher/url_launcher.dart';
+
+import 'theme_upload_view.dart';
+
+class UploadNewThemeWidget extends StatelessWidget {
+  const UploadNewThemeWidget({super.key});
+
+  static const learnMoreRedirect =
+      'https://appflowy.gitbook.io/docs/essential-documentation/themes';
+
+  @override
+  Widget build(BuildContext context) {
+    return Container(
+      color: Theme.of(context)
+          .colorScheme
+          .background
+          .withOpacity(ThemeUploadWidget.fadeOpacity),
+      padding: ThemeUploadWidget.padding,
+      child: Column(
+        mainAxisSize: MainAxisSize.max,
+        mainAxisAlignment: MainAxisAlignment.center,
+        children: [
+          svgWidget(
+            'folder',
+            size: ThemeUploadWidget.iconSize,
+            color: Theme.of(context).colorScheme.onBackground,
+          ),
+          FlowyText.medium(
+            LocaleKeys.settings_appearance_themeUpload_description.tr(),
+            overflow: TextOverflow.ellipsis,
+          ),
+          ThemeUploadWidget.elementSpacer,
+          SizedBox(
+            height: ThemeUploadWidget.buttonSize.height,
+            child: IntrinsicWidth(
+              child: FlowyButton(
+                decoration: BoxDecoration(
+                  borderRadius: BorderRadius.circular(8),
+                  color: Theme.of(context).colorScheme.onBackground,
+                ),
+                hoverColor: Theme.of(context).colorScheme.onBackground,
+                text: FlowyText.medium(
+                  fontSize: ThemeUploadWidget.buttonFontSize,
+                  color: Theme.of(context).colorScheme.onPrimary,
+                  LocaleKeys.document_plugins_autoGeneratorLearnMore.tr(),
+                ),
+                onTap: () async {
+                  final uri = Uri.parse(learnMoreRedirect);
+                  if (await canLaunchUrl(uri)) {
+                    await launchUrl(uri);
+                  } else {
+                    if (context.mounted) {
+                      Dialogs.show(
+                        context,
+                        child: FlowyDialog(
+                          child: FlowyErrorPage.message(
+                            LocaleKeys
+                                .settings_appearance_themeUpload_urlUploadFailure
+                                .tr()
+                                .replaceAll(
+                                  '{}',
+                                  uri.toString(),
+                                ),
+                            howToFix:
+                                LocaleKeys.errorDialog_howToFixFallback.tr(),
+                          ),
+                        ),
+                      );
+                    }
+                  }
+                },
+              ),
+            ),
+          ),
+          const Divider(),
+          ThemeUploadWidget.elementSpacer,
+          const ThemeUploadButton(),
+        ],
+      ),
+    );
+  }
+}

+ 19 - 0
frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/utils/form_factor.dart

@@ -0,0 +1,19 @@
+enum FormFactor {
+  mobile._(600),
+  tablet._(840),
+  desktop._(1280);
+
+  const FormFactor._(this.width);
+
+  final double width;
+
+  factory FormFactor.fromWidth(double width) {
+    if (width < FormFactor.mobile.width) {
+      return FormFactor.mobile;
+    } else if (width < FormFactor.tablet.width) {
+      return FormFactor.tablet;
+    } else {
+      return FormFactor.desktop;
+    }
+  }
+}

+ 14 - 10
frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/colorscheme.dart

@@ -1,10 +1,14 @@
+import 'package:flowy_infra/utils/color_converter.dart';
 import 'package:flutter/material.dart';
 
 import 'package:flowy_infra/theme.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
 import 'default_colorscheme.dart';
 import 'dandelion.dart';
 import 'lavender.dart';
 
+part 'colorscheme.g.dart';
+
 /// A map of all the built-in themes.
 ///
 /// The key is the theme name, and the value is a list of two color schemes:
@@ -25,8 +29,12 @@ const Map<String, List<FlowyColorScheme>> themeMap = {
   ],
 };
 
-@immutable
-abstract class FlowyColorScheme {
+@JsonSerializable(
+  converters: [
+    ColorConverter(),
+  ],
+)
+class FlowyColorScheme {
   final Color surface;
   final Color hover;
   final Color selector;
@@ -127,12 +135,8 @@ abstract class FlowyColorScheme {
     required this.toggleButtonBGColor,
   });
 
-  factory FlowyColorScheme.builtIn(String themeName, Brightness brightness) {
-    switch (brightness) {
-      case Brightness.light:
-        return themeMap[themeName]?[0] ?? const DefaultColorScheme.light();
-      case Brightness.dark:
-        return themeMap[themeName]?[1] ?? const DefaultColorScheme.dark();
-    }
-  }
+  factory FlowyColorScheme.fromJson(Map<String, dynamic> json) =>
+      _$FlowyColorSchemeFromJson(json);
+
+  Map<String, dynamic> toJson() => _$FlowyColorSchemeToJson(this);
 }

+ 6 - 4
frontend/appflowy_flutter/lib/util/file_picker/file_picker_impl.dart → frontend/appflowy_flutter/packages/flowy_infra/lib/file_picker/file_picker_impl.dart

@@ -1,4 +1,4 @@
-import 'package:appflowy/util/file_picker/file_picker_service.dart';
+import 'package:flowy_infra/file_picker/file_picker_service.dart';
 import 'package:file_picker/file_picker.dart' as fp;
 
 class FilePicker implements FilePickerService {
@@ -40,11 +40,11 @@ class FilePicker implements FilePickerService {
     String? dialogTitle,
     String? fileName,
     String? initialDirectory,
-    fp.FileType type = fp.FileType.any,
+    FileType type = FileType.any,
     List<String>? allowedExtensions,
     bool lockParentWindow = false,
-  }) {
-    return fp.FilePicker.platform.saveFile(
+  }) async {
+    final result = await fp.FilePicker.platform.saveFile(
       dialogTitle: dialogTitle,
       fileName: fileName,
       initialDirectory: initialDirectory,
@@ -52,5 +52,7 @@ class FilePicker implements FilePickerService {
       allowedExtensions: allowedExtensions,
       lockParentWindow: lockParentWindow,
     );
+
+    return result;
   }
 }

+ 3 - 0
frontend/appflowy_flutter/lib/util/file_picker/file_picker_service.dart → frontend/appflowy_flutter/packages/flowy_infra/lib/file_picker/file_picker_service.dart

@@ -1,3 +1,6 @@
+export 'package:file_picker/file_picker.dart'
+    show FileType, FilePickerStatus, PlatformFile;
+
 import 'package:file_picker/file_picker.dart';
 
 class FilePickerResult {

+ 71 - 0
frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/bloc/dynamic_plugin_bloc.dart

@@ -0,0 +1,71 @@
+import 'package:bloc/bloc.dart';
+import 'package:flowy_infra/plugins/service/models/exceptions.dart';
+import 'package:flowy_infra/plugins/service/plugin_service.dart';
+
+import '../../file_picker/file_picker_impl.dart';
+import 'dynamic_plugin_event.dart';
+import 'dynamic_plugin_state.dart';
+
+class DynamicPluginBloc extends Bloc<DynamicPluginEvent, DynamicPluginState> {
+  DynamicPluginBloc({FilePicker? filePicker})
+      : super(const DynamicPluginState.uninitialized()) {
+    on<DynamicPluginEvent>(dispatch);
+    add(DynamicPluginEvent.load());
+  }
+
+  Future<void> dispatch(
+      DynamicPluginEvent event, Emitter<DynamicPluginState> emit) async {
+    await event.when(
+      addPlugin: () => addPlugin(emit),
+      removePlugin: (name) => removePlugin(emit, name),
+      load: () => onLoadRequested(emit),
+    );
+  }
+
+  Future<void> onLoadRequested(Emitter<DynamicPluginState> emit) async {
+    emit(DynamicPluginState.ready(
+        plugins: await FlowyPluginService.instance.plugins));
+  }
+
+  Future<void> addPlugin(Emitter<DynamicPluginState> emit) async {
+    emit(const DynamicPluginState.processing());
+    try {
+      final plugin = await FlowyPluginService.pick();
+      if (plugin == null) {
+        emit(DynamicPluginState.ready(
+            plugins: await FlowyPluginService.instance.plugins));
+        return;
+      }
+      await FlowyPluginService.instance.addPlugin(plugin);
+    } on PluginCompilationException {
+      // TODO(a-wallen): Remove path from compilation failure
+      emit(const DynamicPluginState.compilationFailure(path: ''));
+      return;
+    }
+
+    emit(const DynamicPluginState.compilationSuccess());
+    emit(DynamicPluginState.ready(
+        plugins: await FlowyPluginService.instance.plugins));
+  }
+
+  Future<void> removePlugin(
+      Emitter<DynamicPluginState> emit, String name) async {
+    emit(const DynamicPluginState.processing());
+
+    final plugin = await FlowyPluginService.instance.lookup(name: name);
+
+    if (plugin == null) {
+      emit(DynamicPluginState.ready(
+          plugins: await FlowyPluginService.instance.plugins));
+      return;
+    }
+
+    await FlowyPluginService.removePlugin(plugin);
+
+    emit(const DynamicPluginState.deletionSuccess());
+    emit(
+      DynamicPluginState.ready(
+          plugins: await FlowyPluginService.instance.plugins),
+    );
+  }
+}

+ 11 - 0
frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/bloc/dynamic_plugin_event.dart

@@ -0,0 +1,11 @@
+import 'package:freezed_annotation/freezed_annotation.dart';
+
+part 'dynamic_plugin_event.freezed.dart';
+
+@freezed
+class DynamicPluginEvent with _$DynamicPluginEvent {
+  factory DynamicPluginEvent.addPlugin() = _AddPlugin;
+  factory DynamicPluginEvent.removePlugin({required String name}) =
+      _RemovePlugin;
+  factory DynamicPluginEvent.load() = _Load;
+}

+ 22 - 0
frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/bloc/dynamic_plugin_state.dart

@@ -0,0 +1,22 @@
+import 'package:freezed_annotation/freezed_annotation.dart';
+
+import '../service/models/flowy_dynamic_plugin.dart';
+
+part 'dynamic_plugin_state.freezed.dart';
+
+@freezed
+class DynamicPluginState with _$DynamicPluginState {
+  const factory DynamicPluginState.uninitialized() = _Uninitialized;
+  const factory DynamicPluginState.ready({
+    required Iterable<FlowyDynamicPlugin> plugins,
+  }) = Ready;
+  const factory DynamicPluginState.processing() = _Processing;
+  const factory DynamicPluginState.compilationFailure({
+    required String path,
+  }) = _CompilationFailure;
+  const factory DynamicPluginState.deletionFailure({
+    required String path,
+  }) = _DeletionFailure;
+  const factory DynamicPluginState.deletionSuccess() = _DeletionSuccess;
+  const factory DynamicPluginState.compilationSuccess() = _CompilationSuccess;
+}

+ 13 - 0
frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/service/location_service.dart

@@ -0,0 +1,13 @@
+import 'dart:io';
+
+class PluginLocationService {
+  const PluginLocationService({
+    required Future<Directory> fallback,
+  }) : _fallback = fallback;
+
+  final Future<Directory> _fallback;
+
+  Future<Directory> get fallback async => _fallback;
+
+  Future<Directory> get location async => fallback;
+}

+ 5 - 0
frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/service/models/exceptions.dart

@@ -0,0 +1,5 @@
+class PluginCompilationException implements Exception {
+  final String message;
+
+  PluginCompilationException(this.message);
+}

+ 137 - 0
frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/service/models/flowy_dynamic_plugin.dart

@@ -0,0 +1,137 @@
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:file/memory.dart';
+import 'package:flowy_infra/colorscheme/colorscheme.dart';
+import 'package:flowy_infra/plugins/service/models/exceptions.dart';
+import 'package:flowy_infra/theme.dart';
+import 'package:path/path.dart' as p;
+
+import 'plugin_type.dart';
+
+typedef DynamicPluginLibrary = Iterable<FlowyDynamicPlugin>;
+
+/// A class that encapsulates dynamically loaded plugins for AppFlowy.
+///
+/// This class can be modified to support loading node widget builders and other
+/// plugins that are dynamically loaded at runtime for the editor. For now,
+/// it only supports loading app themes.
+class FlowyDynamicPlugin {
+  FlowyDynamicPlugin._({
+    required String name,
+    required String path,
+    this.theme,
+  })  : _name = name,
+        _path = path;
+
+  /// The plugins should be loaded into a folder with the extension `.flowy_plugin`.
+  static bool isPlugin(FileSystemEntity entity) =>
+      entity is Directory && p.extension(entity.path).contains(ext);
+
+  /// The extension for the plugin folder.
+  static const String ext = 'flowy_plugin';
+  static String get lightExtension => ['light', 'json'].join('.');
+  static String get darkExtension => ['dark', 'json'].join('.');
+
+  String get name => _name;
+  late final String _name;
+
+  String get _fsPluginName => [name, ext].join('.');
+
+  final AppTheme? theme;
+  final String _path;
+
+  Directory get source {
+    return Directory(_path);
+  }
+
+  /// Loads and "compiles" loaded plugins.
+  ///
+  /// If the plugin loaded does not contain the `.flowy_plugin` extension, this
+  /// this method will throw an error. Likewise, if the plugin does not follow
+  /// the expected format, this method will throw an error.
+  static Future<FlowyDynamicPlugin> decode({required Directory src}) async {
+    // throw an error if the plugin does not follow the proper format.
+    if (!isPlugin(src)) {
+      throw PluginCompilationException(
+        'The plugin directory must have the extension `.flowy_plugin`.',
+      );
+    }
+
+    // throws an error if the plugin does not follow the proper format.
+    final type = PluginType.from(src: src);
+
+    switch (type) {
+      case PluginType.theme:
+        return _theme(src: src);
+    }
+  }
+
+  /// Encodes the plugin in memory. The Directory given is not the actual
+  /// directory on the file system, but rather a virtual directory in memory.
+  ///
+  /// Instances of this class should always have a path on disk, otherwise a
+  /// compilation error will be thrown during the construction of this object.
+  Future<Directory> encode() async {
+    final fs = MemoryFileSystem();
+    final result = fs.directory(_fsPluginName)..createSync();
+
+    final lightPath = p.join(_fsPluginName, '$name.$lightExtension');
+    result.childFile(lightPath).createSync();
+    result
+        .childFile(lightPath)
+        .writeAsStringSync(jsonEncode(theme!.lightTheme.toJson()));
+
+    final darkPath = p.join(_fsPluginName, '$name.$darkExtension');
+    result.childFile(darkPath).createSync();
+    result
+        .childFile(p.join(_fsPluginName, '$name.$darkExtension'))
+        .writeAsStringSync(jsonEncode(theme!.darkTheme.toJson()));
+
+    return result;
+  }
+
+  /// Theme plugins should have the following format.
+  /// > directory.flowy_plugin // plugin root
+  /// >   - theme.light.json   // the light theme
+  /// >   - theme.dark.json    // the dark theme
+  ///
+  /// If the theme does not adhere to that format, it is considered an error.
+  static Future<FlowyDynamicPlugin> _theme({required Directory src}) async {
+    late final String name;
+    try {
+      name = p.basenameWithoutExtension(src.path).split('.').first;
+    } catch (e) {
+      throw PluginCompilationException(
+        'The theme plugin does not adhere to the following format: `<plugin_name>.flowy_plugin`.',
+      );
+    }
+
+    final light = src
+        .listSync()
+        .where((event) =>
+            event is File && p.basename(event.path).contains(lightExtension))
+        .first as File;
+
+    final dark = src
+        .listSync()
+        .where((event) =>
+            event is File && p.basename(event.path).contains(darkExtension))
+        .first as File;
+
+    final theme = AppTheme(
+      themeName: name,
+      builtIn: false,
+      lightTheme:
+          FlowyColorScheme.fromJson(jsonDecode(await light.readAsString())),
+      darkTheme:
+          FlowyColorScheme.fromJson(jsonDecode(await dark.readAsString())),
+    );
+
+    return FlowyDynamicPlugin._(
+      name: theme.themeName,
+      path: src.path,
+      theme: theme,
+    );
+  }
+}

+ 31 - 0
frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/service/models/plugin_type.dart

@@ -0,0 +1,31 @@
+import 'dart:io';
+
+import 'package:flowy_infra/plugins/service/models/exceptions.dart';
+import 'package:flowy_infra/plugins/service/models/flowy_dynamic_plugin.dart';
+import 'package:path/path.dart' as p;
+
+enum PluginType {
+  theme._();
+
+  const PluginType._();
+
+  factory PluginType.from({required Directory src}) {
+    if (_isTheme(src)) {
+      return PluginType.theme;
+    }
+    throw PluginCompilationException(
+        'Could not determine the plugin type from source `$src`.');
+  }
+
+  static bool _isTheme(Directory plugin) {
+    final files = plugin.listSync();
+    return files.any((entity) =>
+            entity is File &&
+            p
+                .basename(entity.path)
+                .endsWith(FlowyDynamicPlugin.lightExtension)) &&
+        files.any((entity) =>
+            entity is File &&
+            p.basename(entity.path).endsWith(FlowyDynamicPlugin.darkExtension));
+  }
+}

+ 102 - 0
frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/service/plugin_service.dart

@@ -0,0 +1,102 @@
+import 'dart:async';
+import 'dart:io';
+
+import 'package:flowy_infra/file_picker/file_picker_impl.dart';
+
+import 'package:path/path.dart' as p;
+import 'package:path_provider/path_provider.dart';
+import 'location_service.dart';
+import 'models/flowy_dynamic_plugin.dart';
+
+/// A service to maintain the state of the plugins for AppFlowy.
+class FlowyPluginService {
+  FlowyPluginService._();
+  static final FlowyPluginService _instance = FlowyPluginService._();
+  static FlowyPluginService get instance => _instance;
+
+  PluginLocationService _locationService = PluginLocationService(
+    fallback: getApplicationDocumentsDirectory(),
+  );
+
+  void setLocation(PluginLocationService locationService) =>
+      _locationService = locationService;
+
+  Future<Iterable<Directory>> get _targets async {
+    final location = await _locationService.location;
+    final targets = location.listSync().where(FlowyDynamicPlugin.isPlugin);
+    return targets.map<Directory>((entity) => entity as Directory).toList();
+  }
+
+  /// Searches the [PluginLocationService.location] for plugins and compiles them.
+  Future<DynamicPluginLibrary> get plugins async {
+    final List<FlowyDynamicPlugin> compiled = [];
+    for (final src in await _targets) {
+      final plugin = await FlowyDynamicPlugin.decode(src: src);
+      compiled.add(plugin);
+    }
+    return compiled;
+  }
+
+  /// Chooses a plugin from the file system using FilePickerService and tries to compile it.
+  ///
+  /// If the operation is cancelled or the plugin is invalid, this method will return null.
+  static Future<FlowyDynamicPlugin?> pick({FilePicker? service}) async {
+    service ??= FilePicker();
+
+    final result = await service.getDirectoryPath();
+
+    if (result == null) {
+      return null;
+    }
+
+    final directory = Directory(result);
+    return FlowyDynamicPlugin.decode(src: directory);
+  }
+
+  /// Searches the plugin registry for a plugin with the given name.
+  Future<FlowyDynamicPlugin?> lookup({required String name}) async {
+    final library = await plugins;
+    return library
+        // cast to nullable type to allow return of null if not found.
+        .cast<FlowyDynamicPlugin?>()
+        // null assert is fine here because the original list was non-nullable
+        .firstWhere((plugin) => plugin!.name == name, orElse: () => null);
+  }
+
+  /// Adds a plugin to the registry. To construct a [FlowyDynamicPlugin]
+  /// use [FlowyDynamicPlugin.encode()]
+  Future<void> addPlugin(FlowyDynamicPlugin plugin) async {
+    // try to compile the plugin before we add it to the registry.
+    final source = await plugin.encode();
+    // add the plugin to the registry
+    final destionation = [
+      (await _locationService.location).path,
+      p.basename(source.path),
+    ].join(Platform.pathSeparator);
+
+    _copyDirectorySync(source, Directory(destionation));
+  }
+
+  /// Removes a plugin from the registry.
+  static Future<void> removePlugin(FlowyDynamicPlugin plugin) async {
+    final target = plugin.source;
+    await target.delete(recursive: true);
+  }
+
+  static void _copyDirectorySync(Directory source, Directory destination) {
+    if (!destination.existsSync()) {
+      destination.createSync(recursive: true);
+    }
+
+    for (final child in source.listSync(recursive: false)) {
+      final newPath = p.join(destination.path, p.basename(child.path));
+      if (child is File) {
+        File(newPath)
+          ..createSync(recursive: true)
+          ..writeAsStringSync(child.readAsStringSync());
+      } else if (child is Directory) {
+        _copyDirectorySync(child, Directory(newPath));
+      }
+    }
+  }
+}

+ 44 - 7
frontend/appflowy_flutter/packages/flowy_infra/lib/theme.dart

@@ -1,5 +1,6 @@
 import 'package:flowy_infra/colorscheme/colorscheme.dart';
-import 'package:flutter/material.dart';
+import 'package:flowy_infra/colorscheme/default_colorscheme.dart';
+import 'plugins/service/plugin_service.dart';
 
 class BuiltInTheme {
   static const String defaultTheme = 'Default';
@@ -9,22 +10,58 @@ class BuiltInTheme {
 
 class AppTheme {
   // metadata member
+  final bool builtIn;
   final String themeName;
   final FlowyColorScheme lightTheme;
   final FlowyColorScheme darkTheme;
   // static final Map<String, dynamic> _cachedJsonData = {};
 
   const AppTheme({
+    required this.builtIn,
     required this.themeName,
     required this.lightTheme,
     required this.darkTheme,
   });
 
-  factory AppTheme.fromName(String themeName) {
-    return AppTheme(
-      themeName: themeName,
-      lightTheme: FlowyColorScheme.builtIn(themeName, Brightness.light),
-      darkTheme: FlowyColorScheme.builtIn(themeName, Brightness.dark),
-    );
+  static const AppTheme fallback = AppTheme(
+    builtIn: true,
+    themeName: BuiltInTheme.defaultTheme,
+    lightTheme: DefaultColorScheme.light(),
+    darkTheme: DefaultColorScheme.dark(),
+  );
+
+  static Future<Iterable<AppTheme>> _plugins(FlowyPluginService service) async {
+    final plugins = await service.plugins;
+    return plugins.map((plugin) => plugin.theme).whereType<AppTheme>();
+  }
+
+  static Iterable<AppTheme> get builtins => themeMap.entries
+      .map(
+        (entry) => AppTheme(
+          builtIn: true,
+          themeName: entry.key,
+          lightTheme: entry.value[0],
+          darkTheme: entry.value[1],
+        ),
+      )
+      .toList();
+
+  static Future<Iterable<AppTheme>> themes(FlowyPluginService service) async =>
+      [
+        ...builtins,
+        ...(await _plugins(service)),
+      ];
+
+  static Future<AppTheme> fromName(
+    String themeName, {
+    FlowyPluginService? pluginService,
+  }) async {
+    pluginService ??= FlowyPluginService.instance;
+    for (final theme in await themes(pluginService)) {
+      if (theme.themeName == themeName) {
+        return theme;
+      }
+    }
+    throw ArgumentError('The theme $themeName does not exist.');
   }
 }

+ 17 - 0
frontend/appflowy_flutter/packages/flowy_infra/lib/utils/color_converter.dart

@@ -0,0 +1,17 @@
+import 'package:flutter/material.dart';
+import 'package:json_annotation/json_annotation.dart';
+
+class ColorConverter implements JsonConverter<Color, String> {
+  const ColorConverter();
+
+  static const Color fallback = Colors.transparent;
+
+  @override
+  Color fromJson(String radixString) {
+    final int? color = int.tryParse(radixString);
+    return color == null ? fallback : Color(color);
+  }
+
+  @override
+  String toJson(Color color) => "0x${color.value.toRadixString(16)}";
+}

+ 13 - 2
frontend/appflowy_flutter/packages/flowy_infra/pubspec.yaml

@@ -10,14 +10,25 @@ environment:
 dependencies:
   flutter:
     sdk: flutter
-  time: '>=2.0.0'
+  flutter_svg: ^2.0.2
+  json_annotation: ^4.7.0
+  path_provider: ^2.0.15
+  path: ^1.8.2
+  textstyle_extensions: "2.0.0-nullsafety"
+  time: ">=2.0.0"
   uuid: ">=2.2.2"
-  flutter_svg: ^2.0.6
+  bloc: ^8.1.2
+  freezed_annotation: ^2.1.0
+  file_picker: ^5.3.1
+  file: ^6.1.4
 
 dev_dependencies:
   flutter_test:
     sdk: flutter
+  build_runner: ^2.2.0
   flutter_lints: ^2.0.1
+  freezed: 2.2.0
+  json_serializable: ^6.5.4
 
 # For information on the generic Dart part of this file, see the
 # following page: https://dart.dev/tools/pub/pubspec

+ 3 - 2
frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/dialog/styled_dialogs.dart

@@ -9,7 +9,7 @@ extension IntoDialog on Widget {
   Future<dynamic> show(BuildContext context) async {
     FocusNode dialogFocusNode = FocusNode();
     await Dialogs.show(
-      RawKeyboardListener(
+      child: RawKeyboardListener(
         focusNode: dialogFocusNode,
         onKey: (value) {
           if (value.isKeyPressed(LogicalKeyboardKey.escape)) {
@@ -88,7 +88,8 @@ class StyledDialog extends StatelessWidget {
 }
 
 class Dialogs {
-  static Future<dynamic> show(Widget child, BuildContext context) async {
+  static Future<dynamic> show(BuildContext context,
+      {required Widget child}) async {
     return await Navigator.of(context).push(
       StyledDialogRoute(
         barrier: DialogBarrier(color: Colors.black.withOpacity(0.4)),

+ 27 - 3
frontend/appflowy_flutter/pubspec.lock

@@ -337,6 +337,14 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "0.4.1"
+  dotted_border:
+    dependency: "direct main"
+    description:
+      name: dotted_border
+      sha256: "07a5c5e8d4e6e992279e190e0352be8faa5b8f96d81c77a78b2d42f060279840"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.0.0+3"
   easy_localization:
     dependency: "direct main"
     description:
@@ -410,7 +418,7 @@ packages:
     source: hosted
     version: "6.1.4"
   file_picker:
-    dependency: "direct main"
+    dependency: transitive
     description:
       name: file_picker
       sha256: "9d6e95ec73abbd31ec54d0e0df8a961017e165aba1395e462e5b31ea0c165daf"
@@ -539,10 +547,10 @@ packages:
     dependency: "direct dev"
     description:
       name: freezed
-      sha256: "2edb9ef971d0f803860ecd9084afd48c717d002141ad77b69be3e976bee7190e"
+      sha256: a9520490532087cf38bf3f7de478ab6ebeb5f68bb1eb2641546d92719b224445
       url: "https://pub.dev"
     source: hosted
-    version: "2.3.4"
+    version: "2.3.5"
   freezed_annotation:
     dependency: "direct main"
     description:
@@ -905,6 +913,14 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "1.8.3"
+  path_drawing:
+    dependency: transitive
+    description:
+      name: path_drawing
+      sha256: bbb1934c0cbb03091af082a6389ca2080345291ef07a5fa6d6e078ba8682f977
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.0.1"
   path_parsing:
     dependency: transitive
     description:
@@ -1454,6 +1470,14 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "2.0.2"
+  textstyle_extensions:
+    dependency: transitive
+    description:
+      name: textstyle_extensions
+      sha256: b0538352844fb4d1d0eea82f7bc6b96e4dae03a3a071247e4dcc85ec627b2c6c
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.0.0-nullsafety"
   time:
     dependency: "direct main"
     description:

+ 1 - 1
frontend/appflowy_flutter/pubspec.yaml

@@ -90,7 +90,6 @@ dependencies:
   bloc: ^8.1.2
   shared_preferences: ^2.1.1
   google_fonts: ^4.0.5
-  file_picker: ^5.3.1
   percent_indicator: ^4.2.3
   calendar_view: ^1.0.3
   window_manager: ^0.3.4
@@ -102,6 +101,7 @@ dependencies:
   nanoid: ^1.0.0
   supabase_flutter: ^1.10.0
   envied: ^0.3.0+3
+  dotted_border: ^2.0.0+3
 
 dev_dependencies:
   flutter_lints: ^2.0.1

+ 9 - 0
frontend/appflowy_flutter/test/bloc_test/app_setting_test/appearance_test.dart

@@ -2,6 +2,7 @@ import 'package:appflowy/user/application/user_settings_service.dart';
 import 'package:appflowy/workspace/application/appearance.dart';
 import 'package:bloc_test/bloc_test.dart';
 import 'package:appflowy_backend/protobuf/flowy-user/user_setting.pb.dart';
+import 'package:flowy_infra/theme.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_test/flutter_test.dart';
 
@@ -54,5 +55,13 @@ void main() {
         expect(bloc.getValue("123"), null);
       },
     );
+
+    blocTest<AppearanceSettingsCubit, AppearanceSettingsState>(
+      'initial state uses fallback theme',
+      build: () => AppearanceSettingsCubit(appearanceSetting),
+      verify: (bloc) {
+        expect(bloc.state.appTheme.themeName, AppTheme.fallback.themeName);
+      },
+    );
   });
 }

+ 80 - 0
frontend/appflowy_flutter/test/unit_test/theme/theme_test.dart

@@ -0,0 +1,80 @@
+import 'package:flowy_infra/colorscheme/colorscheme.dart';
+import 'package:flowy_infra/plugins/service/location_service.dart';
+import 'package:flowy_infra/plugins/service/models/flowy_dynamic_plugin.dart';
+import 'package:flowy_infra/plugins/service/plugin_service.dart';
+import 'package:flowy_infra/theme.dart';
+import 'package:flutter/widgets.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+class MockPluginService implements FlowyPluginService {
+  @override
+  Future<void> addPlugin(FlowyDynamicPlugin plugin) =>
+      throw UnimplementedError();
+
+  @override
+  Future<FlowyDynamicPlugin?> lookup({required String name}) =>
+      throw UnimplementedError();
+
+  @override
+  Future<DynamicPluginLibrary> get plugins async => const Iterable.empty();
+
+  @override
+  void setLocation(PluginLocationService locationService) =>
+      throw UnimplementedError();
+}
+
+void main() {
+  WidgetsFlutterBinding.ensureInitialized();
+  group('AppTheme', () {
+    test('fallback theme', () {
+      const theme = AppTheme.fallback;
+
+      expect(theme.builtIn, true);
+      expect(theme.themeName, BuiltInTheme.defaultTheme);
+      expect(theme.lightTheme, isA<FlowyColorScheme>());
+      expect(theme.darkTheme, isA<FlowyColorScheme>());
+    });
+
+    test('built-in themes', () {
+      final themes = AppTheme.builtins;
+
+      expect(themes, isNotEmpty);
+      for (final theme in themes) {
+        expect(theme.builtIn, true);
+        expect(
+          theme.themeName,
+          anyOf([
+            BuiltInTheme.defaultTheme,
+            BuiltInTheme.dandelion,
+            BuiltInTheme.lavender,
+          ]),
+        );
+        expect(theme.lightTheme, isA<FlowyColorScheme>());
+        expect(theme.darkTheme, isA<FlowyColorScheme>());
+      }
+    });
+
+    test('fromName returns existing theme', () async {
+      final theme = await AppTheme.fromName(
+        BuiltInTheme.defaultTheme,
+        pluginService: MockPluginService(),
+      );
+
+      expect(theme, isNotNull);
+      expect(theme.builtIn, true);
+      expect(theme.themeName, BuiltInTheme.defaultTheme);
+      expect(theme.lightTheme, isA<FlowyColorScheme>());
+      expect(theme.darkTheme, isA<FlowyColorScheme>());
+    });
+
+    test('fromName throws error for non-existent theme', () async {
+      expect(
+        () async => await AppTheme.fromName(
+          'bogus',
+          pluginService: MockPluginService(),
+        ),
+        throwsArgumentError,
+      );
+    });
+  });
+}

+ 38 - 0
frontend/scripts/code_generation/freezed/generate_freezed.cmd

@@ -0,0 +1,38 @@
+@echo off
+
+REM Store the current working directory
+set "original_dir=%CD%"
+
+REM Change the current working directory to the script's location
+cd /d "%~dp0"
+
+REM Navigate to the project root
+cd ..\..\..\appflowy_flutter
+
+REM Navigate to the appflowy_flutter directory and generate files
+echo Generating files for appflowy_flutter
+call flutter clean >nul 2>&1 && call flutter packages pub get >nul 2>&1 && call dart run build_runner clean && call dart run build_runner build -d
+echo Done generating files for appflowy_flutter
+
+echo Generating files for packages
+cd packages
+for /D %%d in (*) do (
+    REM Navigate into the subdirectory
+    cd "%%d"
+
+    REM Check if the subdirectory contains a pubspec.yaml file
+    if exist "pubspec.yaml" (
+        echo Generating freezed files in %%d...
+        echo Please wait while we clean the project and fetch the dependencies.
+        call flutter clean >nul 2>&1 && call flutter packages pub get >nul 2>&1 && call dart run build_runner clean && call dart run build_runner build -d
+        echo Done running build command in %%d
+    ) else (
+        echo No pubspec.yaml found in %%d, it can't be a Dart project. Skipping.
+    )
+
+    REM Navigate back to the packages directory
+    cd ..
+)
+
+REM Return to the original directory
+cd /d "%original_dir%"

+ 37 - 0
frontend/scripts/code_generation/freezed/generate_freezed.sh

@@ -0,0 +1,37 @@
+#!/bin/bash
+
+# Store the current working directory
+original_dir=$(pwd)
+
+cd "$(dirname "$0")"
+
+# Navigate to the project root
+cd ../../../appflowy_flutter
+
+# Navigate to the appflowy_flutter directory and generate files
+echo "Generating files for appflowy_flutter"
+flutter clean >/dev/null 2>&1 && flutter packages pub get >/dev/null 2>&1 && dart run build_runner clean && dart run build_runner build -d
+echo "Done generating files for appflowy_flutter"
+
+echo "Generating files for packages"
+cd packages
+for d in */ ; do
+  # Navigate into the subdirectory
+  cd "$d"
+
+  # Check if the subdirectory contains a pubspec.yaml file
+  if [ -f "pubspec.yaml" ]; then
+    echo "Generating freezed files in $d..."
+    echo "Please wait while we clean the project and fetch the dependencies."
+    flutter clean >/dev/null 2>&1 && flutter packages pub get >/dev/null 2>&1 && dart run build_runner clean && dart run build_runner build -d
+    echo "Done running build command in $d"
+  else
+    echo "No pubspec.yaml found in $d, it can\'t be a Dart project. Skipping."
+  fi
+
+  # Navigate back to the packages directory
+  cd ..
+done
+
+# Return to the original directory
+cd "$original_dir"

+ 27 - 0
frontend/scripts/code_generation/generate.cmd

@@ -0,0 +1,27 @@
+@echo off
+
+REM Store the current working directory
+set "original_dir=%CD%"
+
+REM Change the current working directory to the script's location
+cd /d "%~dp0"
+
+REM Call the script in the 'language_files' folder
+echo Generating files using easy_localization
+cd language_files
+REM Allow execution permissions on CI
+chmod +x generate_language_files.cmd
+call generate_language_files.cmd %*
+
+REM Return to the main script directory
+cd ..
+
+REM Call the script in the 'freezed' folder
+echo Generating files using build_runner
+cd freezed
+REM Allow execution permissions on CI
+chmod +x generate_freezed.cmd
+call generate_freezed.cmd %*
+
+REM Return to the original directory
+cd /d "%original_dir%"

+ 27 - 0
frontend/scripts/code_generation/generate.sh

@@ -0,0 +1,27 @@
+#!/bin/bash
+
+# Store the current working directory
+original_dir=$(pwd)
+
+# Change the current working directory to the script's location
+cd "$(dirname "$0")"
+
+# Call the script in the 'language_files' folder
+echo "Generating files using easy_localization"
+cd language_files
+# Allow execution permissions on CI
+chmod +x ./generate_language_files.sh
+./generate_language_files.sh "$@"
+
+# Return to the main script directory
+cd ..
+
+# Call the script in the 'freezed' folder
+echo "Generating files using build_runner"
+cd freezed
+# Allow execution permissions on CI
+chmod +x ./generate_freezed.sh
+./generate_freezed.sh "$@"
+
+# Return to the original directory
+cd "$original_dir"

+ 26 - 0
frontend/scripts/code_generation/language_files/generate_language_files.cmd

@@ -0,0 +1,26 @@
+@echo off
+
+echo 'Generating language files'
+
+REM Store the current working directory
+set "original_dir=%CD%"
+
+REM Change the current working directory to the script's location
+cd /d "%~dp0"
+
+cd ..\..\..\appflowy_flutter
+
+call flutter clean
+
+call flutter packages pub get
+
+echo Specifying source directory for AppFlowy Localizations.
+call dart run easy_localization:generate -S assets/translations/
+
+echo Generating language files for AppFlowy.
+call dart run easy_localization:generate -f keys -o locale_keys.g.dart -S assets/translations/ -s en.json
+
+echo Done generating language files.
+
+REM Return to the original directory
+cd /d "%original_dir%"

+ 26 - 0
frontend/scripts/code_generation/language_files/generate_language_files.sh

@@ -0,0 +1,26 @@
+#!/bin/bash
+
+echo "Generating language files"
+
+# Store the current working directory
+original_dir=$(pwd)
+
+cd "$(dirname "$0")"
+
+# Navigate to the project root
+cd ../../../appflowy_flutter
+
+flutter clean
+
+flutter packages pub get
+
+echo "Specifying source directory for AppFlowy Localizations."
+dart run easy_localization:generate -S assets/translations/
+
+echo "Generating language files for AppFlowy."
+dart run easy_localization:generate -f keys -o locale_keys.g.dart -S assets/translations/ -s en.json
+
+echo "Done generating language files."
+
+# Return to the original directory
+cd "$original_dir"

+ 0 - 5
frontend/scripts/generate_language_files.cmd

@@ -1,5 +0,0 @@
-echo 'Generating language files'
-cd appflowy_flutter
-
-call dart run easy_localization:generate -S assets/translations/
-call dart run easy_localization:generate -f keys -o locale_keys.g.dart -S assets/translations/ -s en.json

+ 0 - 6
frontend/scripts/generate_language_files.sh

@@ -1,6 +0,0 @@
-#!/bin/sh
-#!/usr/bin/env fish
-echo 'Generating language files'
-cd appflowy_flutter
-dart run easy_localization:generate -S assets/translations/
-dart run easy_localization:generate -f keys -o locale_keys.g.dart -S assets/translations -s en.json

+ 4 - 12
frontend/scripts/makefile/flutter.toml

@@ -186,28 +186,20 @@ script_runner = "@duckscript"
 script_runner = "@shell"
 script = [
   """
-  cd appflowy_flutter
-  flutter clean
-  flutter pub get
-  flutter packages pub get
-  dart run easy_localization:generate -S assets/translations/ -f keys -o locale_keys.g.dart -S assets/translations -s en.json
-  dart run build_runner build -d
+  chmod +x scripts/code_generation/generate.sh
   """,
+  "scripts/code_generation/generate.sh"
 ]
 
 [tasks.code_generation.windows]
 script_runner = "@duckscript"
 script = [
   """
-  cd ./appflowy_flutter/
-  exec cmd.exe /c flutter clean
-  exec cmd.exe /c flutter pub get
-  exec cmd.exe /c flutter packages pub get
-  exec cmd.exe /c dart run easy_localization:generate -S assets/translations/ -f keys -o locale_keys.g.dart -S assets/translations -s en.json
-  exec cmd.exe /c dart run build_runner build -d
+  exec "scripts/code_generation/generate.cmd"
   """,
 ]
 
+
 [tasks.dry_code_generation]
 script_runner = "@shell"
 script = [