mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
Merge branch 'main' into feat/markdown_syntax_to_code_text
This commit is contained in:
commit
3156568594
25
CHANGELOG.md
25
CHANGELOG.md
@ -1,5 +1,30 @@
|
||||
# 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
|
||||
|
||||
New features
|
||||
|
@ -199,7 +199,8 @@
|
||||
"delete": "Delete",
|
||||
"textPlaceholder": "Empty",
|
||||
"copyProperty": "Copied property to clipboard",
|
||||
"count": "Count"
|
||||
"count": "Count",
|
||||
"newRow": "New row"
|
||||
},
|
||||
"selectOption": {
|
||||
"create": "Create",
|
||||
@ -231,4 +232,4 @@
|
||||
"create_new_card": "New"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -149,7 +149,7 @@
|
||||
"grid": {
|
||||
"settings": {
|
||||
"filter": "Filtrer",
|
||||
"sortBy": "Trier par",
|
||||
"sortBy": "Filtrer par",
|
||||
"Properties": "Propriétés"
|
||||
},
|
||||
"field": {
|
||||
|
@ -94,7 +94,14 @@
|
||||
},
|
||||
"tooltip": {
|
||||
"lightMode": "切换到亮色模式",
|
||||
"darkMode": "切换到暗色模式"
|
||||
"darkMode": "切换到暗色模式",
|
||||
"openAsPage": "作为页面打开",
|
||||
"addNewRow": "增加一行",
|
||||
"openMenu": "点击打开菜单"
|
||||
},
|
||||
"sideBar": {
|
||||
"openSidebar": "打开侧边栏",
|
||||
"closeSidebar": "关闭侧边栏"
|
||||
},
|
||||
"notifications": {
|
||||
"export": {
|
||||
@ -149,15 +156,12 @@
|
||||
"darkLabel": "夜间模式"
|
||||
}
|
||||
},
|
||||
"sideBar": {
|
||||
"openSidebar": "打开侧边栏",
|
||||
"closeSidebar": "关闭侧边栏"
|
||||
},
|
||||
"grid": {
|
||||
"settings": {
|
||||
"filter": "过滤器",
|
||||
"sortBy": "排序",
|
||||
"Properties": "属性"
|
||||
"Properties": "属性",
|
||||
"group": "组"
|
||||
},
|
||||
"field": {
|
||||
"hide": "隐藏",
|
||||
@ -186,13 +190,17 @@
|
||||
"addSelectOption": "添加一个标签",
|
||||
"optionTitle": "标签",
|
||||
"addOption": "添加标签",
|
||||
"editProperty": "编辑列属性"
|
||||
"editProperty": "编辑列属性",
|
||||
"newColumn": "增加一列",
|
||||
"deleteFieldPromptMessage": "确定要删除这个属性吗? "
|
||||
},
|
||||
"row": {
|
||||
"duplicate": "复制",
|
||||
"delete": "删除",
|
||||
"textPlaceholder": "空",
|
||||
"copyProperty": "复制列"
|
||||
"copyProperty": "复制列",
|
||||
"count": "数量",
|
||||
"newRow": "添加一行"
|
||||
},
|
||||
"selectOption": {
|
||||
"create": "新建",
|
||||
@ -218,5 +226,11 @@
|
||||
"timeHintTextInTwelveHour": "01:00 PM",
|
||||
"timeHintTextInTwentyFourHour": "13:00"
|
||||
}
|
||||
},
|
||||
"board": {
|
||||
"column": {
|
||||
"create_new_card": "新建"
|
||||
},
|
||||
"menuName": "看板"
|
||||
}
|
||||
}
|
@ -36,21 +36,21 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
|
||||
super(BoardState.initial(view.id)) {
|
||||
boardController = AppFlowyBoardController(
|
||||
onMoveGroup: (
|
||||
fromColumnId,
|
||||
fromGroupId,
|
||||
fromIndex,
|
||||
toColumnId,
|
||||
toGroupId,
|
||||
toIndex,
|
||||
) {
|
||||
_moveGroup(fromColumnId, toColumnId);
|
||||
_moveGroup(fromGroupId, toGroupId);
|
||||
},
|
||||
onMoveGroupItem: (
|
||||
columnId,
|
||||
groupId,
|
||||
fromIndex,
|
||||
toIndex,
|
||||
) {
|
||||
final fromRow = groupControllers[columnId]?.rowAtIndex(fromIndex);
|
||||
final toRow = groupControllers[columnId]?.rowAtIndex(toIndex);
|
||||
_moveRow(fromRow, columnId, toRow);
|
||||
final fromRow = groupControllers[groupId]?.rowAtIndex(fromIndex);
|
||||
final toRow = groupControllers[groupId]?.rowAtIndex(toIndex);
|
||||
_moveRow(fromRow, groupId, toRow);
|
||||
},
|
||||
onMoveGroupItemToGroup: (
|
||||
fromGroupId,
|
||||
|
@ -68,9 +68,11 @@ class GridPluginDisplay extends PluginDisplay {
|
||||
@override
|
||||
Widget buildWidget(PluginContext context) {
|
||||
notifier.isDeleted.addListener(() {
|
||||
if (notifier.isDeleted.value) {
|
||||
context.onDeleted(view);
|
||||
}
|
||||
notifier.isDeleted.value.fold(() => null, (deletedView) {
|
||||
if (deletedView.hasIndex()) {
|
||||
context.onDeleted(view, deletedView.index);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return BoardPage(key: ValueKey(view.id), view: view);
|
||||
|
@ -69,7 +69,6 @@ class BoardContent extends StatefulWidget {
|
||||
|
||||
class _BoardContentState extends State<BoardContent> {
|
||||
late AppFlowyBoardScrollController scrollManager;
|
||||
final Map<String, ValueKey> cardKeysCache = {};
|
||||
|
||||
final config = AppFlowyBoardConfig(
|
||||
groupBackgroundColor: HexColor.fromHex('#F7F8FC'),
|
||||
@ -104,8 +103,8 @@ class _BoardContentState extends State<BoardContent> {
|
||||
|
||||
Widget _buildBoard(BuildContext context) {
|
||||
return ChangeNotifierProvider.value(
|
||||
value: Provider.of<AppearanceSettingModel>(context, listen: true),
|
||||
child: Selector<AppearanceSettingModel, AppTheme>(
|
||||
value: Provider.of<AppearanceSetting>(context, listen: true),
|
||||
child: Selector<AppearanceSetting, AppTheme>(
|
||||
selector: (ctx, notifier) => notifier.theme,
|
||||
builder: (ctx, theme, child) => Expanded(
|
||||
child: AppFlowyBoard(
|
||||
@ -139,7 +138,7 @@ class _BoardContentState extends State<BoardContent> {
|
||||
.read<BoardBloc>()
|
||||
.add(BoardEvent.endEditRow(editingRow.row.id));
|
||||
} else {
|
||||
scrollManager.scrollToBottom(editingRow.columnId, () {
|
||||
scrollManager.scrollToBottom(editingRow.columnId, (boardContext) {
|
||||
context
|
||||
.read<BoardBloc>()
|
||||
.add(BoardEvent.endEditRow(editingRow.row.id));
|
||||
@ -247,15 +246,8 @@ class _BoardContentState extends State<BoardContent> {
|
||||
);
|
||||
|
||||
final groupItemId = columnItem.id + group.id;
|
||||
ValueKey? key = cardKeysCache[groupItemId];
|
||||
if (key == null) {
|
||||
final newKey = ValueKey(groupItemId);
|
||||
cardKeysCache[groupItemId] = newKey;
|
||||
key = newKey;
|
||||
}
|
||||
|
||||
return AppFlowyGroupCard(
|
||||
key: key,
|
||||
key: ValueKey(groupItemId),
|
||||
margin: config.cardPadding,
|
||||
decoration: _makeBoxDecoration(context),
|
||||
child: BoardCard(
|
||||
@ -331,8 +323,8 @@ class _ToolbarBlocAdaptor extends StatelessWidget {
|
||||
);
|
||||
|
||||
return ChangeNotifierProvider.value(
|
||||
value: Provider.of<AppearanceSettingModel>(context, listen: true),
|
||||
child: Selector<AppearanceSettingModel, AppTheme>(
|
||||
value: Provider.of<AppearanceSetting>(context, listen: true),
|
||||
child: Selector<AppearanceSetting, AppTheme>(
|
||||
selector: (ctx, notifier) => notifier.theme,
|
||||
builder: (ctx, theme, child) {
|
||||
return BoardToolbar(toolbarContext: toolbarContext);
|
||||
|
@ -74,15 +74,26 @@ class DocumentPlugin extends Plugin<int> {
|
||||
class DocumentPluginDisplay extends PluginDisplay with NavigationItem {
|
||||
final ViewPluginNotifier notifier;
|
||||
ViewPB get view => notifier.view;
|
||||
int? deletedViewIndex;
|
||||
|
||||
DocumentPluginDisplay({required this.notifier, Key? key});
|
||||
|
||||
@override
|
||||
Widget buildWidget(PluginContext context) => DocumentPage(
|
||||
view: view,
|
||||
onDeleted: () => context.onDeleted(view),
|
||||
key: ValueKey(view.id),
|
||||
);
|
||||
Widget buildWidget(PluginContext context) {
|
||||
notifier.isDeleted.addListener(() {
|
||||
notifier.isDeleted.value.fold(() => null, (deletedView) {
|
||||
if (deletedView.hasIndex()) {
|
||||
deletedViewIndex = deletedView.index;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return DocumentPage(
|
||||
view: view,
|
||||
onDeleted: () => context.onDeleted(view, deletedViewIndex),
|
||||
key: ValueKey(view.id),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget get leftBarItem => ViewLeftBarItem(view: view);
|
||||
@ -120,8 +131,8 @@ class DocumentShareButton extends StatelessWidget {
|
||||
child: BlocBuilder<DocShareBloc, DocShareState>(
|
||||
builder: (context, state) {
|
||||
return ChangeNotifierProvider.value(
|
||||
value: Provider.of<AppearanceSettingModel>(context, listen: true),
|
||||
child: Selector<AppearanceSettingModel, Locale>(
|
||||
value: Provider.of<AppearanceSetting>(context, listen: true),
|
||||
child: Selector<AppearanceSetting, Locale>(
|
||||
selector: (ctx, notifier) => notifier.locale,
|
||||
builder: (ctx, _, child) => ConstrainedBox(
|
||||
constraints: const BoxConstraints.expand(
|
||||
|
@ -134,7 +134,7 @@ class _DocumentPageState extends State<DocumentPage> {
|
||||
|
||||
Widget _renderToolbar(quill.QuillController controller) {
|
||||
return ChangeNotifierProvider.value(
|
||||
value: Provider.of<AppearanceSettingModel>(context, listen: true),
|
||||
value: Provider.of<AppearanceSetting>(context, listen: true),
|
||||
child: EditorToolbar.basic(
|
||||
controller: controller,
|
||||
),
|
||||
|
@ -11,7 +11,10 @@ class NumberFormatBloc extends Bloc<NumberFormatEvent, NumberFormatState> {
|
||||
event.map(setFilter: (_SetFilter value) {
|
||||
final List<NumberFormat> formats = List.from(NumberFormat.values);
|
||||
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));
|
||||
});
|
||||
@ -91,7 +94,7 @@ extension NumberFormatExtension on NumberFormat {
|
||||
case NumberFormat.Percent:
|
||||
return "Percent";
|
||||
case NumberFormat.PhilippinePeso:
|
||||
return "Percent";
|
||||
return "PhilippinePeso";
|
||||
case NumberFormat.Pound:
|
||||
return "Pound";
|
||||
case NumberFormat.Rand:
|
||||
|
@ -70,9 +70,11 @@ class GridPluginDisplay extends PluginDisplay {
|
||||
@override
|
||||
Widget buildWidget(PluginContext context) {
|
||||
notifier.isDeleted.addListener(() {
|
||||
if (notifier.isDeleted.value) {
|
||||
context.onDeleted(view);
|
||||
}
|
||||
notifier.isDeleted.value.fold(() => null, (deletedView) {
|
||||
if (deletedView.hasIndex()) {
|
||||
context.onDeleted(view, deletedView.index);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return GridPage(key: ValueKey(view.id), view: view);
|
||||
|
@ -97,7 +97,6 @@ class GridURLCell extends GridCellWidget {
|
||||
|
||||
class _GridURLCellState extends GridCellState<GridURLCell> {
|
||||
final _popoverController = PopoverController();
|
||||
GridURLCellController? _cellContext;
|
||||
late URLCellBloc _cellBloc;
|
||||
|
||||
@override
|
||||
@ -132,6 +131,7 @@ class _GridURLCellState extends GridCellState<GridURLCell> {
|
||||
controller: _popoverController,
|
||||
constraints: BoxConstraints.loose(const Size(300, 160)),
|
||||
direction: PopoverDirection.bottomWithLeftAligned,
|
||||
triggerActions: PopoverTriggerFlags.none,
|
||||
offset: const Offset(0, 20),
|
||||
child: SizedBox.expand(
|
||||
child: GestureDetector(
|
||||
@ -144,7 +144,8 @@ class _GridURLCellState extends GridCellState<GridURLCell> {
|
||||
),
|
||||
popupBuilder: (BuildContext popoverContext) {
|
||||
return URLEditorPopover(
|
||||
cellController: _cellContext!,
|
||||
cellController: widget.cellControllerBuilder.build()
|
||||
as GridURLCellController,
|
||||
);
|
||||
},
|
||||
onClose: () {
|
||||
@ -166,17 +167,13 @@ class _GridURLCellState extends GridCellState<GridURLCell> {
|
||||
final uri = Uri.parse(url);
|
||||
if (url.isNotEmpty && await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri);
|
||||
} else {
|
||||
_cellContext =
|
||||
widget.cellControllerBuilder.build() as GridURLCellController;
|
||||
widget.onCellEditing.value = true;
|
||||
_popoverController.show();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void requestBeginFocus() {
|
||||
_openUrlOrEdit(_cellBloc.state.url);
|
||||
widget.onCellEditing.value = true;
|
||||
_popoverController.show();
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -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:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/image.dart';
|
||||
import 'package:flowy_infra/theme.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/button.dart';
|
||||
@ -13,7 +15,7 @@ class GridAddRowButton extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final theme = context.watch<AppTheme>();
|
||||
return FlowyButton(
|
||||
text: const FlowyText.medium('New row', fontSize: 12),
|
||||
text: FlowyText.medium(LocaleKeys.grid_row_newRow.tr(), fontSize: 12),
|
||||
hoverColor: theme.shader6,
|
||||
onTap: () => context.read<GridBloc>().add(const GridEvent.createRow()),
|
||||
leftIcon: svgWidget("home/add", color: theme.iconColor),
|
||||
|
@ -33,8 +33,8 @@ class MenuTrash extends StatelessWidget {
|
||||
Widget _render(BuildContext context) {
|
||||
return Row(children: [
|
||||
ChangeNotifierProvider.value(
|
||||
value: Provider.of<AppearanceSettingModel>(context, listen: true),
|
||||
child: Selector<AppearanceSettingModel, AppTheme>(
|
||||
value: Provider.of<AppearanceSetting>(context, listen: true),
|
||||
child: Selector<AppearanceSetting, AppTheme>(
|
||||
selector: (ctx, notifier) => notifier.theme,
|
||||
builder: (ctx, theme, child) => SizedBox(
|
||||
width: 16,
|
||||
@ -44,8 +44,8 @@ class MenuTrash extends StatelessWidget {
|
||||
),
|
||||
const HSpace(6),
|
||||
ChangeNotifierProvider.value(
|
||||
value: Provider.of<AppearanceSettingModel>(context, listen: true),
|
||||
child: Selector<AppearanceSettingModel, Locale>(
|
||||
value: Provider.of<AppearanceSetting>(context, listen: true),
|
||||
child: Selector<AppearanceSetting, Locale>(
|
||||
selector: (ctx, notifier) => notifier.locale,
|
||||
builder: (ctx, _, child) =>
|
||||
FlowyText.medium(LocaleKeys.trash_text.tr(), fontSize: 12),
|
||||
|
@ -1,15 +1,16 @@
|
||||
import 'package:app_flowy/startup/plugin/plugin.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/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ViewPluginNotifier extends PluginNotifier {
|
||||
class ViewPluginNotifier extends PluginNotifier<Option<DeletedViewPB>> {
|
||||
final ViewListener? _viewListener;
|
||||
ViewPB view;
|
||||
|
||||
@override
|
||||
final ValueNotifier<bool> isDeleted = ValueNotifier(false);
|
||||
final ValueNotifier<Option<DeletedViewPB>> isDeleted = ValueNotifier(none());
|
||||
|
||||
@override
|
||||
final ValueNotifier<int> isDisplayChanged = ValueNotifier(0);
|
||||
@ -27,9 +28,7 @@ class ViewPluginNotifier extends PluginNotifier {
|
||||
);
|
||||
}, onViewMoveToTrash: (result) {
|
||||
result.fold(
|
||||
(deletedView) {
|
||||
isDeleted.value = true;
|
||||
},
|
||||
(deletedView) => isDeleted.value = some(deletedView),
|
||||
(err) => Log.error(err),
|
||||
);
|
||||
});
|
||||
|
@ -32,9 +32,9 @@ abstract class Plugin<T> {
|
||||
}
|
||||
}
|
||||
|
||||
abstract class PluginNotifier {
|
||||
abstract class PluginNotifier<T> {
|
||||
/// Notify if the plugin get deleted
|
||||
ValueNotifier<bool> get isDeleted;
|
||||
ValueNotifier<T> get isDeleted;
|
||||
|
||||
/// Notify if the [PluginDisplay]'s content was changed
|
||||
ValueNotifier<int> get isDisplayChanged;
|
||||
@ -67,7 +67,7 @@ abstract class PluginDisplay with NavigationItem {
|
||||
|
||||
class PluginContext {
|
||||
// calls when widget of the plugin get deleted
|
||||
final Function(ViewPB) onDeleted;
|
||||
final Function(ViewPB, int?) onDeleted;
|
||||
|
||||
PluginContext({required this.onDeleted});
|
||||
}
|
||||
|
@ -17,8 +17,8 @@ class InitAppWidgetTask extends LaunchTask {
|
||||
@override
|
||||
Future<void> initialize(LaunchContext context) async {
|
||||
final widget = context.getIt<EntryPoint>().create();
|
||||
final setting = await UserSettingsService().getAppearanceSettings();
|
||||
final settingModel = AppearanceSettingModel(setting);
|
||||
final setting = await SettingsFFIService().getAppearanceSetting();
|
||||
final settingModel = AppearanceSetting(setting);
|
||||
final app = ApplicationWidget(
|
||||
settingModel: settingModel,
|
||||
child: widget,
|
||||
@ -58,7 +58,7 @@ class InitAppWidgetTask extends LaunchTask {
|
||||
|
||||
class ApplicationWidget extends StatelessWidget {
|
||||
final Widget child;
|
||||
final AppearanceSettingModel settingModel;
|
||||
final AppearanceSetting settingModel;
|
||||
|
||||
const ApplicationWidget({
|
||||
Key? key,
|
||||
@ -75,10 +75,10 @@ class ApplicationWidget extends StatelessWidget {
|
||||
const minWidth = 600.0;
|
||||
setWindowMinSize(const Size(minWidth, minWidth / ratio));
|
||||
settingModel.readLocaleWhenAppLaunch(context);
|
||||
AppTheme theme = context.select<AppearanceSettingModel, AppTheme>(
|
||||
AppTheme theme = context.select<AppearanceSetting, AppTheme>(
|
||||
(value) => value.theme,
|
||||
);
|
||||
Locale locale = context.select<AppearanceSettingModel, Locale>(
|
||||
Locale locale = context.select<AppearanceSetting, Locale>(
|
||||
(value) => value.locale,
|
||||
);
|
||||
|
||||
|
@ -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-user/user_setting.pb.dart';
|
||||
|
||||
class UserSettingsService {
|
||||
Future<AppearanceSettingsPB> getAppearanceSettings() async {
|
||||
class SettingsFFIService {
|
||||
Future<AppearanceSettingsPB> getAppearanceSetting() async {
|
||||
final result = await UserEventGetAppearanceSetting().send();
|
||||
|
||||
return result.fold(
|
||||
@ -18,7 +18,8 @@ class UserSettingsService {
|
||||
);
|
||||
}
|
||||
|
||||
Future<Either<Unit, FlowyError>> setAppearanceSettings(AppearanceSettingsPB settings) {
|
||||
return UserEventSetAppearanceSetting(settings).send();
|
||||
Future<Either<Unit, FlowyError>> setAppearanceSetting(
|
||||
AppearanceSettingsPB setting) {
|
||||
return UserEventSetAppearanceSetting(setting).send();
|
||||
}
|
||||
}
|
||||
|
@ -8,72 +8,114 @@ import 'package:flowy_sdk/protobuf/flowy-user/user_setting.pb.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
|
||||
class AppearanceSettingModel extends ChangeNotifier with EquatableMixin {
|
||||
AppearanceSettingsPB setting;
|
||||
/// [AppearanceSetting] is used to modify the appear setting of AppFlowy application. Including the [Locale], [AppTheme], etc.
|
||||
class AppearanceSetting extends ChangeNotifier with EquatableMixin {
|
||||
final AppearanceSettingsPB _setting;
|
||||
AppTheme _theme;
|
||||
Locale _locale;
|
||||
Timer? _saveOperation;
|
||||
Timer? _debounceSaveOperation;
|
||||
|
||||
AppearanceSettingModel(this.setting)
|
||||
: _theme = AppTheme.fromName(name: setting.theme),
|
||||
_locale =
|
||||
Locale(setting.locale.languageCode, setting.locale.countryCode);
|
||||
AppearanceSetting(AppearanceSettingsPB setting)
|
||||
: _setting = setting,
|
||||
_theme = AppTheme.fromName(name: setting.theme),
|
||||
_locale = Locale(
|
||||
setting.locale.languageCode,
|
||||
setting.locale.countryCode,
|
||||
);
|
||||
|
||||
/// Returns the current [AppTheme]
|
||||
AppTheme get theme => _theme;
|
||||
|
||||
/// Returns the current [Locale]
|
||||
Locale get locale => _locale;
|
||||
|
||||
Future<void> save() async {
|
||||
_saveOperation?.cancel();
|
||||
_saveOperation = Timer(const Duration(seconds: 2), () async {
|
||||
await UserSettingsService().setAppearanceSettings(setting);
|
||||
});
|
||||
}
|
||||
|
||||
@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);
|
||||
setting.theme = themeTypeToString(themeType);
|
||||
notifyListeners();
|
||||
save();
|
||||
/// Updates the current theme and notify the listeners the theme was changed.
|
||||
/// Do nothing if the passed in themeType equal to the current theme type.
|
||||
///
|
||||
void setTheme(ThemeType themeType) {
|
||||
if (_theme.ty == themeType) {
|
||||
return;
|
||||
}
|
||||
|
||||
_theme = AppTheme.fromType(themeType);
|
||||
_setting.theme = themeTypeToString(themeType);
|
||||
_saveAppearSetting();
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
if (!context.supportedLocales.contains(newLocale)) {
|
||||
Log.warn("Unsupported locale: $newLocale");
|
||||
Log.warn("Unsupported locale: $newLocale, Fallback to locale: en");
|
||||
newLocale = const Locale('en');
|
||||
Log.debug("Fallback to locale: $newLocale");
|
||||
}
|
||||
|
||||
context.setLocale(newLocale);
|
||||
|
||||
if (_locale != newLocale) {
|
||||
_locale = newLocale;
|
||||
setting.locale.languageCode = _locale.languageCode;
|
||||
setting.locale.countryCode = _locale.countryCode ?? "";
|
||||
_setting.locale.languageCode = _locale.languageCode;
|
||||
_setting.locale.countryCode = _locale.countryCode ?? "";
|
||||
_saveAppearSetting();
|
||||
|
||||
notifyListeners();
|
||||
save();
|
||||
}
|
||||
}
|
||||
|
||||
void readLocaleWhenAppLaunch(BuildContext context) {
|
||||
if (setting.resetAsDefault) {
|
||||
setting.resetAsDefault = false;
|
||||
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) {
|
||||
if (_setting.resetToDefault) {
|
||||
_setting.resetToDefault = false;
|
||||
_saveAppearSetting();
|
||||
setLocale(context, context.deviceLocale);
|
||||
return;
|
||||
}
|
||||
|
||||
// when opening app the first time
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
@ -16,7 +16,7 @@ typedef UpdateViewNotifiedValue = Either<ViewPB, FlowyError>;
|
||||
// Restore the view from trash
|
||||
typedef RestoreViewNotifiedValue = Either<ViewPB, FlowyError>;
|
||||
// Move the view to trash
|
||||
typedef MoveToTrashNotifiedValue = Either<ViewIdPB, FlowyError>;
|
||||
typedef MoveToTrashNotifiedValue = Either<DeletedViewPB, FlowyError>;
|
||||
|
||||
class ViewListener {
|
||||
StreamSubscription<SubscribeObject>? _subscription;
|
||||
@ -98,8 +98,8 @@ class ViewListener {
|
||||
break;
|
||||
case FolderNotification.ViewMoveToTrash:
|
||||
result.fold(
|
||||
(payload) =>
|
||||
_moveToTrashNotifier.value = left(ViewIdPB.fromBuffer(payload)),
|
||||
(payload) => _moveToTrashNotifier.value =
|
||||
left(DeletedViewPB.fromBuffer(payload)),
|
||||
(error) => _moveToTrashNotifier.value = right(error),
|
||||
);
|
||||
break;
|
||||
|
@ -34,19 +34,6 @@ class HomeScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
ViewPB? initialView;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant HomeScreen oldWidget) {
|
||||
initialView = null;
|
||||
super.didUpdateWidget(oldWidget);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiBlocProvider(
|
||||
@ -129,26 +116,29 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
required BuildContext context,
|
||||
required HomeState state}) {
|
||||
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(
|
||||
user: widget.user,
|
||||
workspaceSetting: workspaceSetting,
|
||||
collapsedNotifier: getIt<HomeStackManager>().collapsedNotifier,
|
||||
);
|
||||
|
||||
final latestView =
|
||||
workspaceSetting.hasLatestView() ? workspaceSetting.latestView : 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.
|
||||
getIt<MenuSharedState>().latestOpenView = latestView;
|
||||
// Only open the last opened view if the [HomeStackManager] current opened
|
||||
// plugin is blank and the last opened view is not null.
|
||||
//
|
||||
// All opened widgets that display on the home screen are in the form
|
||||
// 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));
|
||||
@ -261,14 +251,18 @@ class HomeScreenStackAdaptor extends HomeStackDelegate {
|
||||
});
|
||||
|
||||
@override
|
||||
void didDeleteStackWidget(ViewPB view) {
|
||||
void didDeleteStackWidget(ViewPB view, int? index) {
|
||||
final homeService = HomeService();
|
||||
homeService.readApp(appId: view.appId).then((result) {
|
||||
result.fold(
|
||||
(appPB) {
|
||||
final List<ViewPB> views = appPB.belongings.items;
|
||||
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(
|
||||
pluginType: lastView.pluginType,
|
||||
data: lastView,
|
||||
|
@ -18,7 +18,7 @@ import 'home_layout.dart';
|
||||
typedef NavigationCallback = void Function(String id);
|
||||
|
||||
abstract class HomeStackDelegate {
|
||||
void didDeleteStackWidget(ViewPB view);
|
||||
void didDeleteStackWidget(ViewPB view, int? index);
|
||||
}
|
||||
|
||||
class HomeStack extends StatelessWidget {
|
||||
@ -41,9 +41,11 @@ class HomeStack extends StatelessWidget {
|
||||
child: Container(
|
||||
color: theme.surface,
|
||||
child: FocusTraversalGroup(
|
||||
child: getIt<HomeStackManager>().stackWidget(onDeleted: (view) {
|
||||
delegate.didDeleteStackWidget(view);
|
||||
}),
|
||||
child: getIt<HomeStackManager>().stackWidget(
|
||||
onDeleted: (view, index) {
|
||||
delegate.didDeleteStackWidget(view, index);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -144,6 +146,7 @@ class HomeStackManager {
|
||||
}
|
||||
|
||||
PublishNotifier<bool> get collapsedNotifier => _notifier.collapsedNotifier;
|
||||
Plugin get plugin => _notifier.plugin;
|
||||
|
||||
void setPlugin(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(
|
||||
providers: [ChangeNotifierProvider.value(value: _notifier)],
|
||||
child: Consumer(builder: (ctx, HomeStackNotifier notifier, child) {
|
||||
|
@ -89,8 +89,7 @@ class _MenuAppState extends State<MenuApp> {
|
||||
hasIcon: false,
|
||||
),
|
||||
header: ChangeNotifierProvider.value(
|
||||
value:
|
||||
Provider.of<AppearanceSettingModel>(context, listen: true),
|
||||
value: Provider.of<AppearanceSetting>(context, listen: true),
|
||||
child: MenuAppHeader(widget.app),
|
||||
),
|
||||
expanded: ViewSection(appViewData: viewDataContext),
|
||||
|
@ -33,8 +33,7 @@ class SettingsDialog extends StatelessWidget {
|
||||
..add(const SettingsDialogEvent.initial()),
|
||||
child: BlocBuilder<SettingsDialogBloc, SettingsDialogState>(
|
||||
builder: (context, state) => ChangeNotifierProvider.value(
|
||||
value: Provider.of<AppearanceSettingModel>(context,
|
||||
listen: true),
|
||||
value: Provider.of<AppearanceSetting>(context, listen: true),
|
||||
child: FlowyDialog(
|
||||
title: Text(
|
||||
LocaleKeys.settings_title.tr(),
|
||||
|
@ -13,7 +13,7 @@ class SettingsAppearanceView extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = context.watch<AppTheme>();
|
||||
final theme = context.read<AppTheme>();
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
@ -30,9 +30,7 @@ class SettingsAppearanceView extends StatelessWidget {
|
||||
),
|
||||
Toggle(
|
||||
value: theme.isDark,
|
||||
onChanged: (val) {
|
||||
context.read<AppearanceSettingModel>().swapTheme();
|
||||
},
|
||||
onChanged: (_) => setTheme(context),
|
||||
style: ToggleStyle.big(theme),
|
||||
),
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -13,7 +13,7 @@ class SettingsLanguageView extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
context.watch<AppTheme>();
|
||||
return ChangeNotifierProvider.value(
|
||||
value: Provider.of<AppearanceSettingModel>(context, listen: true),
|
||||
value: Provider.of<AppearanceSetting>(context, listen: true),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@ -43,7 +43,8 @@ class LanguageSelectorDropdown extends StatefulWidget {
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<LanguageSelectorDropdown> createState() => _LanguageSelectorDropdownState();
|
||||
State<LanguageSelectorDropdown> createState() =>
|
||||
_LanguageSelectorDropdownState();
|
||||
}
|
||||
|
||||
class _LanguageSelectorDropdownState extends State<LanguageSelectorDropdown> {
|
||||
@ -77,10 +78,10 @@ class _LanguageSelectorDropdownState extends State<LanguageSelectorDropdown> {
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<Locale>(
|
||||
value: context.read<AppearanceSettingModel>().locale,
|
||||
value: context.read<AppearanceSetting>().locale,
|
||||
onChanged: (val) {
|
||||
setState(() {
|
||||
context.read<AppearanceSettingModel>().setLocale(context, val!);
|
||||
context.read<AppearanceSetting>().setLocale(context, val!);
|
||||
});
|
||||
},
|
||||
icon: const Visibility(
|
||||
|
@ -1,3 +1,5 @@
|
||||
# 0.0.8
|
||||
* Enable drag and drop group
|
||||
# 0.0.7
|
||||
* Rename some classes
|
||||
* Add documentation
|
||||
@ -7,7 +9,7 @@
|
||||
|
||||
# 0.0.5
|
||||
* 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
|
||||
|
||||
# 0.0.4
|
||||
@ -24,6 +26,5 @@
|
||||
|
||||
# 0.0.1
|
||||
|
||||
* Support drag and drop column
|
||||
* Support drag and drop column items from one to another
|
||||
* Support drag and drop group items from one to another
|
||||
|
||||
|
@ -34,7 +34,7 @@ class _MyAppState extends State<MyApp> {
|
||||
appBar: AppBar(
|
||||
title: const Text('AppFlowy Board'),
|
||||
),
|
||||
body: _examples[_currentIndex],
|
||||
body: Container(color: Colors.white, child: _examples[_currentIndex]),
|
||||
bottomNavigationBar: BottomNavigationBar(
|
||||
fixedColor: _bottomNavigationColor,
|
||||
showSelectedLabels: true,
|
||||
|
@ -21,8 +21,11 @@ class _MultiBoardListExampleState extends State<MultiBoardListExample> {
|
||||
},
|
||||
);
|
||||
|
||||
late AppFlowyBoardScrollController boardController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
boardController = AppFlowyBoardScrollController();
|
||||
final group1 = AppFlowyGroupData(id: "To Do", name: "To Do", items: [
|
||||
TextItem("Card 1"),
|
||||
TextItem("Card 2"),
|
||||
@ -67,12 +70,16 @@ class _MultiBoardListExampleState extends State<MultiBoardListExample> {
|
||||
child: _buildCard(groupItem),
|
||||
);
|
||||
},
|
||||
boardScrollController: boardController,
|
||||
footerBuilder: (context, columnData) {
|
||||
return AppFlowyGroupFooter(
|
||||
icon: const Icon(Icons.add, size: 20),
|
||||
title: const Text('New'),
|
||||
height: 50,
|
||||
margin: config.groupItemPadding,
|
||||
onAddButtonClick: () {
|
||||
boardController.scrollToBottom(columnData.id, (p0) {});
|
||||
},
|
||||
);
|
||||
},
|
||||
headerBuilder: (context, columnData) {
|
||||
|
@ -8,25 +8,28 @@ class Log {
|
||||
|
||||
static void info(String? message) {
|
||||
if (enableLog) {
|
||||
debugPrint('ℹ️[Info]=> $message');
|
||||
debugPrint('AppFlowyBoard: ℹ️[Info]=> $message');
|
||||
}
|
||||
}
|
||||
|
||||
static void debug(String? message) {
|
||||
if (enableLog) {
|
||||
debugPrint('🐛[Debug] - ${DateTime.now().second}=> $message');
|
||||
debugPrint(
|
||||
'AppFlowyBoard: 🐛[Debug] - ${DateTime.now().second}=> $message');
|
||||
}
|
||||
}
|
||||
|
||||
static void warn(String? message) {
|
||||
if (enableLog) {
|
||||
debugPrint('🐛[Warn] - ${DateTime.now().second} => $message');
|
||||
debugPrint(
|
||||
'AppFlowyBoard: 🐛[Warn] - ${DateTime.now().second} => $message');
|
||||
}
|
||||
}
|
||||
|
||||
static void trace(String? message) {
|
||||
if (enableLog) {
|
||||
debugPrint('❗️[Trace] - ${DateTime.now().second}=> $message');
|
||||
debugPrint(
|
||||
'AppFlowyBoard: ❗️[Trace] - ${DateTime.now().second}=> $message');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -13,10 +13,8 @@ import '../rendering/board_overlay.dart';
|
||||
class AppFlowyBoardScrollController {
|
||||
AppFlowyBoardState? _groupState;
|
||||
|
||||
void scrollToBottom(String groupId, VoidCallback? completed) {
|
||||
_groupState
|
||||
?.getReorderFlexState(groupId: groupId)
|
||||
?.scrollToBottom(completed);
|
||||
void scrollToBottom(String groupId, void Function(BuildContext)? completed) {
|
||||
_groupState?.reorderFlexActionMap[groupId]?.scrollToBottom(completed);
|
||||
}
|
||||
}
|
||||
|
||||
@ -133,7 +131,7 @@ class AppFlowyBoard extends StatelessWidget {
|
||||
dataController: controller,
|
||||
scrollController: scrollController,
|
||||
scrollManager: boardScrollController,
|
||||
columnsState: _groupState,
|
||||
groupState: _groupState,
|
||||
background: background,
|
||||
delegate: _phantomController,
|
||||
groupConstraints: groupConstraints,
|
||||
@ -158,7 +156,7 @@ class _AppFlowyBoardContent extends StatefulWidget {
|
||||
final ReorderFlexConfig reorderFlexConfig;
|
||||
final BoxConstraints groupConstraints;
|
||||
final AppFlowyBoardScrollController? scrollManager;
|
||||
final AppFlowyBoardState columnsState;
|
||||
final AppFlowyBoardState groupState;
|
||||
final AppFlowyBoardCardBuilder cardBuilder;
|
||||
final AppFlowyBoardHeaderBuilder? headerBuilder;
|
||||
final AppFlowyBoardFooterBuilder? footerBuilder;
|
||||
@ -171,7 +169,7 @@ class _AppFlowyBoardContent extends StatefulWidget {
|
||||
required this.delegate,
|
||||
required this.dataController,
|
||||
required this.scrollManager,
|
||||
required this.columnsState,
|
||||
required this.groupState,
|
||||
this.scrollController,
|
||||
this.background,
|
||||
required this.groupConstraints,
|
||||
@ -192,8 +190,6 @@ class _AppFlowyBoardContentState extends State<_AppFlowyBoardContent> {
|
||||
GlobalKey(debugLabel: '$_AppFlowyBoardContent overlay key');
|
||||
late BoardOverlayEntry _overlayEntry;
|
||||
|
||||
final Map<String, GlobalObjectKey> _reorderFlexKeys = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_overlayEntry = BoardOverlayEntry(
|
||||
@ -202,7 +198,7 @@ class _AppFlowyBoardContentState extends State<_AppFlowyBoardContent> {
|
||||
reorderFlexId: widget.dataController.identifier,
|
||||
acceptedReorderFlexId: widget.dataController.groupIds,
|
||||
delegate: widget.delegate,
|
||||
columnsState: widget.columnsState,
|
||||
columnsState: widget.groupState,
|
||||
);
|
||||
|
||||
final reorderFlex = ReorderFlex(
|
||||
@ -212,7 +208,7 @@ class _AppFlowyBoardContentState extends State<_AppFlowyBoardContent> {
|
||||
dataSource: widget.dataController,
|
||||
direction: Axis.horizontal,
|
||||
interceptor: interceptor,
|
||||
reorderable: false,
|
||||
reorderable: true,
|
||||
children: _buildColumns(),
|
||||
);
|
||||
|
||||
@ -257,18 +253,16 @@ class _AppFlowyBoardContentState extends State<_AppFlowyBoardContent> {
|
||||
dataController: widget.dataController,
|
||||
);
|
||||
|
||||
if (_reorderFlexKeys[columnData.id] == null) {
|
||||
_reorderFlexKeys[columnData.id] = GlobalObjectKey(columnData.id);
|
||||
}
|
||||
final reorderFlexAction = ReorderFlexActionImpl();
|
||||
widget.groupState.reorderFlexActionMap[columnData.id] =
|
||||
reorderFlexAction;
|
||||
|
||||
GlobalObjectKey reorderFlexKey = _reorderFlexKeys[columnData.id]!;
|
||||
return ChangeNotifierProvider.value(
|
||||
key: ValueKey(columnData.id),
|
||||
value: widget.dataController.getGroupController(columnData.id),
|
||||
child: Consumer<AppFlowyGroupController>(
|
||||
builder: (context, value, child) {
|
||||
final boardColumn = AppFlowyBoardGroup(
|
||||
reorderFlexKey: reorderFlexKey,
|
||||
// key: PageStorageKey<String>(columnData.id),
|
||||
margin: _marginFromIndex(columnIndex),
|
||||
itemMargin: widget.config.groupItemPadding,
|
||||
@ -281,11 +275,11 @@ class _AppFlowyBoardContentState extends State<_AppFlowyBoardContent> {
|
||||
onReorder: widget.dataController.moveGroupItem,
|
||||
cornerRadius: widget.config.cornerRadius,
|
||||
backgroundColor: widget.config.groupBackgroundColor,
|
||||
dragStateStorage: widget.columnsState,
|
||||
dragTargetIndexKeyStorage: widget.columnsState,
|
||||
dragStateStorage: widget.groupState,
|
||||
dragTargetKeys: widget.groupState,
|
||||
reorderFlexAction: reorderFlexAction,
|
||||
);
|
||||
|
||||
widget.columnsState.addGroup(columnData.id, boardColumn);
|
||||
return ConstrainedBox(
|
||||
constraints: widget.groupConstraints,
|
||||
child: boardColumn,
|
||||
@ -356,71 +350,61 @@ class AppFlowyGroupContext {
|
||||
}
|
||||
|
||||
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
|
||||
/// AppFlowyBoardGroup's [ReorderFlex] widget.
|
||||
final Map<String, GlobalKey> groupReorderFlexKeys = {};
|
||||
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;
|
||||
}
|
||||
final Map<String, ReorderFlexActionImpl> reorderFlexActionMap = {};
|
||||
|
||||
@override
|
||||
DraggingState? read(String reorderFlexId) {
|
||||
DraggingState? readState(String reorderFlexId) {
|
||||
return groupDragStates[reorderFlexId];
|
||||
}
|
||||
|
||||
@override
|
||||
void write(String reorderFlexId, DraggingState state) {
|
||||
void insertState(String reorderFlexId, DraggingState state) {
|
||||
Log.trace('$reorderFlexId Write dragging state: $state');
|
||||
groupDragStates[reorderFlexId] = state;
|
||||
}
|
||||
|
||||
@override
|
||||
void remove(String reorderFlexId) {
|
||||
void removeState(String reorderFlexId) {
|
||||
groupDragStates.remove(reorderFlexId);
|
||||
}
|
||||
|
||||
@override
|
||||
void addKey(
|
||||
void insertDragTarget(
|
||||
String reorderFlexId,
|
||||
String key,
|
||||
GlobalObjectKey<State<StatefulWidget>> value,
|
||||
) {
|
||||
Map<String, GlobalObjectKey>? group = groupDragDragTargets[reorderFlexId];
|
||||
Map<String, GlobalObjectKey>? group = groupDragTargetKeys[reorderFlexId];
|
||||
if (group == null) {
|
||||
group = {};
|
||||
groupDragDragTargets[reorderFlexId] = group;
|
||||
groupDragTargetKeys[reorderFlexId] = group;
|
||||
}
|
||||
group[key] = value;
|
||||
}
|
||||
|
||||
@override
|
||||
GlobalObjectKey<State<StatefulWidget>>? readKey(
|
||||
String reorderFlexId, String key) {
|
||||
Map<String, GlobalObjectKey>? group = groupDragDragTargets[reorderFlexId];
|
||||
GlobalObjectKey<State<StatefulWidget>>? getDragTarget(
|
||||
String reorderFlexId,
|
||||
String key,
|
||||
) {
|
||||
Map<String, GlobalObjectKey>? group = groupDragTargetKeys[reorderFlexId];
|
||||
if (group != null) {
|
||||
return group[key];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void removeDragTarget(String reorderFlexId) {
|
||||
groupDragTargetKeys.remove(reorderFlexId);
|
||||
}
|
||||
}
|
||||
|
||||
class ReorderFlexActionImpl extends ReorderFlexAction {}
|
||||
|
@ -217,15 +217,15 @@ class AppFlowyBoardController extends ChangeNotifier
|
||||
final fromGroupItem = fromGroupController.removeAt(fromGroupIndex);
|
||||
if (toGroupController.items.length > toGroupIndex) {
|
||||
assert(toGroupController.items[toGroupIndex] is PhantomGroupItem);
|
||||
}
|
||||
|
||||
toGroupController.replace(toGroupIndex, fromGroupItem);
|
||||
onMoveGroupItemToGroup?.call(
|
||||
fromGroupId,
|
||||
fromGroupIndex,
|
||||
toGroupId,
|
||||
toGroupIndex,
|
||||
);
|
||||
toGroupController.replace(toGroupIndex, fromGroupItem);
|
||||
onMoveGroupItemToGroup?.call(
|
||||
fromGroupId,
|
||||
fromGroupIndex,
|
||||
toGroupId,
|
||||
toGroupIndex,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -90,21 +90,21 @@ class AppFlowyBoardGroup extends StatefulWidget {
|
||||
|
||||
final DraggingStateStorage? dragStateStorage;
|
||||
|
||||
final ReorderDragTargetIndexKeyStorage? dragTargetIndexKeyStorage;
|
||||
final ReorderDragTargeKeys? dragTargetKeys;
|
||||
|
||||
final GlobalObjectKey reorderFlexKey;
|
||||
final ReorderFlexAction? reorderFlexAction;
|
||||
|
||||
const AppFlowyBoardGroup({
|
||||
Key? key,
|
||||
required this.reorderFlexKey,
|
||||
this.headerBuilder,
|
||||
this.footerBuilder,
|
||||
required this.cardBuilder,
|
||||
required this.onReorder,
|
||||
required this.dataSource,
|
||||
required this.phantomController,
|
||||
this.reorderFlexAction,
|
||||
this.dragStateStorage,
|
||||
this.dragTargetIndexKeyStorage,
|
||||
this.dragTargetKeys,
|
||||
this.scrollController,
|
||||
this.onDragStarted,
|
||||
this.onDragEnded,
|
||||
@ -112,7 +112,7 @@ class AppFlowyBoardGroup extends StatefulWidget {
|
||||
this.itemMargin = EdgeInsets.zero,
|
||||
this.cornerRadius = 0.0,
|
||||
this.backgroundColor = Colors.transparent,
|
||||
}) : config = const ReorderFlexConfig(setStateWhenEndDrag: false),
|
||||
}) : config = const ReorderFlexConfig(),
|
||||
super(key: key);
|
||||
|
||||
@override
|
||||
@ -146,9 +146,9 @@ class _AppFlowyBoardGroupState extends State<AppFlowyBoardGroup> {
|
||||
);
|
||||
|
||||
Widget reorderFlex = ReorderFlex(
|
||||
key: widget.reorderFlexKey,
|
||||
key: ValueKey(widget.groupId),
|
||||
dragStateStorage: widget.dragStateStorage,
|
||||
dragTargetIndexKeyStorage: widget.dragTargetIndexKeyStorage,
|
||||
dragTargetKeys: widget.dragTargetKeys,
|
||||
scrollController: widget.scrollController,
|
||||
config: widget.config,
|
||||
onDragStarted: (index) {
|
||||
@ -168,6 +168,7 @@ class _AppFlowyBoardGroupState extends State<AppFlowyBoardGroup> {
|
||||
},
|
||||
dataSource: widget.dataSource,
|
||||
interceptor: interceptor,
|
||||
reorderFlexAction: widget.reorderFlexAction,
|
||||
children: children,
|
||||
);
|
||||
|
||||
|
@ -41,7 +41,7 @@ class AppFlowyGroupController extends ChangeNotifier with EquatableMixin {
|
||||
void updateGroupName(String newName) {
|
||||
if (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');
|
||||
final item = groupData._items.removeAt(index);
|
||||
if (notify) {
|
||||
notifyListeners();
|
||||
_notify();
|
||||
}
|
||||
return item;
|
||||
}
|
||||
@ -81,7 +81,7 @@ class AppFlowyGroupController extends ChangeNotifier with EquatableMixin {
|
||||
'[$AppFlowyGroupController] $groupData move item from $fromIndex to $toIndex');
|
||||
final item = groupData._items.removeAt(fromIndex);
|
||||
groupData._items.insert(toIndex, item);
|
||||
notifyListeners();
|
||||
_notify();
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -102,7 +102,7 @@ class AppFlowyGroupController extends ChangeNotifier with EquatableMixin {
|
||||
groupData._items.add(item);
|
||||
}
|
||||
|
||||
if (notify) notifyListeners();
|
||||
if (notify) _notify();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -112,7 +112,7 @@ class AppFlowyGroupController extends ChangeNotifier with EquatableMixin {
|
||||
return false;
|
||||
} else {
|
||||
groupData._items.add(item);
|
||||
if (notify) notifyListeners();
|
||||
if (notify) _notify();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -124,6 +124,8 @@ class AppFlowyGroupController extends ChangeNotifier with EquatableMixin {
|
||||
Log.debug('[$AppFlowyGroupController] $groupData add $newItem');
|
||||
} else {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -133,7 +135,7 @@ class AppFlowyGroupController extends ChangeNotifier with EquatableMixin {
|
||||
'[$AppFlowyGroupController] $groupData replace $removedItem with $newItem at $index');
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
_notify();
|
||||
}
|
||||
|
||||
void replaceOrInsertItem(AppFlowyGroupItem newItem) {
|
||||
@ -141,10 +143,10 @@ class AppFlowyGroupController extends ChangeNotifier with EquatableMixin {
|
||||
if (index != -1) {
|
||||
groupData._items.removeAt(index);
|
||||
groupData._items.insert(index, newItem);
|
||||
notifyListeners();
|
||||
_notify();
|
||||
} else {
|
||||
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) !=
|
||||
-1;
|
||||
}
|
||||
|
||||
void _notify() {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// [AppFlowyGroupData] represents the data of each group of the Board.
|
||||
|
@ -22,6 +22,8 @@ class FlexDragTargetData extends DragTargetData {
|
||||
|
||||
Size? get feedbackSize => _state.feedbackSize;
|
||||
|
||||
bool get isDragging => _state.isDragging();
|
||||
|
||||
final String dragTargetId;
|
||||
|
||||
Offset dragTargetOffset = Offset.zero;
|
||||
@ -48,47 +50,28 @@ class FlexDragTargetData extends DragTargetData {
|
||||
|
||||
bool isOverlapWithWidgets(List<GlobalObjectKey> widgetKeys) {
|
||||
final renderBox = dragTargetIndexKey.currentContext?.findRenderObject();
|
||||
|
||||
if (renderBox == null) return false;
|
||||
if (renderBox is! RenderBox) return false;
|
||||
final size = feedbackSize ?? Size.zero;
|
||||
final Rect rect = dragTargetOffset & size;
|
||||
|
||||
final Rect dragTargetRect = renderBox.localToGlobal(Offset.zero) & size;
|
||||
for (final widgetKey in widgetKeys) {
|
||||
final renderObject = widgetKey.currentContext?.findRenderObject();
|
||||
if (renderObject != null && renderObject is RenderBox) {
|
||||
Rect widgetRect =
|
||||
renderObject.localToGlobal(Offset.zero) & renderObject.size;
|
||||
// return rect.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;
|
||||
return dragTargetRect.overlaps(widgetRect);
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
abstract class DraggingStateStorage {
|
||||
void write(String reorderFlexId, DraggingState state);
|
||||
void remove(String reorderFlexId);
|
||||
DraggingState? read(String reorderFlexId);
|
||||
void insertState(String reorderFlexId, DraggingState state);
|
||||
void removeState(String reorderFlexId);
|
||||
DraggingState? readState(String reorderFlexId);
|
||||
}
|
||||
|
||||
class DraggingState {
|
||||
@ -113,7 +96,7 @@ class DraggingState {
|
||||
int currentIndex = -1;
|
||||
|
||||
/// 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.
|
||||
bool scrolling = false;
|
||||
@ -149,6 +132,7 @@ class DraggingState {
|
||||
dragStartIndex = -1;
|
||||
phantomIndex = -1;
|
||||
currentIndex = -1;
|
||||
nextIndex = -1;
|
||||
_draggingWidget = null;
|
||||
}
|
||||
|
||||
|
@ -1,3 +1,4 @@
|
||||
import 'package:appflowy_board/src/utils/log.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@ -72,11 +73,15 @@ class ReorderDragTarget<T extends DragTargetData> extends StatefulWidget {
|
||||
final ReorderFlexDraggableTargetBuilder? draggableTargetBuilder;
|
||||
|
||||
final AnimationController insertAnimationController;
|
||||
|
||||
final AnimationController deleteAnimationController;
|
||||
|
||||
final bool useMoveAnimation;
|
||||
|
||||
final bool draggable;
|
||||
|
||||
final double draggingOpacity;
|
||||
|
||||
const ReorderDragTarget({
|
||||
Key? key,
|
||||
required this.child,
|
||||
@ -93,6 +98,7 @@ class ReorderDragTarget<T extends DragTargetData> extends StatefulWidget {
|
||||
this.onAccept,
|
||||
this.onLeave,
|
||||
this.draggableTargetBuilder,
|
||||
this.draggingOpacity = 0.3,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
@ -163,6 +169,7 @@ class _ReorderDragTargetState<T extends DragTargetData>
|
||||
feedback: feedbackBuilder,
|
||||
childWhenDragging: IgnorePointerWidget(
|
||||
useIntrinsicSize: !widget.useMoveAnimation,
|
||||
opacity: widget.draggingOpacity,
|
||||
child: widget.child,
|
||||
),
|
||||
onDragStarted: () {
|
||||
@ -184,8 +191,9 @@ class _ReorderDragTargetState<T extends DragTargetData>
|
||||
/// When the drag does not end inside a DragTarget widget, the
|
||||
/// drag fails, but we still reorder the widget to the last position it
|
||||
/// had been dragged to.
|
||||
onDraggableCanceled: (Velocity velocity, Offset offset) =>
|
||||
widget.onDragEnded(widget.dragTargetData),
|
||||
onDraggableCanceled: (Velocity velocity, Offset offset) {
|
||||
widget.onDragEnded(widget.dragTargetData);
|
||||
},
|
||||
child: widget.child,
|
||||
);
|
||||
|
||||
@ -193,7 +201,10 @@ class _ReorderDragTargetState<T extends DragTargetData>
|
||||
}
|
||||
|
||||
Widget _buildDraggableFeedback(
|
||||
BuildContext context, BoxConstraints constraints, Widget child) {
|
||||
BuildContext context,
|
||||
BoxConstraints constraints,
|
||||
Widget child,
|
||||
) {
|
||||
return Transform(
|
||||
transform: Matrix4.rotationZ(0),
|
||||
alignment: FractionalOffset.topLeft,
|
||||
@ -203,7 +214,7 @@ class _ReorderDragTargetState<T extends DragTargetData>
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: ConstrainedBox(
|
||||
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.
|
||||
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;
|
||||
|
||||
// Used to remove the phantom
|
||||
late AnimationController deleteController;
|
||||
|
||||
DragTargetAnimation({
|
||||
@ -238,7 +252,7 @@ class DragTargetAnimation {
|
||||
value: 0, vsync: vsync, duration: reorderAnimationDuration);
|
||||
|
||||
insertController = AnimationController(
|
||||
value: 0.0, vsync: vsync, duration: const Duration(milliseconds: 200));
|
||||
value: 0.0, vsync: vsync, duration: const Duration(milliseconds: 100));
|
||||
|
||||
deleteController = AnimationController(
|
||||
value: 0.0, vsync: vsync, duration: const Duration(milliseconds: 1));
|
||||
@ -269,8 +283,11 @@ class DragTargetAnimation {
|
||||
class IgnorePointerWidget extends StatelessWidget {
|
||||
final Widget? child;
|
||||
final bool useIntrinsicSize;
|
||||
final double opacity;
|
||||
|
||||
const IgnorePointerWidget({
|
||||
required this.child,
|
||||
required this.opacity,
|
||||
this.useIntrinsicSize = false,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
@ -281,11 +298,10 @@ class IgnorePointerWidget extends StatelessWidget {
|
||||
? child
|
||||
: SizedBox(width: 0.0, height: 0.0, child: child);
|
||||
|
||||
final opacity = useIntrinsicSize ? 0.3 : 0.0;
|
||||
return IgnorePointer(
|
||||
ignoring: true,
|
||||
child: Opacity(
|
||||
opacity: opacity,
|
||||
opacity: useIntrinsicSize ? opacity : 0.0,
|
||||
child: sizedChild,
|
||||
),
|
||||
);
|
||||
@ -295,8 +311,10 @@ class IgnorePointerWidget extends StatelessWidget {
|
||||
class AbsorbPointerWidget extends StatelessWidget {
|
||||
final Widget? child;
|
||||
final bool useIntrinsicSize;
|
||||
final double opacity;
|
||||
const AbsorbPointerWidget({
|
||||
required this.child,
|
||||
required this.opacity,
|
||||
this.useIntrinsicSize = false,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
@ -307,10 +325,9 @@ class AbsorbPointerWidget extends StatelessWidget {
|
||||
? child
|
||||
: SizedBox(width: 0.0, height: 0.0, child: child);
|
||||
|
||||
final opacity = useIntrinsicSize ? 0.3 : 0.0;
|
||||
return AbsorbPointer(
|
||||
child: Opacity(
|
||||
opacity: opacity,
|
||||
opacity: useIntrinsicSize ? opacity : 0.0,
|
||||
child: sizedChild,
|
||||
),
|
||||
);
|
||||
@ -423,7 +440,6 @@ abstract class FakeDragTargetEventData {
|
||||
}
|
||||
|
||||
class FakeDragTarget<T extends DragTargetData> extends StatefulWidget {
|
||||
final Duration animationDuration;
|
||||
final FakeDragTargetEventTrigger eventTrigger;
|
||||
final FakeDragTargetEventData eventData;
|
||||
final DragTargetOnStarted onDragStarted;
|
||||
@ -442,7 +458,6 @@ class FakeDragTarget<T extends DragTargetData> extends StatefulWidget {
|
||||
required this.insertAnimationController,
|
||||
required this.deleteAnimationController,
|
||||
required this.child,
|
||||
this.animationDuration = const Duration(milliseconds: 250),
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
@ -468,6 +483,7 @@ class _FakeDragTargetState<T extends DragTargetData>
|
||||
// });
|
||||
|
||||
widget.eventTrigger.fakeOnDragEnded(() {
|
||||
Log.trace("[$FakeDragTarget] on drag end");
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
widget.onDragEnded(widget.eventData.dragTargetData as T);
|
||||
});
|
||||
@ -476,6 +492,13 @@ class _FakeDragTargetState<T extends DragTargetData>
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.insertAnimationController
|
||||
.removeStatusListener(_onInsertedAnimationStatusChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (simulateDragging) {
|
||||
@ -483,6 +506,7 @@ class _FakeDragTargetState<T extends DragTargetData>
|
||||
sizeFactor: widget.deleteAnimationController,
|
||||
axis: Axis.vertical,
|
||||
child: AbsorbPointerWidget(
|
||||
opacity: 0.3,
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
@ -492,6 +516,7 @@ class _FakeDragTargetState<T extends DragTargetData>
|
||||
axis: Axis.vertical,
|
||||
child: AbsorbPointerWidget(
|
||||
useIntrinsicSize: true,
|
||||
opacity: 0.3,
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
@ -503,14 +528,18 @@ class _FakeDragTargetState<T extends DragTargetData>
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
simulateDragging = true;
|
||||
widget.deleteAnimationController.reverse(from: 1.0);
|
||||
widget.onWillAccept(widget.eventData.dragTargetData as T);
|
||||
widget.onDragStarted(
|
||||
widget.child,
|
||||
widget.eventData.index,
|
||||
widget.eventData.feedbackSize,
|
||||
);
|
||||
if (widget.onWillAccept(widget.eventData.dragTargetData as T)) {
|
||||
Log.trace("[$FakeDragTarget] on drag start");
|
||||
simulateDragging = true;
|
||||
widget.deleteAnimationController.reverse(from: 1.0);
|
||||
widget.onDragStarted(
|
||||
widget.child,
|
||||
widget.eventData.index,
|
||||
widget.eventData.feedbackSize,
|
||||
);
|
||||
} else {
|
||||
Log.trace("[$FakeDragTarget] cancel start drag");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -35,7 +35,7 @@ abstract class DragTargetInterceptor {
|
||||
|
||||
abstract class OverlapDragTargetDelegate {
|
||||
void cancel();
|
||||
void moveTo(
|
||||
void dragTargetDidMoveToReorderFlex(
|
||||
String reorderFlexId,
|
||||
FlexDragTargetData dragTargetData,
|
||||
int dragTargetIndex,
|
||||
@ -81,7 +81,7 @@ class OverlappingDragTargetInterceptor extends DragTargetInterceptor {
|
||||
delegate.cancel();
|
||||
} else {
|
||||
// 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) {
|
||||
final keys = columnKeys.values.toList();
|
||||
if (dragTargetData.isOverlapWithWidgets(keys)) {
|
||||
@ -99,10 +99,10 @@ class OverlappingDragTargetInterceptor extends DragTargetInterceptor {
|
||||
if (index != -1) {
|
||||
Log.trace(
|
||||
'[$OverlappingDragTargetInterceptor] move to $dragTargetId at $index');
|
||||
delegate.moveTo(dragTargetId, dragTargetData, index);
|
||||
delegate.dragTargetDidMoveToReorderFlex(
|
||||
dragTargetId, dragTargetData, index);
|
||||
|
||||
columnsState
|
||||
.getReorderFlexState(groupId: dragTargetId)
|
||||
columnsState.reorderFlexActionMap[dragTargetId]
|
||||
?.resetDragTargetIndex(index);
|
||||
}
|
||||
});
|
||||
@ -153,7 +153,7 @@ class CrossReorderFlexDragTargetInterceptor extends DragTargetInterceptor {
|
||||
/// it means the dragTarget is dragging on the top of its own list.
|
||||
/// Otherwise, it means the dargTarget was moved to another list.
|
||||
Log.trace(
|
||||
"[$CrossReorderFlexDragTargetInterceptor] $reorderFlexId accept ${dragTargetData.reorderFlexId} ${reorderFlexId != dragTargetData.reorderFlexId}");
|
||||
"[$CrossReorderFlexDragTargetInterceptor] $reorderFlexId should accept ${dragTargetData.reorderFlexId} : ${reorderFlexId != dragTargetData.reorderFlexId}");
|
||||
return reorderFlexId != dragTargetData.reorderFlexId;
|
||||
} else {
|
||||
Log.trace(
|
||||
|
@ -31,27 +31,43 @@ abstract class ReoderFlexItem {
|
||||
String get id;
|
||||
}
|
||||
|
||||
abstract class ReorderDragTargetIndexKeyStorage {
|
||||
void addKey(String reorderFlexId, String key, GlobalObjectKey value);
|
||||
GlobalObjectKey? readKey(String reorderFlexId, String key);
|
||||
/// Cache each dragTarget's key.
|
||||
/// For the moment, the key is used to locate the render object that will
|
||||
/// 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 {
|
||||
/// The opacity of the dragging widget
|
||||
final double draggingWidgetOpacity = 0.3;
|
||||
final double draggingWidgetOpacity = 0.4;
|
||||
|
||||
// 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
|
||||
final Duration scrollAnimationDuration = const Duration(milliseconds: 300);
|
||||
|
||||
/// 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 Duration scrollAnimationDuration = const Duration(milliseconds: 200);
|
||||
|
||||
final bool useMoveAnimation;
|
||||
|
||||
@ -59,7 +75,6 @@ class ReorderFlexConfig {
|
||||
|
||||
const ReorderFlexConfig({
|
||||
this.useMoveAnimation = true,
|
||||
this.setStateWhenEndDrag = true,
|
||||
}) : useMovePlaceholder = !useMoveAnimation;
|
||||
}
|
||||
|
||||
@ -86,9 +101,12 @@ class ReorderFlex extends StatefulWidget {
|
||||
|
||||
final DragTargetInterceptor? interceptor;
|
||||
|
||||
/// Save the [DraggingState] if the current [ReorderFlex] get reinitialize.
|
||||
final DraggingStateStorage? dragStateStorage;
|
||||
|
||||
final ReorderDragTargetIndexKeyStorage? dragTargetIndexKeyStorage;
|
||||
final ReorderDragTargeKeys? dragTargetKeys;
|
||||
|
||||
final ReorderFlexAction? reorderFlexAction;
|
||||
|
||||
final bool reorderable;
|
||||
|
||||
@ -101,10 +119,11 @@ class ReorderFlex extends StatefulWidget {
|
||||
required this.onReorder,
|
||||
this.reorderable = true,
|
||||
this.dragStateStorage,
|
||||
this.dragTargetIndexKeyStorage,
|
||||
this.dragTargetKeys,
|
||||
this.onDragStarted,
|
||||
this.onDragEnded,
|
||||
this.interceptor,
|
||||
this.reorderFlexAction,
|
||||
this.direction = Axis.vertical,
|
||||
}) : assert(children.every((Widget w) => w.key != null),
|
||||
'All child must have a key.'),
|
||||
@ -117,7 +136,7 @@ class ReorderFlex extends StatefulWidget {
|
||||
}
|
||||
|
||||
class ReorderFlexState extends State<ReorderFlex>
|
||||
with ReorderFlexMinxi, TickerProviderStateMixin<ReorderFlex> {
|
||||
with ReorderFlexMixin, TickerProviderStateMixin<ReorderFlex> {
|
||||
/// Controls scrolls and measures scroll progress.
|
||||
late ScrollController _scrollController;
|
||||
|
||||
@ -139,22 +158,31 @@ class ReorderFlexState extends State<ReorderFlex>
|
||||
void initState() {
|
||||
_notifier = ReorderFlexNotifier();
|
||||
final flexId = widget.reorderFlexId;
|
||||
dragState = widget.dragStateStorage?.read(flexId) ??
|
||||
dragState = widget.dragStateStorage?.readState(flexId) ??
|
||||
DraggingState(widget.reorderFlexId);
|
||||
Log.trace('[DragTarget] init dragState: $dragState');
|
||||
|
||||
widget.dragStateStorage?.remove(flexId);
|
||||
widget.dragStateStorage?.removeState(flexId);
|
||||
|
||||
_animation = DragTargetAnimation(
|
||||
reorderAnimationDuration: widget.config.reorderAnimationDuration,
|
||||
entranceAnimateStatusChanged: (status) {
|
||||
if (status == AnimationStatus.completed) {
|
||||
if (dragState.nextIndex == -1) return;
|
||||
setState(() => _requestAnimationToNextIndex());
|
||||
}
|
||||
},
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
widget.reorderFlexAction?._scrollToBottom = (fn) {
|
||||
scrollToBottom(fn);
|
||||
};
|
||||
|
||||
widget.reorderFlexAction?._resetDragTargetIndex = (index) {
|
||||
resetDragTargetIndex(index);
|
||||
};
|
||||
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@ -191,7 +219,7 @@ class ReorderFlexState extends State<ReorderFlex>
|
||||
|
||||
final indexKey = GlobalObjectKey(child.key!);
|
||||
// Save the index key for quick access
|
||||
widget.dragTargetIndexKeyStorage?.addKey(
|
||||
widget.dragTargetKeys?.insertDragTarget(
|
||||
widget.reorderFlexId,
|
||||
item.id,
|
||||
indexKey,
|
||||
@ -243,8 +271,12 @@ class ReorderFlexState extends State<ReorderFlex>
|
||||
/// [childIndex]: the index of the child in a list
|
||||
Widget _wrap(Widget child, int childIndex, GlobalObjectKey indexKey) {
|
||||
return Builder(builder: (context) {
|
||||
final ReorderDragTarget dragTarget =
|
||||
_buildDragTarget(context, child, childIndex, indexKey);
|
||||
final ReorderDragTarget dragTarget = _buildDragTarget(
|
||||
context,
|
||||
child,
|
||||
childIndex,
|
||||
indexKey,
|
||||
);
|
||||
int shiftedIndex = childIndex;
|
||||
|
||||
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(
|
||||
BuildContext builderContext,
|
||||
Widget child,
|
||||
@ -371,18 +412,24 @@ class ReorderFlexState extends State<ReorderFlex>
|
||||
"[DragTarget] Group:[${widget.dataSource.identifier}] start dragging item at $draggingIndex");
|
||||
_startDragging(draggingWidget, draggingIndex, size);
|
||||
widget.onDragStarted?.call(draggingIndex);
|
||||
widget.dragStateStorage?.remove(widget.reorderFlexId);
|
||||
widget.dragStateStorage?.removeState(widget.reorderFlexId);
|
||||
},
|
||||
onDragMoved: (dragTargetData, offset) {
|
||||
dragTargetData.dragTargetOffset = offset;
|
||||
},
|
||||
onDragEnded: (dragTargetData) {
|
||||
if (!mounted) return;
|
||||
if (!mounted) {
|
||||
Log.warn(
|
||||
"[DragTarget]: Group:[${widget.dataSource.identifier}] end dragging but current widget was unmounted");
|
||||
return;
|
||||
}
|
||||
Log.debug(
|
||||
"[DragTarget]: Group:[${widget.dataSource.identifier}] end dragging");
|
||||
_notifier.updateDragTargetIndex(-1);
|
||||
|
||||
onDragEnded() {
|
||||
_notifier.updateDragTargetIndex(-1);
|
||||
_animation.insertController.stop();
|
||||
|
||||
setState(() {
|
||||
if (dragTargetData.reorderFlexId == widget.reorderFlexId) {
|
||||
_onReordered(
|
||||
dragState.dragStartIndex,
|
||||
@ -391,13 +438,7 @@ class ReorderFlexState extends State<ReorderFlex>
|
||||
}
|
||||
dragState.endDragging();
|
||||
widget.onDragEnded?.call();
|
||||
}
|
||||
|
||||
if (widget.config.setStateWhenEndDrag) {
|
||||
setState(() => onDragEnded());
|
||||
} else {
|
||||
onDragEnded();
|
||||
}
|
||||
});
|
||||
},
|
||||
onWillAccept: (FlexDragTargetData dragTargetData) {
|
||||
// Do not receive any events if the Insert item is animating.
|
||||
@ -405,19 +446,23 @@ class ReorderFlexState extends State<ReorderFlex>
|
||||
return false;
|
||||
}
|
||||
|
||||
assert(widget.dataSource.items.length > dragTargetIndex);
|
||||
if (_interceptDragTarget(dragTargetData, (interceptor) {
|
||||
interceptor.onWillAccept(
|
||||
context: builderContext,
|
||||
reorderFlexState: this,
|
||||
dragTargetData: dragTargetData,
|
||||
dragTargetId: reorderFlexItem.id,
|
||||
dragTargetIndex: dragTargetIndex,
|
||||
);
|
||||
})) {
|
||||
return true;
|
||||
if (dragTargetData.isDragging) {
|
||||
assert(widget.dataSource.items.length > dragTargetIndex);
|
||||
if (_interceptDragTarget(dragTargetData, (interceptor) {
|
||||
interceptor.onWillAccept(
|
||||
context: builderContext,
|
||||
reorderFlexState: this,
|
||||
dragTargetData: dragTargetData,
|
||||
dragTargetId: reorderFlexItem.id,
|
||||
dragTargetIndex: dragTargetIndex,
|
||||
);
|
||||
})) {
|
||||
return true;
|
||||
} else {
|
||||
return handleOnWillAccept(builderContext, dragTargetIndex);
|
||||
}
|
||||
} else {
|
||||
return handleOnWillAccept(builderContext, dragTargetIndex);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
onAccept: (dragTargetData) {
|
||||
@ -438,6 +483,7 @@ class ReorderFlexState extends State<ReorderFlex>
|
||||
draggableTargetBuilder: widget.interceptor?.draggableTargetBuilder,
|
||||
useMoveAnimation: widget.config.useMoveAnimation,
|
||||
draggable: widget.reorderable,
|
||||
draggingOpacity: widget.config.draggingWidgetOpacity,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
@ -485,8 +531,12 @@ class ReorderFlexState extends State<ReorderFlex>
|
||||
}
|
||||
|
||||
void resetDragTargetIndex(int dragTargetIndex) {
|
||||
if (dragTargetIndex > widget.dataSource.items.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
dragState.setStartDraggingIndex(dragTargetIndex);
|
||||
widget.dragStateStorage?.write(
|
||||
widget.dragStateStorage?.insertState(
|
||||
widget.reorderFlexId,
|
||||
dragState,
|
||||
);
|
||||
@ -521,6 +571,9 @@ class ReorderFlexState extends State<ReorderFlex>
|
||||
}
|
||||
|
||||
void _onReordered(int fromIndex, int toIndex) {
|
||||
if (toIndex == -1) return;
|
||||
if (fromIndex == -1) return;
|
||||
|
||||
if (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) {
|
||||
completed?.call();
|
||||
completed?.call(context);
|
||||
return;
|
||||
}
|
||||
|
||||
if (widget.dataSource.items.isNotEmpty) {
|
||||
final item = widget.dataSource.items.last;
|
||||
final indexKey = widget.dragTargetIndexKeyStorage?.readKey(
|
||||
final dragTargetKey = widget.dragTargetKeys?.getDragTarget(
|
||||
widget.reorderFlexId,
|
||||
item.id,
|
||||
);
|
||||
if (indexKey == null) {
|
||||
completed?.call();
|
||||
if (dragTargetKey == null) {
|
||||
completed?.call(context);
|
||||
return;
|
||||
}
|
||||
|
||||
final indexContext = indexKey.currentContext;
|
||||
if (indexContext == null || _scrollController.hasClients == false) {
|
||||
completed?.call();
|
||||
final dragTargetContext = dragTargetKey.currentContext;
|
||||
if (dragTargetContext == null || _scrollController.hasClients == false) {
|
||||
completed?.call(context);
|
||||
return;
|
||||
}
|
||||
|
||||
final renderObject = indexContext.findRenderObject();
|
||||
if (renderObject != null) {
|
||||
final dragTargetRenderObject = dragTargetContext.findRenderObject();
|
||||
if (dragTargetRenderObject != null) {
|
||||
_scrolling = true;
|
||||
_scrollController.position
|
||||
.ensureVisible(
|
||||
renderObject,
|
||||
dragTargetRenderObject,
|
||||
alignment: 0.5,
|
||||
duration: const Duration(milliseconds: 120),
|
||||
)
|
||||
.then((value) {
|
||||
setState(() {
|
||||
_scrolling = false;
|
||||
completed?.call();
|
||||
completed?.call(context);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
completed?.call();
|
||||
completed?.call(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3,7 +3,7 @@ import 'package:flutter/widgets.dart';
|
||||
import '../transitions.dart';
|
||||
import 'drag_target.dart';
|
||||
|
||||
mixin ReorderFlexMinxi {
|
||||
mixin ReorderFlexMixin {
|
||||
@protected
|
||||
Widget makeAppearingWidget(
|
||||
Widget child,
|
||||
|
@ -94,7 +94,8 @@ class BoardPhantomController extends OverlapDragTargetDelegate
|
||||
|
||||
/// Remove the phantom in the group if it contains phantom
|
||||
void _removePhantom(String groupId) {
|
||||
if (delegate.removePhantom(groupId)) {
|
||||
final didRemove = delegate.removePhantom(groupId);
|
||||
if (didRemove) {
|
||||
phantomState.notifyDidRemovePhantom(groupId);
|
||||
phantomState.removeGroupListener(groupId);
|
||||
}
|
||||
@ -195,7 +196,7 @@ class BoardPhantomController extends OverlapDragTargetDelegate
|
||||
}
|
||||
|
||||
@override
|
||||
void moveTo(
|
||||
void dragTargetDidMoveToReorderFlex(
|
||||
String reorderFlexId,
|
||||
FlexDragTargetData dragTargetData,
|
||||
int dragTargetIndex,
|
||||
|
@ -1,50 +1,54 @@
|
||||
import 'package:appflowy_board/src/utils/log.dart';
|
||||
|
||||
import 'phantom_controller.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class GroupPhantomState {
|
||||
final _states = <String, GroupState>{};
|
||||
final _groupStates = <String, GroupState>{};
|
||||
final _groupIsDragging = <String, bool>{};
|
||||
|
||||
void setGroupIsDragging(String groupId, bool isDragging) {
|
||||
_stateWithId(groupId).isDragging = isDragging;
|
||||
_groupIsDragging[groupId] = isDragging;
|
||||
}
|
||||
|
||||
bool isDragging(String groupId) {
|
||||
return _stateWithId(groupId).isDragging;
|
||||
return _groupIsDragging[groupId] ?? false;
|
||||
}
|
||||
|
||||
void addGroupListener(String groupId, PassthroughPhantomListener listener) {
|
||||
_stateWithId(groupId).notifier.addListener(
|
||||
onInserted: (index) => listener.onInserted?.call(index),
|
||||
onDeleted: () => listener.onDragEnded?.call(),
|
||||
);
|
||||
if (_groupStates[groupId] == null) {
|
||||
Log.debug("[$GroupPhantomState] add group listener: $groupId");
|
||||
_groupStates[groupId] = GroupState();
|
||||
_groupStates[groupId]?.notifier.addListener(
|
||||
onInserted: (index) => listener.onInserted?.call(index),
|
||||
onDeleted: () => listener.onDragEnded?.call(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void removeGroupListener(String groupId) {
|
||||
_stateWithId(groupId).notifier.dispose();
|
||||
_states.remove(groupId);
|
||||
Log.debug("[$GroupPhantomState] remove group listener: $groupId");
|
||||
final groupState = _groupStates.remove(groupId);
|
||||
groupState?.dispose();
|
||||
}
|
||||
|
||||
void notifyDidInsertPhantom(String groupId, int index) {
|
||||
_stateWithId(groupId).notifier.insert(index);
|
||||
_groupStates[groupId]?.notifier.insert(index);
|
||||
}
|
||||
|
||||
void notifyDidRemovePhantom(String groupId) {
|
||||
_stateWithId(groupId).notifier.remove();
|
||||
}
|
||||
|
||||
GroupState _stateWithId(String groupId) {
|
||||
var state = _states[groupId];
|
||||
if (state == null) {
|
||||
state = GroupState();
|
||||
_states[groupId] = state;
|
||||
}
|
||||
return state;
|
||||
Log.debug("[$GroupPhantomState] $groupId remove phantom");
|
||||
_groupStates[groupId]?.notifier.remove();
|
||||
}
|
||||
}
|
||||
|
||||
class GroupState {
|
||||
bool isDragging = false;
|
||||
final notifier = PassthroughPhantomNotifier();
|
||||
|
||||
void dispose() {
|
||||
notifier.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
abstract class PassthroughPhantomListener {
|
||||
|
@ -1,6 +1,6 @@
|
||||
name: appflowy_board
|
||||
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
|
||||
repository: https://github.com/AppFlowy-IO/AppFlowy/tree/main/frontend/app_flowy/packages/appflowy_board
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:example/plugin/code_block_node_widget.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@ -116,9 +117,17 @@ class _MyHomePageState extends State<MyHomePage> {
|
||||
editorState: _editorState!,
|
||||
editorStyle: _editorStyle,
|
||||
editable: true,
|
||||
customBuilders: {
|
||||
'text/code_block': CodeBlockNodeWidgetBuilder(),
|
||||
},
|
||||
shortcutEvents: [
|
||||
enterInCodeBlock,
|
||||
ignoreKeysInCodeBlock,
|
||||
underscoreToItalic,
|
||||
],
|
||||
selectionMenuItems: [
|
||||
codeBlockItem,
|
||||
],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
|
@ -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),
|
||||
};
|
@ -43,6 +43,7 @@ dependencies:
|
||||
sdk: flutter
|
||||
file_picker: ^5.0.1
|
||||
universal_html: ^2.0.8
|
||||
highlight: ^0.7.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
@ -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_handler.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';
|
||||
|
@ -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": {}
|
||||
}
|
@ -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;
|
||||
}
|
@ -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,
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
@ -93,12 +93,14 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
|
||||
}
|
||||
|
||||
void updateAttributes(Attributes attributes) {
|
||||
bool shouldNotifyParent = _attributes['subtype'] != attributes['subtype'];
|
||||
|
||||
final oldAttributes = {..._attributes};
|
||||
_attributes = composeAttributes(_attributes, attributes) ?? {};
|
||||
|
||||
// Notifies the new attributes
|
||||
// if attributes contains 'subtype', should notify parent to rebuild node
|
||||
// else, just notify current node.
|
||||
bool shouldNotifyParent =
|
||||
_attributes['subtype'] != oldAttributes['subtype'];
|
||||
shouldNotifyParent ? parent?.notifyListeners() : notifyListeners();
|
||||
}
|
||||
|
||||
|
@ -53,6 +53,10 @@ class Selection {
|
||||
|
||||
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}) {
|
||||
if (atStart) {
|
||||
return Selection(start: start, end: start);
|
||||
|
@ -72,6 +72,8 @@ class EditorState {
|
||||
// TODO: only for testing.
|
||||
bool disableSealTimer = false;
|
||||
|
||||
bool editable = true;
|
||||
|
||||
Selection? get cursorSelection {
|
||||
return _cursorSelection;
|
||||
}
|
||||
@ -112,6 +114,9 @@ class EditorState {
|
||||
/// should record the transaction in undo/redo stack.
|
||||
apply(Transaction transaction,
|
||||
[ApplyOptions options = const ApplyOptions()]) {
|
||||
if (!editable) {
|
||||
return;
|
||||
}
|
||||
// TODO: validate the transation.
|
||||
for (final op in transaction.operations) {
|
||||
_applyOperation(op);
|
||||
|
@ -31,6 +31,7 @@ class _LinkMenuState extends State<LinkMenu> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_textEditingController.text = widget.linkText ?? '';
|
||||
_focusNode.requestFocus();
|
||||
_focusNode.addListener(_onFocusChange);
|
||||
}
|
||||
|
||||
|
@ -1,15 +1,8 @@
|
||||
import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
|
||||
import 'package:appflowy_editor/src/document/node.dart';
|
||||
import 'package:appflowy_editor/src/editor_state.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_editor/src/commands/format_built_in_text.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/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:flutter/material.dart';
|
||||
|
||||
@ -81,8 +74,12 @@ class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
|
||||
padding: iconPadding,
|
||||
name: check ? 'check' : 'uncheck',
|
||||
),
|
||||
onTap: () {
|
||||
formatCheckbox(widget.editorState, !check);
|
||||
onTap: () async {
|
||||
await formatTextToCheckbox(
|
||||
widget.editorState,
|
||||
!check,
|
||||
textNode: widget.textNode,
|
||||
);
|
||||
},
|
||||
),
|
||||
Flexible(
|
||||
|
@ -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/toolbar/toolbar_item.dart';
|
||||
|
||||
const _kRichTextDebugMode = false;
|
||||
|
||||
typedef FlowyTextSpanDecorator = TextSpan Function(TextSpan textSpan);
|
||||
|
||||
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(
|
||||
children: textSpans,
|
||||
);
|
||||
|
@ -1,4 +1,5 @@
|
||||
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/infra/flowy_svg.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:rich_clipboard/rich_clipboard.dart';
|
||||
import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
|
||||
|
||||
typedef ToolbarItemEventHandler = void Function(
|
||||
EditorState editorState, BuildContext context);
|
||||
@ -120,7 +120,7 @@ List<ToolbarItem> defaultToolbarItems = [
|
||||
name: 'toolbar/bold',
|
||||
color: isHighlight ? Colors.lightBlue : null,
|
||||
),
|
||||
validator: _showInTextSelection,
|
||||
validator: _showInBuiltInTextSelection,
|
||||
highlightCallback: (editorState) => _allSatisfy(
|
||||
editorState,
|
||||
BuiltInAttributeKey.bold,
|
||||
@ -136,7 +136,7 @@ List<ToolbarItem> defaultToolbarItems = [
|
||||
name: 'toolbar/italic',
|
||||
color: isHighlight ? Colors.lightBlue : null,
|
||||
),
|
||||
validator: _showInTextSelection,
|
||||
validator: _showInBuiltInTextSelection,
|
||||
highlightCallback: (editorState) => _allSatisfy(
|
||||
editorState,
|
||||
BuiltInAttributeKey.italic,
|
||||
@ -152,7 +152,7 @@ List<ToolbarItem> defaultToolbarItems = [
|
||||
name: 'toolbar/underline',
|
||||
color: isHighlight ? Colors.lightBlue : null,
|
||||
),
|
||||
validator: _showInTextSelection,
|
||||
validator: _showInBuiltInTextSelection,
|
||||
highlightCallback: (editorState) => _allSatisfy(
|
||||
editorState,
|
||||
BuiltInAttributeKey.underline,
|
||||
@ -168,7 +168,7 @@ List<ToolbarItem> defaultToolbarItems = [
|
||||
name: 'toolbar/strikethrough',
|
||||
color: isHighlight ? Colors.lightBlue : null,
|
||||
),
|
||||
validator: _showInTextSelection,
|
||||
validator: _showInBuiltInTextSelection,
|
||||
highlightCallback: (editorState) => _allSatisfy(
|
||||
editorState,
|
||||
BuiltInAttributeKey.strikethrough,
|
||||
@ -184,7 +184,7 @@ List<ToolbarItem> defaultToolbarItems = [
|
||||
name: 'toolbar/code',
|
||||
color: isHighlight ? Colors.lightBlue : null,
|
||||
),
|
||||
validator: _showInTextSelection,
|
||||
validator: _showInBuiltInTextSelection,
|
||||
highlightCallback: (editorState) => _allSatisfy(
|
||||
editorState,
|
||||
BuiltInAttributeKey.code,
|
||||
@ -248,7 +248,7 @@ List<ToolbarItem> defaultToolbarItems = [
|
||||
name: 'toolbar/highlight',
|
||||
color: isHighlight ? Colors.lightBlue : null,
|
||||
),
|
||||
validator: _showInTextSelection,
|
||||
validator: _showInBuiltInTextSelection,
|
||||
highlightCallback: (editorState) => _allSatisfy(
|
||||
editorState,
|
||||
BuiltInAttributeKey.backgroundColor,
|
||||
@ -262,13 +262,22 @@ List<ToolbarItem> defaultToolbarItems = [
|
||||
];
|
||||
|
||||
ToolbarItemValidator _onlyShowInSingleTextSelection = (editorState) {
|
||||
final result = _showInBuiltInTextSelection(editorState);
|
||||
if (!result) {
|
||||
return false;
|
||||
}
|
||||
final nodes = editorState.service.selectionService.currentSelectedNodes;
|
||||
return (nodes.length == 1 && nodes.first is TextNode);
|
||||
};
|
||||
|
||||
ToolbarItemValidator _showInTextSelection = (editorState) {
|
||||
ToolbarItemValidator _showInBuiltInTextSelection = (editorState) {
|
||||
final nodes = editorState.service.selectionService.currentSelectedNodes
|
||||
.whereType<TextNode>();
|
||||
.whereType<TextNode>()
|
||||
.where(
|
||||
(textNode) =>
|
||||
BuiltInAttributeKey.globalStyleKeys.contains(textNode.subtype) ||
|
||||
textNode.subtype == null,
|
||||
);
|
||||
return nodes.isNotEmpty;
|
||||
};
|
||||
|
||||
@ -337,11 +346,8 @@ void showLinkMenu(
|
||||
onOpenLink: () async {
|
||||
await safeLaunchUrl(linkText);
|
||||
},
|
||||
onSubmitted: (text) {
|
||||
TransactionBuilder(editorState)
|
||||
..formatText(
|
||||
textNode, index, length, {BuiltInAttributeKey.href: text})
|
||||
..commit();
|
||||
onSubmitted: (text) async {
|
||||
await formatLinkInText(editorState, text, textNode: textNode);
|
||||
_dismissLinkMenu();
|
||||
},
|
||||
onCopyLink: () {
|
||||
@ -369,6 +375,7 @@ void showLinkMenu(
|
||||
Overlay.of(context)?.insert(_linkMenuOverlay!);
|
||||
|
||||
editorState.service.scrollService?.disable();
|
||||
editorState.service.keyboardService?.disable();
|
||||
editorState.service.selectionService.currentSelection
|
||||
.addListener(_dismissLinkMenu);
|
||||
}
|
||||
|
@ -103,13 +103,17 @@ bool formatTextNodes(EditorState editorState, Attributes attributes) {
|
||||
final builder = TransactionBuilder(editorState);
|
||||
|
||||
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
|
||||
..updateNode(
|
||||
textNode,
|
||||
Attributes.fromIterable(
|
||||
BuiltInAttributeKey.globalStyleKeys,
|
||||
value: (_) => null,
|
||||
)..addAll(attributes),
|
||||
newAttributes,
|
||||
)
|
||||
..afterSelection = Selection.collapsed(
|
||||
Position(
|
||||
|
@ -72,6 +72,7 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
|
||||
editorState.selectionMenuItems = widget.selectionMenuItems;
|
||||
editorState.editorStyle = widget.editorStyle;
|
||||
editorState.service.renderPluginService = _createRenderPlugin();
|
||||
editorState.editable = widget.editable;
|
||||
}
|
||||
|
||||
@override
|
||||
@ -84,6 +85,7 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
|
||||
}
|
||||
|
||||
editorState.editorStyle = widget.editorStyle;
|
||||
editorState.editable = widget.editable;
|
||||
services = null;
|
||||
}
|
||||
|
||||
@ -118,8 +120,8 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
|
||||
key: editorState.service.keyboardServiceKey,
|
||||
editable: widget.editable,
|
||||
shortcutEvents: [
|
||||
...builtInShortcutEvents,
|
||||
...widget.shortcutEvents,
|
||||
...builtInShortcutEvents,
|
||||
],
|
||||
editorState: editorState,
|
||||
child: FlowyToolbar(
|
||||
|
@ -297,7 +297,11 @@ class _AppFlowyInputState extends State<AppFlowyInput>
|
||||
_updateCaretPosition(textNodes.first, selection);
|
||||
}
|
||||
} else {
|
||||
// close();
|
||||
// https://github.com/flutter/flutter/issues/104944
|
||||
// Disable IME for the Web.
|
||||
if (kIsWeb) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -117,12 +117,17 @@ ShortcutEventHandler enterWithoutShiftInTextNodesHandler =
|
||||
makeFollowingNodesIncremental(editorState, insertPath, afterSelection,
|
||||
beginNum: prevNumber);
|
||||
} else {
|
||||
bool needCopyAttributes = ![
|
||||
BuiltInAttributeKey.heading,
|
||||
BuiltInAttributeKey.quote,
|
||||
].contains(subtype);
|
||||
TransactionBuilder(editorState)
|
||||
..insertNode(
|
||||
textNode.path,
|
||||
textNode.copyWith(
|
||||
children: LinkedList(),
|
||||
delta: Delta(),
|
||||
attributes: needCopyAttributes ? null : {},
|
||||
),
|
||||
)
|
||||
..afterSelection = afterSelection
|
||||
@ -173,7 +178,9 @@ ShortcutEventHandler enterWithoutShiftInTextNodesHandler =
|
||||
Attributes _attributesFromPreviousLine(TextNode textNode) {
|
||||
final prevAttributes = textNode.attributes;
|
||||
final subType = textNode.subtype;
|
||||
if (subType == null || subType == BuiltInAttributeKey.heading) {
|
||||
if (subType == null ||
|
||||
subType == BuiltInAttributeKey.heading ||
|
||||
subType == BuiltInAttributeKey.quote) {
|
||||
return {};
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
};
|
@ -13,12 +13,19 @@ ShortcutEventHandler tabHandler = (editorState, event) {
|
||||
|
||||
final textNode = textNodes.first;
|
||||
final previous = textNode.previous;
|
||||
if (textNode.subtype != BuiltInAttributeKey.bulletedList ||
|
||||
previous == null ||
|
||||
previous.subtype != BuiltInAttributeKey.bulletedList) {
|
||||
|
||||
if (textNode.subtype != BuiltInAttributeKey.bulletedList) {
|
||||
TransactionBuilder(editorState)
|
||||
..insertText(textNode, selection.end.offset, ' ' * 4)
|
||||
..commit();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
if (previous == null ||
|
||||
previous.subtype != BuiltInAttributeKey.bulletedList) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
final path = previous.path + [previous.children.length];
|
||||
final afterSelection = Selection(
|
||||
start: selection.start.copyWith(path: path),
|
||||
|
@ -124,6 +124,8 @@ class _AppFlowyKeyboardState extends State<AppFlowyKeyboard>
|
||||
final result = shortcutEvent.handler(widget.editorState, event);
|
||||
if (result == KeyEventResult.handled) {
|
||||
return KeyEventResult.handled;
|
||||
} else if (result == KeyEventResult.skipRemainingHandlers) {
|
||||
return KeyEventResult.skipRemainingHandlers;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
@ -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/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/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/whitespace_handler.dart';
|
||||
import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
//
|
||||
List<ShortcutEvent> builtInShortcutEvents = [
|
||||
@ -255,4 +257,14 @@ List<ShortcutEvent> builtInShortcutEvents = [
|
||||
command: 'backquote',
|
||||
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,
|
||||
),
|
||||
];
|
||||
|
@ -38,14 +38,17 @@ class _FlowyToolbarState extends State<FlowyToolbar>
|
||||
@override
|
||||
void showInOffset(Offset offset, LayerLink layerLink) {
|
||||
hide();
|
||||
|
||||
final items = _filterItems(defaultToolbarItems);
|
||||
if (items.isEmpty) {
|
||||
return;
|
||||
}
|
||||
_toolbarOverlay = OverlayEntry(
|
||||
builder: (context) => ToolbarWidget(
|
||||
key: _toolbarWidgetKey,
|
||||
editorState: widget.editorState,
|
||||
layerLink: layerLink,
|
||||
offset: offset,
|
||||
items: _filterItems(defaultToolbarItems),
|
||||
items: items,
|
||||
),
|
||||
);
|
||||
Overlay.of(context)?.insert(_toolbarOverlay!);
|
||||
@ -102,9 +105,4 @@ class _FlowyToolbarState extends State<FlowyToolbar>
|
||||
}
|
||||
return dividedItems;
|
||||
}
|
||||
|
||||
// List<ToolbarItem> _highlightItems(
|
||||
// List<ToolbarItem> items,
|
||||
// Selection selection,
|
||||
// ) {}
|
||||
}
|
||||
|
@ -2,7 +2,6 @@ import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import '../../infra/test_editor.dart';
|
||||
import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
|
||||
|
||||
void main() async {
|
||||
setUpAll(() {
|
||||
@ -171,13 +170,27 @@ Future<void> _testStyleNeedToBeCopy(WidgetTester tester, String style) async {
|
||||
LogicalKeyboardKey.enter,
|
||||
);
|
||||
expect(editor.documentSelection, Selection.single(path: [4], startOffset: 0));
|
||||
expect(editor.nodeAtPath([4])?.subtype, style);
|
||||
|
||||
await editor.pressLogicKey(
|
||||
LogicalKeyboardKey.enter,
|
||||
);
|
||||
expect(editor.documentSelection, Selection.single(path: [4], startOffset: 0));
|
||||
expect(editor.nodeAtPath([4])?.subtype, null);
|
||||
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);
|
||||
|
||||
await editor.pressLogicKey(
|
||||
LogicalKeyboardKey.enter,
|
||||
);
|
||||
expect(
|
||||
editor.documentSelection, Selection.single(path: [4], startOffset: 0));
|
||||
expect(editor.nodeAtPath([4])?.subtype, null);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _testMultipleSelection(
|
||||
|
@ -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 😁 ',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
@ -15,23 +15,24 @@ void main() async {
|
||||
..insertTextNode(text)
|
||||
..insertTextNode(text);
|
||||
await editor.startTesting();
|
||||
final document = editor.document;
|
||||
|
||||
var selection = Selection.single(path: [0], startOffset: 0);
|
||||
await editor.updateSelection(selection);
|
||||
await editor.pressLogicKey(LogicalKeyboardKey.tab);
|
||||
|
||||
// nothing happens
|
||||
expect(editor.documentSelection, selection);
|
||||
expect(editor.document.toJson(), document.toJson());
|
||||
expect(
|
||||
editor.documentSelection,
|
||||
Selection.single(path: [0], startOffset: 4),
|
||||
);
|
||||
|
||||
selection = Selection.single(path: [1], startOffset: 0);
|
||||
await editor.updateSelection(selection);
|
||||
await editor.pressLogicKey(LogicalKeyboardKey.tab);
|
||||
|
||||
// nothing happens
|
||||
expect(editor.documentSelection, selection);
|
||||
expect(editor.document.toJson(), document.toJson());
|
||||
expect(
|
||||
editor.documentSelection,
|
||||
Selection.single(path: [1], startOffset: 4),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('press tab in bulleted list', (tester) async {
|
||||
@ -63,7 +64,10 @@ void main() async {
|
||||
await editor.pressLogicKey(LogicalKeyboardKey.tab);
|
||||
|
||||
// nothing happens
|
||||
expect(editor.documentSelection, selection);
|
||||
expect(
|
||||
editor.documentSelection,
|
||||
Selection.single(path: [0], startOffset: 0),
|
||||
);
|
||||
expect(editor.document.toJson(), document.toJson());
|
||||
|
||||
// Before
|
||||
|
8
frontend/rust-lib/Cargo.lock
generated
8
frontend/rust-lib/Cargo.lock
generated
@ -1444,9 +1444,9 @@ checksum = "eabb4a44450da02c90444cf74558da904edde8fb4e9035a9a6a4e15445af0bd7"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.11.2"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e"
|
||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
@ -1610,9 +1610,9 @@ checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "1.8.1"
|
||||
version = "1.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0f647032dfaa1f8b6dc29bd3edb7bbef4861b8b8007ebb118d6db284fd59f6ee"
|
||||
checksum = "10a35a97730320ffe8e2d410b5d3b69279b98d2c14bdb8b70ea89ecf7888d41e"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"hashbrown",
|
||||
|
@ -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 {
|
||||
type Target = str;
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
pub use crate::entities::view::ViewDataTypePB;
|
||||
use crate::entities::{ViewInfoPB, ViewLayoutTypePB};
|
||||
use crate::entities::{DeletedViewPB, ViewInfoPB, ViewLayoutTypePB};
|
||||
use crate::manager::{ViewDataProcessor, ViewDataProcessorMap};
|
||||
use crate::{
|
||||
dart_notification::{send_dart_notification, FolderNotification},
|
||||
@ -122,12 +122,12 @@ impl ViewController {
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "debug", skip(self, view_id), fields(view_id = %view_id.value), err)]
|
||||
pub(crate) async fn read_view(&self, view_id: ViewIdPB) -> Result<ViewRevision, FlowyError> {
|
||||
#[tracing::instrument(level = "debug", skip(self, view_id), err)]
|
||||
pub(crate) async fn read_view(&self, view_id: &str) -> Result<ViewRevision, FlowyError> {
|
||||
let view_rev = self
|
||||
.persistence
|
||||
.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)?;
|
||||
if trash_ids.contains(&view.id) {
|
||||
return Err(FlowyError::record_not_found());
|
||||
@ -135,7 +135,6 @@ impl ViewController {
|
||||
Ok(view)
|
||||
})
|
||||
.await?;
|
||||
let _ = self.read_view_on_server(view_id);
|
||||
Ok(view_rev)
|
||||
}
|
||||
|
||||
@ -201,9 +200,26 @@ impl ViewController {
|
||||
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)
|
||||
.payload(view_id_pb)
|
||||
.payload(deleted_view)
|
||||
.send();
|
||||
|
||||
let processor = self.get_data_processor_from_view_id(&view_id).await?;
|
||||
|
@ -31,7 +31,7 @@ pub(crate) async fn read_view_handler(
|
||||
controller: AppData<Arc<ViewController>>,
|
||||
) -> DataResult<ViewPB, FlowyError> {
|
||||
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())
|
||||
}
|
||||
|
||||
|
@ -34,7 +34,7 @@ rayon = "1.5.2"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = {version = "1.0"}
|
||||
serde_repr = "0.1"
|
||||
indexmap = {version = "1.8.1", features = ["serde"]}
|
||||
indexmap = {version = "1.9.1", features = ["serde"]}
|
||||
fancy-regex = "0.10.0"
|
||||
regex = "1.5.6"
|
||||
url = { version = "2"}
|
||||
|
@ -1,3 +1,4 @@
|
||||
use crate::entities::FieldType;
|
||||
use flowy_derive::ProtoBuf;
|
||||
use flowy_error::ErrorCode;
|
||||
use flowy_grid_data_model::parser::NotEmptyStr;
|
||||
@ -74,15 +75,20 @@ pub struct GridCellPB {
|
||||
#[pb(index = 1)]
|
||||
pub field_id: String,
|
||||
|
||||
// The data was encoded in field_type's data type
|
||||
#[pb(index = 2)]
|
||||
pub data: Vec<u8>,
|
||||
|
||||
#[pb(index = 3, one_of)]
|
||||
pub field_type: Option<FieldType>,
|
||||
}
|
||||
|
||||
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 {
|
||||
field_id: field_id.to_owned(),
|
||||
data,
|
||||
field_type: Some(field_type),
|
||||
}
|
||||
}
|
||||
|
||||
@ -90,6 +96,7 @@ impl GridCellPB {
|
||||
Self {
|
||||
field_id: field_id.to_owned(),
|
||||
data: vec![],
|
||||
field_type: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -98,6 +98,7 @@ pub struct MoveGroupPayloadPB {
|
||||
pub to_group_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct MoveGroupParams {
|
||||
pub view_id: String,
|
||||
pub from_group_id: String,
|
||||
|
@ -24,14 +24,28 @@ pub trait CellDisplayable<CD> {
|
||||
decoded_field_type: &FieldType,
|
||||
field_rev: &FieldRevision,
|
||||
) -> 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.
|
||||
// CS: Short for Changeset. Parse the string into specific Changeset type.
|
||||
pub trait CellDataOperation<CD, CS> {
|
||||
/// The cell_data is able to parse into the specific data if CD impl the FromCellData trait.
|
||||
/// For example:
|
||||
/// URLCellData, DateCellData. etc.
|
||||
/// Decode the cell data into `CD` that is certain type of data.
|
||||
///
|
||||
/// 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(
|
||||
&self,
|
||||
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>(
|
||||
data: T,
|
||||
field_rev: &FieldRevision,
|
||||
) -> CellBytes {
|
||||
) -> (FieldType, CellBytes) {
|
||||
let to_field_type = field_rev.ty.into();
|
||||
match data.try_into() {
|
||||
Ok(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_rev, &field_type, &to_field_type) {
|
||||
Ok(cell_bytes) => cell_bytes,
|
||||
match try_decode_cell_data(data.into(), &field_type, &to_field_type, field_rev) {
|
||||
Ok(cell_bytes) => (field_type, cell_bytes),
|
||||
Err(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
|
||||
// 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.
|
||||
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(
|
||||
cell_data: CellData<String>,
|
||||
from_field_type: &FieldType,
|
||||
to_field_type: &FieldType,
|
||||
field_rev: &FieldRevision,
|
||||
s_field_type: &FieldType,
|
||||
t_field_type: &FieldType,
|
||||
) -> FlowyResult<CellBytes> {
|
||||
let cell_data = cell_data.try_into_inner()?;
|
||||
let get_cell_data = || {
|
||||
let field_type: FieldTypeRevision = t_field_type.into();
|
||||
let data = match t_field_type {
|
||||
let field_type: FieldTypeRevision = to_field_type.into();
|
||||
let data = match to_field_type {
|
||||
FieldType::RichText => field_rev
|
||||
.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
|
||||
.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
|
||||
.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
|
||||
.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
|
||||
.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
|
||||
.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
|
||||
.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)
|
||||
};
|
||||
@ -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> {
|
||||
fn from(val: T) -> Self {
|
||||
CellData(Some(val))
|
||||
|
@ -48,6 +48,16 @@ impl CellDisplayable<CheckboxCellData> for CheckboxTypeOptionPB {
|
||||
let cell_data = cell_data.try_into_inner()?;
|
||||
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 {
|
||||
|
@ -127,6 +127,17 @@ impl CellDisplayable<DateTimestamp> for DateTypeOptionPB {
|
||||
let date_cell_data = self.today_desc_from_timestamp(timestamp);
|
||||
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 {
|
||||
|
@ -70,6 +70,15 @@ define_currency_set!(
|
||||
symbol: "RUB",
|
||||
symbol_first: false,
|
||||
},
|
||||
PERCENT : {
|
||||
code: "",
|
||||
exponent: 2,
|
||||
locale: EnIn,
|
||||
minor_units: 1,
|
||||
name: "percent",
|
||||
symbol: "%",
|
||||
symbol_first: false,
|
||||
},
|
||||
USD : {
|
||||
code: "USD",
|
||||
exponent: 2,
|
||||
@ -435,7 +444,7 @@ impl NumberFormat {
|
||||
NumberFormat::Leu => number_currency::RON,
|
||||
NumberFormat::ArgentinePeso => number_currency::ARS,
|
||||
NumberFormat::UruguayanPeso => number_currency::UYU,
|
||||
NumberFormat::Percent => number_currency::USD,
|
||||
NumberFormat::Percent => number_currency::PERCENT,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -93,6 +93,11 @@ mod tests {
|
||||
assert_number(&type_option, "€0.5", "€0,5", &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);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
use crate::entities::FieldType;
|
||||
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::{BoxTypeOptionBuilder, NumberCellData, TypeOptionBuilder};
|
||||
use bytes::Bytes;
|
||||
@ -77,7 +77,7 @@ impl NumberTypeOptionPB {
|
||||
|
||||
pub(crate) fn format_cell_data(&self, s: &str) -> FlowyResult<NumberCellData> {
|
||||
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)),
|
||||
Err(_) => Ok(NumberCellData::new()),
|
||||
},
|
||||
@ -102,17 +102,13 @@ pub(crate) fn strip_currency_symbol<T: ToString>(s: T) -> String {
|
||||
s
|
||||
}
|
||||
|
||||
impl CellDataOperation<String, String> for NumberTypeOptionPB {
|
||||
fn decode_cell_data(
|
||||
impl CellDisplayable<String> for NumberTypeOptionPB {
|
||||
fn display_data(
|
||||
&self,
|
||||
cell_data: CellData<String>,
|
||||
decoded_field_type: &FieldType,
|
||||
_decoded_field_type: &FieldType,
|
||||
_field_rev: &FieldRevision,
|
||||
) -> FlowyResult<CellBytes> {
|
||||
if decoded_field_type.is_date() {
|
||||
return Ok(CellBytes::default());
|
||||
}
|
||||
|
||||
let cell_data: String = cell_data.try_into_inner()?;
|
||||
match self.format_cell_data(&cell_data) {
|
||||
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(
|
||||
&self,
|
||||
changeset: CellDataChangeset<String>,
|
||||
|
@ -120,6 +120,21 @@ where
|
||||
) -> FlowyResult<CellBytes> {
|
||||
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>> {
|
||||
|
@ -1,3 +1,5 @@
|
||||
#![allow(clippy::module_inception)]
|
||||
mod text_tests;
|
||||
mod text_type_option;
|
||||
|
||||
pub use text_type_option::*;
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
@ -1,8 +1,8 @@
|
||||
use crate::entities::FieldType;
|
||||
use crate::impl_type_option;
|
||||
use crate::services::cell::{
|
||||
try_decode_cell_data, CellBytes, CellBytesParser, CellData, CellDataChangeset, CellDataOperation, CellDisplayable,
|
||||
FromCellString,
|
||||
decode_cell_data_to_string, CellBytes, CellBytesParser, CellData, CellDataChangeset, CellDataOperation,
|
||||
CellDisplayable, FromCellString,
|
||||
};
|
||||
use crate::services::field::{BoxTypeOptionBuilder, TypeOptionBuilder};
|
||||
use bytes::Bytes;
|
||||
@ -44,6 +44,16 @@ impl CellDisplayable<String> for RichTextTypeOptionPB {
|
||||
let cell_str: String = cell_data.try_into_inner()?;
|
||||
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 {
|
||||
@ -57,8 +67,10 @@ impl CellDataOperation<String, String> for RichTextTypeOptionPB {
|
||||
|| decoded_field_type.is_single_select()
|
||||
|| decoded_field_type.is_multi_select()
|
||||
|| 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 {
|
||||
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 {
|
||||
fn from_cell_str(s: &str) -> FlowyResult<Self>
|
||||
where
|
||||
@ -94,6 +114,12 @@ impl FromCellString for TextCellData {
|
||||
}
|
||||
}
|
||||
|
||||
impl ToString for TextCellData {
|
||||
fn to_string(&self) -> String {
|
||||
self.0.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TextCellDataParser();
|
||||
impl CellBytesParser for TextCellDataParser {
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -42,6 +42,16 @@ impl CellDisplayable<URLCellDataPB> for URLTypeOptionPB {
|
||||
let cell_data: URLCellDataPB = cell_data.try_into_inner()?;
|
||||
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 {
|
||||
|
@ -368,6 +368,7 @@ impl GridRevisionEditor {
|
||||
Ok(row_pb)
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "trace", skip_all, err)]
|
||||
pub async fn move_group(&self, params: MoveGroupParams) -> FlowyResult<()> {
|
||||
let _ = self.view_manager.move_group(params).await?;
|
||||
Ok(())
|
||||
@ -435,14 +436,18 @@ impl GridRevisionEditor {
|
||||
}
|
||||
|
||||
pub async fn get_cell(&self, params: &GridCellIdParams) -> Option<GridCellPB> {
|
||||
let cell_bytes = self.get_cell_bytes(params).await?;
|
||||
Some(GridCellPB::new(¶ms.field_id, cell_bytes.to_vec()))
|
||||
let (field_type, cell_bytes) = self.decode_any_cell_data(params).await?;
|
||||
Some(GridCellPB::new(¶ms.field_id, field_type, cell_bytes.to_vec()))
|
||||
}
|
||||
|
||||
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(¶ms.field_id).await?;
|
||||
let row_rev = self.block_manager.get_row_rev(¶ms.row_id).await.ok()??;
|
||||
|
||||
let cell_rev = row_rev.cells.get(¶ms.field_id)?.clone();
|
||||
Some(decode_any_cell_data(cell_rev.data, &field_rev))
|
||||
}
|
||||
|
@ -173,6 +173,7 @@ impl GridViewRevisionEditor {
|
||||
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<()> {
|
||||
let _ = self
|
||||
.group_controller
|
||||
@ -180,7 +181,7 @@ impl GridViewRevisionEditor {
|
||||
.await
|
||||
.move_group(¶ms.from_group_id, ¶ms.to_group_id)?;
|
||||
match self.group_controller.read().await.get_group(¶ms.from_group_id) {
|
||||
None => {}
|
||||
None => tracing::warn!("Can not find the group with id: {}", params.from_group_id),
|
||||
Some((index, group)) => {
|
||||
let inserted_group = InsertedGroupPB {
|
||||
group: GroupPB::from(group),
|
||||
@ -201,6 +202,10 @@ impl GridViewRevisionEditor {
|
||||
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 {
|
||||
let field_revs = self.field_delegate.get_field_revs().await;
|
||||
let grid_setting = make_grid_setting(&*self.pad.read().await, &field_revs);
|
||||
@ -224,7 +229,11 @@ impl GridViewRevisionEditor {
|
||||
let _ = self
|
||||
.modify(|pad| {
|
||||
let configuration = default_group_configuration(&field_rev);
|
||||
let changeset = pad.insert_group(¶ms.field_id, ¶ms.field_type_rev, configuration)?;
|
||||
let changeset = pad.insert_or_update_group_configuration(
|
||||
¶ms.field_id,
|
||||
¶ms.field_type_rev,
|
||||
configuration,
|
||||
)?;
|
||||
Ok(changeset)
|
||||
})
|
||||
.await?;
|
||||
@ -492,10 +501,11 @@ impl GroupConfigurationWriter for GroupConfigurationWriterImpl {
|
||||
let field_id = field_id.to_owned();
|
||||
|
||||
wrap_future(async move {
|
||||
let changeset = view_pad
|
||||
.write()
|
||||
.await
|
||||
.insert_group(&field_id, &field_type, group_configuration)?;
|
||||
let changeset = view_pad.write().await.insert_or_update_group_configuration(
|
||||
&field_id,
|
||||
&field_type,
|
||||
group_configuration,
|
||||
)?;
|
||||
|
||||
if let Some(changeset) = changeset {
|
||||
let _ = apply_change(&user_id, rev_manager, changeset).await?;
|
||||
|
@ -178,12 +178,16 @@ impl GridViewManager {
|
||||
#[tracing::instrument(level = "trace", skip(self), err)]
|
||||
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?;
|
||||
// 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 {
|
||||
let _ = view_editor.group_by_field(field_id).await?;
|
||||
} else {
|
||||
let _ = view_editor.did_update_field(field_id).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
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_grid_data_model::revision::{
|
||||
FieldRevision, FieldTypeRevision, GroupConfigurationContentSerde, GroupConfigurationRevision, GroupRevision,
|
||||
@ -29,10 +29,7 @@ impl<T> std::fmt::Display for GroupContext<T> {
|
||||
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!(
|
||||
"Default group has {} rows \n",
|
||||
self.default_group.rows.len()
|
||||
));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@ -44,7 +41,7 @@ pub struct GroupContext<C> {
|
||||
field_rev: Arc<FieldRevision>,
|
||||
groups_map: IndexMap<String, Group>,
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
@ -59,16 +56,6 @@ where
|
||||
reader: Arc<dyn GroupConfigurationReader>,
|
||||
writer: Arc<dyn GroupConfigurationWriter>,
|
||||
) -> 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 {
|
||||
None => {
|
||||
let default_configuration = default_group_configuration(&field_rev);
|
||||
@ -80,24 +67,22 @@ where
|
||||
Some(configuration) => configuration,
|
||||
};
|
||||
|
||||
// let configuration = C::from_configuration_content(&configuration_rev.content)?;
|
||||
Ok(Self {
|
||||
view_id,
|
||||
field_rev,
|
||||
groups_map: IndexMap::new(),
|
||||
default_group,
|
||||
writer,
|
||||
configuration,
|
||||
configuration_content: PhantomData,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn get_default_group(&self) -> &Group {
|
||||
&self.default_group
|
||||
pub(crate) fn get_default_group(&self) -> Option<&Group> {
|
||||
self.groups_map.get(&self.field_rev.id)
|
||||
}
|
||||
|
||||
pub(crate) fn get_mut_default_group(&mut self) -> &mut Group {
|
||||
&mut self.default_group
|
||||
pub(crate) fn get_mut_default_group(&mut self) -> Option<&mut Group> {
|
||||
self.groups_map.get_mut(&self.field_rev.id)
|
||||
}
|
||||
|
||||
/// Returns the groups without the default group
|
||||
@ -122,8 +107,6 @@ where
|
||||
self.groups_map.iter_mut().for_each(|(_, group)| {
|
||||
each(group);
|
||||
});
|
||||
|
||||
each(&mut self.default_group);
|
||||
}
|
||||
|
||||
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);
|
||||
match (from_index, 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| {
|
||||
let from_index = configuration.groups.iter().position(|group| group.id == from_id);
|
||||
let to_index = configuration.groups.iter().position(|group| group.id == to_id);
|
||||
if let (Some(from), Some(to)) = (from_index, to_index) {
|
||||
configuration.groups.swap(from, to);
|
||||
tracing::info!("Configuration groups: {:?} ", configuration.groups);
|
||||
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(())
|
||||
}
|
||||
_ => 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(
|
||||
&mut self,
|
||||
generated_groups: Vec<GeneratedGroup>,
|
||||
reset: bool,
|
||||
) -> FlowyResult<Option<GroupViewChangesetPB>> {
|
||||
let mut new_groups = vec![];
|
||||
let mut filter_content_map = HashMap::new();
|
||||
@ -159,16 +146,17 @@ where
|
||||
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 {
|
||||
mut all_group_revs,
|
||||
new_group_revs,
|
||||
updated_group_revs: _,
|
||||
deleted_group_revs,
|
||||
} = if reset {
|
||||
merge_groups(&[], new_groups)
|
||||
} else {
|
||||
merge_groups(&self.configuration.groups, new_groups)
|
||||
};
|
||||
} = merge_groups(old_groups, new_groups);
|
||||
|
||||
let deleted_group_ids = deleted_group_revs
|
||||
.into_iter()
|
||||
@ -197,31 +185,23 @@ where
|
||||
Some(pos) => {
|
||||
let mut old_group = configuration.groups.remove(pos);
|
||||
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();
|
||||
is_changed = true;
|
||||
configuration.groups.insert(pos, old_group);
|
||||
}
|
||||
old_group.name = group_rev.name.clone();
|
||||
configuration.groups.insert(pos, old_group);
|
||||
}
|
||||
}
|
||||
}
|
||||
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| {
|
||||
if let Some(filter_content) = filter_content_map.get(&group_rev.id) {
|
||||
let group = Group::new(
|
||||
group_rev.id,
|
||||
self.field_rev.id.clone(),
|
||||
group_rev.name,
|
||||
filter_content.clone(),
|
||||
);
|
||||
self.groups_map.insert(group.id.clone(), group);
|
||||
}
|
||||
let filter_content = filter_content_map
|
||||
.get(&group_rev.id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "".to_owned());
|
||||
let group = Group::new(group_rev.id, self.field_rev.id.clone(), group_rev.name, filter_content);
|
||||
self.groups_map.insert(group.id.clone(), group);
|
||||
});
|
||||
|
||||
let new_groups = new_group_revs
|
||||
@ -269,6 +249,7 @@ where
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "trace", skip_all, err)]
|
||||
pub fn save_configuration(&self) -> FlowyResult<()> {
|
||||
let configuration = (&*self.configuration).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();
|
||||
if old_groups.is_empty() {
|
||||
merge_result.all_group_revs = new_groups.clone();
|
||||
merge_result.new_group_revs = new_groups;
|
||||
return merge_result;
|
||||
}
|
||||
// if old_groups.is_empty() {
|
||||
// merge_result.all_group_revs.extend(new_groups.clone());
|
||||
// merge_result.all_group_revs.push(default_group);
|
||||
// merge_result.new_group_revs = new_groups;
|
||||
// return merge_result;
|
||||
// }
|
||||
|
||||
// group_map is a helper map is used to filter out the new groups.
|
||||
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 {
|
||||
if let Some(new) = new_group_map.remove(&old.id) {
|
||||
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);
|
||||
}
|
||||
} else {
|
||||
merge_result.deleted_group_revs.push(old.clone());
|
||||
merge_result.all_group_revs.push(old);
|
||||
}
|
||||
}
|
||||
|
||||
// Find out the new groups
|
||||
new_group_map.reverse();
|
||||
let new_groups = new_group_map.into_values();
|
||||
for (_, group) in new_groups.into_iter().enumerate() {
|
||||
merge_result.all_group_revs.push(group.clone());
|
||||
merge_result.new_group_revs.push(group);
|
||||
merge_result.all_group_revs.insert(0, group.clone());
|
||||
merge_result.new_group_revs.insert(0, group);
|
||||
}
|
||||
merge_result
|
||||
}
|
||||
|
@ -88,7 +88,7 @@ where
|
||||
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 groups = G::generate_groups(&field_rev.id, &configuration, &type_option);
|
||||
let _ = configuration.init_groups(groups, true)?;
|
||||
let _ = configuration.init_groups(groups)?;
|
||||
|
||||
Ok(Self {
|
||||
field_id: field_rev.id.clone(),
|
||||
@ -105,8 +105,8 @@ where
|
||||
&mut self,
|
||||
row_rev: &RowRevision,
|
||||
other_group_changesets: &[GroupChangesetPB],
|
||||
) -> GroupChangesetPB {
|
||||
let default_group = self.group_ctx.get_mut_default_group();
|
||||
) -> Option<GroupChangesetPB> {
|
||||
let default_group = self.group_ctx.get_mut_default_group()?;
|
||||
|
||||
// [other_group_inserted_row] contains all the inserted rows except the default group.
|
||||
let other_group_inserted_row = other_group_changesets
|
||||
@ -163,7 +163,7 @@ where
|
||||
}
|
||||
default_group.rows.retain(|row| !deleted_row_ids.contains(&row.id));
|
||||
changeset.deleted_rows.extend(deleted_row_ids);
|
||||
changeset
|
||||
Some(changeset)
|
||||
}
|
||||
}
|
||||
|
||||
@ -182,11 +182,14 @@ where
|
||||
|
||||
fn groups(&self) -> Vec<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()
|
||||
} 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 {
|
||||
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>()?;
|
||||
for group in self.group_ctx.groups() {
|
||||
if self.can_group(&group.filter_content, &cell_data) {
|
||||
@ -216,17 +219,18 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
if grouped_rows.is_empty() {
|
||||
self.group_ctx.get_mut_default_group().add_row(row_rev.into());
|
||||
} else {
|
||||
if !grouped_rows.is_empty() {
|
||||
for group_row in grouped_rows {
|
||||
if let Some(group) = self.group_ctx.get_mut_group(&group_row.group_id) {
|
||||
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,13 +248,14 @@ where
|
||||
field_rev: &FieldRevision,
|
||||
) -> FlowyResult<Vec<GroupChangesetPB>> {
|
||||
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 mut changesets = self.add_row_if_match(row_rev, &cell_data);
|
||||
let default_group_changeset = self.update_default_group(row_rev, &changesets);
|
||||
tracing::trace!("default_group_changeset: {}", default_group_changeset);
|
||||
if !default_group_changeset.is_empty() {
|
||||
changesets.push(default_group_changeset);
|
||||
if let Some(default_group_changeset) = self.update_default_group(row_rev, &changesets) {
|
||||
tracing::trace!("default_group_changeset: {}", default_group_changeset);
|
||||
if !default_group_changeset.is_empty() {
|
||||
changesets.push(default_group_changeset);
|
||||
}
|
||||
}
|
||||
Ok(changesets)
|
||||
} else {
|
||||
@ -265,15 +270,16 @@ where
|
||||
) -> FlowyResult<Vec<GroupChangesetPB>> {
|
||||
// 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) {
|
||||
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>()?;
|
||||
Ok(self.remove_row_if_match(row_rev, &cell_data))
|
||||
} else {
|
||||
let group = self.group_ctx.get_default_group();
|
||||
} else if let Some(group) = self.group_ctx.get_default_group() {
|
||||
Ok(vec![GroupChangesetPB::delete(
|
||||
group.id.clone(),
|
||||
vec![row_rev.id.clone()],
|
||||
)])
|
||||
} else {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
@ -285,7 +291,7 @@ where
|
||||
};
|
||||
|
||||
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>()?;
|
||||
Ok(self.move_row(&cell_data, context))
|
||||
} else {
|
||||
@ -297,7 +303,7 @@ where
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
|
@ -9,16 +9,17 @@ pub struct Group {
|
||||
pub is_visible: bool,
|
||||
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,
|
||||
}
|
||||
|
||||
impl Group {
|
||||
pub fn new(id: String, field_id: String, name: String, filter_content: String) -> Self {
|
||||
let is_default = id == field_id;
|
||||
Self {
|
||||
id,
|
||||
field_id,
|
||||
is_default: false,
|
||||
is_default,
|
||||
is_visible: true,
|
||||
name,
|
||||
rows: vec![],
|
||||
|
@ -8,8 +8,8 @@ use crate::services::group::{
|
||||
use flowy_error::FlowyResult;
|
||||
use flowy_grid_data_model::revision::{
|
||||
CheckboxGroupConfigurationRevision, DateGroupConfigurationRevision, FieldRevision, GroupConfigurationRevision,
|
||||
LayoutRevision, NumberGroupConfigurationRevision, RowRevision, SelectOptionGroupConfigurationRevision,
|
||||
TextGroupConfigurationRevision, UrlGroupConfigurationRevision,
|
||||
GroupRevision, LayoutRevision, NumberGroupConfigurationRevision, RowRevision,
|
||||
SelectOptionGroupConfigurationRevision, TextGroupConfigurationRevision, UrlGroupConfigurationRevision,
|
||||
};
|
||||
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_type_rev = field_rev.ty;
|
||||
let field_type: FieldType = field_rev.ty.into();
|
||||
match field_type {
|
||||
let mut group_configuration_rev = match field_type {
|
||||
FieldType::RichText => {
|
||||
GroupConfigurationRevision::new(field_id, field_type_rev, TextGroupConfigurationRevision::default())
|
||||
.unwrap()
|
||||
@ -112,5 +112,23 @@ pub fn default_group_configuration(field_rev: &FieldRevision) -> GroupConfigurat
|
||||
FieldType::URL => {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
@ -370,6 +370,28 @@ async fn group_move_group_test() {
|
||||
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]
|
||||
async fn group_insert_single_select_option_test() {
|
||||
let mut test = GridGroupTest::new().await;
|
||||
@ -402,7 +424,7 @@ async fn group_group_by_other_field() {
|
||||
group_index: 1,
|
||||
row_count: 2,
|
||||
},
|
||||
AssertGroupCount(4),
|
||||
AssertGroupCount(5),
|
||||
];
|
||||
test.run_scripts(scripts).await;
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
use flowy_derive::ProtoBuf;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(ProtoBuf, Default, Debug, Clone)]
|
||||
pub struct UserPreferencesPB {
|
||||
@ -21,7 +22,11 @@ pub struct AppearanceSettingsPB {
|
||||
|
||||
#[pb(index = 3)]
|
||||
#[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;
|
||||
@ -52,7 +57,8 @@ impl std::default::Default for AppearanceSettingsPB {
|
||||
AppearanceSettingsPB {
|
||||
theme: APPEARANCE_DEFAULT_THEME.to_owned(),
|
||||
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
8
shared-lib/Cargo.lock
generated
@ -650,9 +650,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.11.2"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e"
|
||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
@ -732,9 +732,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "1.8.1"
|
||||
version = "1.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0f647032dfaa1f8b6dc29bd3edb7bbef4861b8b8007ebb118d6db284fd59f6ee"
|
||||
checksum = "10a35a97730320ffe8e2d410b5d3b69279b98d2c14bdb8b70ea89ecf7888d41e"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"hashbrown",
|
||||
|
@ -12,7 +12,7 @@ serde_json = {version = "1.0"}
|
||||
serde_repr = "0.1"
|
||||
nanoid = "0.4.0"
|
||||
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"] }
|
||||
|
||||
[build-dependencies]
|
||||
|
@ -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) {
|
||||
self.visible = other.visible
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user