Merge branch 'main' into feat/markdown_syntax_to_code_text

This commit is contained in:
Lucas.Xu 2022-10-01 19:35:08 +08:00 committed by GitHub
commit 3156568594
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
101 changed files with 1782 additions and 687 deletions

View File

@ -1,5 +1,30 @@
# Release Notes # Release Notes
## Version 0.0.5.3 - 09/26/2022
New features
- Open the next page automatically after deleting the current page
- Refresh the Kanban board after altering a property type
### Bug Fixes
- Fix switch board bug
- Fix delete the Kanban board's row error
- Remove duplicate time format
- Fix can't delete field in property edit panel
- Adjust some display UI issues
## Version 0.0.5.2 - 09/16/2022
New features
- Enable adding a new card to the "No Status" group
- Fix some bugs
### Bug Fixes
- Fix cannot open AppFlowy error
- Fix delete the Kanban board's row error
## Version 0.0.5.1 - 09/14/2022 ## Version 0.0.5.1 - 09/14/2022
New features New features

View File

@ -199,7 +199,8 @@
"delete": "Delete", "delete": "Delete",
"textPlaceholder": "Empty", "textPlaceholder": "Empty",
"copyProperty": "Copied property to clipboard", "copyProperty": "Copied property to clipboard",
"count": "Count" "count": "Count",
"newRow": "New row"
}, },
"selectOption": { "selectOption": {
"create": "Create", "create": "Create",

View File

@ -149,7 +149,7 @@
"grid": { "grid": {
"settings": { "settings": {
"filter": "Filtrer", "filter": "Filtrer",
"sortBy": "Trier par", "sortBy": "Filtrer par",
"Properties": "Propriétés" "Properties": "Propriétés"
}, },
"field": { "field": {

View File

@ -94,7 +94,14 @@
}, },
"tooltip": { "tooltip": {
"lightMode": "切换到亮色模式", "lightMode": "切换到亮色模式",
"darkMode": "切换到暗色模式" "darkMode": "切换到暗色模式",
"openAsPage": "作为页面打开",
"addNewRow": "增加一行",
"openMenu": "点击打开菜单"
},
"sideBar": {
"openSidebar": "打开侧边栏",
"closeSidebar": "关闭侧边栏"
}, },
"notifications": { "notifications": {
"export": { "export": {
@ -149,15 +156,12 @@
"darkLabel": "夜间模式" "darkLabel": "夜间模式"
} }
}, },
"sideBar": {
"openSidebar": "打开侧边栏",
"closeSidebar": "关闭侧边栏"
},
"grid": { "grid": {
"settings": { "settings": {
"filter": "过滤器", "filter": "过滤器",
"sortBy": "排序", "sortBy": "排序",
"Properties": "属性" "Properties": "属性",
"group": "组"
}, },
"field": { "field": {
"hide": "隐藏", "hide": "隐藏",
@ -186,13 +190,17 @@
"addSelectOption": "添加一个标签", "addSelectOption": "添加一个标签",
"optionTitle": "标签", "optionTitle": "标签",
"addOption": "添加标签", "addOption": "添加标签",
"editProperty": "编辑列属性" "editProperty": "编辑列属性",
"newColumn": "增加一列",
"deleteFieldPromptMessage": "确定要删除这个属性吗? "
}, },
"row": { "row": {
"duplicate": "复制", "duplicate": "复制",
"delete": "删除", "delete": "删除",
"textPlaceholder": "空", "textPlaceholder": "空",
"copyProperty": "复制列" "copyProperty": "复制列",
"count": "数量",
"newRow": "添加一行"
}, },
"selectOption": { "selectOption": {
"create": "新建", "create": "新建",
@ -218,5 +226,11 @@
"timeHintTextInTwelveHour": "01:00 PM", "timeHintTextInTwelveHour": "01:00 PM",
"timeHintTextInTwentyFourHour": "13:00" "timeHintTextInTwentyFourHour": "13:00"
} }
},
"board": {
"column": {
"create_new_card": "新建"
},
"menuName": "看板"
} }
} }

View File

@ -36,21 +36,21 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
super(BoardState.initial(view.id)) { super(BoardState.initial(view.id)) {
boardController = AppFlowyBoardController( boardController = AppFlowyBoardController(
onMoveGroup: ( onMoveGroup: (
fromColumnId, fromGroupId,
fromIndex, fromIndex,
toColumnId, toGroupId,
toIndex, toIndex,
) { ) {
_moveGroup(fromColumnId, toColumnId); _moveGroup(fromGroupId, toGroupId);
}, },
onMoveGroupItem: ( onMoveGroupItem: (
columnId, groupId,
fromIndex, fromIndex,
toIndex, toIndex,
) { ) {
final fromRow = groupControllers[columnId]?.rowAtIndex(fromIndex); final fromRow = groupControllers[groupId]?.rowAtIndex(fromIndex);
final toRow = groupControllers[columnId]?.rowAtIndex(toIndex); final toRow = groupControllers[groupId]?.rowAtIndex(toIndex);
_moveRow(fromRow, columnId, toRow); _moveRow(fromRow, groupId, toRow);
}, },
onMoveGroupItemToGroup: ( onMoveGroupItemToGroup: (
fromGroupId, fromGroupId,

View File

@ -68,10 +68,12 @@ class GridPluginDisplay extends PluginDisplay {
@override @override
Widget buildWidget(PluginContext context) { Widget buildWidget(PluginContext context) {
notifier.isDeleted.addListener(() { notifier.isDeleted.addListener(() {
if (notifier.isDeleted.value) { notifier.isDeleted.value.fold(() => null, (deletedView) {
context.onDeleted(view); if (deletedView.hasIndex()) {
context.onDeleted(view, deletedView.index);
} }
}); });
});
return BoardPage(key: ValueKey(view.id), view: view); return BoardPage(key: ValueKey(view.id), view: view);
} }

View File

@ -69,7 +69,6 @@ class BoardContent extends StatefulWidget {
class _BoardContentState extends State<BoardContent> { class _BoardContentState extends State<BoardContent> {
late AppFlowyBoardScrollController scrollManager; late AppFlowyBoardScrollController scrollManager;
final Map<String, ValueKey> cardKeysCache = {};
final config = AppFlowyBoardConfig( final config = AppFlowyBoardConfig(
groupBackgroundColor: HexColor.fromHex('#F7F8FC'), groupBackgroundColor: HexColor.fromHex('#F7F8FC'),
@ -104,8 +103,8 @@ class _BoardContentState extends State<BoardContent> {
Widget _buildBoard(BuildContext context) { Widget _buildBoard(BuildContext context) {
return ChangeNotifierProvider.value( return ChangeNotifierProvider.value(
value: Provider.of<AppearanceSettingModel>(context, listen: true), value: Provider.of<AppearanceSetting>(context, listen: true),
child: Selector<AppearanceSettingModel, AppTheme>( child: Selector<AppearanceSetting, AppTheme>(
selector: (ctx, notifier) => notifier.theme, selector: (ctx, notifier) => notifier.theme,
builder: (ctx, theme, child) => Expanded( builder: (ctx, theme, child) => Expanded(
child: AppFlowyBoard( child: AppFlowyBoard(
@ -139,7 +138,7 @@ class _BoardContentState extends State<BoardContent> {
.read<BoardBloc>() .read<BoardBloc>()
.add(BoardEvent.endEditRow(editingRow.row.id)); .add(BoardEvent.endEditRow(editingRow.row.id));
} else { } else {
scrollManager.scrollToBottom(editingRow.columnId, () { scrollManager.scrollToBottom(editingRow.columnId, (boardContext) {
context context
.read<BoardBloc>() .read<BoardBloc>()
.add(BoardEvent.endEditRow(editingRow.row.id)); .add(BoardEvent.endEditRow(editingRow.row.id));
@ -247,15 +246,8 @@ class _BoardContentState extends State<BoardContent> {
); );
final groupItemId = columnItem.id + group.id; final groupItemId = columnItem.id + group.id;
ValueKey? key = cardKeysCache[groupItemId];
if (key == null) {
final newKey = ValueKey(groupItemId);
cardKeysCache[groupItemId] = newKey;
key = newKey;
}
return AppFlowyGroupCard( return AppFlowyGroupCard(
key: key, key: ValueKey(groupItemId),
margin: config.cardPadding, margin: config.cardPadding,
decoration: _makeBoxDecoration(context), decoration: _makeBoxDecoration(context),
child: BoardCard( child: BoardCard(
@ -331,8 +323,8 @@ class _ToolbarBlocAdaptor extends StatelessWidget {
); );
return ChangeNotifierProvider.value( return ChangeNotifierProvider.value(
value: Provider.of<AppearanceSettingModel>(context, listen: true), value: Provider.of<AppearanceSetting>(context, listen: true),
child: Selector<AppearanceSettingModel, AppTheme>( child: Selector<AppearanceSetting, AppTheme>(
selector: (ctx, notifier) => notifier.theme, selector: (ctx, notifier) => notifier.theme,
builder: (ctx, theme, child) { builder: (ctx, theme, child) {
return BoardToolbar(toolbarContext: toolbarContext); return BoardToolbar(toolbarContext: toolbarContext);

View File

@ -74,15 +74,26 @@ class DocumentPlugin extends Plugin<int> {
class DocumentPluginDisplay extends PluginDisplay with NavigationItem { class DocumentPluginDisplay extends PluginDisplay with NavigationItem {
final ViewPluginNotifier notifier; final ViewPluginNotifier notifier;
ViewPB get view => notifier.view; ViewPB get view => notifier.view;
int? deletedViewIndex;
DocumentPluginDisplay({required this.notifier, Key? key}); DocumentPluginDisplay({required this.notifier, Key? key});
@override @override
Widget buildWidget(PluginContext context) => DocumentPage( Widget buildWidget(PluginContext context) {
notifier.isDeleted.addListener(() {
notifier.isDeleted.value.fold(() => null, (deletedView) {
if (deletedView.hasIndex()) {
deletedViewIndex = deletedView.index;
}
});
});
return DocumentPage(
view: view, view: view,
onDeleted: () => context.onDeleted(view), onDeleted: () => context.onDeleted(view, deletedViewIndex),
key: ValueKey(view.id), key: ValueKey(view.id),
); );
}
@override @override
Widget get leftBarItem => ViewLeftBarItem(view: view); Widget get leftBarItem => ViewLeftBarItem(view: view);
@ -120,8 +131,8 @@ class DocumentShareButton extends StatelessWidget {
child: BlocBuilder<DocShareBloc, DocShareState>( child: BlocBuilder<DocShareBloc, DocShareState>(
builder: (context, state) { builder: (context, state) {
return ChangeNotifierProvider.value( return ChangeNotifierProvider.value(
value: Provider.of<AppearanceSettingModel>(context, listen: true), value: Provider.of<AppearanceSetting>(context, listen: true),
child: Selector<AppearanceSettingModel, Locale>( child: Selector<AppearanceSetting, Locale>(
selector: (ctx, notifier) => notifier.locale, selector: (ctx, notifier) => notifier.locale,
builder: (ctx, _, child) => ConstrainedBox( builder: (ctx, _, child) => ConstrainedBox(
constraints: const BoxConstraints.expand( constraints: const BoxConstraints.expand(

View File

@ -134,7 +134,7 @@ class _DocumentPageState extends State<DocumentPage> {
Widget _renderToolbar(quill.QuillController controller) { Widget _renderToolbar(quill.QuillController controller) {
return ChangeNotifierProvider.value( return ChangeNotifierProvider.value(
value: Provider.of<AppearanceSettingModel>(context, listen: true), value: Provider.of<AppearanceSetting>(context, listen: true),
child: EditorToolbar.basic( child: EditorToolbar.basic(
controller: controller, controller: controller,
), ),

View File

@ -11,7 +11,10 @@ class NumberFormatBloc extends Bloc<NumberFormatEvent, NumberFormatState> {
event.map(setFilter: (_SetFilter value) { event.map(setFilter: (_SetFilter value) {
final List<NumberFormat> formats = List.from(NumberFormat.values); final List<NumberFormat> formats = List.from(NumberFormat.values);
if (value.filter.isNotEmpty) { if (value.filter.isNotEmpty) {
formats.retainWhere((element) => element.title().toLowerCase().contains(value.filter.toLowerCase())); formats.retainWhere((element) => element
.title()
.toLowerCase()
.contains(value.filter.toLowerCase()));
} }
emit(state.copyWith(formats: formats, filter: value.filter)); emit(state.copyWith(formats: formats, filter: value.filter));
}); });
@ -91,7 +94,7 @@ extension NumberFormatExtension on NumberFormat {
case NumberFormat.Percent: case NumberFormat.Percent:
return "Percent"; return "Percent";
case NumberFormat.PhilippinePeso: case NumberFormat.PhilippinePeso:
return "Percent"; return "PhilippinePeso";
case NumberFormat.Pound: case NumberFormat.Pound:
return "Pound"; return "Pound";
case NumberFormat.Rand: case NumberFormat.Rand:

View File

@ -70,10 +70,12 @@ class GridPluginDisplay extends PluginDisplay {
@override @override
Widget buildWidget(PluginContext context) { Widget buildWidget(PluginContext context) {
notifier.isDeleted.addListener(() { notifier.isDeleted.addListener(() {
if (notifier.isDeleted.value) { notifier.isDeleted.value.fold(() => null, (deletedView) {
context.onDeleted(view); if (deletedView.hasIndex()) {
context.onDeleted(view, deletedView.index);
} }
}); });
});
return GridPage(key: ValueKey(view.id), view: view); return GridPage(key: ValueKey(view.id), view: view);
} }

View File

@ -97,7 +97,6 @@ class GridURLCell extends GridCellWidget {
class _GridURLCellState extends GridCellState<GridURLCell> { class _GridURLCellState extends GridCellState<GridURLCell> {
final _popoverController = PopoverController(); final _popoverController = PopoverController();
GridURLCellController? _cellContext;
late URLCellBloc _cellBloc; late URLCellBloc _cellBloc;
@override @override
@ -132,6 +131,7 @@ class _GridURLCellState extends GridCellState<GridURLCell> {
controller: _popoverController, controller: _popoverController,
constraints: BoxConstraints.loose(const Size(300, 160)), constraints: BoxConstraints.loose(const Size(300, 160)),
direction: PopoverDirection.bottomWithLeftAligned, direction: PopoverDirection.bottomWithLeftAligned,
triggerActions: PopoverTriggerFlags.none,
offset: const Offset(0, 20), offset: const Offset(0, 20),
child: SizedBox.expand( child: SizedBox.expand(
child: GestureDetector( child: GestureDetector(
@ -144,7 +144,8 @@ class _GridURLCellState extends GridCellState<GridURLCell> {
), ),
popupBuilder: (BuildContext popoverContext) { popupBuilder: (BuildContext popoverContext) {
return URLEditorPopover( return URLEditorPopover(
cellController: _cellContext!, cellController: widget.cellControllerBuilder.build()
as GridURLCellController,
); );
}, },
onClose: () { onClose: () {
@ -166,17 +167,13 @@ class _GridURLCellState extends GridCellState<GridURLCell> {
final uri = Uri.parse(url); final uri = Uri.parse(url);
if (url.isNotEmpty && await canLaunchUrl(uri)) { if (url.isNotEmpty && await canLaunchUrl(uri)) {
await launchUrl(uri); await launchUrl(uri);
} else {
_cellContext =
widget.cellControllerBuilder.build() as GridURLCellController;
widget.onCellEditing.value = true;
_popoverController.show();
} }
} }
@override @override
void requestBeginFocus() { void requestBeginFocus() {
_openUrlOrEdit(_cellBloc.state.url); widget.onCellEditing.value = true;
_popoverController.show();
} }
@override @override

View File

@ -1,4 +1,6 @@
import 'package:app_flowy/generated/locale_keys.g.dart';
import 'package:app_flowy/plugins/grid/application/grid_bloc.dart'; import 'package:app_flowy/plugins/grid/application/grid_bloc.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/image.dart'; import 'package:flowy_infra/image.dart';
import 'package:flowy_infra/theme.dart'; import 'package:flowy_infra/theme.dart';
import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/button.dart';
@ -13,7 +15,7 @@ class GridAddRowButton extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = context.watch<AppTheme>(); final theme = context.watch<AppTheme>();
return FlowyButton( return FlowyButton(
text: const FlowyText.medium('New row', fontSize: 12), text: FlowyText.medium(LocaleKeys.grid_row_newRow.tr(), fontSize: 12),
hoverColor: theme.shader6, hoverColor: theme.shader6,
onTap: () => context.read<GridBloc>().add(const GridEvent.createRow()), onTap: () => context.read<GridBloc>().add(const GridEvent.createRow()),
leftIcon: svgWidget("home/add", color: theme.iconColor), leftIcon: svgWidget("home/add", color: theme.iconColor),

View File

@ -33,8 +33,8 @@ class MenuTrash extends StatelessWidget {
Widget _render(BuildContext context) { Widget _render(BuildContext context) {
return Row(children: [ return Row(children: [
ChangeNotifierProvider.value( ChangeNotifierProvider.value(
value: Provider.of<AppearanceSettingModel>(context, listen: true), value: Provider.of<AppearanceSetting>(context, listen: true),
child: Selector<AppearanceSettingModel, AppTheme>( child: Selector<AppearanceSetting, AppTheme>(
selector: (ctx, notifier) => notifier.theme, selector: (ctx, notifier) => notifier.theme,
builder: (ctx, theme, child) => SizedBox( builder: (ctx, theme, child) => SizedBox(
width: 16, width: 16,
@ -44,8 +44,8 @@ class MenuTrash extends StatelessWidget {
), ),
const HSpace(6), const HSpace(6),
ChangeNotifierProvider.value( ChangeNotifierProvider.value(
value: Provider.of<AppearanceSettingModel>(context, listen: true), value: Provider.of<AppearanceSetting>(context, listen: true),
child: Selector<AppearanceSettingModel, Locale>( child: Selector<AppearanceSetting, Locale>(
selector: (ctx, notifier) => notifier.locale, selector: (ctx, notifier) => notifier.locale,
builder: (ctx, _, child) => builder: (ctx, _, child) =>
FlowyText.medium(LocaleKeys.trash_text.tr(), fontSize: 12), FlowyText.medium(LocaleKeys.trash_text.tr(), fontSize: 12),

View File

@ -1,15 +1,16 @@
import 'package:app_flowy/startup/plugin/plugin.dart'; import 'package:app_flowy/startup/plugin/plugin.dart';
import 'package:app_flowy/workspace/application/view/view_listener.dart'; import 'package:app_flowy/workspace/application/view/view_listener.dart';
import 'package:dartz/dartz.dart';
import 'package:flowy_sdk/log.dart'; import 'package:flowy_sdk/log.dart';
import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class ViewPluginNotifier extends PluginNotifier { class ViewPluginNotifier extends PluginNotifier<Option<DeletedViewPB>> {
final ViewListener? _viewListener; final ViewListener? _viewListener;
ViewPB view; ViewPB view;
@override @override
final ValueNotifier<bool> isDeleted = ValueNotifier(false); final ValueNotifier<Option<DeletedViewPB>> isDeleted = ValueNotifier(none());
@override @override
final ValueNotifier<int> isDisplayChanged = ValueNotifier(0); final ValueNotifier<int> isDisplayChanged = ValueNotifier(0);
@ -27,9 +28,7 @@ class ViewPluginNotifier extends PluginNotifier {
); );
}, onViewMoveToTrash: (result) { }, onViewMoveToTrash: (result) {
result.fold( result.fold(
(deletedView) { (deletedView) => isDeleted.value = some(deletedView),
isDeleted.value = true;
},
(err) => Log.error(err), (err) => Log.error(err),
); );
}); });

View File

@ -32,9 +32,9 @@ abstract class Plugin<T> {
} }
} }
abstract class PluginNotifier { abstract class PluginNotifier<T> {
/// Notify if the plugin get deleted /// Notify if the plugin get deleted
ValueNotifier<bool> get isDeleted; ValueNotifier<T> get isDeleted;
/// Notify if the [PluginDisplay]'s content was changed /// Notify if the [PluginDisplay]'s content was changed
ValueNotifier<int> get isDisplayChanged; ValueNotifier<int> get isDisplayChanged;
@ -67,7 +67,7 @@ abstract class PluginDisplay with NavigationItem {
class PluginContext { class PluginContext {
// calls when widget of the plugin get deleted // calls when widget of the plugin get deleted
final Function(ViewPB) onDeleted; final Function(ViewPB, int?) onDeleted;
PluginContext({required this.onDeleted}); PluginContext({required this.onDeleted});
} }

View File

@ -17,8 +17,8 @@ class InitAppWidgetTask extends LaunchTask {
@override @override
Future<void> initialize(LaunchContext context) async { Future<void> initialize(LaunchContext context) async {
final widget = context.getIt<EntryPoint>().create(); final widget = context.getIt<EntryPoint>().create();
final setting = await UserSettingsService().getAppearanceSettings(); final setting = await SettingsFFIService().getAppearanceSetting();
final settingModel = AppearanceSettingModel(setting); final settingModel = AppearanceSetting(setting);
final app = ApplicationWidget( final app = ApplicationWidget(
settingModel: settingModel, settingModel: settingModel,
child: widget, child: widget,
@ -58,7 +58,7 @@ class InitAppWidgetTask extends LaunchTask {
class ApplicationWidget extends StatelessWidget { class ApplicationWidget extends StatelessWidget {
final Widget child; final Widget child;
final AppearanceSettingModel settingModel; final AppearanceSetting settingModel;
const ApplicationWidget({ const ApplicationWidget({
Key? key, Key? key,
@ -75,10 +75,10 @@ class ApplicationWidget extends StatelessWidget {
const minWidth = 600.0; const minWidth = 600.0;
setWindowMinSize(const Size(minWidth, minWidth / ratio)); setWindowMinSize(const Size(minWidth, minWidth / ratio));
settingModel.readLocaleWhenAppLaunch(context); settingModel.readLocaleWhenAppLaunch(context);
AppTheme theme = context.select<AppearanceSettingModel, AppTheme>( AppTheme theme = context.select<AppearanceSetting, AppTheme>(
(value) => value.theme, (value) => value.theme,
); );
Locale locale = context.select<AppearanceSettingModel, Locale>( Locale locale = context.select<AppearanceSetting, Locale>(
(value) => value.locale, (value) => value.locale,
); );

View File

@ -4,8 +4,8 @@ import 'package:flowy_sdk/flowy_sdk.dart';
import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart';
import 'package:flowy_sdk/protobuf/flowy-user/user_setting.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-user/user_setting.pb.dart';
class UserSettingsService { class SettingsFFIService {
Future<AppearanceSettingsPB> getAppearanceSettings() async { Future<AppearanceSettingsPB> getAppearanceSetting() async {
final result = await UserEventGetAppearanceSetting().send(); final result = await UserEventGetAppearanceSetting().send();
return result.fold( return result.fold(
@ -18,7 +18,8 @@ class UserSettingsService {
); );
} }
Future<Either<Unit, FlowyError>> setAppearanceSettings(AppearanceSettingsPB settings) { Future<Either<Unit, FlowyError>> setAppearanceSetting(
return UserEventSetAppearanceSetting(settings).send(); AppearanceSettingsPB setting) {
return UserEventSetAppearanceSetting(setting).send();
} }
} }

View File

@ -8,72 +8,114 @@ import 'package:flowy_sdk/protobuf/flowy-user/user_setting.pb.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
class AppearanceSettingModel extends ChangeNotifier with EquatableMixin { /// [AppearanceSetting] is used to modify the appear setting of AppFlowy application. Including the [Locale], [AppTheme], etc.
AppearanceSettingsPB setting; class AppearanceSetting extends ChangeNotifier with EquatableMixin {
final AppearanceSettingsPB _setting;
AppTheme _theme; AppTheme _theme;
Locale _locale; Locale _locale;
Timer? _saveOperation; Timer? _debounceSaveOperation;
AppearanceSettingModel(this.setting) AppearanceSetting(AppearanceSettingsPB setting)
: _theme = AppTheme.fromName(name: setting.theme), : _setting = setting,
_locale = _theme = AppTheme.fromName(name: setting.theme),
Locale(setting.locale.languageCode, setting.locale.countryCode); _locale = Locale(
setting.locale.languageCode,
setting.locale.countryCode,
);
/// Returns the current [AppTheme]
AppTheme get theme => _theme; AppTheme get theme => _theme;
/// Returns the current [Locale]
Locale get locale => _locale; Locale get locale => _locale;
Future<void> save() async { /// Updates the current theme and notify the listeners the theme was changed.
_saveOperation?.cancel(); /// Do nothing if the passed in themeType equal to the current theme type.
_saveOperation = Timer(const Duration(seconds: 2), () async { ///
await UserSettingsService().setAppearanceSettings(setting); void setTheme(ThemeType themeType) {
}); if (_theme.ty == themeType) {
return;
} }
@override
List<Object> get props {
return [setting.hashCode];
}
void swapTheme() {
final themeType =
(_theme.ty == ThemeType.light ? ThemeType.dark : ThemeType.light);
if (_theme.ty != themeType) {
_theme = AppTheme.fromType(themeType); _theme = AppTheme.fromType(themeType);
setting.theme = themeTypeToString(themeType); _setting.theme = themeTypeToString(themeType);
_saveAppearSetting();
notifyListeners(); notifyListeners();
save();
}
} }
/// Updates the current locale and notify the listeners the locale was changed
/// Fallback to [en] locale If the newLocale is not supported.
///
void setLocale(BuildContext context, Locale newLocale) { void setLocale(BuildContext context, Locale newLocale) {
if (!context.supportedLocales.contains(newLocale)) { if (!context.supportedLocales.contains(newLocale)) {
Log.warn("Unsupported locale: $newLocale"); Log.warn("Unsupported locale: $newLocale, Fallback to locale: en");
newLocale = const Locale('en'); newLocale = const Locale('en');
Log.debug("Fallback to locale: $newLocale");
} }
context.setLocale(newLocale); context.setLocale(newLocale);
if (_locale != newLocale) { if (_locale != newLocale) {
_locale = newLocale; _locale = newLocale;
setting.locale.languageCode = _locale.languageCode; _setting.locale.languageCode = _locale.languageCode;
setting.locale.countryCode = _locale.countryCode ?? ""; _setting.locale.countryCode = _locale.countryCode ?? "";
_saveAppearSetting();
notifyListeners(); notifyListeners();
save();
} }
} }
/// Saves key/value setting to disk.
/// Removes the key if the passed in value is null
void setKeyValue(String key, String? value) {
if (key.isEmpty) {
Log.warn("The key should not be empty");
return;
}
if (value == null) {
_setting.settingKeyValue.remove(key);
}
if (_setting.settingKeyValue[key] != value) {
if (value == null) {
_setting.settingKeyValue.remove(key);
} else {
_setting.settingKeyValue[key] = value;
}
_saveAppearSetting();
notifyListeners();
}
}
/// Called when the application launch.
/// Uses the device locale when open the application for the first time
void readLocaleWhenAppLaunch(BuildContext context) { void readLocaleWhenAppLaunch(BuildContext context) {
if (setting.resetAsDefault) { if (_setting.resetToDefault) {
setting.resetAsDefault = false; _setting.resetToDefault = false;
save(); _saveAppearSetting();
setLocale(context, context.deviceLocale); setLocale(context, context.deviceLocale);
return; return;
} }
// when opening app the first time
setLocale(context, _locale); setLocale(context, _locale);
} }
Future<void> _saveAppearSetting() async {
_debounceSaveOperation?.cancel();
_debounceSaveOperation = Timer(
const Duration(seconds: 1),
() {
SettingsFFIService().setAppearanceSetting(_setting).then((result) {
result.fold((l) => null, (error) => Log.error(error));
});
},
);
}
@override
List<Object> get props {
return [_setting.hashCode];
}
} }

View File

@ -16,7 +16,7 @@ typedef UpdateViewNotifiedValue = Either<ViewPB, FlowyError>;
// Restore the view from trash // Restore the view from trash
typedef RestoreViewNotifiedValue = Either<ViewPB, FlowyError>; typedef RestoreViewNotifiedValue = Either<ViewPB, FlowyError>;
// Move the view to trash // Move the view to trash
typedef MoveToTrashNotifiedValue = Either<ViewIdPB, FlowyError>; typedef MoveToTrashNotifiedValue = Either<DeletedViewPB, FlowyError>;
class ViewListener { class ViewListener {
StreamSubscription<SubscribeObject>? _subscription; StreamSubscription<SubscribeObject>? _subscription;
@ -98,8 +98,8 @@ class ViewListener {
break; break;
case FolderNotification.ViewMoveToTrash: case FolderNotification.ViewMoveToTrash:
result.fold( result.fold(
(payload) => (payload) => _moveToTrashNotifier.value =
_moveToTrashNotifier.value = left(ViewIdPB.fromBuffer(payload)), left(DeletedViewPB.fromBuffer(payload)),
(error) => _moveToTrashNotifier.value = right(error), (error) => _moveToTrashNotifier.value = right(error),
); );
break; break;

View File

@ -34,19 +34,6 @@ class HomeScreen extends StatefulWidget {
} }
class _HomeScreenState extends State<HomeScreen> { class _HomeScreenState extends State<HomeScreen> {
ViewPB? initialView;
@override
void initState() {
super.initState();
}
@override
void didUpdateWidget(covariant HomeScreen oldWidget) {
initialView = null;
super.didUpdateWidget(oldWidget);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MultiBlocProvider( return MultiBlocProvider(
@ -129,26 +116,29 @@ class _HomeScreenState extends State<HomeScreen> {
required BuildContext context, required BuildContext context,
required HomeState state}) { required HomeState state}) {
final workspaceSetting = state.workspaceSetting; final workspaceSetting = state.workspaceSetting;
if (initialView == null && workspaceSetting.hasLatestView()) {
initialView = workspaceSetting.latestView;
final plugin = makePlugin(
pluginType: initialView!.pluginType,
data: initialView,
);
getIt<HomeStackManager>().setPlugin(plugin);
}
final homeMenu = HomeMenu( final homeMenu = HomeMenu(
user: widget.user, user: widget.user,
workspaceSetting: workspaceSetting, workspaceSetting: workspaceSetting,
collapsedNotifier: getIt<HomeStackManager>().collapsedNotifier, collapsedNotifier: getIt<HomeStackManager>().collapsedNotifier,
); );
final latestView = // Only open the last opened view if the [HomeStackManager] current opened
workspaceSetting.hasLatestView() ? workspaceSetting.latestView : null; // plugin is blank and the last opened view is not null.
if (getIt<MenuSharedState>().latestOpenView == null) { //
/// AppFlowy will open the view that the last time the user opened it. The _buildHomeMenu will get called when AppFlowy's screen resizes. So we only set the latestOpenView when it's null. // All opened widgets that display on the home screen are in the form
getIt<MenuSharedState>().latestOpenView = latestView; // of plugins. There is a list of built-in plugins defined in the
// [PluginType] enum, including board, grid and trash.
if (getIt<HomeStackManager>().plugin.ty == PluginType.blank) {
// Open the last opened view.
if (workspaceSetting.hasLatestView()) {
final view = workspaceSetting.latestView;
final plugin = makePlugin(
pluginType: view.pluginType,
data: view,
);
getIt<HomeStackManager>().setPlugin(plugin);
getIt<MenuSharedState>().latestOpenView = view;
}
} }
return FocusTraversalGroup(child: RepaintBoundary(child: homeMenu)); return FocusTraversalGroup(child: RepaintBoundary(child: homeMenu));
@ -261,14 +251,18 @@ class HomeScreenStackAdaptor extends HomeStackDelegate {
}); });
@override @override
void didDeleteStackWidget(ViewPB view) { void didDeleteStackWidget(ViewPB view, int? index) {
final homeService = HomeService(); final homeService = HomeService();
homeService.readApp(appId: view.appId).then((result) { homeService.readApp(appId: view.appId).then((result) {
result.fold( result.fold(
(appPB) { (appPB) {
final List<ViewPB> views = appPB.belongings.items; final List<ViewPB> views = appPB.belongings.items;
if (views.isNotEmpty) { if (views.isNotEmpty) {
final lastView = views.last; var lastView = views.last;
if (index != null && index != 0 && views.length > index - 1) {
lastView = views[index - 1];
}
final plugin = makePlugin( final plugin = makePlugin(
pluginType: lastView.pluginType, pluginType: lastView.pluginType,
data: lastView, data: lastView,

View File

@ -18,7 +18,7 @@ import 'home_layout.dart';
typedef NavigationCallback = void Function(String id); typedef NavigationCallback = void Function(String id);
abstract class HomeStackDelegate { abstract class HomeStackDelegate {
void didDeleteStackWidget(ViewPB view); void didDeleteStackWidget(ViewPB view, int? index);
} }
class HomeStack extends StatelessWidget { class HomeStack extends StatelessWidget {
@ -41,9 +41,11 @@ class HomeStack extends StatelessWidget {
child: Container( child: Container(
color: theme.surface, color: theme.surface,
child: FocusTraversalGroup( child: FocusTraversalGroup(
child: getIt<HomeStackManager>().stackWidget(onDeleted: (view) { child: getIt<HomeStackManager>().stackWidget(
delegate.didDeleteStackWidget(view); onDeleted: (view, index) {
}), delegate.didDeleteStackWidget(view, index);
},
),
), ),
), ),
), ),
@ -144,6 +146,7 @@ class HomeStackManager {
} }
PublishNotifier<bool> get collapsedNotifier => _notifier.collapsedNotifier; PublishNotifier<bool> get collapsedNotifier => _notifier.collapsedNotifier;
Plugin get plugin => _notifier.plugin;
void setPlugin(Plugin newPlugin) { void setPlugin(Plugin newPlugin) {
_notifier.plugin = newPlugin; _notifier.plugin = newPlugin;
@ -167,7 +170,7 @@ class HomeStackManager {
); );
} }
Widget stackWidget({required Function(ViewPB) onDeleted}) { Widget stackWidget({required Function(ViewPB, int?) onDeleted}) {
return MultiProvider( return MultiProvider(
providers: [ChangeNotifierProvider.value(value: _notifier)], providers: [ChangeNotifierProvider.value(value: _notifier)],
child: Consumer(builder: (ctx, HomeStackNotifier notifier, child) { child: Consumer(builder: (ctx, HomeStackNotifier notifier, child) {

View File

@ -89,8 +89,7 @@ class _MenuAppState extends State<MenuApp> {
hasIcon: false, hasIcon: false,
), ),
header: ChangeNotifierProvider.value( header: ChangeNotifierProvider.value(
value: value: Provider.of<AppearanceSetting>(context, listen: true),
Provider.of<AppearanceSettingModel>(context, listen: true),
child: MenuAppHeader(widget.app), child: MenuAppHeader(widget.app),
), ),
expanded: ViewSection(appViewData: viewDataContext), expanded: ViewSection(appViewData: viewDataContext),

View File

@ -33,8 +33,7 @@ class SettingsDialog extends StatelessWidget {
..add(const SettingsDialogEvent.initial()), ..add(const SettingsDialogEvent.initial()),
child: BlocBuilder<SettingsDialogBloc, SettingsDialogState>( child: BlocBuilder<SettingsDialogBloc, SettingsDialogState>(
builder: (context, state) => ChangeNotifierProvider.value( builder: (context, state) => ChangeNotifierProvider.value(
value: Provider.of<AppearanceSettingModel>(context, value: Provider.of<AppearanceSetting>(context, listen: true),
listen: true),
child: FlowyDialog( child: FlowyDialog(
title: Text( title: Text(
LocaleKeys.settings_title.tr(), LocaleKeys.settings_title.tr(),

View File

@ -13,7 +13,7 @@ class SettingsAppearanceView extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = context.watch<AppTheme>(); final theme = context.read<AppTheme>();
return SingleChildScrollView( return SingleChildScrollView(
child: Column( child: Column(
@ -30,9 +30,7 @@ class SettingsAppearanceView extends StatelessWidget {
), ),
Toggle( Toggle(
value: theme.isDark, value: theme.isDark,
onChanged: (val) { onChanged: (_) => setTheme(context),
context.read<AppearanceSettingModel>().swapTheme();
},
style: ToggleStyle.big(theme), style: ToggleStyle.big(theme),
), ),
Text( Text(
@ -48,4 +46,13 @@ class SettingsAppearanceView extends StatelessWidget {
), ),
); );
} }
void setTheme(BuildContext context) {
final theme = context.read<AppTheme>();
if (theme.isDark) {
context.read<AppearanceSetting>().setTheme(ThemeType.light);
} else {
context.read<AppearanceSetting>().setTheme(ThemeType.dark);
}
}
} }

View File

@ -13,7 +13,7 @@ class SettingsLanguageView extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
context.watch<AppTheme>(); context.watch<AppTheme>();
return ChangeNotifierProvider.value( return ChangeNotifierProvider.value(
value: Provider.of<AppearanceSettingModel>(context, listen: true), value: Provider.of<AppearanceSetting>(context, listen: true),
child: SingleChildScrollView( child: SingleChildScrollView(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -43,7 +43,8 @@ class LanguageSelectorDropdown extends StatefulWidget {
}) : super(key: key); }) : super(key: key);
@override @override
State<LanguageSelectorDropdown> createState() => _LanguageSelectorDropdownState(); State<LanguageSelectorDropdown> createState() =>
_LanguageSelectorDropdownState();
} }
class _LanguageSelectorDropdownState extends State<LanguageSelectorDropdown> { class _LanguageSelectorDropdownState extends State<LanguageSelectorDropdown> {
@ -77,10 +78,10 @@ class _LanguageSelectorDropdownState extends State<LanguageSelectorDropdown> {
), ),
child: DropdownButtonHideUnderline( child: DropdownButtonHideUnderline(
child: DropdownButton<Locale>( child: DropdownButton<Locale>(
value: context.read<AppearanceSettingModel>().locale, value: context.read<AppearanceSetting>().locale,
onChanged: (val) { onChanged: (val) {
setState(() { setState(() {
context.read<AppearanceSettingModel>().setLocale(context, val!); context.read<AppearanceSetting>().setLocale(context, val!);
}); });
}, },
icon: const Visibility( icon: const Visibility(

View File

@ -1,3 +1,5 @@
# 0.0.8
* Enable drag and drop group
# 0.0.7 # 0.0.7
* Rename some classes * Rename some classes
* Add documentation * Add documentation
@ -7,7 +9,7 @@
# 0.0.5 # 0.0.5
* Optimize insert card animation * Optimize insert card animation
* Enable insert card at the end of the column * Enable insert card at the end of the group
* Fix some bugs * Fix some bugs
# 0.0.4 # 0.0.4
@ -24,6 +26,5 @@
# 0.0.1 # 0.0.1
* Support drag and drop column * Support drag and drop group items from one to another
* Support drag and drop column items from one to another

View File

@ -34,7 +34,7 @@ class _MyAppState extends State<MyApp> {
appBar: AppBar( appBar: AppBar(
title: const Text('AppFlowy Board'), title: const Text('AppFlowy Board'),
), ),
body: _examples[_currentIndex], body: Container(color: Colors.white, child: _examples[_currentIndex]),
bottomNavigationBar: BottomNavigationBar( bottomNavigationBar: BottomNavigationBar(
fixedColor: _bottomNavigationColor, fixedColor: _bottomNavigationColor,
showSelectedLabels: true, showSelectedLabels: true,

View File

@ -21,8 +21,11 @@ class _MultiBoardListExampleState extends State<MultiBoardListExample> {
}, },
); );
late AppFlowyBoardScrollController boardController;
@override @override
void initState() { void initState() {
boardController = AppFlowyBoardScrollController();
final group1 = AppFlowyGroupData(id: "To Do", name: "To Do", items: [ final group1 = AppFlowyGroupData(id: "To Do", name: "To Do", items: [
TextItem("Card 1"), TextItem("Card 1"),
TextItem("Card 2"), TextItem("Card 2"),
@ -67,12 +70,16 @@ class _MultiBoardListExampleState extends State<MultiBoardListExample> {
child: _buildCard(groupItem), child: _buildCard(groupItem),
); );
}, },
boardScrollController: boardController,
footerBuilder: (context, columnData) { footerBuilder: (context, columnData) {
return AppFlowyGroupFooter( return AppFlowyGroupFooter(
icon: const Icon(Icons.add, size: 20), icon: const Icon(Icons.add, size: 20),
title: const Text('New'), title: const Text('New'),
height: 50, height: 50,
margin: config.groupItemPadding, margin: config.groupItemPadding,
onAddButtonClick: () {
boardController.scrollToBottom(columnData.id, (p0) {});
},
); );
}, },
headerBuilder: (context, columnData) { headerBuilder: (context, columnData) {

View File

@ -8,25 +8,28 @@ class Log {
static void info(String? message) { static void info(String? message) {
if (enableLog) { if (enableLog) {
debugPrint('[Info]=> $message'); debugPrint('AppFlowyBoard: [Info]=> $message');
} }
} }
static void debug(String? message) { static void debug(String? message) {
if (enableLog) { if (enableLog) {
debugPrint('🐛[Debug] - ${DateTime.now().second}=> $message'); debugPrint(
'AppFlowyBoard: 🐛[Debug] - ${DateTime.now().second}=> $message');
} }
} }
static void warn(String? message) { static void warn(String? message) {
if (enableLog) { if (enableLog) {
debugPrint('🐛[Warn] - ${DateTime.now().second} => $message'); debugPrint(
'AppFlowyBoard: 🐛[Warn] - ${DateTime.now().second} => $message');
} }
} }
static void trace(String? message) { static void trace(String? message) {
if (enableLog) { if (enableLog) {
debugPrint('❗️[Trace] - ${DateTime.now().second}=> $message'); debugPrint(
'AppFlowyBoard: ❗️[Trace] - ${DateTime.now().second}=> $message');
} }
} }
} }

View File

@ -13,10 +13,8 @@ import '../rendering/board_overlay.dart';
class AppFlowyBoardScrollController { class AppFlowyBoardScrollController {
AppFlowyBoardState? _groupState; AppFlowyBoardState? _groupState;
void scrollToBottom(String groupId, VoidCallback? completed) { void scrollToBottom(String groupId, void Function(BuildContext)? completed) {
_groupState _groupState?.reorderFlexActionMap[groupId]?.scrollToBottom(completed);
?.getReorderFlexState(groupId: groupId)
?.scrollToBottom(completed);
} }
} }
@ -133,7 +131,7 @@ class AppFlowyBoard extends StatelessWidget {
dataController: controller, dataController: controller,
scrollController: scrollController, scrollController: scrollController,
scrollManager: boardScrollController, scrollManager: boardScrollController,
columnsState: _groupState, groupState: _groupState,
background: background, background: background,
delegate: _phantomController, delegate: _phantomController,
groupConstraints: groupConstraints, groupConstraints: groupConstraints,
@ -158,7 +156,7 @@ class _AppFlowyBoardContent extends StatefulWidget {
final ReorderFlexConfig reorderFlexConfig; final ReorderFlexConfig reorderFlexConfig;
final BoxConstraints groupConstraints; final BoxConstraints groupConstraints;
final AppFlowyBoardScrollController? scrollManager; final AppFlowyBoardScrollController? scrollManager;
final AppFlowyBoardState columnsState; final AppFlowyBoardState groupState;
final AppFlowyBoardCardBuilder cardBuilder; final AppFlowyBoardCardBuilder cardBuilder;
final AppFlowyBoardHeaderBuilder? headerBuilder; final AppFlowyBoardHeaderBuilder? headerBuilder;
final AppFlowyBoardFooterBuilder? footerBuilder; final AppFlowyBoardFooterBuilder? footerBuilder;
@ -171,7 +169,7 @@ class _AppFlowyBoardContent extends StatefulWidget {
required this.delegate, required this.delegate,
required this.dataController, required this.dataController,
required this.scrollManager, required this.scrollManager,
required this.columnsState, required this.groupState,
this.scrollController, this.scrollController,
this.background, this.background,
required this.groupConstraints, required this.groupConstraints,
@ -192,8 +190,6 @@ class _AppFlowyBoardContentState extends State<_AppFlowyBoardContent> {
GlobalKey(debugLabel: '$_AppFlowyBoardContent overlay key'); GlobalKey(debugLabel: '$_AppFlowyBoardContent overlay key');
late BoardOverlayEntry _overlayEntry; late BoardOverlayEntry _overlayEntry;
final Map<String, GlobalObjectKey> _reorderFlexKeys = {};
@override @override
void initState() { void initState() {
_overlayEntry = BoardOverlayEntry( _overlayEntry = BoardOverlayEntry(
@ -202,7 +198,7 @@ class _AppFlowyBoardContentState extends State<_AppFlowyBoardContent> {
reorderFlexId: widget.dataController.identifier, reorderFlexId: widget.dataController.identifier,
acceptedReorderFlexId: widget.dataController.groupIds, acceptedReorderFlexId: widget.dataController.groupIds,
delegate: widget.delegate, delegate: widget.delegate,
columnsState: widget.columnsState, columnsState: widget.groupState,
); );
final reorderFlex = ReorderFlex( final reorderFlex = ReorderFlex(
@ -212,7 +208,7 @@ class _AppFlowyBoardContentState extends State<_AppFlowyBoardContent> {
dataSource: widget.dataController, dataSource: widget.dataController,
direction: Axis.horizontal, direction: Axis.horizontal,
interceptor: interceptor, interceptor: interceptor,
reorderable: false, reorderable: true,
children: _buildColumns(), children: _buildColumns(),
); );
@ -257,18 +253,16 @@ class _AppFlowyBoardContentState extends State<_AppFlowyBoardContent> {
dataController: widget.dataController, dataController: widget.dataController,
); );
if (_reorderFlexKeys[columnData.id] == null) { final reorderFlexAction = ReorderFlexActionImpl();
_reorderFlexKeys[columnData.id] = GlobalObjectKey(columnData.id); widget.groupState.reorderFlexActionMap[columnData.id] =
} reorderFlexAction;
GlobalObjectKey reorderFlexKey = _reorderFlexKeys[columnData.id]!;
return ChangeNotifierProvider.value( return ChangeNotifierProvider.value(
key: ValueKey(columnData.id), key: ValueKey(columnData.id),
value: widget.dataController.getGroupController(columnData.id), value: widget.dataController.getGroupController(columnData.id),
child: Consumer<AppFlowyGroupController>( child: Consumer<AppFlowyGroupController>(
builder: (context, value, child) { builder: (context, value, child) {
final boardColumn = AppFlowyBoardGroup( final boardColumn = AppFlowyBoardGroup(
reorderFlexKey: reorderFlexKey,
// key: PageStorageKey<String>(columnData.id), // key: PageStorageKey<String>(columnData.id),
margin: _marginFromIndex(columnIndex), margin: _marginFromIndex(columnIndex),
itemMargin: widget.config.groupItemPadding, itemMargin: widget.config.groupItemPadding,
@ -281,11 +275,11 @@ class _AppFlowyBoardContentState extends State<_AppFlowyBoardContent> {
onReorder: widget.dataController.moveGroupItem, onReorder: widget.dataController.moveGroupItem,
cornerRadius: widget.config.cornerRadius, cornerRadius: widget.config.cornerRadius,
backgroundColor: widget.config.groupBackgroundColor, backgroundColor: widget.config.groupBackgroundColor,
dragStateStorage: widget.columnsState, dragStateStorage: widget.groupState,
dragTargetIndexKeyStorage: widget.columnsState, dragTargetKeys: widget.groupState,
reorderFlexAction: reorderFlexAction,
); );
widget.columnsState.addGroup(columnData.id, boardColumn);
return ConstrainedBox( return ConstrainedBox(
constraints: widget.groupConstraints, constraints: widget.groupConstraints,
child: boardColumn, child: boardColumn,
@ -356,71 +350,61 @@ class AppFlowyGroupContext {
} }
class AppFlowyBoardState extends DraggingStateStorage class AppFlowyBoardState extends DraggingStateStorage
with ReorderDragTargetIndexKeyStorage { with ReorderDragTargeKeys {
final Map<String, DraggingState> groupDragStates = {};
final Map<String, Map<String, GlobalObjectKey>> groupDragTargetKeys = {};
/// Quick access to the [AppFlowyBoardGroup], the [GlobalKey] is bind to the /// Quick access to the [AppFlowyBoardGroup], the [GlobalKey] is bind to the
/// AppFlowyBoardGroup's [ReorderFlex] widget. /// AppFlowyBoardGroup's [ReorderFlex] widget.
final Map<String, GlobalKey> groupReorderFlexKeys = {}; final Map<String, ReorderFlexActionImpl> reorderFlexActionMap = {};
final Map<String, DraggingState> groupDragStates = {};
final Map<String, Map<String, GlobalObjectKey>> groupDragDragTargets = {};
void addGroup(String groupId, AppFlowyBoardGroup groupWidget) {
groupReorderFlexKeys[groupId] = groupWidget.reorderFlexKey;
}
ReorderFlexState? getReorderFlexState({required String groupId}) {
final flexGlobalKey = groupReorderFlexKeys[groupId];
if (flexGlobalKey == null) return null;
if (flexGlobalKey.currentState is! ReorderFlexState) return null;
final state = flexGlobalKey.currentState as ReorderFlexState;
return state;
}
ReorderFlex? getReorderFlex({required String groupId}) {
final flexGlobalKey = groupReorderFlexKeys[groupId];
if (flexGlobalKey == null) return null;
if (flexGlobalKey.currentWidget is! ReorderFlex) return null;
final widget = flexGlobalKey.currentWidget as ReorderFlex;
return widget;
}
@override @override
DraggingState? read(String reorderFlexId) { DraggingState? readState(String reorderFlexId) {
return groupDragStates[reorderFlexId]; return groupDragStates[reorderFlexId];
} }
@override @override
void write(String reorderFlexId, DraggingState state) { void insertState(String reorderFlexId, DraggingState state) {
Log.trace('$reorderFlexId Write dragging state: $state'); Log.trace('$reorderFlexId Write dragging state: $state');
groupDragStates[reorderFlexId] = state; groupDragStates[reorderFlexId] = state;
} }
@override @override
void remove(String reorderFlexId) { void removeState(String reorderFlexId) {
groupDragStates.remove(reorderFlexId); groupDragStates.remove(reorderFlexId);
} }
@override @override
void addKey( void insertDragTarget(
String reorderFlexId, String reorderFlexId,
String key, String key,
GlobalObjectKey<State<StatefulWidget>> value, GlobalObjectKey<State<StatefulWidget>> value,
) { ) {
Map<String, GlobalObjectKey>? group = groupDragDragTargets[reorderFlexId]; Map<String, GlobalObjectKey>? group = groupDragTargetKeys[reorderFlexId];
if (group == null) { if (group == null) {
group = {}; group = {};
groupDragDragTargets[reorderFlexId] = group; groupDragTargetKeys[reorderFlexId] = group;
} }
group[key] = value; group[key] = value;
} }
@override @override
GlobalObjectKey<State<StatefulWidget>>? readKey( GlobalObjectKey<State<StatefulWidget>>? getDragTarget(
String reorderFlexId, String key) { String reorderFlexId,
Map<String, GlobalObjectKey>? group = groupDragDragTargets[reorderFlexId]; String key,
) {
Map<String, GlobalObjectKey>? group = groupDragTargetKeys[reorderFlexId];
if (group != null) { if (group != null) {
return group[key]; return group[key];
} else { } else {
return null; return null;
} }
} }
@override
void removeDragTarget(String reorderFlexId) {
groupDragTargetKeys.remove(reorderFlexId);
} }
}
class ReorderFlexActionImpl extends ReorderFlexAction {}

View File

@ -217,7 +217,6 @@ class AppFlowyBoardController extends ChangeNotifier
final fromGroupItem = fromGroupController.removeAt(fromGroupIndex); final fromGroupItem = fromGroupController.removeAt(fromGroupIndex);
if (toGroupController.items.length > toGroupIndex) { if (toGroupController.items.length > toGroupIndex) {
assert(toGroupController.items[toGroupIndex] is PhantomGroupItem); assert(toGroupController.items[toGroupIndex] is PhantomGroupItem);
}
toGroupController.replace(toGroupIndex, fromGroupItem); toGroupController.replace(toGroupIndex, fromGroupItem);
onMoveGroupItemToGroup?.call( onMoveGroupItemToGroup?.call(
@ -227,6 +226,7 @@ class AppFlowyBoardController extends ChangeNotifier
toGroupIndex, toGroupIndex,
); );
} }
}
@override @override
List<Object?> get props { List<Object?> get props {

View File

@ -90,21 +90,21 @@ class AppFlowyBoardGroup extends StatefulWidget {
final DraggingStateStorage? dragStateStorage; final DraggingStateStorage? dragStateStorage;
final ReorderDragTargetIndexKeyStorage? dragTargetIndexKeyStorage; final ReorderDragTargeKeys? dragTargetKeys;
final GlobalObjectKey reorderFlexKey; final ReorderFlexAction? reorderFlexAction;
const AppFlowyBoardGroup({ const AppFlowyBoardGroup({
Key? key, Key? key,
required this.reorderFlexKey,
this.headerBuilder, this.headerBuilder,
this.footerBuilder, this.footerBuilder,
required this.cardBuilder, required this.cardBuilder,
required this.onReorder, required this.onReorder,
required this.dataSource, required this.dataSource,
required this.phantomController, required this.phantomController,
this.reorderFlexAction,
this.dragStateStorage, this.dragStateStorage,
this.dragTargetIndexKeyStorage, this.dragTargetKeys,
this.scrollController, this.scrollController,
this.onDragStarted, this.onDragStarted,
this.onDragEnded, this.onDragEnded,
@ -112,7 +112,7 @@ class AppFlowyBoardGroup extends StatefulWidget {
this.itemMargin = EdgeInsets.zero, this.itemMargin = EdgeInsets.zero,
this.cornerRadius = 0.0, this.cornerRadius = 0.0,
this.backgroundColor = Colors.transparent, this.backgroundColor = Colors.transparent,
}) : config = const ReorderFlexConfig(setStateWhenEndDrag: false), }) : config = const ReorderFlexConfig(),
super(key: key); super(key: key);
@override @override
@ -146,9 +146,9 @@ class _AppFlowyBoardGroupState extends State<AppFlowyBoardGroup> {
); );
Widget reorderFlex = ReorderFlex( Widget reorderFlex = ReorderFlex(
key: widget.reorderFlexKey, key: ValueKey(widget.groupId),
dragStateStorage: widget.dragStateStorage, dragStateStorage: widget.dragStateStorage,
dragTargetIndexKeyStorage: widget.dragTargetIndexKeyStorage, dragTargetKeys: widget.dragTargetKeys,
scrollController: widget.scrollController, scrollController: widget.scrollController,
config: widget.config, config: widget.config,
onDragStarted: (index) { onDragStarted: (index) {
@ -168,6 +168,7 @@ class _AppFlowyBoardGroupState extends State<AppFlowyBoardGroup> {
}, },
dataSource: widget.dataSource, dataSource: widget.dataSource,
interceptor: interceptor, interceptor: interceptor,
reorderFlexAction: widget.reorderFlexAction,
children: children, children: children,
); );

View File

@ -41,7 +41,7 @@ class AppFlowyGroupController extends ChangeNotifier with EquatableMixin {
void updateGroupName(String newName) { void updateGroupName(String newName) {
if (groupData.headerData.groupName != newName) { if (groupData.headerData.groupName != newName) {
groupData.headerData.groupName = newName; groupData.headerData.groupName = newName;
notifyListeners(); _notify();
} }
} }
@ -56,7 +56,7 @@ class AppFlowyGroupController extends ChangeNotifier with EquatableMixin {
Log.debug('[$AppFlowyGroupController] $groupData remove item at $index'); Log.debug('[$AppFlowyGroupController] $groupData remove item at $index');
final item = groupData._items.removeAt(index); final item = groupData._items.removeAt(index);
if (notify) { if (notify) {
notifyListeners(); _notify();
} }
return item; return item;
} }
@ -81,7 +81,7 @@ class AppFlowyGroupController extends ChangeNotifier with EquatableMixin {
'[$AppFlowyGroupController] $groupData move item from $fromIndex to $toIndex'); '[$AppFlowyGroupController] $groupData move item from $fromIndex to $toIndex');
final item = groupData._items.removeAt(fromIndex); final item = groupData._items.removeAt(fromIndex);
groupData._items.insert(toIndex, item); groupData._items.insert(toIndex, item);
notifyListeners(); _notify();
return true; return true;
} }
@ -102,7 +102,7 @@ class AppFlowyGroupController extends ChangeNotifier with EquatableMixin {
groupData._items.add(item); groupData._items.add(item);
} }
if (notify) notifyListeners(); if (notify) _notify();
return true; return true;
} }
} }
@ -112,7 +112,7 @@ class AppFlowyGroupController extends ChangeNotifier with EquatableMixin {
return false; return false;
} else { } else {
groupData._items.add(item); groupData._items.add(item);
if (notify) notifyListeners(); if (notify) _notify();
return true; return true;
} }
} }
@ -124,6 +124,8 @@ class AppFlowyGroupController extends ChangeNotifier with EquatableMixin {
Log.debug('[$AppFlowyGroupController] $groupData add $newItem'); Log.debug('[$AppFlowyGroupController] $groupData add $newItem');
} else { } else {
if (index >= groupData._items.length) { if (index >= groupData._items.length) {
Log.warn(
'[$AppFlowyGroupController] unexpected items length, index should less than the count of the items. Index: $index, items count: ${items.length}');
return; return;
} }
@ -133,7 +135,7 @@ class AppFlowyGroupController extends ChangeNotifier with EquatableMixin {
'[$AppFlowyGroupController] $groupData replace $removedItem with $newItem at $index'); '[$AppFlowyGroupController] $groupData replace $removedItem with $newItem at $index');
} }
notifyListeners(); _notify();
} }
void replaceOrInsertItem(AppFlowyGroupItem newItem) { void replaceOrInsertItem(AppFlowyGroupItem newItem) {
@ -141,10 +143,10 @@ class AppFlowyGroupController extends ChangeNotifier with EquatableMixin {
if (index != -1) { if (index != -1) {
groupData._items.removeAt(index); groupData._items.removeAt(index);
groupData._items.insert(index, newItem); groupData._items.insert(index, newItem);
notifyListeners(); _notify();
} else { } else {
groupData._items.add(newItem); groupData._items.add(newItem);
notifyListeners(); _notify();
} }
} }
@ -152,6 +154,10 @@ class AppFlowyGroupController extends ChangeNotifier with EquatableMixin {
return groupData._items.indexWhere((element) => element.id == item.id) != return groupData._items.indexWhere((element) => element.id == item.id) !=
-1; -1;
} }
void _notify() {
notifyListeners();
}
} }
/// [AppFlowyGroupData] represents the data of each group of the Board. /// [AppFlowyGroupData] represents the data of each group of the Board.

View File

@ -22,6 +22,8 @@ class FlexDragTargetData extends DragTargetData {
Size? get feedbackSize => _state.feedbackSize; Size? get feedbackSize => _state.feedbackSize;
bool get isDragging => _state.isDragging();
final String dragTargetId; final String dragTargetId;
Offset dragTargetOffset = Offset.zero; Offset dragTargetOffset = Offset.zero;
@ -48,47 +50,28 @@ class FlexDragTargetData extends DragTargetData {
bool isOverlapWithWidgets(List<GlobalObjectKey> widgetKeys) { bool isOverlapWithWidgets(List<GlobalObjectKey> widgetKeys) {
final renderBox = dragTargetIndexKey.currentContext?.findRenderObject(); final renderBox = dragTargetIndexKey.currentContext?.findRenderObject();
if (renderBox == null) return false; if (renderBox == null) return false;
if (renderBox is! RenderBox) return false; if (renderBox is! RenderBox) return false;
final size = feedbackSize ?? Size.zero; final size = feedbackSize ?? Size.zero;
final Rect rect = dragTargetOffset & size;
final Rect dragTargetRect = renderBox.localToGlobal(Offset.zero) & size;
for (final widgetKey in widgetKeys) { for (final widgetKey in widgetKeys) {
final renderObject = widgetKey.currentContext?.findRenderObject(); final renderObject = widgetKey.currentContext?.findRenderObject();
if (renderObject != null && renderObject is RenderBox) { if (renderObject != null && renderObject is RenderBox) {
Rect widgetRect = Rect widgetRect =
renderObject.localToGlobal(Offset.zero) & renderObject.size; renderObject.localToGlobal(Offset.zero) & renderObject.size;
// return rect.overlaps(widgetRect); return dragTargetRect.overlaps(widgetRect);
if (rect.right <= widgetRect.left || widgetRect.right <= rect.left) {
return false;
}
if (rect.bottom <= widgetRect.top || widgetRect.bottom <= rect.top) {
return false;
}
return true;
} }
} }
// final HitTestResult result = HitTestResult();
// WidgetsBinding.instance.hitTest(result, position);
// for (final HitTestEntry entry in result.path) {
// final HitTestTarget target = entry.target;
// if (target is RenderMetaData) {
// print(target.metaData);
// }
// print(target);
// }
return false; return false;
} }
} }
abstract class DraggingStateStorage { abstract class DraggingStateStorage {
void write(String reorderFlexId, DraggingState state); void insertState(String reorderFlexId, DraggingState state);
void remove(String reorderFlexId); void removeState(String reorderFlexId);
DraggingState? read(String reorderFlexId); DraggingState? readState(String reorderFlexId);
} }
class DraggingState { class DraggingState {
@ -113,7 +96,7 @@ class DraggingState {
int currentIndex = -1; int currentIndex = -1;
/// The widget to move the dragging widget too after the current index. /// The widget to move the dragging widget too after the current index.
int nextIndex = 0; int nextIndex = -1;
/// Whether or not we are currently scrolling this view to show a widget. /// Whether or not we are currently scrolling this view to show a widget.
bool scrolling = false; bool scrolling = false;
@ -149,6 +132,7 @@ class DraggingState {
dragStartIndex = -1; dragStartIndex = -1;
phantomIndex = -1; phantomIndex = -1;
currentIndex = -1; currentIndex = -1;
nextIndex = -1;
_draggingWidget = null; _draggingWidget = null;
} }

View File

@ -1,3 +1,4 @@
import 'package:appflowy_board/src/utils/log.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -72,11 +73,15 @@ class ReorderDragTarget<T extends DragTargetData> extends StatefulWidget {
final ReorderFlexDraggableTargetBuilder? draggableTargetBuilder; final ReorderFlexDraggableTargetBuilder? draggableTargetBuilder;
final AnimationController insertAnimationController; final AnimationController insertAnimationController;
final AnimationController deleteAnimationController; final AnimationController deleteAnimationController;
final bool useMoveAnimation; final bool useMoveAnimation;
final bool draggable; final bool draggable;
final double draggingOpacity;
const ReorderDragTarget({ const ReorderDragTarget({
Key? key, Key? key,
required this.child, required this.child,
@ -93,6 +98,7 @@ class ReorderDragTarget<T extends DragTargetData> extends StatefulWidget {
this.onAccept, this.onAccept,
this.onLeave, this.onLeave,
this.draggableTargetBuilder, this.draggableTargetBuilder,
this.draggingOpacity = 0.3,
}) : super(key: key); }) : super(key: key);
@override @override
@ -163,6 +169,7 @@ class _ReorderDragTargetState<T extends DragTargetData>
feedback: feedbackBuilder, feedback: feedbackBuilder,
childWhenDragging: IgnorePointerWidget( childWhenDragging: IgnorePointerWidget(
useIntrinsicSize: !widget.useMoveAnimation, useIntrinsicSize: !widget.useMoveAnimation,
opacity: widget.draggingOpacity,
child: widget.child, child: widget.child,
), ),
onDragStarted: () { onDragStarted: () {
@ -184,8 +191,9 @@ class _ReorderDragTargetState<T extends DragTargetData>
/// When the drag does not end inside a DragTarget widget, the /// When the drag does not end inside a DragTarget widget, the
/// drag fails, but we still reorder the widget to the last position it /// drag fails, but we still reorder the widget to the last position it
/// had been dragged to. /// had been dragged to.
onDraggableCanceled: (Velocity velocity, Offset offset) => onDraggableCanceled: (Velocity velocity, Offset offset) {
widget.onDragEnded(widget.dragTargetData), widget.onDragEnded(widget.dragTargetData);
},
child: widget.child, child: widget.child,
); );
@ -193,7 +201,10 @@ class _ReorderDragTargetState<T extends DragTargetData>
} }
Widget _buildDraggableFeedback( Widget _buildDraggableFeedback(
BuildContext context, BoxConstraints constraints, Widget child) { BuildContext context,
BoxConstraints constraints,
Widget child,
) {
return Transform( return Transform(
transform: Matrix4.rotationZ(0), transform: Matrix4.rotationZ(0),
alignment: FractionalOffset.topLeft, alignment: FractionalOffset.topLeft,
@ -203,7 +214,7 @@ class _ReorderDragTargetState<T extends DragTargetData>
clipBehavior: Clip.hardEdge, clipBehavior: Clip.hardEdge,
child: ConstrainedBox( child: ConstrainedBox(
constraints: constraints, constraints: constraints,
child: Opacity(opacity: 0.3, child: child), child: Opacity(opacity: widget.draggingOpacity, child: child),
), ),
), ),
); );
@ -221,8 +232,11 @@ class DragTargetAnimation {
// where the widget used to be. // where the widget used to be.
late AnimationController phantomController; late AnimationController phantomController;
// Uses to simulate the insert animation when card was moved from on group to
// another group. Check out the [FakeDragTarget].
late AnimationController insertController; late AnimationController insertController;
// Used to remove the phantom
late AnimationController deleteController; late AnimationController deleteController;
DragTargetAnimation({ DragTargetAnimation({
@ -238,7 +252,7 @@ class DragTargetAnimation {
value: 0, vsync: vsync, duration: reorderAnimationDuration); value: 0, vsync: vsync, duration: reorderAnimationDuration);
insertController = AnimationController( insertController = AnimationController(
value: 0.0, vsync: vsync, duration: const Duration(milliseconds: 200)); value: 0.0, vsync: vsync, duration: const Duration(milliseconds: 100));
deleteController = AnimationController( deleteController = AnimationController(
value: 0.0, vsync: vsync, duration: const Duration(milliseconds: 1)); value: 0.0, vsync: vsync, duration: const Duration(milliseconds: 1));
@ -269,8 +283,11 @@ class DragTargetAnimation {
class IgnorePointerWidget extends StatelessWidget { class IgnorePointerWidget extends StatelessWidget {
final Widget? child; final Widget? child;
final bool useIntrinsicSize; final bool useIntrinsicSize;
final double opacity;
const IgnorePointerWidget({ const IgnorePointerWidget({
required this.child, required this.child,
required this.opacity,
this.useIntrinsicSize = false, this.useIntrinsicSize = false,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -281,11 +298,10 @@ class IgnorePointerWidget extends StatelessWidget {
? child ? child
: SizedBox(width: 0.0, height: 0.0, child: child); : SizedBox(width: 0.0, height: 0.0, child: child);
final opacity = useIntrinsicSize ? 0.3 : 0.0;
return IgnorePointer( return IgnorePointer(
ignoring: true, ignoring: true,
child: Opacity( child: Opacity(
opacity: opacity, opacity: useIntrinsicSize ? opacity : 0.0,
child: sizedChild, child: sizedChild,
), ),
); );
@ -295,8 +311,10 @@ class IgnorePointerWidget extends StatelessWidget {
class AbsorbPointerWidget extends StatelessWidget { class AbsorbPointerWidget extends StatelessWidget {
final Widget? child; final Widget? child;
final bool useIntrinsicSize; final bool useIntrinsicSize;
final double opacity;
const AbsorbPointerWidget({ const AbsorbPointerWidget({
required this.child, required this.child,
required this.opacity,
this.useIntrinsicSize = false, this.useIntrinsicSize = false,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -307,10 +325,9 @@ class AbsorbPointerWidget extends StatelessWidget {
? child ? child
: SizedBox(width: 0.0, height: 0.0, child: child); : SizedBox(width: 0.0, height: 0.0, child: child);
final opacity = useIntrinsicSize ? 0.3 : 0.0;
return AbsorbPointer( return AbsorbPointer(
child: Opacity( child: Opacity(
opacity: opacity, opacity: useIntrinsicSize ? opacity : 0.0,
child: sizedChild, child: sizedChild,
), ),
); );
@ -423,7 +440,6 @@ abstract class FakeDragTargetEventData {
} }
class FakeDragTarget<T extends DragTargetData> extends StatefulWidget { class FakeDragTarget<T extends DragTargetData> extends StatefulWidget {
final Duration animationDuration;
final FakeDragTargetEventTrigger eventTrigger; final FakeDragTargetEventTrigger eventTrigger;
final FakeDragTargetEventData eventData; final FakeDragTargetEventData eventData;
final DragTargetOnStarted onDragStarted; final DragTargetOnStarted onDragStarted;
@ -442,7 +458,6 @@ class FakeDragTarget<T extends DragTargetData> extends StatefulWidget {
required this.insertAnimationController, required this.insertAnimationController,
required this.deleteAnimationController, required this.deleteAnimationController,
required this.child, required this.child,
this.animationDuration = const Duration(milliseconds: 250),
}) : super(key: key); }) : super(key: key);
@override @override
@ -468,6 +483,7 @@ class _FakeDragTargetState<T extends DragTargetData>
// }); // });
widget.eventTrigger.fakeOnDragEnded(() { widget.eventTrigger.fakeOnDragEnded(() {
Log.trace("[$FakeDragTarget] on drag end");
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
widget.onDragEnded(widget.eventData.dragTargetData as T); widget.onDragEnded(widget.eventData.dragTargetData as T);
}); });
@ -476,6 +492,13 @@ class _FakeDragTargetState<T extends DragTargetData>
super.initState(); super.initState();
} }
@override
void dispose() {
widget.insertAnimationController
.removeStatusListener(_onInsertedAnimationStatusChanged);
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (simulateDragging) { if (simulateDragging) {
@ -483,6 +506,7 @@ class _FakeDragTargetState<T extends DragTargetData>
sizeFactor: widget.deleteAnimationController, sizeFactor: widget.deleteAnimationController,
axis: Axis.vertical, axis: Axis.vertical,
child: AbsorbPointerWidget( child: AbsorbPointerWidget(
opacity: 0.3,
child: widget.child, child: widget.child,
), ),
); );
@ -492,6 +516,7 @@ class _FakeDragTargetState<T extends DragTargetData>
axis: Axis.vertical, axis: Axis.vertical,
child: AbsorbPointerWidget( child: AbsorbPointerWidget(
useIntrinsicSize: true, useIntrinsicSize: true,
opacity: 0.3,
child: widget.child, child: widget.child,
), ),
); );
@ -503,14 +528,18 @@ class _FakeDragTargetState<T extends DragTargetData>
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
if (widget.onWillAccept(widget.eventData.dragTargetData as T)) {
Log.trace("[$FakeDragTarget] on drag start");
simulateDragging = true; simulateDragging = true;
widget.deleteAnimationController.reverse(from: 1.0); widget.deleteAnimationController.reverse(from: 1.0);
widget.onWillAccept(widget.eventData.dragTargetData as T);
widget.onDragStarted( widget.onDragStarted(
widget.child, widget.child,
widget.eventData.index, widget.eventData.index,
widget.eventData.feedbackSize, widget.eventData.feedbackSize,
); );
} else {
Log.trace("[$FakeDragTarget] cancel start drag");
}
}); });
}); });
} }

View File

@ -35,7 +35,7 @@ abstract class DragTargetInterceptor {
abstract class OverlapDragTargetDelegate { abstract class OverlapDragTargetDelegate {
void cancel(); void cancel();
void moveTo( void dragTargetDidMoveToReorderFlex(
String reorderFlexId, String reorderFlexId,
FlexDragTargetData dragTargetData, FlexDragTargetData dragTargetData,
int dragTargetIndex, int dragTargetIndex,
@ -81,7 +81,7 @@ class OverlappingDragTargetInterceptor extends DragTargetInterceptor {
delegate.cancel(); delegate.cancel();
} else { } else {
// Ignore the event if the dragTarget overlaps with the other column's dragTargets. // Ignore the event if the dragTarget overlaps with the other column's dragTargets.
final columnKeys = columnsState.groupDragDragTargets[dragTargetId]; final columnKeys = columnsState.groupDragTargetKeys[dragTargetId];
if (columnKeys != null) { if (columnKeys != null) {
final keys = columnKeys.values.toList(); final keys = columnKeys.values.toList();
if (dragTargetData.isOverlapWithWidgets(keys)) { if (dragTargetData.isOverlapWithWidgets(keys)) {
@ -99,10 +99,10 @@ class OverlappingDragTargetInterceptor extends DragTargetInterceptor {
if (index != -1) { if (index != -1) {
Log.trace( Log.trace(
'[$OverlappingDragTargetInterceptor] move to $dragTargetId at $index'); '[$OverlappingDragTargetInterceptor] move to $dragTargetId at $index');
delegate.moveTo(dragTargetId, dragTargetData, index); delegate.dragTargetDidMoveToReorderFlex(
dragTargetId, dragTargetData, index);
columnsState columnsState.reorderFlexActionMap[dragTargetId]
.getReorderFlexState(groupId: dragTargetId)
?.resetDragTargetIndex(index); ?.resetDragTargetIndex(index);
} }
}); });
@ -153,7 +153,7 @@ class CrossReorderFlexDragTargetInterceptor extends DragTargetInterceptor {
/// it means the dragTarget is dragging on the top of its own list. /// it means the dragTarget is dragging on the top of its own list.
/// Otherwise, it means the dargTarget was moved to another list. /// Otherwise, it means the dargTarget was moved to another list.
Log.trace( Log.trace(
"[$CrossReorderFlexDragTargetInterceptor] $reorderFlexId accept ${dragTargetData.reorderFlexId} ${reorderFlexId != dragTargetData.reorderFlexId}"); "[$CrossReorderFlexDragTargetInterceptor] $reorderFlexId should accept ${dragTargetData.reorderFlexId} : ${reorderFlexId != dragTargetData.reorderFlexId}");
return reorderFlexId != dragTargetData.reorderFlexId; return reorderFlexId != dragTargetData.reorderFlexId;
} else { } else {
Log.trace( Log.trace(

View File

@ -31,27 +31,43 @@ abstract class ReoderFlexItem {
String get id; String get id;
} }
abstract class ReorderDragTargetIndexKeyStorage { /// Cache each dragTarget's key.
void addKey(String reorderFlexId, String key, GlobalObjectKey value); /// For the moment, the key is used to locate the render object that will
GlobalObjectKey? readKey(String reorderFlexId, String key); /// be passed in the [ScrollPosition]'s [ensureVisible] function.
///
abstract class ReorderDragTargeKeys {
void insertDragTarget(
String reorderFlexId,
String key,
GlobalObjectKey value,
);
GlobalObjectKey? getDragTarget(
String reorderFlexId,
String key,
);
void removeDragTarget(String reorderFlexId);
}
abstract class ReorderFlexAction {
void Function(void Function(BuildContext)?)? _scrollToBottom;
void Function(void Function(BuildContext)?) get scrollToBottom =>
_scrollToBottom!;
void Function(int)? _resetDragTargetIndex;
void Function(int) get resetDragTargetIndex => _resetDragTargetIndex!;
} }
class ReorderFlexConfig { class ReorderFlexConfig {
/// The opacity of the dragging widget /// The opacity of the dragging widget
final double draggingWidgetOpacity = 0.3; final double draggingWidgetOpacity = 0.4;
// How long an animation to reorder an element // How long an animation to reorder an element
final Duration reorderAnimationDuration = const Duration(milliseconds: 300); final Duration reorderAnimationDuration = const Duration(milliseconds: 200);
// How long an animation to scroll to an off-screen element // How long an animation to scroll to an off-screen element
final Duration scrollAnimationDuration = const Duration(milliseconds: 300); final Duration scrollAnimationDuration = const Duration(milliseconds: 200);
/// Determines if setSatte method needs to be called when the drag is complete.
/// Default value is [true].
///
/// If the [ReorderFlex] needs to be rebuild after the [ReorderFlex] end dragging,
/// the [setStateWhenEndDrag] should set to [true].
final bool setStateWhenEndDrag;
final bool useMoveAnimation; final bool useMoveAnimation;
@ -59,7 +75,6 @@ class ReorderFlexConfig {
const ReorderFlexConfig({ const ReorderFlexConfig({
this.useMoveAnimation = true, this.useMoveAnimation = true,
this.setStateWhenEndDrag = true,
}) : useMovePlaceholder = !useMoveAnimation; }) : useMovePlaceholder = !useMoveAnimation;
} }
@ -86,9 +101,12 @@ class ReorderFlex extends StatefulWidget {
final DragTargetInterceptor? interceptor; final DragTargetInterceptor? interceptor;
/// Save the [DraggingState] if the current [ReorderFlex] get reinitialize.
final DraggingStateStorage? dragStateStorage; final DraggingStateStorage? dragStateStorage;
final ReorderDragTargetIndexKeyStorage? dragTargetIndexKeyStorage; final ReorderDragTargeKeys? dragTargetKeys;
final ReorderFlexAction? reorderFlexAction;
final bool reorderable; final bool reorderable;
@ -101,10 +119,11 @@ class ReorderFlex extends StatefulWidget {
required this.onReorder, required this.onReorder,
this.reorderable = true, this.reorderable = true,
this.dragStateStorage, this.dragStateStorage,
this.dragTargetIndexKeyStorage, this.dragTargetKeys,
this.onDragStarted, this.onDragStarted,
this.onDragEnded, this.onDragEnded,
this.interceptor, this.interceptor,
this.reorderFlexAction,
this.direction = Axis.vertical, this.direction = Axis.vertical,
}) : assert(children.every((Widget w) => w.key != null), }) : assert(children.every((Widget w) => w.key != null),
'All child must have a key.'), 'All child must have a key.'),
@ -117,7 +136,7 @@ class ReorderFlex extends StatefulWidget {
} }
class ReorderFlexState extends State<ReorderFlex> class ReorderFlexState extends State<ReorderFlex>
with ReorderFlexMinxi, TickerProviderStateMixin<ReorderFlex> { with ReorderFlexMixin, TickerProviderStateMixin<ReorderFlex> {
/// Controls scrolls and measures scroll progress. /// Controls scrolls and measures scroll progress.
late ScrollController _scrollController; late ScrollController _scrollController;
@ -139,22 +158,31 @@ class ReorderFlexState extends State<ReorderFlex>
void initState() { void initState() {
_notifier = ReorderFlexNotifier(); _notifier = ReorderFlexNotifier();
final flexId = widget.reorderFlexId; final flexId = widget.reorderFlexId;
dragState = widget.dragStateStorage?.read(flexId) ?? dragState = widget.dragStateStorage?.readState(flexId) ??
DraggingState(widget.reorderFlexId); DraggingState(widget.reorderFlexId);
Log.trace('[DragTarget] init dragState: $dragState'); Log.trace('[DragTarget] init dragState: $dragState');
widget.dragStateStorage?.remove(flexId); widget.dragStateStorage?.removeState(flexId);
_animation = DragTargetAnimation( _animation = DragTargetAnimation(
reorderAnimationDuration: widget.config.reorderAnimationDuration, reorderAnimationDuration: widget.config.reorderAnimationDuration,
entranceAnimateStatusChanged: (status) { entranceAnimateStatusChanged: (status) {
if (status == AnimationStatus.completed) { if (status == AnimationStatus.completed) {
if (dragState.nextIndex == -1) return;
setState(() => _requestAnimationToNextIndex()); setState(() => _requestAnimationToNextIndex());
} }
}, },
vsync: this, vsync: this,
); );
widget.reorderFlexAction?._scrollToBottom = (fn) {
scrollToBottom(fn);
};
widget.reorderFlexAction?._resetDragTargetIndex = (index) {
resetDragTargetIndex(index);
};
super.initState(); super.initState();
} }
@ -191,7 +219,7 @@ class ReorderFlexState extends State<ReorderFlex>
final indexKey = GlobalObjectKey(child.key!); final indexKey = GlobalObjectKey(child.key!);
// Save the index key for quick access // Save the index key for quick access
widget.dragTargetIndexKeyStorage?.addKey( widget.dragTargetKeys?.insertDragTarget(
widget.reorderFlexId, widget.reorderFlexId,
item.id, item.id,
indexKey, indexKey,
@ -243,8 +271,12 @@ class ReorderFlexState extends State<ReorderFlex>
/// [childIndex]: the index of the child in a list /// [childIndex]: the index of the child in a list
Widget _wrap(Widget child, int childIndex, GlobalObjectKey indexKey) { Widget _wrap(Widget child, int childIndex, GlobalObjectKey indexKey) {
return Builder(builder: (context) { return Builder(builder: (context) {
final ReorderDragTarget dragTarget = final ReorderDragTarget dragTarget = _buildDragTarget(
_buildDragTarget(context, child, childIndex, indexKey); context,
child,
childIndex,
indexKey,
);
int shiftedIndex = childIndex; int shiftedIndex = childIndex;
if (dragState.isOverlapWithPhantom()) { if (dragState.isOverlapWithPhantom()) {
@ -349,6 +381,15 @@ class ReorderFlexState extends State<ReorderFlex>
}); });
} }
static ReorderFlexState of(BuildContext context) {
if (context is StatefulElement && context.state is ReorderFlexState) {
return context.state as ReorderFlexState;
}
final ReorderFlexState? result =
context.findAncestorStateOfType<ReorderFlexState>();
return result!;
}
ReorderDragTarget _buildDragTarget( ReorderDragTarget _buildDragTarget(
BuildContext builderContext, BuildContext builderContext,
Widget child, Widget child,
@ -371,18 +412,24 @@ class ReorderFlexState extends State<ReorderFlex>
"[DragTarget] Group:[${widget.dataSource.identifier}] start dragging item at $draggingIndex"); "[DragTarget] Group:[${widget.dataSource.identifier}] start dragging item at $draggingIndex");
_startDragging(draggingWidget, draggingIndex, size); _startDragging(draggingWidget, draggingIndex, size);
widget.onDragStarted?.call(draggingIndex); widget.onDragStarted?.call(draggingIndex);
widget.dragStateStorage?.remove(widget.reorderFlexId); widget.dragStateStorage?.removeState(widget.reorderFlexId);
}, },
onDragMoved: (dragTargetData, offset) { onDragMoved: (dragTargetData, offset) {
dragTargetData.dragTargetOffset = offset; dragTargetData.dragTargetOffset = offset;
}, },
onDragEnded: (dragTargetData) { onDragEnded: (dragTargetData) {
if (!mounted) return; if (!mounted) {
Log.warn(
"[DragTarget]: Group:[${widget.dataSource.identifier}] end dragging but current widget was unmounted");
return;
}
Log.debug( Log.debug(
"[DragTarget]: Group:[${widget.dataSource.identifier}] end dragging"); "[DragTarget]: Group:[${widget.dataSource.identifier}] end dragging");
_notifier.updateDragTargetIndex(-1);
onDragEnded() { _notifier.updateDragTargetIndex(-1);
_animation.insertController.stop();
setState(() {
if (dragTargetData.reorderFlexId == widget.reorderFlexId) { if (dragTargetData.reorderFlexId == widget.reorderFlexId) {
_onReordered( _onReordered(
dragState.dragStartIndex, dragState.dragStartIndex,
@ -391,13 +438,7 @@ class ReorderFlexState extends State<ReorderFlex>
} }
dragState.endDragging(); dragState.endDragging();
widget.onDragEnded?.call(); widget.onDragEnded?.call();
} });
if (widget.config.setStateWhenEndDrag) {
setState(() => onDragEnded());
} else {
onDragEnded();
}
}, },
onWillAccept: (FlexDragTargetData dragTargetData) { onWillAccept: (FlexDragTargetData dragTargetData) {
// Do not receive any events if the Insert item is animating. // Do not receive any events if the Insert item is animating.
@ -405,6 +446,7 @@ class ReorderFlexState extends State<ReorderFlex>
return false; return false;
} }
if (dragTargetData.isDragging) {
assert(widget.dataSource.items.length > dragTargetIndex); assert(widget.dataSource.items.length > dragTargetIndex);
if (_interceptDragTarget(dragTargetData, (interceptor) { if (_interceptDragTarget(dragTargetData, (interceptor) {
interceptor.onWillAccept( interceptor.onWillAccept(
@ -419,6 +461,9 @@ class ReorderFlexState extends State<ReorderFlex>
} else { } else {
return handleOnWillAccept(builderContext, dragTargetIndex); return handleOnWillAccept(builderContext, dragTargetIndex);
} }
} else {
return false;
}
}, },
onAccept: (dragTargetData) { onAccept: (dragTargetData) {
_interceptDragTarget( _interceptDragTarget(
@ -438,6 +483,7 @@ class ReorderFlexState extends State<ReorderFlex>
draggableTargetBuilder: widget.interceptor?.draggableTargetBuilder, draggableTargetBuilder: widget.interceptor?.draggableTargetBuilder,
useMoveAnimation: widget.config.useMoveAnimation, useMoveAnimation: widget.config.useMoveAnimation,
draggable: widget.reorderable, draggable: widget.reorderable,
draggingOpacity: widget.config.draggingWidgetOpacity,
child: child, child: child,
); );
} }
@ -485,8 +531,12 @@ class ReorderFlexState extends State<ReorderFlex>
} }
void resetDragTargetIndex(int dragTargetIndex) { void resetDragTargetIndex(int dragTargetIndex) {
if (dragTargetIndex > widget.dataSource.items.length) {
return;
}
dragState.setStartDraggingIndex(dragTargetIndex); dragState.setStartDraggingIndex(dragTargetIndex);
widget.dragStateStorage?.write( widget.dragStateStorage?.insertState(
widget.reorderFlexId, widget.reorderFlexId,
dragState, dragState,
); );
@ -521,6 +571,9 @@ class ReorderFlexState extends State<ReorderFlex>
} }
void _onReordered(int fromIndex, int toIndex) { void _onReordered(int fromIndex, int toIndex) {
if (toIndex == -1) return;
if (fromIndex == -1) return;
if (fromIndex != toIndex) { if (fromIndex != toIndex) {
widget.onReorder.call(fromIndex, toIndex); widget.onReorder.call(fromIndex, toIndex);
} }
@ -577,46 +630,46 @@ class ReorderFlexState extends State<ReorderFlex>
} }
} }
void scrollToBottom(VoidCallback? completed) { void scrollToBottom(void Function(BuildContext)? completed) {
if (_scrolling) { if (_scrolling) {
completed?.call(); completed?.call(context);
return; return;
} }
if (widget.dataSource.items.isNotEmpty) { if (widget.dataSource.items.isNotEmpty) {
final item = widget.dataSource.items.last; final item = widget.dataSource.items.last;
final indexKey = widget.dragTargetIndexKeyStorage?.readKey( final dragTargetKey = widget.dragTargetKeys?.getDragTarget(
widget.reorderFlexId, widget.reorderFlexId,
item.id, item.id,
); );
if (indexKey == null) { if (dragTargetKey == null) {
completed?.call(); completed?.call(context);
return; return;
} }
final indexContext = indexKey.currentContext; final dragTargetContext = dragTargetKey.currentContext;
if (indexContext == null || _scrollController.hasClients == false) { if (dragTargetContext == null || _scrollController.hasClients == false) {
completed?.call(); completed?.call(context);
return; return;
} }
final renderObject = indexContext.findRenderObject(); final dragTargetRenderObject = dragTargetContext.findRenderObject();
if (renderObject != null) { if (dragTargetRenderObject != null) {
_scrolling = true; _scrolling = true;
_scrollController.position _scrollController.position
.ensureVisible( .ensureVisible(
renderObject, dragTargetRenderObject,
alignment: 0.5, alignment: 0.5,
duration: const Duration(milliseconds: 120), duration: const Duration(milliseconds: 120),
) )
.then((value) { .then((value) {
setState(() { setState(() {
_scrolling = false; _scrolling = false;
completed?.call(); completed?.call(context);
}); });
}); });
} else { } else {
completed?.call(); completed?.call(context);
} }
} }
} }

View File

@ -3,7 +3,7 @@ import 'package:flutter/widgets.dart';
import '../transitions.dart'; import '../transitions.dart';
import 'drag_target.dart'; import 'drag_target.dart';
mixin ReorderFlexMinxi { mixin ReorderFlexMixin {
@protected @protected
Widget makeAppearingWidget( Widget makeAppearingWidget(
Widget child, Widget child,

View File

@ -94,7 +94,8 @@ class BoardPhantomController extends OverlapDragTargetDelegate
/// Remove the phantom in the group if it contains phantom /// Remove the phantom in the group if it contains phantom
void _removePhantom(String groupId) { void _removePhantom(String groupId) {
if (delegate.removePhantom(groupId)) { final didRemove = delegate.removePhantom(groupId);
if (didRemove) {
phantomState.notifyDidRemovePhantom(groupId); phantomState.notifyDidRemovePhantom(groupId);
phantomState.removeGroupListener(groupId); phantomState.removeGroupListener(groupId);
} }
@ -195,7 +196,7 @@ class BoardPhantomController extends OverlapDragTargetDelegate
} }
@override @override
void moveTo( void dragTargetDidMoveToReorderFlex(
String reorderFlexId, String reorderFlexId,
FlexDragTargetData dragTargetData, FlexDragTargetData dragTargetData,
int dragTargetIndex, int dragTargetIndex,

View File

@ -1,50 +1,54 @@
import 'package:appflowy_board/src/utils/log.dart';
import 'phantom_controller.dart'; import 'phantom_controller.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class GroupPhantomState { class GroupPhantomState {
final _states = <String, GroupState>{}; final _groupStates = <String, GroupState>{};
final _groupIsDragging = <String, bool>{};
void setGroupIsDragging(String groupId, bool isDragging) { void setGroupIsDragging(String groupId, bool isDragging) {
_stateWithId(groupId).isDragging = isDragging; _groupIsDragging[groupId] = isDragging;
} }
bool isDragging(String groupId) { bool isDragging(String groupId) {
return _stateWithId(groupId).isDragging; return _groupIsDragging[groupId] ?? false;
} }
void addGroupListener(String groupId, PassthroughPhantomListener listener) { void addGroupListener(String groupId, PassthroughPhantomListener listener) {
_stateWithId(groupId).notifier.addListener( if (_groupStates[groupId] == null) {
Log.debug("[$GroupPhantomState] add group listener: $groupId");
_groupStates[groupId] = GroupState();
_groupStates[groupId]?.notifier.addListener(
onInserted: (index) => listener.onInserted?.call(index), onInserted: (index) => listener.onInserted?.call(index),
onDeleted: () => listener.onDragEnded?.call(), onDeleted: () => listener.onDragEnded?.call(),
); );
} }
}
void removeGroupListener(String groupId) { void removeGroupListener(String groupId) {
_stateWithId(groupId).notifier.dispose(); Log.debug("[$GroupPhantomState] remove group listener: $groupId");
_states.remove(groupId); final groupState = _groupStates.remove(groupId);
groupState?.dispose();
} }
void notifyDidInsertPhantom(String groupId, int index) { void notifyDidInsertPhantom(String groupId, int index) {
_stateWithId(groupId).notifier.insert(index); _groupStates[groupId]?.notifier.insert(index);
} }
void notifyDidRemovePhantom(String groupId) { void notifyDidRemovePhantom(String groupId) {
_stateWithId(groupId).notifier.remove(); Log.debug("[$GroupPhantomState] $groupId remove phantom");
} _groupStates[groupId]?.notifier.remove();
GroupState _stateWithId(String groupId) {
var state = _states[groupId];
if (state == null) {
state = GroupState();
_states[groupId] = state;
}
return state;
} }
} }
class GroupState { class GroupState {
bool isDragging = false; bool isDragging = false;
final notifier = PassthroughPhantomNotifier(); final notifier = PassthroughPhantomNotifier();
void dispose() {
notifier.dispose();
}
} }
abstract class PassthroughPhantomListener { abstract class PassthroughPhantomListener {

View File

@ -1,6 +1,6 @@
name: appflowy_board name: appflowy_board
description: AppFlowyBoard is a board-style widget that consists of multi-groups. It supports drag and drop between different groups. description: AppFlowyBoard is a board-style widget that consists of multi-groups. It supports drag and drop between different groups.
version: 0.0.7 version: 0.0.8
homepage: https://github.com/AppFlowy-IO/AppFlowy homepage: https://github.com/AppFlowy-IO/AppFlowy
repository: https://github.com/AppFlowy-IO/AppFlowy/tree/main/frontend/app_flowy/packages/appflowy_board repository: https://github.com/AppFlowy-IO/AppFlowy/tree/main/frontend/app_flowy/packages/appflowy_board

View File

@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:example/plugin/code_block_node_widget.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@ -116,9 +117,17 @@ class _MyHomePageState extends State<MyHomePage> {
editorState: _editorState!, editorState: _editorState!,
editorStyle: _editorStyle, editorStyle: _editorStyle,
editable: true, editable: true,
customBuilders: {
'text/code_block': CodeBlockNodeWidgetBuilder(),
},
shortcutEvents: [ shortcutEvents: [
enterInCodeBlock,
ignoreKeysInCodeBlock,
underscoreToItalic, underscoreToItalic,
], ],
selectionMenuItems: [
codeBlockItem,
],
), ),
); );
} else { } else {

View File

@ -0,0 +1,277 @@
import 'dart:collection';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:highlight/highlight.dart' as highlight;
import 'package:highlight/languages/all.dart';
ShortcutEvent enterInCodeBlock = ShortcutEvent(
key: 'Enter in code block',
command: 'enter',
handler: _enterInCodeBlockHandler,
);
ShortcutEvent ignoreKeysInCodeBlock = ShortcutEvent(
key: 'White space in code block',
command: 'space,slash,shift+underscore',
handler: _ignorekHandler,
);
ShortcutEventHandler _enterInCodeBlockHandler = (editorState, event) {
final selection = editorState.service.selectionService.currentSelection.value;
final nodes = editorState.service.selectionService.currentSelectedNodes;
final codeBlockNode =
nodes.whereType<TextNode>().where((node) => node.id == 'text/code_block');
if (codeBlockNode.length != 1 || selection == null) {
return KeyEventResult.ignored;
}
if (selection.isCollapsed) {
TransactionBuilder(editorState)
..insertText(codeBlockNode.first, selection.end.offset, '\n')
..commit();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
};
ShortcutEventHandler _ignorekHandler = (editorState, event) {
final nodes = editorState.service.selectionService.currentSelectedNodes;
final codeBlockNodes =
nodes.whereType<TextNode>().where((node) => node.id == 'text/code_block');
if (codeBlockNodes.length == 1) {
return KeyEventResult.skipRemainingHandlers;
}
return KeyEventResult.ignored;
};
SelectionMenuItem codeBlockItem = SelectionMenuItem(
name: 'Code Block',
icon: const Icon(Icons.abc),
keywords: ['code block'],
handler: (editorState, _, __) {
final selection =
editorState.service.selectionService.currentSelection.value;
final textNodes = editorState.service.selectionService.currentSelectedNodes
.whereType<TextNode>();
if (selection == null || textNodes.isEmpty) {
return;
}
if (textNodes.first.toRawString().isEmpty) {
TransactionBuilder(editorState)
..updateNode(textNodes.first, {
'subtype': 'code_block',
'theme': 'vs',
'language': null,
})
..afterSelection = selection
..commit();
} else {
TransactionBuilder(editorState)
..insertNode(
selection.end.path.next,
TextNode(
type: 'text',
children: LinkedList(),
attributes: {
'subtype': 'code_block',
'theme': 'vs',
'language': null,
},
delta: Delta()..insert('\n'),
),
)
..afterSelection = selection
..commit();
}
},
);
class CodeBlockNodeWidgetBuilder extends NodeWidgetBuilder<TextNode> {
@override
Widget build(NodeWidgetContext<TextNode> context) {
return _CodeBlockNodeWidge(
key: context.node.key,
textNode: context.node,
editorState: context.editorState,
);
}
@override
NodeValidator<Node> get nodeValidator => (node) {
return node is TextNode && node.attributes['theme'] is String;
};
}
class _CodeBlockNodeWidge extends StatefulWidget {
const _CodeBlockNodeWidge({
Key? key,
required this.textNode,
required this.editorState,
}) : super(key: key);
final TextNode textNode;
final EditorState editorState;
@override
State<_CodeBlockNodeWidge> createState() => __CodeBlockNodeWidgeState();
}
class __CodeBlockNodeWidgeState extends State<_CodeBlockNodeWidge>
with SelectableMixin, DefaultSelectable {
final _richTextKey = GlobalKey(debugLabel: 'code_block_text');
final _padding = const EdgeInsets.only(left: 20, top: 20, bottom: 20);
String? get _language => widget.textNode.attributes['language'] as String?;
String? _detectLanguage;
@override
SelectableMixin<StatefulWidget> get forward =>
_richTextKey.currentState as SelectableMixin;
@override
GlobalKey<State<StatefulWidget>>? get iconKey => null;
@override
Offset get baseOffset => super.baseOffset + _padding.topLeft;
@override
Widget build(BuildContext context) {
return Stack(
children: [
_buildCodeBlock(context),
_buildSwitchCodeButton(context),
],
);
}
Widget _buildCodeBlock(BuildContext context) {
final result = highlight.highlight.parse(
widget.textNode.toRawString(),
language: _language,
autoDetection: _language == null,
);
_detectLanguage = _language ?? result.language;
final code = result.nodes;
final codeTextSpan = _convert(code!);
return Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
color: Colors.grey.withOpacity(0.1),
),
padding: _padding,
width: MediaQuery.of(context).size.width,
child: FlowyRichText(
key: _richTextKey,
textNode: widget.textNode,
editorState: widget.editorState,
textSpanDecorator: (textSpan) => TextSpan(
style: widget.editorState.editorStyle.textStyle.defaultTextStyle,
children: codeTextSpan,
),
),
);
}
Widget _buildSwitchCodeButton(BuildContext context) {
return Positioned(
top: -5,
right: 0,
child: DropdownButton<String>(
value: _detectLanguage,
onChanged: (value) {
TransactionBuilder(widget.editorState)
..updateNode(widget.textNode, {
'language': value,
})
..commit();
},
items: allLanguages.keys.map<DropdownMenuItem<String>>((String value) {
return DropdownMenuItem<String>(
value: value,
child: Text(
value,
style: const TextStyle(fontSize: 12.0),
),
);
}).toList(growable: false),
),
);
}
// Copy from flutter.highlight package.
// https://github.com/git-touch/highlight.dart/blob/master/flutter_highlight/lib/flutter_highlight.dart
List<TextSpan> _convert(List<highlight.Node> nodes) {
List<TextSpan> spans = [];
var currentSpans = spans;
List<List<TextSpan>> stack = [];
_traverse(highlight.Node node) {
if (node.value != null) {
currentSpans.add(node.className == null
? TextSpan(text: node.value)
: TextSpan(
text: node.value,
style: _builtInCodeBlockTheme[node.className!]));
} else if (node.children != null) {
List<TextSpan> tmp = [];
currentSpans.add(TextSpan(
children: tmp, style: _builtInCodeBlockTheme[node.className!]));
stack.add(currentSpans);
currentSpans = tmp;
for (var n in node.children!) {
_traverse(n);
if (n == node.children!.last) {
currentSpans = stack.isEmpty ? spans : stack.removeLast();
}
}
}
}
for (var node in nodes) {
_traverse(node);
}
return spans;
}
}
const _builtInCodeBlockTheme = {
'root':
TextStyle(backgroundColor: Color(0xffffffff), color: Color(0xff000000)),
'comment': TextStyle(color: Color(0xff007400)),
'quote': TextStyle(color: Color(0xff007400)),
'tag': TextStyle(color: Color(0xffaa0d91)),
'attribute': TextStyle(color: Color(0xffaa0d91)),
'keyword': TextStyle(color: Color(0xffaa0d91)),
'selector-tag': TextStyle(color: Color(0xffaa0d91)),
'literal': TextStyle(color: Color(0xffaa0d91)),
'name': TextStyle(color: Color(0xffaa0d91)),
'variable': TextStyle(color: Color(0xff3F6E74)),
'template-variable': TextStyle(color: Color(0xff3F6E74)),
'code': TextStyle(color: Color(0xffc41a16)),
'string': TextStyle(color: Color(0xffc41a16)),
'meta-string': TextStyle(color: Color(0xffc41a16)),
'regexp': TextStyle(color: Color(0xff0E0EFF)),
'link': TextStyle(color: Color(0xff0E0EFF)),
'title': TextStyle(color: Color(0xff1c00cf)),
'symbol': TextStyle(color: Color(0xff1c00cf)),
'bullet': TextStyle(color: Color(0xff1c00cf)),
'number': TextStyle(color: Color(0xff1c00cf)),
'section': TextStyle(color: Color(0xff643820)),
'meta': TextStyle(color: Color(0xff643820)),
'type': TextStyle(color: Color(0xff5c2699)),
'built_in': TextStyle(color: Color(0xff5c2699)),
'builtin-name': TextStyle(color: Color(0xff5c2699)),
'params': TextStyle(color: Color(0xff5c2699)),
'attr': TextStyle(color: Color(0xff836C28)),
'subst': TextStyle(color: Color(0xff000000)),
'formula': TextStyle(
backgroundColor: Color(0xffeeeeee), fontStyle: FontStyle.italic),
'addition': TextStyle(backgroundColor: Color(0xffbaeeba)),
'deletion': TextStyle(backgroundColor: Color(0xffffc8bd)),
'selector-id': TextStyle(color: Color(0xff9b703f)),
'selector-class': TextStyle(color: Color(0xff9b703f)),
'doctag': TextStyle(fontWeight: FontWeight.bold),
'strong': TextStyle(fontWeight: FontWeight.bold),
'emphasis': TextStyle(fontStyle: FontStyle.italic),
};

View File

@ -43,6 +43,7 @@ dependencies:
sdk: flutter sdk: flutter
file_picker: ^5.0.1 file_picker: ^5.0.1
universal_html: ^2.0.8 universal_html: ^2.0.8
highlight: ^0.7.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

@ -28,4 +28,8 @@ export 'src/service/shortcut_event/keybinding.dart';
export 'src/service/shortcut_event/shortcut_event.dart'; export 'src/service/shortcut_event/shortcut_event.dart';
export 'src/service/shortcut_event/shortcut_event_handler.dart'; export 'src/service/shortcut_event/shortcut_event_handler.dart';
export 'src/extensions/attributes_extension.dart'; export 'src/extensions/attributes_extension.dart';
export 'src/extensions/path_extensions.dart';
export 'src/render/rich_text/default_selectable.dart';
export 'src/render/rich_text/flowy_rich_text.dart';
export 'src/render/selection_menu/selection_menu_widget.dart';
export 'src/l10n/l10n.dart'; export 'src/l10n/l10n.dart';

View File

@ -0,0 +1,35 @@
{
"@@locale": "nl-NL",
"bold": "Vet",
"@bold": {},
"bulletedList": "Opsommingstekens",
"@bulletedList": {},
"checkbox": "Selectievakje",
"@checkbox": {},
"embedCode": "Invoegcode",
"@embedCode": {},
"heading1": "H1",
"@heading1": {},
"heading2": "H2",
"@heading2": {},
"heading3": "H3",
"@heading3": {},
"highlight": "Highlight",
"@highlight": {},
"image": "Afbeelding",
"@image": {},
"italic": "Cursief",
"@italic": {},
"link": "",
"@link": {},
"numberedList": "Nummering",
"@numberedList": {},
"quote": "Quote",
"@quote": {},
"strikethrough": "Doorhalen",
"@strikethrough": {},
"text": "Tekst",
"@text": {},
"underline": "Onderstrepen",
"@underline": {}
}

View File

@ -0,0 +1,34 @@
import 'dart:async';
import 'package:appflowy_editor/src/commands/text_command_infra.dart';
import 'package:appflowy_editor/src/document/node.dart';
import 'package:appflowy_editor/src/document/path.dart';
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/operation/transaction_builder.dart';
import 'package:flutter/widgets.dart';
Future<void> insertContextInText(
EditorState editorState,
int index,
String content, {
Path? path,
TextNode? textNode,
}) async {
final result = getTextNodeToBeFormatted(
editorState,
path: path,
textNode: textNode,
);
final completer = Completer<void>();
TransactionBuilder(editorState)
..insertText(result, index, content)
..commit();
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
completer.complete();
});
return completer.future;
}

View File

@ -0,0 +1,83 @@
import 'package:appflowy_editor/src/commands/format_text.dart';
import 'package:appflowy_editor/src/commands/text_command_infra.dart';
import 'package:appflowy_editor/src/document/attributes.dart';
import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
import 'package:appflowy_editor/src/document/node.dart';
import 'package:appflowy_editor/src/document/path.dart';
import 'package:appflowy_editor/src/document/selection.dart';
import 'package:appflowy_editor/src/editor_state.dart';
Future<void> formatBuiltInTextAttributes(
EditorState editorState,
String key,
Attributes attributes, {
Selection? selection,
Path? path,
TextNode? textNode,
}) async {
final result = getTextNodeToBeFormatted(
editorState,
path: path,
textNode: textNode,
);
if (BuiltInAttributeKey.globalStyleKeys.contains(key)) {
// remove all the existing style
final newAttributes = result.attributes
..removeWhere((key, value) {
if (BuiltInAttributeKey.globalStyleKeys.contains(key)) {
return true;
}
return false;
})
..addAll(attributes)
..addAll({
BuiltInAttributeKey.subtype: key,
});
return updateTextNodeAttributes(
editorState,
newAttributes,
textNode: textNode,
);
} else if (BuiltInAttributeKey.partialStyleKeys.contains(key)) {
return updateTextNodeDeltaAttributes(
editorState,
selection,
attributes,
textNode: textNode,
);
}
}
Future<void> formatTextToCheckbox(
EditorState editorState,
bool check, {
Path? path,
TextNode? textNode,
}) async {
return formatBuiltInTextAttributes(
editorState,
BuiltInAttributeKey.checkbox,
{
BuiltInAttributeKey.checkbox: check,
},
path: path,
textNode: textNode,
);
}
Future<void> formatLinkInText(
EditorState editorState,
String? link, {
Path? path,
TextNode? textNode,
}) async {
return formatBuiltInTextAttributes(
editorState,
BuiltInAttributeKey.href,
{
BuiltInAttributeKey.href: link,
},
path: path,
textNode: textNode,
);
}

View File

@ -0,0 +1,67 @@
import 'dart:async';
import 'package:appflowy_editor/src/commands/text_command_infra.dart';
import 'package:appflowy_editor/src/document/attributes.dart';
import 'package:appflowy_editor/src/document/node.dart';
import 'package:appflowy_editor/src/document/path.dart';
import 'package:appflowy_editor/src/document/selection.dart';
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/operation/transaction_builder.dart';
import 'package:flutter/widgets.dart';
Future<void> updateTextNodeAttributes(
EditorState editorState,
Attributes attributes, {
Path? path,
TextNode? textNode,
}) async {
final result = getTextNodeToBeFormatted(
editorState,
path: path,
textNode: textNode,
);
final completer = Completer<void>();
TransactionBuilder(editorState)
..updateNode(result, attributes)
..commit();
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
completer.complete();
});
return completer.future;
}
Future<void> updateTextNodeDeltaAttributes(
EditorState editorState,
Selection? selection,
Attributes attributes, {
Path? path,
TextNode? textNode,
}) {
final result = getTextNodeToBeFormatted(
editorState,
path: path,
textNode: textNode,
);
final newSelection = getSelection(editorState, selection: selection);
final completer = Completer<void>();
TransactionBuilder(editorState)
..formatText(
result,
newSelection.startIndex,
newSelection.length,
attributes,
)
..commit();
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
completer.complete();
});
return completer.future;
}

View File

@ -0,0 +1,43 @@
import 'package:appflowy_editor/src/document/node.dart';
import 'package:appflowy_editor/src/document/path.dart';
import 'package:appflowy_editor/src/document/selection.dart';
import 'package:appflowy_editor/src/editor_state.dart';
// get formatted [TextNode]
TextNode getTextNodeToBeFormatted(
EditorState editorState, {
Path? path,
TextNode? textNode,
}) {
final currentSelection =
editorState.service.selectionService.currentSelection.value;
TextNode result;
if (textNode != null) {
result = textNode;
} else if (path != null) {
result = editorState.document.nodeAtPath(path) as TextNode;
} else if (currentSelection != null && currentSelection.isCollapsed) {
result = editorState.document.nodeAtPath(currentSelection.start.path)
as TextNode;
} else {
throw Exception('path and textNode cannot be null at the same time');
}
return result;
}
Selection getSelection(
EditorState editorState, {
Selection? selection,
}) {
final currentSelection =
editorState.service.selectionService.currentSelection.value;
Selection result;
if (selection != null) {
result = selection;
} else if (currentSelection != null) {
result = currentSelection;
} else {
throw Exception('path and textNode cannot be null at the same time');
}
return result;
}

View File

@ -93,12 +93,14 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
} }
void updateAttributes(Attributes attributes) { void updateAttributes(Attributes attributes) {
bool shouldNotifyParent = _attributes['subtype'] != attributes['subtype']; final oldAttributes = {..._attributes};
_attributes = composeAttributes(_attributes, attributes) ?? {}; _attributes = composeAttributes(_attributes, attributes) ?? {};
// Notifies the new attributes // Notifies the new attributes
// if attributes contains 'subtype', should notify parent to rebuild node // if attributes contains 'subtype', should notify parent to rebuild node
// else, just notify current node. // else, just notify current node.
bool shouldNotifyParent =
_attributes['subtype'] != oldAttributes['subtype'];
shouldNotifyParent ? parent?.notifyListeners() : notifyListeners(); shouldNotifyParent ? parent?.notifyListeners() : notifyListeners();
} }

View File

@ -53,6 +53,10 @@ class Selection {
Selection get reversed => copyWith(start: end, end: start); Selection get reversed => copyWith(start: end, end: start);
int get startIndex => normalize.start.offset;
int get endIndex => normalize.end.offset;
int get length => endIndex - startIndex;
Selection collapse({bool atStart = false}) { Selection collapse({bool atStart = false}) {
if (atStart) { if (atStart) {
return Selection(start: start, end: start); return Selection(start: start, end: start);

View File

@ -72,6 +72,8 @@ class EditorState {
// TODO: only for testing. // TODO: only for testing.
bool disableSealTimer = false; bool disableSealTimer = false;
bool editable = true;
Selection? get cursorSelection { Selection? get cursorSelection {
return _cursorSelection; return _cursorSelection;
} }
@ -112,6 +114,9 @@ class EditorState {
/// should record the transaction in undo/redo stack. /// should record the transaction in undo/redo stack.
apply(Transaction transaction, apply(Transaction transaction,
[ApplyOptions options = const ApplyOptions()]) { [ApplyOptions options = const ApplyOptions()]) {
if (!editable) {
return;
}
// TODO: validate the transation. // TODO: validate the transation.
for (final op in transaction.operations) { for (final op in transaction.operations) {
_applyOperation(op); _applyOperation(op);

View File

@ -31,6 +31,7 @@ class _LinkMenuState extends State<LinkMenu> {
void initState() { void initState() {
super.initState(); super.initState();
_textEditingController.text = widget.linkText ?? ''; _textEditingController.text = widget.linkText ?? '';
_focusNode.requestFocus();
_focusNode.addListener(_onFocusChange); _focusNode.addListener(_onFocusChange);
} }

View File

@ -1,15 +1,8 @@
import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/document/node.dart'; import 'package:appflowy_editor/src/commands/format_built_in_text.dart';
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/infra/flowy_svg.dart'; import 'package:appflowy_editor/src/infra/flowy_svg.dart';
import 'package:appflowy_editor/src/render/rich_text/built_in_text_widget.dart'; import 'package:appflowy_editor/src/render/rich_text/built_in_text_widget.dart';
import 'package:appflowy_editor/src/render/rich_text/default_selectable.dart';
import 'package:appflowy_editor/src/render/rich_text/flowy_rich_text.dart';
import 'package:appflowy_editor/src/render/selection/selectable.dart';
import 'package:appflowy_editor/src/service/default_text_operations/format_rich_text_style.dart';
import 'package:appflowy_editor/src/service/render_plugin_service.dart';
import 'package:appflowy_editor/src/extensions/attributes_extension.dart';
import 'package:appflowy_editor/src/extensions/text_style_extension.dart'; import 'package:appflowy_editor/src/extensions/text_style_extension.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -81,8 +74,12 @@ class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
padding: iconPadding, padding: iconPadding,
name: check ? 'check' : 'uncheck', name: check ? 'check' : 'uncheck',
), ),
onTap: () { onTap: () async {
formatCheckbox(widget.editorState, !check); await formatTextToCheckbox(
widget.editorState,
!check,
textNode: widget.textNode,
);
}, },
), ),
Flexible( Flexible(

View File

@ -18,6 +18,8 @@ import 'package:appflowy_editor/src/extensions/attributes_extension.dart';
import 'package:appflowy_editor/src/render/selection/selectable.dart'; import 'package:appflowy_editor/src/render/selection/selectable.dart';
import 'package:appflowy_editor/src/render/toolbar/toolbar_item.dart'; import 'package:appflowy_editor/src/render/toolbar/toolbar_item.dart';
const _kRichTextDebugMode = false;
typedef FlowyTextSpanDecorator = TextSpan Function(TextSpan textSpan); typedef FlowyTextSpanDecorator = TextSpan Function(TextSpan textSpan);
class FlowyRichText extends StatefulWidget { class FlowyRichText extends StatefulWidget {
@ -261,6 +263,17 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
), ),
); );
} }
if (_kRichTextDebugMode) {
textSpans.add(
TextSpan(
text: '${widget.textNode.path}',
style: const TextStyle(
backgroundColor: Colors.red,
fontSize: 16.0,
),
),
);
}
return TextSpan( return TextSpan(
children: textSpans, children: textSpans,
); );

View File

@ -1,4 +1,5 @@
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/commands/format_built_in_text.dart';
import 'package:appflowy_editor/src/extensions/url_launcher_extension.dart'; import 'package:appflowy_editor/src/extensions/url_launcher_extension.dart';
import 'package:appflowy_editor/src/infra/flowy_svg.dart'; import 'package:appflowy_editor/src/infra/flowy_svg.dart';
import 'package:appflowy_editor/src/render/link_menu/link_menu.dart'; import 'package:appflowy_editor/src/render/link_menu/link_menu.dart';
@ -8,7 +9,6 @@ import 'package:appflowy_editor/src/service/default_text_operations/format_rich_
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:rich_clipboard/rich_clipboard.dart'; import 'package:rich_clipboard/rich_clipboard.dart';
import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
typedef ToolbarItemEventHandler = void Function( typedef ToolbarItemEventHandler = void Function(
EditorState editorState, BuildContext context); EditorState editorState, BuildContext context);
@ -120,7 +120,7 @@ List<ToolbarItem> defaultToolbarItems = [
name: 'toolbar/bold', name: 'toolbar/bold',
color: isHighlight ? Colors.lightBlue : null, color: isHighlight ? Colors.lightBlue : null,
), ),
validator: _showInTextSelection, validator: _showInBuiltInTextSelection,
highlightCallback: (editorState) => _allSatisfy( highlightCallback: (editorState) => _allSatisfy(
editorState, editorState,
BuiltInAttributeKey.bold, BuiltInAttributeKey.bold,
@ -136,7 +136,7 @@ List<ToolbarItem> defaultToolbarItems = [
name: 'toolbar/italic', name: 'toolbar/italic',
color: isHighlight ? Colors.lightBlue : null, color: isHighlight ? Colors.lightBlue : null,
), ),
validator: _showInTextSelection, validator: _showInBuiltInTextSelection,
highlightCallback: (editorState) => _allSatisfy( highlightCallback: (editorState) => _allSatisfy(
editorState, editorState,
BuiltInAttributeKey.italic, BuiltInAttributeKey.italic,
@ -152,7 +152,7 @@ List<ToolbarItem> defaultToolbarItems = [
name: 'toolbar/underline', name: 'toolbar/underline',
color: isHighlight ? Colors.lightBlue : null, color: isHighlight ? Colors.lightBlue : null,
), ),
validator: _showInTextSelection, validator: _showInBuiltInTextSelection,
highlightCallback: (editorState) => _allSatisfy( highlightCallback: (editorState) => _allSatisfy(
editorState, editorState,
BuiltInAttributeKey.underline, BuiltInAttributeKey.underline,
@ -168,7 +168,7 @@ List<ToolbarItem> defaultToolbarItems = [
name: 'toolbar/strikethrough', name: 'toolbar/strikethrough',
color: isHighlight ? Colors.lightBlue : null, color: isHighlight ? Colors.lightBlue : null,
), ),
validator: _showInTextSelection, validator: _showInBuiltInTextSelection,
highlightCallback: (editorState) => _allSatisfy( highlightCallback: (editorState) => _allSatisfy(
editorState, editorState,
BuiltInAttributeKey.strikethrough, BuiltInAttributeKey.strikethrough,
@ -184,7 +184,7 @@ List<ToolbarItem> defaultToolbarItems = [
name: 'toolbar/code', name: 'toolbar/code',
color: isHighlight ? Colors.lightBlue : null, color: isHighlight ? Colors.lightBlue : null,
), ),
validator: _showInTextSelection, validator: _showInBuiltInTextSelection,
highlightCallback: (editorState) => _allSatisfy( highlightCallback: (editorState) => _allSatisfy(
editorState, editorState,
BuiltInAttributeKey.code, BuiltInAttributeKey.code,
@ -248,7 +248,7 @@ List<ToolbarItem> defaultToolbarItems = [
name: 'toolbar/highlight', name: 'toolbar/highlight',
color: isHighlight ? Colors.lightBlue : null, color: isHighlight ? Colors.lightBlue : null,
), ),
validator: _showInTextSelection, validator: _showInBuiltInTextSelection,
highlightCallback: (editorState) => _allSatisfy( highlightCallback: (editorState) => _allSatisfy(
editorState, editorState,
BuiltInAttributeKey.backgroundColor, BuiltInAttributeKey.backgroundColor,
@ -262,13 +262,22 @@ List<ToolbarItem> defaultToolbarItems = [
]; ];
ToolbarItemValidator _onlyShowInSingleTextSelection = (editorState) { ToolbarItemValidator _onlyShowInSingleTextSelection = (editorState) {
final result = _showInBuiltInTextSelection(editorState);
if (!result) {
return false;
}
final nodes = editorState.service.selectionService.currentSelectedNodes; final nodes = editorState.service.selectionService.currentSelectedNodes;
return (nodes.length == 1 && nodes.first is TextNode); return (nodes.length == 1 && nodes.first is TextNode);
}; };
ToolbarItemValidator _showInTextSelection = (editorState) { ToolbarItemValidator _showInBuiltInTextSelection = (editorState) {
final nodes = editorState.service.selectionService.currentSelectedNodes final nodes = editorState.service.selectionService.currentSelectedNodes
.whereType<TextNode>(); .whereType<TextNode>()
.where(
(textNode) =>
BuiltInAttributeKey.globalStyleKeys.contains(textNode.subtype) ||
textNode.subtype == null,
);
return nodes.isNotEmpty; return nodes.isNotEmpty;
}; };
@ -337,11 +346,8 @@ void showLinkMenu(
onOpenLink: () async { onOpenLink: () async {
await safeLaunchUrl(linkText); await safeLaunchUrl(linkText);
}, },
onSubmitted: (text) { onSubmitted: (text) async {
TransactionBuilder(editorState) await formatLinkInText(editorState, text, textNode: textNode);
..formatText(
textNode, index, length, {BuiltInAttributeKey.href: text})
..commit();
_dismissLinkMenu(); _dismissLinkMenu();
}, },
onCopyLink: () { onCopyLink: () {
@ -369,6 +375,7 @@ void showLinkMenu(
Overlay.of(context)?.insert(_linkMenuOverlay!); Overlay.of(context)?.insert(_linkMenuOverlay!);
editorState.service.scrollService?.disable(); editorState.service.scrollService?.disable();
editorState.service.keyboardService?.disable();
editorState.service.selectionService.currentSelection editorState.service.selectionService.currentSelection
.addListener(_dismissLinkMenu); .addListener(_dismissLinkMenu);
} }

View File

@ -103,13 +103,17 @@ bool formatTextNodes(EditorState editorState, Attributes attributes) {
final builder = TransactionBuilder(editorState); final builder = TransactionBuilder(editorState);
for (final textNode in textNodes) { for (final textNode in textNodes) {
var newAttributes = {...textNode.attributes};
for (final globalStyleKey in BuiltInAttributeKey.globalStyleKeys) {
if (newAttributes.keys.contains(globalStyleKey)) {
newAttributes[globalStyleKey] = null;
}
}
newAttributes.addAll(attributes);
builder builder
..updateNode( ..updateNode(
textNode, textNode,
Attributes.fromIterable( newAttributes,
BuiltInAttributeKey.globalStyleKeys,
value: (_) => null,
)..addAll(attributes),
) )
..afterSelection = Selection.collapsed( ..afterSelection = Selection.collapsed(
Position( Position(

View File

@ -72,6 +72,7 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
editorState.selectionMenuItems = widget.selectionMenuItems; editorState.selectionMenuItems = widget.selectionMenuItems;
editorState.editorStyle = widget.editorStyle; editorState.editorStyle = widget.editorStyle;
editorState.service.renderPluginService = _createRenderPlugin(); editorState.service.renderPluginService = _createRenderPlugin();
editorState.editable = widget.editable;
} }
@override @override
@ -84,6 +85,7 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
} }
editorState.editorStyle = widget.editorStyle; editorState.editorStyle = widget.editorStyle;
editorState.editable = widget.editable;
services = null; services = null;
} }
@ -118,8 +120,8 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
key: editorState.service.keyboardServiceKey, key: editorState.service.keyboardServiceKey,
editable: widget.editable, editable: widget.editable,
shortcutEvents: [ shortcutEvents: [
...builtInShortcutEvents,
...widget.shortcutEvents, ...widget.shortcutEvents,
...builtInShortcutEvents,
], ],
editorState: editorState, editorState: editorState,
child: FlowyToolbar( child: FlowyToolbar(

View File

@ -297,7 +297,11 @@ class _AppFlowyInputState extends State<AppFlowyInput>
_updateCaretPosition(textNodes.first, selection); _updateCaretPosition(textNodes.first, selection);
} }
} else { } else {
// close(); // https://github.com/flutter/flutter/issues/104944
// Disable IME for the Web.
if (kIsWeb) {
close();
}
} }
} }

View File

@ -117,12 +117,17 @@ ShortcutEventHandler enterWithoutShiftInTextNodesHandler =
makeFollowingNodesIncremental(editorState, insertPath, afterSelection, makeFollowingNodesIncremental(editorState, insertPath, afterSelection,
beginNum: prevNumber); beginNum: prevNumber);
} else { } else {
bool needCopyAttributes = ![
BuiltInAttributeKey.heading,
BuiltInAttributeKey.quote,
].contains(subtype);
TransactionBuilder(editorState) TransactionBuilder(editorState)
..insertNode( ..insertNode(
textNode.path, textNode.path,
textNode.copyWith( textNode.copyWith(
children: LinkedList(), children: LinkedList(),
delta: Delta(), delta: Delta(),
attributes: needCopyAttributes ? null : {},
), ),
) )
..afterSelection = afterSelection ..afterSelection = afterSelection
@ -173,7 +178,9 @@ ShortcutEventHandler enterWithoutShiftInTextNodesHandler =
Attributes _attributesFromPreviousLine(TextNode textNode) { Attributes _attributesFromPreviousLine(TextNode textNode) {
final prevAttributes = textNode.attributes; final prevAttributes = textNode.attributes;
final subType = textNode.subtype; final subType = textNode.subtype;
if (subType == null || subType == BuiltInAttributeKey.heading) { if (subType == null ||
subType == BuiltInAttributeKey.heading ||
subType == BuiltInAttributeKey.quote) {
return {}; return {};
} }

View File

@ -0,0 +1,21 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/commands/edit_text.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
ShortcutEventHandler spaceOnWebHandler = (editorState, event) {
final selection = editorState.service.selectionService.currentSelection.value;
final textNodes = editorState.service.selectionService.currentSelectedNodes
.whereType<TextNode>()
.toList(growable: false);
if (selection == null ||
!selection.isCollapsed ||
!kIsWeb ||
textNodes.length != 1) {
return KeyEventResult.ignored;
}
insertContextInText(editorState, selection.startIndex, ' ');
return KeyEventResult.handled;
};

View File

@ -13,12 +13,19 @@ ShortcutEventHandler tabHandler = (editorState, event) {
final textNode = textNodes.first; final textNode = textNodes.first;
final previous = textNode.previous; final previous = textNode.previous;
if (textNode.subtype != BuiltInAttributeKey.bulletedList ||
previous == null || if (textNode.subtype != BuiltInAttributeKey.bulletedList) {
previous.subtype != BuiltInAttributeKey.bulletedList) { TransactionBuilder(editorState)
..insertText(textNode, selection.end.offset, ' ' * 4)
..commit();
return KeyEventResult.handled; return KeyEventResult.handled;
} }
if (previous == null ||
previous.subtype != BuiltInAttributeKey.bulletedList) {
return KeyEventResult.ignored;
}
final path = previous.path + [previous.children.length]; final path = previous.path + [previous.children.length];
final afterSelection = Selection( final afterSelection = Selection(
start: selection.start.copyWith(path: path), start: selection.start.copyWith(path: path),

View File

@ -124,6 +124,8 @@ class _AppFlowyKeyboardState extends State<AppFlowyKeyboard>
final result = shortcutEvent.handler(widget.editorState, event); final result = shortcutEvent.handler(widget.editorState, event);
if (result == KeyEventResult.handled) { if (result == KeyEventResult.handled) {
return KeyEventResult.handled; return KeyEventResult.handled;
} else if (result == KeyEventResult.skipRemainingHandlers) {
return KeyEventResult.skipRemainingHandlers;
} }
continue; continue;
} }

View File

@ -10,9 +10,11 @@ import 'package:appflowy_editor/src/service/internal_key_event_handlers/redo_und
import 'package:appflowy_editor/src/service/internal_key_event_handlers/select_all_handler.dart'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/select_all_handler.dart';
import 'package:appflowy_editor/src/service/internal_key_event_handlers/slash_handler.dart'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/slash_handler.dart';
import 'package:appflowy_editor/src/service/internal_key_event_handlers/format_style_handler.dart'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/format_style_handler.dart';
import 'package:appflowy_editor/src/service/internal_key_event_handlers/space_on_web_handler.dart';
import 'package:appflowy_editor/src/service/internal_key_event_handlers/tab_handler.dart'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/tab_handler.dart';
import 'package:appflowy_editor/src/service/internal_key_event_handlers/whitespace_handler.dart'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/whitespace_handler.dart';
import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event.dart'; import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event.dart';
import 'package:flutter/foundation.dart';
// //
List<ShortcutEvent> builtInShortcutEvents = [ List<ShortcutEvent> builtInShortcutEvents = [
@ -255,4 +257,14 @@ List<ShortcutEvent> builtInShortcutEvents = [
command: 'backquote', command: 'backquote',
handler: backquoteToCodeHandler, handler: backquoteToCodeHandler,
), ),
// https://github.com/flutter/flutter/issues/104944
// Workaround: Using space editing on the web platform often results in errors,
// so adding a shortcut event to handle the space input instead of using the
// `input_service`.
if (kIsWeb)
ShortcutEvent(
key: 'Space on the Web',
command: 'space',
handler: spaceOnWebHandler,
),
]; ];

View File

@ -38,14 +38,17 @@ class _FlowyToolbarState extends State<FlowyToolbar>
@override @override
void showInOffset(Offset offset, LayerLink layerLink) { void showInOffset(Offset offset, LayerLink layerLink) {
hide(); hide();
final items = _filterItems(defaultToolbarItems);
if (items.isEmpty) {
return;
}
_toolbarOverlay = OverlayEntry( _toolbarOverlay = OverlayEntry(
builder: (context) => ToolbarWidget( builder: (context) => ToolbarWidget(
key: _toolbarWidgetKey, key: _toolbarWidgetKey,
editorState: widget.editorState, editorState: widget.editorState,
layerLink: layerLink, layerLink: layerLink,
offset: offset, offset: offset,
items: _filterItems(defaultToolbarItems), items: items,
), ),
); );
Overlay.of(context)?.insert(_toolbarOverlay!); Overlay.of(context)?.insert(_toolbarOverlay!);
@ -102,9 +105,4 @@ class _FlowyToolbarState extends State<FlowyToolbar>
} }
return dividedItems; return dividedItems;
} }
// List<ToolbarItem> _highlightItems(
// List<ToolbarItem> items,
// Selection selection,
// ) {}
} }

View File

@ -2,7 +2,6 @@ import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import '../../infra/test_editor.dart'; import '../../infra/test_editor.dart';
import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
void main() async { void main() async {
setUpAll(() { setUpAll(() {
@ -171,14 +170,28 @@ Future<void> _testStyleNeedToBeCopy(WidgetTester tester, String style) async {
LogicalKeyboardKey.enter, LogicalKeyboardKey.enter,
); );
expect(editor.documentSelection, Selection.single(path: [4], startOffset: 0)); expect(editor.documentSelection, Selection.single(path: [4], startOffset: 0));
if ([BuiltInAttributeKey.heading, BuiltInAttributeKey.quote]
.contains(style)) {
expect(editor.nodeAtPath([4])?.subtype, null);
await editor.pressLogicKey(
LogicalKeyboardKey.enter,
);
expect(
editor.documentSelection, Selection.single(path: [5], startOffset: 0));
expect(editor.nodeAtPath([5])?.subtype, null);
} else {
expect(editor.nodeAtPath([4])?.subtype, style); expect(editor.nodeAtPath([4])?.subtype, style);
await editor.pressLogicKey( await editor.pressLogicKey(
LogicalKeyboardKey.enter, LogicalKeyboardKey.enter,
); );
expect(editor.documentSelection, Selection.single(path: [4], startOffset: 0)); expect(
editor.documentSelection, Selection.single(path: [4], startOffset: 0));
expect(editor.nodeAtPath([4])?.subtype, null); expect(editor.nodeAtPath([4])?.subtype, null);
} }
}
Future<void> _testMultipleSelection( Future<void> _testMultipleSelection(
WidgetTester tester, bool isBackwardSelection) async { WidgetTester tester, bool isBackwardSelection) async {

View File

@ -0,0 +1,45 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../infra/test_editor.dart';
void main() async {
setUpAll(() {
TestWidgetsFlutterBinding.ensureInitialized();
});
group('space_on_web_handler.dart', () {
testWidgets('Presses space key on web', (tester) async {
if (!kIsWeb) return;
const count = 10;
const text = 'Welcome to Appflowy 😁';
final editor = tester.editor;
for (var i = 0; i < count; i++) {
editor.insertTextNode(text);
}
await editor.startTesting();
for (var i = 0; i < count; i++) {
await editor.updateSelection(
Selection.single(path: [i], startOffset: 1),
);
await editor.pressLogicKey(LogicalKeyboardKey.space);
expect(
(editor.nodeAtPath([i]) as TextNode).toRawString(),
'W elcome to Appflowy 😁',
);
}
for (var i = 0; i < count; i++) {
await editor.updateSelection(
Selection.single(path: [i], startOffset: text.length + 1),
);
await editor.pressLogicKey(LogicalKeyboardKey.space);
expect(
(editor.nodeAtPath([i]) as TextNode).toRawString(),
'W elcome to Appflowy 😁 ',
);
}
});
});
}

View File

@ -15,23 +15,24 @@ void main() async {
..insertTextNode(text) ..insertTextNode(text)
..insertTextNode(text); ..insertTextNode(text);
await editor.startTesting(); await editor.startTesting();
final document = editor.document;
var selection = Selection.single(path: [0], startOffset: 0); var selection = Selection.single(path: [0], startOffset: 0);
await editor.updateSelection(selection); await editor.updateSelection(selection);
await editor.pressLogicKey(LogicalKeyboardKey.tab); await editor.pressLogicKey(LogicalKeyboardKey.tab);
// nothing happens expect(
expect(editor.documentSelection, selection); editor.documentSelection,
expect(editor.document.toJson(), document.toJson()); Selection.single(path: [0], startOffset: 4),
);
selection = Selection.single(path: [1], startOffset: 0); selection = Selection.single(path: [1], startOffset: 0);
await editor.updateSelection(selection); await editor.updateSelection(selection);
await editor.pressLogicKey(LogicalKeyboardKey.tab); await editor.pressLogicKey(LogicalKeyboardKey.tab);
// nothing happens expect(
expect(editor.documentSelection, selection); editor.documentSelection,
expect(editor.document.toJson(), document.toJson()); Selection.single(path: [1], startOffset: 4),
);
}); });
testWidgets('press tab in bulleted list', (tester) async { testWidgets('press tab in bulleted list', (tester) async {
@ -63,7 +64,10 @@ void main() async {
await editor.pressLogicKey(LogicalKeyboardKey.tab); await editor.pressLogicKey(LogicalKeyboardKey.tab);
// nothing happens // nothing happens
expect(editor.documentSelection, selection); expect(
editor.documentSelection,
Selection.single(path: [0], startOffset: 0),
);
expect(editor.document.toJson(), document.toJson()); expect(editor.document.toJson(), document.toJson());
// Before // Before

View File

@ -1444,9 +1444,9 @@ checksum = "eabb4a44450da02c90444cf74558da904edde8fb4e9035a9a6a4e15445af0bd7"
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.11.2" version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]] [[package]]
name = "heck" name = "heck"
@ -1610,9 +1610,9 @@ checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683"
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "1.8.1" version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f647032dfaa1f8b6dc29bd3edb7bbef4861b8b8007ebb118d6db284fd59f6ee" checksum = "10a35a97730320ffe8e2d410b5d3b69279b98d2c14bdb8b70ea89ecf7888d41e"
dependencies = [ dependencies = [
"autocfg", "autocfg",
"hashbrown", "hashbrown",

View File

@ -206,6 +206,15 @@ impl std::convert::From<&str> for ViewIdPB {
} }
} }
#[derive(Default, ProtoBuf, Clone, Debug)]
pub struct DeletedViewPB {
#[pb(index = 1)]
pub view_id: String,
#[pb(index = 2, one_of)]
pub index: Option<i32>,
}
impl std::ops::Deref for ViewIdPB { impl std::ops::Deref for ViewIdPB {
type Target = str; type Target = str;

View File

@ -1,5 +1,5 @@
pub use crate::entities::view::ViewDataTypePB; pub use crate::entities::view::ViewDataTypePB;
use crate::entities::{ViewInfoPB, ViewLayoutTypePB}; use crate::entities::{DeletedViewPB, ViewInfoPB, ViewLayoutTypePB};
use crate::manager::{ViewDataProcessor, ViewDataProcessorMap}; use crate::manager::{ViewDataProcessor, ViewDataProcessorMap};
use crate::{ use crate::{
dart_notification::{send_dart_notification, FolderNotification}, dart_notification::{send_dart_notification, FolderNotification},
@ -122,12 +122,12 @@ impl ViewController {
.await .await
} }
#[tracing::instrument(level = "debug", skip(self, view_id), fields(view_id = %view_id.value), err)] #[tracing::instrument(level = "debug", skip(self, view_id), err)]
pub(crate) async fn read_view(&self, view_id: ViewIdPB) -> Result<ViewRevision, FlowyError> { pub(crate) async fn read_view(&self, view_id: &str) -> Result<ViewRevision, FlowyError> {
let view_rev = self let view_rev = self
.persistence .persistence
.begin_transaction(|transaction| { .begin_transaction(|transaction| {
let view = transaction.read_view(&view_id.value)?; let view = transaction.read_view(view_id)?;
let trash_ids = self.trash_controller.read_trash_ids(&transaction)?; let trash_ids = self.trash_controller.read_trash_ids(&transaction)?;
if trash_ids.contains(&view.id) { if trash_ids.contains(&view.id) {
return Err(FlowyError::record_not_found()); return Err(FlowyError::record_not_found());
@ -135,7 +135,6 @@ impl ViewController {
Ok(view) Ok(view)
}) })
.await?; .await?;
let _ = self.read_view_on_server(view_id);
Ok(view_rev) Ok(view_rev)
} }
@ -201,9 +200,26 @@ impl ViewController {
let _ = KV::remove(LATEST_VIEW_ID); let _ = KV::remove(LATEST_VIEW_ID);
} }
} }
let view_id_pb = ViewIdPB::from(view_id.as_str());
let deleted_view = self
.persistence
.begin_transaction(|transaction| {
let view = transaction.read_view(&view_id)?;
let views = read_belonging_views_on_local(&view.app_id, self.trash_controller.clone(), &transaction)?;
let index = views
.iter()
.position(|view| view.id == view_id)
.map(|index| index as i32);
Ok(DeletedViewPB {
view_id: view_id.clone(),
index,
})
})
.await?;
send_dart_notification(&view_id, FolderNotification::ViewMoveToTrash) send_dart_notification(&view_id, FolderNotification::ViewMoveToTrash)
.payload(view_id_pb) .payload(deleted_view)
.send(); .send();
let processor = self.get_data_processor_from_view_id(&view_id).await?; let processor = self.get_data_processor_from_view_id(&view_id).await?;

View File

@ -31,7 +31,7 @@ pub(crate) async fn read_view_handler(
controller: AppData<Arc<ViewController>>, controller: AppData<Arc<ViewController>>,
) -> DataResult<ViewPB, FlowyError> { ) -> DataResult<ViewPB, FlowyError> {
let view_id: ViewIdPB = data.into_inner(); let view_id: ViewIdPB = data.into_inner();
let view_rev = controller.read_view(view_id.clone()).await?; let view_rev = controller.read_view(&view_id.value).await?;
data_result(view_rev.into()) data_result(view_rev.into())
} }

View File

@ -34,7 +34,7 @@ rayon = "1.5.2"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = {version = "1.0"} serde_json = {version = "1.0"}
serde_repr = "0.1" serde_repr = "0.1"
indexmap = {version = "1.8.1", features = ["serde"]} indexmap = {version = "1.9.1", features = ["serde"]}
fancy-regex = "0.10.0" fancy-regex = "0.10.0"
regex = "1.5.6" regex = "1.5.6"
url = { version = "2"} url = { version = "2"}

View File

@ -1,3 +1,4 @@
use crate::entities::FieldType;
use flowy_derive::ProtoBuf; use flowy_derive::ProtoBuf;
use flowy_error::ErrorCode; use flowy_error::ErrorCode;
use flowy_grid_data_model::parser::NotEmptyStr; use flowy_grid_data_model::parser::NotEmptyStr;
@ -74,15 +75,20 @@ pub struct GridCellPB {
#[pb(index = 1)] #[pb(index = 1)]
pub field_id: String, pub field_id: String,
// The data was encoded in field_type's data type
#[pb(index = 2)] #[pb(index = 2)]
pub data: Vec<u8>, pub data: Vec<u8>,
#[pb(index = 3, one_of)]
pub field_type: Option<FieldType>,
} }
impl GridCellPB { impl GridCellPB {
pub fn new(field_id: &str, data: Vec<u8>) -> Self { pub fn new(field_id: &str, field_type: FieldType, data: Vec<u8>) -> Self {
Self { Self {
field_id: field_id.to_owned(), field_id: field_id.to_owned(),
data, data,
field_type: Some(field_type),
} }
} }
@ -90,6 +96,7 @@ impl GridCellPB {
Self { Self {
field_id: field_id.to_owned(), field_id: field_id.to_owned(),
data: vec![], data: vec![],
field_type: None,
} }
} }
} }

View File

@ -98,6 +98,7 @@ pub struct MoveGroupPayloadPB {
pub to_group_id: String, pub to_group_id: String,
} }
#[derive(Debug)]
pub struct MoveGroupParams { pub struct MoveGroupParams {
pub view_id: String, pub view_id: String,
pub from_group_id: String, pub from_group_id: String,

View File

@ -24,14 +24,28 @@ pub trait CellDisplayable<CD> {
decoded_field_type: &FieldType, decoded_field_type: &FieldType,
field_rev: &FieldRevision, field_rev: &FieldRevision,
) -> FlowyResult<CellBytes>; ) -> FlowyResult<CellBytes>;
fn display_string(
&self,
cell_data: CellData<CD>,
decoded_field_type: &FieldType,
field_rev: &FieldRevision,
) -> FlowyResult<String>;
} }
// CD: Short for CellData. This type is the type return by apply_changeset function. // CD: Short for CellData. This type is the type return by apply_changeset function.
// CS: Short for Changeset. Parse the string into specific Changeset type. // CS: Short for Changeset. Parse the string into specific Changeset type.
pub trait CellDataOperation<CD, CS> { pub trait CellDataOperation<CD, CS> {
/// The cell_data is able to parse into the specific data if CD impl the FromCellData trait. /// Decode the cell data into `CD` that is certain type of data.
/// For example: ///
/// URLCellData, DateCellData. etc. /// Each `CD` type represents as a specific field type data. For example:
/// FieldType::URL => URLCellData
/// FieldType::Date=> DateCellData
///
/// `decoded_field_type`: the field type of the cell data
///
/// Returns the error if the cell data can't be parsed into `CD`.
///
fn decode_cell_data( fn decode_cell_data(
&self, &self,
cell_data: CellData<CD>, cell_data: CellData<CD>,
@ -77,16 +91,16 @@ pub fn apply_cell_data_changeset<C: ToString, T: AsRef<FieldRevision>>(
pub fn decode_any_cell_data<T: TryInto<AnyCellData, Error = FlowyError> + Debug>( pub fn decode_any_cell_data<T: TryInto<AnyCellData, Error = FlowyError> + Debug>(
data: T, data: T,
field_rev: &FieldRevision, field_rev: &FieldRevision,
) -> CellBytes { ) -> (FieldType, CellBytes) {
let to_field_type = field_rev.ty.into();
match data.try_into() { match data.try_into() {
Ok(any_cell_data) => { Ok(any_cell_data) => {
let AnyCellData { data, field_type } = any_cell_data; let AnyCellData { data, field_type } = any_cell_data;
let to_field_type = field_rev.ty.into(); match try_decode_cell_data(data.into(), &field_type, &to_field_type, field_rev) {
match try_decode_cell_data(data.into(), field_rev, &field_type, &to_field_type) { Ok(cell_bytes) => (field_type, cell_bytes),
Ok(cell_bytes) => cell_bytes,
Err(e) => { Err(e) => {
tracing::error!("Decode cell data failed, {:?}", e); tracing::error!("Decode cell data failed, {:?}", e);
CellBytes::default() (field_type, CellBytes::default())
} }
} }
} }
@ -94,42 +108,93 @@ pub fn decode_any_cell_data<T: TryInto<AnyCellData, Error = FlowyError> + Debug>
// It's okay to ignore this error, because it's okay that the current cell can't // It's okay to ignore this error, because it's okay that the current cell can't
// display the existing cell data. For example, the UI of the text cell will be blank if // display the existing cell data. For example, the UI of the text cell will be blank if
// the type of the data of cell is Number. // the type of the data of cell is Number.
CellBytes::default()
(to_field_type, CellBytes::default())
} }
} }
} }
pub fn decode_cell_data_to_string(
cell_data: CellData<String>,
from_field_type: &FieldType,
to_field_type: &FieldType,
field_rev: &FieldRevision,
) -> FlowyResult<String> {
let cell_data = cell_data.try_into_inner()?;
let get_cell_display_str = || {
let field_type: FieldTypeRevision = to_field_type.into();
let result = match to_field_type {
FieldType::RichText => field_rev
.get_type_option::<RichTextTypeOptionPB>(field_type)?
.display_string(cell_data.into(), from_field_type, field_rev),
FieldType::Number => field_rev
.get_type_option::<NumberTypeOptionPB>(field_type)?
.display_string(cell_data.into(), from_field_type, field_rev),
FieldType::DateTime => field_rev
.get_type_option::<DateTypeOptionPB>(field_type)?
.display_string(cell_data.into(), from_field_type, field_rev),
FieldType::SingleSelect => field_rev
.get_type_option::<SingleSelectTypeOptionPB>(field_type)?
.display_string(cell_data.into(), from_field_type, field_rev),
FieldType::MultiSelect => field_rev
.get_type_option::<MultiSelectTypeOptionPB>(field_type)?
.display_string(cell_data.into(), from_field_type, field_rev),
FieldType::Checkbox => field_rev
.get_type_option::<CheckboxTypeOptionPB>(field_type)?
.display_string(cell_data.into(), from_field_type, field_rev),
FieldType::URL => field_rev
.get_type_option::<URLTypeOptionPB>(field_type)?
.display_string(cell_data.into(), from_field_type, field_rev),
};
Some(result)
};
match get_cell_display_str() {
Some(Ok(s)) => Ok(s),
Some(Err(err)) => {
tracing::error!("{:?}", err);
Ok("".to_owned())
}
None => Ok("".to_owned()),
}
}
/// Use the `to_field_type`'s TypeOption to parse the cell data into `from_field_type` type's data.
///
/// Each `FieldType` has its corresponding `TypeOption` that implements the `CellDisplayable`
/// and `CellDataOperation` traits.
///
pub fn try_decode_cell_data( pub fn try_decode_cell_data(
cell_data: CellData<String>, cell_data: CellData<String>,
from_field_type: &FieldType,
to_field_type: &FieldType,
field_rev: &FieldRevision, field_rev: &FieldRevision,
s_field_type: &FieldType,
t_field_type: &FieldType,
) -> FlowyResult<CellBytes> { ) -> FlowyResult<CellBytes> {
let cell_data = cell_data.try_into_inner()?; let cell_data = cell_data.try_into_inner()?;
let get_cell_data = || { let get_cell_data = || {
let field_type: FieldTypeRevision = t_field_type.into(); let field_type: FieldTypeRevision = to_field_type.into();
let data = match t_field_type { let data = match to_field_type {
FieldType::RichText => field_rev FieldType::RichText => field_rev
.get_type_option::<RichTextTypeOptionPB>(field_type)? .get_type_option::<RichTextTypeOptionPB>(field_type)?
.decode_cell_data(cell_data.into(), s_field_type, field_rev), .decode_cell_data(cell_data.into(), from_field_type, field_rev),
FieldType::Number => field_rev FieldType::Number => field_rev
.get_type_option::<NumberTypeOptionPB>(field_type)? .get_type_option::<NumberTypeOptionPB>(field_type)?
.decode_cell_data(cell_data.into(), s_field_type, field_rev), .decode_cell_data(cell_data.into(), from_field_type, field_rev),
FieldType::DateTime => field_rev FieldType::DateTime => field_rev
.get_type_option::<DateTypeOptionPB>(field_type)? .get_type_option::<DateTypeOptionPB>(field_type)?
.decode_cell_data(cell_data.into(), s_field_type, field_rev), .decode_cell_data(cell_data.into(), from_field_type, field_rev),
FieldType::SingleSelect => field_rev FieldType::SingleSelect => field_rev
.get_type_option::<SingleSelectTypeOptionPB>(field_type)? .get_type_option::<SingleSelectTypeOptionPB>(field_type)?
.decode_cell_data(cell_data.into(), s_field_type, field_rev), .decode_cell_data(cell_data.into(), from_field_type, field_rev),
FieldType::MultiSelect => field_rev FieldType::MultiSelect => field_rev
.get_type_option::<MultiSelectTypeOptionPB>(field_type)? .get_type_option::<MultiSelectTypeOptionPB>(field_type)?
.decode_cell_data(cell_data.into(), s_field_type, field_rev), .decode_cell_data(cell_data.into(), from_field_type, field_rev),
FieldType::Checkbox => field_rev FieldType::Checkbox => field_rev
.get_type_option::<CheckboxTypeOptionPB>(field_type)? .get_type_option::<CheckboxTypeOptionPB>(field_type)?
.decode_cell_data(cell_data.into(), s_field_type, field_rev), .decode_cell_data(cell_data.into(), from_field_type, field_rev),
FieldType::URL => field_rev FieldType::URL => field_rev
.get_type_option::<URLTypeOptionPB>(field_type)? .get_type_option::<URLTypeOptionPB>(field_type)?
.decode_cell_data(cell_data.into(), s_field_type, field_rev), .decode_cell_data(cell_data.into(), from_field_type, field_rev),
}; };
Some(data) Some(data)
}; };
@ -224,6 +289,12 @@ where
} }
} }
impl std::convert::From<usize> for CellData<String> {
fn from(n: usize) -> Self {
CellData(Some(n.to_string()))
}
}
impl<T> std::convert::From<T> for CellData<T> { impl<T> std::convert::From<T> for CellData<T> {
fn from(val: T) -> Self { fn from(val: T) -> Self {
CellData(Some(val)) CellData(Some(val))

View File

@ -48,6 +48,16 @@ impl CellDisplayable<CheckboxCellData> for CheckboxTypeOptionPB {
let cell_data = cell_data.try_into_inner()?; let cell_data = cell_data.try_into_inner()?;
Ok(CellBytes::new(cell_data)) Ok(CellBytes::new(cell_data))
} }
fn display_string(
&self,
cell_data: CellData<CheckboxCellData>,
_decoded_field_type: &FieldType,
_field_rev: &FieldRevision,
) -> FlowyResult<String> {
let cell_data = cell_data.try_into_inner()?;
Ok(cell_data.to_string())
}
} }
impl CellDataOperation<CheckboxCellData, String> for CheckboxTypeOptionPB { impl CellDataOperation<CheckboxCellData, String> for CheckboxTypeOptionPB {

View File

@ -127,6 +127,17 @@ impl CellDisplayable<DateTimestamp> for DateTypeOptionPB {
let date_cell_data = self.today_desc_from_timestamp(timestamp); let date_cell_data = self.today_desc_from_timestamp(timestamp);
CellBytes::from(date_cell_data) CellBytes::from(date_cell_data)
} }
fn display_string(
&self,
cell_data: CellData<DateTimestamp>,
_decoded_field_type: &FieldType,
_field_rev: &FieldRevision,
) -> FlowyResult<String> {
let timestamp = cell_data.try_into_inner()?;
let date_cell_data = self.today_desc_from_timestamp(timestamp);
Ok(date_cell_data.date)
}
} }
impl CellDataOperation<DateTimestamp, DateCellChangesetPB> for DateTypeOptionPB { impl CellDataOperation<DateTimestamp, DateCellChangesetPB> for DateTypeOptionPB {

View File

@ -70,6 +70,15 @@ define_currency_set!(
symbol: "RUB", symbol: "RUB",
symbol_first: false, symbol_first: false,
}, },
PERCENT : {
code: "",
exponent: 2,
locale: EnIn,
minor_units: 1,
name: "percent",
symbol: "%",
symbol_first: false,
},
USD : { USD : {
code: "USD", code: "USD",
exponent: 2, exponent: 2,
@ -435,7 +444,7 @@ impl NumberFormat {
NumberFormat::Leu => number_currency::RON, NumberFormat::Leu => number_currency::RON,
NumberFormat::ArgentinePeso => number_currency::ARS, NumberFormat::ArgentinePeso => number_currency::ARS,
NumberFormat::UruguayanPeso => number_currency::UYU, NumberFormat::UruguayanPeso => number_currency::UYU,
NumberFormat::Percent => number_currency::USD, NumberFormat::Percent => number_currency::PERCENT,
} }
} }

View File

@ -93,6 +93,11 @@ mod tests {
assert_number(&type_option, "€0.5", "€0,5", &field_type, &field_rev); assert_number(&type_option, "€0.5", "€0,5", &field_type, &field_rev);
assert_number(&type_option, "€1844", "€1.844", &field_type, &field_rev); assert_number(&type_option, "€1844", "€1.844", &field_type, &field_rev);
} }
NumberFormat::Percent => {
assert_number(&type_option, "1", "1%", &field_type, &field_rev);
assert_number(&type_option, "10.1", "10.1%", &field_type, &field_rev);
assert_number(&type_option, "100", "100%", &field_type, &field_rev);
}
_ => {} _ => {}
} }
} }

View File

@ -1,6 +1,6 @@
use crate::entities::FieldType; use crate::entities::FieldType;
use crate::impl_type_option; use crate::impl_type_option;
use crate::services::cell::{CellBytes, CellData, CellDataChangeset, CellDataOperation}; use crate::services::cell::{CellBytes, CellData, CellDataChangeset, CellDataOperation, CellDisplayable};
use crate::services::field::type_options::number_type_option::format::*; use crate::services::field::type_options::number_type_option::format::*;
use crate::services::field::{BoxTypeOptionBuilder, NumberCellData, TypeOptionBuilder}; use crate::services::field::{BoxTypeOptionBuilder, NumberCellData, TypeOptionBuilder};
use bytes::Bytes; use bytes::Bytes;
@ -77,7 +77,7 @@ impl NumberTypeOptionPB {
pub(crate) fn format_cell_data(&self, s: &str) -> FlowyResult<NumberCellData> { pub(crate) fn format_cell_data(&self, s: &str) -> FlowyResult<NumberCellData> {
match self.format { match self.format {
NumberFormat::Num | NumberFormat::Percent => match Decimal::from_str(s) { NumberFormat::Num => match Decimal::from_str(s) {
Ok(value, ..) => Ok(NumberCellData::from_decimal(value)), Ok(value, ..) => Ok(NumberCellData::from_decimal(value)),
Err(_) => Ok(NumberCellData::new()), Err(_) => Ok(NumberCellData::new()),
}, },
@ -102,17 +102,13 @@ pub(crate) fn strip_currency_symbol<T: ToString>(s: T) -> String {
s s
} }
impl CellDataOperation<String, String> for NumberTypeOptionPB { impl CellDisplayable<String> for NumberTypeOptionPB {
fn decode_cell_data( fn display_data(
&self, &self,
cell_data: CellData<String>, cell_data: CellData<String>,
decoded_field_type: &FieldType, _decoded_field_type: &FieldType,
_field_rev: &FieldRevision, _field_rev: &FieldRevision,
) -> FlowyResult<CellBytes> { ) -> FlowyResult<CellBytes> {
if decoded_field_type.is_date() {
return Ok(CellBytes::default());
}
let cell_data: String = cell_data.try_into_inner()?; let cell_data: String = cell_data.try_into_inner()?;
match self.format_cell_data(&cell_data) { match self.format_cell_data(&cell_data) {
Ok(num) => Ok(CellBytes::new(num.to_string())), Ok(num) => Ok(CellBytes::new(num.to_string())),
@ -120,6 +116,31 @@ impl CellDataOperation<String, String> for NumberTypeOptionPB {
} }
} }
fn display_string(
&self,
cell_data: CellData<String>,
_decoded_field_type: &FieldType,
_field_rev: &FieldRevision,
) -> FlowyResult<String> {
let cell_data: String = cell_data.try_into_inner()?;
Ok(cell_data)
}
}
impl CellDataOperation<String, String> for NumberTypeOptionPB {
fn decode_cell_data(
&self,
cell_data: CellData<String>,
decoded_field_type: &FieldType,
field_rev: &FieldRevision,
) -> FlowyResult<CellBytes> {
if decoded_field_type.is_date() {
return Ok(CellBytes::default());
}
self.display_data(cell_data, decoded_field_type, field_rev)
}
fn apply_changeset( fn apply_changeset(
&self, &self,
changeset: CellDataChangeset<String>, changeset: CellDataChangeset<String>,

View File

@ -120,6 +120,21 @@ where
) -> FlowyResult<CellBytes> { ) -> FlowyResult<CellBytes> {
CellBytes::from(self.selected_select_option(cell_data)) CellBytes::from(self.selected_select_option(cell_data))
} }
fn display_string(
&self,
cell_data: CellData<SelectOptionIds>,
_decoded_field_type: &FieldType,
_field_rev: &FieldRevision,
) -> FlowyResult<String> {
Ok(self
.selected_select_option(cell_data)
.select_options
.into_iter()
.map(|option| option.name)
.collect::<Vec<String>>()
.join(SELECTION_IDS_SEPARATOR))
}
} }
pub fn select_option_operation(field_rev: &FieldRevision) -> FlowyResult<Box<dyn SelectOptionOperation>> { pub fn select_option_operation(field_rev: &FieldRevision) -> FlowyResult<Box<dyn SelectOptionOperation>> {

View File

@ -1,3 +1,5 @@
#![allow(clippy::module_inception)] #![allow(clippy::module_inception)]
mod text_tests;
mod text_type_option; mod text_type_option;
pub use text_type_option::*; pub use text_type_option::*;

View File

@ -0,0 +1,49 @@
#[cfg(test)]
mod tests {
use crate::entities::FieldType;
use crate::services::cell::CellDataOperation;
use crate::services::field::FieldBuilder;
use crate::services::field::*;
// Test parser the cell data which field's type is FieldType::Date to cell data
// which field's type is FieldType::Text
#[test]
fn date_type_to_text_type() {
let type_option = RichTextTypeOptionPB::default();
let field_type = FieldType::DateTime;
let field_rev = FieldBuilder::from_field_type(&field_type).build();
assert_eq!(
type_option
.decode_cell_data(1647251762.into(), &field_type, &field_rev)
.unwrap()
.parser::<TextCellDataParser>()
.unwrap()
.as_ref(),
"Mar 14,2022"
);
}
// Test parser the cell data which field's type is FieldType::SingleSelect to cell data
// which field's type is FieldType::Text
#[test]
fn single_select_to_text_type() {
let type_option = RichTextTypeOptionPB::default();
let field_type = FieldType::SingleSelect;
let done_option = SelectOptionPB::new("Done");
let option_id = done_option.id.clone();
let single_select = SingleSelectTypeOptionBuilder::default().add_option(done_option.clone());
let field_rev = FieldBuilder::new(single_select).build();
assert_eq!(
type_option
.decode_cell_data(option_id.into(), &field_type, &field_rev)
.unwrap()
.parser::<TextCellDataParser>()
.unwrap()
.to_string(),
done_option.name,
);
}
}

View File

@ -1,8 +1,8 @@
use crate::entities::FieldType; use crate::entities::FieldType;
use crate::impl_type_option; use crate::impl_type_option;
use crate::services::cell::{ use crate::services::cell::{
try_decode_cell_data, CellBytes, CellBytesParser, CellData, CellDataChangeset, CellDataOperation, CellDisplayable, decode_cell_data_to_string, CellBytes, CellBytesParser, CellData, CellDataChangeset, CellDataOperation,
FromCellString, CellDisplayable, FromCellString,
}; };
use crate::services::field::{BoxTypeOptionBuilder, TypeOptionBuilder}; use crate::services::field::{BoxTypeOptionBuilder, TypeOptionBuilder};
use bytes::Bytes; use bytes::Bytes;
@ -44,6 +44,16 @@ impl CellDisplayable<String> for RichTextTypeOptionPB {
let cell_str: String = cell_data.try_into_inner()?; let cell_str: String = cell_data.try_into_inner()?;
Ok(CellBytes::new(cell_str)) Ok(CellBytes::new(cell_str))
} }
fn display_string(
&self,
cell_data: CellData<String>,
_decoded_field_type: &FieldType,
_field_rev: &FieldRevision,
) -> FlowyResult<String> {
let cell_str: String = cell_data.try_into_inner()?;
Ok(cell_str)
}
} }
impl CellDataOperation<String, String> for RichTextTypeOptionPB { impl CellDataOperation<String, String> for RichTextTypeOptionPB {
@ -57,8 +67,10 @@ impl CellDataOperation<String, String> for RichTextTypeOptionPB {
|| decoded_field_type.is_single_select() || decoded_field_type.is_single_select()
|| decoded_field_type.is_multi_select() || decoded_field_type.is_multi_select()
|| decoded_field_type.is_number() || decoded_field_type.is_number()
|| decoded_field_type.is_url()
{ {
try_decode_cell_data(cell_data, field_rev, decoded_field_type, decoded_field_type) let s = decode_cell_data_to_string(cell_data, decoded_field_type, decoded_field_type, field_rev);
Ok(CellBytes::new(s.unwrap_or_else(|_| "".to_owned())))
} else { } else {
self.display_data(cell_data, decoded_field_type, field_rev) self.display_data(cell_data, decoded_field_type, field_rev)
} }
@ -85,6 +97,14 @@ impl AsRef<str> for TextCellData {
} }
} }
impl std::ops::Deref for TextCellData {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl FromCellString for TextCellData { impl FromCellString for TextCellData {
fn from_cell_str(s: &str) -> FlowyResult<Self> fn from_cell_str(s: &str) -> FlowyResult<Self>
where where
@ -94,6 +114,12 @@ impl FromCellString for TextCellData {
} }
} }
impl ToString for TextCellData {
fn to_string(&self) -> String {
self.0.clone()
}
}
pub struct TextCellDataParser(); pub struct TextCellDataParser();
impl CellBytesParser for TextCellDataParser { impl CellBytesParser for TextCellDataParser {
type Object = TextCellData; type Object = TextCellData;
@ -104,85 +130,3 @@ impl CellBytesParser for TextCellDataParser {
} }
} }
} }
#[cfg(test)]
mod tests {
use crate::entities::FieldType;
use crate::services::cell::CellDataOperation;
use crate::services::field::FieldBuilder;
use crate::services::field::*;
#[test]
fn text_description_test() {
let type_option = RichTextTypeOptionPB::default();
// date
let field_type = FieldType::DateTime;
let date_time_field_rev = FieldBuilder::from_field_type(&field_type).build();
assert_eq!(
type_option
.decode_cell_data(1647251762.to_string().into(), &field_type, &date_time_field_rev)
.unwrap()
.parser::<DateCellDataParser>()
.unwrap()
.date,
"Mar 14,2022".to_owned()
);
// Single select
let done_option = SelectOptionPB::new("Done");
let done_option_id = done_option.id.clone();
let single_select = SingleSelectTypeOptionBuilder::default().add_option(done_option.clone());
let single_select_field_rev = FieldBuilder::new(single_select).build();
assert_eq!(
type_option
.decode_cell_data(
done_option_id.into(),
&FieldType::SingleSelect,
&single_select_field_rev
)
.unwrap()
.parser::<SelectOptionCellDataParser>()
.unwrap()
.select_options,
vec![done_option],
);
// Multiple select
let google_option = SelectOptionPB::new("Google");
let facebook_option = SelectOptionPB::new("Facebook");
let ids = vec![google_option.id.clone(), facebook_option.id.clone()].join(SELECTION_IDS_SEPARATOR);
let cell_data_changeset = SelectOptionCellChangeset::from_insert(&ids).to_str();
let multi_select = MultiSelectTypeOptionBuilder::default()
.add_option(google_option.clone())
.add_option(facebook_option.clone());
let multi_select_field_rev = FieldBuilder::new(multi_select).build();
let multi_type_option = MultiSelectTypeOptionPB::from(&multi_select_field_rev);
let cell_data = multi_type_option
.apply_changeset(cell_data_changeset.into(), None)
.unwrap();
assert_eq!(
type_option
.decode_cell_data(cell_data.into(), &FieldType::MultiSelect, &multi_select_field_rev)
.unwrap()
.parser::<SelectOptionCellDataParser>()
.unwrap()
.select_options,
vec![google_option, facebook_option]
);
//Number
let number = NumberTypeOptionBuilder::default().set_format(NumberFormat::USD);
let number_field_rev = FieldBuilder::new(number).build();
assert_eq!(
type_option
.decode_cell_data("18443".to_owned().into(), &FieldType::Number, &number_field_rev)
.unwrap()
.to_string(),
"$18,443".to_owned()
);
}
}

View File

@ -42,6 +42,16 @@ impl CellDisplayable<URLCellDataPB> for URLTypeOptionPB {
let cell_data: URLCellDataPB = cell_data.try_into_inner()?; let cell_data: URLCellDataPB = cell_data.try_into_inner()?;
CellBytes::from(cell_data) CellBytes::from(cell_data)
} }
fn display_string(
&self,
cell_data: CellData<URLCellDataPB>,
_decoded_field_type: &FieldType,
_field_rev: &FieldRevision,
) -> FlowyResult<String> {
let cell_data: URLCellDataPB = cell_data.try_into_inner()?;
Ok(cell_data.content)
}
} }
impl CellDataOperation<URLCellDataPB, String> for URLTypeOptionPB { impl CellDataOperation<URLCellDataPB, String> for URLTypeOptionPB {

View File

@ -368,6 +368,7 @@ impl GridRevisionEditor {
Ok(row_pb) Ok(row_pb)
} }
#[tracing::instrument(level = "trace", skip_all, err)]
pub async fn move_group(&self, params: MoveGroupParams) -> FlowyResult<()> { pub async fn move_group(&self, params: MoveGroupParams) -> FlowyResult<()> {
let _ = self.view_manager.move_group(params).await?; let _ = self.view_manager.move_group(params).await?;
Ok(()) Ok(())
@ -435,14 +436,18 @@ impl GridRevisionEditor {
} }
pub async fn get_cell(&self, params: &GridCellIdParams) -> Option<GridCellPB> { pub async fn get_cell(&self, params: &GridCellIdParams) -> Option<GridCellPB> {
let cell_bytes = self.get_cell_bytes(params).await?; let (field_type, cell_bytes) = self.decode_any_cell_data(params).await?;
Some(GridCellPB::new(&params.field_id, cell_bytes.to_vec())) Some(GridCellPB::new(&params.field_id, field_type, cell_bytes.to_vec()))
} }
pub async fn get_cell_bytes(&self, params: &GridCellIdParams) -> Option<CellBytes> { pub async fn get_cell_bytes(&self, params: &GridCellIdParams) -> Option<CellBytes> {
let (_, cell_data) = self.decode_any_cell_data(params).await?;
Some(cell_data)
}
async fn decode_any_cell_data(&self, params: &GridCellIdParams) -> Option<(FieldType, CellBytes)> {
let field_rev = self.get_field_rev(&params.field_id).await?; let field_rev = self.get_field_rev(&params.field_id).await?;
let row_rev = self.block_manager.get_row_rev(&params.row_id).await.ok()??; let row_rev = self.block_manager.get_row_rev(&params.row_id).await.ok()??;
let cell_rev = row_rev.cells.get(&params.field_id)?.clone(); let cell_rev = row_rev.cells.get(&params.field_id)?.clone();
Some(decode_any_cell_data(cell_rev.data, &field_rev)) Some(decode_any_cell_data(cell_rev.data, &field_rev))
} }

View File

@ -173,6 +173,7 @@ impl GridViewRevisionEditor {
Ok(groups.into_iter().map(GroupPB::from).collect()) Ok(groups.into_iter().map(GroupPB::from).collect())
} }
#[tracing::instrument(level = "trace", skip(self), err)]
pub(crate) async fn move_group(&self, params: MoveGroupParams) -> FlowyResult<()> { pub(crate) async fn move_group(&self, params: MoveGroupParams) -> FlowyResult<()> {
let _ = self let _ = self
.group_controller .group_controller
@ -180,7 +181,7 @@ impl GridViewRevisionEditor {
.await .await
.move_group(&params.from_group_id, &params.to_group_id)?; .move_group(&params.from_group_id, &params.to_group_id)?;
match self.group_controller.read().await.get_group(&params.from_group_id) { match self.group_controller.read().await.get_group(&params.from_group_id) {
None => {} None => tracing::warn!("Can not find the group with id: {}", params.from_group_id),
Some((index, group)) => { Some((index, group)) => {
let inserted_group = InsertedGroupPB { let inserted_group = InsertedGroupPB {
group: GroupPB::from(group), group: GroupPB::from(group),
@ -201,6 +202,10 @@ impl GridViewRevisionEditor {
Ok(()) Ok(())
} }
pub(crate) async fn group_id(&self) -> String {
self.group_controller.read().await.field_id().to_owned()
}
pub(crate) async fn get_setting(&self) -> GridSettingPB { pub(crate) async fn get_setting(&self) -> GridSettingPB {
let field_revs = self.field_delegate.get_field_revs().await; let field_revs = self.field_delegate.get_field_revs().await;
let grid_setting = make_grid_setting(&*self.pad.read().await, &field_revs); let grid_setting = make_grid_setting(&*self.pad.read().await, &field_revs);
@ -224,7 +229,11 @@ impl GridViewRevisionEditor {
let _ = self let _ = self
.modify(|pad| { .modify(|pad| {
let configuration = default_group_configuration(&field_rev); let configuration = default_group_configuration(&field_rev);
let changeset = pad.insert_group(&params.field_id, &params.field_type_rev, configuration)?; let changeset = pad.insert_or_update_group_configuration(
&params.field_id,
&params.field_type_rev,
configuration,
)?;
Ok(changeset) Ok(changeset)
}) })
.await?; .await?;
@ -492,10 +501,11 @@ impl GroupConfigurationWriter for GroupConfigurationWriterImpl {
let field_id = field_id.to_owned(); let field_id = field_id.to_owned();
wrap_future(async move { wrap_future(async move {
let changeset = view_pad let changeset = view_pad.write().await.insert_or_update_group_configuration(
.write() &field_id,
.await &field_type,
.insert_group(&field_id, &field_type, group_configuration)?; group_configuration,
)?;
if let Some(changeset) = changeset { if let Some(changeset) = changeset {
let _ = apply_change(&user_id, rev_manager, changeset).await?; let _ = apply_change(&user_id, rev_manager, changeset).await?;

View File

@ -178,12 +178,16 @@ impl GridViewManager {
#[tracing::instrument(level = "trace", skip(self), err)] #[tracing::instrument(level = "trace", skip(self), err)]
pub(crate) async fn did_update_field(&self, field_id: &str, is_type_option_changed: bool) -> FlowyResult<()> { pub(crate) async fn did_update_field(&self, field_id: &str, is_type_option_changed: bool) -> FlowyResult<()> {
let view_editor = self.get_default_view_editor().await?; let view_editor = self.get_default_view_editor().await?;
// Only the field_id of the updated field is equal to the field_id of the group.
// Update the group
if view_editor.group_id().await != field_id {
return Ok(());
}
if is_type_option_changed { if is_type_option_changed {
let _ = view_editor.group_by_field(field_id).await?; let _ = view_editor.group_by_field(field_id).await?;
} else { } else {
let _ = view_editor.did_update_field(field_id).await?; let _ = view_editor.did_update_field(field_id).await?;
} }
Ok(()) Ok(())
} }

View File

@ -1,5 +1,5 @@
use crate::entities::{GroupPB, GroupViewChangesetPB}; use crate::entities::{GroupPB, GroupViewChangesetPB};
use crate::services::group::{default_group_configuration, GeneratedGroup, Group}; use crate::services::group::{default_group_configuration, make_default_group, GeneratedGroup, Group};
use flowy_error::{FlowyError, FlowyResult}; use flowy_error::{FlowyError, FlowyResult};
use flowy_grid_data_model::revision::{ use flowy_grid_data_model::revision::{
FieldRevision, FieldTypeRevision, GroupConfigurationContentSerde, GroupConfigurationRevision, GroupRevision, FieldRevision, FieldTypeRevision, GroupConfigurationContentSerde, GroupConfigurationRevision, GroupRevision,
@ -29,10 +29,7 @@ impl<T> std::fmt::Display for GroupContext<T> {
self.groups_map.iter().for_each(|(_, group)| { self.groups_map.iter().for_each(|(_, group)| {
let _ = f.write_fmt(format_args!("Group:{} has {} rows \n", group.id, group.rows.len())); let _ = f.write_fmt(format_args!("Group:{} has {} rows \n", group.id, group.rows.len()));
}); });
let _ = f.write_fmt(format_args!(
"Default group has {} rows \n",
self.default_group.rows.len()
));
Ok(()) Ok(())
} }
} }
@ -44,7 +41,7 @@ pub struct GroupContext<C> {
field_rev: Arc<FieldRevision>, field_rev: Arc<FieldRevision>,
groups_map: IndexMap<String, Group>, groups_map: IndexMap<String, Group>,
/// default_group is used to store the rows that don't belong to any groups. /// default_group is used to store the rows that don't belong to any groups.
default_group: Group, // default_group: Group,
writer: Arc<dyn GroupConfigurationWriter>, writer: Arc<dyn GroupConfigurationWriter>,
} }
@ -59,16 +56,6 @@ where
reader: Arc<dyn GroupConfigurationReader>, reader: Arc<dyn GroupConfigurationReader>,
writer: Arc<dyn GroupConfigurationWriter>, writer: Arc<dyn GroupConfigurationWriter>,
) -> FlowyResult<Self> { ) -> FlowyResult<Self> {
let default_group_id = format!("{}_default_group", view_id);
let default_group = Group {
id: default_group_id,
field_id: field_rev.id.clone(),
name: format!("No {}", field_rev.name),
is_default: true,
is_visible: true,
rows: vec![],
filter_content: "".to_string(),
};
let configuration = match reader.get_configuration().await { let configuration = match reader.get_configuration().await {
None => { None => {
let default_configuration = default_group_configuration(&field_rev); let default_configuration = default_group_configuration(&field_rev);
@ -80,24 +67,22 @@ where
Some(configuration) => configuration, Some(configuration) => configuration,
}; };
// let configuration = C::from_configuration_content(&configuration_rev.content)?;
Ok(Self { Ok(Self {
view_id, view_id,
field_rev, field_rev,
groups_map: IndexMap::new(), groups_map: IndexMap::new(),
default_group,
writer, writer,
configuration, configuration,
configuration_content: PhantomData, configuration_content: PhantomData,
}) })
} }
pub(crate) fn get_default_group(&self) -> &Group { pub(crate) fn get_default_group(&self) -> Option<&Group> {
&self.default_group self.groups_map.get(&self.field_rev.id)
} }
pub(crate) fn get_mut_default_group(&mut self) -> &mut Group { pub(crate) fn get_mut_default_group(&mut self) -> Option<&mut Group> {
&mut self.default_group self.groups_map.get_mut(&self.field_rev.id)
} }
/// Returns the groups without the default group /// Returns the groups without the default group
@ -122,8 +107,6 @@ where
self.groups_map.iter_mut().for_each(|(_, group)| { self.groups_map.iter_mut().for_each(|(_, group)| {
each(group); each(group);
}); });
each(&mut self.default_group);
} }
pub(crate) fn move_group(&mut self, from_id: &str, to_id: &str) -> FlowyResult<()> { pub(crate) fn move_group(&mut self, from_id: &str, to_id: &str) -> FlowyResult<()> {
@ -131,18 +114,23 @@ where
let to_index = self.groups_map.get_index_of(to_id); let to_index = self.groups_map.get_index_of(to_id);
match (from_index, to_index) { match (from_index, to_index) {
(Some(from_index), Some(to_index)) => { (Some(from_index), Some(to_index)) => {
self.groups_map.swap_indices(from_index, to_index); self.groups_map.move_index(from_index, to_index);
self.mut_configuration(|configuration| { self.mut_configuration(|configuration| {
let from_index = configuration.groups.iter().position(|group| group.id == from_id); let from_index = configuration.groups.iter().position(|group| group.id == from_id);
let to_index = configuration.groups.iter().position(|group| group.id == to_id); let to_index = configuration.groups.iter().position(|group| group.id == to_id);
if let (Some(from), Some(to)) = (from_index, to_index) { tracing::info!("Configuration groups: {:?} ", configuration.groups);
configuration.groups.swap(from, to); if let (Some(from), Some(to)) = &(from_index, to_index) {
tracing::trace!("Move group from index:{:?} to index:{:?}", from_index, to_index);
let group = configuration.groups.remove(*from);
configuration.groups.insert(*to, group);
} }
true
from_index.is_some() && to_index.is_some()
})?; })?;
Ok(()) Ok(())
} }
_ => Err(FlowyError::out_of_bounds()), _ => Err(FlowyError::record_not_found().context("Moving group failed. Groups are not exist")),
} }
} }
@ -150,7 +138,6 @@ where
pub(crate) fn init_groups( pub(crate) fn init_groups(
&mut self, &mut self,
generated_groups: Vec<GeneratedGroup>, generated_groups: Vec<GeneratedGroup>,
reset: bool,
) -> FlowyResult<Option<GroupViewChangesetPB>> { ) -> FlowyResult<Option<GroupViewChangesetPB>> {
let mut new_groups = vec![]; let mut new_groups = vec![];
let mut filter_content_map = HashMap::new(); let mut filter_content_map = HashMap::new();
@ -159,16 +146,17 @@ where
new_groups.push(generate_group.group_rev); new_groups.push(generate_group.group_rev);
}); });
let mut old_groups = self.configuration.groups.clone();
if !old_groups.iter().any(|group| group.id == self.field_rev.id) {
old_groups.push(make_default_group(&self.field_rev));
}
let MergeGroupResult { let MergeGroupResult {
mut all_group_revs, mut all_group_revs,
new_group_revs, new_group_revs,
updated_group_revs: _, updated_group_revs: _,
deleted_group_revs, deleted_group_revs,
} = if reset { } = merge_groups(old_groups, new_groups);
merge_groups(&[], new_groups)
} else {
merge_groups(&self.configuration.groups, new_groups)
};
let deleted_group_ids = deleted_group_revs let deleted_group_ids = deleted_group_revs
.into_iter() .into_iter()
@ -197,31 +185,23 @@ where
Some(pos) => { Some(pos) => {
let mut old_group = configuration.groups.remove(pos); let mut old_group = configuration.groups.remove(pos);
group_rev.update_with_other(&old_group); group_rev.update_with_other(&old_group);
is_changed = is_group_changed(group_rev, &old_group);
// Take the GroupRevision if the name has changed
if is_group_changed(group_rev, &old_group) {
old_group.name = group_rev.name.clone(); old_group.name = group_rev.name.clone();
is_changed = true;
configuration.groups.insert(pos, old_group); configuration.groups.insert(pos, old_group);
} }
} }
} }
}
is_changed is_changed
})?; })?;
// The len of the filter_content_map should equal to the len of the all_group_revs
debug_assert_eq!(filter_content_map.len(), all_group_revs.len());
all_group_revs.into_iter().for_each(|group_rev| { all_group_revs.into_iter().for_each(|group_rev| {
if let Some(filter_content) = filter_content_map.get(&group_rev.id) { let filter_content = filter_content_map
let group = Group::new( .get(&group_rev.id)
group_rev.id, .cloned()
self.field_rev.id.clone(), .unwrap_or_else(|| "".to_owned());
group_rev.name, let group = Group::new(group_rev.id, self.field_rev.id.clone(), group_rev.name, filter_content);
filter_content.clone(),
);
self.groups_map.insert(group.id.clone(), group); self.groups_map.insert(group.id.clone(), group);
}
}); });
let new_groups = new_group_revs let new_groups = new_group_revs
@ -269,6 +249,7 @@ where
Ok(()) Ok(())
} }
#[tracing::instrument(level = "trace", skip_all, err)]
pub fn save_configuration(&self) -> FlowyResult<()> { pub fn save_configuration(&self) -> FlowyResult<()> {
let configuration = (&*self.configuration).clone(); let configuration = (&*self.configuration).clone();
let writer = self.writer.clone(); let writer = self.writer.clone();
@ -311,13 +292,14 @@ where
} }
} }
fn merge_groups(old_groups: &[GroupRevision], new_groups: Vec<GroupRevision>) -> MergeGroupResult { fn merge_groups(old_groups: Vec<GroupRevision>, new_groups: Vec<GroupRevision>) -> MergeGroupResult {
let mut merge_result = MergeGroupResult::new(); let mut merge_result = MergeGroupResult::new();
if old_groups.is_empty() { // if old_groups.is_empty() {
merge_result.all_group_revs = new_groups.clone(); // merge_result.all_group_revs.extend(new_groups.clone());
merge_result.new_group_revs = new_groups; // merge_result.all_group_revs.push(default_group);
return merge_result; // merge_result.new_group_revs = new_groups;
} // return merge_result;
// }
// group_map is a helper map is used to filter out the new groups. // group_map is a helper map is used to filter out the new groups.
let mut new_group_map: IndexMap<String, GroupRevision> = IndexMap::new(); let mut new_group_map: IndexMap<String, GroupRevision> = IndexMap::new();
@ -329,19 +311,20 @@ fn merge_groups(old_groups: &[GroupRevision], new_groups: Vec<GroupRevision>) ->
for old in old_groups { for old in old_groups {
if let Some(new) = new_group_map.remove(&old.id) { if let Some(new) = new_group_map.remove(&old.id) {
merge_result.all_group_revs.push(new.clone()); merge_result.all_group_revs.push(new.clone());
if is_group_changed(&new, old) { if is_group_changed(&new, &old) {
merge_result.updated_group_revs.push(new); merge_result.updated_group_revs.push(new);
} }
} else { } else {
merge_result.deleted_group_revs.push(old.clone()); merge_result.all_group_revs.push(old);
} }
} }
// Find out the new groups // Find out the new groups
new_group_map.reverse();
let new_groups = new_group_map.into_values(); let new_groups = new_group_map.into_values();
for (_, group) in new_groups.into_iter().enumerate() { for (_, group) in new_groups.into_iter().enumerate() {
merge_result.all_group_revs.push(group.clone()); merge_result.all_group_revs.insert(0, group.clone());
merge_result.new_group_revs.push(group); merge_result.new_group_revs.insert(0, group);
} }
merge_result merge_result
} }

View File

@ -88,7 +88,7 @@ where
pub async fn new(field_rev: &Arc<FieldRevision>, mut configuration: GroupContext<C>) -> FlowyResult<Self> { pub async fn new(field_rev: &Arc<FieldRevision>, mut configuration: GroupContext<C>) -> FlowyResult<Self> {
let type_option = field_rev.get_type_option::<T>(field_rev.ty); let type_option = field_rev.get_type_option::<T>(field_rev.ty);
let groups = G::generate_groups(&field_rev.id, &configuration, &type_option); let groups = G::generate_groups(&field_rev.id, &configuration, &type_option);
let _ = configuration.init_groups(groups, true)?; let _ = configuration.init_groups(groups)?;
Ok(Self { Ok(Self {
field_id: field_rev.id.clone(), field_id: field_rev.id.clone(),
@ -105,8 +105,8 @@ where
&mut self, &mut self,
row_rev: &RowRevision, row_rev: &RowRevision,
other_group_changesets: &[GroupChangesetPB], other_group_changesets: &[GroupChangesetPB],
) -> GroupChangesetPB { ) -> Option<GroupChangesetPB> {
let default_group = self.group_ctx.get_mut_default_group(); let default_group = self.group_ctx.get_mut_default_group()?;
// [other_group_inserted_row] contains all the inserted rows except the default group. // [other_group_inserted_row] contains all the inserted rows except the default group.
let other_group_inserted_row = other_group_changesets let other_group_inserted_row = other_group_changesets
@ -163,7 +163,7 @@ where
} }
default_group.rows.retain(|row| !deleted_row_ids.contains(&row.id)); default_group.rows.retain(|row| !deleted_row_ids.contains(&row.id));
changeset.deleted_rows.extend(deleted_row_ids); changeset.deleted_rows.extend(deleted_row_ids);
changeset Some(changeset)
} }
} }
@ -182,11 +182,14 @@ where
fn groups(&self) -> Vec<Group> { fn groups(&self) -> Vec<Group> {
if self.use_default_group() { if self.use_default_group() {
let mut groups: Vec<Group> = self.group_ctx.groups().into_iter().cloned().collect();
groups.push(self.group_ctx.get_default_group().clone());
groups
} else {
self.group_ctx.groups().into_iter().cloned().collect() self.group_ctx.groups().into_iter().cloned().collect()
} else {
self.group_ctx
.groups()
.into_iter()
.filter(|group| group.id != self.field_id)
.cloned()
.collect::<Vec<_>>()
} }
} }
@ -205,7 +208,7 @@ where
if let Some(cell_rev) = cell_rev { if let Some(cell_rev) = cell_rev {
let mut grouped_rows: Vec<GroupedRow> = vec![]; let mut grouped_rows: Vec<GroupedRow> = vec![];
let cell_bytes = decode_any_cell_data(cell_rev.data, field_rev); let cell_bytes = decode_any_cell_data(cell_rev.data, field_rev).1;
let cell_data = cell_bytes.parser::<P>()?; let cell_data = cell_bytes.parser::<P>()?;
for group in self.group_ctx.groups() { for group in self.group_ctx.groups() {
if self.can_group(&group.filter_content, &cell_data) { if self.can_group(&group.filter_content, &cell_data) {
@ -216,17 +219,18 @@ where
} }
} }
if grouped_rows.is_empty() { if !grouped_rows.is_empty() {
self.group_ctx.get_mut_default_group().add_row(row_rev.into());
} else {
for group_row in grouped_rows { for group_row in grouped_rows {
if let Some(group) = self.group_ctx.get_mut_group(&group_row.group_id) { if let Some(group) = self.group_ctx.get_mut_group(&group_row.group_id) {
group.add_row(group_row.row); group.add_row(group_row.row);
} }
} }
continue;
} }
} else { }
self.group_ctx.get_mut_default_group().add_row(row_rev.into()); match self.group_ctx.get_mut_default_group() {
None => {}
Some(default_group) => default_group.add_row(row_rev.into()),
} }
} }
@ -244,14 +248,15 @@ where
field_rev: &FieldRevision, field_rev: &FieldRevision,
) -> FlowyResult<Vec<GroupChangesetPB>> { ) -> FlowyResult<Vec<GroupChangesetPB>> {
if let Some(cell_rev) = row_rev.cells.get(&self.field_id) { if let Some(cell_rev) = row_rev.cells.get(&self.field_id) {
let cell_bytes = decode_any_cell_data(cell_rev.data.clone(), field_rev); let cell_bytes = decode_any_cell_data(cell_rev.data.clone(), field_rev).1;
let cell_data = cell_bytes.parser::<P>()?; let cell_data = cell_bytes.parser::<P>()?;
let mut changesets = self.add_row_if_match(row_rev, &cell_data); let mut changesets = self.add_row_if_match(row_rev, &cell_data);
let default_group_changeset = self.update_default_group(row_rev, &changesets); if let Some(default_group_changeset) = self.update_default_group(row_rev, &changesets) {
tracing::trace!("default_group_changeset: {}", default_group_changeset); tracing::trace!("default_group_changeset: {}", default_group_changeset);
if !default_group_changeset.is_empty() { if !default_group_changeset.is_empty() {
changesets.push(default_group_changeset); changesets.push(default_group_changeset);
} }
}
Ok(changesets) Ok(changesets)
} else { } else {
Ok(vec![]) Ok(vec![])
@ -265,15 +270,16 @@ where
) -> FlowyResult<Vec<GroupChangesetPB>> { ) -> FlowyResult<Vec<GroupChangesetPB>> {
// if the cell_rev is none, then the row must be crated from the default group. // if the cell_rev is none, then the row must be crated from the default group.
if let Some(cell_rev) = row_rev.cells.get(&self.field_id) { if let Some(cell_rev) = row_rev.cells.get(&self.field_id) {
let cell_bytes = decode_any_cell_data(cell_rev.data.clone(), field_rev); let cell_bytes = decode_any_cell_data(cell_rev.data.clone(), field_rev).1;
let cell_data = cell_bytes.parser::<P>()?; let cell_data = cell_bytes.parser::<P>()?;
Ok(self.remove_row_if_match(row_rev, &cell_data)) Ok(self.remove_row_if_match(row_rev, &cell_data))
} else { } else if let Some(group) = self.group_ctx.get_default_group() {
let group = self.group_ctx.get_default_group();
Ok(vec![GroupChangesetPB::delete( Ok(vec![GroupChangesetPB::delete(
group.id.clone(), group.id.clone(),
vec![row_rev.id.clone()], vec![row_rev.id.clone()],
)]) )])
} else {
Ok(vec![])
} }
} }
@ -285,7 +291,7 @@ where
}; };
if let Some(cell_rev) = cell_rev { if let Some(cell_rev) = cell_rev {
let cell_bytes = decode_any_cell_data(cell_rev.data, context.field_rev); let cell_bytes = decode_any_cell_data(cell_rev.data, context.field_rev).1;
let cell_data = cell_bytes.parser::<P>()?; let cell_data = cell_bytes.parser::<P>()?;
Ok(self.move_row(&cell_data, context)) Ok(self.move_row(&cell_data, context))
} else { } else {
@ -297,7 +303,7 @@ where
fn did_update_field(&mut self, field_rev: &FieldRevision) -> FlowyResult<Option<GroupViewChangesetPB>> { fn did_update_field(&mut self, field_rev: &FieldRevision) -> FlowyResult<Option<GroupViewChangesetPB>> {
let type_option = field_rev.get_type_option::<T>(field_rev.ty); let type_option = field_rev.get_type_option::<T>(field_rev.ty);
let groups = G::generate_groups(&field_rev.id, &self.group_ctx, &type_option); let groups = G::generate_groups(&field_rev.id, &self.group_ctx, &type_option);
let changeset = self.group_ctx.init_groups(groups, false)?; let changeset = self.group_ctx.init_groups(groups)?;
Ok(changeset) Ok(changeset)
} }
} }

View File

@ -9,16 +9,17 @@ pub struct Group {
pub is_visible: bool, pub is_visible: bool,
pub(crate) rows: Vec<RowPB>, pub(crate) rows: Vec<RowPB>,
/// [content] is used to determine which group the cell belongs to. /// [filter_content] is used to determine which group the cell belongs to.
pub filter_content: String, pub filter_content: String,
} }
impl Group { impl Group {
pub fn new(id: String, field_id: String, name: String, filter_content: String) -> Self { pub fn new(id: String, field_id: String, name: String, filter_content: String) -> Self {
let is_default = id == field_id;
Self { Self {
id, id,
field_id, field_id,
is_default: false, is_default,
is_visible: true, is_visible: true,
name, name,
rows: vec![], rows: vec![],

View File

@ -8,8 +8,8 @@ use crate::services::group::{
use flowy_error::FlowyResult; use flowy_error::FlowyResult;
use flowy_grid_data_model::revision::{ use flowy_grid_data_model::revision::{
CheckboxGroupConfigurationRevision, DateGroupConfigurationRevision, FieldRevision, GroupConfigurationRevision, CheckboxGroupConfigurationRevision, DateGroupConfigurationRevision, FieldRevision, GroupConfigurationRevision,
LayoutRevision, NumberGroupConfigurationRevision, RowRevision, SelectOptionGroupConfigurationRevision, GroupRevision, LayoutRevision, NumberGroupConfigurationRevision, RowRevision,
TextGroupConfigurationRevision, UrlGroupConfigurationRevision, SelectOptionGroupConfigurationRevision, TextGroupConfigurationRevision, UrlGroupConfigurationRevision,
}; };
use std::sync::Arc; use std::sync::Arc;
@ -79,7 +79,7 @@ pub fn default_group_configuration(field_rev: &FieldRevision) -> GroupConfigurat
let field_id = field_rev.id.clone(); let field_id = field_rev.id.clone();
let field_type_rev = field_rev.ty; let field_type_rev = field_rev.ty;
let field_type: FieldType = field_rev.ty.into(); let field_type: FieldType = field_rev.ty.into();
match field_type { let mut group_configuration_rev = match field_type {
FieldType::RichText => { FieldType::RichText => {
GroupConfigurationRevision::new(field_id, field_type_rev, TextGroupConfigurationRevision::default()) GroupConfigurationRevision::new(field_id, field_type_rev, TextGroupConfigurationRevision::default())
.unwrap() .unwrap()
@ -112,5 +112,23 @@ pub fn default_group_configuration(field_rev: &FieldRevision) -> GroupConfigurat
FieldType::URL => { FieldType::URL => {
GroupConfigurationRevision::new(field_id, field_type_rev, UrlGroupConfigurationRevision::default()).unwrap() GroupConfigurationRevision::new(field_id, field_type_rev, UrlGroupConfigurationRevision::default()).unwrap()
} }
};
// Append the no `status` group
let default_group_rev = GroupRevision {
id: field_rev.id.clone(),
name: format!("No {}", field_rev.name),
visible: true,
};
group_configuration_rev.groups.push(default_group_rev);
group_configuration_rev
}
pub fn make_default_group(field_rev: &FieldRevision) -> GroupRevision {
GroupRevision {
id: field_rev.id.clone(),
name: format!("No {}", field_rev.name),
visible: true,
} }
} }

View File

@ -370,6 +370,28 @@ async fn group_move_group_test() {
test.run_scripts(scripts).await; test.run_scripts(scripts).await;
} }
#[tokio::test]
async fn group_default_move_group_test() {
let mut test = GridGroupTest::new().await;
let group_0 = test.group_at_index(0).await;
let group_3 = test.group_at_index(3).await;
let scripts = vec![
MoveGroup {
from_group_index: 3,
to_group_index: 0,
},
AssertGroup {
group_index: 0,
expected_group: group_3,
},
AssertGroup {
group_index: 1,
expected_group: group_0,
},
];
test.run_scripts(scripts).await;
}
#[tokio::test] #[tokio::test]
async fn group_insert_single_select_option_test() { async fn group_insert_single_select_option_test() {
let mut test = GridGroupTest::new().await; let mut test = GridGroupTest::new().await;
@ -402,7 +424,7 @@ async fn group_group_by_other_field() {
group_index: 1, group_index: 1,
row_count: 2, row_count: 2,
}, },
AssertGroupCount(4), AssertGroupCount(5),
]; ];
test.run_scripts(scripts).await; test.run_scripts(scripts).await;
} }

View File

@ -1,5 +1,6 @@
use flowy_derive::ProtoBuf; use flowy_derive::ProtoBuf;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(ProtoBuf, Default, Debug, Clone)] #[derive(ProtoBuf, Default, Debug, Clone)]
pub struct UserPreferencesPB { pub struct UserPreferencesPB {
@ -21,7 +22,11 @@ pub struct AppearanceSettingsPB {
#[pb(index = 3)] #[pb(index = 3)]
#[serde(default = "DEFAULT_RESET_VALUE")] #[serde(default = "DEFAULT_RESET_VALUE")]
pub reset_as_default: bool, pub reset_to_default: bool,
#[pb(index = 4)]
#[serde(default)]
pub setting_key_value: HashMap<String, String>,
} }
const DEFAULT_RESET_VALUE: fn() -> bool = || APPEARANCE_RESET_AS_DEFAULT; const DEFAULT_RESET_VALUE: fn() -> bool = || APPEARANCE_RESET_AS_DEFAULT;
@ -52,7 +57,8 @@ impl std::default::Default for AppearanceSettingsPB {
AppearanceSettingsPB { AppearanceSettingsPB {
theme: APPEARANCE_DEFAULT_THEME.to_owned(), theme: APPEARANCE_DEFAULT_THEME.to_owned(),
locale: LocaleSettingsPB::default(), locale: LocaleSettingsPB::default(),
reset_as_default: APPEARANCE_RESET_AS_DEFAULT, reset_to_default: APPEARANCE_RESET_AS_DEFAULT,
setting_key_value: HashMap::default(),
} }
} }
} }

8
shared-lib/Cargo.lock generated
View File

@ -650,9 +650,9 @@ dependencies = [
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.11.2" version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]] [[package]]
name = "heck" name = "heck"
@ -732,9 +732,9 @@ dependencies = [
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "1.8.1" version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f647032dfaa1f8b6dc29bd3edb7bbef4861b8b8007ebb118d6db284fd59f6ee" checksum = "10a35a97730320ffe8e2d410b5d3b69279b98d2c14bdb8b70ea89ecf7888d41e"
dependencies = [ dependencies = [
"autocfg", "autocfg",
"hashbrown", "hashbrown",

View File

@ -12,7 +12,7 @@ serde_json = {version = "1.0"}
serde_repr = "0.1" serde_repr = "0.1"
nanoid = "0.4.0" nanoid = "0.4.0"
flowy-error-code = { path = "../flowy-error-code"} flowy-error-code = { path = "../flowy-error-code"}
indexmap = {version = "1.8.1", features = ["serde"]} indexmap = {version = "1.9.1", features = ["serde"]}
tracing = { version = "0.1", features = ["log"] } tracing = { version = "0.1", features = ["log"] }
[build-dependencies] [build-dependencies]

View File

@ -128,14 +128,6 @@ impl GroupRevision {
} }
} }
pub fn default_group(id: String, group_name: String) -> Self {
Self {
id,
name: group_name,
visible: true,
}
}
pub fn update_with_other(&mut self, other: &GroupRevision) { pub fn update_with_other(&mut self, other: &GroupRevision) {
self.visible = other.visible self.visible = other.visible
} }

Some files were not shown because too many files have changed in this diff Show More