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 <hyj891204@gmail.com>
Co-authored-by: nathan <nathan@appflowy.io>
This commit is contained in:
Alex Wallen 2023-07-03 04:07:11 -10:00 committed by GitHub
parent 453be8352a
commit 8dfbfe3c42
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
50 changed files with 1417 additions and 134 deletions

View File

@ -118,15 +118,14 @@
{
"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"
}
},
{
"label": "AF: Generate Language Files",
"type": "shell",
"command": "sh ./scripts/generate_language_files.sh",
"cwd": "${workspaceFolder}"
},
"group": {
"kind": "build",
"isDefault": true
},
"windows": {
"options": {
"shell": {
@ -134,7 +133,24 @@
"args": [
"/d",
"/c",
".\\scripts\\generate_language_files.cmd"
".\\scripts\\code_generation\\freezed\\generate_freezed.cmd"
]
}
}
},
},
{
"label": "AF: Generate Language Files",
"type": "shell",
"command": "sh ./scripts/code_generation/language_files/generate_language_files.sh",
"windows": {
"options": {
"shell": {
"executable": "cmd.exe",
"args": [
"/d",
"/c",
".\\scripts\\code_generation\\language_files\\generate_language_files.cmd"
]
}
}

View File

@ -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",

View File

@ -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,

View File

@ -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';

View File

@ -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(),
),

View File

@ -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) {

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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),

View File

@ -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';

View File

@ -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: () {},
),
popupBuilder: (BuildContext context) {
return IntrinsicWidth(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_themeItemButton(context, BuiltInTheme.defaultTheme),
_themeItemButton(context, BuiltInTheme.dandelion),
_themeItemButton(context, BuiltInTheme.lavender),
],
),
);
},
),
ThemeUploadOverlayButton(bloc: bloc),
const SizedBox(width: 4),
ThemeSelectionPopover(currentTheme: currentTheme, bloc: bloc),
],
);
}
}
Widget _themeItemButton(BuildContext context, String theme) {
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(),
),
),
).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: 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) {

View File

@ -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';

View File

@ -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';

View File

@ -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),
),
),
],
),
);
}
}

View File

@ -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()),
),
);
}
}

View File

@ -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,
),
),
);
}
}

View File

@ -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),
],
),
);
}
}

View File

@ -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(),
),
],
),
);
}
}

View File

@ -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,
),
),
),
);
}
}

View File

@ -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(),
],
),
);
}
}

View File

@ -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;
}
}
}

View File

@ -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);
}

View File

@ -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;
}
}

View File

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

View File

@ -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),
);
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

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

View File

@ -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,
);
}
}

View File

@ -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));
}
}

View File

@ -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));
}
}
}
}

View File

@ -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.');
}
}

View File

@ -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)}";
}

View File

@ -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

View File

@ -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)),

View File

@ -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:

View File

@ -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

View File

@ -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);
},
);
});
}

View File

@ -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,
);
});
});
}

View File

@ -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%"

View File

@ -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"

View File

@ -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%"

View File

@ -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"

View File

@ -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%"

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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 = [