feat: hidden kanban groups (#3907)

* feat: hide/unhide ui

* chore: implement collapsible side bar and adjust group header (#2)

* refactor: hidden columns into own file

* chore: adjust new group button position

* fix: flowy icon buton secondary color bleed

* chore: some UI adjustments

* fix: some regressions

* chore: proper group is_visible fetching

* chore: use a bloc to manage hidden groups

* fix: hiding groups not working

* chore: implement hidden group popups

* chore: proper ungrouped item column management

* chore: remove ungrouped items button

* chore: flowy hover build

* fix: clean up code

* test: integration tests

* fix: not null promise on null value

* fix: hide and unhide multiple groups

* chore: i18n and code review

* chore: missed review

* fix: rust-lib-test

* fix: dont completely remove flowyiconhovercolor

* chore: apply suggest

* fix: number of rows inside hidden groups not updating properly

* fix: hidden groups disappearing after collapse

* fix: hidden group title alignment

* fix: insert newly unhidden groups into the correct position

* chore: adjust padding all around

* feat: reorder hidden groups

* chore: adjust padding

* chore: collapse hidden groups section persist

* chore: no status group at beginning

* fix: hiding groups when grouping with other types

* chore: disable rename groups that arent supported

* chore: update appflowy board ref

* chore: better naming

* test: fix tests

---------

Co-authored-by: Mathias Mogensen <mathias@appflowy.io>
This commit is contained in:
Richard Shiue
2023-11-13 16:14:31 +08:00
committed by GitHub
parent 7867f0366e
commit a63a7ea611
35 changed files with 1200 additions and 749 deletions

View File

@ -6,6 +6,7 @@ import 'package:appflowy/plugins/database_view/application/field/field_info.dart
import 'package:appflowy/plugins/database_view/application/group/group_service.dart';
import 'package:appflowy/plugins/database_view/application/row/row_service.dart';
import 'package:appflowy_board/appflowy_board.dart';
import 'package:collection/collection.dart';
import 'package:dartz/dartz.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
@ -14,6 +15,7 @@ import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:protobuf/protobuf.dart' hide FieldInfo;
import '../../application/field/field_controller.dart';
import '../../application/row/row_cache.dart';
@ -23,12 +25,13 @@ import 'group_controller.dart';
part 'board_bloc.freezed.dart';
class BoardBloc extends Bloc<BoardEvent, BoardState> {
late final GroupBackendService groupBackendSvc;
final DatabaseController databaseController;
late final AppFlowyBoardController boardController;
final LinkedHashMap<String, GroupController> groupControllers =
LinkedHashMap();
GroupPB? ungroupedGroup;
final List<GroupPB> groupList = [];
late final GroupBackendService groupBackendSvc;
late final AppFlowyBoardController boardController;
FieldController get fieldController => databaseController.fieldController;
String get viewId => databaseController.viewId;
@ -82,10 +85,8 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
groupId: groupId,
startRowId: startRowId,
);
result.fold(
(_) {},
(err) => Log.error(err),
);
result.fold((_) {}, (err) => Log.error(err));
},
createHeaderRow: (String groupId) async {
final result = await databaseController.createRow(
@ -93,10 +94,7 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
fromBeginning: true,
);
result.fold(
(_) {},
(err) => Log.error(err),
);
result.fold((_) {}, (err) => Log.error(err));
},
createGroup: (name) async {
final result = await groupBackendSvc.createGroup(name: name);
@ -115,6 +113,48 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
);
_groupItemStartEditing(group, row, true);
},
didReceiveGridUpdate: (DatabasePB grid) {
emit(state.copyWith(grid: Some(grid)));
},
didReceiveError: (FlowyError error) {
emit(state.copyWith(noneOrError: some(error)));
},
didReceiveGroups: (List<GroupPB> groups) {
final hiddenGroups = _filterHiddenGroups(hideUngrouped, groups);
emit(
state.copyWith(
hiddenGroups: hiddenGroups,
groupIds: groups.map((group) => group.groupId).toList(),
),
);
},
didUpdateLayoutSettings: (layoutSettings) {
final hiddenGroups = _filterHiddenGroups(hideUngrouped, groupList);
emit(
state.copyWith(
layoutSettings: layoutSettings,
hiddenGroups: hiddenGroups,
),
);
},
toggleGroupVisibility: (GroupPB group, bool isVisible) async {
await _toggleGroupVisibility(group, isVisible);
},
toggleHiddenSectionVisibility: (isVisible) async {
final newLayoutSettings = state.layoutSettings!;
newLayoutSettings.freeze();
final newLayoutSetting = newLayoutSettings.rebuild(
(message) => message.collapseHiddenGroups = isVisible,
);
await databaseController.updateLayoutSetting(
boardLayoutSetting: newLayoutSetting,
);
},
reorderGroup: (fromGroupId, toGroupId) async {
_reorderGroup(fromGroupId, toGroupId, emit);
},
startEditingRow: (group, row) {
emit(
state.copyWith(
@ -140,22 +180,6 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
emit(state.copyWith(isEditingRow: false, editingRow: null));
}
},
didReceiveGridUpdate: (DatabasePB grid) {
emit(state.copyWith(grid: Some(grid)));
},
didReceiveError: (FlowyError error) {
emit(state.copyWith(noneOrError: some(error)));
},
didReceiveGroups: (List<GroupPB> groups) {
emit(
state.copyWith(
groupIds: groups.map((group) => group.groupId).toList(),
),
);
},
didUpdateLayoutSettings: (layoutSettings) {
emit(state.copyWith(layoutSettings: layoutSettings));
},
startEditingHeader: (String groupId) {
emit(
state.copyWith(isEditingHeader: true, editingHeaderId: groupId),
@ -167,7 +191,6 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
groupId: groupId,
name: groupName,
);
emit(state.copyWith(isEditingHeader: false));
},
);
@ -178,13 +201,50 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
void _groupItemStartEditing(GroupPB group, RowMetaPB row, bool isEdit) {
final fieldInfo = fieldController.getField(group.fieldId);
if (fieldInfo == null) {
Log.warn("fieldInfo should not be null");
return;
return Log.warn("fieldInfo should not be null");
}
boardController.enableGroupDragging(!isEdit);
}
Future<void> _toggleGroupVisibility(GroupPB group, bool isVisible) async {
if (group.isDefault) {
final newLayoutSettings = state.layoutSettings!;
newLayoutSettings.freeze();
final newLayoutSetting = newLayoutSettings.rebuild(
(message) => message.hideUngroupedColumn = !isVisible,
);
return databaseController.updateLayoutSetting(
boardLayoutSetting: newLayoutSetting,
);
}
await groupBackendSvc.updateGroup(
fieldId: groupControllers.values.first.group.fieldId,
groupId: group.groupId,
visible: isVisible,
);
}
Future<void> _reorderGroup(
String fromGroupId,
String toGroupId,
Emitter<BoardState> emit,
) async {
final fromIndex = groupList.indexWhere((g) => g.groupId == fromGroupId);
final toIndex = groupList.indexWhere((g) => g.groupId == toGroupId);
final group = groupList.removeAt(fromIndex);
groupList.insert(toIndex, group);
add(BoardEvent.didReceiveGroups(groupList));
final result = await databaseController.moveGroup(
fromGroupId: fromGroupId,
toGroupId: toGroupId,
);
result.fold((l) => {}, (err) => Log.error(err));
}
@override
Future<void> close() async {
for (final controller in groupControllers.values) {
@ -193,40 +253,45 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
return super.close();
}
bool get hideUngrouped =>
databaseController.databaseLayoutSetting?.board.hideUngroupedColumn ??
false;
FieldType? get groupingFieldType {
final fieldInfo = databaseController.fieldController.fieldInfos
.firstWhereOrNull((field) => field.isGroupField);
return fieldInfo?.fieldType;
}
void initializeGroups(List<GroupPB> groups) {
for (final controller in groupControllers.values) {
controller.dispose();
}
groupControllers.clear();
boardController.clear();
final ungroupedGroupIndex =
groups.indexWhere((group) => group.groupId == group.fieldId);
if (ungroupedGroupIndex != -1) {
ungroupedGroup = groups[ungroupedGroupIndex];
final group = groups.removeAt(ungroupedGroupIndex);
if (!(state.layoutSettings?.hideUngroupedColumn ?? false)) {
groups.add(group);
}
}
groupList.clear();
groupList.addAll(groups);
boardController.addGroups(
groups
.where((group) => fieldController.getField(group.fieldId) != null)
.map((group) => initializeGroupData(group))
.where(
(group) =>
fieldController.getField(group.fieldId) != null &&
(group.isVisible || (group.isDefault && !hideUngrouped)),
)
.map((group) => _initializeGroupData(group))
.toList(),
);
for (final group in groups) {
final controller = initializeGroupController(group);
groupControllers[controller.group.groupId] = (controller);
final controller = _initializeGroupController(group);
groupControllers[controller.group.groupId] = controller;
}
}
RowCache? getRowCache() {
return databaseController.rowCache;
}
RowCache? getRowCache() => databaseController.rowCache;
void _startListening() {
final onDatabaseChanged = DatabaseCallbacks(
@ -238,15 +303,22 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
);
final onLayoutSettingsChanged = DatabaseLayoutSettingCallbacks(
onLayoutSettingsChanged: (layoutSettings) {
if (isClosed || !layoutSettings.hasBoard()) {
if (isClosed) {
return;
}
if (ungroupedGroup != null) {
final index = groupList.indexWhere((element) => element.isDefault);
if (index != -1) {
if (layoutSettings.board.hideUngroupedColumn) {
boardController.removeGroup(ungroupedGroup!.fieldId);
boardController.removeGroup(groupList[index].fieldId);
} else {
final newGroup = initializeGroupData(ungroupedGroup!);
boardController.addGroup(newGroup);
final newGroup = _initializeGroupData(groupList[index]);
final visibleGroups = [...groupList]
..retainWhere((g) => g.isVisible || g.isDefault);
final indexInVisibleGroups =
visibleGroups.indexWhere((g) => g.isDefault);
if (indexInVisibleGroups != -1) {
boardController.insertGroup(indexInVisibleGroups, newGroup);
}
}
}
add(BoardEvent.didUpdateLayoutSettings(layoutSettings.board));
@ -254,30 +326,72 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
);
final onGroupChanged = GroupCallbacks(
onGroupByField: (groups) {
if (isClosed) return;
ungroupedGroup = null;
if (isClosed) {
return;
}
initializeGroups(groups);
add(BoardEvent.didReceiveGroups(groups));
},
onDeleteGroup: (groupIds) {
if (isClosed) return;
if (isClosed) {
return;
}
boardController.removeGroups(groupIds);
groupList.removeWhere((group) => groupIds.contains(group.groupId));
add(BoardEvent.didReceiveGroups(groupList));
},
onInsertGroup: (insertGroups) {
if (isClosed) return;
if (isClosed) {
return;
}
final group = insertGroups.group;
final newGroup = initializeGroupData(group);
final controller = initializeGroupController(group);
groupControllers[controller.group.groupId] = (controller);
final newGroup = _initializeGroupData(group);
final controller = _initializeGroupController(group);
groupControllers[controller.group.groupId] = controller;
boardController.addGroup(newGroup);
groupList.insert(insertGroups.index, group);
add(BoardEvent.didReceiveGroups(groupList));
},
onUpdateGroup: (updatedGroups) {
if (isClosed) return;
if (isClosed) {
return;
}
for (final group in updatedGroups) {
// see if the column is already in the board
final index = groupList.indexWhere((g) => g.groupId == group.groupId);
if (index == -1) continue;
final columnController =
boardController.getGroupController(group.groupId);
columnController?.updateGroupName(group.groupName);
if (columnController != null) {
// remove the group or update its name
columnController.updateGroupName(group.groupName);
if (!group.isVisible) {
boardController.removeGroup(group.groupId);
}
} else {
final newGroup = _initializeGroupData(group);
final visibleGroups = [...groupList]..retainWhere(
(g) =>
g.isVisible ||
g.isDefault && !hideUngrouped ||
g.groupId == group.groupId,
);
final indexInVisibleGroups =
visibleGroups.indexWhere((g) => g.groupId == group.groupId);
if (indexInVisibleGroups != -1) {
boardController.insertGroup(indexInVisibleGroups, newGroup);
}
}
groupList.removeAt(index);
groupList.insert(index, group);
}
add(BoardEvent.didReceiveGroups(groupList));
},
);
@ -315,24 +429,35 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
);
}
GroupController initializeGroupController(GroupPB group) {
GroupController _initializeGroupController(GroupPB group) {
final delegate = GroupControllerDelegateImpl(
controller: boardController,
fieldController: fieldController,
onNewColumnItem: (groupId, row, index) {
add(BoardEvent.didCreateRow(group, row, index));
},
onNewColumnItem: (groupId, row, index) =>
add(BoardEvent.didCreateRow(group, row, index)),
);
final controller = GroupController(
viewId: state.viewId,
group: group,
delegate: delegate,
onGroupChanged: (newGroup) {
if (isClosed) return;
final index =
groupList.indexWhere((g) => g.groupId == newGroup.groupId);
if (index != -1) {
groupList.removeAt(index);
groupList.insert(index, newGroup);
add(BoardEvent.didReceiveGroups(groupList));
}
},
);
controller.startListening();
return controller;
return controller..startListening();
}
AppFlowyGroupData initializeGroupData(GroupPB group) {
AppFlowyGroupData _initializeGroupData(GroupPB group) {
return AppFlowyGroupData(
id: group.groupId,
name: group.groupName,
@ -365,6 +490,14 @@ class BoardEvent with _$BoardEvent {
RowMetaPB row,
) = _StartEditRow;
const factory BoardEvent.endEditingRow(RowId rowId) = _EndEditRow;
const factory BoardEvent.toggleGroupVisibility(
GroupPB group,
bool isVisible,
) = _ToggleGroupVisibility;
const factory BoardEvent.toggleHiddenSectionVisibility(bool isVisible) =
_ToggleHiddenSectionVisibility;
const factory BoardEvent.reorderGroup(String fromGroupId, String toGroupId) =
_ReorderGroup;
const factory BoardEvent.didReceiveError(FlowyError error) = _DidReceiveError;
const factory BoardEvent.didReceiveGridUpdate(
DatabasePB grid,
@ -383,12 +516,13 @@ class BoardState with _$BoardState {
required Option<DatabasePB> grid,
required List<String> groupIds,
required bool isEditingHeader,
String? editingHeaderId,
required bool isEditingRow,
BoardEditingRow? editingRow,
required LoadingState loadingState,
required Option<FlowyError> noneOrError,
required BoardLayoutSettingPB? layoutSettings,
String? editingHeaderId,
BoardEditingRow? editingRow,
required List<GroupPB> hiddenGroups,
}) = _BoardState;
factory BoardState.initial(String viewId) => BoardState(
@ -400,9 +534,16 @@ class BoardState with _$BoardState {
noneOrError: none(),
loadingState: const LoadingState.loading(),
layoutSettings: null,
hiddenGroups: [],
);
}
List<GroupPB> _filterHiddenGroups(bool hideUngrouped, List<GroupPB> groups) {
return [...groups]..retainWhere(
(group) => !group.isVisible || group.isDefault && hideUngrouped,
);
}
class GroupItem extends AppFlowyGroupItem {
final RowMetaPB row;
final FieldInfo fieldInfo;
@ -430,12 +571,16 @@ class GroupControllerDelegateImpl extends GroupControllerDelegate {
required this.onNewColumnItem,
});
@override
bool hasGroup(String groupId) {
return controller.groupIds.contains(groupId);
}
@override
void insertRow(GroupPB group, RowMetaPB row, int? index) {
final fieldInfo = fieldController.getField(group.fieldId);
if (fieldInfo == null) {
Log.warn("fieldInfo should not be null");
return;
return Log.warn("fieldInfo should not be null");
}
if (index != null) {
@ -454,17 +599,16 @@ class GroupControllerDelegateImpl extends GroupControllerDelegate {
}
@override
void removeRow(GroupPB group, RowId rowId) {
controller.removeGroupItem(group.groupId, rowId.toString());
}
void removeRow(GroupPB group, RowId rowId) =>
controller.removeGroupItem(group.groupId, rowId.toString());
@override
void updateRow(GroupPB group, RowMetaPB row) {
final fieldInfo = fieldController.getField(group.fieldId);
if (fieldInfo == null) {
Log.warn("fieldInfo should not be null");
return;
return Log.warn("fieldInfo should not be null");
}
controller.updateGroupItem(
group.groupId,
GroupItem(
@ -478,20 +622,17 @@ class GroupControllerDelegateImpl extends GroupControllerDelegate {
void addNewRow(GroupPB group, RowMetaPB row, int? index) {
final fieldInfo = fieldController.getField(group.fieldId);
if (fieldInfo == null) {
Log.warn("fieldInfo should not be null");
return;
return Log.warn("fieldInfo should not be null");
}
final item = GroupItem(
row: row,
fieldInfo: fieldInfo,
draggable: false,
);
final item = GroupItem(row: row, fieldInfo: fieldInfo, draggable: false);
if (index != null) {
controller.insertGroupItem(group.groupId, index, item);
} else {
controller.addGroupItem(group.groupId, item);
}
onNewColumnItem(group.groupId, row, index);
}
}
@ -509,27 +650,26 @@ class BoardEditingRow {
}
class GroupData {
final GroupPB group;
final FieldInfo fieldInfo;
GroupData({
required this.group,
required this.fieldInfo,
});
CheckboxGroup? asCheckboxGroup() {
if (fieldType != FieldType.Checkbox) return null;
return CheckboxGroup(group);
}
final GroupPB group;
final FieldInfo fieldInfo;
CheckboxGroup? asCheckboxGroup() =>
fieldType == FieldType.Checkbox ? CheckboxGroup(group) : null;
FieldType get fieldType => fieldInfo.fieldType;
}
class CheckboxGroup {
const CheckboxGroup(this.group);
final GroupPB group;
CheckboxGroup(this.group);
// Hardcode value: "Yes" that equal to the value defined in Rust
// pub const CHECK: &str = "Yes";
// Hardcode value: "Yes" that equal to the value defined in Rust
// pub const CHECK: &str = "Yes";
bool get isCheck => group.groupId == "Yes";
}

View File

@ -1,12 +0,0 @@
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart';
class BoardGroupService {
final String viewId;
FieldPB? groupField;
BoardGroupService(this.viewId);
void setGroupField(FieldPB field) {
groupField = field;
}
}

View File

@ -7,10 +7,12 @@ import 'dart:typed_data';
import 'package:appflowy/core/notification/grid_notification.dart';
import 'package:flowy_infra/notifier.dart';
import 'package:dartz/dartz.dart';
import 'package:protobuf/protobuf.dart';
typedef OnGroupError = void Function(FlowyError);
abstract class GroupControllerDelegate {
bool hasGroup(String groupId);
void removeRow(GroupPB group, RowId rowId);
void insertRow(GroupPB group, RowMetaPB row, int? index);
void updateRow(GroupPB group, RowMetaPB row);
@ -18,14 +20,16 @@ abstract class GroupControllerDelegate {
}
class GroupController {
final GroupPB group;
GroupPB group;
final SingleGroupListener _listener;
final GroupControllerDelegate delegate;
final void Function(GroupPB group) onGroupChanged;
GroupController({
required String viewId,
required this.group,
required this.delegate,
required this.onGroupChanged,
}) : _listener = SingleGroupListener(group);
RowMetaPB? rowAtIndex(int index) {
@ -46,37 +50,52 @@ class GroupController {
onGroupChanged: (result) {
result.fold(
(GroupRowsNotificationPB changeset) {
final newItems = [...group.rows];
final isGroupExist = delegate.hasGroup(group.groupId);
for (final deletedRow in changeset.deletedRows) {
group.rows.removeWhere((rowPB) => rowPB.id == deletedRow);
delegate.removeRow(group, deletedRow);
newItems.removeWhere((rowPB) => rowPB.id == deletedRow);
if (isGroupExist) {
delegate.removeRow(group, deletedRow);
}
}
for (final insertedRow in changeset.insertedRows) {
final index = insertedRow.hasIndex() ? insertedRow.index : null;
if (insertedRow.hasIndex() &&
group.rows.length > insertedRow.index) {
group.rows.insert(insertedRow.index, insertedRow.rowMeta);
newItems.length > insertedRow.index) {
newItems.insert(insertedRow.index, insertedRow.rowMeta);
} else {
group.rows.add(insertedRow.rowMeta);
newItems.add(insertedRow.rowMeta);
}
if (insertedRow.isNew) {
delegate.addNewRow(group, insertedRow.rowMeta, index);
} else {
delegate.insertRow(group, insertedRow.rowMeta, index);
if (isGroupExist) {
if (insertedRow.isNew) {
delegate.addNewRow(group, insertedRow.rowMeta, index);
} else {
delegate.insertRow(group, insertedRow.rowMeta, index);
}
}
}
for (final updatedRow in changeset.updatedRows) {
final index = group.rows.indexWhere(
final index = newItems.indexWhere(
(rowPB) => rowPB.id == updatedRow.id,
);
if (index != -1) {
group.rows[index] = updatedRow;
delegate.updateRow(group, updatedRow);
newItems[index] = updatedRow;
if (isGroupExist) {
delegate.updateRow(group, updatedRow);
}
}
}
group.freeze();
group = group.rebuild((group) {
group.rows.clear();
group.rows.addAll(newItems);
});
onGroupChanged(group);
},
(err) => Log.error(err),
);

View File

@ -1,112 +0,0 @@
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:bloc/bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'group_controller.dart';
part 'ungrouped_items_bloc.freezed.dart';
class UngroupedItemsBloc
extends Bloc<UngroupedItemsEvent, UngroupedItemsState> {
UngroupedItemsListener? listener;
UngroupedItemsBloc({required GroupPB group})
: super(UngroupedItemsState(ungroupedItems: group.rows)) {
on<UngroupedItemsEvent>(
(event, emit) {
event.when(
initial: () {
listener = UngroupedItemsListener(
initialGroup: group,
onGroupChanged: (ungroupedItems) {
if (isClosed) return;
add(
UngroupedItemsEvent.updateGroup(
ungroupedItems: ungroupedItems,
),
);
},
)..startListening();
},
updateGroup: (newItems) =>
emit(UngroupedItemsState(ungroupedItems: newItems)),
);
},
);
}
}
@freezed
class UngroupedItemsEvent with _$UngroupedItemsEvent {
const factory UngroupedItemsEvent.initial() = _Initial;
const factory UngroupedItemsEvent.updateGroup({
required List<RowMetaPB> ungroupedItems,
}) = _UpdateGroup;
}
@freezed
class UngroupedItemsState with _$UngroupedItemsState {
const factory UngroupedItemsState({
required List<RowMetaPB> ungroupedItems,
}) = _UngroupedItemsState;
}
class UngroupedItemsListener {
List<RowMetaPB> _ungroupedItems;
final SingleGroupListener _listener;
final void Function(List<RowMetaPB> items) onGroupChanged;
UngroupedItemsListener({
required GroupPB initialGroup,
required this.onGroupChanged,
}) : _ungroupedItems = List<RowMetaPB>.from(initialGroup.rows),
_listener = SingleGroupListener(initialGroup);
void startListening() {
_listener.start(
onGroupChanged: (result) {
result.fold(
(GroupRowsNotificationPB changeset) {
final newItems = List<RowMetaPB>.from(_ungroupedItems);
for (final deletedRow in changeset.deletedRows) {
newItems.removeWhere((rowPB) => rowPB.id == deletedRow);
}
for (final insertedRow in changeset.insertedRows) {
final index = newItems.indexWhere(
(rowPB) => rowPB.id == insertedRow.rowMeta.id,
);
if (index != -1) {
continue;
}
if (insertedRow.hasIndex() &&
newItems.length > insertedRow.index) {
newItems.insert(insertedRow.index, insertedRow.rowMeta);
} else {
newItems.add(insertedRow.rowMeta);
}
}
for (final updatedRow in changeset.updatedRows) {
final index = newItems.indexWhere(
(rowPB) => rowPB.id == updatedRow.id,
);
if (index != -1) {
newItems[index] = updatedRow;
}
}
onGroupChanged.call(newItems);
_ungroupedItems = newItems;
},
(err) => Log.error(err),
);
},
);
}
Future<void> dispose() async {
_listener.stop();
}
}

View File

@ -1,5 +1,3 @@
// ignore_for_file: unused_field
import 'dart:collection';
import 'package:appflowy/generated/flowy_svgs.g.dart';
@ -9,7 +7,7 @@ import 'package:appflowy/plugins/database_view/application/field/field_controlle
import 'package:appflowy/plugins/database_view/application/row/row_cache.dart';
import 'package:appflowy/plugins/database_view/application/row/row_controller.dart';
import 'package:appflowy/plugins/database_view/board/presentation/widgets/board_column_header.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/field_type_extension.dart';
import 'package:appflowy/plugins/database_view/tar_bar/tab_bar_view.dart';
import 'package:appflowy/plugins/database_view/widgets/row/row_detail.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
@ -30,7 +28,7 @@ import '../../widgets/row/cell_builder.dart';
import '../application/board_bloc.dart';
import '../../widgets/card/card.dart';
import 'toolbar/board_setting_bar.dart';
import 'ungrouped_items_button.dart';
import 'widgets/board_hidden_groups.dart';
class BoardPageTabBarBuilderImpl implements DatabaseTabBarItemBuilder {
@override
@ -39,46 +37,38 @@ class BoardPageTabBarBuilderImpl implements DatabaseTabBarItemBuilder {
ViewPB view,
DatabaseController controller,
bool shrinkWrap,
) {
return BoardPage(
key: _makeValueKey(controller),
view: view,
databaseController: controller,
);
}
) =>
BoardPage(view: view, databaseController: controller);
@override
Widget settingBar(BuildContext context, DatabaseController controller) {
return BoardSettingBar(
key: _makeValueKey(controller),
databaseController: controller,
);
}
Widget settingBar(BuildContext context, DatabaseController controller) =>
BoardSettingBar(
key: _makeValueKey(controller),
databaseController: controller,
);
@override
Widget settingBarExtension(
BuildContext context,
DatabaseController controller,
) {
return SizedBox.fromSize();
}
) =>
const SizedBox.shrink();
ValueKey _makeValueKey(DatabaseController controller) {
return ValueKey(controller.viewId);
}
ValueKey _makeValueKey(DatabaseController controller) =>
ValueKey(controller.viewId);
}
class BoardPage extends StatelessWidget {
final DatabaseController databaseController;
BoardPage({
required this.view,
required this.databaseController,
Key? key,
this.onEditStateChanged,
}) : super(key: ValueKey(view.id));
final ViewPB view;
final DatabaseController databaseController;
/// Called when edit state changed
final VoidCallback? onEditStateChanged;
@ -91,23 +81,18 @@ class BoardPage extends StatelessWidget {
)..add(const BoardEvent.initial()),
child: BlocBuilder<BoardBloc, BoardState>(
buildWhen: (p, c) => p.loadingState != c.loadingState,
builder: (context, state) {
return state.loadingState.map(
loading: (_) =>
const Center(child: CircularProgressIndicator.adaptive()),
finish: (result) {
return result.successOrFail.fold(
(_) => BoardContent(
onEditStateChanged: onEditStateChanged,
),
(err) => FlowyErrorPage.message(
err.toString(),
howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(),
),
);
},
);
},
builder: (context, state) => state.loadingState.map(
loading: (_) => const Center(
child: CircularProgressIndicator.adaptive(),
),
finish: (result) => result.successOrFail.fold(
(_) => BoardContent(onEditStateChanged: onEditStateChanged),
(err) => FlowyErrorPage.message(
err.toString(),
howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(),
),
),
),
),
);
}
@ -126,12 +111,14 @@ class BoardContent extends StatefulWidget {
}
class _BoardContentState extends State<BoardContent> {
late AppFlowyBoardScrollController scrollManager;
late final ScrollController scrollController;
final renderHook = RowCardRenderHook<String>();
late final ScrollController scrollController;
late final AppFlowyBoardScrollController scrollManager;
final config = const AppFlowyBoardConfig(
groupBackgroundColor: Color(0xffF7F8FC),
headerPadding: EdgeInsets.symmetric(horizontal: 8),
cardPadding: EdgeInsets.symmetric(horizontal: 4, vertical: 3),
);
@override
@ -162,43 +149,36 @@ class _BoardContentState extends State<BoardContent> {
},
child: BlocBuilder<BoardBloc, BoardState>(
builder: (context, state) {
final showCreateGroupButton =
context.read<BoardBloc>().groupingFieldType!.canCreateNewGroup;
return Padding(
padding: GridSize.contentInsets,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const VSpace(8.0),
if (state.layoutSettings?.hideUngroupedColumn ?? false)
_buildBoardHeader(context),
Expanded(
child: AppFlowyBoard(
boardScrollController: scrollManager,
scrollController: scrollController,
controller: context.read<BoardBloc>().boardController,
headerBuilder: (_, groupData) =>
BlocProvider<BoardBloc>.value(
value: context.read<BoardBloc>(),
child: BoardColumnHeader(
groupData: groupData,
margin: config.headerPadding,
),
),
footerBuilder: _buildFooter,
trailing: BoardTrailing(scrollController: scrollController),
cardBuilder: (_, column, columnItem) => _buildCard(
context,
column,
columnItem,
),
groupConstraints: const BoxConstraints.tightFor(width: 300),
config: AppFlowyBoardConfig(
groupBackgroundColor:
Theme.of(context).colorScheme.surfaceVariant,
),
),
)
],
padding: const EdgeInsets.only(top: 8.0),
child: AppFlowyBoard(
boardScrollController: scrollManager,
scrollController: scrollController,
controller: context.read<BoardBloc>().boardController,
groupConstraints: const BoxConstraints.tightFor(width: 300),
config: const AppFlowyBoardConfig(
groupPadding: EdgeInsets.symmetric(horizontal: 4),
groupItemPadding: EdgeInsets.symmetric(horizontal: 4),
),
leading: HiddenGroupsColumn(margin: config.headerPadding),
trailing: showCreateGroupButton
? BoardTrailing(scrollController: scrollController)
: null,
headerBuilder: (_, groupData) => BlocProvider<BoardBloc>.value(
value: context.read<BoardBloc>(),
child: BoardColumnHeader(
groupData: groupData,
margin: config.headerPadding,
),
),
footerBuilder: _buildFooter,
cardBuilder: (_, column, columnItem) => _buildCard(
context,
column,
columnItem,
),
),
);
},
@ -206,19 +186,6 @@ class _BoardContentState extends State<BoardContent> {
);
}
Widget _buildBoardHeader(BuildContext context) {
return const Padding(
padding: EdgeInsets.only(bottom: 8.0),
child: SizedBox(
height: 24,
child: Align(
alignment: AlignmentDirectional.centerEnd,
child: UngroupedItemsButton(),
),
),
);
}
void _handleEditStateChanged(BoardState state, BuildContext context) {
if (state.isEditingRow && state.editingRow != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
@ -231,25 +198,24 @@ class _BoardContentState extends State<BoardContent> {
Widget _buildFooter(BuildContext context, AppFlowyGroupData columnData) {
return AppFlowyGroupFooter(
height: 50,
margin: config.footerPadding,
icon: SizedBox(
height: 20,
width: 20,
child: FlowySvg(
FlowySvgs.add_s,
color: Theme.of(context).iconTheme.color,
color: Theme.of(context).hintColor,
),
),
title: FlowyText.medium(
LocaleKeys.board_column_createNewCard.tr(),
color: Theme.of(context).hintColor,
fontSize: 14,
),
height: 50,
margin: config.footerPadding,
onAddButtonClick: () {
context.read<BoardBloc>().add(
BoardEvent.createBottomRow(columnData.id),
);
},
onAddButtonClick: () => context
.read<BoardBloc>()
.add(BoardEvent.createBottomRow(columnData.id)),
);
}
@ -307,15 +273,27 @@ class _BoardContentState extends State<BoardContent> {
}
BoxDecoration _makeBoxDecoration(BuildContext context) {
final borderSide = BorderSide(
color: Theme.of(context).dividerColor,
width: 1.0,
);
final isLightMode = Theme.of(context).brightness == Brightness.light;
return BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: isLightMode ? Border.fromBorderSide(borderSide) : null,
borderRadius: const BorderRadius.all(Radius.circular(6)),
border: Border.fromBorderSide(
BorderSide(
color: Theme.of(context).dividerColor,
width: 1.4,
),
),
boxShadow: [
BoxShadow(
blurRadius: 4,
spreadRadius: 0,
color: const Color(0xFF1F2329).withOpacity(0.02),
),
BoxShadow(
blurRadius: 4,
spreadRadius: -2,
color: const Color(0xFF1F2329).withOpacity(0.02),
),
],
);
}
@ -343,40 +321,37 @@ class _BoardContentState extends State<BoardContent> {
FlowyOverlay.show(
context: context,
builder: (BuildContext context) {
return RowDetailPage(
cellBuilder: GridCellBuilder(cellCache: dataController.cellCache),
rowController: dataController,
);
},
builder: (_) => RowDetailPage(
cellBuilder: GridCellBuilder(cellCache: dataController.cellCache),
rowController: dataController,
),
);
}
}
class BoardTrailing extends StatefulWidget {
const BoardTrailing({super.key, required this.scrollController});
final ScrollController scrollController;
const BoardTrailing({required this.scrollController, super.key});
@override
State<BoardTrailing> createState() => _BoardTrailingState();
}
class _BoardTrailingState extends State<BoardTrailing> {
bool isEditing = false;
late final TextEditingController _textController;
final TextEditingController _textController = TextEditingController();
late final FocusNode _focusNode;
bool isEditing = false;
void _cancelAddNewGroup() {
_textController.clear();
setState(() {
isEditing = false;
});
setState(() => isEditing = false);
}
@override
void initState() {
super.initState();
_textController = TextEditingController();
_focusNode = FocusNode(
onKeyEvent: (node, event) {
if (_focusNode.hasFocus &&
@ -406,7 +381,7 @@ class _BoardTrailingState extends State<BoardTrailing> {
});
return Padding(
padding: const EdgeInsets.only(left: 8.0),
padding: const EdgeInsets.only(left: 8.0, top: 12),
child: Align(
alignment: AlignmentDirectional.topStart,
child: AnimatedSwitcher(
@ -448,9 +423,7 @@ class _BoardTrailingState extends State<BoardTrailing> {
width: 26,
icon: const FlowySvg(FlowySvgs.add_s),
iconColorOnHover: Theme.of(context).colorScheme.onSurface,
onPressed: () => setState(() {
isEditing = true;
}),
onPressed: () => setState(() => isEditing = true),
),
),
),

View File

@ -1,237 +0,0 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database_view/application/cell/cell_service.dart';
import 'package:appflowy/plugins/database_view/application/field/field_info.dart';
import 'package:appflowy/plugins/database_view/application/row/row_cache.dart';
import 'package:appflowy/plugins/database_view/application/row/row_controller.dart';
import 'package:appflowy/plugins/database_view/board/application/board_bloc.dart';
import 'package:appflowy/plugins/database_view/board/application/ungrouped_items_bloc.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database_view/widgets/card/card_cell_builder.dart';
import 'package:appflowy/plugins/database_view/widgets/card/cells/card_cell.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cell_builder.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/text_cell/text_cell_bloc.dart';
import 'package:appflowy/plugins/database_view/widgets/row/row_detail.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class UngroupedItemsButton extends StatefulWidget {
const UngroupedItemsButton({super.key});
@override
State<UngroupedItemsButton> createState() => _UnscheduledEventsButtonState();
}
class _UnscheduledEventsButtonState extends State<UngroupedItemsButton> {
late final PopoverController _popoverController;
@override
void initState() {
super.initState();
_popoverController = PopoverController();
}
@override
Widget build(BuildContext context) {
return BlocBuilder<BoardBloc, BoardState>(
builder: (context, boardState) {
final ungroupedGroup = context.watch<BoardBloc>().ungroupedGroup;
final databaseController = context.read<BoardBloc>().databaseController;
final primaryField = databaseController.fieldController.fieldInfos
.firstWhereOrNull((element) => element.isPrimary)!;
if (ungroupedGroup == null) {
return const SizedBox.shrink();
}
return BlocProvider<UngroupedItemsBloc>(
create: (_) => UngroupedItemsBloc(group: ungroupedGroup)
..add(const UngroupedItemsEvent.initial()),
child: BlocBuilder<UngroupedItemsBloc, UngroupedItemsState>(
builder: (context, state) {
return AppFlowyPopover(
direction: PopoverDirection.bottomWithCenterAligned,
triggerActions: PopoverTriggerFlags.none,
controller: _popoverController,
offset: const Offset(0, 8),
constraints:
const BoxConstraints(maxWidth: 282, maxHeight: 600),
child: FlowyTooltip(
message: LocaleKeys.board_ungroupedButtonTooltip.tr(),
child: OutlinedButton(
style: OutlinedButton.styleFrom(
shape: RoundedRectangleBorder(
side: BorderSide(
color: Theme.of(context).dividerColor,
width: 1,
),
borderRadius: Corners.s6Border,
),
side: BorderSide(
color: Theme.of(context).dividerColor,
width: 1,
),
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
visualDensity: VisualDensity.compact,
),
onPressed: () {
if (state.ungroupedItems.isNotEmpty) {
_popoverController.show();
}
},
child: FlowyText.regular(
"${LocaleKeys.board_ungroupedButtonText.tr()} (${state.ungroupedItems.length})",
fontSize: 10,
),
),
),
popupBuilder: (context) {
return UngroupedItemList(
viewId: databaseController.viewId,
primaryField: primaryField,
rowCache: databaseController.rowCache,
ungroupedItems: state.ungroupedItems,
);
},
);
},
),
);
},
);
}
}
class UngroupedItemList extends StatelessWidget {
final String viewId;
final FieldInfo primaryField;
final RowCache rowCache;
final List<RowMetaPB> ungroupedItems;
const UngroupedItemList({
required this.viewId,
required this.primaryField,
required this.ungroupedItems,
required this.rowCache,
super.key,
});
@override
Widget build(BuildContext context) {
final cells = <Widget>[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: FlowyText.medium(
LocaleKeys.board_ungroupedItemsTitle.tr(),
fontSize: 10,
color: Theme.of(context).hintColor,
overflow: TextOverflow.ellipsis,
),
),
...ungroupedItems.map(
(item) {
final rowController = RowController(
rowMeta: item,
viewId: viewId,
rowCache: rowCache,
);
final renderHook = RowCardRenderHook<String>();
renderHook.addTextCellHook((cellData, _, __) {
return BlocBuilder<TextCellBloc, TextCellState>(
builder: (context, state) {
final text = cellData.isEmpty
? LocaleKeys.grid_row_titlePlaceholder.tr()
: cellData;
if (text.isEmpty) {
return const SizedBox.shrink();
}
return Align(
alignment: Alignment.centerLeft,
child: FlowyText.medium(
text,
textAlign: TextAlign.left,
fontSize: 11,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
);
},
);
});
return UngroupedItem(
cellContext: rowCache.loadCells(item)[primaryField.id]!,
primaryField: primaryField,
rowController: rowController,
cellBuilder: CardCellBuilder<String>(rowController.cellCache),
renderHook: renderHook,
onPressed: () {
FlowyOverlay.show(
context: context,
builder: (BuildContext context) {
return RowDetailPage(
cellBuilder:
GridCellBuilder(cellCache: rowController.cellCache),
rowController: rowController,
);
},
);
PopoverContainer.of(context).close();
},
);
},
)
];
return ListView.separated(
itemBuilder: (context, index) => cells[index],
itemCount: cells.length,
separatorBuilder: (context, index) =>
VSpace(GridSize.typeOptionSeparatorHeight),
shrinkWrap: true,
);
}
}
class UngroupedItem extends StatelessWidget {
final DatabaseCellContext cellContext;
final FieldInfo primaryField;
final RowController rowController;
final CardCellBuilder cellBuilder;
final RowCardRenderHook<String> renderHook;
final VoidCallback onPressed;
const UngroupedItem({
super.key,
required this.cellContext,
required this.onPressed,
required this.cellBuilder,
required this.rowController,
required this.primaryField,
required this.renderHook,
});
@override
Widget build(BuildContext context) {
return SizedBox(
height: 26,
child: FlowyButton(
margin: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
text: cellBuilder.buildCell(
cellContext: cellContext,
renderHook: renderHook,
hasNotes: false,
),
onTap: onPressed,
),
);
}
}

View File

@ -1,14 +1,15 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database_view/board/application/board_bloc.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/field_type_extension.dart';
import 'package:appflowy/plugins/database_view/widgets/card/define.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/group.pb.dart';
import 'package:appflowy_board/appflowy_board.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@ -18,11 +19,11 @@ class BoardColumnHeader extends StatefulWidget {
const BoardColumnHeader({
super.key,
required this.groupData,
this.margin,
required this.margin,
});
final AppFlowyGroupData groupData;
final EdgeInsets? margin;
final EdgeInsets margin;
@override
State<BoardColumnHeader> createState() => _BoardColumnHeaderState();
@ -74,7 +75,7 @@ class _BoardColumnHeaderState extends State<BoardColumnHeader> {
child: FlowyText.medium(
widget.groupData.headerData.groupName,
fontSize: 14,
overflow: TextOverflow.clip,
overflow: TextOverflow.ellipsis,
),
);
@ -84,22 +85,16 @@ class _BoardColumnHeaderState extends State<BoardColumnHeader> {
fit: FlexFit.tight,
child: FlowyTooltip(
message: LocaleKeys.board_column_renameGroupTooltip.tr(),
child: FlowyHover(
style: HoverStyle(
hoverColor: Colors.transparent,
foregroundColorOnHover:
AFThemeExtension.of(context).textColor,
),
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => context.read<BoardBloc>().add(
BoardEvent.startEditingHeader(
widget.groupData.id,
),
),
onTap: () => context
.read<BoardBloc>()
.add(BoardEvent.startEditingHeader(widget.groupData.id)),
child: FlowyText.medium(
widget.groupData.headerData.groupName,
fontSize: 14,
overflow: TextOverflow.clip,
overflow: TextOverflow.ellipsis,
),
),
),
@ -112,22 +107,31 @@ class _BoardColumnHeaderState extends State<BoardColumnHeader> {
title = _buildTextField(context);
}
return AppFlowyGroupHeader(
title: title,
icon: _buildHeaderIcon(boardCustomData),
addIcon: SizedBox(
height: 20,
width: 20,
child: FlowySvg(
FlowySvgs.add_s,
color: Theme.of(context).iconTheme.color,
return Padding(
padding: widget.margin,
child: SizedBox(
height: 50,
child: Row(
children: [
_buildHeaderIcon(boardCustomData),
title,
const HSpace(6),
_groupOptionsButton(context),
const HSpace(4),
FlowyTooltip(
message: LocaleKeys.board_column_addToColumnTopTooltip.tr(),
child: FlowyIconButton(
width: 20,
icon: const FlowySvg(FlowySvgs.add_s),
iconColorOnHover: Theme.of(context).colorScheme.onSurface,
onPressed: () => context
.read<BoardBloc>()
.add(BoardEvent.createHeaderRow(widget.groupData.id)),
),
),
],
),
),
onAddButtonClick: () => context
.read<BoardBloc>()
.add(BoardEvent.createHeaderRow(widget.groupData.id)),
height: 50,
margin: widget.margin ?? EdgeInsets.zero,
);
},
);
@ -154,7 +158,6 @@ class _BoardColumnHeaderState extends State<BoardColumnHeader> {
filled: true,
fillColor: Theme.of(context).colorScheme.surface,
hoverColor: Colors.transparent,
// Magic number 4 makes the textField take up the same space as FlowyText
contentPadding: EdgeInsets.symmetric(
vertical: CardSizes.cardCellVPadding + 4,
horizontal: 8,
@ -181,45 +184,93 @@ class _BoardColumnHeaderState extends State<BoardColumnHeader> {
);
}
void _saveEdit() {
context.read<BoardBloc>().add(
BoardEvent.endEditingHeader(
widget.groupData.id,
_controller.text,
void _saveEdit() => context
.read<BoardBloc>()
.add(BoardEvent.endEditingHeader(widget.groupData.id, _controller.text));
Widget _buildHeaderIcon(GroupData customData) =>
switch (customData.fieldType) {
FieldType.Checkbox => FlowySvg(
customData.asCheckboxGroup()!.isCheck
? FlowySvgs.check_filled_s
: FlowySvgs.uncheck_s,
blendMode: BlendMode.dst,
),
_ => const SizedBox.shrink(),
};
Widget _groupOptionsButton(BuildContext context) {
return AppFlowyPopover(
clickHandler: PopoverClickHandler.gestureDetector,
margin: const EdgeInsets.fromLTRB(8, 8, 8, 4),
constraints: BoxConstraints.loose(const Size(168, 300)),
direction: PopoverDirection.bottomWithLeftAligned,
child: FlowyIconButton(
width: 20,
icon: const FlowySvg(FlowySvgs.details_horizontal_s),
iconColorOnHover: Theme.of(context).colorScheme.onSurface,
),
popupBuilder: (popoverContext) {
final customGroupData = widget.groupData.customData as GroupData;
final menuItems = GroupOptions.values.toList();
if (!customGroupData.fieldType.canEditHeader) {
menuItems.remove(GroupOptions.rename);
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
...menuItems.map(
(action) => SizedBox(
height: GridSize.popoverItemHeight,
child: Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: FlowyButton(
leftIcon: FlowySvg(action.icon),
text: FlowyText.medium(
action.text,
overflow: TextOverflow.ellipsis,
),
onTap: () {
action.call(context, customGroupData.group);
PopoverContainer.of(popoverContext).close();
},
),
),
),
)
],
);
}
}
Widget? _buildHeaderIcon(GroupData customData) {
Widget? widget;
switch (customData.fieldType) {
case FieldType.Checkbox:
final group = customData.asCheckboxGroup()!;
widget = FlowySvg(
group.isCheck ? FlowySvgs.check_filled_s : FlowySvgs.uncheck_s,
blendMode: BlendMode.dst,
);
break;
case FieldType.DateTime:
case FieldType.LastEditedTime:
case FieldType.CreatedTime:
case FieldType.MultiSelect:
case FieldType.Number:
case FieldType.RichText:
case FieldType.SingleSelect:
case FieldType.URL:
case FieldType.Checklist:
break;
}
if (widget != null) {
widget = SizedBox(
width: 20,
height: 20,
child: widget,
},
);
}
return widget;
}
enum GroupOptions {
rename,
hide;
void call(BuildContext context, GroupPB group) {
switch (this) {
case rename:
context
.read<BoardBloc>()
.add(BoardEvent.startEditingHeader(group.groupId));
break;
case hide:
context
.read<BoardBloc>()
.add(BoardEvent.toggleGroupVisibility(group, false));
break;
}
}
FlowySvgData get icon => switch (this) {
rename => FlowySvgs.edit_s,
hide => FlowySvgs.hide_s,
};
String get text => switch (this) {
rename => LocaleKeys.board_column_renameColumn.tr(),
hide => LocaleKeys.board_column_hideColumn.tr(),
};
}

View File

@ -0,0 +1,483 @@
import 'dart:io';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database_view/application/cell/cell_service.dart';
import 'package:appflowy/plugins/database_view/application/database_controller.dart';
import 'package:appflowy/plugins/database_view/application/field/field_info.dart';
import 'package:appflowy/plugins/database_view/application/row/row_cache.dart';
import 'package:appflowy/plugins/database_view/application/row/row_controller.dart';
import 'package:appflowy/plugins/database_view/board/application/board_bloc.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database_view/widgets/card/card_cell_builder.dart';
import 'package:appflowy/plugins/database_view/widgets/card/cells/card_cell.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cell_builder.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/text_cell/text_cell_bloc.dart';
import 'package:appflowy/plugins/database_view/widgets/row/row_detail.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class HiddenGroupsColumn extends StatelessWidget {
final EdgeInsets margin;
const HiddenGroupsColumn({super.key, required this.margin});
@override
Widget build(BuildContext context) {
final databaseController = context.read<BoardBloc>().databaseController;
return BlocSelector<BoardBloc, BoardState, BoardLayoutSettingPB?>(
selector: (state) => state.layoutSettings,
builder: (context, layoutSettings) {
if (layoutSettings == null) {
return const SizedBox.shrink();
}
final isCollapsed = layoutSettings.collapseHiddenGroups;
return AnimatedSize(
alignment: AlignmentDirectional.topStart,
curve: Curves.easeOut,
duration: const Duration(milliseconds: 150),
child: isCollapsed
? SizedBox(
height: 50,
child: Padding(
padding: const EdgeInsets.only(left: 40, right: 8),
child: Center(
child: _collapseExpandIcon(context, isCollapsed),
),
),
)
: SizedBox(
width: 260,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 50,
child: Padding(
padding: EdgeInsets.only(
left: 40 + margin.left,
right: margin.right,
),
child: Row(
children: [
Expanded(
child: FlowyText.medium(
LocaleKeys
.board_hiddenGroupSection_sectionTitle
.tr(),
fontSize: 14,
overflow: TextOverflow.ellipsis,
color: Theme.of(context).hintColor,
),
),
_collapseExpandIcon(context, isCollapsed),
],
),
),
),
Expanded(
child: HiddenGroupList(
databaseController: databaseController,
),
),
],
),
),
);
},
);
}
Widget _collapseExpandIcon(BuildContext context, bool isCollapsed) {
return FlowyTooltip(
message: isCollapsed
? LocaleKeys.board_hiddenGroupSection_expandTooltip.tr()
: LocaleKeys.board_hiddenGroupSection_collapseTooltip.tr(),
child: FlowyIconButton(
width: 20,
height: 20,
iconColorOnHover: Theme.of(context).colorScheme.onSurface,
onPressed: () => context
.read<BoardBloc>()
.add(BoardEvent.toggleHiddenSectionVisibility(!isCollapsed)),
icon: FlowySvg(
isCollapsed
? FlowySvgs.hamburger_s_s
: FlowySvgs.pull_left_outlined_s,
),
),
);
}
}
class HiddenGroupList extends StatelessWidget {
const HiddenGroupList({
super.key,
required this.databaseController,
});
final DatabaseController databaseController;
@override
Widget build(BuildContext context) {
final bloc = context.read<BoardBloc>();
return BlocBuilder<BoardBloc, BoardState>(
builder: (_, state) => ReorderableListView.builder(
proxyDecorator: (child, index, animation) => Material(
color: Colors.transparent,
child: Stack(
children: [
child,
MouseRegion(
cursor: Platform.isWindows
? SystemMouseCursors.click
: SystemMouseCursors.grabbing,
child: const SizedBox.expand(),
),
],
),
),
buildDefaultDragHandles: false,
itemCount: state.hiddenGroups.length,
itemBuilder: (_, index) => Padding(
padding: const EdgeInsets.only(bottom: 4),
key: ValueKey("hiddenGroup${state.hiddenGroups[index].groupId}"),
child: HiddenGroupCard(
group: state.hiddenGroups[index],
index: index,
bloc: bloc,
),
),
onReorder: (oldIndex, newIndex) {
if (oldIndex < newIndex) {
newIndex--;
}
final fromGroupId = state.hiddenGroups[oldIndex].groupId;
final toGroupId = state.hiddenGroups[newIndex].groupId;
bloc.add(BoardEvent.reorderGroup(fromGroupId, toGroupId));
},
),
);
}
}
class HiddenGroupCard extends StatefulWidget {
const HiddenGroupCard({
super.key,
required this.group,
required this.index,
required this.bloc,
});
final GroupPB group;
final BoardBloc bloc;
final int index;
@override
State<HiddenGroupCard> createState() => _HiddenGroupCardState();
}
class _HiddenGroupCardState extends State<HiddenGroupCard> {
final PopoverController _popoverController = PopoverController();
@override
Widget build(BuildContext context) {
final databaseController = widget.bloc.databaseController;
final primaryField = databaseController.fieldController.fieldInfos
.firstWhereOrNull((element) => element.isPrimary)!;
return Padding(
padding: const EdgeInsets.only(left: 26),
child: AppFlowyPopover(
controller: _popoverController,
direction: PopoverDirection.bottomWithCenterAligned,
triggerActions: PopoverTriggerFlags.none,
constraints: const BoxConstraints(maxWidth: 234, maxHeight: 300),
popupBuilder: (popoverContext) => HiddenGroupPopupItemList(
bloc: widget.bloc,
viewId: databaseController.viewId,
groupId: widget.group.groupId,
primaryField: primaryField,
rowCache: databaseController.rowCache,
),
child: HiddenGroupButtonContent(
popoverController: _popoverController,
groupId: widget.group.groupId,
index: widget.index,
bloc: widget.bloc,
),
),
);
}
}
class HiddenGroupButtonContent extends StatelessWidget {
final String groupId;
final int index;
final BoardBloc bloc;
const HiddenGroupButtonContent({
super.key,
required this.popoverController,
required this.groupId,
required this.index,
required this.bloc,
});
final PopoverController popoverController;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: popoverController.show,
child: FlowyHover(
builder: (context, isHovering) {
return BlocProvider<BoardBloc>.value(
value: bloc,
child: BlocBuilder<BoardBloc, BoardState>(
builder: (context, state) {
final group = state.hiddenGroups.firstWhereOrNull(
(g) => g.groupId == groupId,
);
if (group == null) {
return const SizedBox.shrink();
}
return SizedBox(
height: 30,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 3,
),
child: Row(
children: [
HiddenGroupCardActions(
isVisible: isHovering,
index: index,
),
const HSpace(4),
FlowyText.medium(
group.groupName,
overflow: TextOverflow.ellipsis,
),
const HSpace(6),
Expanded(
child: FlowyText.medium(
group.rows.length.toString(),
overflow: TextOverflow.ellipsis,
color: Theme.of(context).hintColor,
),
),
if (isHovering) ...[
FlowyIconButton(
width: 20,
icon: FlowySvg(
FlowySvgs.show_m,
color: Theme.of(context).hintColor,
),
onPressed: () => context.read<BoardBloc>().add(
BoardEvent.toggleGroupVisibility(
group,
true,
),
),
),
],
],
),
),
);
},
),
);
},
),
),
);
}
}
class HiddenGroupCardActions extends StatelessWidget {
final bool isVisible;
final int index;
const HiddenGroupCardActions({
super.key,
required this.isVisible,
required this.index,
});
@override
Widget build(BuildContext context) {
return ReorderableDragStartListener(
index: index,
enabled: isVisible,
child: MouseRegion(
cursor: SystemMouseCursors.grab,
child: SizedBox(
height: 14,
width: 14,
child: isVisible
? FlowySvg(
FlowySvgs.drag_element_s,
color: Theme.of(context).hintColor,
)
: const SizedBox.shrink(),
),
),
);
}
}
class HiddenGroupPopupItemList extends StatelessWidget {
const HiddenGroupPopupItemList({
required this.bloc,
required this.groupId,
required this.viewId,
required this.primaryField,
required this.rowCache,
super.key,
});
final BoardBloc bloc;
final String groupId;
final String viewId;
final FieldInfo primaryField;
final RowCache rowCache;
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: bloc,
child: BlocBuilder<BoardBloc, BoardState>(
builder: (context, state) {
final group = state.hiddenGroups.firstWhereOrNull(
(g) => g.groupId == groupId,
);
if (group == null) {
return const SizedBox.shrink();
}
final cells = <Widget>[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: FlowyText.medium(
group.groupName,
fontSize: 10,
color: Theme.of(context).hintColor,
overflow: TextOverflow.ellipsis,
),
),
...group.rows.map(
(item) {
final rowController = RowController(
rowMeta: item,
viewId: viewId,
rowCache: rowCache,
);
final renderHook = RowCardRenderHook<String>();
renderHook.addTextCellHook((cellData, _, __) {
return BlocBuilder<TextCellBloc, TextCellState>(
builder: (context, state) {
final text = cellData.isEmpty
? LocaleKeys.grid_row_titlePlaceholder.tr()
: cellData;
if (text.isEmpty) {
return const SizedBox.shrink();
}
return Align(
alignment: Alignment.centerLeft,
child: FlowyText.medium(
text,
textAlign: TextAlign.left,
fontSize: 11,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
);
},
);
});
return HiddenGroupPopupItem(
cellContext: rowCache.loadCells(item)[primaryField.id]!,
primaryField: primaryField,
rowController: rowController,
cellBuilder: CardCellBuilder<String>(rowController.cellCache),
renderHook: renderHook,
onPressed: () {
FlowyOverlay.show(
context: context,
builder: (BuildContext context) {
return RowDetailPage(
cellBuilder: GridCellBuilder(
cellCache: rowController.cellCache,
),
rowController: rowController,
);
},
);
PopoverContainer.of(context).close();
},
);
},
)
];
return ListView.separated(
itemBuilder: (context, index) => cells[index],
itemCount: cells.length,
separatorBuilder: (context, index) =>
VSpace(GridSize.typeOptionSeparatorHeight),
shrinkWrap: true,
);
},
),
);
}
}
class HiddenGroupPopupItem extends StatelessWidget {
const HiddenGroupPopupItem({
super.key,
required this.cellContext,
required this.onPressed,
required this.cellBuilder,
required this.rowController,
required this.primaryField,
required this.renderHook,
});
final DatabaseCellContext cellContext;
final FieldInfo primaryField;
final RowController rowController;
final CardCellBuilder cellBuilder;
final RowCardRenderHook<String> renderHook;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 26,
child: FlowyButton(
margin: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
text: cellBuilder.buildCell(
cellContext: cellContext,
renderHook: renderHook,
hasNotes: !cellContext.rowMeta.isDocumentEmpty,
),
onTap: onPressed,
),
);
}
}

View File

@ -21,7 +21,7 @@ class GridHeaderBloc extends Bloc<GridHeaderEvent, GridHeaderState> {
on<GridHeaderEvent>(
(event, emit) async {
await event.map(
initial: (_InitialHeader value) async {
initial: (_InitialHeader value) {
_startListening();
add(
GridHeaderEvent.didReceiveFieldUpdate(fieldController.fieldInfos),
@ -65,7 +65,7 @@ class GridHeaderBloc extends Bloc<GridHeaderEvent, GridHeaderState> {
result.fold((l) {}, (err) => Log.error(err));
}
Future<void> _startListening() async {
void _startListening() {
fieldController.addListener(
onReceiveFields: (fields) =>
add(GridHeaderEvent.didReceiveFieldUpdate(fields)),

View File

@ -6,7 +6,7 @@ class GridSize {
static double get scrollBarSize => 8 * scale;
static double get headerHeight => 40 * scale;
static double get footerHeight => 40 * scale;
static double get leadingHeaderPadding => 50 * scale;
static double get leadingHeaderPadding => 40 * scale;
static double get trailHeaderPadding => 140 * scale;
static double get headerContainerPadding => 0 * scale;
static double get cellHPadding => 10 * scale;

View File

@ -59,4 +59,10 @@ extension FieldTypeListExtension on FieldType {
FieldType.SingleSelect => true,
_ => false,
};
bool get canCreateNewGroup => switch (this) {
FieldType.MultiSelect => true,
FieldType.SingleSelect => true,
_ => false,
};
}

View File

@ -10,7 +10,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../application/database_controller.dart';
import '../grid/presentation/layout/sizes.dart';
import 'tab_bar_header.dart';
abstract class DatabaseTabBarItemBuilder {
@ -95,13 +94,11 @@ class _DatabaseTabBarViewState extends State<DatabaseTabBarView> {
if (value) {
return const SizedBox.shrink();
}
return SizedBox(
return const SizedBox(
height: 30,
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: GridSize.leadingHeaderPadding,
),
child: const TabBarHeader(),
padding: EdgeInsets.symmetric(horizontal: 40),
child: TabBarHeader(),
),
);
},